mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(coderd): add chat debug service and summary aggregation (#23916)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user