ADR 0002: Gate workflow execution at the JobManager on context-stream readiness#

  • Status: accepted

  • Deciders: Simon

  • Date: 2026-05-21

Context#

Sciline-based workflows parametrise their reduction graph on context values — motor positions, sample geometry, choppers — delivered as their own streams alongside detector data. StreamProcessor holds a hidden None seed for every such context binding. If a workflow runs before its context stream has produced a value, the providers downstream of that input receive None and either crash (bifrost’s process_chunk_workflow reads rotation inside accumulate, raising 'NoneType' object has no attribute 'ndim') or — worse — silently produce detector data attributed to a default geometry. Both failure modes have surfaced in production.

The system needs a gate that prevents workflow invocation until every context input that has no safe default has a value. The design questions are where this gate lives and how it learns which inputs to gate on.

What needs gating, and what does not#

A job consumes several kinds of input beyond its primary data stream. They differ in whether the workflow tolerates the input being absent:

  • Dynamic aux — inputs that accumulate over time inside the workflow (e.g. LOKI’s incident/transmission monitors). They flow through StreamProcessor.accumulate and tolerate late arrival: events processed against an empty accumulator still produce a useful (uncorrected) result the consumer can ignore. Gating these would break accumulation — events arriving before monitors would be dropped, and no result would ever appear even after the monitors do.

  • Context with a safe default — inputs that parametrise the graph but map a missing value to a well-defined result. ROI is the example: the Sciline providers treat “no request” and “empty request” identically as no ROI selected (detector_view/roi.py). There is no failure mode to prevent, so these are not gated either; ROI is routed as an ordinary auxiliary source.

  • Context with no safe default — inputs that parametrise the graph and whose absence is unrepresentable: motion/geometry (bifrost rotation, LOKI carriage). None crashes or yields silently-wrong geometry. Only this category is gated.

The gating axis is therefore “does this input tolerate absence?”, not set_context vs accumulate — ROI flows through set_context yet is not gated. The set of gated streams for a job is derived from ContextBinding declarations at job-creation time; the declaration model is settled in ADR 0003.

Decision#

Gate at JobManager, not at the workflow execution unit.

Job carries gating_streams: set[str] — the wire stream names whose values the workflow needs before it can run. Job.missing_context(available) returns the gating streams not present in available. The set is populated at JobFactory.create from the ContextBinding declarations that resolve for the specific (spec, source) (ADR 0003).

A gated job occupies a dedicated stage between scheduled and active, held in its own _pending_context_jobs bucket. When should_start fires, _start_job routes the job by gating_streams: empty → it becomes active at once; non-empty → it enters pending_context with a pending_context_warning. Only active jobs are processed — a pending job is never fed primary data.

The preprocessor’s context cache is the single source of truth for which context streams have a value. JobManager holds a context_reader onto it; each tick, _open_context_gates refills the batch from the cache for every gating stream of a pending job still absent, then checks job.missing_context(available) against the post-refill batch:

  • non-empty → the job stays pending; its pending_context_warning(missing) is refreshed to the still-missing streams.

  • empty → the job graduates to active (_activate_pending_job), dropping its warning; its own processing outcome drives its state from there.

Because gate opening and value delivery read the same dict, the gate-opening batch structurally carries every context value the job needs — a gate cannot open without its values being deliverable to set_context. Context values arrive intermittently — a motor position is published once and then is quiet — but the cache holds every value ever received, so availability is sticky across ticks.

Graduation is one-way: gating_streams is fixed and the cache only grows, so once a job is active it has left the gate permanently and is never re-checked, and a job with no context never enters the stage at all. The per-tick gate therefore scans only the small, transient pending bucket — not every active job — and refills only gating streams of pending jobs, never the cached context of steady-state active jobs (which would re-fire set_context and force eager downstream recompute on unchanged values).

Time-based activation is preserved: should_start(current_time) drives the job out of scheduled on time, independent of stream-arrival timing. The readiness gate governs only the subsequent pending → active transition, so a job blocked on a dead producer stays visible as pending_context rather than silently rewriting its schedule.

StreamProcessorWorkflow remains a pure “given dynamic + context, produce result” unit. It does not consult the gate, does not track which keys have arrived, and does not drop dynamic data.

There is no seeding mechanism. The gate stays closed until the producer publishes. If the producer is down, the workflow does not run — which is the correct behaviour for a context with no safe default.

Alternatives considered#

Option

Notes

Gate at JobManager on context streams with no safe default (chosen)

