Skip to content

df_engine: all pipeline cores die under real OTLP load (boo panic in metrics record encoder + arrow DictionaryKeyOverflowError), process stays alive and reports healthy #3561

Description

@henrikrexed

Pre-filing checklist

  • I searched existing issues and didn't find a duplicate

Component(s)

Rust OTAP dataflow (rust/otap-dataflow/)

Bug Description

Under sustained real-world OTLP traffic, df_engine panics on every pipeline core and
the engine never recovers. The process stays alive and keeps accepting connections while
processing nothing, so orchestration sees a healthy pod.

Two distinct panic sites fired in one 30-second window:

  1. crates/pdata/src/encode/record/metrics.rs:266panic!("boo {lens:?}") in the
    metrics record encoder's column-length check(). This appears to be already known:
    draft PR fix: stop metrics otap encode panic #2984 ("fix: stop metrics otap encode panic") root-causes it as a shared
    protobuf-cursor misuse producing inconsistent column row counts.
    That PR is open,
    draft and unmerged, so the fix is not in main as of the commit above. Reporting it
    here only as an independent real-world reproduction — a data point for fix: stop metrics otap encode panic #2984, not a
    new bug.

  2. arrow-data-58.3.0/src/transform/mod.rs:680MutableArrayData::new is infallible: DictionaryKeyOverflowError. I could not find any existing issue for this, and it
    was the majority failure — 3 of 4 cores. It is not obviously metrics-specific;
    MutableArrayData::new is the generic array-merge path, so ordinary high-cardinality
    span or log attributes look sufficient to trigger it.

The most operationally serious part is neither panic but the aftermath: a core that dies
is never restarted and the failure is invisible to every process-level health signal.

Steps to Reproduce

  1. Build otap-df at 7502e7d (submodules recursive,
    upstream Dockerfile unmodified):
    cargo build --locked --release --target=x86_64-unknown-linux-gnu
    with RUSTFLAGS=-C target-cpu=x86-64-v2; runtime gcr.io/distroless/cc-debian13:nonroot.
  2. Run it with 4 pipeline cores and the config in the Configuration field — one shared
    chain for all three signals, so a metrics panic takes traces and logs with it.
  3. Point sustained OTLP traffic at it. I used the opentelemetry-demo Helm chart
    0.40.10 (appVersion 2.2.0, ~20 services) with every service's
    OTEL_EXPORTER_OTLP_ENDPOINT set to the engine, plus a service mesh emitting
    access-log spans and a second demo application ( Google Micro service demo). Ordinary smoke-test volume was
    enough — we never reached our intended load level.
  4. Watch the engine log for panicked at and watch the pod's readiness.

Deterministic: reproduced on two independent pods. Time-to-death shortens with
backlog, because upstream SDKs queue telemetry and retry it as a burst — run 1 survived
3m08s, run 2 survived ~48s. That rules out periodic restarts as a mitigation, since each
restart meets a larger backlog than the last.

Expected Behavior

  1. Encoding a batch of ordinary OTLP telemetry should not panic.
  2. If a pipeline core does panic, pipeline_runtime_failed should be recoverable —
    restart the core or start a new generation — rather than terminal.
  3. An engine whose cores have all died should FAIL its health/readiness surface. Reporting
    Running/Ready while processing nothing is the failure mode that costs downstream
    users the most.

On (3) there may be an easy win: the admin server already has the data. GET /api/v1/metrics?format=json&keep_all_zeroes=true returns the internal metric set per
node per core
, so a dead core shows as its receiver.otlp / processor.* counters
going flat while sibling cores advance — no log grepping needed.

Actual Behavior

Each panic kills its pipeline core and logs pipeline_runtime_failed. There is no
restart, no new generation, no recovery. Once all four cores are down:

  • the process is still running and still accepting connections,
  • Kubernetes reports the pod Running, Ready, restarts=0,
  • throughput counters keep their last healthy cumulative values, so a dashboard built on
    cumulative counters shows no drop.

I nearly banked a two-hour benchmark run on an engine that had been dead for six
minutes, because every liveness signal derived from the process was green. The only
thing that caught it was grepping the log for panic.

OTel-Arrow Version

7502e7d (main, 2026-07-20)

Environment

  • Deployment: Kubernetes, 4-core pod, gcr.io/distroless/cc-debian13:nonroot
  • Build: cargo build --locked --release --target=x86_64-unknown-linux-gnu,
    RUSTFLAGS=-C target-cpu=x86-64-v2
  • arrow-data 58.3.0
  • Load: opentelemetry-demo chart 0.40.10 (appVersion 2.2.0), ~20 services, traces +
    logs + metrics into one pipeline, plus mesh access-log spans and a second application
  • Backend: an OTLP/HTTP endpoint that returns 400 on cumulative Sum/Histogram/Summary
    (relevant only as corroboration that the metrics payload was independently judged
    malformed at the same moment the encoder panicked)

