Skip to content
6 changes: 3 additions & 3 deletions coderd/database/dbauthz/dbauthz.go
Original file line number Diff line number Diff line change
Expand Up @@ -1871,15 +1871,15 @@ func (q *querier) DeleteChatDebugDataAfterMessageID(ctx context.Context, arg dat
return q.db.DeleteChatDebugDataAfterMessageID(ctx, arg)
}

func (q *querier) DeleteChatDebugDataByChatID(ctx context.Context, chatID uuid.UUID) (int64, error) {
chat, err := q.db.GetChatByID(ctx, chatID)
func (q *querier) DeleteChatDebugDataByChatID(ctx context.Context, arg database.DeleteChatDebugDataByChatIDParams) (int64, error) {
chat, err := q.db.GetChatByID(ctx, arg.ChatID)
if err != nil {
return 0, err
}
if err := q.authorizeContext(ctx, policy.ActionUpdate, chat); err != nil {
return 0, err
}
return q.db.DeleteChatDebugDataByChatID(ctx, chatID)
return q.db.DeleteChatDebugDataByChatID(ctx, arg)
}

func (q *querier) DeleteChatModelConfigByID(ctx context.Context, id uuid.UUID) error {
Expand Down
7 changes: 4 additions & 3 deletions coderd/database/dbauthz/dbauthz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -463,16 +463,17 @@ func (s *MethodTestSuite) TestChats() {
}))
s.Run("DeleteChatDebugDataAfterMessageID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
arg := database.DeleteChatDebugDataAfterMessageIDParams{ChatID: chat.ID, MessageID: 123}
arg := database.DeleteChatDebugDataAfterMessageIDParams{ChatID: chat.ID, StartedBefore: dbtime.Now(), MessageID: 123}
dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes()
dbm.EXPECT().DeleteChatDebugDataAfterMessageID(gomock.Any(), arg).Return(int64(1), nil).AnyTimes()
check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(int64(1))
}))
s.Run("DeleteChatDebugDataByChatID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
arg := database.DeleteChatDebugDataByChatIDParams{ChatID: chat.ID, StartedBefore: dbtime.Now()}
dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes()
dbm.EXPECT().DeleteChatDebugDataByChatID(gomock.Any(), chat.ID).Return(int64(1), nil).AnyTimes()
check.Args(chat.ID).Asserts(chat, policy.ActionUpdate).Returns(int64(1))
dbm.EXPECT().DeleteChatDebugDataByChatID(gomock.Any(), arg).Return(int64(1), nil).AnyTimes()
check.Args(arg).Asserts(chat, policy.ActionUpdate).Returns(int64(1))
}))
s.Run("FinalizeStaleChatDebugRows", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
now := dbtime.Now()
Expand Down
2 changes: 1 addition & 1 deletion coderd/database/dbmetrics/querymetrics.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions coderd/database/dbmock/dbmock.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 9 additions & 1 deletion coderd/database/querier.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

219 changes: 215 additions & 4 deletions coderd/database/querier_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11524,8 +11524,9 @@ func TestDeleteChatDebugDataAfterMessageIDIncludesTriggeredRuns(t *testing.T) {
require.NoError(t, err)

deletedRows, err := store.DeleteChatDebugDataAfterMessageID(ctx, database.DeleteChatDebugDataAfterMessageIDParams{
ChatID: chat.ID,
MessageID: cutoff,
ChatID: chat.ID,
MessageID: cutoff,
StartedBefore: time.Now().Add(time.Minute),
Comment thread
ThomasK33 marked this conversation as resolved.
})
require.NoError(t, err)
require.EqualValues(t, 3, deletedRows)
Expand Down Expand Up @@ -12406,8 +12407,9 @@ func TestDeleteChatDebugDataAfterMessageIDNullMessagesSurvive(t *testing.T) {
// Delete with an arbitrary cutoff. The run and its step should
// survive because NULL > cutoff evaluates to NULL, not TRUE.
deletedRows, err := store.DeleteChatDebugDataAfterMessageID(ctx, database.DeleteChatDebugDataAfterMessageIDParams{
ChatID: chat.ID,
MessageID: 1,
ChatID: chat.ID,
MessageID: 1,
StartedBefore: time.Now().Add(time.Minute),
})
require.NoError(t, err)
require.EqualValues(t, 0, deletedRows, "rows with NULL message IDs must not be deleted")
Expand All @@ -12424,6 +12426,215 @@ func TestDeleteChatDebugDataAfterMessageIDNullMessagesSurvive(t *testing.T) {
require.Equal(t, nullMsgStep.ID, remainingSteps[0].ID)
Comment thread
ThomasK33 marked this conversation as resolved.
}

// TestDeleteChatDebugDataAfterMessageIDStartedBeforeFiltersNewerRuns
// verifies the started_before bound on DeleteChatDebugDataAfterMessageID.
// The bound exists so that retried cleanup (e.g. after edit or archive)
// cannot delete runs started by a replacement turn that races ahead of
// the retry window. Without this filter, a stale cleanup would wipe
// fresh debug rows.
func TestDeleteChatDebugDataAfterMessageIDStartedBeforeFiltersNewerRuns(t *testing.T) {
t.Parallel()

store, _ := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitMedium)

org := dbgen.Organization(t, store, database.Organization{})
user := dbgen.User(t, store, database.User{})

providerName := "openai"
modelName := "debug-model-started-before-" + uuid.NewString()

_, err := store.InsertChatProvider(ctx, database.InsertChatProviderParams{
Provider: providerName,
DisplayName: "Debug Provider",
APIKey: "test-key",
Enabled: true,
CentralApiKeyEnabled: true,
})
require.NoError(t, err)

modelCfg, err := store.InsertChatModelConfig(ctx, database.InsertChatModelConfigParams{
Provider: providerName,
Model: modelName,
DisplayName: "Debug Model",
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
Enabled: true,
IsDefault: true,
ContextLimit: 128000,
CompressionThreshold: 80,
Options: json.RawMessage(`{}`),
})
require.NoError(t, err)

chat, err := store.InsertChat(ctx, database.InsertChatParams{
OrganizationID: org.ID,
Status: database.ChatStatusWaiting,
ClientType: database.ChatClientTypeUi,
OwnerID: user.ID,
LastModelConfigID: modelCfg.ID,
Title: "chat-debug-started-before-" + uuid.NewString(),
})
require.NoError(t, err)

const cutoff int64 = 50

// oldRun started an hour ago: must be deleted because it started
// before the bound.
oldStartedAt := time.Now().Add(-1 * time.Hour).UTC().
Truncate(time.Microsecond)
oldRun, err := store.InsertChatDebugRun(ctx, database.InsertChatDebugRunParams{
ChatID: chat.ID,
ModelConfigID: uuid.NullUUID{UUID: modelCfg.ID, Valid: true},
TriggerMessageID: sql.NullInt64{Int64: cutoff + 1, Valid: true},
HistoryTipMessageID: sql.NullInt64{Int64: cutoff + 1, Valid: true},
Kind: "chat_turn",
Status: "in_progress",
Provider: sql.NullString{String: providerName, Valid: true},
Model: sql.NullString{String: modelName, Valid: true},
StartedAt: sql.NullTime{Time: oldStartedAt, Valid: true},
UpdatedAt: sql.NullTime{Time: oldStartedAt, Valid: true},
})
require.NoError(t, err)

// Bound sits between the two runs. Any run whose started_at is at
// or after this instant must survive.
cutoffTime := time.Now().Add(-30 * time.Minute).UTC().
Truncate(time.Microsecond)

// newRun started after cutoffTime with identical message_id values
// that would otherwise match the delete predicate. It must survive
// because started_before excludes it.
newStartedAt := time.Now().UTC().Truncate(time.Microsecond)
newRun, err := store.InsertChatDebugRun(ctx, database.InsertChatDebugRunParams{
ChatID: chat.ID,
ModelConfigID: uuid.NullUUID{UUID: modelCfg.ID, Valid: true},
TriggerMessageID: sql.NullInt64{Int64: cutoff + 1, Valid: true},
HistoryTipMessageID: sql.NullInt64{Int64: cutoff + 1, Valid: true},
Kind: "chat_turn",
Status: "in_progress",
Provider: sql.NullString{String: providerName, Valid: true},
Model: sql.NullString{String: modelName, Valid: true},
StartedAt: sql.NullTime{Time: newStartedAt, Valid: true},
UpdatedAt: sql.NullTime{Time: newStartedAt, Valid: true},
})
require.NoError(t, err)

deletedRows, err := store.DeleteChatDebugDataAfterMessageID(ctx, database.DeleteChatDebugDataAfterMessageIDParams{
ChatID: chat.ID,
MessageID: cutoff,
StartedBefore: cutoffTime,
})
require.NoError(t, err)
require.EqualValues(t, 1, deletedRows,
"only the pre-cutoff run should be deleted")

// oldRun must be gone.
_, err = store.GetChatDebugRunByID(ctx, oldRun.ID)
require.ErrorIs(t, err, sql.ErrNoRows)

// newRun must survive the retry window.
remaining, err := store.GetChatDebugRunByID(ctx, newRun.ID)
require.NoError(t, err)
require.Equal(t, newRun.ID, remaining.ID)
}

// TestDeleteChatDebugDataByChatIDStartedBeforeFiltersNewerRuns verifies
// the started_before bound on DeleteChatDebugDataByChatID. Archive
// cleanup retries rely on this bound to avoid deleting runs created
// by a replacement turn that starts after an unarchive races ahead of
// the retry window.
func TestDeleteChatDebugDataByChatIDStartedBeforeFiltersNewerRuns(t *testing.T) {
t.Parallel()

store, _ := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitMedium)

org := dbgen.Organization(t, store, database.Organization{})
user := dbgen.User(t, store, database.User{})

providerName := "openai"
modelName := "debug-model-by-chat-started-before-" + uuid.NewString()

_, err := store.InsertChatProvider(ctx, database.InsertChatProviderParams{
Provider: providerName,
DisplayName: "Debug Provider",
APIKey: "test-key",
Enabled: true,
CentralApiKeyEnabled: true,
})
require.NoError(t, err)

modelCfg, err := store.InsertChatModelConfig(ctx, database.InsertChatModelConfigParams{
Provider: providerName,
Model: modelName,
DisplayName: "Debug Model",
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
Enabled: true,
IsDefault: true,
ContextLimit: 128000,
CompressionThreshold: 80,
Options: json.RawMessage(`{}`),
})
require.NoError(t, err)

chat, err := store.InsertChat(ctx, database.InsertChatParams{
OrganizationID: org.ID,
Status: database.ChatStatusWaiting,
ClientType: database.ChatClientTypeUi,
OwnerID: user.ID,
LastModelConfigID: modelCfg.ID,
Title: "chat-debug-by-chat-" + uuid.NewString(),
})
require.NoError(t, err)

oldStartedAt := time.Now().Add(-1 * time.Hour).UTC().
Truncate(time.Microsecond)
oldRun, err := store.InsertChatDebugRun(ctx, database.InsertChatDebugRunParams{
ChatID: chat.ID,
ModelConfigID: uuid.NullUUID{UUID: modelCfg.ID, Valid: true},
Kind: "chat_turn",
Status: "in_progress",
Provider: sql.NullString{String: providerName, Valid: true},
Model: sql.NullString{String: modelName, Valid: true},
StartedAt: sql.NullTime{Time: oldStartedAt, Valid: true},
UpdatedAt: sql.NullTime{Time: oldStartedAt, Valid: true},
})
require.NoError(t, err)

cutoffTime := time.Now().Add(-30 * time.Minute).UTC().
Truncate(time.Microsecond)

newStartedAt := time.Now().UTC().Truncate(time.Microsecond)
newRun, err := store.InsertChatDebugRun(ctx, database.InsertChatDebugRunParams{
ChatID: chat.ID,
ModelConfigID: uuid.NullUUID{UUID: modelCfg.ID, Valid: true},
Kind: "chat_turn",
Status: "in_progress",
Provider: sql.NullString{String: providerName, Valid: true},
Model: sql.NullString{String: modelName, Valid: true},
StartedAt: sql.NullTime{Time: newStartedAt, Valid: true},
UpdatedAt: sql.NullTime{Time: newStartedAt, Valid: true},
})
require.NoError(t, err)

deletedRows, err := store.DeleteChatDebugDataByChatID(ctx, database.DeleteChatDebugDataByChatIDParams{
ChatID: chat.ID,
StartedBefore: cutoffTime,
})
require.NoError(t, err)
require.EqualValues(t, 1, deletedRows,
"only the pre-cutoff run should be deleted")

_, err = store.GetChatDebugRunByID(ctx, oldRun.ID)
require.ErrorIs(t, err, sql.ErrNoRows)

remaining, err := store.GetChatDebugRunByID(ctx, newRun.ID)
require.NoError(t, err)
require.Equal(t, newRun.ID, remaining.ID)
}

func TestChatHasUnread(t *testing.T) {
t.Parallel()

Expand Down
Loading
Loading