Skip to content

Releases: tiberjs/runner

v0.3.2

Choose a tag to compare

@miinhho miinhho released this 22 Sep 15:37
43e68f8

Changed

FlexJob for intermediate publications

Added FlexJob<Result, Update>, a Job that can publish intermediate values while it is running.

A FlexJob retains the ordinary Job lifecycle and result semantics while exposing a bounded channel for intermediate publications. Its body can call publish(value) multiple times, and consumers can receive those values with receive().

Unlike a handoff, publication is one-way: publish() delivers or stores the value and the body continues without waiting for the consumer to acknowledge it.

import { FlexJob } from "@tiberjs/runner";

type Update = { phase: "started" | "processed" };

const job = new FlexJob<string, Update>(async (publish) => {
  publish({ phase: "started" });
  await doWork();
  publish({ phase: "processed" });
  return "finished";
});

job.start();
const update = await job.receive(); // { done: false, value: Update }, or channel completion
const result = await job.result(); // { ok: true, value: "finished" }, or { ok: false, error }

await job and job.result() retain the ordinary Job contract and wait for the body, descendants, and finalizers to settle.

HandoffJob and Handoff deprecated

HandoffJob and Handoff are now deprecated. Use FlexJob when a Job needs to expose intermediate values before completion.

Existing handoffs retain their one-shot, two-way rendezvous semantics. In particular, offer() suspends the body until the consumer calls resume(). FlexJob intentionally does not provide this acknowledgement mechanism: publish() does not suspend for consumption and there is no corresponding resume().

Code that used a handoff only to expose an intermediate value can generally migrate from offer()/receive() to publish()/receive().

Code that depends on the acknowledgement returned by resume() should not mechanically replace offer() with publish(). Keep that acknowledgement exchange explicit in the Job body instead.

v0.3.1

Choose a tag to compare

@miinhho miinhho released this 12 Sep 10:17
d49faa0

Internal only. No API, behavior, or documentation changes; safe to take from 0.3.0 without reading further.

Changed

