Skip to content
Merged
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
33 changes: 33 additions & 0 deletions packages/cacheable/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
* [Storage Tiering and Caching](#storage-tiering-and-caching)
* [TTL Propagation and Storage Tiering](#ttl-propagation-and-storage-tiering)
* [Shorthand for Time to Live (ttl)](#shorthand-for-time-to-live-ttl)
* [Maximum Time to Live (maxTtl)](#maximum-time-to-live-maxttl)
* [Iteration on Primary and Secondary Stores](#iteration-on-primary-and-secondary-stores)
* [Non-Blocking Operations](#non-blocking-operations)
* [Non-Blocking with @keyv/redis](#non-blocking-with-keyvredis)
Expand Down Expand Up @@ -607,6 +608,36 @@ const value = await serviceB.get('config'); // undefined
* The sync feature requires a message provider to be running and accessible by all cache instances.
* Each cache instance has a unique `cacheId`. Events are only applied if they come from a different instance, preventing infinite loops.

# Maximum Time to Live (maxTtl)

You can set a `maxTtl` option to enforce an upper bound on any TTL in the cache. When `maxTtl` is set:
- Any per-entry TTL that exceeds `maxTtl` will be capped to `maxTtl`.
- Entries with no TTL (that would otherwise live indefinitely) will be capped to `maxTtl`.
- The default TTL is still respected if it is within the `maxTtl` limit.
- The `maxTtl` is enforced on both primary and secondary stores.

This is useful when you want to guarantee that no cache entry lives longer than a certain duration, regardless of what TTL is passed to individual `set()` calls.

```javascript
import { Cacheable } from 'cacheable';

// No entry can live longer than 1 hour
const cache = new Cacheable({ maxTtl: '1h' });

await cache.set('key1', 'value1', '2h'); // capped to 1 hour
await cache.set('key2', 'value2'); // also capped to 1 hour (would otherwise be indefinite)
await cache.set('key3', 'value3', '30m'); // 30 minutes is within maxTtl, so it stays as-is
```

You can also set `maxTtl` after construction:

```javascript
const cache = new Cacheable();
cache.maxTtl = 5000; // 5 seconds max
cache.maxTtl = '10m'; // 10 minutes max
cache.maxTtl = undefined; // disable maxTtl (no upper bound)
```

# Cacheable Options

The following options are available for you to configure `cacheable`:
Expand All @@ -616,6 +647,7 @@ The following options are available for you to configure `cacheable`:
* `nonBlocking`: If the secondary store is non-blocking. Default is `false`.
* `stats`: To enable statistics for this instance. Default is `false`.
* `ttl`: The default time to live for the cache in milliseconds. Default is `undefined` which is disabled.
* `maxTtl`: The maximum time to live for any cache entry. When set, TTLs exceeding this value are capped. Enforced on both primary and secondary stores. Default is `undefined` (no maximum).
* `namespace`: The namespace for the cache. Default is `undefined`.
* `cacheId`: A unique identifier for this cache instance. Used for sync filtering. Default is a random string.
* `sync`: Enable distributed cache synchronization. Can be:
Expand Down Expand Up @@ -667,6 +699,7 @@ _This does not enable statistics for your layer 2 cache as that is a distributed
* `primary`: The primary store for the cache (layer 1) defaults to in-memory by Keyv.
* `secondary`: The secondary store for the cache (layer 2) usually a persistent cache by Keyv.
* `namespace`: The namespace for the cache. Default is `undefined`. This will set the namespace for the primary and secondary stores.
* `maxTtl`: The maximum time to live for any cache entry. When set, TTLs exceeding this value are capped. Default is `undefined` (no maximum).
* `nonBlocking`: If the secondary store is non-blocking. Default is `false`.
* `stats`: The statistics for this instance which includes `hits`, `misses`, `sets`, `deletes`, `clears`, `errors`, `count`, `vsize`, `ksize`.

Expand Down
76 changes: 73 additions & 3 deletions packages/cacheable/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export class Cacheable extends Hookified {
private _secondary: Keyv | undefined;
private _nonBlocking = false;
private _ttl?: number | string;
private _maxTtl?: number | string;
Comment thread
jaredwray marked this conversation as resolved.
private readonly _stats = new CacheableStats({ enabled: false });
private _namespace?: string | (() => string);
private _cacheId: string = Math.random().toString(36).slice(2);
Expand Down Expand Up @@ -64,6 +65,10 @@ export class Cacheable extends Hookified {
this.setTtl(options.ttl);
}

if (options?.maxTtl !== undefined) {
this.setMaxTtl(options.maxTtl);
}

if (options?.cacheId) {
this._cacheId = options.cacheId;
}
Expand Down Expand Up @@ -223,6 +228,40 @@ export class Cacheable extends Hookified {
this.setTtl(ttl);
}

/**
* Gets the maximum time-to-live for the cacheable instance. When set, any TTL that exceeds this
* value is capped to maxTtl. Entries with no TTL will also be capped to maxTtl.
* Can be a number in milliseconds or a human-readable format such as `1s`, `1m`, `1h`, `1d`.
* Default is `undefined` (no maximum).
*
* @returns {number | string | undefined} The maximum time-to-live or undefined if not set
* @example
* ```typescript
* const cacheable = new Cacheable({ maxTtl: '1h' });
* console.log(cacheable.maxTtl); // '1h'
* ```
*/
public get maxTtl(): number | string | undefined {
return this._maxTtl;
}

/**
* Sets the maximum time-to-live for the cacheable instance. When set, any TTL that exceeds this
* value is capped to maxTtl. Entries with no TTL will also be capped to maxTtl.
* If you set a number it is milliseconds, if you set a string it is a human-readable
* format such as `1s` for 1 second or `1h` for 1 hour. Setting undefined disables the maximum.
*
* @param {number | string | undefined} maxTtl The maximum time-to-live
* @example
* ```typescript
* const cacheable = new Cacheable();
* cacheable.maxTtl = '1h'; // Set the max TTL to 1 hour
* ```
*/
public set maxTtl(maxTtl: number | string | undefined) {
this.setMaxTtl(maxTtl);
}

/**
* The cacheId for the cacheable instance. This is primarily used for the wrap function to not have conflicts.
* If it is not set then it will be a random string that is generated
Expand Down Expand Up @@ -517,21 +556,25 @@ export class Cacheable extends Hookified {
): Promise<boolean> {
let result = false;
const explicitTtl = shorthandToMilliseconds(ttl);
const maxTtlMs = shorthandToMilliseconds(this._maxTtl);
Comment thread
jaredwray marked this conversation as resolved.
try {
const primaryTtl = getCascadingTtl(
let primaryTtl = getCascadingTtl(
this._ttl,
this._primary.ttl,
explicitTtl,
);
primaryTtl = this.capTtl(primaryTtl, maxTtlMs);
const item = { key, value, ttl: primaryTtl };
await this.hook(CacheableHooks.BEFORE_SET, item);
const hookOverridden = item.ttl !== primaryTtl;
item.ttl = this.capTtl(item.ttl, maxTtlMs);
const promises = [];
promises.push(this._primary.set(item.key, item.value, item.ttl));
Comment thread
jaredwray marked this conversation as resolved.
if (this._secondary) {
const secondaryTtl = hookOverridden
let secondaryTtl = hookOverridden
? item.ttl
: getCascadingTtl(this._ttl, this._secondary.ttl, explicitTtl);
secondaryTtl = this.capTtl(secondaryTtl, maxTtlMs);
promises.push(this._secondary.set(item.key, item.value, secondaryTtl));
}

Expand Down Expand Up @@ -945,13 +988,15 @@ export class Cacheable extends Hookified {
keyv: Keyv,
items: CacheableItem[],
): Promise<boolean> {
const maxTtlMs = shorthandToMilliseconds(this._maxTtl);
Comment thread
jaredwray marked this conversation as resolved.
const entries: KeyvEntry[] = [];
for (const item of items) {
const finalTtl = getCascadingTtl(
let finalTtl = getCascadingTtl(
this._ttl,
keyv.ttl,
shorthandToMilliseconds(item.ttl),
);
finalTtl = this.capTtl(finalTtl, maxTtlMs);
entries.push({ key: item.key, value: item.value, ttl: finalTtl });
Comment thread
jaredwray marked this conversation as resolved.
}

Expand Down Expand Up @@ -1201,6 +1246,31 @@ export class Cacheable extends Hookified {
this._ttl = undefined;
}
}

private setMaxTtl(maxTtl: number | string | undefined): void {
if (typeof maxTtl === "string" || maxTtl === undefined) {
this._maxTtl = maxTtl;
} else if (maxTtl > 0) {
this._maxTtl = maxTtl;
} else {
this._maxTtl = undefined;
}
}
Comment thread
jaredwray marked this conversation as resolved.

private capTtl(
ttl: number | undefined,
maxTtlMs: number | undefined,
): number | undefined {
if (maxTtlMs === undefined) {
return ttl;
}

if (ttl === undefined) {
return maxTtlMs;
}

return Math.min(ttl, maxTtlMs);
}
}

export {
Expand Down
7 changes: 7 additions & 0 deletions packages/cacheable/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ export type CacheableOptions = {
* or undefined if there is no time-to-live.
*/
ttl?: number | string;
/**
* The maximum time-to-live for the cacheable instance. When set, any TTL that exceeds this value
* is capped to maxTtl. Entries with no TTL will also be capped to maxTtl.
* Can be a number in milliseconds or a human-readable format such as `1s`, `1m`, `1h`, `1d`.
* Default is `undefined` (no maximum).
*/
maxTtl?: number | string;
/**
* The namespace for the cacheable instance. It can be a string or a function that returns a string.
*/
Expand Down
177 changes: 177 additions & 0 deletions packages/cacheable/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1816,3 +1816,180 @@ describe("Non-blocking error handling", () => {
);
});
});

describe("cacheable maxTtl", () => {
test("should have default maxTtl as undefined", () => {
const cacheable = new Cacheable();
expect(cacheable.maxTtl).toBeUndefined();
});

test("should set maxTtl via constructor with number", () => {
const cacheable = new Cacheable({ maxTtl: 5000 });
expect(cacheable.maxTtl).toBe(5000);
});

test("should set maxTtl via constructor with string", () => {
const cacheable = new Cacheable({ maxTtl: "1h" });
expect(cacheable.maxTtl).toBe("1h");
});

test("should set maxTtl via setter", () => {
const cacheable = new Cacheable();
cacheable.maxTtl = 10_000;
expect(cacheable.maxTtl).toBe(10_000);
});

test("should set maxTtl via setter with string", () => {
const cacheable = new Cacheable();
cacheable.maxTtl = "30m";
expect(cacheable.maxTtl).toBe("30m");
});

test("should disable maxTtl by setting to undefined", () => {
const cacheable = new Cacheable({ maxTtl: 5000 });
expect(cacheable.maxTtl).toBe(5000);
cacheable.maxTtl = undefined;
expect(cacheable.maxTtl).toBeUndefined();
});

test("should disable maxTtl by setting to 0 or negative", () => {
const cacheable = new Cacheable({ maxTtl: 5000 });
cacheable.maxTtl = 0;
expect(cacheable.maxTtl).toBeUndefined();
cacheable.maxTtl = 5000;
cacheable.maxTtl = -1;
expect(cacheable.maxTtl).toBeUndefined();
});

test("should handle negative maxTtl in constructor", () => {
const cacheable = new Cacheable({ maxTtl: -1 });
expect(cacheable.maxTtl).toBeUndefined();
});

test("should cap per-entry ttl when it exceeds maxTtl", async () => {
const cacheable = new Cacheable({ maxTtl: 100 });
await cacheable.set("key1", "value1", 5000);
const raw = await cacheable.primary.getRaw("key1");
expect(raw).toBeDefined();
expect(raw.expires).toBeDefined();
const now = Date.now();
expect(raw.expires).toBeLessThanOrEqual(now + 110);
expect(raw.expires).toBeGreaterThan(now);
});

test("should not cap per-entry ttl when within maxTtl", async () => {
const cacheable = new Cacheable({ maxTtl: 10_000 });
await cacheable.set("key1", "value1", 100);
const raw = await cacheable.primary.getRaw("key1");
expect(raw).toBeDefined();
expect(raw.expires).toBeDefined();
const now = Date.now();
expect(raw.expires).toBeLessThanOrEqual(now + 110);
expect(raw.expires).toBeGreaterThan(now);
});

test("should enforce maxTtl when no ttl is set (indefinite)", async () => {
const cacheable = new Cacheable({ maxTtl: 200 });
await cacheable.set("key1", "value1");
const raw = await cacheable.primary.getRaw("key1");
expect(raw).toBeDefined();
expect(raw.expires).toBeDefined();
const now = Date.now();
expect(raw.expires).toBeLessThanOrEqual(now + 210);
expect(raw.expires).toBeGreaterThan(now);
});

test("should enforce maxTtl when default ttl exceeds maxTtl", async () => {
const cacheable = new Cacheable({ ttl: 60_000, maxTtl: 200 });
await cacheable.set("key1", "value1");
const raw = await cacheable.primary.getRaw("key1");
expect(raw).toBeDefined();
expect(raw.expires).toBeDefined();
const now = Date.now();
expect(raw.expires).toBeLessThanOrEqual(now + 210);
expect(raw.expires).toBeGreaterThan(now);
});

test("should work with maxTtl as shorthand string", async () => {
const cacheable = new Cacheable({ maxTtl: "1s" });
await cacheable.set("key1", "value1", "1h");
const raw = await cacheable.primary.getRaw("key1");
expect(raw).toBeDefined();
expect(raw.expires).toBeDefined();
const now = Date.now();
expect(raw.expires).toBeLessThanOrEqual(now + 1010);
expect(raw.expires).toBeGreaterThan(now);
});

test("should expire items at maxTtl boundary", async () => {
const cacheable = new Cacheable({ maxTtl: 50 });
await cacheable.set("key1", "value1", 10_000);
const value1 = await cacheable.get("key1");
expect(value1).toBe("value1");
await sleep(60);
const value2 = await cacheable.get("key1");
expect(value2).toBeUndefined();
});

test("should enforce maxTtl on setMany", async () => {
const cacheable = new Cacheable({ maxTtl: 200 });
await cacheable.setMany([
{ key: "k1", value: "v1", ttl: 60_000 },
{ key: "k2", value: "v2" },
]);
const raw1 = await cacheable.primary.getRaw("k1");
const raw2 = await cacheable.primary.getRaw("k2");
const now = Date.now();
expect(raw1).toBeDefined();
expect(raw1.expires).toBeDefined();
expect(raw1.expires).toBeLessThanOrEqual(now + 210);
expect(raw2).toBeDefined();
expect(raw2.expires).toBeDefined();
expect(raw2.expires).toBeLessThanOrEqual(now + 210);
});

test("should enforce maxTtl on secondary store", async () => {
const secondary = new Keyv();
const cacheable = new Cacheable({ secondary, maxTtl: 200 });
await cacheable.set("key1", "value1", 60_000);
const raw = await secondary.getRaw("key1");
expect(raw).toBeDefined();
expect(raw.expires).toBeDefined();
const now = Date.now();
expect(raw.expires).toBeLessThanOrEqual(now + 210);
});

test("should enforce maxTtl on setMany with secondary store", async () => {
const secondary = new Keyv();
const cacheable = new Cacheable({ secondary, maxTtl: 200 });
await cacheable.setMany([{ key: "k1", value: "v1", ttl: 60_000 }]);
const raw = await secondary.getRaw("k1");
expect(raw).toBeDefined();
expect(raw.expires).toBeDefined();
const now = Date.now();
expect(raw.expires).toBeLessThanOrEqual(now + 210);
});

test("should not interfere when maxTtl is undefined", async () => {
const cacheable = new Cacheable({ ttl: 5000 });
await cacheable.set("key1", "value1");
const raw = await cacheable.primary.getRaw("key1");
expect(raw).toBeDefined();
expect(raw.expires).toBeDefined();
const now = Date.now();
expect(raw.expires).toBeGreaterThan(now + 4000);
});

test("should re-cap ttl after BEFORE_SET hook overrides it", async () => {
const cacheable = new Cacheable({ maxTtl: 200 });
cacheable.onHook(CacheableHooks.BEFORE_SET, (item) => {
item.ttl = 60_000;
});
await cacheable.set("key1", "value1", 100);
const raw = await cacheable.primary.getRaw("key1");
expect(raw).toBeDefined();
expect(raw.expires).toBeDefined();
const now = Date.now();
expect(raw.expires).toBeLessThanOrEqual(now + 210);
});
});
Loading
Loading