Skip to content

fix(ai): best-effort per-conversation send serialization (advisory lock) - #2071

Merged
2witstudios merged 4 commits into
masterfrom
pu/e2-send-lock
Jul 14, 2026
Merged

fix(ai): best-effort per-conversation send serialization (advisory lock)#2071
2witstudios merged 4 commits into
masterfrom
pu/e2-send-lock

Conversation

@2witstudios

@2witstudios 2witstudios commented Jul 14, 2026

Copy link
Copy Markdown
Owner

Summary

PR 4 of the "Server Stream Durability & Rejoin" epic — closes the check-then-act race
between takeOverConversationStreams's SELECT and createStreamLifecycle's INSERT
(documented at apps/web/src/app/api/ai/chat/route.ts:1193-1211), which allowed two
near-simultaneous sends on the same conversation to both see zero in-flight rows and
both proceed — two generations, two sets of tool calls, two bills.

  • Adds startGenerationExclusive (apps/web/src/lib/ai/core/start-generation-exclusive.ts),
    wrapping takeover + stream-lifecycle-create in a per-conversation Postgres advisory
    try-lock (ai-send:<conversationId>, via withAdvisoryLock/getAdvisoryLockPool
    packages/db/src/advisory-lock.ts).
  • Best-effort, not an invariant (named honestly per the board): on lock_busy, retries
    3×300ms then proceeds unlocked — availability wins over serialization, a send must
    never block on this lock.
  • Two distinct degrade paths, both telemetered, both tested:
    • lock_busy — the lock is held by a concurrent send on the SAME conversation; retries
      exhausted, proceeds unlocked.
    • lock_error — the lock connection itself failed (pool.connect() or the try-lock query
      threw: pool exhaustion, connection reset); proceeds unlocked immediately, no retry (a
      broken connection isn't "busy" and won't resolve on a 300ms cadence). Added after
      self-review and an independent Codex review both caught that the initial implementation
      only handled the resolved lock_busy outcome — a connection failure propagated
      uncaught and turned "never block a send" into a hard 500.
    • Both paths emit a named metric (logPerformance) + structured warn, tagged with
      reason so they stay distinguishable in logs/metrics — never silent.
  • The lock-busy retry/degrade decision (decideOnLockBusy) is a pure function, 100%
    branch tested.
  • Wired into both POST /api/ai/chat and POST /api/ai/global/[id]/messages.
  • Existing route-level mock-DB tests updated with a lock-always-free advisory-pool mock
    so they continue exercising the same takeover+lifecycle-create path as before.
  • Leaves a seam note on PR 2's board page (assistant message row at stream start): the
    placeholder insert slots in as one more await inside startGenerationExclusive's
    run closure — no change to the primitive itself needed.

Test plan

  • bun run typecheck (web) — clean
  • bun run lint (web) — clean (0 errors; pre-existing unrelated warnings only)
  • bun vitest run src/lib/ai/core/__tests__/start-generation-exclusive.test.ts — 16/16, 100% line/branch/function coverage
  • bun vitest run src/lib/ai/core/__tests__ — 895+/895+ passing
  • bun vitest run src/app/api/ai — 473+/473+ passing (+3 in the gate-callsites guard)
  • All CI checks green (Lint & TypeScript, Unit Tests, Security Test Suite, CodeQL, Dependency Audit, Secret Scanning, Static Security Analysis)
  • No merge conflicts with master (verified via git merge-tree after master advanced)

🤖 Generated with Claude Code

https://claude.ai/code/session_0197HJSWMLvTu8qe75V7Dkvs

Adds startGenerationExclusive (apps/web/src/lib/ai/core/start-generation-exclusive.ts),
wrapping takeover + stream-lifecycle create in a per-conversation Postgres advisory
try-lock (ai-send:<conversationId>, withAdvisoryLock/getAdvisoryLockPool). Closes the
check-then-act race documented at chat/route.ts:1193-1211 between
takeOverConversationStreams's SELECT and createStreamLifecycle's INSERT.

Best-effort, not an invariant: on lock_busy, retries 3x300ms then proceeds unlocked
(availability over serialization), emitting a named metric (logPerformance) and a
structured warn — never silent. The lock-busy retry/degrade decision is a pure
function (decideOnLockBusy), 100% branch tested.

Wired into both POST /api/ai/chat and POST /api/ai/global/[id]/messages. Existing
route-level mock-DB tests updated with a lock-always-free advisory pool mock so
they exercise the same takeover+lifecycle-create path as before.

Leaves a seam note on PR 2's board page (assistant message row at stream start):
the placeholder insert slots in as one more await inside startGenerationExclusive's
run closure, no changes needed to the primitive itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197HJSWMLvTu8qe75V7Dkvs
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@2witstudios, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 40 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c9d95533-9e9a-4d1f-9511-0c75d7596157

📥 Commits

Reviewing files that changed from the base of the PR and between d50475e and f82204e.

📒 Files selected for processing (8)
  • apps/web/src/app/api/ai/chat/__tests__/stream-socket-events.test.ts
  • apps/web/src/app/api/ai/chat/route.ts
  • apps/web/src/app/api/ai/global/[id]/messages/__tests__/conversation-id-resolution.test.ts
  • apps/web/src/app/api/ai/global/[id]/messages/__tests__/credit-gate.test.ts
  • apps/web/src/app/api/ai/global/[id]/messages/__tests__/stream-socket-events.test.ts
  • apps/web/src/app/api/ai/global/[id]/messages/route.ts
  • apps/web/src/lib/ai/core/__tests__/start-generation-exclusive.test.ts
  • apps/web/src/lib/ai/core/start-generation-exclusive.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pu/e2-send-lock

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b21061c129

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".


let attemptsMade = 0;
for (;;) {
const attempt = await withAdvisoryLock(pool, lockKey, run);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Fall back when the advisory lock probe fails

When the advisory-lock pool cannot connect or pg_try_advisory_lock throws (for example after adding this new 10-connection pool in a deployment that is near Postgres max_connections while the main DB pool is still usable), withAdvisoryLock rejects here and both chat routes fall into their 500 handlers before run executes. That makes the best-effort serialization lock a hard availability dependency and regresses sends that previously continued through takeover/lifecycle creation; lock acquisition errors should take the same logged unlocked fallback as lock_busy.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 26a03fe: startGenerationExclusive now wraps the withAdvisoryLock(...) call in try/catch. On a lock-machinery failure (pool.connect() or the try-lock query throwing — pool exhaustion, connection reset), it degrades to unlocked immediately (no retry, since a broken connection is not "busy" and wont resolve on a 300ms cadence) rather than propagating and 500ing the send. Both degrade paths (lock_busy after retry exhaustion, and this lock_error path) now share one telemetry helper (degradeToUnlocked), tagged with reason so they stay distinguishable in logs/metrics. 5 new tests cover pool.connect() rejection, a poisoned try-lock query, no-retry-on-lock_error, and telemetry content — 15/15 passing at 100% branch coverage. Leaving this thread open for verification against the new commit.

2witstudios and others added 2 commits July 14, 2026 14:22
… just lock_busy

startGenerationExclusive only handled the resolved lock_busy outcome — a
pool.connect() or try-lock query failure (pool exhaustion, connection reset on
the dedicated advisory-lock pool) threw straight out of withAdvisoryLock
uncaught, turning the PR's own "a send must never block on this lock"
guarantee into a hard 500. The PR page's own verification checklist already
required this exact case ("Lock-pool exhaustion simulation: proceeds
unlocked... degraded = today"), which the implementation didn't satisfy.

Wraps the withAdvisoryLock call in try/catch: on a lock-machinery failure,
degrades to unlocked immediately (no retry — a broken connection isn't
"busy" and won't resolve on a 300ms cadence). Both degrade paths now share
one telemetry helper (degradeToUnlocked), tagged with reason: 'lock_busy' |
'lock_error' so they stay distinguishable in logs/metrics.

5 new RED-then-GREEN tests; 15/15 passing at 100% line/branch/function
coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197HJSWMLvTu8qe75V7Dkvs
CI (web:lint via next build) failed: 'error' is defined but never used
(@typescript-eslint/no-unused-vars) in the catch block added for the
lock-connection-failure fix. My local `bun run typecheck` only ran
`tsc --noEmit`, which doesn't catch ESLint rules — `bun run lint` does,
and I hadn't run it before pushing.

Fixes it by actually using the error: degradeToUnlocked now takes an
optional `error` field, stringified into the structured warn (not the
metric — kept lean/structured) so a lock_error degrade is diagnosable
in logs, matching the pattern withAdvisoryLock itself uses for its own
unlock-failure logging.

2 new tests cover the error-message plumbing (Error instance and a
thrown non-Error value) — 16/16 passing at 100% branch coverage.
Confirmed `bun run lint` exits 0 and `bun run typecheck` is clean
before this push.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197HJSWMLvTu8qe75V7Dkvs
@2witstudios

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

…-run tail

Proactive self-review (/simplify) on the degrade path added in the previous
fix commit surfaced two low-risk cleanups, both applied:

- degradeToUnlocked's destructure-then-conditionally-respread for the error
  field was a double negative (strip error out, then maybe splice a
  differently-shaped one back in via a ternary yielding {} or {error}).
  Flattened to one computed errorMessage plus a single flat object literal.
  toEqual (used in the tests) ignores undefined-valued keys, so this is
  behaviorally identical — confirmed by the unchanged test assertions.
- The two call sites (lock_busy exhaustion, lock_error catch) each repeated
  the same "telemeter, run(), wrap in {outcome:'degraded'}" tail.
  degradeToUnlocked now does the run()+wrap itself and returns the outcome
  directly, so each call site is one line.

Also evaluated and declined two other candidates from the review:
- A discriminated-union restructuring of the try/catch (removing the `let
  attempt`) — read cleaner in isolation but doesn't fix the underlying
  ambiguity (withAdvisoryLock can't structurally distinguish its own
  connection failures from `run` throwing); filed as a D task instead of
  a cosmetic-only local change.
- Passing the error through the codebase's PII-scrubbing logger.error() path
  instead of a plain string field — logger.warn() has no Error-aware channel
  at all in this codebase, and passing error.message as a string metadata
  field matches 45+ existing call sites; not a defect unique to this PR.

Filed a D task at the epic level (Server Stream Durability & Rejoin,
lmlmfg9ooagf88dzonqt4yph, task fmfmzw4g4gh6u6q9cjt7ylne) for the deeper
structural fix: withAdvisoryLock should resolve a connection_error outcome
instead of throwing for pool.connect()/try-lock-query failures, so a future
addition to `run` (PR 2's message insert) can't cause misclassified
telemetry + a double-run if it ever throws. Out of scope for this PR's leaf
(4.1-4.3 already satisfied) since it touches a shared, tested primitive with
another live caller (reconcileMachineStorageSerialized).

16/16 tests passing, 100% branch/line/function coverage maintained.
bun run lint / typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197HJSWMLvTu8qe75V7Dkvs
@2witstudios

Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Full review finished.


Your plan includes PR reviews subject to rate limits. More reviews will be available in 40 minutes.

@2witstudios
2witstudios merged commit 4c29055 into master Jul 14, 2026
10 checks passed
@2witstudios
2witstudios deleted the pu/e2-send-lock branch July 14, 2026 21:02
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