Skip to content

fix(backend): close idempotency race; create loan_events index via migrate#32

Open
Akpolo wants to merge 1 commit into
LabsCrypt:mainfrom
Akpolo:feat/issues-1-2
Open

fix(backend): close idempotency race; create loan_events index via migrate#32
Akpolo wants to merge 1 commit into
LabsCrypt:mainfrom
Akpolo:feat/issues-1-2

Conversation

@Akpolo

@Akpolo Akpolo commented Jun 19, 2026

Copy link
Copy Markdown

Summary

Two P0 backend bugs bundled into one PR.

  • [Backend] Idempotency middleware has a concurrency race that allows duplicate money-moving submissions #1 — Idempotency middleware concurrency race (src/middleware/idempotency.ts): the original implementation only wrote the cache on res.on("finish"), so two concurrent requests sharing one Idempotency-Key both missed the cache check at line 30 and both executed the handler — which means /loans/:loanId/repay and /pool/submit could submit the same signed Stellar transaction twice. The middleware now reserves the key with cacheService.setNotExists before the handler runs. The losing concurrent request gets a 409 with X-Idempotency-Cache: IN_PROGRESS; on 5xx the in-progress placeholder is dropped so retries are not poisoned with a stale failure. Existing HIT semantics are preserved for fully-completed keys.
  • [Backend] The only CONCURRENTLY index migration lives in a directory node-pg-migrate never scans #2CONCURRENTLY index migration lives in a directory node-pg-migrate never scanned (src/db/migrations/migrations/): ported the SQL into a proper node-pg-migrate JS migration at migrations/1778000000000_loan-events-type-created-at-index.js with disableTransaction = true so the CREATE INDEX CONCURRENTLY clause survives. The stray src/db/migrations/ directory has been removed so there is a single source of truth.

Test plan

  • jest src/tests/idempotency.test.ts5 / 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.json clean.
  • A fresh npm run migrate:up on a clean database creates idx_loan_events_type_created_at (verified by reading; not run here — requires a DB).

Closes #1
Closes #2

@ogazboiz ogazboiz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. 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.

  2. 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_events view, 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_events will 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
@Akpolo Akpolo force-pushed the feat/issues-1-2 branch from 0653d2e to 5a2cc3b Compare June 19, 2026 23:49

@davidmaronio davidmaronio left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the maintainer will review

@ogazboiz ogazboiz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. npm run build fails (tsc, exit 2) on your own test mock: src/tests/idempotency.test.ts:129 types the setNotExists mock as zero-arg jest.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.)
  2. 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

@ogazboiz ogazboiz dismissed davidmaronio’s stale review June 20, 2026 00:33

posted from the wrong account in error

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants