diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 0d721596..5703e7f3 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,5 +1,10 @@ { "name": "Node.js", "image": "mcr.microsoft.com/devcontainers/javascript-node:latest", + "features": { + "ghcr.io/devcontainers/features/docker-in-docker:4": { + "moby": false + } + }, "postCreateCommand": "bash ./scripts/setup-cloud-environment.sh" } diff --git a/packages/cacheable/README.md b/packages/cacheable/README.md index 6b373028..628f0bdb 100644 --- a/packages/cacheable/README.md +++ b/packages/cacheable/README.md @@ -796,12 +796,17 @@ const cache = new Cacheable({ tags: true }); await cache.set('page:/products', html, { ttl: '10m', tags: ['entity:42', 'collection:products'] }); await cache.set('page:/products/42', detailHtml, { ttl: '10m', tags: ['entity:42'] }); +await cache.getOrSet('summary:products', loadSummary, { ttl: '10m', tags: ['collection:products'] }); // entity 42 changed - purge everything that referenced it await cache.tags.invalidateTag('entity:42'); await cache.get('page:/products'); // undefined await cache.get('page:/products/42'); // undefined + +// the product collection changed - purge values tagged with the collection +await cache.tags.invalidateTag('collection:products'); +await cache.get('summary:products'); // undefined ``` You can also pass tags per item with `setMany`, and invalidate several tags at once: @@ -1064,20 +1069,21 @@ To learn more visit [@cacheable/utils](https://cacheable.org/docs/utils/) # Get Or Set Memoization Function -The `getOrSet` method that comes from [@cacheable/utils](https://cacheable.org/docs/utils/) provides a convenient way to implement the cache-aside pattern. It attempts to retrieve a value from cache, and if not found, calls the provided function to compute the value and store it in cache before returning it. Here are the options: +The `Cacheable#getOrSet` method is backed by [@cacheable/utils](https://cacheable.org/docs/utils/) and provides a convenient way to implement the cache-aside pattern. It attempts to retrieve a value from cache, and if not found, calls the provided function to compute the value and store it in cache before returning it. `Cacheable#getOrSet` extends the standalone utility with per-store TTL and tag options; the standalone `@cacheable/utils` `getOrSet` function does not support tags. Here are the `Cacheable#getOrSet` options: ```typescript export type GetOrSetFunctionOptions = { ttl?: number | string | { primary?: number | string; secondary?: number | string }; + tags?: string[]; cacheErrors?: boolean; - throwErrors?: boolean; + throwErrors?: boolean | 'function' | 'store'; nonBlocking?: boolean; }; ``` -The `ttl` also accepts a [per-store object](#per-store-ttl-per-operation) such as `{ primary: '10s', secondary: '5m' }` to give the primary and secondary stores different expirations for this operation. +The `ttl` also accepts a [per-store object](#per-store-ttl-per-operation) such as `{ primary: '10s', secondary: '5m' }` to give the primary and secondary stores different expirations for this operation. The `tags` option associates a newly computed value with tags for [tag-based invalidation](#tag-based-invalidation). Tag tracking must be enabled with `new Cacheable({ tags: true })`. Tags are applied only when `getOrSet` stores a newly computed value after a cache miss; a cache hit returns the existing value without replacing its tags. -The `nonBlocking` option allows you to override the instance-level `nonBlocking` setting for the `get` call within `getOrSet`. When set to `false`, the `get` will block and wait for a response from the secondary store before deciding whether to call the provided function. When set to `true`, the primary store returns immediately and syncs from secondary in the background. +The `nonBlocking` option overrides the instance-level `nonBlocking` setting for the `get` call within `getOrSet` only. After a primary miss, both modes await the secondary-store read before deciding whether to call the provided function. If the secondary store has a value, `nonBlocking: true` returns it while the secondary-to-primary backfill and its hook run on a fire-and-forget basis; `nonBlocking: false` waits for the hook and backfill to complete before returning. Here is an example of how to use the `getOrSet` method: @@ -1092,8 +1098,9 @@ console.log(value); // e.g. 42.123456789 You can also use a function to compute the key for the function: -```javascript -import { Cacheable, GetOrSetOptions } from 'cacheable'; +```typescript +import { Cacheable } from 'cacheable'; +import type { GetOrSetOptions } from 'cacheable'; const cache = new Cacheable(); // Function to generate a key based on options @@ -1102,7 +1109,7 @@ const generateKey = (options?: GetOrSetOptions) => { }; const function_ = async () => Math.random() * 100; -const value = await cache.getOrSet(generateKey(), function_, { ttl: '1h' }); +const value = await cache.getOrSet(generateKey, function_, { ttl: '1h' }); ``` To learn more go to [@cacheable/utils](https://cacheable.org/docs/utils/) diff --git a/packages/cacheable/src/index.ts b/packages/cacheable/src/index.ts index 98682177..1410bd31 100644 --- a/packages/cacheable/src/index.ts +++ b/packages/cacheable/src/index.ts @@ -69,6 +69,16 @@ export type CacheableHookHandlerMap = { ) => void | Promise; }; +type PrimaryBackfillPhase = "pending" | "hook" | "writing" | "done"; + +type PrimaryBackfillController = { + key: string; + cancelled: boolean; + phase: PrimaryBackfillPhase; + done: Promise; + resolveDone: () => void; +}; + export class Cacheable extends Hookified { private static _instance?: Cacheable; private _primary: Keyv = createKeyv(); @@ -81,6 +91,10 @@ export class Cacheable extends Hookified { private _cacheId: string = Math.random().toString(36).slice(2); private _sync?: CacheableSync; private _tags: CacheTags = this.createCacheTags(); + private readonly _primaryBackfills = new Map< + string, + Set + >(); /** * Creates a new cacheable instance * @param {CacheableOptions} [options] The options for the cacheable instance @@ -539,6 +553,9 @@ export class Cacheable extends Hookified { options?: GetOptions, ): Promise> { let result: StoredDataRaw; + let primaryBackfill: + | { backfill: () => void; discard: () => void } + | undefined; try { await this.hook(CacheableHooks.BEFORE_GET, key); @@ -563,6 +580,8 @@ export class Cacheable extends Hookified { | { result: StoredDataRaw; ttl?: number | string; + backfill?: () => void; + discard?: () => void; } | undefined; if (nonBlocking) { @@ -582,16 +601,34 @@ export class Cacheable extends Hookified { if (secondaryProcessResult) { result = secondaryProcessResult.result; ttl = secondaryProcessResult.ttl; + if ( + secondaryProcessResult.backfill && + secondaryProcessResult.discard + ) { + primaryBackfill = { + backfill: secondaryProcessResult.backfill, + discard: secondaryProcessResult.discard, + }; + } } } if (result && this._tags.enabled && (await this._tags.isKeyStale(key))) { - await this.delete(key); + primaryBackfill?.discard(); + primaryBackfill = undefined; + await this.deleteInternal(key, false); result = undefined; + } else { + // A secondary value must be known fresh before its fire-and-forget primary + // backfill starts. Otherwise a delayed stale write can race with deletion and + // overwrite a value recomputed by getOrSet. + primaryBackfill?.backfill(); + primaryBackfill = undefined; } await this.hook(CacheableHooks.AFTER_GET, { key, result, ttl }); } catch (error: unknown) { + primaryBackfill?.discard(); this.emit(CacheableEvents.ERROR, error); } @@ -625,6 +662,11 @@ export class Cacheable extends Hookified { options?: GetOptions, ): Promise>> { let result: Array> = []; + let primaryBackfills: Array<{ + index: number; + backfill: () => void; + discard: () => void; + }> = []; try { await this.hook(CacheableHooks.BEFORE_GET_MANY, keys); @@ -646,12 +688,13 @@ export class Cacheable extends Hookified { if (this._secondary) { if (nonBlocking) { - await this.processSecondaryForGetManyRawNonBlocking( - this._primary, - this._secondary, - keys, - result, - ); + primaryBackfills = + await this.processSecondaryForGetManyRawNonBlocking( + this._primary, + this._secondary, + keys, + result, + ); } else { await this.processSecondaryForGetManyRaw( this._primary, @@ -673,12 +716,25 @@ export class Cacheable extends Hookified { } } - await this.deleteMany(staleKeys); + await this.deleteManyInternal(staleKeys, false); } } + // Start only backfills whose secondary values survived the tag freshness check. + for (const { index, backfill, discard } of primaryBackfills) { + if (result[index] !== undefined) { + backfill(); + } else { + discard(); + } + } + primaryBackfills = []; + await this.hook(CacheableHooks.AFTER_GET_MANY, { keys, result }); } catch (error: unknown) { + for (const { discard } of primaryBackfills) { + discard(); + } this.emit(CacheableEvents.ERROR, error); } @@ -732,6 +788,7 @@ export class Cacheable extends Hookified { value: T, ttlOrOptions?: number | string | SetOptions, ): Promise { + const primaryBackfillBarrier = this.cancelPrimaryBackfills([key]); let result = false; const options: SetOptions = typeof ttlOrOptions === "object" && ttlOrOptions !== null @@ -742,6 +799,7 @@ export class Cacheable extends Hookified { resolvePerStoreTtl(options.ttl); const maxTtlMs = shorthandToMilliseconds(this._maxTtl); try { + await primaryBackfillBarrier; let primaryTtl = getCascadingTtl( this._ttl, this._primary.ttl, @@ -882,8 +940,12 @@ export class Cacheable extends Hookified { * @returns {boolean} Whether the values were set */ public async setMany(items: CacheableSetItem[]): Promise { + const primaryBackfillBarrier = this.cancelPrimaryBackfills( + items.map((item) => item.key), + ); let result = false; try { + await primaryBackfillBarrier; await this.hook(CacheableHooks.BEFORE_SET_MANY, items); result = await this.setManyKeyv(this._primary, items, "primary"); if (this._secondary) { @@ -1022,6 +1084,15 @@ export class Cacheable extends Hookified { * @returns {Promise} Whether the key was deleted */ public async delete(key: string): Promise { + return this.deleteInternal(key, this.nonBlocking); + } + + private async deleteInternal( + key: string, + nonBlocking: boolean, + ): Promise { + const primaryBackfillBarrier = this.cancelPrimaryBackfills([key]); + await primaryBackfillBarrier; let result = false; const promises = []; if (this.stats.enabled) { @@ -1040,7 +1111,7 @@ export class Cacheable extends Hookified { promises.push(this._secondary.delete(key)); } - if (this.nonBlocking) { + if (nonBlocking) { result = await Promise.race(promises); // Catch any rejected promises to avoid unhandled rejections for (const promise of promises) { @@ -1054,7 +1125,7 @@ export class Cacheable extends Hookified { } if (this._tags.enabled) { - await this._tags.removeKeys([key], { nonBlocking: this.nonBlocking }); + await this._tags.removeKeys([key], { nonBlocking }); } // Publish to sync if enabled @@ -1074,6 +1145,15 @@ export class Cacheable extends Hookified { * @returns {Promise} Whether the keys were deleted */ public async deleteMany(keys: string[]): Promise { + return this.deleteManyInternal(keys, this._nonBlocking); + } + + private async deleteManyInternal( + keys: string[], + nonBlocking: boolean, + ): Promise { + const primaryBackfillBarrier = this.cancelPrimaryBackfills(keys); + await primaryBackfillBarrier; if (this.stats.enabled) { const statResult = (await this._primary.get(keys)) as unknown; for (const key of keys) { @@ -1086,7 +1166,7 @@ export class Cacheable extends Hookified { const result = await this._primary.deleteMany(keys); if (this._secondary) { - if (this._nonBlocking) { + if (nonBlocking) { // Catch any errors to avoid unhandled promise rejections this._secondary.deleteMany(keys).catch((error) => { this.emit(CacheableEvents.ERROR, error); @@ -1097,7 +1177,7 @@ export class Cacheable extends Hookified { } if (this._tags.enabled) { - await this._tags.removeKeys(keys, { nonBlocking: this._nonBlocking }); + await this._tags.removeKeys(keys, { nonBlocking }); } // Publish to sync if enabled @@ -1203,7 +1283,7 @@ export class Cacheable extends Hookified { * @param {GetOrSetKey} key - The key to retrieve or set in the cache. This can also be a function that returns a string key. * If a function is provided, it will be called with the cache options to generate the key. * @param {() => Promise} function_ - The asynchronous function that computes the value to be cached if the key does not exist. - * @param {GetOrSetFunctionOptions} [options] - Optional settings for caching, such as the time to live (TTL) or whether to cache errors. + * @param {GetOrSetFunctionOptions} [options] - Optional settings for caching, such as the time to live (TTL), tags, or whether to cache errors. * @return {Promise} - A promise that resolves to the cached or newly computed value, or undefined if an error occurs and caching is not configured for errors. */ public async getOrSet( @@ -1225,7 +1305,7 @@ export class Cacheable extends Hookified { value: unknown, ttl?: number | string | PerStoreTtl, ) => { - await this.set(key, value, { ttl }); + await this.set(key, value, { ttl, tags: options?.tags }); }, /* v8 ignore next -- @preserve */ on: (event: string, listener: (...args: unknown[]) => void) => { @@ -1351,6 +1431,99 @@ export class Cacheable extends Hookified { await Promise.all(promises); } + private createPrimaryBackfill(key: string): PrimaryBackfillController { + let resolveDone!: () => void; + const done = new Promise((resolve) => { + resolveDone = resolve; + }); + const controller: PrimaryBackfillController = { + key, + cancelled: false, + phase: "pending", + done, + resolveDone, + }; + const controllers = + this._primaryBackfills.get(key) ?? new Set(); + controllers.add(controller); + this._primaryBackfills.set(key, controllers); + return controller; + } + + private finishPrimaryBackfill(controller: PrimaryBackfillController): void { + if (controller.phase === "done") { + return; + } + + controller.phase = "done"; + controller.resolveDone(); + const controllers = this._primaryBackfills.get(controller.key); + controllers?.delete(controller); + if (controllers?.size === 0) { + this._primaryBackfills.delete(controller.key); + } + } + + private discardPrimaryBackfill(controller: PrimaryBackfillController): void { + controller.cancelled = true; + /* v8 ignore next -- @preserve */ + if (controller.phase !== "writing") { + this.finishPrimaryBackfill(controller); + } + } + + private cancelPrimaryBackfills(keys: string[]): Promise { + const writing: Promise[] = []; + for (const key of new Set(keys)) { + const controllers = this._primaryBackfills.get(key); + if (!controllers) { + continue; + } + + for (const controller of [...controllers]) { + controller.cancelled = true; + if (controller.phase === "writing") { + writing.push(controller.done); + } else { + this.finishPrimaryBackfill(controller); + } + } + } + + return Promise.all(writing).then(() => undefined); + } + + private startPrimaryBackfill( + controller: PrimaryBackfillController, + primary: Keyv, + setItem: CacheableSecondarySetsPrimaryItem, + ): void { + if (controller.cancelled || controller.phase === "done") { + this.finishPrimaryBackfill(controller); + return; + } + + controller.phase = "hook"; + void (async () => { + try { + await this.hook(CacheableHooks.BEFORE_SECONDARY_SETS_PRIMARY, setItem); + if (controller.cancelled) { + return; + } + + controller.phase = "writing"; + await primary.set( + setItem.key, + setItem.value, + resolvePerStoreTtl(setItem.ttl).primary, + ); + } catch (error: unknown) { + this.emit(CacheableEvents.ERROR, error); + } finally { + this.finishPrimaryBackfill(controller); + } + })(); + } /** * Processes a single key from secondary store for getRaw operation @@ -1413,12 +1586,21 @@ export class Cacheable extends Hookified { | { result: StoredDataRaw; ttl?: number | string; + backfill: () => void; + discard: () => void; } | undefined > { - const secondaryResult = await secondary.getRaw(key); + const controller = this.createPrimaryBackfill(key); + let secondaryResult: StoredDataRaw | undefined; + try { + secondaryResult = await secondary.getRaw(key); + } catch (error: unknown) { + this.discardPrimaryBackfill(controller); + throw error; + } + if (secondaryResult?.value) { - // Emit cache hit for secondary store this.emit(CacheableEvents.CACHE_HIT, { key, value: secondaryResult.value, @@ -1428,29 +1610,19 @@ export class Cacheable extends Hookified { const expires = secondaryResult.expires ?? undefined; const ttl = calculateTtlFromExpiration(cascadeTtl, expires); const setItem = { key, value: secondaryResult.value, ttl }; + const backfill = () => { + this.startPrimaryBackfill(controller, primary, setItem); + }; + const discard = () => { + this.discardPrimaryBackfill(controller); + }; - // In non-blocking mode, fire and forget the hook and primary store update - /* v8 ignore next -- @preserve */ - this.hook(CacheableHooks.BEFORE_SECONDARY_SETS_PRIMARY, setItem) - .then(async () => { - await primary.set( - setItem.key, - setItem.value, - resolvePerStoreTtl(setItem.ttl).primary, - ); - }) - /* v8 ignore next -- @preserve */ - .catch((error) => { - /* v8 ignore next -- @preserve */ - this.emit(CacheableEvents.ERROR, error); - }); - - return { result: secondaryResult, ttl }; - } else { - // Emit cache miss for secondary store - this.emit(CacheableEvents.CACHE_MISS, { key, store: "secondary" }); - return undefined; + return { result: secondaryResult, ttl, backfill, discard }; } + + this.discardPrimaryBackfill(controller); + this.emit(CacheableEvents.CACHE_MISS, { key, store: "secondary" }); + return undefined; } /** @@ -1529,75 +1701,84 @@ export class Cacheable extends Hookified { * @param secondary - the secondary store to use * @param keys - The original array of keys requested * @param result - The result array from primary store (will be modified) - * @returns Promise + * @returns Deferred primary backfills, keyed by their result index */ private async processSecondaryForGetManyRawNonBlocking( primary: Keyv, secondary: Keyv, keys: string[], result: Array>, - ): Promise { - const missingKeys = []; - for (const [i, key] of keys.entries()) { - if (!result[i]) { - missingKeys.push(key); + ): Promise< + Array<{ + index: number; + backfill: () => void; + discard: () => void; + }> + > { + const missingItems: Array<{ + index: number; + key: string; + controller: PrimaryBackfillController; + }> = []; + for (const [index, key] of keys.entries()) { + if (!result[index]) { + missingItems.push({ + index, + key, + controller: this.createPrimaryBackfill(key), + }); } } - // Get secondary results synchronously but don't wait for primary store updates - const secondaryResults = await secondary.getManyRaw(missingKeys); - - let secondaryIndex = 0; - for await (const [i, key] of keys.entries()) { - if (!result[i]) { - const secondaryResult = secondaryResults[secondaryIndex]; - if (secondaryResult && secondaryResult.value !== undefined) { - result[i] = secondaryResult; - // Emit cache hit for secondary store - this.emit(CacheableEvents.CACHE_HIT, { - key, - value: secondaryResult.value, - store: "secondary", - }); - - const cascadeTtl = getCascadingTtl(this._ttl, this._primary.ttl); - - let { expires } = secondaryResult; - - /* v8 ignore next -- @preserve */ - if (expires === null) { - expires = undefined; - } - - const ttl = calculateTtlFromExpiration(cascadeTtl, expires); + let secondaryResults: Array>; + try { + secondaryResults = await secondary.getManyRaw( + missingItems.map((item) => item.key), + ); + } catch (error: unknown) { + for (const { controller } of missingItems) { + this.discardPrimaryBackfill(controller); + } + throw error; + } - const setItem = { key, value: secondaryResult.value, ttl }; + const primaryBackfills: Array<{ + index: number; + backfill: () => void; + discard: () => void; + }> = []; + for (const [secondaryIndex, item] of missingItems.entries()) { + const { index, key, controller } = item; + const secondaryResult = secondaryResults[secondaryIndex]; + if (secondaryResult && secondaryResult.value !== undefined) { + result[index] = secondaryResult; + this.emit(CacheableEvents.CACHE_HIT, { + key, + value: secondaryResult.value, + store: "secondary", + }); - // In non-blocking mode, fire and forget the hook and primary store update - /* v8 ignore next -- @preserve */ - this.hook(CacheableHooks.BEFORE_SECONDARY_SETS_PRIMARY, setItem) - .then(async () => { - await primary.set( - setItem.key, - setItem.value, - resolvePerStoreTtl(setItem.ttl).primary, - ); - }) - /* v8 ignore next -- @preserve */ - .catch((error) => { - /* v8 ignore next -- @preserve */ - this.emit(CacheableEvents.ERROR, error); - }); - } else { - // Emit cache miss for secondary store - this.emit(CacheableEvents.CACHE_MISS, { - key, - store: "secondary", - }); - } - secondaryIndex++; + const cascadeTtl = getCascadingTtl(this._ttl, this._primary.ttl); + const expires = secondaryResult.expires ?? undefined; + const ttl = calculateTtlFromExpiration(cascadeTtl, expires); + const setItem = { key, value: secondaryResult.value, ttl }; + const backfill = () => { + this.startPrimaryBackfill(controller, primary, setItem); + }; + const discard = () => { + this.discardPrimaryBackfill(controller); + }; + primaryBackfills.push({ index, backfill, discard }); + } else { + this.discardPrimaryBackfill(controller); + this.emit(CacheableEvents.CACHE_MISS, { + key, + store: "secondary", + }); } } + + return primaryBackfills; } private setTtl(ttl: number | string | undefined): void { diff --git a/packages/cacheable/src/types.ts b/packages/cacheable/src/types.ts index 806fc05c..0cb5a0ab 100644 --- a/packages/cacheable/src/types.ts +++ b/packages/cacheable/src/types.ts @@ -10,16 +10,23 @@ import type { CacheableSync, CacheableSyncOptions } from "./sync.js"; export type { PerStoreTtl } from "@cacheable/utils"; /** - * Options for {@link Cacheable.getOrSet}. Identical to the shared - * `GetOrSetFunctionOptions` from `@cacheable/utils`, except `ttl` also accepts a per-store object + * Options for {@link Cacheable.getOrSet}. Extends the shared `GetOrSetFunctionOptions` from + * `@cacheable/utils` with per-store TTLs and tags. The `ttl` option also accepts a per-store object * (`{ primary, secondary }`) so the primary and secondary stores can be given different - * expirations for that operation. + * expirations for that operation, while `tags` associates newly computed entries with tags for + * invalidation. */ export type GetOrSetFunctionOptions = Omit< UtilsGetOrSetFunctionOptions, "ttl" > & { ttl?: number | string | PerStoreTtl; + /** + * Tags to associate with a newly computed entry for tag-based invalidation. Tags are only + * applied when `getOrSet` stores a value after a cache miss. + * @type {string[]} + */ + tags?: string[]; }; /** diff --git a/packages/cacheable/test/tags.test.ts b/packages/cacheable/test/tags.test.ts index bf9e19f3..1ecf58d8 100644 --- a/packages/cacheable/test/tags.test.ts +++ b/packages/cacheable/test/tags.test.ts @@ -1,10 +1,33 @@ import { faker } from "@faker-js/faker"; import { Keyv } from "keyv"; import { describe, expect, test, vi } from "vitest"; -import { Cacheable, CacheableEvents, CacheTags } from "../src/index.js"; +import { + Cacheable, + CacheableEvents, + CacheableHooks, + CacheTags, +} from "../src/index.js"; const TAG_PREFIX = "--cacheable--tags--"; +type Deferred = { + promise: Promise; + resolve: () => void; +}; + +const createDeferred = (): Deferred => { + let resolve: () => void = () => {}; + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve; + }); + return { promise, resolve }; +}; + +const nextEventLoopTurn = () => + new Promise((resolve) => { + setImmediate(resolve); + }); + describe("cacheable tags", () => { test("tag service is created by default and disabled until enabled", () => { const cacheable = new Cacheable(); @@ -69,6 +92,479 @@ describe("cacheable tags", () => { expect(await cacheable.get(key)).toBeUndefined(); }); + test("getOrSet associates tags with a newly computed entry", async () => { + const cacheable = new Cacheable({ tags: true }); + const key = faker.string.uuid(); + let calls = 0; + const options = { tags: ["entity:42"] }; + const getValue = async () => { + calls++; + return `value-${calls}`; + }; + + expect(await cacheable.getOrSet(key, getValue, options)).toEqual("value-1"); + expect(await cacheable.tags.getTags(key)).toEqual(["entity:42"]); + expect(await cacheable.getOrSet(key, getValue, options)).toEqual("value-1"); + expect(calls).toBe(1); + + await cacheable.tags.invalidateTag("entity:42"); + expect(await cacheable.getOrSet(key, getValue, options)).toEqual("value-2"); + expect(calls).toBe(2); + expect(await cacheable.tags.getTags(key)).toEqual(["entity:42"]); + }); + + test("getOrSet leaves a recomputed entry untagged when tags are omitted", async () => { + const cacheable = new Cacheable({ tags: true }); + const key = faker.string.uuid(); + let calls = 0; + const getValue = async () => { + calls++; + return `value-${calls}`; + }; + + await cacheable.getOrSet(key, getValue, { tags: ["entity:42"] }); + await cacheable.tags.invalidateTag("entity:42"); + + expect(await cacheable.getOrSet(key, getValue)).toEqual("value-2"); + expect(calls).toBe(2); + expect(await cacheable.tags.getTags(key)).toBeUndefined(); + + await cacheable.tags.invalidateTag("entity:42"); + expect(await cacheable.getOrSet(key, getValue)).toEqual("value-2"); + expect(calls).toBe(2); + }); + + test("a scheduled secondary backfill cannot overwrite a newer getOrSet value", async () => { + const primary = new Keyv(); + const secondary = new Keyv(); + const cacheable = new Cacheable({ primary, secondary, tags: true }); + const key = "scheduled-backfill-race"; + const tag = "entity:42"; + + await cacheable.set(key, "stale", { tags: [tag] }); + await primary.delete(key); + + const staleBackfillGate = createDeferred(); + const staleBackfillStarted = createDeferred(); + const staleBackfillReleased = createDeferred(); + cacheable.onHook( + CacheableHooks.BEFORE_SECONDARY_SETS_PRIMARY, + async (item) => { + if (item.key === key && item.value === "stale") { + staleBackfillStarted.resolve(); + await staleBackfillGate.promise; + staleBackfillReleased.resolve(); + } + }, + ); + + const freshPrimaryWritten = createDeferred(); + const originalSet = primary.set.bind(primary); + vi.spyOn(primary, "set").mockImplementation( + async (setKey: string, value: unknown, ttl?: number) => { + const result = await originalSet(setKey, value, ttl); + if (setKey === key && value === "fresh") { + freshPrimaryWritten.resolve(); + } + return result; + }, + ); + + // The secondary value is fresh when this non-blocking backfill is scheduled. + expect(await cacheable.get(key, { nonBlocking: true })).toEqual("stale"); + await staleBackfillStarted.promise; + + await cacheable.tags.invalidateTag(tag); + const getValue = vi.fn(async () => "fresh"); + const recompute = cacheable.getOrSet(key, getValue, { + tags: [tag], + nonBlocking: true, + }); + + // Allow cancellation to write immediately, or serialization to wait for the gate. + await Promise.race([freshPrimaryWritten.promise, nextEventLoopTurn()]); + staleBackfillGate.resolve(); + + expect(await recompute).toEqual("fresh"); + await Promise.all([ + freshPrimaryWritten.promise, + staleBackfillReleased.promise, + ]); + await nextEventLoopTurn(); + + expect(getValue).toHaveBeenCalledTimes(1); + expect(await primary.get(key)).toEqual("fresh"); + expect(await secondary.get(key)).toEqual("fresh"); + expect(await cacheable.get(key)).toEqual("fresh"); + }); + + test("getOrSet waits for an in-flight secondary backfill write", async () => { + const primary = new Keyv(); + const secondary = new Keyv(); + const cacheable = new Cacheable({ primary, secondary, tags: true }); + const key = "writing-backfill-race"; + const tag = "entity:42"; + + await cacheable.set(key, "stale", { tags: [tag] }); + await primary.delete(key); + + const staleWriteGate = createDeferred(); + const staleWriteStarted = createDeferred(); + const staleWriteFinished = createDeferred(); + const originalPrimarySet = primary.set.bind(primary); + vi.spyOn(primary, "set").mockImplementation( + async (setKey: string, value: unknown, ttl?: number) => { + const result = await originalPrimarySet(setKey, value, ttl); + if (setKey === key && value === "stale") { + staleWriteStarted.resolve(); + await staleWriteGate.promise; + staleWriteFinished.resolve(); + } + return result; + }, + ); + + expect(await cacheable.get(key, { nonBlocking: true })).toEqual("stale"); + await staleWriteStarted.promise; + expect(await primary.get(key)).toEqual("stale"); + + await cacheable.tags.invalidateTag(tag); + const getValue = vi.fn(async () => "fresh"); + let recomputeSettled = false; + const recompute = cacheable + .getOrSet(key, getValue, { tags: [tag], nonBlocking: true }) + .finally(() => { + recomputeSettled = true; + }); + + await nextEventLoopTurn(); + const settledBeforeRelease = recomputeSettled; + staleWriteGate.resolve(); + + expect(await recompute).toEqual("fresh"); + await staleWriteFinished.promise; + expect(settledBeforeRelease).toBe(false); + expect(getValue).toHaveBeenCalledTimes(1); + expect(await primary.get(key)).toEqual("fresh"); + expect(await secondary.get(key)).toEqual("fresh"); + expect(await cacheable.get(key)).toEqual("fresh"); + }); + + test("a rejected non-blocking secondary read releases its controller", async () => { + const secondary = new Keyv(); + const cacheable = new Cacheable({ secondary }); + const key = "rejected-secondary-read"; + const error = new Error("secondary read failed"); + const errors = vi.fn(); + cacheable.on(CacheableEvents.ERROR, errors); + vi.spyOn(secondary, "getRaw").mockRejectedValueOnce(error); + + expect(await cacheable.get(key, { nonBlocking: true })).toBeUndefined(); + expect(errors).toHaveBeenCalledWith(error); + + let setSettled = false; + const setResult = cacheable.set(key, "fresh").finally(() => { + setSettled = true; + }); + await nextEventLoopTurn(); + + expect(setSettled).toBe(true); + expect(await setResult).toBe(true); + expect(await cacheable.get(key)).toEqual("fresh"); + }); + + test("a non-blocking secondary miss releases its controller", async () => { + const secondary = new Keyv(); + const cacheable = new Cacheable({ secondary }); + const key = "secondary-miss"; + const misses = vi.fn(); + cacheable.on(CacheableEvents.CACHE_MISS, misses); + + expect(await cacheable.get(key, { nonBlocking: true })).toBeUndefined(); + expect(misses).toHaveBeenNthCalledWith(1, { key, store: "primary" }); + expect(misses).toHaveBeenNthCalledWith(2, { key, store: "secondary" }); + + expect(await cacheable.set(key, "fresh")).toBe(true); + expect(await cacheable.get(key)).toEqual("fresh"); + }); + + test("a rejected non-blocking batched secondary read releases its controllers", async () => { + const secondary = new Keyv(); + const cacheable = new Cacheable({ secondary }); + const keys = ["rejected-secondary-many-a", "rejected-secondary-many-b"]; + const error = new Error("secondary batched read failed"); + const errors = vi.fn(); + cacheable.on(CacheableEvents.ERROR, errors); + vi.spyOn(secondary, "getManyRaw").mockRejectedValueOnce(error); + + expect(await cacheable.getMany(keys, { nonBlocking: true })).toEqual([ + undefined, + undefined, + ]); + expect(errors).toHaveBeenCalledWith(error); + + let setManySettled = false; + const setManyResult = cacheable + .setMany([ + { key: keys[0], value: "fresh-a" }, + { key: keys[1], value: "fresh-b" }, + ]) + .finally(() => { + setManySettled = true; + }); + await nextEventLoopTurn(); + + expect(setManySettled).toBe(true); + expect(await setManyResult).toBe(true); + expect(await cacheable.getMany(keys)).toEqual(["fresh-a", "fresh-b"]); + }); + + test("a rejected tag check discards pending batched backfills", async () => { + const primary = new Keyv(); + const secondary = new Keyv(); + const cacheable = new Cacheable({ primary, secondary, tags: true }); + const keys = ["rejected-tag-check-a", "rejected-tag-check-b"]; + const error = new Error("tag freshness check failed"); + const errors = vi.fn(); + cacheable.on(CacheableEvents.ERROR, errors); + await secondary.setMany([ + { key: keys[0], value: "stale-a" }, + { key: keys[1], value: "stale-b" }, + ]); + vi.spyOn(cacheable.tags, "getStaleKeys").mockRejectedValueOnce(error); + + expect(await cacheable.getMany(keys, { nonBlocking: true })).toEqual([ + "stale-a", + "stale-b", + ]); + expect(errors).toHaveBeenCalledWith(error); + await nextEventLoopTurn(); + expect(await primary.getMany(keys)).toEqual([undefined, undefined]); + + expect( + await cacheable.setMany([ + { key: keys[0], value: "fresh-a" }, + { key: keys[1], value: "fresh-b" }, + ]), + ).toBe(true); + }); + + test("an authoritative set cancels a backfill before its secondary read returns", async () => { + const primary = new Keyv(); + const secondary = new Keyv(); + const cacheable = new Cacheable({ primary, secondary }); + const key = "cancelled-pending-backfill"; + + await secondary.set(key, "stale"); + const secondaryReadGate = createDeferred(); + const secondaryReadStarted = createDeferred(); + const originalSecondaryGetRaw = secondary.getRaw.bind(secondary); + vi.spyOn(secondary, "getRaw").mockImplementation( + async (readKey: string) => { + const result = await originalSecondaryGetRaw(readKey); + if (readKey === key) { + secondaryReadStarted.resolve(); + await secondaryReadGate.promise; + } + return result; + }, + ); + const backfillHook = vi.fn(); + cacheable.onHook( + CacheableHooks.BEFORE_SECONDARY_SETS_PRIMARY, + backfillHook, + ); + + const oldRead = cacheable.get(key, { nonBlocking: true }); + await secondaryReadStarted.promise; + expect(await cacheable.set(key, "fresh")).toBe(true); + secondaryReadGate.resolve(); + + expect(await oldRead).toEqual("stale"); + await nextEventLoopTurn(); + expect(backfillHook).not.toHaveBeenCalled(); + expect(await primary.get(key)).toEqual("fresh"); + expect(await secondary.get(key)).toEqual("fresh"); + expect(await cacheable.get(key)).toEqual("fresh"); + }); + + test("a rejected non-blocking primary backfill releases its controller", async () => { + const primary = new Keyv(); + const secondary = new Keyv(); + const cacheable = new Cacheable({ primary, secondary }); + const key = "rejected-primary-backfill"; + const error = new Error("primary backfill failed"); + + await secondary.set(key, "stale"); + const backfillErrorEmitted = createDeferred(); + const errors = vi.fn((emittedError: unknown) => { + if (emittedError === error) { + backfillErrorEmitted.resolve(); + } + }); + cacheable.on(CacheableEvents.ERROR, errors); + + const originalPrimarySet = primary.set.bind(primary); + let shouldRejectBackfill = true; + vi.spyOn(primary, "set").mockImplementation( + async (setKey: string, value: unknown, ttl?: number) => { + if (setKey === key && value === "stale" && shouldRejectBackfill) { + shouldRejectBackfill = false; + throw error; + } + return originalPrimarySet(setKey, value, ttl); + }, + ); + + expect(await cacheable.get(key, { nonBlocking: true })).toEqual("stale"); + await backfillErrorEmitted.promise; + expect(errors).toHaveBeenCalledWith(error); + + let setSettled = false; + const setResult = cacheable.set(key, "fresh").finally(() => { + setSettled = true; + }); + await nextEventLoopTurn(); + + expect(setSettled).toBe(true); + expect(await setResult).toBe(true); + expect(await primary.get(key)).toEqual("fresh"); + expect(await secondary.get(key)).toEqual("fresh"); + expect(await cacheable.get(key)).toEqual("fresh"); + }); + + test("a delayed stale secondary delete cannot erase a recomputed value", async () => { + const primary = new Keyv(); + const secondary = new Keyv(); + const cacheable = new Cacheable({ + primary, + secondary, + nonBlocking: true, + tags: true, + }); + const key = "secondary-delete-race"; + const tag = "entity:42"; + + await cacheable.set(key, "stale", { + nonBlocking: false, + tags: [tag], + }); + await cacheable.tags.invalidateTag(tag); + + const staleDeleteGate = createDeferred(); + const staleDeleteStarted = createDeferred(); + const staleDeleteFinished = createDeferred(); + const originalDelete = secondary.delete.bind(secondary); + vi.spyOn(secondary, "delete").mockImplementation( + async (deleteKey: string | string[]) => { + if (deleteKey === key) { + staleDeleteStarted.resolve(); + await staleDeleteGate.promise; + const result = await originalDelete(deleteKey); + staleDeleteFinished.resolve(); + return result; + } + return originalDelete(deleteKey); + }, + ); + + const freshSecondaryWritten = createDeferred(); + const originalSecondarySet = secondary.set.bind(secondary); + vi.spyOn(secondary, "set").mockImplementation( + async (setKey: string, value: unknown, ttl?: number) => { + const result = await originalSecondarySet(setKey, value, ttl); + if (setKey === key && value === "fresh") { + freshSecondaryWritten.resolve(); + } + return result; + }, + ); + + const recompute = cacheable.getOrSet(key, async () => "fresh", { + tags: [tag], + }); + await staleDeleteStarted.promise; + await Promise.race([freshSecondaryWritten.promise, nextEventLoopTurn()]); + staleDeleteGate.resolve(); + + expect(await recompute).toEqual("fresh"); + await Promise.all([ + staleDeleteFinished.promise, + freshSecondaryWritten.promise, + ]); + await nextEventLoopTurn(); + + expect(await secondary.get(key)).toEqual("fresh"); + expect(await cacheable.get(key)).toEqual("fresh"); + }); + + test("a delayed stale snapshot delete cannot erase a newer tag snapshot", async () => { + const primary = new Keyv(); + const cacheable = new Cacheable({ + primary, + nonBlocking: true, + tags: true, + }); + const key = "snapshot-delete-race"; + const tag = "entity:42"; + const snapshotKey = `${TAG_PREFIX}:default:key:${key}`; + + await cacheable.set(key, "stale", { + nonBlocking: false, + tags: [tag], + }); + await cacheable.tags.invalidateTag(tag); + + const staleSnapshotDeleteGate = createDeferred(); + const staleSnapshotDeleteStarted = createDeferred(); + const staleSnapshotDeleteFinished = createDeferred(); + const originalDeleteMany = primary.deleteMany.bind(primary); + let shouldDelaySnapshotDelete = true; + vi.spyOn(primary, "deleteMany").mockImplementation( + async (keys: string[]) => { + if (shouldDelaySnapshotDelete && keys.includes(snapshotKey)) { + shouldDelaySnapshotDelete = false; + staleSnapshotDeleteStarted.resolve(); + await staleSnapshotDeleteGate.promise; + const result = await originalDeleteMany(keys); + staleSnapshotDeleteFinished.resolve(); + return result; + } + return originalDeleteMany(keys); + }, + ); + + const freshSnapshotWritten = createDeferred(); + const originalPrimarySet = primary.set.bind(primary); + vi.spyOn(primary, "set").mockImplementation( + async (setKey: string, value: unknown, ttl?: number) => { + const result = await originalPrimarySet(setKey, value, ttl); + if (setKey === snapshotKey) { + freshSnapshotWritten.resolve(); + } + return result; + }, + ); + + const recompute = cacheable.getOrSet(key, async () => "fresh", { + tags: [tag], + }); + await staleSnapshotDeleteStarted.promise; + await Promise.race([freshSnapshotWritten.promise, nextEventLoopTurn()]); + staleSnapshotDeleteGate.resolve(); + + expect(await recompute).toEqual("fresh"); + await Promise.all([ + staleSnapshotDeleteFinished.promise, + freshSnapshotWritten.promise, + ]); + await nextEventLoopTurn(); + + expect(await cacheable.tags.getTags(key)).toEqual([tag]); + await cacheable.tags.invalidateTag(tag); + expect(await cacheable.get(key)).toBeUndefined(); + }); + test("set still supports ttl as the third argument", async () => { const cacheable = new Cacheable(); const key = faker.string.uuid(); @@ -176,6 +672,50 @@ describe("cacheable tags", () => { expect(await cacheable.getMany(["a", "b", "c"])).toEqual([undefined, 2, 3]); }); + test("getMany only backfills tag-fresh secondary values in non-blocking mode", async () => { + const primary = new Keyv(); + const secondary = new Keyv(); + const cacheable = new Cacheable({ primary, secondary, tags: true }); + const staleKey = "stale-many"; + const freshKey = "fresh-many"; + + await cacheable.setMany([ + { key: staleKey, value: "stale", tags: ["stale-tag"] }, + { key: freshKey, value: "fresh", tags: ["fresh-tag"] }, + ]); + await primary.deleteMany([staleKey, freshKey]); + await cacheable.tags.invalidateTag("stale-tag"); + + let releaseStaleBackfill: () => void = () => {}; + const staleBackfillGate = new Promise((resolve) => { + releaseStaleBackfill = resolve; + }); + const originalSet = primary.set.bind(primary); + vi.spyOn(primary, "set").mockImplementation( + async (setKey: string, value: unknown, ttl?: number) => { + if (setKey === staleKey && value === "stale") { + await staleBackfillGate; + } + + return originalSet(setKey, value, ttl); + }, + ); + + expect( + await cacheable.getMany([staleKey, freshKey], { nonBlocking: true }), + ).toEqual([undefined, "fresh"]); + + releaseStaleBackfill(); + await new Promise((resolve) => { + setImmediate(resolve); + }); + + expect(await primary.get(staleKey)).toBeUndefined(); + expect(await primary.get(freshKey)).toEqual("fresh"); + expect(await cacheable.get(staleKey)).toBeUndefined(); + expect(await cacheable.get(freshKey)).toEqual("fresh"); + }); + test("setMany with tags while disabled stores values without tracking", async () => { const cacheable = new Cacheable(); await cacheable.setMany([{ key: "a", value: 1, tags: ["t"] }]); diff --git a/packages/cacheable/tsconfig.json b/packages/cacheable/tsconfig.json index d6359664..35a98d34 100644 --- a/packages/cacheable/tsconfig.json +++ b/packages/cacheable/tsconfig.json @@ -2,8 +2,7 @@ "compilerOptions": { "target": "ESNext", "module": "ESNext", - "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ - "baseUrl": "./src", /* Specify the base directory to resolve non-relative module names. */ + "moduleResolution": "bundler", /* Specify how TypeScript looks up a file from a given module specifier. */ /* Emit */ "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ @@ -23,4 +22,4 @@ "ESNext", "DOM" ] } -} \ No newline at end of file +} diff --git a/scripts/setup-cloud-environment.sh b/scripts/setup-cloud-environment.sh index 0c052e65..23b1ee49 100755 --- a/scripts/setup-cloud-environment.sh +++ b/scripts/setup-cloud-environment.sh @@ -21,8 +21,13 @@ if [[ ! -f pnpm-lock.yaml ]]; then exit 1 fi -if [[ -f package.json ]] && grep -q '"packageManager"' package.json && command -v corepack >/dev/null; then - corepack enable +if ! command -v pnpm >/dev/null \ + && [[ -f package.json ]] \ + && grep -q '"packageManager"' package.json \ + && command -v corepack >/dev/null; then + mkdir -p "$SAFE_CHAIN_BIN" + corepack enable --install-directory "$SAFE_CHAIN_BIN" pnpm + export PATH="${SAFE_CHAIN_BIN}:${PATH}" fi if ! command -v pnpm >/dev/null; then @@ -36,7 +41,12 @@ trap 'rm -f "$installer"' EXIT curl -fsSL "$SAFE_CHAIN_INSTALLER_URL" -o "$installer" echo "${SAFE_CHAIN_INSTALLER_SHA256} ${installer}" | sha256sum -c - -sh "$installer" --ci +# NVM auto-selects from the current directory when sourced. Run outside the +# repository so .nvmrc cannot break the installer's optional legacy scan. +( + cd / + sh "$installer" --ci +) export PATH="${SAFE_CHAIN_SHIMS}:${SAFE_CHAIN_BIN}:${PATH}"