Repository navigation
Conversation
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.
|
This PR doesn't fully meet our contributing guidelines and PR template. What needs to be fixed:
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. |
|
The following comment was made by an LLM, it may be inaccurate: |
There was a problem hiding this comment.
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 leavesseqvalues with no event row. A real repair belongs whereseqis assigned: start from the highest seq actually stored, or detect and repair the desync before choosingseq. Better still, fix the cause of the desync. - Extra query on every message. Each
insertMessagenow runs amax(seq)query even in the normal case (see inline comment). - The repair can be undone. The batch publish path writes
seq = finalSeqoutright (notmax(...)), which overwrites thereserveSequencemade here (see inline comment).
| // 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 |
There was a problem hiding this comment.
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.
|
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 2. Wrong layer / divergence. 3. Perf + undo. Also verified the pre-existing Test report: both new tests fail on pristine base (message-side with the byte-identical production |
|
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. |
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:surfaced only as a generic "Failed to drain Session". Consequences observed in production:
EventSequenceupsert 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,reserveSequenceforward 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
packages/core/test/session-projector.test.tsseeds a squatter row at the colliding seq and asserts the message lands past it plus the counter heals for the next message.UNIQUE constraint failederror; with it, all 16 tests in the file pass.tsc --noEmitclean for touched files, oxlint clean, prettier applied.Related upstream reports
opencode.dbcollide onsession_message.seqand permanently break sessions. This PR fixes that failure mode at the projection layer regardless of how the counters desync.Added by the opencode bot (AI-generated).
Related: #53146, #51798