Skip to content

fix(core): repair desynced session seq instead of dropping messages - #54907

Closed
nihal697 wants to merge 2 commits into
anomalyco:devfrom
nihal697:fix/session-message-seq-collision
Closed

nihal697 wants to merge 2 commits into
anomalyco:devfrom
nihal697:fix/session-message-seq-collision

Conversation

@nihal697

@nihal697 nihal697 commented Oct 12, 2026 •

Copy link
Copy Markdown

Problem

When a session's durable aggregate counter falls behind rows already stored in session_message (via forks, transfers, concurrent writers, or manual repairs), projecting the next message dies on:

SQLiteError: UNIQUE constraint failed: session_message.session_id, session_message.seq

surfaced only as a generic "Failed to drain Session". Consequences observed in production:

  • the user's message is silently dropped while model tokens keep burning,
  • the session is permanently wedged: the EventSequence upsert runs after projection, so the counter never advances past the collision and every subsequent message dies the same way (verified live: counter stuck at 3 against 859 stored rows).

Fix

In insertMessage (packages/core/src/session/projector.ts), when the event's seq is already taken, reassign past the stored max, reserveSequence forward so the wedge clears permanently, and log a warning naming the session. Normal-path behavior is unchanged (the event seq is used when free).

Verification

  • New regression test in packages/core/test/session-projector.test.ts seeds a squatter row at the colliding seq and asserts the message lands past it plus the counter heals for the next message.
  • Without the fix the test fails with the exact production UNIQUE constraint failed error; with it, all 16 tests in the file pass.
  • tsc --noEmit clean for touched files, oxlint clean, prettier applied.

Related upstream reports


Added by the opencode bot (AI-generated).

Related: #53146, #51798

When the durable aggregate counter falls behind rows already stored
(forks, transfers, concurrent writers, manual repairs), projecting a
message dies on UNIQUE(session_id, seq). The user's message is silently
dropped while tokens burn, and the session wedges forever because the
sequence upsert runs after projection.

Reassign past the stored max, reserve the counter forward so the wedge
clears, and log a warning naming the session.
@github-actions github-actions Bot added the needs:compliance This means the issue will auto-close after 2 hours. label Oct 12, 2026
@github-actions

Copy link
Copy Markdown
Contributor

This PR doesn't fully meet our contributing guidelines and PR template.

What needs to be fixed:

  • PR description is missing required template sections. Please use the PR template.

Please edit this PR description to address the above within 72 hours, or it will be automatically closed.

If you believe this was flagged incorrectly, please let a maintainer know.

@github-actions

Copy link
Copy Markdown
Contributor

The following comment was made by an LLM, it may be inaccurate:

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This handles one narrow case: session_message rows ahead of the counter while the event log is not. It doesn't fix the wedged sessions described here or in #53146.

How I checked: the new test fails on the base with the UNIQUE constraint failed error and passes with this change. packages/core typechecks. I also tried a temporary test that sends two messages, sets event_sequence.seq back to 0 (a stale counter), then sends a third. The message projection now succeeds, but the same transaction then dies when it inserts into the event table, which has its own unique (aggregate_id, seq) index. The whole commit rolls back, the message is still lost and the counter stays stale. When the counter really is behind (concurrent writers, #53146), the event log is ahead too, so this is the case that matters.

Public API: unchanged.

Concerns:

  • Wrong layer. The counter is owned by Bus (commitDurableEvent / publishMany). A projector that renumbers its rows and moves the aggregate counter forward from inside a single event's projection leaves the event at seq N while its message sits at seq M, and leaves seq values with no event row. A real repair belongs where seq is assigned: start from the highest seq actually stored, or detect and repair the desync before choosing seq. Better still, fix the cause of the desync.
  • Extra query on every message. Each insertMessage now runs a max(seq) query even in the normal case (see inline comment).
  • The repair can be undone. The batch publish path writes seq = finalSeq outright (not max(...)), which overwrites the reserveSequence made here (see inline comment).

Related: #53146, #51798

Comment thread packages/core/src/session/projector.ts Outdated
// tokens burn, and wedges the session forever because the counter upsert
// runs after projection. Reassign past the stored max instead so the
// message lands and the counter heals via reserveSequence below.
const max = yield* db

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This adds a max(seq) query to every message insert, including the normal case. It also only catches collisions in session_message. If the event log is ahead as well (the usual shape of a stale counter, e.g. #53146), the transaction still dies right after this, when it inserts into event (unique event_aggregate_seq_idx), and everything rolls back. I reproduced that by setting event_sequence.seq back to 0 after two messages and sending a third: it fails on insert into "event" and the message is not stored. The fix needs to go where Bus assigns seq, not in a single projector.

Moving the aggregate counter forward from inside a projector changes the event sequence as a side effect of projecting one event. The moved message's seq then differs from its event's durable.seq, and the skipped seqs have no event rows. Also, publishMany in bus.ts finishes with .onConflictDoUpdate({ set: { seq: finalSeq } }), not max(...), so in a batched publish this reservation is overwritten and the counter falls behind again.

bus.publish/publishMany now assign from the high-water mark of the
durable counter and the persisted event log instead of the counter
alone, and batch counter updates use max() rather than overwriting a
higher value advanced concurrently.

session projector insertMessage tolerates a taken (session_id, seq)
without extra queries on the normal path: insert with
onConflictDoNothing + RETURNING, repair past the stored max and heal
the counter on genuine collision, and keep failing loudly when the same
message id reappears (preserving rejection of reused message ids).

Without this, a stale counter wedges the session permanently: every
message dies on UNIQUE(session_id, seq) / UNIQUE(aggregate_id, seq)
while tokens burn. Regression tests cover both the event-side and the
message-side collision.
@nihal697

Copy link
Copy Markdown
Author

Thanks for the thorough review — all three points were correct, and I've reworked the PR to address each:

1. Event-side wedge (your repro: counter reset → dies at event insert).
Fixed where Bus assigns seq: both single publish and publishMany now derive from the high-water mark of the durable counter and the persisted event log, instead of the counter alone. New test assigns event seq past stored event rows when the counter is stale covers exactly your repro shape (seeded event rows ahead of the counter) and fails on base.

2. Wrong layer / divergence.
The assignment fix above is the primary repair and keeps event seqs contiguous. The projector keeps a narrower backstop for the shape Bus cannot see without a layering violation: message rows ahead of both the counter and the event log (fork copies, transfer imports, legacy rows — the exact production case in the PR description, where the event table is empty for the aggregate). Divergent message seqs there are append-only placements for the newest arrival, which listing, revert boundaries, and fork copies all consume from the row, not the event.

3. Perf + undo.
No extra query on the normal path anymore: the insert goes first with onConflictDoNothing(...).returning(), and the max+reserve work only runs on an actual conflict. And publishMany's counter upsert is now max(...) instead of a plain overwrite, so a batch computed from a stale read can no longer regress a concurrently advanced counter.

Also verified the pre-existing rejects distinct creator events that reuse one projected message ID test still passes — a re-projected duplicate keeps failing loudly on the id primary key rather than being silently absorbed.

Test report: both new tests fail on pristine base (message-side with the byte-identical production UNIQUE constraint failed error) and pass with the fix; full file 17/17, tsc/oxlint/prettier clean.

@github-actions

Copy link
Copy Markdown
Contributor

This pull request has been automatically closed because it was not updated to meet our contributing guidelines within the 72-hour window.

Feel free to open a new pull request that follows our guidelines.

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