Skip to content

Og/continue protobuf nesting depth limit - #26099

Merged
pront merged 63 commits into
vectordotdev:masterfrom
EricaJ6:og/continue-protobuf-nesting-depth-limit
Aug 17, 2026
Merged

pront merged 63 commits into
vectordotdev:masterfrom
EricaJ6:og/continue-protobuf-nesting-depth-limit

Conversation

@EricaJ6

@EricaJ6 EricaJ6 commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Note: this continues #25417, which itself continued #25053. That branch had gone stale against
master and had two outstanding review items. Since we hit this bug as a production outage we have
picked the work up rather than let it sit. All original authorship is preserved in the commit
history and in the changelog fragment; thanks to @connoryy and @ganelo for the original design,
which we have not altered beyond the review feedback below.

Summary

Log events nested beyond prost's decode recursion limit can be encoded but never decoded. Vector
writes such a record into a disk buffer successfully, and from that point the sink cannot read it
back and the process refuses to start at all. Recovery requires deleting the buffer directory and
losing everything queued in it.

This PR carries #25417 forward with two changes:

  1. Merged master. The branch was conflicted. Resolving it surfaced three compile breaks, all
    from master moving underneath the branch: Bufferable gained a GroupedFinalizable supertrait
    that the branch's test fixture did not satisfy; master's TryWriteOutcome<T> refactor replaced
    the Result<()> and Option<T> returns the branch was written against; and the blanket
    impl<T> Bufferable for T conflicted with the explicit impls this branch introduces.

  2. Made overflow behaviour state-independent, per review feedback. Previously an over-nested
    item was pruned while the disk had room but forwarded whole once the disk reported full, so with
    a 100 MB disk overflowing to memory the same event took a different path at 99 MB than at 100 MB.

The fix moves the routing decision out of the disk backend and into BufferSender, where the
WhenFull policy already lives:

  • Bufferable::is_fully_encodable(&self) -> bool is a new non-consuming counterpart to
    filter_unencodable, defaulting to true. It lets policy be decided before any filtering.
    EventArray implements it as check_event_array_nesting_cost(self).is_ok(), reusing the existing
    encode-time gate so the routing decision and the eventual encode cannot disagree.
  • BufferSender::send under WhenFull::Overflow hands an item failing that check to the overflow
    stage intact, before the base stage is consulted at all. The overflow stage may have no
    wire-format constraint (an in-memory stage does not), so pruning sub-items on its behalf would
    discard events it could accept.
  • SenderAdapter::try_send loses its is_buffer_full() short-circuit and the KNOWN LIMITATION
    comment documenting the deferral. Filtering there is now unconditional, so an item is treated
    identically at 99% full and 100% full.
  • BufferWriter::is_buffer_full returns to private visibility, since nothing outside the writer
    needs it now.

One behaviour change beyond the review request, called out because it shifts metrics: removing the
short-circuit also affects WhenFull::DropNewest at capacity. Previously the item was returned
Full unfiltered and counted as an intentional drop. Now it is filtered first, so over-budget
events are counted as unintentional drops via track_dropped and only the remainder is reported
Full. We believe this is more correct, and it is required for state-independence, but it is a
visible change.

References

Vector configuration

Minimal configuration that reproduces the bug on 0.57.0, with no Kubernetes and no interruption of
any kind:

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 34-object-level line on stdin, holding stdin open for about 15
seconds so the buffer flushes and the reader reads the record back:

{"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"}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}

Before this change, pass one logs:

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"

and restarting against the same buffer directory refuses to come up:

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

At 33 object levels both passes are clean.

How did you test this PR?

Unit tests. Two new regression tests in
lib/vector-buffers/src/variants/disk_v2/tests/filter_metrics.rs cover the near-full and
already-full cases from the review, asserting the unencodable item reaches the overflow stage with
its original event count rather than a pruned one. The pre-existing filter-accounting test in the
same module still passes, which matters because both share the FilterableBatch fixture.

test unencodable_item_overflows_intact_when_base_is_full ... ok
test unencodable_item_overflows_intact_when_base_has_room ... ok
test filter_drops_are_reported_as_unintentional_buffer_drops ... ok

Empirically validating the frame budget. We bisected the failure depth on 0.57.0 in a clean
single process and costed frames from event.proto at 3 per object level and 2 per array level:

payload decodes 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 limit of 100 to a single frame, and 99 is the
last value that decodes. That was measured independently of this PR and matches
MAX_VALUE_NESTING_FRAMES = 99 exactly, which we take as good corroboration of the constant chosen
here.

Production context. We hit this on a multi-tenant aggregator when an application logged a stack
trace with several nested causedBy clauses. The buffer sat on ephemeral storage shared across
container restarts, so the poisoned record survived every restart and the process crash looped
indefinitely until the volume was recreated. Two knock-on effects worth noting: 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); and once a sink reaches its size
boundary and has to hand a record back to the caller, it fails with the writer entered inconsistent state error from #20212.

Open question for reviewers. The already-full test uses an in-memory base stage rather than a
disk stage, because reliably driving disk-v2's is_buffer_full() to true under the minimum-size
config needs careful record and buffer size tuning, as the prior code comment in this module noted.
We think the substitution is sound for this property, since the unencodable-item decision is taken
in BufferSender before any backend is consulted, so the base stage's type and occupancy are both
immaterial, and that is exactly the invariant being asserted. Happy to invest in a disk-based
variant if you would prefer it.

Is this a breaking change?

  • Yes
  • No

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.

The existing changelog.d/protobuf_nesting_depth_limit.fix.md fragment has been extended to cover
the overflow behaviour.

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: 20c5cbd4b8

ℹ️ 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/vector-core/src/event/ser.rs
@pront