ContextFrame keeps one representation for its bindings (#28, follows #25/#26)

0.3.0 stored a frame with a single binding in a dedicated key/value object instead of a Map. Measured on its own, that saved 20–40 ns per Job — under 3% of a Job and invisible end to end — and left two representations behind every frame that from, with, and withEntries had to keep in step. It is reverted; a frame is a Map again.

The consumer that created a one-binding frame per request (the HTTP server binding ContainerKey) now hosts its DI scope on the Runner attachment and creates no per-request frame at all (@tiberjs/di ScopeHost), so the one-binding case is no longer on any hot path.

v0.3.0

Choose a tag to compare

@miinhho miinhho released this 12 Sep 09:03
90a4bce

Job is a single-result lifetime again, one-shot handoff has its own type, and a Job no longer pays for cancellation machinery it never uses. Every consumer of the removed publication API must migrate; everything else is source-compatible.

Breaking

Job<Result> has one type parameter and no publication API (#22, closes #21)

Removed: the Published type parameter on Job, Supervisor, GroupMember, and GroupResults; the publisher argument passed to Job bodies; JobPublisher; Job.value().

// 0.2.x
const job = new Job<void, Prepared>((publish) => { publish(prepared); return finish(); });
const prepared = await job.value();

// 0.3.0
const job = new HandoffJob<void, Prepared, Outcome>(async (handoff) => {
  const outcome = await handoff.offer(prepared); // suspended until resume()
  await finish(outcome);
});
const prepared = await job.receive();
job.resume(outcome);

Bodies that never published need only drop the unused parameter. Every Job that never published used to allocate a LifecycleStateError on completion; plain Job start → complete drops from 3.5 µs to 0.98 µs.

Added

HandoffJob<Result, Offered, Resumed> — a Job whose body offers one value mid-execution and suspends until the consumer resumes it. The lifetime rule is unchanged: the Job settles only after body and descendants finish.

  • offer() and resume() each accept one call; a second throws TypeError.
  • Cancellation (direct, cascaded, deadline) rejects a suspended offer() with the original reason. An offer after cancellation still reaches receive() but rejects for the body; a late resume() is discarded.
  • A body that returns without offering fails; a Job that closes without offering rejects receive() with its failure.
  • receive() rejects self/ancestor observation synchronously, like result().
  • Supervisor.run(job) preserves the submitted subtype.

Changed

External cancellation sources are followed lazily (#24, closes #23)

A Job seeded with an external signal no longer registers a listener in start(). It subscribes the first time its cancellation is observable — a job.signal, signal(), or context.signal read, or a child start — and never if nothing observes it. What you can rely on:

  • A source that aborted before the body runs still cancels the Job without running it.
  • A source that aborts during the body still yields a cancelled result.
  • Only code already suspended needs the subscription to be woken. For HandoffJob, an offer consults its sources before suspending; a source that aborts during the suspension releases the offer only if the body observed signal() first — otherwise the consumer's resume() ends it and the result still reports the cancellation.

Job fixed cost (#26, closes #25)

  • The AbortController is created by cancel() or the first signal read, not in the constructor.
  • A Job without children no longer awaits an empty drain.
  • A single-binding ContextFrame holds one key/value instead of a Map.
  • Internally, a Job's incoming cancellation (signal, followed sources, deadline) is one Cancellation component; every delivery still goes through Job.cancel().

Per-Job cost, Node 24 / Bun, 0.2.1 → 0.3.0:

scenario Node Bun
plain Job 3.5 → 0.82 µs — → 0.70 µs
Job with an external signal nobody observes — → 0.83 µs — → 0.77 µs
HandoffJob with an external signal and one provided value 1.41 → 1.36 µs 1.67 → 1.29 µs

Migration

  1. Replace Job<R, P> + publish() + value() with HandoffJob<R, P, Resumed> + offer() / receive() / resume(); drop the body's publisher parameter elsewhere.
  2. If code relied on an external signal waking a suspended offer() without ever reading signal(), either read signal() in the body before offering or have the consumer resume() with the cancellation outcome.
  3. Nothing else changes: execute, fork, timeout, Supervisor, TaskGroup, context, and error classification are as in 0.2.x.

v0.2.1

Choose a tag to compare

@miinhho miinhho released this 12 Sep 04:23
c1571d1

This release separates early value publication from a Job’s full structured lifetime, adds explicit descendant-control APIs, and removes per-child abort listeners.

Early Job value publication

A Job can now publish one typed value before its body and descendants fully settle.

const release = Promise.withResolvers<void>();

const job = new Job<number, string>(async (publish) => {
  publish("ready");
  await release.promise;
  return 42;
}).start();

console.log(await job.value()); // "ready"

release.resolve();
console.log(await job); // 42
  • Added Job<T, Published = T>.
  • Added the one-shot publish(value) body argument.
  • Added job.value() for observing the published value.
  • Publication remains available after cancellation while the body is still running.
  • job.value() and the final Job result remain independent.
  • Published-value types propagate through Supervisor and TaskGroup APIs.

Descendant lifecycle control

  • Added job.cancelChildren(reason?).
    • Cancels direct children.
    • Cascades cancellation through their descendants.
    • Waits for the cancelled child lifetimes to settle.
    • Does not cancel the owning Job or prevent later child admission.
  • Added job.reconcileFailure(error).
    • Reconciles a caught rejection with genuine descendant failures already recorded by the Job.
    • Preserves independent failures together as an AggregateError.

Cancellation performance and reliability

  • Removed the parent AbortSignal listener previously installed for every child Job.
  • Parent cancellation now uses the Job ownership tree directly.
  • External cancellation listeners are deduplicated and released after the complete Job lifetime drains.
  • Cancellation sources and deadline timers are no longer registered after cancellation has already been committed.
  • Reentrant cancellation during listener registration no longer admits unnecessary subsequent bindings.
  • Descendant cancellation now uses iterative traversal, avoiding call-stack exhaustion for deeply nested Job trees.

A smoke measurement with 10,000 live children confirmed:

  • 0 child-installed listeners on the parent signal.
  • 1 listener on an external cancellation source.
  • Cancellation still propagates through the complete ownership tree.

Compatibility

No intentional breaking API changes. Existing Job<T> usage remains valid because the published-value type defaults to the Job result type.

Changes

  • #17 (#17) Separate Job values from lifetime
  • #18 (#18) Remove parent-child abort listeners
  • #19 (#19) Harden cancellation binding lifecycle
  • #20 (#20) Version 0.2.1

Full Changelog: v0.2.0...v0.2.1

v0.2.0

Choose a tag to compare

@miinhho miinhho released this 11 Sep 17:51
96569b2

@tiberjs/runner:0.2.0 is a clean redesign of the runtime around one lifetime primitive: Job.

A Job is now both an execution and the owner of its actual descendants. Supervision, grouped execution, cancellation, deadlines, and immutable context are composed around that ownership model without hidden scopes or lifecycle managers.

Important

This is a breaking release. It intentionally removes the 0.1 compatibility surface rather than retaining aliases or adapters.

Highlights

Job-owned structured concurrency

Job<T> replaces the split between executions, tasks, task groups, and manually managed runtime state.

import { Job, execute, fork } from "@tiberjs/runner";

const job = new Job(() => 42).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:

  • is cold and single-use
  • captures ownership and context when started, not when constructed
  • settles only after its body and all actual descendants finish
  • owns cancellation for its complete subtree
  • preserves the original success value, cancellation reason, or failure
  • supports await using through AsyncDisposable

await job and job.join() unwrap the result. job.result() observes success or failure as a value without throwing execution errors.

Explicit ownership

Jobs form a real ownership tree.

const child = new Job(handler).start({ parent });

Inside an execution, fork() and new Job(...).start() create children of the current Job. Pass { parent: undefined } to explicitly start an independent root.

There are no synthetic owners between a Job and its descendants.

Explicit supervision

Supervisor manages an ordinary Job supplied by the application. It does not create a hidden root, startup phase, or shutdown scope.

import { addAbortListener } from "node:events";
import { Job, Supervisor, signal } from "@tiberjs/runner";

const application = new Job(async () => {
 const stopped = Promise.withResolvers<void>();
 using registration = addAbortListener(signal(), stopped.resolve);

 await stopped.promise;
});

await using supervisor = new Supervisor(application, {
 failure: "isolate",
});

supervisor.start({ parent: undefined });

const value = await supervisor.run(() => 42);

Supervisor submissions become direct children of the managed Job.

  • failure: "isolate" keeps an independent child failure from cancelling the owner or its siblings.
  • failure: "fail-fast" propagates child failures to the owner.
  • flush() joins current children without closing the owner.
  • close() delegates cancellation and completion to the managed Job.

Initialization order, readiness, logging, retries, and error reporting remain application responsibilities.

Declarative TaskGroup

TaskGroup is now an immutable declaration rather than a lifetime owner.

const group = new TaskGroup([
 new TaskGroup([new Job(() => "A"), new Job(() => "B")]),
 new Job(() => "C"),
]);

const result = await supervisor.run(group);
// [["A", "B"], "C"]

Nested declarations preserve their result shape without creating intermediate Jobs.

Each group selects either:

  • failure: "fail-fast" — cancel sibling leaves when a genuine failure escapes
  • failure: "isolate" — contain that failure at the group boundary

The complete declaration is validated before any member starts.

Context is separate from ownership

Immutable context frames describe an execution environment; they do not create lifetime nodes.

import { contextKey, execute, provide, use, withContext } from "@tiberjs/runner";

const Tenant = contextKey<string>("tenant");

await execute({ values: [provide(Tenant, "outer")] }, async () => {
 await withContext([provide(Tenant, "inner")], async () => {
   console.log(use(Tenant)); // "inner"
 });

 console.log(use(Tenant)); // "outer"
});

New context APIs include:

  • withContext()
  • hasContext()
  • requireContext()
  • MissingContextError

Job seeds can supply context values, an external signal, an absolute deadline, and an opaque attachment.

Stronger cancellation and failure semantics

Cancellation and deadlines now belong to the Job that owns the affected subtree.

  • parent cancellation reaches every actual descendant
  • external abort signals remain linked until the Job drains
  • deadlines cover the complete subtree, including asynchronous finalization
  • cancellation does not hide an independent execution or cleanup failure
  • original error identity and cause are preserved
  • independent failures are represented with AggregateError

Catching an ordinary child Job failure does not erase its ownership effect. Use execute(), timeout(), Supervisor isolation, or an isolated TaskGroup when a failure must be contained.

Breaking changes

Node.js 24 is required

The minimum supported Node.js version is now 24.

Removed lifecycle and infrastructure APIs

Runner now contains only transport-independent execution ownership, supervision, grouping, cancellation, deadlines, and context.

The following responsibilities have been removed from the package:

  • dependency injection and hierarchical resource scopes
  • application startup and shutdown orchestration
  • event delivery
  • tracing
  • implicit cleanup registration

Removed APIs include:

  • ApplicationLifecycle
  • AppStarted, AppClosing, AppClosed
  • Scope and its DI/resource APIs
  • inject, scoped, currentScope, onStart, onDispose
  • EventBus, eventKey
  • setTracer, span
  • defer
  • begin
  • scheduleDeadline

These concerns can now be composed independently at the application boundary instead of being coupled to every execution.

Use native try/finally, using, and await using for lexical resource ownership.

fork() returns Job

fork() now creates and returns a real child Job<T>. The previous Task<T> type and observation-transfer semantics have been removed.

TaskGroup no longer owns runtime state

The old execution-owned TaskGroup and forkGroup() APIs have been removed.

Create cold Jobs, place them in a declarativeTaskGroup, and submit the group through a running Supervisor.

RuntimeState changed

RuntimeState is now:

interface RuntimeState {
 readonly job: Job<unknown>;
 readonly context: ExecutionContext;
}

Task, scope, and attachment fields no longer exist at the top level. Attachments are available through:

currentState().context.attachment;
currentAttachment<T>();

Do not manually recreate the old begin() lifecycle. Use an explicit Job for executions whose lifetime must outlive the initial call chain.

Why this release

The 0.1 runtime combined execution ownership, context, dependency injection, resources, events, tracing, and application lifecycle in one package.

0.2 establishes a smaller boundary:

  • Job owns execution lifetime.
  • Supervisor applies admission and failure policy.
  • TaskGroup declares grouped work.
  • Context carries immutable execution values.
  • Applications and optional packages own infrastructure concerns.

This removes duplicated lifecycle state and makes ownership, cancellation, failure propagation, and shutdown behavior directly observable from the Job tree.

Full changelog: v0.1.1...v0.2.0 (v0.1.1...v0.2.0)

v0.1.1

Choose a tag to compare

@miinhho miinhho released this 10 Sep 14:29
Update package version