Skip to content

Commit dd414da

Browse files
schicklingclaudetim-smart
authored
RcMap: support dynamic idleTimeToLive values per key (#5859)
Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Tim Smart <hello@timsmart.co>
1 parent bf9bdb4 commit dd414da

File tree

4 files changed

+100
-13
lines changed

4 files changed

+100
-13
lines changed
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"effect": minor
3+
---
4+
5+
RcMap: support dynamic `idleTimeToLive` values per key
6+
7+
The `idleTimeToLive` option can now be a function that receives the key and returns a duration, allowing different TTL values for different resources.
8+
9+
```ts
10+
const map = yield* RcMap.make({
11+
lookup: (key: string) => acquireResource(key),
12+
idleTimeToLive: (key: string) => {
13+
if (key.startsWith("premium:")) return Duration.minutes(10)
14+
return Duration.minutes(1)
15+
}
16+
})
17+
```

packages/effect/src/RcMap.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export declare namespace RcMap {
5656
*
5757
* - `capacity`: The maximum number of resources that can be held in the map.
5858
* - `idleTimeToLive`: When the reference count reaches zero, the resource will be released after this duration.
59+
* Can be a static duration or a function that returns a duration based on the key.
5960
*
6061
* @since 3.5.0
6162
* @category models
@@ -85,14 +86,14 @@ export const make: {
8586
<K, A, E, R>(
8687
options: {
8788
readonly lookup: (key: K) => Effect.Effect<A, E, R>
88-
readonly idleTimeToLive?: Duration.DurationInput | undefined
89+
readonly idleTimeToLive?: Duration.DurationInput | ((key: K) => Duration.DurationInput) | undefined
8990
readonly capacity?: undefined
9091
}
9192
): Effect.Effect<RcMap<K, A, E>, never, Scope.Scope | R>
9293
<K, A, E, R>(
9394
options: {
9495
readonly lookup: (key: K) => Effect.Effect<A, E, R>
95-
readonly idleTimeToLive?: Duration.DurationInput | undefined
96+
readonly idleTimeToLive?: Duration.DurationInput | ((key: K) => Duration.DurationInput) | undefined
9697
readonly capacity: number
9798
}
9899
): Effect.Effect<RcMap<K, A, E | Cause.ExceededCapacityException>, never, Scope.Scope | R>

packages/effect/src/internal/rcMap.ts

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type * as Deferred from "../Deferred.js"
44
import * as Duration from "../Duration.js"
55
import type { Effect } from "../Effect.js"
66
import type { RuntimeFiber } from "../Fiber.js"
7-
import { dual, identity } from "../Function.js"
7+
import { constant, dual, flow, identity } from "../Function.js"
88
import * as MutableHashMap from "../MutableHashMap.js"
99
import { pipeArguments } from "../Pipeable.js"
1010
import type * as RcMap from "../RcMap.js"
@@ -33,6 +33,7 @@ declare namespace State {
3333
readonly deferred: Deferred.Deferred<A, E>
3434
readonly scope: Scope.CloseableScope
3535
readonly finalizer: Effect<void>
36+
readonly idleTimeToLive: Duration.Duration
3637
fiber: RuntimeFiber<void, never> | undefined
3738
expiresAt: number
3839
refCount: number
@@ -58,7 +59,7 @@ class RcMapImpl<K, A, E> implements RcMap.RcMap<K, A, E> {
5859
readonly lookup: (key: K) => Effect<A, E, Scope.Scope>,
5960
readonly context: Context.Context<never>,
6061
readonly scope: Scope.Scope,
61-
readonly idleTimeToLive: Duration.Duration | undefined,
62+
readonly idleTimeToLive: ((key: K) => Duration.Duration) | undefined,
6263
readonly capacity: number
6364
) {
6465
this[TypeId] = variance
@@ -73,27 +74,32 @@ class RcMapImpl<K, A, E> implements RcMap.RcMap<K, A, E> {
7374
export const make: {
7475
<K, A, E, R>(options: {
7576
readonly lookup: (key: K) => Effect<A, E, R>
76-
readonly idleTimeToLive?: Duration.DurationInput | undefined
77+
readonly idleTimeToLive?: Duration.DurationInput | ((key: K) => Duration.DurationInput) | undefined
7778
readonly capacity?: undefined
7879
}): Effect<RcMap.RcMap<K, A, E>, never, Scope.Scope | R>
7980
<K, A, E, R>(options: {
8081
readonly lookup: (key: K) => Effect<A, E, R>
81-
readonly idleTimeToLive?: Duration.DurationInput | undefined
82+
readonly idleTimeToLive?: Duration.DurationInput | ((key: K) => Duration.DurationInput) | undefined
8283
readonly capacity: number
8384
}): Effect<RcMap.RcMap<K, A, E | Cause.ExceededCapacityException>, never, Scope.Scope | R>
8485
} = <K, A, E, R>(options: {
8586
readonly lookup: (key: K) => Effect<A, E, R>
86-
readonly idleTimeToLive?: Duration.DurationInput | undefined
87+
readonly idleTimeToLive?: Duration.DurationInput | ((key: K) => Duration.DurationInput) | undefined
8788
readonly capacity?: number | undefined
8889
}) =>
8990
core.withFiberRuntime<RcMap.RcMap<K, A, E>, never, R | Scope.Scope>((fiber) => {
9091
const context = fiber.getFiberRef(core.currentContext) as Context.Context<R | Scope.Scope>
9192
const scope = Context.get(context, fiberRuntime.scopeTag)
93+
const idleTimeToLive = options.idleTimeToLive === undefined
94+
? undefined
95+
: typeof options.idleTimeToLive === "function"
96+
? flow(options.idleTimeToLive, Duration.decode)
97+
: constant(Duration.decode(options.idleTimeToLive))
9298
const self = new RcMapImpl<K, A, E>(
9399
options.lookup as any,
94100
context,
95101
scope,
96-
options.idleTimeToLive ? Duration.decode(options.idleTimeToLive) : undefined,
102+
idleTimeToLive,
97103
Math.max(options.capacity ?? Number.POSITIVE_INFINITY, 0)
98104
)
99105
return core.as(
@@ -169,10 +175,12 @@ const acquire = core.fnUntraced(function*<K, A, E>(self: RcMapImpl<K, A, E>, key
169175
core.flatMap((exit) => core.deferredDone(deferred, exit)),
170176
circular.forkIn(scope)
171177
)
178+
const idleTimeToLive = self.idleTimeToLive ? self.idleTimeToLive(key) : Duration.zero
172179
const entry: State.Entry<A, E> = {
173180
deferred,
174181
scope,
175182
finalizer: undefined as any,
183+
idleTimeToLive,
176184
fiber: undefined,
177185
expiresAt: 0,
178186
refCount: 1
@@ -192,19 +200,19 @@ const release = <K, A, E>(self: RcMapImpl<K, A, E>, key: K, entry: State.Entry<A
192200
} else if (
193201
self.state._tag === "Closed"
194202
|| !MutableHashMap.has(self.state.map, key)
195-
|| self.idleTimeToLive === undefined
203+
|| Duration.isZero(entry.idleTimeToLive)
196204
) {
197205
if (self.state._tag === "Open") {
198206
MutableHashMap.remove(self.state.map, key)
199207
}
200208
return core.scopeClose(entry.scope, core.exitVoid)
201209
}
202210

203-
if (!Duration.isFinite(self.idleTimeToLive)) {
211+
if (!Duration.isFinite(entry.idleTimeToLive)) {
204212
return core.void
205213
}
206214

207-
entry.expiresAt = clock.unsafeCurrentTimeMillis() + Duration.toMillis(self.idleTimeToLive)
215+
entry.expiresAt = clock.unsafeCurrentTimeMillis() + Duration.toMillis(entry.idleTimeToLive)
208216
if (entry.fiber) return core.void
209217

210218
return core.interruptibleMask(function loop(restore): Effect<void> {
@@ -276,10 +284,12 @@ export const touch: {
276284
<K, A, E>(self_: RcMap.RcMap<K, A, E>, key: K) =>
277285
coreEffect.clockWith((clock) => {
278286
const self = self_ as RcMapImpl<K, A, E>
279-
if (!self.idleTimeToLive || self.state._tag === "Closed") return core.void
287+
if (self.state._tag === "Closed") return core.void
280288
const o = MutableHashMap.get(self.state.map, key)
281289
if (o._tag === "None") return core.void
282-
o.value.expiresAt = clock.unsafeCurrentTimeMillis() + Duration.toMillis(self.idleTimeToLive)
290+
const entry = o.value
291+
if (Duration.isZero(entry.idleTimeToLive)) return core.void
292+
entry.expiresAt = clock.unsafeCurrentTimeMillis() + Duration.toMillis(entry.idleTimeToLive)
283293
return core.void
284294
})
285295
)

packages/effect/test/RcMap.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,4 +175,63 @@ describe("RcMap", () => {
175175

176176
deepStrictEqual(yield* RcMap.keys(map), ["foo", "bar", "baz"])
177177
}))
178+
179+
it.scoped("dynamic idleTimeToLive", () =>
180+
Effect.gen(function*() {
181+
const acquired: Array<string> = []
182+
const released: Array<string> = []
183+
const map = yield* RcMap.make({
184+
lookup: (key: string) =>
185+
Effect.acquireRelease(
186+
Effect.sync(() => {
187+
acquired.push(key)
188+
return key
189+
}),
190+
() => Effect.sync(() => released.push(key))
191+
),
192+
idleTimeToLive: (key: string) => key.startsWith("short:") ? 500 : 2000
193+
})
194+
195+
deepStrictEqual(acquired, [])
196+
197+
yield* Effect.scoped(RcMap.get(map, "short:a"))
198+
yield* Effect.scoped(RcMap.get(map, "long:b"))
199+
deepStrictEqual(acquired, ["short:a", "long:b"])
200+
deepStrictEqual(released, [])
201+
202+
yield* TestClock.adjust(500)
203+
deepStrictEqual(released, ["short:a"])
204+
205+
yield* TestClock.adjust(1500)
206+
deepStrictEqual(released, ["short:a", "long:b"])
207+
}))
208+
209+
it.scoped("dynamic idleTimeToLive with touch", () =>
210+
Effect.gen(function*() {
211+
const acquired: Array<string> = []
212+
const released: Array<string> = []
213+
const map = yield* RcMap.make({
214+
lookup: (key: string) =>
215+
Effect.acquireRelease(
216+
Effect.sync(() => {
217+
acquired.push(key)
218+
return key
219+
}),
220+
() => Effect.sync(() => released.push(key))
221+
),
222+
idleTimeToLive: (key: string) => key.startsWith("short:") ? 500 : 2000
223+
})
224+
225+
yield* Effect.scoped(RcMap.get(map, "short:a"))
226+
deepStrictEqual(acquired, ["short:a"])
227+
deepStrictEqual(released, [])
228+
229+
yield* TestClock.adjust(250)
230+
yield* RcMap.touch(map, "short:a")
231+
yield* TestClock.adjust(250)
232+
deepStrictEqual(released, [])
233+
234+
yield* TestClock.adjust(250)
235+
deepStrictEqual(released, ["short:a"])
236+
}))
178237
})

0 commit comments

Comments
 (0)