Skip to main content

dfe-transform-vrl — Design Document

Overview​

dfe-transform-vrl is an embedded VRL transform engine for Kafka-to-Kafka pipelines. Unlike dfe-transform-vector (which runs Vector as a subprocess), this service embeds the VRL crate directly and owns the Kafka consumer/producer, giving full control over memory, backpressure, and wire format.

Problem Statement​

Memory Control​

Vector.dev has no configurable memory cap. Its internal buffers grow unbounded based on throughput. In K8s autoscaling, pod memory limits must be set to the maximum Vector might ever use, resulting in 2-4x memory waste compared to DFE services that control their own buffers.

Architecture​

Data Flow​

Per-Event Processing​

Kafka partition message (raw bytes)
│
├─ Parse: sonic_rs::from_slice::<Value>() -- a record that is not JSON is dropped
│
├─ Run VRL program(s) on Value
│ └─ VRL operates directly on Value — no format conversion
│
├─ Serialise: sonic_rs::to_vec(&value)
│
└─ KafkaTransport::send() (scalo) → delivery future

Offset Commit Strategy (At-Least-Once)​

Release is batch-level. scalo's batch engine hands a block of records to the transform, sends the result, and releases the block's source offsets once the batch send returns Ok. Under that, scalo's Kafka transport holds each source offset it handed out and commits a partition only up to its lowest unreleased one, so the cumulative commit never passes a record not yet released.

If the process crashes between send and release, the whole block is re-consumed and re-processed (at-least-once, not exactly-once). VRL transforms must be idempotent.

Payload Format​

JSON is the only payload format. MessagePack, supported in DFE/XDR 2.0 and 2.1, is deprecated in DFE 2.2 and no longer accepted: the JSON path (SIMD parsing with sonic-rs, zstd on the wire) is fast enough that MessagePack gave no CPU saving.

A record that does not parse as JSON is removed from its block, counted in records_error_total{stage="deserialise"}, and logged (sampled) with the parse error. The block's source is then released Dropped, so the record is not redelivered.

VRL Integration​

Compilation​

VRL programs are compiled once at startup:

let program = vrl::compiler::compile(source, &vrl::stdlib::all())
.map_err(|diagnostics| /* format errors */)?;

The compiled Program is reused for every event — zero per-event compilation cost.

Execution​

Each event is wrapped in a TargetValueRef and passed to the VRL runtime:

let mut target = TargetValueRef {
value: &mut event_value,
metadata: &mut metadata,
secrets: &mut secrets,
};
runtime.resolve(&mut target, &program)?;

VRL's Value type is serde-compatible, so sonic_rs parses JSON bytes directly into it.

Transform File Format​

Transform files contain raw VRL source code (not Vector YAML):

# transforms/01_parse.vrl
.parsed = parse_json!(.message)
del(.message)

# transforms/02_enrich.vrl
.environment = .labels.env ?? "unknown"
.processed_at = now()

Files are loaded in sorted order (by filename) and concatenated into a single VRL program. This matches Vector's remap transform behaviour where multiple VRL statements execute sequentially.

vrl is built without its enable_env_functions and enable_network_functions features, and compile_vrl drops get_hostname from the system functions it keeps for parse_etld. A program calling get_env_var, get_hostname, http_request, dns_lookup or reverse_dns fails to compile as a call to an undefined function.

Bundled Pipeline: filebeat-compat (INTERIM)​

pipelines/filebeat/ ships a pre-canned, opt-in port of the DFE 2.1 Vector filebeat templates (Cisco Meraki logs, Cisco IOS, Cisco Umbrella) as one pure-VRL file plus its timezones.csv enrichment table. It is convenience data, not engine capability: opting in means pointing the standard transforms.dir and enrichment_tables config at those files. The engine carries no filebeat-specific code; the integration tests (tests/integration/filebeat_pipeline.rs) drive the bundle with the real elastic/integrations pipeline test corpus, which doubles as a full-engine workload.

INTERIM: elastic compatibility is being replaced by dfe-transform-elastic (Rust-native, in beta). Wiring, routing behaviour, known limitations, and regeneration tooling (scripts/filebeat/) are documented in pipelines/filebeat/README.md.

