fix(backend): close idempotency race; create loan_events index via migrate#32
fix(backend): close idempotency race; create loan_events index via migrate#32Akpolo wants to merge 1 commit into
Conversation
ogazboiz
left a comment
There was a problem hiding this comment.
the idempotency middleware rewrite is the right fix and it's well done. reserving the key with an atomic setNotExists before the handler runs closes the original race where two concurrent requests could both miss the get() and double-submit, the in-progress placeholder returns a clean 409 to the loser, you drop the placeholder on 5xx so a failed request doesn't poison retries, and the bind()/bodyCaptured handling of res.json and res.send is correct. the concurrency regression test (handler called exactly once, loser gets 409 IN_PROGRESS) actually proves the fix. nice.
two things on the migration need sorting before it can land:
-
numbering. the new file is
1778000000000_..., which sorts below migrations that are already applied (main is up to 1789000000001 now). a back-dated migration is out of order for node-pg-migrate and breaks the monotonic convention the rest follow. renumber it to the next slot, e.g.1790000000000_loan-events-type-created-at-index.js. -
loan_events is (or becomes) a view, not a table. 1788000000019_unified-contract-events renames the loan_events table to contract_events and creates a backward-compat
loan_eventsview, while 1789000000000_ensure-core-tables then tries to ensure a loan_events table, so the final type is genuinely ambiguous in the current history.CREATE INDEX CONCURRENTLY ON loan_eventswill error if loan_events resolves to that view. please run migrate:up on a fresh db and confirm what loan_events actually is at this point. if it's the view, point the index at the underlying table instead:CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_contract_events_type_created_at ON contract_events (event_type, created_at);.
dropping the orphaned src/db/migrations/.sql that node-pg-migrate never scanned is a good cleanup, keep that. once the migration is renumbered and confirmed to target a real table, and npm run build / npm test / prettier are green, this is good to go.
if you want to keep contributing, join us on Telegram: https://t.me/+DOylgFv1jyJlNzM0
…a migrate - LabsCrypt#1: idempotencyMiddleware now reserves the cache key with an atomic setNotExists BEFORE forwarding to the handler, so two concurrent requests sharing one Idempotency-Key can't both miss the cache check and submit the same money-moving transaction twice. The losing concurrent request gets a 409 with X-Idempotency-Cache: IN_PROGRESS; on 5xx the in-progress placeholder is dropped so retries aren't poisoned. Regression test fires two requests with the same key at once and asserts the handler runs exactly once. - LabsCrypt#2: replace the orphan src/db/migrations SQL file (which node-pg-migrate never scanned) with a properly-formatted node-pg-migrate JS migration using disableTransaction = true so CREATE INDEX CONCURRENTLY survives. Per review: * renumbered the new migration from 1778000000000 to 1790000000000 so it sorts after the current head (1789000000001) instead of being back-dated and violating the monotonic convention; * retargeted the index from loan_events to contract_events because 1788000000019_unified-contract-events turned loan_events into a backward-compat view over contract_events, and CREATE INDEX CONCURRENTLY cannot index a view. The index name follows the contract_events_* convention that migration established. Single source of truth: the stray src/db/migrations directory has been removed. Closes LabsCrypt#1 Closes LabsCrypt#2
ogazboiz
left a comment
There was a problem hiding this comment.
the migration is sorted now, renumbered to 1790000000000 so it sits above the current head, and it targets contract_events directly with a clear comment about loan_events being a view, exactly right. the idempotency middleware and the 5 tests (including the concurrency regression) are solid and all pass. two small things keep the build red though:
npm run buildfails (tsc, exit 2) on your own test mock: src/tests/idempotency.test.ts:129 types the setNotExists mock as zero-argjest.Mock<() => Promise<boolean>>, but line 130's mockImplementation passes two args (_k, value), so TS2345. type the mock to match, e.g.jest.Mock<(k: any, value: any, ttl: number) => Promise<boolean>>. (typecheck passes only because tsconfig.build.json excludes tests; the default build config compiles them.)- prettier fails on src/middleware/idempotency.ts and src/tests/idempotency.test.ts (the chained res.status().set().json() blocks plus a couple of wrapped calls). run
npx prettier --write src/middleware/idempotency.ts src/tests/idempotency.test.ts.
fix those two and build/test/prettier are all green and this is good to go.
if you want to keep contributing, join us on Telegram: https://t.me/+DOylgFv1jyJlNzM0
posted from the wrong account in error
Summary
Two P0 backend bugs bundled into one PR.
src/middleware/idempotency.ts): the original implementation only wrote the cache onres.on("finish"), so two concurrent requests sharing oneIdempotency-Keyboth missed the cache check at line 30 and both executed the handler — which means/loans/:loanId/repayand/pool/submitcould submit the same signed Stellar transaction twice. The middleware now reserves the key withcacheService.setNotExistsbefore the handler runs. The losing concurrent request gets a409withX-Idempotency-Cache: IN_PROGRESS; on 5xx the in-progress placeholder is dropped so retries are not poisoned with a stale failure. ExistingHITsemantics are preserved for fully-completed keys.CONCURRENTLYindex migration lives in a directorynode-pg-migratenever scanned (src/db/migrations/→migrations/): ported the SQL into a proper node-pg-migrate JS migration atmigrations/1778000000000_loan-events-type-created-at-index.jswithdisableTransaction = trueso theCREATE INDEX CONCURRENTLYclause survives. The straysrc/db/migrations/directory has been removed so there is a single source of truth.Test plan
jest src/tests/idempotency.test.ts— 5 / 5 pass, including the new concurrency regression that fires two requests with the same key in parallel and asserts the handler runs exactly once.tsc --noEmit -p tsconfig.build.jsonclean.npm run migrate:upon a clean database createsidx_loan_events_type_created_at(verified by reading; not run here — requires a DB).Closes #1
Closes #2