1import { assert, describe, it } from "@effect/vitest"
2import {
3 Cause,
4 Clock,
5 Context,
6 Data,
7 Deferred,
8 Duration,
9 Effect,
10 Exit,
11 Fiber,
12 Latch,
13 Option,
14 Scope,
15 ScopedCache
16} from "effect"
17import { TestClock } from "effect/testing"
18import { collectGarbage } from "./utils/gc.ts"
19
20describe("ScopedCache", () => {
21 it.effect.skipIf(process.versions.bun !== undefined || process.versions.deno !== undefined)(
22 "does not retain prior equal object keys on cache hits",
23 () =>
24 Effect.gen(function*() {
25 const references: Array<WeakRef<object>> = []
26 const control = new WeakRef({})
27 let lookups = 0
28 const cache = yield* ScopedCache.make({
29 capacity: 16,
30 lookup: (_key: { readonly region: string }) => Effect.sync(() => ++lookups)
31 })
32 for (let i = 0; i < 10; i++) {
33 const key = { region: "us-east-1" }
34 references.push(new WeakRef(key))
35 assert.strictEqual(yield* Effect.scoped(ScopedCache.get(cache, key)), 1)
36 }
37 assert.strictEqual(lookups, 1)
38 assert.strictEqual(yield* ScopedCache.size(cache), 1)
39 // The last key may still be held by the cache.
40 references.pop()
41 yield* collectGarbage
42 assert.isUndefined(control.deref())
43 for (const reference of references) assert.isUndefined(reference.deref())
44 assert.strictEqual(yield* ScopedCache.size(cache), 1)
45 })
46 )
47
48 describe("constructors", () => {
49 describe("make", () => {
50 it.effect("creates cache with fixed capacity", () =>
51 Effect.gen(function*() {
52 const cache = yield* ScopedCache.make({
53 capacity: 10,
54 lookup: (key: string) => Effect.succeed(key.length)
55 })
56
57 assert.strictEqual(cache.capacity, 10)
58 }))
59
60 it.effect("creates cache with default infinite TTL", () =>
61 Effect.gen(function*() {
62 const cache = yield* ScopedCache.make({
63 capacity: 10,
64 lookup: (key: string) => Effect.succeed(key.length)
65 })
66
67 // Add value and verify it doesn't expire
68 yield* ScopedCache.get(cache, "test")
69 yield* TestClock.adjust(Duration.hours(1000))
70 assert.isTrue(yield* ScopedCache.has(cache, "test"))
71 }))
72
73 it.effect("creates cache with custom TTL", () =>
74 Effect.gen(function*() {
75 const cache = yield* ScopedCache.make({
76 capacity: 10,
77 lookup: (key: string) => Effect.succeed(key.length),
78 timeToLive: "1 hour"
79 })
80
81 yield* ScopedCache.get(cache, "test")
82 yield* TestClock.adjust("30 minutes")
83 assert.isTrue(yield* ScopedCache.has(cache, "test"))
84
85 yield* TestClock.adjust("31 minutes")
86 assert.isFalse(yield* ScopedCache.has(cache, "test"))
87 }))
88
89 it.effect("uses a numeric zero TTL", () =>
90 Effect.gen(function*() {
91 const cache = yield* ScopedCache.make({
92 capacity: 10,
93 lookup: (key: string) => Effect.succeed(key.length),
94 timeToLive: 0
95 })
96
97 yield* ScopedCache.get(cache, "test")
98
99 assert.isFalse(yield* ScopedCache.has(cache, "test"))
100 }))
101
102 it.effect("lookup function context is preserved", () =>
103 Effect.gen(function*() {
104 class TestService extends Context.Service<TestService, { value: number }>()("TestService") {}
105
106 const program = Effect.gen(function*() {
107 const cache = yield* ScopedCache.make({
108 capacity: 10,
109 lookup: (_key: string) => Effect.map(TestService, (service) => service.value)
110 })
111 return yield* ScopedCache.get(cache, "test")
112 })
113
114 const result = yield* Effect.scoped(
115 program.pipe(
116 Effect.provideService(TestService, TestService.of({ value: 42 }))
117 )
118 )
119
120 assert.strictEqual(result, 42)
121 }))
122
123 it.effect("cache resources are released and future gets interrupt when scope closes", () =>
124 Effect.gen(function*() {
125 const cache = yield* Effect.scoped(
126 Effect.gen(function*() {
127 const cache = yield* ScopedCache.make({
128 capacity: 10,
129 lookup: (key: string) => Effect.succeed(key.length)
130 })
131 yield* ScopedCache.get(cache, "test")
132 return cache
133 })
134 )
135
136 assert.strictEqual(cache.state._tag, "Closed")
137 assert.isTrue(Exit.hasInterrupts(yield* Effect.exit(ScopedCache.get(cache, "test"))))
138 }))
139 })
140
141 describe("makeWith", () => {
142 it.effect("creates cache with function-based TTL", () =>
143 Effect.gen(function*() {
144 const cache = yield* ScopedCache.makeWith({
145 capacity: 10,
146 lookup: (key: string) => key === "fail" ? Effect.fail("error") : Effect.succeed(key.length),
147 timeToLive: (exit, key) => {
148 if (Exit.isFailure(exit)) return "1 second"
149 return key === "short" ? "1 minute" : "1 hour"
150 }
151 })
152
153 // Success with long TTL
154 yield* ScopedCache.get(cache, "long")
155 yield* TestClock.adjust("30 minutes")
156 assert.isTrue(yield* ScopedCache.has(cache, "long"))
157
158 // Success with short TTL
159 yield* ScopedCache.get(cache, "short")
160 assert.isTrue(yield* ScopedCache.has(cache, "short"))
161 yield* TestClock.adjust(Duration.sum(Duration.minutes(1), Duration.seconds(1)))
162 assert.isFalse(yield* ScopedCache.has(cache, "short"))
163 assert.isTrue(yield* ScopedCache.has(cache, "long"))
164
165 // Failure with very short TTL
166 yield* Effect.exit(ScopedCache.get(cache, "fail"))
167 assert.isTrue(yield* ScopedCache.has(cache, "fail"))
168 yield* TestClock.adjust("2 seconds")
169 assert.isFalse(yield* ScopedCache.has(cache, "fail"))
170 }))
171
172 it.effect("TTL function receives correct parameters", () =>
173 Effect.gen(function*() {
174 const receivedParams: Array<{ exit: Exit.Exit<number, string>; key: string }> = []
175
176 const cache = yield* ScopedCache.makeWith({
177 capacity: 10,
178 lookup: (key: string) => key === "fail" ? Effect.fail("error") : Effect.succeed(key.length),
179 timeToLive: (exit, key) => {
180 receivedParams.push({ exit, key })
181 return Duration.infinity
182 }
183 })
184
185 yield* ScopedCache.get(cache, "test")
186 yield* Effect.exit(ScopedCache.get(cache, "fail"))
187
188 assert.strictEqual(receivedParams.length, 2)
189 assert.strictEqual(receivedParams[0].key, "test")
190 assert.isTrue(Exit.isSuccess(receivedParams[0].exit))
191 assert(Exit.isSuccess(receivedParams[0].exit))
192 assert.strictEqual(receivedParams[0].exit.value, 4)
193
194 assert.strictEqual(receivedParams[1].key, "fail")
195 assert.isTrue(Exit.isFailure(receivedParams[1].exit))
196 assert.deepStrictEqual(receivedParams[1].exit, Exit.fail("error"))
197 }))
198
199 it.effect("different TTL for success vs failure", () =>
200 Effect.gen(function*() {
201 const cache = yield* ScopedCache.makeWith({
202 capacity: 10,
203 lookup: (key: string) => key === "fail" ? Effect.fail("error") : Effect.succeed(key.length),
204 timeToLive: (exit) => Exit.isSuccess(exit) ? "1 hour" : "1 minute"
205 })
206
207 yield* ScopedCache.get(cache, "success")
208 yield* Effect.exit(ScopedCache.get(cache, "fail"))
209
210 yield* TestClock.adjust("30 minutes")
211 assert.isTrue(yield* ScopedCache.has(cache, "success"))
212 assert.isFalse(yield* ScopedCache.has(cache, "fail"))
213 }))
214
215 it.effect("TTL based on key values", () =>
216 Effect.gen(function*() {
217 const cache = yield* ScopedCache.makeWith({
218 capacity: 10,
219 lookup: (key: string) => Effect.succeed(key.length),
220 timeToLive: (_exit, key) => key === "short" ? "1 minute" : "1 hour"
221 })
222
223 yield* ScopedCache.get(cache, "short")
224 yield* ScopedCache.get(cache, "long")
225
226 yield* TestClock.adjust("30 minutes")
227 assert.isFalse(yield* ScopedCache.has(cache, "short"))
228 assert.isTrue(yield* ScopedCache.has(cache, "long"))
229 }))
230
231 it.effect("TTL based on result values", () =>
232 Effect.gen(function*() {
233 const cache = yield* ScopedCache.makeWith({
234 capacity: 10,
235 lookup: (key: string) => Effect.succeed(key.length),
236 timeToLive: (exit) => {
237 const value = Exit.isSuccess(exit) ? exit.value : 0
238 return value > 3 ? "1 hour" : "1 minute"
239 }
240 })
241
242 yield* ScopedCache.get(cache, "ab") // length 2
243 yield* ScopedCache.get(cache, "abcd") // length 4
244
245 yield* TestClock.adjust("30 minutes")
246 assert.isFalse(yield* ScopedCache.has(cache, "ab"))
247 assert.isTrue(yield* ScopedCache.has(cache, "abcd"))
248 }))
249
250 it.effect("infinite TTL handling", () =>
251 Effect.gen(function*() {
252 const cache = yield* ScopedCache.makeWith({
253 capacity: 10,
254 lookup: (key: string) => Effect.succeed(key.length),
255 timeToLive: () => Duration.infinity
256 })
257
258 yield* ScopedCache.get(cache, "test")
259 yield* TestClock.adjust(Duration.hours(1000))
260 assert.isTrue(yield* ScopedCache.has(cache, "test"))
261 }))
262
263 it.effect("zero duration TTL", () =>
264 Effect.gen(function*() {
265 const cache = yield* ScopedCache.makeWith({
266 capacity: 10,
267 lookup: (key: string) => Effect.succeed(key.length),
268 timeToLive: () => Duration.zero
269 })
270
271 yield* ScopedCache.get(cache, "test")
272 // Entry should expire immediately
273 assert.isFalse(yield* ScopedCache.has(cache, "test"))
274 }))
275 })
276 })
277
278 describe("basic operations", () => {
279 describe("get", () => {
280 it.effect("cache hit - multiple different keys", () =>
281 Effect.gen(function*() {
282 const cache = yield* ScopedCache.make({
283 capacity: 10,
284 lookup: (key: string) => Effect.succeed(key.length)
285 })
286
287 const result1 = yield* ScopedCache.get(cache, "a")
288 const result2 = yield* ScopedCache.get(cache, "ab")
289 const result3 = yield* ScopedCache.get(cache, "abc")
290
291 assert.strictEqual(result1, 1)
292 assert.strictEqual(result2, 2)
293 assert.strictEqual(result3, 3)
294 }))
295
296 it.effect("cache hit - same key multiple times doesn't invoke lookup again", () =>
297 Effect.gen(function*() {
298 const { cache, lookupCount, setLookupResult } = yield* makeScopedTestCache(10)
299 setLookupResult("test", Effect.succeed(4))
300
301 const results = yield* Effect.all([
302 ScopedCache.get(cache, "test"),
303 ScopedCache.get(cache, "test"),
304 ScopedCache.get(cache, "test")
305 ])
306
307 assert.deepStrictEqual(results, [4, 4, 4])
308 assert.strictEqual(lookupCount(), 1)
309 }))
310
311 it.effect("cache miss - invokes lookup for non-existent key", () =>
312 Effect.gen(function*() {
313 const { cache, lookupCount, setLookupResult } = yield* makeScopedTestCache(10)
314 setLookupResult("test", Effect.succeed(42))
315
316 const result = yield* ScopedCache.get(cache, "test")
317
318 assert.strictEqual(result, 42)
319 assert.strictEqual(lookupCount(), 1)
320 }))
321
322 it.effect("cache miss - invokes lookup again after TTL expiration", () =>
323 Effect.gen(function*() {
324 let counter = 0
325 const cache = yield* ScopedCache.make({
326 capacity: 10,
327 lookup: (_key: string) => Effect.sync(() => ++counter),
328 timeToLive: "1 hour"
329 })
330
331 const result1 = yield* ScopedCache.get(cache, "test")
332 assert.strictEqual(result1, 1)
333
334 yield* TestClock.adjust("30 minutes")
335 const result2 = yield* ScopedCache.get(cache, "test")
336 assert.strictEqual(result2, 1)
337
338 yield* TestClock.adjust("31 minutes")
339 const result3 = yield* ScopedCache.get(cache, "test")
340 assert.strictEqual(result3, 2)
341 }))
342
343 it.effect("error handling - lookup function fails", () =>
344 Effect.gen(function*() {
345 const cache = yield* ScopedCache.make({
346 capacity: 10,
347 lookup: (_key: string) => Effect.fail("lookup error")
348 })
349
350 const result = yield* Effect.exit(ScopedCache.get(cache, "test"))
351
352 assert.deepStrictEqual(result, Exit.fail("lookup error"))
353 }))
354
355 it.effect("concurrent access - multiple fibers getting same key only invoke lookup once", () =>
356 Effect.gen(function*() {
357 let lookupCount = 0
358 const cache = yield* ScopedCache.make({
359 capacity: 10,
360 lookup: () => Effect.delay(Duration.millis(100))(Effect.sync(() => ++lookupCount))
361 })
362
363 const effects = [ScopedCache.get(cache, "key"), ScopedCache.get(cache, "key"), ScopedCache.get(cache, "key")]
364 const resultsFiber = yield* Effect.all(effects, { concurrency: "unbounded" }).pipe(Effect.forkChild)
365
366 yield* TestClock.adjust(Duration.millis(150))
367 const results = yield* Fiber.join(resultsFiber)
368
369 assert.strictEqual(lookupCount, 1)
370 assert.deepStrictEqual(results, [1, 1, 1])
371 }))
372
373 it.effect("concurrent access - race between get and invalidate", () =>
374 Effect.gen(function*() {
375 let lookupCount = 0
376 const cache = yield* ScopedCache.make({
377 capacity: 10,
378 lookup: (_key: string) => Effect.delay(Duration.millis(50))(Effect.sync(() => ++lookupCount))
379 })
380
381 // First get starts
382 const fiber1 = yield* ScopedCache.get(cache, "test").pipe(Effect.forkChild)
383 yield* TestClock.adjust(Duration.millis(25))
384
385 // Invalidate while first get is in progress
386 yield* ScopedCache.invalidate(cache, "test")
387
388 // Second get starts after invalidation
389 const fiber2 = yield* ScopedCache.get(cache, "test").pipe(Effect.forkChild)
390
391 yield* TestClock.adjust(Duration.millis(100))
392 const result1 = yield* Fiber.join(fiber1)
393 const result2 = yield* Fiber.join(fiber2)
394
395 assert.strictEqual(result1, 1)
396 assert.strictEqual(result2, 2)
397 assert.strictEqual(lookupCount, 2)
398 }))
399
400 it.effect("resource cleanup when entry is replaced", () =>
401 Effect.gen(function*() {
402 const { cache, cleanupTracker } = yield* makeManagedResourceCache(2)
403
404 // Fill cache to capacity
405 yield* ScopedCache.get(cache, "key1")
406 yield* ScopedCache.get(cache, "key2")
407 assert.strictEqual(cleanupTracker.cleanedUp.length, 0)
408
409 // Add third entry, should evict first
410 yield* ScopedCache.get(cache, "key3")
411 assert.strictEqual(cleanupTracker.cleanedUp.length, 1)
412 assert.deepStrictEqual(cleanupTracker.cleanedUp, ["key1"])
413 }))
414
415 it.effect("scope is properly provided to lookup function", () =>
416 Effect.gen(function*() {
417 const scopeIds: Array<string> = []
418
419 const cache = yield* ScopedCache.make({
420 capacity: 10,
421 lookup: (key: string) =>
422 Effect.gen(function*() {
423 // Create a resource in the provided scope
424 yield* Effect.acquireRelease(
425 Effect.sync(() => scopeIds.push(`acquired-${key}`)),
426 () => Effect.sync(() => scopeIds.push(`released-${key}`))
427 )
428 return `value-${key}`
429 })
430 })
431
432 yield* ScopedCache.get(cache, "test")
433 assert.deepStrictEqual(scopeIds, ["acquired-test"])
434
435 // Invalidate should trigger resource cleanup
436 yield* ScopedCache.invalidate(cache, "test")
437 assert.deepStrictEqual(scopeIds, ["acquired-test", "released-test"])
438 }))
439
440 it.effect("cache closed state - get returns interrupt", () =>
441 Effect.gen(function*() {
442 const cache = yield* Effect.scoped(
443 ScopedCache.make({
444 capacity: 10,
445 lookup: (key: string) => Effect.succeed(key.length)
446 })
447 )
448
449 // Cache should be closed now
450 const result = yield* Effect.exit(ScopedCache.get(cache!, "test"))
451 assert.isTrue(Exit.hasInterrupts(result))
452 }))
453 })
454
455 describe("getOption", () => {
456 it.effect("returns Some for existing cached value", () =>
457 Effect.gen(function*() {
458 const cache = yield* ScopedCache.make({
459 capacity: 10,
460 lookup: (key: string) => Effect.succeed(key.length)
461 })
462
463 yield* ScopedCache.get(cache, "test")
464 const result = yield* ScopedCache.getOption(cache, "test")
465
466 assert.deepStrictEqual(result, Option.some(4))
467 }))
468
469 it.effect("returns None for non-existent key", () =>
470 Effect.gen(function*() {
471 const cache = yield* ScopedCache.make({
472 capacity: 10,
473 lookup: (key: string) => Effect.succeed(key.length)
474 })
475
476 const result = yield* ScopedCache.getOption(cache, "test")
477
478 assert.deepStrictEqual(result, Option.none())
479 }))
480
481 it.effect("returns None for expired value", () =>
482 Effect.gen(function*() {
483 const cache = yield* ScopedCache.make({
484 capacity: 10,
485 lookup: (key: string) => Effect.succeed(key.length),
486 timeToLive: "1 hour"
487 })
488
489 yield* ScopedCache.get(cache, "test")
490 yield* TestClock.adjust("2 hours")
491 const result = yield* ScopedCache.getOption(cache, "test")
492
493 assert.deepStrictEqual(result, Option.none())
494 }))
495
496 it.effect("waits for value being computed and returns result", () =>
497 Effect.gen(function*() {
498 const deferred = yield* Deferred.make<void>()
499 const cache = yield* ScopedCache.make({
500 capacity: 10,
501 lookup: (_key: string) => Deferred.await(deferred).pipe(Effect.as(42))
502 })
503
504 const getFiber = yield* ScopedCache.get(cache, "test").pipe(Effect.forkChild)
505 const optionFiber = yield* ScopedCache.getOption(cache, "test").pipe(Effect.forkChild)
506
507 yield* Deferred.succeed(deferred, void 0)
508
509 const getResult = yield* Fiber.join(getFiber)
510 const optionResult = yield* Fiber.join(optionFiber)
511
512 assert.strictEqual(getResult, 42)
513 assert.deepStrictEqual(optionResult, Option.some(42))
514 }))
515
516 it.effect("affects LRU order", () =>
517 Effect.gen(function*() {
518 const cache = yield* ScopedCache.make({
519 capacity: 2,
520 lookup: (key: string) => Effect.succeed(key.length)
521 })
522
523 yield* ScopedCache.get(cache, "a")
524 yield* ScopedCache.get(cache, "b")
525
526 yield* ScopedCache.getOption(cache, "a")
527
528 yield* ScopedCache.get(cache, "c")
529
530 assert.isTrue(yield* ScopedCache.has(cache, "a"))
531 assert.isFalse(yield* ScopedCache.has(cache, "b"))
532 assert.isTrue(yield* ScopedCache.has(cache, "c"))
533 }))
534 })
535
536 describe("getSuccess", () => {
537 it.effect("returns Some for successfully cached value", () =>
538 Effect.gen(function*() {
539 const cache = yield* ScopedCache.make({
540 capacity: 10,
541 lookup: (key: string) => Effect.succeed(key.length)
542 })
543
544 yield* ScopedCache.get(cache, "test")
545 const result = yield* ScopedCache.getSuccess(cache, "test")
546
547 assert.deepStrictEqual(result, Option.some(4))
548 }))
549
550 it.effect("returns None for failed cached value", () =>
551 Effect.gen(function*() {
552 const cache = yield* ScopedCache.make({
553 capacity: 10,
554 lookup: (_key: string) => Effect.fail("error")
555 })
556
557 yield* Effect.exit(ScopedCache.get(cache, "test"))
558 const result = yield* ScopedCache.getSuccess(cache, "test")
559
560 assert.deepStrictEqual(result, Option.none())
561 }))
562
563 it.effect("returns None for non-existent key", () =>
564 Effect.gen(function*() {
565 const cache = yield* ScopedCache.make({
566 capacity: 10,
567 lookup: (key: string) => Effect.succeed(key.length)
568 })
569
570 const result = yield* ScopedCache.getSuccess(cache, "test")
571
572 assert.deepStrictEqual(result, Option.none())
573 }))
574
575 it.effect("returns None for expired value", () =>
576 Effect.gen(function*() {
577 const cache = yield* ScopedCache.make({
578 capacity: 10,
579 lookup: (key: string) => Effect.succeed(key.length),
580 timeToLive: "1 hour"
581 })
582
583 yield* ScopedCache.get(cache, "test")
584 yield* TestClock.adjust("2 hours")
585 const result = yield* ScopedCache.getSuccess(cache, "test")
586
587 assert.deepStrictEqual(result, Option.none())
588 }))
589 })
590 })
591
592 describe("modification operations", () => {
593 describe("set", () => {
594 it.effect("sets new key-value pair", () =>
595 Effect.gen(function*() {
596 const { cache, lookupCount } = yield* makeScopedTestCache(10)
597
598 yield* ScopedCache.set(cache, "test", 42)
599 const result = yield* ScopedCache.get(cache, "test")
600
601 assert.strictEqual(result, 42)
602 assert.strictEqual(lookupCount(), 0)
603 }))
604
605 it.effect("overwrites existing key", () =>
606 Effect.gen(function*() {
607 const cache = yield* ScopedCache.make({
608 capacity: 10,
609 lookup: (key: string) => Effect.succeed(key.length)
610 })
611
612 yield* ScopedCache.get(cache, "test")
613 yield* ScopedCache.set(cache, "test", 100)
614 const result = yield* ScopedCache.get(cache, "test")
615
616 assert.strictEqual(result, 100)
617 }))
618
619 it.effect("set with TTL - value expires", () =>
620 Effect.gen(function*() {
621 const cache = yield* ScopedCache.make({
622 capacity: 10,
623 lookup: (key: string) => Effect.succeed(key.length),
624 timeToLive: "1 hour"
625 })
626
627 yield* ScopedCache.set(cache, "test", 42)
628 assert.isTrue(yield* ScopedCache.has(cache, "test"))
629
630 yield* TestClock.adjust("2 hours")
631 assert.isFalse(yield* ScopedCache.has(cache, "test"))
632 }))
633
634 it.effect("set doesn't invoke lookup function", () =>
635 Effect.gen(function*() {
636 const { cache, lookupCount } = yield* makeScopedTestCache(10)
637
638 yield* ScopedCache.set(cache, "test", 42)
639 yield* ScopedCache.set(cache, "test2", 43)
640 yield* ScopedCache.set(cache, "test3", 44)
641
642 assert.strictEqual(lookupCount(), 0)
643 }))
644
645 it.effect("set enforces capacity constraints", () =>
646 Effect.gen(function*() {
647 const cache = yield* ScopedCache.make({
648 capacity: 2,
649 lookup: (key: string) => Effect.succeed(key.length)
650 })
651
652 yield* ScopedCache.set(cache, "a", 1)
653 yield* ScopedCache.set(cache, "b", 2)
654 const sizeBefore = yield* ScopedCache.size(cache)
655 assert.strictEqual(sizeBefore, 2)
656
657 yield* ScopedCache.set(cache, "c", 3)
658 const sizeAfter = yield* ScopedCache.size(cache)
659 assert.strictEqual(sizeAfter, 2)
660 }))
661
662 it.effect("resource cleanup when overwriting existing entry", () =>
663 Effect.gen(function*() {
664 const { cache, cleanupTracker } = yield* makeManagedResourceCache(10)
665
666 yield* ScopedCache.get(cache, "test")
667 assert.strictEqual(cleanupTracker.cleanedUp.length, 0)
668
669 // Overwrite should clean up old resource
670 yield* ScopedCache.set(cache, "test", "new-value")
671 assert.strictEqual(cleanupTracker.cleanedUp.length, 1)
672 assert.deepStrictEqual(cleanupTracker.cleanedUp, ["test"])
673 }))
674 })
675
676 describe("has", () => {
677 it.effect("returns true for existing key", () =>
678 Effect.gen(function*() {
679 const cache = yield* ScopedCache.make({
680 capacity: 10,
681 lookup: (key: string) => Effect.succeed(key.length)
682 })
683
684 yield* ScopedCache.get(cache, "test")
685 const result = yield* ScopedCache.has(cache, "test")
686
687 assert.isTrue(result)
688 }))
689
690 it.effect("returns false for non-existent key", () =>
691 Effect.gen(function*() {
692 const cache = yield* ScopedCache.make({
693 capacity: 10,
694 lookup: (key: string) => Effect.succeed(key.length)
695 })
696
697 const result = yield* ScopedCache.has(cache, "test")
698
699 assert.isFalse(result)
700 }))
701
702 it.effect("returns false for expired key", () =>
703 Effect.gen(function*() {
704 const cache = yield* ScopedCache.make({
705 capacity: 10,
706 lookup: (key: string) => Effect.succeed(key.length),
707 timeToLive: "1 hour"
708 })
709
710 yield* ScopedCache.get(cache, "test")
711 yield* TestClock.adjust("2 hours")
712 const result = yield* ScopedCache.has(cache, "test")
713
714 assert.isFalse(result)
715 }))
716
717 it.effect("does not affect LRU order", () =>
718 Effect.gen(function*() {
719 const cache = yield* ScopedCache.make({
720 capacity: 2,
721 lookup: (key: string) => Effect.succeed(key.length)
722 })
723
724 yield* ScopedCache.get(cache, "a")
725 yield* ScopedCache.get(cache, "b")
726
727 // Check "a" exists without affecting LRU order
728 assert.isTrue(yield* ScopedCache.has(cache, "a"))
729
730 // Add "c" - should still evict "a" (oldest) not "b"
731 yield* ScopedCache.get(cache, "c")
732
733 assert.isFalse(yield* ScopedCache.has(cache, "a"))
734 assert.isTrue(yield* ScopedCache.has(cache, "b"))
735 assert.isTrue(yield* ScopedCache.has(cache, "c"))
736 }))
737 })
738
739 describe("invalidate", () => {
740 it.effect("invalidates existing key", () =>
741 Effect.gen(function*() {
742 const cache = yield* ScopedCache.make({
743 capacity: 10,
744 lookup: (key: string) => Effect.succeed(key.length)
745 })
746
747 yield* ScopedCache.get(cache, "test")
748 yield* ScopedCache.invalidate(cache, "test")
749 const result = yield* ScopedCache.has(cache, "test")
750
751 assert.isFalse(result)
752 }))
753
754 it.effect("invalidating non-existent key doesn't error", () =>
755 Effect.gen(function*() {
756 const cache = yield* ScopedCache.make({
757 capacity: 10,
758 lookup: (key: string) => Effect.succeed(key.length)
759 })
760
761 yield* ScopedCache.invalidate(cache, "test")
762 const result = yield* ScopedCache.has(cache, "test")
763
764 assert.isFalse(result)
765 }))
766
767 it.effect("get after invalidate invokes lookup again", () =>
768 Effect.gen(function*() {
769 let counter = 0
770 const cache = yield* ScopedCache.make({
771 capacity: 10,
772 lookup: (_key: string) => Effect.sync(() => ++counter)
773 })
774
775 const result1 = yield* ScopedCache.get(cache, "test")
776 yield* ScopedCache.invalidate(cache, "test")
777 const result2 = yield* ScopedCache.get(cache, "test")
778
779 assert.strictEqual(result1, 1)
780 assert.strictEqual(result2, 2)
781 }))
782
783 it.effect("resource cleanup when invalidating entry", () =>
784 Effect.gen(function*() {
785 const { cache, cleanupTracker } = yield* makeManagedResourceCache(10)
786
787 yield* ScopedCache.get(cache, "test")
788 assert.strictEqual(cleanupTracker.cleanedUp.length, 0)
789
790 yield* ScopedCache.invalidate(cache, "test")
791 assert.strictEqual(cleanupTracker.cleanedUp.length, 1)
792 assert.deepStrictEqual(cleanupTracker.cleanedUp, ["test"])
793 }))
794 })
795
796 describe("invalidateWhen", () => {
797 it.effect.each([Exit.succeed(1), Exit.fail("error")])(
798 "preserves a replacement resource after an old lookup completes (%#)",
799 (exit) =>
800 Effect.gen(function*() {
801 let acquired = 0
802 const released: Array<number> = []
803 const values: Array<number> = []
804 const gate = yield* Deferred.make<number, string>()
805 const owner = yield* Scope.make()
806 const cache = yield* ScopedCache.make({
807 capacity: 1,
808 lookup: (_key: string) =>
809 Effect.gen(function*() {
810 const id = yield* Effect.acquireRelease(
811 Effect.sync(() => ++acquired),
812 (value) => Effect.sync(() => released.push(value))
813 )
814 if (id === 1) return yield* Deferred.await(gate)
815 return id
816 })
817 }).pipe(Scope.provide(owner))
818 const lookup = yield* ScopedCache.get(cache, "key").pipe(
819 Effect.exit,
820 Effect.forkChild({ startImmediately: true })
821 )
822 const invalidator = yield* ScopedCache.invalidateWhen(cache, "key", (value) => {
823 values.push(value)
824 return value === 1
825 }).pipe(
826 Effect.forkChild({ startImmediately: true })
827 )
828
829 yield* ScopedCache.invalidate(cache, "key")
830 const replacement = yield* ScopedCache.get(cache, "key")
831 yield* Deferred.done(gate, exit)
832 const original = yield* Fiber.join(lookup)
833 const invalidated = yield* Fiber.join(invalidator)
834 const cached = yield* ScopedCache.getSuccess(cache, "key")
835 const releasedBeforeClose = [...released]
836 yield* Scope.close(owner, Exit.void)
837
838 assert.deepStrictEqual(releasedBeforeClose, [1])
839 assert.deepStrictEqual(original, exit)
840 assert.deepStrictEqual(values, Exit.isSuccess(exit) ? [1] : [])
841 assert.deepStrictEqual(
842 { invalidated, replacement, cached, released },
843 { invalidated: false, replacement: 2, cached: Option.some(2), released: [1, 2] }
844 )
845 })
846 )
847
848 it.effect("invalidates when predicate matches", () =>
849 Effect.gen(function*() {
850 const cache = yield* ScopedCache.make({
851 capacity: 10,
852 lookup: (key: string) => Effect.succeed(key.length)
853 })
854
855 yield* ScopedCache.get(cache, "test")
856 const result = yield* ScopedCache.invalidateWhen(cache, "test", (value) => value === 4)
857
858 assert.isTrue(result)
859 assert.isFalse(yield* ScopedCache.has(cache, "test"))
860 }))
861
862 it.effect("doesn't invalidate when predicate doesn't match", () =>
863 Effect.gen(function*() {
864 const cache = yield* ScopedCache.make({
865 capacity: 10,
866 lookup: (key: string) => Effect.succeed(key.length)
867 })
868
869 yield* ScopedCache.get(cache, "test")
870 const result = yield* ScopedCache.invalidateWhen(cache, "test", (value) => value === 5)
871
872 assert.isFalse(result)
873 assert.isTrue(yield* ScopedCache.has(cache, "test"))
874 }))
875
876 it.effect("returns false for non-existent key", () =>
877 Effect.gen(function*() {
878 const cache = yield* ScopedCache.make({
879 capacity: 10,
880 lookup: (key: string) => Effect.succeed(key.length)
881 })
882
883 const result = yield* ScopedCache.invalidateWhen(cache, "test", () => true)
884
885 assert.isFalse(result)
886 }))
887
888 it.effect("returns false for failed cached value", () =>
889 Effect.gen(function*() {
890 const cache = yield* ScopedCache.make({
891 capacity: 10,
892 lookup: (_key: string) => Effect.fail("error")
893 })
894
895 yield* Effect.exit(ScopedCache.get(cache, "test"))
896 const result = yield* ScopedCache.invalidateWhen(cache, "test", () => true)
897
898 assert.isFalse(result)
899 }))
900
901 it.effect("resource cleanup when predicate matches", () =>
902 Effect.gen(function*() {
903 const { cache, cleanupTracker } = yield* makeManagedResourceCache(10)
904
905 yield* ScopedCache.get(cache, "test")
906 assert.strictEqual(cleanupTracker.cleanedUp.length, 0)
907
908 const result = yield* ScopedCache.invalidateWhen(cache, "test", (value) => value.includes("resource"))
909 assert.isTrue(result)
910 assert.strictEqual(cleanupTracker.cleanedUp.length, 1)
911 assert.deepStrictEqual(cleanupTracker.cleanedUp, ["test"])
912 }))
913 })
914
915 describe("refresh", () => {
916 it.effect("caches a synchronous lookup defect for a missing key", () =>
917 Effect.gen(function*() {
918 let lookups = 0
919 const cache = yield* ScopedCache.make({
920 capacity: 10,
921 lookup: (_key: string): Effect.Effect<number> => {
922 lookups++
923 throw "lookup defect"
924 }
925 })
926
927 assert.deepStrictEqual(yield* Effect.exit(ScopedCache.refresh(cache, "test")), Exit.die("lookup defect"))
928 const reader = yield* ScopedCache.get(cache, "test").pipe(
929 Effect.exit,
930 Effect.timeoutOption("1 second"),
931 Effect.forkChild
932 )
933 yield* TestClock.adjust("1 second")
934
935 assert.deepStrictEqual(yield* Fiber.join(reader), Option.some(Exit.die("lookup defect")))
936 assert.strictEqual(lookups, 1)
937 }))
938
939 it.effect("replaces an existing entry with a synchronous lookup defect", () =>
940 Effect.gen(function*() {
941 let fail = false
942 let released = 0
943 const cache = yield* ScopedCache.make({
944 capacity: 10,
945 lookup: (_key: string) => {
946 if (fail) throw "lookup defect"
947 return Effect.acquireRelease(
948 Effect.succeed(42),
949 () => Effect.sync(() => released++)
950 )
951 }
952 })
953 assert.strictEqual(yield* ScopedCache.get(cache, "test"), 42)
954 fail = true
955
956 assert.deepStrictEqual(yield* Effect.exit(ScopedCache.refresh(cache, "test")), Exit.die("lookup defect"))
957
958 assert.deepStrictEqual({
959 result: yield* Effect.exit(ScopedCache.get(cache, "test")),
960 released
961 }, {
962 result: Exit.die("lookup defect"),
963 released: 1
964 })
965 }))
966
967 it.effect("refresh existing key invokes lookup again", () =>
968 Effect.gen(function*() {
969 let counter = 0
970 const cache = yield* ScopedCache.make({
971 capacity: 10,
972 lookup: (_key: string) => Effect.sync(() => ++counter)
973 })
974
975 yield* ScopedCache.get(cache, "test")
976 const result = yield* ScopedCache.refresh(cache, "test")
977
978 assert.strictEqual(result, 2)
979 }))
980
981 it.effect("refresh non-existent key invokes lookup and enforces capacity", () =>
982 Effect.gen(function*() {
983 let counter = 0
984 const cache = yield* ScopedCache.make({
985 capacity: 1,
986 lookup: (_key: string) => Effect.sync(() => ++counter)
987 })
988
989 yield* ScopedCache.set(cache, "other", 99)
990 const result = yield* ScopedCache.refresh(cache, "test")
991
992 assert.strictEqual(result, 1)
993 assert.strictEqual(yield* ScopedCache.size(cache), 1)
994 assert.deepStrictEqual(yield* ScopedCache.keys(cache), ["test"])
995 }))
996
997 it.effect("refresh enforces capacity when the existing key is evicted during lookup", () =>
998 Effect.gen(function*() {
999 const started = yield* Deferred.make<void>()
1000 const result = yield* Deferred.make<number>()
1001 const released: Array<number> = []
1002 const cache = yield* ScopedCache.make({
1003 capacity: 1,
1004 lookup: (key: string) =>
1005 Effect.gen(function*() {
1006 const value = key === "a"
1007 ? yield* Deferred.succeed(started, void 0).pipe(Effect.andThen(Deferred.await(result)))
1008 : 99
1009 return yield* Effect.acquireRelease(
1010 Effect.succeed(value),
1011 (value) => Effect.sync(() => released.push(value))
1012 )
1013 })
1014 })
1015
1016 yield* ScopedCache.set(cache, "a", 1)
1017 const refresh = yield* ScopedCache.refresh(cache, "a").pipe(Effect.forkChild({ startImmediately: true }))
1018 yield* Deferred.await(started)
1019 assert.strictEqual(yield* ScopedCache.get(cache, "b"), 99)
1020 assert.isFalse(yield* ScopedCache.has(cache, "a"))
1021 assert.deepStrictEqual(released, [])
1022
1023 yield* Deferred.succeed(result, 2)
1024 assert.strictEqual(yield* Fiber.join(refresh), 2)
1025 assert.deepStrictEqual({
1026 size: yield* ScopedCache.size(cache),
1027 keys: (yield* ScopedCache.keys(cache)).sort(),
1028 released: released.slice()
1029 }, { size: 1, keys: ["a"], released: [99] })
1030 assert.deepStrictEqual(yield* ScopedCache.getSuccess(cache, "a"), Option.some(2))
1031
1032 yield* ScopedCache.invalidateAll(cache)
1033 assert.deepStrictEqual(released, [99, 2])
1034 }))
1035
1036 it.effect("refresh updates TTL", () =>
1037 Effect.gen(function*() {
1038 const cache = yield* ScopedCache.make({
1039 capacity: 10,
1040 lookup: (key: string) => Effect.succeed(key.length),
1041 timeToLive: "1 hour"
1042 })
1043
1044 yield* ScopedCache.get(cache, "test")
1045 yield* TestClock.adjust("30 minutes")
1046 yield* ScopedCache.refresh(cache, "test")
1047 yield* TestClock.adjust("40 minutes")
1048
1049 assert.isTrue(yield* ScopedCache.has(cache, "test"))
1050 }))
1051
1052 it.effect("concurrent refresh calls each invoke lookup independently", () =>
1053 Effect.gen(function*() {
1054 let lookupCount = 0
1055 const cache = yield* ScopedCache.make({
1056 capacity: 10,
1057 lookup: () => Effect.sync(() => ++lookupCount)
1058 })
1059
1060 yield* ScopedCache.get(cache, "test")
1061 lookupCount = 0
1062
1063 const results = yield* Effect.all([
1064 ScopedCache.refresh(cache, "test"),
1065 ScopedCache.refresh(cache, "test"),
1066 ScopedCache.refresh(cache, "test")
1067 ], { concurrency: "unbounded" })
1068
1069 // Each refresh calls lookup independently
1070 assert.strictEqual(lookupCount, 3)
1071 assert.deepStrictEqual(results, [1, 2, 3])
1072 }))
1073
1074 it.effect("resource cleanup for old entry after refresh", () =>
1075 Effect.gen(function*() {
1076 const { cache, cleanupTracker } = yield* makeManagedResourceCache(10)
1077
1078 yield* ScopedCache.get(cache, "test")
1079 assert.strictEqual(cleanupTracker.cleanedUp.length, 0)
1080
1081 yield* ScopedCache.refresh(cache, "test")
1082 assert.strictEqual(cleanupTracker.cleanedUp.length, 1)
1083 assert.deepStrictEqual(cleanupTracker.cleanedUp, ["test"])
1084 }))
1085 })
1086
1087 describe("invalidateAll", () => {
1088 it.effect.each([false, true])(
1089 "retains ownership of entries refreshed by finalizers (yield: %s)",
1090 (yieldBeforeRefresh) =>
1091 Effect.gen(function*() {
1092 let acquired = 0
1093 const released: Array<number> = []
1094
1095 const cached = yield* Effect.scoped(Effect.gen(function*() {
1096 const cache: ScopedCache.ScopedCache<string, number> = yield* ScopedCache.make({
1097 capacity: 10,
1098 lookup: (_key: string) =>
1099 Effect.acquireRelease(
1100 Effect.sync(() => ++acquired),
1101 (value) =>
1102 Effect.gen(function*() {
1103 released.push(value)
1104 if (value === 1) {
1105 if (yieldBeforeRefresh) {
1106 yield* Effect.yieldNow
1107 }
1108 yield* ScopedCache.refresh(cache, "key")
1109 }
1110 })
1111 )
1112 })
1113
1114 yield* ScopedCache.get(cache, "key")
1115 yield* ScopedCache.invalidateAll(cache)
1116 assert.deepStrictEqual(released, [1])
1117 return yield* ScopedCache.getSuccess(cache, "key")
1118 }))
1119
1120 assert.strictEqual(acquired, 2)
1121 assert.deepStrictEqual(released, [1, 2])
1122 assert.deepStrictEqual(cached, Option.some(2))
1123 })
1124 )
1125
1126 it.effect("removes all entries before running finalizers", () =>
1127 Effect.gen(function*() {
1128 const observedKeys: Array<Array<string>> = []
1129 const cache: ScopedCache.ScopedCache<string, string> = yield* ScopedCache.make({
1130 capacity: 10,
1131 lookup: (key: string) =>
1132 Effect.acquireRelease(
1133 Effect.succeed(key),
1134 () =>
1135 Effect.gen(function*() {
1136 observedKeys.push(yield* ScopedCache.keys(cache))
1137 })
1138 )
1139 })
1140
1141 yield* ScopedCache.get(cache, "first")
1142 yield* ScopedCache.get(cache, "second")
1143 yield* ScopedCache.invalidateAll(cache)
1144
1145 assert.deepStrictEqual(observedKeys, [[], []])
1146 }))
1147
1148 it.effect("clears all entries", () =>
1149 Effect.gen(function*() {
1150 const cache = yield* ScopedCache.make({
1151 capacity: 10,
1152 lookup: (key: string) => Effect.succeed(key.length)
1153 })
1154
1155 yield* ScopedCache.get(cache, "a")
1156 yield* ScopedCache.get(cache, "b")
1157 yield* ScopedCache.get(cache, "c")
1158
1159 yield* ScopedCache.invalidateAll(cache)
1160
1161 assert.isFalse(yield* ScopedCache.has(cache, "a"))
1162 assert.isFalse(yield* ScopedCache.has(cache, "b"))
1163 assert.isFalse(yield* ScopedCache.has(cache, "c"))
1164 }))
1165
1166 it.effect("size becomes 0 after invalidateAll", () =>
1167 Effect.gen(function*() {
1168 const cache = yield* ScopedCache.make({
1169 capacity: 10,
1170 lookup: (key: string) => Effect.succeed(key.length)
1171 })
1172
1173 yield* ScopedCache.get(cache, "a")
1174 yield* ScopedCache.get(cache, "b")
1175 yield* ScopedCache.get(cache, "c")
1176
1177 yield* ScopedCache.invalidateAll(cache)
1178 const size = yield* ScopedCache.size(cache)
1179
1180 assert.strictEqual(size, 0)
1181 }))
1182
1183 it.effect("all keys return None after invalidateAll", () =>
1184 Effect.gen(function*() {
1185 const cache = yield* ScopedCache.make({
1186 capacity: 10,
1187 lookup: (key: string) => Effect.succeed(key.length)
1188 })
1189
1190 yield* ScopedCache.get(cache, "a")
1191 yield* ScopedCache.get(cache, "b")
1192
1193 yield* ScopedCache.invalidateAll(cache)
1194
1195 const result1 = yield* ScopedCache.getOption(cache, "a")
1196 const result2 = yield* ScopedCache.getOption(cache, "b")
1197
1198 assert.deepStrictEqual(result1, Option.none())
1199 assert.deepStrictEqual(result2, Option.none())
1200 }))
1201
1202 it.effect("resource cleanup for all entries", () =>
1203 Effect.gen(function*() {
1204 const { cache, cleanupTracker } = yield* makeManagedResourceCache(10)
1205
1206 yield* ScopedCache.get(cache, "test1")
1207 yield* ScopedCache.get(cache, "test2")
1208 yield* ScopedCache.get(cache, "test3")
1209 assert.strictEqual(cleanupTracker.cleanedUp.length, 0)
1210
1211 yield* ScopedCache.invalidateAll(cache)
1212 assert.strictEqual(cleanupTracker.cleanedUp.length, 3)
1213 assert.deepStrictEqual(cleanupTracker.cleanedUp.sort(), ["test1", "test2", "test3"])
1214 }))
1215 })
1216 })
1217
1218 describe("utility operations", () => {
1219 describe("size", () => {
1220 it.effect("empty cache returns 0", () =>
1221 Effect.gen(function*() {
1222 const cache = yield* ScopedCache.make({
1223 capacity: 10,
1224 lookup: (key: string) => Effect.succeed(key.length)
1225 })
1226
1227 const size = yield* ScopedCache.size(cache)
1228
1229 assert.strictEqual(size, 0)
1230 }))
1231
1232 it.effect("returns correct count after adding entries", () =>
1233 Effect.gen(function*() {
1234 const cache = yield* ScopedCache.make({
1235 capacity: 10,
1236 lookup: (key: string) => Effect.succeed(key.length)
1237 })
1238
1239 yield* ScopedCache.get(cache, "a")
1240 yield* ScopedCache.get(cache, "b")
1241 yield* ScopedCache.get(cache, "c")
1242
1243 const size = yield* ScopedCache.size(cache)
1244
1245 assert.strictEqual(size, 3)
1246 }))
1247
1248 it.effect("decreases after invalidation", () =>
1249 Effect.gen(function*() {
1250 const cache = yield* ScopedCache.make({
1251 capacity: 10,
1252 lookup: (key: string) => Effect.succeed(key.length)
1253 })
1254
1255 yield* ScopedCache.get(cache, "a")
1256 yield* ScopedCache.get(cache, "b")
1257 yield* ScopedCache.get(cache, "c")
1258 yield* ScopedCache.invalidate(cache, "b")
1259
1260 const size = yield* ScopedCache.size(cache)
1261
1262 assert.strictEqual(size, 2)
1263 }))
1264
1265 it.effect("expired entries are counted until accessed", () =>
1266 Effect.gen(function*() {
1267 const cache = yield* ScopedCache.make({
1268 capacity: 10,
1269 lookup: (key: string) => Effect.succeed(key.length),
1270 timeToLive: "1 hour"
1271 })
1272
1273 yield* ScopedCache.get(cache, "a")
1274 yield* ScopedCache.get(cache, "b")
1275
1276 yield* TestClock.adjust("2 hours")
1277
1278 // Size still includes expired entries
1279 const sizeBefore = yield* ScopedCache.size(cache)
1280 assert.strictEqual(sizeBefore, 2)
1281
1282 // Accessing expired entries removes them
1283 yield* ScopedCache.has(cache, "a")
1284 const sizeAfter = yield* ScopedCache.size(cache)
1285 assert.strictEqual(sizeAfter, 1)
1286 }))
1287 })
1288
1289 describe("keys", () => {
1290 it.effect("returns all active keys", () =>
1291 Effect.gen(function*() {
1292 const cache = yield* ScopedCache.make({
1293 capacity: 10,
1294 lookup: (key: string) => Effect.succeed(key.length)
1295 })
1296
1297 yield* ScopedCache.get(cache, "a")
1298 yield* ScopedCache.get(cache, "b")
1299 yield* ScopedCache.get(cache, "c")
1300
1301 const keys = yield* ScopedCache.keys(cache)
1302
1303 assert.deepStrictEqual(keys.sort(), ["a", "b", "c"])
1304 }))
1305
1306 it.effect("excludes keys for expired entries", () =>
1307 Effect.gen(function*() {
1308 const cache = yield* ScopedCache.make({
1309 capacity: 10,
1310 lookup: (key: string) => Effect.succeed(key.length),
1311 timeToLive: "1 hour"
1312 })
1313
1314 yield* ScopedCache.get(cache, "a")
1315 yield* ScopedCache.get(cache, "b")
1316
1317 yield* TestClock.adjust("2 hours")
1318
1319 const keys = yield* ScopedCache.keys(cache)
1320
1321 assert.deepStrictEqual(keys.sort(), [])
1322 }))
1323
1324 it.effect("empty after invalidateAll", () =>
1325 Effect.gen(function*() {
1326 const cache = yield* ScopedCache.make({
1327 capacity: 10,
1328 lookup: (key: string) => Effect.succeed(key.length)
1329 })
1330
1331 yield* ScopedCache.get(cache, "a")
1332 yield* ScopedCache.get(cache, "b")
1333 yield* ScopedCache.invalidateAll(cache)
1334
1335 const keys = yield* ScopedCache.keys(cache)
1336
1337 assert.deepStrictEqual(keys, [])
1338 }))
1339
1340 it.effect("resource cleanup for expired entries as side effect", () =>
1341 Effect.gen(function*() {
1342 const { cache, cleanupTracker } = yield* Effect.gen(function*() {
1343 const cleanupTracker: CleanupTracker = {
1344 cleanedUp: [],
1345 acquired: []
1346 }
1347
1348 const cache = yield* ScopedCache.make({
1349 capacity: 10,
1350 lookup: (key: string) =>
1351 Effect.gen(function*() {
1352 yield* Effect.acquireRelease(
1353 Effect.sync(() => cleanupTracker.acquired.push(key)),
1354 () => Effect.sync(() => cleanupTracker.cleanedUp.push(key))
1355 )
1356 return `resource-${key}`
1357 }),
1358 timeToLive: "1 hour"
1359 })
1360
1361 return { cache, cleanupTracker }
1362 })
1363
1364 yield* ScopedCache.get(cache, "a")
1365 yield* ScopedCache.get(cache, "b")
1366 assert.strictEqual(cleanupTracker.cleanedUp.length, 0)
1367
1368 yield* TestClock.adjust("2 hours")
1369
1370 // keys() should clean up expired entries as side effect
1371 const keys = yield* ScopedCache.keys(cache)
1372 assert.deepStrictEqual(keys, [])
1373 assert.strictEqual(cleanupTracker.cleanedUp.length, 2)
1374 assert.deepStrictEqual(cleanupTracker.cleanedUp.sort(), ["a", "b"])
1375 }))
1376 })
1377
1378 describe("values", () => {
1379 it.effect("returns all successful cached values", () =>
1380 Effect.gen(function*() {
1381 const cache = yield* ScopedCache.make({
1382 capacity: 10,
1383 lookup: (key: string) => Effect.succeed(key.length),
1384 timeToLive: "1 hour"
1385 })
1386
1387 yield* ScopedCache.get(cache, "a")
1388 yield* ScopedCache.get(cache, "ab")
1389 yield* ScopedCache.get(cache, "abc")
1390
1391 const values = yield* ScopedCache.values(cache)
1392 const valuesArray = values.sort()
1393
1394 assert.deepStrictEqual(valuesArray, [1, 2, 3])
1395 }))
1396
1397 it.effect("doesn't include failed lookups", () =>
1398 Effect.gen(function*() {
1399 const cache = yield* ScopedCache.make({
1400 capacity: 10,
1401 lookup: (key: string) => key === "fail" ? Effect.fail("error") : Effect.succeed(key.length),
1402 timeToLive: "1 hour"
1403 })
1404
1405 yield* ScopedCache.get(cache, "a")
1406 yield* Effect.exit(ScopedCache.get(cache, "fail"))
1407 yield* ScopedCache.get(cache, "ab")
1408
1409 const values = yield* ScopedCache.values(cache)
1410 const valuesArray = values.sort()
1411
1412 assert.deepStrictEqual(valuesArray, [1, 2])
1413 }))
1414
1415 it.effect("doesn't include expired values", () =>
1416 Effect.gen(function*() {
1417 const cache = yield* ScopedCache.make({
1418 capacity: 10,
1419 lookup: (key: string) => Effect.succeed(key.length),
1420 timeToLive: "1 hour"
1421 })
1422
1423 yield* ScopedCache.get(cache, "a")
1424 yield* ScopedCache.get(cache, "b")
1425
1426 yield* TestClock.adjust("2 hours")
1427
1428 const values = yield* ScopedCache.values(cache)
1429
1430 assert.deepStrictEqual(values, [])
1431 }))
1432
1433 it.effect("resource cleanup for expired entries as side effect", () =>
1434 Effect.gen(function*() {
1435 const { cache, cleanupTracker } = yield* Effect.gen(function*() {
1436 const cleanupTracker: CleanupTracker = {
1437 cleanedUp: [],
1438 acquired: []
1439 }
1440
1441 const cache = yield* ScopedCache.make({
1442 capacity: 10,
1443 lookup: (key: string) =>
1444 Effect.gen(function*() {
1445 yield* Effect.acquireRelease(
1446 Effect.sync(() => cleanupTracker.acquired.push(key)),
1447 () => Effect.sync(() => cleanupTracker.cleanedUp.push(key))
1448 )
1449 return `resource-${key}`
1450 }),
1451 timeToLive: "1 hour"
1452 })
1453
1454 return { cache, cleanupTracker }
1455 })
1456
1457 yield* ScopedCache.get(cache, "a")
1458 yield* ScopedCache.get(cache, "b")
1459 assert.strictEqual(cleanupTracker.cleanedUp.length, 0)
1460
1461 yield* TestClock.adjust("2 hours")
1462
1463 // values() should clean up expired entries as side effect
1464 const values = yield* ScopedCache.values(cache)
1465 assert.deepStrictEqual(values, [])
1466 assert.strictEqual(cleanupTracker.cleanedUp.length, 2)
1467 assert.deepStrictEqual(cleanupTracker.cleanedUp.sort(), ["a", "b"])
1468 }))
1469 })
1470
1471 describe("entries", () => {
1472 it.effect("returns all key-value pairs", () =>
1473 Effect.gen(function*() {
1474 const cache = yield* ScopedCache.make({
1475 capacity: 10,
1476 lookup: (key: string) => Effect.succeed(key.length),
1477 timeToLive: "1 hour"
1478 })
1479
1480 yield* ScopedCache.get(cache, "a")
1481 yield* ScopedCache.get(cache, "ab")
1482 yield* ScopedCache.get(cache, "abc")
1483
1484 const entries = yield* ScopedCache.entries(cache)
1485 const entriesArray = entries.sort(([a], [b]) => a.localeCompare(b))
1486
1487 assert.deepStrictEqual(entriesArray, [["a", 1], ["ab", 2], ["abc", 3]])
1488 }))
1489
1490 it.effect("filters out failed lookups", () =>
1491 Effect.gen(function*() {
1492 const cache = yield* ScopedCache.make({
1493 capacity: 10,
1494 lookup: (key: string) => key === "fail" ? Effect.fail("error") : Effect.succeed(key.length),
1495 timeToLive: "1 hour"
1496 })
1497
1498 yield* ScopedCache.get(cache, "a")
1499 yield* Effect.exit(ScopedCache.get(cache, "fail"))
1500 yield* ScopedCache.get(cache, "ab")
1501
1502 const entries = yield* ScopedCache.entries(cache)
1503 const entriesArray = entries.sort(([a], [b]) => a.localeCompare(b))
1504
1505 assert.deepStrictEqual(entriesArray, [["a", 1], ["ab", 2]])
1506 }))
1507
1508 it.effect("filters out expired entries", () =>
1509 Effect.gen(function*() {
1510 const cache = yield* ScopedCache.make({
1511 capacity: 10,
1512 lookup: (key: string) => Effect.succeed(key.length),
1513 timeToLive: "1 hour"
1514 })
1515
1516 yield* ScopedCache.get(cache, "a")
1517 yield* TestClock.adjust("1 hour")
1518 yield* ScopedCache.get(cache, "b")
1519
1520 const entries = yield* ScopedCache.entries(cache)
1521
1522 assert.deepStrictEqual(entries, [["b", 1]])
1523 }))
1524
1525 it.effect("removes expired entries as side effect with resource cleanup", () =>
1526 Effect.gen(function*() {
1527 const { cache, cleanupTracker } = yield* Effect.gen(function*() {
1528 const cleanupTracker: CleanupTracker = {
1529 cleanedUp: [],
1530 acquired: []
1531 }
1532
1533 const cache = yield* ScopedCache.make({
1534 capacity: 10,
1535 lookup: (key: string) =>
1536 Effect.gen(function*() {
1537 yield* Effect.acquireRelease(
1538 Effect.sync(() => cleanupTracker.acquired.push(key)),
1539 () => Effect.sync(() => cleanupTracker.cleanedUp.push(key))
1540 )
1541 return `resource-${key}`
1542 }),
1543 timeToLive: "1 hour"
1544 })
1545
1546 return { cache, cleanupTracker }
1547 })
1548
1549 yield* ScopedCache.get(cache, "a")
1550 yield* ScopedCache.get(cache, "b")
1551
1552 yield* TestClock.adjust("2 hours")
1553
1554 const sizeBefore = yield* ScopedCache.size(cache)
1555 assert.strictEqual(sizeBefore, 2)
1556
1557 const entriesResult = yield* ScopedCache.entries(cache)
1558
1559 const sizeAfter = yield* ScopedCache.size(cache)
1560
1561 // entries() should remove expired entries as side effect
1562 assert.strictEqual(sizeAfter, 0)
1563 assert.strictEqual(entriesResult.length, 0)
1564 assert.strictEqual(cleanupTracker.cleanedUp.length, 2)
1565 assert.deepStrictEqual(cleanupTracker.cleanedUp.sort(), ["a", "b"])
1566 }))
1567 })
1568 })
1569
1570 describe("capacity management", () => {
1571 describe("LRU Eviction", () => {
1572 it.effect("oldest entries removed when capacity exceeded", () =>
1573 Effect.gen(function*() {
1574 const cache = yield* ScopedCache.make({
1575 capacity: 3,
1576 lookup: (key: string) => Effect.succeed(key.length)
1577 })
1578
1579 yield* ScopedCache.get(cache, "first")
1580 yield* ScopedCache.get(cache, "second")
1581 yield* ScopedCache.get(cache, "third")
1582
1583 // Check all entries are present
1584 assert.isTrue(yield* ScopedCache.has(cache, "first"))
1585 assert.isTrue(yield* ScopedCache.has(cache, "second"))
1586 assert.isTrue(yield* ScopedCache.has(cache, "third"))
1587
1588 // Add fourth entry, should evict first (oldest)
1589 yield* ScopedCache.get(cache, "fourth")
1590
1591 assert.isFalse(yield* ScopedCache.has(cache, "first"))
1592 assert.isTrue(yield* ScopedCache.has(cache, "second"))
1593 assert.isTrue(yield* ScopedCache.has(cache, "third"))
1594 assert.isTrue(yield* ScopedCache.has(cache, "fourth"))
1595 }))
1596
1597 it.effect("access order determines eviction", () =>
1598 Effect.gen(function*() {
1599 const cache = yield* ScopedCache.make({
1600 capacity: 3,
1601 lookup: (key: string) => Effect.succeed(key.length)
1602 })
1603
1604 yield* ScopedCache.get(cache, "a")
1605 yield* ScopedCache.get(cache, "b")
1606 yield* ScopedCache.get(cache, "c")
1607
1608 // Access "a" to make it most recently used
1609 yield* ScopedCache.get(cache, "a")
1610
1611 // Add "d" - should evict "b" (now oldest)
1612 yield* ScopedCache.get(cache, "d")
1613
1614 assert.isTrue(yield* ScopedCache.has(cache, "a"))
1615 assert.isFalse(yield* ScopedCache.has(cache, "b"))
1616 assert.isTrue(yield* ScopedCache.has(cache, "c"))
1617 assert.isTrue(yield* ScopedCache.has(cache, "d"))
1618 }))
1619
1620 it.effect("capacity of 1", () =>
1621 Effect.gen(function*() {
1622 const cache = yield* ScopedCache.make({
1623 capacity: 1,
1624 lookup: (key: string) => Effect.succeed(key.length)
1625 })
1626
1627 yield* ScopedCache.get(cache, "a")
1628 assert.isTrue(yield* ScopedCache.has(cache, "a"))
1629
1630 yield* ScopedCache.get(cache, "b")
1631 assert.isFalse(yield* ScopedCache.has(cache, "a"))
1632 assert.isTrue(yield* ScopedCache.has(cache, "b"))
1633
1634 yield* ScopedCache.get(cache, "c")
1635 assert.isFalse(yield* ScopedCache.has(cache, "b"))
1636 assert.isTrue(yield* ScopedCache.has(cache, "c"))
1637 }))
1638
1639 it.effect("very large capacity", () =>
1640 Effect.gen(function*() {
1641 const cache = yield* ScopedCache.make({
1642 capacity: 10000,
1643 lookup: (key: string) => Effect.succeed(key.length)
1644 })
1645
1646 // Add many entries
1647 for (let i = 0; i < 100; i++) {
1648 yield* ScopedCache.get(cache, `key-${i}`)
1649 }
1650
1651 // All should still be present
1652 for (let i = 0; i < 100; i++) {
1653 assert.isTrue(yield* ScopedCache.has(cache, `key-${i}`))
1654 }
1655
1656 const size = yield* ScopedCache.size(cache)
1657 assert.strictEqual(size, 100)
1658 }))
1659
1660 it.effect("resource cleanup for evicted entries", () =>
1661 Effect.gen(function*() {
1662 const { cache, cleanupTracker } = yield* makeManagedResourceCache(2)
1663
1664 yield* ScopedCache.get(cache, "a")
1665 yield* ScopedCache.get(cache, "b")
1666 assert.deepStrictEqual(cleanupTracker.cleanedUp, [])
1667
1668 // Adding third entry should evict first and clean up its resource
1669 yield* ScopedCache.get(cache, "c")
1670 assert.deepStrictEqual(cleanupTracker.cleanedUp, ["a"])
1671
1672 // Adding fourth entry should evict second
1673 yield* ScopedCache.get(cache, "d")
1674 assert.deepStrictEqual(cleanupTracker.cleanedUp, ["a", "b"])
1675 }))
1676 })
1677 })
1678
1679 describe("TTL (Time To Live) Testing", () => {
1680 describe("Fixed TTL", () => {
1681 it.effect("entry expires after specified duration", () =>
1682 Effect.gen(function*() {
1683 const cache = yield* ScopedCache.make({
1684 capacity: 10,
1685 lookup: (key: string) => Effect.succeed(key.length),
1686 timeToLive: "2 hours"
1687 })
1688
1689 yield* ScopedCache.get(cache, "test")
1690
1691 yield* TestClock.adjust("1 hour")
1692 assert.isTrue(yield* ScopedCache.has(cache, "test"))
1693
1694 yield* TestClock.adjust(Duration.sum(Duration.hours(1), Duration.seconds(1)))
1695 assert.isFalse(yield* ScopedCache.has(cache, "test"))
1696 }))
1697
1698 it.effect("get after expiration invokes lookup", () =>
1699 Effect.gen(function*() {
1700 let lookupCount = 0
1701 const cache = yield* ScopedCache.make({
1702 capacity: 10,
1703 lookup: (_key: string) => Effect.sync(() => ++lookupCount),
1704 timeToLive: "1 hour"
1705 })
1706
1707 const result1 = yield* ScopedCache.get(cache, "test")
1708 assert.strictEqual(result1, 1)
1709 assert.strictEqual(lookupCount, 1)
1710
1711 yield* TestClock.adjust("2 hours")
1712
1713 const result2 = yield* ScopedCache.get(cache, "test")
1714 assert.strictEqual(result2, 2)
1715 assert.strictEqual(lookupCount, 2)
1716 }))
1717
1718 it.effect("has returns false for expired entries", () =>
1719 Effect.gen(function*() {
1720 const cache = yield* ScopedCache.make({
1721 capacity: 10,
1722 lookup: (key: string) => Effect.succeed(key.length),
1723 timeToLive: "1 hour"
1724 })
1725
1726 yield* ScopedCache.get(cache, "test")
1727 assert.isTrue(yield* ScopedCache.has(cache, "test"))
1728
1729 yield* TestClock.adjust("2 hours")
1730 assert.isFalse(yield* ScopedCache.has(cache, "test"))
1731 }))
1732
1733 it.effect("resource cleanup for expired entries", () =>
1734 Effect.gen(function*() {
1735 const { cache, cleanupTracker } = yield* Effect.gen(function*() {
1736 const cleanupTracker: CleanupTracker = {
1737 cleanedUp: [],
1738 acquired: []
1739 }
1740
1741 const cache = yield* ScopedCache.make({
1742 capacity: 10,
1743 lookup: (key: string) =>
1744 Effect.gen(function*() {
1745 yield* Effect.acquireRelease(
1746 Effect.sync(() => cleanupTracker.acquired.push(key)),
1747 () => Effect.sync(() => cleanupTracker.cleanedUp.push(key))
1748 )
1749 return `resource-${key}`
1750 }),
1751 timeToLive: "1 hour"
1752 })
1753
1754 return { cache, cleanupTracker }
1755 })
1756
1757 yield* ScopedCache.get(cache, "test")
1758 assert.deepStrictEqual(cleanupTracker.cleanedUp, [])
1759
1760 yield* TestClock.adjust("2 hours")
1761
1762 // Accessing expired entry should trigger cleanup
1763 yield* ScopedCache.has(cache, "test")
1764 assert.deepStrictEqual(cleanupTracker.cleanedUp, ["test"])
1765 }))
1766 })
1767
1768 describe("Function-based TTL", () => {
1769 it.effect("different TTL for success vs failure", () =>
1770 Effect.gen(function*() {
1771 const cache = yield* ScopedCache.makeWith({
1772 capacity: 10,
1773 lookup: (key: string) => key === "fail" ? Effect.fail("error") : Effect.succeed(key.length),
1774 timeToLive: (exit) => Exit.isSuccess(exit) ? "2 hours" : "30 minutes"
1775 })
1776
1777 yield* ScopedCache.get(cache, "success")
1778 yield* Effect.exit(ScopedCache.get(cache, "fail"))
1779
1780 yield* TestClock.adjust("1 hour")
1781 assert.isTrue(yield* ScopedCache.has(cache, "success"))
1782 assert.isFalse(yield* ScopedCache.has(cache, "fail"))
1783
1784 yield* TestClock.adjust("2 hours")
1785 assert.isFalse(yield* ScopedCache.has(cache, "success"))
1786 }))
1787
1788 it.effect("TTL based on key", () =>
1789 Effect.gen(function*() {
1790 const cache = yield* ScopedCache.makeWith({
1791 capacity: 10,
1792 lookup: (key: string) => Effect.succeed(key.length),
1793 timeToLive: (_exit, key) => {
1794 if (key.startsWith("short-")) return "1 minute"
1795 if (key.startsWith("medium-")) return "1 hour"
1796 return "1 day"
1797 }
1798 })
1799
1800 yield* ScopedCache.get(cache, "short-item")
1801 yield* ScopedCache.get(cache, "medium-item")
1802 yield* ScopedCache.get(cache, "long-item")
1803
1804 yield* TestClock.adjust("2 minutes")
1805 assert.isFalse(yield* ScopedCache.has(cache, "short-item"))
1806 assert.isTrue(yield* ScopedCache.has(cache, "medium-item"))
1807 assert.isTrue(yield* ScopedCache.has(cache, "long-item"))
1808
1809 yield* TestClock.adjust("2 hours")
1810 assert.isFalse(yield* ScopedCache.has(cache, "medium-item"))
1811 assert.isTrue(yield* ScopedCache.has(cache, "long-item"))
1812 }))
1813
1814 it.effect("TTL based on value", () =>
1815 Effect.gen(function*() {
1816 const cache = yield* ScopedCache.makeWith({
1817 capacity: 10,
1818 lookup: (key: string) => Effect.succeed(key.length),
1819 timeToLive: (exit) => {
1820 if (Exit.isSuccess(exit)) {
1821 const value = exit.value
1822 return value > 5 ? "1 hour" : "1 minute"
1823 }
1824 return Duration.infinity
1825 }
1826 })
1827
1828 yield* ScopedCache.get(cache, "abc") // length 3
1829 yield* ScopedCache.get(cache, "longkey") // length 7
1830
1831 yield* TestClock.adjust("2 minutes")
1832 assert.isFalse(yield* ScopedCache.has(cache, "abc"))
1833 assert.isTrue(yield* ScopedCache.has(cache, "longkey"))
1834 }))
1835
1836 it.effect("infinite TTL", () =>
1837 Effect.gen(function*() {
1838 const cache = yield* ScopedCache.makeWith({
1839 capacity: 10,
1840 lookup: (key: string) => Effect.succeed(key.length),
1841 timeToLive: () => Duration.infinity
1842 })
1843
1844 yield* ScopedCache.get(cache, "test")
1845 yield* TestClock.adjust(Duration.days(365))
1846 assert.isTrue(yield* ScopedCache.has(cache, "test"))
1847 }))
1848
1849 it.effect("zero duration TTL", () =>
1850 Effect.gen(function*() {
1851 const cache = yield* ScopedCache.makeWith({
1852 capacity: 10,
1853 lookup: (key: string) => Effect.succeed(key.length),
1854 timeToLive: () => Duration.zero
1855 })
1856
1857 yield* ScopedCache.get(cache, "test")
1858 // Should expire immediately
1859 assert.isFalse(yield* ScopedCache.has(cache, "test"))
1860 }))
1861
1862 it.effect("TTL updates on refresh", () =>
1863 Effect.gen(function*() {
1864 let refreshCount = 0
1865 const cache = yield* ScopedCache.makeWith({
1866 capacity: 10,
1867 lookup: (_key: string) => Effect.sync(() => ++refreshCount),
1868 timeToLive: (exit) => {
1869 if (Exit.isSuccess(exit)) {
1870 // First lookup gets 1 hour, refresh gets 2 hours
1871 return refreshCount === 1 ? "1 hour" : "2 hours"
1872 }
1873 return Duration.infinity
1874 }
1875 })
1876
1877 yield* ScopedCache.get(cache, "test")
1878 yield* TestClock.adjust("30 minutes")
1879 assert.isTrue(yield* ScopedCache.has(cache, "test"))
1880
1881 // Refresh should update TTL
1882 yield* ScopedCache.refresh(cache, "test")
1883
1884 yield* TestClock.adjust("1 hour")
1885 assert.isTrue(yield* ScopedCache.has(cache, "test")) // Still alive due to 2 hour TTL
1886
1887 yield* TestClock.adjust("1 hours")
1888 assert.isFalse(yield* ScopedCache.has(cache, "test"))
1889 }))
1890 })
1891 })
1892
1893 describe("error scenarios", () => {
1894 describe("Failed Lookups", () => {
1895 it.effect("settles concurrent gets when TTL calculation throws after an asynchronous lookup", () =>
1896 Effect.gen(function*() {
1897 const started = yield* Latch.make()
1898 const finish = yield* Latch.make()
1899 const cache = yield* ScopedCache.makeWith({
1900 capacity: 10,
1901 lookup: (_key: string) => Effect.andThen(started.open, Effect.as(finish.await, 42)),
1902 timeToLive: () => {
1903 throw new Error("TTL defect")
1904 }
1905 })
1906
1907 const first = yield* ScopedCache.get(cache, "test").pipe(
1908 Effect.exit,
1909 Effect.timeoutOption("1 second"),
1910 Effect.forkChild({ startImmediately: true })
1911 )
1912 yield* started.await
1913 const second = yield* ScopedCache.get(cache, "test").pipe(
1914 Effect.exit,
1915 Effect.timeoutOption("1 second"),
1916 Effect.forkChild({ startImmediately: true })
1917 )
1918 yield* Effect.yieldNow
1919 yield* finish.open
1920 yield* TestClock.adjust("1 second")
1921
1922 assert.deepStrictEqual(yield* Fiber.join(first), Option.some(Exit.succeed(42)))
1923 assert.deepStrictEqual(yield* Fiber.join(second), Option.some(Exit.succeed(42)))
1924 }))
1925
1926 it.effect("does not cache an asynchronous failure with zero TTL", () =>
1927 Effect.gen(function*() {
1928 let lookupCount = 0
1929 const cache = yield* ScopedCache.makeWith({
1930 capacity: 10,
1931 lookup: (_key: string) =>
1932 Effect.promise(() => Promise.resolve()).pipe(
1933 Effect.andThen(Effect.sync(() => ++lookupCount)),
1934 Effect.flatMap((count) => count === 1 ? Effect.fail("error") : Effect.succeed(count))
1935 ),
1936 timeToLive: (exit) => Exit.isFailure(exit) ? Duration.zero : Duration.infinity
1937 })
1938
1939 assert.deepStrictEqual(yield* Effect.exit(ScopedCache.get(cache, "test")), Exit.fail("error"))
1940 assert.deepStrictEqual(yield* Effect.exit(ScopedCache.get(cache, "test")), Exit.succeed(2))
1941 assert.strictEqual(lookupCount, 2)
1942 }))
1943
1944 it.effect("failed lookup caches the failure", () =>
1945 Effect.gen(function*() {
1946 let lookupCount = 0
1947 const cache = yield* ScopedCache.make({
1948 capacity: 10,
1949 lookup: (_key: string) => Effect.sync(() => ++lookupCount).pipe(Effect.flatMap(() => Effect.fail("error")))
1950 })
1951
1952 const result1 = yield* Effect.exit(ScopedCache.get(cache, "test"))
1953 const result2 = yield* Effect.exit(ScopedCache.get(cache, "test"))
1954
1955 assert.deepStrictEqual(result1, Exit.fail("error"))
1956 assert.deepStrictEqual(result2, Exit.fail("error"))
1957 assert.strictEqual(lookupCount, 1) // Only called once
1958 }))
1959
1960 it.effect("subsequent gets return same failure", () =>
1961 Effect.gen(function*() {
1962 const cache = yield* ScopedCache.make({
1963 capacity: 10,
1964 lookup: (_key: string) => Effect.fail("lookup failed")
1965 })
1966
1967 const results = yield* Effect.all([
1968 Effect.exit(ScopedCache.get(cache, "test")),
1969 Effect.exit(ScopedCache.get(cache, "test")),
1970 Effect.exit(ScopedCache.get(cache, "test"))
1971 ])
1972
1973 assert.deepStrictEqual(results, [
1974 Exit.fail("lookup failed"),
1975 Exit.fail("lookup failed"),
1976 Exit.fail("lookup failed")
1977 ])
1978 }))
1979
1980 it.effect("can refresh after failure", () =>
1981 Effect.gen(function*() {
1982 let shouldFail = true
1983 const cache = yield* ScopedCache.make({
1984 capacity: 10,
1985 lookup: (_key: string) => shouldFail ? Effect.fail("error") : Effect.succeed(42)
1986 })
1987
1988 const failResult = yield* Effect.exit(ScopedCache.get(cache, "test"))
1989 assert.isTrue(Exit.isFailure(failResult))
1990
1991 shouldFail = false
1992 const successResult = yield* ScopedCache.refresh(cache, "test")
1993 assert.strictEqual(successResult, 42)
1994 }))
1995
1996 it.effect("multiple fibers encountering same error", () =>
1997 Effect.gen(function*() {
1998 let lookupCount = 0
1999 const cache = yield* ScopedCache.make({
2000 capacity: 10,
2001 lookup: () =>
2002 Effect.delay(Duration.millis(50))(
2003 Effect.sync(() => ++lookupCount).pipe(Effect.flatMap(() => Effect.fail("error")))
2004 )
2005 })
2006
2007 const fibers = yield* Effect.all([
2008 ScopedCache.get(cache, "test").pipe(Effect.exit, Effect.forkChild),
2009 ScopedCache.get(cache, "test").pipe(Effect.exit, Effect.forkChild),
2010 ScopedCache.get(cache, "test").pipe(Effect.exit, Effect.forkChild)
2011 ])
2012
2013 yield* TestClock.adjust(Duration.millis(100))
2014
2015 const results = yield* Effect.all(fibers.map(Fiber.join))
2016
2017 assert.strictEqual(lookupCount, 1)
2018 assert.deepStrictEqual(results, [
2019 Exit.fail("error"),
2020 Exit.fail("error"),
2021 Exit.fail("error")
2022 ])
2023 }))
2024
2025 it.effect("resource cleanup for failed entries", () =>
2026 Effect.gen(function*() {
2027 const resourceTracker: Array<string> = []
2028
2029 const cache = yield* ScopedCache.make({
2030 capacity: 2,
2031 lookup: (key: string) =>
2032 Effect.gen(function*() {
2033 yield* Effect.acquireRelease(
2034 Effect.sync(() => resourceTracker.push(`acquired-${key}`)),
2035 () => Effect.sync(() => resourceTracker.push(`released-${key}`))
2036 )
2037 return yield* Effect.fail(`error-${key}`)
2038 })
2039 })
2040
2041 yield* Effect.exit(ScopedCache.get(cache, "fail1"))
2042 yield* Effect.exit(ScopedCache.get(cache, "fail2"))
2043 assert.deepStrictEqual(resourceTracker, ["acquired-fail1", "acquired-fail2"])
2044
2045 // Adding third failed entry should evict first
2046 yield* Effect.exit(ScopedCache.get(cache, "fail3"))
2047 assert.deepStrictEqual(resourceTracker, [
2048 "acquired-fail1",
2049 "acquired-fail2",
2050 "released-fail1",
2051 "acquired-fail3"
2052 ])
2053 }))
2054 })
2055 })
2056
2057 describe("scope integration", () => {
2058 describe("resource management", () => {
2059 it.effect("resources are properly scoped to cache entries", () =>
2060 Effect.gen(function*() {
2061 const resourceTracker: Array<string> = []
2062
2063 const cache = yield* ScopedCache.make({
2064 capacity: 10,
2065 lookup: Effect.fnUntraced(function*(key: string) {
2066 yield* Effect.acquireRelease(
2067 Effect.sync(() => resourceTracker.push(`acquired-${key}`)),
2068 () => Effect.sync(() => resourceTracker.push(`released-${key}`))
2069 )
2070 return `value-${key}`
2071 })
2072 })
2073
2074 yield* ScopedCache.get(cache, "test")
2075 assert.deepStrictEqual(resourceTracker, ["acquired-test"])
2076
2077 // Resource is still alive while cached
2078 yield* ScopedCache.get(cache, "test")
2079 assert.deepStrictEqual(resourceTracker, ["acquired-test"])
2080 }))
2081
2082 it.effect("resources are cleaned up when entries are evicted", () =>
2083 Effect.gen(function*() {
2084 const resourceTracker: Array<string> = []
2085
2086 const cache = yield* ScopedCache.make({
2087 capacity: 2,
2088 lookup: (key: string) =>
2089 Effect.gen(function*() {
2090 yield* Effect.acquireRelease(
2091 Effect.sync(() => resourceTracker.push(`acquired-${key}`)),
2092 () => Effect.sync(() => resourceTracker.push(`released-${key}`))
2093 )
2094 return `value-${key}`
2095 })
2096 })
2097
2098 yield* ScopedCache.get(cache, "a")
2099 yield* ScopedCache.get(cache, "b")
2100 assert.deepStrictEqual(resourceTracker, ["acquired-a", "acquired-b"])
2101
2102 // Adding third entry should evict first and clean up its resource
2103 yield* ScopedCache.get(cache, "c")
2104 assert.deepStrictEqual(resourceTracker, ["acquired-a", "acquired-b", "released-a", "acquired-c"])
2105 }))
2106
2107 it.effect("resources are cleaned up when cache is closed", () =>
2108 Effect.gen(function*() {
2109 const resourceTracker: Array<string> = []
2110 const cache = yield* Effect.scoped(
2111 Effect.gen(function*() {
2112 const cache = yield* ScopedCache.make({
2113 capacity: 10,
2114 lookup: (key: string) =>
2115 Effect.gen(function*() {
2116 yield* Effect.acquireRelease(
2117 Effect.sync(() => resourceTracker.push(`acquired-${key}`)),
2118 () => Effect.sync(() => resourceTracker.push(`released-${key}`))
2119 )
2120 return `value-${key}`
2121 })
2122 })
2123
2124 yield* ScopedCache.get(cache, "test1")
2125 yield* ScopedCache.get(cache, "test2")
2126 assert.deepStrictEqual(resourceTracker, ["acquired-test1", "acquired-test2"])
2127
2128 return cache
2129 })
2130 )
2131
2132 // After scope closes, all resources should be cleaned up
2133 assert.strictEqual(cache.state._tag, "Closed")
2134 assert.deepStrictEqual(resourceTracker.sort(), [
2135 "acquired-test1",
2136 "acquired-test2",
2137 "released-test1",
2138 "released-test2"
2139 ])
2140 }))
2141
2142 it.effect("resources are cleaned up when entries expire", () =>
2143 Effect.gen(function*() {
2144 const resourceTracker: Array<string> = []
2145
2146 const cache = yield* ScopedCache.make({
2147 capacity: 10,
2148 lookup: (key: string) =>
2149 Effect.gen(function*() {
2150 yield* Effect.acquireRelease(
2151 Effect.sync(() => resourceTracker.push(`acquired-${key}`)),
2152 () => Effect.sync(() => resourceTracker.push(`released-${key}`))
2153 )
2154 return `value-${key}`
2155 }),
2156 timeToLive: "1 hour"
2157 })
2158
2159 yield* ScopedCache.get(cache, "test")
2160 assert.deepStrictEqual(resourceTracker, ["acquired-test"])
2161
2162 yield* TestClock.adjust("2 hours")
2163
2164 // Accessing expired entry should clean up resource
2165 yield* ScopedCache.has(cache, "test")
2166 assert.deepStrictEqual(resourceTracker, ["acquired-test", "released-test"])
2167 }))
2168
2169 it.effect("resources are cleaned up when entries are invalidated", () =>
2170 Effect.gen(function*() {
2171 const resourceTracker: Array<string> = []
2172
2173 const cache = yield* ScopedCache.make({
2174 capacity: 10,
2175 lookup: (key: string) =>
2176 Effect.gen(function*() {
2177 yield* Effect.acquireRelease(
2178 Effect.sync(() => resourceTracker.push(`acquired-${key}`)),
2179 () => Effect.sync(() => resourceTracker.push(`released-${key}`))
2180 )
2181 return `value-${key}`
2182 })
2183 })
2184
2185 yield* ScopedCache.get(cache, "test")
2186 assert.deepStrictEqual(resourceTracker, ["acquired-test"])
2187
2188 yield* ScopedCache.invalidate(cache, "test")
2189 assert.deepStrictEqual(resourceTracker, ["acquired-test", "released-test"])
2190 }))
2191
2192 it.effect("resource cleanup is atomic and doesn't leak", () =>
2193 Effect.gen(function*() {
2194 const resourceTracker: Array<string> = []
2195 const cache = yield* ScopedCache.make({
2196 capacity: 3,
2197 lookup: (key: string) =>
2198 Effect.gen(function*() {
2199 yield* Effect.acquireRelease(
2200 Effect.sync(() => resourceTracker.push(`acquired-${key}`)),
2201 () => Effect.sync(() => resourceTracker.push(`released-${key}`))
2202 )
2203 return `value-${key}`
2204 })
2205 })
2206
2207 // Fill cache
2208 yield* ScopedCache.get(cache, "a")
2209 yield* ScopedCache.get(cache, "b")
2210 yield* ScopedCache.get(cache, "c")
2211
2212 // Multiple operations that should trigger cleanup
2213 yield* Effect.all([
2214 ScopedCache.invalidate(cache, "a"),
2215 ScopedCache.refresh(cache, "b"),
2216 ScopedCache.set(cache, "c", "new-value"),
2217 ScopedCache.get(cache, "d")
2218 ], { concurrency: "unbounded" })
2219
2220 // Verify all resources were properly acquired and released
2221 const acquired = resourceTracker.filter((r) => r.startsWith("acquired")).length
2222 const released = resourceTracker.filter((r) => r.startsWith("released")).length
2223
2224 assert.strictEqual(acquired, 5)
2225 assert.strictEqual(released, 3)
2226 assert.strictEqual(yield* ScopedCache.size(cache), 3)
2227 }))
2228 })
2229
2230 describe("scope lifecycle", () => {
2231 it.effect("cache becomes closed when parent scope is closed", () =>
2232 Effect.gen(function*() {
2233 const cache = yield* Effect.scoped(
2234 Effect.gen(function*() {
2235 const cache = yield* ScopedCache.make({
2236 capacity: 10,
2237 lookup: (key: string) => Effect.succeed(key.length)
2238 })
2239 assert.strictEqual(cache.state._tag, "Open")
2240 yield* ScopedCache.get(cache, "test")
2241 return cache
2242 })
2243 )
2244
2245 // After scope closure, cache should be closed
2246 assert.strictEqual(cache.state._tag, "Closed")
2247 }))
2248
2249 it.effect("resource cleanup happens in correct order during cache closure", () =>
2250 Effect.gen(function*() {
2251 const cleanupOrder: Array<string> = []
2252
2253 yield* Effect.scoped(
2254 Effect.gen(function*() {
2255 const cache = yield* ScopedCache.make({
2256 capacity: 10,
2257 lookup: (key: string) =>
2258 Effect.gen(function*() {
2259 yield* Effect.acquireRelease(
2260 Effect.sync(() => cleanupOrder.push(`acquired-${key}`)),
2261 () => Effect.sync(() => cleanupOrder.push(`released-${key}`))
2262 )
2263 return `value-${key}`
2264 })
2265 })
2266
2267 yield* ScopedCache.get(cache, "first")
2268 yield* ScopedCache.get(cache, "second")
2269 yield* ScopedCache.get(cache, "third")
2270
2271 assert.deepStrictEqual(cleanupOrder, ["acquired-first", "acquired-second", "acquired-third"])
2272 })
2273 )
2274
2275 // All resources should be cleaned up after scope closure
2276 const releases = cleanupOrder.filter((item) => item.startsWith("released"))
2277 assert.strictEqual(releases.length, 3)
2278 assert.isTrue(releases.includes("released-first"))
2279 assert.isTrue(releases.includes("released-second"))
2280 assert.isTrue(releases.includes("released-third"))
2281 }))
2282 })
2283 })
2284
2285 // TODO: Service context tests are commented out due to TypeScript issues with Context
2286 // These tests are failing due to complex service dependency injection features
2287 // that may not be fully implemented in ScopedCache
2288 describe("service context", () => {
2289 describe("Service Dependency Injection", () => {
2290 it.effect("services are available in lookup functions", () =>
2291 Effect.gen(function*() {
2292 class ConfigService extends Context.Service<ConfigService, { multiplier: number }>()("ConfigService") {}
2293
2294 const cache = yield* ScopedCache.make({
2295 capacity: 10,
2296 lookup: (key: string) =>
2297 Effect.gen(function*() {
2298 const config = yield* ConfigService
2299 return key.length * config.multiplier
2300 })
2301 }).pipe(
2302 Effect.provideService(ConfigService, ConfigService.of({ multiplier: 10 }))
2303 )
2304
2305 const result = yield* ScopedCache.get(cache, "test")
2306
2307 assert.strictEqual(result, 40) // "test".length * 10
2308 }))
2309
2310 it.effect("requireServicesAt: 'lookup' provides services at lookup time", () =>
2311 Effect.gen(function*() {
2312 class CounterService extends Context.Service<CounterService, { value: number }>()("CounterService") {}
2313
2314 const cache = yield* ScopedCache.make({
2315 capacity: 10,
2316 lookup: (_key: string) => Effect.map(CounterService, (service) => service.value),
2317 requireServicesAt: "lookup"
2318 })
2319
2320 const result1 = yield* ScopedCache.get(cache, "test").pipe(
2321 Effect.provideService(CounterService, CounterService.of({ value: 42 }))
2322 )
2323
2324 const result2 = yield* ScopedCache.get(cache, "another").pipe(
2325 Effect.provideService(CounterService, CounterService.of({ value: 100 }))
2326 )
2327
2328 // Same key with different service context
2329 const result3 = yield* ScopedCache.get(cache, "test").pipe(
2330 Effect.provideService(CounterService, CounterService.of({ value: 200 }))
2331 )
2332
2333 assert.strictEqual(result1, 42)
2334 assert.strictEqual(result2, 100)
2335 assert.strictEqual(result3, 42) // Cached value from first lookup
2336 }))
2337 })
2338 })
2339
2340 describe("integration tests", () => {
2341 describe("TestClock Integration", () => {
2342 it.effect("multiple time advances work correctly", () =>
2343 Effect.gen(function*() {
2344 const cache = yield* ScopedCache.make({
2345 capacity: 10,
2346 lookup: (key: string) => Effect.succeed(key.length),
2347 timeToLive: "1 hour"
2348 })
2349
2350 yield* ScopedCache.get(cache, "test")
2351
2352 // Multiple small advances
2353 for (let i = 0; i < 10; i++) {
2354 yield* TestClock.adjust("5 minutes")
2355 assert.isTrue(yield* ScopedCache.has(cache, "test"))
2356 }
2357
2358 // Final advance to expire
2359 yield* TestClock.adjust("15 minutes")
2360 assert.isFalse(yield* ScopedCache.has(cache, "test"))
2361 }))
2362
2363 it.effect("resource cleanup timing is correct", () =>
2364 Effect.gen(function*() {
2365 const cleanupTimes: Array<{ key: string; time: number }> = []
2366
2367 const cache = yield* ScopedCache.make({
2368 capacity: 10,
2369 lookup: (key: string) =>
2370 Effect.gen(function*() {
2371 yield* Effect.acquireRelease(
2372 Effect.void,
2373 () =>
2374 Effect.gen(function*() {
2375 const time = yield* Clock.currentTimeMillis
2376 cleanupTimes.push({ key, time })
2377 })
2378 )
2379 return key.length
2380 }),
2381 timeToLive: "1 hour"
2382 })
2383
2384 const startTime = yield* Clock.currentTimeMillis
2385
2386 yield* ScopedCache.get(cache, "a")
2387 yield* TestClock.adjust("30 minutes")
2388 yield* ScopedCache.get(cache, "b")
2389
2390 yield* TestClock.adjust("35 minutes") // "a" should expire
2391 yield* ScopedCache.has(cache, "a") // Trigger cleanup
2392
2393 yield* TestClock.adjust("30 minutes") // "b" should expire
2394 yield* ScopedCache.has(cache, "b") // Trigger cleanup
2395
2396 assert.strictEqual(cleanupTimes.length, 2)
2397 assert.strictEqual(cleanupTimes[0].key, "a")
2398 assert.strictEqual(cleanupTimes[0].time, startTime + 65 * 60 * 1000)
2399 assert.strictEqual(cleanupTimes[1].key, "b")
2400 assert.strictEqual(cleanupTimes[1].time, startTime + 95 * 60 * 1000)
2401 }))
2402 })
2403
2404 describe("Different Key Types", () => {
2405 it.effect("string keys work correctly", () =>
2406 Effect.gen(function*() {
2407 const cache = yield* ScopedCache.make({
2408 capacity: 10,
2409 lookup: (key: string) => Effect.succeed(key.toUpperCase())
2410 })
2411
2412 yield* ScopedCache.set(cache, "hello", "HELLO!")
2413 const result1 = yield* ScopedCache.get(cache, "hello")
2414 const result2 = yield* ScopedCache.get(cache, "world")
2415
2416 assert.strictEqual(result1, "HELLO!")
2417 assert.strictEqual(result2, "WORLD")
2418 }))
2419
2420 it.effect("number keys work correctly", () =>
2421 Effect.gen(function*() {
2422 const cache = yield* ScopedCache.make({
2423 capacity: 10,
2424 lookup: (key: number) => Effect.succeed(key * key)
2425 })
2426
2427 yield* ScopedCache.set(cache, 5, 25)
2428 const result1 = yield* ScopedCache.get(cache, 5)
2429 const result2 = yield* ScopedCache.get(cache, 7)
2430
2431 assert.strictEqual(result1, 25)
2432 assert.strictEqual(result2, 49)
2433 }))
2434
2435 it.effect("complex object keys work correctly", () =>
2436 Effect.gen(function*() {
2437 class CacheKey extends Data.Class<{
2438 userId: string
2439 productId: number
2440 }> {}
2441
2442 const cache = yield* ScopedCache.make({
2443 capacity: 10,
2444 lookup: (key: CacheKey) => Effect.succeed(`${key.userId}-${key.productId}`)
2445 })
2446
2447 const key1 = new CacheKey({ userId: "user1", productId: 100 })
2448 const key2 = new CacheKey({ userId: "user2", productId: 200 })
2449
2450 const result1 = yield* ScopedCache.get(cache, key1)
2451 const result2 = yield* ScopedCache.get(cache, key2)
2452
2453 assert.strictEqual(result1, "user1-100")
2454 assert.strictEqual(result2, "user2-200")
2455
2456 const key1Copy = new CacheKey({ userId: "user1", productId: 100 })
2457 const hasCopy = yield* ScopedCache.has(cache, key1Copy)
2458 assert.isTrue(hasCopy)
2459 }))
2460 })
2461 })
2462
2463 describe("concurrency tests", () => {
2464 it.effect("a get made while an abandoned lookup is finalizing starts a new lookup", () =>
2465 Effect.gen(function*() {
2466 let lookups = 0
2467 const started = yield* Latch.make()
2468 const finalizing = yield* Latch.make()
2469 const finishFinalizer = yield* Latch.make()
2470 const cache = yield* ScopedCache.make({
2471 capacity: 10,
2472 lookup: (_key: string) =>
2473 Effect.suspend(() => {
2474 if (++lookups > 1) return Effect.succeed(lookups)
2475 return started.open.pipe(
2476 Effect.andThen(Effect.never),
2477 Effect.onInterrupt(() => finalizing.open.pipe(Effect.andThen(finishFinalizer.await)))
2478 )
2479 })
2480 })
2481
2482 const first = yield* ScopedCache.get(cache, "key").pipe(Effect.forkChild({ startImmediately: true }))
2483 yield* started.await
2484 const interruptFirst = yield* Fiber.interrupt(first).pipe(Effect.forkChild({ startImmediately: true }))
2485 yield* finalizing.await
2486 const fresh = yield* ScopedCache.get(cache, "key").pipe(Effect.forkChild({ startImmediately: true }))
2487 yield* finishFinalizer.open
2488 yield* Fiber.join(interruptFirst)
2489
2490 assert.deepStrictEqual(yield* Fiber.await(fresh), Exit.succeed(2))
2491 assert.strictEqual(yield* ScopedCache.get(cache, "key"), 2)
2492 assert.strictEqual(lookups, 2)
2493 }))
2494
2495 it.effect("a getOption waiter arriving during eviction keeps the lookup alive when the first caller leaves", () =>
2496 Effect.gen(function*() {
2497 const closing = yield* Latch.make()
2498 const close = yield* Latch.make()
2499 const started = yield* Latch.make()
2500 const finish = yield* Latch.make()
2501 const cache = yield* ScopedCache.make({
2502 capacity: 1,
2503 lookup: (key: string) =>
2504 Effect.gen(function*() {
2505 if (key === "a") {
2506 yield* Effect.acquireRelease(Effect.void, () => closing.open.pipe(Effect.andThen(close.await)))
2507 return 1
2508 }
2509 yield* started.open
2510 yield* finish.await
2511 return 42
2512 })
2513 })
2514
2515 yield* ScopedCache.get(cache, "a")
2516 const first = yield* ScopedCache.get(cache, "b").pipe(Effect.forkChild({ startImmediately: true }))
2517 yield* closing.await
2518 const second = yield* ScopedCache.getOption(cache, "b").pipe(Effect.forkChild({ startImmediately: true }))
2519 yield* close.open
2520 yield* started.await
2521 yield* Effect.yieldNow
2522 yield* Fiber.interrupt(first)
2523 yield* finish.open
2524
2525 assert.deepStrictEqual(yield* Fiber.await(second), Exit.succeed(Option.some(42)))
2526 assert.strictEqual(yield* ScopedCache.get(cache, "b"), 42)
2527 }))
2528
2529 it.effect("interrupting the only caller during eviction releases its lookup", () =>
2530 Effect.gen(function*() {
2531 const closing = yield* Latch.make()
2532 const close = yield* Latch.make()
2533 const acquired = yield* Latch.make()
2534 let acquisitions = 0
2535 let releases = 0
2536 const cache = yield* ScopedCache.make({
2537 capacity: 1,
2538 lookup: (key: string) =>
2539 Effect.gen(function*() {
2540 if (key === "a") {
2541 yield* Effect.acquireRelease(Effect.void, () =>
2542 Effect.gen(function*() {
2543 yield* closing.open
2544 yield* close.await
2545 }))
2546 return 1
2547 }
2548 yield* Effect.acquireRelease(Effect.sync(() => acquisitions++), () => Effect.sync(() => releases++))
2549 yield* acquired.open
2550 return yield* Effect.never
2551 })
2552 })
2553
2554 yield* ScopedCache.get(cache, "a")
2555 const caller = yield* ScopedCache.get(cache, "b").pipe(Effect.forkChild({ startImmediately: true }))
2556 yield* closing.await
2557 const interrupt = yield* Fiber.interrupt(caller).pipe(Effect.forkChild({ startImmediately: true }))
2558 yield* Effect.yieldNow
2559 yield* close.open
2560 yield* acquired.await
2561 yield* Fiber.join(interrupt)
2562
2563 assert.strictEqual(acquisitions, 1)
2564 assert.strictEqual(releases, 1)
2565 assert.strictEqual(yield* ScopedCache.size(cache), 0)
2566 }))
2567
2568 it.effect("an interrupt during eviction does not strand a pending lookup", () =>
2569 Effect.gen(function*() {
2570 const closing = yield* Latch.make()
2571 const close = yield* Latch.make()
2572 const acquired = yield* Latch.make()
2573 let acquisitions = 0
2574 let releases = 0
2575 const cache = yield* ScopedCache.make({
2576 capacity: 1,
2577 lookup: (key: string) =>
2578 Effect.gen(function*() {
2579 if (key === "a") {
2580 yield* Effect.acquireRelease(Effect.void, () =>
2581 Effect.gen(function*() {
2582 yield* closing.open
2583 yield* close.await
2584 }))
2585 return 1
2586 }
2587 yield* Effect.acquireRelease(Effect.sync(() => acquisitions++), () => Effect.sync(() => releases++))
2588 yield* acquired.open
2589 return yield* Effect.never
2590 })
2591 })
2592
2593 yield* ScopedCache.get(cache, "a")
2594 const first = yield* ScopedCache.get(cache, "b").pipe(Effect.forkChild({ startImmediately: true }))
2595 yield* closing.await
2596 const interruptFirst = yield* Fiber.interrupt(first).pipe(Effect.forkChild({ startImmediately: true }))
2597 yield* Effect.yieldNow
2598 yield* close.open
2599 yield* acquired.await
2600 const second = yield* ScopedCache.get(cache, "b").pipe(Effect.forkChild({ startImmediately: true }))
2601 yield* Effect.yieldNow
2602 yield* Fiber.interrupt(second)
2603 yield* Fiber.join(interruptFirst)
2604
2605 assert.isAbove(acquisitions, 0)
2606 assert.strictEqual(releases, acquisitions)
2607 assert.strictEqual(yield* ScopedCache.size(cache), 0)
2608 }))
2609
2610 it.effect("an interrupted get does not poison a getOption waiter", () =>
2611 Effect.gen(function*() {
2612 const started = yield* Latch.make()
2613 const finish = yield* Latch.make()
2614 const cache = yield* ScopedCache.make({
2615 capacity: 10,
2616 lookup: (_key: string) => Effect.andThen(started.open, Effect.as(finish.await, 42))
2617 })
2618 const first = yield* ScopedCache.get(cache, "key").pipe(Effect.forkChild({ startImmediately: true }))
2619 yield* started.await
2620 const second = yield* ScopedCache.getOption(cache, "key").pipe(Effect.forkChild({ startImmediately: true }))
2621 yield* Effect.yieldNow
2622 yield* Fiber.interrupt(first)
2623 yield* finish.open
2624
2625 assert.deepStrictEqual(yield* Fiber.await(second), Exit.succeed(Option.some(42)))
2626 }))
2627
2628 it.effect("an interrupted get does not poison an invalidateWhen waiter", () =>
2629 Effect.gen(function*() {
2630 const started = yield* Latch.make()
2631 const finish = yield* Latch.make()
2632 const cache = yield* ScopedCache.make({
2633 capacity: 10,
2634 lookup: (_key: string) => Effect.andThen(started.open, Effect.as(finish.await, 42))
2635 })
2636 const first = yield* ScopedCache.get(cache, "key").pipe(Effect.forkChild({ startImmediately: true }))
2637 yield* started.await
2638 const waiter = yield* ScopedCache.invalidateWhen(cache, "key", () => false).pipe(
2639 Effect.forkChild({ startImmediately: true })
2640 )
2641 yield* Effect.yieldNow
2642 yield* Fiber.interrupt(first)
2643 yield* finish.open
2644
2645 assert.deepStrictEqual(yield* Fiber.await(waiter), Exit.succeed(false))
2646 assert.strictEqual(yield* ScopedCache.get(cache, "key"), 42)
2647 }))
2648
2649 it.effect("an interrupted refresh does not poison a get joining its new entry", () =>
2650 Effect.gen(function*() {
2651 const started = yield* Latch.make()
2652 const finish = yield* Latch.make()
2653 const cache = yield* ScopedCache.make({
2654 capacity: 10,
2655 lookup: (_key: string) => Effect.andThen(started.open, Effect.as(finish.await, 42))
2656 })
2657 const refresh = yield* ScopedCache.refresh(cache, "key").pipe(Effect.forkChild({ startImmediately: true }))
2658 yield* started.await
2659 const get = yield* ScopedCache.get(cache, "key").pipe(Effect.forkChild({ startImmediately: true }))
2660 yield* Effect.yieldNow
2661 yield* Fiber.interrupt(refresh)
2662 yield* finish.open
2663
2664 assert.deepStrictEqual(yield* Fiber.await(get), Exit.succeed(42))
2665 }))
2666
2667 it.effect("an abandoned get reports defects from its scope finalizer", () =>
2668 Effect.gen(function*() {
2669 const acquired = yield* Latch.make()
2670 const cache = yield* ScopedCache.make({
2671 capacity: 10,
2672 lookup: (_key: string) =>
2673 Effect.gen(function*() {
2674 yield* Effect.acquireRelease(Effect.void, () => Effect.die("finalizer defect"))
2675 yield* acquired.open
2676 return yield* Effect.never
2677 })
2678 })
2679 const get = yield* ScopedCache.get(cache, "key").pipe(Effect.forkChild({ startImmediately: true }))
2680 yield* acquired.await
2681 yield* Fiber.interrupt(get)
2682 const exit = yield* Fiber.await(get)
2683 assert.isTrue(Exit.isFailure(exit))
2684 if (Exit.isFailure(exit)) {
2685 assert.isTrue(exit.cause.reasons.some(Cause.isDieReason))
2686 }
2687 }))
2688
2689 it.effect("an interrupted first get does not poison another pending get", () =>
2690 Effect.gen(function*() {
2691 const started = yield* Latch.make()
2692 const finish = yield* Latch.make()
2693 let lookups = 0
2694 const cache = yield* ScopedCache.make({
2695 capacity: 10,
2696 lookup: (_key: string) =>
2697 Effect.gen(function*() {
2698 lookups++
2699 yield* started.open
2700 yield* finish.await
2701 return 42
2702 })
2703 })
2704
2705 const first = yield* ScopedCache.get(cache, "key").pipe(Effect.forkChild({ startImmediately: true }))
2706 yield* started.await
2707 const second = yield* ScopedCache.get(cache, "key").pipe(Effect.forkChild({ startImmediately: true }))
2708 yield* Fiber.interrupt(first)
2709 yield* finish.open
2710
2711 assert.deepStrictEqual(yield* Fiber.await(second), Exit.succeed(42))
2712 assert.strictEqual(yield* ScopedCache.get(cache, "key"), 42)
2713 assert.strictEqual(lookups, 1)
2714 }))
2715
2716 it.effect("the last interrupted get releases its resource and allows a new lookup", () =>
2717 Effect.gen(function*() {
2718 const acquired = yield* Latch.make()
2719 let lookups = 0
2720 let releases = 0
2721 const cache = yield* ScopedCache.make({
2722 capacity: 10,
2723 lookup: (_key: string) =>
2724 Effect.gen(function*() {
2725 lookups++
2726 if (lookups === 1) {
2727 yield* Effect.acquireRelease(Effect.void, () => Effect.sync(() => releases++))
2728 yield* acquired.open
2729 return yield* Effect.never
2730 }
2731 return 42
2732 })
2733 })
2734
2735 const first = yield* ScopedCache.get(cache, "key").pipe(Effect.forkChild({ startImmediately: true }))
2736 yield* acquired.await
2737 yield* Fiber.interrupt(first)
2738 assert.strictEqual(releases, 1)
2739 assert.strictEqual(yield* ScopedCache.get(cache, "key"), 42)
2740 assert.strictEqual(lookups, 2)
2741 }))
2742
2743 it.effect("an interrupted refresh cannot replace a usable entry or leak its scope", () =>
2744 Effect.gen(function*() {
2745 const acquired = yield* Latch.make()
2746 let lookups = 0
2747 const released: Array<number> = []
2748 const cache = yield* ScopedCache.make({
2749 capacity: 10,
2750 lookup: (_key: string) =>
2751 Effect.gen(function*() {
2752 const id = ++lookups
2753 yield* Effect.acquireRelease(Effect.void, () =>
2754 Effect.sync(() => {
2755 released.push(id)
2756 }))
2757 if (id === 2) {
2758 yield* acquired.open
2759 return yield* Effect.never
2760 }
2761 return id
2762 })
2763 })
2764
2765 assert.strictEqual(yield* ScopedCache.get(cache, "key"), 1)
2766 const refresh = yield* ScopedCache.refresh(cache, "key").pipe(Effect.forkChild({ startImmediately: true }))
2767 yield* acquired.await
2768 yield* Fiber.interrupt(refresh)
2769
2770 assert.isTrue(released.includes(2))
2771 assert.strictEqual(yield* ScopedCache.get(cache, "key"), 1)
2772 assert.isFalse(released.includes(1))
2773 }))
2774
2775 describe("Concurrent Resource Access", () => {
2776 it.effect("concurrent gets share same resource", () =>
2777 Effect.gen(function*() {
2778 let resourceCreationCount = 0
2779 const cache = yield* ScopedCache.make({
2780 capacity: 10,
2781 lookup: (key: string) =>
2782 Effect.delay(Duration.millis(100))(
2783 Effect.sync(() => {
2784 resourceCreationCount++
2785 return `resource-${key}-${resourceCreationCount}`
2786 })
2787 )
2788 })
2789
2790 const fibers = yield* Effect.all([
2791 ScopedCache.get(cache, "shared").pipe(Effect.forkChild),
2792 ScopedCache.get(cache, "shared").pipe(Effect.forkChild),
2793 ScopedCache.get(cache, "shared").pipe(Effect.forkChild),
2794 ScopedCache.get(cache, "shared").pipe(Effect.forkChild)
2795 ])
2796
2797 yield* TestClock.adjust(Duration.millis(150))
2798 const results = yield* Effect.all(fibers.map(Fiber.join))
2799
2800 assert.strictEqual(resourceCreationCount, 1)
2801 assert.isTrue(results.every((r) => r === "resource-shared-1"))
2802 }))
2803
2804 it.effect("concurrent invalidations don't cause resource leaks", () =>
2805 Effect.gen(function*() {
2806 const activeResources: Set<string> = new Set()
2807
2808 const cache = yield* ScopedCache.make({
2809 capacity: 10,
2810 lookup: (key: string) =>
2811 Effect.gen(function*() {
2812 const resourceId = `${key}-${Date.now()}-${Math.random()}`
2813
2814 yield* Effect.acquireRelease(
2815 Effect.sync(() => activeResources.add(resourceId)),
2816 () => Effect.sync(() => activeResources.delete(resourceId))
2817 )
2818
2819 return resourceId
2820 })
2821 })
2822
2823 // Create initial resources
2824 yield* ScopedCache.get(cache, "key1")
2825 yield* ScopedCache.get(cache, "key2")
2826 yield* ScopedCache.get(cache, "key3")
2827 assert.strictEqual(activeResources.size, 3)
2828
2829 // Concurrent invalidations
2830 yield* Effect.all([
2831 ScopedCache.invalidate(cache, "key1"),
2832 ScopedCache.invalidate(cache, "key2"),
2833 ScopedCache.invalidate(cache, "key3"),
2834 ScopedCache.invalidate(cache, "key1"), // Duplicate
2835 ScopedCache.invalidate(cache, "key2") // Duplicate
2836 ], { concurrency: "unbounded" })
2837
2838 assert.strictEqual(activeResources.size, 0)
2839 }))
2840
2841 it.effect("concurrent refresh operations don't interfere", () =>
2842 Effect.gen(function*() {
2843 let counter = 0
2844 const cache = yield* ScopedCache.make({
2845 capacity: 10,
2846 lookup: (key: string) =>
2847 Effect.delay(Duration.millis(50))(
2848 Effect.sync(() => `${key}-${++counter}`)
2849 )
2850 })
2851
2852 // Initial population
2853 yield* ScopedCache.get(cache, "a").pipe(Effect.forkChild)
2854 yield* ScopedCache.get(cache, "b").pipe(Effect.forkChild)
2855 yield* TestClock.adjust(50)
2856 counter = 0 // Reset counter
2857
2858 // Concurrent refreshes
2859 const fibers = yield* Effect.all([
2860 ScopedCache.refresh(cache, "a").pipe(Effect.forkChild),
2861 ScopedCache.refresh(cache, "b").pipe(Effect.forkChild),
2862 ScopedCache.refresh(cache, "a").pipe(Effect.forkChild),
2863 ScopedCache.refresh(cache, "b").pipe(Effect.forkChild)
2864 ])
2865
2866 yield* TestClock.adjust(Duration.millis(100))
2867 const results = yield* Effect.all(fibers.map(Fiber.join))
2868
2869 // Each refresh should get a unique value
2870 assert.deepStrictEqual(results, [
2871 "a-1", // First refresh
2872 "b-2", // Second refresh
2873 "a-3", // Third refresh
2874 "b-4" // Fourth refresh
2875 ])
2876 assert.strictEqual(counter, 4)
2877 }))
2878 })
2879 })
2880})
2881
2882// Helper functions for testing
2883const makeScopedTestCache = (capacity: number, ttl?: Duration.Input) =>
2884 Effect.gen(function*() {
2885 let lookupCount = 0
2886 const lookupResults = new Map<string, Effect.Effect<number, string>>()
2887
2888 const cache = yield* ScopedCache.make({
2889 capacity,
2890 lookup: (key: string) => {
2891 lookupCount++
2892 return lookupResults.get(key) ?? Effect.fail(`Key not found: ${key}`)
2893 },
2894 timeToLive: ttl
2895 })
2896
2897 return {
2898 cache,
2899 lookupCount: () => lookupCount,
2900 resetLookupCount: () => {
2901 lookupCount = 0
2902 },
2903 setLookupResult: (key: string, result: Effect.Effect<number, string>) => {
2904 lookupResults.set(key, result)
2905 }
2906 }
2907 })
2908
2909// Helper for tracking resource cleanup
2910interface CleanupTracker {
2911 cleanedUp: Array<string>
2912 acquired: Array<string>
2913}
2914
2915const makeManagedResourceCache = (capacity: number) =>
2916 Effect.gen(function*() {
2917 const cleanupTracker: CleanupTracker = {
2918 cleanedUp: [],
2919 acquired: []
2920 }
2921
2922 const cache = yield* ScopedCache.make({
2923 capacity,
2924 lookup: (key: string) =>
2925 Effect.gen(function*() {
2926 yield* Effect.acquireRelease(
2927 Effect.sync(() => cleanupTracker.acquired.push(key)),
2928 () => Effect.sync(() => cleanupTracker.cleanedUp.push(key))
2929 )
2930 return `resource-${key}`
2931 })
2932 })
2933
2934 return { cache, cleanupTracker }
2935 })