Skip to content

refactor: single-result Job plus HandoffJob for one-shot handoff - #22

Merged
miinhho merged 4 commits into
mainfrom
refactor/job-single-result
Sep 12, 2026
Merged

miinhho merged 4 commits into
mainfrom
refactor/job-single-result

Conversation

@miinhho

@miinhho miinhho commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #21 on the runner side.

Job is a single-result lifetime again.

  • remove the Published type parameter from Job, Supervisor, GroupMember, and GroupResults
  • remove JobPublisher, the publisher body argument, Job.value(), and publication state/completion logic
  • new Job<Result>(() => Result | PromiseLike<Result>); result(), join(), and await job remain the only observations

HandoffJob<Result, Offered, Resumed> models the one-shot, two-way handoff.

  • the body receives a Handoff and await handoff.offer(value) suspends it until the consumer answers; the consumer uses job.receive() and job.resume(answer)
  • the Job's lifetime rule is unchanged: it settles only after body and descendants finish
  • the handoff is rendezvous state only (two withResolvers, three booleans). It owns no Job, signal, queue, or listener; Job.cancel() and Job.complete() release it with a field check, so no per-Job abort listener or settlement observer is reintroduced
  • Supervisor.run(job) now preserves the submitted Job subtype

This departs from the issue's "composition first" ordering deliberately: the four required semantics below all bind to Job-internal events (cancellation, closure, body return). Composed outside the runner each consumer would re-add a result().then plus an abort listener per Job — the cost #18 removed — and re-derive the same rules.

Required semantics (tests/handoff-job.test.ts)

  • offer() and resume() are each accepted once; a second call throws TypeError
  • failure before an offer rejects receive() with the composed Job failure (child and body failures aggregated)
  • a body that returns without offering fails with TypeError; the Job does not succeed
  • cancellation (direct, cascaded via Supervisor, or deadline) rejects a suspended offer() with the original reason; an offer made after cancellation still reaches the consumer and rejects immediately for the body; a resume() after release is discarded
  • an abandoned consumer cannot keep a closing Job suspended
  • independent delivery and cleanup failures keep their identities
  • receive() rejects self/ancestor observation synchronously

A generator-based body was measured and rejected: one yield/next() round trip costs ~1.2 µs versus ~0.33 µs for a promise rendezvous, async generators queue concurrent next() calls, and 0..N yields drop the exactly-once guarantee.

Measured

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

scenario publish build this branch
plain Job start → complete 3.5 µs 0.98 µs
publish/handoff round trip 1.5 µs 1.4 µs

The plain-Job difference is a publication artifact: every Job that never published allocated a LifecycleStateError (with stack capture) on completion. The handoff round trip is unchanged within noise.

Server migration

@tiberjs/server currently uses Job<void, ExchangeResponse> and exchange.job.value(). Against this runner:

this.job = new HandoffJob<void, ExchangeResponse, DeliveryOutcome>((h) => this.#run(h), seed);
// #run: replaces withResolvers<DeliveryOutcome> + addAbortListener + aborted branch
try { outcome = await handoff.offer(exchangeResponse); }
catch (reason) { outcome = { kind: "aborted", reason }; }
// finish(): this.job.resume(delivered); await this.#finalized.promise;
// ExchangeRunner.handle: return await exchange.job.receive();

One Exchange Job per request is retained and the transport never awaits settlement. The server working tree is mid-refactor and does not typecheck at the moment, so that migration, its tests, the packed-runner compatibility check, and the request-path benchmark are not included here and remain open before #21 closes.

Verification

  • pnpm format, pnpm check, pnpm build
  • pnpm test — 11 files, 124 tests
  • pnpm packdist/index.d.ts no longer mentions Publish

Remove the Published type parameter, JobPublisher, the publisher body
argument, Job.value(), and publication state. A Job answers one question:
the final result once its body and descendants settle. One-shot handoff
belongs to the consumer that needs it, composed with an ordinary Job.

Closes #21
A HandoffJob's body offers one value mid-execution and suspends until the
consumer resumes it. The rendezvous is state released by the Job's own
cancellation and closure: no listener, settlement observer, gate Job, or
queue. A body that returns without offering fails; a Job that closes
without offering rejects its receiver with its failure.

Supervisor.run(job) now preserves the submitted Job subtype.
@miinhho miinhho changed the title refactor: return Job to a single-result lifetime refactor: single-result Job plus HandoffJob for one-shot handoff Sep 12, 2026
Expose the offered flag directly, name the rendezvous sides by role,
and settle a receiver only with a Job failure: a successful close
without an offer is already impossible because the body fails first.

Replace a tautological failure-identity test with the actual contract
(a resumed answer is the body's to interpret), assert receiver and Job
share one failure, and cover the unobserved-receiver path.
@miinhho
miinhho merged commit 712e517 into main Sep 12, 2026
1 check passed
@miinhho
miinhho deleted the refactor/job-single-result branch September 12, 2026 07:50
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.

Separate one-shot handoff from Job lifetime

1 participant