@@ -14,6 +14,14 @@ export interface TtlCacheSetOptions {
1414 * overrides the cache's default TTL. Must be a finite, non-negative number.
1515 */
1616 ttl ?: number ;
17+ /**
18+ * A maximum lifetime in milliseconds for this entry, measured from the
19+ * time it is set. When
20+ * {@linkcode TtlCacheOptions.slidingExpiration | slidingExpiration} is
21+ * enabled, the sliding window cannot extend past this duration. Throws
22+ * if `slidingExpiration` is not enabled.
23+ */
24+ absoluteExpiration ?: number ;
1725}
1826
1927/**
@@ -27,6 +35,16 @@ export interface TtlCacheOptions<K, V> {
2735 * manual deletion, or clearing the cache.
2836 */
2937 onEject ?: ( ejectedKey : K , ejectedValue : V ) => void ;
38+ /**
39+ * When `true`, each {@linkcode TtlCache.prototype.get | get()} call resets
40+ * the entry's TTL.
41+ *
42+ * If both `slidingExpiration` and `absoluteExpiration` are set on an entry,
43+ * the sliding window cannot extend past the absolute expiration.
44+ *
45+ * @default {false}
46+ */
47+ slidingExpiration ?: boolean ;
3048}
3149
3250/**
@@ -38,7 +56,6 @@ export interface TtlCacheOptions<K, V> {
3856 *
3957 * @typeParam K The type of the cache keys.
4058 * @typeParam V The type of the cache values.
41- *
4259 * @example Usage
4360 * ```ts
4461 * import { TtlCache } from "@std/cache/ttl-cache";
@@ -53,31 +70,34 @@ export interface TtlCacheOptions<K, V> {
5370 * assertEquals(cache.size, 0);
5471 * ```
5572 *
56- * @example Adding an onEject callback
73+ * @example Sliding expiration
5774 * ```ts
5875 * import { TtlCache } from "@std/cache/ttl-cache";
59- * import { delay } from "@std/async/delay";
6076 * import { assertEquals } from "@std/assert/equals";
77+ * import { FakeTime } from "@std/testing/time";
6178 *
62- * const cache = new TtlCache<string, string>(100, { onEject: (key, value) => {
63- * console.log("Revoking: ", key)
64- * URL.revokeObjectURL(value)
65- * }})
66- *
67- * cache.set(
68- * "fast-url",
69- * URL.createObjectURL(new Blob(["Hello, World"], { type: "text/plain" }))
70- * );
79+ * using time = new FakeTime(0);
80+ * const cache = new TtlCache<string, number>(100, {
81+ * slidingExpiration: true,
82+ * });
7183 *
72- * await delay(200) // "Revoking: fast-url"
73- * assertEquals(cache.get("fast-url"), undefined)
84+ * cache.set("a", 1);
85+ * time.now = 80;
86+ * assertEquals(cache.get("a"), 1); // resets TTL
87+ * time.now = 160;
88+ * assertEquals(cache.get("a"), 1); // still alive, TTL was reset at t=80
89+ * time.now = 260;
90+ * assertEquals(cache.get("a"), undefined); // expired
7491 * ```
7592 */
7693export class TtlCache < K , V > extends Map < K , V >
7794 implements MemoizationCache < K , V > {
7895 #defaultTtl: number ;
7996 #timeouts = new Map < K , number > ( ) ;
8097 #eject?: ( ( ejectedKey : K , ejectedValue : V ) => void ) | undefined ;
98+ #slidingExpiration: boolean ;
99+ #entryTtls?: Map < K , number > ;
100+ #absoluteDeadlines?: Map < K , number > ;
81101
82102 /**
83103 * Constructs a new instance.
@@ -101,6 +121,11 @@ export class TtlCache<K, V> extends Map<K, V>
101121 }
102122 this . #defaultTtl = defaultTtl ;
103123 this . #eject = options ?. onEject ;
124+ this . #slidingExpiration = options ?. slidingExpiration ?? false ;
125+ if ( this . #slidingExpiration) {
126+ this . #entryTtls = new Map ( ) ;
127+ this . #absoluteDeadlines = new Map ( ) ;
128+ }
104129 }
105130
106131 /**
@@ -128,7 +153,17 @@ export class TtlCache<K, V> extends Map<K, V>
128153 * assertEquals(cache.get("a"), undefined);
129154 * ```
130155 */
131- override set ( key : K , value : V , options ?: TtlCacheSetOptions ) : this {
156+ override set (
157+ key : K ,
158+ value : V ,
159+ options ?: TtlCacheSetOptions ,
160+ ) : this {
161+ if ( options ?. absoluteExpiration !== undefined && ! this . #slidingExpiration) {
162+ throw new TypeError (
163+ "Cannot set entry in TtlCache: absoluteExpiration requires slidingExpiration to be enabled" ,
164+ ) ;
165+ }
166+
132167 const ttl = options ?. ttl ?? this . #defaultTtl;
133168 if ( ! ( ttl >= 0 ) || ! Number . isFinite ( ttl ) ) {
134169 throw new RangeError (
@@ -140,9 +175,54 @@ export class TtlCache<K, V> extends Map<K, V>
140175 if ( existing !== undefined ) clearTimeout ( existing ) ;
141176 super . set ( key , value ) ;
142177 this . #timeouts. set ( key , setTimeout ( ( ) => this . delete ( key ) , ttl ) ) ;
178+
179+ if ( this . #slidingExpiration) {
180+ this . #entryTtls! . set ( key , ttl ) ;
181+ if ( options ?. absoluteExpiration !== undefined ) {
182+ const abs = options . absoluteExpiration ;
183+ if ( ! ( abs >= 0 ) || ! Number . isFinite ( abs ) ) {
184+ throw new RangeError (
185+ `Cannot set entry in TtlCache: absoluteExpiration must be a finite, non-negative number: received ${ abs } ` ,
186+ ) ;
187+ }
188+ this . #absoluteDeadlines! . set ( key , Date . now ( ) + abs ) ;
189+ } else {
190+ this . #absoluteDeadlines! . delete ( key ) ;
191+ }
192+ }
193+
143194 return this ;
144195 }
145196
197+ /**
198+ * Gets the value associated with the specified key.
199+ *
200+ * @experimental **UNSTABLE**: New API, yet to be vetted.
201+ *
202+ * When {@linkcode TtlCacheOptions.slidingExpiration | slidingExpiration} is
203+ * enabled, accessing an entry resets its TTL.
204+ *
205+ * @param key The key to get the value for.
206+ * @returns The value associated with the specified key, or `undefined` if
207+ * the key is not present in the cache.
208+ *
209+ * @example Usage
210+ * ```ts
211+ * import { TtlCache } from "@std/cache/ttl-cache";
212+ * import { assertEquals } from "@std/assert/equals";
213+ *
214+ * using cache = new TtlCache<string, number>(1000);
215+ *
216+ * cache.set("a", 1);
217+ * assertEquals(cache.get("a"), 1);
218+ * ```
219+ */
220+ override get ( key : K ) : V | undefined {
221+ if ( ! super . has ( key ) ) return undefined ;
222+ if ( this . #slidingExpiration) this . #resetTtl( key ) ;
223+ return super . get ( key ) ;
224+ }
225+
146226 /**
147227 * Deletes the value associated with the given key.
148228 *
@@ -171,6 +251,8 @@ export class TtlCache<K, V> extends Map<K, V>
171251 const timeout = this . #timeouts. get ( key ) ;
172252 if ( timeout !== undefined ) clearTimeout ( timeout ) ;
173253 this . #timeouts. delete ( key ) ;
254+ this . #entryTtls?. delete ( key ) ;
255+ this . #absoluteDeadlines?. delete ( key ) ;
174256 this . #eject?.( key , value ! ) ;
175257 return true ;
176258 }
@@ -198,6 +280,8 @@ export class TtlCache<K, V> extends Map<K, V>
198280 clearTimeout ( timeout ) ;
199281 }
200282 this . #timeouts. clear ( ) ;
283+ this . #entryTtls?. clear ( ) ;
284+ this . #absoluteDeadlines?. clear ( ) ;
201285 const entries = [ ...super . entries ( ) ] ;
202286 super . clear ( ) ;
203287 let error : unknown ;
@@ -234,4 +318,21 @@ export class TtlCache<K, V> extends Map<K, V>
234318 [ Symbol . dispose ] ( ) : void {
235319 this . clear ( ) ;
236320 }
321+
322+ #resetTtl( key : K ) : void {
323+ const ttl = this . #entryTtls! . get ( key ) ;
324+ if ( ttl === undefined ) return ;
325+
326+ const deadline = this . #absoluteDeadlines! . get ( key ) ;
327+ const effectiveTtl = deadline !== undefined
328+ ? Math . min ( ttl , Math . max ( 0 , deadline - Date . now ( ) ) )
329+ : ttl ;
330+
331+ const existing = this . #timeouts. get ( key ) ;
332+ if ( existing !== undefined ) clearTimeout ( existing ) ;
333+ this . #timeouts. set (
334+ key ,
335+ setTimeout ( ( ) => this . delete ( key ) , effectiveTtl ) ,
336+ ) ;
337+ }
237338}
0 commit comments