feat: implement the chat state machine - #25908
Conversation
|
@hugodutka I think it would be beneficial to use grill-with-docs to create a CONTEXT.md style file to describe all the concepts |
|
@ibetitsmike I'm planning to include that file in the final PR - it'll be a lightly edited version of the RFC we have in Notion. |
5d6ca0a to
723e393
Compare
| _ = f | ||
| _ = seeded | ||
| _ = result |
There was a problem hiding this comment.
drive-by nit: underscore the func params instead
| // Store exposes the active transaction store so callers can perform | ||
| // validation reads (for example loading the messages affected by an | ||
| // EditMessage transition) and metadata writes (for example updating | ||
| // title or labels) that must be atomic with the transition. | ||
| // | ||
| // Callers MUST NOT use Store to mutate execution-state tables | ||
| // (chats.status, chat_messages, chat_queued_messages, chat_heartbeats, | ||
| // or the version fields on chats). Those mutations belong to the | ||
| // transition methods and are validated against the state machine | ||
| // matrix. | ||
| func (tx *Tx) Store() database.Store { return tx.store } |
There was a problem hiding this comment.
concern, non-blocking: My biggest architectural suggestion: could we instead expose only the set of read-only 'safe' methods that cannot mutate state here? This could be a follow-up meta-refactor.
There was a problem hiding this comment.
We talked about it on Slack: we can't expose only read-only methods, since metadata updates, like the workspace binding, are still handled by direct DB calls rather than transitions. We can however expose only a subset of Store to make direct DB calls inside machine.Update more intentional.
There was a problem hiding this comment.
I'm going to do this in a follow up PR after we merge the refactor. Then we'll be sure what subset is needed.
There was a problem hiding this comment.
review: this is replaced by SetFamilyArchived. We need chatd to handle the state transition so we can't just go around mutating it with a bulk query.
There was a problem hiding this comment.
^ refers to AutoArchiveInactiveChats
There was a problem hiding this comment.
I'm going to remove AutoArchiveInactiveChats in a follow up PR. I had to keep it here because it was causing problems during rebase.
779754a to
f2c32c2
Compare
Docs preview📖 View docs preview for |
mafredri
left a comment
There was a problem hiding this comment.
Submitting part 1 of my review, looking at chatstate next.
| @@ -1999,6 +2032,13 @@ func (q *querier) DeleteChatDebugDataByChatID(ctx context.Context, arg database. | |||
| return q.db.DeleteChatDebugDataByChatID(ctx, arg) | |||
| } | |||
|
|
|||
| func (q *querier) DeleteChatHeartbeats(ctx context.Context, arg database.DeleteChatHeartbeatsParams) (int64, error) { | |||
There was a problem hiding this comment.
This seems to be a batch method, although the naming suggests its for a single chat, we should make the name reflect what it does (applies to multiple methods).
There was a problem hiding this comment.
Renamed DeleteChatHeartbeats to BatchDeleteChatHeartbeats and UpsertChatHeartbeats to BatchUpsertChatHeartbeats. I didn't find any other queries that needed renaming.
| @@ -0,0 +1,346 @@ | |||
| -- Foundation for the chatd core state machine refactor (PR 1). | |||
There was a problem hiding this comment.
I'd prefer to remove self-referential references like "PR 1" from these comments as the comments will live on without that context.
|
|
||
| -- 2. Add new versioning, ownership, retry, and pending-action fields to chats. | ||
| ALTER TABLE chats | ||
| ADD COLUMN snapshot_version bigint NOT NULL DEFAULT 1, |
There was a problem hiding this comment.
Readers will wonder why snapshots begin at 1 while other versions at 0. I suggest encoding reasoning from the RFC as COMMENT ON on tables, columns, etc so that the knowledge doesn't just live hidden away in the migration file and RFC.
There was a problem hiding this comment.
Added COMMENT ON entries to explain it.
|
|
||
| -- 11. Index for queue-order reads and head selection. | ||
| CREATE INDEX IF NOT EXISTS idx_chat_queued_messages_chat_position_id | ||
| ON chat_queued_messages(chat_id, position, id); |
There was a problem hiding this comment.
This index requires a bit more explanation about its purpose and what it's targeting. Is the chat_id, position, and id known when querying? Is it for joining efficiency? Index-only lookups? Depending on table size it could even slow things down so worth going through the EXPLAIN (ANALYZE, COSTS, VERBOSE, BUFFERS, FORMAT JSON) of it.
There was a problem hiding this comment.
Removed the index altogether. I don't think it's needed at this point.
|
|
||
| -- 14. chat_heartbeats: unlogged table for ownership leases. Keyed by | ||
| -- (chat_id, runner_id) so a single chat can briefly have entries from | ||
| -- multiple runners during failover. |
There was a problem hiding this comment.
When adding a comment on, worth mentioning why it's safe that it's unlogged as well.
There was a problem hiding this comment.
Updated the comment.
| -- current runner heartbeat is stale. The runner_id IS NULL predicate is | ||
| -- a robustness extension for inconsistent rows where a worker_id exists | ||
| -- without a runner_id; normal missing ownership is worker_id IS NULL or | ||
| -- a missing or stale heartbeat row. |
There was a problem hiding this comment.
I wonder if we should encode more about the inclusion criteria in the doc comment. Just seeing this comment doesn't really leave enough clues to verify the implementation and thus trusting that its correct. Making changes here becomes slightly dangerous. I'm guessing test coverage will prove it behaves the right way but that may also change as time goes on.
There was a problem hiding this comment.
Updated the comment to describe the inclusion criteria.
| -- are intentionally kept side-by-side with the legacy chatd queries | ||
| -- above so the existing runtime keeps working while the state machine | ||
| -- lands behind it. | ||
| -- ===================================================================== |
There was a problem hiding this comment.
I think these belong in their own file rather than having a custom form header comment with === separation. Just put this doc at the top. Also, referring to previous comment about query comments having enough high-level detail that a reader can verify the query implementation, I think it's worth going through the queries below through that lens as well.
There was a problem hiding this comment.
I think chats.sql is the right place for these queries. State machine queries aren't special in any way - they should be in the same place as all the other queries that modify and read from chat-related tables.
I think it's worth going through the queries below through that lens as well
Could you point out queries that should have clearer comments? I think they are pretty clear already.
| HistoryVersion int64 `db:"history_version" json:"history_version"` | ||
| QueueVersion int64 `db:"queue_version" json:"queue_version"` | ||
| GenerationAttempt int64 `db:"generation_attempt" json:"generation_attempt"` | ||
| RetryState pqtype.NullRawMessage `db:"retry_state" json:"retry_state"` |
There was a problem hiding this comment.
What data type is this, curious why it's a raw message rather than e.g. json if the content is json?
There was a problem hiding this comment.
The content is jsonb. sqlc uses pqtype.NullRawMessage to represent it.
mafredri
left a comment
There was a problem hiding this comment.
Since there was so much to go through, I've mainly focused on trying to review the production code with spot checks that the allowable states matches what the code claims. I have not done in-depth analysis of the tests and what they prove. From what I've gathered so far, the state machine seems sound.
| // stillBlocked returns true if c has NOT received a value yet. The | ||
| // caller must already have established a happens-before ordering via | ||
| // some other channel so this check is meaningful. | ||
| func stillBlocked(c <-chan struct{}) bool { |
There was a problem hiding this comment.
"Received a value" can be expressed as len(c) == 0 which is simpler. Does this also check if c is closed? Docs could be updated and len would remove disambiguity.
There was a problem hiding this comment.
It also checks if c is closed. Updated the comment.
| lockWG.Go(func() { | ||
| lockErr = m.Lock(ctx, func(_ database.Store) error { | ||
| close(lockEntered) | ||
| <-releaseLock |
There was a problem hiding this comment.
We should try to avoid naked selects that block forever. If release doesn't happen we'll leave this goroutine running forever, lockWG.Wait blocks forever, etc. Channels won't et closed when require short-circuits the test. These types of setups have typically lead to flakes or hard-to-diagnose issues.
There was a problem hiding this comment.
Addressed by also selecting on ctx.Done.
| // Force a sync round-trip through the DB. This serves the | ||
| // same role as a Sleep but is deterministic: by the time | ||
| // this read completes, the scheduler has had a chance to | ||
| // run goroutine B if it could make progress. |
There was a problem hiding this comment.
This is not necessarily true on a highly congested low-resource system. Ideally we could test this with true determinism. Can't we hook into calls done by m.Update to know for sure?
There was a problem hiding this comment.
Addressed by hooking into LockChatAndBumpSnapshotVersion. It's still not a deterministic solution, but it should be a bit more reliable.
| // state classification helpers live in `state.go` and `transition.go` | ||
| // alongside unit-testable classifiers; the SQL is in | ||
| // `coderd/database/queries/chats.sql` (e.g. `LockChatAndBumpSnapshotVersion`, | ||
| // `UpdateChatExecutionState`). |
There was a problem hiding this comment.
Referencing functio names and files is ripe to go stale. I'd like this doc to stand the test of time (barring a refactor).
|
|
||
| // ErrInvalidState is returned when the chat row, queue, and | ||
| // archive flag together produce a combination outside the 13 | ||
| // valid execution states described in the RFC. |
There was a problem hiding this comment.
"The RFC" will not stand the test of time. These comments need to be updated and terminology established and kept consistent.
There was a problem hiding this comment.
Removed mentions of the RFC from the comments.
| return PromoteQueuedMessageResult{}, xerrors.Errorf("set running: %w", err) | ||
| } | ||
| cancellations := inserted[:len(inserted)-1] | ||
| insertedUserMsg := inserted[len(inserted)-1] |
There was a problem hiding this comment.
This looks safe today, but we never check len(inserted) as a guard, should we?
| } | ||
| return InterruptResult{}, newTransitionError(TransitionInterrupt, from, "unhandled state in Interrupt") |
There was a problem hiding this comment.
| } | |
| return InterruptResult{}, newTransitionError(TransitionInterrupt, from, "unhandled state in Interrupt") | |
| default: | |
| return InterruptResult{}, newTransitionError(TransitionInterrupt, from, "unhandled state in Interrupt") | |
| } |
Suggestion: This structure protects against unintended logic changes in earlier cases where the return wouldn't happen. Also makes it more explicit that the earlier cases are the only allowed states.
| if err != nil { | ||
| return EnterRequiresActionResult{}, xerrors.Errorf("get db now: %w", err) | ||
| } | ||
| deadline := sql.NullTime{Time: now.Add(5 * time.Minute), Valid: true} |
There was a problem hiding this comment.
Nit: Move 5 * time.Minute to a constant with accompanying documentation.
| "no pending dynamic tool calls", | ||
| ) | ||
| } | ||
| now, err := tx.store.GetDatabaseNow(tx.ctx) |
There was a problem hiding this comment.
Inside a transaction, this always returns the same value. Do we really need to do this round-trip? This seems only used once, should we bake it into the update query and just pass in the offset (5 min)?.
There was a problem hiding this comment.
We could, but I think it's clearer this way. I'd like to avoid premature optimization: if this ever proves to be a bottleneck, let's optimize it.
| }); err != nil { | ||
| return FinishInterruptionResult{}, xerrors.Errorf("set running: %w", err) | ||
| } | ||
| all := append([]database.ChatMessage{}, insertedPartial...) |
There was a problem hiding this comment.
Small inconsistency, above we return insertedPartial directly (no copy), here we make a copy for all. If we don't need a copy because we're guarding the underlying array, we can just all = append(insertedPartial, insertedHead...).
93e4119 to
bb090c5
Compare
bb090c5 to
0bc334f
Compare
PR 1 of the chatd refactor. It serves 2 purposes:
I kept the database changes in a single PR to make rebasing easier. However, because of it, this PR doesn't compile and it doesn't pass tests - that's intentional. Only the tip of the stack will pass all tests.