Skip to main content

Architecture

Why this repo exists, what owns what, and the invariants you cannot read off a single file. For the field-by-field design -- every config key, every generated block, every state transition -- read DESIGN.md. This document sits above it.

The problem​

DFE needs a transform stage between its sources and its loader. Vector.dev already does the per-record transform work well, so building another transform engine was never the question. The question was the other half.

Vector on its own is opaque to the platform around it. It has its own config shape, its own metrics endpoint, its own process lifecycle and its own idea of what "started" means. Every other DFE app presents one control surface: a big-dial YAML in the config registry, one ops port carrying /livez, /readyz and /metrics, an OTLP push, a consumer-lag scaling signal and a Helm chart compiled by dfe-engine. Adopting Vector as it ships would mean dfe-engine, GitOps and the observability stack each learning a special case.

So Vector is kept, and wrapped. The Rust supervisor is PID 1 and Vector is its child. The supervisor supplies the production half -- config, credentials, health, metrics, restarts -- and Vector does the transform. From dfe-engine's side this app is indistinguishable from dfe-loader or dfe-receiver.

Why a subprocess​

Vector is not designed as a library and has no stable public API for embedding, so the image downloads the upstream release binary at a pinned version and the supervisor spawns it with --config-dir. Against compiling Vector in as a crate, the deciding factor was upgrades: a subprocess swaps the binary, a crate rebuilds against every new Vector version.

That choice has one visible consequence. This is the only DFE Rust app whose runtime image carries two binaries -- its own, which scalo's deployment contract builds, and vector, which has no slot in that contract. The Dockerfile is therefore generated by scalo and then spliced: src/deployment.rs::emit_dockerfile() appends src/vector-layer.dockerfile with the version substituted in. The other five DFE Rust apps are single-binary images and need no such override. When hyperi-ci grows an overlay framework the override moves into .hyperi-ci.yaml.

The fragment is a real Dockerfile rather than a Rust string literal so hadolint and shellcheck can read it. Shell embedded in format! is validated by nothing until the image build runs.

Who owns what​

The boundary is the thing to get right, because most confusion here is a question asked of the wrong layer.

LayerOwns
Vector (the child)Per-record transform work, the Kafka client on the bus transport, its own internal metrics
This supervisorConfig assembly, process lifecycle and crash recovery, health reporting, the metrics merge, the direct-transport bridge, the deployment artefacts
dfe-engineWhat the app IS -- it reads dfe-infra/apps.yaml, compiles the Helm values and holds the config in its registry
dfe-infraThe image pin and the GitOps deployment

Nothing about this app's shape is coded into dfe-engine. It is a per_config app with scale_deployed: true, declared in dfe-infra/apps.yaml -- one deployment per source config, not a singleton, and KEDA drives the replica count. Its default_in list is empty, so it is not part of the default deploy composition and arrives only when a deployment asks for it.

The pieces​

The config engine turns a handful of big dials into a Vector config directory: a numbered source file, the user's transform files, a sink file and an observability file. The user authors only the transforms: block. wiring parses the component labels and inputs references in those files and joins dfe_source to dfe_sink through them, so a transform author does not hand-wire the topology.

The process manager spawns Vector, forwards signals, and restarts it with exponential backoff between one and sixty seconds.

The bridge exists on the direct transport only. With no broker in the path the two protocols do not meet: the rest of DFE speaks scalo's Transport/Push and Vector speaks vector.Vector/PushEvents. The supervisor translates in both directions, and both inner legs stay on loopback inside the one pod. On bus the bridge does nothing and Vector talks to Kafka itself.

Runtime invariants​

These are the facts that cost someone a debugging session. None of them is visible from reading one file.

Reload polls, and must. The watcher polls rather than using inotify because S3-backed mounts -- s3fs, goofys, Mountpoint for S3 -- generate no filesystem notification events. Swapping in inotify would work on every developer laptop and silently stop reloading in production.

Only transforms hot-reload. A transform change is validated, SIGHUPed into Vector, and reported from Vector's reload counters, since the signal succeeds even when Vector refuses it. Everything else needs a pod restart, and not by policy: the consumer, the Push listener, the bridge legs, the consumer group id, the metrics labels and the ops listener are all set at startup. A change that classifies as transforms-only when it is not logs a successful reload for work it never did.

