feat: key-package maintenance as TaskRunner recurring tasks#3814
Conversation
|
Claude finished @insipx's task —— View job Code Review CompleteReview Date: July 9, 2026, 12:00 AM UTC SummaryThis PR successfully refactors key-package maintenance from a polling worker to TaskRunner recurring tasks. The implementation is solid overall, with excellent test coverage and thoughtful handling of edge cases discovered during review iterations. Below are focused observations on aspects not extensively covered in prior reviews. Key Observations1. Transaction Atomicity & Recovery (✅ Well Done) The
This addresses the partial-failure window that was previously discussed and eliminates the race condition entirely. 2. State Machine Correctness The three-state model for
The NULL-handling fix in 3. Error Classification & Reconnect Logic (✅ Recently Fixed) The Self::OpenMlsStorageError(SqlKeyStoreError::Connection(c)) => c.db_needs_connection()This ensures that keystore connection pool loss during rotate/delete operations triggers worker restart rather than infinite backoff. 4. Test Coverage Strengths The test suite in key_package_maintenance.rs demonstrates thorough coverage:
The regression tests are well-named and document the specific bugs they prevent (e.g., Minor Observations (Non-Blocking)A. Constant Usage Consistency Lines tasks.rs:425 and :437 use B. Builder Failure Mode Line builder.rs:428 propagates if let Err(e) = crate::worker::key_package_maintenance::seed_and_reconcile_kp_tasks(&context) {
tracing::error!("KP task seeding failed, cannot build client: {e}");
return Err(e.into());
}C. Welcome Sync Nudge Failure Handling Line welcome_sync.rs:165-169 downgrades nudge_rotation failures to warnings. While the self-healing properties (startup reconcile, next welcome retry) mean this won't permanently strand rotation, long-lived clients with infrequent welcomes could delay rotation significantly. The comment explains this trade-off (discarding committed welcome groups would be worse), but documenting the recovery paths in the warning message would help future maintainers: tracing::warn!(
"key rotation nudge failed after welcome sync (will self-heal on restart or next welcome): {e}"
);D. Documentation Opportunity The PR description and in-code comments are excellent, but consider adding a module-level doc comment to
This would help new contributors understand the architecture without reading the entire PR thread. Security & PerformanceSecurity: No concerns. The changes reduce attack surface by eliminating the polling loop and making maintenance event-driven. Performance: The event-driven approach is more efficient than 5s polling. The startup seeding adds negligible overhead (two task row inserts + two pull-in inserts, all Concurrency: The transaction-based approach in ConclusionThis is high-quality work with excellent attention to edge cases and recovery properties. The architectural shift from polling to event-driven maintenance is the right direction. The issues raised in earlier reviews have been addressed comprehensively. The minor observations above are suggestions for clarity and diagnostics rather than correctness concerns. Recommendation: Approve with optional follow-up for documentation enhancements. Tasks Completed
|
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #3814 +/- ##
==========================================
+ Coverage 84.74% 85.12% +0.37%
==========================================
Files 409 411 +2
Lines 61369 63290 +1921
==========================================
+ Hits 52008 53873 +1865
- Misses 9361 9417 +56 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
01bd75a to
d1cabec
Compare
0cbad05 to
7efc6b3
Compare
7efc6b3 to
d379108
Compare
d1cabec to
142ec3b
Compare
d379108 to
c35a589
Compare
c35a589 to
93ff321
Compare
10717bf to
6074cbe
Compare
… + regen xmtp_proto (#3805) Proto-only base of the TaskRunner-recurrence stack. Adds three variants to the `xmtp.mls.database.Task` oneof (vendored regen): - **`PullInDeadline` (tag 4)** — generic one-shot "run a task sooner" primitive: lowers a target task row's `next_attempt_at_ns` to `MIN(current, not_later_than_ns)`, targeting the row by its unique `data_hash`. - **`KpRotation` (tag 5)** / **`KpDeletion` (tag 6)** — recurring singleton tasks for key-package maintenance; empty payloads give each a stable `data_hash` so pull-ins can target them. Minimal dispatch arms (warn + delete) keep `xmtp_mls` compiling standalone; real handlers land in the stacked PRs (#3806 generic layer, #3814 KP consumers). `gen/` is regenerated AHEAD of the upstream proto pin: xmtp/proto#339 carries all three messages; `proto_version` gets bumped to its merge SHA once it lands. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- Macroscope's pull request summary starts here --> <!-- Macroscope will only edit the content between these invisible markers, and the markers themselves will not be visible in the GitHub rendered markdown. --> <!-- If you delete either of the start / end markers from your PR's description, Macroscope will append its summary at the bottom of the description. --> > [!NOTE] > ### Add `PullInDeadline`, `KpRotation`, and `KpDeletion` task variants to the MLS database worker > - Adds three new `Task` oneof variants to the protobuf schema in [xmtp.mls.database.rs](https://github.com/xmtp/libxmtp/pull/3805/files#diff-1b48cc83eb618a15345516b946b4138dae9c2be24155cc8d9a950038f118096c): `PullInDeadline` (with `target_data_hash` and `not_later_than_ns`), `KpRotation`, and `KpDeletion`. > - Regenerates serde impls and the proto descriptor binary to include these new types. > - Adds placeholder match arms in [tasks.rs](https://github.com/xmtp/libxmtp/pull/3805/files#diff-b3d01dc512a077cf4b625f516f8c36ea61978f070bcce720ed8d522065a268b6) that log a warning and delete the task — no business logic is implemented yet. > > <!-- Macroscope's review summary starts here --> > > <sup><a href="https://app.macroscope.com">Macroscope</a> summarized bd706d7.</sup> > <!-- Macroscope's review summary ends here --> > > <!-- macroscope-ui-refresh --> <!-- Macroscope's pull request summary ends here -->
93ff321 to
021fd2f
Compare
d17c678 to
da945c8
Compare
0a2485d to
0d7cc85
Compare
next_key_package_rotation_ns (identity) and min_key_package_delete_at_ns (key_package_history): the two recurring KP tasks' reschedule sources. Columns pre-exist; no migration. NEVER_EXPIRES names the i64::MAX expiry so pull-in call sites are self-documenting.
…iring Payload/hash/seed helpers, rotate_if_needed, sweep_expired, seed_and_reconcile_kp_tasks. IdentityError/KeyPackagesCleanerError forward needs_db_reconnect so a DB outage triggers supervisor reconnect, not backoff.
Rotation rotates when due, self-heals + pulls in the deletion singleton, and reschedules to the LIVE rotation column (never a hardcoded +30d). Deletion sweeps expired local KPs and reschedules to the next pending delete_at. RescheduleAt gains its first production constructors — dead_code expects removed/narrowed accordingly.
…er registration Seeding gates on the TaskRunner branch (seeding without a dispatcher would strand due rows). next_key_package_rotation_ns + is_identity_needs_rotation are optional-aware: no identity row (pre-registration) = nothing to rotate; build-time seeding runs before register_identity. NOTE (mid-stack): at this commit a welcome-queued rotation only fires if the KpRotation seed dispatches after the column write — the durable pull-in nudge landing in the next commit closes that race. Do not make this commit a merge tip.
…owns KP maintenance Worker loop, factory, and rotate_last_key_package_if_needed deleted — the KpRotation/KpDeletion recurring tasks + welcome pull-in replace the 5s poll. Struct + delete helpers remain as the KpDeletion arm's implementation. Dead disable-workers entry swept; misleading queue log demoted to accurate debug.
…rotation queue_key_package_rotation now also initializes a NULL column (migrated DBs): restores the 5s debounce there, and makes welcome-nudge payloads stable so pull-ins coalesce on data_hash instead of accumulating one row per welcome when the runner is disabled. nudge_deletion extracted and wired into the manual Client::rotate_and_upload_key_package path so its delete_at marks get swept at the grace deadline, not the parked ~30d one.
…try re-nudges deletion nudge_rotation failure after welcomes are committed now warns instead of discarding the processed groups (next welcome / startup reconcile re-nudges). nudge_deletion runs on every KpRotation dispatch but only acts when something is marked for deletion — a backoff retry after rotate-ok/nudge-failed now converges instead of skipping the nudge forever.
982e50c to
4691495
Compare
…e_maintenance The worker is gone; a Worker-named struct wrapping two delete helpers was gutting-stage scaffolding. Helpers become free fns in the maintenance module, the error is renamed KeyPackageMaintenanceError to match its actual role, and the file is deleted.
bd46ca0 to
b7453be
Compare
| .queue_key_rotation(&self.context.db()) | ||
| .await?; | ||
|
|
||
| pub fn queue_key_rotation(&self) -> Result<(), ClientError> { |
There was a problem hiding this comment.
🟠 High src/client.rs:1078
Client::queue_key_rotation now fails with a database NotFound error when called on a client that has not yet registered. The previous implementation was a no-op in that state, but the new one delegates to queue_key_rotation_with_nudge, which reads the identity row inside the transaction and surfaces Diesel's NotFound when the row does not exist. Any caller that invokes queue_key_rotation() on a fresh client now receives an error instead of a benign no-op. Consider handling the missing-row case (e.g., treat it as a no-op or early-return) so the method remains safe to call before registration.
Also found in 1 other location(s)
crates/xmtp_mls/src/worker/key_package_maintenance.rs:177
queue_key_rotationnow callsdb.queue_key_rotation_with_nudge(...), which reads theidentityrow back with.first(...). On a client that exists beforeregister_identity()has stored the row, that query returnsNotFound, so the publicClient::queue_key_rotation()API now fails instead of behaving like the oldqueue_key_package_rotation()path (which was an empty-table no-op).
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/xmtp_mls/src/client.rs around line 1078:
`Client::queue_key_rotation` now fails with a database `NotFound` error when called on a client that has not yet registered. The previous implementation was a no-op in that state, but the new one delegates to `queue_key_rotation_with_nudge`, which reads the `identity` row inside the transaction and surfaces Diesel's `NotFound` when the row does not exist. Any caller that invokes `queue_key_rotation()` on a fresh client now receives an error instead of a benign no-op. Consider handling the missing-row case (e.g., treat it as a no-op or early-return) so the method remains safe to call before registration.
Also found in 1 other location(s):
- crates/xmtp_mls/src/worker/key_package_maintenance.rs:177 -- `queue_key_rotation` now calls `db.queue_key_rotation_with_nudge(...)`, which reads the `identity` row back with `.first(...)`. On a client that exists before `register_identity()` has stored the row, that query returns `NotFound`, so the public `Client::queue_key_rotation()` API now fails instead of behaving like the old `queue_key_package_rotation()` path (which was an empty-table no-op).
There was a problem hiding this comment.
Good catch — real regression. Fixed in the branch tip: the in-transaction read-back now uses .optional(), and a missing identity row (pre-registration) early-returns as a no-op — no column write matched, no pull-in inserted — matching the old zero-rows-matched behavior exactly. Regression test queue_with_nudge_is_noop_before_registration pins it (empty identity table → Ok + zero task rows).
… enqueue are one transaction queue_key_rotation_with_nudge lowers/initializes the rotation column and enqueues the KpRotation pull-in atomically; the TaskWorker wake happens after commit. Kills the partial-failure window (column lowered but nudge lost) and the reverse. The column is read inside the transaction so pull-in payloads stay stable and coalesce. Identity::queue_key_rotation and nudge_rotation fold into the new path.
b7453be to
0f37b1c
Compare
IdentityError::OpenMlsStorageError(SqlKeyStoreError::Connection) now forwards db_needs_connection: a dropped pool inside keystore ops (rotate/delete paths) restarts the worker instead of backing off on a dead connection.
ApprovabilityVerdict: Needs human review 1 blocking correctness issue found. Architectural change replacing standalone worker with task-based key package maintenance. Unresolved high-severity comment identifies behavioral regression where pre-registration calls now error instead of being no-ops. Questions about startup error handling remain open. You can customize Macroscope's approvability policy. Learn more. |
tylerhawkes
left a comment
There was a problem hiding this comment.
Replacing the KP cleaner with recurring TaskRunner tasks reads well and the failure-mode test coverage is strong. A few notes: one medium (disable coupling), two low, one nit.
| workers.register_new_worker::<TaskWorker<ContextParts<ApiClient, S, Db>>, _>( | ||
| context.clone(), | ||
| ); | ||
| // KP maintenance is a critical MLS function: always on when the |
There was a problem hiding this comment.
Maintenance is gated on TaskRunner, which is individually disableable (no_runner_cfg() in tests). So disabling just the TaskRunner silently stops rotation/deletion — contradicting the "only disable_workers can kill it" claim. Guard against it, or soften the wording?
| ); | ||
| // KP maintenance is a critical MLS function: always on when the | ||
| // TaskRunner runs (disable_workers disables both, coherently). | ||
| crate::worker::key_package_maintenance::seed_and_reconcile_kp_tasks(&context)?; |
There was a problem hiding this comment.
This ? makes a transient DB error at startup fail the whole client build; the worker registration it replaces was effectively infallible. Intended?
| let next = context | ||
| .db() | ||
| .min_key_package_delete_at_ns()? | ||
| .unwrap_or(now + NS_IN_30_DAYS); // nothing pending: far-future |
There was a problem hiding this comment.
Idle deletion parks at now + NS_IN_30_DAYS; idle rotation uses KEY_PACKAGE_ROTATION_INTERVAL_NS. Both ~30d — use one constant so the gap doesn't read as load-bearing.
…dle-park constant - queue_key_rotation_with_nudge now insert-or-ignores the rotation seed in the same transaction as the column write and pull-in enqueue, so a pull-in can never commit without a live target (commit-target-first), even when startup seeding never ran. Pre-registration stays a strict no-op. - KpDeletion idle park uses KEY_PACKAGE_ROTATION_INTERVAL_NS like the rotation arm; the two ~30d constants read as if the gap were load-bearing. - Builder comment + PR description: disabling the TaskRunner (per-kind or via disable_workers) disables KP maintenance with it; seeding failure is fatal by design. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Stacked on #3806 (generic recurrence); the KpRotation/KpDeletion proto variants land in #3805. Replaces the 5s-poll
KeyPackageCleanerworker with two recurring TaskRunner tasks.What
KpRotation— recurring singleton: rotates + uploads a fresh key package when the identity's rotation deadline (next_key_package_rotation_ns) is due, then reschedules to the live column (never a hardcoded +30d). On rotation it self-heals the deletion singleton and pulls it in to the earliest pendingdelete_at_ns.KpDeletion— recurring singleton: sweeps expired local key-package material, reschedules to the next pending deadline.queue_key_package_rotationlowers the column (~5s debounce, a security property), a durablePullInDeadlinepulls the rotation task in to match. Closes a real race: without it, a seed that dispatched before the column write parks ~30d out and the debounce is silently lost (reproduced; regression-tested).KpDeletionarm's implementation.Behavior notes
WorkerKind::KeyPackageCleanerremains in core and all three bindings' enums for FFI compatibility, but enabling/disabling it is now a no-op. Disabling the TaskRunner itself — per-kind via worker config or globally viadisable_workers— disables KP maintenance with it.None/false, the rotation seed parks ~30d and converges silently once registration writes the column. No transient errors.expires_at_ns = i64::MAX,max_attempts = i32::MAX), so the existing reaper predicate can't fire on them.🤖 Generated with Claude Code
Note
Replace key-package cleaner worker with TaskRunner recurring tasks for rotation and deletion
KeyPackagesCleanerWorkerand replaces it with durableKpRotationandKpDeletiontask rows managed by theTaskRunnerin key_package_maintenance.rs.KpRotationtasks reschedule to the DB-backednext_key_package_rotation_nsdeadline;KpDeletiontasks reschedule to the earliest pendingdelete_at_nsacross marked key packages.seed_and_reconcile_kp_tasksis called at startup (afterTaskWorkerregistration) to seed both recurring tasks and pull in deadlines from live DB state.queue_key_rotationis now synchronous and atomically updates the rotation column and enqueues aPullInDeadlinenudge;nudge_deletiondoes the same for deletion.is_identity_needs_rotationnow returnsfalsepre-registration and treats aNULLrotation column on an existing row as due immediately, fixing a bug where migrated DBs withNULLdeadlines were skipped.TaskRunnerdispatch loop.Changes since #3814 opened
KpDeletiontask rescheduling to use rotation interval constant instead of fixed 30-day value [3cb7a61]Macroscope summarized 74a0d28.