Skip to content

perf(coderd/x/chatd): validate chat transitions from the locked row - #30886

Open
cstyan wants to merge 1 commit into
mainfrom
callum/chatd-tx-current
Open

cstyan wants to merge 1 commit into
mainfrom
callum/chatd-tx-current

Conversation

@cstyan

@cstyan cstyan commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

One of the main costs in terms of DB load from chats is extra round trips during transactions. This results in locks being held for longer periods of time -> an increase in lock waits for chats related queries.

One of the opportunities for reducing some of the round trips is to eliminate extra chat row reads after we have just read the row in order to lock it.

In this PR ChatMachine.Update now seeds the transaction struct with the row returned by LockChatAndBumpSnapshotVersion, plus a queue count taken right after it. Callbacks can then read it with tx.Current() and the first transition validates against it, so neither re-reads the chat row from the database while the row lock is held. Note that there is also a change such that readState also returns the chat row from the transaction struct, then clearing the cached chat, for operations which would then immediately write an update to the chat row.

ChatMachine.UpdateReturning is new, it returns the row already read after the callback for publication. updateChat (send, edit, promote, tool results, interrupt, compact, clear, reconcile), hook failure, family archive, and the worker's generation and task transitions use it instead of reloading the chat under the lock.

This PR relative to main:

measurement main this PR percent change
Round trips under lock 180.9 142.0 -39%
Statements under lock 86.1 66.6 -23%
GetChatByIDunder lock 54.1 35.8 -34%
GetChatByIDtotal 75.5 57.1 -25%
Statements 235.8 216.2 -8%

Generated by Coder Agents on behalf of @cstyan.

ChatMachine.Update seeds the transaction with the row returned by
LockChatAndBumpSnapshotVersion plus a queue count taken right after it.
Callbacks read it with tx.Current() and the first transition validates
against it, so neither re-reads the chat while the row lock is held.

ChatMachine.UpdateReturning returns the row Update already reads after
the callback for publication. updateChat (send, edit, promote, tool
results, interrupt, compact, clear, reconcile), hook failure, family
archive, and the worker's generation and task transitions use it
instead of reloading the chat under the lock.

Generated by Coder Agents on behalf of @cstyan.

Each transaction that applies one or more transitions advances the `snapshot_version` field on the `chats` table by 1 immediately after locking the chat row and before mutating any tables. This lets us version the chat's execution state. The chat worker and the stream loop rely on it to ensure they do not process outdated or out of order notifications.

TODO (tx.Current): `ChatMachine.Update` counts queued messages right after the lock and seeds the transaction with the locked row. Callbacks read it with `tx.Current()` and the first transition validates against it, so neither reads the chat again under the lock. Later transitions in the same transaction and the publication read still go to the database. Describe this here.

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.

we don't need changes in this file

// under the lock. The row is zero if fn or that read failed. It is still
// set when the commit or publication fails afterwards, so treat it as
// committed only when err is nil.
func (m *ChatMachine) UpdateReturning(

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.

I'd rather we made Update return a chat instead of adding UpdateReturning.


// Current returns the chat row and execution state, reusing the locked
// row without consuming it when no transition has run yet.
func (tx *Tx) Current() (database.Chat, ExecutionState, error) {

@hugodutka hugodutka Oct 16, 2026 •

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.

The name Current() is misleading. It will return a stale chat if loadState isn't called after modifying the chat, and there's no convention to when loadState gets called.

I think the cleanest way to pass a cached chat to a transition is via an extra optional argument on the transition itself. You could define a ChatSnapshot struct that'd be an optional field on transitions' input structs. E.g.

type ChatSnapshot struct {
  	Chat      database.Chat
	HasQueued bool
}

type DeleteQueuedMessageInput struct {
    *ChatSnapshot
	QueuedMessageID int64
}

I'd also encourage you to pass the initial chat snapshot in the callback in Update:

func (m *ChatMachine) Update(
	ctx context.Context,
	fn func(*Tx, database.Store, *ChatSnapshot) error,
) error {

That way it's clear this snapshot is only valid at the beginning of the callback and there's no mechanism that keeps it up to date later.

This branch has not been deployed

No deployments
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.

2 participants