Skip to content

Bug: Idempotency operations sharing a persistence store complete under the wrong key prefix #5707

Description

@svozza

Expected Behavior

When several idempotent operations share one persistence store instance, each operation should read, write, and delete only the record it acquired. A nested idempotent call should not change which record the enclosing operation completes, and retrying the inner operation should replay the inner result.

Current Behavior

BasePersistenceLayer keeps the key prefix as mutable instance state. Every IdempotencyHandler construction calls configure() on the shared store and overwrites that prefix with its own keyPrefix. Each persistence call recomputes the idempotency key from whatever prefix was set most recently, not from the prefix the operation started with.

When an outer operation awaits an inner operation on the same store, the inner call switches the prefix to inner. After it returns, the outer operation's saveSuccess() hashes under inner#... and overwrites the inner record with the outer result. The outer#... record is left INPROGRESS until it expires. A retry of inner with the same payload then returns the outer result, and a retry of outer throws IdempotencyAlreadyInProgressError.

No concurrency is needed. A plain nested await is enough. This is separate from the documented restriction on sharing a store across different IdempotencyConfig settings: the reproduction uses one config, and the documentation recommends sharing a store in most cases.

Code snippet

import assert from 'node:assert/strict';
import {
  IdempotencyConfig,
  makeIdempotent,
} from '@aws-lambda-powertools/idempotency';
import { DynamoDBPersistenceLayer } from '@aws-lambda-powertools/idempotency/dynamodb';

const persistenceStore = new DynamoDBPersistenceLayer({
  tableName: process.env.IDEMPOTENCY_TABLE_NAME ?? 'idempotency',
});
const config = new IdempotencyConfig({ expiresAfterSeconds: 3600 });

const inner = makeIdempotent(async (_order: { id: string }) => 'inner', {
  persistenceStore,
  config,
  keyPrefix: 'inner',
});

const outer = makeIdempotent(
  async (order: { id: string }) => `outer:${await inner(order)}`,
  { persistenceStore, config, keyPrefix: 'outer' }
);

const order = { id: 'order-1' };

assert.equal(await outer(order), 'outer:inner');

// Should replay the inner result. Currently returns 'outer:inner' because the
// outer completion overwrote the inner record.
assert.equal(await inner(order), 'inner');

Steps to Reproduce

  1. Configure two makeIdempotent wrappers with different keyPrefix values against the same persistence store instance and the same config.
  2. Have the outer wrapped function call the inner wrapped function with the same payload.
  3. Invoke the outer function once. Inspect the store: the inner#... record holds the outer result and the outer#... record is still INPROGRESS.
  4. Invoke the inner function again with the same payload. It returns the outer result instead of its own.
  5. As a control, give each wrapper its own persistence store instance. Both records complete correctly and replays return the right result.

Possible Solution

Have IdempotencyHandler capture the prefix the store resolved at construction and restore it on the store immediately before every persistence call: save in progress, get record, process existing record, save success, and delete. Key hashing happens synchronously at the start of each of those store methods, so the prefix cannot change between restore and use, even with interleaved operations.

A fuller change would bind record identity to the operation instead of to store state, which would also cover input mutation between acquisition and completion. That is a larger API change and can be tackled separately.

Powertools for AWS Lambda (TypeScript) version

2.35.0

AWS Lambda function runtime

22.x

Packaging format used

npm

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    confirmedThe scope is clear, ready for implementationidempotencyThis item relates to the Idempotency Utility

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions