Repository navigation
IdempotencyStore has no processing lease: a crashed claim submission is stuck returning 409 #429
Description
Problem
The idempotency store treats a processing record as permanent until a matching request calls complete(), but complete() is fire-and-forget and there is no lease or TTL on in-flight records. In middleware.ts:
// app/backend/src/idempotency/middleware.ts
res.send = (body: any) => {
// Fire and forget cache save (log on failure)
store.complete(key, recordStatus, status, bodyString).catch(err => console.error(...));
return originalSend(body);
};And in store.ts, tryAcquire inserts status='processing' and never times it out; the only expiry is cleanup(maxAgeHours) which deletes by created_at with no regard for status.
Consequences, distinctly:
- A crash orphans the key. If the process dies after
tryAcquireinsertsprocessingbut beforecompleteruns, the row staysprocessingforever. Every retry with the sameIdempotency-Keyreturns 409AlreadyProcessingErrorindefinitely (until a manualcleanupwith a large window deletes it), permanently stranding a claim submission — the exact class of operation idempotency exists to protect. - Replay-after-success races to 409. Because
completeis not awaited, a fast retry that arrives immediately after the first response can still readprocessingand return 409 instead of the cached response, so the "same key ⇒ same result" guarantee is violated under concurrency. - The record has no state-machine recovery. There is no
failed-terminal distinction for a crashed in-flight request; a caller cannot distinguish "still running" from "abandoned", so safe retry policy is impossible.
Root cause
The store models three statuses (processing/succeeded/failed) but no notion of a lease/ownership window or an abandoned terminal state, and the middleware treats complete as best-effort rather than as part of the request lifecycle.
Why this is architecturally hard
- This is a concurrency-control problem, not a timeout. A correct fix needs a lease/heartbeat on the
processingrecord (e.g.lease_expires_atrefreshed while the handler runs) plus a transition rule for abandoned leases; naively deletingprocessingrows older than N seconds can delete a still-running long request and allow double-execution — the opposite of idempotency. - The response-caching path is coupled to
res.send. Caching must be awaited or moved to anonFinished/interceptor that runs before the connection is released, so a replay can never observeprocessingafter the first response is committed. - Two storage backends.
store.tstargets Postgres viaPool, but the module must keep working with whateverIdempotencyStorethe app boots (and its tests); the lease protocol must be expressed in the store interface, not in one SQL dialect. - Claim submission is the trust boundary. The key protects exactly-once claim submissions to the on-chain adapter; getting the lease wrong risks either stranding claims (current bug) or double-submitting them (a naive fix).
Proposed design
Add a lease_expires_at (or processing_until) column set on acquire and refreshed via a heartbeat while the handler runs; treat expired-lease processing rows as abandoned (transition to a terminal failed/abandoned state and allow re-acquire). Await complete (or use a response-finish hook) so a succeeded result is visible before the client's connection closes. A table of the target semantics:
| State | On acquire (existing key) | On expiry | On complete |
|---|---|---|---|
processing (lease live) |
409 | n/a | → succeeded/failed |
processing (lease expired) |
re-acquire with new lease | → abandoned |
n/a |
succeeded/failed |
replay cached response | n/a | no-op |
Acceptance criteria
Service
- A simulated crash between acquire and complete does not permanently strand the key: a subsequent request with the same key re-acquires within a bounded lease window.
- A retry arriving after the first request has responded replays the cached result (200) rather than returning 409.
- An abandoned lease cannot cause the handler to run twice concurrently.
Tests
- Unit tests cover: lease expiry re-acquire, replay-after-success, and concurrent acquire contention.
Out of scope
Redis ZSET rate limiting and webhook HMAC are separate, already-tracked concerns.
Getting started
Files: app/backend/src/idempotency/store.ts, app/backend/src/idempotency/middleware.ts, app/backend/src/idempotency/error.ts, and the idempotency specs under app/backend/src/idempotency/.
cd app/backend
npm testGood first files to read: store.ts (the tryAcquire/complete/cleanup contract) and middleware.ts (the fire-and-forget complete and 409 paths).
Activity
Devadakene commentedon Aug 19, 2026
I am experienced enough to implement this issue having worked on similar issues in the past. Please assign
Opulencechuks commentedon Aug 20, 2026
I am confident that I can implement this having worked on something similar in the past
nasalehj commentedon Aug 20, 2026
Hi Maintainer
May I fix this issue please
grantfox-oss commentedon Aug 20, 2026
🦊 GrantFox — @nasalehj has been assigned to this issue as part of the Third Campaign campaign!
Next steps:
Closes #429)Good luck! Track your progress on GrantFox.
grantfox-oss commentedon Aug 22, 2026
🎉 This issue has been marked as completed on GrantFox as part of the Third Campaign campaign!
@nasalehj's PR #453 was approved and merged by @kilodesodiq-arch.
🏆 @nasalehj: You earned 35 FoxPoints for this contribution! Your current tier: Explorer (223 total points). Track your full progress on GrantFox.
👏 Great work, @nasalehj! Keep contributing to ChainForgee.