fix(coderd): give chat message ids an append-order guarantee - #27495
Conversation
bdc295f to
f4c9360
Compare
f4c9360 to
6ba9454
Compare
|
@codex review |
|
Codex Review: Didn't find any major issues. Another round soon, please! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
1e49a5e to
026bb95
Compare
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 026bb956ff
ℹ️ 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".
|
@codex review |
|
Codex Review: Didn't find any major issues. Keep it up! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
`InsertChatMessages` relied on PostgreSQL evaluating the `BIGSERIAL` default in input-array order, and `GetChatMessagesByChatID` ordered by `created_at` while paginating by `id`. Callers that index the returned slice positionally, and readers that reconstruct history, therefore had no guarantee behind them. Allocate the ids before the insert and assign the k-th smallest to input index k, then return the rows explicitly ordered by id. Order history reads by id alone so they agree with the `after_id` cursor: `created_at` is the transaction start time, so it can disagree with append order when a transaction takes the chat row lock later than one that started after it. Wrapping the insert in a CTE makes sqlc synthesize `InsertChatMessagesRow`, which converts to `ChatMessage` at the four call sites.
GetChatMessagesByRevisionForStream and GetLastChatMessageByRole led with created_at, which is the transaction start time and shared by every row in an insert batch. The stream query disagreed with the id-ordered full history snapshot the same socket emits on reset, and the last-message id is consumed as an id cursor by synthetic tool cancellation and by last_read_message_id. Ordering GetLastChatMessageByRole by id leaves it with no index that can supply its LIMIT 1 row in index order, so add (chat_id, role, id DESC) where deleted = false. Without it the planner takes a backward primary key scan and filters every newer row in the table, scanning all of it when the chat has no message in that role.
026bb95 to
5d4893f
Compare
|
Rebased onto Nothing else changed: Revalidated after the rebase:
|
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5d4893f1af
ℹ️ 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".
|
@codex review |
Chat message ordering was derived from
created_at, which isnow()and therefore the transaction start time. That makes it unusable as an append-order column for two independent reasons: every row in oneInsertChatMessagesbatch shares a single timestamp, and two concurrent transactions can commit in the opposite order to the one they started in.This PR gives
chat_messages.ida real append-order guarantee and moves the history reads onto it.Changes
InsertChatMessageshad no input-order guarantee. Callers index the returned slice by input position. That only worked because PostgreSQL happens to evaluate theBIGSERIALdefault in row order. Ids are now allocated up front and the k-th smallest is assigned to input index k, so the pairing does not depend on where the column default is evaluated. Returned rows are explicitlyORDER BY id.Three history reads now order by
id.GetChatMessagesByChatIDcreated_at ASCid ASCGetChatMessagesByRevisionForStreamcreated_at ASC, id ASCid ASCGetLastChatMessageByRolecreated_at DESC, id DESCid DESCGetChatMessagesByChatIDpaginated byidwhile ordering bycreated_at, which is incoherent on its own terms.The other two matter because of who consumes them. The stream query supplies incremental updates on the same socket that emits a full
GetChatMessagesByChatIDsnapshot on history reset, so once that snapshot moved toidthe two disagreed under timestamp skew.GetLastChatMessageByRolereturns an id that is then used as an id cursor, both asAfterIDwhen synthesizing tool cancellations and aschats.last_read_message_id, where a stale anchor leaves later assistant messages permanently unread.A tie-breaker would not have fixed either one. It only resolves equal timestamps; leading with
created_atis the actual defect.GetLastChatMessageByRoleloses its index, so this adds one.ORDER BY created_at DESC, id DESCcould take an ordered scan ofidx_chat_messages_chat_created. Nothing in the schema can supplyORDER BY id DESC LIMIT 1for a givenchat_idandrole, so the planner switches to a backward scan of the primary key and filters every newer row in the table, scanning all of it when the chat has no message in that role, which is the routine case for a fresh chat. Migration000559adds(chat_id, role, id DESC) WHERE deleted = false, the same shape as the existingidx_chat_messages_user_prompts. This matters because the query is hot: it runs on every stream connect and disconnect, and once per turn when synthesizing tool cancellations.GetChatMessagesForPromptByChatIDhas the same defect and is fixed in the stacked PR, because its compaction boundary change is semantic and deserves a separate review. Auto-archive stays timestamp-based deliberately: it measures activity, not order.Wrapping the insert in a CTE (needed because
INSERTcannot takeORDER BY) makes sqlc synthesizeInsertChatMessagesRow. It is structurally identical toChatMessage, so the call sites use a direct struct conversion that stops compiling if the two ever diverge.Testing
Behavior tests write
created_atvalues inverted against id order, so a reader that leads withcreated_atreturns the batch backwards. All three queries were verified red by reverting theORDER BYand regenerating: the stream query returned[3,2,1]for[1,2,3], andGetLastChatMessageByRolepicked id 1 instead of id 3.TestInsertChatMessagesOrderContractasserts against the generated SQL, covering what a behavior test cannot: PostgreSQL evaluates the id default in row order anyway, so a batch still looks ordered once the guarantee is removed.TestChatMessagesSequenceCacheIsOneguards the cross-batch half of the invariant. Ids follow chat row lock order only while the sequence hands out one value at a time; sequence cache blocks are per session, so with a cache above one a backend holding stale cached values can lock second and still commit lower ids. Bumping a sequence cache is an ordinary throughput tweak, and it would silently corrupt history order.The index was checked on a 200k row fixture. Without it, the zero-match lookup filters all 200,000 rows over 2763 buffers; with it, the plan is an index scan with both
chat_idandrolein the index condition, no sort node, and 3 buffers.Note that the within-batch mapping does not depend on the cache size. It is established by
ROW_NUMBER() OVER (ORDER BY id)over the allocated ids, so it holds regardless ofnextvalevaluation order.Note on the deleted subagent hand-sort
The subagent history reader's hand-sort stays deleted, but calling it redundant was imprecise. It sorted by
created_atthenid, so it is only equivalent toidordering when the two agree. When they disagree the old code selected a different "latest assistant". This is a deliberate behavior change to match the new invariant, not dead-code removal.