diff --git a/package-lock.json b/package-lock.json index c6a39567de..640740e12e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10818,7 +10818,8 @@ "version": "2.27.0", "license": "MIT-0", "dependencies": { - "@aws-lambda-powertools/commons": "2.27.0" + "@aws-lambda-powertools/commons": "2.27.0", + "@aws/lambda-invoke-store": "^0.1.0" }, "devDependencies": { "@aws-lambda-powertools/testing-utils": "file:../testing", @@ -10835,6 +10836,15 @@ } } }, + "packages/metrics/node_modules/@aws/lambda-invoke-store": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.1.0.tgz", + "integrity": "sha512-I1y5yahbSFTfKldV4qoKv2IEZ20QOhn5rPvWwGnswZ8hssN7tsLANLg9tL8dp2klz2MZDGL5jZrvBwplIWtM8A==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "packages/parameters": { "name": "@aws-lambda-powertools/parameters", "version": "2.27.0", diff --git a/packages/metrics/package.json b/packages/metrics/package.json index c6fe42c47a..e50789a747 100644 --- a/packages/metrics/package.json +++ b/packages/metrics/package.json @@ -88,7 +88,8 @@ "url": "https://github.com/aws-powertools/powertools-lambda-typescript/issues" }, "dependencies": { - "@aws-lambda-powertools/commons": "2.27.0" + "@aws-lambda-powertools/commons": "2.27.0", + "@aws/lambda-invoke-store": "0.1.0" }, "keywords": [ "aws", diff --git a/packages/metrics/src/DimensionsStore.ts b/packages/metrics/src/DimensionsStore.ts new file mode 100644 index 0000000000..0ce43132e7 --- /dev/null +++ b/packages/metrics/src/DimensionsStore.ts @@ -0,0 +1,104 @@ +import { InvokeStore } from '@aws/lambda-invoke-store'; +import type { Dimensions } from './types/Metrics.js'; + +/** + * Manages storage of metrics dimensions with automatic context detection. + * + * This class abstracts the storage mechanism for metrics, automatically + * choosing between AsyncLocalStorage (when in async context) and a fallback + * object (when outside async context). The decision is made at runtime on + * every method call to support Lambda's transition to async contexts. + */ +class DimensionsStore { + readonly #dimensionsKey = Symbol('powertools.metrics.dimensions'); + readonly #dimensionSetsKey = Symbol('powertools.metrics.dimensionSets'); + + #fallbackDimensions: Dimensions = {}; + #fallbackDimensionSets: Dimensions[] = []; + #defaultDimensions: Dimensions = {}; + + #getDimensions(): Dimensions { + if (InvokeStore.getContext() === undefined) { + return this.#fallbackDimensions; + } + + let stored = InvokeStore.get(this.#dimensionsKey) as Dimensions | undefined; + if (stored == null) { + stored = {}; + InvokeStore.set(this.#dimensionsKey, stored); + } + return stored; + } + + #getDimensionSets(): Dimensions[] { + if (InvokeStore.getContext() === undefined) { + return this.#fallbackDimensionSets; + } + + let stored = InvokeStore.get(this.#dimensionSetsKey) as + | Dimensions[] + | undefined; + if (stored == null) { + stored = []; + InvokeStore.set(this.#dimensionSetsKey, stored); + } + return stored; + } + + public addDimension(name: string, value: string): string { + this.#getDimensions()[name] = value; + return value; + } + + public addDimensionSet(dimensionSet: Dimensions): Dimensions { + this.#getDimensionSets().push({ ...dimensionSet }); + return dimensionSet; + } + + public getDimensions(): Dimensions { + return { ...this.#getDimensions() }; + } + + public getDimensionSets(): Dimensions[] { + return this.#getDimensionSets().map((set) => ({ ...set })); + } + + public clearRequestDimensions(): void { + if (InvokeStore.getContext() === undefined) { + this.#fallbackDimensions = {}; + this.#fallbackDimensionSets = []; + return; + } + + InvokeStore.set(this.#dimensionsKey, {}); + InvokeStore.set(this.#dimensionSetsKey, []); + } + + public clearDefaultDimensions(): void { + this.#defaultDimensions = {}; + } + + public getDimensionCount(): number { + const dimensions = this.#getDimensions(); + const dimensionSets = this.#getDimensionSets(); + const dimensionSetsCount = dimensionSets.reduce( + (total, dimensionSet) => total + Object.keys(dimensionSet).length, + 0 + ); + return ( + Object.keys(dimensions).length + + Object.keys(this.#defaultDimensions).length + + dimensionSetsCount + ); + } + + public setDefaultDimensions(dimensions: Dimensions): void { + this.#defaultDimensions = { ...dimensions }; + } + + public getDefaultDimensions(): Dimensions { + return { ...this.#defaultDimensions }; + } +} + +export { DimensionsStore }; diff --git a/packages/metrics/src/MetadataStore.ts b/packages/metrics/src/MetadataStore.ts new file mode 100644 index 0000000000..833048e442 --- /dev/null +++ b/packages/metrics/src/MetadataStore.ts @@ -0,0 +1,50 @@ +import { InvokeStore } from '@aws/lambda-invoke-store'; + +/** + * Manages storage of metrics #metadata with automatic context detection. + * + * This class abstracts the storage mechanism for metrics, automatically + * choosing between AsyncLocalStorage (when in async context) and a fallback + * object (when outside async context). The decision is made at runtime on + * every method call to support Lambda's transition to async contexts. + */ +class MetadataStore { + readonly #metadataKey = Symbol('powertools.metrics.metadata'); + + #fallbackStorage: Record = {}; + + #getStorage(): Record { + if (InvokeStore.getContext() === undefined) { + return this.#fallbackStorage; + } + + let stored = InvokeStore.get(this.#metadataKey) as + | Record + | undefined; + if (stored == null) { + stored = {}; + InvokeStore.set(this.#metadataKey, stored); + } + return stored; + } + + public set(key: string, value: string): string { + this.#getStorage()[key] = value; + return value; + } + + public getAll(): Record { + return { ...this.#getStorage() }; + } + + public clear(): void { + if (InvokeStore.getContext() === undefined) { + this.#fallbackStorage = {}; + return; + } + + InvokeStore.set(this.#metadataKey, {}); + } +} + +export { MetadataStore }; diff --git a/packages/metrics/src/Metrics.ts b/packages/metrics/src/Metrics.ts index ad9e2de7f4..c29160e89b 100644 --- a/packages/metrics/src/Metrics.ts +++ b/packages/metrics/src/Metrics.ts @@ -30,6 +30,9 @@ import { MetricUnit as MetricUnits, MIN_METRIC_NAME_LENGTH, } from './constants.js'; +import { DimensionsStore } from './DimensionsStore.js'; +import { MetadataStore } from './MetadataStore.js'; +import { MetricsStore } from './MetricsStore.js'; import type { ConfigServiceInterface, Dimensions, @@ -40,7 +43,6 @@ import type { MetricsInterface, MetricsOptions, MetricUnit, - StoredMetrics, } from './types/index.js'; /** @@ -156,22 +158,9 @@ class Metrics extends Utility implements MetricsInterface { private customConfigService?: ConfigServiceInterface; /** - * Default dimensions to be added to all metrics - * @default {} + * Storage for dimensions */ - private defaultDimensions: Dimensions = {}; - - /** - * Additional dimensions for the current metrics context - * @default {} - */ - private dimensions: Dimensions = {}; - - /** - * Additional dimension sets for the current metrics context - * @default [] - */ - private dimensionSets: Dimensions[] = []; + readonly #dimensionsStore = new DimensionsStore(); /** * Name of the Lambda function @@ -193,9 +182,8 @@ class Metrics extends Utility implements MetricsInterface { /** * Additional metadata to be included with metrics - * @default {} */ - private metadata: Record = {}; + readonly #metadataStore = new MetadataStore(); /** * Namespace for the metrics @@ -210,9 +198,8 @@ class Metrics extends Utility implements MetricsInterface { /** * Storage for metrics before they are published - * @default {} */ - private storedMetrics: StoredMetrics = {}; + readonly #metricsStore = new MetricsStore(); /** * Whether to disable metrics @@ -231,15 +218,8 @@ class Metrics extends Utility implements MetricsInterface { devMode: false, }; - /** - * Custom timestamp for the metrics - */ - #timestamp?: number; - public constructor(options: MetricsOptions = {}) { super(); - - this.dimensions = {}; this.setEnvConfig(); this.setConsole(); this.#logger = options.logger || this.console; @@ -266,20 +246,22 @@ class Metrics extends Utility implements MetricsInterface { ); return; } - if (MAX_DIMENSION_COUNT <= this.getCurrentDimensionsCount()) { + if (MAX_DIMENSION_COUNT <= this.#dimensionsStore.getDimensionCount()) { throw new RangeError( `The number of metric dimensions must be lower than ${MAX_DIMENSION_COUNT}` ); } + const dimensions = this.#dimensionsStore.getDimensions(); + const defaultDimensions = this.#dimensionsStore.getDefaultDimensions(); if ( - Object.hasOwn(this.dimensions, name) || - Object.hasOwn(this.defaultDimensions, name) + Object.hasOwn(dimensions, name) || + Object.hasOwn(defaultDimensions, name) ) { this.#logger.warn( `Dimension "${name}" has already been added. The previous value will be overwritten.` ); } - this.dimensions[name] = value; + this.#dimensionsStore.addDimension(name, value); } /** @@ -296,7 +278,7 @@ class Metrics extends Utility implements MetricsInterface { */ public addDimensions(dimensions: Dimensions): void { const newDimensions = this.#sanitizeDimensions(dimensions); - const currentCount = this.getCurrentDimensionsCount(); + const currentCount = this.#dimensionsStore.getDimensionCount(); const newSetCount = Object.keys(newDimensions).length; if (currentCount + newSetCount >= MAX_DIMENSION_COUNT) { throw new RangeError( @@ -304,7 +286,7 @@ class Metrics extends Utility implements MetricsInterface { ); } - this.dimensionSets.push(newDimensions); + this.#dimensionsStore.addDimensionSet(newDimensions); } /** @@ -335,7 +317,7 @@ class Metrics extends Utility implements MetricsInterface { * @param value - The value of the metadata */ public addMetadata(key: string, value: string): void { - this.metadata[key] = value; + this.#metadataStore.set(key, value); } /** @@ -442,7 +424,7 @@ class Metrics extends Utility implements MetricsInterface { * ``` */ public clearDefaultDimensions(): void { - this.defaultDimensions = {}; + this.#dimensionsStore.clearDefaultDimensions(); } /** @@ -475,8 +457,7 @@ class Metrics extends Utility implements MetricsInterface { * The method is primarily intended for internal use, but it is exposed for advanced use cases. */ public clearDimensions(): void { - this.dimensions = {}; - this.dimensionSets = []; + this.#dimensionsStore.clearRequestDimensions(); } /** @@ -488,7 +469,7 @@ class Metrics extends Utility implements MetricsInterface { * The method is primarily intended for internal use, but it is exposed for advanced use cases. */ public clearMetadata(): void { - this.metadata = {}; + this.#metadataStore.clear(); } /** @@ -499,14 +480,14 @@ class Metrics extends Utility implements MetricsInterface { * The method is primarily intended for internal use, but it is exposed for advanced use cases. */ public clearMetrics(): void { - this.storedMetrics = {}; + this.#metricsStore.clearMetrics(); } /** * Check if there are stored metrics in the buffer. */ public hasStoredMetrics(): boolean { - return Object.keys(this.storedMetrics).length > 0; + return this.#metricsStore.hasMetrics(); } /** @@ -670,7 +651,7 @@ class Metrics extends Utility implements MetricsInterface { 'Ensure the timestamp is within 14 days in the past or up to 2 hours in the future and is also a valid number or Date object.' ); } - this.#timestamp = this.#convertTimestampToEmfFormat(timestamp); + this.#metricsStore.setTimestamp(timestamp); } /** @@ -686,16 +667,17 @@ class Metrics extends Utility implements MetricsInterface { * The object is then emitted to standard output, which in AWS Lambda is picked up by CloudWatch logs and processed asynchronously. */ public serializeMetrics(): EmfOutput { - // Storage resolution is included only for High resolution metrics - const metricDefinitions: MetricDefinition[] = Object.values( - this.storedMetrics - ).map((metricDefinition) => ({ - Name: metricDefinition.name, - Unit: metricDefinition.unit, - ...(metricDefinition.resolution === MetricResolutions.High - ? { StorageResolution: metricDefinition.resolution } - : {}), - })); + const metricDefinitions: MetricDefinition[] = this.#metricsStore + .getAllMetrics() + .map((metricDefinition) => { + return { + Name: metricDefinition.name, + Unit: metricDefinition.unit, + ...(metricDefinition.resolution === MetricResolutions.High + ? { StorageResolution: metricDefinition.resolution } + : {}), + }; + }); if (metricDefinitions.length === 0 && this.shouldThrowOnEmptyMetrics) { throw new RangeError( @@ -708,32 +690,40 @@ class Metrics extends Utility implements MetricsInterface { // We reduce the stored metrics to a single object with the metric // name as the key and the value as the value. - const metricValues = Object.values(this.storedMetrics).reduce( + const metricValues = this.#metricsStore.getAllMetrics().reduce( ( result: Record, - { name, value }: { name: string; value: number | number[] } + { + name, + value, + }: { + name: string; + value: number | number[]; + } ) => { result[name] = value; - return result; }, {} ); const dimensionNames = []; + const dimensions = this.#dimensionsStore.getDimensions(); + const dimensionSets = this.#dimensionsStore.getDimensionSets(); + const defaultDimensions = this.#dimensionsStore.getDefaultDimensions(); const allDimensionKeys = new Set([ - ...Object.keys(this.defaultDimensions), - ...Object.keys(this.dimensions), + ...Object.keys(defaultDimensions), + ...Object.keys(dimensions), ]); - if (Object.keys(this.dimensions).length > 0) { + if (Object.keys(dimensions).length > 0) { dimensionNames.push([...allDimensionKeys]); } - for (const dimensionSet of this.dimensionSets) { + for (const dimensionSet of dimensionSets) { const dimensionSetKeys = new Set([ - ...Object.keys(this.defaultDimensions), + ...Object.keys(defaultDimensions), ...Object.keys(dimensionSet), ]); dimensionNames.push([...dimensionSetKeys]); @@ -741,14 +731,14 @@ class Metrics extends Utility implements MetricsInterface { if ( dimensionNames.length === 0 && - Object.keys(this.defaultDimensions).length > 0 + Object.keys(defaultDimensions).length > 0 ) { - dimensionNames.push([...Object.keys(this.defaultDimensions)]); + dimensionNames.push(Object.keys(defaultDimensions)); } return { _aws: { - Timestamp: this.#timestamp ?? Date.now(), + Timestamp: this.#metricsStore.getTimestamp() ?? Date.now(), CloudWatchMetrics: [ { Namespace: this.namespace || DEFAULT_NAMESPACE, @@ -757,17 +747,17 @@ class Metrics extends Utility implements MetricsInterface { }, ], }, - ...this.defaultDimensions, - ...this.dimensions, + ...defaultDimensions, + ...dimensions, // Merge all dimension sets efficiently by mutating the accumulator - ...this.dimensionSets.reduce((acc, dims) => { + ...dimensionSets.reduce((acc, dims) => { for (const [key, value] of Object.entries(dims)) { acc[key] = value; } return acc; - }, {} as Dimensions), + }, {}), ...metricValues, - ...this.metadata, + ...this.#metadataStore.getAll(), }; } @@ -797,7 +787,9 @@ class Metrics extends Utility implements MetricsInterface { */ public setDefaultDimensions(dimensions: Dimensions): void { const newDimensions = this.#sanitizeDimensions(dimensions); - const currentCount = Object.keys(this.defaultDimensions).length; + const currentDefaultDimensions = + this.#dimensionsStore.getDefaultDimensions(); + const currentCount = Object.keys(currentDefaultDimensions).length; const newSetCount = Object.keys(newDimensions).length; if (currentCount + newSetCount >= MAX_DIMENSION_COUNT) { throw new RangeError( @@ -805,10 +797,10 @@ class Metrics extends Utility implements MetricsInterface { ); } - this.defaultDimensions = { - ...this.defaultDimensions, + this.#dimensionsStore.setDefaultDimensions({ + ...currentDefaultDimensions, ...newDimensions, - }; + }); } /** @@ -865,7 +857,7 @@ class Metrics extends Utility implements MetricsInterface { public singleMetric(): Metrics { return new Metrics({ namespace: this.namespace, - defaultDimensions: this.defaultDimensions, + defaultDimensions: this.#dimensionsStore.getDefaultDimensions(), singleMetric: true, logger: this.#logger, }); @@ -878,21 +870,6 @@ class Metrics extends Utility implements MetricsInterface { this.shouldThrowOnEmptyMetrics = true; } /* v8 ignore stop */ - /** - * Gets the current number of dimensions count. - */ - private getCurrentDimensionsCount(): number { - const dimensionSetsCount = this.dimensionSets.reduce( - (total, dimensionSet) => total + Object.keys(dimensionSet).length, - 0 - ); - return ( - Object.keys(this.dimensions).length + - Object.keys(this.defaultDimensions).length + - dimensionSetsCount - ); - } - /** * Get the custom config service if it exists. */ @@ -900,32 +877,6 @@ class Metrics extends Utility implements MetricsInterface { return this.customConfigService; } - /** - * Check if a metric is new or not. - * - * A metric is considered new if there is no metric with the same name already stored. - * - * When a metric is not new, we also check if the unit is consistent with the stored metric with - * the same name. If the units are inconsistent, we throw an error as this is likely a bug or typo. - * This can happen if a metric is added without using the `MetricUnit` helper in JavaScript codebases. - * - * @param name - The name of the metric - * @param unit - The unit of the metric - */ - private isNewMetric(name: string, unit: MetricUnit): boolean { - if (this.storedMetrics[name]) { - if (this.storedMetrics[name].unit !== unit) { - const currentUnit = this.storedMetrics[name].unit; - throw new Error( - `Metric "${name}" has already been added with unit "${currentUnit}", but we received unit "${unit}". Did you mean to use metric unit "${currentUnit}"?` - ); - } - - return false; - } - return true; - } - /** * Initialize the console property as an instance of the internal version of `Console()` class (PR #748) * or as the global node console if the `POWERTOOLS_DEV' env variable is set and has truthy value. @@ -1099,26 +1050,21 @@ class Metrics extends Utility implements MetricsInterface { `Invalid metric resolution '${resolution}', expected either option: ${Object.values(MetricResolutions).join(',')}` ); - if (Object.keys(this.storedMetrics).length >= MAX_METRICS_SIZE) { + if (this.#metricsStore.getMetricsCount() >= MAX_METRICS_SIZE) { this.publishStoredMetrics(); } - if (this.isNewMetric(name, unit)) { - this.storedMetrics[name] = { - unit, - value, - name, - resolution, - }; - } else { - const storedMetric = this.storedMetrics[name]; - if (!Array.isArray(storedMetric.value)) { - storedMetric.value = [storedMetric.value]; - } - storedMetric.value.push(value); - if (storedMetric.value.length === MAX_METRIC_VALUES_SIZE) { - this.publishStoredMetrics(); - } + const storedMetric = this.#metricsStore.setMetric( + name, + unit, + value, + resolution + ); + if ( + Array.isArray(storedMetric.value) && + storedMetric.value.length === MAX_METRIC_VALUES_SIZE + ) { + this.publishStoredMetrics(); } } @@ -1147,27 +1093,6 @@ class Metrics extends Utility implements MetricsInterface { return timestampMs >= minValidTimestamp && timestampMs <= maxValidTimestamp; } - /** - * Converts a given timestamp to EMF compatible format. - * - * @param timestamp - The timestamp to convert, which can be either a number (in milliseconds) or a Date object. - * @returns The timestamp in milliseconds. If the input is invalid, returns 0. - */ - #convertTimestampToEmfFormat(timestamp: number | Date): number { - if (isIntegerNumber(timestamp)) { - return timestamp; - } - if (timestamp instanceof Date) { - return timestamp.getTime(); - } - /** - * If this point is reached, it indicates timestamp was neither a valid number nor Date - * Returning zero represents the initial date of epoch time, - * which will be skipped by Amazon CloudWatch. - */ - return 0; - } - /** * Sanitizes the dimensions by removing invalid entries and skipping duplicates. * @@ -1175,6 +1100,7 @@ class Metrics extends Utility implements MetricsInterface { */ #sanitizeDimensions(dimensions: Dimensions): Dimensions { const newDimensions: Dimensions = {}; + const currentDimensions = this.#dimensionsStore.getDimensions(); for (const [key, value] of Object.entries(dimensions)) { if ( isStringUndefinedNullEmpty(key) || @@ -1185,9 +1111,10 @@ class Metrics extends Utility implements MetricsInterface { ); continue; } + const defaultDimensions = this.#dimensionsStore.getDefaultDimensions(); if ( - Object.hasOwn(this.dimensions, key) || - Object.hasOwn(this.defaultDimensions, key) || + Object.hasOwn(currentDimensions, key) || + Object.hasOwn(defaultDimensions, key) || Object.hasOwn(newDimensions, key) ) { this.#logger.warn( diff --git a/packages/metrics/src/MetricsStore.ts b/packages/metrics/src/MetricsStore.ts new file mode 100644 index 0000000000..d2c08b3559 --- /dev/null +++ b/packages/metrics/src/MetricsStore.ts @@ -0,0 +1,157 @@ +import { InvokeStore } from '@aws/lambda-invoke-store'; +import { isIntegerNumber } from '@aws-lambda-powertools/commons/typeutils'; +import { MetricResolution as MetricResolutions } from './constants.js'; +import type { + MetricResolution, + MetricUnit, + StoredMetric, + StoredMetrics, +} from './types/index.js'; + +/** + * Manages storage of metrics with automatic context detection. + * + * This class abstracts the storage mechanism for metrics, automatically + * choosing between AsyncLocalStorage (when in async context) and a fallback + * object (when outside async context). The decision is made at runtime on + * every method call to support Lambda's transition to async contexts. + */ +class MetricsStore { + readonly #storedMetricsKey = Symbol('powertools.metrics.storedMetrics'); + readonly #timestampKey = Symbol('powertools.metrics.timestamp'); + + #fallbackStorage: StoredMetrics = {}; + #fallbackTimestamp?: number; + + #getStorage(): StoredMetrics { + if (InvokeStore.getContext() === undefined) { + return this.#fallbackStorage; + } + + let stored = InvokeStore.get(this.#storedMetricsKey) as + | StoredMetrics + | undefined; + if (stored == null) { + stored = {}; + InvokeStore.set(this.#storedMetricsKey, stored); + } + return stored; + } + + public getMetric(name: string): StoredMetric | undefined { + return this.#getStorage()[name]; + } + + /** + * Adds a metric value to storage. If a metric with the same name already exists, + * the value is appended to an array. Validates that the unit matches any existing metric. + * + * @example + * ```typescript + * store.setMetric('latency', MetricUnit.Milliseconds, 100); + * // Returns: { name: 'latency', unit: 'Milliseconds', value: 100, resolution: 60 } + * + * store.setMetric('latency', MetricUnit.Milliseconds, 150); + * // Returns: { name: 'latency', unit: 'Milliseconds', value: [100, 150], resolution: 60 } + * ``` + * + * @param name - The metric name + * @param unit - The metric unit (must match existing metric if present) + * @param value - The metric value to add + * @param resolution - The metric resolution (defaults to Standard) + * @returns The stored metric with updated values + * @throws Error if unit doesn't match existing metric + */ + public setMetric( + name: string, + unit: MetricUnit, + value: number, + resolution: MetricResolution = MetricResolutions.Standard + ): StoredMetric { + const storage = this.#getStorage(); + const existingMetric = storage[name]; + + if (existingMetric === undefined) { + const newMetric: StoredMetric = { + name, + unit, + value, + resolution, + }; + storage[name] = newMetric; + return { ...newMetric }; + } + + if (existingMetric.unit !== unit) { + const currentUnit = existingMetric.unit; + throw new Error( + `Metric "${name}" has already been added with unit "${currentUnit}", but we received unit "${unit}". Did you mean to use metric unit "${currentUnit}"?` + ); + } + + if (!Array.isArray(existingMetric.value)) { + existingMetric.value = [existingMetric.value]; + } + existingMetric.value.push(value); + return { ...existingMetric, value: [...existingMetric.value] }; + } + + public getMetricNames(): string[] { + return Object.keys(this.#getStorage()); + } + + public getAllMetrics(): StoredMetric[] { + return Object.values(this.#getStorage()); + } + + public clearMetrics(): void { + if (InvokeStore.getContext() === undefined) { + this.#fallbackStorage = {}; + this.#fallbackTimestamp = undefined; + return; + } + + InvokeStore.set(this.#storedMetricsKey, {}); + InvokeStore.set(this.#timestampKey, undefined); + } + + public hasMetrics(): boolean { + return this.getMetricNames().length > 0; + } + + public getMetricsCount(): number { + return this.getMetricNames().length; + } + + public getTimestamp(): number | undefined { + if (InvokeStore.getContext() === undefined) { + return this.#fallbackTimestamp; + } + + return InvokeStore.get(this.#timestampKey) as number | undefined; + } + + public setTimestamp(timestamp: number | Date): number { + const timestampMs = this.#convertTimestampToEmfFormat(timestamp); + + if (InvokeStore.getContext() === undefined) { + this.#fallbackTimestamp = timestampMs; + return timestampMs; + } + + InvokeStore.set(this.#timestampKey, timestampMs); + return timestampMs; + } + + #convertTimestampToEmfFormat(timestamp: number | Date): number { + if (isIntegerNumber(timestamp)) { + return timestamp; + } + if (timestamp instanceof Date) { + return timestamp.getTime(); + } + return 0; + } +} + +export { MetricsStore }; diff --git a/packages/metrics/tests/unit/concurrency/dimensionsStore.test.ts b/packages/metrics/tests/unit/concurrency/dimensionsStore.test.ts new file mode 100644 index 0000000000..f1433e7f5c --- /dev/null +++ b/packages/metrics/tests/unit/concurrency/dimensionsStore.test.ts @@ -0,0 +1,156 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { DimensionsStore } from '../../../src/DimensionsStore.js'; +import { sequence } from '../helpers.js'; + +describe('DimensionsStore concurrent invocation isolation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.each([ + { + description: 'without InvokeStore', + useInvokeStore: false, + expectedResult1: { env: 'dev' }, + expectedResult2: { env: 'dev' }, + }, + { + description: 'with InvokeStore', + useInvokeStore: true, + expectedResult1: { env: 'prod' }, + expectedResult2: { env: 'dev' }, + }, + ])( + 'handles storing dimensions $description', + async ({ useInvokeStore, expectedResult1, expectedResult2 }) => { + // Prepare + const store = new DimensionsStore(); + + // Act + const [result1, result2] = await sequence( + { + sideEffects: [() => store.addDimension('env', 'prod')], + return: () => store.getDimensions(), + }, + { + sideEffects: [() => store.addDimension('env', 'dev')], + return: () => store.getDimensions(), + }, + { useInvokeStore } + ); + + // Assess + expect(result1).toEqual(expectedResult1); + expect(result2).toEqual(expectedResult2); + } + ); + + it.each([ + { + description: 'without InvokeStore', + useInvokeStore: false, + expectedResult1: [ + { service: 'api', version: '1.0' }, + { service: 'web', version: '2.0' }, + ], + expectedResult2: [ + { service: 'api', version: '1.0' }, + { service: 'web', version: '2.0' }, + ], + }, + { + description: 'with InvokeStore', + useInvokeStore: true, + expectedResult1: [{ service: 'api', version: '1.0' }], + expectedResult2: [{ service: 'web', version: '2.0' }], + }, + ])( + 'handles storing dimension sets $description', + async ({ useInvokeStore, expectedResult1, expectedResult2 }) => { + // Prepare + const store = new DimensionsStore(); + + // Act + const [result1, result2] = await sequence( + { + sideEffects: [ + () => store.addDimensionSet({ service: 'api', version: '1.0' }), + ], + return: () => store.getDimensionSets(), + }, + { + sideEffects: [ + () => store.addDimensionSet({ service: 'web', version: '2.0' }), + ], + return: () => store.getDimensionSets(), + }, + { useInvokeStore } + ); + + // Assess + expect(result1).toEqual(expectedResult1); + expect(result2).toEqual(expectedResult2); + } + ); + + it.each([ + { + description: 'without InvokeStore', + useInvokeStore: false, + expectedResult1: { dims: {}, sets: [] }, + expectedResult2: { dims: {}, sets: [] }, + }, + { + description: 'with InvokeStore', + useInvokeStore: true, + expectedResult1: { dims: {}, sets: [] }, + expectedResult2: { + dims: { region: 'us-east-1' }, + sets: [{ version: '2.0' }], + }, + }, + ])( + 'handles clearing the store $description', + async ({ useInvokeStore, expectedResult1, expectedResult2 }) => { + // Prepare + const store = new DimensionsStore(); + + // Act + const [result1, result2] = await sequence( + { + sideEffects: [ + () => { + store.addDimension('env', 'prod'); + store.addDimensionSet({ service: 'api' }); + }, + () => {}, // Wait for inv2 to add + () => store.clearRequestDimensions(), + ], + return: () => ({ + dims: store.getDimensions(), + sets: store.getDimensionSets(), + }), + }, + { + sideEffects: [ + () => {}, // Wait for inv1 to add + () => { + store.addDimension('region', 'us-east-1'); + store.addDimensionSet({ version: '2.0' }); + }, + () => {}, // Wait for clear + ], + return: () => ({ + dims: store.getDimensions(), + sets: store.getDimensionSets(), + }), + }, + { useInvokeStore } + ); + + // Assess + expect(result1).toEqual(expectedResult1); + expect(result2).toEqual(expectedResult2); + } + ); +}); diff --git a/packages/metrics/tests/unit/concurrency/metadataStore.test.ts b/packages/metrics/tests/unit/concurrency/metadataStore.test.ts new file mode 100644 index 0000000000..8e31e45510 --- /dev/null +++ b/packages/metrics/tests/unit/concurrency/metadataStore.test.ts @@ -0,0 +1,193 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { MetadataStore } from '../../../src/MetadataStore.js'; +import { sequence } from '../helpers.js'; + +describe('MetadataStore concurrent invocation isolation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.each([ + { + description: 'without InvokeStore', + useInvokeStore: false, + expectedResult1: { env: 'dev' }, + expectedResult2: { env: 'dev' }, + }, + { + description: 'with InvokeStore', + useInvokeStore: true, + expectedResult1: { env: 'prod' }, + expectedResult2: { env: 'dev' }, + }, + ])( + 'handles storing metadata $description', + async ({ useInvokeStore, expectedResult1, expectedResult2 }) => { + // Prepare + const store = new MetadataStore(); + + // Act + const [result1, result2] = await sequence( + { + sideEffects: [() => store.set('env', 'prod')], + return: () => store.getAll(), + }, + { + sideEffects: [() => store.set('env', 'dev')], + return: () => store.getAll(), + }, + { useInvokeStore } + ); + + // Assess + expect(result1).toEqual(expectedResult1); + expect(result2).toEqual(expectedResult2); + } + ); + + it.each([ + { + description: 'without InvokeStore', + useInvokeStore: false, + expectedResult1: { service: 'web', version: '1.0', region: 'us-east-1' }, + expectedResult2: { service: 'web', version: '1.0', region: 'us-east-1' }, + }, + { + description: 'with InvokeStore', + useInvokeStore: true, + expectedResult1: { service: 'api', version: '1.0' }, + expectedResult2: { service: 'web', region: 'us-east-1' }, + }, + ])( + 'handles storing multiple metadata keys $description', + async ({ useInvokeStore, expectedResult1, expectedResult2 }) => { + // Prepare + const store = new MetadataStore(); + + // Act + const [result1, result2] = await sequence( + { + sideEffects: [ + () => { + store.set('service', 'api'); + store.set('version', '1.0'); + }, + ], + return: () => store.getAll(), + }, + { + sideEffects: [ + () => { + store.set('service', 'web'); + store.set('region', 'us-east-1'); + }, + ], + return: () => store.getAll(), + }, + { useInvokeStore } + ); + + // Assess + expect(result1).toEqual(expectedResult1); + expect(result2).toEqual(expectedResult2); + } + ); + + it.each([ + { + description: 'without InvokeStore', + useInvokeStore: false, + expectedResult1: {}, + expectedResult2: {}, + }, + { + description: 'with InvokeStore', + useInvokeStore: true, + expectedResult1: {}, + expectedResult2: { region: 'us-east-1', env: 'prod' }, + }, + ])( + 'handles clearing the store $description', + async ({ useInvokeStore, expectedResult1, expectedResult2 }) => { + // Prepare + const store = new MetadataStore(); + + // Act + const [result1, result2] = await sequence( + { + sideEffects: [ + () => { + store.set('service', 'api'); + store.set('version', '1.0'); + }, + () => {}, // Wait for inv2 to add + () => store.clear(), + ], + return: () => store.getAll(), + }, + { + sideEffects: [ + () => {}, // Wait for inv1 to add + () => { + store.set('region', 'us-east-1'); + store.set('env', 'prod'); + }, + () => {}, // Wait for clear + ], + return: () => store.getAll(), + }, + { useInvokeStore } + ); + + // Assess + expect(result1).toEqual(expectedResult1); + expect(result2).toEqual(expectedResult2); + } + ); + + it.each([ + { + description: 'without InvokeStore', + useInvokeStore: false, + expectedResult1: { key: 'value3' }, + expectedResult2: { key: 'value3' }, + }, + { + description: 'with InvokeStore', + useInvokeStore: true, + expectedResult1: { key: 'value3' }, + expectedResult2: { key: 'value2' }, + }, + ])( + 'handles overwriting same key $description', + async ({ useInvokeStore, expectedResult1, expectedResult2 }) => { + // Prepare + const store = new MetadataStore(); + + // Act + const [result1, result2] = await sequence( + { + sideEffects: [ + () => store.set('key', 'value1'), + () => {}, // Wait for inv2 + () => store.set('key', 'value3'), + ], + return: () => store.getAll(), + }, + { + sideEffects: [ + () => {}, // Wait for inv1 + () => store.set('key', 'value2'), + () => {}, // Wait for inv1's final write + ], + return: () => store.getAll(), + }, + { useInvokeStore } + ); + + // Assess + expect(result1).toEqual(expectedResult1); + expect(result2).toEqual(expectedResult2); + } + ); +}); diff --git a/packages/metrics/tests/unit/concurrency/metrics.test.ts b/packages/metrics/tests/unit/concurrency/metrics.test.ts new file mode 100644 index 0000000000..950a3550a2 --- /dev/null +++ b/packages/metrics/tests/unit/concurrency/metrics.test.ts @@ -0,0 +1,134 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Metrics, MetricUnit } from '../../../src/index.js'; +import { sequence } from '../helpers.js'; + +describe('Metrics concurrent invocation isolation', () => { + beforeEach(() => { + vi.stubEnv('POWERTOOLS_DEV', 'true'); + vi.stubEnv('POWERTOOLS_METRICS_DISABLED', 'false'); + vi.clearAllMocks(); + }); + + it.each([ + { + description: 'without InvokeStore', + useInvokeStore: false, + expectedCallCount: 1, + expectedOutputs: [ + { + env: 'dev', + key: 'value2', + count: [1, 2], + }, + ], + }, + { + description: 'with InvokeStore', + useInvokeStore: true, + expectedCallCount: 2, + expectedOutputs: [ + { env: 'prod', key: 'value1', count: 1 }, + { env: 'dev', key: 'value2', count: 2 }, + ], + }, + ])( + 'handles metrics, metadata, and dimensions $description', + async ({ useInvokeStore, expectedCallCount, expectedOutputs }) => { + const metrics = new Metrics({ singleMetric: false }); + + await sequence( + { + sideEffects: [ + () => { + metrics.addDimension('env', 'prod'); + metrics.addMetric('count', MetricUnit.Count, 1); + metrics.addMetadata('key', 'value1'); + }, + ], + return: () => metrics.publishStoredMetrics(), + }, + { + sideEffects: [ + () => { + metrics.addDimension('env', 'dev'); + metrics.addMetric('count', MetricUnit.Count, 2); + metrics.addMetadata('key', 'value2'); + }, + ], + return: () => metrics.publishStoredMetrics(), + }, + { useInvokeStore } + ); + + expect(console.log).toHaveBeenCalledTimes(expectedCallCount); + for (const expectedOutput of expectedOutputs) { + expect(console.log).toHaveEmittedEMFWith( + expect.objectContaining(expectedOutput) + ); + } + } + ); + + it.each([ + { + description: 'without InvokeStore', + useInvokeStore: false, + expectedCallCount: 1, + expectedOutputs: [ + { + _aws: expect.objectContaining({ Timestamp: 2000 }), + count: [1, 2], + }, + ], + }, + { + description: 'with InvokeStore', + useInvokeStore: true, + expectedCallCount: 2, + expectedOutputs: [ + { _aws: expect.objectContaining({ Timestamp: 1000 }), count: 1 }, + { _aws: expect.objectContaining({ Timestamp: 2000 }), count: 2 }, + ], + }, + ])( + 'handles timestamps $description', + async ({ useInvokeStore, expectedCallCount, expectedOutputs }) => { + const metrics = new Metrics({ singleMetric: false }); + const timestamp1 = 1000; + const timestamp2 = 2000; + + await sequence( + { + sideEffects: [ + () => { + metrics.setTimestamp(timestamp1); + metrics.addMetric('count', MetricUnit.Count, 1); + }, + () => {}, + () => metrics.publishStoredMetrics(), + ], + return: () => {}, + }, + { + sideEffects: [ + () => {}, + () => { + metrics.setTimestamp(timestamp2); + metrics.addMetric('count', MetricUnit.Count, 2); + }, + () => metrics.publishStoredMetrics(), + ], + return: () => {}, + }, + { useInvokeStore } + ); + + expect(console.log).toHaveBeenCalledTimes(expectedCallCount); + for (const expectedOutput of expectedOutputs) { + expect(console.log).toHaveEmittedEMFWith( + expect.objectContaining(expectedOutput) + ); + } + } + ); +}); diff --git a/packages/metrics/tests/unit/concurrency/metricsStore.test.ts b/packages/metrics/tests/unit/concurrency/metricsStore.test.ts new file mode 100644 index 0000000000..75e83e7254 --- /dev/null +++ b/packages/metrics/tests/unit/concurrency/metricsStore.test.ts @@ -0,0 +1,319 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { MetricUnit } from '../../../src/index.js'; +import { MetricsStore } from '../../../src/MetricsStore.js'; +import type { StoredMetric } from '../../../src/types/index.js'; +import { sequence } from '../helpers.js'; + +describe('MetricsStore concurrent invocation isolation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.each([ + { + description: 'without InvokeStore', + useInvokeStore: false, + expectedResult1: { + name: 'count', + unit: MetricUnit.Count, + value: [1, 2], + resolution: 60, + }, + expectedResult2: { + name: 'count', + unit: MetricUnit.Count, + value: [1, 2], + resolution: 60, + }, + }, + { + description: 'with InvokeStore', + useInvokeStore: true, + expectedResult1: { + name: 'count', + unit: MetricUnit.Count, + value: 1, + resolution: 60, + }, + expectedResult2: { + name: 'count', + unit: MetricUnit.Count, + value: 2, + resolution: 60, + }, + }, + ])( + 'getMetric() $description', + async ({ useInvokeStore, expectedResult1, expectedResult2 }) => { + // Prepare + const store = new MetricsStore(); + + // Act + const [result1, result2] = await sequence( + { + sideEffects: [ + () => store.setMetric('count', MetricUnit.Count, 1, 60), + ], + return: () => store.getMetric('count'), + }, + { + sideEffects: [ + () => store.setMetric('count', MetricUnit.Count, 2, 60), + ], + return: () => store.getMetric('count'), + }, + { useInvokeStore } + ); + + // Assess + expect(result1).toEqual(expectedResult1); + expect(result2).toEqual(expectedResult2); + } + ); + + const countMetric: StoredMetric = { + name: 'count', + unit: MetricUnit.Count, + value: 1, + resolution: 60, + }; + const latencyMetric: StoredMetric = { + name: 'latency', + unit: MetricUnit.Milliseconds, + value: 100, + resolution: 60, + }; + const errorMetric: StoredMetric = { + name: 'errors', + unit: MetricUnit.Count, + value: 1, + resolution: 60, + }; + + it.each([ + { + description: 'without InvokeStore', + useInvokeStore: false, + expectedResult1: [countMetric, latencyMetric, errorMetric], + expectedResult2: [countMetric, latencyMetric, errorMetric], + }, + { + description: 'with InvokeStore', + useInvokeStore: true, + expectedResult1: [countMetric, latencyMetric], + expectedResult2: [errorMetric], + }, + ])( + 'getAllMetrics() $description', + async ({ useInvokeStore, expectedResult1, expectedResult2 }) => { + // Prepare + const store = new MetricsStore(); + + // Act + const [result1, result2] = await sequence( + { + sideEffects: [ + () => { + store.setMetric('count', MetricUnit.Count, 1, 60); + store.setMetric('latency', MetricUnit.Milliseconds, 100, 60); + }, + ], + return: () => store.getAllMetrics(), + }, + { + sideEffects: [ + () => { + store.setMetric('errors', MetricUnit.Count, 1, 60); + }, + ], + return: () => store.getAllMetrics(), + }, + { useInvokeStore } + ); + + // Assess + expect(result1).toEqual(expectedResult1); + expect(result2).toEqual(expectedResult2); + } + ); + + it.each([ + { + description: 'without InvokeStore', + useInvokeStore: false, + expectedResult1: 2000, + expectedResult2: 2000, + }, + { + description: 'with InvokeStore', + useInvokeStore: true, + expectedResult1: 1000, + expectedResult2: 2000, + }, + ])( + 'timestamp $description', + async ({ useInvokeStore, expectedResult1, expectedResult2 }) => { + // Prepare + const store = new MetricsStore(); + const timestamp1 = 1000; + const timestamp2 = 2000; + + // Act + const [result1, result2] = await sequence( + { + sideEffects: [() => store.setTimestamp(timestamp1)], + return: () => store.getTimestamp(), + }, + { + sideEffects: [() => store.setTimestamp(timestamp2)], + return: () => store.getTimestamp(), + }, + { useInvokeStore } + ); + + // Assess + expect(result1).toBe(expectedResult1); + expect(result2).toBe(expectedResult2); + } + ); + + it.each([ + { + description: 'without InvokeStore', + useInvokeStore: false, + expectedResult1: { metrics: [], timestamp: undefined }, + expectedResult2: { metrics: [], timestamp: undefined }, + }, + { + description: 'with InvokeStore', + useInvokeStore: true, + expectedResult1: { metrics: [], timestamp: undefined }, + expectedResult2: { metrics: [errorMetric], timestamp: 2000 }, + }, + ])( + 'clearMetrics() $description', + async ({ useInvokeStore, expectedResult1, expectedResult2 }) => { + // Prepare + const store = new MetricsStore(); + + // Act + const [result1, result2] = await sequence( + { + sideEffects: [ + () => { + store.setMetric('count', MetricUnit.Count, 1, 60); + store.setTimestamp(1000); + }, + () => {}, // Wait for inv2 to add + () => store.clearMetrics(), + ], + return: () => ({ + metrics: store.getAllMetrics(), + timestamp: store.getTimestamp(), + }), + }, + { + sideEffects: [ + () => {}, // Wait for inv1 to add + () => { + store.setMetric('errors', MetricUnit.Count, 1, 60); + store.setTimestamp(2000); + }, + () => {}, // Wait for clear + ], + return: () => ({ + metrics: store.getAllMetrics(), + timestamp: store.getTimestamp(), + }), + }, + { useInvokeStore } + ); + + // Assess + expect(result1).toEqual(expectedResult1); + expect(result2).toEqual(expectedResult2); + } + ); + + it.each([ + { + description: 'without InvokeStore', + useInvokeStore: false, + expectedResult1: true, + expectedResult2: true, + }, + { + description: 'with InvokeStore', + useInvokeStore: true, + expectedResult1: true, + expectedResult2: false, + }, + ])( + 'hasMetrics() $description', + async ({ useInvokeStore, expectedResult1, expectedResult2 }) => { + // Prepare + const store = new MetricsStore(); + + // Act + const [result1, result2] = await sequence( + { + sideEffects: [ + () => store.setMetric('count', MetricUnit.Count, 1, 60), + ], + return: () => store.hasMetrics(), + }, + { + sideEffects: [() => {}], // No-op + return: () => store.hasMetrics(), + }, + { useInvokeStore } + ); + + // Assess + expect(result1).toBe(expectedResult1); + expect(result2).toBe(expectedResult2); + } + ); + + it.each([ + { + description: 'without InvokeStore', + useInvokeStore: false, + expectedResult1: 2, + expectedResult2: 2, + }, + { + description: 'with InvokeStore', + useInvokeStore: true, + expectedResult1: 1, + expectedResult2: 1, + }, + ])( + 'getMetricsCount() $description', + async ({ useInvokeStore, expectedResult1, expectedResult2 }) => { + // Prepare + const store = new MetricsStore(); + + // Act + const [result1, result2] = await sequence( + { + sideEffects: [ + () => store.setMetric('count', MetricUnit.Count, 1, 60), + ], + return: () => store.getMetricsCount(), + }, + { + sideEffects: [ + () => store.setMetric('errors', MetricUnit.Count, 1, 60), + ], + return: () => store.getMetricsCount(), + }, + { useInvokeStore } + ); + + // Assess + expect(result1).toBe(expectedResult1); + expect(result2).toBe(expectedResult2); + } + ); +}); diff --git a/packages/metrics/tests/unit/helpers.ts b/packages/metrics/tests/unit/helpers.ts new file mode 100644 index 0000000000..87eb7973c6 --- /dev/null +++ b/packages/metrics/tests/unit/helpers.ts @@ -0,0 +1,134 @@ +import { InvokeStore } from '@aws/lambda-invoke-store'; + +type Invocation = { + sideEffects: (() => void)[]; + return: () => unknown; +}; + +/** + * Creates a Promise with externally accessible resolve and reject functions. + * + * This is a polyfill for the proposed Promise.withResolvers() method that provides + * a more convenient way to create promises that can be resolved or rejected from + * outside the Promise constructor. + * + * We need this polyfill because this function is not available in Node 20. When we drop + * support for this version of Node, then we should remove this function and use the + * inbuilt `Promise.withResolvers` static methods. + * + * @returns Object containing the promise and its resolve/reject functions + * + * @example + * ```typescript + * const { promise, resolve, reject } = withResolvers(); + * + * // Later, from somewhere else: + * resolve('success'); + * + * // Or: + * reject(new Error('failed')); + * ``` + */ +const withResolvers = () => { + let resolve: (value: T) => void = () => {}; + let reject: (reason?: unknown) => void = () => {}; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +}; + +/** + * Executes two invocations concurrently with synchronized side effects to test isolation behavior. + * + * This function ensures that side effects are executed in a specific order across both + * invocations using barrier synchronization. Each step waits for the corresponding step + * in the other invocation to complete before proceeding to the next step. + * + * @param inv1 - First invocation configuration + * @param inv1.sideEffects - Array of functions to execute sequentially, synchronized with inv2 + * @param inv1.return - Function to call after all side effects, returns the test result + * @param inv2 - Second invocation configuration + * @param inv2.sideEffects - Array of functions to execute sequentially, synchronized with inv1 + * @param inv2.return - Function to call after all side effects, returns the test result + * @param options - Execution options + * @param options.useInvokeStore - Whether to run invocations in separate InvokeStore contexts + * - `true`: Each invocation runs in its own InvokeStore.run() context (isolated) + * - `false`: Both invocations run in shared context (no isolation) + * + * @returns Promise that resolves to tuple of [inv1Result, inv2Result] + * + * @example + * ```typescript + * // Basic 2-step sequencing: inv1 acts, then inv2 acts + * const [result1, result2] = await sequence({ + * sideEffects: [() => doSomething('A')], + * return: () => getResult() + * }, { + * sideEffects: [() => doSomething('B')], + * return: () => getResult() + * }, { useInvokeStore: true }); + * + * // Execution order: inv1 doSomething('A') → inv2 doSomething('B') → both return + * ``` + * + * @example + * ```typescript + * // Complex 3-step sequencing with barriers + * const [result1, result2] = await sequence({ + * sideEffects: [ + * () => action1(), // Step 1: inv1 acts first + * () => {}, // Step 2: inv1 waits for inv2 + * () => action3() // Step 3: inv1 acts after inv2 + * ], + * return: () => getResult() + * }, { + * sideEffects: [ + * () => {}, // Step 1: inv2 waits for inv1 + * () => action2(), // Step 2: inv2 acts after inv1 + * () => {} // Step 3: inv2 waits for inv1 + * ], + * return: () => getResult() + * }, { useInvokeStore: false }); + * + * // Execution order: action1() → action2() → action3() → both return + * ``` + */ +function sequence( + inv1: Invocation, + inv2: Invocation, + options: { useInvokeStore?: boolean } +) { + const executionEnv = options?.useInvokeStore + ? (f: () => unknown) => InvokeStore.run({}, f) + : (f: () => unknown) => f(); + + const inv1Barriers = inv1.sideEffects.map(() => withResolvers()); + const inv2Barriers = inv2.sideEffects.map(() => withResolvers()); + + const invocation1 = executionEnv(async () => { + for (let i = 0; i < inv1Barriers.length; i++) { + const sideEffect = inv1.sideEffects[i] ?? (() => {}); + sideEffect(); + inv1Barriers[i].resolve(); + await inv2Barriers[i].promise; + } + return inv1.return(); + }); + + const invocation2 = executionEnv(async () => { + for (let i = 0; i < inv2Barriers.length; i++) { + await inv1Barriers[i].promise; + const sideEffect = inv2.sideEffects[i] ?? (() => {}); + sideEffect(); + inv2Barriers[i].resolve(); + } + return inv2.return(); + }); + + return Promise.all([invocation1, invocation2]); +} + +export { withResolvers, sequence }; +export type { Invocation };