Skip to content

v0.3.0

Choose a tag to compare

@miinhho miinhho released this 12 Sep 09:03
· 4 commits to main since this release
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.