| 1 | /** |
| 2 | * Caches values that need scoped resource management. |
| 3 | * |
| 4 | * Each cached entry owns its own `Scope`, so resources opened while creating a |
| 5 | * value stay alive while that entry is cached and are released when the entry is |
| 6 | * removed. A `ScopedCache` also belongs to an outer scope, which closes all |
| 7 | * remaining entries when the cache is closed. Lookups for the same missing key |
| 8 | * share one in-progress effect, and entries can expire, be refreshed, be |
| 9 | * invalidated, or be evicted by capacity limits. |
| 10 | * |
| 11 | * @since 4.0.0 |
| 12 | */ |
| 13 | import * as Arr from "./Array.ts" |
| 14 | import * as Cause from "./Cause.ts" |
| 15 | import * as Context from "./Context.ts" |
| 16 | import * as Deferred from "./Deferred.ts" |
| 17 | import * as Duration from "./Duration.ts" |
| 18 | import type * as Effect from "./Effect.ts" |
| 19 | import * as Exit from "./Exit.ts" |
| 20 | import * as Fiber from "./Fiber.ts" |
| 21 | import { dual, identity } from "./Function.ts" |
| 22 | import * as core from "./internal/core.ts" |
| 23 | import { PipeInspectableProto } from "./internal/core.ts" |
| 24 | import * as effect from "./internal/effect.ts" |
| 25 | import * as MutableHashMap from "./MutableHashMap.ts" |
| 26 | import * as Option from "./Option.ts" |
| 27 | import type { Pipeable } from "./Pipeable.ts" |
| 28 | import * as Predicate from "./Predicate.ts" |
| 29 | import * as Scope from "./Scope.ts" |
| 30 | |
| 31 | const TypeId = "~effect/ScopedCache" |
| 32 | |
| 33 | /** |
| 34 | * A scoped cache whose values are acquired by a lookup effect and stored in |
| 35 | * per-entry scopes. |
| 36 | * |
| 37 | * **When to use** |
| 38 | * |
| 39 | * Use to cache values that acquire scoped resources and must release those |
| 40 | * resources when entries expire, are evicted, or are invalidated. |
| 41 | * |
| 42 | * **Details** |
| 43 | * |
| 44 | * Concurrent requests for the same key share the same in-flight lookup. |
| 45 | * Entries can expire based on the lookup exit, are evicted when capacity is |
| 46 | * exceeded, and release their entry scopes when invalidated, evicted, expired, |
| 47 | * or when the cache's owning scope closes. |
| 48 | * |
| 49 | * @see {@link make} for creating a scoped cache with a fixed time-to-live |
| 50 | * @see {@link makeWith} for creating a scoped cache with dynamic time-to-live |
| 51 | * |
| 52 | * @category models |
| 53 | * @since 2.0.0 |
| 54 | */ |
| 55 | export interface ScopedCache<in out Key, in out A, in out E = never, out R = never> extends Pipeable { |
| 56 | readonly [TypeId]: typeof TypeId |
| 57 | state: State<Key, A, E> |
| 58 | readonly capacity: number |
| 59 | readonly lookup: (key: Key) => Effect.Effect<A, E, R | Scope.Scope> |
| 60 | readonly timeToLive: (exit: Exit.Exit<A, E>, key: Key) => Duration.Duration |
| 61 | } |
| 62 | |
| 63 | /** |
| 64 | * Represents whether a `ScopedCache` is open or closed. |
| 65 | * |
| 66 | * **When to use** |
| 67 | * |
| 68 | * Use when inspecting the low-level lifecycle state of a scoped cache. |
| 69 | * |
| 70 | * **Details** |
| 71 | * |
| 72 | * `Open` stores cached entries in access order for reuse and eviction. |
| 73 | * `Closed` means the owning scope has closed and the cache can no longer |
| 74 | * perform lookup operations. |
| 75 | * |
| 76 | * @category models |
| 77 | * @since 4.0.0 |
| 78 | */ |
| 79 | export type State<K, A, E> = { |
| 80 | readonly _tag: "Open" |
| 81 | readonly map: MutableHashMap.MutableHashMap<K, Entry<A, E>> |
| 82 | } | { |
| 83 | readonly _tag: "Closed" |
| 84 | } |
| 85 | |
| 86 | /** |
| 87 | * A single scoped cache entry. |
| 88 | * |
| 89 | * **When to use** |
| 90 | * |
| 91 | * Use when inspecting the open state of a `ScopedCache` and you need the stored |
| 92 | * deferred result, entry scope, or expiration timestamp for a key. |
| 93 | * |
| 94 | * **Details** |
| 95 | * |
| 96 | * The entry contains the deferred lookup result shared by readers, the scope |
| 97 | * that owns resources acquired while computing the value, and an optional |
| 98 | * expiration time in milliseconds. Removing the entry closes its scope. |
| 99 | * |
| 100 | * @see {@link State} for the open/closed cache state that stores entries by key |
| 101 | * |
| 102 | * @category models |
| 103 | * @since 4.0.0 |
| 104 | */ |
| 105 | export interface Entry<A, E> { |
| 106 | expiresAt: number | undefined |
| 107 | readonly deferred: Deferred.Deferred<A, E> |
| 108 | readonly scope: Scope.Closeable |
| 109 | awaiters: number |
| 110 | fiber?: Fiber.Fiber<A, E> |
| 111 | } |
| 112 | |
| 113 | /** |
| 114 | * Creates a `ScopedCache` from a lookup function, maximum capacity, and a |
| 115 | * time-to-live function computed from each lookup exit and key. |
| 116 | * |
| 117 | * **When to use** |
| 118 | * |
| 119 | * Use when you need a scoped cache whose entry lifetime depends on each lookup |
| 120 | * result or key. |
| 121 | * |
| 122 | * **Details** |
| 123 | * |
| 124 | * The cache must be constructed in a `Scope`. Each lookup runs in its own entry |
| 125 | * scope, and that scope is closed when the entry expires, is invalidated, is |
| 126 | * evicted by capacity, or when the cache's owning scope closes. |
| 127 | * `requireServicesAt` controls whether lookup services are captured at |
| 128 | * construction time or required when lookup operations run. |
| 129 | * |
| 130 | * @see {@link make} for creating a scoped cache with one fixed time-to-live |
| 131 | * |
| 132 | * @category constructors |
| 133 | * @since 2.0.0 |
| 134 | */ |
| 135 | export const makeWith = < |
| 136 | Key, |
| 137 | A, |
| 138 | E = never, |
| 139 | R = never, |
| 140 | ServiceMode extends "lookup" | "construction" = never |
| 141 | >(options: { |
| 142 | readonly lookup: (key: Key) => Effect.Effect<A, E, R | Scope.Scope> |
| 143 | readonly capacity: number |
| 144 | readonly timeToLive?: ((exit: Exit.Exit<A, E>, key: Key) => Duration.Input) | undefined |
| 145 | readonly requireServicesAt?: ServiceMode | undefined |
| 146 | }): Effect.Effect< |
| 147 | ScopedCache<Key, A, E, "lookup" extends ServiceMode ? Exclude<R, Scope.Scope> : never>, |
| 148 | never, |
| 149 | ("lookup" extends ServiceMode ? never : R) | Scope.Scope |
| 150 | > => |
| 151 | effect.contextWith((context: Context.Context<any>) => { |
| 152 | const scope = Context.get(context, Scope.Scope) |
| 153 | const self = Object.create(Proto) |
| 154 | self.lookup = (key: Key): Effect.Effect<A, E> => |
| 155 | effect.updateContext( |
| 156 | options.lookup(key), |
| 157 | (input) => Context.merge(context, input) |
| 158 | ) |
| 159 | const map = MutableHashMap.empty<Key, Entry<A, E>>() |
| 160 | self.state = { _tag: "Open", map } |
| 161 | self.capacity = options.capacity |
| 162 | self.timeToLive = options.timeToLive |
| 163 | ? (exit: Exit.Exit<A, E>, key: Key) => Duration.fromInputUnsafe(options.timeToLive!(exit, key)) |
| 164 | : defaultTimeToLive |
| 165 | return effect.as( |
| 166 | Scope.addFinalizer( |
| 167 | scope, |
| 168 | core.withFiber((fiber) => { |
| 169 | self.state = { _tag: "Closed" } |
| 170 | return invalidateAllImpl(fiber, map) |
| 171 | }) |
| 172 | ), |
| 173 | self |
| 174 | ) |
| 175 | }) |
| 176 | |
| 177 | /** |
| 178 | * Creates a `ScopedCache` with a fixed time-to-live for every lookup result. |
| 179 | * |
| 180 | * **When to use** |
| 181 | * |
| 182 | * Use to create a scoped cache when every cached lookup result should share the |
| 183 | * same lifetime. |
| 184 | * |
| 185 | * **Details** |
| 186 | * |
| 187 | * This is the constant-TTL variant of `makeWith`: values are acquired by the |
| 188 | * lookup effect in per-entry scopes, capacity can evict older entries, and |
| 189 | * entry scopes are closed when entries expire, are invalidated, are evicted, or |
| 190 | * when the cache's owning scope closes. |
| 191 | * |
| 192 | * @see {@link makeWith} for computing time-to-live from each lookup result and key |
| 193 | * |
| 194 | * @category constructors |
| 195 | * @since 2.0.0 |
| 196 | */ |
| 197 | export const make = < |
| 198 | Key, |
| 199 | A, |
| 200 | E = never, |
| 201 | R = never, |
| 202 | ServiceMode extends "lookup" | "construction" = never |
| 203 | >( |
| 204 | options: { |
| 205 | readonly lookup: (key: Key) => Effect.Effect<A, E, R | Scope.Scope> |
| 206 | readonly capacity: number |
| 207 | readonly timeToLive?: Duration.Input | undefined |
| 208 | readonly requireServicesAt?: ServiceMode | undefined |
| 209 | } |
| 210 | ): Effect.Effect< |
| 211 | ScopedCache<Key, A, E, "lookup" extends ServiceMode ? Exclude<R, Scope.Scope> : never>, |
| 212 | never, |
| 213 | ("lookup" extends ServiceMode ? never : R) | Scope.Scope |
| 214 | > => |
| 215 | makeWith<Key, A, E, R, ServiceMode>({ |
| 216 | ...options, |
| 217 | timeToLive: options.timeToLive !== undefined ? () => options.timeToLive! : defaultTimeToLive |
| 218 | }) |
| 219 | |
| 220 | const Proto = { |
| 221 | ...PipeInspectableProto, |
| 222 | [TypeId]: TypeId, |
| 223 | toJSON(this: ScopedCache<any, any, any>) { |
| 224 | return { |
| 225 | _id: "ScopedCache", |
| 226 | capacity: this.capacity, |
| 227 | state: this.state |
| 228 | } |
| 229 | } |
| 230 | } |
| 231 | |
| 232 | const defaultTimeToLive = <A, E>(_: Exit.Exit<A, E>, _key: unknown): Duration.Duration => Duration.infinity |
| 233 | |
| 234 | /** |
| 235 | * Gets the value for a key, running the cache lookup when no unexpired entry is |
| 236 | * present. |
| 237 | * |
| 238 | * **When to use** |
| 239 | * |
| 240 | * Use to retrieve a scoped cached value by key when a missing or expired entry |
| 241 | * should run the cache lookup and share the in-flight lookup with concurrent |
| 242 | * callers. |
| 243 | * |
| 244 | * **Details** |
| 245 | * |
| 246 | * Concurrent `get` calls for the same key share the same in-flight lookup. |
| 247 | * Successful and failed lookup exits are cached according to the configured |
| 248 | * TTL. If the cache is closed, the effect is interrupted. |
| 249 | * |
| 250 | * @see {@link getOption} for reading only when an unexpired entry is already cached |
| 251 | * @see {@link getSuccess} for inspecting an already-completed successful entry |
| 252 | * @see {@link refresh} for forcing a new lookup |
| 253 | * |
| 254 | * @category combinators |
| 255 | * @since 4.0.0 |
| 256 | */ |
| 257 | export const get: { |
| 258 | <Key, A>(key: Key): <E, R>(self: ScopedCache<Key, A, E, R>) => Effect.Effect<A, E, R> |
| 259 | <Key, A, E, R>(self: ScopedCache<Key, A, E, R>, key: Key): Effect.Effect<A, E, R> |
| 260 | } = dual( |
| 261 | 2, |
| 262 | <Key, A, E, R>(self: ScopedCache<Key, A, E, R>, key: Key): Effect.Effect<A, E, R> => |
| 263 | effect.uninterruptibleMask((restore) => |
| 264 | core.withFiber((fiber) => { |
| 265 | const state = self.state |
| 266 | if (state._tag === "Closed") { |
| 267 | return effect.interrupt |
| 268 | } |
| 269 | const oentry = MutableHashMap.get(state.map, key) |
| 270 | if (Option.isSome(oentry) && !hasExpired(oentry.value, fiber)) { |
| 271 | // Move the entry to the end of the map to keep it fresh |
| 272 | MutableHashMap.remove(state.map, key) |
| 273 | MutableHashMap.set(state.map, key, oentry.value) |
| 274 | return awaitEntry(self, key, oentry.value, restore) |
| 275 | } |
| 276 | const scope = Scope.makeUnsafe() |
| 277 | const deferred = Deferred.makeUnsafe<A, E>() |
| 278 | const entry: Entry<A, E> = { |
| 279 | expiresAt: undefined, |
| 280 | deferred, |
| 281 | scope, |
| 282 | awaiters: 0 |
| 283 | } |
| 284 | MutableHashMap.set(state.map, key, entry) |
| 285 | return checkCapacity(fiber, state.map, self.capacity).pipe( |
| 286 | Option.isSome(oentry) ? effect.flatMap(() => Scope.close(oentry.value.scope, effect.exitVoid)) : identity, |
| 287 | effect.flatMap(() => { |
| 288 | entry.fiber = effect.forkUnsafe( |
| 289 | fiber, |
| 290 | effect.onExit(effect.suspend(() => Scope.provide(self.lookup(key), scope)), (exit) => { |
| 291 | if (effect.exitHasInterrupts(exit)) { |
| 292 | removeEntry(self, key, entry) |
| 293 | Deferred.doneUnsafe(deferred, exit) |
| 294 | return Scope.close(scope, exit) |
| 295 | } |
| 296 | try { |
| 297 | const ttl = self.timeToLive(exit, key) |
| 298 | if (Duration.isFinite(ttl)) { |
| 299 | entry.expiresAt = fiber.getRef(effect.ClockRef).currentTimeMillisUnsafe() + Duration.toMillis(ttl) |
| 300 | } |
| 301 | } finally { |
| 302 | Deferred.doneUnsafe(deferred, exit) |
| 303 | } |
| 304 | return effect.void |
| 305 | }), |
| 306 | true, |
| 307 | true |
| 308 | ) |
| 309 | return awaitEntry(self, key, entry, restore) |
| 310 | }) |
| 311 | ) |
| 312 | }) |
| 313 | ) |
| 314 | ) |
| 315 | |
| 316 | const awaitEntry = <Key, A, E, R>( |
| 317 | self: ScopedCache<Key, A, E, R>, |
| 318 | key: Key, |
| 319 | entry: Entry<A, E>, |
| 320 | restore: <X, Y, Z>(effect: Effect.Effect<X, Y, Z>) => Effect.Effect<X, Y, Z> |
| 321 | ): Effect.Effect<A, E> => { |
| 322 | if (Deferred.isDoneUnsafe(entry.deferred)) return restore(Deferred.await(entry.deferred)) |
| 323 | entry.awaiters++ |
| 324 | // Install cleanup before restore so a pending interrupt cannot skip the decrement. |
| 325 | return effect.onExit(restore(Deferred.await(entry.deferred)), () => { |
| 326 | entry.awaiters-- |
| 327 | const fiber = entry.fiber |
| 328 | if (entry.awaiters > 0 || fiber === undefined || Deferred.isDoneUnsafe(entry.deferred)) return effect.void |
| 329 | // Detach before interruption so callers arriving during finalization start fresh. |
| 330 | removeEntry(self, key, entry) |
| 331 | return effect.flatMap(effect.fiberInterrupt(fiber), () => { |
| 332 | const exit = fiber.pollUnsafe()! |
| 333 | return Exit.isFailure(exit) && Cause.hasDies(exit.cause) ? effect.failCause(exit.cause) : effect.void |
| 334 | }) |
| 335 | }) |
| 336 | } |
| 337 | |
| 338 | const removeEntry = <Key, A, E, R>(self: ScopedCache<Key, A, E, R>, key: Key, entry: Entry<A, E>): void => { |
| 339 | if (self.state._tag !== "Open") return |
| 340 | const current = MutableHashMap.get(self.state.map, key) |
| 341 | if (Option.isSome(current) && current.value === entry) MutableHashMap.remove(self.state.map, key) |
| 342 | } |
| 343 | |
| 344 | const hasExpired = <A, E>(entry: Entry<A, E>, fiber: Fiber.Fiber<unknown, unknown>): boolean => { |
| 345 | if (entry.expiresAt === undefined) { |
| 346 | return false |
| 347 | } |
| 348 | return fiber.getRef(effect.ClockRef).currentTimeMillisUnsafe() >= entry.expiresAt |
| 349 | } |
| 350 | |
| 351 | const checkCapacity = <K, A, E>( |
| 352 | parent: Fiber.Fiber<unknown, unknown>, |
| 353 | map: MutableHashMap.MutableHashMap<K, Entry<A, E>>, |
| 354 | capacity: number |
| 355 | ): Effect.Effect<void> => { |
| 356 | if (!Number.isFinite(capacity)) return effect.void |
| 357 | let diff = MutableHashMap.size(map) - capacity |
| 358 | if (diff <= 0) return effect.void |
| 359 | // MutableHashMap has insertion order, so we can remove the oldest entries |
| 360 | const fibers = Arr.empty<Fiber.Fiber<unknown, unknown>>() |
| 361 | for (const [key, entry] of map) { |
| 362 | MutableHashMap.remove(map, key) |
| 363 | fibers.push(effect.forkUnsafe(parent as any, Scope.close(entry.scope, effect.exitVoid), true)) |
| 364 | diff-- |
| 365 | if (diff === 0) break |
| 366 | } |
| 367 | return effect.fiberAwaitAll(fibers) |
| 368 | } |
| 369 | |
| 370 | /** |
| 371 | * Reads an existing unexpired cache entry without running the lookup function. |
| 372 | * |
| 373 | * **When to use** |
| 374 | * |
| 375 | * Use to read a scoped value only when it is already cached, without starting |
| 376 | * the lookup for missing or expired keys. |
| 377 | * |
| 378 | * **Details** |
| 379 | * |
| 380 | * Returns `Option.none` when the key is absent or expired. If an entry exists, |
| 381 | * the effect waits for its cached result and returns `Option.some(value)` on |
| 382 | * success, or fails with the cached lookup error. |
| 383 | * |
| 384 | * @see {@link get} for running the lookup on missing or expired keys |
| 385 | * @see {@link getSuccess} for inspecting only already-completed successful entries |
| 386 | * |
| 387 | * @category combinators |
| 388 | * @since 4.0.0 |
| 389 | */ |
| 390 | export const getOption: { |
| 391 | <Key, A>(key: Key): <E, R>(self: ScopedCache<Key, A, E, R>) => Effect.Effect<Option.Option<A>, E> |
| 392 | <Key, A, E, R>(self: ScopedCache<Key, A, E, R>, key: Key): Effect.Effect<Option.Option<A>, E> |
| 393 | } = dual( |
| 394 | 2, |
| 395 | <Key, A, E, R>(self: ScopedCache<Key, A, E, R>, key: Key): Effect.Effect<Option.Option<A>, E> => |
| 396 | effect.uninterruptibleMask((restore) => |
| 397 | core.withFiber((fiber) => |
| 398 | effect.flatMap( |
| 399 | getImpl(self, key, fiber), |
| 400 | (entry) => entry ? effect.asSome(awaitEntry(self, key, entry, restore)) : effect.succeedNone |
| 401 | ) |
| 402 | ) |
| 403 | ) |
| 404 | ) |
| 405 | |
| 406 | const getImpl = <Key, A, E, R>( |
| 407 | self: ScopedCache<Key, A, E, R>, |
| 408 | key: Key, |
| 409 | fiber: Fiber.Fiber<any, any>, |
| 410 | isRead = true |
| 411 | ): Effect.Effect<Entry<A, E> | undefined> => { |
| 412 | if (self.state._tag === "Closed") { |
| 413 | return effect.interrupt |
| 414 | } |
| 415 | const state = self.state |
| 416 | const oentry = MutableHashMap.get(state.map, key) |
| 417 | if (Option.isNone(oentry)) { |
| 418 | return effect.undefined |
| 419 | } else if (hasExpired(oentry.value, fiber)) { |
| 420 | MutableHashMap.remove(state.map, key) |
| 421 | return effect.as( |
| 422 | Scope.close(oentry.value.scope, effect.exitVoid), |
| 423 | undefined |
| 424 | ) |
| 425 | } else if (isRead) { |
| 426 | MutableHashMap.remove(state.map, key) |
| 427 | MutableHashMap.set(state.map, key, oentry.value) |
| 428 | } |
| 429 | return effect.succeed(oentry.value) |
| 430 | } |
| 431 | |
| 432 | /** |
| 433 | * Retrieves the value associated with the specified key from the cache, only if |
| 434 | * it contains a resolved successful value. |
| 435 | * |
| 436 | * **When to use** |
| 437 | * |
| 438 | * Use to inspect an already-completed successful scoped cache entry without |
| 439 | * running or awaiting the lookup effect. |
| 440 | * |
| 441 | * **Details** |
| 442 | * |
| 443 | * Returns `Option.some` for a resolved successful entry. Returns `Option.none` |
| 444 | * for missing, expired, failed, or still-pending entries. |
| 445 | * |
| 446 | * @see {@link get} for awaiting or starting the lookup effect |
| 447 | * @see {@link getOption} for awaiting an already-cached entry without starting a lookup |
| 448 | * |
| 449 | * @category combinators |
| 450 | * @since 4.0.0 |
| 451 | */ |
| 452 | export const getSuccess: { |
| 453 | <Key, A, R>(key: Key): <E>(self: ScopedCache<Key, A, E, R>) => Effect.Effect<Option.Option<A>> |
| 454 | <Key, A, E, R>(self: ScopedCache<Key, A, E, R>, key: Key): Effect.Effect<Option.Option<A>> |
| 455 | } = dual( |
| 456 | 2, |
| 457 | <Key, A, E, R>(self: ScopedCache<Key, A, E, R>, key: Key): Effect.Effect<Option.Option<A>> => |
| 458 | effect.uninterruptible( |
| 459 | core.withFiber((fiber) => |
| 460 | effect.map( |
| 461 | getImpl(self, key, fiber), |
| 462 | (entry) => { |
| 463 | const exit = entry?.deferred.effect as Exit.Exit<A, E> | undefined |
| 464 | if (exit && effect.exitIsSuccess(exit)) { |
| 465 | return Option.some(exit.value) |
| 466 | } |
| 467 | return Option.none() |
| 468 | } |
| 469 | ) |
| 470 | ) |
| 471 | ) |
| 472 | ) |
| 473 | |
| 474 | /** |
| 475 | * Sets a successful value for a key without running the lookup function. |
| 476 | * |
| 477 | * **When to use** |
| 478 | * |
| 479 | * Use to seed or overwrite a scoped cache entry with an already available |
| 480 | * successful value. |
| 481 | * |
| 482 | * **Details** |
| 483 | * |
| 484 | * This replaces and closes any existing entry scope for the key, applies the |
| 485 | * cache's TTL using a successful exit for the value, and may evict older |
| 486 | * entries if the cache capacity is exceeded. |
| 487 | * |
| 488 | * @see {@link get} for reading or computing a cached value |
| 489 | * @see {@link refresh} for replacing an entry by running the lookup function |
| 490 | * |
| 491 | * @category combinators |
| 492 | * @since 4.0.0 |
| 493 | */ |
| 494 | export const set: { |
| 495 | <Key, A>(key: Key, value: A): <E, R>(self: ScopedCache<Key, A, E, R>) => Effect.Effect<void> |
| 496 | <Key, A, E, R>(self: ScopedCache<Key, A, E, R>, key: Key, value: A): Effect.Effect<void> |
| 497 | } = dual( |
| 498 | 3, |
| 499 | <Key, A, E, R>(self: ScopedCache<Key, A, E, R>, key: Key, value: A): Effect.Effect<void> => |
| 500 | effect.uninterruptible( |
| 501 | core.withFiber((fiber) => { |
| 502 | if (self.state._tag === "Closed") { |
| 503 | return effect.interrupt |
| 504 | } |
| 505 | const oentry = MutableHashMap.get(self.state.map, key) |
| 506 | const state = self.state |
| 507 | const exit = core.exitSucceed(value) |
| 508 | const deferred = Deferred.makeUnsafe<A, E>() |
| 509 | Deferred.doneUnsafe(deferred, exit) |
| 510 | const ttl = self.timeToLive(exit, key) |
| 511 | MutableHashMap.set(state.map, key, { |
| 512 | scope: Scope.makeUnsafe(), |
| 513 | deferred, |
| 514 | awaiters: 0, |
| 515 | expiresAt: Duration.isFinite(ttl) |
| 516 | ? fiber.getRef(effect.ClockRef).currentTimeMillisUnsafe() + Duration.toMillis(ttl) |
| 517 | : undefined |
| 518 | }) |
| 519 | const check = checkCapacity(fiber, state.map, self.capacity) |
| 520 | return Option.isSome(oentry) |
| 521 | ? effect.flatMap(Scope.close(oentry.value.scope, effect.exitVoid), () => check) |
| 522 | : check |
| 523 | }) |
| 524 | ) |
| 525 | ) |
| 526 | |
| 527 | /** |
| 528 | * Checks whether the cache contains an entry for the specified key. |
| 529 | * |
| 530 | * **When to use** |
| 531 | * |
| 532 | * Use to test whether an unexpired entry exists for a key without running the |
| 533 | * cache lookup. |
| 534 | * |
| 535 | * **Details** |
| 536 | * |
| 537 | * This does not start lookups and does not refresh access order. Expired |
| 538 | * entries are treated as absent and their scopes are closed while checking. If |
| 539 | * the cache is closed, the effect is interrupted. |
| 540 | * |
| 541 | * @see {@link getOption} for reading an existing cached entry |
| 542 | * @see {@link get} for running the lookup on missing or expired keys |
| 543 | * |
| 544 | * @category combinators |
| 545 | * @since 4.0.0 |
| 546 | */ |
| 547 | export const has: { |
| 548 | <Key, A>(key: Key): <E, R>(self: ScopedCache<Key, A, E, R>) => Effect.Effect<boolean> |
| 549 | <Key, A, E, R>(self: ScopedCache<Key, A, E, R>, key: Key): Effect.Effect<boolean> |
| 550 | } = dual( |
| 551 | 2, |
| 552 | <Key, A, E>(self: ScopedCache<Key, A, E>, key: Key): Effect.Effect<boolean> => |
| 553 | effect.uninterruptible( |
| 554 | core.withFiber((fiber) => effect.map(getImpl(self, key, fiber, false), Predicate.isNotUndefined)) |
| 555 | ) |
| 556 | ) |
| 557 | |
| 558 | /** |
| 559 | * Removes the entry associated with a key and closes its entry scope. |
| 560 | * |
| 561 | * **When to use** |
| 562 | * |
| 563 | * Use to remove a single key from a scoped cache and release any resources owned |
| 564 | * by that entry before a later lookup computes it again. |
| 565 | * |
| 566 | * **Details** |
| 567 | * |
| 568 | * If the key is absent, this is a no-op. |
| 569 | * |
| 570 | * **Gotchas** |
| 571 | * |
| 572 | * If the cache is closed, the effect is interrupted. |
| 573 | * |
| 574 | * @see {@link refresh} for replacing a key by running a new lookup immediately |
| 575 | * @see {@link invalidateWhen} for invalidating only when a cached value matches a predicate |
| 576 | * @see {@link invalidateAll} for removing every cached entry |
| 577 | * |
| 578 | * @category combinators |
| 579 | * @since 4.0.0 |
| 580 | */ |
| 581 | export const invalidate: { |
| 582 | <Key, A>(key: Key): <E, R>(self: ScopedCache<Key, A, E, R>) => Effect.Effect<void> |
| 583 | <Key, A, E, R>(self: ScopedCache<Key, A, E, R>, key: Key): Effect.Effect<void> |
| 584 | } = dual(2, <Key, A, E, R>(self: ScopedCache<Key, A, E, R>, key: Key): Effect.Effect<void> => |
| 585 | effect.uninterruptible( |
| 586 | effect.suspend(() => { |
| 587 | if (self.state._tag === "Closed") { |
| 588 | return effect.interrupt |
| 589 | } |
| 590 | const oentry = MutableHashMap.get(self.state.map, key) |
| 591 | if (Option.isNone(oentry)) { |
| 592 | return effect.void |
| 593 | } |
| 594 | MutableHashMap.remove(self.state.map, key) |
| 595 | return Scope.close(oentry.value.scope, effect.exitVoid) |
| 596 | }) |
| 597 | )) |
| 598 | |
| 599 | /** |
| 600 | * Invalidates the entry associated with the specified key in the cache when the |
| 601 | * predicate returns true for the cached value. |
| 602 | * |
| 603 | * **When to use** |
| 604 | * |
| 605 | * Use to remove an already-cached scoped value only when the successful cached |
| 606 | * value satisfies a predicate. |
| 607 | * |
| 608 | * **Details** |
| 609 | * |
| 610 | * Returns `true` only when a successful cached value matches and is removed. It |
| 611 | * returns `false` for absent, expired, failed, or non-matching entries. |
| 612 | * |
| 613 | * **Gotchas** |
| 614 | * |
| 615 | * A matching invalidation closes the entry scope and releases its resources. |
| 616 | * |
| 617 | * @see {@link invalidate} for unconditional removal by key |
| 618 | * |
| 619 | * @category combinators |
| 620 | * @since 4.0.0 |
| 621 | */ |
| 622 | export const invalidateWhen: { |
| 623 | <Key, A>(key: Key, f: Predicate.Predicate<A>): <E, R>(self: ScopedCache<Key, A, E, R>) => Effect.Effect<boolean> |
| 624 | <Key, A, E, R>(self: ScopedCache<Key, A, E, R>, key: Key, f: Predicate.Predicate<A>): Effect.Effect<boolean> |
| 625 | } = dual( |
| 626 | 3, |
| 627 | <Key, A, E, R>(self: ScopedCache<Key, A, E, R>, key: Key, f: Predicate.Predicate<A>): Effect.Effect<boolean> => |
| 628 | effect.uninterruptibleMask((restore) => |
| 629 | core.withFiber((fiber) => |
| 630 | effect.flatMap(getImpl(self, key, fiber, false), (entry) => { |
| 631 | if (entry === undefined) { |
| 632 | return effect.succeed(false) |
| 633 | } |
| 634 | return awaitEntry(self, key, entry, restore).pipe( |
| 635 | effect.flatMap((value) => { |
| 636 | if (self.state._tag === "Closed") { |
| 637 | return effect.succeed(false) |
| 638 | } else if (f(value)) { |
| 639 | const current = MutableHashMap.get(self.state.map, key) |
| 640 | if (Option.isNone(current) || current.value !== entry) { |
| 641 | return effect.succeed(false) |
| 642 | } |
| 643 | MutableHashMap.remove(self.state.map, key) |
| 644 | return effect.as(Scope.close(entry.scope, effect.exitVoid), true) |
| 645 | } |
| 646 | return effect.succeed(false) |
| 647 | }), |
| 648 | effect.catch_(() => effect.succeed(false)) |
| 649 | ) |
| 650 | }) |
| 651 | ) |
| 652 | ) |
| 653 | ) |
| 654 | |
| 655 | /** |
| 656 | * Forces a refresh of the value associated with the specified key in the cache. |
| 657 | * |
| 658 | * **When to use** |
| 659 | * |
| 660 | * Use to recompute a scoped cache entry immediately, even when an unexpired |
| 661 | * value is already cached. |
| 662 | * |
| 663 | * **Details** |
| 664 | * |
| 665 | * It will always invoke the lookup function to construct a new value, |
| 666 | * overwriting any existing value for that key. |
| 667 | * |
| 668 | * @see {@link get} for reusing an unexpired entry before running the lookup |
| 669 | * @see {@link invalidate} for removing an entry without recomputing it |
| 670 | * |
| 671 | * @category combinators |
| 672 | * @since 4.0.0 |
| 673 | */ |
| 674 | export const refresh: { |
| 675 | <Key, A>(key: Key): <E, R>(self: ScopedCache<Key, A, E, R>) => Effect.Effect<A, E, R> |
| 676 | <Key, A, E, R>(self: ScopedCache<Key, A, E, R>, key: Key): Effect.Effect<A, E, R> |
| 677 | } = dual( |
| 678 | 2, |
| 679 | <Key, A, E, R>(self: ScopedCache<Key, A, E, R>, key: Key): Effect.Effect<A, E, R> => |
| 680 | effect.uninterruptibleMask(effect.fnUntraced(function*(restore) { |
| 681 | if (self.state._tag === "Closed") return yield* effect.interrupt |
| 682 | if (!MutableHashMap.has(self.state.map, key)) return yield* restore(get(self, key)) |
| 683 | const fiber = Fiber.getCurrent()! |
| 684 | const scope = Scope.makeUnsafe() |
| 685 | const deferred = Deferred.makeUnsafe<A, E>() |
| 686 | const exit = yield* effect.exit(effect.suspend(() => restore(Scope.provide(self.lookup(key), scope)))) |
| 687 | Deferred.doneUnsafe(deferred, exit) |
| 688 | if (effect.exitHasInterrupts(exit)) { |
| 689 | yield* Scope.close(scope, exit) |
| 690 | return yield* exit |
| 691 | } |
| 692 | // @ts-ignore async gap |
| 693 | if (self.state._tag === "Closed") { |
| 694 | yield* Scope.close(scope, effect.exitVoid) |
| 695 | return yield* effect.interrupt |
| 696 | } |
| 697 | const ttl = self.timeToLive(exit, key) |
| 698 | const entry: Entry<A, E> = { |
| 699 | scope, |
| 700 | deferred, |
| 701 | awaiters: 0, |
| 702 | expiresAt: Duration.isFinite(ttl) |
| 703 | ? fiber.getRef(effect.ClockRef).currentTimeMillisUnsafe() + Duration.toMillis(ttl) |
| 704 | : undefined |
| 705 | } |
| 706 | const oentry = MutableHashMap.get(self.state.map, key) |
| 707 | MutableHashMap.set(self.state.map, key, entry) |
| 708 | yield* checkCapacity(fiber, self.state.map, self.capacity) |
| 709 | if (Option.isSome(oentry)) { |
| 710 | yield* Scope.close(oentry.value.scope, effect.exitVoid) |
| 711 | } |
| 712 | return yield* exit |
| 713 | })) |
| 714 | ) |
| 715 | |
| 716 | /** |
| 717 | * Removes every entry from the cache and closes each entry scope. |
| 718 | * |
| 719 | * **When to use** |
| 720 | * |
| 721 | * Use to clear a scoped cache and release resources owned by all cached entries. |
| 722 | * |
| 723 | * **Details** |
| 724 | * |
| 725 | * If the cache is closed, the effect is interrupted. |
| 726 | * |
| 727 | * @see {@link invalidate} for removing one cached entry |
| 728 | * |
| 729 | * @category combinators |
| 730 | * @since 4.0.0 |
| 731 | */ |
| 732 | export const invalidateAll = <Key, A, E, R>(self: ScopedCache<Key, A, E, R>): Effect.Effect<void> => |
| 733 | core.withFiber((parent) => { |
| 734 | if (self.state._tag === "Closed") { |
| 735 | return effect.interrupt |
| 736 | } |
| 737 | return invalidateAllImpl(parent, self.state.map) |
| 738 | }) |
| 739 | |
| 740 | const invalidateAllImpl = <Key, A, E>( |
| 741 | parent: Fiber.Fiber<unknown, unknown>, |
| 742 | map: MutableHashMap.MutableHashMap<Key, Entry<A, E>> |
| 743 | ): Effect.Effect<void> => { |
| 744 | // Detach the batch before finalizers can reenter the cache. |
| 745 | const entries = Array.from(MutableHashMap.values(map)) |
| 746 | MutableHashMap.clear(map) |
| 747 | const fibers = Arr.empty<Fiber.Fiber<unknown, unknown>>() |
| 748 | for (const entry of entries) { |
| 749 | fibers.push(effect.forkUnsafe(parent as any, Scope.close(entry.scope, effect.exitVoid), true, true)) |
| 750 | } |
| 751 | return effect.fiberAwaitAll(fibers) |
| 752 | } |
| 753 | |
| 754 | /** |
| 755 | * Retrieves the approximate number of entries in the cache. |
| 756 | * |
| 757 | * **When to use** |
| 758 | * |
| 759 | * Use to inspect how many entries are currently stored in the scoped cache. |
| 760 | * |
| 761 | * **Gotchas** |
| 762 | * |
| 763 | * Note that expired entries are counted until they are accessed and removed. |
| 764 | * The size reflects the current number of entries stored, not the number |
| 765 | * of valid entries. |
| 766 | * |
| 767 | * @category combinators |
| 768 | * @since 4.0.0 |
| 769 | */ |
| 770 | export const size = <Key, A, E, R>(self: ScopedCache<Key, A, E, R>): Effect.Effect<number> => |
| 771 | effect.sync(() => self.state._tag === "Closed" ? 0 : MutableHashMap.size(self.state.map)) |
| 772 | |
| 773 | /** |
| 774 | * Retrieves all active keys from the cache, automatically filtering out expired entries. |
| 775 | * |
| 776 | * **When to use** |
| 777 | * |
| 778 | * Use to inspect currently cached unexpired keys without running cache lookups. |
| 779 | * |
| 780 | * **Gotchas** |
| 781 | * |
| 782 | * Expired entries are removed and their scopes are closed while filtering. |
| 783 | * |
| 784 | * @see {@link entries} for retrieving successful cached key-value pairs |
| 785 | * @see {@link values} for retrieving only successfully cached values |
| 786 | * |
| 787 | * @category combinators |
| 788 | * @since 4.0.0 |
| 789 | */ |
| 790 | export const keys = <Key, A, E, R>(self: ScopedCache<Key, A, E, R>): Effect.Effect<Array<Key>> => |
| 791 | core.withFiber((fiber) => { |
| 792 | if (self.state._tag === "Closed") return effect.succeed([]) |
| 793 | const state = self.state |
| 794 | const now = fiber.getRef(effect.ClockRef).currentTimeMillisUnsafe() |
| 795 | const fibers = Arr.empty<Fiber.Fiber<unknown, unknown>>() |
| 796 | const keys: Array<Key> = [] |
| 797 | for (const [key, entry] of state.map) { |
| 798 | if (entry.expiresAt === undefined || entry.expiresAt > now) { |
| 799 | keys.push(key) |
| 800 | } else { |
| 801 | MutableHashMap.remove(state.map, key) |
| 802 | fibers.push(effect.forkUnsafe(fiber, Scope.close(entry.scope, effect.exitVoid), true, true)) |
| 803 | } |
| 804 | } |
| 805 | return fibers.length === 0 ? effect.succeed(keys) : effect.as(effect.fiberAwaitAll(fibers), keys) |
| 806 | }) |
| 807 | |
| 808 | /** |
| 809 | * Retrieves all successfully cached values from the cache, excluding failed |
| 810 | * lookups and expired entries. |
| 811 | * |
| 812 | * **When to use** |
| 813 | * |
| 814 | * Use to inspect currently successful cached values without running cache |
| 815 | * lookups. |
| 816 | * |
| 817 | * **Gotchas** |
| 818 | * |
| 819 | * Expired entries are removed and their scopes are closed while filtering. |
| 820 | * |
| 821 | * @see {@link entries} for retrieving successful cached key-value pairs |
| 822 | * @see {@link keys} for retrieving only cached keys |
| 823 | * |
| 824 | * @category combinators |
| 825 | * @since 4.0.0 |
| 826 | */ |
| 827 | export const values = <Key, A, E, R>(self: ScopedCache<Key, A, E, R>): Effect.Effect<Array<A>> => |
| 828 | effect.map(entries(self), Arr.map(([, value]) => value)) |
| 829 | |
| 830 | /** |
| 831 | * Retrieves all key-value pairs from the cache as an array. This function |
| 832 | * only returns entries with successfully resolved values, filtering out any |
| 833 | * failed lookups or expired entries. |
| 834 | * |
| 835 | * **When to use** |
| 836 | * |
| 837 | * Use to inspect the currently successful cached key-value pairs without |
| 838 | * running cache lookups. |
| 839 | * |
| 840 | * **Gotchas** |
| 841 | * |
| 842 | * Expired entries are removed and their scopes are closed while filtering. |
| 843 | * |
| 844 | * @see {@link keys} for retrieving only cached keys |
| 845 | * @see {@link values} for retrieving only cached values |
| 846 | * |
| 847 | * @category combinators |
| 848 | * @since 4.0.0 |
| 849 | */ |
| 850 | export const entries = <Key, A, E, R>(self: ScopedCache<Key, A, E, R>): Effect.Effect<Array<[Key, A]>> => |
| 851 | core.withFiber((fiber) => { |
| 852 | if (self.state._tag === "Closed") return effect.succeed([]) |
| 853 | const state = self.state |
| 854 | const now = fiber.getRef(effect.ClockRef).currentTimeMillisUnsafe() |
| 855 | const fibers = Arr.empty<Fiber.Fiber<unknown, unknown>>() |
| 856 | const arr: Array<[Key, A]> = [] |
| 857 | for (const [key, entry] of state.map) { |
| 858 | if (entry.expiresAt === undefined || entry.expiresAt > now) { |
| 859 | const exit = entry.deferred.effect |
| 860 | if (core.isExit(exit) && !effect.exitIsFailure(exit)) { |
| 861 | arr.push([key, exit.value as A]) |
| 862 | } |
| 863 | } else { |
| 864 | MutableHashMap.remove(state.map, key) |
| 865 | fibers.push(effect.forkUnsafe(fiber, Scope.close(entry.scope, effect.exitVoid), true, true)) |
| 866 | } |
| 867 | } |
| 868 | return fibers.length === 0 |
| 869 | ? effect.succeed(arr) |
| 870 | : effect.as(effect.fiberAwaitAll(fibers), arr) |
| 871 | }) |