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
- Configure two
makeIdempotent wrappers with different keyPrefix values against the same persistence store instance and the same config.
- Have the outer wrapped function call the inner wrapped function with the same payload.
- Invoke the outer function once. Inspect the store: the
inner#... record holds the outer result and the outer#... record is still INPROGRESS.
- Invoke the inner function again with the same payload. It returns the outer result instead of its own.
- 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
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
BasePersistenceLayerkeeps the key prefix as mutable instance state. EveryIdempotencyHandlerconstruction callsconfigure()on the shared store and overwrites that prefix with its ownkeyPrefix. Each persistence call recomputes the idempotency key from whatever prefix was set most recently, not from the prefix the operation started with.When an
outeroperation awaits aninneroperation on the same store, the inner call switches the prefix toinner. After it returns, the outer operation'ssaveSuccess()hashes underinner#...and overwrites the inner record with the outer result. Theouter#...record is leftINPROGRESSuntil it expires. A retry ofinnerwith the same payload then returns the outer result, and a retry ofouterthrowsIdempotencyAlreadyInProgressError.No concurrency is needed. A plain nested
awaitis enough. This is separate from the documented restriction on sharing a store across differentIdempotencyConfigsettings: the reproduction uses one config, and the documentation recommends sharing a store in most cases.Code snippet
Steps to Reproduce
makeIdempotentwrappers with differentkeyPrefixvalues against the same persistence store instance and the same config.inner#...record holds the outer result and theouter#...record is stillINPROGRESS.Possible Solution
Have
IdempotencyHandlercapture 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