From 95574363a5b03e8c696babd2ef569882a8fda9fa Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Mon, 10 Aug 2026 06:10:49 +0000 Subject: [PATCH 1/4] fix(coderd): read chat model configs under the write lock The PATCH and DELETE chat model config handlers read the target row outside `LockIDChatModelConfigWrites`, then wrote based on that copy. A concurrent writer could change or delete the row in between. PATCH now re-reads the row inside the write transaction and merges the request against that copy. Request-only validation still runs before the lock so a bad payload never acquires it. DELETE now re-reads the row inside the transaction and returns 404 when it is gone. `DeleteChatModelConfigByID` is an unguarded `UPDATE ... SET deleted = TRUE` with no `RETURNING`, so deleting an already-deleted row previously reported 204 for a row it did not delete. Moving provider validation under the lock loses the handler's access to the 412 status, so `errAIProviderNotConfigured` and `errAIProviderDisabled` carry those two cases out of the transaction. --- coderd/exp_chats.go | 194 +++++++++++++++++++++++---------------- coderd/exp_chats_test.go | 95 +++++++++++++++++++ 2 files changed, 208 insertions(+), 81 deletions(-) diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 8c7a3fa4f16f6..6a8629d86b593 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -7104,8 +7104,7 @@ func (api *API) updateChatModelConfig(rw http.ResponseWriter, r *http.Request) { return } - existing, err := api.Database.GetChatModelConfigByID(ctx, modelConfigID) - if err != nil { + if _, err := api.Database.GetChatModelConfigByID(ctx, modelConfigID); err != nil { if httpapi.Is404Error(err) { httpapi.ResourceNotFound(rw) return @@ -7122,71 +7121,31 @@ func (api *API) updateChatModelConfig(rw http.ResponseWriter, r *http.Request) { return } - aiProviderID := existing.AIProviderID - if req.AIProviderID != nil { - //nolint:gocritic // The route already authorized chat model config updates. - aiProvider, err := api.Database.GetAIProviderByID(dbauthz.AsChatd(ctx), *req.AIProviderID) - if err != nil { - if httpapi.Is404Error(err) { - httpapi.Write(ctx, rw, http.StatusPreconditionFailed, codersdk.Response{Message: "AI provider is not configured."}) - return - } - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to get AI provider.", - Detail: err.Error(), - }) - return - } - if !aiProvider.Enabled { - httpapi.Write(ctx, rw, http.StatusPreconditionFailed, codersdk.Response{Message: "AI provider is disabled."}) - return - } - aiProviderID = uuid.NullUUID{UUID: aiProvider.ID, Valid: true} - } - - model := existing.Model - if trimmed := strings.TrimSpace(req.Model); trimmed != "" { - model = trimmed - } - - displayName := existing.DisplayName - if trimmed := strings.TrimSpace(req.DisplayName); trimmed != "" { - displayName = trimmed - } - - enabled := existing.Enabled - if req.Enabled != nil { - enabled = *req.Enabled - } - isDefault := existing.IsDefault - if req.IsDefault != nil { - isDefault = *req.IsDefault + if req.ContextLimit != nil && *req.ContextLimit <= 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Context limit must be greater than zero.", + }) + return } - contextLimit := existing.ContextLimit - if req.ContextLimit != nil { - if *req.ContextLimit <= 0 { + // Only a request-supplied threshold is validated; the merge with the + // stored value happens later under the write lock. A PATCH that omits + // the field keeps the stored value, which the chat_model_configs CHECK + // constraint already bounds to 0..100. + var requestedCompressionThreshold *int32 + if req.CompressionThreshold != nil { + compressionThreshold, thresholdErr := normalizeChatCompressionThreshold(req.CompressionThreshold, 0) + if thresholdErr != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Context limit must be greater than zero.", + Message: "Invalid compression threshold.", + Detail: thresholdErr.Error(), }) return } - contextLimit = *req.ContextLimit + requestedCompressionThreshold = &compressionThreshold } - compressionThreshold, thresholdErr := normalizeChatCompressionThreshold( - req.CompressionThreshold, - existing.CompressionThreshold, - ) - if thresholdErr != nil { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Invalid compression threshold.", - Detail: thresholdErr.Error(), - }) - return - } - - modelConfigRaw := existing.Options + var requestedModelConfig json.RawMessage if req.ModelConfig != nil { encodedModelConfig, modelConfigErr := marshalChatModelCallConfig(req.ModelConfig) if modelConfigErr != nil { @@ -7196,36 +7155,85 @@ func (api *API) updateChatModelConfig(rw http.ResponseWriter, r *http.Request) { }) return } - modelConfigRaw = encodedModelConfig + requestedModelConfig = encodedModelConfig } - updateParams := database.UpdateChatModelConfigParams{ - Model: model, - DisplayName: displayName, - Enabled: enabled, - IsDefault: isDefault, - ContextLimit: contextLimit, - CompressionThreshold: compressionThreshold, - Options: modelConfigRaw, - AIProviderID: aiProviderID, - UpdatedBy: uuid.NullUUID{UUID: apiKey.UserID, Valid: apiKey.UserID != uuid.Nil}, - ID: existing.ID, - } - - // Re-derive the provider type under lock when the model or provider changes. - revalidateProviderModel := updateParams.AIProviderID.Valid && (req.AIProviderID != nil || strings.TrimSpace(req.Model) != "") var updated database.ChatModelConfig - err = api.inChatModelConfigWriteTx(ctx, func(tx database.Store) error { + err := api.inChatModelConfigWriteTx(ctx, func(tx database.Store) error { + // Re-read the row under the write lock. The unlocked read above only + // rejects unknown IDs; a concurrent writer can change or delete the row + // between the two reads, so the update must merge against this copy. + lockedExisting, err := tx.GetChatModelConfigByID(ctx, modelConfigID) + if err != nil { + if xerrors.Is(err, sql.ErrNoRows) { + return errChatModelConfigNotFound + } + return xerrors.Errorf("get chat model config for update: %w", err) + } + + model := lockedExisting.Model + if trimmed := strings.TrimSpace(req.Model); trimmed != "" { + model = trimmed + } + displayName := lockedExisting.DisplayName + if trimmed := strings.TrimSpace(req.DisplayName); trimmed != "" { + displayName = trimmed + } + enabled := lockedExisting.Enabled + if req.Enabled != nil { + enabled = *req.Enabled + } + isDefault := lockedExisting.IsDefault + if req.IsDefault != nil { + isDefault = *req.IsDefault + } + contextLimit := lockedExisting.ContextLimit + if req.ContextLimit != nil { + contextLimit = *req.ContextLimit + } + compressionThreshold := lockedExisting.CompressionThreshold + if requestedCompressionThreshold != nil { + compressionThreshold = *requestedCompressionThreshold + } + modelConfigRaw := lockedExisting.Options + if requestedModelConfig != nil { + modelConfigRaw = requestedModelConfig + } + aiProviderID := lockedExisting.AIProviderID + if req.AIProviderID != nil { + aiProviderID = uuid.NullUUID{UUID: *req.AIProviderID, Valid: true} + } + + updateParams := database.UpdateChatModelConfigParams{ + Model: model, + DisplayName: displayName, + Enabled: enabled, + IsDefault: isDefault, + ContextLimit: contextLimit, + CompressionThreshold: compressionThreshold, + Options: modelConfigRaw, + AIProviderID: aiProviderID, + UpdatedBy: uuid.NullUUID{UUID: apiKey.UserID, Valid: apiKey.UserID != uuid.Nil}, + ID: lockedExisting.ID, + } + + revalidateProviderModel := updateParams.AIProviderID.Valid && (req.AIProviderID != nil || strings.TrimSpace(req.Model) != "") if revalidateProviderModel { //nolint:gocritic // The route already authorized chat model config updates. aiProvider, err := tx.GetAIProviderByIDForReferenceLock(dbauthz.AsChatd(ctx), updateParams.AIProviderID.UUID) if err != nil { if xerrors.Is(err, sql.ErrNoRows) { + if req.AIProviderID != nil { + return errAIProviderNotConfigured + } return errChatProviderNotConfigured } return xerrors.Errorf("get AI provider for update: %w", err) } if !aiProvider.Enabled { + if req.AIProviderID != nil { + return errAIProviderDisabled + } return errChatProviderNotConfigured } if err := validateChatModelConfigProviderModel(aiProvider, updateParams.Model); err != nil { @@ -7233,14 +7241,14 @@ func (api *API) updateChatModelConfig(rw http.ResponseWriter, r *http.Request) { } } - setAsDefault := updateParams.IsDefault && !existing.IsDefault + setAsDefault := updateParams.IsDefault && !lockedExisting.IsDefault if setAsDefault { if err := tx.UnsetDefaultChatModelConfigs(ctx); err != nil { return xerrors.Errorf("unset default model configs: %w", err) } } - _, err := tx.UpdateChatModelConfig(ctx, updateParams) + _, err = tx.UpdateChatModelConfig(ctx, updateParams) if err != nil { if xerrors.Is(err, sql.ErrNoRows) { return errChatModelConfigNotFound @@ -7249,8 +7257,8 @@ func (api *API) updateChatModelConfig(rw http.ResponseWriter, r *http.Request) { } excludeConfigID := uuid.Nil - if existing.IsDefault && req.IsDefault != nil && !*req.IsDefault { - excludeConfigID = existing.ID + if lockedExisting.IsDefault && req.IsDefault != nil && !*req.IsDefault { + excludeConfigID = lockedExisting.ID } if err := ensureDefaultChatModelConfig( @@ -7261,7 +7269,7 @@ func (api *API) updateChatModelConfig(rw http.ResponseWriter, r *http.Request) { return err } - refreshedConfig, err := tx.GetChatModelConfigByID(ctx, existing.ID) + refreshedConfig, err := tx.GetChatModelConfigByID(ctx, lockedExisting.ID) if err != nil { if xerrors.Is(err, sql.ErrNoRows) { // Do not wrap with %w. The outer handler maps target misses to 404. @@ -7284,6 +7292,18 @@ func (api *API) updateChatModelConfig(rw http.ResponseWriter, r *http.Request) { Detail: err.Error(), }) return + case xerrors.Is(err, errAIProviderNotConfigured): + httpapi.Write(ctx, rw, http.StatusPreconditionFailed, codersdk.Response{ + Message: "AI provider is not configured.", + Detail: err.Error(), + }) + return + case xerrors.Is(err, errAIProviderDisabled): + httpapi.Write(ctx, rw, http.StatusPreconditionFailed, codersdk.Response{ + Message: "AI provider is disabled.", + Detail: err.Error(), + }) + return case xerrors.Is(err, errChatProviderNotConfigured): httpapi.Write(ctx, rw, http.StatusPreconditionFailed, codersdk.Response{ Message: "Chat provider is not configured.", @@ -7332,11 +7352,21 @@ func (api *API) deleteChatModelConfig(rw http.ResponseWriter, r *http.Request) { } if err := api.inChatModelConfigWriteTx(ctx, func(tx database.Store) error { + if _, err := tx.GetChatModelConfigByID(ctx, modelConfigID); err != nil { + if xerrors.Is(err, sql.ErrNoRows) { + return errChatModelConfigNotFound + } + return xerrors.Errorf("get chat model config for delete: %w", err) + } if err := tx.DeleteChatModelConfigByID(ctx, modelConfigID); err != nil { return err } return ensureDefaultChatModelConfig(ctx, tx) }); err != nil { + if xerrors.Is(err, errChatModelConfigNotFound) { + httpapi.ResourceNotFound(rw) + return + } httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to delete chat model config.", Detail: err.Error(), @@ -7625,6 +7655,8 @@ func validateChatProviderAPIKeySize(apiKey string) error { } var ( + errAIProviderDisabled = xerrors.New("AI provider is disabled") + errAIProviderNotConfigured = xerrors.New("AI provider is not configured") errChatModelConfigNotFound = xerrors.New("chat model config not found") errChatProviderNotConfigured = xerrors.New("chat provider is not configured") ) diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index cfb8e00b1f9a2..737c50e90ccbf 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -481,6 +481,46 @@ func (s *failNextUpdateChatModelConfigStore) UpdateChatModelConfig( return s.Store.UpdateChatModelConfig(ctx, arg) } +// vanishInTxChatModelConfigStore makes a model-config read return +// sql.ErrNoRows only inside a transaction, so a handler's pre-transaction +// read succeeds while its locked re-read observes the row as already gone. +type vanishInTxChatModelConfigStore struct { + database.Store + + inTx bool + vanishInTxChatModelConfig *atomic.Bool + vanishInTxChatModelConfigID uuid.UUID +} + +func newVanishInTxChatModelConfigStore(store database.Store) *vanishInTxChatModelConfigStore { + return &vanishInTxChatModelConfigStore{ + Store: store, + vanishInTxChatModelConfig: &atomic.Bool{}, + } +} + +func (s *vanishInTxChatModelConfigStore) InTx(function func(database.Store) error, txOpts *database.TxOptions) error { + return s.Store.InTx(func(tx database.Store) error { + return function(&vanishInTxChatModelConfigStore{ + Store: tx, + inTx: true, + vanishInTxChatModelConfig: s.vanishInTxChatModelConfig, + vanishInTxChatModelConfigID: s.vanishInTxChatModelConfigID, + }) + }, txOpts) +} + +func (s *vanishInTxChatModelConfigStore) GetChatModelConfigByID( + ctx context.Context, + id uuid.UUID, +) (database.ChatModelConfig, error) { + if s.inTx && id == s.vanishInTxChatModelConfigID && + s.vanishInTxChatModelConfig.CompareAndSwap(true, false) { + return database.ChatModelConfig{}, sql.ErrNoRows + } + return s.Store.GetChatModelConfigByID(ctx, id) +} + func insertAssistantMessage( t *testing.T, db database.Store, @@ -5215,6 +5255,34 @@ func TestUpdateChatModelConfig(t *testing.T) { require.Equal(t, "Context limit must be greater than zero.", sdkErr.Message) }) + // The handler re-reads the target inside the write transaction. A row + // that disappears between the pre-read and the locked read is a miss, + // not a silent no-op update. + t.Run("NotFoundWhenTargetRowDisappearsBeforeUpdate", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + rawDB, pubsub := dbtestutil.NewDB(t) + store := newVanishInTxChatModelConfigStore(rawDB) + rawClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ + Database: store, + Pubsub: pubsub, + DeploymentValues: coderdtest.DeploymentValues(t), + }) + aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil) + client := codersdk.NewExperimentalClient(rawClient) + _ = coderdtest.CreateFirstUser(t, client.Client) + modelConfig := createChatModelConfig(t, client) + + store.vanishInTxChatModelConfigID = modelConfig.ID + store.vanishInTxChatModelConfig.Store(true) + + _, err := client.UpdateChatModelConfig(ctx, modelConfig.ID, codersdk.UpdateChatModelConfigRequest{ + DisplayName: "vanished before update", + }) + requireSDKError(t, err, http.StatusNotFound) + }) + t.Run("InvalidModelConfigID", func(t *testing.T) { t.Parallel() @@ -5325,6 +5393,33 @@ func TestDeleteChatModelConfig(t *testing.T) { requireSDKError(t, err, http.StatusNotFound) }) + // Deleting a row that disappears between the pre-read and the locked + // re-read reports a miss. DeleteChatModelConfigByID is an unguarded + // UPDATE, so without the locked read the request would report success + // for a row it did not delete. + t.Run("NotFoundWhenTargetRowDisappearsInTx", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + rawDB, pubsub := dbtestutil.NewDB(t) + store := newVanishInTxChatModelConfigStore(rawDB) + rawClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ + Database: store, + Pubsub: pubsub, + DeploymentValues: coderdtest.DeploymentValues(t), + }) + aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil) + client := codersdk.NewExperimentalClient(rawClient) + _ = coderdtest.CreateFirstUser(t, client.Client) + modelConfig := createChatModelConfig(t, client) + + store.vanishInTxChatModelConfigID = modelConfig.ID + store.vanishInTxChatModelConfig.Store(true) + + err := client.DeleteChatModelConfig(ctx, modelConfig.ID) + requireSDKError(t, err, http.StatusNotFound) + }) + t.Run("InvalidModelConfigID", func(t *testing.T) { t.Parallel() From bd68164fcca915de07077ab7fec7ae238d9b092a Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 11 Aug 2026 13:09:48 +0000 Subject: [PATCH 2/4] fix(coderd): address review findings for chat model config write-lock reads Guard `DeleteChatModelConfigByID` with `AND deleted = FALSE` and switch it to `:one` returning the row. The handler maps `sql.ErrNoRows` to 404, and the returned row still carries the fields the upstack org-scoped lock needs. Move provider validation errors out of request-shape branching. The transaction closure now returns two fact sentinels, `errChatProviderMissing` and `errChatProviderDisabled`, for both create and update paths. The handlers map those facts to the specific 412 messages, so a model-only PATCH against a disabled stored provider now answers "AI provider is disabled." instead of the generic "not configured" wording. New 412 responses no longer attach `Detail: err.Error()`, so the `execute transaction:` wrapper no longer leaks into API responses. Extract `validateChatCompressionThreshold` and call it directly on the PATCH pointer value, dropping the unreachable fallback argument. Document that `inChatModelConfigWriteTx` must run at ReadCommitted, since snapshot isolation would capture pre-lock state at the lock statement. Collapse `failNextUpdateChatModelConfigStore` and `vanishInTxChatModelConfigStore` into one `chatModelConfigHookStore` with three `atomic.Pointer[uuid.UUID]` hooks: `failNextUpdate`, `vanishAtLockedRead`, and `mutateAtLockedRead`. Consume each hook with `Load` plus `CompareAndSwap` so background `dbrollup` transactions cannot race on plain UUID copies. Add `TestUpdateChatModelConfig` subtests: - `MergesFromLockedCopy` mutates the row at the locked read and asserts the PATCH response carries the sentinel model, proving the merge reads the locked copy. - `StoredProviderDisabledOnModelOnlyUpdate` asserts the disabled cause on model-only PATCH. - `CompressionThresholdOutOfRange` and `CompressionThresholdInRange` cover both branches of the extracted validator. - `ModelConfigUpdated` covers the model-config PATCH path. Rename the disappearance tests in both suites to `NotFoundWhenTargetRowDisappearsAtLockedRead` so the same name means the same failure across `TestUpdateChatModelConfig` and `TestDeleteChatModelConfig`. --- coderd/database/dbauthz/dbauthz.go | 4 +- coderd/database/dbmetrics/querymetrics.go | 6 +- coderd/database/dbmock/dbmock.go | 7 +- coderd/database/querier.go | 2 +- coderd/database/queries.sql.go | 28 +- coderd/database/queries/chatmodelconfigs.sql | 6 +- coderd/exp_chats.go | 94 +++---- coderd/exp_chats_test.go | 256 +++++++++++++------ coderd/telemetry/telemetry_test.go | 2 +- 9 files changed, 271 insertions(+), 134 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 2a776824d77eb..8d82c13944a5c 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2217,9 +2217,9 @@ 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) (database.ChatModelConfig, error) { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { - return err + return database.ChatModelConfig{}, err } return q.db.DeleteChatModelConfigByID(ctx, id) } diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index ca75449cca4e1..7d844f8c3d527 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -520,12 +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) (database.ChatModelConfig, 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 + return r0, r1 } func (m queryMetricsStore) DeleteChatModelConfigsByAIProviderID(ctx context.Context, aiProviderID uuid.UUID) error { diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 328c4d0e8cdbe..8d197b567ce38 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -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) (database.ChatModelConfig, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "DeleteChatModelConfigByID", ctx, id) - ret0, _ := ret[0].(error) - return ret0 + ret0, _ := ret[0].(database.ChatModelConfig) + ret1, _ := ret[1].(error) + return ret0, ret1 } // DeleteChatModelConfigByID indicates an expected call of DeleteChatModelConfigByID. diff --git a/coderd/database/querier.go b/coderd/database/querier.go index a17b3bc26c36e..143dd0dedeef9 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -146,7 +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 + DeleteChatModelConfigByID(ctx context.Context, id uuid.UUID) (ChatModelConfig, error) DeleteChatModelConfigsByAIProviderID(ctx context.Context, aiProviderID uuid.UUID) error DeleteChatQueuedMessage(ctx context.Context, arg DeleteChatQueuedMessageParams) error // Deletes a queued message, scoped to the parent chat. Returns the diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 722b9dfe0aeda..2ae731905437f 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -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,11 +6144,31 @@ SET updated_at = NOW() WHERE id = $1::uuid + AND deleted = FALSE +RETURNING id, model, display_name, created_by, updated_by, enabled, is_default, deleted, deleted_at, created_at, updated_at, context_limit, compression_threshold, options, ai_provider_id ` -func (q *sqlQuerier) DeleteChatModelConfigByID(ctx context.Context, id uuid.UUID) error { - _, err := q.db.ExecContext(ctx, deleteChatModelConfigByID, id) - return err +func (q *sqlQuerier) DeleteChatModelConfigByID(ctx context.Context, id uuid.UUID) (ChatModelConfig, error) { + row := q.db.QueryRowContext(ctx, deleteChatModelConfigByID, id) + var i ChatModelConfig + err := row.Scan( + &i.ID, + &i.Model, + &i.DisplayName, + &i.CreatedBy, + &i.UpdatedBy, + &i.Enabled, + &i.IsDefault, + &i.Deleted, + &i.DeletedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.ContextLimit, + &i.CompressionThreshold, + &i.Options, + &i.AIProviderID, + ) + return i, err } const deleteChatModelConfigsByAIProviderID = `-- name: DeleteChatModelConfigsByAIProviderID :exec diff --git a/coderd/database/queries/chatmodelconfigs.sql b/coderd/database/queries/chatmodelconfigs.sql index ae95083991370..5b190c401c8a2 100644 --- a/coderd/database/queries/chatmodelconfigs.sql +++ b/coderd/database/queries/chatmodelconfigs.sql @@ -123,7 +123,7 @@ WHERE is_default = TRUE AND deleted = FALSE; --- name: DeleteChatModelConfigByID :exec +-- name: DeleteChatModelConfigByID :one UPDATE chat_model_configs SET @@ -131,7 +131,9 @@ SET deleted_at = NOW(), updated_at = NOW() WHERE - id = @id::uuid; + id = @id::uuid + AND deleted = FALSE +RETURNING *; -- name: DeleteChatModelConfigsByAIProviderID :exec UPDATE diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 6a8629d86b593..fb4b0435b1dd0 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -4518,6 +4518,20 @@ func (api *API) defaultCreateChatModelConfigID( return defaultModelConfig.ID, 0, nil } +// validateChatCompressionThreshold enforces the chat_model_configs CHECK +// constraint range. +func validateChatCompressionThreshold(threshold int32) error { + if threshold < minChatContextCompressionThreshold || + threshold > maxChatContextCompressionThreshold { + return xerrors.Errorf( + "context_compression_threshold must be between %d and %d", + minChatContextCompressionThreshold, + maxChatContextCompressionThreshold, + ) + } + return nil +} + func normalizeChatCompressionThreshold( requested *int32, fallback int32, @@ -4527,13 +4541,8 @@ func normalizeChatCompressionThreshold( threshold = *requested } - if threshold < minChatContextCompressionThreshold || - threshold > maxChatContextCompressionThreshold { - return 0, xerrors.Errorf( - "context_compression_threshold must be between %d and %d", - minChatContextCompressionThreshold, - maxChatContextCompressionThreshold, - ) + if err := validateChatCompressionThreshold(threshold); err != nil { + return 0, err } return threshold, nil @@ -6895,6 +6904,10 @@ func validateChatModelConfigProviderModel(aiProvider database.AIProvider, model // through this helper so concurrent writers cannot act on stale reads, e.g. // two creates on an empty deployment both self-promoting to default and // violating the idx_chat_model_configs_single_default unique index. +// +// The transaction must run at ReadCommitted. A snapshot isolation level takes +// the snapshot at the lock statement, so a re-read inside fn would observe +// pre-lock state and reintroduce the lost update this helper prevents. func (api *API) inChatModelConfigWriteTx(ctx context.Context, fn func(tx database.Store) error) error { return api.Database.InTx(func(tx database.Store) error { if err := tx.AcquireLock(ctx, database.LockIDChatModelConfigWrites); err != nil { @@ -7011,12 +7024,12 @@ func (api *API) createChatModelConfig(rw http.ResponseWriter, r *http.Request) { lockedAIProvider, err := tx.GetAIProviderByIDForReferenceLock(dbauthz.AsChatd(ctx), insertParams.AIProviderID.UUID) if err != nil { if xerrors.Is(err, sql.ErrNoRows) { - return errChatProviderNotConfigured + return errChatProviderMissing } return xerrors.Errorf("get AI provider for update: %w", err) } if !lockedAIProvider.Enabled { - return errChatProviderNotConfigured + return errChatProviderDisabled } if err := validateChatModelConfigProviderModel(lockedAIProvider, insertParams.Model); err != nil { return err @@ -7071,10 +7084,14 @@ func (api *API) createChatModelConfig(rw http.ResponseWriter, r *http.Request) { Detail: err.Error(), }) return - case xerrors.Is(err, errChatProviderNotConfigured): + case xerrors.Is(err, errChatProviderMissing): httpapi.Write(ctx, rw, http.StatusPreconditionFailed, codersdk.Response{ - Message: "Chat provider is not configured.", - Detail: err.Error(), + Message: "AI provider is not configured.", + }) + return + case xerrors.Is(err, errChatProviderDisabled): + httpapi.Write(ctx, rw, http.StatusPreconditionFailed, codersdk.Response{ + Message: "AI provider is disabled.", }) return default: @@ -7128,21 +7145,18 @@ func (api *API) updateChatModelConfig(rw http.ResponseWriter, r *http.Request) { return } - // Only a request-supplied threshold is validated; the merge with the - // stored value happens later under the write lock. A PATCH that omits - // the field keeps the stored value, which the chat_model_configs CHECK - // constraint already bounds to 0..100. + // A PATCH that omits the field keeps the stored value, which the + // chat_model_configs CHECK constraint already bounds to 0..100. var requestedCompressionThreshold *int32 if req.CompressionThreshold != nil { - compressionThreshold, thresholdErr := normalizeChatCompressionThreshold(req.CompressionThreshold, 0) - if thresholdErr != nil { + if thresholdErr := validateChatCompressionThreshold(*req.CompressionThreshold); thresholdErr != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Invalid compression threshold.", Detail: thresholdErr.Error(), }) return } - requestedCompressionThreshold = &compressionThreshold + requestedCompressionThreshold = req.CompressionThreshold } var requestedModelConfig json.RawMessage @@ -7160,9 +7174,9 @@ func (api *API) updateChatModelConfig(rw http.ResponseWriter, r *http.Request) { var updated database.ChatModelConfig err := api.inChatModelConfigWriteTx(ctx, func(tx database.Store) error { - // Re-read the row under the write lock. The unlocked read above only - // rejects unknown IDs; a concurrent writer can change or delete the row - // between the two reads, so the update must merge against this copy. + // The unlocked read above only rejects unknown IDs; a concurrent writer + // can change or delete the row between the two reads, so merge against + // this copy. lockedExisting, err := tx.GetChatModelConfigByID(ctx, modelConfigID) if err != nil { if xerrors.Is(err, sql.ErrNoRows) { @@ -7217,24 +7231,20 @@ func (api *API) updateChatModelConfig(rw http.ResponseWriter, r *http.Request) { ID: lockedExisting.ID, } + // An update that touches neither the provider nor the model cannot + // invalidate the stored provider/model pair. revalidateProviderModel := updateParams.AIProviderID.Valid && (req.AIProviderID != nil || strings.TrimSpace(req.Model) != "") if revalidateProviderModel { //nolint:gocritic // The route already authorized chat model config updates. aiProvider, err := tx.GetAIProviderByIDForReferenceLock(dbauthz.AsChatd(ctx), updateParams.AIProviderID.UUID) if err != nil { if xerrors.Is(err, sql.ErrNoRows) { - if req.AIProviderID != nil { - return errAIProviderNotConfigured - } - return errChatProviderNotConfigured + return errChatProviderMissing } return xerrors.Errorf("get AI provider for update: %w", err) } if !aiProvider.Enabled { - if req.AIProviderID != nil { - return errAIProviderDisabled - } - return errChatProviderNotConfigured + return errChatProviderDisabled } if err := validateChatModelConfigProviderModel(aiProvider, updateParams.Model); err != nil { return err @@ -7292,22 +7302,14 @@ func (api *API) updateChatModelConfig(rw http.ResponseWriter, r *http.Request) { Detail: err.Error(), }) return - case xerrors.Is(err, errAIProviderNotConfigured): + case xerrors.Is(err, errChatProviderMissing): httpapi.Write(ctx, rw, http.StatusPreconditionFailed, codersdk.Response{ Message: "AI provider is not configured.", - Detail: err.Error(), }) return - case xerrors.Is(err, errAIProviderDisabled): + case xerrors.Is(err, errChatProviderDisabled): httpapi.Write(ctx, rw, http.StatusPreconditionFailed, codersdk.Response{ Message: "AI provider is disabled.", - Detail: err.Error(), - }) - return - case xerrors.Is(err, errChatProviderNotConfigured): - httpapi.Write(ctx, rw, http.StatusPreconditionFailed, codersdk.Response{ - Message: "Chat provider is not configured.", - Detail: err.Error(), }) return case xerrors.Is(err, errChatModelConfigNotFound): @@ -7358,7 +7360,10 @@ func (api *API) deleteChatModelConfig(rw http.ResponseWriter, r *http.Request) { } return xerrors.Errorf("get chat model config for delete: %w", err) } - if err := tx.DeleteChatModelConfigByID(ctx, modelConfigID); err != nil { + if _, err := tx.DeleteChatModelConfigByID(ctx, modelConfigID); err != nil { + if xerrors.Is(err, sql.ErrNoRows) { + return errChatModelConfigNotFound + } return err } return ensureDefaultChatModelConfig(ctx, tx) @@ -7655,10 +7660,9 @@ func validateChatProviderAPIKeySize(apiKey string) error { } var ( - errAIProviderDisabled = xerrors.New("AI provider is disabled") - errAIProviderNotConfigured = xerrors.New("AI provider is not configured") - errChatModelConfigNotFound = xerrors.New("chat model config not found") - errChatProviderNotConfigured = xerrors.New("chat provider is not configured") + errChatProviderDisabled = xerrors.New("AI provider is disabled") + errChatProviderMissing = xerrors.New("AI provider is not configured") + errChatModelConfigNotFound = xerrors.New("chat model config not found") ) // ChatProviderAPIKeysFromDeploymentValues returns deployment-backed chat diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 737c50e90ccbf..b57f6342eb080 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -443,81 +443,92 @@ func (s *lockSwitchChatPlanModeInstructionsStore) GetChatPlanModeInstructions(ct return instructions, err } -// failNextUpdateChatModelConfigStore shares its failure state across InTx -// wrappers so tests can force a specific in-transaction model-config update to -// return sql.ErrNoRows. -type failNextUpdateChatModelConfigStore struct { +// chatModelConfigHookStore forces a specific in-transaction chat model config +// operation to behave abnormally. Each hook targets one ID and consumes +// itself on first match. The single-consume gate uses atomic.Pointer so a +// concurrent InTx wrapper cannot race with the arming goroutine. +type chatModelConfigHookStore struct { database.Store - failNextUpdateChatModelConfig *atomic.Bool - failNextUpdateChatModelConfigID uuid.UUID + inTx bool + + failNextUpdate *atomic.Pointer[uuid.UUID] + vanishAtLockedRead *atomic.Pointer[uuid.UUID] + mutateAtLockedRead *atomic.Pointer[uuid.UUID] + mutateAtLockedModel string } -func newFailNextUpdateChatModelConfigStore(store database.Store) *failNextUpdateChatModelConfigStore { - return &failNextUpdateChatModelConfigStore{ - Store: store, - failNextUpdateChatModelConfig: &atomic.Bool{}, +func newChatModelConfigHookStore(store database.Store) *chatModelConfigHookStore { + return &chatModelConfigHookStore{ + Store: store, + failNextUpdate: &atomic.Pointer[uuid.UUID]{}, + vanishAtLockedRead: &atomic.Pointer[uuid.UUID]{}, + mutateAtLockedRead: &atomic.Pointer[uuid.UUID]{}, } } -func (s *failNextUpdateChatModelConfigStore) InTx(function func(database.Store) error, txOpts *database.TxOptions) error { +func (s *chatModelConfigHookStore) armFailNextUpdate(id uuid.UUID) { + s.failNextUpdate.Store(&id) +} + +func (s *chatModelConfigHookStore) armVanishAtLockedRead(id uuid.UUID) { + s.vanishAtLockedRead.Store(&id) +} + +func (s *chatModelConfigHookStore) armMutateAtLockedRead(id uuid.UUID, model string) { + s.mutateAtLockedModel = model + s.mutateAtLockedRead.Store(&id) +} + +func (s *chatModelConfigHookStore) InTx(function func(database.Store) error, txOpts *database.TxOptions) error { return s.Store.InTx(func(tx database.Store) error { - return function(&failNextUpdateChatModelConfigStore{ - Store: tx, - failNextUpdateChatModelConfig: s.failNextUpdateChatModelConfig, - failNextUpdateChatModelConfigID: s.failNextUpdateChatModelConfigID, + return function(&chatModelConfigHookStore{ + Store: tx, + inTx: true, + failNextUpdate: s.failNextUpdate, + vanishAtLockedRead: s.vanishAtLockedRead, + mutateAtLockedRead: s.mutateAtLockedRead, + mutateAtLockedModel: s.mutateAtLockedModel, }) }, txOpts) } -func (s *failNextUpdateChatModelConfigStore) UpdateChatModelConfig( +func consumeChatModelConfigHook(hook *atomic.Pointer[uuid.UUID], id uuid.UUID) bool { + target := hook.Load() + if target == nil || *target != id { + return false + } + return hook.CompareAndSwap(target, nil) +} + +func (s *chatModelConfigHookStore) UpdateChatModelConfig( ctx context.Context, arg database.UpdateChatModelConfigParams, ) (database.ChatModelConfig, error) { - if arg.ID == s.failNextUpdateChatModelConfigID && - s.failNextUpdateChatModelConfig.CompareAndSwap(true, false) { + if consumeChatModelConfigHook(s.failNextUpdate, arg.ID) { return database.ChatModelConfig{}, sql.ErrNoRows } return s.Store.UpdateChatModelConfig(ctx, arg) } -// vanishInTxChatModelConfigStore makes a model-config read return -// sql.ErrNoRows only inside a transaction, so a handler's pre-transaction -// read succeeds while its locked re-read observes the row as already gone. -type vanishInTxChatModelConfigStore struct { - database.Store - - inTx bool - vanishInTxChatModelConfig *atomic.Bool - vanishInTxChatModelConfigID uuid.UUID -} - -func newVanishInTxChatModelConfigStore(store database.Store) *vanishInTxChatModelConfigStore { - return &vanishInTxChatModelConfigStore{ - Store: store, - vanishInTxChatModelConfig: &atomic.Bool{}, - } -} - -func (s *vanishInTxChatModelConfigStore) InTx(function func(database.Store) error, txOpts *database.TxOptions) error { - return s.Store.InTx(func(tx database.Store) error { - return function(&vanishInTxChatModelConfigStore{ - Store: tx, - inTx: true, - vanishInTxChatModelConfig: s.vanishInTxChatModelConfig, - vanishInTxChatModelConfigID: s.vanishInTxChatModelConfigID, - }) - }, txOpts) -} - -func (s *vanishInTxChatModelConfigStore) GetChatModelConfigByID( +func (s *chatModelConfigHookStore) GetChatModelConfigByID( ctx context.Context, id uuid.UUID, ) (database.ChatModelConfig, error) { - if s.inTx && id == s.vanishInTxChatModelConfigID && - s.vanishInTxChatModelConfig.CompareAndSwap(true, false) { + if s.inTx && consumeChatModelConfigHook(s.vanishAtLockedRead, id) { return database.ChatModelConfig{}, sql.ErrNoRows } + if s.inTx { + target := s.mutateAtLockedRead.Load() + if target != nil && *target == id && s.mutateAtLockedRead.CompareAndSwap(target, nil) { + row, err := s.Store.GetChatModelConfigByID(ctx, id) + if err != nil { + return row, err + } + row.Model = s.mutateAtLockedModel + return row, nil + } + } return s.Store.GetChatModelConfigByID(ctx, id) } @@ -5168,7 +5179,7 @@ func TestUpdateChatModelConfig(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) rawDB, pubsub := dbtestutil.NewDB(t) - store := newFailNextUpdateChatModelConfigStore(rawDB) + store := newChatModelConfigHookStore(rawDB) rawClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ Database: store, Pubsub: pubsub, @@ -5179,8 +5190,7 @@ func TestUpdateChatModelConfig(t *testing.T) { _ = coderdtest.CreateFirstUser(t, client.Client) modelConfig := createChatModelConfig(t, client) - store.failNextUpdateChatModelConfigID = modelConfig.ID - store.failNextUpdateChatModelConfig.Store(true) + store.armFailNextUpdate(modelConfig.ID) _, err := client.UpdateChatModelConfig(ctx, modelConfig.ID, codersdk.UpdateChatModelConfigRequest{ DisplayName: "missing in tx", @@ -5193,7 +5203,7 @@ func TestUpdateChatModelConfig(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) rawDB, pubsub := dbtestutil.NewDB(t) - store := newFailNextUpdateChatModelConfigStore(rawDB) + store := newChatModelConfigHookStore(rawDB) rawClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ Database: store, Pubsub: pubsub, @@ -5216,8 +5226,7 @@ func TestUpdateChatModelConfig(t *testing.T) { }) require.NoError(t, err) - store.failNextUpdateChatModelConfigID = candidateConfig.ID - store.failNextUpdateChatModelConfig.Store(true) + store.armFailNextUpdate(candidateConfig.ID) _, err = client.UpdateChatModelConfig(ctx, defaultConfig.ID, codersdk.UpdateChatModelConfigRequest{ IsDefault: ptr.Ref(false), @@ -5255,15 +5264,14 @@ func TestUpdateChatModelConfig(t *testing.T) { require.Equal(t, "Context limit must be greater than zero.", sdkErr.Message) }) - // The handler re-reads the target inside the write transaction. A row - // that disappears between the pre-read and the locked read is a miss, - // not a silent no-op update. - t.Run("NotFoundWhenTargetRowDisappearsBeforeUpdate", func(t *testing.T) { + // A row that disappears between the pre-read and the locked read must + // 404, not silently no-op the update. + t.Run("NotFoundWhenTargetRowDisappearsAtLockedRead", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) rawDB, pubsub := dbtestutil.NewDB(t) - store := newVanishInTxChatModelConfigStore(rawDB) + store := newChatModelConfigHookStore(rawDB) rawClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ Database: store, Pubsub: pubsub, @@ -5274,8 +5282,7 @@ func TestUpdateChatModelConfig(t *testing.T) { _ = coderdtest.CreateFirstUser(t, client.Client) modelConfig := createChatModelConfig(t, client) - store.vanishInTxChatModelConfigID = modelConfig.ID - store.vanishInTxChatModelConfig.Store(true) + store.armVanishAtLockedRead(modelConfig.ID) _, err := client.UpdateChatModelConfig(ctx, modelConfig.ID, codersdk.UpdateChatModelConfigRequest{ DisplayName: "vanished before update", @@ -5283,6 +5290,111 @@ func TestUpdateChatModelConfig(t *testing.T) { requireSDKError(t, err, http.StatusNotFound) }) + // Swapping Model at the locked read to a sentinel forces the response + // to carry the sentinel when the merge reads the locked row. + t.Run("MergesFromLockedCopy", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + rawDB, pubsub := dbtestutil.NewDB(t) + store := newChatModelConfigHookStore(rawDB) + rawClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ + Database: store, + Pubsub: pubsub, + DeploymentValues: coderdtest.DeploymentValues(t), + }) + aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil) + client := codersdk.NewExperimentalClient(rawClient) + _ = coderdtest.CreateFirstUser(t, client.Client) + modelConfig := createChatModelConfig(t, client) + + const sentinel = "locked-copy-sentinel-model" + store.armMutateAtLockedRead(modelConfig.ID, sentinel) + + updated, err := client.UpdateChatModelConfig(ctx, modelConfig.ID, codersdk.UpdateChatModelConfigRequest{ + DisplayName: "only-display-name", + }) + require.NoError(t, err) + require.Equal(t, sentinel, updated.Model) + require.Equal(t, "only-display-name", updated.DisplayName) + }) + + // The generic "provider is not configured" fallback for the stored + // provider hides the real cause; report the disabled state instead. + t.Run("StoredProviderDisabledOnModelOnlyUpdate", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client.Client) + modelConfig := createChatModelConfig(t, client) + + disabled := false + _, err := client.UpdateAIProvider(ctx, modelConfig.AIProviderID.String(), codersdk.UpdateAIProviderRequest{ + Enabled: &disabled, + }) + require.NoError(t, err) + + _, err = client.UpdateChatModelConfig(ctx, modelConfig.ID, codersdk.UpdateChatModelConfigRequest{ + Model: "gpt-4o-different", + }) + sdkErr := requireSDKError(t, err, http.StatusPreconditionFailed) + require.Equal(t, "AI provider is disabled.", sdkErr.Message) + }) + + t.Run("CompressionThresholdOutOfRange", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client.Client) + modelConfig := createChatModelConfig(t, client) + + _, err := client.UpdateChatModelConfig(ctx, modelConfig.ID, codersdk.UpdateChatModelConfigRequest{ + CompressionThreshold: ptr.Ref(int32(150)), + }) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Equal(t, "Invalid compression threshold.", sdkErr.Message) + }) + + t.Run("CompressionThresholdInRange", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client.Client) + modelConfig := createChatModelConfig(t, client) + + updated, err := client.UpdateChatModelConfig(ctx, modelConfig.ID, codersdk.UpdateChatModelConfigRequest{ + CompressionThreshold: ptr.Ref(int32(55)), + }) + require.NoError(t, err) + require.Equal(t, int32(55), updated.CompressionThreshold) + }) + + t.Run("ModelConfigUpdated", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client.Client) + modelConfig := createChatModelConfig(t, client) + + updated, err := client.UpdateChatModelConfig(ctx, modelConfig.ID, codersdk.UpdateChatModelConfigRequest{ + ModelConfig: &codersdk.ChatModelCallConfig{ + ReasoningEffort: &codersdk.ChatModelReasoningEffortConfig{ + Default: ptr.Ref("high"), + Max: ptr.Ref("high"), + }, + }, + }) + require.NoError(t, err) + require.NotNil(t, updated.ModelConfig) + require.NotNil(t, updated.ModelConfig.ReasoningEffort) + require.NotNil(t, updated.ModelConfig.ReasoningEffort.Default) + require.Equal(t, "high", *updated.ModelConfig.ReasoningEffort.Default) + }) + t.Run("InvalidModelConfigID", func(t *testing.T) { t.Parallel() @@ -5393,16 +5505,14 @@ func TestDeleteChatModelConfig(t *testing.T) { requireSDKError(t, err, http.StatusNotFound) }) - // Deleting a row that disappears between the pre-read and the locked - // re-read reports a miss. DeleteChatModelConfigByID is an unguarded - // UPDATE, so without the locked read the request would report success - // for a row it did not delete. - t.Run("NotFoundWhenTargetRowDisappearsInTx", func(t *testing.T) { + // The DELETE query guards on `deleted = FALSE` and returns + // sql.ErrNoRows for a miss; the locked re-read maps that to 404. + t.Run("NotFoundWhenTargetRowDisappearsAtLockedRead", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) rawDB, pubsub := dbtestutil.NewDB(t) - store := newVanishInTxChatModelConfigStore(rawDB) + store := newChatModelConfigHookStore(rawDB) rawClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ Database: store, Pubsub: pubsub, @@ -5413,8 +5523,7 @@ func TestDeleteChatModelConfig(t *testing.T) { _ = coderdtest.CreateFirstUser(t, client.Client) modelConfig := createChatModelConfig(t, client) - store.vanishInTxChatModelConfigID = modelConfig.ID - store.vanishInTxChatModelConfig.Store(true) + store.armVanishAtLockedRead(modelConfig.ID) err := client.DeleteChatModelConfig(ctx, modelConfig.ID) requireSDKError(t, err, http.StatusNotFound) @@ -12699,10 +12808,11 @@ func seedChatWithDeletedModelConfig( Title: "chat without model config", }) seedManualTitleSourceMessage(t, db, chat, modelConfig.ID) - require.NoError(t, db.DeleteChatModelConfigByID( + _, err := db.DeleteChatModelConfigByID( dbauthz.AsSystemRestricted(ctx), modelConfig.ID, - )) + ) + require.NoError(t, err) return chat } diff --git a/coderd/telemetry/telemetry_test.go b/coderd/telemetry/telemetry_test.go index 875882388f458..a03fe05abdb69 100644 --- a/coderd/telemetry/telemetry_test.go +++ b/coderd/telemetry/telemetry_test.go @@ -1662,7 +1662,7 @@ func TestChatsTelemetry(t *testing.T) { DisplayName: "Deleted Model", ContextLimit: 100000, }) - err := db.DeleteChatModelConfigByID(ctx, deletedCfg.ID) + _, err := db.DeleteChatModelConfigByID(ctx, deletedCfg.ID) require.NoError(t, err) // Create a root chat with a workspace. From 08316a50e579d4b54a8095084b0533ee62c42e60 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Mon, 17 Aug 2026 12:30:26 +0000 Subject: [PATCH 3/4] fix(coderd): simplify chat model config write transactions --- coderd/database/dbauthz/dbauthz.go | 7 -- coderd/database/dbauthz/dbauthz_test.go | 11 +-- coderd/database/dbmetrics/querymetrics.go | 8 --- coderd/database/dbmock/dbmock.go | 14 ---- coderd/database/querier.go | 1 - coderd/database/queries.sql.go | 17 ----- coderd/database/queries/chatmodelconfigs.sql | 11 --- coderd/exp_chats.go | 4 +- coderd/exp_chats_test.go | 75 ++++++++++++++++---- 9 files changed, 66 insertions(+), 82 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 8d82c13944a5c..4ba848648e739 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2224,13 +2224,6 @@ func (q *querier) DeleteChatModelConfigByID(ctx context.Context, id uuid.UUID) ( 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 { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 73b2c9654d2cf..8fe16089b240d 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -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) + config := database.ChatModelConfig{ID: uuid.New()} + dbm.EXPECT().DeleteChatModelConfigByID(gomock.Any(), config.ID).Return(config, nil).AnyTimes() + check.Args(config.ID).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate).Returns(config) })) s.Run("DeleteChatQueuedMessage", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { chat := testutil.Fake(s.T(), faker, database.Chat{}) diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 7d844f8c3d527..ff1bf3f8c651e 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -528,14 +528,6 @@ func (m queryMetricsStore) DeleteChatModelConfigByID(ctx context.Context, id uui return r0, r1 } -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 -} - func (m queryMetricsStore) DeleteChatQueuedMessage(ctx context.Context, arg database.DeleteChatQueuedMessageParams) error { start := time.Now() r0 := m.s.DeleteChatQueuedMessage(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 8d197b567ce38..322c35ab5f1de 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -863,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() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 143dd0dedeef9..e1e5df21c458c 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -147,7 +147,6 @@ type sqlcQuerier interface { // archive-cleanup retry). DeleteChatDebugDataByChatID(ctx context.Context, arg DeleteChatDebugDataByChatIDParams) (int64, error) DeleteChatModelConfigByID(ctx context.Context, id uuid.UUID) (ChatModelConfig, error) - DeleteChatModelConfigsByAIProviderID(ctx context.Context, aiProviderID 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 diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 2ae731905437f..9f938dd7c7336 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -6171,23 +6171,6 @@ func (q *sqlQuerier) DeleteChatModelConfigByID(ctx context.Context, id uuid.UUID return i, 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 -` - -func (q *sqlQuerier) DeleteChatModelConfigsByAIProviderID(ctx context.Context, aiProviderID uuid.UUID) error { - _, err := q.db.ExecContext(ctx, deleteChatModelConfigsByAIProviderID, aiProviderID) - return err -} - const getChatModelConfigByID = `-- name: GetChatModelConfigByID :one SELECT id, model, display_name, created_by, updated_by, enabled, is_default, deleted, deleted_at, created_at, updated_at, context_limit, compression_threshold, options, ai_provider_id diff --git a/coderd/database/queries/chatmodelconfigs.sql b/coderd/database/queries/chatmodelconfigs.sql index 5b190c401c8a2..8834c4497aad8 100644 --- a/coderd/database/queries/chatmodelconfigs.sql +++ b/coderd/database/queries/chatmodelconfigs.sql @@ -134,14 +134,3 @@ WHERE id = @id::uuid AND deleted = FALSE RETURNING *; - --- 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; diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index fb4b0435b1dd0..3ecf841b94799 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -6914,7 +6914,7 @@ func (api *API) inChatModelConfigWriteTx(ctx context.Context, fn func(tx databas return xerrors.Errorf("acquire chat model config write lock: %w", err) } return fn(tx) - }, nil) + }, &database.TxOptions{Isolation: sql.LevelReadCommitted}) } func (api *API) createChatModelConfig(rw http.ResponseWriter, r *http.Request) { @@ -7026,7 +7026,7 @@ func (api *API) createChatModelConfig(rw http.ResponseWriter, r *http.Request) { if xerrors.Is(err, sql.ErrNoRows) { return errChatProviderMissing } - return xerrors.Errorf("get AI provider for update: %w", err) + return xerrors.Errorf("get AI provider for create: %w", err) } if !lockedAIProvider.Enabled { return errChatProviderDisabled diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index b57f6342eb080..4cc65c92b6423 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -452,18 +452,20 @@ type chatModelConfigHookStore struct { inTx bool - failNextUpdate *atomic.Pointer[uuid.UUID] - vanishAtLockedRead *atomic.Pointer[uuid.UUID] - mutateAtLockedRead *atomic.Pointer[uuid.UUID] - mutateAtLockedModel string + failNextUpdate *atomic.Pointer[uuid.UUID] + failProviderReferenceLock *atomic.Pointer[uuid.UUID] + vanishAtLockedRead *atomic.Pointer[uuid.UUID] + mutateAtLockedRead *atomic.Pointer[uuid.UUID] + mutateAtLockedModel string } func newChatModelConfigHookStore(store database.Store) *chatModelConfigHookStore { return &chatModelConfigHookStore{ - Store: store, - failNextUpdate: &atomic.Pointer[uuid.UUID]{}, - vanishAtLockedRead: &atomic.Pointer[uuid.UUID]{}, - mutateAtLockedRead: &atomic.Pointer[uuid.UUID]{}, + Store: store, + failNextUpdate: &atomic.Pointer[uuid.UUID]{}, + failProviderReferenceLock: &atomic.Pointer[uuid.UUID]{}, + vanishAtLockedRead: &atomic.Pointer[uuid.UUID]{}, + mutateAtLockedRead: &atomic.Pointer[uuid.UUID]{}, } } @@ -471,6 +473,10 @@ func (s *chatModelConfigHookStore) armFailNextUpdate(id uuid.UUID) { s.failNextUpdate.Store(&id) } +func (s *chatModelConfigHookStore) armFailProviderReferenceLock(id uuid.UUID) { + s.failProviderReferenceLock.Store(&id) +} + func (s *chatModelConfigHookStore) armVanishAtLockedRead(id uuid.UUID) { s.vanishAtLockedRead.Store(&id) } @@ -483,12 +489,13 @@ func (s *chatModelConfigHookStore) armMutateAtLockedRead(id uuid.UUID, model str func (s *chatModelConfigHookStore) InTx(function func(database.Store) error, txOpts *database.TxOptions) error { return s.Store.InTx(func(tx database.Store) error { return function(&chatModelConfigHookStore{ - Store: tx, - inTx: true, - failNextUpdate: s.failNextUpdate, - vanishAtLockedRead: s.vanishAtLockedRead, - mutateAtLockedRead: s.mutateAtLockedRead, - mutateAtLockedModel: s.mutateAtLockedModel, + Store: tx, + inTx: true, + failNextUpdate: s.failNextUpdate, + failProviderReferenceLock: s.failProviderReferenceLock, + vanishAtLockedRead: s.vanishAtLockedRead, + mutateAtLockedRead: s.mutateAtLockedRead, + mutateAtLockedModel: s.mutateAtLockedModel, }) }, txOpts) } @@ -501,6 +508,16 @@ func consumeChatModelConfigHook(hook *atomic.Pointer[uuid.UUID], id uuid.UUID) b return hook.CompareAndSwap(target, nil) } +func (s *chatModelConfigHookStore) GetAIProviderByIDForReferenceLock( + ctx context.Context, + id uuid.UUID, +) (database.AIProvider, error) { + if consumeChatModelConfigHook(s.failProviderReferenceLock, id) { + return database.AIProvider{}, stderrors.New("forced provider reference lock failure") + } + return s.Store.GetAIProviderByIDForReferenceLock(ctx, id) +} + func (s *chatModelConfigHookStore) UpdateChatModelConfig( ctx context.Context, arg database.UpdateChatModelConfigParams, @@ -4495,6 +4512,36 @@ func TestCreateChatModelConfig(t *testing.T) { require.Len(t, configs, 1) }) + t.Run("ProviderReferenceLockFailureUsesCreateErrorText", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + rawDB, pubsub := dbtestutil.NewDB(t) + store := newChatModelConfigHookStore(rawDB) + rawClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ + Database: store, + Pubsub: pubsub, + DeploymentValues: coderdtest.DeploymentValues(t), + }) + aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil) + client := codersdk.NewExperimentalClient(rawClient) + _ = coderdtest.CreateFirstUser(t, client.Client) + aiProvider := createAIProviderForTest(t, client, "openai", "test-api-key") + + store.armFailProviderReferenceLock(aiProvider.ID) + + contextLimit := int64(4096) + _, err := client.CreateChatModelConfig(ctx, codersdk.CreateChatModelConfigRequest{ + AIProviderID: &aiProvider.ID, + Model: "gpt-4o-mini", + ContextLimit: &contextLimit, + }) + sdkErr := requireSDKError(t, err, http.StatusInternalServerError) + require.Equal(t, "Failed to create chat model config.", sdkErr.Message) + require.Contains(t, sdkErr.Detail, "get AI provider for create") + require.NotContains(t, sdkErr.Detail, "get AI provider for update") + }) + t.Run("ConcurrentCreatesElectSingleDefault", func(t *testing.T) { t.Parallel() From c89ec669c2bb69937226b6718e5c3af7d1ba9ca7 Mon Sep 17 00:00:00 2001 From: Ethan Dickson Date: Tue, 18 Aug 2026 12:54:54 +0000 Subject: [PATCH 4/4] fix(coderd): address chat model transaction feedback Simplify delete locking, narrow the generated delete result, and keep provider validation responses consistent. --- coderd/database/dbauthz/dbauthz.go | 4 +- coderd/database/dbauthz/dbauthz_test.go | 6 +- coderd/database/dbmetrics/querymetrics.go | 2 +- coderd/database/dbmock/dbmock.go | 4 +- coderd/database/querier.go | 2 +- coderd/database/querier_test.go | 15 ++ coderd/database/queries.sql.go | 26 +--- coderd/database/queries/chatmodelconfigs.sql | 2 +- coderd/exp_chats.go | 56 ++++---- coderd/exp_chats_test.go | 137 +++++++++---------- 10 files changed, 119 insertions(+), 135 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 4ba848648e739..616a2fedc31e3 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2217,9 +2217,9 @@ 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) (database.ChatModelConfig, 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 database.ChatModelConfig{}, err + return uuid.Nil, err } return q.db.DeleteChatModelConfigByID(ctx, id) } diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 8fe16089b240d..a2c8d0733fd1f 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -704,9 +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) { - config := database.ChatModelConfig{ID: uuid.New()} - dbm.EXPECT().DeleteChatModelConfigByID(gomock.Any(), config.ID).Return(config, nil).AnyTimes() - check.Args(config.ID).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate).Returns(config) + 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{}) diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index ff1bf3f8c651e..765d747eee2d1 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -520,7 +520,7 @@ func (m queryMetricsStore) DeleteChatDebugDataByChatID(ctx context.Context, chat return r0, r1 } -func (m queryMetricsStore) DeleteChatModelConfigByID(ctx context.Context, id uuid.UUID) (database.ChatModelConfig, error) { +func (m queryMetricsStore) DeleteChatModelConfigByID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) { start := time.Now() r0, r1 := m.s.DeleteChatModelConfigByID(ctx, id) m.queryLatencies.WithLabelValues("DeleteChatModelConfigByID").Observe(time.Since(start).Seconds()) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 322c35ab5f1de..da8dafdd559c9 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -849,10 +849,10 @@ func (mr *MockStoreMockRecorder) DeleteChatDebugDataByChatID(ctx, arg any) *gomo } // DeleteChatModelConfigByID mocks base method. -func (m *MockStore) DeleteChatModelConfigByID(ctx context.Context, id uuid.UUID) (database.ChatModelConfig, 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].(database.ChatModelConfig) + ret0, _ := ret[0].(uuid.UUID) ret1, _ := ret[1].(error) return ret0, ret1 } diff --git a/coderd/database/querier.go b/coderd/database/querier.go index e1e5df21c458c..aac29a4c55d3f 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -146,7 +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) (ChatModelConfig, 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 diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index c09f38e731308..dd2299dcc4de8 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -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() diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 9f938dd7c7336..17e7731bf8f23 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -6145,30 +6145,14 @@ SET WHERE id = $1::uuid AND deleted = FALSE -RETURNING id, model, display_name, created_by, updated_by, enabled, is_default, deleted, deleted_at, created_at, updated_at, context_limit, compression_threshold, options, ai_provider_id +RETURNING id ` -func (q *sqlQuerier) DeleteChatModelConfigByID(ctx context.Context, id uuid.UUID) (ChatModelConfig, error) { +func (q *sqlQuerier) DeleteChatModelConfigByID(ctx context.Context, id uuid.UUID) (uuid.UUID, error) { row := q.db.QueryRowContext(ctx, deleteChatModelConfigByID, id) - var i ChatModelConfig - err := row.Scan( - &i.ID, - &i.Model, - &i.DisplayName, - &i.CreatedBy, - &i.UpdatedBy, - &i.Enabled, - &i.IsDefault, - &i.Deleted, - &i.DeletedAt, - &i.CreatedAt, - &i.UpdatedAt, - &i.ContextLimit, - &i.CompressionThreshold, - &i.Options, - &i.AIProviderID, - ) - return i, err + var id_2 uuid.UUID + err := row.Scan(&id_2) + return id_2, err } const getChatModelConfigByID = `-- name: GetChatModelConfigByID :one diff --git a/coderd/database/queries/chatmodelconfigs.sql b/coderd/database/queries/chatmodelconfigs.sql index 8834c4497aad8..1e90da264fc6c 100644 --- a/coderd/database/queries/chatmodelconfigs.sql +++ b/coderd/database/queries/chatmodelconfigs.sql @@ -133,4 +133,4 @@ SET WHERE id = @id::uuid AND deleted = FALSE -RETURNING *; +RETURNING id; diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 3ecf841b94799..4e503abf54900 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -6698,7 +6698,7 @@ func (api *API) upsertUserAIProviderKey(rw http.ResponseWriter, r *http.Request) return } if !provider.Enabled { - httpapi.Write(ctx, rw, http.StatusPreconditionFailed, codersdk.Response{Message: "AI provider is disabled."}) + writeChatProviderPreconditionError(ctx, rw, errChatProviderDisabled) return } var req codersdk.CreateUserAIProviderKeyRequest @@ -6938,7 +6938,7 @@ func (api *API) createChatModelConfig(rw http.ResponseWriter, r *http.Request) { aiProvider, err := api.Database.GetAIProviderByID(dbauthz.AsChatd(ctx), *req.AIProviderID) if err != nil { if httpapi.Is404Error(err) { - httpapi.Write(ctx, rw, http.StatusPreconditionFailed, codersdk.Response{Message: "AI provider is not configured."}) + writeChatProviderPreconditionError(ctx, rw, errChatProviderMissing) return } httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -6948,7 +6948,7 @@ func (api *API) createChatModelConfig(rw http.ResponseWriter, r *http.Request) { return } if !aiProvider.Enabled { - httpapi.Write(ctx, rw, http.StatusPreconditionFailed, codersdk.Response{Message: "AI provider is disabled."}) + writeChatProviderPreconditionError(ctx, rw, errChatProviderDisabled) return } aiProviderID := uuid.NullUUID{UUID: aiProvider.ID, Valid: true} @@ -7073,6 +7073,9 @@ func (api *API) createChatModelConfig(rw http.ResponseWriter, r *http.Request) { return nil }) if err != nil { + if writeChatProviderPreconditionError(ctx, rw, err) { + return + } var providerModelErr *chatModelConfigProviderModelError switch { case errors.As(err, &providerModelErr): @@ -7084,16 +7087,6 @@ func (api *API) createChatModelConfig(rw http.ResponseWriter, r *http.Request) { Detail: err.Error(), }) return - case xerrors.Is(err, errChatProviderMissing): - httpapi.Write(ctx, rw, http.StatusPreconditionFailed, codersdk.Response{ - Message: "AI provider is not configured.", - }) - return - case xerrors.Is(err, errChatProviderDisabled): - httpapi.Write(ctx, rw, http.StatusPreconditionFailed, codersdk.Response{ - Message: "AI provider is disabled.", - }) - return default: httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Failed to create chat model config.", @@ -7281,16 +7274,15 @@ func (api *API) updateChatModelConfig(rw http.ResponseWriter, r *http.Request) { refreshedConfig, err := tx.GetChatModelConfigByID(ctx, lockedExisting.ID) if err != nil { - if xerrors.Is(err, sql.ErrNoRows) { - // Do not wrap with %w. The outer handler maps target misses to 404. - return xerrors.Errorf("refresh updated chat model config: %v", err) - } return xerrors.Errorf("refresh updated chat model config: %w", err) } updated = refreshedConfig return nil }) if err != nil { + if writeChatProviderPreconditionError(ctx, rw, err) { + return + } var providerModelErr *chatModelConfigProviderModelError switch { case errors.As(err, &providerModelErr): @@ -7302,16 +7294,6 @@ func (api *API) updateChatModelConfig(rw http.ResponseWriter, r *http.Request) { Detail: err.Error(), }) return - case xerrors.Is(err, errChatProviderMissing): - httpapi.Write(ctx, rw, http.StatusPreconditionFailed, codersdk.Response{ - Message: "AI provider is not configured.", - }) - return - case xerrors.Is(err, errChatProviderDisabled): - httpapi.Write(ctx, rw, http.StatusPreconditionFailed, codersdk.Response{ - Message: "AI provider is disabled.", - }) - return case xerrors.Is(err, errChatModelConfigNotFound): httpapi.ResourceNotFound(rw) return @@ -7354,12 +7336,6 @@ func (api *API) deleteChatModelConfig(rw http.ResponseWriter, r *http.Request) { } if err := api.inChatModelConfigWriteTx(ctx, func(tx database.Store) error { - if _, err := tx.GetChatModelConfigByID(ctx, modelConfigID); err != nil { - if xerrors.Is(err, sql.ErrNoRows) { - return errChatModelConfigNotFound - } - return xerrors.Errorf("get chat model config for delete: %w", err) - } if _, err := tx.DeleteChatModelConfigByID(ctx, modelConfigID); err != nil { if xerrors.Is(err, sql.ErrNoRows) { return errChatModelConfigNotFound @@ -7659,6 +7635,20 @@ func validateChatProviderAPIKeySize(apiKey string) error { return nil } +func writeChatProviderPreconditionError(ctx context.Context, rw http.ResponseWriter, err error) bool { + var message string + switch { + case xerrors.Is(err, errChatProviderMissing): + message = "AI provider is not configured." + case xerrors.Is(err, errChatProviderDisabled): + message = "AI provider is disabled." + default: + return false + } + httpapi.Write(ctx, rw, http.StatusPreconditionFailed, codersdk.Response{Message: message}) + return true +} + var ( errChatProviderDisabled = xerrors.New("AI provider is disabled") errChatProviderMissing = xerrors.New("AI provider is not configured") diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 4cc65c92b6423..ec50029fb4fdd 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -443,6 +443,11 @@ func (s *lockSwitchChatPlanModeInstructionsStore) GetChatPlanModeInstructions(ct return instructions, err } +type chatModelConfigLockedReadMutation struct { + id uuid.UUID + model string +} + // chatModelConfigHookStore forces a specific in-transaction chat model config // operation to behave abnormally. Each hook targets one ID and consumes // itself on first match. The single-consume gate uses atomic.Pointer so a @@ -453,26 +458,43 @@ type chatModelConfigHookStore struct { inTx bool failNextUpdate *atomic.Pointer[uuid.UUID] + failNextDelete *atomic.Pointer[uuid.UUID] failProviderReferenceLock *atomic.Pointer[uuid.UUID] vanishAtLockedRead *atomic.Pointer[uuid.UUID] - mutateAtLockedRead *atomic.Pointer[uuid.UUID] - mutateAtLockedModel string + mutateAtLockedRead *atomic.Pointer[chatModelConfigLockedReadMutation] } func newChatModelConfigHookStore(store database.Store) *chatModelConfigHookStore { return &chatModelConfigHookStore{ Store: store, failNextUpdate: &atomic.Pointer[uuid.UUID]{}, + failNextDelete: &atomic.Pointer[uuid.UUID]{}, failProviderReferenceLock: &atomic.Pointer[uuid.UUID]{}, vanishAtLockedRead: &atomic.Pointer[uuid.UUID]{}, - mutateAtLockedRead: &atomic.Pointer[uuid.UUID]{}, + mutateAtLockedRead: &atomic.Pointer[chatModelConfigLockedReadMutation]{}, } } +func newChatClientWithModelConfigHookStore(t testing.TB) (*codersdk.ExperimentalClient, *chatModelConfigHookStore) { + t.Helper() + + rawDB, pubsub := dbtestutil.NewDB(t) + store := newChatModelConfigHookStore(rawDB) + client := newChatClient(t, func(opts *coderdtest.Options) { + opts.Database = store + opts.Pubsub = pubsub + }) + return client, store +} + func (s *chatModelConfigHookStore) armFailNextUpdate(id uuid.UUID) { s.failNextUpdate.Store(&id) } +func (s *chatModelConfigHookStore) armFailNextDelete(id uuid.UUID) { + s.failNextDelete.Store(&id) +} + func (s *chatModelConfigHookStore) armFailProviderReferenceLock(id uuid.UUID) { s.failProviderReferenceLock.Store(&id) } @@ -482,8 +504,7 @@ func (s *chatModelConfigHookStore) armVanishAtLockedRead(id uuid.UUID) { } func (s *chatModelConfigHookStore) armMutateAtLockedRead(id uuid.UUID, model string) { - s.mutateAtLockedModel = model - s.mutateAtLockedRead.Store(&id) + s.mutateAtLockedRead.Store(&chatModelConfigLockedReadMutation{id: id, model: model}) } func (s *chatModelConfigHookStore) InTx(function func(database.Store) error, txOpts *database.TxOptions) error { @@ -492,10 +513,10 @@ func (s *chatModelConfigHookStore) InTx(function func(database.Store) error, txO Store: tx, inTx: true, failNextUpdate: s.failNextUpdate, + failNextDelete: s.failNextDelete, failProviderReferenceLock: s.failProviderReferenceLock, vanishAtLockedRead: s.vanishAtLockedRead, mutateAtLockedRead: s.mutateAtLockedRead, - mutateAtLockedModel: s.mutateAtLockedModel, }) }, txOpts) } @@ -528,6 +549,16 @@ func (s *chatModelConfigHookStore) UpdateChatModelConfig( return s.Store.UpdateChatModelConfig(ctx, arg) } +func (s *chatModelConfigHookStore) DeleteChatModelConfigByID( + ctx context.Context, + id uuid.UUID, +) (uuid.UUID, error) { + if consumeChatModelConfigHook(s.failNextDelete, id) { + return uuid.Nil, sql.ErrNoRows + } + return s.Store.DeleteChatModelConfigByID(ctx, id) +} + func (s *chatModelConfigHookStore) GetChatModelConfigByID( ctx context.Context, id uuid.UUID, @@ -536,13 +567,13 @@ func (s *chatModelConfigHookStore) GetChatModelConfigByID( return database.ChatModelConfig{}, sql.ErrNoRows } if s.inTx { - target := s.mutateAtLockedRead.Load() - if target != nil && *target == id && s.mutateAtLockedRead.CompareAndSwap(target, nil) { + mutation := s.mutateAtLockedRead.Load() + if mutation != nil && mutation.id == id && s.mutateAtLockedRead.CompareAndSwap(mutation, nil) { row, err := s.Store.GetChatModelConfigByID(ctx, id) if err != nil { return row, err } - row.Model = s.mutateAtLockedModel + row.Model = mutation.model return row, nil } } @@ -4516,15 +4547,7 @@ func TestCreateChatModelConfig(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - rawDB, pubsub := dbtestutil.NewDB(t) - store := newChatModelConfigHookStore(rawDB) - rawClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ - Database: store, - Pubsub: pubsub, - DeploymentValues: coderdtest.DeploymentValues(t), - }) - aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil) - client := codersdk.NewExperimentalClient(rawClient) + client, store := newChatClientWithModelConfigHookStore(t) _ = coderdtest.CreateFirstUser(t, client.Client) aiProvider := createAIProviderForTest(t, client, "openai", "test-api-key") @@ -5225,15 +5248,7 @@ func TestUpdateChatModelConfig(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - rawDB, pubsub := dbtestutil.NewDB(t) - store := newChatModelConfigHookStore(rawDB) - rawClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ - Database: store, - Pubsub: pubsub, - DeploymentValues: coderdtest.DeploymentValues(t), - }) - aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil) - client := codersdk.NewExperimentalClient(rawClient) + client, store := newChatClientWithModelConfigHookStore(t) _ = coderdtest.CreateFirstUser(t, client.Client) modelConfig := createChatModelConfig(t, client) @@ -5249,15 +5264,7 @@ func TestUpdateChatModelConfig(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - rawDB, pubsub := dbtestutil.NewDB(t) - store := newChatModelConfigHookStore(rawDB) - rawClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ - Database: store, - Pubsub: pubsub, - DeploymentValues: coderdtest.DeploymentValues(t), - }) - aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil) - client := codersdk.NewExperimentalClient(rawClient) + client, store := newChatClientWithModelConfigHookStore(t) _ = coderdtest.CreateFirstUser(t, client.Client) defaultConfig := createChatModelConfig(t, client) @@ -5317,15 +5324,7 @@ func TestUpdateChatModelConfig(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - rawDB, pubsub := dbtestutil.NewDB(t) - store := newChatModelConfigHookStore(rawDB) - rawClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ - Database: store, - Pubsub: pubsub, - DeploymentValues: coderdtest.DeploymentValues(t), - }) - aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil) - client := codersdk.NewExperimentalClient(rawClient) + client, store := newChatClientWithModelConfigHookStore(t) _ = coderdtest.CreateFirstUser(t, client.Client) modelConfig := createChatModelConfig(t, client) @@ -5337,21 +5336,11 @@ func TestUpdateChatModelConfig(t *testing.T) { requireSDKError(t, err, http.StatusNotFound) }) - // Swapping Model at the locked read to a sentinel forces the response - // to carry the sentinel when the merge reads the locked row. t.Run("MergesFromLockedCopy", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - rawDB, pubsub := dbtestutil.NewDB(t) - store := newChatModelConfigHookStore(rawDB) - rawClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ - Database: store, - Pubsub: pubsub, - DeploymentValues: coderdtest.DeploymentValues(t), - }) - aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil) - client := codersdk.NewExperimentalClient(rawClient) + client, store := newChatClientWithModelConfigHookStore(t) _ = coderdtest.CreateFirstUser(t, client.Client) modelConfig := createChatModelConfig(t, client) @@ -5366,8 +5355,6 @@ func TestUpdateChatModelConfig(t *testing.T) { require.Equal(t, "only-display-name", updated.DisplayName) }) - // The generic "provider is not configured" fallback for the stored - // provider hides the real cause; report the disabled state instead. t.Run("StoredProviderDisabledOnModelOnlyUpdate", func(t *testing.T) { t.Parallel() @@ -5389,6 +5376,24 @@ func TestUpdateChatModelConfig(t *testing.T) { require.Equal(t, "AI provider is disabled.", sdkErr.Message) }) + t.Run("StoredProviderMissingOnModelOnlyUpdate", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + _ = coderdtest.CreateFirstUser(t, client.Client) + modelConfig := createChatModelConfig(t, client) + + err := client.DeleteAIProvider(ctx, modelConfig.AIProviderID.String()) + require.NoError(t, err) + + _, err = client.UpdateChatModelConfig(ctx, modelConfig.ID, codersdk.UpdateChatModelConfigRequest{ + Model: "gpt-4o-different", + }) + sdkErr := requireSDKError(t, err, http.StatusPreconditionFailed) + require.Equal(t, "AI provider is not configured.", sdkErr.Message) + }) + t.Run("CompressionThresholdOutOfRange", func(t *testing.T) { t.Parallel() @@ -5552,25 +5557,15 @@ func TestDeleteChatModelConfig(t *testing.T) { requireSDKError(t, err, http.StatusNotFound) }) - // The DELETE query guards on `deleted = FALSE` and returns - // sql.ErrNoRows for a miss; the locked re-read maps that to 404. - t.Run("NotFoundWhenTargetRowDisappearsAtLockedRead", func(t *testing.T) { + t.Run("NotFoundWhenTargetRowDisappearsAtDelete", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) - rawDB, pubsub := dbtestutil.NewDB(t) - store := newChatModelConfigHookStore(rawDB) - rawClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ - Database: store, - Pubsub: pubsub, - DeploymentValues: coderdtest.DeploymentValues(t), - }) - aibridgedtest.StartTestAIBridgeDaemon(t.Context(), t, api, nil) - client := codersdk.NewExperimentalClient(rawClient) + client, store := newChatClientWithModelConfigHookStore(t) _ = coderdtest.CreateFirstUser(t, client.Client) modelConfig := createChatModelConfig(t, client) - store.armVanishAtLockedRead(modelConfig.ID) + store.armFailNextDelete(modelConfig.ID) err := client.DeleteChatModelConfig(ctx, modelConfig.ID) requireSDKError(t, err, http.StatusNotFound)