Repository navigation
Add compare-and-set update for DCR credentials - #6758
Conversation
DCRCredentialStore could only create-if-absent or overwrite-if-present, so a caller coordinating DCR re-registration across replicas could not make its overwrite safe against a concurrent writer: a lock holder that stalls past its lease would clobber a newer row on resume. The check and the write cannot be made atomic from outside the store because the lock and row keys hash to different Cluster slots and the row encoding is unexported. Implements changes for issue #6757: - Add UpdateDCRCredentialsIfUnchanged and ErrDCRCredentialsChanged - Memory: compare and write under the storage mutex - Redis: single-key WATCH/MULTI comparing decoded stored forms, so it is Cluster-safe and tolerant of the stored one-second time precision - TTL handling matches UpdateDCRCredentialsIfPresent - Regenerate mocks; add unit and Sentinel integration tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
One consumer-side note on the A caller typically reads a row, decides to replace it, and then calls this method with The right recovery is to fall back to the create path, It may be worth one sentence in the interface doc comment, next to the existing |
tgrunnagle
left a comment
There was a problem hiding this comment.
Multi-Agent Consensus Review
Agents consulted: concurrency-reviewer, api-reviewer, test-reviewer
Consensus Summary
| # | Finding | Consensus | Severity | Action |
|---|---|---|---|---|
| 1 | Whole-value contract is hard for randomized-encryption decorators | 7/10 | MEDIUM | Discuss |
| 2 | Retry, corrupt-row and expired-but-present paths untested | 8/10 | MEDIUM | Fix |
| 3 | No per-field comparison test (times, ClientSecretExpiresAt) |
7/10 | MEDIUM | Fix |
| 4 | Retry exhaustion is an undocumented third outcome | 8/10 | LOW | Fix |
| 5 | Struct == silently misses a future time.Time field |
8/10 | LOW | Fix |
| 6 | maxDCRClaimRetries comment is stale |
7/10 | LOW | Fix |
Overall
This adds a single-key compare-and-set to DCRCredentialStore. The memory backend compares and writes under s.mu. The Redis backend compares decoded stored forms in a WATCH/MULTI on one key. That closes the stalled-lock-holder window from #6757 and keeps the operation Cluster-safe. The approach is sound, and the newStoredDCRCredentials extraction preserves existing behavior.
The findings are about contract clarity and test depth rather than correctness. The main design question is how an encrypting decorator can supply the "stored form" of expected when the cipher is randomized. The main test gaps are the retry, exhaustion and corrupt-row paths, and field-by-field coverage of the comparison.
Documentation
The comment on maxDCRClaimRetries (redis.go:42) still describes only StoreDCRCredentialsIfAbsent.
Generated with Claude Code
Addresses #6758 review comments: - MEDIUM types.go (4198367657): document how a decorator with a non-reproducible transform supplies the stored form of expected - LOW redis.go (4198367706): document the retry-exhaustion outcome - LOW memory.go (4198367719): note new time.Time fields must be compared with Equal in dcrCredentialsEqual - LOW redis.go (4198367731): extend maxDCRClaimRetries comment to cover UpdateDCRCredentialsIfUnchanged Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Addresses #6758 review comments: - MEDIUM redis_test.go (4198367681): test WATCH retry, retry exhaustion, corrupt stored row, and expired-but-present row - MEDIUM memory_test.go (4198367695): per-field comparison table run on both backends, with a reflection check that every field is listed - LOW memory.go (4198367719): reflection test that every time.Time field in dcrCredentialsEqual compares by instant Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #6758 +/- ##
=======================================
Coverage 79.31% 79.32%
=======================================
Files 802 802
Lines 81372 81430 +58
=======================================
+ Hits 64544 64592 +48
- Misses 16823 16833 +10
Partials 5 5 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Summary
DCRCredentialStorecan write a DCR row only if it is absent (StoreDCRCredentialsIfAbsent) or only if it is present (UpdateDCRCredentialsIfPresent). It cannot overwrite a row only if the row still holds the value the caller read. Consumers that coordinate DCR re-registration across replicas need that guarantee. Suppose a lock holder passes its ownership check and then stalls past its lease (a GC pause or a frozen process). In the meantime another replica registers and persists a newer client. When the stalled holder resumes, itsUpdateDCRCredentialsIfPresentoverwrites that newer row. Consumers can't close this gap themselves: the lock key and the row key hash to different Cluster slots, and the DCR key format and stored encoding are unexported.UpdateDCRCredentialsIfUnchanged(ctx, creds, expected)toDCRCredentialStore. It overwrites the row atcreds.Keyonly if the stored row still equalsexpected. It returnsErrNotFound(wrapped) when the row is absent and the newErrDCRCredentialsChangedsentinel when the row differs. In both cases nothing is written.validateDCRCompareAndSet) requires a non-nilexpectedwhoseKeymatchescreds.Key. This stops a caller from gating a write to one row on the contents of another.WATCH/MULTItransaction on the single row key, the same patternStoreDCRCredentialsIfAbsentuses. IfEXECaborts because of a concurrent touch, the operation retries up tomaxDCRClaimRetriestimes. The TTL follows the sameClientSecretExpiresAtrules asUpdateDCRCredentialsIfPresent. The stored-form conversion moved out ofmarshalDCRCredentialsForStoreintonewStoredDCRCredentials, so the compare reuses it. This part is a refactor with no behavior change.s.mu. Time fields are compared withtime.Time.Equal.Closes #6757
Type of change
Test plan
task test)task test-e2e)task lint-fix)New unit tests for both the memory and Redis (miniredis) backends cover these cases:
creds, nilexpected, and mismatched keysexpectedvalue: exactly one wins and the others getErrDCRCredentialsChangedRedis-only tests also cover the one-second stored time precision, the TTL rules, and connection failure.
Ran the DCR integration tests against a real Redis Sentinel cluster in Docker with
go test -race -tags integration -run TestIntegration_DCRCredentials ./pkg/authserver/storage/. They passed, including the newTestIntegration_DCRCredentials_UpdateIfUnchanged.Changes
pkg/authserver/storage/types.goErrDCRCredentialsChanged,validateDCRCompareAndSetpkg/authserver/storage/redis.goWATCH/MULTIimplementation;newStoredDCRCredentialsextracted frommarshalDCRCredentialsForStorepkg/authserver/storage/memory.godcrCredentialsEqualpkg/authserver/storage/mocks/mock_storage.gopkg/authserver/storage/*_test.goDoes this introduce a user-facing change?
No. This is a new storage API for internal consumers. Any out-of-tree implementation of
DCRCredentialStoremust add the new method.Special notes for reviewers
GetDCRCredentialsalways matches its row even though times are stored at one-second precision. The trade-off: a decorator that transforms fields (for example, encryption) must pass the stored form ofexpected, meaning what the innerGetDCRCredentialsreturned. The interface doc comment says so.WATCH/MULTIinstead of a Lua script. The issue suggested Lua.WATCH/MULTIon one key gives the same single-key atomicity and matches the existingStoreDCRCredentialsIfAbsentcode path.StoreDCRCredentialsIfAbsent. Standalone mode is covered by the miniredis tests and Sentinel mode by the integration tests.🤖 Generated with Claude Code