Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/cacheable/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -796,6 +796,7 @@ 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');
Expand Down Expand Up @@ -1069,13 +1070,14 @@ The `getOrSet` method that comes from [@cacheable/utils](https://cacheable.org/
```typescript
export type GetOrSetFunctionOptions = {
ttl?: number | string | { primary?: number | string; secondary?: number | string };
tags?: string[];
cacheErrors?: boolean;
throwErrors?: boolean;
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).

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.

Expand Down
108 changes: 71 additions & 37 deletions packages/cacheable/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,7 @@ export class Cacheable extends Hookified {
result = await this._primary.getRaw(key);
// biome-ignore lint/suspicious/noImplicitAnyLet: allowed
let ttl;
let primaryBackfill: (() => void) | undefined;
// Emit cache hit or miss for primary store
if (result) {
this.emit(CacheableEvents.CACHE_HIT, {
Expand All @@ -563,6 +564,7 @@ export class Cacheable extends Hookified {
| {
result: StoredDataRaw<T>;
ttl?: number | string;
backfill?: () => void;
}
| undefined;
if (nonBlocking) {
Expand All @@ -582,12 +584,18 @@ export class Cacheable extends Hookified {
if (secondaryProcessResult) {
result = secondaryProcessResult.result;
ttl = secondaryProcessResult.ttl;
primaryBackfill = secondaryProcessResult.backfill;
}
}

if (result && this._tags.enabled && (await this._tags.isKeyStale(key))) {
await this.delete(key);
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?.();
}

await this.hook(CacheableHooks.AFTER_GET, { key, result, ttl });
Expand Down Expand Up @@ -643,15 +651,20 @@ export class Cacheable extends Hookified {
}

const nonBlocking = options?.nonBlocking ?? this._nonBlocking;
let primaryBackfills: Array<{
index: number;
backfill: () => void;
}> = [];

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,
Expand All @@ -677,6 +690,13 @@ export class Cacheable extends Hookified {
}
}

// Start only backfills whose secondary values survived the tag freshness check.
for (const { index, backfill } of primaryBackfills) {
if (result[index] !== undefined) {
backfill();
}
}

await this.hook(CacheableHooks.AFTER_GET_MANY, { keys, result });
} catch (error: unknown) {
this.emit(CacheableEvents.ERROR, error);
Expand Down Expand Up @@ -1203,7 +1223,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<T>} 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<T | undefined>} - 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<T>(
Expand All @@ -1225,7 +1245,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) => {
Expand Down Expand Up @@ -1413,6 +1433,7 @@ export class Cacheable extends Hookified {
| {
result: StoredDataRaw<T>;
ttl?: number | string;
backfill: () => void;
}
| undefined
> {
Expand All @@ -1429,23 +1450,26 @@ export class Cacheable extends Hookified {
const ttl = calculateTtlFromExpiration(cascadeTtl, expires);
const setItem = { key, value: secondaryResult.value, ttl };

// 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,
);
})
// The caller starts this only after tag freshness has been checked.
const backfill = () => {
// In non-blocking mode, fire and forget the hook and primary store update
/* v8 ignore next -- @preserve */
.catch((error) => {
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 */
this.emit(CacheableEvents.ERROR, error);
});
.catch((error) => {
/* v8 ignore next -- @preserve */
this.emit(CacheableEvents.ERROR, error);
});
};

return { result: secondaryResult, ttl };
return { result: secondaryResult, ttl, backfill };
} else {
// Emit cache miss for secondary store
this.emit(CacheableEvents.CACHE_MISS, { key, store: "secondary" });
Expand Down Expand Up @@ -1529,15 +1553,19 @@ 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<void>
* @returns Deferred primary backfills, keyed by their result index
*/
private async processSecondaryForGetManyRawNonBlocking<T>(
primary: Keyv,
secondary: Keyv,
keys: string[],
result: Array<StoredDataRaw<T>>,
): Promise<void> {
): Promise<Array<{ index: number; backfill: () => void }>> {
const missingKeys = [];
const primaryBackfills: Array<{
index: number;
backfill: () => void;
}> = [];
for (const [i, key] of keys.entries()) {
if (!result[i]) {
missingKeys.push(key);
Expand Down Expand Up @@ -1573,21 +1601,25 @@ export class Cacheable extends Hookified {

const setItem = { key, value: secondaryResult.value, ttl };

// 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,
);
})
// The caller starts this only after tag freshness has been checked.
const backfill = () => {
// In non-blocking mode, fire and forget the hook and primary store update
/* v8 ignore next -- @preserve */
.catch((error) => {
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 */
this.emit(CacheableEvents.ERROR, error);
});
.catch((error) => {
/* v8 ignore next -- @preserve */
this.emit(CacheableEvents.ERROR, error);
});
};
primaryBackfills.push({ index: i, backfill });
} else {
// Emit cache miss for secondary store
this.emit(CacheableEvents.CACHE_MISS, {
Expand All @@ -1598,6 +1630,8 @@ export class Cacheable extends Hookified {
secondaryIndex++;
}
}

return primaryBackfills;
}

private setTtl(ttl: number | string | undefined): void {
Expand Down
6 changes: 6 additions & 0 deletions packages/cacheable/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ export type GetOrSetFunctionOptions = Omit<
"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[];
};

/**
Expand Down
131 changes: 131 additions & 0 deletions packages/cacheable/test/tags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,93 @@ 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("getOrSet does not backfill a stale secondary value after recomputing", async () => {
const primary = new Keyv();
const secondary = new Keyv();
const cacheable = new Cacheable({ primary, secondary, tags: true });
const key = "get-or-set-race";
const tag = "entity:42";

await cacheable.set(key, "stale", { tags: [tag] });
await primary.delete(key);
await cacheable.tags.invalidateTag(tag);

let releaseStaleBackfill: () => void = () => {};
const staleBackfillGate = new Promise<void>((resolve) => {
releaseStaleBackfill = resolve;
});
const originalSet = primary.set.bind(primary);
vi.spyOn(primary, "set").mockImplementation(
async (setKey: string, value: unknown, ttl?: number) => {
if (setKey === key && value === "stale") {
await staleBackfillGate;
}

return originalSet(setKey, value, ttl);
},
);

const getValue = vi.fn(async () => "fresh");
expect(
await cacheable.getOrSet(key, getValue, {
tags: [tag],
nonBlocking: true,
}),
).toEqual("fresh");

releaseStaleBackfill();
await new Promise<void>((resolve) => {
setImmediate(resolve);
});

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("set still supports ttl as the third argument", async () => {
const cacheable = new Cacheable();
const key = faker.string.uuid();
Expand Down Expand Up @@ -176,6 +263,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<void>((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<void>((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"] }]);
Expand Down