Repository navigation
fix(config): make the service-config PUT read-merge-write atomic - #1509
Conversation
The service-config PUT read the stored row, merged the request's present keys into it and upserted the full row, with no transaction or lock. Two admins saving different fields of the same row at once both merged onto the same base and the later write dropped the earlier change. Add PostgresStore.UpdateServiceConfigAtomic, mirroring UpdateGlobalConfigAtomic: one transaction takes a per-(provider, service) advisory lock (serializes two first saves of a row that does not exist yet), reads the row FOR UPDATE (so the global-defaults propagation, which updates service rows without the advisory lock, cannot be overwritten by a stale read), runs the caller's merge and validation, and upserts. The handler now merges and validates inside that callback; mergeServiceConfig becomes a pure overlay onto the row the store hands it. The mock gets a matching UpdateServiceConfigAtomic that falls back to the mocked Get/Save. Real-Postgres tests fire 40 rounds of two concurrent PUTs of different keys (existing row and first save); before the fix 39 of 40 rounds lost an update in each. Store-level lock tests pin each of the two locks. Closes LeanerCloud#1488 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: REQUEST_CHANGES Blocking:
Non-blocking:
Attacked:
Body-stale:
Reviewer: rF3. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths. |
…tion The service-config PUT now runs its read-merge-write in one locked transaction (LeanerCloud#1488), and the merge callback ran the commitment-option check, whose Validate reads the commitmentopts store (and, on a cold cache, lists cloud accounts and probes AWS) through the same pool. Each save then held one pooled connection and waited for a second. The Settings page saves its AWS rows in parallel, so with as many saves as pool connections (3 on Fargate, 6 on Cloud Run) every save waited on the others until its deadline and the instance starved. Fetch the options with Get before the transaction opens and check the merged row inside it with the new pure commitmentopts.ValidateCombo, which Service.Validate now also uses. A read error is kept and only fails a save whose merged row carries a term and payment, as before. The rest of the callback (merge, ServiceConfig.Validate) was already store-free; the store side runs every statement on the transaction. The other combo-check callers (account override PUT, override import planning) do not hold a transaction and keep using Validate. Adds a real-Postgres test with a pool of 3 and the nine Settings AWS rows saved at once, wired as app.go wires the service: it timed out on every save before this change and passes in about 100ms after it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Reply to rF3's review of Blocking: pool deadlock from the commitment-option check inside the transaction. Fixed.
Regression test (real Postgres):
The new unit test Non-blocking: Body-stale: agreed. The PR body's line about a cloud probe holding the row lock no longer applies, because nothing in the transaction reads the commitment options now. I have not edited the PR body. This comment supersedes that line. Checks on this head: |
|
VERDICT: APPROVE 8fcb1af Findings (none blocking):
Checked and holding:
Attacked:
|
|
Independent review 2 (claude-fleet, cold): APPROVE 8fcb1af Findings:
Attacked:
Verified locally (GOTOOLCHAIN=go1.26.10, no cloud creds):
Reviewed by an independent agent with no author context (second reviewer). Review only: nothing was pushed, and the GitHub review state is unchanged. |
|
Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE Blocking:
Non-blocking:
Attacked:
Body-stale:
Reviewer: rF7. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths. |
|
Local verification at |
|
Merged by IAmJSD's agent at IAmJSD's request (IAmJSD is acting owner while the maintainers are away), under the bar IAmJSD set: 2 approving independent review(s) on |
|
Local verification at
|
Root cause
PUT /api/config/service/{provider}/{service}read the stored row (GetServiceConfig), overlaid only the keys the body carried (mergeServiceConfig) and upserted the whole row (SaveServiceConfig). These were three separate statements with no transaction or lock. When two admins saved different fields of the same row at once, both merged onto the same base and the later upsert wrote back the stale value of the other admin's field. #1487 made the window smaller but did not close it.Fix
internal/config/store_postgres.go: newPostgresStore.UpdateServiceConfigAtomic(ctx, provider, service, apply), modeled onUpdateGlobalConfigAtomic. In one transaction it:pg_advisory_xact_lock(hashtextextended('cudly:service-config:<provider>/<service>', 0)), which serializes two first saves of a row that doesn't exist yet.FOR UPDATEalone can't do that because there is no row to lock.FOR UPDATE. The global-defaults propagation (propagateChangedDefaults, run insideUpdateGlobalConfigAtomic) rewrites service rows with a plainUPDATEand does not take the per-service advisory lock. The row lock makes it and this transaction wait for each other, so a stale read can't overwrite a propagated default.apply(existing), whereexistingis nil if there is no row. An error fromapplyrolls back and is returned unchanged, so a 400 from validation stays a 400.applyreturns. It refuses a nil row or a row with a different provider/service key.GetServiceConfigandSaveServiceConfignow go through the shared helpersgetServiceConfigFromandsaveServiceConfigWith, which accept either the pool or a tx. Their behavior is unchanged.internal/config/interfaces.go:UpdateServiceConfigAtomicadded toStoreInterface.internal/api/handler_config.go:updateServiceConfignow runs the merge,Validate()and the commitment-option combo check inside the atomic callback (mergeAndValidateServiceConfig).mergeServiceConfigis now a pure overlay onto the row the store hands it and no longer mutates that row. The old "not found" string match is gone because the store passes nil for a missing row.internal/mocks/stores.go: addsMockConfigStore.UpdateServiceConfigAtomic. With an expectation registered, it dispatches throughm.Called. Without one, it falls back to the mockedGetServiceConfig+ apply +SaveServiceConfig(a wrappedconfig.ErrNotFoundbecomes nil), the same pattern asUpdateGlobalConfigAtomic. Existing handler tests therefore keep working unchanged.Lock ordering: service PUT takes advisory(service), then row(service). Global PUT takes advisory(global), then rows(all service rows). Neither path waits on a lock the other takes first, so there is no cycle. Two service PUTs on different rows use different keys. A 64-bit hash collision could only cause an unnecessary wait.
Closes #1488
Verification
Commands, run on this branch (
54c4ff48) withAWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true:go build ./...,go vet ./...,go vet -tags integration ./...: cleango test -count=1 ./internal/mocks/,./internal/config/,./internal/api/: all ok (unit, mocks/pgxmock)golangci-lint run --allow-parallel-runners [--build-tags integration] ./internal/api/ ./internal/config/ ./internal/mocks/: 0 issuesgocyclo -over 10 -ignore "_test\.go"on the changed non-test files: no outputReal-Postgres integration (testcontainers), fail-before / pass-after. These are the new handler-level race tests in
internal/api/handler_config_service_race_integration_test.go. Each one fires 40 rounds of two concurrent PUTs with different keys throughHandler.updateServiceConfigagainst a realPostgresStore:TestUpdateServiceConfig_ConcurrentPartialPutsKeepBothKeys(existing row,{"coverage":N}vs{"min_count":N})TestUpdateServiceConfig_ConcurrentFirstSavesKeepBothKeys(no row yet)The existing propagation tests
TestUpdateConfigPropagatesOnlyChangedDefaultsandTestUpdateConfigPaymentDefaultSkipsAzureAndGCPalso pass on this branch.Deterministic store-level lock tests. These are in
internal/config/store_postgres_service_config_atomic_integration_test.go. Each parks a competing writer mid-transaction, waits until the update is blocked on a lock (testhelpers.WaitForLockWait), commits the writer, and checks that the update merged onto the writer's committed row:TestUpdateServiceConfigAtomic_QueuesBehindRowWriter: the competing writer does apropagateChangedDefaults-shapedUPDATE ... SET term = 3. PASSTestUpdateServiceConfigAtomic_QueuesBehindFirstInsert: the competing first save holds the advisory lock and inserts. PASSMutation probes (each mutation was applied locally and then reverted):
FOR UPDATEfailsQueuesBehindRowWriter("the concurrent writer's term must survive").QueuesBehindFirstInsert("the first save's min_count must survive") andConcurrentFirstSavesKeepBothKeys(39/40 lost).Unit tests (mock/pgxmock evidence):
TestPGXMock_UpdateServiceConfigAtomic_*(new filestore_postgres_service_config_atomic_test.go) checks the statement order BEGIN, advisory lock (exact key),SELECT ... FOR UPDATE, upsert, COMMIT. It also checks that no row gives apply a nil, and that an apply error, a nil row or a mismatched key, or a read error each roll back with no upsert.TestHandler_updateServiceConfig_MergesInsideAtomicUpdatechecks that the handler merges insideUpdateServiceConfigAtomicand never callsGetServiceConfig/SaveServiceConfigdirectly.TestHandler_updateServiceConfig_StoredRowReadcovers the no-row and read-error paths.mergeServiceConfigtests were adapted to the pure signature. A malformed-body test and a no-mutation-of-stored-row assertion were added.Combined-tree verification at local queue commit
08ea6aeb= origin/main3bfc2fe5+fix/issue-1484,fix/issue-1498,fix/issue-1489,fix/issue-1494,fix/issue-1488andfix/issue-1493merged in that order (so migration 000147 runs on top of #1484's 000146). Passed:go build ./...;go vetwith and without-tags integration;go test -race -short -count=1 ./...; golangci-lint v2.10.1 with and without the integration tag; gocyclo -over 10;go test -race -tags integrationoninternal/api,internal/configandinternal/database/postgres/migrations; the git-secrets scan andscripts/test-git-secrets-allowlist.sh.Notes / out of scope
prefetchCommitmentOptions, in 8fcb1af). Inside the lock, the merged row is checked against that snapshot with the purecommitmentopts.ValidateCombo, so nothing in the transaction needs a second pool connection or a cloud probe. Before this, the check ran inside the transaction and could deadlock the pool when the Settings page saved several AWS rows in parallel (rF3; regression testTestUpdateServiceConfig_ParallelSettingsSaveDoesNotExhaustPool). If the read fails, the save still returns 503, but only when the merged row has a term and payment.ListServiceConfigsstill has its own copy of the column list. I left it alone to keep this diff small.Maintainer: please mirror the issue's triage labels onto this PR:
priority/p3,severity/low,urgency/this-quarter,impact/few,effort/s,type/bug(the issue has notriagedlabel).🤖 Generated with Claude Code