diff --git a/.changeset/dirty-ducks-double.md b/.changeset/dirty-ducks-double.md new file mode 100644 index 0000000000..533bfed1b3 --- /dev/null +++ b/.changeset/dirty-ducks-double.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +expose Layer MemoMap apis diff --git a/.changeset/tricky-timers-guess.md b/.changeset/tricky-timers-guess.md new file mode 100644 index 0000000000..ac1ca5ab8e --- /dev/null +++ b/.changeset/tricky-timers-guess.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +fix memoization of Layer.effect/scoped diff --git a/docs/modules/Layer.ts.md b/docs/modules/Layer.ts.md index 6b755f48f8..f9036fc764 100644 --- a/docs/modules/Layer.ts.md +++ b/docs/modules/Layer.ts.md @@ -77,8 +77,12 @@ Added in v2.0.0 - [discard](#discard) - [map](#map) - [mapError](#maperror) +- [memo map](#memo-map) + - [buildWithMemoMap](#buildwithmemomap) + - [makeMemoMap](#makememomap) - [models](#models) - [Layer (interface)](#layer-interface) + - [MemoMap (interface)](#memomap-interface) - [requests & batching](#requests--batching) - [setRequestBatching](#setrequestbatching) - [setRequestCache](#setrequestcache) @@ -96,6 +100,8 @@ Added in v2.0.0 - [symbols](#symbols) - [LayerTypeId](#layertypeid) - [LayerTypeId (type alias)](#layertypeid-type-alias) + - [MemoMapTypeId](#memomaptypeid) + - [MemoMapTypeId (type alias)](#memomaptypeid-type-alias) - [tracing](#tracing) - [parentSpan](#parentspan) - [setTracer](#settracer) @@ -729,6 +735,43 @@ export declare const mapError: { Added in v2.0.0 +# memo map + +## buildWithMemoMap + +Builds a layer into an `Effect` value, using the specified `MemoMap` to memoize +the layer construction. + +**Signature** + +```ts +export declare const buildWithMemoMap: { + ( + memoMap: MemoMap, + scope: Scope.Scope + ): (self: Layer) => Effect.Effect> + ( + self: Layer, + memoMap: MemoMap, + scope: Scope.Scope + ): Effect.Effect> +} +``` + +Added in v2.0.0 + +## makeMemoMap + +Constructs a `MemoMap` that can be used to build additional layers. + +**Signature** + +```ts +export declare const makeMemoMap: Effect.Effect +``` + +Added in v2.0.0 + # models ## Layer (interface) @@ -741,6 +784,24 @@ export interface Layer extends Layer.Variance( + layer: Layer, + scope: Scope.Scope + ) => Effect.Effect> +} +``` + +Added in v2.0.0 + # requests & batching ## setRequestBatching @@ -929,6 +990,26 @@ export type LayerTypeId = typeof LayerTypeId Added in v2.0.0 +## MemoMapTypeId + +**Signature** + +```ts +export declare const MemoMapTypeId: typeof MemoMapTypeId +``` + +Added in v2.0.0 + +## MemoMapTypeId (type alias) + +**Signature** + +```ts +export type MemoMapTypeId = typeof MemoMapTypeId +``` + +Added in v2.0.0 + # tracing ## parentSpan diff --git a/src/Layer.ts b/src/Layer.ts index 3ccc5c947d..2c1412eeb6 100644 --- a/src/Layer.ts +++ b/src/Layer.ts @@ -93,6 +93,32 @@ export declare namespace Layer { export type Success> = [T] extends [Layer] ? _A : never } +/** + * @since 2.0.0 + * @category symbols + */ +export const MemoMapTypeId: unique symbol = internal.MemoMapTypeId + +/** + * @since 2.0.0 + * @category symbols + */ +export type MemoMapTypeId = typeof MemoMapTypeId + +/** + * @since 2.0.0 + * @category models + */ +export interface MemoMap { + readonly [MemoMapTypeId]: MemoMapTypeId + + /** @internal */ + readonly getOrElseMemoize: ( + layer: Layer, + scope: Scope.Scope + ) => Effect.Effect> +} + /** * Returns `true` if the specified value is a `Layer`, `false` otherwise. * @@ -984,3 +1010,34 @@ export const withParentSpan: { (span: Tracer.ParentSpan): (self: Layer) => Layer, E, A> (self: Layer, span: Tracer.ParentSpan): Layer, E, A> } = internal.withParentSpan + +// ----------------------------------------------------------------------------- +// memo map +// ----------------------------------------------------------------------------- + +/** + * Constructs a `MemoMap` that can be used to build additional layers. + * + * @since 2.0.0 + * @category memo map + */ +export const makeMemoMap: Effect.Effect = internal.makeMemoMap + +/** + * Builds a layer into an `Effect` value, using the specified `MemoMap` to memoize + * the layer construction. + * + * @since 2.0.0 + * @category memo map + */ +export const buildWithMemoMap: { + ( + memoMap: MemoMap, + scope: Scope.Scope + ): (self: Layer) => Effect.Effect> + ( + self: Layer, + memoMap: MemoMap, + scope: Scope.Scope + ): Effect.Effect> +} = internal.buildWithMemoMap diff --git a/src/internal/layer.ts b/src/internal/layer.ts index 2569db2192..e4cd5ebe91 100644 --- a/src/internal/layer.ts +++ b/src/internal/layer.ts @@ -55,6 +55,14 @@ const proto = { } } +/** @internal */ +const MemoMapTypeIdKey = "effect/Layer/MemoMap" + +/** @internal */ +export const MemoMapTypeId: Layer.MemoMapTypeId = Symbol.for( + MemoMapTypeIdKey +) as Layer.MemoMapTypeId + /** @internal */ export type Primitive = | ExtendScope @@ -164,7 +172,8 @@ export const isFresh = (self: Layer.Layer): boolean => { // ----------------------------------------------------------------------------- /** @internal */ -class MemoMap { +class MemoMapImpl implements Layer.MemoMap { + readonly [MemoMapTypeId]: Layer.MemoMapTypeId constructor( readonly ref: Synchronized.SynchronizedRef< Map< @@ -173,6 +182,7 @@ class MemoMap { > > ) { + this[MemoMapTypeId] = MemoMapTypeId } /** @@ -214,7 +224,7 @@ class MemoMap { core.flatMap((innerScope) => pipe( restore(core.flatMap( - withScope(layer, innerScope), + makeBuilder(layer, innerScope, true), (f) => effect.diffFiberRefs(f(this)) )), core.exit, @@ -241,7 +251,8 @@ class MemoMap { core.zipRight( core.scopeAddFinalizerExit(scope, (exit) => pipe( - ref.get(finalizerRef), + core.sync(() => map.delete(layer)), + core.zipRight(ref.get(finalizerRef)), core.flatMap((finalizer) => finalizer(exit)) )) ), @@ -285,7 +296,8 @@ class MemoMap { } } -const makeMemoMap = (): Effect.Effect => +/** @internal */ +export const makeMemoMap: Effect.Effect = core.suspend(() => core.map( circular.makeSynchronized< Map< @@ -296,8 +308,9 @@ const makeMemoMap = (): Effect.Effect => ] > >(new Map()), - (ref) => new MemoMap(ref) + (ref) => new MemoMapImpl(ref) ) +) /** @internal */ export const build = ( @@ -316,28 +329,42 @@ export const buildWithScope = dual< ) => Effect.Effect> >(2, (self, scope) => core.flatMap( - makeMemoMap(), - (memoMap) => core.flatMap(withScope(self, scope), (run) => run(memoMap)) + makeMemoMap, + (memoMap) => core.flatMap(makeBuilder(self, scope), (run) => run(memoMap)) )) -const withScope = ( +/** @internal */ +export const buildWithMemoMap = dual< + ( + memoMap: Layer.MemoMap, + scope: Scope.Scope + ) => (self: Layer.Layer) => Effect.Effect>, + ( + self: Layer.Layer, + memoMap: Layer.MemoMap, + scope: Scope.Scope + ) => Effect.Effect> +>(3, (self, memoMap, scope) => core.flatMap(makeBuilder(self, scope), (run) => run(memoMap))) + +const makeBuilder = ( self: Layer.Layer, - scope: Scope.Scope -): Effect.Effect Effect.Effect>> => { + scope: Scope.Scope, + inMemoMap = false +): Effect.Effect Effect.Effect>> => { const op = self as Primitive switch (op._tag) { case "Locally": { - return core.sync(() => (memoMap: MemoMap) => op.f(memoMap.getOrElseMemoize(op.self, scope))) + return core.sync(() => (memoMap: Layer.MemoMap) => op.f(memoMap.getOrElseMemoize(op.self, scope))) } case "ExtendScope": { - return core.sync(() => (memoMap: MemoMap) => + return core.sync(() => (memoMap: Layer.MemoMap) => fiberRuntime.scopeWith( (scope) => memoMap.getOrElseMemoize(op.layer, scope) ) as unknown as Effect.Effect> ) } case "Fold": { - return core.sync(() => (memoMap: MemoMap) => + return core.sync(() => (memoMap: Layer.MemoMap) => pipe( memoMap.getOrElseMemoize(op.layer, scope), core.matchCauseEffect({ @@ -348,13 +375,15 @@ const withScope = ( ) } case "Fresh": { - return core.sync(() => (_: MemoMap) => pipe(op.layer, buildWithScope(scope))) + return core.sync(() => (_: Layer.MemoMap) => pipe(op.layer, buildWithScope(scope))) } case "FromEffect": { - return core.sync(() => (_: MemoMap) => op.effect as Effect.Effect>) + return inMemoMap + ? core.sync(() => (_: Layer.MemoMap) => op.effect as Effect.Effect>) + : core.sync(() => (memoMap: Layer.MemoMap) => memoMap.getOrElseMemoize(self, scope)) } case "Provide": { - return core.sync(() => (memoMap: MemoMap) => + return core.sync(() => (memoMap: Layer.MemoMap) => pipe( memoMap.getOrElseMemoize(op.first, scope), core.flatMap((env) => @@ -367,15 +396,17 @@ const withScope = ( ) } case "Scoped": { - return core.sync(() => (_: MemoMap) => - fiberRuntime.scopeExtend( - op.effect as Effect.Effect>, - scope + return inMemoMap + ? core.sync(() => (_: Layer.MemoMap) => + fiberRuntime.scopeExtend( + op.effect as Effect.Effect>, + scope + ) ) - ) + : core.sync(() => (memoMap: Layer.MemoMap) => memoMap.getOrElseMemoize(self, scope)) } case "Suspend": { - return core.sync(() => (memoMap: MemoMap) => + return core.sync(() => (memoMap: Layer.MemoMap) => memoMap.getOrElseMemoize( op.evaluate(), scope @@ -383,7 +414,7 @@ const withScope = ( ) } case "ProvideMerge": { - return core.sync(() => (memoMap: MemoMap) => + return core.sync(() => (memoMap: Layer.MemoMap) => pipe( memoMap.getOrElseMemoize(op.first, scope), core.zipWith( @@ -394,7 +425,7 @@ const withScope = ( ) } case "ZipWith": { - return core.sync(() => (memoMap: MemoMap) => + return core.sync(() => (memoMap: Layer.MemoMap) => pipe( memoMap.getOrElseMemoize(op.first, scope), fiberRuntime.zipWithOptions( @@ -828,9 +859,7 @@ export const scoped = dual< /** @internal */ export const scopedDiscard = ( effect: Effect.Effect -): Layer.Layer, E, never> => { - return scopedContext(pipe(effect, core.as(Context.empty()))) -} +): Layer.Layer, E, never> => scopedContext(pipe(effect, core.as(Context.empty()))) /** @internal */ export const scopedContext = ( @@ -856,9 +885,7 @@ export const scope: Layer.Layer = scopedCon /** @internal */ export const service = >( tag: T -): Layer.Layer, never, Context.Tag.Identifier> => { - return fromEffect(tag, tag) -} +): Layer.Layer, never, Context.Tag.Identifier> => fromEffect(tag, tag) /** @internal */ export const succeed = dual< diff --git a/test/Layer.test.ts b/test/Layer.test.ts index fb5a6e1322..3d06cf2ebf 100644 --- a/test/Layer.test.ts +++ b/test/Layer.test.ts @@ -11,6 +11,7 @@ import { identity } from "effect/Function" import * as Layer from "effect/Layer" import * as Ref from "effect/Ref" import * as Schedule from "effect/Schedule" +import * as Scope from "effect/Scope" import { assert, describe } from "vitest" export const acquire1 = "Acquiring Module 1" @@ -662,6 +663,49 @@ describe.concurrent("Layer", () => { const result = Context.get(env, BarTag) assert.strictEqual(result.bar, "bar: 1") })) + + describe("MemoMap", () => { + it.effect("memoizes layer across builds", () => + Effect.gen(function*($) { + const ref = yield* $(makeRef()) + const layer1 = makeLayer1(ref) + const layer2 = makeLayer2(ref).pipe( + Layer.provide(layer1) + ) + const memoMap = yield* $(Layer.makeMemoMap) + const scope1 = yield* $(Scope.make()) + const scope2 = yield* $(Scope.make()) + + yield* $(Layer.buildWithMemoMap(layer1, memoMap, scope1)) + yield* $(Layer.buildWithMemoMap(layer2, memoMap, scope2)) + yield* $(Scope.close(scope2, Exit.unit)) + yield* $(Layer.buildWithMemoMap(layer2, memoMap, scope1)) + yield* $(Scope.close(scope1, Exit.unit)) + + const result = yield* $(Ref.get(ref)) + assert.deepStrictEqual(Array.from(result), [acquire1, acquire2, release2, acquire2, release2, release1]) + })) + + it.effect("layers are not released early", () => + Effect.gen(function*($) { + const ref = yield* $(makeRef()) + const layer1 = makeLayer1(ref) + const layer2 = makeLayer2(ref).pipe( + Layer.provide(layer1) + ) + const memoMap = yield* $(Layer.makeMemoMap) + const scope1 = yield* $(Scope.make()) + const scope2 = yield* $(Scope.make()) + + yield* $(Layer.buildWithMemoMap(layer1, memoMap, scope1)) + yield* $(Layer.buildWithMemoMap(layer2, memoMap, scope2)) + yield* $(Scope.close(scope1, Exit.unit)) + yield* $(Scope.close(scope2, Exit.unit)) + + const result = yield* $(Ref.get(ref)) + assert.deepStrictEqual(Array.from(result), [acquire1, acquire2, release2, release1]) + })) + }) }) export const makeRef = (): Effect.Effect>> => { return Ref.make(Chunk.empty())