Skip to content

fix(buffers): add nesting depth limit to prevent protobuf decode corruption - #25417

Closed
ganelo wants to merge 51 commits into
vectordotdev:masterfrom
ganelo:og/continue-protobuf-nesting-depth-limit
Closed

ganelo wants to merge 51 commits into
vectordotdev:masterfrom
ganelo:og/continue-protobuf-nesting-depth-limit

Conversation

@ganelo

@ganelo ganelo commented May 12, 2026

Copy link
Copy Markdown
Contributor

Note: this is a continuation of #25053 - the author of that PR was interning with us and his internship has now ended, so I'm taking over iteration. Please close the older PR at your leisure.

Summary

Log events with deeply nested fields (>32 levels) corrupt disk buffers irreversibly. The buffer cannot be read, Vector cannot start, and the only recovery is manually deleting the buffer. The same events also cause infinite retry loops in the vector-to-vector gRPC path.

The root cause is that prost encodes these events successfully but fails to decode them. prost enforces a recursion limit of 100 on decode but not on encode. In Vector's proto schema, each level of map nesting consumes 3 prost recursion levels, so events deeper than ~32 levels encode fine but fail on every subsequent read.

The disk buffer failure is straightforward:

  1. A deeply nested event encodes successfully and is written to the disk buffer
  2. On read, prost decode fails with InvalidProtobufPayload and the event is lost
  3. On next startup, buffer initialization validates the last written record by decoding which fails with FailedToValidate, and Vector cannot start. The buffer must be manually deleted.

Approach

This PR rejects overly nested events at encode time, before they reach the buffer or wire. A MAX_NESTING_DEPTH (32) check is added at the three protobuf encoding boundaries:

  • EventArray::encode guards disk buffer writes
  • NativeSerializer::encode guards the native codec (TCP/file sinks)
  • VectorSink stream filter guards the gRPC vector-to-vector path, emitting ComponentEventsDropped (intentional) so operators can monitor via component_discarded_events_total

The limit of 32 is the highest safe value across all proto encoding paths. Unit tests verify that events at this depth roundtrip correctly and that depth 33 fails prost decode.

Each check plugs into its existing error handling framework: the disk buffer sender treats encode errors as fatal (pre-existing behavior), the native codec surfaces errors through EncoderSerializeError, and the vector sink emits ComponentEventsDropped directly.

Comparison with prior approaches

#19413 proposed enabling prost's no-recursion-limit feature. It was closed without merge because maintainers preferred a configurable limit. A configurable prost limit would require upstream changes to prost (prost#785 is in review for build-time configurable limits). This PR provides an immediate fix that can coexist with a future configurable limit.

Vector configuration

Reproduction using disk buffer:

sources:
  stdin:
    type: stdin
transforms:
  parse_json:
    type: remap
    inputs: ["stdin"]
    source: .message = parse_json!(string!(.message), 128)
sinks:
  blackhole:
    type: blackhole
    inputs: ["parse_json"]
    buffer:
      type: disk
      max_size: 268435488
# 32-deep JSON → parse_json nests into LogEvent field → effective depth 33 → corruption
echo '{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":"deep"}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}' | vector --config test.yaml

Reproduction using disk buffer:

sources:
  stdin:
    type: stdin
transforms:
  parse_json:
    type: remap
    inputs: ["stdin"]
    source: .message = parse_json!(string!(.message), 128)
sinks:
  blackhole:
    type: blackhole
    inputs: ["parse_json"]
    buffer:
      type: disk
      max_size: 268435488
# 32-deep JSON → parse_json nests into LogEvent field → effective depth 33 → corruption
echo '{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":"deep"}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}' | vector --config test.yaml

How did you test this PR?

  • Unit test proving the root cause: prost encodes deeply nested events but fails to decode them (deeply_nested_event_encodes_but_fails_prost_decode)
  • Unit test proving the boundary: depth 32 roundtrips via raw prost, depth 33 fails (event_at_max_depth_roundtrips_via_prost)
  • Nesting gate tests: rejects events exceeding the limit, accepts events at exactly the maximum depth
  • Roundtrip tests for both EventArray (disk buffer) and native codec paths
  • Coverage for logs, traces (both gated), and metrics (always pass, fixed structure)
  • Unit tests for check_value_depth with configurable limits, arrays, and objects
  • EventWrapper path (vector sink gRPC) verified safe at MAX_NESTING_DEPTH

