Skip to content

perf: link an external cancellation source lazily, on first observation - #24

Merged
miinhho merged 1 commit into
mainfrom
perf/lazy-signal-link
Sep 12, 2026
Merged

miinhho merged 1 commit into
mainfrom
perf/lazy-signal-link

Conversation

@miinhho

@miinhho miinhho commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #23.

An external cancellation source is linked lazily, on first observation.

  • prepare() no longer subscribes to seed.signal / an inherited foreign context.signal. It records them as pending and runs a synchronous recheck, so a source that already aborted still cancels the Job before its body runs (execute({ signal: AbortSignal.abort(reason) }, body) keeps rejecting without calling body).
  • The subscription happens the first time the Job's cancellation becomes observable: job.signal, signal(), context.signal, or a child start() (which already reads parent.signal.throwIfAborted()). Job.context is a JobContext whose signal getter forwards to job.signal; withContext() derives contexts that forward likewise instead of copying the signal.
  • Internal bookkeeping (prepare, perform, recordFailure, cancel, reconcileFailure, complete) reads controller.signal directly and never subscribes.
  • After the body returns, a recheck honors an external abort that happened while nothing observed it, so the result is still cancelled exactly as with the eager link.
  • Parent/child cascade is unchanged: children still cancel through Job.cancel(), never through listeners on the parent's signal.

HandoffJob.offer() does not subscribe (option B from #23).

  • Before suspending, an offer rechecks the pending sources; an already-aborted one releases the handoff and the offer rejects immediately.
  • 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. cancel()/close() (direct, cascaded, deadline) release the handoff as before.
  • This is the case that makes the change worthwhile for the server: its exchange always offers, and the transport already reports disconnect through resume(aborted). It is a documented narrowing of the HandoffJob contract (README, AGENTS.md).

Measured

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

scenario main this branch
plain Job 0.97 µs 0.98 µs
Job + external signal, unobserved 1.99 µs 0.98 µs
Job + external signal, signal() read 2.14 µs 2.15 µs
HandoffJob + external signal, offer/resume 2.41 µs 1.44 µs

Server /echo (50 connections, 8 s) against the packed artifact of this branch, server tree otherwise unchanged:

packed main packed branch
tiber-node 31,616 rps 41,456 rps
tiber-bun 78,160 rps 101,680 rps (raw Bun.serve on the same box: 105k)

The Bun gain exceeds the listener's own cost because attaching a listener to Bun's native request.signal materializes the signal; the unobserved path never touches it.

A first attempt used an object literal with a get signal() accessor for the context; that allocated a closure per context and doubled plain-Job cost (0.95 → 2.33 µs). JobContext with a prototype getter keeps plain Jobs at parity.

Tests

  • job-cancellation.test.ts: unobserved source registers no listener yet its abort ends the Job; first signal() read subscribes once and a late reader sees a preceding abort; child start subscribes the parent so the cascade reaches it. The reentrant-registration test is restated at the new registration point (abort during the first observation is visible before the observer continues); the shared-source dedupe test now observes signal() so it still exercises one subscription for two references.
  • handoff-job.test.ts: an offer consults an already-aborted source and rejects without subscribing; an unobserved abort during suspension reaches the body only through resume() while the result reports the cancellation; a body that observed its signal first is released by a later external abort.

Verification

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

Closes #23.

A Job seeded with an external signal subscribed to it eagerly in prepare(),
so every Job paid an AbortSignal listener registration and removal whether
or not anything could observe its cancellation. The subscription now happens
the first time the Job's cancellation becomes observable: a job.signal,
signal(), or context.signal read, or a child start. Internal bookkeeping
reads the controller's signal directly and never subscribes.

Already-aborted sources are honored without a listener by synchronous
rechecks before the body, after it, and before a HandoffJob offer. An
external abort during an offer suspension reaches the body only if it
observed its signal first; otherwise the consumer's resume() ends the
suspension and the result still reports the cancellation.

Per-Job (Node 24, 400k iterations): external signal unobserved 1.99 -> 0.98 us,
HandoffJob offer/resume with external signal 2.41 -> 1.44 us, plain Job
unchanged. Server /echo against the packed artifact: Node 31.6k -> 41.5k rps,
Bun 78k -> 101.7k rps.
@miinhho
miinhho force-pushed the perf/lazy-signal-link branch from 0f63bca to 5d5a1a9 Compare September 12, 2026 08:15
@miinhho
miinhho merged commit aadeb7c into main Sep 12, 2026
1 check passed
@miinhho
miinhho deleted the perf/lazy-signal-link branch September 12, 2026 08:19
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.

Link an external cancellation source lazily, on first observation

1 participant