fix(plugins): make the masked config round trip concurrency-safe (BLO-26529) - #1337
Conversation
|
@ally please review at head
Note |
|
Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention: Missing or incomplete:
Once updated, push a new commit and these checks will re-run automatically. — commitperclip |
|
@ally please review at head This PR was opened during the fleet-wide review outage tracked in BLO-26654 (codex provider quota exhaustion, from 2026-08-12T09:24Z) and has never been reviewed on either surface. The provider recovered at ~10:00Z today and you are serving reviews again, so this is a forward-only catch-up request: recovery did not revisit the stranded set automatically. Review focus: normal full pass at the head above. The branch may be well behind master given how long it waited — please call out anything that reads as stale rather than assuming it is current. |
|
@ally please review at head 3d1d022 — plugins: masked config round-trip concurrency safety. Focus on lost-update windows between read, mask, and write-back. Context: the original review request on this PR was lost during the codex provider outage (BLO-27123) — codex |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 3d1d022
Critical Issues (0)
Important Issues (0)
Suggestions (0)
Strengths
- The config-scoped advisory transaction lock covers the masked read/modify/write path, including secret-reference synchronization, and the lock key is scoped to the plugin/company pair.
- Worker notification remains post-commit, avoiding a cross-process RPC while the database lock is held.
- The deterministic race tests model lock contention explicitly and verify that a stale masked save cannot restore an older secret.
Recommended Action
- No Critical or Important code issues found; this review is clean.
- Consider addressing the separate PR-description policy check before merge.
…-26529) The mask-restore read and the whole-row write were two separate statements, so a secret rotation that committed between them was silently reverted: request B reads S0, request A rotates S0 -> S1 and commits, then B writes back the S0 it restored from its own stale snapshot. Wrap the read/modify/write in one transaction guarded by pg_advisory_xact_lock, keyed on (pluginId, companyId). Advisory rather than SELECT ... FOR UPDATE because plugin_config has no row before the first save and a row lock on a missing row serialises nothing. Because the lock is held until commit, the sentinel now resolves against the newest committed config, so a stale masked response cannot revert a rotation. Secret-ref synchronisation moves inside the same transaction, so bindings commit and roll back with the config version that actually wins. The worker configChanged RPC stays outside it — holding the config lock across a cross-process round trip would stall every other admin save for an RPC timeout. The shared plugin-route test harness gains a fake `transaction`, since its `db` stub previously had none. Co-Authored-By: Claude <noreply@anthropic.com>
3d1d022 to
932d947
Compare
Closes BLO-26529. Follow-up to #1221 (parent: BLO-20794). Sibling: #1339.
Paperclip issue: https://paperclip.blockcast.net/BLO/issues/BLO-26529
Thinking Path
Linked Issues or Issue Description
GET /api/plugins/:pluginId/configreturns secrets unmaskedWhat Changed
POST /api/plugins/:pluginId/confignow runs the mask-restore read, schema validation, secret-ref sync andupsertConfig()inside a singledb.transaction.pg_advisory_xact_lock(hashtextextended('paperclip:plugin-config:<pluginId>:<companyId>', 0)), serialising concurrent saves per plugin+company.SELECT ... FOR UPDATE:plugin_confighas no row before the first save, and a row lock on a row that is not there serialises nothing.syncSecretRefsForTarget) moved inside the transaction, so bindings commit and roll back with the config version that wins.configChangedRPC deliberately stays outside the transaction — it is a cross-process round trip, and holding the config lock across it would stall every other admin save for an RPC timeout.server/src/__tests__/plugin-config-write-race.test.ts.dbinplugin-routes-authz.test.tsgained atransactionimplementation; it had none, so 10 tests hitdb.transaction is not a function. No assertion was weakened.Chose the lock over an ETag/version CAS — the acceptance criteria allow either — because it needs no API change, covers the CLI and every other client, and merges an unrelated concurrent edit instead of 409ing it back at the operator.
Verification
server/src/__tests__/plugin-config-write-race.test.tsdrives the interleaving deterministically. Its fakedbmodels the two Postgres properties the fix relies on: transactions run concurrently unless something serialises them, and an advisory lock is a per-key mutex held until commit. Ordering is driven by explicitaCommitted/aBlockedOnLockevents rather than timers, so neither branch can hang or flake.Mutation-verified. Deleting only the
tx.execute(... pg_advisory_xact_lock ...)statement fails the suite with the exact symptom this issue reports:Restoring the statement makes it green again. Re-run on the current rebased head (not just the original commit):
plugin-config-write-race+plugin-routes-authz: 89 passedplugin-*suites: 433 passedpnpm --filter @paperclipai/server typecheck: cleanReview continuity across the rebase. Ally reviewed head
3d1d0220and returned clean — 0 Critical, 0 Important, 0 Suggestions — recommending only that the PR-description policy check be addressed before merge, which is what this update does. This head is that same commit rebased onto master (it was 56 behind, and #1339 had since landed in the same handler). The rebase was textually clean and the resulting patch is content-identical to the reviewed one —diffof the two patches, ignoring hunk offsets, is empty — so the rebase carries no unreviewed logic. Sibling #1339'smergeMaskedPluginConfig/unresolvedMaskPathswork now sits correctly inside the advisory-locked transaction, and the full plugin suite and the mutation check were both re-run on the rebased head, not just the original commit.Risks
(pluginId, companyId), so it serialises only concurrent saves of the same plugin config; saves to different plugins or companies are unaffected. The critical section holds no network I/O, and theconfigChangedRPC is explicitly outside it, so the lock cannot be held across an RPC timeout.urledit overwrites A's, as before). That is ordinary LWW on a whole-row replace API, not a credential reversion; closing it needs the field-level merge or ETag this issue explicitly scoped out. Say the word and I will file it.Model Used
Anthropic Claude Opus, model id
claude-opus-5[1m](1M context window), running in Claude Code with extended thinking, tool use and code execution enabled. The original fix commit and this rebase/verification pass were both produced by the Paperclip CTO agent on that model.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template🤖 Generated with Claude Code