A push on the direct path is answered only once the next hop has it. Both bridge listeners are built armed, so a push is held until the pipeline releases its records. A hop that refuses is retried until the hold runs out, and then the push is answered Unavailable and its sender retries. The holds nest so an inner hop is answered while the one around it can still answer its own sender: 18 s at the intake, 16.5 s on the leg back from Vector, and 15 s for each send to the next stage. A record no retry would get through is released dropped and counted rather than resent forever: one over the next stage's message-size ceiling, or one Vector refused for good because a sink it feeds rejected it.

Only the intake sheds under pressure. The leg back from Vector drains everything the intake holds, so shedding it would stall the stage until the holds expired into duplicates. Both listeners lease held bytes on the runtime's memory guard.

A failed bridge leg stops the process. Its senders would otherwise retry for ever against a pod that stayed up, so the service shuts down and exits non-zero.

Health and metrics invariants​

Port 9598 is a loopback debug surface, not a scrape target. Vector's own prometheus_exporter binds it. The supervisor GETs it on scalo's metrics interval and registers every sample on the registry scalo already serves and pushes over OTLP, so one endpoint carries both. No second Prometheus target and no PodMonitor selector are involved. The merge is also what makes the app's own throughput counters non-zero, since Vector owns the Kafka client and the supervisor never sees a record on the bus transport.

/readyz proves the subprocess exists, not that it carries traffic. The gate is that the child was still alive 500ms after spawn, which catches bad argv, an unreadable config and a pod in crash-recovery backoff. Vector's own startup routinely takes longer than that, so there is a window where the pod reports ready and Vector is still coming up. Proving traffic would mean reading Vector's API, which the supervisor does not do.

Build, chart and licence invariants​

The Vector version has one source. The constant VECTOR_VERSION in src/deployment.rs is substituted into the Dockerfile's ARG VECTOR_VERSION, and both VectorConfig::default() and the contract's default_config read it for vector.version. version_check: strict refuses to start a pod when the config's version and the binary's disagree, so a deployment that sets vector.version itself has to move it with the image.

password is a String on purpose, not a SensitiveString. The config goes through a figment serialize-merge-deserialize round trip in apply_figment_env(), and SensitiveString serialises as ***REDACTED***, which destroys the value in transit. The protection is elsewhere: the env var is masked in logs, Debug redacts the field, and no credential is written into the Vector config. The generated components name it SECRET[<backend>.<key>] and Vector's directory secret backend reads it from files: a mounted secret at sasl.secret_dir, or owner-only files the assembler writes under <config_dir>/.secrets from a credential given as text. The released chart takes the second path: its Kafka Secret arrives in DFE_TRANSFORM_{SOURCE,SINK}_SASL_*, and <config_dir> sits on the run writable path. Changing the type to look safer breaks authentication.

Vector expands no ${VAR}, and vector validate resolves no SECRET[...]. Since 0.57 interpolation needs --dangerously-allow-env-var-interpolation, which the supervisor never passes, and validate() refuses a ${...} in a SASL credential. Validate would hand the broker the literal SECRET[...] reference, so the supervisor runs it with --no-environment, and a missing secret file fails only when Vector starts -- which is why the assembler checks secret_dir itself.

Vector is redistributed unmodified under MPL-2.0. That carries two obligations -- ship the licence text and tell recipients where the source is. Both are discharged inside the image from the release archive's own LICENSE, NOTICE and licenses tree, so the attribution always matches the exact binary shipped. Do not replace that with a separate download.

No chart is committed. At release, hyperi-ci emits the contract with generate-artefacts and assembles a thin chart from it on the scalo-service library chart at release.helm.library. The contract is schema version 4: a 120 s startup budget, kubernetes.io/h2c on the Push port, the Kafka Secret mounted into the per-endpoint SASL names, the writable paths data at /var/lib/vector and run at /var/run/vector under a read-only root, requests 100m/128Mi, limits 500m/512Mi and a 90 s grace period.

The ScaledObject scales on CPU alone. contract() sets KafkaLagTrigger::disabled(): consumer-group lag rises when a downstream stage breaks, and the library's lag trigger reads config.kafka.*, a block this app does not have, so the library refused to render it. A deployment adds a trigger with the library's keda.extraTriggers value.

Where the transports differ​

One deployment runs one transport and the transform you author is identical on both. What changes is who hands the record over: on bus it is a Kafka topic at each end and Vector uses its own kafka source and sink, on direct it is a scalo Push listener in and a Push client out with Vector on vector components over loopback. templates/bus.yaml and templates/direct.yaml are the whole topology for each, runnable as they stand. In a DFE deployment the supervisor generates the sources: and sinks: blocks, so a whole template mounted as a transform file gives Vector two sources and two sinks.