pront commented Aug 13, 2026

Copy link
Copy Markdown
Member

@pront thanks for the quick review on #25417. We hit this as a production outage last week so we have picked the branch up: master is merged and the conflicts resolved, and we have made the overflow behavior state-independent as you asked, moving the routing decision out of the disk backend into BufferSender where the WhenFull policy already lives, so an unencodable item goes to the overflow stage intact at any occupancy. Regression tests added for both the near-full and already-full cases. One thing flagged in the description for your call: the already-full test uses an in-memory base stage rather than disk, since driving disk-v2's is_buffer_full() to true reliably needs careful size tuning, and happy to build the disk-based variant if you would prefer it.

@EricaJ6 Thanks for picking this up!

The in-memory substitution actually exposed an issue: the base stage’s type is material here, BufferSender applies is_fully_encodable() to every overflow stage.

base: memory, currently empty
overflow: disk
item: too deeply nested for protobuf

Memory could store the item safely, so it should remain there. Instead, the current code skips memory immediately and sends it to disk, where it is filtered or dropped.

Comment thread changelog.d/protobuf_nesting_depth_limit.fix.md Outdated
Co-authored-by: Pavlos Rontidis <pavlos.rontidis@gmail.com>
@pront

pront commented Aug 13, 2026

Copy link
Copy Markdown
Member

@EricaJ6 some CI checks are failing, please see list of checks to run here: https://github.com/vectordotdev/vector/blob/master/CONTRIBUTING.md?plain=1#L120

@EricaJ6

EricaJ6 commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

@pront thanks for the quick review on #25417. We hit this as a production outage last week so we have picked the branch up: master is merged and the conflicts resolved, and we have made the overflow behavior state-independent as you asked, moving the routing decision out of the disk backend into BufferSender where the WhenFull policy already lives, so an unencodable item goes to the overflow stage intact at any occupancy. Regression tests added for both the near-full and already-full cases. One thing flagged in the description for your call: the already-full test uses an in-memory base stage rather than disk, since driving disk-v2's is_buffer_full() to true reliably needs careful size tuning, and happy to build the disk-based variant if you would prefer it.

@EricaJ6 Thanks for picking this up!

The in-memory substitution actually exposed an issue: the base stage’s type is material here, BufferSender applies is_fully_encodable() to every overflow stage.

base: memory, currently empty
overflow: disk
item: too deeply nested for protobuf

Memory could store the item safely, so it should remain there. Instead, the current code skips memory immediately and sends it to disk, where it is filtered or dropped.

@pront good catch, you are right. The check needs to be asked of the base stage rather than applied to every topology. Working on the fix now, gating the diversion on whether the base stage actually has a wire-format constraint so a memory base keeps the item instead of pushing it to disk. Adding a regression test for the memory -> disk case you described, and sorting out the CI failures at the same time.

@EricaJ6

EricaJ6 commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

@pront fixed, thanks for catching that. The diversion is now gated on SenderAdapter::requires_encodable_items(), following the provides_instrumentation() precedent, so the check is asked of the specific stage rather than assumed of every topology. A memory base now keeps the item instead of pushing it to a disk overflow that has to drop it. Added a regression test for exactly the memory -> disk case you described, since none of my existing tests used a memory base, which is why the gap got through. Ran locally on the pinned 1.95 toolchain, all passing: make check-clippy (full workspace, --all-targets --all-features), make test (3409 tests, 13 skipped), make check-changelog-fragments, and make fmt.

@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: 9e165b150e

ℹ️ 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-core/src/event/ser.rs Outdated
Comment thread lib/vector-buffers/src/topology/channel/sender.rs
@EricaJ6

EricaJ6 commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

All checks have passed!
passed

@pront

pront commented Aug 17, 2026

Copy link
Copy Markdown
Member

All checks have passed! ..

Hello, please see #26099 (comment).

Also worth taking a look at this: https://github.com/vectordotdev/vector/blob/master/AI_POLICY.md#ai-review-comments.

@EricaJ6

EricaJ6 commented Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

All checks have passed! ..

Hello, please see #26099 (comment).

Also worth taking a look at this: https://github.com/vectordotdev/vector/blob/master/AI_POLICY.md#ai-review-comments.

Hi @pront, all should be resolved, refer to this.

This PR is a major blocker, so we would appreciate an approval. Once merged, will it make into the August release or is there a different timeline you can share?

@pront

pront commented Aug 17, 2026

Copy link
Copy Markdown
Member

@codex review

Once merged, will it make into the August release or is there a different timeline you can share?

@EricaJ6 you can refer to https://calendar.vector.dev/. If a PR is merged before the next release date then it will be included in the release. If this PR isn't merged by then, then I would recommend forking and creating a custom release to unblock.

@chatgpt-codex-connector

Copy link
Copy Markdown

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

Reviewed commit: d40de33c33

ℹ️ 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".

Comment thread changelog.d/protobuf_nesting_depth_limit.fix.md Outdated
@pront
pront enabled auto-merge August 17, 2026 18:50
@EricaJ6

EricaJ6 commented Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

Once merged, will it make into the August release or is there a different timeline you can share?

@EricaJ6 you can refer to https://calendar.vector.dev/. If a PR is merged before the next release date then it will be included in the release. If this PR isn't merged by then, then I would recommend forking and creating a custom release to unblock.

Thank you @pront for sharing the release calendar. Looks like this PR will be merged before next week's August 25 Vector release.

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@pront
pront added this pull request to the merge queue Aug 17, 2026
Merged via the queue into vectordotdev:master with commit 2273959 Aug 17, 2026
59 checks passed
@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: core Anything related to core crates i.e. vector-core, core-common, etc domain: sinks Anything related to the Vector's sinks

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