The gate respects the workflow’s own tolerates-absence distinction. The set is derived from declarations, so adding a gated workflow needs no JobManager change.

Gate at JobManager on all aux

Conflates routing with execution; gates dynamic aux (monitors) that legitimately accumulate without context. Forces tests and production to pre-publish dynamic aux before events. Rejected.

Gate inside StreamProcessorWorkflow with per-workflow opt-in

Couples scheduling to workflow execution; forces StreamProcessorWorkflow to carry a “drop dynamic while pending” branch and spreads gate configuration across factories. Rejected.

Defer job construction until context ready

Conflicts with time-based scheduling and moves the Sciline-pipeline build cost from config-load to first-data-arrival, introducing user-visible latency. Rejected.

Hold gated jobs in scheduled rather than a distinct stage

Entangles the schedule contract with producer liveness — a dead motion producer would keep a job “scheduled” past its start, and the dashboard could not tell “not due yet” from “started but blocked”. A dedicated pending_context stage keeps the two axes separate. Rejected.

Key design choices#

The gate set is derived, not declared per workflow#

Job.gating_streams is populated at JobFactory.create from the ContextBinding records resolved for the (spec, source). Declarations are the single source of truth for which inputs gate; adding a workflow that needs gating requires only the standard declaration (ADR 0003), no JobManager change.

The context cache is the gate’s only readiness source#

Gating compares against the streams present in the batch after refilling it from the preprocessor’s context cache (context_reader), not against the streams that happened to arrive this tick. The cache holds every context value ever received, so a value seen once stays available and the gate can open on a tick where the last missing stream finally arrives, with all earlier values refilled alongside it. Tracking readiness separately (e.g. a per-job set of seen stream names) was rejected: a second sticky store can disagree with the cache, opening a gate whose values are not deliverable — the workflow would then finalize against the hidden None seed, the exact failure this ADR exists to prevent. With a single source, gate opening and value delivery cannot diverge.

A distinct stage, not a per-tick skip#

A gated job is held in its own _pending_context_jobs bucket, not left among the active jobs with a per-tick “skip if gated” branch. Because gating_streams is fixed and the cache only grows, readiness is monotonic: a job graduates once and never re-gates. Modelling that as a one-way stage move means the processing loop iterates only runnable jobs, the per-tick gate scans only the (small) pending bucket, and a job that never needed context never participates in gating at all.

Primary data arriving before context is dropped#

A pending job is not among the active jobs a batch is dispatched to, so primary data arriving while a gating stream is still missing simply is not delivered to it and nothing is buffered. It processes the first primary batch on or after the tick its gate opens. This is conceptually correct: detector events whose geometry context is unknown cannot be meaningfully reduced, and silent attribution to a default geometry is the exact failure mode the gate exists to prevent. Dynamic aux is unaffected — it is not in the gate set and accumulates normally.

Dedicated JobState.pending_context#

A job held in the pending stage is conceptually distinct both from one not yet due (scheduled) and from one whose last processing attempt errored (warning). JobState.pending_context is healthy and waiting on a producer; surfacing it distinctly lets an operator tell a dead motion producer from a job that simply has not started.

Consequences#

  • StreamProcessorWorkflow stays focused on workflow execution and carries no gate state.

  • Any workflow that declares motion or geometry context gets correct startup behaviour automatically — the only requirement is the ContextBinding declaration (ADR 0003). No per-instrument gate opt-in.

  • Dynamic aux (LOKI monitors) flows through unchanged — the gate does not see it.

  • ROI is routed as an auxiliary source, not gated: “no ROI” is a safe default, so there is no readiness to wait on and no seed to inject.

  • Warning emission for context-pending jobs is consolidated inside JobManager. There is no warning channel between the workflow execution unit and the job layer.

  • JobState.pending_context is added to the job state machine, backed by a _pending_context_jobs bucket between _scheduled_jobs and _active_jobs. Job lookup, control, reset, status, and finish handling all account for the new bucket.

  • JobManager takes a context_reader onto the preprocessor’s context cache; the gate’s refill-then-check runs entirely inside JobManager, so gate correctness does not depend on callers enriching the batch.

  • Tests at JobManager level cover: dynamic aux not gated; context (motion) blocks then unblocks once its stream has a value; a value cached on an earlier tick still opens the gate (and is delivered) when the last stream arrives later; primary dropped while gated; gates independent across jobs.

  • The declaration model that feeds gating_streams and context_keys, and the routing that ensures the gated streams are actually subscribed, are settled in ADR 0003.