Skip to content

Persist snooze until and reopen it with a periodic sweep (CL-7208) - #514

Merged
TheGreatAxios merged 4 commits into
mainfrom
cl-7208-inbox-snooze-reopen
Aug 30, 2026
Merged

TheGreatAxios merged 4 commits into
mainfrom
cl-7208-inbox-snooze-reopen

Conversation

@TheGreatAxios

Copy link
Copy Markdown
Contributor

Summary

Stacked on #510 (CL-7207, cl-7207-bulk-ops-atomicity), which is itself stacked on #486 (CL-7206). Base this PR against cl-7207-bulk-ops-atomicity, not main, until both merge.

POST /:id/snooze accepted an until timestamp, validated its type, and echoed it back in the response — but the store call underneath only ever flipped the mailbox row's status to "snoozed". Nothing recorded until or ever reopened the item: a snoozed approval or credential-reconnect sat under the snoozed filter forever, invisible, with no reminder ever firing.

Changes

  • packages/inbox/src/schema.ts (new): @corbits/mailbox's enrichMailboxMessage only supports priority/classification/status — no column for a timestamp — so this adds the one product table @corbits/inbox needs: inbox.snooze (tenantId, principalId, messageId, until), its own Postgres schema, following @workbench/onboarding's pattern for a package-owned table.
  • packages/inbox/src/migrations.ts: applyInboxMigrations, the same hand-rolled ledger-table pattern as onboarding/routines/notify, registered in scripts/db-setup.ts's INSTALLED_PACKAGE_MIGRATIONS as @corbits/inbox.
  • packages/inbox/src/snooze-store.ts (new): setSnoozeUntil/clearSnoozeUntil, findDueSnoozes (a bare, non-claiming SELECT), and claimAndReopenSnooze — a single transaction that deletes the due row and, only if that succeeds, flips the message back to open if it's still snoozed. Two replicas racing the same row: the second's DELETE ... RETURNING simply returns nothing (the first already deleted it). A throw anywhere inside rolls the whole transaction back, so a failed claim leaves the snooze row exactly as it was for the next tick to retry — the same mail-then-claim ordering lesson CL-7209 applied to the credential-expiry sweep, applied here to the claim itself.
  • packages/inbox/src/routes.ts: /:id/snooze now requires until, parsed at the boundary with arktype (never as-cast) per AGENTS.md, and rejects with 400 if it doesn't parse as a valid timestamp or has already passed — silently snoozing forever is exactly the bug being fixed, so a past until is refused rather than accepted or silently reopened. Both the snooze-until write and the status flip run inside one db.transaction (see "Race found and fixed" below for why).
  • apps/hub/src/inbox-unsnooze-sweep.ts (new): a small periodic sweep (tickInboxUnsnoozeSweep/createInboxUnsnoozeSweep) mirroring credential-expiry-sweep.ts and routine-scheduler.ts — the only other periodic loops in this hub — rather than inventing a new scheduling primitive.

Which scheduling primitive, and why not the others

I checked @corbits/notify's createNotifyDispatcher and @corbits/routines' scheduler machinery first. Neither fits:

  • createNotifyDispatcher is shaped around external-sink delivery (attempts, backoff, a SinkRegistry) — a status flip on our own table isn't a delivery, and bending its retry/attempt semantics onto this would be a worse fit than writing the ~15 lines this needs directly.
  • The routine scheduler's RoutineStore is routine-domain-specific (fire schedules, launches) — nothing here is a routine.

The actual fit is the pattern both routine-scheduler.ts and credential-expiry-sweep.ts already use: a small setInterval tick, a listDue*/claim* store method, a standalone exported tick function for deterministic tests. That's exactly what CL-7209 reused too (the credential-expiry sweep's own due-scan, not a new mechanism) — this PR repeats that established idiom rather than building a third parallel poller shape. Wired into apps/hub/src/index.ts's createHub() next to credentialExpirySweep, sharing the existing mailboxDb/mailboxBus, stopped in shutdown alongside the others.

Race found and fixed mid-review

