Skip to content

Redis idempotency persistence layer reclaims a live in-progress record as an orphan, allowing concurrent double-execution #8386

Description

@Adityaj0

Expected Behaviour

When two concurrent Lambda invocations use the same idempotency key with RedisCachePersistenceLayer (or CachePersistenceLayer), and the first invocation is still genuinely running, the second invocation should be rejected with IdempotencyItemAlreadyExistsError — not proceed to execute the underlying function.

Current Behaviour

If the in-progress record's in_progress_expiry_timestamp is None, the second invocation incorrectly treats the first (still-running) invocation's record as an abandoned "orphan" and overwrites it, then proceeds to execute the function — even though the first invocation is still actively running.

in_progress_expiry_timestamp is None whenever remaining_time_in_millis was not provided when save_inprogress was called (persistence/base.py), which happens whenever IdempotencyConfig.register_lambda_context() was never called. Concretely, this is the normal, default state for idempotent_function: unlike the idempotent() handler decorator (which does call config.register_lambda_context(context)), idempotent_function's decorate() never calls register_lambda_context() at all. So any typical use of @idempotent_function(...) — e.g. to make a helper function idempotent inside an SQS batch loop — hits this gap by default.

Root cause

aws_lambda_powertools/utilities/idempotency/persistence/redis.py, _put_in_progress_record:

idempotency_record = self._get_record(data_record.idempotency_key)

if idempotency_record.status == STATUS_CONSTANTS["COMPLETED"] and not idempotency_record.is_expired:
    raise IdempotencyItemAlreadyExistsError

if (
    idempotency_record.status == STATUS_CONSTANTS["INPROGRESS"]
    and idempotency_record.in_progress_expiry_timestamp
    and idempotency_record.in_progress_expiry_timestamp > int(now.timestamp() * 1000)
):
    raise IdempotencyItemAlreadyExistsError

# Reaching this point indicates that the idempotency record found is an orphan record...
raise IdempotencyPersistenceConsistencyError

The INPROGRESS guard requires in_progress_expiry_timestamp to be truthy. When it's None (the common idempotent_function case above), the guard is skipped entirely regardless of whether the record is genuinely, actively in progress — execution falls straight through to IdempotencyPersistenceConsistencyError, which is caught and leads to an unconditional overwrite with no NX guard:

except IdempotencyPersistenceConsistencyError:
    with self._acquire_lock(name=item["name"]):
        self.client.set(name=item["name"], value=encoded_item, ex=ttl)

By contrast, persistence/dynamodb.py's conditional-write expression requires attribute_exists(#in_progress_expiry) before allowing an expired-in-progress reclaim — a missing attribute correctly blocks the competing writer there instead of granting a reclaim. The Redis layer's None-is-falsy check inverts this safety property: it fails open instead of closed.

Notably, persistence/base.py's save_inprogress already logs a warning for exactly this situation:

UserWarning: Couldn't determine the remaining time left. Did you call register_lambda_context on IdempotencyConfig?

...but nothing downstream actually fails closed on it for the Redis/Cache persistence layer.

Concrete reproduction

  1. A function is decorated with @idempotent_function(persistence_store=RedisCachePersistenceLayer(...)), without a config that has register_lambda_context() called (the default/common case for this decorator).
  2. Invocation A calls save_inprogress → Redis SET key NX succeeds → begins e.g. a payment charge that takes several seconds.
  3. A retried/duplicate invocation B with the same idempotency key calls save_inprogress while A is still running → SET key NX fails (key exists) → fetches A's record: status=INPROGRESS, in_progress_expiry_timestamp=None.
  4. The COMPLETED check fails; the INPROGRESS-with-valid-expiry check fails because None is falsy (even though A is actively running) → falls through to IdempotencyPersistenceConsistencyError → treated as an orphan.
  5. B acquires the orphan-reclaim lock and does client.set(..., ex=ttl) with no nx, unconditionally overwriting A's still-live INPROGRESS record.
  6. save_inprogress returns normally for B → the underlying function executes a second time, concurrently with A. E.g., a customer is charged twice.

Possible Solution

When status == INPROGRESS and in_progress_expiry_timestamp is None, treat the record as still in progress (fail closed) rather than falling through to the orphan-reclaim path — mirroring the DynamoDB persistence layer's attribute_exists requirement. I have a fix + regression test ready and will open a PR referencing this issue.

Steps to Reproduce

See "Concrete reproduction" above — reproducible via unit test against RedisCachePersistenceLayer._put_in_progress_record() using a DataRecord with status=INPROGRESS and in_progress_expiry_timestamp=None.

Powertools for AWS Lambda (Python) version

latest (develop, v3.34.0)

AWS Lambda function runtime

3.12

Debugging logs

N/A — logic bug reproducible via unit test, not runtime-log-dependent.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status
    Triage

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions