Skip to content

Maintenance: extract the InvokeStore/instance-fallback branch into a shared primitive #5554

Description

@svozza

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    internalPRs that introduce changes in governance, tech debt and chores (linting setup, baseline, etc.)

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions