Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cached-with-ttl-callback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"effect": patch
---

Allow `Effect.cachedWithTTL` to compute the TTL from each completed `Exit`, so successes and failures can use different cache durations.
46 changes: 43 additions & 3 deletions packages/effect/src/Effect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7118,8 +7118,8 @@ export const onExitFilter: {
export const cached: <A, E, R>(self: Effect<A, E, R>) => Effect<Effect<A, E, R>> = internal.cached

/**
* Returns an effect that caches its result for a specified `Duration`,
* known as "timeToLive" (TTL).
* Returns an effect that caches its result for a fixed duration or a duration
* computed from its `Exit`, known as "timeToLive" (TTL).
*
* **When to use**
*
Expand All @@ -7138,6 +7138,17 @@ export const cached: <A, E, R>(self: Effect<A, E, R>) => Effect<Effect<A, E, R>>
* After the specified duration has passed, the cache expires, and the effect
* will be recomputed upon the next evaluation.
*
* `timeToLive` accepts a `Duration.Input` or a function from `Exit<A, E>` to
* `Duration.Input`. The function runs once after each fresh computation,
* including failures, so successes and failures can have different TTLs. It
* does not run when the cache is created or when a cached result is reused.
* The callback also receives interruption exits, which are cached for the
* returned duration.
*
* The TTL starts when the computation completes. Concurrent callers share the
* pending computation. A zero TTL expires immediately, and an infinite TTL
* keeps the result indefinitely.
*
* **Example** (Memoizing an effect with TTL)
*
* ```ts import.meta.vitest
Expand All @@ -7162,6 +7173,26 @@ export const cached: <A, E, R>(self: Effect<A, E, R>) => Effect<Effect<A, E, R>>
* output // => ["expensive task...", "result 1", "result 1", "result 1"]
* ```
*
* **Example** (Caching successes while retrying failures)
*
* ```ts import.meta.vitest
* import { Effect, Exit } from "effect"
*
* let attempts = 0
* const task = Effect.suspend(() =>
* ++attempts === 1 ? Effect.fail("temporary failure") : Effect.succeed(42)
* )
* const program = Effect.gen(function*() {
* const cached = yield* task.pipe(
* Effect.cachedWithTTL((exit) => Exit.isSuccess(exit) ? "1 hour" : 0)
* )
* yield* Effect.exit(cached)
* return yield* cached
* })
*
* Effect.runSync(program) // => 42
* ```
*
* @see {@link cached} for a similar function that caches the result
* indefinitely.
* @see {@link cachedInvalidateWithTTL} for a similar function that includes an
Expand All @@ -7170,8 +7201,17 @@ export const cached: <A, E, R>(self: Effect<A, E, R>) => Effect<Effect<A, E, R>>
* @since 2.0.0
*/
export const cachedWithTTL: {
<A, E>(
timeToLive: (exit: Exit.Exit<A, E>) => Duration.Input
): <R>(self: Effect<A, E, R>) => Effect<Effect<A, E, R>>
(timeToLive: Duration.Input): <A, E, R>(self: Effect<A, E, R>) => Effect<Effect<A, E, R>>
<A, E, R>(self: Effect<A, E, R>, timeToLive: Duration.Input): Effect<Effect<A, E, R>>
<A, E>(
timeToLive: Duration.Input | ((exit: Exit.Exit<A, E>) => Duration.Input)
): <R>(self: Effect<A, E, R>) => Effect<Effect<A, E, R>>
<A, E, R>(
self: Effect<A, E, R>,
timeToLive: Duration.Input | ((exit: Exit.Exit<A, E>) => Duration.Input)
): Effect<Effect<A, E, R>>
} = internal.cachedWithTTL

/**
Expand Down
45 changes: 34 additions & 11 deletions packages/effect/src/internal/effect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import type { Primitive } from "./core.ts"
import {
args,
causeAnnotate,
causeDie,
causeEmpty,
causeFromReasons,
CauseImpl,
Expand Down Expand Up @@ -4371,20 +4372,27 @@ export const acquireDisposable = <A extends AsyncDisposable | Disposable, E, R>(

/** @internal */
export const cachedInvalidateWithTTL: {
<A, E>(timeToLive: (exit: Exit.Exit<A, E>) => Duration.Input): <R>(
self: Effect.Effect<A, E, R>
) => Effect.Effect<[Effect.Effect<A, E, R>, Effect.Effect<void>]>
(timeToLive: Duration.Input): <A, E, R>(
self: Effect.Effect<A, E, R>
) => Effect.Effect<[Effect.Effect<A, E, R>, Effect.Effect<void>]>
<A, E>(timeToLive: Duration.Input | ((exit: Exit.Exit<A, E>) => Duration.Input)): <R>(
self: Effect.Effect<A, E, R>
) => Effect.Effect<[Effect.Effect<A, E, R>, Effect.Effect<void>]>
<A, E, R>(
self: Effect.Effect<A, E, R>,
timeToLive: Duration.Input
timeToLive: Duration.Input | ((exit: Exit.Exit<A, E>) => Duration.Input)
): Effect.Effect<[Effect.Effect<A, E, R>, Effect.Effect<void>]>
} = dual(2, <A, E, R>(
self: Effect.Effect<A, E, R>,
ttl: Duration.Input
ttl: Duration.Input | ((exit: Exit.Exit<A, E>) => Duration.Input)
): Effect.Effect<[Effect.Effect<A, E, R>, Effect.Effect<void>]> =>
sync(() => {
const ttlMillis = Duration.toMillis(Duration.fromInputUnsafe(ttl))
const isFinite = Number.isFinite(ttlMillis)
const ttlMillis = typeof ttl === "function"
? (exit: Exit.Exit<A, E>) => Duration.toMillis(Duration.fromInputUnsafe(ttl(exit)))
: constant(Duration.toMillis(Duration.fromInputUnsafe(ttl)))
const latch = makeLatchUnsafe(false)
let expiresAt = 0
let running = false
Expand All @@ -4393,17 +4401,26 @@ export const cachedInvalidateWithTTL: {
return [
withFiber((fiber) => {
const clock = fiber.getRef(ClockRef)
const now = isFinite ? clock.currentTimeMillisUnsafe() : 0
const now = expiresAt === Infinity ? 0 : clock.currentTimeMillisUnsafe()
if (running || now < expiresAt) return exit ?? wait
running = true
latch.closeUnsafe()
exit = undefined
return onExit(self, (exit_) =>
sync(() => {
running = false
expiresAt = clock.currentTimeMillisUnsafe() + ttlMillis
exit = exit_
latch.openUnsafe()
try {
const duration = ttlMillis(exit_)
expiresAt = clock.currentTimeMillisUnsafe() + duration
exit = exit_
} catch (error) {
const cause = causeDie(error)
// Publish the same combined cause that onExit returns to the owner.
exit = exitFailCause(exitIsFailure(exit_) ? causeCombine(exit_.cause, cause) : cause)
throw error
} finally {
running = false
latch.openUnsafe()
}
}))
}),
sync(() => {
Expand All @@ -4416,18 +4433,24 @@ export const cachedInvalidateWithTTL: {

/** @internal */
export const cachedWithTTL: {
<A, E>(
timeToLive: (exit: Exit.Exit<A, E>) => Duration.Input
): <R>(self: Effect.Effect<A, E, R>) => Effect.Effect<Effect.Effect<A, E, R>>
(
timeToLive: Duration.Input
): <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<Effect.Effect<A, E, R>>
<A, E>(
timeToLive: Duration.Input | ((exit: Exit.Exit<A, E>) => Duration.Input)
): <R>(self: Effect.Effect<A, E, R>) => Effect.Effect<Effect.Effect<A, E, R>>
<A, E, R>(
self: Effect.Effect<A, E, R>,
timeToLive: Duration.Input
timeToLive: Duration.Input | ((exit: Exit.Exit<A, E>) => Duration.Input)
): Effect.Effect<Effect.Effect<A, E, R>>
} = dual(
2,
<A, E, R>(
self: Effect.Effect<A, E, R>,
timeToLive: Duration.Input
timeToLive: Duration.Input | ((exit: Exit.Exit<A, E>) => Duration.Input)
): Effect.Effect<Effect.Effect<A, E, R>> => map(cachedInvalidateWithTTL(self, timeToLive), (tuple) => tuple[0])
)

Expand Down
48 changes: 48 additions & 0 deletions packages/effect/test/Effect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3773,6 +3773,54 @@ describe("Effect", () => {
})

describe("cachedWithTTL", () => {
it.effect("selects ttl from each fresh exit without evaluating on cache hits", () =>
Effect.gen(function*() {
let count = 0
const exits: Array<Exit.Exit<number>> = []
const cached = yield* Effect.cachedWithTTL(
Effect.sync(() => ++count),
(exit: Exit.Exit<number>) => {
exits.push(exit)
return Exit.isSuccess(exit) && exit.value === 1 ? "1 second" : Duration.seconds(3)
}
)

assert.strictEqual(count, 0)
assert.deepStrictEqual(exits, [])
assert.strictEqual(yield* cached, 1)
yield* TestClock.adjust("999 millis")
assert.strictEqual(yield* cached, 1)
assert.deepStrictEqual(exits, [Exit.succeed(1)])

yield* TestClock.adjust("1 milli")
assert.strictEqual(yield* cached, 2)
yield* TestClock.adjust("1 second")
assert.strictEqual(yield* cached, 2)
yield* TestClock.adjust("2 seconds")
assert.strictEqual(yield* cached, 3)
assert.deepStrictEqual(exits, [Exit.succeed(1), Exit.succeed(2), Exit.succeed(3)])
}))

it.effect("supports a piped callback that skips failures and caches successes", () =>
Effect.gen(function*() {
let count = 0
const exits: Array<Exit.Exit<number, string>> = []
const cached = yield* Effect.suspend(() => ++count === 1 ? Effect.fail("boom") : Effect.succeed(count)).pipe(
Effect.cachedWithTTL((exit: Exit.Exit<number, string>) => {
exits.push(exit)
return Exit.isFailure(exit) ? 0 : "1 second"
})
)

assert.deepStrictEqual(yield* Effect.exit(cached), Exit.fail("boom"))
assert.strictEqual(yield* cached, 2)
assert.strictEqual(yield* cached, 2)
assert.deepStrictEqual(exits, [Exit.fail("boom"), Exit.succeed(2)])
yield* TestClock.adjust("1 second")
assert.strictEqual(yield* cached, 3)
assert.deepStrictEqual(exits, [Exit.fail("boom"), Exit.succeed(2), Exit.succeed(3)])
}))

it.effect("starts ttl from value creation", () =>
Effect.gen(function*() {
let count = 0
Expand Down
21 changes: 21 additions & 0 deletions packages/effect/typetest/Effect.tst.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
type Channel,
Context,
Data,
Duration,
Effect,
type ExecutionPlan,
Exit,
Expand Down Expand Up @@ -1385,6 +1386,26 @@ describe("Effect.withExecutionPlan", () => {
})
})

describe("Effect.cachedWithTTL", () => {
it("data-first", () => {
const cached = Effect.cachedWithTTL(number, (exit) => {
expect(exit).type.toBe<Exit.Exit<number, "err-2">>()
return Exit.isSuccess(exit) ? Duration.seconds(exit.value) : 0
})

expect(cached).type.toBe<Effect.Effect<Effect.Effect<number, "err-2", "dep-2">>>()
})

it("data-last", () => {
const cached = number.pipe(Effect.cachedWithTTL((exit) => {
expect(exit).type.toBe<Exit.Exit<number, "err-2">>()
return Exit.isSuccess(exit) ? "1 second" : 0
}))

expect(cached).type.toBe<Effect.Effect<Effect.Effect<number, "err-2", "dep-2">>>()
})
})

describe("Effect.track", () => {
const observe = Effect.track(
Metric.gauge("track"),
Expand Down
Loading