Skip to content

fix(mail): reclaim stuck 'received' inbound deliveries via lease (HT-45) - #48

Merged
zaridan merged 2 commits into
mainfrom
fix/ht-45-stuck-received-reclaim
Jul 16, 2026
Merged

fix(mail): reclaim stuck 'received' inbound deliveries via lease (HT-45)#48
zaridan merged 2 commits into
mainfrom
fix/ht-45-stuck-received-reclaim

Conversation

@zaridan

@zaridan zaridan commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Inbound deliveries could get stuck forever at status = 'received': a hard crash between InboundDeliveryStore.claim() committing 'received' and the ingest pipeline's step-5 store transaction (or its catch-block markFailed) stranded the row, since claim() only ever reclaimed 'failed' rows. Every retry replayed the same stuck 'in-progress' outcome, and (via HT-41's cursor coupling) could block the mailbox's reconcile cursor from advancing.
  • Adds inbound_deliveries.claimed_until (migration 014), mirroring threads.claimed_until (migration 003) and ConversationStore's outbound lease/reclaim pattern. claim() now stamps a lease on every successful claim and reclaims a 'received' row once its lease has lapsed, via the same row-locked UPDATE shape already used for the failed-row reclaim.
  • No new sweep function needed: a re-delivered push notification, or the Gmail reconcile handler's history replay, already calls claim() again for the same key — the lease is what makes that call actually reclaim and reprocess instead of reporting 'in-progress' forever.
  • Follow-up review fix: the initial reclaim mechanism had no fencing at commit time — a slow-but-alive owner that outlives its reclaimed lease could still commit after another worker reclaimed it, risking two live owners writing two outcomes for one email (spec §8's "exactly one conversation"). attempts now doubles as a claim generation: every successful claim returns it, and every ledger write (markStoredInTx/markSuppressed/markFailed/markDeadLetter) fences its UPDATE on status = 'received' AND attempts = $claimedAttempts, throwing a new LeaseLostError on a stale write instead of silently overwriting the current owner. ingestInboundMessage catches it and reports in-progress rather than forcing a failed/dead-letter write against a generation it no longer owns.
  • Also closes the retry-budget gap this created: a received-lease reclaim now bumps attempts too (a lapsed lease is itself evidence of an abandoned attempt), so a message that hard-crashes the process on every try still converges to dead-letter via MAX_INGEST_ATTEMPTS instead of retrying forever.
  • Removes IngestDeps.leaseMs (dead configurability with no consumers — composition root and tests never set it); DEFAULT_INBOUND_LEASE_MS is used directly.
  • specs/mail/inbound-ingestion.md §4/§8 updated for the fence and the reclaim-counts-toward-budget behavior.

Design decisions

  • Lease/reclaim pattern reused from threads.claimed_until / ConversationStore, not a new mechanism — keeps the concurrency-safety story consistent across the codebase (row-locked UPDATE, concurrent-reclaim test proves it).
  • No dedicated sweep function for stuck 'received' rows — relies on existing re-delivery paths (push notification redelivery, Gmail reconcile history replay) calling claim() again, bounded above by the daily watch-maintenance sweep even with no new mail. This is a deliberate scope call to avoid adding a new background job; flagging for the maintainer's sign-off in case a dedicated sweep is wanted as a belt-and-suspenders backstop.
  • attempts reused as claim generation for fencing rather than adding a separate generation/version column — smaller schema footprint, but worth the maintainer's sign-off since it overloads a field that also drives the retry-budget/dead-letter threshold (the PR's second commit explicitly reasons through why that overlap is safe: a lapsed lease is itself an abandoned attempt).
  • Removed IngestDeps.leaseMs as dead configurability discovered during this change — flagging in case there was a reason it existed that isn't visible from current call sites.

Review

Independent gate: typecheck 0, lint 0, tests 0 (exit codes), clean tree. Adversarial review of record: 5 findings (3 actionable), fixes applied and re-gated.

Verification

Ran in /Users/tjbaker/Projects/helpthread-worktrees/fix-ht-45-stuck-received-reclaim.

  • git status --porcelain produced zero lines (tree clean).
  • npm run typecheck (tsc --noEmit -p tsconfig.json) exited 0 with no output/errors.
  • npm run lint (biome check .) exited 0: "Checked 180 files in 81ms. No fixes applied."
  • npm test (vitest run) exited 0 on the full suite: "Test Files 41 passed (41)", "Tests 760 passed".

