fix(coderd): prevent stale chat model config writes (#27968)

Chat model updates could merge PATCH fields from a row read before the
serialized transaction. Deletes could also succeed after another request
deleted the row.

Merge updates from the row read after the write lock. Make soft deletes
report missing rows, and validate provider availability and model
compatibility in the same transaction.

This PR also removes `DeleteChatModelConfigsByAIProviderID`. The query
has no production callers, including the AI provider deletion path, and
only remained in generated database wrappers and mocks. Its removal is
intentional dead-code cleanup and does not change provider deletion
behavior.

_This pull request description was generated by Coder Agents._
This commit is contained in:
Ethan
2026-08-24 09:30:00 +00:00
committed by GitHub
parent 4ce8e8ed56
commit cd1362abfd
11 changed files with 476 additions and 244 deletions
+2 -9
View File
@@ -2217,20 +2217,13 @@ func (q *querier) DeleteChatDebugDataByChatID(ctx context.Context, arg database.
return q.db.DeleteChatDebugDataByChatID(ctx, arg)
}
func (q *querier) DeleteChatModelConfigByID(ctx context.Context, id uuid.UUID) error {
func (q *querier) DeleteChatModelConfigByID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil {
return err
return uuid.Nil, err
}
return q.db.DeleteChatModelConfigByID(ctx, id)
}
func (q *querier) DeleteChatModelConfigsByAIProviderID(ctx context.Context, aiProviderID uuid.UUID) error {
if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceAIProvider); err != nil {
return err
}
return q.db.DeleteChatModelConfigsByAIProviderID(ctx, aiProviderID)
}
func (q *querier) DeleteChatQueuedMessage(ctx context.Context, arg database.DeleteChatQueuedMessageParams) error {
chat, err := q.db.GetChatByID(ctx, arg.ChatID)
if err != nil {
+3 -8
View File
@@ -704,14 +704,9 @@ func (s *MethodTestSuite) TestChats() {
check.Args(msg.ID).Asserts(chat, policy.ActionUpdate).Returns()
}))
s.Run("DeleteChatModelConfigByID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
id := uuid.New()
dbm.EXPECT().DeleteChatModelConfigByID(gomock.Any(), id).Return(nil).AnyTimes()
check.Args(id).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate)
}))
s.Run("DeleteChatModelConfigsByAIProviderID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
providerID := uuid.New()
dbm.EXPECT().DeleteChatModelConfigsByAIProviderID(gomock.Any(), providerID).Return(nil).AnyTimes()
check.Args(providerID).Asserts(rbac.ResourceAIProvider, policy.ActionDelete)
configID := uuid.New()
dbm.EXPECT().DeleteChatModelConfigByID(gomock.Any(), configID).Return(configID, nil).AnyTimes()
check.Args(configID).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate).Returns(configID)
}))
s.Run("DeleteChatQueuedMessage", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
chat := testutil.Fake(s.T(), faker, database.Chat{})
+3 -11
View File
@@ -520,20 +520,12 @@ func (m queryMetricsStore) DeleteChatDebugDataByChatID(ctx context.Context, chat
return r0, r1
}
func (m queryMetricsStore) DeleteChatModelConfigByID(ctx context.Context, id uuid.UUID) error {
func (m queryMetricsStore) DeleteChatModelConfigByID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) {
start := time.Now()
r0 := m.s.DeleteChatModelConfigByID(ctx, id)
r0, r1 := m.s.DeleteChatModelConfigByID(ctx, id)
m.queryLatencies.WithLabelValues("DeleteChatModelConfigByID").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "DeleteChatModelConfigByID").Inc()
return r0
}
func (m queryMetricsStore) DeleteChatModelConfigsByAIProviderID(ctx context.Context, aiProviderID uuid.UUID) error {
start := time.Now()
r0 := m.s.DeleteChatModelConfigsByAIProviderID(ctx, aiProviderID)
m.queryLatencies.WithLabelValues("DeleteChatModelConfigsByAIProviderID").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "DeleteChatModelConfigsByAIProviderID").Inc()
return r0
return r0, r1
}
func (m queryMetricsStore) DeleteChatQueuedMessage(ctx context.Context, arg database.DeleteChatQueuedMessageParams) error {
+4 -17
View File
@@ -849,11 +849,12 @@ func (mr *MockStoreMockRecorder) DeleteChatDebugDataByChatID(ctx, arg any) *gomo
}
// DeleteChatModelConfigByID mocks base method.
func (m *MockStore) DeleteChatModelConfigByID(ctx context.Context, id uuid.UUID) error {
func (m *MockStore) DeleteChatModelConfigByID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DeleteChatModelConfigByID", ctx, id)
ret0, _ := ret[0].(error)
return ret0
ret0, _ := ret[0].(uuid.UUID)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// DeleteChatModelConfigByID indicates an expected call of DeleteChatModelConfigByID.
@@ -862,20 +863,6 @@ func (mr *MockStoreMockRecorder) DeleteChatModelConfigByID(ctx, id any) *gomock.
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteChatModelConfigByID", reflect.TypeOf((*MockStore)(nil).DeleteChatModelConfigByID), ctx, id)
}
// DeleteChatModelConfigsByAIProviderID mocks base method.
func (m *MockStore) DeleteChatModelConfigsByAIProviderID(ctx context.Context, aiProviderID uuid.UUID) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DeleteChatModelConfigsByAIProviderID", ctx, aiProviderID)
ret0, _ := ret[0].(error)
return ret0
}
// DeleteChatModelConfigsByAIProviderID indicates an expected call of DeleteChatModelConfigsByAIProviderID.
func (mr *MockStoreMockRecorder) DeleteChatModelConfigsByAIProviderID(ctx, aiProviderID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteChatModelConfigsByAIProviderID", reflect.TypeOf((*MockStore)(nil).DeleteChatModelConfigsByAIProviderID), ctx, aiProviderID)
}
// DeleteChatQueuedMessage mocks base method.
func (m *MockStore) DeleteChatQueuedMessage(ctx context.Context, arg database.DeleteChatQueuedMessageParams) error {
m.ctrl.T.Helper()
+1 -2
View File
@@ -146,8 +146,7 @@ type sqlcQuerier interface {
// window (for example, after an unarchive races with a pending
// archive-cleanup retry).
DeleteChatDebugDataByChatID(ctx context.Context, arg DeleteChatDebugDataByChatIDParams) (int64, error)
DeleteChatModelConfigByID(ctx context.Context, id uuid.UUID) error
DeleteChatModelConfigsByAIProviderID(ctx context.Context, aiProviderID uuid.UUID) error
DeleteChatModelConfigByID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
DeleteChatQueuedMessage(ctx context.Context, arg DeleteChatQueuedMessageParams) error
// Deletes a queued message, scoped to the parent chat. Returns the
// number of affected rows so callers can detect missing rows without
+15
View File
@@ -12351,6 +12351,21 @@ func TestInsertWorkspaceAgentDevcontainers(t *testing.T) {
}
}
func TestDeleteChatModelConfigByID(t *testing.T) {
t.Parallel()
store, _ := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitMedium)
config := dbgen.ChatModelConfig(t, store, database.ChatModelConfig{})
deletedID, err := store.DeleteChatModelConfigByID(ctx, config.ID)
require.NoError(t, err)
require.Equal(t, config.ID, deletedID)
_, err = store.DeleteChatModelConfigByID(ctx, config.ID)
require.ErrorIs(t, err, sql.ErrNoRows)
}
func TestGetEnabledChatModelConfigsUsesAIProviders(t *testing.T) {
t.Parallel()
+7 -20
View File
@@ -6135,7 +6135,7 @@ func (q *sqlQuerier) InsertChatFile(ctx context.Context, arg InsertChatFileParam
return i, err
}
const deleteChatModelConfigByID = `-- name: DeleteChatModelConfigByID :exec
const deleteChatModelConfigByID = `-- name: DeleteChatModelConfigByID :one
UPDATE
chat_model_configs
SET
@@ -6144,28 +6144,15 @@ SET
updated_at = NOW()
WHERE
id = $1::uuid
`
func (q *sqlQuerier) DeleteChatModelConfigByID(ctx context.Context, id uuid.UUID) error {
_, err := q.db.ExecContext(ctx, deleteChatModelConfigByID, id)
return err
}
const deleteChatModelConfigsByAIProviderID = `-- name: DeleteChatModelConfigsByAIProviderID :exec
UPDATE
chat_model_configs
SET
deleted = TRUE,
deleted_at = NOW(),
updated_at = NOW()
WHERE
ai_provider_id = $1::uuid
AND deleted = FALSE
RETURNING id
`
func (q *sqlQuerier) DeleteChatModelConfigsByAIProviderID(ctx context.Context, aiProviderID uuid.UUID) error {
_, err := q.db.ExecContext(ctx, deleteChatModelConfigsByAIProviderID, aiProviderID)
return err
func (q *sqlQuerier) DeleteChatModelConfigByID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) {
row := q.db.QueryRowContext(ctx, deleteChatModelConfigByID, id)
var id_2 uuid.UUID
err := row.Scan(&id_2)
return id_2, err
}
const getChatModelConfigByID = `-- name: GetChatModelConfigByID :one
+4 -13
View File
@@ -123,7 +123,7 @@ WHERE
is_default = TRUE
AND deleted = FALSE;
-- name: DeleteChatModelConfigByID :exec
-- name: DeleteChatModelConfigByID :one
UPDATE
chat_model_configs
SET
@@ -131,15 +131,6 @@ SET
deleted_at = NOW(),
updated_at = NOW()
WHERE
id = @id::uuid;
-- name: DeleteChatModelConfigsByAIProviderID :exec
UPDATE
chat_model_configs
SET
deleted = TRUE,
deleted_at = NOW(),
updated_at = NOW()
WHERE
ai_provider_id = @ai_provider_id::uuid
AND deleted = FALSE;
id = @id::uuid
AND deleted = FALSE
RETURNING id;