Change Type

  • Bug fix
  • New feature
  • Dependencies
  • Non-functional (chore, refactoring, docs)
  • Performance

Is this a breaking change?

  • Yes
  • No

Events at depth ≤32 continue to work. Events at depth >32 previously caused silent disk buffer corruption, infinite gRPC retries, or undecodable bytes, and are now explicitly rejected. No working pipeline is affected.

Does this PR include user facing changes?

  • Yes. Please add a changelog fragment based on our guidelines.
  • No. A maintainer will apply the no-changelog label to this PR.

References

Notes

  • Please read our Vector contributor resources.
  • Do not hesitate to use @vectordotdev/vector to reach out to us regarding this PR.
  • Some CI checks run only after we manually approve them.
    • We recommend adding a pre-push hook, please see this template.
    • Alternatively, we recommend running the following locally before pushing to the remote branch:
      • make fmt
      • make check-clippy (if there are failures it's possible some of them can be fixed with make clippy-fix)
      • make test
  • After a review is requested, please avoid force pushes to help us review incrementally.
    • Feel free to push as many commits as you want. They will be squashed into one before merging.
    • For example, you can run git merge origin master and git push.
  • If this PR introduces changes Vector dependencies (modifies Cargo.lock), please
    run make build-licenses to regenerate the license inventory and commit the changes (if any). More details on the dd-rust-license-tool.

connoryy added 30 commits March 26, 2026 10:06
…uption

Prost enforces a recursion limit of 100 during protobuf decoding. In
Vector's proto schema, each level of map nesting costs ~3 prost recursion
levels, causing decode failures at Value tree depth 34. Events that encode
successfully but fail to decode corrupt the disk buffer irreversibly —
subsequent reads and even startup validation fail with
InvalidProtobufPayload, requiring manual buffer deletion.

Add a MAX_NESTING_DEPTH (33) check at three protobuf encoding boundaries:

- EventArray::encode (disk buffer writes)
- NativeSerializer::encode (native codec over TCP/file)
- VectorSink stream filter (gRPC vector-to-vector path)

Events exceeding the limit are rejected before encoding. The vector sink
emits ComponentEventsDropped (intentional) to the standard metrics pipeline
so operators can monitor rejected events via component_discarded_events_total.
The protobuf encoding path encodes both the event value and the
event metadata value into the same message. A deeply nested metadata
value would bypass the nesting check and cause the same corruption.
Check both values in event_exceeds_max_nesting_depth and
check_event_array_nesting_depth.
Traced through prost 0.12's decode path and Vector's generated proto
code to derive the exact formula: 4 outer recursion levels (EventArray
→ LogArray → Log → fields map) + 3 per Value map nesting level (Value
message → ValueMap message → map entry). 4 + 3*32 = 100 = RECURSION_LIMIT
exactly, confirmed by empirical test probing depths 28-38.
Empirical testing revealed that different proto encoding paths have
different prost recursion overhead:

- Log Object root (Log.fields path): 4 outer levels → fails at depth 33
- Log non-Object root (Log.value path): 5 outer levels → fails at depth 32
- Metadata value: fails at depth 33
- Trace events: fails at depth 33

With MAX_NESTING_DEPTH=33, the non-Object root path and metadata path
had gaps where our check passed but prost decode failed. Lowering to 32
closes all gaps — verified by probing all four paths across depths 28-36
and confirming zero cases where our check passes but prost fails.
Verify that the EventWrapper decode path (used by the vector sink's
gRPC receiver) is safe at MAX_NESTING_DEPTH. EventWrapper has fewer
outer proto wrappers than EventArray, so our limit is conservative
for this path.
Remove the approximate formula (5 + 3*N <= 100) and hedged outer
overhead range (3-5) that were not fully verified. The comment now
states only what we know for certain: the value was determined
empirically and unit tests verify the boundary.
…oryy/vector into connor/protobuf-nesting-depth-limit
The metadata_full encoding path (EventArray → *Array → Event → Metadata
→ Value) is the tightest proto path, using exactly 100 of prost's 100
recursion budget at MAX_NESTING_DEPTH=32. This was the only path without
a roundtrip success test — only gate rejection and decode failure tests
existed for metadata.

Add explicit prost encode/decode roundtrip tests for both log and metric
metadata at depth 32 to prove the tightest path works.
Consolidate the scattered nesting depth tests into a data-driven framework
that exhaustively covers every proto encoding path where a Value can appear:

- Log value (via Log.fields), Log metadata (via metadata_full)
- Trace value (via Trace.fields), Trace metadata (via metadata_full)
- Metric metadata (via metadata_full)
- Both EventArray and EventWrapper top-level wrappers

The key test (`max_nesting_depth_is_correct_for_all_proto_paths`) verifies
MAX_NESTING_DEPTH is exactly right:
1. ALL paths must roundtrip at depth 32 (limit isn't too high)
2. At least one path must fail decode at depth 33 (limit isn't too low)

Adding a new proto wrapper message or Value-carrying field requires adding
it to `all_proto_paths()`, and the test immediately surfaces if the limit
needs adjustment.
…ing paths

Instead of maintaining a list of individual proto paths, create events with
ALL Value-carrying fields at max depth simultaneously. The proto conversion
code populates every field (including deprecated ones like Log.metadata),
so a single roundtrip per event type covers every proto path automatically.

If a new Value-carrying field is added to event.proto, the conversion code
must populate it, and these tests cover it with zero maintenance. No path
enumeration to keep in sync.
Add two isolated tests:
- log_fields_has_headroom_beyond_max_depth: proves Log.fields can handle
  depth 33 (the loosest path has 3 entries of headroom)
- metadata_full_fails_beyond_max_depth: proves metadata_full fails at
  depth 33 (the tightest path, exactly 100/100 at depth 32)

Together these demonstrate that MAX_NESTING_DEPTH=32 is constrained
specifically by the metadata_full encoding path.
Replace the two one-sided tests with a single per_path_boundaries test
that verifies both sides of both paths:

- Log.fields (loosest): succeeds at depth 33, fails at depth 34
- metadata_full (tightest): succeeds at depth 32, fails at depth 33

This proves the exact prost budget for each path and that the uniform
MAX_NESTING_DEPTH=32 is set by metadata_full specifically.
The metadata_full encoding path has one more proto wrapper message than
the event data path (Log.fields/Trace.fields), so it hits prost's
recursion limit at a lower nesting depth. Instead of penalizing event
data with the metadata path's stricter limit, use separate constants:

- MAX_NESTING_DEPTH (33): for event data values (Log.fields, Trace.fields)
- MAX_METADATA_NESTING_DEPTH (32): for metadata values (via metadata_full)

Both limits are verified by per_path_boundaries: each constant succeeds
at its value and fails at +1 via raw prost encode/decode.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 35981225af

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread lib/vector-buffers/src/topology/channel/sender.rs
…he sink

WriterSink::run propagated encoder errors fatally via `?`. A single
data-dependent encoder failure -- e.g., one event exceeding the native
codec's protobuf nesting budget added in this branch -- would terminate
the whole sink, silently dropping every later valid event in the stream.

Treat encoder failures as per-event: mark the offending event's
finalizers as Errored (source-side acks see the drop), then continue to
the next event. The Encoder framework already logs the error, counts a
component error, and emits ComponentEventsDropped via EncoderSerializeError,
so the only sink-level work needed is finalization + continuation.

Adds per_event_encoder_failure_does_not_terminate_sink: streams a too-deep
log event followed by a valid one through the native-codec console sink,
asserts the bad event is finalized Errored and the next event is still
Delivered. Stashing only the encoder-call change confirms the test
catches the regression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 860f40a3f8

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread lib/codecs/src/encoding/format/native.rs Outdated
Comment thread src/sinks/vector/sink.rs
In WhenFull::Overflow topologies where a disk-v2 stage cascades to an
in-memory overflow stage, an over-budget EventArray arriving at the full
disk would have its sub-items dropped here even though the overflow
stage has no wire-format constraint and could accept the item intact.

Short-circuit in SenderAdapter::try_send: with the writer lock held,
check is_buffer_full() before filtering. When the buffer is already at
its size limit, return the item unfiltered as rejected so BufferSender
can forward it to the overflow stage. Holding the writer lock makes the
check race-free against other writers (only writers grow the buffer;
readers only shrink).

Exposes BufferWriter::is_buffer_full as pub(crate) for the new caller.

# Coverage

This addresses the steady-state-full case. The narrower
"buffer has slack but not enough for this specific record" path
(can_write_record returning false from inside try_write_record) still
filters before the rejection -- detecting that case requires knowing
the encoded size pre-encode, which is not cheaply available.

# Multi-stage topologies

The behavioural shift extends to chains: in
disk-v2(Overflow) -> disk-v2(Overflow) -> disk-v2(Block), an over-budget
item now flows unfiltered through the full Overflow stages and is
finally filtered at the Block stage that actually persists it (with
ComponentEventsDropped emitted there). Each intermediate stage accounts
for it as dropped_intentional (fullness drop), so buffer_size gauges
stay accurate.

The one configuration to call out: when the chain terminates at an
in-memory overflow (e.g., disk(Overflow) -> in-memory(Block)), the
over-budget events flow all the way to memory unfiltered and are never
dropped by any buffer stage. Whatever downstream codec consumes them
hits the wire-format limit and is responsible for the drop (e.g., the
console sink's non-fatal encoder handling). This matches the codex
premise that the in-memory overflow stage has no wire-format constraint
and should receive items intact.

# Counterintuitive consequence

For topologies that end in an in-memory buffer, this fix introduces an
unintuitive delivery curve for over-budget events: such events are
delivered *only* when the upstream disk stages are full. When the disk
stages have capacity, an over-budget event arrives, gets filtered at
the disk stage, and the over-budget sub-items are dropped via
ComponentEventsDropped. When the disk stages are full, the same event
flows unfiltered through to the in-memory tail and is delivered
downstream intact. Disk fullness therefore raises the effective
delivery rate of over-budget events for this class of topology -- the
opposite of the intuition that disk backpressure should reduce
throughput. The codex feedback accepted this tradeoff in exchange for
not dropping events the overflow stage could accept; it is documented
here so operators of disk-then-memory topologies understand why drop
telemetry for over-budget events may correlate inversely with disk
utilization.

No regression test is added because reliably driving the disk-v2
writer's is_buffer_full() to true under the minimum-size test config
takes careful tuning of record/buffer sizes (can_write_record tends to
short-circuit writes before total_buffer_size reaches max_buffer_size).
The diff itself is a single is_buffer_full() short-circuit before the
existing filter path; existing disk-v2 writer-level tests cover the
full-buffer behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f28b2a35e2

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread lib/vector-buffers/src/topology/channel/sender.rs Outdated
Comment thread lib/codecs/src/encoding/format/native.rs Outdated
Comment thread src/sinks/console/sink.rs Outdated
The native protobuf encoder returned Err for events exceeding the wire
format's recursion budget. Inside the per-event console sink that was
fixed up by handling the Err non-fatally, but the *batched* path in
src/sinks/util/encoding.rs propagates the same error fatally via `?`:
one over-budget event in a request would surface as InvalidData,
cause the HTTP sink (and any other batched sink built on this util)
to drop the entire request, and finalize every other valid event in
the same batch as dropped/Errored. Codex flagged this for the HTTP
sink + native codec configuration.

Move the drop semantics into the encoder itself:

- `NativeSerializer::encode` now detects over-budget events, sets
  `EventStatus::Rejected` on the event metadata, emits
  `ComponentEventsDropped`, and returns Ok(()) without writing any
  bytes. Batched and per-event callers alike continue past the drop
  unchanged.
- `Encoder<Framer>::encode` (lib/codecs/src/encoding/encoder.rs)
  skips framing when the inner serializer produced no payload, so an
  empty buffer is a clean per-event drop signal rather than a stray
  delimiter.
- `WriterSink::run` (console sink) checks `bytes.is_empty()` after a
  successful encode and finalizes the event as `Rejected` (encoder
  dropped it) before continuing. Combined with the prior fix, no
  data-dependent encoder outcome can terminate the sink.

Tests updated:

- The four native-codec reject tests in lib/codecs/tests/native.rs
  now assert Ok(()) + buffer.is_empty() instead of Err.
- The console sink's per_event_encoder_failure_does_not_terminate_sink
  asserts BatchStatus::Rejected for the dropped event (was Errored).
- New test_encode_batch_native_drops_over_budget_event_without_aborting
  in src/sinks/util/encoding.rs pins the batched-encoder behaviour:
  stashing the encoder change confirms the test catches the regression
  (`InvalidData: SerializingError ...`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@pront pront added the meta: awaiting author Pull requests that are awaiting their author. label Jun 17, 2026
oganel and others added 3 commits June 17, 2026 16:01
…budget

Codex flagged a concern that vector-sink traffic might need a tighter
nesting budget than the disk-buffer / native-codec path because
PushEventsRequest wraps EventWrapper inside a "repeated EventWrapper
events = 1" field, supposedly adding one extra recursion frame.

Investigation shows the claim does not hold. Both decode paths consume
exactly four `enter_recursion` calls before reaching the user-controlled
Value root:

  EventArray   path: EventArray(top) -> LogArray(oneof message)
                  -> Log(repeated)   -> map_entry(Log.fields map)
                  -> Value (recurse_count = 96)

  PushEvents.. path: PushEventsRequest(top) -> EventWrapper(repeated)
                  -> Log(oneof message of EventWrapper.event)
                  -> map_entry(Log.fields map)
                  -> Value (recurse_count = 96)

The two paths arrange the "oneof message" and "repeated message" layers
in the opposite order, but each layer costs one frame regardless of
order, so the totals match. The existing `MAX_VALUE_NESTING_FRAMES` and
`MAX_METADATA_VALUE_NESTING_FRAMES` budgets calibrated for EventArray
apply unchanged to PushEventsRequest, and `event_exceeds_max_nesting_cost`
needs no adjustment for the vector sink.

Adds three regression tests pinning the boundary so a future protocol
change that adds a wrapper (and would shift the boundary) gets caught:

- push_events_request_decode_at_value_budget: an event with cost 99
  (33 effective object levels) roundtrips cleanly.
- push_events_request_decode_one_past_value_budget_fails: cost 102
  (34 levels) fails decode, proving the boundary is tight.
- push_events_request_decode_at_metadata_budget: metadata at cost 96
  (32 levels) roundtrips.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Codex flagged that the previous filter-drops accounting was dead code
in production: `TopologyBuilder::build` sees
`DiskV2Buffer::provides_instrumentation() == true` and deliberately
skips `sender.with_usage_instrumentation(...)` for the disk-v2 stage.
The handle that owns disk-v2 accounting lives on the ledger, not the
BufferSender, so the dropped instrumentation bump introduced in
3598122 never fired in real deployments. Filter rejections emitted
`component_discarded_events_total` but never showed up under
`buffer_size_events` / `buffer_size_bytes` — operators saw a queue that
appeared full of phantom events that would never be consumed.

Route filter drops through the ledger directly:

- `Ledger::track_dropped(event_count, byte_size)` increments both
  `received` and the unintentional `dropped` counter on the usage handle
  (and leaves `total_buffer_size` alone, since the events never reached
  disk). The double bump keeps `buffer_size = received - sent - dropped`
  consistent without requiring callers to plumb anything extra.
- `BufferWriter::track_dropped` exposes that as a `pub(crate)` wrapper
  so `SenderAdapter` can call it without grabbing the ledger directly.
- `SenderAdapter::DiskV2::send` and `try_send` now call
  `writer.track_dropped(...)` themselves when filter_unencodable drops
  sub-items, and return `Result<()>` / `Result<Option<T>>` again. The
  `FilterDrops` and `TrySendOutcome` types added in 3598122 are
  removed -- they only existed to plumb counts through BufferSender,
  which is unreachable for the only backend that filters.
- `BufferSender::send` reverts to its pre-FilterDrops shape (the
  `was_dropped` + `item_sizing` pattern), with a docstring noting that
  filter drops are now reported by the backend's own usage handle.

The disk-v2 filter-metrics test drops its manual
`sender.with_usage_instrumentation(...)` call, mirroring the production
wiring (`TopologyBuilder` does not attach instrumentation to disk-v2
senders). The test now reads from the ledger's usage handle directly --
the canonical place where disk-v2 accounting lives -- and adds a
`sender.flush()` between sends so `track_write` actually reaches the
ledger (it only fires when buffered writes are flushed to disk).

A regression of `SenderAdapter::DiskV2::{send,try_send}` is caught at
compile time rather than test time: `track_dropped` has exactly one
caller, so reverting either fix turns it into dead code and trips
`#![deny(warnings)]`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The TCP and Unix socket sinks built `EncodedEvent::new(Bytes::new(), ...)`
on encoder failure with default (fresh) finalizers, dropping the original
finalizers without any status update. That fall-through path turned both
hard encoder errors and the native serializer's new "Ok with empty
bytes" silent-drop signal into source-side acks that looked like
successful deliveries -- so with end-to-end acknowledgements,
over-budget events on a native-encoded TCP/Unix pipeline could be
checkpointed as delivered when the encoder actually dropped them.

Replace the if-ok shape with an explicit match on three outcomes:

- Encoder Err: framework already logged + counted +
  `ComponentEventsDropped`. Set `EventStatus::Errored` on the
  finalizers before they go out of scope so source acks reflect the
  drop. Return a stub `EncodedEvent` to keep the existing pipeline
  shape.
- Encoder Ok with empty bytes: the encoder accepted the event but
  produced no output (e.g. native codec rejecting an over-budget
  event). Set `EventStatus::Rejected` and return a stub. The encoder
  has already emitted `ComponentEventsDropped` with the nesting
  reason; nothing else needs to.
- Encoder Ok with bytes: existing behaviour, finalizers attached to
  the live `EncodedEvent`.

The Err branch was already buggy before the native silent-drop change
landed -- finalizers fell to the default delivered status whenever any
codec returned Err. This commit fixes both that pre-existing bug and
the new native-codec silent-drop signal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot removed the meta: awaiting author Pull requests that are awaiting their author. label Jun 17, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 13999c5f58

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread lib/codecs/src/encoding/encoder.rs Outdated
Comment thread lib/codecs/src/encoding/format/native.rs Outdated
Comment thread src/sinks/util/tcp.rs Outdated
Comment thread lib/codecs/src/encoding/format/native.rs Outdated
oganel and others added 2 commits June 18, 2026 11:45
The framer-skip introduced in 8a22d86 treated *any* empty serializer
payload as a dropped-event sentinel, but text-based serializers
legitimately produce zero bytes for events with no message field --
including `TextSerializer` for logs with no `message` and any `Trace`
event, and `RawMessageSerializer` for the same shape. Suppressing
framing for those swallowed the per-event delimiter NDJSON consumers
expect, and downstream sinks that key off `bytes.is_empty()`
finalized the (otherwise valid) events as Rejected, losing them.

Gate the framer-skip on the serializer:

- Add `Serializer::empty_output_means_dropped()` -- only the native
  protobuf serializer claims `true` today. Valid native events always
  produce at least the protobuf wrapper bytes, so an empty payload
  there can only mean the encoder refused the event; for everything
  else, zero bytes is part of the legitimate output domain.
- `Encoder<Framer>::encode` skips framing only when the serializer
  opts in via that method. Text/raw-message empty payloads now get
  their delimiter back.

Adds `test_encode_batch_text_empty_message_keeps_newline_framing` to
pin the behaviour: a batch of [valid, empty, valid] through
NewlineDelimited + Text now emits `"ok\n\nok\n"` rather than `"okok\n"`
(the bug shape). Stashing the encoder change confirms the test catches
the regression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ode-owned paths

Reassessing the branch holistically: the protobuf nesting limit is a
*decode*-side constraint (prost caps recursion at 100 frames on decode;
encode has no limit), so a deeply-nested event always serializes fine and
only fails when something later decodes it. The branch had grown an
encoder-side "silently drop over-budget events" guard in the native
serializer, which spawned a cascade of follow-on fixes (empty-output
framing sentinel, console/tcp/unix finalization, batched-encoder
handling) and an unresolved 16-sink finalizer problem in the
detach-then-encode request-builder pattern.

That guard was the wrong layer and, it turns out, redundant: every Vector
decode consumer of the native format already handles an undecodable frame
gracefully — `decoding::Error::ParsingError` reports `can_continue() ==
true`, so a native source drops the bad frame with telemetry and keeps
going. No corruption, no loop. The encoder guard was defending a case the
receiver already handles.

The two cases that genuinely need encode-side prevention are the ones
where Vector owns the decode and graceful handling is impossible, and
both already filter cleanly while finalizers are attached:

- disk buffer (`Bufferable::filter_unencodable` at the buffer sender,
  ledger drop accounting) — a written-but-unreadable record permanently
  poisons the buffer.
- `vector` sink (`event_exceeds_max_nesting_cost` filter in `run_inner`)
  — avoids the tonic-decode-error → gRPC Internal → infinite-retry loop.

So this revert removes the native-encoder guard and its entire cascade,
restoring to the branch base:

- lib/codecs: native serializer (plain encode), encoder framing, the
  `Serializer::empty_output_means_dropped` helper, and the native-codec
  nesting tests.
- src/sinks/console, src/sinks/util/{tcp,unix}: back to master.
- src/sinks/util/encoding.rs: cascade tests removed.

Kept (the coherent core — Vector-owned decode paths, finalizers attached,
tested): the nesting cost machinery, `Bufferable::filter_unencodable` +
ledger `track_dropped` + disk-full overflow handling, the vector-sink
filter, and the PushEventsRequest boundary tests. The 16-sink question
no longer exists.

Changelog updated to drop the native-codec scope claim.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b172e6cb18

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread lib/vector-buffers/src/topology/channel/sender.rs
…y_send

  In WhenFull::Overflow with a disk-v2 base, over-budget sub-items are
  filtered and dropped at the disk stage past the is_buffer_full() check
  rather than routed to a (possibly non-protobuf) overflow stage. This
  surfaces two ways: a partially-over-budget item whose remainder is then
  rejected for fullness overflows minus the dropped events, and a fully
  over-budget item is dropped before any capacity check. Routing
  unencodable items by WhenFull is a BufferSender-level policy decision
  while filtering lives in the backend; reconciling them is deferred.
  The window is narrow and atypical (disk in Overflow mode, non-protobuf
  overflow target, >32-level nesting, a downstream egress that could
  deliver it); in other topologies these events are dropped a stage later
  regardless.

  Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ganelo

ganelo commented Jun 22, 2026

Copy link
Copy Markdown
Contributor Author

@pront - I think this is ready for re-review when you have a moment!

@pront pront added the type: bug A bug fix label Jul 13, 2026
openshift-merge-bot Bot pushed a commit to ViaQ/vector that referenced this pull request Aug 7, 2026
…uption

apply vectordotdev#25417

Signed-off-by: Vitalii Parfonov <vparfono@redhat.com>
@EricaJ6

EricaJ6 commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

We hit this last week as a production outage and I want to add evidence in support of merging. It happened when an application logged a stack trace with several nested causedBy clauses; the deep nesting broke the disk buffer and Vector was never able to recover.

Still present in 0.57.0. Reproduced in a clean single process, no Kubernetes and no interruption of any kind.

vector.yaml:

data_dir: /var/lib/vector
sources:
  stdin:
    type: stdin
transforms:
  parse:
    type: remap
    inputs: [stdin]
    source: |
      . = parse_json!(string!(.message), 128)
sinks:
  poison:
    type: http
    inputs: [parse]
    uri: http://127.0.0.1:9/        # discard port, never drains
    encoding:
      codec: json
    healthcheck:
      enabled: false
    buffer:
      type: disk
      max_size: 268435500
      when_full: drop_newest

Feed a few hundred copies of one line on stdin, holding stdin open for about 15 seconds so the buffer flushes and the reader reads the record back.

Fails, 34 object levels, 102 frames:

{"d":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"leaf":"x"}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}

Clean control, 33 object levels, 99 frames:

{"d":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"leaf":"x"}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}

Or generate any depth:

python3 -c 'o="{\"leaf\":\"x\"}"; [o:=f"{{\"a\":{o}}}" for _ in range(32)]; print("{\"d\":"+o+"}")'

Pass 1, fresh buffer directory:

ERROR sink{component_id=poison component_type=http}: vector_buffers::internal_events:
Error encountered during buffer read. error=failed to decoded record:
InvalidProtobufPayload error_code="decode_failed" error_type="reader_failed" stage="processing"

Pass 2, restart against the same buffer directory:

ERROR vector::topology::builder: Configuration error. error=Sink "poison": error occurred
when building buffer: failed to build individual stage 0: failed to seek to position where
writer left off: failed to validate the last written record: failed to decoded record:
InvalidProtobufPayload

Vector will not start. At 33 levels both passes are clean.

Our measured boundary matches MAX_VALUE_NESTING_FRAMES = 99 exactly. We bisected on 0.57.0 and costed frames from event.proto at 3 per object level (Value, ValueMap, map entry) and 2 per array level (Value, ValueArray):

payload clean fails
all objects 33 levels, 99 frames 34 levels, 102 frames
1 object plus N arrays, {"d": [[[...]]]} N=48, 3 + 2*48 = 99 N=49, 3 + 2*49 = 101

Two structurally different shapes bracket prost's 100 limit to a single frame, and 99 is the last value that decodes. We arrived at that number independently of this PR, so the constant chosen here is exactly right.

Why this is more severe than a delivery failure. error_code="decode_failed" is ReaderError::Decode, which disk_v2/reader.rs excludes from is_bad_read(). There is no recovery path at buffer initialization, so the failure is fatal on every start. When the buffer sits on ephemeral storage shared across container restarts, the poisoned record survives every restart and the process crash loops indefinitely. Recovery means destroying the buffer directory and losing all buffered data.

Two knock-on effects we saw in production:

  • A poisoned sink cannot drain, so it hangs shutdown until the grace period expires and is then killed. Out of roughly fifty components, only the poisoned sinks failed to drain, which made the shutdown timeout look like a capacity problem when it was a symptom.
  • Once a sink reaches its size boundary and has to hand a record back to the caller, it decodes what it just encoded and fails with writer entered inconsistent state: failed to decode record immediately after encoding it, which is classified unrecoverable and takes down the whole topology. That is the symptom in Hitting "failed to decode record immediately after encoding it" frequently #20212, where the debug output shows a data-file rollover immediately beforehand, so I believe Hitting "failed to decode record immediately after encoding it" frequently #20212 is depth rather than the oversized record suspected there.

These look like one bug. #19315 is the root cause, #20212 the writer-side symptom, #25642 the startup symptom, and #25691 makes the startup case recoverable without stopping the poison being written. This PR fixes the cause.

Also worth noting ViaQ have already cherry-picked this PR into their downstream fork (ViaQ#286, merged 7 Aug), so it has a distributor running it.

@pront

pront commented Aug 12, 2026

Copy link
Copy Markdown
Member

@codex fresh review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 👍

Reviewed commit: f57988f6ac

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@pront pront left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before final approval, please update the branch from master, the are merge conflicts.

Please also make overflow behavior state-independent: when a disk stage uses WhenFull::Overflow, an item that the disk cannot safely encode should be passed intact to the overflow stage, regardless of current disk occupancy.

Today, with a 100 MB disk overflowing to memory, the same over-nested event is rejected at 99 MB but reaches memory at 100 MB. Please fix that inconsistency and add a regression test covering both the near-full and already-full cases.

@EricaJ6 EricaJ6 mentioned this pull request Aug 12, 2026
2 of 4 tasks
@EricaJ6

EricaJ6 commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

@ganelo we hit this as a production outage last week, so rather than let the branch sit we have opened #26099 against vectordotdev/vector building directly on this one: your commits, with the master merge on top, plus the state-independent overflow behaviour and the two regression tests from @pront's review. Authorship is preserved in the commit history and the changelog fragment, and we have not touched the original design beyond that feedback. Thanks for the original work, the diagnosis in it saved us a great deal of time.

@pront

pront commented Aug 17, 2026

Copy link
Copy Markdown
Member

Final version: #26099

@pront pront closed this Aug 17, 2026
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 17, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

domain: buffers Anything related to Vector's memory/disk buffers domain: core Anything related to core crates i.e. vector-core, core-common, etc domain: sinks Anything related to the Vector's sinks type: bug A bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Protobuf default recursion depth limit of 32 - fails disk buffering, codec, and Vector source/sink

5 participants