https://resonantiq.atlassian.net/browse/HT-45

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added lease-based recovery for inbound messages that become stuck during processing.
    • Prevented duplicate processing when a delivery is actively being handled.
    • Added safe concurrent recovery so only one retry proceeds after a lease expires.
    • Added safeguards to reject stale processing attempts.
  • Bug Fixes

    • Prevented repeatedly crashing deliveries from retrying indefinitely; they now move to dead-letter status after the retry limit.
  • Documentation

    • Expanded inbound ingestion guidance and acceptance criteria for lease, retry, and crash-recovery behavior.

zaridan and others added 2 commits July 16, 2026 12:59
A hard crash between InboundDeliveryStore.claim() committing 'received'
and the ingest pipeline's step-5 store transaction (or its catch-block
markFailed) stranded the delivery at 'received' forever: claim() only
reclaimed 'failed' rows, so re-delivery replayed the same stuck
'in-progress' outcome on every retry, and (HT-41's cursor coupling)
could block the mailbox's reconcile cursor from ever advancing.

Adds inbound_deliveries.claimed_until (migration 014), mirroring
threads.claimed_until (migration 003) and ConversationStore's outbound
lease/reclaim pattern. claim() now stamps a lease on every successful
claim and reclaims a 'received' row once its lease has lapsed, via the
same row-locked UPDATE shape already used for the failed-row reclaim -
concurrency-safe by construction, proven by a concurrent-reclaim test.

No new sweep function: a re-delivered push notification, or the Gmail
reconcile handler's history replay (which keeps re-listing the same
stuck message while the cursor can't advance past it, bounded above by
the daily watch-maintenance sweep even with no new mail), already calls
claim() again for the same key - the lease is what makes that call
actually reclaim and reprocess instead of reporting 'in-progress'
forever. Decision and reasoning recorded in inbound-deliveries.ts's
module doc and specs/mail/inbound-ingestion.md §4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… (HT-45 review)

