fix(idempotency): Redis persistence layer reclaims live in-progress records as orphans, allowing concurrent double-execution - #8387
Conversation
…ecords as orphans, allowing concurrent double-execution
RedisCachePersistenceLayer._put_in_progress_record() only guarded against a
competing invocation when the existing record's in_progress_expiry_timestamp
was set AND still in the future. When that field is None -- which is the
normal case for idempotent_function, since it never calls
config.register_lambda_context(), unlike the idempotent() handler decorator --
the guard was skipped entirely and a genuinely still-running invocation's
record fell through to the "orphan record" branch, which unconditionally
overwrites the record with no NX guard. A second concurrent invocation with
the same idempotency key would then proceed to execute the underlying
function too, defeating idempotency (e.g. double-charging a customer).
This is exactly what persistence/base.py's own warning at that call site
flags ("Couldn't determine the remaining time left. Did you call
register_lambda_context on IdempotencyConfig?") -- it warns, but nothing
downstream actually failed closed on it.
The DynamoDB persistence layer does not have this gap: its conditional
expression requires attribute_exists(#in_progress_expiry) before allowing an
expired-in-progress reclaim, so a missing attribute correctly blocks the
competing writer instead of granting a reclaim.
Fix: when status is INPROGRESS and in_progress_expiry_timestamp is None,
treat the record as still in progress (fail closed) instead of falling
through to the orphan-reclaim path, mirroring the DynamoDB layer's behavior.
|
Thanks a lot for your first contribution! Please check out our contributing guidelines and don't hesitate to ask whatever you need. |
|
leandrodamascena
left a comment
There was a problem hiding this comment.
Thanks for finding and fixing this issue. I reproduced the double execution through the public idempotent_function and CachePersistenceLayer APIs and confirmed the fix works.
I pushed a follow-up commit with a deterministic concurrency test. It keeps the first invocation running, starts a second invocation with the same key, and verifies that the function executes only once while the second invocation receives IdempotencyAlreadyInProgressError.
I also updated the lower-level test to use a different contender record, so its assertion now proves that the original record was not overwritten, and fixed the formatting issue.
The fix and coverage now look correct to me. Thank you for working on this.
|
Tick the box to add this pull request to the merge queue (same as
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## develop #8387 +/- ##
===========================================
+ Coverage 96.64% 96.65% +0.01%
===========================================
Files 296 296
Lines 14767 14767
Branches 1246 1246
===========================================
+ Hits 14271 14273 +2
+ Misses 361 359 -2
Partials 135 135 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|



Issue number
Fixes #8386
Summary
Fixes a race condition in
RedisCachePersistenceLayer._put_in_progress_record()where a genuinely still-running in-progress record gets wrongly reclaimed as an "orphan" and overwritten, letting a second concurrent invocation with the same idempotency key execute the underlying function too — defeating idempotency (e.g. double-charging a customer).Changes
_put_in_progress_recordonly guarded against a competing invocation when the existing record'sin_progress_expiry_timestampwas set and still in the future:When
in_progress_expiry_timestampisNone, this guard is skipped entirely, and the code falls through to the orphan-reclaim branch, which unconditionally overwrites the record (noNX) — even though the record may represent a genuinely still-running invocation.in_progress_expiry_timestampisNonewheneverremaining_time_in_milliswasn't available whensave_inprogresswas called, which happens wheneverIdempotencyConfig.register_lambda_context()was never invoked. This is the default/common case foridempotent_function— unlike theidempotent()handler decorator (which callsconfig.register_lambda_context(context)),idempotent_function'sdecorate()never calls it. So any typical@idempotent_function(...)usage (e.g. inside an SQS batch loop) hits this by default.The fix:
When the expiry timestamp is missing, we now fail closed (treat it as still in progress) instead of falling through to orphan-reclaim — mirroring
persistence/dynamodb.py, whose conditional-write expression requiresattribute_exists(#in_progress_expiry)before allowing an expired-in-progress reclaim.This only changes behavior for the specific case where
in_progress_expiry_timestampisNone; the existing orphan-reclaim path for records that legitimately timed out (timestamp set and in the past) is unchanged.User experience
Before this fix, using
@idempotent_functionwithRedisCachePersistenceLayer/CachePersistenceLayer(without separately wiringregister_lambda_context) could silently execute a decorated function twice for the same idempotency key under concurrent retries. After this fix, the second concurrent invocation is correctly rejected withIdempotencyItemAlreadyExistsErroruntil the first one completes or its record's own TTL expires.Checklist
developbranch docs, if applicable — N/A, this is a bug fix with no behavior/API change for the documented, correct-usage path (it only changes behavior for a previously-unsafe implicit case)Is this a breaking change?
NO. The existing orphan-reclaim path for records with a set, expired
in_progress_expiry_timestampis unchanged. Only the (previously unsafe)in_progress_expiry_timestamp is Nonecase now fails closed instead of failing open.Tests
Added
test_redis_in_progress_record_missing_expiry_is_not_treated_as_orphanintests/functional/idempotency/_redis/test_redis_layer.py. It fails against the pre-fix code (Failed: DID NOT RAISE IdempotencyItemAlreadyExistsError) and passes after the fix — verified by reverting the fix locally and re-running.Full suite
tests/functional/idempotency/_redis/test_redis_layer.py: 21 passed (independently re-verified).By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.