feat: persistent idempotency store with Redis backend#100
Conversation
…ackends Introduce IdempotencyStore trait with two implementations: - RedisIdempotencyStore: uses ConnectionManager for persistent, TTL-based key storage - MemoryIdempotencyStore: DashMap-based fallback for dev/test (keys lost on restart) Factory function create_idempotency_store() selects backend based on REDIS_URL config, with graceful fallback to memory if Redis is unavailable.
- Add REDIS_URL to Config (read from env, optional) - Engine::new() now accepts Arc<dyn IdempotencyStore> via injection - main.rs constructs store via factory (Redis or memory based on config) - payments handler awaits async idempotency calls with fail-open on set errors - Document REDIS_URL in .env.example
- Use MemoryIdempotencyStore in test helpers (no Redis needed) - Update Engine::new() calls to pass injected store - Await async idempotency get/set calls in test
Jaydbrown
left a comment
There was a problem hiding this comment.
Good implementation overall — this is a real improvement. Fixing the in-process memory store has been on my list for a while; the rolling-deploy duplicate-payment scenario in the motivation section is exactly the failure mode I was worried about. The trait abstraction is clean, DI via Engine::new is the right shape, and the graceful Redis fallback at startup is the correct UX. Appreciated.
A few things worth talking through before merge:
get errors propagating as 500
In payments.rs the get path does await?, so a transient Redis hiccup during the duplicate check returns a 500 to the caller. That's the opposite of what we want — if we can't confirm whether this key was seen before, failing the whole request blocks a legitimate payment. The set path logs-and-continues, which is the right call (payment succeeded; we just can't cache it). I'd argue get should follow the same "fail open" pattern: on Redis error, log a warning and proceed as if no cached response exists. The worst outcome is one duplicated operation, which is recoverable. A hard 500 on every Redis blip during the idempotency check is not.
evict_expired is never called in prod
MemoryIdempotencyStore::evict_expired is marked #[allow(dead_code)] and called only in the unit test. Long-running instances using the in-memory fallback will accumulate entries until restart. The previous IdempotencyStore had the same gap so this isn't a regression, but since you're refactoring it anyway — a simple periodic eviction task spawned alongside the store in main.rs (or on a background interval in create_idempotency_store) would close that out cleanly.
async_trait dependency
The diff adds async_trait::async_trait to idempotency.rs but I don't see it added to Cargo.toml. Was it already a transitive dependency? Worth pinning explicitly so it doesn't disappear on a dep update.
Minor
- The
idem:key prefix in Redis is good — keeps keys isolated. Worth documenting in.env.exampleor a comment in case someone needs to inspect keys manually in production. - The 24h TTL is hardcoded in
main.rs. Fine for now, butIDEMPOTENCY_TTL_SECSwould be a cheap config win if mobile clients ever need it tuned. - No Redis integration test is expected for a first pass, but a
#[cfg(feature = "integration")]test that actually connects to Redis and round-trips throughRedisIdempotencyStorewould be worth adding before this goes to a production environment. Not a merge blocker.
The get error handling is the one thing I'd want resolved before this lands — everything else is either fine as-is or a future improvement. Happy to pair on the fail-open approach if useful.
Ok, working on it. |
A transient Redis hiccup during the duplicate check previously returned a 500 to the caller, blocking legitimate payments. Now follows the same fail-open pattern as the set path: log a warning and proceed as if no cached response exists. Co-authored-by: oomokaro1 <oomokaro25@gmail.com>
- Spawn background eviction task for MemoryIdempotencyStore to prevent unbounded entry accumulation in long-running instances - Add IDEMPOTENCY_TTL_SECS env var (default 86400) to make TTL tunable - Document idem: key prefix in .env.example for production debugging Co-authored-by: oomokaro1 <oomokaro25@gmail.com>
oomokaro1
left a comment
There was a problem hiding this comment.
@Jaydbrown Pushed the fixes for your review comments:
getfail-open —payments.rsnow logs a warning and proceeds as first request on Redis error, matching thesetpath behavior.- Periodic eviction —
MemoryIdempotencyStorenow spawns a background task that evicts expired entries every TTL/4 interval. IDEMPOTENCY_TTL_SECS— Added configurable TTL via env var (default 86400), documented in.env.example.idem:prefix — Documented in.env.examplefor production key inspection.
Note: async-trait is already in Cargo.toml (line 30) — no change needed there.
Ready for re-review when you have a chance.
|
Hey, just flagging that the CI is currently failing on this PR. The Format check step is erroring out — |
Co-authored-by: oomokaro1 <oomokaro25@gmail.com>
oomokaro1
left a comment
There was a problem hiding this comment.
@Jaydbrown CI is stuck on action_required — looks like it needs a maintainer approval to run since this is a fork-based PR. Could you approve the workflow at https://github.com/Nodus-protocol/Nodus-Protocol-Core-Engine/actions/runs/27794201643 ? The fmt fix is already pushed, should pass once CI runs.
Closes #52
Type of Change
Summary
The idempotency store used an in-memory
DashMapthat was lost on every engine restart. This allowed duplicate payments when a client retried a request after a deploy or crash. This PR replaces the in-memory store with a trait-based abstraction supporting Redis-backed persistence, with automatic fallback to in-memory when Redis is unavailable.Motivation / Context
Fixes #52 —
idempotency.rsstores keys in memory only, so process restart loses all idempotency guarantees. In a Kubernetes rolling deploy, at least one pod restarts every deploy. Mobile clients that retry on timeout (standard behavior) can trigger duplicate payments.Detailed Changes
src/idempotency.rs— IntroducedIdempotencyStoreasync trait with two implementations:RedisIdempotencyStore: Usesredis::aio::ConnectionManagerfor auto-reconnecting persistent storage. Keys stored with native Redis TTL viaSET EX.MemoryIdempotencyStore: Wraps existingDashMapfor dev/test environments (no Redis required).create_idempotency_store()selects backend based onREDIS_URLconfig, with graceful fallback to memory on Redis connection failure.src/config.rs— Addedredis_url: Option<String>field, read fromREDIS_URLenv var.src/engine/mod.rs—Engine::new()now acceptsArc<dyn IdempotencyStore>via dependency injection.idempotency()returns&dyn IdempotencyStore.src/api/payments.rs— Idempotencyget/setcalls are now async.geterrors propagate as 500;seterrors are logged but don't fail the response (payment already succeeded).src/main.rs— Constructs idempotency store from config, passes to Engine..env.example— DocumentedREDIS_URLwith usage notes.tests/integration_test.rs— Updated to useMemoryIdempotencyStoreand async idempotency API.Current Behavior vs. New Behavior
Before:
IdempotencyStoreusesDashMap— all keys lost on restart. Duplicate payments possible on retry after deploy/crash.After: When
REDIS_URLis set, keys persist in Redis across restarts with 24h TTL. When unset, falls back to in-memory (same as before). Redis unavailable at startup logs a warning and falls back to memory.Testing
MemoryIdempotencyStore(store/retrieve, missing key, eviction, overwrite)cargo fmt --checkBreaking Changes
No
Risks and Rollback
Risks:
redisdependency adds ~1MB to binary. Redis connection at startup adds ~100ms if URL is configured.Rollback: Remove
REDIS_URLfrom env — engine reverts to in-memory store automatically.Checklist
Self-Review
Testing
CI / Pipeline
cargo fmt,cargo clippy,cargo test)Documentation
.env.example)Reviewer Notes
src/idempotency.rsis the core change — review the trait definition, Redis implementation, and factory function.MemoryIdempotencyStoreis identical to the previous in-memory behavior, just wrapped in the trait.seterrors inpayments.rsare logged-and-continued (not propagated) because the payment already succeeded — this is a deliberate design choice.