From 20e63fb9207210f3fe2d136ec40d0a2dbff3225e Mon Sep 17 00:00:00 2001 From: Tim Date: Mon, 11 Mar 2024 16:49:45 +1300 Subject: [PATCH] add ManagedRuntime module, to make incremental adoption easier (#2211) --- .changeset/famous-mugs-attack.md | 29 +++++ .changeset/strong-flowers-laugh.md | 11 ++ packages/effect/src/Layer.ts | 17 +++ packages/effect/src/ManagedRuntime.ts | 115 ++++++++++++++++++ packages/effect/src/index.ts | 5 + packages/effect/src/internal/layer.ts | 29 ++++- .../effect/src/internal/managedRuntime.ts | 111 +++++++++++++++++ packages/effect/test/ManagedRuntime.test.ts | 51 ++++++++ packages/effect/test/utils/extend.ts | 22 ++-- vitest.shared.ts | 3 + 10 files changed, 379 insertions(+), 14 deletions(-) create mode 100644 .changeset/famous-mugs-attack.md create mode 100644 .changeset/strong-flowers-laugh.md create mode 100644 packages/effect/src/ManagedRuntime.ts create mode 100644 packages/effect/src/internal/managedRuntime.ts create mode 100644 packages/effect/test/ManagedRuntime.test.ts diff --git a/.changeset/famous-mugs-attack.md b/.changeset/famous-mugs-attack.md new file mode 100644 index 0000000000..8040e12fad --- /dev/null +++ b/.changeset/famous-mugs-attack.md @@ -0,0 +1,29 @@ +--- +"effect": patch +--- + +add ManagedRuntime module, to make incremental adoption easier + +You can use a ManagedRuntime to run Effect's that can use the +dependencies from the given Layer. For example: + +```ts +import { Console, Effect, Layer, ManagedRuntime } from "effect"; + +class Notifications extends Effect.Tag("Notifications")< + Notifications, + { readonly notify: (message: string) => Effect.Effect } +>() { + static Live = Layer.succeed(this, { + notify: (message) => Console.log(message), + }); +} + +async function main() { + const runtime = ManagedRuntime.make(Notifications.Live); + await runtime.runPromise(Notifications.notify("Hello, world!")); + await runtime.dispose(); +} + +main(); +``` diff --git a/.changeset/strong-flowers-laugh.md b/.changeset/strong-flowers-laugh.md new file mode 100644 index 0000000000..4da1afc3b4 --- /dev/null +++ b/.changeset/strong-flowers-laugh.md @@ -0,0 +1,11 @@ +--- +"effect": patch +--- + +add Layer.toRuntimeWithMemoMap api + +Similar to Layer.toRuntime, but allows you to share a Layer.MemoMap between +layer builds. + +By sharing the MemoMap, layers are shared between each build - ensuring layers +are only built once between multiple calls to Layer.toRuntimeWithMemoMap. diff --git a/packages/effect/src/Layer.ts b/packages/effect/src/Layer.ts index 71c463c119..defd9ea4e0 100644 --- a/packages/effect/src/Layer.ts +++ b/packages/effect/src/Layer.ts @@ -784,6 +784,23 @@ export const toRuntime: ( self: Layer ) => Effect.Effect, E, Scope.Scope | RIn> = internal.toRuntime +/** + * Converts a layer that requires no services into a scoped runtime, which can + * be used to execute effects. + * + * @since 2.0.0 + * @category conversions + */ +export const toRuntimeWithMemoMap: { + ( + memoMap: MemoMap + ): (self: Layer) => Effect.Effect, E, Scope.Scope | RIn> + ( + self: Layer, + memoMap: MemoMap + ): Effect.Effect, E, Scope.Scope | RIn> +} = internal.toRuntimeWithMemoMap + /** * Feeds the output services of this builder into the input of the specified * builder, resulting in a new builder with the inputs of this builder as diff --git a/packages/effect/src/ManagedRuntime.ts b/packages/effect/src/ManagedRuntime.ts new file mode 100644 index 0000000000..b6360bc0a9 --- /dev/null +++ b/packages/effect/src/ManagedRuntime.ts @@ -0,0 +1,115 @@ +/** + * @since 2.0.0 + */ +import type * as Effect from "./Effect.js" +import type * as Exit from "./Exit.js" +import type * as Fiber from "./Fiber.js" +import * as internal from "./internal/managedRuntime.js" +import type * as Layer from "./Layer.js" +import type { Pipeable } from "./Pipeable.js" +import type * as Runtime from "./Runtime.js" + +/** + * @since 2.0.0 + * @category models + */ +export interface ManagedRuntime extends Pipeable { + readonly memoMap: Layer.MemoMap + readonly runtimeEffect: Effect.Effect, ER> + readonly runtime: () => Promise> + + /** + * Executes the effect using the provided Scheduler or using the global + * Scheduler if not provided + */ + readonly runFork: ( + self: Effect.Effect, + options?: Runtime.RunForkOptions + ) => Fiber.RuntimeFiber + + /** + * Executes the effect synchronously returning the exit. + * + * This method is effectful and should only be invoked at the edges of your + * program. + */ + readonly runSyncExit: (effect: Effect.Effect) => Exit.Exit + + /** + * Executes the effect synchronously throwing in case of errors or async boundaries. + * + * This method is effectful and should only be invoked at the edges of your + * program. + */ + readonly runSync: (effect: Effect.Effect) => A + + /** + * Executes the effect asynchronously, eventually passing the exit value to + * the specified callback. + * + * This method is effectful and should only be invoked at the edges of your + * program. + */ + readonly runCallback: ( + effect: Effect.Effect, + options?: Runtime.RunCallbackOptions | undefined + ) => Runtime.Cancel + + /** + * Runs the `Effect`, returning a JavaScript `Promise` that will be resolved + * with the value of the effect once the effect has been executed, or will be + * rejected with the first error or exception throw by the effect. + * + * This method is effectful and should only be used at the edges of your + * program. + */ + readonly runPromise: (effect: Effect.Effect) => Promise + + /** + * Runs the `Effect`, returning a JavaScript `Promise` that will be resolved + * with the `Exit` state of the effect once the effect has been executed. + * + * This method is effectful and should only be used at the edges of your + * program. + */ + readonly runPromiseExit: (effect: Effect.Effect) => Promise> + + /** + * Dispose of the resources associated with the runtime. + */ + readonly dispose: () => Promise + + /** + * Dispose of the resources associated with the runtime. + */ + readonly disposeEffect: Effect.Effect +} + +/** + * Convert a Layer into an ManagedRuntime, that can be used to run Effect's using + * your services. + * + * @since 2.0.0 + * @category runtime class + * @example + * import { Console, Effect, Layer, ManagedRuntime } from "effect" + * + * class Notifications extends Effect.Tag("Notifications")< + * Notifications, + * { readonly notify: (message: string) => Effect.Effect } + * >() { + * static Live = Layer.succeed(this, { notify: (message) => Console.log(message) }) + * } + * + * async function main() { + * const runtime = ManagedRuntime.make(Notifications.Live) + * await runtime.runPromise(Notifications.notify("Hello, world!")) + * await runtime.dispose() + * } + * + * main() + */ +export const make: ( + layer: Layer.Layer, + memoMap?: Layer.MemoMap | undefined +) => ManagedRuntime = internal.make diff --git a/packages/effect/src/index.ts b/packages/effect/src/index.ts index 98e5b104cf..0c60509dca 100644 --- a/packages/effect/src/index.ts +++ b/packages/effect/src/index.ts @@ -385,6 +385,11 @@ export * as LogSpan from "./LogSpan.js" */ export * as Logger from "./Logger.js" +/** + * @since 2.0.0 + */ +export * as ManagedRuntime from "./ManagedRuntime.js" + /** * @since 1.0.0 */ diff --git a/packages/effect/src/internal/layer.ts b/packages/effect/src/internal/layer.ts index c255108344..1148158fb8 100644 --- a/packages/effect/src/internal/layer.ts +++ b/packages/effect/src/internal/layer.ts @@ -312,6 +312,9 @@ export const makeMemoMap: Effect.Effect = core.suspend(() => ) ) +/** @internal */ +export const unsafeMakeMemoMap = (): Layer.MemoMap => new MemoMapImpl(circular.unsafeMakeSynchronized(new Map())) + /** @internal */ export const build = ( self: Layer.Layer @@ -988,9 +991,9 @@ export const tapErrorCause = dual< /** @internal */ export const toRuntime = ( self: Layer.Layer -): Effect.Effect, E, RIn | Scope.Scope> => { - return pipe( - fiberRuntime.scopeWith((scope) => pipe(self, buildWithScope(scope))), +): Effect.Effect, E, RIn | Scope.Scope> => + pipe( + fiberRuntime.scopeWith((scope) => buildWithScope(self, scope)), core.flatMap((context) => pipe( runtime.runtime(), @@ -998,7 +1001,25 @@ export const toRuntime = ( ) ) ) -} + +/** @internal */ +export const toRuntimeWithMemoMap = dual< + ( + memoMap: Layer.MemoMap + ) => (self: Layer.Layer) => Effect.Effect, E, RIn | Scope.Scope>, + ( + self: Layer.Layer, + memoMap: Layer.MemoMap + ) => Effect.Effect, E, RIn | Scope.Scope> +>(2, (self, memoMap) => + core.flatMap( + fiberRuntime.scopeWith((scope) => buildWithMemoMap(self, memoMap, scope)), + (context) => + pipe( + runtime.runtime(), + core.provideContext(context) + ) + )) /** @internal */ export const provide = dual< diff --git a/packages/effect/src/internal/managedRuntime.ts b/packages/effect/src/internal/managedRuntime.ts new file mode 100644 index 0000000000..068deadefb --- /dev/null +++ b/packages/effect/src/internal/managedRuntime.ts @@ -0,0 +1,111 @@ +import type * as Effect from "../Effect.js" +import type { Exit } from "../Exit.js" +import type * as Fiber from "../Fiber.js" +import type * as Layer from "../Layer.js" +import type { ManagedRuntime } from "../ManagedRuntime.js" +import { pipeArguments } from "../Pipeable.js" +import type * as Runtime from "../Runtime.js" +import * as Scope from "../Scope.js" +import * as effect from "./core-effect.js" +import * as core from "./core.js" +import * as fiberRuntime from "./fiberRuntime.js" +import * as internalLayer from "./layer.js" +import * as internalRuntime from "./runtime.js" + +interface ManagedRuntimeImpl extends ManagedRuntime { + readonly scope: Scope.CloseableScope + cachedRuntime: Runtime.Runtime | undefined +} + +function provide( + managed: ManagedRuntimeImpl, + effect: Effect.Effect +): Effect.Effect { + return core.flatMap( + managed.runtimeEffect, + (rt) => + core.withFiberRuntime((fiber) => { + fiber.setFiberRefs(rt.fiberRefs) + fiber._runtimeFlags = rt.runtimeFlags + return core.provideContext(effect, rt.context) + }) + ) +} + +/** @internal */ +export const make = ( + layer: Layer.Layer, + memoMap?: Layer.MemoMap +): ManagedRuntime => { + memoMap = memoMap ?? internalLayer.unsafeMakeMemoMap() + const scope = internalRuntime.unsafeRunSyncEffect(fiberRuntime.scopeMake()) + const self: ManagedRuntimeImpl = { + memoMap, + scope, + runtimeEffect: internalRuntime + .unsafeRunSyncEffect( + effect.memoize( + core.tap( + Scope.extend( + internalLayer.toRuntimeWithMemoMap(layer, memoMap), + scope + ), + (rt) => { + self.cachedRuntime = rt + } + ) + ) + ), + cachedRuntime: undefined, + pipe() { + return pipeArguments(this, arguments) + }, + runtime() { + return self.cachedRuntime === undefined ? + internalRuntime.unsafeRunPromiseEffect(self.runtimeEffect) : + Promise.resolve(self.cachedRuntime) + }, + dispose(): Promise { + return internalRuntime.unsafeRunPromiseEffect(self.disposeEffect) + }, + disposeEffect: core.suspend(() => { + ;(self as any).runtime = core.die("ManagedRuntime disposed") + self.cachedRuntime = undefined + return Scope.close(self.scope, core.exitUnit) + }), + runFork(effect: Effect.Effect, options?: Runtime.RunForkOptions): Fiber.RuntimeFiber { + return self.cachedRuntime === undefined ? + internalRuntime.unsafeForkEffect(provide(self, effect), options) : + internalRuntime.unsafeFork(self.cachedRuntime)(effect, options) + }, + runSyncExit(effect: Effect.Effect): Exit { + return self.cachedRuntime === undefined ? + internalRuntime.unsafeRunSyncExitEffect(provide(self, effect)) : + internalRuntime.unsafeRunSyncExit(self.cachedRuntime)(effect) + }, + runSync(effect: Effect.Effect): A { + return self.cachedRuntime === undefined ? + internalRuntime.unsafeRunSyncEffect(provide(self, effect)) : + internalRuntime.unsafeRunSync(self.cachedRuntime)(effect) + }, + runPromiseExit(effect: Effect.Effect): Promise> { + return self.cachedRuntime === undefined ? + internalRuntime.unsafeRunPromiseExitEffect(provide(self, effect)) : + internalRuntime.unsafeRunPromiseExit(self.cachedRuntime)(effect) + }, + runCallback( + effect: Effect.Effect, + options?: Runtime.RunCallbackOptions | undefined + ): Runtime.Cancel { + return self.cachedRuntime === undefined ? + internalRuntime.unsafeRunCallback(internalRuntime.defaultRuntime)(provide(self, effect), options) : + internalRuntime.unsafeRunCallback(self.cachedRuntime)(effect, options) + }, + runPromise(effect: Effect.Effect): Promise { + return self.cachedRuntime === undefined ? + internalRuntime.unsafeRunPromiseEffect(provide(self, effect)) : + internalRuntime.unsafeRunPromise(self.cachedRuntime)(effect) + } + } + return self +} diff --git a/packages/effect/test/ManagedRuntime.test.ts b/packages/effect/test/ManagedRuntime.test.ts new file mode 100644 index 0000000000..5c58ea3c5f --- /dev/null +++ b/packages/effect/test/ManagedRuntime.test.ts @@ -0,0 +1,51 @@ +import { ManagedRuntime } from "effect" +import * as Context from "effect/Context" +import * as Effect from "effect/Effect" +import * as FiberRef from "effect/FiberRef" +import * as Layer from "effect/Layer" +import { assert, describe, test } from "vitest" + +describe.concurrent("ManagedRuntime", () => { + test("memoizes the layer build", async () => { + let count = 0 + const layer = Layer.effectDiscard(Effect.sync(() => { + count++ + })) + const runtime = ManagedRuntime.make(layer) + await runtime.runPromise(Effect.unit) + await runtime.runPromise(Effect.unit) + await runtime.dispose() + assert.strictEqual(count, 1) + }) + + test("provides context", async () => { + const tag = Context.GenericTag("string") + const layer = Layer.succeed(tag, "test") + const runtime = ManagedRuntime.make(layer) + const result = await runtime.runPromise(tag) + await runtime.dispose() + assert.strictEqual(result, "test") + }) + + test("provides fiberRefs", async () => { + const layer = Layer.setRequestCaching(true) + const runtime = ManagedRuntime.make(layer) + const result = await runtime.runPromise(FiberRef.get(FiberRef.currentRequestCacheEnabled)) + await runtime.dispose() + assert.strictEqual(result, true) + }) + + test("allows sharing a MemoMap", async () => { + let count = 0 + const layer = Layer.effectDiscard(Effect.sync(() => { + count++ + })) + const runtimeA = ManagedRuntime.make(layer) + const runtimeB = ManagedRuntime.make(layer, runtimeA.memoMap) + await runtimeA.runPromise(Effect.unit) + await runtimeB.runPromise(Effect.unit) + await runtimeA.dispose() + await runtimeB.dispose() + assert.strictEqual(count, 1) + }) +}) diff --git a/packages/effect/test/utils/extend.ts b/packages/effect/test/utils/extend.ts index bd9dd0179f..3648dff552 100644 --- a/packages/effect/test/utils/extend.ts +++ b/packages/effect/test/utils/extend.ts @@ -21,14 +21,14 @@ const TestEnv = TestEnvironment.TestContext.pipe( export const effect = (() => { const f = ( name: string, - self: () => Effect.Effect, + self: Effect.Effect | (() => Effect.Effect), timeout: number | V.TestOptions = 5_000 ) => { return it( name, () => pipe( - Effect.suspend(self), + Effect.isEffect(self) ? self : Effect.suspend(self), Effect.provide(TestEnv), Effect.runPromise ), @@ -38,14 +38,14 @@ export const effect = (() => { return Object.assign(f, { skip: ( name: string, - self: () => Effect.Effect, + self: Effect.Effect | (() => Effect.Effect), timeout = 5_000 ) => { return it.skip( name, () => pipe( - Effect.suspend(self), + Effect.isEffect(self) ? self : Effect.suspend(self), Effect.provide(TestEnv), Effect.runPromise ), @@ -54,14 +54,14 @@ export const effect = (() => { }, only: ( name: string, - self: () => Effect.Effect, + self: Effect.Effect | (() => Effect.Effect), timeout = 5_000 ) => { return it.only( name, () => pipe( - Effect.suspend(self), + Effect.isEffect(self) ? self : Effect.suspend(self), Effect.provide(TestEnv), Effect.runPromise ), @@ -73,14 +73,14 @@ export const effect = (() => { export const live = ( name: string, - self: () => Effect.Effect, + self: Effect.Effect | (() => Effect.Effect), timeout = 5_000 ) => { return it( name, () => pipe( - Effect.suspend(self), + Effect.isEffect(self) ? self : Effect.suspend(self), Effect.runPromise ), timeout @@ -106,14 +106,16 @@ export const flakyTest = ( export const scoped = ( name: string, - self: () => Effect.Effect, + self: + | Effect.Effect + | (() => Effect.Effect), timeout = 5_000 ) => { return it( name, () => pipe( - Effect.suspend(self), + Effect.isEffect(self) ? self : Effect.suspend(self), Effect.scoped, Effect.provide(TestEnv), Effect.runPromise diff --git a/vitest.shared.ts b/vitest.shared.ts index e1d07b122f..8c178a1f45 100644 --- a/vitest.shared.ts +++ b/vitest.shared.ts @@ -8,6 +8,9 @@ const alias = (pkg: string) => ({ // This is a workaround, see https://github.com/vitest-dev/vitest/issues/4744 const config: UserConfig = { + esbuild: { + target: "es2020" + }, test: { fakeTimers: { toFake: undefined