Skip to content

perf: create the AbortController, drain, and binding Map only when used - #26

Merged
miinhho merged 2 commits into
mainfrom
perf/job-fixed-cost
Sep 12, 2026
Merged

miinhho merged 2 commits into
mainfrom
perf/job-fixed-cost

Conversation

@miinhho

@miinhho miinhho commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #25.

Three eager resources every Job paid for without using them are now created on demand, and the state behind them lives in one place.

Cancellation (replaces CancellationBindings) holds one Job's incoming cancellation. After #24 the pieces of "incoming cancellation" had spread across Job: the controller, two pending-source fields, the bindings bag, and ~10 methods (aborted, abortReason, throwIfAborted, isCancellation, isExternalCancellationSource, observeCancellation, recheckCancellation, deadline scheduling inside prepare, controller creation inside cancel). They are now one component with one contract:

  • signal — the Job's signal; reading it subscribes to followed sources. The AbortController is created here or by abort(), never before.
  • follow(inherited, external) / recheck() / observe() — record, honor without subscribing, subscribe.
  • deadline(at) — owns the timer; elapsing cancels the target with a TimeoutError.
  • aborted / reason / current / throwIfAborted() / isCancellation(error) — what Job bookkeeping asks.
  • dispose() — releases subscriptions and the timer; the signal keeps its state.

Every delivery goes through CancellationTarget.cancel() — the owning Job — so cascades stay the Job's. The component owns no lifetime and no completion state; AGENTS.md's support-state rule is reworded to say exactly that. Job keeps ownership, phases, failures, children, and the result, and holds the component lazily: a plain Job without a seed signal, deadline, or cancellation never creates it.

Two consequences of the consolidation:

  • A child's inherited context signal is its parent's by construction, so prepare() no longer reads inherited.signal for it (which used to materialize the parent's controller). Only an explicitly supplied options.context is a foreign source, and one that hands back the owner's signal is still excluded.
  • Starting a child observes the parent through parent.cancellation?.observe() rather than by reading parent.signal, so the parent gains a subscription without gaining a controller.

drain() runs only with children. Admission closes when phase becomes "closing", so the check cannot race a late child.

A single-binding ContextFrame holds key/value, not a Map. Frame bindings are an internal Bindings { has, get, keys }; Map satisfies it for several entries, SingleBinding for one. Lookup order and shadowing are unchanged.

No public behavior changes.

Measured

Per-Job microbenchmark (400k iterations, /tmp/job-bench.mjs), packed main (post-#24) vs this branch:

scenario Node main Node branch Bun main Bun branch
plain Job 0.97 µs 0.82 µs 1.12 µs 0.70 µs
Job + external signal, unobserved 0.97 µs 0.83 µs 1.13 µs 0.77 µs
Job + external signal, signal() read 2.07 µs 2.10 µs 2.00 µs 1.93 µs
HandoffJob + signal, offer/resume 1.35 µs 1.32 µs 1.55 µs 1.24 µs
HandoffJob + signal + values: [provide(...)] (server shape) 1.41 µs 1.36 µs 1.67 µs 1.29 µs

A flat variant that kept the fields on Job measured 1.20 µs on both runtimes for the server shape; the component costs one small allocation per Job that has a seed signal (~0.1 µs). The consolidation is worth that.

Verification

  • pnpm format, pnpm check, pnpm build
  • pnpm test — 11 files, 131 tests, unchanged
  • pnpm pack; server typecheck, vitest --project @tiberjs/server (12/12), leak-probe (live 0, graph 0) against the packed tarball

Closes #25.

The AbortController is created by cancel() or the first signal read;
internal checks treat an absent controller as not aborted. drain() runs
only when the Job has children. A single-binding ContextFrame holds one
key/value instead of a Map.

Per-Job (Bun): plain 1.12 -> 0.71 us, HandoffJob with signal and one
provided value 1.67 -> 1.20 us. Node: 0.97 -> 0.79 us and 1.41 -> 1.20 us.
Closes #25.

Incoming cancellation is now one component. Cancellation owns a Job's
signal, the sources it follows, and its deadline, each created on demand;
Job keeps ownership, phases, failures, children, and the result, and every
delivered cancellation still goes through Job.cancel() so cascades stay the
Job's. CancellationBindings is folded into it.

drain() runs only when the Job has children. A single-binding ContextFrame
holds one key/value instead of a Map.
@miinhho
miinhho merged commit c9d787b into main Sep 12, 2026
1 check passed
@miinhho
miinhho deleted the perf/job-fixed-cost branch September 12, 2026 08:43
@miinhho miinhho mentioned this pull request Sep 12, 2026
miinhho added a commit that referenced this pull request Sep 12, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Job pays for an AbortController, a drain, and a Map it does not use

1 participant