The lease reclaim HT-45 added had no fencing at commit time: a slow-but-alive
owner that outlives its reclaimed lease could still commit after another
worker reclaimed it, risking two live owners writing two outcomes for one
email (spec §8's "exactly one conversation"). `attempts` now doubles as a
claim generation — every successful claim returns it, and every ledger write
(markStoredInTx/markSuppressed/markFailed/markDeadLetter) fences its UPDATE on
`status = 'received' AND attempts = $claimedAttempts`, throwing the new
LeaseLostError on a stale write instead of silently overwriting the current
owner. ingestInboundMessage catches it and reports `in-progress` rather than
forcing a failed/dead-letter write against a generation it no longer owns.

Also closes the retry-budget gap this created: a received-lease reclaim now
bumps `attempts` too (a lapsed lease is itself evidence of an abandoned
attempt), so a message that hard-crashes the process on every try still
converges to dead-letter via MAX_INGEST_ATTEMPTS instead of retrying forever
and keeping the mailbox's reconcile cursor wedged behind it.

Removes IngestDeps.leaseMs (dead configurability with no consumers — the
composition root and tests never set it); DEFAULT_INBOUND_LEASE_MS is used
directly.

specs/mail/inbound-ingestion.md §4/§8 updated for the fence and the
reclaim-counts-toward-budget behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Inbound delivery ingestion now uses expiring claims and attempt-generation fences. Expired received rows can be reclaimed atomically, stale workers receive LeaseLostError, and repeated abandoned attempts converge to dead-letter. A migration adds claimed_until, with store, ingest, specification, and test updates.

Changes

Inbound delivery lease recovery

Layer / File(s) Summary
Lease schema and contracts
specs/mail/inbound-ingestion.md, src/db/migrate.ts, src/db/migrate.test.ts, src/store/inbound-deliveries.ts
Adds claimed_until, documents lease and attempt-generation semantics, and updates delivery-store contracts for lease-aware claims and fenced writes.
Claim reclaim and fenced writes
src/store/inbound-deliveries.ts, src/store/inbound-deliveries.test.ts
Reclaims failed or lease-expired deliveries, increments attempts, prevents concurrent reclaim winners, and raises LeaseLostError for stale transitions.
Ingest lease handling and recovery validation
src/mail/ingest.ts, src/mail/ingest.test.ts
Passes lease generations through ingestion, reports lease loss as in-progress, enforces the reclaim budget, and tests crash recovery, concurrency, and dead-letter convergence.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ingestInboundMessage
  participant InboundDeliveryStore
  participant Database
  ingestInboundMessage->>InboundDeliveryStore: claim(delivery, leaseMs)
  InboundDeliveryStore->>Database: atomically claim or reclaim row
  Database-->>InboundDeliveryStore: delivery and attempts generation
  InboundDeliveryStore-->>ingestInboundMessage: claim result
  ingestInboundMessage->>InboundDeliveryStore: markStoredInTx(..., claimedAttempts)
  InboundDeliveryStore->>Database: fenced status transition
  Database-->>InboundDeliveryStore: stored row or LeaseLostError
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: reclaiming stuck received inbound deliveries with a lease-based fix.
Docstring Coverage ✅ Passed Docstring coverage is 92.86% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/ht-45-stuck-received-reclaim

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 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/ingest.ts`:
- Around line 328-344: Update the exhausted reclaim branch in src/mail/ingest.ts
(lines 328-344) to dead-letter the delivery without incrementing attempts,
preserving the already-counted MAX_INGEST_ATTEMPTS value. Update the
corresponding expectation in src/mail/ingest.test.ts (lines 395-426) to assert
MAX_INGEST_ATTEMPTS rather than MAX_INGEST_ATTEMPTS + 1, and retain verifiable
fixture evidence for the mail-semantic completion claim.

In `@src/store/inbound-deliveries.test.ts`:
- Around line 203-221: Update src/store/inbound-deliveries.test.ts lines 203-221
to create the conversation within the same transaction as markStoredInTx, then
assert the stale transaction rolls back without leaving a conversation or
thread. Update src/mail/ingest.test.ts lines 364-381 to pause worker A after
claiming, let worker B expire, reclaim, and commit, then resume A; assert A
returns in-progress, B returns stored, and exactly one conversation/thread
exists.
- Around line 165-184: Update the stale-generation test around store.claim and
the mark* methods so it keeps the delivery status as received while advancing
only its attempts value, rather than calling markFailed. Exercise each mark*
method with the stale delivery.attempts fence and assert LeaseLostError plus
direct-row evidence that no fields were mutated; preserve the fixture-backed
mailbox/delivery setup and verify the expected mail semantics.
🪄 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: 43daeb72-f1c8-493f-9ab6-ba41d2132675

📥 Commits

Reviewing files that changed from the base of the PR and between f69ba48 and 3ad587f.

📒 Files selected for processing (7)
  • specs/mail/inbound-ingestion.md
  • src/db/migrate.test.ts
  • src/db/migrate.ts
  • src/mail/ingest.test.ts
  • src/mail/ingest.ts
  • src/store/inbound-deliveries.test.ts
  • src/store/inbound-deliveries.ts

Comment thread src/mail/ingest.ts
Comment on lines +328 to +344
if (delivery.attempts >= MAX_INGEST_ATTEMPTS) {
const message =
`lease reclaimed ${delivery.attempts} times without completing ` +
`(exceeded MAX_INGEST_ATTEMPTS = ${MAX_INGEST_ATTEMPTS})`
const updated = await deps.inboundDeliveryStore.markDeadLetter(
delivery.id,
message,
delivery.attempts,
)
logIngestEvent({
...base,
outcome: 'dead-letter',
stage: 'lease-reclaim-budget',
attempts: updated.attempts,
error: message,
})
return { ...base, kind: 'dead-letter', attempts: updated.attempts, error: message }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Keep exhausted reclaim attempts capped at MAX_INGEST_ATTEMPTS.

The reclaim already counts the abandoned attempt. Calling the incrementing markDeadLetter then records a nonexistent additional attempt.

  • src/mail/ingest.ts#L328-L344: dead-letter the exhausted generation without incrementing its already-counted attempts.
  • src/mail/ingest.test.ts#L395-L426: expect MAX_INGEST_ATTEMPTS, not MAX_INGEST_ATTEMPTS + 1.

As per coding guidelines, mail-semantic completion claims require verifiable fixture evidence. <coding_guidelines>

📍 Affects 2 files
  • src/mail/ingest.ts#L328-L344 (this comment)
  • src/mail/ingest.test.ts#L395-L426
🤖 Prompt for 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.

In `@src/mail/ingest.ts` around lines 328 - 344, Update the exhausted reclaim
branch in src/mail/ingest.ts (lines 328-344) to dead-letter the delivery without
incrementing attempts, preserving the already-counted MAX_INGEST_ATTEMPTS value.
Update the corresponding expectation in src/mail/ingest.test.ts (lines 395-426)
to assert MAX_INGEST_ATTEMPTS rather than MAX_INGEST_ATTEMPTS + 1, and retain
verifiable fixture evidence for the mail-semantic completion claim.

Source: Coding guidelines

Comment on lines +165 to +184
it('a mark* write whose claimed-attempts fence no longer matches throws LeaseLostError, and does NOT touch the row', async () => {
const { db, store, mailboxId } = await freshStore()
const { delivery } = await store.claim(mailboxId, 'provider-msg-1', LEASE_MS)

// Simulate a concurrent reclaim having moved the row's generation on
// (e.g. a received-lease reclaim bumped attempts) — this stale caller's
// captured fence (0) is no longer current.
await store.markFailed(delivery.id, 'boom', delivery.attempts)

await expect(
store.markSuppressed(delivery.id, 'own-message-loop', delivery.attempts),
).rejects.toThrow(LeaseLostError)

// The row was NOT overwritten by the stale write — read it directly
// rather than via `claim()`, which would itself reclaim the 'failed' row.
const row = await db.query<{ status: string }>(
'SELECT status FROM inbound_deliveries WHERE id = $1',
[delivery.id],
)
expect(row[0].status).toBe('failed')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Exercise the attempts predicate independently of status.

markFailed first changes the row to failed, so the subsequent write is rejected by status = 'received' even if the attempts fence is removed. Keep the row received, advance only attempts, and verify each mark* method rejects the stale generation without mutation.

As per coding guidelines, “Convert vague requests into verifiable success criteria” and require fixture-proven evidence for mail semantics. <coding_guidelines>

🤖 Prompt for 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.

In `@src/store/inbound-deliveries.test.ts` around lines 165 - 184, Update the
stale-generation test around store.claim and the mark* methods so it keeps the
delivery status as received while advancing only its attempts value, rather than
calling markFailed. Exercise each mark* method with the stale delivery.attempts
fence and assert LeaseLostError plus direct-row evidence that no fields were
mutated; preserve the fixture-backed mailbox/delivery setup and verify the
expected mail semantics.

Source: Coding guidelines

Comment on lines +203 to +221
const { threadId } = await createConversationStore(db).createConversation({
subject: 'Help with my order',
customerEmail: 'customer@example.test',
firstMessage: {
direction: 'inbound',
messageId: '<cust-1@customer.example.test>',
fromAddress: 'customer@example.test',
bodyText: 'Where is my order?',
},
})
await expect(
db.transaction((tx) =>
markStoredInTx(tx, first.delivery.id, threadId, first.delivery.attempts),
),
).rejects.toThrow(LeaseLostError)

// B's claim is untouched: still 'received', still holding attempts: 1 —
// A's stale write did not overwrite it, and no duplicate conversation
// was left behind (Db.transaction rolled the whole attempt back).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Verify the actual stale-owner interleaving and rollback.

The current tests do not prove the acceptance scenario where an original worker resumes after another worker reclaimed and committed.

  • src/store/inbound-deliveries.test.ts#L203-L221: create the conversation inside the same transaction as markStoredInTx; currently it commits beforehand and cannot be rolled back. Assert no stale conversation/thread remains.
  • src/mail/ingest.test.ts#L364-L381: orchestrate worker A pausing after its claim, expire/reclaim and commit worker B, then resume A; assert A returns in-progress, B returns stored, and exactly one conversation/thread exists.

As per coding guidelines, changes affecting mail semantics require fixture-proven equivalence and verifiable success criteria. <coding_guidelines>

📍 Affects 2 files
  • src/store/inbound-deliveries.test.ts#L203-L221 (this comment)
  • src/mail/ingest.test.ts#L364-L381
🤖 Prompt for 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.

In `@src/store/inbound-deliveries.test.ts` around lines 203 - 221, Update
src/store/inbound-deliveries.test.ts lines 203-221 to create the conversation
within the same transaction as markStoredInTx, then assert the stale transaction
rolls back without leaving a conversation or thread. Update
src/mail/ingest.test.ts lines 364-381 to pause worker A after claiming, let
worker B expire, reclaim, and commit, then resume A; assert A returns
in-progress, B returns stored, and exactly one conversation/thread exists.

Source: Coding guidelines

@zaridan
zaridan merged commit 0834cc4 into main Jul 16, 2026
5 checks passed
@zaridan
zaridan deleted the fix/ht-45-stuck-received-reclaim branch August 2, 2026 19:19
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