A Critique pass on the first two commits found and reproduced against a real Postgres a genuine race: persisting until and flipping status to snoozed as two independent statements let a concurrent sweep tick's claimAndReopenSnooze land in the gap — see the row before the flip, find the message not yet snoozed, no-op, and delete the row as harmless-looking cleanup — and then the route's own status flip would still land afterward, leaving the message stuck snoozed with the row that was supposed to reopen it already gone. That is exactly this ticket's bug, reintroduced by the un-transacted write order.

Fixed by running both writes in one db.transaction: an uncommitted insert is invisible to any other transaction under read-committed isolation, so a concurrent claim can never observe the row until the status flip has also committed alongside it. The third commit adds a regression test that races the route's own write against a concurrent claim and asserts the message is never left snoozed with its row gone — verified red-then-green against a real Postgres (reproducibly failed before the fix across 8/8 runs, passed 8/8 after).

Tests

  • Route-level: rejects a missing/non-string/unparseable/past until (routes.test.ts).
  • packages/inbox/test/snooze-store.test.ts (DB-gated, scratch-database pattern from delivery.test.ts): persists and reads back a due row, claims and reopens it, a second claim on an already-claimed row returns false, a no-longer-snoozed message's stale row gets cleaned up without a spurious reopen, and the TOCTOU regression test above. Ran against a real local Postgres repeatedly during development, not just typechecked.
  • apps/hub/test/inbox-unsnooze-sweep.test.ts: DB-free unit test of the tick loop against a fake store — reopens every due row and publishes once per actual reopen, doesn't publish on a no-op claim, and a per-row throw is swallowed (reported, not propagated) so one bad row can't abort the rest of the tick.

Verification

  • cd packages/inbox && bun run typecheck && bun test — 44 pass (unit), 9 skip locally (DB-gated, no DATABASE_URL in CI-less local runs) — separately ran with a real Postgres: 49 pass, 0 fail, including the new migration genuinely applying (@corbits/inbox: 0001_snooze).
  • cd apps/hub && bun run typecheck && bun test test/inbox-unsnooze-sweep.test.ts — 3 pass, 0 fail.
  • bunx prettier --check and bunx eslint on all touched/new files — clean.
  • Reviewed by Greybeard (approach, before writing code — caught wrong precedent claims, the wrong migration pattern, and the write-order gap that became the regression test) and Critique (implementation, twice — the second pass found the TOCTOU race above).

Linear: CL-7208

Base automatically changed from cl-7207-bulk-ops-atomicity to main August 30, 2026 21:29
New, package-owned Postgres table for CL-7208: `@corbits/mailbox`'s
enrichment (packages/inbox's only dependency for a message's status)
supports only priority/classification/status -- there is no column
to hold a snooze's `until` timestamp, so `POST /:id/snooze` has never
had anywhere to durably record when to reopen a snoozed item.

- schema.ts: `inbox.snooze` (tenantId, principalId, messageId, until),
  own schema, following @workbench/onboarding's pattern for a
  package-owned product table.
- migrations.ts: `applyInboxMigrations`, following the same
  ledger-table pattern as onboarding/routines/notify -- registered in
  scripts/db-setup.ts's INSTALLED_PACKAGE_MIGRATIONS as
  `@corbits/inbox`.
- snooze-store.ts: `setSnoozeUntil`/`clearSnoozeUntil` (plain
  upsert/delete), `findDueSnoozes` (a bare, non-claiming SELECT), and
  `claimAndReopenSnooze` -- the one operation with real teeth. It
  deletes the due row and flips the message back to `open` inside a
  single transaction: two hub replicas racing the same row only ever
  have one `DELETE ... RETURNING` succeed, and a throw between the
  delete and the status flip rolls both back, leaving the snooze row
  exactly as it was for the next sweep tick to retry. This is the
  same mail-then-claim ordering lesson CL-7209 applied to the
  credential-expiry sweep (act before durably marking "handled"),
  applied here to the claim itself so a mid-claim failure can never
  orphan an item snoozed forever.

