From 60bc3d0867b13e48b24dc22604b4dd2e7b2c1ca4 Mon Sep 17 00:00:00 2001 From: Tim Date: Tue, 9 Jul 2024 09:27:50 +1200 Subject: [PATCH] add RcMap & RcRef modules (#3179) Co-authored-by: Tim Smart --- .changeset/five-olives-applaud.md | 25 +++ .changeset/little-taxis-drop.md | 5 + .changeset/spicy-hats-travel.md | 31 ++++ packages/effect/src/Cause.ts | 44 ++++++ packages/effect/src/Duration.ts | 18 +++ packages/effect/src/RcMap.ts | 103 +++++++++++++ packages/effect/src/RcRef.ts | 91 +++++++++++ packages/effect/src/index.ts | 10 ++ packages/effect/src/internal/core.ts | 14 ++ packages/effect/src/internal/rcMap.ts | 213 ++++++++++++++++++++++++++ packages/effect/src/internal/rcRef.ts | 172 +++++++++++++++++++++ packages/effect/test/RcMap.test.ts | 127 +++++++++++++++ packages/effect/test/RcRef.test.ts | 94 ++++++++++++ 13 files changed, 947 insertions(+) create mode 100644 .changeset/five-olives-applaud.md create mode 100644 .changeset/little-taxis-drop.md create mode 100644 .changeset/spicy-hats-travel.md create mode 100644 packages/effect/src/RcMap.ts create mode 100644 packages/effect/src/RcRef.ts create mode 100644 packages/effect/src/internal/rcMap.ts create mode 100644 packages/effect/src/internal/rcRef.ts create mode 100644 packages/effect/test/RcMap.test.ts create mode 100644 packages/effect/test/RcRef.test.ts diff --git a/.changeset/five-olives-applaud.md b/.changeset/five-olives-applaud.md new file mode 100644 index 0000000000..ac148f1349 --- /dev/null +++ b/.changeset/five-olives-applaud.md @@ -0,0 +1,25 @@ +--- +"effect": minor +--- + +add RcRef module + +An `RcRef` wraps a reference counted resource that can be acquired and released multiple times. + +The resource is lazily acquired on the first call to `get` and released when the last reference is released. + +```ts +import { Effect, RcRef } from "effect"; + +Effect.gen(function* () { + const ref = yield* RcRef.make({ + acquire: Effect.acquireRelease(Effect.succeed("foo"), () => + Effect.log("release foo"), + ), + }); + + // will only acquire the resource once, and release it + // when the scope is closed + yield* RcRef.get(ref).pipe(Effect.andThen(RcRef.get(ref)), Effect.scoped); +}); +``` diff --git a/.changeset/little-taxis-drop.md b/.changeset/little-taxis-drop.md new file mode 100644 index 0000000000..fbddf8f058 --- /dev/null +++ b/.changeset/little-taxis-drop.md @@ -0,0 +1,5 @@ +--- +"effect": minor +--- + +add Duration.isZero, for checking if a Duration is zero diff --git a/.changeset/spicy-hats-travel.md b/.changeset/spicy-hats-travel.md new file mode 100644 index 0000000000..071fef7dd4 --- /dev/null +++ b/.changeset/spicy-hats-travel.md @@ -0,0 +1,31 @@ +--- +"effect": minor +--- + +add RcMap module + +An `RcMap` can contain multiple reference counted resources that can be indexed +by a key. The resources are lazily acquired on the first call to `get` and +released when the last reference is released. + +Complex keys can extend `Equal` and `Hash` to allow lookups by value. + +```ts +import { Effect, RcMap } from "effect"; + +Effect.gen(function* () { + const map = yield* RcMap.make({ + lookup: (key: string) => + Effect.acquireRelease(Effect.succeed(`acquired ${key}`), () => + Effect.log(`releasing ${key}`), + ), + }); + + // Get "foo" from the map twice, which will only acquire it once + // It will then be released once the scope closes. + yield* RcMap.get(map, "foo").pipe( + Effect.andThen(RcMap.get(map, "foo")), + Effect.scoped, + ); +}); +``` diff --git a/packages/effect/src/Cause.ts b/packages/effect/src/Cause.ts index 3368592163..eda06b12ab 100644 --- a/packages/effect/src/Cause.ts +++ b/packages/effect/src/Cause.ts @@ -111,6 +111,18 @@ export const InvalidPubSubCapacityExceptionTypeId: unique symbol = core.InvalidP */ export type InvalidPubSubCapacityExceptionTypeId = typeof InvalidPubSubCapacityExceptionTypeId +/** + * @since 3.5.0 + * @category symbols + */ +export const ExceededCapacityExceptionTypeId: unique symbol = core.ExceededCapacityExceptionTypeId + +/** + * @since 3.5.0 + * @category symbols + */ +export type ExceededCapacityExceptionTypeId = typeof ExceededCapacityExceptionTypeId + /** * @since 2.0.0 * @category symbols @@ -264,6 +276,18 @@ export interface InvalidPubSubCapacityException extends YieldableError { readonly [InvalidPubSubCapacityExceptionTypeId]: InvalidPubSubCapacityExceptionTypeId } +/** + * Represents a checked exception which occurs when a resources capacity has + * been exceeded. + * + * @since 3.5.0 + * @category models + */ +export interface ExceededCapacityException extends YieldableError { + readonly _tag: "ExceededCapacityException" + readonly [ExceededCapacityExceptionTypeId]: ExceededCapacityExceptionTypeId +} + /** * Represents a checked exception which occurs when a computation doesn't * finish on schedule. @@ -907,6 +931,26 @@ export const UnknownException: new(error: unknown, message?: string | undefined) */ export const isUnknownException: (u: unknown) => u is UnknownException = core.isUnknownException +/** + * Represents a checked exception which occurs when a resources capacity has + * been exceeded. + * + * @since 3.5.0 + * @category errors + */ +export const ExceededCapacityException: new(message?: string | undefined) => ExceededCapacityException = + core.ExceededCapacityException + +/** + * Returns `true` if the specified value is an `ExceededCapacityException`, `false` + * otherwise. + * + * @since 3.5.0 + * @category refinements + */ +export const isExceededCapacityException: (u: unknown) => u is ExceededCapacityException = + core.isExceededCapacityException + /** * Returns the specified `Cause` as a pretty-printed string. * diff --git a/packages/effect/src/Duration.ts b/packages/effect/src/Duration.ts index 2a64a491f7..0203f384c9 100644 --- a/packages/effect/src/Duration.ts +++ b/packages/effect/src/Duration.ts @@ -200,6 +200,24 @@ export const isDuration = (u: unknown): u is Duration => hasProperty(u, TypeId) */ export const isFinite = (self: Duration): boolean => self.value._tag !== "Infinity" +/** + * @since 3.5.0 + * @category guards + */ +export const isZero = (self: Duration): boolean => { + switch (self.value._tag) { + case "Millis": { + return self.value.millis === 0 + } + case "Nanos": { + return self.value.nanos === bigint0 + } + case "Infinity": { + return false + } + } +} + /** * @since 2.0.0 * @category constructors diff --git a/packages/effect/src/RcMap.ts b/packages/effect/src/RcMap.ts new file mode 100644 index 0000000000..54f0170da7 --- /dev/null +++ b/packages/effect/src/RcMap.ts @@ -0,0 +1,103 @@ +/** + * @since 3.5.0 + */ +import type * as Cause from "./Cause.js" +import type * as Duration from "./Duration.js" +import type * as Effect from "./Effect.js" +import * as internal from "./internal/rcMap.js" +import { type Pipeable } from "./Pipeable.js" +import type * as Scope from "./Scope.js" +import type * as Types from "./Types.js" + +/** + * @since 3.5.0 + * @category type ids + */ +export const TypeId: unique symbol = internal.TypeId + +/** + * @since 3.5.0 + * @category type ids + */ +export type TypeId = typeof TypeId + +/** + * @since 3.5.0 + * @category models + */ +export interface RcMap extends Pipeable { + readonly [TypeId]: RcMap.Variance +} + +/** + * @since 3.5.0 + * @category models + */ +export declare namespace RcMap { + /** + * @since 3.5.0 + * @category models + */ + export interface Variance { + readonly _K: Types.Contravariant + readonly _A: Types.Covariant + readonly _E: Types.Covariant + } +} + +/** + * An `RcMap` can contain multiple reference counted resources that can be indexed + * by a key. The resources are lazily acquired on the first call to `get` and + * released when the last reference is released. + * + * Complex keys can extend `Equal` and `Hash` to allow lookups by value. + * + * @since 3.5.0 + * @category models + * @param capacity The maximum number of resources that can be held in the map. + * @param idleTimeToLive When the reference count reaches zero, the resource will be released after this duration. + * @example + * import { Effect, RcMap } from "effect" + * + * Effect.gen(function*() { + * const map = yield* RcMap.make({ + * lookup: (key: string) => + * Effect.acquireRelease( + * Effect.succeed(`acquired ${key}`), + * () => Effect.log(`releasing ${key}`) + * ) + * }) + * + * // Get "foo" from the map twice, which will only acquire it once. + * // It will then be released once the scope closes. + * yield* RcMap.get(map, "foo").pipe( + * Effect.andThen(RcMap.get(map, "foo")), + * Effect.scoped + * ) + * }) + */ +export const make: { + ( + options: { + readonly lookup: (key: K) => Effect.Effect + readonly idleTimeToLive?: Duration.DurationInput | undefined + readonly capacity?: undefined + } + ): Effect.Effect, never, Scope.Scope | R> + ( + options: { + readonly lookup: (key: K) => Effect.Effect + readonly idleTimeToLive?: Duration.DurationInput | undefined + readonly capacity: number + } + ): Effect.Effect, never, Scope.Scope | R> +} = internal.make + +/** + * @since 3.5.0 + * @category combinators + */ +export const get: { + (key: K): (self: RcMap) => Effect.Effect + (self: RcMap, key: K): Effect.Effect +} = internal.get diff --git a/packages/effect/src/RcRef.ts b/packages/effect/src/RcRef.ts new file mode 100644 index 0000000000..bbdf780a5e --- /dev/null +++ b/packages/effect/src/RcRef.ts @@ -0,0 +1,91 @@ +/** + * @since 3.5.0 + */ +import type * as Duration from "./Duration.js" +import type * as Effect from "./Effect.js" +import * as internal from "./internal/rcRef.js" +import { type Pipeable } from "./Pipeable.js" +import type * as Scope from "./Scope.js" +import type * as Types from "./Types.js" + +/** + * @since 3.5.0 + * @category type ids + */ +export const TypeId: unique symbol = internal.TypeId + +/** + * @since 3.5.0 + * @category type ids + */ +export type TypeId = typeof TypeId + +/** + * @since 3.5.0 + * @category models + */ +export interface RcRef extends Pipeable { + readonly [TypeId]: RcRef.Variance +} + +/** + * @since 3.5.0 + * @category models + */ +export declare namespace RcRef { + /** + * @since 3.5.0 + * @category models + */ + export interface Variance { + readonly _A: Types.Covariant + readonly _E: Types.Covariant + } +} + +/** + * Create an `RcRef` from an acquire `Effect`. + * + * An RcRef wraps a reference counted resource that can be acquired and released + * multiple times. + * + * The resource is lazily acquired on the first call to `get` and released when + * the last reference is released. + * + * @since 3.5.0 + * @category constructors + * @example + * import { Effect, RcRef } from "effect" + * + * Effect.gen(function*() { + * const ref = yield* RcRef.make({ + * acquire: Effect.acquireRelease( + * Effect.succeed("foo"), + * () => Effect.log("release foo") + * ) + * }) + * + * // will only acquire the resource once, and release it + * // when the scope is closed + * yield* RcRef.get(ref).pipe( + * Effect.andThen(RcRef.get(ref)), + * Effect.scoped + * ) + * }) + */ +export const make: ( + options: { + readonly acquire: Effect.Effect + /** + * When the reference count reaches zero, the resource will be released + * after this duration. + */ + readonly idleTimeToLive?: Duration.DurationInput | undefined + } +) => Effect.Effect, never, R | Scope.Scope> = internal.make + +/** + * @since 3.5.0 + * @category combinators + */ +export const get: (self: RcRef) => Effect.Effect = internal.get diff --git a/packages/effect/src/index.ts b/packages/effect/src/index.ts index 2eb6e1273a..eb5ce51c2d 100644 --- a/packages/effect/src/index.ts +++ b/packages/effect/src/index.ts @@ -605,6 +605,16 @@ export * as Random from "./Random.js" */ export * as RateLimiter from "./RateLimiter.js" +/** + * @since 3.5.0 + */ +export * as RcMap from "./RcMap.js" + +/** + * @since 3.5.0 + */ +export * as RcRef from "./RcRef.js" + /** * @since 2.0.0 */ diff --git a/packages/effect/src/internal/core.ts b/packages/effect/src/internal/core.ts index 7230d04c81..2385a82524 100644 --- a/packages/effect/src/internal/core.ts +++ b/packages/effect/src/internal/core.ts @@ -2270,6 +2270,20 @@ export const InvalidPubSubCapacityException = makeException({ + [ExceededCapacityExceptionTypeId]: ExceededCapacityExceptionTypeId +}, "ExceededCapacityException") + +/** @internal */ +export const isExceededCapacityException = (u: unknown): u is Cause.ExceededCapacityException => + hasProperty(u, ExceededCapacityExceptionTypeId) + /** @internal */ export const isInvalidCapacityError = (u: unknown): u is Cause.InvalidPubSubCapacityException => hasProperty(u, InvalidPubSubCapacityExceptionTypeId) diff --git a/packages/effect/src/internal/rcMap.ts b/packages/effect/src/internal/rcMap.ts new file mode 100644 index 0000000000..566d8e9476 --- /dev/null +++ b/packages/effect/src/internal/rcMap.ts @@ -0,0 +1,213 @@ +import type * as Cause from "../Cause.js" +import * as Context from "../Context.js" +import type * as Deferred from "../Deferred.js" +import * as Duration from "../Duration.js" +import type { Effect } from "../Effect.js" +import type { RuntimeFiber } from "../Fiber.js" +import { dual, identity } from "../Function.js" +import * as MutableHashMap from "../MutableHashMap.js" +import { pipeArguments } from "../Pipeable.js" +import type * as RcMap from "../RcMap.js" +import type * as Scope from "../Scope.js" +import * as coreEffect from "./core-effect.js" +import * as core from "./core.js" +import * as circular from "./effect/circular.js" +import * as fiberRuntime from "./fiberRuntime.js" + +/** @internal */ +export const TypeId: RcMap.TypeId = Symbol.for("effect/RcMap") as RcMap.TypeId + +type State = State.Open | State.Closed + +declare namespace State { + interface Open { + readonly _tag: "Open" + readonly map: MutableHashMap.MutableHashMap> + } + + interface Closed { + readonly _tag: "Closed" + } + + interface Entry { + readonly deferred: Deferred.Deferred + readonly scope: Scope.CloseableScope + fiber: RuntimeFiber | undefined + refCount: number + } +} + +const variance: RcMap.RcMap.Variance = { + _K: identity, + _A: identity, + _E: identity +} + +class RcMapImpl implements RcMap.RcMap { + readonly [TypeId]: RcMap.RcMap.Variance + + state: State = { + _tag: "Open", + map: MutableHashMap.empty() + } + readonly semaphore = circular.unsafeMakeSemaphore(1) + + constructor( + readonly lookup: (key: K) => Effect, + readonly context: Context.Context, + readonly scope: Scope.Scope, + readonly idleTimeToLive: Duration.Duration | undefined, + readonly capacity: number + ) { + this[TypeId] = variance + } + + pipe() { + return pipeArguments(this, arguments) + } +} + +/** @internal */ +export const make: { + (options: { + readonly lookup: (key: K) => Effect + readonly idleTimeToLive?: Duration.DurationInput | undefined + readonly capacity?: undefined + }): Effect, never, Scope.Scope | R> + (options: { + readonly lookup: (key: K) => Effect + readonly idleTimeToLive?: Duration.DurationInput | undefined + readonly capacity: number + }): Effect, never, Scope.Scope | R> +} = (options: { + readonly lookup: (key: K) => Effect + readonly idleTimeToLive?: Duration.DurationInput | undefined + readonly capacity?: number | undefined +}) => + core.withFiberRuntime, never, R | Scope.Scope>((fiber) => { + const context = fiber.getFiberRef(core.currentContext) as Context.Context + const scope = Context.get(context, fiberRuntime.scopeTag) + const self = new RcMapImpl( + options.lookup as any, + context, + scope, + options.idleTimeToLive ? Duration.decode(options.idleTimeToLive) : undefined, + Math.max(options.capacity ?? Number.POSITIVE_INFINITY, 0) + ) + return core.as( + scope.addFinalizer(() => + core.suspend(() => { + if (self.state._tag === "Closed") { + return core.void + } + const map = self.state.map + self.state = { _tag: "Closed" } + return core.forEachSequentialDiscard( + map, + ([, entry]) => core.scopeClose(entry.scope, core.exitVoid) + ).pipe( + core.tap(() => { + MutableHashMap.clear(map) + }), + self.semaphore.withPermits(1) + ) + }) + ), + self + ) + }) + +/** @internal */ +export const get: { + (key: K): (self: RcMap.RcMap) => Effect + (self: RcMap.RcMap, key: K): Effect +} = dual( + 2, + (self_: RcMap.RcMap, key: K): Effect => { + const self = self_ as RcMapImpl + return core.uninterruptibleMask((restore) => + core.suspend(() => { + if (self.state._tag === "Closed") { + return core.interrupt + } + const state = self.state + const o = MutableHashMap.get(state.map, key) + if (o._tag === "Some") { + const entry = o.value + entry.refCount++ + return entry.fiber + ? core.as(core.interruptFiber(entry.fiber), entry) + : core.succeed(entry) + } else if (Number.isFinite(self.capacity) && MutableHashMap.size(self.state.map) >= self.capacity) { + return core.fail( + new core.ExceededCapacityException(`RcMap attempted to exceed capacity of ${self.capacity}`) + ) as Effect + } + const acquire = self.lookup(key) + return fiberRuntime.scopeMake().pipe( + coreEffect.bindTo("scope"), + coreEffect.bind("deferred", () => core.deferredMake()), + core.tap(({ deferred, scope }) => + restore(core.fiberRefLocally( + acquire as Effect, + core.currentContext, + Context.add(self.context, fiberRuntime.scopeTag, scope) + )).pipe( + core.exit, + core.flatMap((exit) => core.deferredDone(deferred, exit)), + circular.forkIn(scope) + ) + ), + core.map(({ deferred, scope }) => { + const entry: State.Entry = { + deferred, + scope, + fiber: undefined, + refCount: 1 + } + MutableHashMap.set(state.map, key, entry) + return entry + }) + ) + }).pipe( + self.semaphore.withPermits(1), + coreEffect.bindTo("entry"), + coreEffect.bind("scope", () => fiberRuntime.scopeTag), + core.tap(({ entry, scope }) => + scope.addFinalizer(() => + core.suspend(() => { + entry.refCount-- + if (entry.refCount > 0) { + return core.void + } else if (self.idleTimeToLive === undefined) { + if (self.state._tag === "Open") { + MutableHashMap.remove(self.state.map, key) + } + return core.scopeClose(entry.scope, core.exitVoid) + } + return coreEffect.sleep(self.idleTimeToLive).pipe( + core.interruptible, + core.zipRight(core.suspend(() => { + if (self.state._tag === "Open" && entry.refCount === 0) { + MutableHashMap.remove(self.state.map, key) + return core.scopeClose(entry.scope, core.exitVoid) + } + return core.void + })), + fiberRuntime.ensuring(core.sync(() => { + entry.fiber = undefined + })), + circular.forkIn(self.scope), + core.tap((fiber) => { + entry.fiber = fiber + }), + self.semaphore.withPermits(1) + ) + }) + ) + ), + core.flatMap(({ entry }) => restore(core.deferredAwait(entry.deferred))) + ) + ) + } +) diff --git a/packages/effect/src/internal/rcRef.ts b/packages/effect/src/internal/rcRef.ts new file mode 100644 index 0000000000..8fe9841397 --- /dev/null +++ b/packages/effect/src/internal/rcRef.ts @@ -0,0 +1,172 @@ +import * as Context from "../Context.js" +import * as Duration from "../Duration.js" +import type { Effect } from "../Effect.js" +import type { RuntimeFiber } from "../Fiber.js" +import { identity } from "../Function.js" +import { pipeArguments } from "../Pipeable.js" +import type * as RcRef from "../RcRef.js" +import type * as Scope from "../Scope.js" +import * as coreEffect from "./core-effect.js" +import * as core from "./core.js" +import * as circular from "./effect/circular.js" +import * as fiberRuntime from "./fiberRuntime.js" + +/** @internal */ +export const TypeId: RcRef.TypeId = Symbol.for("effect/RcRef") as RcRef.TypeId + +type State = State.Empty | State.Acquired | State.Closed + +declare namespace State { + interface Empty { + readonly _tag: "Empty" + } + + interface Acquired { + readonly _tag: "Acquired" + readonly value: A + readonly scope: Scope.CloseableScope + fiber: RuntimeFiber | undefined + refCount: number + } + + interface Closed { + readonly _tag: "Closed" + } +} + +const stateEmpty: State = { _tag: "Empty" } +const stateClosed: State = { _tag: "Closed" } + +const variance: RcRef.RcRef.Variance = { + _A: identity, + _E: identity +} + +class RcRefImpl implements RcRef.RcRef { + readonly [TypeId]: RcRef.RcRef.Variance + + state: State = stateEmpty + readonly semaphore = circular.unsafeMakeSemaphore(1) + + constructor( + readonly acquire: Effect, + readonly context: Context.Context, + readonly scope: Scope.Scope, + readonly idleTimeToLive: Duration.Duration | undefined + ) { + this[TypeId] = variance + } + + pipe() { + return pipeArguments(this, arguments) + } +} + +/** @internal */ +export const make = (options: { + readonly acquire: Effect + readonly idleTimeToLive?: Duration.DurationInput | undefined +}) => + core.withFiberRuntime, never, R | Scope.Scope>((fiber) => { + const context = fiber.getFiberRef(core.currentContext) as Context.Context + const scope = Context.get(context, fiberRuntime.scopeTag) + const ref = new RcRefImpl( + options.acquire as Effect, + context, + scope, + options.idleTimeToLive ? Duration.decode(options.idleTimeToLive) : undefined + ) + return core.as( + scope.addFinalizer(() => + ref.semaphore.withPermits(1)(core.suspend(() => { + const close = ref.state._tag === "Acquired" + ? core.scopeClose(ref.state.scope, core.exitVoid) + : core.void + ref.state = stateClosed + return close + })) + ), + ref + ) + }) + +/** @internal */ +export const get = ( + self_: RcRef.RcRef +): Effect => { + const self = self_ as RcRefImpl + return core.uninterruptibleMask((restore) => + core.suspend(() => { + switch (self.state._tag) { + case "Closed": { + return core.interrupt + } + case "Acquired": { + self.state.refCount++ + return self.state.fiber + ? core.as(core.interruptFiber(self.state.fiber), self.state) + : core.succeed(self.state) + } + case "Empty": { + return fiberRuntime.scopeMake().pipe( + coreEffect.bindTo("scope"), + coreEffect.bind("value", ({ scope }) => + restore(core.fiberRefLocally( + self.acquire as Effect, + core.currentContext, + Context.add(self.context, fiberRuntime.scopeTag, scope) + ))), + core.map(({ scope, value }) => { + const state: State.Acquired = { + _tag: "Acquired", + value, + scope, + fiber: undefined, + refCount: 1 + } + self.state = state + return state + }) + ) + } + } + }) + ).pipe( + self.semaphore.withPermits(1), + coreEffect.bindTo("state"), + coreEffect.bind("scope", () => fiberRuntime.scopeTag), + core.tap(({ scope, state }) => + scope.addFinalizer(() => + core.suspend(() => { + state.refCount-- + if (state.refCount > 0) { + return core.void + } + if (self.idleTimeToLive === undefined) { + self.state = stateEmpty + return core.scopeClose(state.scope, core.exitVoid) + } + return coreEffect.sleep(self.idleTimeToLive).pipe( + core.interruptible, + core.zipRight(core.suspend(() => { + if (self.state._tag === "Acquired" && self.state.refCount === 0) { + self.state = stateEmpty + return core.scopeClose(state.scope, core.exitVoid) + } + return core.void + })), + fiberRuntime.ensuring(core.sync(() => { + state.fiber = undefined + })), + circular.forkIn(self.scope), + core.tap((fiber) => { + state.fiber = fiber + }), + self.semaphore.withPermits(1) + ) + }) + ) + ), + core.map(({ state }) => state.value) + ) +} diff --git a/packages/effect/test/RcMap.test.ts b/packages/effect/test/RcMap.test.ts new file mode 100644 index 0000000000..7e353879f7 --- /dev/null +++ b/packages/effect/test/RcMap.test.ts @@ -0,0 +1,127 @@ +import { Cause, Data, Effect, Exit, RcMap, Scope, TestClock } from "effect" +import { assert, describe, it } from "effect/test/utils/extend" + +describe("RcMap", () => { + it.effect("deallocation", () => + Effect.gen(function*() { + const acquired: Array = [] + const released: Array = [] + const mapScope = yield* Scope.make() + const map = yield* RcMap.make({ + lookup: (key: string) => + Effect.acquireRelease( + Effect.sync(() => { + acquired.push(key) + return key + }), + () => Effect.sync(() => released.push(key)) + ) + }).pipe( + Scope.extend(mapScope) + ) + + assert.deepStrictEqual(acquired, []) + assert.strictEqual(yield* Effect.scoped(RcMap.get(map, "foo")), "foo") + assert.deepStrictEqual(acquired, ["foo"]) + assert.deepStrictEqual(released, ["foo"]) + + const scopeA = yield* Scope.make() + const scopeB = yield* Scope.make() + yield* RcMap.get(map, "bar").pipe(Scope.extend(scopeA)) + yield* Effect.scoped(RcMap.get(map, "bar")) + yield* RcMap.get(map, "baz").pipe(Scope.extend(scopeB)) + yield* Effect.scoped(RcMap.get(map, "baz")) + assert.deepStrictEqual(acquired, ["foo", "bar", "baz"]) + assert.deepStrictEqual(released, ["foo"]) + yield* Scope.close(scopeB, Exit.void) + assert.deepStrictEqual(acquired, ["foo", "bar", "baz"]) + assert.deepStrictEqual(released, ["foo", "baz"]) + yield* Scope.close(scopeA, Exit.void) + assert.deepStrictEqual(acquired, ["foo", "bar", "baz"]) + assert.deepStrictEqual(released, ["foo", "baz", "bar"]) + + const scopeC = yield* Scope.make() + yield* RcMap.get(map, "qux").pipe(Scope.extend(scopeC)) + assert.deepStrictEqual(acquired, ["foo", "bar", "baz", "qux"]) + assert.deepStrictEqual(released, ["foo", "baz", "bar"]) + + yield* Scope.close(mapScope, Exit.void) + assert.deepStrictEqual(acquired, ["foo", "bar", "baz", "qux"]) + assert.deepStrictEqual(released, ["foo", "baz", "bar", "qux"]) + + const exit = yield* RcMap.get(map, "boom").pipe(Effect.scoped, Effect.exit) + assert.isTrue(Exit.isInterrupted(exit)) + })) + + it.scoped("idleTimeToLive", () => + Effect.gen(function*() { + const acquired: Array = [] + const released: Array = [] + const map = yield* RcMap.make({ + lookup: (key: string) => + Effect.acquireRelease( + Effect.sync(() => { + acquired.push(key) + return key + }), + () => Effect.sync(() => released.push(key)) + ), + idleTimeToLive: 1000 + }) + + assert.deepStrictEqual(acquired, []) + assert.strictEqual(yield* Effect.scoped(RcMap.get(map, "foo")), "foo") + assert.deepStrictEqual(acquired, ["foo"]) + assert.deepStrictEqual(released, []) + + yield* TestClock.adjust(1000) + assert.deepStrictEqual(released, ["foo"]) + + assert.strictEqual(yield* Effect.scoped(RcMap.get(map, "bar")), "bar") + assert.deepStrictEqual(acquired, ["foo", "bar"]) + assert.deepStrictEqual(released, ["foo"]) + + yield* TestClock.adjust(500) + assert.strictEqual(yield* Effect.scoped(RcMap.get(map, "bar")), "bar") + assert.deepStrictEqual(acquired, ["foo", "bar"]) + assert.deepStrictEqual(released, ["foo"]) + + yield* TestClock.adjust(1000) + assert.deepStrictEqual(released, ["foo", "bar"]) + })) + + it.scoped("capacity", () => + Effect.gen(function*() { + const map = yield* RcMap.make({ + lookup: (key: string) => Effect.succeed(key), + capacity: 2, + idleTimeToLive: 1000 + }) + + assert.strictEqual(yield* Effect.scoped(RcMap.get(map, "foo")), "foo") + assert.strictEqual(yield* Effect.scoped(RcMap.get(map, "foo")), "foo") + assert.strictEqual(yield* Effect.scoped(RcMap.get(map, "bar")), "bar") + + const exit = yield* RcMap.get(map, "baz").pipe(Effect.scoped, Effect.exit) + assert.deepStrictEqual( + exit, + Exit.fail(new Cause.ExceededCapacityException(`RcMap attempted to exceed capacity of 2`)) + ) + + yield* TestClock.adjust(1000) + assert.strictEqual(yield* Effect.scoped(RcMap.get(map, "baz")), "baz") + })) + + it.scoped("complex key", () => + Effect.gen(function*() { + class Key extends Data.Class<{ readonly id: number }> {} + const map = yield* RcMap.make({ + lookup: (key: Key) => Effect.succeed(key.id), + capacity: 1 + }) + + assert.strictEqual(yield* RcMap.get(map, new Key({ id: 1 })), 1) + // no failure means a hit + assert.strictEqual(yield* RcMap.get(map, new Key({ id: 1 })), 1) + })) +}) diff --git a/packages/effect/test/RcRef.test.ts b/packages/effect/test/RcRef.test.ts new file mode 100644 index 0000000000..c3c439cf2b --- /dev/null +++ b/packages/effect/test/RcRef.test.ts @@ -0,0 +1,94 @@ +import { Effect, Exit, RcRef, Scope, TestClock } from "effect" +import { assert, describe, it } from "effect/test/utils/extend" + +describe("RcRef", () => { + it.effect("deallocation", () => + Effect.gen(function*() { + let acquired = 0 + let released = 0 + const refScope = yield* Scope.make() + const ref = yield* RcRef.make({ + acquire: Effect.acquireRelease( + Effect.sync(() => { + acquired++ + return "foo" + }), + () => + Effect.sync(() => { + released++ + }) + ) + }).pipe( + Scope.extend(refScope) + ) + + assert.strictEqual(acquired, 0) + assert.strictEqual(yield* Effect.scoped(RcRef.get(ref)), "foo") + assert.strictEqual(acquired, 1) + assert.strictEqual(released, 1) + + const scopeA = yield* Scope.make() + const scopeB = yield* Scope.make() + yield* RcRef.get(ref).pipe(Scope.extend(scopeA)) + yield* RcRef.get(ref).pipe(Scope.extend(scopeB)) + assert.strictEqual(acquired, 2) + assert.strictEqual(released, 1) + yield* Scope.close(scopeB, Exit.void) + assert.strictEqual(acquired, 2) + assert.strictEqual(released, 1) + yield* Scope.close(scopeA, Exit.void) + assert.strictEqual(acquired, 2) + assert.strictEqual(released, 2) + + const scopeC = yield* Scope.make() + yield* RcRef.get(ref).pipe(Scope.extend(scopeC)) + assert.strictEqual(acquired, 3) + assert.strictEqual(released, 2) + + yield* Scope.close(refScope, Exit.void) + assert.strictEqual(acquired, 3) + assert.strictEqual(released, 3) + + const exit = yield* RcRef.get(ref).pipe(Effect.scoped, Effect.exit) + assert.isTrue(Exit.isInterrupted(exit)) + })) + + it.scoped("idleTimeToLive", () => + Effect.gen(function*() { + let acquired = 0 + let released = 0 + const ref = yield* RcRef.make({ + acquire: Effect.acquireRelease( + Effect.sync(() => { + acquired++ + return "foo" + }), + () => + Effect.sync(() => { + released++ + }) + ), + idleTimeToLive: 1000 + }) + + assert.strictEqual(acquired, 0) + assert.strictEqual(yield* Effect.scoped(RcRef.get(ref)), "foo") + assert.strictEqual(acquired, 1) + assert.strictEqual(released, 0) + + yield* TestClock.adjust(1000) + assert.strictEqual(released, 1) + + assert.strictEqual(yield* Effect.scoped(RcRef.get(ref)), "foo") + assert.strictEqual(acquired, 2) + assert.strictEqual(released, 1) + + yield* TestClock.adjust(500) + assert.strictEqual(yield* Effect.scoped(RcRef.get(ref)), "foo") + assert.strictEqual(acquired, 2) + assert.strictEqual(released, 1) + + yield* TestClock.adjust(1000) + assert.strictEqual(released, 2) + })) +})