You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
When an inter-stage pipe hop declines the Rust stream pump and falls back to
the Python one, or when a hop cancelled mid-transfer turns out to have had a
failing Rust worker, the decision is visible only as a DEBUG log record. An
operator whose deployment has quietly stopped taking the fast path — a kernel
that no longer yields raw descriptors, a transport that refuses to pause, a
blocking-mode switch that fails — sees no counter move and no aggregate
change. The pipeline still returns exactly what it would have returned, which
is precisely why the regression is invisible: routing is not a result, so
nothing in PipelineResult records it, and grepping every worker's DEBUG
stream for rust_pump_declined is not an answer a dashboard can give.
Cuprum already ships a MetricsCollector protocol and a MetricsHook that
projects ExecEvent values onto it. The obvious move — add a phase to ExecPhase — is the one that must not be taken. ExecPhase is a closed set
that registered consumers match exhaustively, and Cuprum's own MetricsHook
raises _UnhandledMetricsPhaseError on an unrecognized phase by design. A new
member would start raising inside third-party consumers that were correct when
they were written, and, because Cuprum re-raises observe-hook failures, would
fail the runs those consumers were merely observing. Pump facts need a channel
of their own, entered by opting in.
Required work
Publish Rust-pump routing decisions on a dedicated observation channel that
cannot perturb the existing ExecEvent contract.
1. A pump event type, separate from ExecEvent
cuprum/pump_events.py carries the vocabulary: a frozen, slotted PumpEvent
dataclass; a closed PumpPhase literal of declined and failed_after_cancel; and RustPumpDeclineReason, promoted from a private
enum to public API because operators aggregate on it. The reason set is a StrEnum rather than free text so that a typo at a new call site is a type
error rather than a value an operator's filter silently misses.
PumpEvent carries the phase and, for a decline, the reason — and nothing
else. Descriptor numbers, argument vectors, exception types, and tracebacks are
either unbounded as metric labels or a disclosure risk; the DEBUG records
emitted alongside these events already carry that detail for the cases that
need it.
2. A hook registry on its own ContextVar
cuprum/pump_observation.py provides observe_pump(), a PumpHookRegistration handle, current_pump_hooks(), and the internal _emit_pump_event. Hooks live on their own ContextVar, deliberately not
on CuprumContext. That is the design, not a shortcut: ScopeConfig, CuprumContext.narrow(), and every consumer that reads the execution context
keep exactly the shape they had, so adding this channel cannot alter how an
existing caller's commands execute. Registration follows the repository's
token-restoration discipline, so a pump registration nests and detaches like
every other one.
3. A metrics adapter reusing the existing collector protocol
cuprum/adapters/pump_metrics.py provides PumpMetricsHook (and a pump_metrics_hook factory) consuming PumpEvent values and writing through
the existing cuprum.adapters.metrics_adapter.MetricsCollector protocol, so
one collector backs both hooks and no new telemetry dependency enters the
project.
4. Two counters with a bounded label set
cuprum_rust_pump_declined_total, labelled by reasonand nothing else,
incremented once per hop that fell back to the Python pump.
cuprum_rust_pump_failed_after_cancel_total, unlabelled, incremented once
per Rust-pump worker failure consumed during cancellation unwinding.
The reason domain is bounded by RustPumpDeclineReason plus the public UNKNOWN_DECLINE_REASON constant — four values, no more. UNKNOWN_DECLINE_REASON
is public because it is operator-visible: a dashboard filtering on the enum
alone would silently miss it. It is not an enum member because no seam reports
it; it exists only so that a malformed event cannot introduce an unbounded
label.
5. An ADR recording the decision
docs/adr-008-rust-pump-observation-channel.md, indexed from docs/contents.md, recording the options weighed — an ExecPhase member, a
module-global counter registry, a dedicated channel — the drivers, the
hook-failure policy, and the non-goals. The users guide, developers guide, and
design document describe registration, the counters, and the label domain.
Acceptance criteria
cuprum/pump_events.py defines PumpEvent, a closed PumpPhase literal
(declined, failed_after_cancel), and a public RustPumpDeclineReason StrEnum with raw_fd_unavailable, reader_pause_failed, and blocking_mode_unavailable. PumpEvent, PumpHook, PumpHookRegistration, RustPumpDeclineReason, and observe_pump are
re-exported from the cuprum package.
No pump phase reaches the ExecEvent contract.ExecPhase gains no
member, so a registration of the existing MetricsHook — which matches ExecPhase exhaustively and raises on anything else — cannot begin
raising because this channel exists. A test pins ExecPhase's membership
and asserts the two phase sets are disjoint, and a second test drives a
real decline past an already-registered MetricsHook and asserts it
never sees it.
Hooks are held on a ContextVar private to cuprum.pump_observation,
with CuprumContext, ScopeConfig, and CuprumContext.narrow()
unchanged. observe_pump() returns a handle that is both a context
manager and detachable; registration order is preserved on delivery;
detach restores the preceding tuple, and detaching twice is harmless.
_emit_pump_event logs a raising hook at WARNING and continues.
It does not re-raise, and the remaining hooks still run. This diverges
deliberately from _emit_exec_event: both pump emission sites lie on
paths contracted to complete — the fall-back that keeps a hop working
when the fast path is unavailable, and cancellation unwinding, where a
raise would displace the CancelledError the caller is owed. Registering
a metrics backend must not turn a pipeline that would have succeeded into
one that fails. The failure is recorded, not swallowed: a pump_observer_failed record names the hook's error type and the event
that provoked it.
A BaseException that is not an Exception — SystemExit, KeyboardInterrupt, asyncio.CancelledError — propagates from a hook
untouched, so a shutdown signal arriving here still travels.
A raising collector leaves the fall-back signal intact: the hop still
reports that it declined and still runs on the Python pump.
Pump hooks are synchronous by contract; a hook returning an awaitable is
reported at WARNING and the coroutine is closed, rather than left to
surface later as an unrelated "never awaited" warning.
With no hooks registered, _emit_pump_event returns before touching the
event, so a caller that has not opted in pays nothing and behaves exactly
as before.
PumpMetricsHook increments cuprum_rust_pump_declined_total once per
decline, labelled by reason alone, and cuprum_rust_pump_failed_after_cancel_total once, unlabelled, per
failure recovered after cancellation. A successful hand-off counts
nothing, and a cancelled hop whose pump succeeded counts nothing.
The reason label is bounded by the closed enum. Each of the three
real decline seams is driven through the actual fall-back path — not a
synthesized event — and the resulting labels are asserted to be exactly {"reason"} with a value drawn from a bounded set derived from RustPumpDeclineReason itself plus UNKNOWN_DECLINE_REASON, rather than
from a hard-coded copy that could drift from the enum. The assertion loop
is guarded so that it cannot pass vacuously over an empty collection.
An unrecognized phase is ignored rather than raised on, applying to this
adapter the lesson that motivated the separate channel.
docs/adr-008-rust-pump-observation-channel.md records the decision, the
rejected options, the hook-failure policy, and the non-goals, and is
indexed from docs/contents.md. The users guide documents registration,
both counter names, and the four-value reason domain; the developers
guide maps each counter to the seam that emits it.
Covered by cuprum/unittests/test_pump_observation.py, cuprum/unittests/test_pump_metrics_adapter.py, and cuprum/unittests/test_pipeline_streams_observability.py, the last of
which also asserts that registering an observer does not displace the
existing DEBUG log record.
Notes
This work was specified for, and is delivered by, PR #244 alongside the
Hypothesis fault-injection work for #74, at the maintainer's written direction
that it be treated as required for that PR. The issue records the requirement
that PR satisfies.
Companion to #285, which records the equivalent requirement for the pipeline
fail-fast decision. The two channels differ deliberately: the fail-fast
decision is a command lifecycle fact and rides ExecPhase, accepting the
breaking change; pump routing is not, and gets a channel of its own so that no
existing consumer changes behaviour.
Problem
When an inter-stage pipe hop declines the Rust stream pump and falls back to
the Python one, or when a hop cancelled mid-transfer turns out to have had a
failing Rust worker, the decision is visible only as a
DEBUGlog record. Anoperator whose deployment has quietly stopped taking the fast path — a kernel
that no longer yields raw descriptors, a transport that refuses to pause, a
blocking-mode switch that fails — sees no counter move and no aggregate
change. The pipeline still returns exactly what it would have returned, which
is precisely why the regression is invisible: routing is not a result, so
nothing in
PipelineResultrecords it, and grepping every worker'sDEBUGstream for
rust_pump_declinedis not an answer a dashboard can give.Cuprum already ships a
MetricsCollectorprotocol and aMetricsHookthatprojects
ExecEventvalues onto it. The obvious move — add a phase toExecPhase— is the one that must not be taken.ExecPhaseis a closed setthat registered consumers match exhaustively, and Cuprum's own
MetricsHookraises
_UnhandledMetricsPhaseErroron an unrecognized phase by design. A newmember would start raising inside third-party consumers that were correct when
they were written, and, because Cuprum re-raises observe-hook failures, would
fail the runs those consumers were merely observing. Pump facts need a channel
of their own, entered by opting in.
Required work
Publish Rust-pump routing decisions on a dedicated observation channel that
cannot perturb the existing
ExecEventcontract.1. A pump event type, separate from
ExecEventcuprum/pump_events.pycarries the vocabulary: a frozen, slottedPumpEventdataclass; a closed
PumpPhaseliteral ofdeclinedandfailed_after_cancel; andRustPumpDeclineReason, promoted from a privateenum to public API because operators aggregate on it. The reason set is a
StrEnumrather than free text so that a typo at a new call site is a typeerror rather than a value an operator's filter silently misses.
PumpEventcarries the phase and, for a decline, the reason — and nothingelse. Descriptor numbers, argument vectors, exception types, and tracebacks are
either unbounded as metric labels or a disclosure risk; the
DEBUGrecordsemitted alongside these events already carry that detail for the cases that
need it.
2. A hook registry on its own
ContextVarcuprum/pump_observation.pyprovidesobserve_pump(), aPumpHookRegistrationhandle,current_pump_hooks(), and the internal_emit_pump_event. Hooks live on their ownContextVar, deliberately noton
CuprumContext. That is the design, not a shortcut:ScopeConfig,CuprumContext.narrow(), and every consumer that reads the execution contextkeep exactly the shape they had, so adding this channel cannot alter how an
existing caller's commands execute. Registration follows the repository's
token-restoration discipline, so a pump registration nests and detaches like
every other one.
3. A metrics adapter reusing the existing collector protocol
cuprum/adapters/pump_metrics.pyprovidesPumpMetricsHook(and apump_metrics_hookfactory) consumingPumpEventvalues and writing throughthe existing
cuprum.adapters.metrics_adapter.MetricsCollectorprotocol, soone collector backs both hooks and no new telemetry dependency enters the
project.
4. Two counters with a bounded label set
cuprum_rust_pump_declined_total, labelled byreasonand nothing else,incremented once per hop that fell back to the Python pump.
cuprum_rust_pump_failed_after_cancel_total, unlabelled, incremented onceper Rust-pump worker failure consumed during cancellation unwinding.
The
reasondomain is bounded byRustPumpDeclineReasonplus the publicUNKNOWN_DECLINE_REASONconstant — four values, no more.UNKNOWN_DECLINE_REASONis public because it is operator-visible: a dashboard filtering on the enum
alone would silently miss it. It is not an enum member because no seam reports
it; it exists only so that a malformed event cannot introduce an unbounded
label.
5. An ADR recording the decision
docs/adr-008-rust-pump-observation-channel.md, indexed fromdocs/contents.md, recording the options weighed — anExecPhasemember, amodule-global counter registry, a dedicated channel — the drivers, the
hook-failure policy, and the non-goals. The users guide, developers guide, and
design document describe registration, the counters, and the label domain.
Acceptance criteria
cuprum/pump_events.pydefinesPumpEvent, a closedPumpPhaseliteral(
declined,failed_after_cancel), and a publicRustPumpDeclineReasonStrEnumwithraw_fd_unavailable,reader_pause_failed, andblocking_mode_unavailable.PumpEvent,PumpHook,PumpHookRegistration,RustPumpDeclineReason, andobserve_pumparere-exported from the
cuprumpackage.ExecEventcontract.ExecPhasegains nomember, so a registration of the existing
MetricsHook— which matchesExecPhaseexhaustively and raises on anything else — cannot beginraising because this channel exists. A test pins
ExecPhase's membershipand asserts the two phase sets are disjoint, and a second test drives a
real decline past an already-registered
MetricsHookand asserts itnever sees it.
ContextVarprivate tocuprum.pump_observation,with
CuprumContext,ScopeConfig, andCuprumContext.narrow()unchanged.
observe_pump()returns a handle that is both a contextmanager and detachable; registration order is preserved on delivery;
detach restores the preceding tuple, and detaching twice is harmless.
_emit_pump_eventlogs a raising hook atWARNINGand continues.It does not re-raise, and the remaining hooks still run. This diverges
deliberately from
_emit_exec_event: both pump emission sites lie onpaths contracted to complete — the fall-back that keeps a hop working
when the fast path is unavailable, and cancellation unwinding, where a
raise would displace the
CancelledErrorthe caller is owed. Registeringa metrics backend must not turn a pipeline that would have succeeded into
one that fails. The failure is recorded, not swallowed: a
pump_observer_failedrecord names the hook's error type and the eventthat provoked it.
BaseExceptionthat is not anException—SystemExit,KeyboardInterrupt,asyncio.CancelledError— propagates from a hookuntouched, so a shutdown signal arriving here still travels.
reports that it declined and still runs on the Python pump.
reported at
WARNINGand the coroutine is closed, rather than left tosurface later as an unrelated "never awaited" warning.
_emit_pump_eventreturns before touching theevent, so a caller that has not opted in pays nothing and behaves exactly
as before.
PumpMetricsHookincrementscuprum_rust_pump_declined_totalonce perdecline, labelled by
reasonalone, andcuprum_rust_pump_failed_after_cancel_totalonce, unlabelled, perfailure recovered after cancellation. A successful hand-off counts
nothing, and a cancelled hop whose pump succeeded counts nothing.
reasonlabel is bounded by the closed enum. Each of the threereal decline seams is driven through the actual fall-back path — not a
synthesized event — and the resulting labels are asserted to be exactly
{"reason"}with a value drawn from a bounded set derived fromRustPumpDeclineReasonitself plusUNKNOWN_DECLINE_REASON, rather thanfrom a hard-coded copy that could drift from the enum. The assertion loop
is guarded so that it cannot pass vacuously over an empty collection.
adapter the lesson that motivated the separate channel.
docs/adr-008-rust-pump-observation-channel.mdrecords the decision, therejected options, the hook-failure policy, and the non-goals, and is
indexed from
docs/contents.md. The users guide documents registration,both counter names, and the four-value
reasondomain; the developersguide maps each counter to the seam that emits it.
cuprum/unittests/test_pump_observation.py,cuprum/unittests/test_pump_metrics_adapter.py, andcuprum/unittests/test_pipeline_streams_observability.py, the last ofwhich also asserts that registering an observer does not displace the
existing
DEBUGlog record.Notes
This work was specified for, and is delivered by, PR #244 alongside the
Hypothesis fault-injection work for #74, at the maintainer's written direction
that it be treated as required for that PR. The issue records the requirement
that PR satisfies.
Companion to #285, which records the equivalent requirement for the pipeline
fail-fast decision. The two channels differ deliberately: the fail-fast
decision is a command lifecycle fact and rides
ExecPhase, accepting thebreaking change; pump routing is not, and gets a channel of its own so that no
existing consumer changes behaviour.