Verified against a real Postgres (not just typechecked): a scratch-db
integration test proves setSnoozeUntil/findDueSnoozes/
claimAndReopenSnooze/clearSnoozeUntil all behave correctly, including
the race guard (a second claim on an already-claimed row returns
false) and the self-healing no-op (claiming a row whose message left
`snoozed` some other way cleans up the stale row instead of
reopening it).

Moves `postgres` from a devDependency to a runtime one (migrations.ts
uses it directly, like onboarding/routines/notify) and adds
`drizzle-orm`.
`POST /:id/snooze` accepted an `until` timestamp, validated its type,
and echoed it back -- but only ever persisted `{status: "snoozed"}`.
Nothing recorded `until` or ever reopened the item: a snoozed
approval or credential-reconnect sat under the snoozed filter
forever, invisible, with no reminder ever firing.

- routes.ts: `until` is now required and parsed at the boundary with
  arktype (never `as`-cast), rejected with 400 if it doesn't parse as
  a timestamp or has already passed -- silently snoozing forever is
  exactly the bug being fixed, so a past `until` is refused rather
  than accepted. `until` is persisted via `setSnoozeUntil` *before*
  the status flips to `snoozed`; if the flip then fails, the snooze
  row is rolled back too rather than left orphaned.
- apps/hub/src/inbox-unsnooze-sweep.ts: a small periodic sweep
  (`tickInboxUnsnoozeSweep`/`createInboxUnsnoozeSweep`) mirroring
  `credential-expiry-sweep.ts` and `routine-scheduler.ts` -- the only
  other periodic loops in this hub -- rather than inventing a new
  scheduling primitive. `@corbits/notify`'s `createNotifyDispatcher`
  is shaped around sink delivery (attempts, backoff, a
  `SinkRegistry`), not a status flip, and the routine scheduler's
  `RoutineStore` is routine-domain-specific; neither fits "reopen a
  mailbox row once its snooze elapses" without bending its semantics,
  so this repeats the small-setInterval-plus-own-due-scan shape both
  already use. Wired into apps/hub/src/index.ts next to
  credentialExpirySweep, on the same mailboxDb/mailboxBus every other
  mailbox consumer there shares, and stopped in shutdown alongside
  the others.

Tests: route-level rejection of a missing/non-string/unparseable/past
`until`; a DB-free unit test of the sweep tick against a fake store
(reopens every due row and publishes once per actual reopen, does
not publish when a claim reports no-op, and a per-row throw is
swallowed -- logged, not propagated -- so one bad row can't abort the
rest of the tick).
…-7208)

A Critique pass on the two prior CL-7208 commits found and reproduced
a real race against a live Postgres: `/:id/snooze` persisted `until`
and flipped `status` to `snoozed` as two independent statements. A
sweep tick's `claimAndReopenSnooze` landing in the gap between them
could see the snooze row before the status flip landed, find the
message not yet `snoozed`, no-op, and delete the row as
harmless-looking cleanup -- and then the route's own status flip
would still land afterward, leaving the message stuck `snoozed`
with the row that was supposed to reopen it already gone. That is
exactly the bug CL-7208 exists to fix, reintroduced by the
un-transacted write order.

Both writes now run inside one `db.transaction`: an uncommitted
insert is invisible to any other transaction under read-committed
isolation, so a concurrent claim can never observe the snooze row
until the status flip has also committed alongside it. A missing
message (enrichMailboxMessage returning false) throws a sentinel
caught outside the transaction, rolling the insert back too rather
than leaving an orphaned row.

Adds a regression test that races the route's own snooze write
against a concurrent claimAndReopenSnooze call and asserts the
message is never left `snoozed` with its snooze row already gone --
whichever side wins the race, the result must be either genuinely
`open` or `snoozed` with the row still there for the next tick.
Verified by running it against a real Postgres repeatedly (not just
typechecked): 8/8 runs green after the fix, reproducibly red before
it.
@TheGreatAxios
TheGreatAxios force-pushed the cl-7208-inbox-snooze-reopen branch from 29b9f2d to 9843bde Compare August 30, 2026 21:29
@TheGreatAxios
TheGreatAxios merged commit baf38a7 into main Aug 30, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant