ADR 0004: Key workflow inputs uniformly by canonical stream name#

  • Status: accepted

  • Deciders: Simon

  • Date: 2026-06-02

Context#

A message’s identifier passes through four naming schemes between Kafka and a Sciline provider:

  • (topic, source_name) — raw Kafka identity.

  • role — the logical input a workflow author and a user reason in for auxiliary data (“the incident monitor used for normalisation”), which may be filled by any of several physical streams.

  • canonical stream_name — the name a stream is given in the instrument stream config once StreamLUT resolves its Kafka identity on ingress. It is a NeXus name: for f144 streams the NeXus path generated from the geometry file; for detector/monitor data the NeXus group name, which the workflow spec calls source_name and which need not equal the Kafka source_name it resolved from.

  • sciline.Key — the graph’s type-addressed input (e.g. NeXusData[Incident, SampleRun]).

The three kinds of workflow input (primary detector data, dynamic auxiliary data, context — ADR 0002/0003) all arrive on the data path and all end up inside the wrapped StreamProcessor, differing only in how their stream binding is chosen and how they are fed into the graph.

Dynamic aux used to be keyed in dynamic_keys by its stable role (e.g. incident_monitor), while the primary input and context inputs were keyed by canonical stream name. That asymmetry leaked the role scheme onto the data path: the role→stream map had to be threaded into wire_dynamic_transforms (to match bindings against canonical dependent_sources) and into the Job’s runtime routing (a _stream_to_fields remap turning incoming stream-named data back into roles before handing it to the workflow). Two layers spoke a scheme that only mattered at construction.

Decision#

Key every dynamic input — primary, aux, context — uniformly by its canonical stream_name on the data path. The role scheme is resolved to a stream name exactly once, at workflow construction, and never enters the running workflow object.

Translation between schemes happens at three points, only two of which are on the data path:

#

Boundary

From → To

Performed by

Path

1

Kafka ingress

(topic, source_name)stream_name

MessageAdapter.get_stream_id + StreamLUT

data

2

Workflow construction

role → stream_name (building dynamic_keys)

instrument factory, inline aux_source_names[role]

build-time

3

Workflow runtime

stream_namesciline.Key

StreamProcessorWorkflow.accumulate / set_context (dict lookup)

data

JobFactory.create computes the role→stream map (aux_source_names) from AuxSources.render — instrument defaults overlaid with the user’s selection — and passes it to the factory. The factory keys dynamic_keys by canonical stream name directly, resolving each role inline (aux_source_names['incident_monitor']: NeXusData[Incident, SampleRun]). An unknown role raises KeyError at job creation, visible in factory code. The primary input is the degenerate case: its binding is the job’s identity (source_name), so translation 2 is the identity function and the factory keys it directly without aux_source_names. Context wire names equal their stream names (ADR 0003), so translation 2 does not touch them either — it concerns auxiliary inputs only.

Consequently the Job carries no mapping: input_streams and gating_streams are plain set[str] of stream names, and Job.add forwards {**primary_data, **aux_data} to the workflow unchanged. The only place a stream_name becomes a sciline.Key is the dict lookup inside StreamProcessorWorkflow.accumulate, where which dict the name is found in (dynamic_keys vs context_keys) decides accumulate vs set_context.

Because the routing layer must inject per-job context and f144-driven transforms after the factory has produced the workflow but before the wrapped StreamProcessor is built, StreamProcessorWorkflow defers construction behind SupportsContext.build(context_keys, chain_patch_bindings). The factory passes only its own internal context (e.g. ROI) to the constructor; build merges the routing-layer context_keys, wires chain_patch_bindings, and bakes the graph.

One-stream-to-many-roles multiplexing is dropped. It was unused and is incompatible with canonical-name keying: a single incoming stream_name maps to exactly one dynamic_keys entry.

Alternatives considered#

Option

Notes

Uniform canonical-name keying, role resolved at construction (chosen)

One scheme on the data path; Job is a plain name set; role lives only in factory code where an unknown role fails loudly at job creation.

Keep aux keyed by role; remap stream_name → role in Job

The prior state. Forces the role map into wire_dynamic_transforms and a Job._stream_to_fields remap on every batch; two data-path layers carry a scheme that only matters at build time. Rejected.

Resolve roles at runtime in Job/JobManager

Moves a construction-time concern onto the hot path and re-introduces the remap. Rejected.

Preserve one-stream-to-many-roles multiplexing

Unused, and irreconcilable with a one-to-one stream_namedynamic_keys mapping. Rejected.

Key design choices#

Only one naming scheme is on the data path#

Translation 2 happens at construction. Once messages flow, the entire stack from WorkflowData.data down to the StreamProcessorWorkflow dict lookup speaks a single language — stream_name. Only the innermost shell (StreamProcessor / Sciline) speaks sciline.Key. This is the invariant that lets Job hold plain name sets and forward data untouched.

Role resolution is a build-time concern, co-located with the factory#

The role scheme is the one a workflow author and a user reason in. Resolving it in the factory that constructs the workflow — the same module that declares the dynamic_keys — keeps role knowledge local and makes a typo a loud KeyError at job creation rather than a silent mis-route.

Two independent sources must agree on the canonical name#

The StreamLUT drives translation 1 on ingress; the render / aux_source_names map drives translation 2 in the factory. Nothing cross-checks them: an aux source whose rendered stream_name never appears on ingress simply never delivers data. This is the price of decoupling routing from construction; it is acceptable because both sides derive from the same instrument stream definitions.

Consequences#

  • Job.input_streams / gating_streams are set[str]; Job.add forwards primary and aux data to the workflow unchanged, with no remap.

  • dynamic_keys is keyed by canonical stream name for every input; the role scheme never enters the workflow object.

  • wire_dynamic_transforms takes only the workflow and the resolved ChainPatchBinding records — no aux_source_names — because it reads component types off dynamic_keys, which is already stream-name-keyed (ADR 0003).

  • StreamProcessorWorkflow defers its StreamProcessor construction behind SupportsContext.build, so the routing layer can inject per-job context and chain-patch bindings before the graph is baked.

  • One-stream-to-many-roles multiplexing is no longer supported.

  • The naming model and its translation points are illustrated in

    doc:

    /developer/design/stream-keying.