Summary
Every utility that holds invocation-scoped state has grown its own *Store class that resolves, on each accessor, whether to read/write the InvokeStore (when invocations may run concurrently in the same execution environment) or an instance-level fallback field (when they run sequentially). The decision has to be made per access rather than once at construction, so the same five-line branch — check shouldUseInvokeStore(), assert globalThis.awslambda.InvokeStore exists, get-or-lazily-create under a symbol key, otherwise touch a #fallback* field — is currently written out 36 times across 7 files in 3 packages (LogAttributesStore, LogInvocationStore, DimensionsStore, MetricsStore, MetadataStore, BatchProcessingStore, SqsFifoProcessorStore).
The per-feature store abstraction itself is working well: Logger, Metrics and BasePartialProcessor ask only for domain values and contain no runtime detail. The proposal is to keep that layer and move the mode-resolution one level down, into a single primitive in commons, so the stores become declarations of what they hold plus their domain logic.
Why is this needed?
Duplicating the branch per accessor has already produced divergence in how the same situation is handled:
LogInvocationStore.setLogLevel guards on store.hasContext() before writing; LogAttributesStore never does, so appendKeys()/addContext() called outside an invocation (module scope, or a test that doesn't wrap in InvokeStore.run()) goes straight to store.get/store.set with no active context.
BatchProcessingStore asserts InvokeStore availability in 2 of its 14 accessors; the other 12 go straight to globalThis.awslambda?.InvokeStore, so the same failure surfaces as a TypeError in some paths and a clear Error in others.
It also means the branch matrix (flag off / flag on but global missing / flag on with no active context / fully scoped) is re-tested indirectly through each store's public API instead of once, directly. Consolidating gets it under commons' 100% coverage gate in one place, and makes future changes — new Lambda execution modes, changes to how the RIC exposes context — a single edit rather than 36.
Solution
Add a small primitive to commons that owns only the mode resolution. Rough shape, names to be settled in review:
/**
* A value stored per invocation when invocations may run concurrently in the
* same execution environment, and per instance otherwise. The storage is
* resolved on every access, so a value created during initialization keeps
* working once an invocation context exists.
*/
class InvocationScoped<T> {
/**
* @param name - Description of the value, used for the storage key
* @param options.initial - Value shared across invocations until one sets its own
* @param options.fresh - Creates the value, called once per invocation on first access
*/
constructor(name: string, options: { initial: T } | { fresh: () => T });
/** The invocation-scoped value when there is one, otherwise the shared value. */
get(): T;
/** Sets the value for the current invocation, or the shared value when no invocation context is active. */
set(value: T): void;
/**
* Discards the invocation-scoped value: a `fresh` cell gets a newly created
* one, an `initial` cell falls back to the shared value.
*/
reset(): void;
/** The value shared across invocations, ignoring any invocation context. */
getShared(): T;
setShared(value: T): void;
/** Whether reads and writes are currently scoped to a single invocation. */
get isScoped(): boolean;
}
initial and fresh are the two constructor option shapes, not members, and they cover both existing needs: initial for values that fall back to a shared default (the log level's base value, the Lambda context), fresh for containers that must be created per invocation (attribute records, the keys map, dimension objects, the log buffer). getShared/setShared replace what LogInvocationStore currently hand-rolls as getBaseLogLevel/setBaseLogLevel. isScoped exists because a couple of places have a genuine semantic dependence on the mode rather than a storage one — LogInvocationStore.add() clears a stale trace id's entries only when the buffer isn't per-invocation — and those should ask the cell instead of re-reading the environment.
Stores then reduce to declarations plus domain logic, e.g.:
readonly #logLevel = new InvocationScoped<number>(
'powertools.logger.logLevel', { initial: LogLevelThreshold.INFO }
);
readonly #temporaryAttributes = new InvocationScoped<LogAttributes>(
'powertools.logger.temporaryAttributes', { fresh: () => ({}) }
);
It needs a subpath export (exports + typesVersions) because the consuming packages resolve their imports at customer runtime. Following the ./utils/lru-cache precedent: @internal, absent from the README, options type not re-exported from ./types.
Not in scope: copy semantics. Part of the original intent behind the stores was that they hand out values rather than references, so a caller can't retain a handle on invocation-scoped state and mutate it from another invocation — a class of concurrency bug removed by construction rather than by discipline. Batch forced that to loosen (BasePartialProcessor.records is read per record in the processing loop, and SqsFifoProcessorStore.addFailedGroupId mutates the live Set it gets back), so copying can't be baked into the primitive and has to be opt-in. The primitive above therefore stays reference-based and unchanged in that respect: this issue moves the mode branch only, and every store keeps its current copy behaviour verbatim. How the by-value guarantee gets layered on — and the three places it currently doesn't hold (getLambdaContext, MetricsStore.getMetric/getAllMetrics, and batch's public getters) — will be a follow-up issue, since each is a behaviour change on a public surface.
Which area does this relate to?
Commons, Logger, Metrics, Batch Processing
Acknowledgment
Future readers
Please react with 👍 and your use case to help us understand customer demand.
Summary
Every utility that holds invocation-scoped state has grown its own
*Storeclass that resolves, on each accessor, whether to read/write theInvokeStore(when invocations may run concurrently in the same execution environment) or an instance-level fallback field (when they run sequentially). The decision has to be made per access rather than once at construction, so the same five-line branch — checkshouldUseInvokeStore(), assertglobalThis.awslambda.InvokeStoreexists, get-or-lazily-create under a symbol key, otherwise touch a#fallback*field — is currently written out 36 times across 7 files in 3 packages (LogAttributesStore,LogInvocationStore,DimensionsStore,MetricsStore,MetadataStore,BatchProcessingStore,SqsFifoProcessorStore).The per-feature store abstraction itself is working well:
Logger,MetricsandBasePartialProcessorask only for domain values and contain no runtime detail. The proposal is to keep that layer and move the mode-resolution one level down, into a single primitive incommons, so the stores become declarations of what they hold plus their domain logic.Why is this needed?
Duplicating the branch per accessor has already produced divergence in how the same situation is handled:
LogInvocationStore.setLogLevelguards onstore.hasContext()before writing;LogAttributesStorenever does, soappendKeys()/addContext()called outside an invocation (module scope, or a test that doesn't wrap inInvokeStore.run()) goes straight tostore.get/store.setwith no active context.BatchProcessingStoreassertsInvokeStoreavailability in 2 of its 14 accessors; the other 12 go straight toglobalThis.awslambda?.InvokeStore, so the same failure surfaces as aTypeErrorin some paths and a clearErrorin others.It also means the branch matrix (flag off / flag on but global missing / flag on with no active context / fully scoped) is re-tested indirectly through each store's public API instead of once, directly. Consolidating gets it under
commons' 100% coverage gate in one place, and makes future changes — new Lambda execution modes, changes to how the RIC exposes context — a single edit rather than 36.Solution
Add a small primitive to
commonsthat owns only the mode resolution. Rough shape, names to be settled in review:initialandfreshare the two constructor option shapes, not members, and they cover both existing needs:initialfor values that fall back to a shared default (the log level's base value, the Lambda context),freshfor containers that must be created per invocation (attribute records, the keys map, dimension objects, the log buffer).getShared/setSharedreplace whatLogInvocationStorecurrently hand-rolls asgetBaseLogLevel/setBaseLogLevel.isScopedexists because a couple of places have a genuine semantic dependence on the mode rather than a storage one —LogInvocationStore.add()clears a stale trace id's entries only when the buffer isn't per-invocation — and those should ask the cell instead of re-reading the environment.Stores then reduce to declarations plus domain logic, e.g.:
It needs a subpath export (
exports+typesVersions) because the consuming packages resolve their imports at customer runtime. Following the./utils/lru-cacheprecedent:@internal, absent from the README, options type not re-exported from./types.Not in scope: copy semantics. Part of the original intent behind the stores was that they hand out values rather than references, so a caller can't retain a handle on invocation-scoped state and mutate it from another invocation — a class of concurrency bug removed by construction rather than by discipline. Batch forced that to loosen (
BasePartialProcessor.recordsis read per record in the processing loop, andSqsFifoProcessorStore.addFailedGroupIdmutates the liveSetit gets back), so copying can't be baked into the primitive and has to be opt-in. The primitive above therefore stays reference-based and unchanged in that respect: this issue moves the mode branch only, and every store keeps its current copy behaviour verbatim. How the by-value guarantee gets layered on — and the three places it currently doesn't hold (getLambdaContext,MetricsStore.getMetric/getAllMetrics, and batch's public getters) — will be a follow-up issue, since each is a behaviour change on a public surface.Which area does this relate to?
Commons, Logger, Metrics, Batch Processing
Acknowledgment
Future readers
Please react with 👍 and your use case to help us understand customer demand.