1import { assert, describe, it } from "@effect/vitest"
2import { Cache, Context, Data, Deferred, Duration, Effect, Exit, Fiber, Latch, MutableHashMap, Option } from "effect"
3import { Persistable, PersistedCache, Persistence } from "effect/persistence"
4import { TestClock } from "effect/testing"
5import { collectGarbage } from "./utils/gc.ts"
6
7describe("Cache", () => {
8 it.effect.skipIf(process.versions.bun !== undefined || process.versions.deno !== undefined)(
9 "does not retain prior equal object keys on cache hits",
10 () =>
11 Effect.gen(function*() {
12 const references: Array<WeakRef<object>> = []
13 const control = new WeakRef({})
14 let lookups = 0
15 const cache = yield* Cache.make({
16 capacity: 16,
17 lookup: (_key: { readonly region: string }) => Effect.sync(() => ++lookups)
18 })
19 for (let i = 0; i < 10; i++) {
20 const key = { region: "us-east-1" }
21 references.push(new WeakRef(key))
22 assert.strictEqual(yield* Cache.get(cache, key), 1)
23 }
24 assert.strictEqual(lookups, 1)
25 assert.strictEqual(yield* Cache.size(cache), 1)
26 // The last key may still be held by the cache.
27 references.pop()
28 yield* collectGarbage
29 assert.isUndefined(control.deref())
30 for (const reference of references) assert.isUndefined(reference.deref())
31 assert.strictEqual(yield* Cache.size(cache), 1)
32 })
33 )
34
35 describe("constructors", () => {
36 it.effect("make - creates cache with fixed capacity", () =>
37 Effect.gen(function*() {
38 const cache = yield* Cache.make<string, number>({
39 capacity: 10,
40 lookup: (key) => Effect.succeed(key.length)
41 })
42
43 assert.strictEqual(cache.capacity, 10)
44 }))
45
46 it.effect("make - creates cache with default infinite TTL", () =>
47 Effect.gen(function*() {
48 const cache = yield* Cache.make<string, number>({
49 capacity: 10,
50 lookup: (key) => Effect.succeed(key.length)
51 })
52
53 // Add value and verify it doesn't expire
54 yield* Cache.get(cache, "test")
55 yield* TestClock.adjust(Duration.hours(1000))
56 assert.isTrue(yield* Cache.has(cache, "test"))
57 }))
58
59 it.effect("make - creates cache with custom TTL", () =>
60 Effect.gen(function*() {
61 const cache = yield* Cache.make<string, number>({
62 capacity: 10,
63 lookup: (key) => Effect.succeed(key.length),
64 timeToLive: "1 hour"
65 })
66
67 yield* Cache.get(cache, "test")
68 yield* TestClock.adjust("30 minutes")
69 assert.isTrue(yield* Cache.has(cache, "test"))
70
71 yield* TestClock.adjust("31 minutes")
72 assert.isFalse(yield* Cache.has(cache, "test"))
73 }))
74
75 it.effect("make - uses a numeric zero TTL", () =>
76 Effect.gen(function*() {
77 const cache = yield* Cache.make<string, number>({
78 capacity: 10,
79 lookup: (key) => Effect.succeed(key.length),
80 timeToLive: 0
81 })
82
83 yield* Cache.get(cache, "test")
84
85 assert.isFalse(yield* Cache.has(cache, "test"))
86 }))
87
88 it.effect("make - lookup function context is preserved", () =>
89 Effect.gen(function*() {
90 class TestService extends Context.Service<TestService, { value: number }>()("TestService") {}
91
92 const program = Effect.gen(function*() {
93 const cache = yield* Cache.make({
94 capacity: 10,
95 lookup: (_key: string) => Effect.map(TestService, (service) => service.value)
96 })
97 return yield* Cache.get(cache, "test")
98 })
99
100 const result = yield* program.pipe(
101 Effect.provideService(TestService, TestService.of({ value: 42 }))
102 )
103
104 assert.strictEqual(result, 42)
105 }))
106
107 it.effect("make - spans are connected", () =>
108 Effect.gen(function*() {
109 const program = Effect.gen(function*() {
110 const cache = yield* Cache.make({
111 capacity: 10,
112 lookup: (_key: string) =>
113 Effect.currentSpan.pipe(
114 Effect.withSpan("lookup")
115 )
116 })
117 return yield* Cache.get(cache, "test")
118 })
119
120 const result = yield* program.pipe(
121 Effect.withSpan("parent")
122 )
123 assert.strictEqual(result.name, "lookup")
124 if (Option.isNone(result.parent)) {
125 return yield* Effect.die("Expected parent span")
126 }
127 if (result.parent.value._tag !== "Span") {
128 return yield* Effect.die("Expected span parent")
129 }
130 assert.strictEqual(result.parent.value.name, "parent")
131 }))
132
133 it.effect("makeWith - creates cache with function-based TTL", () =>
134 Effect.gen(function*() {
135 const cache = yield* Cache.makeWith(
136 (key: string) => key === "fail" ? Effect.fail("error") : Effect.succeed(key.length),
137 {
138 capacity: 10,
139 timeToLive: (exit, key) => {
140 if (Exit.isFailure(exit)) return "1 second"
141 return key === "short" ? "1 minute" : "1 hour"
142 }
143 }
144 )
145
146 // Success with long TTL
147 yield* Cache.get(cache, "long")
148 yield* TestClock.adjust("30 minutes")
149 assert.isTrue(yield* Cache.has(cache, "long"))
150
151 // Success with short TTL
152 yield* Cache.get(cache, "short")
153 assert.isTrue(yield* Cache.has(cache, "short"))
154 yield* TestClock.adjust(Duration.sum(Duration.minutes(1), Duration.seconds(1)))
155 assert.isFalse(yield* Cache.has(cache, "short"))
156 assert.isTrue(yield* Cache.has(cache, "long"))
157
158 // Failure with very short TTL
159 yield* Effect.exit(Cache.get(cache, "fail"))
160 assert.isTrue(yield* Cache.has(cache, "fail"))
161 yield* TestClock.adjust("2 seconds")
162 assert.isFalse(yield* Cache.has(cache, "fail"))
163 }))
164
165 it.effect("makeWith - TTL function receives correct parameters", () =>
166 Effect.gen(function*() {
167 const receivedParams: Array<{ exit: Exit.Exit<number, string>; key: string }> = []
168
169 const cache = yield* Cache.makeWith<string, number, string>(
170 (key) => key === "fail" ? Effect.fail("error") : Effect.succeed(key.length),
171 {
172 capacity: 10,
173 timeToLive: (exit, key) => {
174 receivedParams.push({ exit, key })
175 return Duration.infinity
176 }
177 }
178 )
179
180 yield* Cache.get(cache, "test")
181 yield* Effect.exit(Cache.get(cache, "fail"))
182
183 assert.strictEqual(receivedParams.length, 2)
184 assert.strictEqual(receivedParams[0].key, "test")
185 assert.isTrue(Exit.isSuccess(receivedParams[0].exit))
186 assert(Exit.isSuccess(receivedParams[0].exit))
187 assert.strictEqual(receivedParams[0].exit.value, 4)
188
189 assert.strictEqual(receivedParams[1].key, "fail")
190 assert.isTrue(Exit.isFailure(receivedParams[1].exit))
191 assert.deepStrictEqual(receivedParams[1].exit, Exit.fail("error"))
192 }))
193 })
194
195 describe("basic operations", () => {
196 describe("get", () => {
197 it.effect("does not retain a synchronous zero-TTL lookup", () =>
198 Effect.gen(function*() {
199 const cache = yield* Cache.makeWith(
200 (_key: string) => Effect.succeed(42),
201 { capacity: 1, timeToLive: () => Duration.zero }
202 )
203
204 assert.strictEqual(yield* Cache.get(cache, "key"), 42)
205 assert.strictEqual(yield* Cache.size(cache), 0)
206 }))
207
208 it.effect("removes a suspended zero-TTL lookup after completion", () =>
209 Effect.gen(function*() {
210 const started = yield* Latch.make()
211 const gate = yield* Deferred.make<number>()
212 const cache = yield* Cache.makeWith(
213 (_key: string) => started.open.pipe(Effect.andThen(Deferred.await(gate))),
214 { capacity: 1, timeToLive: () => Duration.zero }
215 )
216
217 const getter = yield* Cache.get(cache, "key").pipe(Effect.forkChild({ startImmediately: true }))
218 yield* started.await
219 assert.strictEqual(yield* Cache.size(cache), 1)
220
221 yield* Deferred.succeed(gate, 42)
222 assert.strictEqual(yield* Fiber.join(getter), 42)
223 assert.strictEqual(yield* Cache.size(cache), 0)
224 }))
225
226 it.effect("zero-TTL failure does not evict a live entry at capacity", () =>
227 Effect.gen(function*() {
228 const cache = yield* Cache.makeWith(
229 (key: string) => key === "bad" ? Effect.fail("error") : Effect.succeed(key.length),
230 { capacity: 2, timeToLive: (exit) => Exit.isFailure(exit) ? Duration.zero : Duration.hours(1) }
231 )
232
233 yield* Cache.get(cache, "a")
234 assert.deepStrictEqual(yield* Effect.exit(Cache.get(cache, "bad")), Exit.fail("error"))
235 assert.strictEqual(yield* Cache.size(cache), 1)
236 // Do not enumerate keys here: that would prune the expired entry and hide the eviction.
237 yield* Cache.get(cache, "b")
238
239 assert.deepStrictEqual(yield* Cache.getSuccess(cache, "a"), Option.some(1))
240 assert.deepStrictEqual(yield* Cache.getSuccess(cache, "b"), Option.some(1))
241 assert.strictEqual(yield* Cache.size(cache), 2)
242 }))
243
244 it.effect("zero-TTL lookup after expiration does not evict a live entry", () =>
245 Effect.gen(function*() {
246 let kLookups = 0
247 const cache = yield* Cache.makeWith(
248 (key: string) => key === "k" && ++kLookups === 2 ? Effect.fail("error") : Effect.succeed(1),
249 {
250 capacity: 2,
251 timeToLive: (exit, key) =>
252 Exit.isFailure(exit) ? Duration.zero : key === "k" ? Duration.minutes(1) : Duration.hours(1)
253 }
254 )
255
256 yield* Cache.get(cache, "a")
257 yield* Cache.get(cache, "k")
258 yield* TestClock.adjust("2 minutes")
259 assert.deepStrictEqual(yield* Effect.exit(Cache.get(cache, "k")), Exit.fail("error"))
260 // Do not enumerate keys: that would prune the stale entry and hide the eviction.
261 yield* Cache.get(cache, "b")
262
263 assert.deepStrictEqual(yield* Cache.getSuccess(cache, "a"), Option.some(1))
264 assert.deepStrictEqual(yield* Cache.getSuccess(cache, "b"), Option.some(1))
265 assert.strictEqual(yield* Cache.size(cache), 2)
266 }))
267
268 it.effect("zero-TTL completion does not remove a newer set value", () =>
269 Effect.gen(function*() {
270 const started = yield* Latch.make()
271 const gate = yield* Deferred.make<number, string>()
272 const cache = yield* Cache.makeWith(
273 (_key: string) => started.open.pipe(Effect.andThen(Deferred.await(gate))),
274 { capacity: 1, timeToLive: (exit) => Exit.isFailure(exit) ? Duration.zero : Duration.hours(1) }
275 )
276
277 const getter = yield* Cache.get(cache, "key").pipe(
278 Effect.exit,
279 Effect.forkChild({ startImmediately: true })
280 )
281 yield* started.await
282 yield* Cache.set(cache, "key", 99)
283 yield* Deferred.fail(gate, "error")
284
285 assert.deepStrictEqual(yield* Fiber.join(getter), Exit.fail("error"))
286 assert.deepStrictEqual(yield* Cache.getSuccess(cache, "key"), Option.some(99))
287 assert.strictEqual(yield* Cache.size(cache), 1)
288 }))
289
290 it.effect("does not retain synchronously interrupted lookups", () =>
291 Effect.gen(function*() {
292 let calls = 0
293 const cache = yield* Cache.make<string, number>({
294 capacity: 1,
295 lookup: () => Effect.suspend(() => ++calls > 1 ? Effect.succeed(42) : Effect.interrupt)
296 })
297
298 yield* Effect.exit(Cache.get(cache, "key"))
299 const second = yield* Effect.exit(Cache.get(cache, "key"))
300
301 assert.deepStrictEqual(second, Exit.succeed(42))
302 }))
303
304 it.effect("cache hit - returns existing cached value", () =>
305 Effect.gen(function*() {
306 const { cache, lookupCount, setLookupResult } = yield* makeTestCache(10)
307 setLookupResult("test", Effect.succeed(4))
308
309 yield* Cache.get(cache, "test")
310 const result = yield* Cache.get(cache, "test")
311
312 assert.strictEqual(result, 4)
313 assert.strictEqual(lookupCount(), 1)
314 }))
315
316 it.effect("cache hit - multiple different keys", () =>
317 Effect.gen(function*() {
318 const cache = yield* Cache.make<string, number>({
319 capacity: 10,
320 lookup: (key) => Effect.succeed(key.length)
321 })
322
323 const result1 = yield* Cache.get(cache, "a")
324 const result2 = yield* Cache.get(cache, "ab")
325 const result3 = yield* Cache.get(cache, "abc")
326
327 assert.strictEqual(result1, 1)
328 assert.strictEqual(result2, 2)
329 assert.strictEqual(result3, 3)
330 }))
331
332 it.effect("cache hit - same key multiple times doesn't invoke lookup again", () =>
333 Effect.gen(function*() {
334 const { cache, lookupCount, setLookupResult } = yield* makeTestCache(10)
335 setLookupResult("test", Effect.succeed(4))
336
337 const results = yield* Effect.all([
338 Cache.get(cache, "test"),
339 Cache.get(cache, "test"),
340 Cache.get(cache, "test")
341 ])
342
343 assert.deepStrictEqual(results, [4, 4, 4])
344 assert.strictEqual(lookupCount(), 1)
345 }))
346
347 it.effect("cache miss - invokes lookup for non-existent key", () =>
348 Effect.gen(function*() {
349 const { cache, lookupCount, setLookupResult } = yield* makeTestCache(10)
350 setLookupResult("test", Effect.succeed(42))
351
352 const result = yield* Cache.get(cache, "test")
353
354 assert.strictEqual(result, 42)
355 assert.strictEqual(lookupCount(), 1)
356 }))
357
358 it.effect("cache miss - invokes lookup again after TTL expiration", () =>
359 Effect.gen(function*() {
360 let counter = 0
361 const cache = yield* Cache.make<string, number>({
362 capacity: 10,
363 lookup: (_key) => Effect.sync(() => ++counter),
364 timeToLive: "1 hour"
365 })
366
367 const result1 = yield* Cache.get(cache, "test")
368 assert.strictEqual(result1, 1)
369
370 yield* TestClock.adjust("30 minutes")
371 const result2 = yield* Cache.get(cache, "test")
372 assert.strictEqual(result2, 1)
373
374 yield* TestClock.adjust("31 minutes")
375 const result3 = yield* Cache.get(cache, "test")
376 assert.strictEqual(result3, 2)
377 }))
378
379 it.effect("error handling - lookup function fails", () =>
380 Effect.gen(function*() {
381 const cache = yield* Cache.make<string, number, string>({
382 capacity: 10,
383 lookup: (_key) => Effect.fail("lookup error")
384 })
385
386 const result = yield* Effect.exit(Cache.get(cache, "test"))
387
388 assert.deepStrictEqual(result, Exit.fail("lookup error"))
389 }))
390
391 it.effect("concurrent access - multiple fibers getting same key only invoke lookup once", () =>
392 Effect.gen(function*() {
393 let lookupCount = 0
394 const cache = yield* Cache.make({
395 capacity: 10,
396 lookup: () => Effect.delay(Duration.millis(100))(Effect.sync(() => ++lookupCount))
397 })
398
399 const effects = [Cache.get(cache, "key"), Cache.get(cache, "key"), Cache.get(cache, "key")]
400 const resultsFiber = yield* Effect.all(effects, { concurrency: "unbounded" }).pipe(Effect.forkChild)
401
402 yield* TestClock.adjust(Duration.millis(150))
403 const results = yield* Fiber.join(resultsFiber)
404
405 assert.strictEqual(lookupCount, 1)
406 assert.deepStrictEqual(results, [1, 1, 1])
407 }))
408
409 it.effect("concurrent access - race between get and invalidate", () =>
410 Effect.gen(function*() {
411 let lookupCount = 0
412 const cache = yield* Cache.make<string, number>({
413 capacity: 10,
414 lookup: (_key) => Effect.delay(Duration.millis(50))(Effect.sync(() => ++lookupCount))
415 })
416
417 // First get starts
418 const fiber1 = yield* Cache.get(cache, "test").pipe(Effect.forkChild)
419 yield* TestClock.adjust(Duration.millis(25))
420
421 // Invalidate while first get is in progress
422 yield* Cache.invalidate(cache, "test")
423
424 // Second get starts after invalidation
425 const fiber2 = yield* Cache.get(cache, "test").pipe(Effect.forkChild)
426
427 yield* TestClock.adjust(Duration.millis(100))
428 const result1 = yield* Fiber.join(fiber1)
429 const result2 = yield* Fiber.join(fiber2)
430
431 assert.strictEqual(result1, 1)
432 assert.strictEqual(result2, 2)
433 assert.strictEqual(lookupCount, 2)
434 }))
435
436 it.effect("concurrent access - interrupting the first consumer does not interrupt the second", () =>
437 Effect.gen(function*() {
438 const lookupStarted = yield* Latch.make()
439 const lookupComplete = yield* Latch.make()
440 const cache = yield* Cache.make<string, number>({
441 capacity: 10,
442 lookup: () =>
443 Effect.gen(function*() {
444 yield* lookupStarted.open
445 yield* lookupComplete.await
446 return 42
447 })
448 })
449
450 const first = yield* Cache.get(cache, "K1").pipe(Effect.forkChild({ startImmediately: true }))
451 yield* lookupStarted.await
452 const second = yield* Cache.get(cache, "K1").pipe(Effect.forkChild({ startImmediately: true }))
453 const entry = Option.getOrThrow(MutableHashMap.get(cache.map, "K1"))
454 const third = yield* entry.await().pipe(Effect.forkChild({ startImmediately: true }))
455
456 yield* Fiber.interrupt(first)
457 yield* lookupComplete.open
458
459 assert.deepStrictEqual(yield* Fiber.await(second), Exit.succeed(42))
460 assert.deepStrictEqual(yield* Fiber.await(third), Exit.succeed(42))
461 }))
462
463 it.effect("interrupting a getOption waiter keeps the shared lookup available", () =>
464 Effect.gen(function*() {
465 const started = yield* Latch.make()
466 const finish = yield* Latch.make()
467 let lookups = 0
468 const cache = yield* Cache.make<string, number>({
469 capacity: 10,
470 lookup: () =>
471 Effect.gen(function*() {
472 lookups++
473 yield* started.open
474 yield* finish.await
475 return 42
476 })
477 })
478
479 const first = yield* Cache.get(cache, "key").pipe(Effect.forkChild({ startImmediately: true }))
480 yield* started.await
481 const waiter = yield* Cache.getOption(cache, "key").pipe(Effect.forkChild({ startImmediately: true }))
482 const second = yield* Cache.get(cache, "key").pipe(Effect.forkChild({ startImmediately: true }))
483 yield* Fiber.interrupt(waiter)
484 yield* finish.open
485
486 assert.deepStrictEqual(yield* Fiber.await(first), Exit.succeed(42))
487 assert.deepStrictEqual(yield* Fiber.await(second), Exit.succeed(42))
488 assert.deepStrictEqual(yield* Cache.getOption(cache, "key"), Option.some(42))
489 assert.strictEqual(lookups, 1)
490 }))
491
492 it.effect("concurrent access - interrupting the last consumer interrupts the lookup", () =>
493 Effect.gen(function*() {
494 let lookupCount = 0
495 const lookupStarted = yield* Latch.make()
496 const lookupInterrupted = yield* Latch.make()
497 const cache = yield* Cache.make<string, number>({
498 capacity: 10,
499 lookup() {
500 lookupCount++
501 return lookupCount === 1 ?
502 Effect.onInterrupt(
503 lookupStarted.open.pipe(Effect.andThen(Effect.never)),
504 () => lookupInterrupted.open
505 ) :
506 Effect.succeed(42)
507 }
508 })
509
510 const fiber = yield* Cache.get(cache, "K1").pipe(Effect.forkChild({ startImmediately: true }))
511 yield* lookupStarted.await
512 yield* Fiber.interrupt(fiber)
513
514 yield* lookupInterrupted.await
515 assert.strictEqual(yield* Cache.get(cache, "K1"), 42)
516 }))
517
518 it.effect("concurrent access - interrupted lookup does not remove a newer set value", () =>
519 Effect.gen(function*() {
520 const lookupStarted = yield* Latch.make()
521 const lookupInterrupted = yield* Latch.make()
522 const cache = yield* Cache.make<string, number>({
523 capacity: 1,
524 lookup: () =>
525 Effect.onInterrupt(
526 lookupStarted.open.pipe(Effect.andThen(Effect.never)),
527 () => lookupInterrupted.open
528 )
529 })
530
531 const getter = yield* Cache.get(cache, "key").pipe(Effect.forkChild({ startImmediately: true }))
532 yield* lookupStarted.await
533 yield* Cache.set(cache, "key", 99)
534 yield* Fiber.interrupt(getter)
535 yield* lookupInterrupted.await
536
537 assert.deepStrictEqual(yield* Cache.getSuccess(cache, "key"), Option.some(99))
538 }))
539
540 it.effect("concurrent access - a get made while an abandoned lookup is finalizing starts a new lookup", () =>
541 Effect.gen(function*() {
542 let lookups = 0
543 const started = yield* Latch.make()
544 const finalizing = yield* Latch.make()
545 const finishFinalizer = yield* Latch.make()
546 const cache = yield* Cache.make<string, number>({
547 capacity: 10,
548 lookup: () =>
549 Effect.suspend(() => {
550 if (++lookups > 1) return Effect.succeed(lookups)
551 return started.open.pipe(
552 Effect.andThen(Effect.never),
553 Effect.onInterrupt(() => finalizing.open.pipe(Effect.andThen(finishFinalizer.await)))
554 )
555 })
556 })
557
558 const getter = yield* Cache.get(cache, "key").pipe(Effect.forkChild({ startImmediately: true }))
559 yield* started.await
560 const interruptGetter = yield* Fiber.interrupt(getter).pipe(Effect.forkChild({ startImmediately: true }))
561 yield* finalizing.await
562 const fresh = yield* Cache.get(cache, "key").pipe(Effect.forkChild({ startImmediately: true }))
563 yield* finishFinalizer.open
564 yield* Fiber.join(interruptGetter)
565
566 assert.deepStrictEqual(yield* Fiber.await(fresh), Exit.succeed(2))
567 assert.strictEqual(yield* Cache.get(cache, "key"), 2)
568 assert.strictEqual(lookups, 2)
569 }))
570 })
571
572 describe("getOption", () => {
573 it.effect("returns Some for existing cached value", () =>
574 Effect.gen(function*() {
575 const cache = yield* Cache.make<string, number>({
576 capacity: 10,
577 lookup: (key) => Effect.succeed(key.length)
578 })
579
580 yield* Cache.get(cache, "test")
581 const result = yield* Cache.getOption(cache, "test")
582
583 assert.deepStrictEqual(result, Option.some(4))
584 }))
585
586 it.effect("returns None for non-existent key", () =>
587 Effect.gen(function*() {
588 const cache = yield* Cache.make<string, number>({
589 capacity: 10,
590 lookup: (key) => Effect.succeed(key.length)
591 })
592
593 const result = yield* Cache.getOption(cache, "test")
594
595 assert.deepStrictEqual(result, Option.none())
596 }))
597
598 it.effect("returns None for expired value", () =>
599 Effect.gen(function*() {
600 const cache = yield* Cache.make<string, number>({
601 capacity: 10,
602 lookup: (key) => Effect.succeed(key.length),
603 timeToLive: "1 hour"
604 })
605
606 yield* Cache.get(cache, "test")
607 yield* TestClock.adjust("2 hours")
608 const result = yield* Cache.getOption(cache, "test")
609
610 assert.deepStrictEqual(result, Option.none())
611 }))
612
613 it.effect("waits for value being computed and returns result", () =>
614 Effect.gen(function*() {
615 const deferred = yield* Deferred.make<void>()
616 const cache = yield* Cache.make<string, number>({
617 capacity: 10,
618 lookup: (_key) => Deferred.await(deferred).pipe(Effect.as(42))
619 })
620
621 const getFiber = yield* Cache.get(cache, "test").pipe(Effect.forkChild)
622 const optionFiber = yield* Cache.getOption(cache, "test").pipe(Effect.forkChild)
623
624 yield* Deferred.succeed(deferred, void 0)
625
626 const getResult = yield* Fiber.join(getFiber)
627 const optionResult = yield* Fiber.join(optionFiber)
628
629 assert.strictEqual(getResult, 42)
630 assert.deepStrictEqual(optionResult, Option.some(42))
631 }))
632 })
633
634 describe("getSuccess", () => {
635 it.effect("returns Some for successfully cached value", () =>
636 Effect.gen(function*() {
637 const cache = yield* Cache.make<string, number>({
638 capacity: 10,
639 lookup: (key) => Effect.succeed(key.length)
640 })
641
642 yield* Cache.get(cache, "test")
643 const result = yield* Cache.getSuccess(cache, "test")
644
645 assert.deepStrictEqual(result, Option.some(4))
646 }))
647
648 it.effect("returns None for failed cached value", () =>
649 Effect.gen(function*() {
650 const cache = yield* Cache.make<string, number, string>({
651 capacity: 10,
652 lookup: (_key) => Effect.fail("error")
653 })
654
655 yield* Effect.exit(Cache.get(cache, "test"))
656 const result = yield* Cache.getSuccess(cache, "test")
657
658 assert.deepStrictEqual(result, Option.none())
659 }))
660
661 it.effect("returns None for non-existent key", () =>
662 Effect.gen(function*() {
663 const cache = yield* Cache.make<string, number>({
664 capacity: 10,
665 lookup: (key) => Effect.succeed(key.length)
666 })
667
668 const result = yield* Cache.getSuccess(cache, "test")
669
670 assert.deepStrictEqual(result, Option.none())
671 }))
672
673 it.effect("returns None for expired value", () =>
674 Effect.gen(function*() {
675 const cache = yield* Cache.make<string, number>({
676 capacity: 10,
677 lookup: (key) => Effect.succeed(key.length),
678 timeToLive: "1 hour"
679 })
680
681 yield* Cache.get(cache, "test")
682 yield* TestClock.adjust("2 hours")
683 const result = yield* Cache.getSuccess(cache, "test")
684
685 assert.deepStrictEqual(result, Option.none())
686 }))
687 })
688 })
689
690 describe("modification operations", () => {
691 describe("set", () => {
692 it.effect("sets new key-value pair", () =>
693 Effect.gen(function*() {
694 const { cache, lookupCount } = yield* makeTestCache(10)
695
696 yield* Cache.set(cache, "test", 42)
697 const result = yield* Cache.get(cache, "test")
698
699 assert.strictEqual(result, 42)
700 assert.strictEqual(lookupCount(), 0)
701 }))
702
703 it.effect("overwrites existing key", () =>
704 Effect.gen(function*() {
705 const cache = yield* Cache.make<string, number>({
706 capacity: 10,
707 lookup: (key) => Effect.succeed(key.length)
708 })
709
710 yield* Cache.get(cache, "test")
711 yield* Cache.set(cache, "test", 100)
712 const result = yield* Cache.get(cache, "test")
713
714 assert.strictEqual(result, 100)
715 }))
716
717 it.effect("set with TTL - value expires", () =>
718 Effect.gen(function*() {
719 const cache = yield* Cache.make<string, number>({
720 capacity: 10,
721 lookup: (key) => Effect.succeed(key.length),
722 timeToLive: "1 hour"
723 })
724
725 yield* Cache.set(cache, "test", 42)
726 assert.isTrue(yield* Cache.has(cache, "test"))
727
728 yield* TestClock.adjust("2 hours")
729 assert.isFalse(yield* Cache.has(cache, "test"))
730 }))
731
732 it.effect("set doesn't invoke lookup function", () =>
733 Effect.gen(function*() {
734 const { cache, lookupCount } = yield* makeTestCache(10)
735
736 yield* Cache.set(cache, "test", 42)
737 yield* Cache.set(cache, "test2", 43)
738 yield* Cache.set(cache, "test3", 44)
739
740 assert.strictEqual(lookupCount(), 0)
741 }))
742
743 it.effect("set enforces capacity", () =>
744 Effect.gen(function*() {
745 const cache = yield* Cache.make<string, number>({
746 capacity: 2,
747 lookup: (key) => Effect.succeed(key.length)
748 })
749
750 yield* Cache.set(cache, "a", 1)
751 yield* Cache.set(cache, "b", 2)
752 const sizeBefore = yield* Cache.size(cache)
753 assert.strictEqual(sizeBefore, 2)
754
755 yield* Cache.set(cache, "c", 3)
756 const sizeAfter = yield* Cache.size(cache)
757 assert.strictEqual(sizeAfter, 2)
758 }))
759 })
760
761 describe("has", () => {
762 it.effect("returns true for existing key", () =>
763 Effect.gen(function*() {
764 const cache = yield* Cache.make<string, number>({
765 capacity: 10,
766 lookup: (key) => Effect.succeed(key.length)
767 })
768
769 yield* Cache.get(cache, "test")
770 const result = yield* Cache.has(cache, "test")
771
772 assert.isTrue(result)
773 }))
774
775 it.effect("returns false for non-existent key", () =>
776 Effect.gen(function*() {
777 const cache = yield* Cache.make<string, number>({
778 capacity: 10,
779 lookup: (key) => Effect.succeed(key.length)
780 })
781
782 const result = yield* Cache.has(cache, "test")
783
784 assert.isFalse(result)
785 }))
786
787 it.effect("returns false for expired key", () =>
788 Effect.gen(function*() {
789 const cache = yield* Cache.make<string, number>({
790 capacity: 10,
791 lookup: (key) => Effect.succeed(key.length),
792 timeToLive: "1 hour"
793 })
794
795 yield* Cache.get(cache, "test")
796 yield* TestClock.adjust("2 hours")
797 const result = yield* Cache.has(cache, "test")
798
799 assert.isFalse(result)
800 }))
801 })
802
803 describe("invalidate", () => {
804 it.effect("invalidates existing key", () =>
805 Effect.gen(function*() {
806 const cache = yield* Cache.make<string, number>({
807 capacity: 10,
808 lookup: (key) => Effect.succeed(key.length)
809 })
810
811 yield* Cache.get(cache, "test")
812 yield* Cache.invalidate(cache, "test")
813 const result = yield* Cache.has(cache, "test")
814
815 assert.isFalse(result)
816 }))
817
818 it.effect("invalidating non-existent key doesn't error", () =>
819 Effect.gen(function*() {
820 const cache = yield* Cache.make<string, number>({
821 capacity: 10,
822 lookup: (key) => Effect.succeed(key.length)
823 })
824
825 yield* Cache.invalidate(cache, "test")
826 const result = yield* Cache.has(cache, "test")
827
828 assert.isFalse(result)
829 }))
830
831 it.effect("get after invalidate invokes lookup again", () =>
832 Effect.gen(function*() {
833 let counter = 0
834 const cache = yield* Cache.make<string, number>({
835 capacity: 10,
836 lookup: (_key) => Effect.sync(() => ++counter)
837 })
838
839 const result1 = yield* Cache.get(cache, "test")
840 yield* Cache.invalidate(cache, "test")
841 const result2 = yield* Cache.get(cache, "test")
842
843 assert.strictEqual(result1, 1)
844 assert.strictEqual(result2, 2)
845 }))
846 })
847
848 describe("invalidateWhen", () => {
849 it.effect.each([Exit.succeed(1), Exit.fail("error")])(
850 "preserves a newer set value after an old lookup completes (%#)",
851 (exit) =>
852 Effect.gen(function*() {
853 const gate = yield* Deferred.make<number, string>()
854 const values: Array<number> = []
855 const cache = yield* Cache.make<string, number, string>({
856 capacity: 1,
857 lookup: () => Deferred.await(gate)
858 })
859 const lookup = yield* Cache.get(cache, "key").pipe(
860 Effect.exit,
861 Effect.forkChild({ startImmediately: true })
862 )
863 const invalidator = yield* Cache.invalidateWhen(cache, "key", (value) => {
864 values.push(value)
865 return value === 1
866 }).pipe(
867 Effect.forkChild({ startImmediately: true })
868 )
869
870 yield* Cache.set(cache, "key", 99)
871 yield* Deferred.done(gate, exit)
872 const original = yield* Fiber.join(lookup)
873 const invalidated = yield* Fiber.join(invalidator)
874
875 assert.deepStrictEqual(original, exit)
876 assert.deepStrictEqual(values, Exit.isSuccess(exit) ? [1] : [])
877 assert.isFalse(invalidated)
878 assert.deepStrictEqual(yield* Cache.getSuccess(cache, "key"), Option.some(99))
879 })
880 )
881
882 it.effect("invalidates when predicate matches", () =>
883 Effect.gen(function*() {
884 const cache = yield* Cache.make<string, number>({
885 capacity: 10,
886 lookup: (key) => Effect.succeed(key.length)
887 })
888
889 yield* Cache.get(cache, "test")
890 const result = yield* Cache.invalidateWhen(cache, "test", (value) => value === 4)
891
892 assert.isTrue(result)
893 assert.isFalse(yield* Cache.has(cache, "test"))
894 }))
895
896 it.effect("doesn't invalidate when predicate doesn't match", () =>
897 Effect.gen(function*() {
898 const cache = yield* Cache.make<string, number>({
899 capacity: 10,
900 lookup: (key) => Effect.succeed(key.length)
901 })
902
903 yield* Cache.get(cache, "test")
904 const result = yield* Cache.invalidateWhen(cache, "test", (value) => value === 5)
905
906 assert.isFalse(result)
907 assert.isTrue(yield* Cache.has(cache, "test"))
908 }))
909
910 it.effect("returns false for non-existent key", () =>
911 Effect.gen(function*() {
912 const cache = yield* Cache.make<string, number>({
913 capacity: 10,
914 lookup: (key) => Effect.succeed(key.length)
915 })
916
917 const result = yield* Cache.invalidateWhen(cache, "test", () => true)
918
919 assert.isFalse(result)
920 }))
921
922 it.effect("returns false for failed cached value", () =>
923 Effect.gen(function*() {
924 const cache = yield* Cache.make<string, number, string>({
925 capacity: 10,
926 lookup: (_key) => Effect.fail("error")
927 })
928
929 yield* Effect.exit(Cache.get(cache, "test"))
930 const result = yield* Cache.invalidateWhen(cache, "test", () => true)
931
932 assert.isFalse(result)
933 }))
934 })
935
936 describe("refresh", () => {
937 it.effect("refresh existing key invokes lookup again", () =>
938 Effect.gen(function*() {
939 let counter = 0
940 const cache = yield* Cache.make<string, number>({
941 capacity: 10,
942 lookup: (_key) => Effect.sync(() => ++counter)
943 })
944
945 yield* Cache.get(cache, "test")
946 const result = yield* Cache.refresh(cache, "test")
947
948 assert.strictEqual(result, 2)
949 }))
950
951 it.effect("refresh non-existent key invokes lookup and enforces capacity", () =>
952 Effect.gen(function*() {
953 let counter = 0
954 const cache = yield* Cache.make<string, number>({
955 capacity: 1,
956 lookup: (_key) => Effect.sync(() => ++counter)
957 })
958
959 yield* Cache.set(cache, "other", 99)
960 const result = yield* Cache.refresh(cache, "test")
961
962 assert.strictEqual(result, 1)
963 assert.strictEqual(yield* Cache.size(cache), 1)
964 assert.deepStrictEqual(Array.from(yield* Cache.keys(cache)), ["test"])
965 }))
966
967 it.effect("refresh enforces capacity when the existing key is evicted during lookup", () =>
968 Effect.gen(function*() {
969 const started = yield* Deferred.make<void>()
970 const result = yield* Deferred.make<number>()
971 const cache = yield* Cache.make<string, number>({
972 capacity: 1,
973 lookup: () => Deferred.succeed(started, void 0).pipe(Effect.andThen(Deferred.await(result)))
974 })
975
976 yield* Cache.set(cache, "a", 1)
977 const refresh = yield* Cache.refresh(cache, "a").pipe(Effect.forkChild({ startImmediately: true }))
978 yield* Deferred.await(started)
979 yield* Cache.set(cache, "b", 99)
980 assert.isFalse(yield* Cache.has(cache, "a"))
981
982 yield* Deferred.succeed(result, 2)
983 assert.strictEqual(yield* Fiber.join(refresh), 2)
984 assert.deepStrictEqual({
985 size: yield* Cache.size(cache),
986 keys: Array.from(yield* Cache.keys(cache)).sort()
987 }, { size: 1, keys: ["a"] })
988 assert.deepStrictEqual(yield* Cache.getSuccess(cache, "a"), Option.some(2))
989 }))
990
991 it.effect("repro: interrupting a missing-key refresh does not remove a newer set value", () =>
992 Effect.gen(function*() {
993 const lookupStarted = yield* Latch.make()
994 const cache = yield* Cache.make<string, number>({
995 capacity: 1,
996 lookup: () => lookupStarted.open.pipe(Effect.andThen(Effect.never))
997 })
998
999 const refresh = yield* Cache.refresh(cache, "key").pipe(Effect.forkChild({ startImmediately: true }))
1000 yield* lookupStarted.await
1001 yield* Cache.set(cache, "key", 99)
1002 assert.deepStrictEqual(yield* Cache.getSuccess(cache, "key"), Option.some(99))
1003
1004 yield* Fiber.interrupt(refresh)
1005
1006 assert.deepStrictEqual(yield* Cache.getSuccess(cache, "key"), Option.some(99))
1007 }))
1008
1009 it.effect("interrupting a missing-key refresh removes its pending entry", () =>
1010 Effect.gen(function*() {
1011 const lookupStarted = yield* Latch.make()
1012 const cache = yield* Cache.make<string, number>({
1013 capacity: 1,
1014 lookup: () => lookupStarted.open.pipe(Effect.andThen(Effect.never))
1015 })
1016
1017 const refresh = yield* Cache.refresh(cache, "key").pipe(Effect.forkChild({ startImmediately: true }))
1018 yield* lookupStarted.await
1019 assert.isTrue(yield* Cache.has(cache, "key"))
1020
1021 yield* Fiber.interrupt(refresh)
1022
1023 assert.isFalse(yield* Cache.has(cache, "key"))
1024 }))
1025
1026 it.effect.each([false, true])(
1027 "zero-TTL refresh with replacement (existing: %s)",
1028 (existing) =>
1029 Effect.gen(function*() {
1030 const gate = yield* Deferred.make<number, string>()
1031 const cache = yield* Cache.makeWith((_key: string) => Deferred.await(gate), {
1032 capacity: 1,
1033 timeToLive: (exit) => Exit.isFailure(exit) ? 0 : "1 hour"
1034 })
1035 if (existing) yield* Cache.set(cache, "key", 1)
1036
1037 const refresh = yield* Cache.refresh(cache, "key").pipe(Effect.forkChild({ startImmediately: true }))
1038 yield* Cache.set(cache, "key", 99)
1039 yield* Deferred.fail(gate, "error")
1040
1041 assert.deepStrictEqual(yield* Fiber.await(refresh), Exit.fail("error"))
1042 assert.deepStrictEqual(
1043 yield* Cache.getSuccess(cache, "key"),
1044 existing ? Option.none() : Option.some(99)
1045 )
1046 })
1047 )
1048
1049 it.effect("zero-TTL refresh removes its own entry", () =>
1050 Effect.gen(function*() {
1051 const cache = yield* Cache.makeWith((_key: string) => Effect.fail("error"), {
1052 capacity: 1,
1053 timeToLive: () => 0
1054 })
1055
1056 assert.deepStrictEqual(yield* Effect.exit(Cache.refresh(cache, "key")), Exit.fail("error"))
1057 assert.isFalse(yield* Cache.has(cache, "key"))
1058 }))
1059
1060 it.effect("refresh updates TTL", () =>
1061 Effect.gen(function*() {
1062 const cache = yield* Cache.make<string, number>({
1063 capacity: 10,
1064 lookup: (key) => Effect.succeed(key.length),
1065 timeToLive: "1 hour"
1066 })
1067
1068 yield* Cache.get(cache, "test")
1069 yield* TestClock.adjust("30 minutes")
1070 yield* Cache.refresh(cache, "test")
1071 yield* TestClock.adjust("40 minutes")
1072
1073 assert.isTrue(yield* Cache.has(cache, "test"))
1074 }))
1075
1076 it.effect("concurrent refresh calls each invoke lookup independently", () =>
1077 Effect.gen(function*() {
1078 let lookupCount = 0
1079 const cache = yield* Cache.make<string, number>({
1080 capacity: 10,
1081 lookup: () => Effect.sync(() => ++lookupCount)
1082 })
1083
1084 yield* Cache.get(cache, "test")
1085 lookupCount = 0
1086
1087 const results = yield* Effect.all([
1088 Cache.refresh(cache, "test"),
1089 Cache.refresh(cache, "test"),
1090 Cache.refresh(cache, "test")
1091 ], { concurrency: "unbounded" })
1092
1093 // Each refresh calls lookup independently
1094 assert.strictEqual(lookupCount, 3)
1095 assert.deepStrictEqual(results, [1, 2, 3])
1096 }))
1097 })
1098
1099 describe("invalidateAll", () => {
1100 it.effect("clears all entries", () =>
1101 Effect.gen(function*() {
1102 const cache = yield* Cache.make<string, number>({
1103 capacity: 10,
1104 lookup: (key) => Effect.succeed(key.length)
1105 })
1106
1107 yield* Cache.get(cache, "a")
1108 yield* Cache.get(cache, "b")
1109 yield* Cache.get(cache, "c")
1110
1111 yield* Cache.invalidateAll(cache)
1112
1113 assert.isFalse(yield* Cache.has(cache, "a"))
1114 assert.isFalse(yield* Cache.has(cache, "b"))
1115 assert.isFalse(yield* Cache.has(cache, "c"))
1116 }))
1117
1118 it.effect("size becomes 0 after invalidateAll", () =>
1119 Effect.gen(function*() {
1120 const cache = yield* Cache.make<string, number>({
1121 capacity: 10,
1122 lookup: (key) => Effect.succeed(key.length)
1123 })
1124
1125 yield* Cache.get(cache, "a")
1126 yield* Cache.get(cache, "b")
1127 yield* Cache.get(cache, "c")
1128
1129 yield* Cache.invalidateAll(cache)
1130 const size = yield* Cache.size(cache)
1131
1132 assert.strictEqual(size, 0)
1133 }))
1134
1135 it.effect("all keys return None after invalidateAll", () =>
1136 Effect.gen(function*() {
1137 const cache = yield* Cache.make<string, number>({
1138 capacity: 10,
1139 lookup: (key) => Effect.succeed(key.length)
1140 })
1141
1142 yield* Cache.get(cache, "a")
1143 yield* Cache.get(cache, "b")
1144
1145 yield* Cache.invalidateAll(cache)
1146
1147 const result1 = yield* Cache.getOption(cache, "a")
1148 const result2 = yield* Cache.getOption(cache, "b")
1149
1150 assert.deepStrictEqual(result1, Option.none())
1151 assert.deepStrictEqual(result2, Option.none())
1152 }))
1153 })
1154 })
1155
1156 describe("utility operations", () => {
1157 describe("size", () => {
1158 it.effect("empty cache returns 0", () =>
1159 Effect.gen(function*() {
1160 const cache = yield* Cache.make<string, number>({
1161 capacity: 10,
1162 lookup: (key) => Effect.succeed(key.length)
1163 })
1164
1165 const size = yield* Cache.size(cache)
1166
1167 assert.strictEqual(size, 0)
1168 }))
1169
1170 it.effect("returns correct count after adding entries", () =>
1171 Effect.gen(function*() {
1172 const cache = yield* Cache.make<string, number>({
1173 capacity: 10,
1174 lookup: (key) => Effect.succeed(key.length)
1175 })
1176
1177 yield* Cache.get(cache, "a")
1178 yield* Cache.get(cache, "b")
1179 yield* Cache.get(cache, "c")
1180
1181 const size = yield* Cache.size(cache)
1182
1183 assert.strictEqual(size, 3)
1184 }))
1185
1186 it.effect("decreases after invalidation", () =>
1187 Effect.gen(function*() {
1188 const cache = yield* Cache.make<string, number>({
1189 capacity: 10,
1190 lookup: (key) => Effect.succeed(key.length)
1191 })
1192
1193 yield* Cache.get(cache, "a")
1194 yield* Cache.get(cache, "b")
1195 yield* Cache.get(cache, "c")
1196 yield* Cache.invalidate(cache, "b")
1197
1198 const size = yield* Cache.size(cache)
1199
1200 assert.strictEqual(size, 2)
1201 }))
1202
1203 it.effect("expired entries are counted until accessed", () =>
1204 Effect.gen(function*() {
1205 const cache = yield* Cache.make<string, number>({
1206 capacity: 10,
1207 lookup: (key) => Effect.succeed(key.length),
1208 timeToLive: "1 hour"
1209 })
1210
1211 yield* Cache.get(cache, "a")
1212 yield* Cache.get(cache, "b")
1213
1214 yield* TestClock.adjust("2 hours")
1215
1216 // Size still includes expired entries
1217 const sizeBefore = yield* Cache.size(cache)
1218 assert.strictEqual(sizeBefore, 2)
1219
1220 // Accessing expired entries removes them
1221 yield* Cache.has(cache, "a")
1222 const sizeAfter = yield* Cache.size(cache)
1223 assert.strictEqual(sizeAfter, 1)
1224 }))
1225 })
1226
1227 describe("keys", () => {
1228 it.effect("returns all active keys", () =>
1229 Effect.gen(function*() {
1230 const cache = yield* Cache.make<string, number>({
1231 capacity: 10,
1232 lookup: (key) => Effect.succeed(key.length)
1233 })
1234
1235 yield* Cache.get(cache, "a")
1236 yield* Cache.get(cache, "b")
1237 yield* Cache.get(cache, "c")
1238
1239 const keys = yield* Cache.keys(cache)
1240
1241 assert.deepStrictEqual(Array.from(keys).sort(), ["a", "b", "c"])
1242 }))
1243
1244 it.effect("excludes keys for expired entries", () =>
1245 Effect.gen(function*() {
1246 const cache = yield* Cache.make<string, number>({
1247 capacity: 10,
1248 lookup: (key) => Effect.succeed(key.length),
1249 timeToLive: "1 hour"
1250 })
1251
1252 yield* Cache.get(cache, "a")
1253 yield* Cache.get(cache, "b")
1254
1255 yield* TestClock.adjust("2 hours")
1256
1257 const keys = yield* Cache.keys(cache)
1258
1259 assert.deepStrictEqual(Array.from(keys).sort(), [])
1260 }))
1261
1262 it.effect("empty after invalidateAll", () =>
1263 Effect.gen(function*() {
1264 const cache = yield* Cache.make<string, number>({
1265 capacity: 10,
1266 lookup: (key) => Effect.succeed(key.length)
1267 })
1268
1269 yield* Cache.get(cache, "a")
1270 yield* Cache.get(cache, "b")
1271 yield* Cache.invalidateAll(cache)
1272
1273 const keys = yield* Cache.keys(cache)
1274
1275 assert.deepStrictEqual(Array.from(keys), [])
1276 }))
1277 })
1278
1279 describe("values", () => {
1280 it.effect("returns all successful cached values", () =>
1281 Effect.gen(function*() {
1282 const cache = yield* Cache.make<string, number>({
1283 capacity: 10,
1284 lookup: (key) => Effect.succeed(key.length),
1285 timeToLive: "1 hour"
1286 })
1287
1288 yield* Cache.get(cache, "a")
1289 yield* Cache.get(cache, "ab")
1290 yield* Cache.get(cache, "abc")
1291
1292 const values = yield* Cache.values(cache)
1293 const valuesArray = Array.from(values).sort()
1294
1295 assert.deepStrictEqual(valuesArray, [1, 2, 3])
1296 }))
1297
1298 it.effect("doesn't include failed lookups", () =>
1299 Effect.gen(function*() {
1300 const cache = yield* Cache.make<string, number, string>({
1301 capacity: 10,
1302 lookup: (key) => key === "fail" ? Effect.fail("error") : Effect.succeed(key.length),
1303 timeToLive: "1 hour"
1304 })
1305
1306 yield* Cache.get(cache, "a")
1307 yield* Effect.exit(Cache.get(cache, "fail"))
1308 yield* Cache.get(cache, "ab")
1309
1310 const values = yield* Cache.values(cache)
1311 const valuesArray = Array.from(values).sort()
1312
1313 assert.deepStrictEqual(valuesArray, [1, 2])
1314 }))
1315
1316 it.effect("doesn't include expired values", () =>
1317 Effect.gen(function*() {
1318 const cache = yield* Cache.make<string, number>({
1319 capacity: 10,
1320 lookup: (key) => Effect.succeed(key.length),
1321 timeToLive: "1 hour"
1322 })
1323
1324 yield* Cache.get(cache, "a")
1325 yield* Cache.get(cache, "b")
1326
1327 yield* TestClock.adjust("2 hours")
1328
1329 const values = yield* Cache.values(cache)
1330 const valuesArray = Array.from(values)
1331
1332 assert.deepStrictEqual(valuesArray, [])
1333 }))
1334 })
1335
1336 describe("entries", () => {
1337 it.effect("returns all key-value pairs", () =>
1338 Effect.gen(function*() {
1339 const cache = yield* Cache.make<string, number>({
1340 capacity: 10,
1341 lookup: (key) => Effect.succeed(key.length),
1342 timeToLive: "1 hour"
1343 })
1344
1345 yield* Cache.get(cache, "a")
1346 yield* Cache.get(cache, "ab")
1347 yield* Cache.get(cache, "abc")
1348
1349 const entries = yield* Cache.entries(cache)
1350 const entriesArray = Array.from(entries).sort(([a], [b]) => a.localeCompare(b))
1351
1352 assert.deepStrictEqual(entriesArray, [["a", 1], ["ab", 2], ["abc", 3]])
1353 }))
1354
1355 it.effect("filters out failed lookups", () =>
1356 Effect.gen(function*() {
1357 const cache = yield* Cache.make<string, number, string>({
1358 capacity: 10,
1359 lookup: (key) => key === "fail" ? Effect.fail("error") : Effect.succeed(key.length),
1360 timeToLive: "1 hour"
1361 })
1362
1363 yield* Cache.get(cache, "a")
1364 yield* Effect.exit(Cache.get(cache, "fail"))
1365 yield* Cache.get(cache, "ab")
1366
1367 const entries = yield* Cache.entries(cache)
1368 const entriesArray = Array.from(entries).sort(([a], [b]) => a.localeCompare(b))
1369
1370 assert.deepStrictEqual(entriesArray, [["a", 1], ["ab", 2]])
1371 }))
1372
1373 it.effect("filters out expired entries", () =>
1374 Effect.gen(function*() {
1375 const cache = yield* Cache.make<string, number>({
1376 capacity: 10,
1377 lookup: (key) => Effect.succeed(key.length),
1378 timeToLive: "1 hour"
1379 })
1380
1381 yield* Cache.get(cache, "a")
1382 yield* Cache.get(cache, "b")
1383
1384 yield* TestClock.adjust("2 hours")
1385
1386 const entries = yield* Cache.entries(cache)
1387 const entriesArray = Array.from(entries)
1388
1389 assert.deepStrictEqual(entriesArray, [])
1390 }))
1391
1392 it.effect("removes expired entries as side effect", () =>
1393 Effect.gen(function*() {
1394 const cache = yield* Cache.make<string, number>({
1395 capacity: 10,
1396 lookup: (key) => Effect.succeed(key.length),
1397 timeToLive: "1 hour"
1398 })
1399
1400 yield* Cache.get(cache, "a")
1401 yield* Cache.get(cache, "b")
1402
1403 yield* TestClock.adjust("2 hours")
1404
1405 const sizeBefore = yield* Cache.size(cache)
1406 assert.strictEqual(sizeBefore, 2)
1407
1408 const entriesResult = yield* Cache.entries(cache)
1409 const entriesArray = Array.from(entriesResult)
1410
1411 const sizeAfter = yield* Cache.size(cache)
1412
1413 // entries() should remove expired entries as side effect
1414 assert.strictEqual(sizeAfter, 0)
1415 assert.strictEqual(entriesArray.length, 0)
1416 }))
1417 })
1418 })
1419
1420 describe("capacity management", () => {
1421 it.effect("LRU eviction - oldest entries removed when capacity exceeded", () =>
1422 Effect.gen(function*() {
1423 const cache = yield* Cache.make<string, number>({
1424 capacity: 3,
1425 lookup: (key) => Effect.succeed(key.length)
1426 })
1427
1428 yield* Cache.get(cache, "a")
1429 yield* Cache.get(cache, "b")
1430 yield* Cache.get(cache, "c")
1431 yield* Cache.get(cache, "d")
1432
1433 assert.isFalse(yield* Cache.has(cache, "a"))
1434 assert.isTrue(yield* Cache.has(cache, "b"))
1435 assert.isTrue(yield* Cache.has(cache, "c"))
1436 assert.isTrue(yield* Cache.has(cache, "d"))
1437 }))
1438
1439 it.effect("LRU eviction - access order determines eviction", () =>
1440 Effect.gen(function*() {
1441 const cache = yield* Cache.make<string, number>({
1442 capacity: 3,
1443 lookup: (key) => Effect.succeed(key.length)
1444 })
1445
1446 yield* Cache.get(cache, "a")
1447 yield* Cache.get(cache, "b")
1448 yield* Cache.get(cache, "c")
1449
1450 yield* Cache.get(cache, "a")
1451
1452 yield* Cache.get(cache, "d")
1453
1454 assert.isTrue(yield* Cache.has(cache, "a"))
1455 assert.isFalse(yield* Cache.has(cache, "b"))
1456 assert.isTrue(yield* Cache.has(cache, "c"))
1457 assert.isTrue(yield* Cache.has(cache, "d"))
1458 }))
1459
1460 it.effect("capacity of 1", () =>
1461 Effect.gen(function*() {
1462 const cache = yield* Cache.make<string, number>({
1463 capacity: 1,
1464 lookup: (key) => Effect.succeed(key.length)
1465 })
1466
1467 yield* Cache.get(cache, "a")
1468 assert.isTrue(yield* Cache.has(cache, "a"))
1469
1470 yield* Cache.get(cache, "b")
1471 assert.isFalse(yield* Cache.has(cache, "a"))
1472 assert.isTrue(yield* Cache.has(cache, "b"))
1473 }))
1474
1475 it.effect("very large capacity", () =>
1476 Effect.gen(function*() {
1477 const cache = yield* Cache.make<string, number>({
1478 capacity: 10000,
1479 lookup: (key) => Effect.succeed(key.length)
1480 })
1481
1482 // Add many entries
1483 for (let i = 0; i < 100; i++) {
1484 yield* Cache.get(cache, `key${i}`)
1485 }
1486
1487 const size = yield* Cache.size(cache)
1488 assert.strictEqual(size, 100)
1489 assert.isTrue(yield* Cache.has(cache, "key0"))
1490 assert.isTrue(yield* Cache.has(cache, "key99"))
1491 }))
1492 })
1493
1494 describe("TTL (Time To Live) testing", () => {
1495 it.effect("fixed TTL - entry expires after specified duration", () =>
1496 Effect.gen(function*() {
1497 const cache = yield* Cache.make<string, number>({
1498 capacity: 10,
1499 lookup: (key) => Effect.succeed(key.length),
1500 timeToLive: "1 hour"
1501 })
1502
1503 yield* Cache.get(cache, "test")
1504 yield* TestClock.adjust("59 minutes")
1505 assert.isTrue(yield* Cache.has(cache, "test"))
1506
1507 yield* TestClock.adjust("2 minutes")
1508 assert.isFalse(yield* Cache.has(cache, "test"))
1509 }))
1510
1511 it.effect("fixed TTL - get after expiration invokes lookup", () =>
1512 Effect.gen(function*() {
1513 let counter = 0
1514 const cache = yield* Cache.make<string, number>({
1515 capacity: 10,
1516 lookup: (_key) => Effect.sync(() => ++counter),
1517 timeToLive: "1 hour"
1518 })
1519
1520 const result1 = yield* Cache.get(cache, "test")
1521 yield* TestClock.adjust("2 hours")
1522 const result2 = yield* Cache.get(cache, "test")
1523
1524 assert.strictEqual(result1, 1)
1525 assert.strictEqual(result2, 2)
1526 }))
1527
1528 it.effect("fixed TTL - has returns false for expired entries", () =>
1529 Effect.gen(function*() {
1530 const cache = yield* Cache.make<string, number>({
1531 capacity: 10,
1532 lookup: (key) => Effect.succeed(key.length),
1533 timeToLive: "30 minutes"
1534 })
1535
1536 yield* Cache.get(cache, "test")
1537 assert.isTrue(yield* Cache.has(cache, "test"))
1538
1539 yield* TestClock.adjust("31 minutes")
1540 assert.isFalse(yield* Cache.has(cache, "test"))
1541 }))
1542
1543 it.effect("function-based TTL - different TTL for success vs failure", () =>
1544 Effect.gen(function*() {
1545 const cache = yield* Cache.makeWith<string, number, string>(
1546 (key) => key === "fail" ? Effect.fail("error") : Effect.succeed(key.length),
1547 {
1548 capacity: 10,
1549 timeToLive: (exit) => Exit.isSuccess(exit) ? "1 hour" : "1 minute"
1550 }
1551 )
1552
1553 yield* Cache.get(cache, "success")
1554 yield* Effect.exit(Cache.get(cache, "fail"))
1555
1556 yield* TestClock.adjust("30 minutes")
1557 assert.isTrue(yield* Cache.has(cache, "success"))
1558 assert.isFalse(yield* Cache.has(cache, "fail"))
1559 }))
1560
1561 it.effect("function-based TTL - TTL based on key", () =>
1562 Effect.gen(function*() {
1563 const cache = yield* Cache.makeWith<string, number, never>((key) => Effect.succeed(key.length), {
1564 capacity: 10,
1565 timeToLive: (_exit, key) => key === "short" ? "1 minute" : "1 hour"
1566 })
1567
1568 yield* Cache.get(cache, "short")
1569 yield* Cache.get(cache, "long")
1570
1571 yield* TestClock.adjust("30 minutes")
1572 assert.isFalse(yield* Cache.has(cache, "short"))
1573 assert.isTrue(yield* Cache.has(cache, "long"))
1574 }))
1575
1576 it.effect("function-based TTL - TTL based on value", () =>
1577 Effect.gen(function*() {
1578 const cache = yield* Cache.makeWith<string, number, never>((key) => Effect.succeed(key.length), {
1579 capacity: 10,
1580 timeToLive: (exit) => {
1581 const value = Exit.isSuccess(exit) ? exit.value : 0
1582 return value > 3 ? "1 hour" : "1 minute"
1583 }
1584 })
1585
1586 yield* Cache.get(cache, "ab")
1587 yield* Cache.get(cache, "abcd")
1588
1589 yield* TestClock.adjust("30 minutes")
1590 assert.isFalse(yield* Cache.has(cache, "ab"))
1591 assert.isTrue(yield* Cache.has(cache, "abcd"))
1592 }))
1593
1594 it.effect("infinite TTL", () =>
1595 Effect.gen(function*() {
1596 const cache = yield* Cache.makeWith<string, number, never>((key) => Effect.succeed(key.length), {
1597 capacity: 10,
1598 timeToLive: () => Duration.infinity
1599 })
1600
1601 yield* Cache.get(cache, "test")
1602 yield* TestClock.adjust(Duration.hours(1000))
1603 assert.isTrue(yield* Cache.has(cache, "test"))
1604 }))
1605
1606 it.effect("zero duration TTL", () =>
1607 Effect.gen(function*() {
1608 const cache = yield* Cache.make<string, number>({
1609 capacity: 10,
1610 lookup: (key) => Effect.succeed(key.length),
1611 timeToLive: Duration.zero
1612 })
1613
1614 yield* Cache.get(cache, "test")
1615 // Entry should expire immediately
1616 assert.isFalse(yield* Cache.has(cache, "test"))
1617 }))
1618
1619 it.effect("TTL updates on refresh", () =>
1620 Effect.gen(function*() {
1621 const cache = yield* Cache.make<string, number>({
1622 capacity: 10,
1623 lookup: (key) => Effect.succeed(key.length),
1624 timeToLive: "1 hour"
1625 })
1626
1627 yield* Cache.get(cache, "test")
1628 yield* TestClock.adjust("45 minutes")
1629
1630 // Refresh resets TTL
1631 yield* Cache.refresh(cache, "test")
1632 yield* TestClock.adjust("30 minutes")
1633
1634 // Should still be present (45 + 30 = 75 minutes, but refresh reset TTL)
1635 assert.isTrue(yield* Cache.has(cache, "test"))
1636
1637 yield* TestClock.adjust("31 minutes")
1638 // Now it should be expired (30 + 31 = 61 minutes after refresh)
1639 assert.isFalse(yield* Cache.has(cache, "test"))
1640 }))
1641 })
1642
1643 describe("error scenarios", () => {
1644 it.effect("failed lookup caches the failure", () =>
1645 Effect.gen(function*() {
1646 const cache = yield* Cache.make<string, number, string>({
1647 capacity: 10,
1648 lookup: (_key) => Effect.fail("lookup error")
1649 })
1650
1651 const result1 = yield* Effect.exit(Cache.get(cache, "test"))
1652 const result2 = yield* Effect.exit(Cache.get(cache, "test"))
1653
1654 assert.isTrue(Exit.isFailure(result1))
1655 assert.isTrue(Exit.isFailure(result2))
1656 assert.deepStrictEqual(result1, result2)
1657 }))
1658
1659 it.effect("subsequent gets return same failure", () =>
1660 Effect.gen(function*() {
1661 let lookupCount = 0
1662 const cache = yield* Cache.make<string, number, string>({
1663 capacity: 10,
1664 lookup: (_key) => Effect.sync(() => lookupCount++).pipe(Effect.flatMap(() => Effect.fail("error")))
1665 })
1666
1667 yield* Effect.exit(Cache.get(cache, "test"))
1668 yield* Effect.exit(Cache.get(cache, "test"))
1669 yield* Effect.exit(Cache.get(cache, "test"))
1670
1671 assert.strictEqual(lookupCount, 1)
1672 }))
1673
1674 it.effect("can refresh after failure", () =>
1675 Effect.gen(function*() {
1676 let shouldFail = true
1677 const cache = yield* Cache.make<string, number, string>({
1678 capacity: 10,
1679 lookup: (_key) => shouldFail ? Effect.fail("error") : Effect.succeed(42)
1680 })
1681
1682 const result1 = yield* Effect.exit(Cache.get(cache, "test"))
1683 assert.isTrue(Exit.isFailure(result1))
1684
1685 shouldFail = false
1686 const result2 = yield* Cache.refresh(cache, "test")
1687 assert.strictEqual(result2, 42)
1688 }))
1689
1690 it.effect("multiple fibers encountering same error", () =>
1691 Effect.gen(function*() {
1692 let lookupCount = 0
1693 const cache = yield* Cache.make<string, number, string>({
1694 capacity: 10,
1695 lookup: () =>
1696 Effect.delay("100 millis")(
1697 Effect.sync(() => ++lookupCount).pipe(Effect.flatMap(() => Effect.fail("error")))
1698 )
1699 })
1700
1701 const fiber = yield* Effect.all(
1702 [
1703 Effect.exit(Cache.get(cache, "key")),
1704 Effect.exit(Cache.get(cache, "key")),
1705 Effect.exit(Cache.get(cache, "key"))
1706 ],
1707 { concurrency: "unbounded" }
1708 ).pipe(Effect.forkChild)
1709
1710 yield* TestClock.adjust("150 millis")
1711 const results = yield* Fiber.join(fiber)
1712
1713 assert.strictEqual(lookupCount, 1)
1714 assert.isTrue(results.every(Exit.isFailure))
1715 assert.deepStrictEqual(results[0], results[1])
1716 assert.deepStrictEqual(results[1], results[2])
1717 }))
1718 })
1719
1720 describe("integration tests", () => {
1721 it.effect("with TestClock - multiple time advances", () =>
1722 Effect.gen(function*() {
1723 const cache = yield* Cache.make<string, number>({
1724 capacity: 10,
1725 lookup: (key) => Effect.succeed(key.length),
1726 timeToLive: "1 hour"
1727 })
1728
1729 yield* Cache.get(cache, "a")
1730 yield* TestClock.adjust("30 minutes")
1731
1732 yield* Cache.get(cache, "b")
1733 yield* TestClock.adjust("30 minutes")
1734
1735 yield* Cache.get(cache, "c")
1736 yield* TestClock.adjust("30 minutes")
1737
1738 assert.isFalse(yield* Cache.has(cache, "a")) // Expired at 60 min, now at 90 min
1739 assert.isFalse(yield* Cache.has(cache, "b")) // Expired at 90 min, now at 90 min
1740 assert.isTrue(yield* Cache.has(cache, "c")) // Expires at 120 min, now at 90 min
1741 }))
1742
1743 it.effect("with TestClock - set specific time", () =>
1744 Effect.gen(function*() {
1745 const cache = yield* Cache.make<string, number>({
1746 capacity: 10,
1747 lookup: (key) => Effect.succeed(key.length),
1748 timeToLive: Duration.millis(1000)
1749 })
1750
1751 yield* Cache.get(cache, "test")
1752 yield* TestClock.setTime(500)
1753 assert.isTrue(yield* Cache.has(cache, "test"))
1754
1755 yield* TestClock.setTime(1500)
1756 assert.isFalse(yield* Cache.has(cache, "test"))
1757 }))
1758
1759 it.effect("with different key types - string keys", () =>
1760 Effect.gen(function*() {
1761 const cache = yield* Cache.make<string, number>({
1762 capacity: 10,
1763 lookup: (key) => Effect.succeed(key.length)
1764 })
1765
1766 yield* Cache.get(cache, "hello")
1767 yield* Cache.get(cache, "world")
1768
1769 assert.strictEqual(yield* Cache.get(cache, "hello"), 5)
1770 assert.strictEqual(yield* Cache.get(cache, "world"), 5)
1771 }))
1772
1773 it.effect("with different key types - number keys", () =>
1774 Effect.gen(function*() {
1775 const cache = yield* Cache.make<number, string>({
1776 capacity: 10,
1777 lookup: (key) => Effect.succeed(`number: ${key}`)
1778 })
1779
1780 yield* Cache.get(cache, 1)
1781 yield* Cache.get(cache, 2)
1782 yield* Cache.get(cache, 3)
1783
1784 assert.strictEqual(yield* Cache.get(cache, 1), "number: 1")
1785 assert.strictEqual(yield* Cache.get(cache, 2), "number: 2")
1786 assert.strictEqual(yield* Cache.get(cache, 3), "number: 3")
1787 }))
1788
1789 it.effect("with different key types - complex object keys", () =>
1790 Effect.gen(function*() {
1791 class CacheKey extends Data.Class<{ id: number; name: string }> {}
1792
1793 const cache = yield* Cache.make<CacheKey, string>({
1794 capacity: 10,
1795 lookup: (key) => Effect.succeed(`${key.name}-${key.id}`)
1796 })
1797
1798 const key1 = new CacheKey({ id: 1, name: "test" })
1799 const key2 = new CacheKey({ id: 2, name: "test" })
1800 const key3 = new CacheKey({ id: 1, name: "test" }) // Same as key1
1801
1802 yield* Cache.get(cache, key1)
1803 yield* Cache.get(cache, key2)
1804
1805 assert.strictEqual(yield* Cache.get(cache, key1), "test-1")
1806 assert.strictEqual(yield* Cache.get(cache, key2), "test-2")
1807 assert.strictEqual(yield* Cache.get(cache, key3), "test-1") // Uses cached value
1808 }))
1809 })
1810})
1811
1812describe("PersistedCache", () => {
1813 it.effect("persists synchronous lookup defects", () =>
1814 Effect.gen(function*() {
1815 class LookupRequest extends Persistable.Class()("LookupRequest", {
1816 primaryKey: () => "lookup"
1817 }) {}
1818
1819 let calls = 0
1820 const lookup = (_key: LookupRequest): Effect.Effect<void> => {
1821 calls++
1822 throw new Error("lookup defect")
1823 }
1824 const options = { storeId: "synchronous-lookup-defect", timeToLive: () => "1 hour" as const }
1825 const key = new LookupRequest()
1826
1827 const cache = yield* PersistedCache.make(lookup, options)
1828 yield* Effect.exit(cache.get(key))
1829 const recreated = yield* PersistedCache.make(lookup, options)
1830 yield* Effect.exit(recreated.get(key))
1831
1832 assert.strictEqual(calls, 1)
1833 }).pipe(Effect.provide(Persistence.layerMemory)))
1834})
1835
1836const makeTestCache = (capacity: number, ttl?: Duration.Input) =>
1837 Effect.gen(function*() {
1838 let lookupCount = 0
1839 const lookupResults = new Map<string, Effect.Effect<number, string>>()
1840
1841 const cache = yield* Cache.make<string, number, string>({
1842 capacity,
1843 lookup: (key) => {
1844 lookupCount++
1845 return lookupResults.get(key) ?? Effect.fail(`Key not found: ${key}`)
1846 },
1847 timeToLive: ttl
1848 })
1849
1850 return {
1851 cache,
1852 lookupCount: () => lookupCount,
1853 resetLookupCount: () => {
1854 lookupCount = 0
1855 },
1856 setLookupResult: (key: string, result: Effect.Effect<number, string>) => {
1857 lookupResults.set(key, result)
1858 }
1859 }
1860 })