veffect
Version:
powerful TypeScript validation library built on the robust foundation of Effect combining exceptional type safety, high performance, and developer experience. Taking inspiration from Effect's functional principles, VEffect delivers a balanced approach tha
498 lines (464 loc) • 16.7 kB
text/typescript
import type * as Clock from "../Clock.js"
import * as Context from "../Context.js"
import * as Duration from "../Duration.js"
import type * as Effect from "../Effect.js"
import * as Equal from "../Equal.js"
import type * as Exit from "../Exit.js"
import { dual, pipe } from "../Function.js"
import * as Hash from "../Hash.js"
import * as HashSet from "../HashSet.js"
import { pipeArguments } from "../Pipeable.js"
import type * as Pool from "../Pool.js"
import { hasProperty } from "../Predicate.js"
import type * as Queue from "../Queue.js"
import type * as Ref from "../Ref.js"
import type * 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 queue from "./queue.js"
import * as ref from "./ref.js"
/** @internal */
const PoolSymbolKey = "effect/Pool"
/** @internal */
export const PoolTypeId: Pool.PoolTypeId = Symbol.for(
PoolSymbolKey
) as Pool.PoolTypeId
const poolVariance = {
/* c8 ignore next */
_E: (_: never) => _,
/* c8 ignore next */
_A: (_: any) => _
}
interface PoolState {
readonly size: number
readonly free: number
}
interface Attempted<A, E> {
readonly result: Exit.Exit<A, E>
readonly finalizer: Effect.Effect<unknown>
}
/**
* A `Strategy` describes the protocol for how a pool whose excess items are
* not being used should shrink down to the minimum pool size.
*/
interface Strategy<S, R, E, A> {
/**
* Describes how the initial state of the strategy should be allocated.
*/
initial(): Effect.Effect<S, never, R>
/**
* Describes how the state of the strategy should be updated when an item is
* added to the pool or returned to the pool.
*/
track(state: S, attempted: Exit.Exit<A, E>): Effect.Effect<void>
/**
* Describes how excess items that are not being used should shrink down.
*/
run(
state: S,
getExcess: Effect.Effect<number>,
shrink: Effect.Effect<void>
): Effect.Effect<void>
}
/**
* A strategy that does nothing to shrink excess items. This is useful when
* the minimum size of the pool is equal to its maximum size and so there is
* nothing to do.
*/
class NoneStrategy implements Strategy<unknown, never, never, never> {
initial(): Effect.Effect<void> {
return core.unit
}
track(): Effect.Effect<void> {
return core.unit
}
run(): Effect.Effect<void> {
return core.unit
}
}
/**
* A strategy that shrinks the pool down to its minimum size if items in the
* pool have not been used for the specified duration.
*/
class TimeToLiveStrategy implements Strategy<readonly [Clock.Clock, Ref.Ref<number>], never, never, never> {
constructor(readonly timeToLive: Duration.Duration) {}
initial(): Effect.Effect<readonly [Clock.Clock, Ref.Ref<number>]> {
return core.flatMap(effect.clock, (clock) =>
core.flatMap(clock.currentTimeMillis, (now) =>
core.map(
ref.make(now),
(ref) => [clock, ref] as const
)))
}
track(state: readonly [Clock.Clock, Ref.Ref<number>]): Effect.Effect<void> {
return core.asUnit(core.flatMap(
state[0].currentTimeMillis,
(now) => ref.set(state[1], now)
))
}
run(
state: readonly [Clock.Clock, Ref.Ref<number>],
getExcess: Effect.Effect<number>,
shrink: Effect.Effect<void>
): Effect.Effect<void> {
return core.flatMap(getExcess, (excess) =>
excess <= 0
? core.zipRight(
state[0].sleep(this.timeToLive),
this.run(state, getExcess, shrink)
)
: pipe(
core.zipWith(
ref.get(state[1]),
state[0].currentTimeMillis,
(start, end) => end - start
),
core.flatMap((duration) => {
if (duration >= Duration.toMillis(this.timeToLive)) {
return core.zipRight(shrink, this.run(state, getExcess, shrink))
} else {
return core.zipRight(state[0].sleep(this.timeToLive), this.run(state, getExcess, shrink))
}
})
))
}
}
class PoolImpl<in out A, in out E> implements Pool.Pool<A, E> {
readonly [PoolTypeId] = poolVariance
constructor(
readonly creator: Effect.Effect<A, E, Scope.Scope>,
readonly min: number,
readonly max: number,
readonly isShuttingDown: Ref.Ref<boolean>,
readonly state: Ref.Ref<PoolState>,
readonly items: Queue.Queue<Attempted<A, E>>,
readonly invalidated: Ref.Ref<HashSet.HashSet<A>>,
readonly track: (exit: Exit.Exit<A, E>) => Effect.Effect<unknown>
) {}
[Hash.symbol](): number {
return pipe(
Hash.hash(this.creator),
Hash.combine(Hash.number(this.min)),
Hash.combine(Hash.number(this.max)),
Hash.combine(Hash.hash(this.isShuttingDown)),
Hash.combine(Hash.hash(this.state)),
Hash.combine(Hash.hash(this.items)),
Hash.combine(Hash.hash(this.invalidated)),
Hash.combine(Hash.hash(this.track)),
Hash.cached(this)
)
}
[Equal.symbol](that: unknown): boolean {
return isPool(that) &&
Equal.equals(this.creator, (that as PoolImpl<A, E>).creator) &&
this.min === (that as PoolImpl<A, E>).min &&
this.max === (that as PoolImpl<A, E>).max &&
Equal.equals(this.isShuttingDown, (that as PoolImpl<A, E>).isShuttingDown) &&
Equal.equals(this.state, (that as PoolImpl<A, E>).state) &&
Equal.equals(this.items, (that as PoolImpl<A, E>).items) &&
Equal.equals(this.invalidated, (that as PoolImpl<A, E>).invalidated) &&
Equal.equals(this.track, (that as PoolImpl<A, E>).track)
}
pipe() {
return pipeArguments(this, arguments)
}
get get(): Effect.Effect<A, E, Scope.Scope> {
const acquire = (
restore: <AX, EX, RX>(effect: Effect.Effect<AX, EX, RX>) => Effect.Effect<AX, EX, RX>
): Effect.Effect<Attempted<A, E>> =>
core.flatMap(ref.get(this.isShuttingDown), (down) =>
down
? core.interrupt
: core.flatten(ref.modify(this.state, (state) => {
if (state.free > 0 || state.size >= this.max) {
return [
core.flatMap(
queue.take(this.items),
(attempted) =>
core.exitMatch(attempted.result, {
onFailure: () => core.succeed(attempted),
onSuccess: (item) =>
core.flatMap(
ref.get(this.invalidated),
(set) => {
if (pipe(set, HashSet.has(item))) {
return core.zipRight(finalizeInvalid(this, attempted), acquire(restore))
}
return core.succeed(attempted)
}
)
})
),
{ ...state, free: state.free - 1 }
] as const
}
if (state.size >= 0) {
return [
core.zipRight(allocate(this, restore), acquire(restore)),
{ size: state.size + 1, free: state.free + 1 }
] as const
}
return [core.interrupt, state] as const
})))
const release = (attempted: Attempted<A, E>): Effect.Effect<unknown> =>
core.exitMatch(attempted.result, {
onFailure: () =>
core.flatten(ref.modify(this.state, (state) => {
if (state.size <= this.min) {
return [allocateUinterruptible(this), { ...state, free: state.free + 1 }] as const
}
return [core.unit, { ...state, size: state.size - 1 }] as const
})),
onSuccess: (item) =>
core.flatMap(ref.get(this.invalidated), (set) => {
if (pipe(set, HashSet.has(item))) {
return finalizeInvalid(this, attempted)
}
return pipe(
ref.update(this.state, (state) => ({ ...state, free: state.free + 1 })),
core.zipRight(queue.offer(this.items, attempted)),
core.zipRight(this.track(attempted.result)),
core.zipRight(core.whenEffect(getAndShutdown(this), ref.get(this.isShuttingDown)))
)
})
})
return pipe(
core.uninterruptibleMask((restore) =>
core.tap(acquire(restore), (a) => fiberRuntime.addFinalizer((_exit) => release(a)))
),
fiberRuntime.withEarlyRelease,
fiberRuntime.disconnect,
core.flatMap(([release, attempted]) =>
pipe(
effect.when(release, () => isFailure(attempted)),
core.zipRight(toEffect(attempted))
)
)
)
}
invalidate(item: A): Effect.Effect<void> {
return ref.update(this.invalidated, HashSet.add(item))
}
}
const allocate = <A, E>(
self: PoolImpl<A, E>,
restore: <AX, EX, RX>(effect: Effect.Effect<AX, EX, RX>) => Effect.Effect<AX, EX, RX>
): Effect.Effect<unknown> =>
core.flatMap(fiberRuntime.scopeMake(), (scope) =>
core.flatMap(
core.exit(restore(fiberRuntime.scopeExtend(self.creator, scope))),
(exit) =>
core.flatMap(
core.succeed<Attempted<A, E>>({
result: exit as Exit.Exit<A, E>,
finalizer: core.scopeClose(scope, core.exitSucceed(void 0))
}),
(attempted) =>
pipe(
queue.offer(self.items, attempted),
core.zipRight(self.track(attempted.result)),
core.zipRight(core.whenEffect(getAndShutdown(self), ref.get(self.isShuttingDown))),
core.as(attempted)
)
)
))
const allocateUinterruptible = <A, E>(
self: PoolImpl<A, E>
): Effect.Effect<unknown> => core.uninterruptibleMask((restore) => allocate(self, restore))
/**
* Returns the number of items in the pool in excess of the minimum size.
*/
const excess = <A, E>(self: PoolImpl<A, E>): Effect.Effect<number> =>
core.map(ref.get(self.state), (state) => state.size - Math.min(self.min, state.free))
const finalizeInvalid = <A, E>(
self: PoolImpl<A, E>,
attempted: Attempted<A, E>
): Effect.Effect<unknown> =>
pipe(
forEach(attempted, (a) => ref.update(self.invalidated, HashSet.remove(a))),
core.zipRight(attempted.finalizer),
core.zipRight(
core.flatten(ref.modify(self.state, (state) => {
if (state.size <= self.min) {
return [allocateUinterruptible(self), { ...state, free: state.free + 1 }] as const
}
return [core.unit, { ...state, size: state.size - 1 }] as const
}))
)
)
/**
* Gets items from the pool and shuts them down as long as there are items
* free, signalling shutdown of the pool if the pool is empty.
*/
const getAndShutdown = <A, E>(self: PoolImpl<A, E>): Effect.Effect<void> =>
core.flatten(ref.modify(self.state, (state) => {
if (state.free > 0) {
return [
core.matchCauseEffect(queue.take(self.items), {
onFailure: () => core.unit,
onSuccess: (attempted) =>
pipe(
forEach(attempted, (a) => ref.update(self.invalidated, HashSet.remove(a))),
core.zipRight(attempted.finalizer),
core.zipRight(ref.update(self.state, (state) => ({ ...state, size: state.size - 1 }))),
core.flatMap(() => getAndShutdown(self))
)
}),
{ ...state, free: state.free - 1 }
] as const
}
if (state.size > 0) {
return [core.unit, state] as const
}
return [queue.shutdown(self.items), { ...state, size: state.size - 1 }] as const
}))
/**
* Begins pre-allocating pool entries based on minimum pool size.
*/
const initialize = <A, E>(self: PoolImpl<A, E>): Effect.Effect<void> =>
fiberRuntime.replicateEffect(
core.uninterruptibleMask((restore) =>
core.flatten(ref.modify(self.state, (state) => {
if (state.size < self.min && state.size >= 0) {
return [
allocate(self, restore),
{ size: state.size + 1, free: state.free + 1 }
] as const
}
return [core.unit, state] as const
}))
),
self.min,
{ discard: true }
)
/**
* Shrinks the pool down, but never to less than the minimum size.
*/
const shrink = <A, E>(self: PoolImpl<A, E>): Effect.Effect<void> =>
core.uninterruptible(
core.flatten(ref.modify(self.state, (state) => {
if (state.size > self.min && state.free > 0) {
return [
pipe(
queue.take(self.items),
core.flatMap((attempted) =>
pipe(
forEach(attempted, (a) => ref.update(self.invalidated, HashSet.remove(a))),
core.zipRight(attempted.finalizer),
core.zipRight(ref.update(self.state, (state) => ({ ...state, size: state.size - 1 })))
)
)
),
{ ...state, free: state.free - 1 }
] as const
}
return [core.unit, state] as const
}))
)
const shutdown = <A, E>(self: PoolImpl<A, E>): Effect.Effect<void> =>
core.flatten(ref.modify(self.isShuttingDown, (down) =>
down
? [queue.awaitShutdown(self.items), true] as const
: [core.zipRight(getAndShutdown(self), queue.awaitShutdown(self.items)), true]))
const isFailure = <A, E>(self: Attempted<A, E>): boolean => core.exitIsFailure(self.result)
const forEach = <E, A, E2, R>(
self: Attempted<A, E>,
f: (a: A) => Effect.Effect<unknown, E2, R>
): Effect.Effect<unknown, E2, R> =>
core.exitMatch(self.result, {
onFailure: () => core.unit,
onSuccess: f
})
const toEffect = <A, E>(self: Attempted<A, E>): Effect.Effect<A, E> => self.result
/**
* A more powerful variant of `make` that allows specifying a `Strategy` that
* describes how a pool whose excess items are not being used will be shrunk
* down to the minimum size.
*/
const makeWith = <A, E, R, S, R2>(
options: {
readonly acquire: Effect.Effect<A, E, R>
readonly min: number
readonly max: number
readonly strategy: Strategy<S, R2, E, A>
}
): Effect.Effect<Pool.Pool<A, E>, never, R | R2 | Scope.Scope> =>
core.uninterruptibleMask((restore) =>
pipe(
fiberRuntime.all([
core.context<R>(),
ref.make(false),
ref.make<PoolState>({ size: 0, free: 0 }),
queue.bounded<Attempted<A, E>>(options.max),
ref.make(HashSet.empty<A>()),
options.strategy.initial()
]),
core.flatMap(([context, down, state, items, inv, initial]) => {
const pool = new PoolImpl<A, E>(
core.mapInputContext(options.acquire, (old) => Context.merge(old)(context)),
options.min,
options.max,
down,
state,
items,
inv,
(exit) => options.strategy.track(initial, exit)
)
return pipe(
fiberRuntime.forkDaemon(restore(initialize(pool))),
core.flatMap((fiber) =>
core.flatMap(
fiberRuntime.forkDaemon(restore(options.strategy.run(initial, excess(pool), shrink(pool)))),
(shrink) =>
fiberRuntime.addFinalizer(() =>
pipe(
shutdown(pool),
core.zipRight(core.interruptFiber(fiber)),
core.zipRight(core.interruptFiber(shrink))
)
)
)
),
core.as<Pool.Pool<A, E>>(pool)
)
})
)
)
/** @internal */
export const isPool = (u: unknown): u is Pool.Pool<unknown, unknown> => hasProperty(u, PoolTypeId)
/** @internal */
export const make = <A, E, R>(
options: {
readonly acquire: Effect.Effect<A, E, R>
readonly size: number
}
): Effect.Effect<Pool.Pool<A, E>, never, R | Scope.Scope> =>
makeWith({
acquire: options.acquire,
min: options.size,
max: options.size,
strategy: new NoneStrategy()
})
/** @internal */
export const makeWithTTL = <A, E, R>(
options: {
readonly acquire: Effect.Effect<A, E, R>
readonly min: number
readonly max: number
readonly timeToLive: Duration.DurationInput
}
): Effect.Effect<Pool.Pool<A, E>, never, R | Scope.Scope> =>
makeWith({
acquire: options.acquire,
min: options.min,
max: options.max,
strategy: new TimeToLiveStrategy(Duration.decode(options.timeToLive))
})
/** @internal */
export const get = <A, E>(self: Pool.Pool<A, E>): Effect.Effect<A, E, Scope.Scope> => self.get
/** @internal */
export const invalidate = dual<
<A>(value: A) => <E>(self: Pool.Pool<A, E>) => Effect.Effect<void, never, Scope.Scope>,
<A, E>(self: Pool.Pool<A, E>, value: A) => Effect.Effect<void, never, Scope.Scope>
>(2, (self, value) => self.invalidate(value))