UNPKG

actor

Version:

Actor based concurrency primitives for managing effects

1,115 lines (1,028 loc) 27.7 kB
import * as Task from "./task.js" export * from "./task.js" /** * Turns a task (that never fails or sends messages) into an effect of it's * result. * * @template T * @param {Task.Task<T, never>} task * @returns {Task.Effect<T>} */ export const effect = function* (task) { const message = yield* task yield* send(message) } /** * Gets a handle to the task that invoked it. Useful when task needs to * suspend execution until some outside event occurs, in which case handle * can be used resume execution (see `suspend` code example for more details) * * @template T, M, X * @returns {Task.Task<Task.Controller<T, X, M>, never>} */ export function* current() { return /** @type {Task.Controller<T, X, M>} */ (yield CURRENT) } /** * Suspends the current task (task that invokes it), which can then be * resumed from another task or an outside event (e.g. `setTimeout` callback) * by calling the `resume` with an task's handle. * * Calling this in almost all cases is preceeded by call to `current()` in * order to obtain a `handle` which can be passed to `resume` function * to resume the execution. * * Note: This task never fails, although it may never resume either. However * you can utilize `finally` block to do a necessary cleanup in case execution * is aborted. * * @example * ```js * import { current, suspend, resume } from "actor" * function * sleep(duration) { * // get a reference to this task so we can resume it. * const self = yield * current() * // resume this task when timeout fires * const id = setTimeout(() => resume(self), duration) * try { * // suspend this task nothing below this line will run until task is * // resumed. * yield * suspend() * } finally { * // if task is aborted finally block will still run which given you * // chance to cleanup. * clearTimeout(id) * } * } * ``` * * @returns {Task.Task<void, never>} */ export const suspend = function* () { yield SUSPEND } /** * Suspends execution for the given duration in milliseconds, after which * execution is resumed (unless it was aborted in the meantime). * * @example * ```js * function * demo() { * console.log("I'm going to take small nap") * yield * sleep(200) * console.log("I am back to work") * } * ``` * * @param {number} [duration] * @returns {Task.Task<void, never>} */ export function* sleep(duration = 0) { const task = yield* current() const id = setTimeout(enqueue, duration, task) try { yield* suspend() } finally { clearTimeout(id) } } /** * Provides equivalent of `await` in async functions. Specifically it takes * a value that you can `await` on (that is `Promise<T>|T`) and suspends * execution until promise is settled. If promise succeeds execution is resumed * with `T` otherwise an error of type `X` is thrown (which is by default * `unknown` since promises do not encode error type). * * It is useful when you need to deal with potentially async set of operations * without having to check if thing is a promise at every step. * * Please note: This that execution is suspended even if given value is not a * promise, however scheduler will still resume it in the same tick of the event * loop after, just processing other scheduled tasks. This avoids problematic * race condititions that can otherwise occur when values are sometimes promises * and other times are not. * * @example * ```js * function * fetchJSON (url, options) { * const response = yield * wait(fetch(url, options)) * const json = yield * wait(response.json()) * return json * } * ``` * * @template T, [X=unknown] * @param {Task.Await<T>} input * @returns {Task.Task<T, Error>} */ export const wait = function* (input) { const task = yield* current() if (isAsync(input)) { let failed = false /** @type {unknown} */ let output = undefined input.then( value => { failed = false output = value enqueue(task) }, error => { failed = true output = error enqueue(task) } ) yield* suspend() if (failed) { throw output } else { return /** @type {T} */ (output) } } else { // This may seem redundunt but it is not, by enqueuing this task we allow // scheduler to perform other queued tasks first. This way many race // conditions can be avoided when values are sometimes promises and other // times aren't. // Unlike `await` however this will resume in the same tick. main(wake(task)) yield* suspend() return input } } /** * @template T, X, M * @param {Task.Controller<T, X, M>} task * @returns {Task.Task<void, never, never>} */ function* wake(task) { enqueue(task) } /** * Checks if value value is a promise (or it's lookalike). * * @template T * @param {any} node * @returns {node is PromiseLike<T>} */ const isAsync = node => node != null && typeof (/** @type {{then?:unknown}} */ (node).then) === "function" /** * Task that sends given message (or rather an effect producing this message). * Please note, that while you could use `yield message` instead, but you'd risk * having to deal with potential breaking changes if library internals change * in the future, which in fact may happen as anticipated improvements in * TS generator inference could enable replace need for `yield *`. * * @see https://github.com/microsoft/TypeScript/issues/43632 * * @template T * @param {T} message * @returns {Task.Effect<T>} */ export const send = function* (message) { yield /** @type {Task.Message<T>} */ (message) } /** * Takes several effects and merges them into a single effect of tagged * variants so that their source could be identified via `type` field. * * @example * ```js * listen({ * read: Task.effect(dbRead), * write: Task.effect(dbWrite) * }) * ``` * * @template {string} Tag * @template T * @param {{ [K in Tag]: Task.Effect<T> }} source * @returns {Task.Effect<Tagged<Tag, T>>} */ export const listen = function* (source) { /** @type {Task.Fork<void, never, Tagged<Tag, T>>[]} */ const forks = [] for (const entry of Object.entries(source)) { const [name, effect] = /** @type {[Tag, Task.Effect<T>]} */ (entry) if (effect !== NONE) { forks.push(yield* fork(tag(effect, name))) } } yield* group(forks) } /** * Takes several tasks and creates an effect of them all. * * @example * ```js * Task.effects([ * dbRead, * dbWrite * ]) * ``` * * @template {string} Tag * @template T * @param {Task.Task<T, never>[]} tasks * @returns {Task.Effect<T>} */ export const effects = tasks => tasks.length > 0 ? batch(tasks.map(effect)) : NONE /** * Takes several effects and combines them into a one. * * @template T * @param {Task.Effect<T>[]} effects * @returns {Task.Effect<T>} */ export function* batch(effects) { const forks = [] for (const effect of effects) { forks.push(yield* fork(effect)) } yield* group(forks) } /** * @template {string} Tag * @template T * @typedef {{type: Tag} & {[K in Tag]: T}} Tagged */ /** * Tags an effect by boxing each event with an object that has `type` field * corresponding to given tag and same named field holding original message * e.g. given `nums` effect that produces numbers, `tag(nums, "inc")` would * create an effect that produces events like `{type:'inc', inc:1}`. * * @template {string} Tag * @template T, M, X * @param {Task.Task<T, X, M>} effect * @param {Tag} tag * @returns {Task.Task<T, X, Tagged<Tag, M>>} */ export const tag = (effect, tag) => // @ts-ignore effect === NONE ? NONE : effect instanceof Tagger ? new Tagger([...effect.tags, tag], effect.source) : new Tagger([tag], effect) /** * @template {string} Tag * @template Success, Failure, Message * * @implements {Task.Task<Success, Failure, Tagged<Tag, Message>>} * @implements {Task.Controller<Success, Failure, Tagged<Tag, Message>>} */ class Tagger { /** * @param {Task.Task<Success, Failure, Message>} source * @param {string[]} tags */ constructor(tags, source) { this.tags = tags this.source = source /** @type {Task.Controller<Success, Failure, Message>} */ this.controller } /* c8 ignore next 3 */ [Symbol.iterator]() { if (!this.controller) { this.controller = this.source[Symbol.iterator]() } return this } /** * @param {Task.TaskState<Success, Message>} state * @returns {Task.TaskState<Success, Tagged<Tag, Message>>} */ box(state) { if (state.done) { return state } else { switch (state.value) { case SUSPEND: case CURRENT: return /** @type {Task.TaskState<Success, Tagged<Tag, Message>>} */ ( state ) default: { // Instead of boxing result at each transform step we perform in-place // mutation as we know nothing else is accessing this value. const tagged = /** @type {{ done: false, value: any }} */ (state) let { value } = tagged for (const tag of this.tags) { value = withTag(tag, value) } tagged.value = value return tagged } } } } /** * * @param {Task.Instruction<Message>} instruction */ next(instruction) { return this.box(this.controller.next(instruction)) } /** * * @param {Failure} error */ throw(error) { return this.box(this.controller.throw(error)) } /** * @param {Success} value */ return(value) { return this.box(this.controller.return(value)) } get [Symbol.toStringTag]() { return "TaggedEffect" } } /** * Returns empty `Effect`, that is produces no messages. Kind of like `[]` or * `""` but for effects. * * @type {() => Task.Effect<never>} */ export const none = () => NONE /** * Takes iterable of tasks and runs them concurrently, returning array of * results in an order of tasks (not the order of completion). If any of the * tasks fail all the rest are aborted and error is throw into calling task. * * > This is basically equivalent of `Promise.all` except cancelation logic * because tasks unlike promises can be cancelled. * * @template T, X * @param {Iterable<Task.Task<T, X>>} tasks * @returns {Task.Task<T[], X>} */ export const all = function* (tasks) { const self = yield* current() /** @type {(id:number) => (value:T) => void} */ const succeed = id => value => { delete forks[id] results[id] = value count -= 1 if (count === 0) { enqueue(self) } } /** @type {(error:X) => void} */ const fail = error => { for (const handle of forks) { if (handle) { enqueue(abort(handle, error)) } } enqueue(abort(self, error)) } /** @type {Task.Fork<void, never>[]} */ let forks = [] let count = 0 for (const task of tasks) { forks.push(yield* fork(then(task, succeed(count++), fail))) } const results = new Array(count) if (count > 0) { yield* suspend() } return results } /** * @template {string} Tag * @template T * @param {Tag} tag * @param {Task.Message<T>} value */ const withTag = (tag, value) => /** @type {Tagged<Tag, T>} */ ({ type: tag, [tag]: value }) /** * Kind of like promise.then which is handy when you want to extract result * from the given task from the outside. * * @template T, U, X, M * @param {Task.Task<T, X, M>} task * @param {(value:T) => U} resolve * @param {(error:X) => U} reject * @returns {Task.Task<U, never, M>} */ export function* then(task, resolve, reject) { try { return resolve(yield* task) } catch (error) { return reject(/** @type {X} */ (error)) } } // Special control instructions recognized by a scheduler. const CURRENT = Symbol("current") const SUSPEND = Symbol("suspend") /** @typedef {typeof SUSPEND|typeof CURRENT} Control */ /** * @template M * @param {Task.Instruction<M>} value * @returns {value is M} */ export const isMessage = value => { switch (value) { case SUSPEND: case CURRENT: return false default: return true } } /** * @template M * @param {Task.Instruction<M>} value * @returns {value is Control} */ export const isInstruction = value => !isMessage(value) /** * @template T, X, M * @implements {Task.TaskGroup<T, X, M>} */ class Group { /** * @template T, X, M * @param {Task.Controller<T, X, M>|Task.Fork<T, X, M>} member * @returns {Task.Group<T, X, M>} */ static of(member) { return ( /** @type {{group?:Task.TaskGroup<T, X, M>}} */ (member).group || MAIN ) } /** * @template T, X, M * @param {(Task.Controller<T, X, M>|Task.Fork<T, X, M>) & {group?:Task.TaskGroup<T, X, M>}} member * @param {Task.TaskGroup<T, X, M>} group */ static enqueue(member, group) { member.group = group group.stack.active.push(member) } /** * @param {Task.Controller<T, X, M>} driver * @param {Task.Controller<T, X, M>[]} [active] * @param {Set<Task.Controller<T, X, M>>} [idle] * @param {Task.Stack<T, X, M>} [stack] */ constructor( driver, active = [], idle = new Set(), stack = new Stack(active, idle) ) { this.driver = driver this.parent = Group.of(driver) this.stack = stack this.id = ++ID } } /** * @template T, X, M * @implements {Task.Main<T, X, M>} */ class Main { constructor() { this.status = IDLE this.stack = new Stack() this.id = /** @type {0} */ (0) } } /** * @template T, X, M */ class Stack { /** * @param {Task.Controller<T, X, M>[]} [active] * @param {Set<Task.Controller<T, X, M>>} [idle] */ constructor(active = [], idle = new Set()) { this.active = active this.idle = idle } /** * * @param {Task.Stack<unknown, unknown, unknown>} stack * @returns */ static size({ active, idle }) { return active.length + idle.size } } /** * Starts a main task. * * @param {Task.Task<void, never>} task */ export const main = task => enqueue(task[Symbol.iterator]()) /** * @template T, X, M * @param {Task.Controller<T, X, M>} task */ const enqueue = task => { let group = Group.of(task) group.stack.active.push(task) group.stack.idle.delete(task) // then walk up the group chain and unblock their driver tasks. while (group.parent) { const { idle, active } = group.parent.stack if (idle.has(group.driver)) { idle.delete(group.driver) active.push(group.driver) } else { // if driver was not blocked it must have been unblocked by // other task so stop there. break } group = group.parent } if (MAIN.status === IDLE) { MAIN.status = ACTIVE while (true) { try { for (const _message of step(MAIN)) { } MAIN.status = IDLE break } catch (_error) { // Top level task may crash and throw an error, but given this is a main // group we do not want to interupt other unrelated tasks, which is why // we discard the error and the task that caused it. MAIN.stack.active.shift() } } } } /** * @template T, X, M * @param {Task.Controller<T, X, M>} task */ export const resume = task => enqueue(task) /** * @template T, X, M * @param {Task.Group<T, X, M>} group */ const step = function* (group) { const { active } = group.stack let task = active[0] group.stack.idle.delete(task) while (task) { /** @type {Task.TaskState<T, M>} */ let state = INIT // Keep processing insturctions until task is done, it send suspend request // or it's has been removed from the active queue. // ⚠️ Group changes require extra care so please make sure to understand // the detail here. It occurs when spawned task(s) are joined into a group // which will change the task driver, that is when `task === active[0]` will // became false and need to to drop the task immediately otherwise race // condition will occur due to task been driven by multiple concurrent // schedulers. loop: while (!state.done && task === active[0]) { const instruction = state.value switch (instruction) { // if task is suspended we add it to the idle list and break the loop // to move to a next task. case SUSPEND: group.stack.idle.add(task) break loop // if task requested a context (which is usually to suspend itself) // pass back a task reference and continue. case CURRENT: state = task.next(task) break default: // otherwise task sent a message which we yield to the driver and // continue state = task.next( yield /** @type {M & Task.Message<M>}*/ (instruction) ) break } } // If task is complete, or got suspended we move to a next task active.shift() task = active[0] group.stack.idle.delete(task) } } /** * Executes given task concurrently with a current task (task that spawned it). * Spawned task is detached from the task that spawned it and it can outlive it * and / or fail without affecting a task that spawned it. If you need to wait * on concurrent task completion consider using `fork` instead which can be * later `joined`. If you just want a to block on task execution you can just * `yield* work()` directly instead. * * @param {Task.Task<void, never, never>} task * @returns {Task.Task<void, never>} */ export function* spawn(task) { main(task) } /** * Executes given task concurrently with current task (the task that initiated * fork). Froked task is detached from the task that created it and it can * outlive it and / or fail without affecting it. You do however get a handle * for the fork which could be used to `join` the task, in which case `joining` * task will block until fork finishes execution. * * This is also a primary interface for executing tasks from the outside of the * task context. Function returns `Fork` which implements `Promise` interface * so it could be awaited. Please note that calling `fork` does not really do * anything, it lazily starts execution when you either `await fork(work())` * from arbitray context or `yield* fork(work())` in anothe task context. * * @template T, X, M * @param {Task.Task<T, X, M>} task * @param {Task.ForkOptions} [options] * @returns {Task.Fork<T, X, M>} */ export const fork = (task, options) => new Fork(task, options) /** * Exits task succesfully with a given return value. * * @template T, M, X * @param {Task.Controller<T, M, X>} handle * @param {T} value * @returns {Task.Task<void, never>} */ export const exit = (handle, value) => conclude(handle, { ok: true, value }) /** * Terminates task execution execution. Only takes task that produces no * result, if your task has non `void` return type you should use `exit` instead. * * @template M, X * @param {Task.Controller<void, X, M>} handle */ export const terminate = handle => conclude(handle, { ok: true, value: undefined }) /** * Aborts given task with an error. Task error type should match provided error. * * @template T, M, X * @param {Task.Controller<T, X, M>} handle * @param {X} [error] */ export const abort = (handle, error) => conclude(handle, { ok: false, error }) /** * Aborts given task with an given error. * * @template T, M, X * @param {Task.Controller<T, X, M>} handle * @param {Task.Result<T, X>} result * @returns {Task.Task<void, never> & Task.Controller<void, never>} */ function* conclude(handle, result) { try { const task = handle const state = result.ok ? task.return(result.value) : task.throw(result.error) if (!state.done) { if (state.value === SUSPEND) { const { idle } = Group.of(task).stack idle.add(task) } else { enqueue(task) } } } catch (error) {} } /** * Groups multiple forks togather and joins joins them with current task. * * @template T, X, M * @param {Task.Fork<T, X, M>[]} forks * @returns {Task.Task<void, X, M>} */ export function* group(forks) { // Abort eraly if there'se no work todo. if (forks.length === 0) return const self = yield* current() /** @type {Task.TaskGroup<T, X, M>} */ const group = new Group(self) /** @type {Task.Failure<X>|null} */ let failure = null for (const fork of forks) { const { result } = fork if (result) { if (!result.ok && !failure) { failure = result } continue } move(fork, group) } // Keep work looping until there is nom more work to be done try { if (failure) { throw failure.error } while (true) { yield* step(group) if (Stack.size(group.stack) > 0) { yield* suspend() } else { break } } } catch (error) { for (const task of group.stack.active) { yield* abort(task, error) } for (const task of group.stack.idle) { yield* abort(task, error) enqueue(task) } throw error } } /** * @template T, X, M * @param {Task.Fork<T, X, M>} fork * @param {Task.TaskGroup<T, X, M>} group */ const move = (fork, group) => { const from = Group.of(fork) if (from !== group) { const { active, idle } = from.stack const target = group.stack fork.group = group // If it is idle just move from one group to the other // and update the group task thinks it belongs to. if (idle.has(fork)) { idle.delete(fork) target.idle.add(fork) } else { const index = active.indexOf(fork) // If task is in the job queue, we move it to a target job queue. Moving // top task in the queue requires extra care so it does not end up // processed by two groups which would lead to race. For that reason // `step` loop checks for group changes on each turn. if (index >= 0) { active.splice(index, 1) target.active.push(fork) } // otherwise task is complete } } } /** * @template T, X, M * @param {Task.Fork<T, X, M>} fork * @returns {Task.Task<T, X, M>} */ export function* join(fork) { // If fork is still idle activate it. if (fork.status === IDLE) { yield* fork } if (!fork.result) { yield* group([fork]) } const result = /** @type {Task.Result<T, X>} */ (fork.result) if (result.ok) { return result.value } else { throw result.error } } /** * @template T, X * @implements {Task.Future<T, X>} */ class Future { /** * @param {Task.StateHandler<T, X>} handler */ constructor(handler) { this.handler = handler /** * @abstract * @type {Task.Result<T, X>|void} */ this.result } /** * @type {Promise<T>} */ get promise() { const { result } = this const promise = result == null ? new Promise((succeed, fail) => { this.handler.onsuccess = succeed this.handler.onfailure = fail }) : result.ok ? Promise.resolve(result.value) : Promise.reject(result.error) Object.defineProperty(this, "promise", { value: promise }) return promise } /** * @template U, [E=never] * @param {((value:T) => U | PromiseLike<U>)|undefined|null} [onresolve] * @param {((error:X) => E|PromiseLike<E>)|undefined|null} [onreject] * @returns {Promise<U|E>} */ then(onresolve, onreject) { return this.activate().promise.then(onresolve, onreject) } /** * @template [U=never] * @param {(error:X) => U} onreject */ catch(onreject) { return /** @type {Task.Future<T|U, never>} */ ( this.activate().promise.catch(onreject) ) } /** * @param {() => void} onfinally * @returns {Task.Future<T, X>} */ finally(onfinally) { return /** @type {Task.Future<T, X>} */ ( this.activate().promise.finally(onfinally) ) } /** * @abstract */ /* c8 ignore next 3 */ activate() { return this } } /** * @template T, X, M * @implements {Task.Fork<T, X, M>} * @implements {Task.Controller<T, X, M>} * @implements {Task.Task<Task.Fork<T, X, M>, never>} * @implements {Task.Future<T, X>} * @extends {Future<T, X>} */ class Fork extends Future { /** * @param {Task.Task<T, X, M>} task * @param {Task.ForkOptions} [options] * @param {Task.StateHandler<T, X>} [handler] * @param {Task.TaskState<T, M>} [state] */ constructor(task, options = BLANK, handler = {}, state = INIT) { super(handler) this.id = ++ID this.name = options.name || "" /** @type {Task.Task<T, X, M>} */ this.task = task this.state = state this.status = IDLE /** @type {Task.Result<T, X>} */ this.result this.handler = handler /** @type {Task.Controller<T, X, M>} */ this.controller } *resume() { resume(this) } /** * @returns {Task.Task<T, X, M>} */ join() { return join(this) } /** * @param {X} error */ abort(error) { return abort(this, error) } /** * @param {T} value */ exit(value) { return exit(this, value) } get [Symbol.toStringTag]() { return "Fork" } /** * @returns {Task.Controller<Task.Fork<T, X, M>, never, never>} */ *[Symbol.iterator]() { return this.activate() } activate() { this.controller = this.task[Symbol.iterator]() this.status = ACTIVE enqueue(this) return this } /** * @private * @param {any} error * @returns {never} */ panic(error) { this.result = { ok: false, error } this.status = FINISHED const { handler } = this if (handler.onfailure) { handler.onfailure(error) } throw error } /** * @private * @param {Task.TaskState<T, M>} state */ step(state) { this.state = state if (state.done) { this.result = { ok: true, value: state.value } this.status = FINISHED const { handler } = this if (handler.onsuccess) { handler.onsuccess(state.value) } } return state } /** * @param {unknown} value */ next(value) { try { return this.step(this.controller.next(value)) } catch (error) { return this.panic(error) } } /** * @param {T} value */ return(value) { try { return this.step(this.controller.return(value)) } catch (error) { return this.panic(error) } } /** * @param {X} error */ throw(error) { try { return this.step(this.controller.throw(error)) } catch (error) { return this.panic(error) } } } /** * @template M * @param {Task.Effect<M>} init * @param {(message:M) => Task.Effect<M>} next * @returns {Task.Task<void, never, never>} */ export const loop = function* (init, next) { /** @type {Task.Controller<void, never, M>} */ const controller = yield* current() const group = new Group(controller) Group.enqueue(init[Symbol.iterator](), group) while (true) { for (const message of step(group)) { Group.enqueue(next(message)[Symbol.iterator](), group) } if (Stack.size(group.stack) > 0) { yield* suspend() } else { break } } } let ID = 0 /** @type {Task.Status} */ const IDLE = "idle" const ACTIVE = "active" const FINISHED = "finished" /** @type {Task.TaskState<any, any>} */ const INIT = { done: false, value: CURRENT } const BLANK = {} /** @type {Task.Effect<never>} */ const NONE = (function* none() {})() /** @type {Task.Main<any, any, any>} */ const MAIN = new Main()