Configuration

version: otel_dataflow/v1
engine: {}
groups:
  default:
    pipelines:
      main:
        policies:
          channel_capacity:
            control: { node: 100, pipeline: 100 }
            pdata: 128
        nodes:
          otlp_in:
            type: receiver:otlp
            config:
              protocols:
                grpc: { listening_addr: 0.0.0.0:4317 }
                http: { listening_addr: 0.0.0.0:4318 }
          enrich:
            type: processor:attribute
            config:
              actions:
                - { key: some.static.key, action: upsert, value: some-value }
          parity:
            type: processor:transform
            config:
              kql_query: "logs | extend severity_text = 'ERROR'"
          batch:
            type: processor:batch
            config:
              otap: { min_size: 1000, sizer: items }
              max_batch_duration: 3s
          out:
            type: exporter:otlp_http
            config:
              endpoint: https://<otlp-endpoint>/api/v2/otlp
              client_pool_size: 4
              http:
                headers:
                  Authorization: "<redacted>"
        connections:
          - { from: otlp_in, to: enrich }
          - { from: enrich,  to: parity }
          - { from: parity,  to: batch }
          - { from: batch,   to: out }

Log Output

thread 'pipeline-default-main-core-3-gen-0' (14) panicked at crates/pdata/src/encode/record/metrics.rs:266:13:
boo [21, 21, 21, 7, 21, 21, 21, 21, 21, 21]
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
2026-07-23T10:53:34.869Z  ERROR otap-df-controller::controller.pipeline_runtime_failed: Pipeline terminated with a runtime error [core_id=3]

thread 'pipeline-default-main-core-1-gen-0' (12) panicked at /usr/local/cargo/registry/src/index.crates.io-.../arrow-data-58.3.0/src/transform/mod.rs:680:31:
MutableArrayData::new is infallible: DictionaryKeyOverflowError
2026-07-23T10:53:52.252Z  ERROR otap-df-controller::controller.pipeline_runtime_failed: Pipeline terminated with a runtime error [core_id=1] entity/pipeline.attrs: pipeline.id=main pipeline.group.id=default deployment.generation=0 core.id=1 numa.node.id=0

thread 'pipeline-default-main-core-0-gen-0' (11) panicked at /usr/local/cargo/registry/src/index.crates.io-.../arrow-data-58.3.0/src/transform/mod.rs:680:31:
MutableArrayData::new is infallible: DictionaryKeyOverflowError
2026-07-23T10:53:58.263Z  ERROR otap-df-controller::controller.pipeline_runtime_failed: Pipeline terminated with a runtime error [core_id=0]

thread 'pipeline-default-main-core-2-gen-0' (13) panicked at /usr/local/cargo/registry/src/index.crates.io-.../arrow-data-58.3.0/src/transform/mod.rs:680:31:
MutableArrayData::new is infallible: DictionaryKeyOverflowError
2026-07-23T10:53:59.582Z  ERROR otap-df-controller::controller.pipeline_runtime_failed: Pipeline terminated with a runtime error [core_id=2]

# after this point: process alive, pod Ready, restarts=0, zero records processed

Additional Context

Relationship to existing issues (from the pre-filing search):

Workaround I adopted, in case it helps others: insert processor:type_router
immediately after the receiver and terminate the metrics port in exporter:noop, so the
metrics encoder is never reached:

router:       { type: processor:type_router, outputs: [logs, metrics, traces], config: {} }
metrics_noop: { type: exporter:noop, config: {} }
# connections: otlp_in -> router; router["metrics"] -> metrics_noop; router["logs"|"traces"] -> ...

With that plus max_batch_duration reduced 3s -> 1s, the same workload ran 41 minutes
with zero panics
where it previously died in 30 seconds. ⚠️ I changed two things at
once and cannot attribute the improvement: cutting the batch window also reduces how many
records merge into each array, which plausibly suppresses the dictionary overflow on its
own. So this is not evidence that excluding metrics is sufficient — panic site 2 was
never addressed and we believe it can still fire on high-cardinality traces or logs.

Separately, --validate-and-exit accepts configurations that cannot work at runtime.
Happy to split these out if you'd prefer separate issues:

  • processor:filter accepts config: {__bogus__: 1} and prints Configuration is valid.
  • processor:type_router with config: {} and no outputs: declared at all validates
    clean, although nothing would be routed by name.
  • the KQL processor:transform validates predicates it cannot evaluate at runtime —
    contains, ==, matches regex and replace_regex all fail at runtime against a
    config that validates clean.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    Projects

    Status
    No status

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions