Skip to content

feat(core): carry the unit's AbortSignal on the ambient record - #44

Merged
btravers merged 2 commits into
mainfrom
feat/unit-signal
Aug 16, 2026
Merged

feat(core): carry the unit's AbortSignal on the ambient record#44
btravers merged 2 commits into
mainfrom
feat/unit-signal

Conversation

@btravers

Copy link
Copy Markdown
Contributor

Closes #23.

The gap

RunUnit hands its work callback the kernel's per-unit AbortSignal, and @btravstack/http forwards it to the handler as a third parameter. But a middleware-shaped runtime opens its unit around a call it does not own the arguments of — packages/temporal/src/activity-units.ts and packages/amqp/src/message-units.ts both hand the kernel a work callback that is the library's next() — so an activity or a handler had no parameter to receive it through, and the kernel's drainTimeoutMs was unobservable from inside the work. The issue is right that a deadline the work cannot observe defeats the point of handing one out.

The fix: the ambient record, not a context

UnitRecord gains signal: AbortSignal — the very controller the callback is handed, not a copy. currentUnit()?.signal now answers in every transport with no per-transport plumbing.

Injecting it into each library's middleware context (next({ context: { signal } })) was the alternative and was rejected: it reopens the context channel all three starters just removed, it cannot be typed by the transport's contract, and it would be three implementations of one idea. A signal is data about this unit — like deadline, which was already there in a less usable form — with nothing to substitute in a test, which is the line thesis 2 draws. @btravstack/http is unchanged.

And the written reason the issue also asked for

Both middlewares now state it where they live, including what the transport's own cancellation is and is not:

  • Temporal: Context.current().cancellationSignal is a different clock — workflow-side cancellation, and worker shutdown after shutdownGraceTime. It is not the kernel's drainTimeoutMs, so the two are honoured together rather than one standing in for the other.
  • AMQP: no cancellation story at all — a redelivery is recovery, not cancellation.

Proof, and the examples (acceptance 2)

  • packages/temporal and packages/amqp each gain a deadline fixture and a spec driving a real transport: the activity / handler waits on currentUnit()?.signal, the drain runs out of time, and the spec asserts { abandoned: 1, sawAbort: true } in one assertion.
  • packages/core gains a registry spec pinning that the record's signal is the work's own, aborted by abortAll.
  • examples/order-amqp-worker's handler answers a RetryableError when the signal is aborted, leaving the delivery un-acked for the next worker.
  • examples/order-temporal-worker's ShippingService.arrange fails as a defect, which the platform retries on another worker — the contract's ShippingUnavailable is a permanent no and would be the wrong error for "we ran out of time".

Test plan

  • format / lint / typecheck / knip / build green; core still 100% lines/functions
  • tests green locally minus the two Docker suites (CI)
  • docs + all eight CLAUDE files swept; pnpm --filter @btravstack/docs build clean
  • CI

Closes #23. `RunUnit` hands its work callback the per-unit signal, and
@btravstack/http forwards it to the handler — but a middleware-shaped runtime
opens its unit around a call it does not own the arguments of: temporal's
activityUnits and amqp's messageUnits both hand the kernel a callback that IS
the library's next(), so an activity or a handler had no parameter to receive
it through, and the kernel's drainTimeoutMs was unobservable from inside the
work.

UnitRecord gains `signal` — the very controller the callback is handed, not a
copy — so currentUnit()?.signal answers in every transport with no per-
transport plumbing and no context channel the contract cannot type. It is data
about the unit, like `deadline`, with nothing to substitute in a test.

Both middlewares now say so where they live, including what the transport's
own cancellation is and is not: Temporal's Context.current().cancellationSignal
is a different clock (workflow-side cancellation, and shutdownGraceTime), and
AMQP has none — a redelivery is recovery, not cancellation. Each starter's
suite gains a spec proving the signal reaches the work through a real
transport, and both worker examples honour it: the AMQP handler answers a
RetryableError so the broker hands the delivery to the next worker, and the
Temporal shipping adapter fails as a defect so the platform retries the
attempt elsewhere.
Copilot AI lite review requested due to automatic review settings August 16, 2026 13:37

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR extends the kernel’s per-unit ambient record (UnitRecord) to include the unit’s AbortSignal, so middleware-shaped runtimes (Temporal activity middleware and AMQP worker middleware) can observe the drain deadline via currentUnit()?.signal without injecting transport-specific context.

Changes:

  • Add signal: AbortSignal to UnitRecord and ensure it is the same signal passed to unit work.
  • Add Temporal and AMQP runtime tests/fixtures that assert abort visibility through the ambient record during a timed-out drain.
  • Update examples and documentation/CLAUDE specs to describe and demonstrate the new deadline/cancellation story.

Reviewed changes

Copilot reviewed 30 out of 30 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
packages/core/src/units.ts Add signal to UnitRecord and populate it from the unit’s AbortController.
packages/core/src/units.spec.ts Add a regression test asserting record signal identity with the work parameter and abort behavior.
packages/core/README.md Document signal as part of the ambient record and why it exists.
packages/core/CLAUDE.md Update kernel invariants/spec narrative to include the ambient signal contract.
packages/temporal/src/activity-units.ts Document why Temporal activities read currentUnit()?.signal (middleware-shaped next() callback).
packages/temporal/src/test-fixtures.ts Add a deadline fixture activity that waits on the ambient-record signal.
packages/temporal/src/temporal-runtime.spec.ts Add a spec proving drain-timeout abort is observable from inside an activity via the record.
packages/temporal/CLAUDE.md Update Temporal package spec to describe the ambient signal route and new spec count.
packages/amqp/src/message-units.ts Document why handlers read currentUnit()?.signal (middleware-shaped next() callback; no native cancellation).
packages/amqp/src/test-fixtures.ts Add a deadline fixture handler that waits on the ambient-record signal.
packages/amqp/src/amqp-runtime.spec.ts Add a spec proving drain-timeout abort is observable from inside a handler via the record.
packages/amqp/CLAUDE.md Update AMQP package spec to describe the ambient signal route and new spec count.
examples/order-temporal-worker/src/fulfillment.ts Demonstrate honoring drain deadline in a Temporal adapter via currentUnit()?.signal.
examples/order-amqp-worker/src/handlers.ts Demonstrate honoring drain deadline in an AMQP handler by returning RetryableError when aborted.
docs/reference/core/runtime.md Update runtime reference to include UnitRecord.signal and clarify it matches the work argument.
docs/reference/glossary.md Update “ambient record” and “unit” glossary entries to include signal.
docs/reference/temporal.md Document the ambient record as the only route to the kernel signal inside activities.
docs/reference/amqp.md Document the ambient record as the only route to the kernel signal inside handlers.
docs/how-to/write-a-runtime.md Add guidance for middleware-shaped runtimes to read currentUnit()?.signal.
docs/how-to/tune-the-drain-for-kubernetes.md Explain that aborted work only stops if it observes the abort, and where to read the signal.
docs/how-to/run-a-temporal-worker.md Add a section showing how to honor the drain deadline from an activity via the ambient signal.
docs/how-to/read-the-ambient-unit.md Expand the how-to to include honoring drain deadline from activities/handlers via signal.
docs/how-to/consume-amqp-messages.md Add a section showing honoring drain deadline in AMQP handlers via the ambient signal.
docs/explanation/draining-in-three-beats.md Clarify abort is observable via both the work parameter and ambient record signal.
docs/explanation/ambient-vs-context.md Update explanation to “five fields” and justify signal as ambient data for middleware runtimes.
docs/examples/order-temporal-worker.md Update example write-up to include the new signal-based deadline honoring behavior.
docs/examples/order-amqp-worker.md Update example write-up to include the new signal-based deadline honoring behavior.
CLAUDE.md Update root thesis/spec sections to include signal in UnitRecord and explain rationale.
.changeset/unit-signal.md Changeset describing the new UnitRecord.signal surface and its motivation.
.changeset/initial-kernel.md Update initial-kernel changeset text to reflect the new ambient record shape.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread packages/temporal/src/test-fixtures.ts
Comment thread packages/amqp/src/test-fixtures.ts
@btravers
btravers merged commit 96a6b51 into main Aug 16, 2026
13 checks passed
@btravers
btravers deleted the feat/unit-signal branch August 16, 2026 13:47
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.

The per-unit AbortSignal is unreachable from Temporal activities and AMQP handlers

2 participants