feat(coderd): add chat debug service and summary aggregation (#23916)

This commit is contained in:
Thomas Kosiewski
2026-04-17 16:27:53 +02:00
committed by GitHub
parent a41c8d73b1
commit 91f9de27a1
21 changed files with 3884 additions and 379 deletions
+23 -1
View File
@@ -2369,7 +2369,7 @@ func (q *querier) FetchVolumesResourceMonitorsUpdatedAfter(ctx context.Context,
return q.db.FetchVolumesResourceMonitorsUpdatedAfter(ctx, updatedAt)
}
func (q *querier) FinalizeStaleChatDebugRows(ctx context.Context, updatedBefore time.Time) (database.FinalizeStaleChatDebugRowsRow, error) {
func (q *querier) FinalizeStaleChatDebugRows(ctx context.Context, updatedBefore database.FinalizeStaleChatDebugRowsParams) (database.FinalizeStaleChatDebugRowsRow, error) {
// Background sweep operates across all chats.
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceChat); err != nil {
return database.FinalizeStaleChatDebugRowsRow{}, err
@@ -5915,6 +5915,28 @@ func (q *querier) SoftDeleteContextFileMessages(ctx context.Context, chatID uuid
return q.db.SoftDeleteContextFileMessages(ctx, chatID)
}
func (q *querier) TouchChatDebugRunUpdatedAt(ctx context.Context, arg database.TouchChatDebugRunUpdatedAtParams) error {
chat, err := q.db.GetChatByID(ctx, arg.ChatID)
if err != nil {
return err
}
if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil {
return err
}
return q.db.TouchChatDebugRunUpdatedAt(ctx, arg)
}
func (q *querier) TouchChatDebugStepAndRun(ctx context.Context, arg database.TouchChatDebugStepAndRunParams) error {
chat, err := q.db.GetChatByID(ctx, arg.ChatID)
if err != nil {
return err
}
if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil {
return err
}
return q.db.TouchChatDebugStepAndRun(ctx, arg)
}
func (q *querier) TryAcquireLock(ctx context.Context, id int64) (bool, error) {
return q.db.TryAcquireLock(ctx, id)
}
+21 -3
View File
@@ -475,10 +475,14 @@ func (s *MethodTestSuite) TestChats() {
check.Args(chat.ID).Asserts(chat, policy.ActionUpdate).Returns(int64(1))
}))
s.Run("FinalizeStaleChatDebugRows", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
updatedBefore := dbtime.Now()
now := dbtime.Now()
arg := database.FinalizeStaleChatDebugRowsParams{
Now: now,
UpdatedBefore: now.Add(-5 * time.Minute),
}
row := database.FinalizeStaleChatDebugRowsRow{RunsFinalized: 1, StepsFinalized: 2}
dbm.EXPECT().FinalizeStaleChatDebugRows(gomock.Any(), updatedBefore).Return(row, nil).AnyTimes()
check.Args(updatedBefore).Asserts(rbac.ResourceChat, policy.ActionUpdate).Returns(row)
dbm.EXPECT().FinalizeStaleChatDebugRows(gomock.Any(), arg).Return(row, nil).AnyTimes()
check.Args(arg).Asserts(rbac.ResourceChat, policy.ActionUpdate).Returns(row)
}))
s.Run("GetChatDebugLoggingAllowUsers", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
dbm.EXPECT().GetChatDebugLoggingAllowUsers(gomock.Any()).Return(true, nil).AnyTimes()
@@ -532,6 +536,20 @@ func (s *MethodTestSuite) TestChats() {
dbm.EXPECT().UpdateChatDebugRun(gomock.Any(), arg).Return(run, nil).AnyTimes()
check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(run)
}))
s.Run("TouchChatDebugRunUpdatedAt", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
arg := database.TouchChatDebugRunUpdatedAtParams{ID: uuid.New(), ChatID: chat.ID}
dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes()
dbm.EXPECT().TouchChatDebugRunUpdatedAt(gomock.Any(), arg).Return(nil).AnyTimes()
check.Args(arg).Asserts(chat, policy.ActionUpdate)
}))
s.Run("TouchChatDebugStepAndRun", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
arg := database.TouchChatDebugStepAndRunParams{StepID: uuid.New(), RunID: uuid.New(), ChatID: chat.ID}
dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes()
dbm.EXPECT().TouchChatDebugStepAndRun(gomock.Any(), arg).Return(nil).AnyTimes()
check.Args(arg).Asserts(chat, policy.ActionUpdate)
}))
s.Run("UpdateChatDebugStep", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
arg := database.UpdateChatDebugStepParams{ID: uuid.New(), ChatID: chat.ID}
+17 -1
View File
@@ -888,7 +888,7 @@ func (m queryMetricsStore) FetchVolumesResourceMonitorsUpdatedAfter(ctx context.
return r0, r1
}
func (m queryMetricsStore) FinalizeStaleChatDebugRows(ctx context.Context, updatedBefore time.Time) (database.FinalizeStaleChatDebugRowsRow, error) {
func (m queryMetricsStore) FinalizeStaleChatDebugRows(ctx context.Context, updatedBefore database.FinalizeStaleChatDebugRowsParams) (database.FinalizeStaleChatDebugRowsRow, error) {
start := time.Now()
r0, r1 := m.s.FinalizeStaleChatDebugRows(ctx, updatedBefore)
m.queryLatencies.WithLabelValues("FinalizeStaleChatDebugRows").Observe(time.Since(start).Seconds())
@@ -4240,6 +4240,22 @@ func (m queryMetricsStore) SoftDeleteContextFileMessages(ctx context.Context, ch
return r0
}
func (m queryMetricsStore) TouchChatDebugRunUpdatedAt(ctx context.Context, arg database.TouchChatDebugRunUpdatedAtParams) error {
start := time.Now()
r0 := m.s.TouchChatDebugRunUpdatedAt(ctx, arg)
m.queryLatencies.WithLabelValues("TouchChatDebugRunUpdatedAt").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "TouchChatDebugRunUpdatedAt").Inc()
return r0
}
func (m queryMetricsStore) TouchChatDebugStepAndRun(ctx context.Context, arg database.TouchChatDebugStepAndRunParams) error {
start := time.Now()
r0 := m.s.TouchChatDebugStepAndRun(ctx, arg)
m.queryLatencies.WithLabelValues("TouchChatDebugStepAndRun").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "TouchChatDebugStepAndRun").Inc()
return r0
}
func (m queryMetricsStore) TryAcquireLock(ctx context.Context, pgTryAdvisoryXactLock int64) (bool, error) {
start := time.Now()
r0, r1 := m.s.TryAcquireLock(ctx, pgTryAdvisoryXactLock)
+32 -4
View File
@@ -1518,18 +1518,18 @@ func (mr *MockStoreMockRecorder) FetchVolumesResourceMonitorsUpdatedAfter(ctx, u
}
// FinalizeStaleChatDebugRows mocks base method.
func (m *MockStore) FinalizeStaleChatDebugRows(ctx context.Context, updatedBefore time.Time) (database.FinalizeStaleChatDebugRowsRow, error) {
func (m *MockStore) FinalizeStaleChatDebugRows(ctx context.Context, arg database.FinalizeStaleChatDebugRowsParams) (database.FinalizeStaleChatDebugRowsRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "FinalizeStaleChatDebugRows", ctx, updatedBefore)
ret := m.ctrl.Call(m, "FinalizeStaleChatDebugRows", ctx, arg)
ret0, _ := ret[0].(database.FinalizeStaleChatDebugRowsRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// FinalizeStaleChatDebugRows indicates an expected call of FinalizeStaleChatDebugRows.
func (mr *MockStoreMockRecorder) FinalizeStaleChatDebugRows(ctx, updatedBefore any) *gomock.Call {
func (mr *MockStoreMockRecorder) FinalizeStaleChatDebugRows(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FinalizeStaleChatDebugRows", reflect.TypeOf((*MockStore)(nil).FinalizeStaleChatDebugRows), ctx, updatedBefore)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FinalizeStaleChatDebugRows", reflect.TypeOf((*MockStore)(nil).FinalizeStaleChatDebugRows), ctx, arg)
}
// FindMatchingPresetID mocks base method.
@@ -8034,6 +8034,34 @@ func (mr *MockStoreMockRecorder) SoftDeleteContextFileMessages(ctx, chatID any)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SoftDeleteContextFileMessages", reflect.TypeOf((*MockStore)(nil).SoftDeleteContextFileMessages), ctx, chatID)
}
// TouchChatDebugRunUpdatedAt mocks base method.
func (m *MockStore) TouchChatDebugRunUpdatedAt(ctx context.Context, arg database.TouchChatDebugRunUpdatedAtParams) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "TouchChatDebugRunUpdatedAt", ctx, arg)
ret0, _ := ret[0].(error)
return ret0
}
// TouchChatDebugRunUpdatedAt indicates an expected call of TouchChatDebugRunUpdatedAt.
func (mr *MockStoreMockRecorder) TouchChatDebugRunUpdatedAt(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TouchChatDebugRunUpdatedAt", reflect.TypeOf((*MockStore)(nil).TouchChatDebugRunUpdatedAt), ctx, arg)
}
// TouchChatDebugStepAndRun mocks base method.
func (m *MockStore) TouchChatDebugStepAndRun(ctx context.Context, arg database.TouchChatDebugStepAndRunParams) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "TouchChatDebugStepAndRun", ctx, arg)
ret0, _ := ret[0].(error)
return ret0
}
// TouchChatDebugStepAndRun indicates an expected call of TouchChatDebugStepAndRun.
func (mr *MockStoreMockRecorder) TouchChatDebugStepAndRun(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TouchChatDebugStepAndRun", reflect.TypeOf((*MockStore)(nil).TouchChatDebugStepAndRun), ctx, arg)
}
// TryAcquireLock mocks base method.
func (m *MockStore) TryAcquireLock(ctx context.Context, pgTryAdvisoryXactLock int64) (bool, error) {
m.ctrl.T.Helper()
+52 -7
View File
@@ -197,15 +197,18 @@ type sqlcQuerier interface {
FetchVolumesResourceMonitorsByAgentID(ctx context.Context, agentID uuid.UUID) ([]WorkspaceAgentVolumeResourceMonitor, error)
FetchVolumesResourceMonitorsUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]WorkspaceAgentVolumeResourceMonitor, error)
// Marks orphaned in-progress rows as interrupted so they do not stay
// in a non-terminal state forever. The NOT IN list must match the
// in a non-terminal state forever. The NOT IN list must match the
// terminal statuses defined by ChatDebugStatus in codersdk/chats.go.
//
// The steps CTE also catches steps whose parent run was just finalized
// (via run_id IN), because PostgreSQL data-modifying CTEs share the
// same snapshot and cannot see each other's row updates. Without this,
// same snapshot and cannot see each other's row updates. Without this,
// a step with a recent updated_at would survive its run's finalization
// and remain in 'in_progress' state permanently.
FinalizeStaleChatDebugRows(ctx context.Context, updatedBefore time.Time) (FinalizeStaleChatDebugRowsRow, error)
//
// @now is the caller's clock timestamp so that mock-clock tests stay
// consistent with the @updated_before cutoff.
FinalizeStaleChatDebugRows(ctx context.Context, arg FinalizeStaleChatDebugRowsParams) (FinalizeStaleChatDebugRowsRow, error)
// FindMatchingPresetID finds a preset ID that is the largest exact subset of the provided parameters.
// It returns the preset ID if a match is found, or NULL if no match is found.
// The query finds presets where all preset parameters are present in the provided parameters,
@@ -780,6 +783,12 @@ type sqlcQuerier interface {
InsertAuditLog(ctx context.Context, arg InsertAuditLogParams) (AuditLog, error)
InsertChat(ctx context.Context, arg InsertChatParams) (Chat, error)
InsertChatDebugRun(ctx context.Context, arg InsertChatDebugRunParams) (ChatDebugRun, error)
// The CTE atomically locks the parent run via UPDATE, bumps its
// updated_at (eliminating a separate TouchChatDebugRunUpdatedAt
// call), and enforces the finalization guard: if the run is already
// finished, the UPDATE returns zero rows, the INSERT gets no source
// rows, and sql.ErrNoRows is returned. The UPDATE also serializes
// with concurrent FinalizeStale under READ COMMITTED isolation.
InsertChatDebugStep(ctx context.Context, arg InsertChatDebugStepParams) (ChatDebugStep, error)
InsertChatFile(ctx context.Context, arg InsertChatFileParams) (InsertChatFileRow, error)
InsertChatMessages(ctx context.Context, arg InsertChatMessagesParams) ([]ChatMessage, error)
@@ -946,6 +955,31 @@ type sqlcQuerier interface {
SoftDeleteChatMessageByID(ctx context.Context, id int64) error
SoftDeleteChatMessagesAfterID(ctx context.Context, arg SoftDeleteChatMessagesAfterIDParams) error
SoftDeleteContextFileMessages(ctx context.Context, chatID uuid.UUID) error
// Overrides updated_at on the parent run without touching any
// other column. Used by tests that need to stamp a run with a
// specific timestamp after the InsertChatDebugStep CTE has
// already bumped it to NOW(), so stale-row finalization paths
// can be exercised deterministically. The chatdebug service
// itself does not call this: heartbeats go through
// TouchChatDebugStepAndRun, and step creation updates the parent
// run via the InsertChatDebugStep CTE.
TouchChatDebugRunUpdatedAt(ctx context.Context, arg TouchChatDebugRunUpdatedAtParams) error
// Atomically bumps updated_at on both the step and its parent run
// in a single statement. This prevents FinalizeStale from
// interleaving between the two touches and finalizing a run whose
// step heartbeat was just written.
//
// The step UPDATE joins through touched_run (via FROM) and reads
// its RETURNING rows. Per the PostgreSQL WITH semantics, RETURNING
// is the only way to communicate values between a data-modifying
// CTE and the main query, and consuming those rows forces the run
// UPDATE to complete before the step UPDATE. That matches the
// lock order used by FinalizeStaleChatDebugRows and avoids a
// deadlock between concurrent heartbeats and stale sweeps. The
// join also constrains the step update to the specified run so a
// mismatched (run_id, step_id) pair cannot silently refresh an
// unrelated step.
TouchChatDebugStepAndRun(ctx context.Context, arg TouchChatDebugStepAndRunParams) error
// Non blocking lock. Returns true if the lock was acquired, false otherwise.
//
// This must be called from within a transaction. The lock will be automatically
@@ -966,14 +1000,25 @@ type sqlcQuerier interface {
UpdateChatBuildAgentBinding(ctx context.Context, arg UpdateChatBuildAgentBindingParams) (Chat, error)
UpdateChatByID(ctx context.Context, arg UpdateChatByIDParams) (Chat, error)
// Uses COALESCE so that passing NULL from Go means "keep the
// existing value." This is intentional: debug rows follow a
// existing value." This is intentional: debug rows follow a
// write-once-finalize pattern where fields are set at creation
// or finalization and never cleared back to NULL.
// or finalization and never cleared back to NULL. The @now
// parameter keeps updated_at under the caller's clock.
//
// finished_at is enforced as write-once at the SQL level: once
// populated it cannot be overwritten by a later call. Callers
// that issue a summary or status refresh after the run has
// already finalized therefore cannot corrupt the original
// completion timestamp, which keeps duration and ordering
// calculations stable regardless of how many times the row is
// updated.
UpdateChatDebugRun(ctx context.Context, arg UpdateChatDebugRunParams) (ChatDebugRun, error)
// Uses COALESCE so that passing NULL from Go means "keep the
// existing value." This is intentional: debug rows follow a
// existing value." This is intentional: debug rows follow a
// write-once-finalize pattern where fields are set at creation
// or finalization and never cleared back to NULL.
// or finalization and never cleared back to NULL. The @now
// parameter keeps updated_at under the caller's clock, matching
// the injectable quartz.Clock used by FinalizeStale sweeps.
UpdateChatDebugStep(ctx context.Context, arg UpdateChatDebugStepParams) (ChatDebugStep, error)
// Bumps the heartbeat timestamp for the given set of chat IDs,
// provided they are still running and owned by the specified
+72 -38
View File
@@ -11651,7 +11651,10 @@ func TestFinalizeStaleChatDebugRows(t *testing.T) {
require.NoError(t, err)
// --- orphanStep: in_progress step whose run is already completed ---
// its own updated_at is old, so it should be finalized directly.
// Its own updated_at is old, so it should be finalized directly.
// The step must be inserted while the run is still open because
// InsertChatDebugStep requires finished_at IS NULL on the parent
// run (atomic guard against appending steps to finalized runs).
completedRun, err := store.InsertChatDebugRun(ctx, database.InsertChatDebugRunParams{
ChatID: chat.ID,
ModelConfigID: uuid.NullUUID{UUID: modelCfg.ID, Valid: true},
@@ -11662,7 +11665,19 @@ func TestFinalizeStaleChatDebugRows(t *testing.T) {
})
require.NoError(t, err)
// Mark the run as completed with a finished_at timestamp.
// Insert the step while the run is still open (finished_at IS NULL).
orphanStep, err := store.InsertChatDebugStep(ctx, database.InsertChatDebugStepParams{
RunID: completedRun.ID,
ChatID: chat.ID,
StepNumber: 1,
Operation: "stream",
Status: "in_progress",
UpdatedAt: sql.NullTime{Time: staleTime, Valid: true},
})
require.NoError(t, err)
// Now mark the run as completed with a finished_at timestamp,
// leaving the step orphaned in in_progress state.
_, err = store.UpdateChatDebugRun(ctx, database.UpdateChatDebugRunParams{
ID: completedRun.ID,
ChatID: completedRun.ChatID,
@@ -11671,16 +11686,7 @@ func TestFinalizeStaleChatDebugRows(t *testing.T) {
Time: time.Now(),
Valid: true,
},
})
require.NoError(t, err)
orphanStep, err := store.InsertChatDebugStep(ctx, database.InsertChatDebugStepParams{
RunID: completedRun.ID,
ChatID: chat.ID,
StepNumber: 1,
Operation: "stream",
Status: "in_progress",
UpdatedAt: sql.NullTime{Time: staleTime, Valid: true},
Now: time.Now(),
})
require.NoError(t, err)
@@ -11715,6 +11721,16 @@ func TestFinalizeStaleChatDebugRows(t *testing.T) {
})
require.NoError(t, err)
// The InsertChatDebugStep CTE atomically bumps the parent run's
// updated_at to NOW(). Reset it back to staleTime so the run is
// still caught by the age predicate in FinalizeStaleChatDebugRows.
err = store.TouchChatDebugRunUpdatedAt(ctx, database.TouchChatDebugRunUpdatedAtParams{
ID: cascadeRun.ID,
ChatID: chat.ID,
Now: staleTime,
})
require.NoError(t, err)
// --- alreadyDone: completed run/step --- should NOT be touched.
doneRun, err := store.InsertChatDebugRun(ctx, database.InsertChatDebugRunParams{
ChatID: chat.ID,
@@ -11726,6 +11742,17 @@ func TestFinalizeStaleChatDebugRows(t *testing.T) {
})
require.NoError(t, err)
// Insert step while run is still open.
doneStep, err := store.InsertChatDebugStep(ctx, database.InsertChatDebugStepParams{
RunID: doneRun.ID,
ChatID: chat.ID,
StepNumber: 1,
Operation: "stream",
Status: "completed",
})
require.NoError(t, err)
// Now finalize both run and step.
_, err = store.UpdateChatDebugRun(ctx, database.UpdateChatDebugRunParams{
ID: doneRun.ID,
ChatID: doneRun.ChatID,
@@ -11734,15 +11761,7 @@ func TestFinalizeStaleChatDebugRows(t *testing.T) {
Time: time.Now(),
Valid: true,
},
})
require.NoError(t, err)
doneStep, err := store.InsertChatDebugStep(ctx, database.InsertChatDebugStepParams{
RunID: doneRun.ID,
ChatID: chat.ID,
StepNumber: 1,
Operation: "stream",
Status: "completed",
Now: time.Now(),
})
require.NoError(t, err)
@@ -11754,6 +11773,7 @@ func TestFinalizeStaleChatDebugRows(t *testing.T) {
Time: time.Now(),
Valid: true,
},
Now: time.Now(),
})
require.NoError(t, err)
@@ -11769,6 +11789,17 @@ func TestFinalizeStaleChatDebugRows(t *testing.T) {
})
require.NoError(t, err)
// Insert step while run is still open.
errorStep, err := store.InsertChatDebugStep(ctx, database.InsertChatDebugStepParams{
RunID: errorRun.ID,
ChatID: chat.ID,
StepNumber: 1,
Operation: "stream",
Status: "error",
})
require.NoError(t, err)
// Now finalize both run and step.
_, err = store.UpdateChatDebugRun(ctx, database.UpdateChatDebugRunParams{
ID: errorRun.ID,
ChatID: errorRun.ChatID,
@@ -11777,15 +11808,7 @@ func TestFinalizeStaleChatDebugRows(t *testing.T) {
Time: time.Now(),
Valid: true,
},
})
require.NoError(t, err)
errorStep, err := store.InsertChatDebugStep(ctx, database.InsertChatDebugStepParams{
RunID: errorRun.ID,
ChatID: chat.ID,
StepNumber: 1,
Operation: "stream",
Status: "error",
Now: time.Now(),
})
require.NoError(t, err)
@@ -11797,6 +11820,7 @@ func TestFinalizeStaleChatDebugRows(t *testing.T) {
Time: time.Now(),
Valid: true,
},
Now: time.Now(),
})
require.NoError(t, err)
@@ -11828,7 +11852,10 @@ func TestFinalizeStaleChatDebugRows(t *testing.T) {
require.NoError(t, err)
// --- Execute the finalization sweep. ---
result, err := store.FinalizeStaleChatDebugRows(ctx, staleThreshold)
result, err := store.FinalizeStaleChatDebugRows(ctx, database.FinalizeStaleChatDebugRowsParams{
Now: time.Now(),
UpdatedBefore: staleThreshold,
})
require.NoError(t, err)
// staleRun + cascadeRun were finalized; completedRun and doneRun
@@ -11921,7 +11948,10 @@ func TestFinalizeStaleChatDebugRows(t *testing.T) {
"fresh step should not have a finished_at timestamp")
// A second sweep should be a no-op.
result2, err := store.FinalizeStaleChatDebugRows(ctx, staleThreshold)
result2, err := store.FinalizeStaleChatDebugRows(ctx, database.FinalizeStaleChatDebugRowsParams{
Now: time.Now(),
UpdatedBefore: staleThreshold,
})
require.NoError(t, err)
assert.EqualValues(t, 0, result2.RunsFinalized,
"second sweep should find nothing to finalize")
@@ -12034,6 +12064,7 @@ func TestChatDebugSQLGuards(t *testing.T) {
Time: time.Now(),
Valid: true,
},
Now: time.Now(),
})
require.ErrorIs(t, err, sql.ErrNoRows,
"UpdateChatDebugRun should fail when chat_id does not match")
@@ -12051,6 +12082,7 @@ func TestChatDebugSQLGuards(t *testing.T) {
Time: time.Now(),
Valid: true,
},
Now: time.Now(),
})
require.ErrorIs(t, err, sql.ErrNoRows,
"UpdateChatDebugStep should fail when chat_id does not match")
@@ -12137,6 +12169,7 @@ func TestChatDebugRunCOALESCEPreservation(t *testing.T) {
Time: now,
Valid: true,
},
Now: now,
})
require.NoError(t, err)
@@ -12144,9 +12177,9 @@ func TestChatDebugRunCOALESCEPreservation(t *testing.T) {
require.Equal(t, "completed", updated.Status)
require.True(t, updated.FinishedAt.Valid)
// UpdatedAt should advance (set to NOW() unconditionally).
require.True(t, updated.UpdatedAt.After(original.UpdatedAt) ||
updated.UpdatedAt.Equal(original.UpdatedAt))
// UpdatedAt should be set to the @now value we passed in.
require.WithinDuration(t, now, updated.UpdatedAt, time.Millisecond,
"updated_at should equal the @now parameter")
// Every field not in the update call must be preserved exactly.
require.Equal(t, original.RootChatID, updated.RootChatID,
@@ -12257,6 +12290,7 @@ func TestChatDebugStepCOALESCEPreservation(t *testing.T) {
Time: now,
Valid: true,
},
Now: now,
})
require.NoError(t, err)
@@ -12264,9 +12298,9 @@ func TestChatDebugStepCOALESCEPreservation(t *testing.T) {
require.Equal(t, "completed", updated.Status)
require.True(t, updated.FinishedAt.Valid)
// UpdatedAt should advance (set to NOW() unconditionally).
require.True(t, updated.UpdatedAt.After(original.UpdatedAt) ||
updated.UpdatedAt.Equal(original.UpdatedAt))
// UpdatedAt should be set to the @now value we passed in.
require.WithinDuration(t, now, updated.UpdatedAt, time.Millisecond,
"updated_at should equal the @now parameter")
// Every field not in the update call must be preserved exactly.
require.Equal(t, original.HistoryTipMessageID, updated.HistoryTipMessageID,
+134 -25
View File
@@ -2956,9 +2956,9 @@ WITH finalized_runs AS (
UPDATE chat_debug_runs
SET
status = 'interrupted',
updated_at = NOW(),
finished_at = NOW()
WHERE updated_at < $1::timestamptz
updated_at = $1::timestamptz,
finished_at = $1::timestamptz
WHERE updated_at < $2::timestamptz
AND finished_at IS NULL
AND status NOT IN ('completed', 'error', 'interrupted')
RETURNING id
@@ -2966,10 +2966,10 @@ WITH finalized_runs AS (
UPDATE chat_debug_steps
SET
status = 'interrupted',
updated_at = NOW(),
finished_at = NOW()
updated_at = $1::timestamptz,
finished_at = $1::timestamptz
WHERE (
updated_at < $1::timestamptz
updated_at < $2::timestamptz
OR run_id IN (SELECT id FROM finalized_runs)
)
AND finished_at IS NULL
@@ -2981,22 +2981,30 @@ SELECT
(SELECT COUNT(*) FROM finalized_steps)::bigint AS steps_finalized
`
type FinalizeStaleChatDebugRowsParams struct {
Now time.Time `db:"now" json:"now"`
UpdatedBefore time.Time `db:"updated_before" json:"updated_before"`
}
type FinalizeStaleChatDebugRowsRow struct {
RunsFinalized int64 `db:"runs_finalized" json:"runs_finalized"`
StepsFinalized int64 `db:"steps_finalized" json:"steps_finalized"`
}
// Marks orphaned in-progress rows as interrupted so they do not stay
// in a non-terminal state forever. The NOT IN list must match the
// in a non-terminal state forever. The NOT IN list must match the
// terminal statuses defined by ChatDebugStatus in codersdk/chats.go.
//
// The steps CTE also catches steps whose parent run was just finalized
// (via run_id IN), because PostgreSQL data-modifying CTEs share the
// same snapshot and cannot see each other's row updates. Without this,
// same snapshot and cannot see each other's row updates. Without this,
// a step with a recent updated_at would survive its run's finalization
// and remain in 'in_progress' state permanently.
func (q *sqlQuerier) FinalizeStaleChatDebugRows(ctx context.Context, updatedBefore time.Time) (FinalizeStaleChatDebugRowsRow, error) {
row := q.db.QueryRowContext(ctx, finalizeStaleChatDebugRows, updatedBefore)
//
// @now is the caller's clock timestamp so that mock-clock tests stay
// consistent with the @updated_before cutoff.
func (q *sqlQuerier) FinalizeStaleChatDebugRows(ctx context.Context, arg FinalizeStaleChatDebugRowsParams) (FinalizeStaleChatDebugRowsRow, error) {
row := q.db.QueryRowContext(ctx, finalizeStaleChatDebugRows, arg.Now, arg.UpdatedBefore)
var i FinalizeStaleChatDebugRowsRow
err := row.Scan(&i.RunsFinalized, &i.StepsFinalized)
return i, err
@@ -3225,6 +3233,14 @@ func (q *sqlQuerier) InsertChatDebugRun(ctx context.Context, arg InsertChatDebug
}
const insertChatDebugStep = `-- name: InsertChatDebugStep :one
WITH locked_run AS (
UPDATE chat_debug_runs
SET updated_at = COALESCE($14::timestamptz, NOW())
WHERE id = $1::uuid
AND chat_id = $16::uuid
AND finished_at IS NULL
RETURNING chat_id
)
INSERT INTO chat_debug_steps (
run_id,
chat_id,
@@ -3245,7 +3261,7 @@ INSERT INTO chat_debug_steps (
)
SELECT
$1::uuid,
run.chat_id,
locked_run.chat_id,
$2::int,
$3::text,
$4::text,
@@ -3260,9 +3276,7 @@ SELECT
COALESCE($13::timestamptz, NOW()),
COALESCE($14::timestamptz, NOW()),
$15::timestamptz
FROM chat_debug_runs run
WHERE run.id = $1::uuid
AND run.chat_id = $16::uuid
FROM locked_run
RETURNING id, run_id, chat_id, step_number, operation, status, history_tip_message_id, assistant_message_id, normalized_request, normalized_response, usage, attempts, error, metadata, started_at, updated_at, finished_at
`
@@ -3285,6 +3299,12 @@ type InsertChatDebugStepParams struct {
ChatID uuid.UUID `db:"chat_id" json:"chat_id"`
}
// The CTE atomically locks the parent run via UPDATE, bumps its
// updated_at (eliminating a separate TouchChatDebugRunUpdatedAt
// call), and enforces the finalization guard: if the run is already
// finished, the UPDATE returns zero rows, the INSERT gets no source
// rows, and sql.ErrNoRows is returned. The UPDATE also serializes
// with concurrent FinalizeStale under READ COMMITTED isolation.
func (q *sqlQuerier) InsertChatDebugStep(ctx context.Context, arg InsertChatDebugStepParams) (ChatDebugStep, error) {
row := q.db.QueryRowContext(ctx, insertChatDebugStep,
arg.RunID,
@@ -3327,6 +3347,80 @@ func (q *sqlQuerier) InsertChatDebugStep(ctx context.Context, arg InsertChatDebu
return i, err
}
const touchChatDebugRunUpdatedAt = `-- name: TouchChatDebugRunUpdatedAt :exec
UPDATE chat_debug_runs
SET updated_at = $1::timestamptz
WHERE id = $2::uuid
AND chat_id = $3::uuid
`
type TouchChatDebugRunUpdatedAtParams struct {
Now time.Time `db:"now" json:"now"`
ID uuid.UUID `db:"id" json:"id"`
ChatID uuid.UUID `db:"chat_id" json:"chat_id"`
}
// Overrides updated_at on the parent run without touching any
// other column. Used by tests that need to stamp a run with a
// specific timestamp after the InsertChatDebugStep CTE has
// already bumped it to NOW(), so stale-row finalization paths
// can be exercised deterministically. The chatdebug service
// itself does not call this: heartbeats go through
// TouchChatDebugStepAndRun, and step creation updates the parent
// run via the InsertChatDebugStep CTE.
func (q *sqlQuerier) TouchChatDebugRunUpdatedAt(ctx context.Context, arg TouchChatDebugRunUpdatedAtParams) error {
_, err := q.db.ExecContext(ctx, touchChatDebugRunUpdatedAt, arg.Now, arg.ID, arg.ChatID)
return err
}
const touchChatDebugStepAndRun = `-- name: TouchChatDebugStepAndRun :exec
WITH touched_run AS (
UPDATE chat_debug_runs
SET updated_at = $1::timestamptz
WHERE id = $3::uuid
AND chat_id = $4::uuid
RETURNING id, chat_id
)
UPDATE chat_debug_steps
SET updated_at = $1::timestamptz
FROM touched_run
WHERE chat_debug_steps.id = $2::uuid
AND chat_debug_steps.run_id = touched_run.id
AND chat_debug_steps.chat_id = touched_run.chat_id
`
type TouchChatDebugStepAndRunParams struct {
Now time.Time `db:"now" json:"now"`
StepID uuid.UUID `db:"step_id" json:"step_id"`
RunID uuid.UUID `db:"run_id" json:"run_id"`
ChatID uuid.UUID `db:"chat_id" json:"chat_id"`
}
// Atomically bumps updated_at on both the step and its parent run
// in a single statement. This prevents FinalizeStale from
// interleaving between the two touches and finalizing a run whose
// step heartbeat was just written.
//
// The step UPDATE joins through touched_run (via FROM) and reads
// its RETURNING rows. Per the PostgreSQL WITH semantics, RETURNING
// is the only way to communicate values between a data-modifying
// CTE and the main query, and consuming those rows forces the run
// UPDATE to complete before the step UPDATE. That matches the
// lock order used by FinalizeStaleChatDebugRows and avoids a
// deadlock between concurrent heartbeats and stale sweeps. The
// join also constrains the step update to the specified run so a
// mismatched (run_id, step_id) pair cannot silently refresh an
// unrelated step.
func (q *sqlQuerier) TouchChatDebugStepAndRun(ctx context.Context, arg TouchChatDebugStepAndRunParams) error {
_, err := q.db.ExecContext(ctx, touchChatDebugStepAndRun,
arg.Now,
arg.StepID,
arg.RunID,
arg.ChatID,
)
return err
}
const updateChatDebugRun = `-- name: UpdateChatDebugRun :one
UPDATE chat_debug_runs
SET
@@ -3339,10 +3433,10 @@ SET
provider = COALESCE($7::text, provider),
model = COALESCE($8::text, model),
summary = COALESCE($9::jsonb, summary),
finished_at = COALESCE($10::timestamptz, finished_at),
updated_at = NOW()
WHERE id = $11::uuid
AND chat_id = $12::uuid
finished_at = COALESCE(finished_at, $10::timestamptz),
updated_at = $11::timestamptz
WHERE id = $12::uuid
AND chat_id = $13::uuid
RETURNING id, chat_id, root_chat_id, parent_chat_id, model_config_id, trigger_message_id, history_tip_message_id, kind, status, provider, model, summary, started_at, updated_at, finished_at
`
@@ -3357,14 +3451,24 @@ type UpdateChatDebugRunParams struct {
Model sql.NullString `db:"model" json:"model"`
Summary pqtype.NullRawMessage `db:"summary" json:"summary"`
FinishedAt sql.NullTime `db:"finished_at" json:"finished_at"`
Now time.Time `db:"now" json:"now"`
ID uuid.UUID `db:"id" json:"id"`
ChatID uuid.UUID `db:"chat_id" json:"chat_id"`
}
// Uses COALESCE so that passing NULL from Go means "keep the
// existing value." This is intentional: debug rows follow a
// existing value." This is intentional: debug rows follow a
// write-once-finalize pattern where fields are set at creation
// or finalization and never cleared back to NULL.
// or finalization and never cleared back to NULL. The @now
// parameter keeps updated_at under the caller's clock.
//
// finished_at is enforced as write-once at the SQL level: once
// populated it cannot be overwritten by a later call. Callers
// that issue a summary or status refresh after the run has
// already finalized therefore cannot corrupt the original
// completion timestamp, which keeps duration and ordering
// calculations stable regardless of how many times the row is
// updated.
func (q *sqlQuerier) UpdateChatDebugRun(ctx context.Context, arg UpdateChatDebugRunParams) (ChatDebugRun, error) {
row := q.db.QueryRowContext(ctx, updateChatDebugRun,
arg.RootChatID,
@@ -3377,6 +3481,7 @@ func (q *sqlQuerier) UpdateChatDebugRun(ctx context.Context, arg UpdateChatDebug
arg.Model,
arg.Summary,
arg.FinishedAt,
arg.Now,
arg.ID,
arg.ChatID,
)
@@ -3414,9 +3519,9 @@ SET
error = COALESCE($8::jsonb, error),
metadata = COALESCE($9::jsonb, metadata),
finished_at = COALESCE($10::timestamptz, finished_at),
updated_at = NOW()
WHERE id = $11::uuid
AND chat_id = $12::uuid
updated_at = $11::timestamptz
WHERE id = $12::uuid
AND chat_id = $13::uuid
RETURNING id, run_id, chat_id, step_number, operation, status, history_tip_message_id, assistant_message_id, normalized_request, normalized_response, usage, attempts, error, metadata, started_at, updated_at, finished_at
`
@@ -3431,14 +3536,17 @@ type UpdateChatDebugStepParams struct {
Error pqtype.NullRawMessage `db:"error" json:"error"`
Metadata pqtype.NullRawMessage `db:"metadata" json:"metadata"`
FinishedAt sql.NullTime `db:"finished_at" json:"finished_at"`
Now time.Time `db:"now" json:"now"`
ID uuid.UUID `db:"id" json:"id"`
ChatID uuid.UUID `db:"chat_id" json:"chat_id"`
}
// Uses COALESCE so that passing NULL from Go means "keep the
// existing value." This is intentional: debug rows follow a
// existing value." This is intentional: debug rows follow a
// write-once-finalize pattern where fields are set at creation
// or finalization and never cleared back to NULL.
// or finalization and never cleared back to NULL. The @now
// parameter keeps updated_at under the caller's clock, matching
// the injectable quartz.Clock used by FinalizeStale sweeps.
func (q *sqlQuerier) UpdateChatDebugStep(ctx context.Context, arg UpdateChatDebugStepParams) (ChatDebugStep, error) {
row := q.db.QueryRowContext(ctx, updateChatDebugStep,
arg.Status,
@@ -3451,6 +3559,7 @@ func (q *sqlQuerier) UpdateChatDebugStep(ctx context.Context, arg UpdateChatDebu
arg.Error,
arg.Metadata,
arg.FinishedAt,
arg.Now,
arg.ID,
arg.ChatID,
)
+87 -17
View File
@@ -35,9 +35,18 @@ RETURNING *;
-- name: UpdateChatDebugRun :one
-- Uses COALESCE so that passing NULL from Go means "keep the
-- existing value." This is intentional: debug rows follow a
-- existing value." This is intentional: debug rows follow a
-- write-once-finalize pattern where fields are set at creation
-- or finalization and never cleared back to NULL.
-- or finalization and never cleared back to NULL. The @now
-- parameter keeps updated_at under the caller's clock.
--
-- finished_at is enforced as write-once at the SQL level: once
-- populated it cannot be overwritten by a later call. Callers
-- that issue a summary or status refresh after the run has
-- already finalized therefore cannot corrupt the original
-- completion timestamp, which keeps duration and ordering
-- calculations stable regardless of how many times the row is
-- updated.
UPDATE chat_debug_runs
SET
root_chat_id = COALESCE(sqlc.narg('root_chat_id')::uuid, root_chat_id),
@@ -49,13 +58,27 @@ SET
provider = COALESCE(sqlc.narg('provider')::text, provider),
model = COALESCE(sqlc.narg('model')::text, model),
summary = COALESCE(sqlc.narg('summary')::jsonb, summary),
finished_at = COALESCE(sqlc.narg('finished_at')::timestamptz, finished_at),
updated_at = NOW()
finished_at = COALESCE(finished_at, sqlc.narg('finished_at')::timestamptz),
updated_at = @now::timestamptz
WHERE id = @id::uuid
AND chat_id = @chat_id::uuid
RETURNING *;
-- name: InsertChatDebugStep :one
-- The CTE atomically locks the parent run via UPDATE, bumps its
-- updated_at (eliminating a separate TouchChatDebugRunUpdatedAt
-- call), and enforces the finalization guard: if the run is already
-- finished, the UPDATE returns zero rows, the INSERT gets no source
-- rows, and sql.ErrNoRows is returned. The UPDATE also serializes
-- with concurrent FinalizeStale under READ COMMITTED isolation.
WITH locked_run AS (
UPDATE chat_debug_runs
SET updated_at = COALESCE(sqlc.narg('updated_at')::timestamptz, NOW())
WHERE id = @run_id::uuid
AND chat_id = @chat_id::uuid
AND finished_at IS NULL
RETURNING chat_id
)
INSERT INTO chat_debug_steps (
run_id,
chat_id,
@@ -76,7 +99,7 @@ INSERT INTO chat_debug_steps (
)
SELECT
@run_id::uuid,
run.chat_id,
locked_run.chat_id,
@step_number::int,
@operation::text,
@status::text,
@@ -91,16 +114,16 @@ SELECT
COALESCE(sqlc.narg('started_at')::timestamptz, NOW()),
COALESCE(sqlc.narg('updated_at')::timestamptz, NOW()),
sqlc.narg('finished_at')::timestamptz
FROM chat_debug_runs run
WHERE run.id = @run_id::uuid
AND run.chat_id = @chat_id::uuid
FROM locked_run
RETURNING *;
-- name: UpdateChatDebugStep :one
-- Uses COALESCE so that passing NULL from Go means "keep the
-- existing value." This is intentional: debug rows follow a
-- existing value." This is intentional: debug rows follow a
-- write-once-finalize pattern where fields are set at creation
-- or finalization and never cleared back to NULL.
-- or finalization and never cleared back to NULL. The @now
-- parameter keeps updated_at under the caller's clock, matching
-- the injectable quartz.Clock used by FinalizeStale sweeps.
UPDATE chat_debug_steps
SET
status = COALESCE(sqlc.narg('status')::text, status),
@@ -113,11 +136,55 @@ SET
error = COALESCE(sqlc.narg('error')::jsonb, error),
metadata = COALESCE(sqlc.narg('metadata')::jsonb, metadata),
finished_at = COALESCE(sqlc.narg('finished_at')::timestamptz, finished_at),
updated_at = NOW()
updated_at = @now::timestamptz
WHERE id = @id::uuid
AND chat_id = @chat_id::uuid
RETURNING *;
-- name: TouchChatDebugRunUpdatedAt :exec
-- Overrides updated_at on the parent run without touching any
-- other column. Used by tests that need to stamp a run with a
-- specific timestamp after the InsertChatDebugStep CTE has
-- already bumped it to NOW(), so stale-row finalization paths
-- can be exercised deterministically. The chatdebug service
-- itself does not call this: heartbeats go through
-- TouchChatDebugStepAndRun, and step creation updates the parent
-- run via the InsertChatDebugStep CTE.
UPDATE chat_debug_runs
SET updated_at = @now::timestamptz
WHERE id = @id::uuid
AND chat_id = @chat_id::uuid;
-- name: TouchChatDebugStepAndRun :exec
-- Atomically bumps updated_at on both the step and its parent run
-- in a single statement. This prevents FinalizeStale from
-- interleaving between the two touches and finalizing a run whose
-- step heartbeat was just written.
--
-- The step UPDATE joins through touched_run (via FROM) and reads
-- its RETURNING rows. Per the PostgreSQL WITH semantics, RETURNING
-- is the only way to communicate values between a data-modifying
-- CTE and the main query, and consuming those rows forces the run
-- UPDATE to complete before the step UPDATE. That matches the
-- lock order used by FinalizeStaleChatDebugRows and avoids a
-- deadlock between concurrent heartbeats and stale sweeps. The
-- join also constrains the step update to the specified run so a
-- mismatched (run_id, step_id) pair cannot silently refresh an
-- unrelated step.
WITH touched_run AS (
UPDATE chat_debug_runs
SET updated_at = @now::timestamptz
WHERE id = @run_id::uuid
AND chat_id = @chat_id::uuid
RETURNING id, chat_id
)
UPDATE chat_debug_steps
SET updated_at = @now::timestamptz
FROM touched_run
WHERE chat_debug_steps.id = @step_id::uuid
AND chat_debug_steps.run_id = touched_run.id
AND chat_debug_steps.chat_id = touched_run.chat_id;
-- name: GetChatDebugRunsByChatID :many
-- Returns the most recent debug runs for a chat, ordered newest-first.
-- Callers must supply an explicit limit to avoid unbounded result sets.
@@ -168,20 +235,23 @@ WHERE chat_id = @chat_id::uuid
-- name: FinalizeStaleChatDebugRows :one
-- Marks orphaned in-progress rows as interrupted so they do not stay
-- in a non-terminal state forever. The NOT IN list must match the
-- in a non-terminal state forever. The NOT IN list must match the
-- terminal statuses defined by ChatDebugStatus in codersdk/chats.go.
--
-- The steps CTE also catches steps whose parent run was just finalized
-- (via run_id IN), because PostgreSQL data-modifying CTEs share the
-- same snapshot and cannot see each other's row updates. Without this,
-- same snapshot and cannot see each other's row updates. Without this,
-- a step with a recent updated_at would survive its run's finalization
-- and remain in 'in_progress' state permanently.
--
-- @now is the caller's clock timestamp so that mock-clock tests stay
-- consistent with the @updated_before cutoff.
WITH finalized_runs AS (
UPDATE chat_debug_runs
SET
status = 'interrupted',
updated_at = NOW(),
finished_at = NOW()
updated_at = @now::timestamptz,
finished_at = @now::timestamptz
WHERE updated_at < @updated_before::timestamptz
AND finished_at IS NULL
AND status NOT IN ('completed', 'error', 'interrupted')
@@ -190,8 +260,8 @@ WITH finalized_runs AS (
UPDATE chat_debug_steps
SET
status = 'interrupted',
updated_at = NOW(),
finished_at = NOW()
updated_at = @now::timestamptz,
finished_at = @now::timestamptz
WHERE (
updated_at < @updated_before::timestamptz
OR run_id IN (SELECT id FROM finalized_runs)