Repository navigation
Conversation
…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.
…oryy/vector into spawn-src-94650
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.
There was a problem hiding this comment.
💡 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".
@EricaJ6 Thanks for picking this up! The in-memory substitution actually exposed an issue: the base stage’s type is material here, 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. |
Co-authored-by: Pavlos Rontidis <pavlos.rontidis@gmail.com>
|
@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 |
@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. |
|
@pront fixed, thanks for catching that. The diversion is now gated on |
There was a problem hiding this comment.
💡 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".
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? |
|
@codex review
@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. |
|
Codex Review: Didn't find any major issues. 🎉 Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
Thank you @pront for sharing the release calendar. Looks like this PR will be merged before next week's August 25 Vector release. |
|
To use Codex here, create a Codex account and connect to github. |

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:
Merged master. The branch was conflicted. Resolving it surfaced three compile breaks, all
from master moving underneath the branch:
Bufferablegained aGroupedFinalizablesupertraitthat the branch's test fixture did not satisfy; master's
TryWriteOutcome<T>refactor replacedthe
Result<()>andOption<T>returns the branch was written against; and the blanketimpl<T> Bufferable for Tconflicted with the explicit impls this branch introduces.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 theWhenFullpolicy already lives:Bufferable::is_fully_encodable(&self) -> boolis a new non-consuming counterpart tofilter_unencodable, defaulting totrue. It lets policy be decided before any filtering.EventArrayimplements it ascheck_event_array_nesting_cost(self).is_ok(), reusing the existingencode-time gate so the routing decision and the eventual encode cannot disagree.
BufferSender::sendunderWhenFull::Overflowhands an item failing that check to the overflowstage 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_sendloses itsis_buffer_full()short-circuit and theKNOWN LIMITATIONcomment documenting the deferral. Filtering there is now unconditional, so an item is treated
identically at 99% full and 100% full.
BufferWriter::is_buffer_fullreturns to private visibility, since nothing outside the writerneeds it now.
One behaviour change beyond the review request, called out because it shifts metrics: removing the
short-circuit also affects
WhenFull::DropNewestat capacity. Previously the item was returnedFullunfiltered and counted as an intentional drop. Now it is filtered first, so over-budgetevents are counted as unintentional drops via
track_droppedand only the remainder is reportedFull. We believe this is more correct, and it is required for state-independence, but it is avisible change.
References
writer entered inconsistent state: failed to decode record immediately after encoding it, andits debug output shows a data-file rollover immediately beforehand, which is the boundary where
the writer must decode a record back in order to return it to the caller. We believe that report
is nesting depth rather than the oversized record suspected in the thread.
Vector configuration
Minimal configuration that reproduces the bug on 0.57.0, with no Kubernetes and no interruption of
any kind:
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:
Before this change, pass one logs:
and restarting against the same buffer directory refuses to come up:
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.rscover the near-full andalready-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
FilterableBatchfixture.Empirically validating the frame budget. We bisected the failure depth on 0.57.0 in a clean
single process and costed frames from
event.protoat 3 per object level and 2 per array level:{"d": [[[...]]]}3 + 2*48 = 993 + 2*49 = 101Two 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 = 99exactly, which we take as good corroboration of the constant chosenhere.
Production context. We hit this on a multi-tenant aggregator when an application logged a stack
trace with several nested
causedByclauses. The buffer sat on ephemeral storage shared acrosscontainer 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 stateerror 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()totrueunder the minimum-sizeconfig 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
BufferSenderbefore any backend is consulted, so the base stage's type and occupancy are bothimmaterial, 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?
Does this PR include user facing changes?
no-changeloglabel to this PR.The existing
changelog.d/protobuf_nesting_depth_limit.fix.mdfragment has been extended to coverthe overflow behaviour.