From e886563a0a8d40618091901ebb8a0ad038d0a1a1 Mon Sep 17 00:00:00 2001 From: miinhho Date: Sat, 12 Sep 2026 17:53:40 +0900 Subject: [PATCH] chore: version 0.3.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Breaking since 0.2.1: Job is a single-result lifetime (#22) — the Published type parameter, the publisher body argument, JobPublisher, and Job.value() are gone; HandoffJob is the one-shot handoff. #24 and #26 change no public behavior. The README is rewritten for a first-time reader: the model in one paragraph, Jobs grouped by ownership / lifecycle / results / failure, cancellation before context, Supervisor and TaskGroup with their policies, HandoffJob last, and an API summary. --- README.md | 262 ++++++++++++++++++++++++++------------------------- package.json | 2 +- 2 files changed, 136 insertions(+), 128 deletions(-) diff --git a/README.md b/README.md index 16934f7..2913427 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # @tiberjs/runner -Structured concurrency for Node.js, built around **Job**, **Supervisor**, **TaskGroup**, and immutable **Context**. +Structured concurrency for Node.js: every piece of asynchronous work is a **Job** that owns the work it starts, settles only after all of it has finished, and is cancelled together with it. Requires Node.js 24 or newer. ESM only. @@ -8,45 +8,59 @@ Requires Node.js 24 or newer. ESM only. pnpm add @tiberjs/runner ``` +## The model in one paragraph + +A Job runs a body. Anything the body starts — with `fork()`, `execute()`, `timeout()`, or a Supervisor — becomes a child of that Job. A Job does not finish when its body returns; it finishes when its body **and every descendant** have finished, finalizers included. Cancelling a Job cancels its subtree. A child that fails cancels its siblings and fails its owner. Nothing runs detached by accident: work that must outlive its Job is submitted explicitly to another owner. + +The code that runs inside a Job sees its environment through a small ambient API: `signal()` for cancellation, `deadline()` for its time budget, `use(key)` for typed context values. None of that is passed by hand. + +```ts +import { execute, fork, signal, use } from "@tiberjs/runner"; + +const total = await execute(async () => { + const a = fork(() => fetchPart(1, { signal: signal() })); + const b = fork(() => fetchPart(2, { signal: signal() })); + return (await a) + (await b); +}); +// execute() resolves only after both children have settled. +// If one child throws, the other is cancelled and execute() rejects with the failure. +``` + ## Jobs -A `Job` is both an execution and its awaitable lifetime. Construction is cold: it neither runs the body nor captures the current context. Call `start()` once to activate it. +A `Job` is an execution and its awaitable lifetime. Construction is cold: it neither runs the body nor captures the current context. `start()` activates it once. ```ts -import { Job, execute, fork } from "@tiberjs/runner"; +import { Job } from "@tiberjs/runner"; const job = new Job(() => 42); job.start(); console.log(await job); // 42 - -const result = await execute(async () => { - const first = fork(() => "first"); - const second = fork(() => "second"); - return [await first, await second]; -}); ``` -- A Job settles only after its body and all descendants finish, including asynchronous finalizers. -- `fork()` starts a child of the current Job. `new Job(body).start()` does the same inside an execution, or starts a root outside one. -- `start({ parent })` selects an explicit owner and inherits that owner's context. `start({ parent: undefined })` starts an independent root. -- `cancel(reason)` requests cooperative cancellation. It does not mean the work has finished. -- `join()` and `await job` observe the complete result. Awaiting a cold Job rejects; awaiting never starts it. -- `finish()` stops admission of direct children and joins without cancellation. `close(reason)` stops admission, cancels, and joins. Repeated `close()` calls share their result. -- `close()` ignores expected cancellation but rejects genuine execution or finalizer failures. Jobs support `await using`. -- Natural body completion stops direct-child admission but does not cancel existing descendants. -- An ordinary child failure propagates to its owner and cancels siblings, even if the child is awaited and its rejection is caught. `execute()` and `timeout()` create lexical failure boundaries: catching their rejection leaves the enclosing Job usable. -- A Job cannot await itself or an ancestor. Its body may use `joinChildren()` to wait for descendants or `cancelChildren(reason)` to cancel and join them without cancelling itself. +### Ownership -`job.state` is `"created"`, `"running"`, `"closing"`, or `"closed"`. `job.parent` is its actual owner; `job.size` counts active direct children. +- `fork(body)` starts a child of the current Job. `new Job(body).start()` does the same inside an execution and starts an independent root outside one. +- `start({ parent })` selects an explicit owner and inherits that owner's context. `start({ parent: undefined })` starts a root deliberately. +- `job.parent` is the actual owner; `job.size` counts active direct children. +- A Job cannot await itself or an ancestor; that is a `LifecycleDependencyError`. Inside a body, use `joinChildren()` to wait for descendants, or `cancelChildren(reason)` to cancel and join them without cancelling the Job itself. -`await job.result()` returns a `JobResult`: `{ ok: true, value }` on success or `{ ok: false, error }` on failure, including cancellation. It waits for the body and all descendants without throwing their execution errors. In contrast, `await job` and `job.join()` return the success value or throw the original error. +### Lifecycle -```ts -import { Job } from "@tiberjs/runner"; +`job.state` moves through `"created"` → `"running"` → `"closing"` → `"closed"`. + +- When the body returns, the Job stops admitting new direct children and waits for the existing ones. It does not cancel them. +- `finish()` closes admission and joins without cancelling. `close(reason)` closes admission, cancels, and joins. Repeated `close()` calls share one result. Jobs support `await using`. +- `cancel(reason)` requests cooperative cancellation of the subtree. It returns immediately; the work has not necessarily stopped. -const job = new Job(() => 42).start(); -const result = await job.result(); +### Observing the result +- `await job` and `job.join()` return the body's value or throw the original error. Awaiting a cold Job rejects; awaiting never starts it. +- `await job.result()` returns `{ ok: true, value }` or `{ ok: false, error }` and never throws for execution errors. Use `ok` to tell the cases apart: both a value and a thrown error may be `undefined`. +- `close()` resolves on expected cancellation and rejects on genuine execution or finalizer failures. + +```ts +const result = await new Job(() => 42).start().result(); if (result.ok) { console.log(result.value); } else { @@ -54,52 +68,78 @@ if (result.ok) { } ``` -Use `ok` to distinguish success from failure: both a successful value and a thrown error may be `undefined`. `result()` rejects self/ancestor observation synchronously. +### Failure -`cancelChildren(reason)` cancels direct children, lets cancellation cascade through -their descendants, and resolves after those child lifetimes settle. It does not cancel -the Job itself or close admission for later children. +- An ordinary child failure is recorded on its owner and cancels the owner's other children — even if the child was awaited and its rejection caught. +- `execute()` and `timeout()` are lexical failure boundaries: catching their rejection leaves the enclosing Job usable. +- Independent failures (for example a body error and a finalizer error) are kept together in an `AggregateError`; a sole failure keeps its identity. +- `reconcileFailure(error)` replaces a caught cancellation reason with the genuine failures already recorded from descendants. Use it where a body catches its own cancellation but must report why the subtree really failed. -`reconcileFailure(error)` replaces the Job's cancellation reason with genuine failures -already recorded from its descendants. An independent caught error and recorded failure -are preserved together in an `AggregateError`. +Use native `try/finally`, `using`, or `await using` for resources. Body-local cleanup has its ordinary lexical lifetime; a resource that must outlive a subtree is acquired outside the awaited Job or `execute()` call. -Use native `try/finally`, `using`, or `await using` for resources. Body-local cleanup has its ordinary lexical lifetime. If a resource must outlive an entire subtree, acquire it outside the awaited Job or `execute()` call. - -### HandoffJob +## Cancellation and deadlines -A `HandoffJob` body offers one value and suspends until the consumer resumes it. The Job still settles only after its body and descendants finish. +`signal()` returns the current Job's `AbortSignal`. Pass it to cancellable native APIs and check it after awaits and before irreversible work. Runner cannot force uncooperative code to stop. ```ts -import { HandoffJob } from "@tiberjs/runner"; +import { setTimeout } from "node:timers/promises"; +import { execute, signal, timeout } from "@tiberjs/runner"; -const exchange = new HandoffJob(async (handoff) => { - const response = await prepare(); - const outcome = await handoff.offer(response); // suspended until resume() - await release(outcome); +await execute(async () => { + const value = await timeout(1_000, () => setTimeout(25, "ready", { signal: signal() })); + console.log(value); }); +``` -exchange.start(); -const response = await exchange.receive(); // the Job is still running -exchange.resume(await deliver(response)); -await exchange; // body and descendants have settled +- `deadline()` returns the current absolute deadline in epoch milliseconds, if any. A seed deadline only narrows the inherited one. +- `timeout(ms, body)` creates a child boundary that owns its timer through the completion of the entire subtree, not merely the body. Its promise settles only after cancelled descendants and finalizers finish. +- A Job seeded with an external `signal` follows it: an abort before the body runs cancels the Job without running it; an abort during the body yields a cancelled result. The subscription itself is made lazily, the first time the Job's cancellation is observed — a `signal()` read or a child start — so a Job nobody observes registers no listener. +- Cancellation classification is strict. A rejection that _is_ the signal reason, or Node's `AbortError` with `code: "ABORT_ERR"` and that reason as `cause`, is expected cancellation. An application error is not cancellation merely because its `cause` references the reason. + +## Context + +Context is the execution environment, not an ownership node. Bindings use identity-based keys and are immutable: a derived frame shadows the parent without changing it, so concurrent branches never see each other's values. + +```ts +import { + contextKey, + currentState, + execute, + fork, + provide, + use, + withContext, +} from "@tiberjs/runner"; + +const Tenant = contextKey("tenant"); + +await execute({ values: [provide(Tenant, "outer")] }, async () => { + const owner = currentState().job; + await withContext([provide(Tenant, "inner")], async () => { + console.log(currentState().job === owner); // true: withContext creates no Job + console.log(use(Tenant)); // "inner" + console.log(await fork(() => use(Tenant))); // "inner": children inherit the call-chain frame + }); + console.log(use(Tenant)); // "outer" +}); ``` -- `offer()` and `resume()` each accept one call; a second throws `TypeError`. -- Cancellation rejects a pending `offer()` with its reason. An offer after cancellation still reaches `receive()` but rejects for the body. A later `resume()` is discarded. -- An offer consults the Job's external sources before suspending: one that already aborted rejects the offer. A source that aborts during the suspension releases it only if the body observed `signal()` first; otherwise the consumer's `resume()` ends the suspension and the Job's result still reports the cancellation. -- A body that returns without offering fails. A Job that closes without offering rejects `receive()` with its failure. -- `receive()` rejects self/ancestor observation synchronously. +- `use(key)` returns the binding or `undefined`. `hasContext(key)` distinguishes absence from an explicit `undefined`. `requireContext(key)` throws `MissingContextError` when absent. +- Bound values are stored by reference; they are not cloned or frozen. +- A **seed** (`ExecutionSeed`) is what a Job is started with: `values` (entries to overlay, or a `ContextFrame` to replace inherited values), an `attachment`, an external `signal`, and an absolute `deadline`. Omitted fields inherit. An explicit `attachment`, including `undefined`, replaces the inherited one. +- `currentState()` exposes `{ job, context }` for the active call chain; `context` carries `values`, the Job's `signal`, the `deadline`, and the `attachment`. `currentAttachment()` reads the attachment directly. `job.context` is available after activation and is not a cursor for later `withContext()` calls. +- `ContextFrame.from(entries)` builds an independent frame; `frame.withEntries(entries)` derives one. -## Supervision +## Supervisor -A `Supervisor` manages an ordinary Job supplied by the caller. It directs submissions to that Job and applies failure policy. The application decides when initialization has finished and when to submit subsequent work. +A `Supervisor` manages a Job the caller supplies, submits work to it, and applies a failure policy. It creates no hidden owner: `supervisor.job` is the owner, and every submission is a direct child of it. ```ts import { addAbortListener } from "node:events"; import { readFile } from "node:fs/promises"; import { Job, Supervisor, signal } from "@tiberjs/runner"; +// The owner's body runs until it is cancelled — by close() below. const application = new Job(async () => { const stopped = Promise.withResolvers(); using registration = addAbortListener(signal(), () => stopped.resolve()); @@ -107,9 +147,9 @@ const application = new Job(async () => { }); await using supervisor = new Supervisor(application, { failure: "isolate" }); -supervisor.start({ parent: undefined }); // Explicitly choose an independent root. +supervisor.start({ parent: undefined }); // an independent root -// Initialization is ordinary owned work. The caller decides to await it first. +// Initialization is ordinary owned work; the caller awaits it before depending on it. const packageInfo = await supervisor.run(async () => { const contents = await readFile(new URL("./package.json", import.meta.url), { encoding: "utf8", @@ -118,31 +158,24 @@ const packageInfo = await supervisor.run(async () => { return JSON.parse(contents); }); -const packageName = await supervisor.run(() => packageInfo.name); -console.log(packageName); +console.log(await supervisor.run(() => packageInfo.name)); // Leaving this scope closes the owner and joins all of its work. ``` -The application Job does not reference its Supervisor. There is no separate preparation state or handshake: submissions are accepted whenever the owner is running and not cancelled. If initialization must precede other work, await that initialization before submitting it. - -`Supervisor` must be attached before its Job starts. `start(options)` follows `Job.start(options)`: inside an execution it inherits the current owner and call-chain context; outside one it starts a root. Pass `{ parent: undefined }` to explicitly detach, or `{ parent: owner }` to select an owner. Repeated starts return the same supplied Job without running its body again. - -`run(handler)`, `run(seed, handler)`, and `run(coldJob)` start direct children of the managed Job. These submissions inherit the owner's environment, not the submitter's context. - -The default `failure: "isolate"` keeps a child failure from cancelling its owner or independent siblings. `failure: "fail-fast"` propagates it to the owner. Either way, the Job's result preserves the failure. - -Logging, retries, and notifications belong to the caller. Observe `job.result()`, await the Job, or catch a group submission's rejection. Runner does not automatically log failures; an isolated Job whose result is ignored is not automatically reported. - -`supervisor.state` is the managed Job's state. `flush()` joins children without closing the owner. `close()` delegates to that Job and waits for its body and descendants. `Supervisor` also supports `await using`. +- Attach the Supervisor before its Job starts. `start(options)` follows `Job.start(options)`; repeated starts return the same Job without running its body again. +- `run(body)`, `run(seed, body)`, and `run(coldJob)` start direct children of the managed Job. They inherit the **owner's** environment, not the submitter's. +- Submissions are accepted whenever the owner is running and not cancelled. There is no readiness handshake: if initialization must precede other work, await it first. +- `failure: "isolate"` (the default) keeps a child failure from cancelling the owner or its other children. `failure: "fail-fast"` propagates it to the owner. Either way the failed Job's result preserves the failure. +- `flush()` joins children without closing the owner. `close()` closes the owner and waits for its subtree. `supervisor.state` is the owner's state. Supervisors support `await using`. +- Nothing is logged automatically. Observe results, await Jobs, or catch rejections; an isolated failure whose result nobody reads is not reported. -## Declarative task groups +## Task groups -A `TaskGroup` is an immutable, nested declaration. It has no execution lifetime or cancellation signal. Submit it to a Supervisor with a running owner: +A `TaskGroup` is an immutable, nested declaration of cold Jobs. It has no lifetime and no signal of its own; submitting it to a Supervisor starts every leaf as a direct child of the owner and returns results in the declaration's shape. ```ts import { Job, TaskGroup } from "@tiberjs/runner"; -// Within the scope above, while supervisor.job is running: const plan = new TaskGroup([ new TaskGroup([new Job(() => "A"), new Job(() => "B"), new Job(() => "C")]), new TaskGroup([new Job(() => "D"), new Job(() => "E")]), @@ -152,69 +185,44 @@ const result = await supervisor.run(plan); // [["A", "B", "C"], ["D", "E"]] ``` -All five leaves are actual children of `supervisor.job`. Nested groups do not add synthetic Jobs or owners. The result retains the declaration's nested shape and order. +- Each Job may appear once and must be cold; the whole declaration is validated before any leaf starts. Jobs are single-use, so a declaration cannot be rerun. +- The default group policy is `failure: "fail-fast"`: a genuine member failure cancels the group's other leaves and propagates through enclosing groups. `{ failure: "isolate" }` stops that propagation at the group boundary; inner fail-fast groups still cancel their own leaves. A failure escaping every group follows the Supervisor's policy. +- Completion joins every member and descendant, including cancelled finalizers. A failed group rejects with its genuine failure, or an `AggregateError` for independent ones. +- Leaves capture no ambient environment: each uses its own seed over the owner's context. -- Each Job may occur only once and must still be cold. The entire declaration is checked before any member starts. -- The default group policy is `failure: "fail-fast"`: a genuine member failure cancels the group's other leaves and propagates through enclosing groups. -- A group with `{ failure: "isolate" }` stops that propagation without cancelling its siblings. Inner fail-fast groups still cancel their own leaves. -- A failure escaping all group boundaries follows the Supervisor's policy. -- Group completion joins every member and descendant, including cancelled finalizers. A genuine failure raised after an earlier cancellation still activates group policy. -- A failed group rejects with its genuine failure or an `AggregateError` for independent failures. A sole failure retains its identity; the caller decides how to handle it. +## HandoffJob -Declarations capture no ambient environment. Each leaf uses its explicit seed over the managed owner's context when activated. Since Jobs are single-use, a declaration containing already-started Jobs cannot be rerun. - -## Immutable context - -Context describes the execution environment; it is not an ownership node. Typed bindings use identity-based keys: +Sometimes a Job must hand a value to someone else _before_ it is done — an HTTP exchange publishes its response, then keeps owning cleanup until delivery is acknowledged. `HandoffJob` models that as a one-shot, two-way rendezvous inside an ordinary Job lifetime. ```ts -import { - contextKey, - currentState, - execute, - fork, - provide, - use, - withContext, -} from "@tiberjs/runner"; - -const Tenant = contextKey("tenant"); +import { HandoffJob } from "@tiberjs/runner"; -await execute({ values: [provide(Tenant, "outer")] }, async () => { - const owner = currentState().job; - await withContext([provide(Tenant, "inner")], async () => { - console.log(currentState().job === owner); // true: no new Job - console.log(use(Tenant)); // "inner" - console.log(await fork(() => use(Tenant))); // "inner" - }); - console.log(use(Tenant)); // "outer" +const exchange = new HandoffJob(async (handoff) => { + const response = await prepare(); + const outcome = await handoff.offer(response); // suspended until resume() + await release(outcome); }); -``` - -`withContext()` derives a frame for one call chain. Concurrent derivations do not overwrite one another or mutate the Job's initial context. Plain child activation inherits the current call-chain frame; explicit-owner submissions inherit the owner's frame. - -`use(key)` returns a binding or `undefined`. `hasContext(key)` distinguishes absence from an explicit `undefined`. `requireContext(key)` throws `MissingContextError` when absent. Bound values are stored by reference, not deeply cloned or frozen. -`ContextFrame.from(entries)` constructs an independent frame; `frame.withEntries(entries)` derives one. A Job seed may supply entries to overlay inherited values, or a `ContextFrame` to replace them. Seeds may also supply `attachment`, `signal`, and an absolute `deadline`. Omitted fields inherit; an explicit attachment, including `undefined`, replaces the inherited attachment. - -`currentState()` exposes `{ job, context }` for the active call chain. `context` contains `values`, the Job's signal, an optional deadline, and an attachment. `currentAttachment()` reads that attachment. `job.context` is available after activation; it is not a mutable cursor for later `withContext()` calls. - -## Cancellation and deadlines - -`signal()` returns the current Job's cancellation signal. Pass it to cancellable native APIs, and check it after awaits and before irreversible work. Runner cannot force uncooperative code to stop. - -A Job seeded with an external `signal` subscribes to it lazily, the first time its own cancellation is observed: a `signal()` or `job.signal` read, or a child starting. A Job that never observes it registers no listener. An external source that aborted before the body starts, or while it runs, still yields a cancelled result; only the wake-up of code already suspended requires the subscription. - -```ts -import { setTimeout } from "node:timers/promises"; -import { execute, signal, timeout } from "@tiberjs/runner"; - -await execute(async () => { - const value = await timeout(1_000, () => setTimeout(25, "ready", { signal: signal() })); - console.log(value); -}); +exchange.start(); +const response = await exchange.receive(); // the Job is still running +exchange.resume(await deliver(response)); +await exchange; // body and descendants have settled ``` -`deadline()` returns the current absolute deadline in epoch milliseconds. A seed deadline narrows the inherited deadline. `timeout(milliseconds, handler)` requires an active Job, creates a child boundary, and owns its timer through the completion of the entire subtree—not merely the body. Its promise settles only after cancelled descendants and finalizers finish. - -Cancellation reasons and genuine errors retain their original identity and cause. A rejection identical to the current signal reason, or Node's signal-related `AbortError` with code `ABORT_ERR` and that reason as its cause, is expected cancellation. A different application error is not cancellation merely because its cause references that reason. Independent execution failures are aggregated rather than replacing one another. +- `offer()` and `resume()` each accept one call; a second throws `TypeError`. +- A body that returns without offering fails. A Job that closes before offering rejects `receive()` with its failure. +- Cancelling the Job rejects a pending `offer()` with the reason. An offer made after cancellation still reaches `receive()` but rejects for the body; a `resume()` after release is discarded. +- An external `signal` that already aborted rejects the offer. One that aborts _during_ the suspension releases it only if the body observed `signal()` first; otherwise the consumer's `resume()` ends the suspension and the Job's result still reports the cancellation. +- `receive()` rejects self/ancestor observation synchronously, like `result()`. + +## API summary + +| Export | Role | +| ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | +| `Job`, `HandoffJob` | An execution and its lifetime; the handoff variant offers one value mid-flight | +| `execute`, `fork`, `timeout` | Start a child: awaited boundary, background child, deadline-bounded boundary | +| `Supervisor`, `TaskGroup` | Submit work to a supplied owner; declare nested groups of cold Jobs | +| `signal`, `deadline`, `use`, `hasContext`, `requireContext`, `withContext` | Ambient environment of the running Job | +| `contextKey`, `provide`, `ContextFrame` | Typed context bindings | +| `currentState`, `currentAttachment`, `peekState`, `runWith` | Runtime state access for integrations | +| `combinedError`, `LifecycleStateError`, `LifecycleDependencyError`, `MissingContextError` | Errors | diff --git a/package.json b/package.json index 0b724dd..3d3d528 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tiberjs/runner", - "version": "0.2.1", + "version": "0.3.0", "description": "Job-based structured concurrency, supervision, declarative task groups, and immutable execution context.", "type": "module", "main": "./dist/index.js",