Memory Budget​

The wrapper controls all memory allocation:

ComponentConfigurationDefault
Consumer prefetchsource.max_buffer_bytes64 MiB
Transform chunkbatch_processing.max_chunk_size (env only)10 000 events
Producer queuesink.max_buffer_bytes64 MiB
Total pod memoryK8s resource limit512 MiB (128 MiB request)

The sum of consumer + producer buffers must fit within the pod memory limit minus overhead (binary, runtime, VRL compiled programs). The wrapper enforces backpressure: if the producer queue is full, the consumer pauses fetching.

Configuration​

Same big-dial pattern as dfe-transform-vector, minus Vector subprocess config:

pipeline:
name: "my-pipeline"

source:
brokers: ["kafka:9092"]
topics: ["raw_events"]
group_id: "dfe-transform-vrl-my-pipeline"
max_buffer_bytes: 67108864 # 64 MiB consumer buffer

transforms:
dir: "/etc/dfe-transform-vrl/transforms/" # directory of .vrl files

sink:
brokers: ["kafka:9092"]
topic: "enriched_events"
max_buffer_bytes: 67108864 # 64 MiB producer buffer

That is the wrapper's whole schema. metrics, logger, scaling, worker_pool, batch_processing, self_regulation, version_check and otel_tracing belong to scalo's cascade, which reads this file as its settings layer when it is passed with --config, so they may sit beside these sections. The env layer (METRICS_ADDR, LOG_LEVEL, DFE_TRANSFORM_VRL_SCALING__*, ...) outranks the file.

Comparison: dfe-transform-vrl vs dfe-transform-vector​

Aspectdfe-transform-vrldfe-transform-vector
Transform engineVRL crate (in-process)Vector subprocess
Kafka source/sinkrdkafka (wrapper-controlled)Vector-managed
Memory controlFull (bounded buffers)None (Vector unbounded)
Supported transformsVRL onlyAll Vector transforms
Container image size~20 MiB (Rust binary)~170 MiB (Rust + Vector)
Process modelSingle processWrapper + subprocess
Startup timeFast (compile VRL)Slower (spawn + healthcheck Vector)
Crash recoveryRestart binaryRestart subprocess + binary

When to Use Which​

  • dfe-transform-vrl: VRL-only transforms (the common case), memory-constrained pods, high-density deployments
  • dfe-transform-vector: Pipelines needing Vector-native transforms (lua, aggregate, dedupe, throttle, sample), or complex multi-source/sink routing

Reload​

Carried in the reloadable subset​

ConfigReloader (file polling + SIGHUP) re-reads and re-validates the file and swaps SharedConfig<HotConfig>, so a bad edit is caught without a restart.

FieldWhat it would controlState
pipeline.batch_sizeEvents per transform chunknot applied -- see below
pipeline.batch_timeout_msMax wait before flushing partial batchnot applied -- no such timer
sink.key_fieldKafka partition key pathnot applied -- scalo-rs#37

Accepted but not applied​

The pipeline holds the reloadable subset but reads none of it yet, so a reload changes no pipeline behaviour today. config::INERT_SETTINGS is the list, and the wrapper warns at startup for each one a deployment has set. The dial that does size a chunk is batch_processing.max_chunk_size, set via the env layer.

Requires pod restart​

These fields are bound to connections, compiled programs, or server sockets established at startup. Changing them requires a pod restart (which is the standard K8s pattern — ConfigMap changes trigger rolling restart via the checksum/config annotation in the Deployment template).

FieldWhy
pipeline.nameBaked into Kafka group_id, metrics labels, tracing spans
source.*rdkafka consumer: connection, subscription, auth, TLS, buffers
sink.brokersrdkafka producer connection
sink.topicOutput topic (changing mid-stream risks data loss)
sink.compressionrdkafka compression.type
sink.sasl.* / sink.tls.*Security protocol
sink.max_buffer_bytesrdkafka queue.buffering.max.kbytes
sink.librdkafka_optionslibrdkafka ClientConfig
transforms.*VRL programs compiled at startup