feat(inbound): Gmail watch() renewal cron + reconciliation sweep (HT-42) - #42
Conversation
A daily maintenance pass over active Gmail mailboxes (a plain injected-deps function like the HT-16 delivery worker; the SchedulerProvider/Vercel-cron schedule is composition-root wiring, deferred to HT-43). Per active mailbox, failure-isolated: - Re-arm users.watch() (Gmail push silently expires every ~7 days) and store the fresh expiration via GmailWatchStateStore.setWatchExpiration, which updates watch_expiration ONLY and never the cursor — a renewal's fresh historyId is ahead of the stored cursor, so overwriting it would silently skip un-reconciled mail (charter invariant #1). - Enqueue a bounded reconciliation sweep (one reconcile job per active mailbox onto HT-41's GMAIL_RECONCILE_TOPIC) — the safety net for dropped best-effort pushes, so a quiet mailbox whose last push was dropped never sits stale indefinitely. No dedupeKey, so daily sweeps of a quiet mailbox aren't suppressed; idempotent ingest makes the redundant work safe. Failure handling: the OAuth token layer (HT-38) owns needs_reconnect via invalid_grant; a valid-token watch() failure is treated as transient (retried next daily run) rather than halting a healthy mailbox. Re-arm and sweep are independent. One mailbox's failure never stops the batch. New: MailboxStore.listActiveMailboxes, GmailWatchStateStore.setWatchExpiration, src/mail/gmail-watch-maintenance.ts. No migration. Scope: renewal + sweep only, per the ticket's "NOT on the ingest path" (the sweep reuses HT-41, adds no new ingest code). The reconciliation lease from gmail-push.md §6 is deferred to HT-48 (pure efficiency, not correctness); §6 updated to re-attribute it and refine the watch()-failure handling. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
@coderabbitai review |
✅ Action performedReview finished.
|
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
🚧 Files skipped from review as they are similar to previous changes (2)
📝 WalkthroughWalkthroughChangesGmail watch maintenance
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Maintenance
participant MailboxStore
participant OAuth
participant Gmail
participant WatchState
participant Queue
Maintenance->>MailboxStore: List active mailboxes
Maintenance->>OAuth: Acquire access token
Maintenance->>Gmail: Renew watch registration
Gmail-->>WatchState: Store expiration
Maintenance->>WatchState: Read history cursor
Maintenance->>Queue: Enqueue reconciliation job
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/mail/gmail-watch-maintenance.ts`:
- Around line 228-260: Reuse the access token obtained during Step 1 when
invoking watch() in the maintenance flow, rather than calling getAccessToken
again through createWatchClient. Update the surrounding logic in the maintenance
function to pass the validated token into the watch client while preserving
existing failure handling, and add a regression test asserting one token-service
call per mailbox.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 1f396720-aba1-46f3-bf9d-6a53ef0a9f83
📒 Files selected for processing (11)
specs/mail/gmail-push.mdsrc/api/gmail-webhook.test.tssrc/api/index.test.tssrc/mail/gmail-connect.test.tssrc/mail/gmail-reconcile.test.tssrc/mail/gmail-watch-maintenance.test.tssrc/mail/gmail-watch-maintenance.tssrc/store/gmail-watch-state.test.tssrc/store/gmail-watch-state.tssrc/store/mailboxes.test.tssrc/store/mailboxes.ts
…n (HT-42 review) Addresses CodeRabbit on PR #42. Acquire the access token once per mailbox and reuse it for the single watch() call, instead of probing getAccessToken in step 1 and letting the watch client fetch it a second time. Beyond dropping a redundant token-service call, this classifies token-acquisition failures in one place — a watch() failure is now unambiguously a watch-API failure, not a token refresh that raced revocation mid-call and got mislabeled transient. (gmail-reconcile.ts keeps the getAccessToken closure because its multi-page, long-running client must not carry a token that goes stale mid-run; a single watch() call has no such need.) Adds a regression test asserting exactly one token-service call per mailbox. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Addressed in |
…n (HT-42 review) Addresses CodeRabbit on PR #42. Acquire the access token once per mailbox and reuse it for the single watch() call, instead of probing getAccessToken in step 1 and letting the watch client fetch it a second time. Beyond dropping a redundant token-service call, this classifies token-acquisition failures in one place — a watch() failure is now unambiguously a watch-API failure, not a token refresh that raced revocation mid-call and got mislabeled transient. (gmail-reconcile.ts keeps the getAccessToken closure because its multi-page, long-running client must not carry a token that goes stale mid-run; a single watch() call has no such need.) Adds a regression test asserting exactly one token-service call per mailbox. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
b1b0da4 to
f04f878
Compare
HT-42 — Gmail
watch()renewal cron + reconciliation sweepA daily maintenance pass over active Gmail mailboxes. Gmail push
watch()silently expires every ~7 days; push is also best-effort (Gmail may drop/delay notifications). This closes both gaps.Structured like the HT-16 delivery worker: a plain injected-deps function (
runGmailWatchMaintenance), not aSchedulerProvideradapter — the Vercel-cron schedule declaration and the queue-consumer wiring are composition-root concerns deferred to HT-43 (no such adapter is wired yet). Per active mailbox, failure-isolated:watch()and store the fresh expiration viaGmailWatchStateStore.setWatchExpiration— which updateswatch_expirationonly, never the cursor. A renewal's freshhistoryIdis ahead of the stored cursor, so overwriting it would silently skip un-reconciled mail (invariant HT-3: clean-room protocol doc #1). A test assertshistory_idis preserved across renewal.GMAIL_RECONCILE_TOPIC. Enqueued with nodedupeKey, so a daily sweep of a quiet mailbox isn't suppressed as a duplicate; idempotent ingest ((mailboxId, providerMessageId)) makes the redundant work safe.New surface:
MailboxStore.listActiveMailboxes,GmailWatchStateStore.setWatchExpiration,src/mail/gmail-watch-maintenance.ts. No migration.Scope decision (worth a look)
The Jira ticket scopes HT-42 to the renewal cron and says "NOT on the ingest path";
gmail-push.md§6 bundles renewal + a reconciliation sweep + a per-mailbox reconciliation lease. I consulted a second engineering opinion (Codex) and landed on: renewal + sweep here, defer only the lease (→ new ticket HT-48). Rationale: the sweep is the safety net that makes best-effort push acceptable under invariant #1 — on a quiet mailbox a dropped push isn't "caught by the next push" (there may be none), so mail could sit unseen indefinitely; and the sweep is a small add that reuses HT-41 (no new ingest code, satisfying the ticket's intent). The lease is pure efficiency (§6's own words: "not a correctness one") + needs a migration → HT-48.§6updated to re-attribute the lease.Spec refinement I made (judgment call — flagging it)
gmail-push.md§6 said "onwatch()failure → mark needs-reconnect." Since HT-38, the token layer owns the dead-grant→needs_reconnecttransition (viainvalid_grant) and catches every dead grant within the ~1h access-token cache lifetime. So auto-markingneeds_reconnecton a genericwatch()failure would halt a healthy mailbox on a transient Gmail blip. This PR instead treats a valid-tokenwatch()failure as transient (retry next daily run — the 7-day expiry leaves margin) and lets the token layer ownneeds_reconnect. I updated §6 to reflect this. The dead-grant outcome is unchanged; only where it's decided moved.Verified (watched, not claimed)
setWatchExpirationonly (grep-confirmed — neverseedBaseline/setCursor); type-only adapter import with injectedcreateWatchClient(adapter-boundary rule); no token in any log or thrown error; failure-isolated per mailbox.typecheck✓,lint✓ (166 files),test✓ (35 files / 692 tests, +16 new).Deferred / unchanged boundaries
Schedule declaration + queue-consumer wiring → HT-43; reconciliation lease → HT-48; live end-to-end proof → HT-44. Exercised entirely against fakes (fake watch client, fake queue, in-memory stores) — no cloud.
🤖 Generated with Claude Code
Summary by CodeRabbit