feat(xmtp_mls): event-driven pending-self-remove via TaskRunner#3744
Conversation
|
Claude finished @insipx's task —— View job PR Review CompleteReview Date: June 9, 2026, 21:35 UTC Todo List
Review FeedbackThe PR successfully consolidates self-removal into the event-driven TaskRunner, and the author has addressed most issues raised during review. Here are the remaining items not covered in existing comments: 1. Performance: Full Table Scan in
|
ac6f821 to
a66edb8
Compare
9404c47 to
e1a28d4
Compare
a66edb8 to
7afeb65
Compare
e1a28d4 to
b6c202a
Compare
7afeb65 to
c483f13
Compare
b6c202a to
998ccfd
Compare
c483f13 to
f667cf9
Compare
998ccfd to
7376a79
Compare
f667cf9 to
b3c1e7c
Compare
7376a79 to
abc71cc
Compare
| pub(crate) async fn process_pending_self_removals(&self) -> Result<(), GroupError> { | ||
| self.remove_members_pending_removal().await?; | ||
| self.cleanup_pending_removal_list().await?; | ||
| Ok(()) | ||
| } |
There was a problem hiding this comment.
🟢 Low groups/mod.rs:1720
process_pending_self_removals() documents itself as a no-op for non-super-admin clients, but line 1722 unconditionally calls cleanup_pending_removal_list() even when remove_members_pending_removal() has already early-returned for a non-super-admin. cleanup_pending_removal_list() then fetches members().await, which triggers network work and can return GroupError failures—so follower devices still do expensive work and potentially fail/exit with error instead of harmlessly skipping the entire operation.
pub(crate) async fn process_pending_self_removals(&self) -> Result<(), GroupError> {
- self.remove_members_pending_removal().await?;
- self.cleanup_pending_removal_list().await?;
- Ok(())
+ // Only super-admins have work to do; this is a documented no-op otherwise
+ if self.is_super_admin(self.context.inbox_id().to_string())? {
+ self.remove_members_pending_removal().await?;
+ self.cleanup_pending_removal_list().await?;
+ }
+ Ok(())
}Also found in 1 other location(s)
crates/xmtp_mls/src/worker/tasks.rs:280
ProcessPendingSelfRemoveretries permanently missing groups instead of dropping the task.MlsStore::group()returnsMlsStoreError::NotFoundwhen the referencedgroup_idis absent, butrun_and_reschedule_task()reschedules everyErr. If the group was deleted before this durable task runs, line280keeps the stale task retrying untilmax_attempts/expiry, generating repeated warnings and delaying cleanup instead of treating the task as completed.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/xmtp_mls/src/groups/mod.rs around lines 1720-1724:
`process_pending_self_removals()` documents itself as a no-op for non-super-admin clients, but line 1722 unconditionally calls `cleanup_pending_removal_list()` even when `remove_members_pending_removal()` has already early-returned for a non-super-admin. `cleanup_pending_removal_list()` then fetches `members().await`, which triggers network work and can return `GroupError` failures—so follower devices still do expensive work and potentially fail/exit with error instead of harmlessly skipping the entire operation.
Also found in 1 other location(s):
- crates/xmtp_mls/src/worker/tasks.rs:280 -- `ProcessPendingSelfRemove` retries permanently missing groups instead of dropping the task. `MlsStore::group()` returns `MlsStoreError::NotFound` when the referenced `group_id` is absent, but `run_and_reschedule_task()` reschedules every `Err`. If the group was deleted before this durable task runs, line `280` keeps the stale task retrying until `max_attempts`/expiry, generating repeated warnings and delaying cleanup instead of treating the task as completed.
There was a problem hiding this comment.
cleanup_pending_removal_list() is intentionally admin-agnostic and idempotent: it removes pending-remove rows for inbox-ids no longer in the group (true for any observer, not just admins) and clears has_pending_leave_request once the list drains. Running it on a non-super-admin client is harmless and correct — that client still observes departures and keeps its local pending list consistent. The 'no-op for non-admin' doc refers to remove_members_pending_removal (which can't commit a removal without admin rights), not to cleanup. So calling both from the shared helper is intended; no change here.
abc71cc to
7427cf4
Compare
7a3dd08 to
24c6c8a
Compare
ApprovabilityVerdict: Needs human review 1 blocking correctness issue found. This PR replaces a 2-second polling worker with event-driven task processing for self-removals, a significant architectural change. An unresolved high-severity comment identifies potential day-long delays when the task table is empty, which requires human evaluation before merging. You can customize Macroscope's approvability policy. Learn more. |
7427cf4 to
b118d32
Compare
24c6c8a to
612f19d
Compare
There was a problem hiding this comment.
🟠 High
libxmtp/crates/xmtp_mls/src/builder.rs
Line 372 in b118d32
Removing PendingSelfRemoveWorker causes pending self-removals to be processed by TaskWorker, but process_leave_request_message now inserts tasks via storage.db().create_task(...) instead of context.task_channels().send(...). TaskWorker::run() only re-queries the database after receiving on task_receiver or when its current sleep(next_wakeup) finishes. When the task table is empty, this sleep lasts up to one day, so a newly inserted ProcessPendingSelfRemove task can be delayed until another unrelated task or event arrives — leaving self-removals stuck far longer than the previous 2s polling interval.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/xmtp_mls/src/builder.rs around line 372:
Removing `PendingSelfRemoveWorker` causes pending self-removals to be processed by `TaskWorker`, but `process_leave_request_message` now inserts tasks via `storage.db().create_task(...)` instead of `context.task_channels().send(...)`. `TaskWorker::run()` only re-queries the database after receiving on `task_receiver` or when its current `sleep(next_wakeup)` finishes. When the task table is empty, this sleep lasts up to one day, so a newly inserted `ProcessPendingSelfRemove` task can be delayed until another unrelated task or event arrives — leaving self-removals stuck far longer than the previous 2s polling interval.
There was a problem hiding this comment.
Verified this is not the case here. process_leave_request_message runs inside provider.key_store().transaction(|conn| { let storage = conn.key_store(); ... }) (mls_sync.rs ~1297). Within that closure, storage.db() is SqlKeyStore::db() → DbConnection::new(&self.conn), pointing at the same transaction-scoped connection that immediate_transaction opened — not a fresh autocommitting one. So PendingRemove.store_or_ignore(&storage.db()) and storage.db().create_task(...) execute on the same connection inside the same transaction and commit/roll back together (this is also why the existing rollback-on-error path for the pending-remove insert works). The atomicity comment is accurate. See the sibling thread — storage.db() inside the key_store().transaction() closure reuses the transactional connection, so the task insert is atomic with the pending-remove insert.
There was a problem hiding this comment.
That's not the concern here. The issue is that the task worker is maximally lazy and will only reload when it knows it needs to. Sending it a message forces it to store and re-evaluate. If you want an atomic commit, then we'll need a new message that just wakes the task runner up and has it re-query for tasks (so that the new one can potentially be run before whatever it was planning on)
There was a problem hiding this comment.
You're correct. I confirmed that when process_own_leave_request_message is called (from within the process_message transaction at mls_sync.rs:1078), it passes deferred_events = None. The conditional wake at line 1696 (if let Some(deferred_events) = deferred_events { deferred_events.wake_worker(...) }) doesn't fire, and there's no post-commit wake call after the transaction completes. The task sits until the worker's next scheduled iteration, which could be up to 24 hours if the table was previously empty.
The disappearing-messages path (lines 2098-2111) shows the pattern needed: set a flag during processing and wake after the transaction. The own-leave path lacks this mechanism.
There was a problem hiding this comment.
Done in 1419643 — the Notify is gone. The task channel now carries a TaskMessage enum (New(NewTask) | Wake), and wake() just sends TaskMessage::Wake through the existing mpsc. The run loop handles Wake as a no-op and falls through to re-query get_next_task, so a task row committed directly in a DB transaction gets re-evaluated immediately (and can run ahead of whatever the worker was previously sleeping toward). One select arm fewer, no Notify permit semantics.
There was a problem hiding this comment.
The TaskMessage::Wake infrastructure looks good — cleaner than the separate Notify. However, I still see process_own_leave_request_message (line 1603) passing None for deferred_events, so the conditional wake at line 1696 is skipped. The task is inserted but the worker isn't woken, meaning it could sit until the next scheduled poll (up to 24h if the table was empty). Should the own-leave path set a flag in ProcessedMessageOutcome (like disappearing_message_stored) and wake post-commit, similar to the disappearing-messages pattern at lines 2098-2111?
b118d32 to
f5c69f4
Compare
f5c69f4 to
f1b9f75
Compare
d1552d6 to
51afddf
Compare
139a51f to
68443b6
Compare
68443b6 to
1fd0f3e
Compare
1fd0f3e to
5d3dec0
Compare
17922ef to
c7d8458
Compare
CI fix: d14n
|
c7d8458 to
6091aa1
Compare
CI fix: node-sdk + browser-sdk
|
6091aa1 to
b7e8902
Compare
| for kind in self.wake_workers.drain() { | ||
| // Never nudge a disabled worker — it won't drain the signal. | ||
| if !context.worker_config().worker_enabled(kind) { | ||
| continue; | ||
| } | ||
| match kind { | ||
| WorkerKind::DisappearingMessages => context.disappearing_channels().rearm(), | ||
| WorkerKind::TaskRunner => context.task_channels().wake(), | ||
| // Other workers have no post-commit wake channel today. | ||
| _ => {} | ||
| } |
There was a problem hiding this comment.
This is a fair amount of work to avoid just adding a new Task enum that only wakes up the task runner to get it to re-evaluate.
There was a problem hiding this comment.
Agreed — reworked in 1419643. Dropped the Notify entirely; wake() now sends a TaskMessage::Wake variant through the existing task mpsc channel, which the loop treats as "re-query the tasks table". The only remaining indirection is that the send still goes through DeferredEvents post-commit — sending mid-transaction would let the worker consume the wake and re-query before the row is visible (the race we hit with the Notify originally).
Replace the 2s-poll PendingSelfRemoveWorker with a durable TaskRunner task. On receiving a LeaveRequest, enqueue a ProcessPendingSelfRemove task in the SAME db transaction as the pending_remove insert (atomic, restart-safe), deduped per group via a pre-insert existence check. The inline admin fast-path is kept; the task is the retry/backstop (TaskRunner gives backoff + persistence across restart). Dispatch loads the group and runs the shared MlsGroup::process_pending_self_removals() helper (remove + cleanup), called from both the inline path and the task. Deletes PendingSelfRemoveWorker, its WorkerKind, and registration; updates the wasm/mobile/node bindings that mirrored the WorkerKind enum. Adds the ProcessPendingSelfRemove Task proto variant (xmtp/proto#335) via regenerated protos. The proto regen also pulls accumulated upstream drift in unrelated message_api/message_contents/xmtpv4 gen files (the committed gen lagged its pin); the only intentional proto change is ProcessPendingSelfRemove. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
b7e8902 to
1419643
Compare
| /// insert-or-ignores, so a live retrying task keeps its backoff and is never | ||
| /// deleted out from under the TaskRunner, while a stale dead row can't block | ||
| /// a fresh retry via the `data_hash` unique constraint. | ||
| fn upsert_pending_self_remove_task( |
There was a problem hiding this comment.
🟢 Low encrypted_store/tasks.rs:102
upsert_pending_self_remove_task takes a group_id and a separate NewTask, but nothing enforces that task encodes ProcessPendingSelfRemove for that group_id. A caller can pass a mismatched task — the function will delete dead rows for group_id, then insert the unrelated task unchanged, leaving stale self-remove rows or creating spurious tasks. Consider taking only group_id and constructing the NewTask internally, or validating that task matches group_id before proceeding.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/xmtp_db/src/encrypted_store/tasks.rs around line 102:
`upsert_pending_self_remove_task` takes a `group_id` and a separate `NewTask`, but nothing enforces that `task` encodes `ProcessPendingSelfRemove` for that `group_id`. A caller can pass a mismatched `task` — the function will delete dead rows for `group_id`, then insert the unrelated `task` unchanged, leaving stale self-remove rows or creating spurious tasks. Consider taking only `group_id` and constructing the `NewTask` internally, or validating that `task` matches `group_id` before proceeding.
| @@ -155,9 +177,14 @@ where | |||
| xmtp_common::time::now_ns(), | |||
| ); | |||
| tokio::select! { | |||
There was a problem hiding this comment.
🟢 Low worker/tasks.rs:179
When TaskMessage::Wake arrives before next_wakeup, the tokio::select! drops the in-flight sleep future and restarts a fresh one from "now". If wake messages arrive frequently enough, the timer branch never becomes ready and due tasks are postponed indefinitely. Consider ignoring wake messages when a sleep is already pending, or using a deadline-based approach that doesn't reset the wait each iteration.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/xmtp_mls/src/worker/tasks.rs around line 179:
When `TaskMessage::Wake` arrives before `next_wakeup`, the `tokio::select!` drops the in-flight sleep future and restarts a fresh one from "now". If wake messages arrive frequently enough, the timer branch never becomes ready and due tasks are postponed indefinitely. Consider ignoring wake messages when a sleep is already pending, or using a deadline-based approach that doesn't reset the wait each iteration.
…ibling error variants A dropped DB pool (PoolNeedsConnection) surfacing during a sync was misclassified as not-a-disconnect: SyncSummary had no NeedsDbReconnect impl, so any disconnect landing in its publish/post-commit/other/per-message errors got wrapped into GroupError::Sync / SyncFailedToWait / DeviceSyncError::Sync / CommitLogError::SyncError and hit `_ => false`. The worker supervisor never dropped the pool to reconnect — it retried forever on a dead pool (GroupError::SyncFailedToWait is also is_retryable, a true hot-loop). Fix — forward the signal everywhere it was dropped: - SqlKeyStoreError gains db_needs_connection() (mirrors StorageError / ConnectionError) so the OpenMLS key-store disconnect check lives in one place. - SyncSummary: NeedsDbReconnect scans all four error sources, including the per-message process.errored failures that is_retryable deliberately skips. - GroupMessageProcessingError: NeedsDbReconnect forwards the directly-wrapped DB variants and the OpenMLS state-I/O wrappers (process/merge), which carry a drop as StorageError(SqlKeyStoreError::Connection(_)). - GroupError: Sync / SyncFailedToWait / SqlKeyStore forwarded. - TaskWorkerError: Group / LoadGroup / DeviceSync forward the full classification instead of structurally matching only ::Storage. - CommitLogError: SyncError / KeystoreError forwarded. - DeviceSyncError: Sync forwarded. Every new arm bottoms out at db_needs_connection(), which matches only PoolNeedsConnection / Pool(_) — benign connection errors (e.g. DisconnectInTransaction) stay false, pinned by negative test cases. Rebased on main (post #3744 TaskRunner rework; tasks.rs merged with the new LoadGroup variant). Adds 3 disconnect-propagation tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Folds the poll-based
PendingSelfRemoveWorkerinto the durableTaskRunner. PR 3 of 3 — stacked on #3745 (proto regen), which is stacked on #3743 (disappearing).Problem
PendingSelfRemoveWorkerpolledget_groups_have_pending_leave_request()every 2s (~43,200 empty queries/day per device). The work — building an MLS RemoveProposal/Commit to evict a member who sent aLeaveRequest— is discrete, immediate, and must retry (only a super-admin can commit it). That is exactly what the existing durableTaskRunnerprovides.Changes
LeaveRequest, aProcessPendingSelfRemovetask is created in the same DB transaction as thepending_removeinsert (atomic, restart-safe), deduped per group viapending_self_remove_task_exists()— a pre-insert check (notasksmigration) that excludes dead rows (expired / max-attempts-exhausted) so a fresh backstop isn't suppressed by a not-yet-GC'd task.process_admin_pending_remove_actionscall stays; the task is the retry/backstop. Removal logic lives in one shared helperMlsGroup::process_pending_self_removals()(remove + cleanup) called from both.TaskRunner::run_taskgains aProcessPendingSelfRemovearm that loads the group and runs the shared helper; idempotent, no-op when not super-admin, so retries are safe.PendingSelfRemoveWorker, itsWorkerKind, builder registration; the disconnect-propagation test is repurposed toTaskWorkerError::LoadGroup. The wasm/mobile/node bindings that mirroredWorkerKindare updated.The
ProcessPendingSelfRemoveproto variant this consumes is regenerated in #3745 (split out per review).Testing
pending_self_remove_task_exists+task_worker_load_group_forwards_disconnectpass; native + wasm + mobile + node compile;just lint-rustgreen. (Integration tests requiring the XMTP Docker node not run here.)🤖 Generated with Claude Code
Note
Replace
PendingSelfRemoveWorkerwith event-drivenTaskRunner-based self-removalPendingSelfRemoveWorkerpolling loop; self-removal is now triggered byprocess_leave_request_message, which durably enqueues aProcessPendingSelfRemovetask and wakes theTaskRunnerpost-commit.upsert_pending_self_remove_tasktoQueryTasksin tasks.rs to deduplicate live tasks per group while replacing dead/exhausted ones.backfill_pending_self_remove_tasksin builder.rs seeds tasks for any pre-existing pending leave requests so in-flight removals survive restarts.DeferredEventsdisappearing-rearm boolean with aHashSet<WorkerKind>so multiple workers can be woken idempotently after a commit.PendingSelfRemovefrom all public FFI/WASM/NodeWorkerKindenums.Macroscope summarized 1419643.