From f469b3f87fa017c3ed7fa44bc6e8b3e66721386b Mon Sep 17 00:00:00 2001 From: Connor Yang Date: Thu, 26 Mar 2026 09:38:04 -0400 Subject: [PATCH 01/49] fix(buffers): add nesting depth limit to prevent protobuf decode corruption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../protobuf_nesting_depth_limit.fix.md | 3 + lib/codecs/src/encoding/format/native.rs | 8 +- lib/codecs/tests/native.rs | 59 ++++++ lib/vector-core/src/event/mod.rs | 1 + lib/vector-core/src/event/ser.rs | 93 +++++++++ .../src/event/test/serialization.rs | 188 ++++++++++++++++++ src/sinks/vector/sink.rs | 13 ++ 7 files changed, 364 insertions(+), 1 deletion(-) create mode 100644 changelog.d/protobuf_nesting_depth_limit.fix.md diff --git a/changelog.d/protobuf_nesting_depth_limit.fix.md b/changelog.d/protobuf_nesting_depth_limit.fix.md new file mode 100644 index 0000000000000..b419e3947bebf --- /dev/null +++ b/changelog.d/protobuf_nesting_depth_limit.fix.md @@ -0,0 +1,3 @@ +Fixed disk buffer corruption caused by deeply nested events exceeding prost's protobuf recursion limit during decode. Events with nesting depth greater than 33 are now rejected at encode time across disk buffers, the native codec, and the `vector` sink's gRPC path, preventing unrecoverable buffer corruption. + +authors: connoryy diff --git a/lib/codecs/src/encoding/format/native.rs b/lib/codecs/src/encoding/format/native.rs index c3af9544b766e..0c328c48a4187 100644 --- a/lib/codecs/src/encoding/format/native.rs +++ b/lib/codecs/src/encoding/format/native.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; use tokio_util::codec::Encoder; use vector_core::{ config::DataType, - event::{Event, EventArray, proto}, + event::{Event, EventArray, MAX_NESTING_DEPTH, event_exceeds_max_nesting_depth, proto}, schema, }; @@ -37,6 +37,12 @@ impl Encoder for NativeSerializer { type Error = vector_common::Error; fn encode(&mut self, event: Event, buffer: &mut BytesMut) -> Result<(), Self::Error> { + if event_exceeds_max_nesting_depth(&event) { + return Err(format!( + "event nesting depth exceeds maximum of {MAX_NESTING_DEPTH} for protobuf encoding" + ) + .into()); + } let array = EventArray::from(event); let proto = proto::EventArray::from(array); proto.encode(buffer)?; diff --git a/lib/codecs/tests/native.rs b/lib/codecs/tests/native.rs index d0c6329c35090..704bacb3146d2 100644 --- a/lib/codecs/tests/native.rs +++ b/lib/codecs/tests/native.rs @@ -322,3 +322,62 @@ fn rebuild_fixtures(proto: &str, deserializer: &dyn Deserializer, serializer: &m out.flush().expect("Could not write rebuilt data"); } } + +// --------------------------------------------------------------------------- +// Nesting depth guard integration tests for the native codec +// --------------------------------------------------------------------------- + +use tokio_util::codec::Encoder; +use vector_core::event::{LogEvent, ObjectMap, Value}; + +fn create_nested_log_event(wrapping_levels: usize) -> LogEvent { + let mut value = Value::from("innermost"); + for _ in 0..wrapping_levels { + let mut map = ObjectMap::new(); + map.insert("nested".into(), value); + value = Value::Object(map); + } + let mut event = LogEvent::default(); + event.insert("data", value); + event +} + +#[test] +fn native_codec_rejects_overly_nested_event() { + // 33 wrapping levels + "data" key = depth 34 > MAX_NESTING_DEPTH (33) + let event = create_nested_log_event(33); + let event = Event::Log(event); + + let mut serializer = NativeSerializerConfig.build(); + let mut buffer = BytesMut::with_capacity(8192); + + let result = serializer.encode(event, &mut buffer); + assert!( + result.is_err(), + "native codec should reject events exceeding MAX_NESTING_DEPTH" + ); +} + +#[test] +fn native_codec_roundtrip_max_depth_event() { + // 32 wrapping levels + "data" key = depth 33 = MAX_NESTING_DEPTH + let event = create_nested_log_event(32); + let original_data = event.value().get("data").cloned(); + let event = Event::Log(event); + + let mut serializer = NativeSerializerConfig.build(); + let mut buffer = BytesMut::with_capacity(8192); + + serializer + .encode(event, &mut buffer) + .expect("native codec should accept events at MAX_NESTING_DEPTH"); + + let deserializer = NativeDeserializerConfig.build(); + let decoded_events = deserializer + .parse(buffer.freeze(), LogNamespace::Legacy) + .expect("native codec should decode events at MAX_NESTING_DEPTH"); + + assert_eq!(decoded_events.len(), 1); + let decoded_log = decoded_events.into_iter().next().unwrap().into_log(); + assert_eq!(original_data.as_ref(), decoded_log.value().get("data"),); +} diff --git a/lib/vector-core/src/event/mod.rs b/lib/vector-core/src/event/mod.rs index 9d72301e85804..7f172870115f0 100644 --- a/lib/vector-core/src/event/mod.rs +++ b/lib/vector-core/src/event/mod.rs @@ -35,6 +35,7 @@ pub mod metric; pub mod proto; mod r#ref; mod ser; +pub use ser::{MAX_NESTING_DEPTH, event_exceeds_max_nesting_depth}; #[cfg(test)] mod test; mod trace; diff --git a/lib/vector-core/src/event/ser.rs b/lib/vector-core/src/event/ser.rs index a456ccf69368f..960c724ff34f9 100644 --- a/lib/vector-core/src/event/ser.rs +++ b/lib/vector-core/src/event/ser.rs @@ -3,13 +3,101 @@ use enumflags2::{BitFlags, FromBitsError, bitflags}; use prost::Message; use snafu::Snafu; use vector_buffers::encoding::{AsMetadata, Encodable}; +use vrl::value::Value; use super::{Event, EventArray, proto}; +/// Maximum nesting depth allowed for events before protobuf encoding. +/// +/// 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 (map entry -> key/value -> nested +/// message). Empirically, prost decode fails at Value tree depth 34 and succeeds at depth 33. +/// +/// We set the limit to 33 — the highest safe depth — to prevent corruption across all protobuf +/// encoding paths (disk buffers, gRPC, native codec) while preserving backward compatibility +/// with JSON payloads up to 32 levels deep (which become depth 33 after `parse_json` places them +/// inside a `LogEvent` field). +pub const MAX_NESTING_DEPTH: usize = 33; + +/// Check the nesting depth of a `Value`, returning `Err(actual_depth)` if it exceeds `max_depth`. +/// +/// This performs an early-exit traversal: it returns as soon as any branch exceeds the limit, +/// avoiding unnecessary work on well-formed events. +/// +/// # Errors +/// +/// Returns `Err(actual_depth)` if any branch of the value tree exceeds `max_depth`. +pub(crate) fn check_value_depth( + value: &Value, + current_depth: usize, + max_depth: usize, +) -> Result<(), usize> { + if current_depth > max_depth { + return Err(current_depth); + } + match value { + Value::Object(map) => { + for v in map.values() { + check_value_depth(v, current_depth + 1, max_depth)?; + } + } + Value::Array(arr) => { + for v in arr { + check_value_depth(v, current_depth + 1, max_depth)?; + } + } + _ => {} + } + Ok(()) +} + +/// Returns `true` if the event's nesting depth exceeds `MAX_NESTING_DEPTH`. +/// +/// Metrics have a fixed structure and cannot be deeply nested, so they always return `false`. +pub fn event_exceeds_max_nesting_depth(event: &Event) -> bool { + let value = match event { + Event::Log(log) => log.value(), + Event::Trace(trace) => trace.value(), + Event::Metric(_) => return false, + }; + check_value_depth(value, 0, MAX_NESTING_DEPTH).is_err() +} + +/// Checks all events in an `EventArray` for nesting depth violations. +/// +/// Returns `Err(EncodeError::NestingTooDeep)` if any log or trace event exceeds +/// `MAX_NESTING_DEPTH`. Metrics are skipped (fixed structure, no deep nesting possible). +fn check_event_array_nesting_depth(events: &EventArray) -> Result<(), EncodeError> { + let check = |value: &Value| { + check_value_depth(value, 0, MAX_NESTING_DEPTH).map_err(|depth| { + EncodeError::NestingTooDeep { + depth, + max_depth: MAX_NESTING_DEPTH, + } + }) + }; + match events { + EventArray::Logs(logs) => { + for log in logs { + check(log.value())?; + } + } + EventArray::Traces(traces) => { + for trace in traces { + check(trace.value())?; + } + } + EventArray::Metrics(_) => {} + } + Ok(()) +} + #[derive(Debug, Snafu)] pub enum EncodeError { #[snafu(display("the provided buffer was too small to fully encode this item"))] BufferTooSmall, + #[snafu(display("event nesting depth {depth} exceeds maximum of {max_depth}"))] + NestingTooDeep { depth: usize, max_depth: usize }, } #[derive(Debug, Snafu)] @@ -95,6 +183,11 @@ impl Encodable for EventArray { where B: BufMut, { + // Check nesting depth before encoding. Deeply nested events encode + // successfully but fail to decode due to prost's recursion limit, + // which would corrupt the disk buffer. + check_event_array_nesting_depth(&self)?; + proto::EventArray::from(self) .encode(buffer) .map_err(|_| EncodeError::BufferTooSmall) diff --git a/lib/vector-core/src/event/test/serialization.rs b/lib/vector-core/src/event/test/serialization.rs index 75304e3960d54..a8b67eb06be61 100644 --- a/lib/vector-core/src/event/test/serialization.rs +++ b/lib/vector-core/src/event/test/serialization.rs @@ -1,4 +1,5 @@ use bytes::{Buf, BufMut, BytesMut}; +use prost::Message; use quickcheck::{QuickCheck, TestResult}; use regex::Regex; use similar_asserts::assert_eq; @@ -6,6 +7,7 @@ use vector_buffers::encoding::Encodable; use super::*; use crate::config::log_schema; +use crate::event::ser::check_value_depth; fn encode_value(value: T, buffer: &mut B) { value.encode(buffer).expect("encoding should not fail"); @@ -96,3 +98,189 @@ fn type_serialization() { assert_eq!(map["bool"], json!(true)); assert_eq!(map["string"], json!("thisisastring")); } + +// --------------------------------------------------------------------------- +// Nesting depth validation tests +// --------------------------------------------------------------------------- + +/// Create a `LogEvent` with the specified nesting depth of maps. +/// +/// The resulting `LogEvent` has a root Object (depth 0) containing a "data" key +/// whose value is `wrapping_levels` levels of nested maps with a string leaf. +/// The leaf string is at effective depth `wrapping_levels + 1` from the root. +fn create_nested_log_event(wrapping_levels: usize) -> LogEvent { + let mut value = Value::from("innermost"); + for _ in 0..wrapping_levels { + let mut map = ObjectMap::new(); + map.insert("nested".into(), value); + value = Value::Object(map); + } + let mut event = LogEvent::default(); + event.insert("data", value); + event +} + +/// Demonstrates the root cause: prost encodes deeply nested events successfully, +/// but fails to decode them due to its internal recursion limit of 100. +#[test] +fn deeply_nested_event_encodes_but_fails_prost_decode() { + // 33 wrapping levels + root "data" key = effective depth 34 + let event = create_nested_log_event(33); + let array = EventArray::Logs(LogArray::from(vec![event])); + + // Bypass our nesting check: convert directly to proto and encode raw + let proto_array = proto::EventArray::from(array); + let mut buffer = BytesMut::with_capacity(16384); + proto_array + .encode(&mut buffer) + .expect("prost encode should succeed even for deeply nested data"); + + // Decode fails: prost hits its recursion limit + let result = proto::EventArray::decode(buffer.freeze()); + assert!( + result.is_err(), + "prost decode should fail at effective depth 34" + ); +} + +/// Confirms that events at exactly the max allowed depth encode AND decode via raw prost. +#[test] +fn event_at_max_depth_roundtrips_via_prost() { + // 32 wrapping levels + "data" key = leaf at depth 33 = MAX_NESTING_DEPTH + let event = create_nested_log_event(32); + let original = event.clone(); + let array = EventArray::Logs(LogArray::from(vec![event])); + + let proto_array = proto::EventArray::from(array); + let mut buffer = BytesMut::with_capacity(8192); + proto_array + .encode(&mut buffer) + .expect("prost encode should succeed at depth 33"); + + let decoded_proto = proto::EventArray::decode(buffer.freeze()) + .expect("prost decode should succeed at depth 33"); + let decoded_array = EventArray::from(decoded_proto); + + let decoded_event = decoded_array.into_events().next().unwrap().into_log(); + assert_eq!( + decoded_event.value().get("data"), + original.value().get("data"), + ); +} + +#[test] +fn nesting_gate_accepts_flat_event() { + let mut event = LogEvent::from("hello world"); + event.insert("foo", "bar"); + event.insert("count", 42); + + let events = EventArray::Logs(LogArray::from(vec![event])); + let mut buffer = BytesMut::with_capacity(1024); + assert!(events.encode(&mut buffer).is_ok()); +} + +#[test] +fn nesting_gate_accepts_event_at_max_depth() { + // 32 wrapping levels + "data" key = leaf at depth 33 = MAX_NESTING_DEPTH + let event = create_nested_log_event(32); + let original = event.clone(); + let events = EventArray::Logs(LogArray::from(vec![event])); + let mut buffer = BytesMut::with_capacity(8192); + + events + .encode(&mut buffer) + .expect("encode should succeed at exactly MAX_NESTING_DEPTH"); + + let decoded = EventArray::decode(EventArray::get_metadata(), buffer) + .expect("decode should succeed at exactly MAX_NESTING_DEPTH"); + + let decoded_event = decoded.into_events().next().unwrap().into_log(); + assert_eq!( + decoded_event.value().get("data"), + original.value().get("data"), + ); +} + +#[test] +fn nesting_gate_rejects_event_exceeding_max_depth() { + // 33 wrapping levels + "data" key = leaf at depth 34, exceeds MAX_NESTING_DEPTH (33) + let event = create_nested_log_event(33); + let events = EventArray::Logs(LogArray::from(vec![event])); + let mut buffer = BytesMut::with_capacity(8192); + + let result = events.encode(&mut buffer); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!( + matches!( + err, + super::super::ser::EncodeError::NestingTooDeep { + depth: 34, + max_depth: 33 + } + ), + "expected NestingTooDeep error, got: {err:?}" + ); +} + +#[test] +fn nesting_gate_rejects_trace_event_exceeding_max_depth() { + let mut value = Value::from("innermost"); + for _ in 0..33 { + let mut map = ObjectMap::new(); + map.insert("nested".into(), value); + value = Value::Object(map); + } + let mut trace = TraceEvent::default(); + trace.insert("data", value); + + let events = EventArray::Traces(TraceArray::from(vec![trace])); + let mut buffer = BytesMut::with_capacity(8192); + + let result = events.encode(&mut buffer); + assert!(matches!( + result, + Err(super::super::ser::EncodeError::NestingTooDeep { .. }) + )); +} + +#[test] +fn nesting_gate_accepts_metric_events() { + let metric = Metric::new( + "test_counter", + MetricKind::Incremental, + MetricValue::Counter { value: 1.0 }, + ); + let events = EventArray::Metrics(MetricArray::from(vec![metric])); + let mut buffer = BytesMut::with_capacity(1024); + assert!(events.encode(&mut buffer).is_ok()); +} + +#[test] +fn check_value_depth_with_configurable_limit() { + let mut value = Value::from("leaf"); + for _ in 0..5 { + let mut map = ObjectMap::new(); + map.insert("n".into(), value); + value = Value::Object(map); + } + + assert!(check_value_depth(&value, 0, 5).is_ok()); + assert!(check_value_depth(&value, 0, 4).is_err()); + assert!(check_value_depth(&value, 0, 10).is_ok()); + + let flat = Value::from("hello"); + assert!(check_value_depth(&flat, 0, 0).is_ok()); +} + +#[test] +fn check_value_depth_with_arrays() { + // Array containing an object containing an array containing a value = depth 3 + let inner = Value::Array(vec![Value::from("leaf")]); + let mut map = ObjectMap::new(); + map.insert("arr".into(), inner); + let value = Value::Array(vec![Value::Object(map)]); + + assert!(check_value_depth(&value, 0, 3).is_ok()); + assert!(check_value_depth(&value, 0, 2).is_err()); +} diff --git a/src/sinks/vector/sink.rs b/src/sinks/vector/sink.rs index bac25450c615f..648276e5dcbc7 100644 --- a/src/sinks/vector/sink.rs +++ b/src/sinks/vector/sink.rs @@ -7,6 +7,8 @@ use tower::Service; use vector_lib::{ ByteSizeOf, EstimatedJsonEncodedSizeOf, config::telemetry, + event::event_exceeds_max_nesting_depth, + internal_event::{ComponentEventsDropped, INTENTIONAL}, request_metadata::GroupedCountByteSize, stream::{BatcherSettings, DriverResponse, batcher::data::BatchReduce}, }; @@ -60,6 +62,17 @@ where { async fn run_inner(self: Box, input: BoxStream<'_, Event>) -> Result<(), ()> { input + .filter_map(|event| { + std::future::ready(if event_exceeds_max_nesting_depth(&event) { + emit!(ComponentEventsDropped:: { + count: 1, + reason: "Event nesting depth exceeds maximum for protobuf encoding.", + }); + None + } else { + Some(event) + }) + }) .map(|mut event| { let mut byte_size = telemetry().create_request_count_byte_size(); byte_size.add_event(&event, event.estimated_json_encoded_size_of()); From b05d98a462eecbc748c91b672a1ad709ae32a743 Mon Sep 17 00:00:00 2001 From: Connor Yang Date: Thu, 26 Mar 2026 10:13:31 -0400 Subject: [PATCH 02/49] fix: also check event metadata value for nesting depth 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. --- lib/vector-core/src/event/ser.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/vector-core/src/event/ser.rs b/lib/vector-core/src/event/ser.rs index 960c724ff34f9..3da9ca0495e69 100644 --- a/lib/vector-core/src/event/ser.rs +++ b/lib/vector-core/src/event/ser.rs @@ -53,14 +53,18 @@ pub(crate) fn check_value_depth( /// Returns `true` if the event's nesting depth exceeds `MAX_NESTING_DEPTH`. /// +/// Both the event value and event metadata value are checked, since both are +/// encoded into the protobuf message. +/// /// Metrics have a fixed structure and cannot be deeply nested, so they always return `false`. pub fn event_exceeds_max_nesting_depth(event: &Event) -> bool { - let value = match event { - Event::Log(log) => log.value(), - Event::Trace(trace) => trace.value(), + let (value, metadata_value) = match event { + Event::Log(log) => (log.value(), log.metadata().value()), + Event::Trace(trace) => (trace.value(), trace.metadata().value()), Event::Metric(_) => return false, }; check_value_depth(value, 0, MAX_NESTING_DEPTH).is_err() + || check_value_depth(metadata_value, 0, MAX_NESTING_DEPTH).is_err() } /// Checks all events in an `EventArray` for nesting depth violations. @@ -80,11 +84,13 @@ fn check_event_array_nesting_depth(events: &EventArray) -> Result<(), EncodeErro EventArray::Logs(logs) => { for log in logs { check(log.value())?; + check(log.metadata().value())?; } } EventArray::Traces(traces) => { for trace in traces { check(trace.value())?; + check(trace.metadata().value())?; } } EventArray::Metrics(_) => {} From 87d25cd72de55afce7f36b96f866e2d0aa8253bc Mon Sep 17 00:00:00 2001 From: Connor Yang Date: Thu, 26 Mar 2026 11:45:27 -0400 Subject: [PATCH 03/49] docs: replace approximate nesting formula with verified exact derivation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lib/vector-core/src/event/ser.rs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/lib/vector-core/src/event/ser.rs b/lib/vector-core/src/event/ser.rs index 3da9ca0495e69..06e5c2da9a962 100644 --- a/lib/vector-core/src/event/ser.rs +++ b/lib/vector-core/src/event/ser.rs @@ -9,14 +9,16 @@ use super::{Event, EventArray, proto}; /// Maximum nesting depth allowed for events before protobuf encoding. /// -/// 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 (map entry -> key/value -> nested -/// message). Empirically, prost decode fails at Value tree depth 34 and succeeds at depth 33. +/// Prost enforces a decode recursion limit of 100 (no limit on encode). In Vector's proto +/// schema (`event.proto`), decoding a `LogEvent` consumes 4 prost recursion levels for the +/// outer wrappers (`EventArray` → `LogArray` → `Log` → fields map), then 3 levels per +/// `Value` map nesting level (`Value` message → `ValueMap` message → map entry). The formula +/// is `4 + 3*N <= 100`, giving N <= 32 map nesting levels. Since `check_value_depth` counts +/// from the `LogEvent` root (depth 0), a leaf at depth 33 uses exactly `4 + 3*32 = 100` +/// recursion levels — the maximum prost allows. Depth 34 exceeds it. /// -/// We set the limit to 33 — the highest safe depth — to prevent corruption across all protobuf -/// encoding paths (disk buffers, gRPC, native codec) while preserving backward compatibility -/// with JSON payloads up to 32 levels deep (which become depth 33 after `parse_json` places them -/// inside a `LogEvent` field). +/// Unit tests `deeply_nested_event_encodes_but_fails_prost_decode` and +/// `event_at_max_depth_roundtrips_via_prost` verify this boundary empirically. pub const MAX_NESTING_DEPTH: usize = 33; /// Check the nesting depth of a `Value`, returning `Err(actual_depth)` if it exceeds `max_depth`. From eff1c70bb0b6c52d8b04801ff187854c5b6093ce Mon Sep 17 00:00:00 2001 From: Connor Yang Date: Thu, 26 Mar 2026 11:59:00 -0400 Subject: [PATCH 04/49] fix: lower MAX_NESTING_DEPTH from 33 to 32 to cover all proto paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../protobuf_nesting_depth_limit.fix.md | 2 +- lib/codecs/tests/native.rs | 8 +++--- lib/vector-core/src/event/ser.rs | 18 ++++++------- .../src/event/test/serialization.rs | 26 +++++++++---------- 4 files changed, 27 insertions(+), 27 deletions(-) diff --git a/changelog.d/protobuf_nesting_depth_limit.fix.md b/changelog.d/protobuf_nesting_depth_limit.fix.md index b419e3947bebf..d53f69d899765 100644 --- a/changelog.d/protobuf_nesting_depth_limit.fix.md +++ b/changelog.d/protobuf_nesting_depth_limit.fix.md @@ -1,3 +1,3 @@ -Fixed disk buffer corruption caused by deeply nested events exceeding prost's protobuf recursion limit during decode. Events with nesting depth greater than 33 are now rejected at encode time across disk buffers, the native codec, and the `vector` sink's gRPC path, preventing unrecoverable buffer corruption. +Fixed disk buffer corruption caused by deeply nested events exceeding prost's protobuf recursion limit during decode. Events with nesting depth greater than 32 are now rejected at encode time across disk buffers, the native codec, and the `vector` sink's gRPC path, preventing unrecoverable buffer corruption. authors: connoryy diff --git a/lib/codecs/tests/native.rs b/lib/codecs/tests/native.rs index 704bacb3146d2..8c105844fc8bc 100644 --- a/lib/codecs/tests/native.rs +++ b/lib/codecs/tests/native.rs @@ -344,8 +344,8 @@ fn create_nested_log_event(wrapping_levels: usize) -> LogEvent { #[test] fn native_codec_rejects_overly_nested_event() { - // 33 wrapping levels + "data" key = depth 34 > MAX_NESTING_DEPTH (33) - let event = create_nested_log_event(33); + // 32 wrapping levels + "data" key = depth 33 > MAX_NESTING_DEPTH (32) + let event = create_nested_log_event(32); let event = Event::Log(event); let mut serializer = NativeSerializerConfig.build(); @@ -360,8 +360,8 @@ fn native_codec_rejects_overly_nested_event() { #[test] fn native_codec_roundtrip_max_depth_event() { - // 32 wrapping levels + "data" key = depth 33 = MAX_NESTING_DEPTH - let event = create_nested_log_event(32); + // 31 wrapping levels + "data" key = depth 32 = MAX_NESTING_DEPTH + let event = create_nested_log_event(31); let original_data = event.value().get("data").cloned(); let event = Event::Log(event); diff --git a/lib/vector-core/src/event/ser.rs b/lib/vector-core/src/event/ser.rs index 06e5c2da9a962..292c4852bb45d 100644 --- a/lib/vector-core/src/event/ser.rs +++ b/lib/vector-core/src/event/ser.rs @@ -10,16 +10,16 @@ use super::{Event, EventArray, proto}; /// Maximum nesting depth allowed for events before protobuf encoding. /// /// Prost enforces a decode recursion limit of 100 (no limit on encode). In Vector's proto -/// schema (`event.proto`), decoding a `LogEvent` consumes 4 prost recursion levels for the -/// outer wrappers (`EventArray` → `LogArray` → `Log` → fields map), then 3 levels per -/// `Value` map nesting level (`Value` message → `ValueMap` message → map entry). The formula -/// is `4 + 3*N <= 100`, giving N <= 32 map nesting levels. Since `check_value_depth` counts -/// from the `LogEvent` root (depth 0), a leaf at depth 33 uses exactly `4 + 3*32 = 100` -/// recursion levels — the maximum prost allows. Depth 34 exceeds it. +/// schema (`event.proto`), each `Value` map nesting level consumes 3 prost recursion levels +/// (`Value` message → `ValueMap` message → map entry). The outer message wrappers and +/// metadata paths consume varying numbers of levels (3-5 depending on the path), making +/// the tightest constraint approximately `5 + 3*N <= 100`, giving N <= 31 map nesting +/// levels from the `Value` root, or depth 32 as counted by `check_value_depth`. /// -/// Unit tests `deeply_nested_event_encodes_but_fails_prost_decode` and -/// `event_at_max_depth_roundtrips_via_prost` verify this boundary empirically. -pub const MAX_NESTING_DEPTH: usize = 33; +/// We set the limit to 32 to be safe across all encoding paths (event value via `Log.fields`, +/// event value via `Log.value`, event metadata, and trace events). Unit tests verify that no +/// encoding path has a gap between our check and prost's actual decode limit. +pub const MAX_NESTING_DEPTH: usize = 32; /// Check the nesting depth of a `Value`, returning `Err(actual_depth)` if it exceeds `max_depth`. /// diff --git a/lib/vector-core/src/event/test/serialization.rs b/lib/vector-core/src/event/test/serialization.rs index a8b67eb06be61..983b10829a63a 100644 --- a/lib/vector-core/src/event/test/serialization.rs +++ b/lib/vector-core/src/event/test/serialization.rs @@ -124,7 +124,7 @@ fn create_nested_log_event(wrapping_levels: usize) -> LogEvent { /// but fails to decode them due to its internal recursion limit of 100. #[test] fn deeply_nested_event_encodes_but_fails_prost_decode() { - // 33 wrapping levels + root "data" key = effective depth 34 + // 33 wrapping levels exceeds the prost decode limit for the Log.fields path let event = create_nested_log_event(33); let array = EventArray::Logs(LogArray::from(vec![event])); @@ -139,15 +139,15 @@ fn deeply_nested_event_encodes_but_fails_prost_decode() { let result = proto::EventArray::decode(buffer.freeze()); assert!( result.is_err(), - "prost decode should fail at effective depth 34" + "prost decode should fail for events exceeding the recursion limit" ); } /// Confirms that events at exactly the max allowed depth encode AND decode via raw prost. #[test] fn event_at_max_depth_roundtrips_via_prost() { - // 32 wrapping levels + "data" key = leaf at depth 33 = MAX_NESTING_DEPTH - let event = create_nested_log_event(32); + // 31 wrapping levels + "data" key = leaf at depth 32 = MAX_NESTING_DEPTH + let event = create_nested_log_event(31); let original = event.clone(); let array = EventArray::Logs(LogArray::from(vec![event])); @@ -155,10 +155,10 @@ fn event_at_max_depth_roundtrips_via_prost() { let mut buffer = BytesMut::with_capacity(8192); proto_array .encode(&mut buffer) - .expect("prost encode should succeed at depth 33"); + .expect("prost encode should succeed at MAX_NESTING_DEPTH"); let decoded_proto = proto::EventArray::decode(buffer.freeze()) - .expect("prost decode should succeed at depth 33"); + .expect("prost decode should succeed at MAX_NESTING_DEPTH"); let decoded_array = EventArray::from(decoded_proto); let decoded_event = decoded_array.into_events().next().unwrap().into_log(); @@ -181,8 +181,8 @@ fn nesting_gate_accepts_flat_event() { #[test] fn nesting_gate_accepts_event_at_max_depth() { - // 32 wrapping levels + "data" key = leaf at depth 33 = MAX_NESTING_DEPTH - let event = create_nested_log_event(32); + // 31 wrapping levels + "data" key = leaf at depth 32 = MAX_NESTING_DEPTH + let event = create_nested_log_event(31); let original = event.clone(); let events = EventArray::Logs(LogArray::from(vec![event])); let mut buffer = BytesMut::with_capacity(8192); @@ -203,8 +203,8 @@ fn nesting_gate_accepts_event_at_max_depth() { #[test] fn nesting_gate_rejects_event_exceeding_max_depth() { - // 33 wrapping levels + "data" key = leaf at depth 34, exceeds MAX_NESTING_DEPTH (33) - let event = create_nested_log_event(33); + // 32 wrapping levels + "data" key = leaf at depth 33, exceeds MAX_NESTING_DEPTH (32) + let event = create_nested_log_event(32); let events = EventArray::Logs(LogArray::from(vec![event])); let mut buffer = BytesMut::with_capacity(8192); @@ -215,8 +215,8 @@ fn nesting_gate_rejects_event_exceeding_max_depth() { matches!( err, super::super::ser::EncodeError::NestingTooDeep { - depth: 34, - max_depth: 33 + depth: 33, + max_depth: 32 } ), "expected NestingTooDeep error, got: {err:?}" @@ -226,7 +226,7 @@ fn nesting_gate_rejects_event_exceeding_max_depth() { #[test] fn nesting_gate_rejects_trace_event_exceeding_max_depth() { let mut value = Value::from("innermost"); - for _ in 0..33 { + for _ in 0..32 { let mut map = ObjectMap::new(); map.insert("nested".into(), value); value = Value::Object(map); From 59de3d477550f46b4bf58c10581c3ce938999a08 Mon Sep 17 00:00:00 2001 From: Connor Yang Date: Thu, 26 Mar 2026 12:24:51 -0400 Subject: [PATCH 05/49] test: add EventWrapper path boundary test for vector sink gRPC 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. --- lib/vector-core/src/event/test/serialization.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/lib/vector-core/src/event/test/serialization.rs b/lib/vector-core/src/event/test/serialization.rs index 983b10829a63a..1eb76f2764481 100644 --- a/lib/vector-core/src/event/test/serialization.rs +++ b/lib/vector-core/src/event/test/serialization.rs @@ -120,6 +120,23 @@ fn create_nested_log_event(wrapping_levels: usize) -> LogEvent { event } +/// Verifies that the `EventWrapper` path (used by the vector sink gRPC) is at least as +/// permissive as our `MAX_NESTING_DEPTH` check. `EventWrapper` has fewer outer wrappers +/// than `EventArray`, so its prost decode limit is higher — our check is conservative. +#[test] +fn event_wrapper_path_safe_at_max_depth() { + let event = create_nested_log_event(super::super::ser::MAX_NESTING_DEPTH - 1); + let wrapper = proto::EventWrapper::from(Event::Log(event)); + + let mut buffer = BytesMut::with_capacity(65536); + wrapper.encode(&mut buffer).unwrap(); + let result = proto::EventWrapper::decode(buffer.freeze()); + assert!( + result.is_ok(), + "EventWrapper path should succeed at MAX_NESTING_DEPTH" + ); +} + /// Demonstrates the root cause: prost encodes deeply nested events successfully, /// but fails to decode them due to its internal recursion limit of 100. #[test] From da1e2ebc987107577a2d71ff3872a8ad178a5792 Mon Sep 17 00:00:00 2001 From: Connor Yang Date: Thu, 26 Mar 2026 18:19:43 -0400 Subject: [PATCH 06/49] docs: simplify MAX_NESTING_DEPTH comment to state only verified facts 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. --- lib/vector-core/src/event/ser.rs | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/lib/vector-core/src/event/ser.rs b/lib/vector-core/src/event/ser.rs index 292c4852bb45d..631fa86e89b10 100644 --- a/lib/vector-core/src/event/ser.rs +++ b/lib/vector-core/src/event/ser.rs @@ -9,16 +9,14 @@ use super::{Event, EventArray, proto}; /// Maximum nesting depth allowed for events before protobuf encoding. /// -/// Prost enforces a decode recursion limit of 100 (no limit on encode). In Vector's proto -/// schema (`event.proto`), each `Value` map nesting level consumes 3 prost recursion levels -/// (`Value` message → `ValueMap` message → map entry). The outer message wrappers and -/// metadata paths consume varying numbers of levels (3-5 depending on the path), making -/// the tightest constraint approximately `5 + 3*N <= 100`, giving N <= 31 map nesting -/// levels from the `Value` root, or depth 32 as counted by `check_value_depth`. +/// Prost enforces a decode recursion limit of 100 (no limit on encode). Vector's proto +/// schema uses multiple prost recursion levels per `Value` nesting level, so the effective +/// safe depth is much lower than 100. The exact overhead depends on the encoding path +/// (`Log.fields` vs `Log.value`, event value vs metadata, log vs trace). /// -/// We set the limit to 32 to be safe across all encoding paths (event value via `Log.fields`, -/// event value via `Log.value`, event metadata, and trace events). Unit tests verify that no -/// encoding path has a gap between our check and prost's actual decode limit. +/// The value 32 was determined empirically by testing all encoding paths and finding the +/// highest depth that roundtrips successfully across all of them. Unit tests verify that +/// depth 32 succeeds and depth 33 fails prost decode. pub const MAX_NESTING_DEPTH: usize = 32; /// Check the nesting depth of a `Value`, returning `Err(actual_depth)` if it exceeds `max_depth`. From fbd398c75cab16fe17d164331e722a4831a08bab Mon Sep 17 00:00:00 2001 From: Connor Yang Date: Thu, 26 Mar 2026 18:26:25 -0400 Subject: [PATCH 07/49] docs: remove proto field names from MAX_NESTING_DEPTH comment --- lib/vector-core/src/event/ser.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/vector-core/src/event/ser.rs b/lib/vector-core/src/event/ser.rs index 631fa86e89b10..2afd2071e178b 100644 --- a/lib/vector-core/src/event/ser.rs +++ b/lib/vector-core/src/event/ser.rs @@ -11,8 +11,8 @@ use super::{Event, EventArray, proto}; /// /// Prost enforces a decode recursion limit of 100 (no limit on encode). Vector's proto /// schema uses multiple prost recursion levels per `Value` nesting level, so the effective -/// safe depth is much lower than 100. The exact overhead depends on the encoding path -/// (`Log.fields` vs `Log.value`, event value vs metadata, log vs trace). +/// safe depth is much lower than 100. The exact overhead varies across encoding paths +/// (log events, trace events, event metadata each have different proto message wrappers). /// /// The value 32 was determined empirically by testing all encoding paths and finding the /// highest depth that roundtrips successfully across all of them. Unit tests verify that From 2191d9bd0475212634cecbc3eb4ed12fecf25170 Mon Sep 17 00:00:00 2001 From: Connor Yang Date: Thu, 26 Mar 2026 18:32:51 -0400 Subject: [PATCH 08/49] docs: simplify test comments to remove proto-specific terminology --- lib/vector-core/src/event/test/serialization.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/vector-core/src/event/test/serialization.rs b/lib/vector-core/src/event/test/serialization.rs index 1eb76f2764481..22629984b9ff7 100644 --- a/lib/vector-core/src/event/test/serialization.rs +++ b/lib/vector-core/src/event/test/serialization.rs @@ -120,9 +120,8 @@ fn create_nested_log_event(wrapping_levels: usize) -> LogEvent { event } -/// Verifies that the `EventWrapper` path (used by the vector sink gRPC) is at least as -/// permissive as our `MAX_NESTING_DEPTH` check. `EventWrapper` has fewer outer wrappers -/// than `EventArray`, so its prost decode limit is higher — our check is conservative. +/// The vector sink encodes events as `EventWrapper` (not `EventArray`), which has a +/// different proto structure. Verify that it can also decode at `MAX_NESTING_DEPTH`. #[test] fn event_wrapper_path_safe_at_max_depth() { let event = create_nested_log_event(super::super::ser::MAX_NESTING_DEPTH - 1); @@ -141,7 +140,7 @@ fn event_wrapper_path_safe_at_max_depth() { /// but fails to decode them due to its internal recursion limit of 100. #[test] fn deeply_nested_event_encodes_but_fails_prost_decode() { - // 33 wrapping levels exceeds the prost decode limit for the Log.fields path + // 33 wrapping levels exceeds the prost decode limit let event = create_nested_log_event(33); let array = EventArray::Logs(LogArray::from(vec![event])); From 5046ec5f976b6aed47a870eda31149d817bfcacb Mon Sep 17 00:00:00 2001 From: Connor Yang Date: Wed, 1 Apr 2026 14:10:44 -0400 Subject: [PATCH 09/49] make error slightly more informative --- lib/codecs/src/encoding/format/native.rs | 4 ++-- lib/vector-core/src/event/ser.rs | 16 ++++++++++------ src/sinks/vector/sink.rs | 2 +- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/lib/codecs/src/encoding/format/native.rs b/lib/codecs/src/encoding/format/native.rs index 0c328c48a4187..42b1fb5b5d36a 100644 --- a/lib/codecs/src/encoding/format/native.rs +++ b/lib/codecs/src/encoding/format/native.rs @@ -37,9 +37,9 @@ impl Encoder for NativeSerializer { type Error = vector_common::Error; fn encode(&mut self, event: Event, buffer: &mut BytesMut) -> Result<(), Self::Error> { - if event_exceeds_max_nesting_depth(&event) { + if let Some(depth) = event_exceeds_max_nesting_depth(&event) { return Err(format!( - "event nesting depth exceeds maximum of {MAX_NESTING_DEPTH} for protobuf encoding" + "event nesting depth {depth} exceeds maximum of {MAX_NESTING_DEPTH} for protobuf encoding" ) .into()); } diff --git a/lib/vector-core/src/event/ser.rs b/lib/vector-core/src/event/ser.rs index 2afd2071e178b..49868146caf71 100644 --- a/lib/vector-core/src/event/ser.rs +++ b/lib/vector-core/src/event/ser.rs @@ -51,20 +51,24 @@ pub(crate) fn check_value_depth( Ok(()) } -/// Returns `true` if the event's nesting depth exceeds `MAX_NESTING_DEPTH`. +/// Checks whether an event's nesting depth exceeds `MAX_NESTING_DEPTH`. +/// +/// Returns `Some(depth)` with the violating depth if the event exceeds the limit, +/// or `None` if the event is within bounds. /// /// Both the event value and event metadata value are checked, since both are /// encoded into the protobuf message. /// -/// Metrics have a fixed structure and cannot be deeply nested, so they always return `false`. -pub fn event_exceeds_max_nesting_depth(event: &Event) -> bool { +/// Metrics have a fixed structure and cannot be deeply nested, so they always return `None`. +pub fn event_exceeds_max_nesting_depth(event: &Event) -> Option { let (value, metadata_value) = match event { Event::Log(log) => (log.value(), log.metadata().value()), Event::Trace(trace) => (trace.value(), trace.metadata().value()), - Event::Metric(_) => return false, + Event::Metric(_) => return None, }; - check_value_depth(value, 0, MAX_NESTING_DEPTH).is_err() - || check_value_depth(metadata_value, 0, MAX_NESTING_DEPTH).is_err() + check_value_depth(value, 0, MAX_NESTING_DEPTH) + .or_else(|_| check_value_depth(metadata_value, 0, MAX_NESTING_DEPTH)) + .err() } /// Checks all events in an `EventArray` for nesting depth violations. diff --git a/src/sinks/vector/sink.rs b/src/sinks/vector/sink.rs index 648276e5dcbc7..0e66933964903 100644 --- a/src/sinks/vector/sink.rs +++ b/src/sinks/vector/sink.rs @@ -63,7 +63,7 @@ where async fn run_inner(self: Box, input: BoxStream<'_, Event>) -> Result<(), ()> { input .filter_map(|event| { - std::future::ready(if event_exceeds_max_nesting_depth(&event) { + std::future::ready(if event_exceeds_max_nesting_depth(&event).is_some() { emit!(ComponentEventsDropped:: { count: 1, reason: "Event nesting depth exceeds maximum for protobuf encoding.", From 390c30b7286471def3dac12cd2fced5b237c2f5c Mon Sep 17 00:00:00 2001 From: Connor Yang Date: Wed, 1 Apr 2026 14:10:55 -0400 Subject: [PATCH 10/49] move imports --- lib/codecs/tests/native.rs | 6 ++---- lib/vector-core/src/event/mod.rs | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/lib/codecs/tests/native.rs b/lib/codecs/tests/native.rs index 8c105844fc8bc..aa9b89b125333 100644 --- a/lib/codecs/tests/native.rs +++ b/lib/codecs/tests/native.rs @@ -12,7 +12,8 @@ use codecs::{ NativeSerializerConfig, decoding::format::Deserializer, encoding::format::Serializer, }; use similar_asserts::assert_eq; -use vector_core::{config::LogNamespace, event::Event}; +use tokio_util::codec::Encoder; +use vector_core::{config::LogNamespace, event::{Event, LogEvent, ObjectMap, Value}}; #[test] fn pre_v24_fixtures_match() { @@ -327,9 +328,6 @@ fn rebuild_fixtures(proto: &str, deserializer: &dyn Deserializer, serializer: &m // Nesting depth guard integration tests for the native codec // --------------------------------------------------------------------------- -use tokio_util::codec::Encoder; -use vector_core::event::{LogEvent, ObjectMap, Value}; - fn create_nested_log_event(wrapping_levels: usize) -> LogEvent { let mut value = Value::from("innermost"); for _ in 0..wrapping_levels { diff --git a/lib/vector-core/src/event/mod.rs b/lib/vector-core/src/event/mod.rs index 7f172870115f0..e20ecb08ee923 100644 --- a/lib/vector-core/src/event/mod.rs +++ b/lib/vector-core/src/event/mod.rs @@ -11,6 +11,7 @@ pub use metadata::{DatadogMetricOriginMetadata, EventMetadata, WithMetadata}; pub use metric::{Metric, MetricKind, MetricTags, MetricValue, StatisticKind}; pub use r#ref::{EventMutRef, EventRef}; use serde::{Deserialize, Serialize}; +pub use ser::{MAX_NESTING_DEPTH, event_exceeds_max_nesting_depth}; pub use trace::TraceEvent; use vector_buffers::EventCount; use vector_common::{ @@ -35,7 +36,6 @@ pub mod metric; pub mod proto; mod r#ref; mod ser; -pub use ser::{MAX_NESTING_DEPTH, event_exceeds_max_nesting_depth}; #[cfg(test)] mod test; mod trace; From 3eb9fb4e9feaf9c66ed8d9fc7d21caded378f5c3 Mon Sep 17 00:00:00 2001 From: Connor Yang Date: Wed, 1 Apr 2026 14:11:02 -0400 Subject: [PATCH 11/49] add test --- .../src/event/test/serialization.rs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/lib/vector-core/src/event/test/serialization.rs b/lib/vector-core/src/event/test/serialization.rs index 22629984b9ff7..6d6d2a9b47929 100644 --- a/lib/vector-core/src/event/test/serialization.rs +++ b/lib/vector-core/src/event/test/serialization.rs @@ -272,6 +272,32 @@ fn nesting_gate_accepts_metric_events() { assert!(events.encode(&mut buffer).is_ok()); } +#[test] +fn nesting_gate_rejects_deeply_nested_metadata() { + let mut event = LogEvent::from("normal event data"); + + // Build a deeply nested metadata value (32 wrapping levels → depth 33 exceeds limit) + let mut value = Value::from("innermost"); + for _ in 0..32 { + let mut map = ObjectMap::new(); + map.insert("nested".into(), value); + value = Value::Object(map); + } + *event.metadata_mut().value_mut() = value; + + let events = EventArray::Logs(LogArray::from(vec![event])); + let mut buffer = BytesMut::with_capacity(8192); + + let result = events.encode(&mut buffer); + assert!( + matches!( + result, + Err(super::super::ser::EncodeError::NestingTooDeep { .. }) + ), + "should reject events with deeply nested metadata" + ); +} + #[test] fn check_value_depth_with_configurable_limit() { let mut value = Value::from("leaf"); From 39a9924ac3584c19ff0e75d4ef1c60f4380a7d0e Mon Sep 17 00:00:00 2001 From: Connor Yang Date: Mon, 6 Apr 2026 10:50:10 -0400 Subject: [PATCH 12/49] fix conditional to new function signature --- lib/vector-core/src/event/ser.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/vector-core/src/event/ser.rs b/lib/vector-core/src/event/ser.rs index 49868146caf71..539a4eb8aec5f 100644 --- a/lib/vector-core/src/event/ser.rs +++ b/lib/vector-core/src/event/ser.rs @@ -67,7 +67,7 @@ pub fn event_exceeds_max_nesting_depth(event: &Event) -> Option { Event::Metric(_) => return None, }; check_value_depth(value, 0, MAX_NESTING_DEPTH) - .or_else(|_| check_value_depth(metadata_value, 0, MAX_NESTING_DEPTH)) + .and_then(|()| check_value_depth(metadata_value, 0, MAX_NESTING_DEPTH)) .err() } From d835bc8f64bf81e1618530e79a7850000cec80d5 Mon Sep 17 00:00:00 2001 From: Connor Yang Date: Mon, 6 Apr 2026 10:53:53 -0400 Subject: [PATCH 13/49] style: auto-fix lint/format errors --- lib/codecs/tests/native.rs | 5 ++++- lib/vector-core/src/event/mod.rs | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/codecs/tests/native.rs b/lib/codecs/tests/native.rs index aa9b89b125333..d56b288efc392 100644 --- a/lib/codecs/tests/native.rs +++ b/lib/codecs/tests/native.rs @@ -13,7 +13,10 @@ use codecs::{ }; use similar_asserts::assert_eq; use tokio_util::codec::Encoder; -use vector_core::{config::LogNamespace, event::{Event, LogEvent, ObjectMap, Value}}; +use vector_core::{ + config::LogNamespace, + event::{Event, LogEvent, ObjectMap, Value}, +}; #[test] fn pre_v24_fixtures_match() { diff --git a/lib/vector-core/src/event/mod.rs b/lib/vector-core/src/event/mod.rs index e20ecb08ee923..60a0d7342e33a 100644 --- a/lib/vector-core/src/event/mod.rs +++ b/lib/vector-core/src/event/mod.rs @@ -10,8 +10,8 @@ pub use log_event::LogEvent; pub use metadata::{DatadogMetricOriginMetadata, EventMetadata, WithMetadata}; pub use metric::{Metric, MetricKind, MetricTags, MetricValue, StatisticKind}; pub use r#ref::{EventMutRef, EventRef}; -use serde::{Deserialize, Serialize}; pub use ser::{MAX_NESTING_DEPTH, event_exceeds_max_nesting_depth}; +use serde::{Deserialize, Serialize}; pub use trace::TraceEvent; use vector_buffers::EventCount; use vector_common::{ From 573dafbd97146c351842ad62e4a719b83700fc12 Mon Sep 17 00:00:00 2001 From: Connor Yang Date: Mon, 6 Apr 2026 12:44:48 -0400 Subject: [PATCH 14/49] fix metrics case and test failure --- lib/vector-core/src/event/ser.rs | 38 +++++++++++-------- .../src/event/test/serialization.rs | 34 ++++++++++++++++- 2 files changed, 55 insertions(+), 17 deletions(-) diff --git a/lib/vector-core/src/event/ser.rs b/lib/vector-core/src/event/ser.rs index 539a4eb8aec5f..ef08e658bb6ca 100644 --- a/lib/vector-core/src/event/ser.rs +++ b/lib/vector-core/src/event/ser.rs @@ -56,25 +56,29 @@ pub(crate) fn check_value_depth( /// Returns `Some(depth)` with the violating depth if the event exceeds the limit, /// or `None` if the event is within bounds. /// -/// Both the event value and event metadata value are checked, since both are -/// encoded into the protobuf message. -/// -/// Metrics have a fixed structure and cannot be deeply nested, so they always return `None`. +/// For logs and traces, both the event value and event metadata value are checked. +/// For metrics, the metric value has a fixed structure that cannot be deeply nested, +/// but the metadata value is an arbitrary `Value` that is encoded into protobuf, +/// so it is still checked. pub fn event_exceeds_max_nesting_depth(event: &Event) -> Option { - let (value, metadata_value) = match event { - Event::Log(log) => (log.value(), log.metadata().value()), - Event::Trace(trace) => (trace.value(), trace.metadata().value()), - Event::Metric(_) => return None, - }; - check_value_depth(value, 0, MAX_NESTING_DEPTH) - .and_then(|()| check_value_depth(metadata_value, 0, MAX_NESTING_DEPTH)) - .err() + match event { + Event::Log(log) => check_value_depth(log.value(), 0, MAX_NESTING_DEPTH) + .and_then(|()| check_value_depth(log.metadata().value(), 0, MAX_NESTING_DEPTH)) + .err(), + Event::Trace(trace) => check_value_depth(trace.value(), 0, MAX_NESTING_DEPTH) + .and_then(|()| check_value_depth(trace.metadata().value(), 0, MAX_NESTING_DEPTH)) + .err(), + Event::Metric(metric) => { + check_value_depth(metric.metadata().value(), 0, MAX_NESTING_DEPTH).err() + } + } } /// Checks all events in an `EventArray` for nesting depth violations. /// -/// Returns `Err(EncodeError::NestingTooDeep)` if any log or trace event exceeds -/// `MAX_NESTING_DEPTH`. Metrics are skipped (fixed structure, no deep nesting possible). +/// Returns `Err(EncodeError::NestingTooDeep)` if any event exceeds `MAX_NESTING_DEPTH`. +/// For metrics, only metadata is checked since metric values have a fixed structure, +/// but metadata is an arbitrary `Value` encoded into protobuf. fn check_event_array_nesting_depth(events: &EventArray) -> Result<(), EncodeError> { let check = |value: &Value| { check_value_depth(value, 0, MAX_NESTING_DEPTH).map_err(|depth| { @@ -97,7 +101,11 @@ fn check_event_array_nesting_depth(events: &EventArray) -> Result<(), EncodeErro check(trace.metadata().value())?; } } - EventArray::Metrics(_) => {} + EventArray::Metrics(metrics) => { + for metric in metrics { + check(metric.metadata().value())?; + } + } } Ok(()) } diff --git a/lib/vector-core/src/event/test/serialization.rs b/lib/vector-core/src/event/test/serialization.rs index 6d6d2a9b47929..21bdd3ff1e4f5 100644 --- a/lib/vector-core/src/event/test/serialization.rs +++ b/lib/vector-core/src/event/test/serialization.rs @@ -272,13 +272,43 @@ fn nesting_gate_accepts_metric_events() { assert!(events.encode(&mut buffer).is_ok()); } +#[test] +fn nesting_gate_rejects_metric_with_deeply_nested_metadata() { + let mut metric = Metric::new( + "test_counter", + MetricKind::Incremental, + MetricValue::Counter { value: 1.0 }, + ); + + // 33 wrapping levels = leaf at depth 33, exceeds MAX_NESTING_DEPTH (32) + let mut value = Value::from("innermost"); + for _ in 0..33 { + let mut map = ObjectMap::new(); + map.insert("nested".into(), value); + value = Value::Object(map); + } + *metric.metadata_mut().value_mut() = value; + + let events = EventArray::Metrics(MetricArray::from(vec![metric])); + let mut buffer = BytesMut::with_capacity(8192); + + let result = events.encode(&mut buffer); + assert!( + matches!( + result, + Err(super::super::ser::EncodeError::NestingTooDeep { .. }) + ), + "should reject metrics with deeply nested metadata" + ); +} + #[test] fn nesting_gate_rejects_deeply_nested_metadata() { let mut event = LogEvent::from("normal event data"); - // Build a deeply nested metadata value (32 wrapping levels → depth 33 exceeds limit) + // 33 wrapping levels = leaf at depth 33, exceeds MAX_NESTING_DEPTH (32) let mut value = Value::from("innermost"); - for _ in 0..32 { + for _ in 0..33 { let mut map = ObjectMap::new(); map.insert("nested".into(), value); value = Value::Object(map); From 6ff03c53a605f8c890dde79130425dab90ebc5c9 Mon Sep 17 00:00:00 2001 From: Connor Yang Date: Wed, 15 Apr 2026 13:31:27 -0400 Subject: [PATCH 15/49] Add roundtrip tests for depth-32 metadata via prost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/event/test/serialization.rs | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/lib/vector-core/src/event/test/serialization.rs b/lib/vector-core/src/event/test/serialization.rs index 21bdd3ff1e4f5..9485950161d87 100644 --- a/lib/vector-core/src/event/test/serialization.rs +++ b/lib/vector-core/src/event/test/serialization.rs @@ -328,6 +328,86 @@ fn nesting_gate_rejects_deeply_nested_metadata() { ); } +/// Creates a Value with the specified number of nested Object wrapping levels. +/// +/// Returns a Value that is `wrapping_levels` nested Objects deep, with a string leaf. +/// `check_value_depth` will measure this as depth `wrapping_levels` (the leaf). +fn create_nested_value(wrapping_levels: usize) -> Value { + let mut value = Value::from("innermost"); + for _ in 0..wrapping_levels { + let mut map = ObjectMap::new(); + map.insert("nested".into(), value); + value = Value::Object(map); + } + value +} + +/// The metadata_full encoding path (EventArray → LogArray → Log → Metadata → Value) is the +/// tightest proto path, using exactly 100 of prost's 100 recursion budget at depth 32. +/// This test proves depth-32 metadata roundtrips via raw prost encode/decode. +#[test] +fn metadata_at_max_depth_roundtrips_via_prost() { + let mut event = LogEvent::from("normal event data"); + + // 32 wrapping levels = leaf at depth 32 = MAX_NESTING_DEPTH + let value = create_nested_value(32); + *event.metadata_mut().value_mut() = value.clone(); + + let array = EventArray::Logs(LogArray::from(vec![event])); + + // Encode via proto directly (same path as Encodable::encode but without the depth gate) + let proto_array = proto::EventArray::from(array); + let mut buffer = BytesMut::with_capacity(16384); + proto_array + .encode(&mut buffer) + .expect("prost encode should succeed for depth-32 metadata"); + + // Decode must also succeed — this is the tightest path (exactly 100/100 prost budget) + let decoded_proto = proto::EventArray::decode(buffer.freeze()) + .expect("prost decode should succeed for depth-32 metadata (tightest path)"); + let decoded_array = EventArray::from(decoded_proto); + + let decoded_event = decoded_array.into_events().next().unwrap().into_log(); + assert_eq!( + decoded_event.metadata().value(), + &value, + "metadata value should roundtrip at MAX_NESTING_DEPTH" + ); +} + +/// Same test for metric metadata, which follows the same tightest path: +/// EventArray → MetricArray → Metric → Metadata → Value +#[test] +fn metric_metadata_at_max_depth_roundtrips_via_prost() { + let mut metric = Metric::new( + "test_counter", + MetricKind::Incremental, + MetricValue::Counter { value: 1.0 }, + ); + + let value = create_nested_value(32); + *metric.metadata_mut().value_mut() = value.clone(); + + let array = EventArray::Metrics(MetricArray::from(vec![metric])); + + let proto_array = proto::EventArray::from(array); + let mut buffer = BytesMut::with_capacity(16384); + proto_array + .encode(&mut buffer) + .expect("prost encode should succeed for depth-32 metric metadata"); + + let decoded_proto = proto::EventArray::decode(buffer.freeze()) + .expect("prost decode should succeed for depth-32 metric metadata"); + let decoded_array = EventArray::from(decoded_proto); + + let decoded_event = decoded_array.into_events().next().unwrap().into_metric(); + assert_eq!( + decoded_event.metadata().value(), + &value, + "metric metadata value should roundtrip at MAX_NESTING_DEPTH" + ); +} + #[test] fn check_value_depth_with_configurable_limit() { let mut value = Value::from("leaf"); From 13bc23718d3df76d3c75e4465dbcd6c2633827c3 Mon Sep 17 00:00:00 2001 From: Connor Yang Date: Wed, 15 Apr 2026 13:40:23 -0400 Subject: [PATCH 16/49] Replace individual nesting tests with exhaustive path coverage 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. --- .../src/event/test/serialization.rs | 487 +++++++++--------- 1 file changed, 230 insertions(+), 257 deletions(-) diff --git a/lib/vector-core/src/event/test/serialization.rs b/lib/vector-core/src/event/test/serialization.rs index 9485950161d87..24d92c17c9b14 100644 --- a/lib/vector-core/src/event/test/serialization.rs +++ b/lib/vector-core/src/event/test/serialization.rs @@ -102,88 +102,261 @@ fn type_serialization() { // --------------------------------------------------------------------------- // Nesting depth validation tests // --------------------------------------------------------------------------- +// +// MAX_NESTING_DEPTH (32) is the highest Value depth that roundtrips through prost +// encode/decode across ALL proto encoding paths. The limit is constrained by prost's +// fixed recursion budget of 100 (RECURSION_LIMIT in prost/src/lib.rs). +// +// Each Value nesting level consumes 3 prost recursion entries (Value + ValueMap + +// map_entry for Objects), and each encoding path has a different number of proto +// wrapper messages before the Value tree starts. The tightest path is metadata_full +// (EventArray → *Array → Event → Metadata → Value) which uses exactly 100/100 +// budget at depth 32. +// +// The exhaustive test below (`max_nesting_depth_is_correct_for_all_proto_paths`) +// verifies every encoding path at both boundaries: depth 32 must roundtrip, depth 33 +// must fail prost decode. If a proto schema change adds wrapper messages, or prost +// changes its recursion limit, this test will fail and tell you exactly which path +// broke. -/// Create a `LogEvent` with the specified nesting depth of maps. +/// Creates a Value with the specified number of nested Object wrapping levels. /// -/// The resulting `LogEvent` has a root Object (depth 0) containing a "data" key -/// whose value is `wrapping_levels` levels of nested maps with a string leaf. -/// The leaf string is at effective depth `wrapping_levels + 1` from the root. -fn create_nested_log_event(wrapping_levels: usize) -> LogEvent { +/// Returns a Value that is `wrapping_levels` nested Objects deep, with a string leaf. +/// `check_value_depth` will measure this as depth `wrapping_levels` (the leaf). +fn create_nested_value(wrapping_levels: usize) -> Value { let mut value = Value::from("innermost"); for _ in 0..wrapping_levels { let mut map = ObjectMap::new(); map.insert("nested".into(), value); value = Value::Object(map); } + value +} + +/// Create a `LogEvent` with the specified nesting depth of maps. +/// +/// The resulting `LogEvent` has a root Object (depth 0) containing a "data" key +/// whose value is `wrapping_levels` levels of nested maps with a string leaf. +/// The leaf string is at effective depth `wrapping_levels + 1` from the root. +fn create_nested_log_event(wrapping_levels: usize) -> LogEvent { let mut event = LogEvent::default(); - event.insert("data", value); + event.insert("data", create_nested_value(wrapping_levels)); event } -/// The vector sink encodes events as `EventWrapper` (not `EventArray`), which has a -/// different proto structure. Verify that it can also decode at `MAX_NESTING_DEPTH`. -#[test] -fn event_wrapper_path_safe_at_max_depth() { - let event = create_nested_log_event(super::super::ser::MAX_NESTING_DEPTH - 1); - let wrapper = proto::EventWrapper::from(Event::Log(event)); +/// Describes a proto encoding path for testing. +struct ProtoPath { + /// Human-readable name for error messages. + name: &'static str, + /// Creates an EventArray with a Value at the given `check_value_depth` depth. + make_event_array: fn(depth: usize) -> EventArray, + /// Creates an EventWrapper with a Value at the given depth (None if path is + /// EventArray-only and has no EventWrapper equivalent). + make_event_wrapper: Option Event>, +} +/// Returns all proto encoding paths that carry an arbitrary `Value`. +/// +/// Each path represents a distinct route through the proto schema where a deeply +/// nested Value could hit prost's recursion limit. When adding new proto message +/// wrappers or new Value-carrying fields, add the corresponding path here. +fn all_proto_paths() -> Vec { + vec![ + // --- Log event data (Object root → Log.fields) --- + ProtoPath { + name: "EventArray → LogArray → Log → Log.fields", + make_event_array: |depth| { + // depth-1 wrapping levels + "data" key = leaf at `depth` + let event = create_nested_log_event(depth - 1); + EventArray::Logs(LogArray::from(vec![event])) + }, + make_event_wrapper: Some(|depth| { + Event::Log(create_nested_log_event(depth - 1)) + }), + }, + // --- Log metadata (via metadata_full → Metadata → Value) --- + ProtoPath { + name: "EventArray → LogArray → Log → Metadata → Value (metadata_full)", + make_event_array: |depth| { + let mut event = LogEvent::from("data"); + *event.metadata_mut().value_mut() = create_nested_value(depth); + EventArray::Logs(LogArray::from(vec![event])) + }, + make_event_wrapper: Some(|depth| { + let mut event = LogEvent::from("data"); + *event.metadata_mut().value_mut() = create_nested_value(depth); + Event::Log(event) + }), + }, + // --- Trace event data (Trace.fields) --- + ProtoPath { + name: "EventArray → TraceArray → Trace → Trace.fields", + make_event_array: |depth| { + let mut trace = TraceEvent::default(); + trace.insert("data", create_nested_value(depth - 1)); + EventArray::Traces(TraceArray::from(vec![trace])) + }, + make_event_wrapper: Some(|depth| { + let mut trace = TraceEvent::default(); + trace.insert("data", create_nested_value(depth - 1)); + Event::Trace(trace) + }), + }, + // --- Trace metadata (via metadata_full) --- + ProtoPath { + name: "EventArray → TraceArray → Trace → Metadata → Value (metadata_full)", + make_event_array: |depth| { + let mut trace = TraceEvent::default(); + trace.insert("placeholder", "data"); + *trace.metadata_mut().value_mut() = create_nested_value(depth); + EventArray::Traces(TraceArray::from(vec![trace])) + }, + make_event_wrapper: Some(|depth| { + let mut trace = TraceEvent::default(); + trace.insert("placeholder", "data"); + *trace.metadata_mut().value_mut() = create_nested_value(depth); + Event::Trace(trace) + }), + }, + // --- Metric metadata (via metadata_full) --- + ProtoPath { + name: "EventArray → MetricArray → Metric → Metadata → Value (metadata_full)", + make_event_array: |depth| { + let mut metric = Metric::new( + "test", + MetricKind::Incremental, + MetricValue::Counter { value: 1.0 }, + ); + *metric.metadata_mut().value_mut() = create_nested_value(depth); + EventArray::Metrics(MetricArray::from(vec![metric])) + }, + make_event_wrapper: Some(|depth| { + let mut metric = Metric::new( + "test", + MetricKind::Incremental, + MetricValue::Counter { value: 1.0 }, + ); + *metric.metadata_mut().value_mut() = create_nested_value(depth); + Event::Metric(metric) + }), + }, + ] +} + +/// Helper: encode an EventArray to proto bytes, bypassing the nesting gate. +fn proto_encode_event_array(array: EventArray) -> BytesMut { + let proto_array = proto::EventArray::from(array); let mut buffer = BytesMut::with_capacity(65536); - wrapper.encode(&mut buffer).unwrap(); - let result = proto::EventWrapper::decode(buffer.freeze()); - assert!( - result.is_ok(), - "EventWrapper path should succeed at MAX_NESTING_DEPTH" - ); + proto_array.encode(&mut buffer).expect("prost encode should always succeed (no encode-side recursion limit)"); + buffer } -/// Demonstrates the root cause: prost encodes deeply nested events successfully, -/// but fails to decode them due to its internal recursion limit of 100. -#[test] -fn deeply_nested_event_encodes_but_fails_prost_decode() { - // 33 wrapping levels exceeds the prost decode limit - let event = create_nested_log_event(33); - let array = EventArray::Logs(LogArray::from(vec![event])); +/// Helper: encode an Event as an EventWrapper to proto bytes. +fn proto_encode_event_wrapper(event: Event) -> BytesMut { + let wrapper = proto::EventWrapper::from(event); + let mut buffer = BytesMut::with_capacity(65536); + wrapper.encode(&mut buffer).expect("prost encode should always succeed"); + buffer +} - // Bypass our nesting check: convert directly to proto and encode raw - let proto_array = proto::EventArray::from(array); - let mut buffer = BytesMut::with_capacity(16384); - proto_array - .encode(&mut buffer) - .expect("prost encode should succeed even for deeply nested data"); +/// Exhaustive test: verify MAX_NESTING_DEPTH is exactly right across all proto paths. +/// +/// "Exactly right" means: +/// 1. ALL paths roundtrip at MAX_NESTING_DEPTH (limit isn't too high) +/// 2. At least one path fails decode at MAX_NESTING_DEPTH + 1 (limit isn't too low) +/// +/// This is the single source of truth for the nesting limit. If a proto schema change +/// adds wrapper messages, prost changes its recursion limit, or a new Value-carrying +/// field is added, this test will fail and identify the broken path. +#[test] +fn max_nesting_depth_is_correct_for_all_proto_paths() { + let max = super::super::ser::MAX_NESTING_DEPTH; + let paths = all_proto_paths(); + + // 1. Every path must roundtrip at MAX_NESTING_DEPTH. + // If this fails, the limit is too high — lower it or fix the proto schema. + for path in &paths { + // EventArray + let buffer = proto_encode_event_array((path.make_event_array)(max)); + assert!( + proto::EventArray::decode(buffer.freeze()).is_ok(), + "EventArray decode FAILED at depth {max} for path: {name}\n\ + MAX_NESTING_DEPTH is too high for this path. \ + Lower the limit or check for new proto wrapper messages.", + name = path.name, + ); + + // EventWrapper + if let Some(make_wrapper) = path.make_event_wrapper { + let buffer = proto_encode_event_wrapper(make_wrapper(max)); + assert!( + proto::EventWrapper::decode(buffer.freeze()).is_ok(), + "EventWrapper decode FAILED at depth {max} for path: {name}\n\ + MAX_NESTING_DEPTH is too high for this path via EventWrapper.", + name = path.name, + ); + } + } - // Decode fails: prost hits its recursion limit - let result = proto::EventArray::decode(buffer.freeze()); + // 2. At least one path must fail at MAX_NESTING_DEPTH + 1. + // If no path fails, the limit is too low — raise it. + let any_event_array_fails = paths.iter().any(|path| { + let buffer = proto_encode_event_array((path.make_event_array)(max + 1)); + proto::EventArray::decode(buffer.freeze()).is_err() + }); + let any_event_wrapper_fails = paths.iter().any(|path| { + path.make_event_wrapper.map_or(false, |make_wrapper| { + let buffer = proto_encode_event_wrapper(make_wrapper(max + 1)); + proto::EventWrapper::decode(buffer.freeze()).is_err() + }) + }); assert!( - result.is_err(), - "prost decode should fail for events exceeding the recursion limit" + any_event_array_fails, + "All EventArray paths succeeded at depth {}. MAX_NESTING_DEPTH ({}) could be raised.", + max + 1, max, + ); + assert!( + any_event_wrapper_fails, + "All EventWrapper paths succeeded at depth {}. MAX_NESTING_DEPTH ({}) could be raised.", + max + 1, max, ); } -/// Confirms that events at exactly the max allowed depth encode AND decode via raw prost. +/// Verify the nesting gate rejects events at MAX_NESTING_DEPTH + 1 for all paths. #[test] -fn event_at_max_depth_roundtrips_via_prost() { - // 31 wrapping levels + "data" key = leaf at depth 32 = MAX_NESTING_DEPTH - let event = create_nested_log_event(31); - let original = event.clone(); - let array = EventArray::Logs(LogArray::from(vec![event])); +fn nesting_gate_rejects_all_paths_above_max_depth() { + let max = super::super::ser::MAX_NESTING_DEPTH; + + for path in all_proto_paths() { + let array = (path.make_event_array)(max + 1); + let mut buffer = BytesMut::with_capacity(8192); + let result = array.encode(&mut buffer); + assert!( + matches!(result, Err(super::super::ser::EncodeError::NestingTooDeep { .. })), + "nesting gate should reject depth {} for path: {}", + max + 1, + path.name, + ); + } +} - let proto_array = proto::EventArray::from(array); - let mut buffer = BytesMut::with_capacity(8192); - proto_array - .encode(&mut buffer) - .expect("prost encode should succeed at MAX_NESTING_DEPTH"); - - let decoded_proto = proto::EventArray::decode(buffer.freeze()) - .expect("prost decode should succeed at MAX_NESTING_DEPTH"); - let decoded_array = EventArray::from(decoded_proto); - - let decoded_event = decoded_array.into_events().next().unwrap().into_log(); - assert_eq!( - decoded_event.value().get("data"), - original.value().get("data"), - ); +/// Verify the nesting gate accepts events at exactly MAX_NESTING_DEPTH for all paths. +#[test] +fn nesting_gate_accepts_all_paths_at_max_depth() { + let max = super::super::ser::MAX_NESTING_DEPTH; + + for path in all_proto_paths() { + let array = (path.make_event_array)(max); + let mut buffer = BytesMut::with_capacity(65536); + assert!( + array.encode(&mut buffer).is_ok(), + "nesting gate should accept depth {max} for path: {}", + path.name, + ); + } } +/// Verify flat events pass without issues. #[test] fn nesting_gate_accepts_flat_event() { let mut event = LogEvent::from("hello world"); @@ -195,71 +368,7 @@ fn nesting_gate_accepts_flat_event() { assert!(events.encode(&mut buffer).is_ok()); } -#[test] -fn nesting_gate_accepts_event_at_max_depth() { - // 31 wrapping levels + "data" key = leaf at depth 32 = MAX_NESTING_DEPTH - let event = create_nested_log_event(31); - let original = event.clone(); - let events = EventArray::Logs(LogArray::from(vec![event])); - let mut buffer = BytesMut::with_capacity(8192); - - events - .encode(&mut buffer) - .expect("encode should succeed at exactly MAX_NESTING_DEPTH"); - - let decoded = EventArray::decode(EventArray::get_metadata(), buffer) - .expect("decode should succeed at exactly MAX_NESTING_DEPTH"); - - let decoded_event = decoded.into_events().next().unwrap().into_log(); - assert_eq!( - decoded_event.value().get("data"), - original.value().get("data"), - ); -} - -#[test] -fn nesting_gate_rejects_event_exceeding_max_depth() { - // 32 wrapping levels + "data" key = leaf at depth 33, exceeds MAX_NESTING_DEPTH (32) - let event = create_nested_log_event(32); - let events = EventArray::Logs(LogArray::from(vec![event])); - let mut buffer = BytesMut::with_capacity(8192); - - let result = events.encode(&mut buffer); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!( - matches!( - err, - super::super::ser::EncodeError::NestingTooDeep { - depth: 33, - max_depth: 32 - } - ), - "expected NestingTooDeep error, got: {err:?}" - ); -} - -#[test] -fn nesting_gate_rejects_trace_event_exceeding_max_depth() { - let mut value = Value::from("innermost"); - for _ in 0..32 { - let mut map = ObjectMap::new(); - map.insert("nested".into(), value); - value = Value::Object(map); - } - let mut trace = TraceEvent::default(); - trace.insert("data", value); - - let events = EventArray::Traces(TraceArray::from(vec![trace])); - let mut buffer = BytesMut::with_capacity(8192); - - let result = events.encode(&mut buffer); - assert!(matches!( - result, - Err(super::super::ser::EncodeError::NestingTooDeep { .. }) - )); -} - +/// Verify flat metrics pass without issues. #[test] fn nesting_gate_accepts_metric_events() { let metric = Metric::new( @@ -272,142 +381,6 @@ fn nesting_gate_accepts_metric_events() { assert!(events.encode(&mut buffer).is_ok()); } -#[test] -fn nesting_gate_rejects_metric_with_deeply_nested_metadata() { - let mut metric = Metric::new( - "test_counter", - MetricKind::Incremental, - MetricValue::Counter { value: 1.0 }, - ); - - // 33 wrapping levels = leaf at depth 33, exceeds MAX_NESTING_DEPTH (32) - let mut value = Value::from("innermost"); - for _ in 0..33 { - let mut map = ObjectMap::new(); - map.insert("nested".into(), value); - value = Value::Object(map); - } - *metric.metadata_mut().value_mut() = value; - - let events = EventArray::Metrics(MetricArray::from(vec![metric])); - let mut buffer = BytesMut::with_capacity(8192); - - let result = events.encode(&mut buffer); - assert!( - matches!( - result, - Err(super::super::ser::EncodeError::NestingTooDeep { .. }) - ), - "should reject metrics with deeply nested metadata" - ); -} - -#[test] -fn nesting_gate_rejects_deeply_nested_metadata() { - let mut event = LogEvent::from("normal event data"); - - // 33 wrapping levels = leaf at depth 33, exceeds MAX_NESTING_DEPTH (32) - let mut value = Value::from("innermost"); - for _ in 0..33 { - let mut map = ObjectMap::new(); - map.insert("nested".into(), value); - value = Value::Object(map); - } - *event.metadata_mut().value_mut() = value; - - let events = EventArray::Logs(LogArray::from(vec![event])); - let mut buffer = BytesMut::with_capacity(8192); - - let result = events.encode(&mut buffer); - assert!( - matches!( - result, - Err(super::super::ser::EncodeError::NestingTooDeep { .. }) - ), - "should reject events with deeply nested metadata" - ); -} - -/// Creates a Value with the specified number of nested Object wrapping levels. -/// -/// Returns a Value that is `wrapping_levels` nested Objects deep, with a string leaf. -/// `check_value_depth` will measure this as depth `wrapping_levels` (the leaf). -fn create_nested_value(wrapping_levels: usize) -> Value { - let mut value = Value::from("innermost"); - for _ in 0..wrapping_levels { - let mut map = ObjectMap::new(); - map.insert("nested".into(), value); - value = Value::Object(map); - } - value -} - -/// The metadata_full encoding path (EventArray → LogArray → Log → Metadata → Value) is the -/// tightest proto path, using exactly 100 of prost's 100 recursion budget at depth 32. -/// This test proves depth-32 metadata roundtrips via raw prost encode/decode. -#[test] -fn metadata_at_max_depth_roundtrips_via_prost() { - let mut event = LogEvent::from("normal event data"); - - // 32 wrapping levels = leaf at depth 32 = MAX_NESTING_DEPTH - let value = create_nested_value(32); - *event.metadata_mut().value_mut() = value.clone(); - - let array = EventArray::Logs(LogArray::from(vec![event])); - - // Encode via proto directly (same path as Encodable::encode but without the depth gate) - let proto_array = proto::EventArray::from(array); - let mut buffer = BytesMut::with_capacity(16384); - proto_array - .encode(&mut buffer) - .expect("prost encode should succeed for depth-32 metadata"); - - // Decode must also succeed — this is the tightest path (exactly 100/100 prost budget) - let decoded_proto = proto::EventArray::decode(buffer.freeze()) - .expect("prost decode should succeed for depth-32 metadata (tightest path)"); - let decoded_array = EventArray::from(decoded_proto); - - let decoded_event = decoded_array.into_events().next().unwrap().into_log(); - assert_eq!( - decoded_event.metadata().value(), - &value, - "metadata value should roundtrip at MAX_NESTING_DEPTH" - ); -} - -/// Same test for metric metadata, which follows the same tightest path: -/// EventArray → MetricArray → Metric → Metadata → Value -#[test] -fn metric_metadata_at_max_depth_roundtrips_via_prost() { - let mut metric = Metric::new( - "test_counter", - MetricKind::Incremental, - MetricValue::Counter { value: 1.0 }, - ); - - let value = create_nested_value(32); - *metric.metadata_mut().value_mut() = value.clone(); - - let array = EventArray::Metrics(MetricArray::from(vec![metric])); - - let proto_array = proto::EventArray::from(array); - let mut buffer = BytesMut::with_capacity(16384); - proto_array - .encode(&mut buffer) - .expect("prost encode should succeed for depth-32 metric metadata"); - - let decoded_proto = proto::EventArray::decode(buffer.freeze()) - .expect("prost decode should succeed for depth-32 metric metadata"); - let decoded_array = EventArray::from(decoded_proto); - - let decoded_event = decoded_array.into_events().next().unwrap().into_metric(); - assert_eq!( - decoded_event.metadata().value(), - &value, - "metric metadata value should roundtrip at MAX_NESTING_DEPTH" - ); -} - #[test] fn check_value_depth_with_configurable_limit() { let mut value = Value::from("leaf"); From 79e46b811f9ba48bcb62471db9496fcfd6a4d647 Mon Sep 17 00:00:00 2001 From: Connor Yang Date: Wed, 15 Apr 2026 13:49:16 -0400 Subject: [PATCH 17/49] Simplify nesting tests: saturate all Value fields instead of enumerating 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. --- .../src/event/test/serialization.rs | 313 +++++++----------- 1 file changed, 114 insertions(+), 199 deletions(-) diff --git a/lib/vector-core/src/event/test/serialization.rs b/lib/vector-core/src/event/test/serialization.rs index 24d92c17c9b14..3a95ff44f300f 100644 --- a/lib/vector-core/src/event/test/serialization.rs +++ b/lib/vector-core/src/event/test/serialization.rs @@ -113,11 +113,12 @@ fn type_serialization() { // (EventArray → *Array → Event → Metadata → Value) which uses exactly 100/100 // budget at depth 32. // -// The exhaustive test below (`max_nesting_depth_is_correct_for_all_proto_paths`) -// verifies every encoding path at both boundaries: depth 32 must roundtrip, depth 33 -// must fail prost decode. If a proto schema change adds wrapper messages, or prost -// changes its recursion limit, this test will fail and tell you exactly which path -// broke. +// Rather than enumerating individual proto paths, the tests below create events with +// ALL Value-carrying fields set to max depth simultaneously. The proto conversion code +// populates every field (including deprecated ones like Log.metadata), so a single +// roundtrip covers every path for a given event type. If a new Value-carrying field +// is added to the proto schema, the conversion code must populate it, and these tests +// automatically cover it with zero maintenance. /// Creates a Value with the specified number of nested Object wrapping levels. /// @@ -133,252 +134,166 @@ fn create_nested_value(wrapping_levels: usize) -> Value { value } -/// Create a `LogEvent` with the specified nesting depth of maps. -/// -/// The resulting `LogEvent` has a root Object (depth 0) containing a "data" key -/// whose value is `wrapping_levels` levels of nested maps with a string leaf. -/// The leaf string is at effective depth `wrapping_levels + 1` from the root. -fn create_nested_log_event(wrapping_levels: usize) -> LogEvent { +/// Create a LogEvent with both event data and metadata at the given nesting depth. +fn create_saturated_log(depth: usize) -> LogEvent { + let nested = create_nested_value(depth); let mut event = LogEvent::default(); - event.insert("data", create_nested_value(wrapping_levels)); + // Event data: "data" key + (depth-1) wrapping levels = leaf at `depth`. + // This covers Log.fields (or Log.value for non-Object roots). + event.insert("data", create_nested_value(depth - 1)); + // Metadata: covers both Log.metadata (deprecated) and Log.metadata_full.value. + *event.metadata_mut().value_mut() = nested; event } -/// Describes a proto encoding path for testing. -struct ProtoPath { - /// Human-readable name for error messages. - name: &'static str, - /// Creates an EventArray with a Value at the given `check_value_depth` depth. - make_event_array: fn(depth: usize) -> EventArray, - /// Creates an EventWrapper with a Value at the given depth (None if path is - /// EventArray-only and has no EventWrapper equivalent). - make_event_wrapper: Option Event>, +/// Create a TraceEvent with both event data and metadata at the given nesting depth. +fn create_saturated_trace(depth: usize) -> TraceEvent { + let nested = create_nested_value(depth); + let mut trace = TraceEvent::default(); + trace.insert("data", create_nested_value(depth - 1)); + *trace.metadata_mut().value_mut() = nested; + trace } -/// Returns all proto encoding paths that carry an arbitrary `Value`. -/// -/// Each path represents a distinct route through the proto schema where a deeply -/// nested Value could hit prost's recursion limit. When adding new proto message -/// wrappers or new Value-carrying fields, add the corresponding path here. -fn all_proto_paths() -> Vec { - vec![ - // --- Log event data (Object root → Log.fields) --- - ProtoPath { - name: "EventArray → LogArray → Log → Log.fields", - make_event_array: |depth| { - // depth-1 wrapping levels + "data" key = leaf at `depth` - let event = create_nested_log_event(depth - 1); - EventArray::Logs(LogArray::from(vec![event])) - }, - make_event_wrapper: Some(|depth| { - Event::Log(create_nested_log_event(depth - 1)) - }), - }, - // --- Log metadata (via metadata_full → Metadata → Value) --- - ProtoPath { - name: "EventArray → LogArray → Log → Metadata → Value (metadata_full)", - make_event_array: |depth| { - let mut event = LogEvent::from("data"); - *event.metadata_mut().value_mut() = create_nested_value(depth); - EventArray::Logs(LogArray::from(vec![event])) - }, - make_event_wrapper: Some(|depth| { - let mut event = LogEvent::from("data"); - *event.metadata_mut().value_mut() = create_nested_value(depth); - Event::Log(event) - }), - }, - // --- Trace event data (Trace.fields) --- - ProtoPath { - name: "EventArray → TraceArray → Trace → Trace.fields", - make_event_array: |depth| { - let mut trace = TraceEvent::default(); - trace.insert("data", create_nested_value(depth - 1)); - EventArray::Traces(TraceArray::from(vec![trace])) - }, - make_event_wrapper: Some(|depth| { - let mut trace = TraceEvent::default(); - trace.insert("data", create_nested_value(depth - 1)); - Event::Trace(trace) - }), - }, - // --- Trace metadata (via metadata_full) --- - ProtoPath { - name: "EventArray → TraceArray → Trace → Metadata → Value (metadata_full)", - make_event_array: |depth| { - let mut trace = TraceEvent::default(); - trace.insert("placeholder", "data"); - *trace.metadata_mut().value_mut() = create_nested_value(depth); - EventArray::Traces(TraceArray::from(vec![trace])) - }, - make_event_wrapper: Some(|depth| { - let mut trace = TraceEvent::default(); - trace.insert("placeholder", "data"); - *trace.metadata_mut().value_mut() = create_nested_value(depth); - Event::Trace(trace) - }), - }, - // --- Metric metadata (via metadata_full) --- - ProtoPath { - name: "EventArray → MetricArray → Metric → Metadata → Value (metadata_full)", - make_event_array: |depth| { - let mut metric = Metric::new( - "test", - MetricKind::Incremental, - MetricValue::Counter { value: 1.0 }, - ); - *metric.metadata_mut().value_mut() = create_nested_value(depth); - EventArray::Metrics(MetricArray::from(vec![metric])) - }, - make_event_wrapper: Some(|depth| { - let mut metric = Metric::new( - "test", - MetricKind::Incremental, - MetricValue::Counter { value: 1.0 }, - ); - *metric.metadata_mut().value_mut() = create_nested_value(depth); - Event::Metric(metric) - }), - }, - ] +/// Create a Metric with metadata at the given nesting depth. +/// (Metric values have fixed structure — only metadata carries arbitrary Values.) +fn create_saturated_metric(depth: usize) -> Metric { + let mut metric = Metric::new( + "test", + MetricKind::Incremental, + MetricValue::Counter { value: 1.0 }, + ); + *metric.metadata_mut().value_mut() = create_nested_value(depth); + metric } -/// Helper: encode an EventArray to proto bytes, bypassing the nesting gate. -fn proto_encode_event_array(array: EventArray) -> BytesMut { - let proto_array = proto::EventArray::from(array); - let mut buffer = BytesMut::with_capacity(65536); - proto_array.encode(&mut buffer).expect("prost encode should always succeed (no encode-side recursion limit)"); - buffer +/// Build all three EventArray variants with every Value field at the given depth. +fn saturated_event_arrays(depth: usize) -> Vec<(&'static str, EventArray)> { + vec![ + ("Log", EventArray::Logs(LogArray::from(vec![create_saturated_log(depth)]))), + ("Trace", EventArray::Traces(TraceArray::from(vec![create_saturated_trace(depth)]))), + ("Metric", EventArray::Metrics(MetricArray::from(vec![create_saturated_metric(depth)]))), + ] } -/// Helper: encode an Event as an EventWrapper to proto bytes. -fn proto_encode_event_wrapper(event: Event) -> BytesMut { - let wrapper = proto::EventWrapper::from(event); - let mut buffer = BytesMut::with_capacity(65536); - wrapper.encode(&mut buffer).expect("prost encode should always succeed"); - buffer +/// Build all three Event variants for EventWrapper encoding. +fn saturated_events(depth: usize) -> Vec<(&'static str, Event)> { + vec![ + ("Log", Event::Log(create_saturated_log(depth))), + ("Trace", Event::Trace(create_saturated_trace(depth))), + ("Metric", Event::Metric(create_saturated_metric(depth))), + ] } -/// Exhaustive test: verify MAX_NESTING_DEPTH is exactly right across all proto paths. -/// -/// "Exactly right" means: -/// 1. ALL paths roundtrip at MAX_NESTING_DEPTH (limit isn't too high) -/// 2. At least one path fails decode at MAX_NESTING_DEPTH + 1 (limit isn't too low) +/// Verify MAX_NESTING_DEPTH is exactly right: all event types roundtrip at depth 32, +/// and at least one fails prost decode at depth 33. /// -/// This is the single source of truth for the nesting limit. If a proto schema change -/// adds wrapper messages, prost changes its recursion limit, or a new Value-carrying -/// field is added, this test will fail and identify the broken path. +/// Each event has ALL Value-carrying fields saturated at the test depth, so every +/// proto field (including deprecated ones) is exercised. No path enumeration needed — +/// if a new Value field is added to the proto schema, the conversion code populates it +/// and this test covers it automatically. #[test] -fn max_nesting_depth_is_correct_for_all_proto_paths() { +fn max_nesting_depth_is_correct() { let max = super::super::ser::MAX_NESTING_DEPTH; - let paths = all_proto_paths(); - // 1. Every path must roundtrip at MAX_NESTING_DEPTH. - // If this fails, the limit is too high — lower it or fix the proto schema. - for path in &paths { - // EventArray - let buffer = proto_encode_event_array((path.make_event_array)(max)); + // --- Depth MAX must roundtrip for all event types --- + + for (name, array) in saturated_event_arrays(max) { + let proto_array = proto::EventArray::from(array); + let mut buf = BytesMut::with_capacity(65536); + proto_array.encode(&mut buf).unwrap(); assert!( - proto::EventArray::decode(buffer.freeze()).is_ok(), - "EventArray decode FAILED at depth {max} for path: {name}\n\ - MAX_NESTING_DEPTH is too high for this path. \ - Lower the limit or check for new proto wrapper messages.", - name = path.name, + proto::EventArray::decode(buf.freeze()).is_ok(), + "EventArray decode FAILED at depth {max} for {name}.\n\ + MAX_NESTING_DEPTH is too high — lower it or check for new proto wrappers.", ); + } - // EventWrapper - if let Some(make_wrapper) = path.make_event_wrapper { - let buffer = proto_encode_event_wrapper(make_wrapper(max)); - assert!( - proto::EventWrapper::decode(buffer.freeze()).is_ok(), - "EventWrapper decode FAILED at depth {max} for path: {name}\n\ - MAX_NESTING_DEPTH is too high for this path via EventWrapper.", - name = path.name, - ); - } + for (name, event) in saturated_events(max) { + let wrapper = proto::EventWrapper::from(event); + let mut buf = BytesMut::with_capacity(65536); + wrapper.encode(&mut buf).unwrap(); + assert!( + proto::EventWrapper::decode(buf.freeze()).is_ok(), + "EventWrapper decode FAILED at depth {max} for {name}.\n\ + MAX_NESTING_DEPTH is too high for the EventWrapper path.", + ); } - // 2. At least one path must fail at MAX_NESTING_DEPTH + 1. - // If no path fails, the limit is too low — raise it. - let any_event_array_fails = paths.iter().any(|path| { - let buffer = proto_encode_event_array((path.make_event_array)(max + 1)); - proto::EventArray::decode(buffer.freeze()).is_err() - }); - let any_event_wrapper_fails = paths.iter().any(|path| { - path.make_event_wrapper.map_or(false, |make_wrapper| { - let buffer = proto_encode_event_wrapper(make_wrapper(max + 1)); - proto::EventWrapper::decode(buffer.freeze()).is_err() - }) + // --- Depth MAX+1 must fail for at least one event type --- + // (proves the limit can't be raised without hitting prost's recursion limit) + + let any_array_fails = saturated_event_arrays(max + 1).into_iter().any(|(_, array)| { + let proto_array = proto::EventArray::from(array); + let mut buf = BytesMut::with_capacity(65536); + proto_array.encode(&mut buf).unwrap(); + proto::EventArray::decode(buf.freeze()).is_err() }); assert!( - any_event_array_fails, - "All EventArray paths succeeded at depth {}. MAX_NESTING_DEPTH ({}) could be raised.", + any_array_fails, + "All EventArray types decoded at depth {}. MAX_NESTING_DEPTH ({}) could be raised.", max + 1, max, ); + + let any_wrapper_fails = saturated_events(max + 1).into_iter().any(|(_, event)| { + let wrapper = proto::EventWrapper::from(event); + let mut buf = BytesMut::with_capacity(65536); + wrapper.encode(&mut buf).unwrap(); + proto::EventWrapper::decode(buf.freeze()).is_err() + }); assert!( - any_event_wrapper_fails, - "All EventWrapper paths succeeded at depth {}. MAX_NESTING_DEPTH ({}) could be raised.", + any_wrapper_fails, + "All EventWrapper types decoded at depth {}. MAX_NESTING_DEPTH ({}) could be raised.", max + 1, max, ); } -/// Verify the nesting gate rejects events at MAX_NESTING_DEPTH + 1 for all paths. +/// Verify the nesting gate accepts all event types at MAX_NESTING_DEPTH. #[test] -fn nesting_gate_rejects_all_paths_above_max_depth() { - let max = super::super::ser::MAX_NESTING_DEPTH; - - for path in all_proto_paths() { - let array = (path.make_event_array)(max + 1); - let mut buffer = BytesMut::with_capacity(8192); - let result = array.encode(&mut buffer); +fn nesting_gate_accepts_all_types_at_max_depth() { + for (name, array) in saturated_event_arrays(super::super::ser::MAX_NESTING_DEPTH) { + let mut buf = BytesMut::with_capacity(65536); assert!( - matches!(result, Err(super::super::ser::EncodeError::NestingTooDeep { .. })), - "nesting gate should reject depth {} for path: {}", - max + 1, - path.name, + array.encode(&mut buf).is_ok(), + "nesting gate rejected {name} at MAX_NESTING_DEPTH", ); } } -/// Verify the nesting gate accepts events at exactly MAX_NESTING_DEPTH for all paths. +/// Verify the nesting gate rejects all event types at MAX_NESTING_DEPTH + 1. #[test] -fn nesting_gate_accepts_all_paths_at_max_depth() { +fn nesting_gate_rejects_all_types_above_max_depth() { let max = super::super::ser::MAX_NESTING_DEPTH; - - for path in all_proto_paths() { - let array = (path.make_event_array)(max); - let mut buffer = BytesMut::with_capacity(65536); + for (name, array) in saturated_event_arrays(max + 1) { + let mut buf = BytesMut::with_capacity(65536); assert!( - array.encode(&mut buffer).is_ok(), - "nesting gate should accept depth {max} for path: {}", - path.name, + matches!( + array.encode(&mut buf), + Err(super::super::ser::EncodeError::NestingTooDeep { .. }) + ), + "nesting gate should reject {name} at depth {}", + max + 1, ); } } /// Verify flat events pass without issues. #[test] -fn nesting_gate_accepts_flat_event() { - let mut event = LogEvent::from("hello world"); - event.insert("foo", "bar"); - event.insert("count", 42); +fn nesting_gate_accepts_flat_events() { + let mut log = LogEvent::from("hello world"); + log.insert("foo", "bar"); + let events = EventArray::Logs(LogArray::from(vec![log])); + let mut buf = BytesMut::with_capacity(1024); + assert!(events.encode(&mut buf).is_ok()); - let events = EventArray::Logs(LogArray::from(vec![event])); - let mut buffer = BytesMut::with_capacity(1024); - assert!(events.encode(&mut buffer).is_ok()); -} - -/// Verify flat metrics pass without issues. -#[test] -fn nesting_gate_accepts_metric_events() { let metric = Metric::new( "test_counter", MetricKind::Incremental, MetricValue::Counter { value: 1.0 }, ); let events = EventArray::Metrics(MetricArray::from(vec![metric])); - let mut buffer = BytesMut::with_capacity(1024); - assert!(events.encode(&mut buffer).is_ok()); + let mut buf = BytesMut::with_capacity(1024); + assert!(events.encode(&mut buf).is_ok()); } #[test] From 6800e85988b8683488053765b6721a734cb34853 Mon Sep 17 00:00:00 2001 From: Connor Yang Date: Wed, 15 Apr 2026 14:29:28 -0400 Subject: [PATCH 18/49] style: auto-fix lint/format errors --- .../src/event/test/serialization.rs | 35 +++++++++++++------ 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/lib/vector-core/src/event/test/serialization.rs b/lib/vector-core/src/event/test/serialization.rs index 3a95ff44f300f..3985e18f1838b 100644 --- a/lib/vector-core/src/event/test/serialization.rs +++ b/lib/vector-core/src/event/test/serialization.rs @@ -170,9 +170,18 @@ fn create_saturated_metric(depth: usize) -> Metric { /// Build all three EventArray variants with every Value field at the given depth. fn saturated_event_arrays(depth: usize) -> Vec<(&'static str, EventArray)> { vec![ - ("Log", EventArray::Logs(LogArray::from(vec![create_saturated_log(depth)]))), - ("Trace", EventArray::Traces(TraceArray::from(vec![create_saturated_trace(depth)]))), - ("Metric", EventArray::Metrics(MetricArray::from(vec![create_saturated_metric(depth)]))), + ( + "Log", + EventArray::Logs(LogArray::from(vec![create_saturated_log(depth)])), + ), + ( + "Trace", + EventArray::Traces(TraceArray::from(vec![create_saturated_trace(depth)])), + ), + ( + "Metric", + EventArray::Metrics(MetricArray::from(vec![create_saturated_metric(depth)])), + ), ] } @@ -223,16 +232,19 @@ fn max_nesting_depth_is_correct() { // --- Depth MAX+1 must fail for at least one event type --- // (proves the limit can't be raised without hitting prost's recursion limit) - let any_array_fails = saturated_event_arrays(max + 1).into_iter().any(|(_, array)| { - let proto_array = proto::EventArray::from(array); - let mut buf = BytesMut::with_capacity(65536); - proto_array.encode(&mut buf).unwrap(); - proto::EventArray::decode(buf.freeze()).is_err() - }); + let any_array_fails = saturated_event_arrays(max + 1) + .into_iter() + .any(|(_, array)| { + let proto_array = proto::EventArray::from(array); + let mut buf = BytesMut::with_capacity(65536); + proto_array.encode(&mut buf).unwrap(); + proto::EventArray::decode(buf.freeze()).is_err() + }); assert!( any_array_fails, "All EventArray types decoded at depth {}. MAX_NESTING_DEPTH ({}) could be raised.", - max + 1, max, + max + 1, + max, ); let any_wrapper_fails = saturated_events(max + 1).into_iter().any(|(_, event)| { @@ -244,7 +256,8 @@ fn max_nesting_depth_is_correct() { assert!( any_wrapper_fails, "All EventWrapper types decoded at depth {}. MAX_NESTING_DEPTH ({}) could be raised.", - max + 1, max, + max + 1, + max, ); } From 9ea18cd4951d0699b204c90f5bf44d5874330a2d Mon Sep 17 00:00:00 2001 From: Connor Yang Date: Wed, 15 Apr 2026 14:46:43 -0400 Subject: [PATCH 19/49] Fix clippy doc_markdown lints in nesting depth tests --- lib/vector-core/src/event/test/serialization.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/vector-core/src/event/test/serialization.rs b/lib/vector-core/src/event/test/serialization.rs index 3985e18f1838b..27c8e7d031307 100644 --- a/lib/vector-core/src/event/test/serialization.rs +++ b/lib/vector-core/src/event/test/serialization.rs @@ -134,7 +134,7 @@ fn create_nested_value(wrapping_levels: usize) -> Value { value } -/// Create a LogEvent with both event data and metadata at the given nesting depth. +/// Create a [`LogEvent`] with both event data and metadata at the given nesting depth. fn create_saturated_log(depth: usize) -> LogEvent { let nested = create_nested_value(depth); let mut event = LogEvent::default(); @@ -146,7 +146,7 @@ fn create_saturated_log(depth: usize) -> LogEvent { event } -/// Create a TraceEvent with both event data and metadata at the given nesting depth. +/// Create a [`TraceEvent`] with both event data and metadata at the given nesting depth. fn create_saturated_trace(depth: usize) -> TraceEvent { let nested = create_nested_value(depth); let mut trace = TraceEvent::default(); @@ -167,7 +167,7 @@ fn create_saturated_metric(depth: usize) -> Metric { metric } -/// Build all three EventArray variants with every Value field at the given depth. +/// Build all three `EventArray` variants with every Value field at the given depth. fn saturated_event_arrays(depth: usize) -> Vec<(&'static str, EventArray)> { vec![ ( @@ -185,7 +185,7 @@ fn saturated_event_arrays(depth: usize) -> Vec<(&'static str, EventArray)> { ] } -/// Build all three Event variants for EventWrapper encoding. +/// Build all three Event variants for `EventWrapper` encoding. fn saturated_events(depth: usize) -> Vec<(&'static str, Event)> { vec![ ("Log", Event::Log(create_saturated_log(depth))), @@ -194,7 +194,7 @@ fn saturated_events(depth: usize) -> Vec<(&'static str, Event)> { ] } -/// Verify MAX_NESTING_DEPTH is exactly right: all event types roundtrip at depth 32, +/// Verify `MAX_NESTING_DEPTH` is exactly right: all event types roundtrip at depth 32, /// and at least one fails prost decode at depth 33. /// /// Each event has ALL Value-carrying fields saturated at the test depth, so every @@ -261,7 +261,7 @@ fn max_nesting_depth_is_correct() { ); } -/// Verify the nesting gate accepts all event types at MAX_NESTING_DEPTH. +/// Verify the nesting gate accepts all event types at `MAX_NESTING_DEPTH`. #[test] fn nesting_gate_accepts_all_types_at_max_depth() { for (name, array) in saturated_event_arrays(super::super::ser::MAX_NESTING_DEPTH) { @@ -273,7 +273,7 @@ fn nesting_gate_accepts_all_types_at_max_depth() { } } -/// Verify the nesting gate rejects all event types at MAX_NESTING_DEPTH + 1. +/// Verify the nesting gate rejects all event types at `MAX_NESTING_DEPTH` + 1. #[test] fn nesting_gate_rejects_all_types_above_max_depth() { let max = super::super::ser::MAX_NESTING_DEPTH; From 7b4247804ec26dc2d72071cdde401bd7749c8ee6 Mon Sep 17 00:00:00 2001 From: Connor Yang Date: Thu, 16 Apr 2026 10:32:54 -0400 Subject: [PATCH 20/49] Add tests proving metadata_full is the tightest path 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. --- .../src/event/test/serialization.rs | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/lib/vector-core/src/event/test/serialization.rs b/lib/vector-core/src/event/test/serialization.rs index 27c8e7d031307..5dd16b3e8ddf0 100644 --- a/lib/vector-core/src/event/test/serialization.rs +++ b/lib/vector-core/src/event/test/serialization.rs @@ -290,6 +290,53 @@ fn nesting_gate_rejects_all_types_above_max_depth() { } } +/// Verify that `Log.fields` (the loosest path) can handle depth 33 when metadata is flat. +/// This proves the uniform limit of 32 is conservative for this path — it's constrained +/// by the tighter `metadata_full` path, not by `Log.fields` itself. +#[test] +fn log_fields_has_headroom_beyond_max_depth() { + let max = super::super::ser::MAX_NESTING_DEPTH; + + // Event value at depth MAX+1, but metadata left flat (default). + // This isolates the Log.fields path from the metadata_full path. + let mut event = LogEvent::default(); + event.insert("data", create_nested_value(max)); // leaf at depth max+1 + + let array = EventArray::Logs(LogArray::from(vec![event])); + let proto_array = proto::EventArray::from(array); + let mut buf = BytesMut::with_capacity(65536); + proto_array.encode(&mut buf).unwrap(); + + assert!( + proto::EventArray::decode(buf.freeze()).is_ok(), + "Log.fields path should succeed at depth {} (has headroom beyond MAX_NESTING_DEPTH)", + max + 1, + ); +} + +/// Verify that `metadata_full` (the tightest path) fails at depth 33. +/// This is the path that actually constrains `MAX_NESTING_DEPTH`. +#[test] +fn metadata_full_fails_beyond_max_depth() { + let max = super::super::ser::MAX_NESTING_DEPTH; + + // Metadata at depth MAX+1, event value left flat. + // This isolates the metadata_full path. + let mut event = LogEvent::from("flat data"); + *event.metadata_mut().value_mut() = create_nested_value(max + 1); + + let array = EventArray::Logs(LogArray::from(vec![event])); + let proto_array = proto::EventArray::from(array); + let mut buf = BytesMut::with_capacity(65536); + proto_array.encode(&mut buf).unwrap(); + + assert!( + proto::EventArray::decode(buf.freeze()).is_err(), + "metadata_full path should fail at depth {} (this is the tightest path)", + max + 1, + ); +} + /// Verify flat events pass without issues. #[test] fn nesting_gate_accepts_flat_events() { From bf5119c4859936c9953d98c7081976cbacd0148c Mon Sep 17 00:00:00 2001 From: Connor Yang Date: Thu, 16 Apr 2026 10:36:02 -0400 Subject: [PATCH 21/49] style: auto-fix lint/format errors --- lib/vector-core/src/event/test/serialization.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/vector-core/src/event/test/serialization.rs b/lib/vector-core/src/event/test/serialization.rs index 5dd16b3e8ddf0..9e98fe4af3836 100644 --- a/lib/vector-core/src/event/test/serialization.rs +++ b/lib/vector-core/src/event/test/serialization.rs @@ -300,7 +300,7 @@ fn log_fields_has_headroom_beyond_max_depth() { // Event value at depth MAX+1, but metadata left flat (default). // This isolates the Log.fields path from the metadata_full path. let mut event = LogEvent::default(); - event.insert("data", create_nested_value(max)); // leaf at depth max+1 + event.insert("data", create_nested_value(max)); // leaf at depth max+1 let array = EventArray::Logs(LogArray::from(vec![event])); let proto_array = proto::EventArray::from(array); From 5ebc6168eee51cb3c2d9730064e002bc15bd0580 Mon Sep 17 00:00:00 2001 From: Connor Yang Date: Thu, 16 Apr 2026 10:54:30 -0400 Subject: [PATCH 22/49] Test full boundaries for loosest and tightest encoding paths 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. --- .../src/event/test/serialization.rs | 79 +++++++++---------- 1 file changed, 39 insertions(+), 40 deletions(-) diff --git a/lib/vector-core/src/event/test/serialization.rs b/lib/vector-core/src/event/test/serialization.rs index 9e98fe4af3836..5c8078e4d2f9d 100644 --- a/lib/vector-core/src/event/test/serialization.rs +++ b/lib/vector-core/src/event/test/serialization.rs @@ -290,51 +290,50 @@ fn nesting_gate_rejects_all_types_above_max_depth() { } } -/// Verify that `Log.fields` (the loosest path) can handle depth 33 when metadata is flat. -/// This proves the uniform limit of 32 is conservative for this path — it's constrained -/// by the tighter `metadata_full` path, not by `Log.fields` itself. -#[test] -fn log_fields_has_headroom_beyond_max_depth() { - let max = super::super::ser::MAX_NESTING_DEPTH; - - // Event value at depth MAX+1, but metadata left flat (default). - // This isolates the Log.fields path from the metadata_full path. - let mut event = LogEvent::default(); - event.insert("data", create_nested_value(max)); // leaf at depth max+1 - - let array = EventArray::Logs(LogArray::from(vec![event])); - let proto_array = proto::EventArray::from(array); - let mut buf = BytesMut::with_capacity(65536); - proto_array.encode(&mut buf).unwrap(); - - assert!( - proto::EventArray::decode(buf.freeze()).is_ok(), - "Log.fields path should succeed at depth {} (has headroom beyond MAX_NESTING_DEPTH)", - max + 1, - ); -} - -/// Verify that `metadata_full` (the tightest path) fails at depth 33. -/// This is the path that actually constrains `MAX_NESTING_DEPTH`. +/// Verify the per-path boundaries for the loosest and tightest encoding paths. +/// +/// `Log.fields` is the loosest path (3 entries of headroom at depth 32): +/// - depth 33 succeeds (100/100 budget) +/// - depth 34 fails (103/100 budget) +/// +/// `metadata_full` is the tightest path (0 headroom at depth 32): +/// - depth 32 succeeds (100/100 budget) +/// - depth 33 fails (103/100 budget) +/// +/// The uniform `MAX_NESTING_DEPTH = 32` is set by the tightest path. #[test] -fn metadata_full_fails_beyond_max_depth() { +fn per_path_boundaries() { let max = super::super::ser::MAX_NESTING_DEPTH; - // Metadata at depth MAX+1, event value left flat. - // This isolates the metadata_full path. - let mut event = LogEvent::from("flat data"); - *event.metadata_mut().value_mut() = create_nested_value(max + 1); + // Helper: encode a LogEvent with nested value and flat metadata via EventArray. + let roundtrip_value = |depth: usize| -> bool { + let mut event = LogEvent::default(); + event.insert("data", create_nested_value(depth - 1)); + let array = EventArray::Logs(LogArray::from(vec![event])); + let proto_array = proto::EventArray::from(array); + let mut buf = BytesMut::with_capacity(65536); + proto_array.encode(&mut buf).unwrap(); + proto::EventArray::decode(buf.freeze()).is_ok() + }; + + // Helper: encode a LogEvent with flat value and nested metadata via EventArray. + let roundtrip_metadata = |depth: usize| -> bool { + let mut event = LogEvent::from("flat"); + *event.metadata_mut().value_mut() = create_nested_value(depth); + let array = EventArray::Logs(LogArray::from(vec![event])); + let proto_array = proto::EventArray::from(array); + let mut buf = BytesMut::with_capacity(65536); + proto_array.encode(&mut buf).unwrap(); + proto::EventArray::decode(buf.freeze()).is_ok() + }; - let array = EventArray::Logs(LogArray::from(vec![event])); - let proto_array = proto::EventArray::from(array); - let mut buf = BytesMut::with_capacity(65536); - proto_array.encode(&mut buf).unwrap(); + // Log.fields (loosest): succeeds at 33, fails at 34 + assert!(roundtrip_value(max + 1), "Log.fields should succeed at depth {}", max + 1); + assert!(!roundtrip_value(max + 2), "Log.fields should fail at depth {}", max + 2); - assert!( - proto::EventArray::decode(buf.freeze()).is_err(), - "metadata_full path should fail at depth {} (this is the tightest path)", - max + 1, - ); + // metadata_full (tightest): succeeds at 32, fails at 33 + assert!(roundtrip_metadata(max), "metadata_full should succeed at depth {max}"); + assert!(!roundtrip_metadata(max + 1), "metadata_full should fail at depth {}", max + 1); } /// Verify flat events pass without issues. From 1021e1ee4446c31ca86a1ce00b789ce68ee2ca14 Mon Sep 17 00:00:00 2001 From: Connor Yang Date: Thu, 16 Apr 2026 10:57:08 -0400 Subject: [PATCH 23/49] style: auto-fix lint/format errors --- .../src/event/test/serialization.rs | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/lib/vector-core/src/event/test/serialization.rs b/lib/vector-core/src/event/test/serialization.rs index 5c8078e4d2f9d..012baa4bd7a5c 100644 --- a/lib/vector-core/src/event/test/serialization.rs +++ b/lib/vector-core/src/event/test/serialization.rs @@ -328,12 +328,27 @@ fn per_path_boundaries() { }; // Log.fields (loosest): succeeds at 33, fails at 34 - assert!(roundtrip_value(max + 1), "Log.fields should succeed at depth {}", max + 1); - assert!(!roundtrip_value(max + 2), "Log.fields should fail at depth {}", max + 2); + assert!( + roundtrip_value(max + 1), + "Log.fields should succeed at depth {}", + max + 1 + ); + assert!( + !roundtrip_value(max + 2), + "Log.fields should fail at depth {}", + max + 2 + ); // metadata_full (tightest): succeeds at 32, fails at 33 - assert!(roundtrip_metadata(max), "metadata_full should succeed at depth {max}"); - assert!(!roundtrip_metadata(max + 1), "metadata_full should fail at depth {}", max + 1); + assert!( + roundtrip_metadata(max), + "metadata_full should succeed at depth {max}" + ); + assert!( + !roundtrip_metadata(max + 1), + "metadata_full should fail at depth {}", + max + 1 + ); } /// Verify flat events pass without issues. From fbd2ad2776fa1debce8e335b3441434fbf10b521 Mon Sep 17 00:00:00 2001 From: Connor Yang Date: Thu, 16 Apr 2026 11:02:19 -0400 Subject: [PATCH 24/49] Add Trace to flat events test for uniformity --- lib/vector-core/src/event/test/serialization.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/vector-core/src/event/test/serialization.rs b/lib/vector-core/src/event/test/serialization.rs index 012baa4bd7a5c..70a288088821e 100644 --- a/lib/vector-core/src/event/test/serialization.rs +++ b/lib/vector-core/src/event/test/serialization.rs @@ -360,6 +360,12 @@ fn nesting_gate_accepts_flat_events() { let mut buf = BytesMut::with_capacity(1024); assert!(events.encode(&mut buf).is_ok()); + let mut trace = TraceEvent::default(); + trace.insert("foo", "bar"); + let events = EventArray::Traces(TraceArray::from(vec![trace])); + let mut buf = BytesMut::with_capacity(1024); + assert!(events.encode(&mut buf).is_ok()); + let metric = Metric::new( "test_counter", MetricKind::Incremental, From 8c8139148435f79842a355bbb8fb32b8fe0f98c7 Mon Sep 17 00:00:00 2001 From: Connor Yang Date: Thu, 16 Apr 2026 13:17:45 -0400 Subject: [PATCH 25/49] Use per-path depth limits: 33 for event data, 32 for metadata 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. --- lib/codecs/src/encoding/format/native.rs | 4 +- lib/codecs/tests/native.rs | 8 +- lib/vector-core/src/event/mod.rs | 2 +- lib/vector-core/src/event/ser.rs | 72 +++--- .../src/event/test/serialization.rs | 218 ++++++++---------- 5 files changed, 147 insertions(+), 157 deletions(-) diff --git a/lib/codecs/src/encoding/format/native.rs b/lib/codecs/src/encoding/format/native.rs index 42b1fb5b5d36a..a1ca804ae4851 100644 --- a/lib/codecs/src/encoding/format/native.rs +++ b/lib/codecs/src/encoding/format/native.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; use tokio_util::codec::Encoder; use vector_core::{ config::DataType, - event::{Event, EventArray, MAX_NESTING_DEPTH, event_exceeds_max_nesting_depth, proto}, + event::{Event, EventArray, event_exceeds_max_nesting_depth, proto}, schema, }; @@ -39,7 +39,7 @@ impl Encoder for NativeSerializer { fn encode(&mut self, event: Event, buffer: &mut BytesMut) -> Result<(), Self::Error> { if let Some(depth) = event_exceeds_max_nesting_depth(&event) { return Err(format!( - "event nesting depth {depth} exceeds maximum of {MAX_NESTING_DEPTH} for protobuf encoding" + "event nesting depth {depth} exceeds maximum for protobuf encoding" ) .into()); } diff --git a/lib/codecs/tests/native.rs b/lib/codecs/tests/native.rs index d56b288efc392..39f724c5019d9 100644 --- a/lib/codecs/tests/native.rs +++ b/lib/codecs/tests/native.rs @@ -345,8 +345,8 @@ fn create_nested_log_event(wrapping_levels: usize) -> LogEvent { #[test] fn native_codec_rejects_overly_nested_event() { - // 32 wrapping levels + "data" key = depth 33 > MAX_NESTING_DEPTH (32) - let event = create_nested_log_event(32); + // 33 wrapping levels + "data" key = depth 34 > MAX_NESTING_DEPTH (33) + let event = create_nested_log_event(33); let event = Event::Log(event); let mut serializer = NativeSerializerConfig.build(); @@ -361,8 +361,8 @@ fn native_codec_rejects_overly_nested_event() { #[test] fn native_codec_roundtrip_max_depth_event() { - // 31 wrapping levels + "data" key = depth 32 = MAX_NESTING_DEPTH - let event = create_nested_log_event(31); + // 32 wrapping levels + "data" key = depth 33 = MAX_NESTING_DEPTH + let event = create_nested_log_event(32); let original_data = event.value().get("data").cloned(); let event = Event::Log(event); diff --git a/lib/vector-core/src/event/mod.rs b/lib/vector-core/src/event/mod.rs index 60a0d7342e33a..00a1cbb1629c5 100644 --- a/lib/vector-core/src/event/mod.rs +++ b/lib/vector-core/src/event/mod.rs @@ -10,7 +10,7 @@ pub use log_event::LogEvent; pub use metadata::{DatadogMetricOriginMetadata, EventMetadata, WithMetadata}; pub use metric::{Metric, MetricKind, MetricTags, MetricValue, StatisticKind}; pub use r#ref::{EventMutRef, EventRef}; -pub use ser::{MAX_NESTING_DEPTH, event_exceeds_max_nesting_depth}; +pub use ser::{MAX_METADATA_NESTING_DEPTH, MAX_NESTING_DEPTH, event_exceeds_max_nesting_depth}; use serde::{Deserialize, Serialize}; pub use trace::TraceEvent; use vector_buffers::EventCount; diff --git a/lib/vector-core/src/event/ser.rs b/lib/vector-core/src/event/ser.rs index ef08e658bb6ca..c66ac47a1e62e 100644 --- a/lib/vector-core/src/event/ser.rs +++ b/lib/vector-core/src/event/ser.rs @@ -7,17 +7,24 @@ use vrl::value::Value; use super::{Event, EventArray, proto}; -/// Maximum nesting depth allowed for events before protobuf encoding. +/// Maximum nesting depth for event data values (Log.fields, Trace.fields). /// -/// Prost enforces a decode recursion limit of 100 (no limit on encode). Vector's proto -/// schema uses multiple prost recursion levels per `Value` nesting level, so the effective -/// safe depth is much lower than 100. The exact overhead varies across encoding paths -/// (log events, trace events, event metadata each have different proto message wrappers). +/// Prost enforces a decode recursion limit of 100 (no limit on encode). Each Value nesting +/// level consumes 3 prost recursion entries (Value + ValueMap + map_entry). The event data +/// path (`EventArray → *Array → Event → fields map_entry → Value`) has 3 proto wrapper +/// messages before the Value tree, leaving room for 33 depth levels: 3 + (33 × 3) + 1 = 100. /// -/// The value 32 was determined empirically by testing all encoding paths and finding the -/// highest depth that roundtrips successfully across all of them. Unit tests verify that -/// depth 32 succeeds and depth 33 fails prost decode. -pub const MAX_NESTING_DEPTH: usize = 32; +/// Verified by `per_path_boundaries`: depth 33 succeeds, depth 34 fails prost decode. +pub const MAX_NESTING_DEPTH: usize = 33; + +/// Maximum nesting depth for event metadata values (via `metadata_full`). +/// +/// The metadata path (`EventArray → *Array → Event → Metadata → Value`) has 4 proto wrapper +/// messages — one more than the event data path due to the `Metadata` message. This leaves +/// room for 32 depth levels: 4 + (32 × 3) + 1 = 100, exactly at prost's recursion limit. +/// +/// Verified by `per_path_boundaries`: depth 32 succeeds, depth 33 fails prost decode. +pub const MAX_METADATA_NESTING_DEPTH: usize = 32; /// Check the nesting depth of a `Value`, returning `Err(actual_depth)` if it exceeds `max_depth`. /// @@ -51,59 +58,62 @@ pub(crate) fn check_value_depth( Ok(()) } -/// Checks whether an event's nesting depth exceeds `MAX_NESTING_DEPTH`. +/// Checks whether an event's nesting depth exceeds the safe limits for protobuf encoding. /// /// Returns `Some(depth)` with the violating depth if the event exceeds the limit, /// or `None` if the event is within bounds. /// -/// For logs and traces, both the event value and event metadata value are checked. -/// For metrics, the metric value has a fixed structure that cannot be deeply nested, -/// but the metadata value is an arbitrary `Value` that is encoded into protobuf, -/// so it is still checked. +/// Event data values (Log.fields, Trace.fields) are checked against [`MAX_NESTING_DEPTH`], +/// while metadata values are checked against the stricter [`MAX_METADATA_NESTING_DEPTH`] +/// because the `Metadata` proto message adds an extra wrapper layer. +/// +/// For metrics, only metadata is checked since metric values have a fixed structure. pub fn event_exceeds_max_nesting_depth(event: &Event) -> Option { match event { Event::Log(log) => check_value_depth(log.value(), 0, MAX_NESTING_DEPTH) - .and_then(|()| check_value_depth(log.metadata().value(), 0, MAX_NESTING_DEPTH)) + .and_then(|()| { + check_value_depth(log.metadata().value(), 0, MAX_METADATA_NESTING_DEPTH) + }) .err(), Event::Trace(trace) => check_value_depth(trace.value(), 0, MAX_NESTING_DEPTH) - .and_then(|()| check_value_depth(trace.metadata().value(), 0, MAX_NESTING_DEPTH)) + .and_then(|()| { + check_value_depth(trace.metadata().value(), 0, MAX_METADATA_NESTING_DEPTH) + }) .err(), Event::Metric(metric) => { - check_value_depth(metric.metadata().value(), 0, MAX_NESTING_DEPTH).err() + check_value_depth(metric.metadata().value(), 0, MAX_METADATA_NESTING_DEPTH).err() } } } /// Checks all events in an `EventArray` for nesting depth violations. /// -/// Returns `Err(EncodeError::NestingTooDeep)` if any event exceeds `MAX_NESTING_DEPTH`. -/// For metrics, only metadata is checked since metric values have a fixed structure, -/// but metadata is an arbitrary `Value` encoded into protobuf. +/// Event data is checked against [`MAX_NESTING_DEPTH`] and metadata against +/// [`MAX_METADATA_NESTING_DEPTH`]. For metrics, only metadata is checked since +/// metric values have a fixed structure. fn check_event_array_nesting_depth(events: &EventArray) -> Result<(), EncodeError> { - let check = |value: &Value| { - check_value_depth(value, 0, MAX_NESTING_DEPTH).map_err(|depth| { - EncodeError::NestingTooDeep { - depth, - max_depth: MAX_NESTING_DEPTH, - } + let check = |value: &Value, max_depth: usize| { + check_value_depth(value, 0, max_depth).map_err(|depth| EncodeError::NestingTooDeep { + depth, + max_depth, }) }; match events { EventArray::Logs(logs) => { for log in logs { - check(log.value())?; - check(log.metadata().value())?; + check(log.value(), MAX_NESTING_DEPTH)?; + check(log.metadata().value(), MAX_METADATA_NESTING_DEPTH)?; } } EventArray::Traces(traces) => { for trace in traces { - check(trace.value())?; - check(trace.metadata().value())?; + check(trace.value(), MAX_NESTING_DEPTH)?; + check(trace.metadata().value(), MAX_METADATA_NESTING_DEPTH)?; } } EventArray::Metrics(metrics) => { for metric in metrics { - check(metric.metadata().value())?; + check(metric.metadata().value(), MAX_METADATA_NESTING_DEPTH)?; } } } diff --git a/lib/vector-core/src/event/test/serialization.rs b/lib/vector-core/src/event/test/serialization.rs index 70a288088821e..2706c7d146371 100644 --- a/lib/vector-core/src/event/test/serialization.rs +++ b/lib/vector-core/src/event/test/serialization.rs @@ -103,22 +103,21 @@ fn type_serialization() { // Nesting depth validation tests // --------------------------------------------------------------------------- // -// MAX_NESTING_DEPTH (32) is the highest Value depth that roundtrips through prost -// encode/decode across ALL proto encoding paths. The limit is constrained by prost's -// fixed recursion budget of 100 (RECURSION_LIMIT in prost/src/lib.rs). +// Prost enforces a decode recursion limit of 100 (no limit on encode). Each Value +// nesting level consumes 3 prost recursion entries (Value + ValueMap + map_entry), +// and each encoding path has a different number of proto wrapper messages before +// the Value tree starts: // -// Each Value nesting level consumes 3 prost recursion entries (Value + ValueMap + -// map_entry for Objects), and each encoding path has a different number of proto -// wrapper messages before the Value tree starts. The tightest path is metadata_full -// (EventArray → *Array → Event → Metadata → Value) which uses exactly 100/100 -// budget at depth 32. +// - Event data path (Log.fields, Trace.fields): 3 wrappers → max depth 33 +// - Metadata path (metadata_full): 4 wrappers → max depth 32 // -// Rather than enumerating individual proto paths, the tests below create events with -// ALL Value-carrying fields set to max depth simultaneously. The proto conversion code -// populates every field (including deprecated ones like Log.metadata), so a single -// roundtrip covers every path for a given event type. If a new Value-carrying field -// is added to the proto schema, the conversion code must populate it, and these tests -// automatically cover it with zero maintenance. +// These limits are encoded as MAX_NESTING_DEPTH (33) and MAX_METADATA_NESTING_DEPTH (32). +// The `per_path_boundaries` test verifies both boundaries empirically via prost roundtrip. +// +// The saturated-event tests create events with ALL Value-carrying fields at their +// respective max depths 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. /// Creates a Value with the specified number of nested Object wrapping levels. /// @@ -134,105 +133,94 @@ fn create_nested_value(wrapping_levels: usize) -> Value { value } -/// Create a [`LogEvent`] with both event data and metadata at the given nesting depth. -fn create_saturated_log(depth: usize) -> LogEvent { - let nested = create_nested_value(depth); +/// Create a [`LogEvent`] with event data at `value_depth` and metadata at `metadata_depth`. +fn create_saturated_log(value_depth: usize, metadata_depth: usize) -> LogEvent { let mut event = LogEvent::default(); - // Event data: "data" key + (depth-1) wrapping levels = leaf at `depth`. - // This covers Log.fields (or Log.value for non-Object roots). - event.insert("data", create_nested_value(depth - 1)); - // Metadata: covers both Log.metadata (deprecated) and Log.metadata_full.value. - *event.metadata_mut().value_mut() = nested; + event.insert("data", create_nested_value(value_depth - 1)); + *event.metadata_mut().value_mut() = create_nested_value(metadata_depth); event } -/// Create a [`TraceEvent`] with both event data and metadata at the given nesting depth. -fn create_saturated_trace(depth: usize) -> TraceEvent { - let nested = create_nested_value(depth); +/// Create a [`TraceEvent`] with event data at `value_depth` and metadata at `metadata_depth`. +fn create_saturated_trace(value_depth: usize, metadata_depth: usize) -> TraceEvent { let mut trace = TraceEvent::default(); - trace.insert("data", create_nested_value(depth - 1)); - *trace.metadata_mut().value_mut() = nested; + trace.insert("data", create_nested_value(value_depth - 1)); + *trace.metadata_mut().value_mut() = create_nested_value(metadata_depth); trace } -/// Create a Metric with metadata at the given nesting depth. +/// Create a Metric with metadata at `metadata_depth`. /// (Metric values have fixed structure — only metadata carries arbitrary Values.) -fn create_saturated_metric(depth: usize) -> Metric { +fn create_saturated_metric(metadata_depth: usize) -> Metric { let mut metric = Metric::new( "test", MetricKind::Incremental, MetricValue::Counter { value: 1.0 }, ); - *metric.metadata_mut().value_mut() = create_nested_value(depth); + *metric.metadata_mut().value_mut() = create_nested_value(metadata_depth); metric } -/// Build all three `EventArray` variants with every Value field at the given depth. -fn saturated_event_arrays(depth: usize) -> Vec<(&'static str, EventArray)> { +/// Build all three `EventArray` variants with each field at its respective max depth. +fn saturated_event_arrays(value_depth: usize, metadata_depth: usize) -> Vec<(&'static str, EventArray)> { vec![ ( "Log", - EventArray::Logs(LogArray::from(vec![create_saturated_log(depth)])), + EventArray::Logs(LogArray::from(vec![create_saturated_log(value_depth, metadata_depth)])), ), ( "Trace", - EventArray::Traces(TraceArray::from(vec![create_saturated_trace(depth)])), + EventArray::Traces(TraceArray::from(vec![create_saturated_trace(value_depth, metadata_depth)])), ), ( "Metric", - EventArray::Metrics(MetricArray::from(vec![create_saturated_metric(depth)])), + EventArray::Metrics(MetricArray::from(vec![create_saturated_metric(metadata_depth)])), ), ] } /// Build all three Event variants for `EventWrapper` encoding. -fn saturated_events(depth: usize) -> Vec<(&'static str, Event)> { +fn saturated_events(value_depth: usize, metadata_depth: usize) -> Vec<(&'static str, Event)> { vec![ - ("Log", Event::Log(create_saturated_log(depth))), - ("Trace", Event::Trace(create_saturated_trace(depth))), - ("Metric", Event::Metric(create_saturated_metric(depth))), + ("Log", Event::Log(create_saturated_log(value_depth, metadata_depth))), + ("Trace", Event::Trace(create_saturated_trace(value_depth, metadata_depth))), + ("Metric", Event::Metric(create_saturated_metric(metadata_depth))), ] } -/// Verify `MAX_NESTING_DEPTH` is exactly right: all event types roundtrip at depth 32, -/// and at least one fails prost decode at depth 33. -/// -/// Each event has ALL Value-carrying fields saturated at the test depth, so every -/// proto field (including deprecated ones) is exercised. No path enumeration needed — -/// if a new Value field is added to the proto schema, the conversion code populates it -/// and this test covers it automatically. +/// Verify both depth constants are exactly right: all event types roundtrip at the +/// max depths, and at least one fails prost decode when either limit is exceeded. #[test] -fn max_nesting_depth_is_correct() { - let max = super::super::ser::MAX_NESTING_DEPTH; +fn max_nesting_depths_are_correct() { + let max_val = super::super::ser::MAX_NESTING_DEPTH; + let max_meta = super::super::ser::MAX_METADATA_NESTING_DEPTH; - // --- Depth MAX must roundtrip for all event types --- + // --- Both limits at max must roundtrip for all event types --- - for (name, array) in saturated_event_arrays(max) { + for (name, array) in saturated_event_arrays(max_val, max_meta) { let proto_array = proto::EventArray::from(array); let mut buf = BytesMut::with_capacity(65536); proto_array.encode(&mut buf).unwrap(); assert!( proto::EventArray::decode(buf.freeze()).is_ok(), - "EventArray decode FAILED at depth {max} for {name}.\n\ - MAX_NESTING_DEPTH is too high — lower it or check for new proto wrappers.", + "EventArray decode FAILED for {name} at value depth {max_val}, metadata depth {max_meta}.", ); } - for (name, event) in saturated_events(max) { + for (name, event) in saturated_events(max_val, max_meta) { let wrapper = proto::EventWrapper::from(event); let mut buf = BytesMut::with_capacity(65536); wrapper.encode(&mut buf).unwrap(); assert!( proto::EventWrapper::decode(buf.freeze()).is_ok(), - "EventWrapper decode FAILED at depth {max} for {name}.\n\ - MAX_NESTING_DEPTH is too high for the EventWrapper path.", + "EventWrapper decode FAILED for {name} at value depth {max_val}, metadata depth {max_meta}.", ); } - // --- Depth MAX+1 must fail for at least one event type --- - // (proves the limit can't be raised without hitting prost's recursion limit) + // --- Exceeding either limit must fail for at least one event type --- - let any_array_fails = saturated_event_arrays(max + 1) + // Exceed value depth + let any_fails = saturated_event_arrays(max_val + 1, max_meta) .into_iter() .any(|(_, array)| { let proto_array = proto::EventArray::from(array); @@ -240,72 +228,80 @@ fn max_nesting_depth_is_correct() { proto_array.encode(&mut buf).unwrap(); proto::EventArray::decode(buf.freeze()).is_err() }); - assert!( - any_array_fails, - "All EventArray types decoded at depth {}. MAX_NESTING_DEPTH ({}) could be raised.", - max + 1, - max, - ); + assert!(any_fails, "No path failed at value depth {}. MAX_NESTING_DEPTH could be raised.", max_val + 1); - let any_wrapper_fails = saturated_events(max + 1).into_iter().any(|(_, event)| { - let wrapper = proto::EventWrapper::from(event); - let mut buf = BytesMut::with_capacity(65536); - wrapper.encode(&mut buf).unwrap(); - proto::EventWrapper::decode(buf.freeze()).is_err() - }); - assert!( - any_wrapper_fails, - "All EventWrapper types decoded at depth {}. MAX_NESTING_DEPTH ({}) could be raised.", - max + 1, - max, - ); + // Exceed metadata depth + let any_fails = saturated_event_arrays(max_val, max_meta + 1) + .into_iter() + .any(|(_, array)| { + let proto_array = proto::EventArray::from(array); + let mut buf = BytesMut::with_capacity(65536); + proto_array.encode(&mut buf).unwrap(); + proto::EventArray::decode(buf.freeze()).is_err() + }); + assert!(any_fails, "No path failed at metadata depth {}. MAX_METADATA_NESTING_DEPTH could be raised.", max_meta + 1); } -/// Verify the nesting gate accepts all event types at `MAX_NESTING_DEPTH`. +/// Verify the nesting gate accepts all event types at the max depths. #[test] fn nesting_gate_accepts_all_types_at_max_depth() { - for (name, array) in saturated_event_arrays(super::super::ser::MAX_NESTING_DEPTH) { + let max_val = super::super::ser::MAX_NESTING_DEPTH; + let max_meta = super::super::ser::MAX_METADATA_NESTING_DEPTH; + for (name, array) in saturated_event_arrays(max_val, max_meta) { let mut buf = BytesMut::with_capacity(65536); assert!( array.encode(&mut buf).is_ok(), - "nesting gate rejected {name} at MAX_NESTING_DEPTH", + "nesting gate rejected {name} at max depths", ); } } -/// Verify the nesting gate rejects all event types at `MAX_NESTING_DEPTH` + 1. +/// Verify the nesting gate rejects when either limit is exceeded. #[test] -fn nesting_gate_rejects_all_types_above_max_depth() { - let max = super::super::ser::MAX_NESTING_DEPTH; - for (name, array) in saturated_event_arrays(max + 1) { +fn nesting_gate_rejects_above_max_depth() { + let max_val = super::super::ser::MAX_NESTING_DEPTH; + let max_meta = super::super::ser::MAX_METADATA_NESTING_DEPTH; + + // Exceed value depth (Log and Trace have event data; Metric does not) + for (name, array) in saturated_event_arrays(max_val + 1, max_meta) { + // Metric has no event data field, so it won't be rejected here + if name == "Metric" { + continue; + } let mut buf = BytesMut::with_capacity(65536); assert!( matches!( array.encode(&mut buf), Err(super::super::ser::EncodeError::NestingTooDeep { .. }) ), - "nesting gate should reject {name} at depth {}", - max + 1, + "nesting gate should reject {name} at value depth {}", + max_val + 1, + ); + } + + // Exceed metadata depth + for (name, array) in saturated_event_arrays(max_val, max_meta + 1) { + let mut buf = BytesMut::with_capacity(65536); + assert!( + matches!( + array.encode(&mut buf), + Err(super::super::ser::EncodeError::NestingTooDeep { .. }) + ), + "nesting gate should reject {name} at metadata depth {}", + max_meta + 1, ); } } -/// Verify the per-path boundaries for the loosest and tightest encoding paths. -/// -/// `Log.fields` is the loosest path (3 entries of headroom at depth 32): -/// - depth 33 succeeds (100/100 budget) -/// - depth 34 fails (103/100 budget) +/// Verify the per-path prost boundaries match the constants. /// -/// `metadata_full` is the tightest path (0 headroom at depth 32): -/// - depth 32 succeeds (100/100 budget) -/// - depth 33 fails (103/100 budget) -/// -/// The uniform `MAX_NESTING_DEPTH = 32` is set by the tightest path. +/// `Log.fields` (loosest): `MAX_NESTING_DEPTH` (33) succeeds, 34 fails. +/// `metadata_full` (tightest): `MAX_METADATA_NESTING_DEPTH` (32) succeeds, 33 fails. #[test] fn per_path_boundaries() { - let max = super::super::ser::MAX_NESTING_DEPTH; + let max_val = super::super::ser::MAX_NESTING_DEPTH; + let max_meta = super::super::ser::MAX_METADATA_NESTING_DEPTH; - // Helper: encode a LogEvent with nested value and flat metadata via EventArray. let roundtrip_value = |depth: usize| -> bool { let mut event = LogEvent::default(); event.insert("data", create_nested_value(depth - 1)); @@ -316,7 +312,6 @@ fn per_path_boundaries() { proto::EventArray::decode(buf.freeze()).is_ok() }; - // Helper: encode a LogEvent with flat value and nested metadata via EventArray. let roundtrip_metadata = |depth: usize| -> bool { let mut event = LogEvent::from("flat"); *event.metadata_mut().value_mut() = create_nested_value(depth); @@ -327,28 +322,13 @@ fn per_path_boundaries() { proto::EventArray::decode(buf.freeze()).is_ok() }; - // Log.fields (loosest): succeeds at 33, fails at 34 - assert!( - roundtrip_value(max + 1), - "Log.fields should succeed at depth {}", - max + 1 - ); - assert!( - !roundtrip_value(max + 2), - "Log.fields should fail at depth {}", - max + 2 - ); + // Log.fields: MAX_NESTING_DEPTH succeeds, MAX_NESTING_DEPTH+1 fails + assert!(roundtrip_value(max_val), "Log.fields should succeed at depth {max_val}"); + assert!(!roundtrip_value(max_val + 1), "Log.fields should fail at depth {}", max_val + 1); - // metadata_full (tightest): succeeds at 32, fails at 33 - assert!( - roundtrip_metadata(max), - "metadata_full should succeed at depth {max}" - ); - assert!( - !roundtrip_metadata(max + 1), - "metadata_full should fail at depth {}", - max + 1 - ); + // metadata_full: MAX_METADATA_NESTING_DEPTH succeeds, MAX_METADATA_NESTING_DEPTH+1 fails + assert!(roundtrip_metadata(max_meta), "metadata_full should succeed at depth {max_meta}"); + assert!(!roundtrip_metadata(max_meta + 1), "metadata_full should fail at depth {}", max_meta + 1); } /// Verify flat events pass without issues. From f0fc05af3169aaf8527e738711324adccf633162 Mon Sep 17 00:00:00 2001 From: Connor Yang Date: Thu, 16 Apr 2026 13:19:42 -0400 Subject: [PATCH 26/49] style: auto-fix lint/format errors --- lib/vector-core/src/event/ser.rs | 10 +-- .../src/event/test/serialization.rs | 68 +++++++++++++++---- 2 files changed, 58 insertions(+), 20 deletions(-) diff --git a/lib/vector-core/src/event/ser.rs b/lib/vector-core/src/event/ser.rs index c66ac47a1e62e..591d3af9a1f5f 100644 --- a/lib/vector-core/src/event/ser.rs +++ b/lib/vector-core/src/event/ser.rs @@ -71,9 +71,7 @@ pub(crate) fn check_value_depth( pub fn event_exceeds_max_nesting_depth(event: &Event) -> Option { match event { Event::Log(log) => check_value_depth(log.value(), 0, MAX_NESTING_DEPTH) - .and_then(|()| { - check_value_depth(log.metadata().value(), 0, MAX_METADATA_NESTING_DEPTH) - }) + .and_then(|()| check_value_depth(log.metadata().value(), 0, MAX_METADATA_NESTING_DEPTH)) .err(), Event::Trace(trace) => check_value_depth(trace.value(), 0, MAX_NESTING_DEPTH) .and_then(|()| { @@ -93,10 +91,8 @@ pub fn event_exceeds_max_nesting_depth(event: &Event) -> Option { /// metric values have a fixed structure. fn check_event_array_nesting_depth(events: &EventArray) -> Result<(), EncodeError> { let check = |value: &Value, max_depth: usize| { - check_value_depth(value, 0, max_depth).map_err(|depth| EncodeError::NestingTooDeep { - depth, - max_depth, - }) + check_value_depth(value, 0, max_depth) + .map_err(|depth| EncodeError::NestingTooDeep { depth, max_depth }) }; match events { EventArray::Logs(logs) => { diff --git a/lib/vector-core/src/event/test/serialization.rs b/lib/vector-core/src/event/test/serialization.rs index 2706c7d146371..db0aea05e0dbb 100644 --- a/lib/vector-core/src/event/test/serialization.rs +++ b/lib/vector-core/src/event/test/serialization.rs @@ -162,19 +162,30 @@ fn create_saturated_metric(metadata_depth: usize) -> Metric { } /// Build all three `EventArray` variants with each field at its respective max depth. -fn saturated_event_arrays(value_depth: usize, metadata_depth: usize) -> Vec<(&'static str, EventArray)> { +fn saturated_event_arrays( + value_depth: usize, + metadata_depth: usize, +) -> Vec<(&'static str, EventArray)> { vec![ ( "Log", - EventArray::Logs(LogArray::from(vec![create_saturated_log(value_depth, metadata_depth)])), + EventArray::Logs(LogArray::from(vec![create_saturated_log( + value_depth, + metadata_depth, + )])), ), ( "Trace", - EventArray::Traces(TraceArray::from(vec![create_saturated_trace(value_depth, metadata_depth)])), + EventArray::Traces(TraceArray::from(vec![create_saturated_trace( + value_depth, + metadata_depth, + )])), ), ( "Metric", - EventArray::Metrics(MetricArray::from(vec![create_saturated_metric(metadata_depth)])), + EventArray::Metrics(MetricArray::from(vec![create_saturated_metric( + metadata_depth, + )])), ), ] } @@ -182,9 +193,18 @@ fn saturated_event_arrays(value_depth: usize, metadata_depth: usize) -> Vec<(&'s /// Build all three Event variants for `EventWrapper` encoding. fn saturated_events(value_depth: usize, metadata_depth: usize) -> Vec<(&'static str, Event)> { vec![ - ("Log", Event::Log(create_saturated_log(value_depth, metadata_depth))), - ("Trace", Event::Trace(create_saturated_trace(value_depth, metadata_depth))), - ("Metric", Event::Metric(create_saturated_metric(metadata_depth))), + ( + "Log", + Event::Log(create_saturated_log(value_depth, metadata_depth)), + ), + ( + "Trace", + Event::Trace(create_saturated_trace(value_depth, metadata_depth)), + ), + ( + "Metric", + Event::Metric(create_saturated_metric(metadata_depth)), + ), ] } @@ -228,7 +248,11 @@ fn max_nesting_depths_are_correct() { proto_array.encode(&mut buf).unwrap(); proto::EventArray::decode(buf.freeze()).is_err() }); - assert!(any_fails, "No path failed at value depth {}. MAX_NESTING_DEPTH could be raised.", max_val + 1); + assert!( + any_fails, + "No path failed at value depth {}. MAX_NESTING_DEPTH could be raised.", + max_val + 1 + ); // Exceed metadata depth let any_fails = saturated_event_arrays(max_val, max_meta + 1) @@ -239,7 +263,11 @@ fn max_nesting_depths_are_correct() { proto_array.encode(&mut buf).unwrap(); proto::EventArray::decode(buf.freeze()).is_err() }); - assert!(any_fails, "No path failed at metadata depth {}. MAX_METADATA_NESTING_DEPTH could be raised.", max_meta + 1); + assert!( + any_fails, + "No path failed at metadata depth {}. MAX_METADATA_NESTING_DEPTH could be raised.", + max_meta + 1 + ); } /// Verify the nesting gate accepts all event types at the max depths. @@ -323,12 +351,26 @@ fn per_path_boundaries() { }; // Log.fields: MAX_NESTING_DEPTH succeeds, MAX_NESTING_DEPTH+1 fails - assert!(roundtrip_value(max_val), "Log.fields should succeed at depth {max_val}"); - assert!(!roundtrip_value(max_val + 1), "Log.fields should fail at depth {}", max_val + 1); + assert!( + roundtrip_value(max_val), + "Log.fields should succeed at depth {max_val}" + ); + assert!( + !roundtrip_value(max_val + 1), + "Log.fields should fail at depth {}", + max_val + 1 + ); // metadata_full: MAX_METADATA_NESTING_DEPTH succeeds, MAX_METADATA_NESTING_DEPTH+1 fails - assert!(roundtrip_metadata(max_meta), "metadata_full should succeed at depth {max_meta}"); - assert!(!roundtrip_metadata(max_meta + 1), "metadata_full should fail at depth {}", max_meta + 1); + assert!( + roundtrip_metadata(max_meta), + "metadata_full should succeed at depth {max_meta}" + ); + assert!( + !roundtrip_metadata(max_meta + 1), + "metadata_full should fail at depth {}", + max_meta + 1 + ); } /// Verify flat events pass without issues. From ef06620bbacabdc260c3a6c55e9e22bd9ae9db2a Mon Sep 17 00:00:00 2001 From: Connor Yang Date: Thu, 16 Apr 2026 13:24:46 -0400 Subject: [PATCH 27/49] Fix clippy doc_markdown lint --- lib/vector-core/src/event/ser.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/vector-core/src/event/ser.rs b/lib/vector-core/src/event/ser.rs index 591d3af9a1f5f..a8b7e322fe31d 100644 --- a/lib/vector-core/src/event/ser.rs +++ b/lib/vector-core/src/event/ser.rs @@ -10,7 +10,7 @@ use super::{Event, EventArray, proto}; /// Maximum nesting depth for event data values (Log.fields, Trace.fields). /// /// Prost enforces a decode recursion limit of 100 (no limit on encode). Each Value nesting -/// level consumes 3 prost recursion entries (Value + ValueMap + map_entry). The event data +/// level consumes 3 prost recursion entries (`Value` + `ValueMap` + `map_entry`). The event data /// path (`EventArray → *Array → Event → fields map_entry → Value`) has 3 proto wrapper /// messages before the Value tree, leaving room for 33 depth levels: 3 + (33 × 3) + 1 = 100. /// From 2a5874007a6428caabfe34da2b6d628e3f95ca45 Mon Sep 17 00:00:00 2001 From: Connor Yang Date: Thu, 16 Apr 2026 13:40:50 -0400 Subject: [PATCH 28/49] Fix doc comment arithmetic and add native codec metadata tests - Remove incorrect arithmetic formulas from MAX_NESTING_DEPTH and MAX_METADATA_NESTING_DEPTH doc comments. The constants are empirically verified by per_path_boundaries; the formulas were wrong (e.g. 3 + 33*3 + 1 = 103, not 100). Replace with plain descriptions pointing to the verifying test. - Add native codec integration tests for metadata depth: the existing native codec tests only exercised event value depth. Now also test that NativeSerializer rejects metadata at depth 33 and roundtrips metadata at depth 32. --- lib/codecs/tests/native.rs | 52 ++++++++++++++++++++++++++++++++ lib/vector-core/src/event/ser.rs | 21 +++++++------ 2 files changed, 64 insertions(+), 9 deletions(-) diff --git a/lib/codecs/tests/native.rs b/lib/codecs/tests/native.rs index 39f724c5019d9..c52adf67afd36 100644 --- a/lib/codecs/tests/native.rs +++ b/lib/codecs/tests/native.rs @@ -382,3 +382,55 @@ fn native_codec_roundtrip_max_depth_event() { let decoded_log = decoded_events.into_iter().next().unwrap().into_log(); assert_eq!(original_data.as_ref(), decoded_log.value().get("data"),); } + +fn create_nested_value(wrapping_levels: usize) -> Value { + let mut value = Value::from("innermost"); + for _ in 0..wrapping_levels { + let mut map = ObjectMap::new(); + map.insert("nested".into(), value); + value = Value::Object(map); + } + value +} + +#[test] +fn native_codec_rejects_overly_nested_metadata() { + // Metadata at depth 33 > MAX_METADATA_NESTING_DEPTH (32) + let mut event = LogEvent::from("flat data"); + *event.metadata_mut().value_mut() = create_nested_value(33); + let event = Event::Log(event); + + let mut serializer = NativeSerializerConfig.build(); + let mut buffer = BytesMut::with_capacity(8192); + + let result = serializer.encode(event, &mut buffer); + assert!( + result.is_err(), + "native codec should reject events with metadata exceeding MAX_METADATA_NESTING_DEPTH" + ); +} + +#[test] +fn native_codec_roundtrip_max_metadata_depth_event() { + // Metadata at depth 32 = MAX_METADATA_NESTING_DEPTH + let mut event = LogEvent::from("flat data"); + let metadata_value = create_nested_value(32); + *event.metadata_mut().value_mut() = metadata_value.clone(); + let event = Event::Log(event); + + let mut serializer = NativeSerializerConfig.build(); + let mut buffer = BytesMut::with_capacity(8192); + + serializer + .encode(event, &mut buffer) + .expect("native codec should accept events at MAX_METADATA_NESTING_DEPTH"); + + let deserializer = NativeDeserializerConfig.build(); + let decoded_events = deserializer + .parse(buffer.freeze(), LogNamespace::Legacy) + .expect("native codec should decode events at MAX_METADATA_NESTING_DEPTH"); + + assert_eq!(decoded_events.len(), 1); + let decoded_log = decoded_events.into_iter().next().unwrap().into_log(); + assert_eq!(decoded_log.metadata().value(), &metadata_value); +} diff --git a/lib/vector-core/src/event/ser.rs b/lib/vector-core/src/event/ser.rs index a8b7e322fe31d..9e8991fcf96ed 100644 --- a/lib/vector-core/src/event/ser.rs +++ b/lib/vector-core/src/event/ser.rs @@ -7,23 +7,26 @@ use vrl::value::Value; use super::{Event, EventArray, proto}; -/// Maximum nesting depth for event data values (Log.fields, Trace.fields). +/// Maximum nesting depth for event data values (`Log.fields`, `Trace.fields`). /// /// Prost enforces a decode recursion limit of 100 (no limit on encode). Each Value nesting -/// level consumes 3 prost recursion entries (`Value` + `ValueMap` + `map_entry`). The event data -/// path (`EventArray → *Array → Event → fields map_entry → Value`) has 3 proto wrapper -/// messages before the Value tree, leaving room for 33 depth levels: 3 + (33 × 3) + 1 = 100. +/// level consumes multiple prost recursion entries (`Value` + `ValueMap` + `map_entry` for +/// Objects), and each encoding path has a fixed number of proto wrapper messages before the +/// Value tree starts. The event data path (`EventArray` → `*Array` → Event → fields) has +/// fewer wrappers than the metadata path, allowing one extra depth level. /// -/// Verified by `per_path_boundaries`: depth 33 succeeds, depth 34 fails prost decode. +/// Determined empirically and verified by `per_path_boundaries`: depth 33 roundtrips +/// successfully via prost, depth 34 fails decode. pub const MAX_NESTING_DEPTH: usize = 33; /// Maximum nesting depth for event metadata values (via `metadata_full`). /// -/// The metadata path (`EventArray → *Array → Event → Metadata → Value`) has 4 proto wrapper -/// messages — one more than the event data path due to the `Metadata` message. This leaves -/// room for 32 depth levels: 4 + (32 × 3) + 1 = 100, exactly at prost's recursion limit. +/// The metadata path (`EventArray` → `*Array` → Event → `Metadata` → Value) has one more +/// proto wrapper message than the event data path due to the `Metadata` message, reducing +/// the maximum safe depth by one level. /// -/// Verified by `per_path_boundaries`: depth 32 succeeds, depth 33 fails prost decode. +/// Determined empirically and verified by `per_path_boundaries`: depth 32 roundtrips +/// successfully via prost, depth 33 fails decode. pub const MAX_METADATA_NESTING_DEPTH: usize = 32; /// Check the nesting depth of a `Value`, returning `Err(actual_depth)` if it exceeds `max_depth`. From 87038f5871cb256daed3cc19c022f3afeec26eef Mon Sep 17 00:00:00 2001 From: Orri Ganel Date: Tue, 12 May 2026 17:18:19 -0400 Subject: [PATCH 29/49] Fix compile --- lib/vector-core/src/event/test/serialization.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/vector-core/src/event/test/serialization.rs b/lib/vector-core/src/event/test/serialization.rs index 56db6c109f574..7981794e9ed2f 100644 --- a/lib/vector-core/src/event/test/serialization.rs +++ b/lib/vector-core/src/event/test/serialization.rs @@ -7,8 +7,6 @@ use regex::Regex; use similar_asserts::assert_eq; use vector_buffers::encoding::Encodable; -use super::*; -use crate::config::log_schema; use crate::event::ser::check_value_depth; fn encode_value(value: T, buffer: &mut B) { From 57c9c91e3a28bbbaabc8d6e080707d955fadbcee Mon Sep 17 00:00:00 2001 From: Orri Ganel Date: Mon, 18 May 2026 12:57:37 -0400 Subject: [PATCH 30/49] fix(buffers): drop oversized events gracefully on disk-buffer write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The disk buffer's `Encodable::encode` returns `NestingTooDeep` when an event exceeds the protobuf nesting limits, which the buffer sender then surfaced as "unrecoverable error" — losing the event without `ComponentEventsDropped` telemetry or a `Rejected` status, breaking source-side ack semantics. Adds an opt-in `Encodable::pre_encode_drop_unencodable` trait method (default no-op) overridden for `EventArray` to drop nesting-too-deep events with proper telemetry/status. The disk-buffer `SenderAdapter` arms call it before `write_record`. Memory-buffered sinks are unaffected. The encode-time check remains as defense-in-depth. Also threads the observed/max depth into the vector sink's drop reason for actionable log diagnostics; `reason` is not a metric label so dynamic values do not affect cardinality. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/codecs/src/encoding/format/native.rs | 4 +- lib/vector-buffers/src/encoding.rs | 11 ++++ .../src/topology/channel/sender.rs | 24 ++++---- lib/vector-core/src/event/ser.rs | 58 +++++++++++++++++-- src/sinks/vector/sink.rs | 25 ++++++-- 5 files changed, 100 insertions(+), 22 deletions(-) diff --git a/lib/codecs/src/encoding/format/native.rs b/lib/codecs/src/encoding/format/native.rs index a1ca804ae4851..2244d81f06139 100644 --- a/lib/codecs/src/encoding/format/native.rs +++ b/lib/codecs/src/encoding/format/native.rs @@ -37,9 +37,9 @@ impl Encoder for NativeSerializer { type Error = vector_common::Error; fn encode(&mut self, event: Event, buffer: &mut BytesMut) -> Result<(), Self::Error> { - if let Some(depth) = event_exceeds_max_nesting_depth(&event) { + if let Some((depth, max)) = event_exceeds_max_nesting_depth(&event) { return Err(format!( - "event nesting depth {depth} exceeds maximum for protobuf encoding" + "event nesting depth ({depth}) exceeds maximum ({max}) for protobuf encoding" ) .into()); } diff --git a/lib/vector-buffers/src/encoding.rs b/lib/vector-buffers/src/encoding.rs index a9b1a4e4b0211..ded360bdca58d 100644 --- a/lib/vector-buffers/src/encoding.rs +++ b/lib/vector-buffers/src/encoding.rs @@ -101,6 +101,17 @@ pub trait Encodable: Sized { /// guaranteed. fn encode(self, buffer: &mut B) -> Result<(), Self::EncodeError>; + /// Removes any sub-items from `self` that cannot be encoded (e.g. due to format-imposed + /// nesting depth limits), recording them as dropped via the appropriate telemetry, and + /// returns the number removed. + /// + /// Called by buffer senders prior to `encode` so that unencodable items can be reported + /// as `ComponentEventsDropped` rather than poisoning the buffer with an encode error. + /// The default implementation removes nothing. + fn pre_encode_drop_unencodable(&mut self) -> usize { + 0 + } + /// Gets the encoded size, in bytes, of this value, if available. /// /// Not all types can know ahead of time how many bytes they will occupy when encoded, hence the diff --git a/lib/vector-buffers/src/topology/channel/sender.rs b/lib/vector-buffers/src/topology/channel/sender.rs index c3eb22c3c952e..65d9776721a7f 100644 --- a/lib/vector-buffers/src/topology/channel/sender.rs +++ b/lib/vector-buffers/src/topology/channel/sender.rs @@ -40,18 +40,18 @@ impl SenderAdapter where T: Bufferable, { - pub(crate) async fn send(&mut self, item: T) -> crate::Result<()> { + pub(crate) async fn send(&mut self, mut item: T) -> crate::Result<()> { match self { Self::InMemory(tx) => tx.send(item).await.map_err(Into::into), Self::DiskV2(writer) => { + item.pre_encode_drop_unencodable(); + if item.event_count() == 0 { + return Ok(()); + } + let mut writer = writer.lock().await; writer.write_record(item).await.map(|_| ()).map_err(|e| { - // TODO: Could some errors be handled and not be unrecoverable? Right now, - // encoding should theoretically be recoverable -- encoded value was too big, or - // error during encoding -- but the traits don't allow for recovering the - // original event value because we have to consume it to do the encoding... but - // that might not always be the case. error!("Disk buffer writer has encountered an unrecoverable error."); e.into() @@ -60,21 +60,21 @@ where } } - pub(crate) async fn try_send(&mut self, item: T) -> crate::Result> { + pub(crate) async fn try_send(&mut self, mut item: T) -> crate::Result> { match self { Self::InMemory(tx) => tx .try_send(item) .map(|()| None) .or_else(|e| Ok(Some(e.into_inner()))), Self::DiskV2(writer) => { + item.pre_encode_drop_unencodable(); + if item.event_count() == 0 { + return Ok(None); + } + let mut writer = writer.lock().await; writer.try_write_record(item).await.map_err(|e| { - // TODO: Could some errors be handled and not be unrecoverable? Right now, - // encoding should theoretically be recoverable -- encoded value was too big, or - // error during encoding -- but the traits don't allow for recovering the - // original event value because we have to consume it to do the encoding... but - // that might not always be the case. error!("Disk buffer writer has encountered an unrecoverable error."); e.into() diff --git a/lib/vector-core/src/event/ser.rs b/lib/vector-core/src/event/ser.rs index 9e8991fcf96ed..f08a020a457f4 100644 --- a/lib/vector-core/src/event/ser.rs +++ b/lib/vector-core/src/event/ser.rs @@ -3,9 +3,10 @@ use enumflags2::{BitFlags, FromBitsError, bitflags}; use prost::Message; use snafu::Snafu; use vector_buffers::encoding::{AsMetadata, Encodable}; +use vector_common::internal_event::{self, ComponentEventsDropped, UNINTENTIONAL}; use vrl::value::Value; -use super::{Event, EventArray, proto}; +use super::{Event, EventArray, EventStatus, proto}; /// Maximum nesting depth for event data values (`Log.fields`, `Trace.fields`). /// @@ -71,18 +72,26 @@ pub(crate) fn check_value_depth( /// because the `Metadata` proto message adds an extra wrapper layer. /// /// For metrics, only metadata is checked since metric values have a fixed structure. -pub fn event_exceeds_max_nesting_depth(event: &Event) -> Option { +pub fn event_exceeds_max_nesting_depth(event: &Event) -> Option<(usize, usize)> { match event { Event::Log(log) => check_value_depth(log.value(), 0, MAX_NESTING_DEPTH) - .and_then(|()| check_value_depth(log.metadata().value(), 0, MAX_METADATA_NESTING_DEPTH)) + .map_err(|op| (op, MAX_NESTING_DEPTH)) + .and_then(|()| { + check_value_depth(log.metadata().value(), 0, MAX_METADATA_NESTING_DEPTH) + .map_err(|op| (op, MAX_METADATA_NESTING_DEPTH)) + }) .err(), Event::Trace(trace) => check_value_depth(trace.value(), 0, MAX_NESTING_DEPTH) + .map_err(|op| (op, MAX_NESTING_DEPTH)) .and_then(|()| { check_value_depth(trace.metadata().value(), 0, MAX_METADATA_NESTING_DEPTH) + .map_err(|op| (op, MAX_METADATA_NESTING_DEPTH)) }) .err(), Event::Metric(metric) => { - check_value_depth(metric.metadata().value(), 0, MAX_METADATA_NESTING_DEPTH).err() + check_value_depth(metric.metadata().value(), 0, MAX_METADATA_NESTING_DEPTH) + .map_err(|op| (op, MAX_METADATA_NESTING_DEPTH)) + .err() } } } @@ -220,6 +229,47 @@ impl Encodable for EventArray { .map_err(|_| EncodeError::BufferTooSmall) } + fn pre_encode_drop_unencodable(&mut self) -> usize { + let exceeds = + |value: &Value, max_depth: usize| check_value_depth(value, 0, max_depth).is_err(); + let mut dropped = 0; + match self { + EventArray::Logs(logs) => logs.retain(|log| { + let too_deep = exceeds(log.value(), MAX_NESTING_DEPTH) + || exceeds(log.metadata().value(), MAX_METADATA_NESTING_DEPTH); + if too_deep { + log.metadata().update_status(EventStatus::Rejected); + dropped += 1; + } + !too_deep + }), + EventArray::Traces(traces) => traces.retain(|trace| { + let too_deep = exceeds(trace.value(), MAX_NESTING_DEPTH) + || exceeds(trace.metadata().value(), MAX_METADATA_NESTING_DEPTH); + if too_deep { + trace.metadata().update_status(EventStatus::Rejected); + dropped += 1; + } + !too_deep + }), + EventArray::Metrics(metrics) => metrics.retain(|metric| { + let too_deep = exceeds(metric.metadata().value(), MAX_METADATA_NESTING_DEPTH); + if too_deep { + metric.metadata().update_status(EventStatus::Rejected); + dropped += 1; + } + !too_deep + }), + } + if dropped > 0 { + internal_event::emit(ComponentEventsDropped:: { + count: dropped, + reason: "Event nesting depth exceeds maximum for protobuf encoding.", + }); + } + dropped + } + fn decode(metadata: Self::Metadata, buffer: B) -> Result where B: Buf + Clone, diff --git a/src/sinks/vector/sink.rs b/src/sinks/vector/sink.rs index 0e66933964903..7d874dead072d 100644 --- a/src/sinks/vector/sink.rs +++ b/src/sinks/vector/sink.rs @@ -8,7 +8,7 @@ use vector_lib::{ ByteSizeOf, EstimatedJsonEncodedSizeOf, config::telemetry, event::event_exceeds_max_nesting_depth, - internal_event::{ComponentEventsDropped, INTENTIONAL}, + internal_event::{ComponentEventsDropped, UNINTENTIONAL}, request_metadata::GroupedCountByteSize, stream::{BatcherSettings, DriverResponse, batcher::data::BatchReduce}, }; @@ -63,11 +63,28 @@ where async fn run_inner(self: Box, input: BoxStream<'_, Event>) -> Result<(), ()> { input .filter_map(|event| { - std::future::ready(if event_exceeds_max_nesting_depth(&event).is_some() { - emit!(ComponentEventsDropped:: { + std::future::ready(if let Some((depth, max)) = + event_exceeds_max_nesting_depth(&event) + { + let reason = format!( + "Event nesting depth {depth} exceeds maximum of {max} for protobuf encoding." + ); + emit!(ComponentEventsDropped:: { count: 1, - reason: "Event nesting depth exceeds maximum for protobuf encoding.", + reason: &reason, }); + match event { + Event::Log(log) => log + .metadata() + .update_status(vector_lib::event::EventStatus::Rejected), + Event::Metric(metric) => metric + .metadata() + .update_status(vector_lib::event::EventStatus::Rejected), + Event::Trace(trace) => trace + .metadata() + .update_status(vector_lib::event::EventStatus::Rejected), + } + None } else { Some(event) From 9d44c367ea397acb0ee979d4fceec6d100904704 Mon Sep 17 00:00:00 2001 From: Orri Ganel Date: Mon, 18 May 2026 17:59:57 -0400 Subject: [PATCH 31/49] fix(buffers): account for array vs object cost in nesting check The depth-based check rejected deeply-nested array-only events that prost would happily roundtrip: `Value::Object` levels consume 3 prost recursion frames each (`Value` + `ValueMap` + `map_entry`) while `Value::Array` levels consume only 2 (`Value` + `ValueArray`). Applying a uniform depth-33 cap dropped valid array-only events at depth 34+ from disk buffers, the native codec, and the vector sink. Replaces the depth-based check with a frame-cost-based one that weighs each level by its actual prost recursion cost. Constants renamed to make the new semantics explicit: `MAX_VALUE_NESTING_FRAMES` (99) and `MAX_METADATA_VALUE_NESTING_FRAMES` (96). Object-only boundaries are unchanged (depth 33 / 32); array-only nesting now correctly accepts up to depth 49 / 48; mixed nesting is weighted correctly. Adds `per_path_boundaries` array coverage and new `nesting_gate_accepts_deep_array_nesting` and `nesting_gate_handles_mixed_array_object_nesting` tests that lock in the regression fix. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/codecs/src/encoding/format/native.rs | 11 +- lib/codecs/tests/native.rs | 24 +- lib/vector-core/src/event/mod.rs | 4 +- lib/vector-core/src/event/ser.rs | 172 +++++++----- .../src/event/test/serialization.rs | 259 +++++++++++++----- src/sinks/vector/sink.rs | 52 ++-- 6 files changed, 335 insertions(+), 187 deletions(-) diff --git a/lib/codecs/src/encoding/format/native.rs b/lib/codecs/src/encoding/format/native.rs index 2244d81f06139..3b14db87426ab 100644 --- a/lib/codecs/src/encoding/format/native.rs +++ b/lib/codecs/src/encoding/format/native.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; use tokio_util::codec::Encoder; use vector_core::{ config::DataType, - event::{Event, EventArray, event_exceeds_max_nesting_depth, proto}, + event::{Event, EventArray, event_exceeds_max_nesting_cost, proto}, schema, }; @@ -37,11 +37,10 @@ impl Encoder for NativeSerializer { type Error = vector_common::Error; fn encode(&mut self, event: Event, buffer: &mut BytesMut) -> Result<(), Self::Error> { - if let Some((depth, max)) = event_exceeds_max_nesting_depth(&event) { - return Err(format!( - "event nesting depth ({depth}) exceeds maximum ({max}) for protobuf encoding" - ) - .into()); + if let Some((cost, budget)) = event_exceeds_max_nesting_cost(&event) { + return Err( + format!("event nesting cost ({cost}) exceeds protobuf budget ({budget})").into(), + ); } let array = EventArray::from(event); let proto = proto::EventArray::from(array); diff --git a/lib/codecs/tests/native.rs b/lib/codecs/tests/native.rs index c52adf67afd36..ff24c19278014 100644 --- a/lib/codecs/tests/native.rs +++ b/lib/codecs/tests/native.rs @@ -328,7 +328,7 @@ fn rebuild_fixtures(proto: &str, deserializer: &dyn Deserializer, serializer: &m } // --------------------------------------------------------------------------- -// Nesting depth guard integration tests for the native codec +// Nesting cost guard integration tests for the native codec // --------------------------------------------------------------------------- fn create_nested_log_event(wrapping_levels: usize) -> LogEvent { @@ -345,7 +345,8 @@ fn create_nested_log_event(wrapping_levels: usize) -> LogEvent { #[test] fn native_codec_rejects_overly_nested_event() { - // 33 wrapping levels + "data" key = depth 34 > MAX_NESTING_DEPTH (33) + // 33 wrapping object levels + outer fields object = 34 object levels in Value tree + // (cost 102) > MAX_VALUE_NESTING_FRAMES (99). let event = create_nested_log_event(33); let event = Event::Log(event); @@ -355,13 +356,14 @@ fn native_codec_rejects_overly_nested_event() { let result = serializer.encode(event, &mut buffer); assert!( result.is_err(), - "native codec should reject events exceeding MAX_NESTING_DEPTH" + "native codec should reject events exceeding MAX_VALUE_NESTING_FRAMES" ); } #[test] fn native_codec_roundtrip_max_depth_event() { - // 32 wrapping levels + "data" key = depth 33 = MAX_NESTING_DEPTH + // 32 wrapping object levels + outer fields object = 33 object levels in Value tree + // (cost 99) = MAX_VALUE_NESTING_FRAMES. let event = create_nested_log_event(32); let original_data = event.value().get("data").cloned(); let event = Event::Log(event); @@ -371,12 +373,12 @@ fn native_codec_roundtrip_max_depth_event() { serializer .encode(event, &mut buffer) - .expect("native codec should accept events at MAX_NESTING_DEPTH"); + .expect("native codec should accept events at MAX_VALUE_NESTING_FRAMES"); let deserializer = NativeDeserializerConfig.build(); let decoded_events = deserializer .parse(buffer.freeze(), LogNamespace::Legacy) - .expect("native codec should decode events at MAX_NESTING_DEPTH"); + .expect("native codec should decode events at MAX_VALUE_NESTING_FRAMES"); assert_eq!(decoded_events.len(), 1); let decoded_log = decoded_events.into_iter().next().unwrap().into_log(); @@ -395,7 +397,7 @@ fn create_nested_value(wrapping_levels: usize) -> Value { #[test] fn native_codec_rejects_overly_nested_metadata() { - // Metadata at depth 33 > MAX_METADATA_NESTING_DEPTH (32) + // Metadata at 33 object levels = 99 frame cost > MAX_METADATA_VALUE_NESTING_FRAMES (96). let mut event = LogEvent::from("flat data"); *event.metadata_mut().value_mut() = create_nested_value(33); let event = Event::Log(event); @@ -406,13 +408,13 @@ fn native_codec_rejects_overly_nested_metadata() { let result = serializer.encode(event, &mut buffer); assert!( result.is_err(), - "native codec should reject events with metadata exceeding MAX_METADATA_NESTING_DEPTH" + "native codec should reject events with metadata exceeding MAX_METADATA_VALUE_NESTING_FRAMES" ); } #[test] fn native_codec_roundtrip_max_metadata_depth_event() { - // Metadata at depth 32 = MAX_METADATA_NESTING_DEPTH + // Metadata at 32 object levels = 96 frame cost = MAX_METADATA_VALUE_NESTING_FRAMES. let mut event = LogEvent::from("flat data"); let metadata_value = create_nested_value(32); *event.metadata_mut().value_mut() = metadata_value.clone(); @@ -423,12 +425,12 @@ fn native_codec_roundtrip_max_metadata_depth_event() { serializer .encode(event, &mut buffer) - .expect("native codec should accept events at MAX_METADATA_NESTING_DEPTH"); + .expect("native codec should accept events at MAX_METADATA_VALUE_NESTING_FRAMES"); let deserializer = NativeDeserializerConfig.build(); let decoded_events = deserializer .parse(buffer.freeze(), LogNamespace::Legacy) - .expect("native codec should decode events at MAX_METADATA_NESTING_DEPTH"); + .expect("native codec should decode events at MAX_METADATA_VALUE_NESTING_FRAMES"); assert_eq!(decoded_events.len(), 1); let decoded_log = decoded_events.into_iter().next().unwrap().into_log(); diff --git a/lib/vector-core/src/event/mod.rs b/lib/vector-core/src/event/mod.rs index cd220b1551945..01d3b416e89ee 100644 --- a/lib/vector-core/src/event/mod.rs +++ b/lib/vector-core/src/event/mod.rs @@ -10,7 +10,9 @@ pub use log_event::LogEvent; pub use metadata::{DatadogMetricOriginMetadata, EventMetadata, Secrets, WithMetadata}; pub use metric::{Metric, MetricKind, MetricTags, MetricValue, StatisticKind}; pub use r#ref::{EventMutRef, EventRef}; -pub use ser::{MAX_METADATA_NESTING_DEPTH, MAX_NESTING_DEPTH, event_exceeds_max_nesting_depth}; +pub use ser::{ + MAX_METADATA_VALUE_NESTING_FRAMES, MAX_VALUE_NESTING_FRAMES, event_exceeds_max_nesting_cost, +}; use serde::{Deserialize, Serialize}; pub use trace::TraceEvent; use vector_buffers::EventCount; diff --git a/lib/vector-core/src/event/ser.rs b/lib/vector-core/src/event/ser.rs index f08a020a457f4..85dc2da7600de 100644 --- a/lib/vector-core/src/event/ser.rs +++ b/lib/vector-core/src/event/ser.rs @@ -8,53 +8,72 @@ use vrl::value::Value; use super::{Event, EventArray, EventStatus, proto}; -/// Maximum nesting depth for event data values (`Log.fields`, `Trace.fields`). +/// Per-level prost recursion frame cost of an [`Value::Object`]. /// -/// Prost enforces a decode recursion limit of 100 (no limit on encode). Each Value nesting -/// level consumes multiple prost recursion entries (`Value` + `ValueMap` + `map_entry` for -/// Objects), and each encoding path has a fixed number of proto wrapper messages before the -/// Value tree starts. The event data path (`EventArray` → `*Array` → Event → fields) has -/// fewer wrappers than the metadata path, allowing one extra depth level. +/// Decoding an object level walks `Value → ValueMap → map_entry (synthetic) → Value`, +/// adding three message-decode frames before reaching the child Value. +pub(crate) const OBJECT_FRAME_COST: usize = 3; + +/// Per-level prost recursion frame cost of an [`Value::Array`]. +/// +/// Decoding an array level walks `Value → ValueArray → Value`, adding two message-decode +/// frames before reaching the child Value. +pub(crate) const ARRAY_FRAME_COST: usize = 2; + +/// Maximum prost recursion frame cost for event data values (`Log.fields`, `Trace.fields`). /// -/// Determined empirically and verified by `per_path_boundaries`: depth 33 roundtrips -/// successfully via prost, depth 34 fails decode. -pub const MAX_NESTING_DEPTH: usize = 33; +/// Prost enforces a decode recursion limit of 100 (no limit on encode). Each nesting level +/// consumes 3 frames for [`Value::Object`] or 2 for [`Value::Array`], plus a fixed overhead +/// for the proto wrappers outside the Value tree. The event data path (`EventArray` → +/// `*Array` → Event → fields) has fewer wrappers than the metadata path, allowing a higher +/// frame budget. +/// +/// Object-only depth 33 (cost 99) roundtrips; depth 34 (cost 102) fails decode. Array-only +/// nesting is correspondingly looser: depth 49 (cost 98) is the highest that fits. +pub const MAX_VALUE_NESTING_FRAMES: usize = 99; -/// Maximum nesting depth for event metadata values (via `metadata_full`). +/// Maximum prost recursion frame cost for event metadata values (via `metadata_full`). /// /// The metadata path (`EventArray` → `*Array` → Event → `Metadata` → Value) has one more /// proto wrapper message than the event data path due to the `Metadata` message, reducing -/// the maximum safe depth by one level. +/// the safe budget by 3 frames. /// -/// Determined empirically and verified by `per_path_boundaries`: depth 32 roundtrips -/// successfully via prost, depth 33 fails decode. -pub const MAX_METADATA_NESTING_DEPTH: usize = 32; +/// Object-only depth 32 (cost 96) roundtrips; depth 33 (cost 99) fails decode. +pub const MAX_METADATA_VALUE_NESTING_FRAMES: usize = 96; -/// Check the nesting depth of a `Value`, returning `Err(actual_depth)` if it exceeds `max_depth`. +/// Walks a [`Value`] tree accumulating prost recursion frame cost, returning +/// `Err(over_budget_cost)` as soon as any branch exceeds `budget`. /// -/// This performs an early-exit traversal: it returns as soon as any branch exceeds the limit, -/// avoiding unnecessary work on well-formed events. +/// Object levels weigh [`OBJECT_FRAME_COST`] frames each, array levels weigh +/// [`ARRAY_FRAME_COST`]; scalar leaves are free. Performs an early-exit traversal so +/// well-formed events incur a single descent of the deepest branch only. /// /// # Errors /// -/// Returns `Err(actual_depth)` if any branch of the value tree exceeds `max_depth`. -pub(crate) fn check_value_depth( +/// Returns `Err(actual_cost)` if any branch's cumulative frame cost exceeds `budget`. +pub(crate) fn check_value_nesting_cost( value: &Value, - current_depth: usize, - max_depth: usize, + accumulated: usize, + budget: usize, ) -> Result<(), usize> { - if current_depth > max_depth { - return Err(current_depth); + let level_cost = match value { + Value::Object(_) => OBJECT_FRAME_COST, + Value::Array(_) => ARRAY_FRAME_COST, + _ => 0, + }; + let next = accumulated + level_cost; + if next > budget { + return Err(next); } match value { Value::Object(map) => { for v in map.values() { - check_value_depth(v, current_depth + 1, max_depth)?; + check_value_nesting_cost(v, next, budget)?; } } Value::Array(arr) => { for v in arr { - check_value_depth(v, current_depth + 1, max_depth)?; + check_value_nesting_cost(v, next, budget)?; } } _ => {} @@ -62,66 +81,77 @@ pub(crate) fn check_value_depth( Ok(()) } -/// Checks whether an event's nesting depth exceeds the safe limits for protobuf encoding. +/// Checks whether an event's nesting frame cost exceeds the safe limits for protobuf encoding. /// -/// Returns `Some(depth)` with the violating depth if the event exceeds the limit, -/// or `None` if the event is within bounds. +/// Returns `Some((cost, budget))` identifying the path that violated its budget, or `None` +/// if the event is within bounds. /// -/// Event data values (Log.fields, Trace.fields) are checked against [`MAX_NESTING_DEPTH`], -/// while metadata values are checked against the stricter [`MAX_METADATA_NESTING_DEPTH`] -/// because the `Metadata` proto message adds an extra wrapper layer. +/// Event data values (Log.fields, Trace.fields) are checked against +/// [`MAX_VALUE_NESTING_FRAMES`], while metadata values are checked against the stricter +/// [`MAX_METADATA_VALUE_NESTING_FRAMES`] because the `Metadata` proto message adds an +/// extra wrapper layer. /// /// For metrics, only metadata is checked since metric values have a fixed structure. -pub fn event_exceeds_max_nesting_depth(event: &Event) -> Option<(usize, usize)> { +pub fn event_exceeds_max_nesting_cost(event: &Event) -> Option<(usize, usize)> { match event { - Event::Log(log) => check_value_depth(log.value(), 0, MAX_NESTING_DEPTH) - .map_err(|op| (op, MAX_NESTING_DEPTH)) + Event::Log(log) => check_value_nesting_cost(log.value(), 0, MAX_VALUE_NESTING_FRAMES) + .map_err(|cost| (cost, MAX_VALUE_NESTING_FRAMES)) .and_then(|()| { - check_value_depth(log.metadata().value(), 0, MAX_METADATA_NESTING_DEPTH) - .map_err(|op| (op, MAX_METADATA_NESTING_DEPTH)) + check_value_nesting_cost( + log.metadata().value(), + 0, + MAX_METADATA_VALUE_NESTING_FRAMES, + ) + .map_err(|cost| (cost, MAX_METADATA_VALUE_NESTING_FRAMES)) }) .err(), - Event::Trace(trace) => check_value_depth(trace.value(), 0, MAX_NESTING_DEPTH) - .map_err(|op| (op, MAX_NESTING_DEPTH)) + Event::Trace(trace) => check_value_nesting_cost(trace.value(), 0, MAX_VALUE_NESTING_FRAMES) + .map_err(|cost| (cost, MAX_VALUE_NESTING_FRAMES)) .and_then(|()| { - check_value_depth(trace.metadata().value(), 0, MAX_METADATA_NESTING_DEPTH) - .map_err(|op| (op, MAX_METADATA_NESTING_DEPTH)) + check_value_nesting_cost( + trace.metadata().value(), + 0, + MAX_METADATA_VALUE_NESTING_FRAMES, + ) + .map_err(|cost| (cost, MAX_METADATA_VALUE_NESTING_FRAMES)) }) .err(), - Event::Metric(metric) => { - check_value_depth(metric.metadata().value(), 0, MAX_METADATA_NESTING_DEPTH) - .map_err(|op| (op, MAX_METADATA_NESTING_DEPTH)) - .err() - } + Event::Metric(metric) => check_value_nesting_cost( + metric.metadata().value(), + 0, + MAX_METADATA_VALUE_NESTING_FRAMES, + ) + .map_err(|cost| (cost, MAX_METADATA_VALUE_NESTING_FRAMES)) + .err(), } } -/// Checks all events in an `EventArray` for nesting depth violations. +/// Checks all events in an `EventArray` for nesting cost violations. /// -/// Event data is checked against [`MAX_NESTING_DEPTH`] and metadata against -/// [`MAX_METADATA_NESTING_DEPTH`]. For metrics, only metadata is checked since +/// Event data is checked against [`MAX_VALUE_NESTING_FRAMES`] and metadata against +/// [`MAX_METADATA_VALUE_NESTING_FRAMES`]. For metrics, only metadata is checked since /// metric values have a fixed structure. -fn check_event_array_nesting_depth(events: &EventArray) -> Result<(), EncodeError> { - let check = |value: &Value, max_depth: usize| { - check_value_depth(value, 0, max_depth) - .map_err(|depth| EncodeError::NestingTooDeep { depth, max_depth }) +fn check_event_array_nesting_cost(events: &EventArray) -> Result<(), EncodeError> { + let check = |value: &Value, budget: usize| { + check_value_nesting_cost(value, 0, budget) + .map_err(|cost| EncodeError::NestingTooDeep { cost, budget }) }; match events { EventArray::Logs(logs) => { for log in logs { - check(log.value(), MAX_NESTING_DEPTH)?; - check(log.metadata().value(), MAX_METADATA_NESTING_DEPTH)?; + check(log.value(), MAX_VALUE_NESTING_FRAMES)?; + check(log.metadata().value(), MAX_METADATA_VALUE_NESTING_FRAMES)?; } } EventArray::Traces(traces) => { for trace in traces { - check(trace.value(), MAX_NESTING_DEPTH)?; - check(trace.metadata().value(), MAX_METADATA_NESTING_DEPTH)?; + check(trace.value(), MAX_VALUE_NESTING_FRAMES)?; + check(trace.metadata().value(), MAX_METADATA_VALUE_NESTING_FRAMES)?; } } EventArray::Metrics(metrics) => { for metric in metrics { - check(metric.metadata().value(), MAX_METADATA_NESTING_DEPTH)?; + check(metric.metadata().value(), MAX_METADATA_VALUE_NESTING_FRAMES)?; } } } @@ -132,8 +162,8 @@ fn check_event_array_nesting_depth(events: &EventArray) -> Result<(), EncodeErro pub enum EncodeError { #[snafu(display("the provided buffer was too small to fully encode this item"))] BufferTooSmall, - #[snafu(display("event nesting depth {depth} exceeds maximum of {max_depth}"))] - NestingTooDeep { depth: usize, max_depth: usize }, + #[snafu(display("event nesting cost {cost} exceeds protobuf budget of {budget}"))] + NestingTooDeep { cost: usize, budget: usize }, } #[derive(Debug, Snafu)] @@ -219,10 +249,11 @@ impl Encodable for EventArray { where B: BufMut, { - // Check nesting depth before encoding. Deeply nested events encode - // successfully but fail to decode due to prost's recursion limit, - // which would corrupt the disk buffer. - check_event_array_nesting_depth(&self)?; + // Defense-in-depth: well-behaved callers run `pre_encode_drop_unencodable` + // first, but if any deeply-nested event reaches encode it would corrupt the + // disk buffer by encoding successfully and then failing prost's recursion + // limit on decode. + check_event_array_nesting_cost(&self)?; proto::EventArray::from(self) .encode(buffer) @@ -231,12 +262,12 @@ impl Encodable for EventArray { fn pre_encode_drop_unencodable(&mut self) -> usize { let exceeds = - |value: &Value, max_depth: usize| check_value_depth(value, 0, max_depth).is_err(); + |value: &Value, budget: usize| check_value_nesting_cost(value, 0, budget).is_err(); let mut dropped = 0; match self { EventArray::Logs(logs) => logs.retain(|log| { - let too_deep = exceeds(log.value(), MAX_NESTING_DEPTH) - || exceeds(log.metadata().value(), MAX_METADATA_NESTING_DEPTH); + let too_deep = exceeds(log.value(), MAX_VALUE_NESTING_FRAMES) + || exceeds(log.metadata().value(), MAX_METADATA_VALUE_NESTING_FRAMES); if too_deep { log.metadata().update_status(EventStatus::Rejected); dropped += 1; @@ -244,8 +275,8 @@ impl Encodable for EventArray { !too_deep }), EventArray::Traces(traces) => traces.retain(|trace| { - let too_deep = exceeds(trace.value(), MAX_NESTING_DEPTH) - || exceeds(trace.metadata().value(), MAX_METADATA_NESTING_DEPTH); + let too_deep = exceeds(trace.value(), MAX_VALUE_NESTING_FRAMES) + || exceeds(trace.metadata().value(), MAX_METADATA_VALUE_NESTING_FRAMES); if too_deep { trace.metadata().update_status(EventStatus::Rejected); dropped += 1; @@ -253,7 +284,8 @@ impl Encodable for EventArray { !too_deep }), EventArray::Metrics(metrics) => metrics.retain(|metric| { - let too_deep = exceeds(metric.metadata().value(), MAX_METADATA_NESTING_DEPTH); + let too_deep = + exceeds(metric.metadata().value(), MAX_METADATA_VALUE_NESTING_FRAMES); if too_deep { metric.metadata().update_status(EventStatus::Rejected); dropped += 1; @@ -264,7 +296,7 @@ impl Encodable for EventArray { if dropped > 0 { internal_event::emit(ComponentEventsDropped:: { count: dropped, - reason: "Event nesting depth exceeds maximum for protobuf encoding.", + reason: "Event nesting cost exceeds maximum for protobuf encoding.", }); } dropped diff --git a/lib/vector-core/src/event/test/serialization.rs b/lib/vector-core/src/event/test/serialization.rs index 7981794e9ed2f..1d164702922b6 100644 --- a/lib/vector-core/src/event/test/serialization.rs +++ b/lib/vector-core/src/event/test/serialization.rs @@ -7,7 +7,10 @@ use regex::Regex; use similar_asserts::assert_eq; use vector_buffers::encoding::Encodable; -use crate::event::ser::check_value_depth; +use crate::event::ser::{ + ARRAY_FRAME_COST, MAX_METADATA_VALUE_NESTING_FRAMES, MAX_VALUE_NESTING_FRAMES, + OBJECT_FRAME_COST, check_value_nesting_cost, +}; fn encode_value(value: T, buffer: &mut B) { value.encode(buffer).expect("encoding should not fail"); @@ -100,29 +103,42 @@ fn type_serialization() { } // --------------------------------------------------------------------------- -// Nesting depth validation tests +// Nesting validation tests // --------------------------------------------------------------------------- // -// Prost enforces a decode recursion limit of 100 (no limit on encode). Each Value -// nesting level consumes 3 prost recursion entries (Value + ValueMap + map_entry), -// and each encoding path has a different number of proto wrapper messages before -// the Value tree starts: +// Prost enforces a decode recursion limit of 100 (no limit on encode). Each nesting +// level consumes a path-dependent number of prost recursion frames: // -// - Event data path (Log.fields, Trace.fields): 3 wrappers → max depth 33 -// - Metadata path (metadata_full): 4 wrappers → max depth 32 +// - `Value::Object` level: Value + ValueMap + map_entry = 3 frames +// - `Value::Array` level: Value + ValueArray = 2 frames // -// These limits are encoded as MAX_NESTING_DEPTH (33) and MAX_METADATA_NESTING_DEPTH (32). -// The `per_path_boundaries` test verifies both boundaries empirically via prost roundtrip. +// Each encoding path has a fixed proto-wrapper overhead before the Value tree starts: +// +// - Event data path (Log.fields, Trace.fields): frame budget MAX_VALUE_NESTING_FRAMES (99) +// - Metadata path (metadata_full): frame budget MAX_METADATA_VALUE_NESTING_FRAMES (96) +// +// The `per_path_boundaries` test verifies both budgets empirically via prost roundtrip. // // The saturated-event tests create events with ALL Value-carrying fields at their -// respective max depths 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. +// respective max frame cost 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. + +/// Maximum number of object-only nesting levels that fit the event-data frame budget. +const MAX_OBJECT_DEPTH_VALUE: usize = MAX_VALUE_NESTING_FRAMES / OBJECT_FRAME_COST; + +/// Maximum number of object-only nesting levels that fit the metadata frame budget. +const MAX_OBJECT_DEPTH_METADATA: usize = MAX_METADATA_VALUE_NESTING_FRAMES / OBJECT_FRAME_COST; + +/// Maximum number of array-only nesting levels that fit the event-data frame budget. +const MAX_ARRAY_DEPTH_VALUE: usize = MAX_VALUE_NESTING_FRAMES / ARRAY_FRAME_COST; + +/// Maximum number of array-only nesting levels that fit the metadata frame budget. +const MAX_ARRAY_DEPTH_METADATA: usize = MAX_METADATA_VALUE_NESTING_FRAMES / ARRAY_FRAME_COST; /// Creates a Value with the specified number of nested Object wrapping levels. /// /// Returns a Value that is `wrapping_levels` nested Objects deep, with a string leaf. -/// `check_value_depth` will measure this as depth `wrapping_levels` (the leaf). fn create_nested_value(wrapping_levels: usize) -> Value { let mut value = Value::from("innermost"); for _ in 0..wrapping_levels { @@ -133,6 +149,15 @@ fn create_nested_value(wrapping_levels: usize) -> Value { value } +/// Creates a Value with the specified number of nested Array wrapping levels. +fn create_nested_array(wrapping_levels: usize) -> Value { + let mut value = Value::from("innermost"); + for _ in 0..wrapping_levels { + value = Value::Array(vec![value]); + } + value +} + /// Create a [`LogEvent`] with event data at `value_depth` and metadata at `metadata_depth`. fn create_saturated_log(value_depth: usize, metadata_depth: usize) -> LogEvent { let mut event = LogEvent::default(); @@ -208,14 +233,15 @@ fn saturated_events(value_depth: usize, metadata_depth: usize) -> Vec<(&'static ] } -/// Verify both depth constants are exactly right: all event types roundtrip at the -/// max depths, and at least one fails prost decode when either limit is exceeded. +/// Verify the frame budgets are exactly right: all event types roundtrip at the +/// max object-only depth, and at least one fails prost decode when either budget +/// is exceeded. #[test] -fn max_nesting_depths_are_correct() { - let max_val = super::super::ser::MAX_NESTING_DEPTH; - let max_meta = super::super::ser::MAX_METADATA_NESTING_DEPTH; +fn max_nesting_budgets_are_correct() { + let max_val = MAX_OBJECT_DEPTH_VALUE; + let max_meta = MAX_OBJECT_DEPTH_METADATA; - // --- Both limits at max must roundtrip for all event types --- + // --- Both budgets at max must roundtrip for all event types --- for (name, array) in saturated_event_arrays(max_val, max_meta) { let proto_array = proto::EventArray::from(array); @@ -237,9 +263,9 @@ fn max_nesting_depths_are_correct() { ); } - // --- Exceeding either limit must fail for at least one event type --- + // --- Exceeding either budget must fail for at least one event type --- - // Exceed value depth + // Exceed value budget let any_fails = saturated_event_arrays(max_val + 1, max_meta) .into_iter() .any(|(_, array)| { @@ -250,11 +276,11 @@ fn max_nesting_depths_are_correct() { }); assert!( any_fails, - "No path failed at value depth {}. MAX_NESTING_DEPTH could be raised.", + "No path failed at object value depth {}. MAX_VALUE_NESTING_FRAMES could be raised.", max_val + 1 ); - // Exceed metadata depth + // Exceed metadata budget let any_fails = saturated_event_arrays(max_val, max_meta + 1) .into_iter() .any(|(_, array)| { @@ -265,33 +291,30 @@ fn max_nesting_depths_are_correct() { }); assert!( any_fails, - "No path failed at metadata depth {}. MAX_METADATA_NESTING_DEPTH could be raised.", + "No path failed at object metadata depth {}. MAX_METADATA_VALUE_NESTING_FRAMES could be raised.", max_meta + 1 ); } -/// Verify the nesting gate accepts all event types at the max depths. +/// Verify the nesting gate accepts all event types at the max object-only depth. #[test] fn nesting_gate_accepts_all_types_at_max_depth() { - let max_val = super::super::ser::MAX_NESTING_DEPTH; - let max_meta = super::super::ser::MAX_METADATA_NESTING_DEPTH; - for (name, array) in saturated_event_arrays(max_val, max_meta) { + for (name, array) in saturated_event_arrays(MAX_OBJECT_DEPTH_VALUE, MAX_OBJECT_DEPTH_METADATA) { let mut buf = BytesMut::with_capacity(65536); assert!( array.encode(&mut buf).is_ok(), - "nesting gate rejected {name} at max depths", + "nesting gate rejected {name} at max object depths", ); } } -/// Verify the nesting gate rejects when either limit is exceeded. +/// Verify the nesting gate rejects when either object-only budget is exceeded. #[test] fn nesting_gate_rejects_above_max_depth() { - let max_val = super::super::ser::MAX_NESTING_DEPTH; - let max_meta = super::super::ser::MAX_METADATA_NESTING_DEPTH; - - // Exceed value depth (Log and Trace have event data; Metric does not) - for (name, array) in saturated_event_arrays(max_val + 1, max_meta) { + // Exceed value budget (Log and Trace have event data; Metric does not) + for (name, array) in + saturated_event_arrays(MAX_OBJECT_DEPTH_VALUE + 1, MAX_OBJECT_DEPTH_METADATA) + { // Metric has no event data field, so it won't be rejected here if name == "Metric" { continue; @@ -302,37 +325,39 @@ fn nesting_gate_rejects_above_max_depth() { array.encode(&mut buf), Err(super::super::ser::EncodeError::NestingTooDeep { .. }) ), - "nesting gate should reject {name} at value depth {}", - max_val + 1, + "nesting gate should reject {name} at object value depth {}", + MAX_OBJECT_DEPTH_VALUE + 1, ); } - // Exceed metadata depth - for (name, array) in saturated_event_arrays(max_val, max_meta + 1) { + // Exceed metadata budget + for (name, array) in + saturated_event_arrays(MAX_OBJECT_DEPTH_VALUE, MAX_OBJECT_DEPTH_METADATA + 1) + { let mut buf = BytesMut::with_capacity(65536); assert!( matches!( array.encode(&mut buf), Err(super::super::ser::EncodeError::NestingTooDeep { .. }) ), - "nesting gate should reject {name} at metadata depth {}", - max_meta + 1, + "nesting gate should reject {name} at object metadata depth {}", + MAX_OBJECT_DEPTH_METADATA + 1, ); } } -/// Verify the per-path prost boundaries match the constants. +/// Verify the per-path prost boundaries match the budgets for both object-only and +/// array-only nesting. /// -/// `Log.fields` (loosest): `MAX_NESTING_DEPTH` (33) succeeds, 34 fails. -/// `metadata_full` (tightest): `MAX_METADATA_NESTING_DEPTH` (32) succeeds, 33 fails. +/// Object-only `Log.fields`: depth 33 succeeds, 34 fails. +/// Object-only `metadata_full`: depth 32 succeeds, 33 fails. +/// Array-only `Log.fields`: depth 49 succeeds, 50 fails. +/// Array-only `metadata_full`: depth 48 succeeds, 49 fails. #[test] fn per_path_boundaries() { - let max_val = super::super::ser::MAX_NESTING_DEPTH; - let max_meta = super::super::ser::MAX_METADATA_NESTING_DEPTH; - - let roundtrip_value = |depth: usize| -> bool { + let roundtrip_value = |value: Value| -> bool { let mut event = LogEvent::default(); - event.insert("data", create_nested_value(depth - 1)); + event.insert("data", value); let array = EventArray::Logs(LogArray::from(vec![event])); let proto_array = proto::EventArray::from(array); let mut buf = BytesMut::with_capacity(65536); @@ -340,9 +365,9 @@ fn per_path_boundaries() { proto::EventArray::decode(buf.freeze()).is_ok() }; - let roundtrip_metadata = |depth: usize| -> bool { + let roundtrip_metadata = |value: Value| -> bool { let mut event = LogEvent::from("flat"); - *event.metadata_mut().value_mut() = create_nested_value(depth); + *event.metadata_mut().value_mut() = value; let array = EventArray::Logs(LogArray::from(vec![event])); let proto_array = proto::EventArray::from(array); let mut buf = BytesMut::with_capacity(65536); @@ -350,26 +375,113 @@ fn per_path_boundaries() { proto::EventArray::decode(buf.freeze()).is_ok() }; - // Log.fields: MAX_NESTING_DEPTH succeeds, MAX_NESTING_DEPTH+1 fails + // Object-only Log.fields: the "data" key contributes one level on top of the inner + // nested value, so we subtract one when building the value. assert!( - roundtrip_value(max_val), - "Log.fields should succeed at depth {max_val}" + roundtrip_value(create_nested_value(MAX_OBJECT_DEPTH_VALUE - 1)), + "Log.fields should succeed at object depth {MAX_OBJECT_DEPTH_VALUE}" ); assert!( - !roundtrip_value(max_val + 1), - "Log.fields should fail at depth {}", - max_val + 1 + !roundtrip_value(create_nested_value(MAX_OBJECT_DEPTH_VALUE)), + "Log.fields should fail at object depth {}", + MAX_OBJECT_DEPTH_VALUE + 1 ); - // metadata_full: MAX_METADATA_NESTING_DEPTH succeeds, MAX_METADATA_NESTING_DEPTH+1 fails + // Object-only metadata_full: metadata Value is the root, no key on top. assert!( - roundtrip_metadata(max_meta), - "metadata_full should succeed at depth {max_meta}" + roundtrip_metadata(create_nested_value(MAX_OBJECT_DEPTH_METADATA)), + "metadata_full should succeed at object depth {MAX_OBJECT_DEPTH_METADATA}" ); assert!( - !roundtrip_metadata(max_meta + 1), - "metadata_full should fail at depth {}", - max_meta + 1 + !roundtrip_metadata(create_nested_value(MAX_OBJECT_DEPTH_METADATA + 1)), + "metadata_full should fail at object depth {}", + MAX_OBJECT_DEPTH_METADATA + 1 + ); + + // Array-only Log.fields: array contributes 2 frames per level, so it fits more levels. + assert!( + roundtrip_value(create_nested_array(MAX_ARRAY_DEPTH_VALUE - 1)), + "Log.fields should succeed at array depth {MAX_ARRAY_DEPTH_VALUE}" + ); + assert!( + !roundtrip_value(create_nested_array(MAX_ARRAY_DEPTH_VALUE)), + "Log.fields should fail at array depth {}", + MAX_ARRAY_DEPTH_VALUE + 1 + ); + + // Array-only metadata_full + assert!( + roundtrip_metadata(create_nested_array(MAX_ARRAY_DEPTH_METADATA)), + "metadata_full should succeed at array depth {MAX_ARRAY_DEPTH_METADATA}" + ); + assert!( + !roundtrip_metadata(create_nested_array(MAX_ARRAY_DEPTH_METADATA + 1)), + "metadata_full should fail at array depth {}", + MAX_ARRAY_DEPTH_METADATA + 1 + ); +} + +/// Verify that array-only nesting deeper than the object-only cap (33) is accepted by +/// the gate — this is the regression that the frame-cost check addresses. Previously a +/// uniform depth-33 cap dropped array-only events that prost would happily roundtrip. +#[test] +fn nesting_gate_accepts_deep_array_nesting() { + // An array depth 40 = 80 frames, comfortably under the 99-frame value budget but well + // over the 33-depth limit the old uniform check would have applied. + let mut event = LogEvent::default(); + event.insert("data", create_nested_array(40)); + let array = EventArray::Logs(LogArray::from(vec![event])); + let mut buf = BytesMut::with_capacity(65536); + assert!( + array.encode(&mut buf).is_ok(), + "nesting gate should accept array-only nesting at depth 40", + ); +} + +/// Verify the gate correctly accounts for mixed array/object nesting via the per-variant +/// frame weights. Uses the metadata path because it has no outer wrapping object, making +/// the arithmetic match the inserted Value's cost directly. +#[test] +fn nesting_gate_handles_mixed_array_object_nesting() { + // Alternating levels (innermost-Array, then Object, then Array, ...). For N levels, + // cost = ceil(N/2)*ARRAY_FRAME_COST + floor(N/2)*OBJECT_FRAME_COST. + let build_alternating = |total_levels: usize| -> Value { + let mut value = Value::from("leaf"); + for i in 0..total_levels { + if i.is_multiple_of(2) { + value = Value::Array(vec![value]); + } else { + let mut map = ObjectMap::new(); + map.insert("k".into(), value); + value = Value::Object(map); + } + } + value + }; + + // 38 alternating levels: 19 array (cost 38) + 19 object (cost 57) = 95 frames. + // Under the metadata budget of 96. Fits. + let mut event = LogEvent::from("flat"); + *event.metadata_mut().value_mut() = build_alternating(38); + let array = EventArray::Logs(LogArray::from(vec![event])); + let mut buf = BytesMut::with_capacity(65536); + assert!( + array.encode(&mut buf).is_ok(), + "nesting gate should accept 38 alternating metadata levels (cost 95)", + ); + + // 39 alternating levels: 20 array (cost 40) + 19 object (cost 57) = 97 frames. + // Over the metadata budget of 96. Fails. + let mut event = LogEvent::from("flat"); + *event.metadata_mut().value_mut() = build_alternating(39); + let array = EventArray::Logs(LogArray::from(vec![event])); + let mut buf = BytesMut::with_capacity(65536); + assert!( + matches!( + array.encode(&mut buf), + Err(super::super::ser::EncodeError::NestingTooDeep { .. }) + ), + "nesting gate should reject 39 alternating metadata levels (cost 97)", ); } @@ -399,7 +511,8 @@ fn nesting_gate_accepts_flat_events() { } #[test] -fn check_value_depth_with_configurable_limit() { +fn check_value_nesting_cost_with_configurable_budget() { + // Five nested objects: 5 levels × 3 frames per object = 15 frame cost. let mut value = Value::from("leaf"); for _ in 0..5 { let mut map = ObjectMap::new(); @@ -407,22 +520,22 @@ fn check_value_depth_with_configurable_limit() { value = Value::Object(map); } - assert!(check_value_depth(&value, 0, 5).is_ok()); - assert!(check_value_depth(&value, 0, 4).is_err()); - assert!(check_value_depth(&value, 0, 10).is_ok()); + assert!(check_value_nesting_cost(&value, 0, 15).is_ok()); + assert!(check_value_nesting_cost(&value, 0, 14).is_err()); + assert!(check_value_nesting_cost(&value, 0, 30).is_ok()); let flat = Value::from("hello"); - assert!(check_value_depth(&flat, 0, 0).is_ok()); + assert!(check_value_nesting_cost(&flat, 0, 0).is_ok()); } #[test] -fn check_value_depth_with_arrays() { - // Array containing an object containing an array containing a value = depth 3 +fn check_value_nesting_cost_with_mixed_variants() { + // Outer array (2) → inner object (3) → inner array (2) → leaf = 7 frame cost. let inner = Value::Array(vec![Value::from("leaf")]); let mut map = ObjectMap::new(); map.insert("arr".into(), inner); let value = Value::Array(vec![Value::Object(map)]); - assert!(check_value_depth(&value, 0, 3).is_ok()); - assert!(check_value_depth(&value, 0, 2).is_err()); + assert!(check_value_nesting_cost(&value, 0, 7).is_ok()); + assert!(check_value_nesting_cost(&value, 0, 6).is_err()); } diff --git a/src/sinks/vector/sink.rs b/src/sinks/vector/sink.rs index 7d874dead072d..c3af5c61d87e7 100644 --- a/src/sinks/vector/sink.rs +++ b/src/sinks/vector/sink.rs @@ -7,7 +7,7 @@ use tower::Service; use vector_lib::{ ByteSizeOf, EstimatedJsonEncodedSizeOf, config::telemetry, - event::event_exceeds_max_nesting_depth, + event::event_exceeds_max_nesting_cost, internal_event::{ComponentEventsDropped, UNINTENTIONAL}, request_metadata::GroupedCountByteSize, stream::{BatcherSettings, DriverResponse, batcher::data::BatchReduce}, @@ -63,32 +63,32 @@ where async fn run_inner(self: Box, input: BoxStream<'_, Event>) -> Result<(), ()> { input .filter_map(|event| { - std::future::ready(if let Some((depth, max)) = - event_exceeds_max_nesting_depth(&event) - { - let reason = format!( - "Event nesting depth {depth} exceeds maximum of {max} for protobuf encoding." - ); - emit!(ComponentEventsDropped:: { - count: 1, - reason: &reason, - }); - match event { - Event::Log(log) => log - .metadata() - .update_status(vector_lib::event::EventStatus::Rejected), - Event::Metric(metric) => metric - .metadata() - .update_status(vector_lib::event::EventStatus::Rejected), - Event::Trace(trace) => trace - .metadata() - .update_status(vector_lib::event::EventStatus::Rejected), - } + std::future::ready( + if let Some((cost, budget)) = event_exceeds_max_nesting_cost(&event) { + let reason = format!( + "Event nesting cost {cost} exceeds protobuf budget of {budget}." + ); + emit!(ComponentEventsDropped:: { + count: 1, + reason: &reason, + }); + match event { + Event::Log(log) => log + .metadata() + .update_status(vector_lib::event::EventStatus::Rejected), + Event::Metric(metric) => metric + .metadata() + .update_status(vector_lib::event::EventStatus::Rejected), + Event::Trace(trace) => trace + .metadata() + .update_status(vector_lib::event::EventStatus::Rejected), + } - None - } else { - Some(event) - }) + None + } else { + Some(event) + }, + ) }) .map(|mut event| { let mut byte_size = telemetry().create_request_count_byte_size(); From 4993bb5f42ad7af8020635888e3a85b7316995f6 Mon Sep 17 00:00:00 2001 From: Orri Ganel Date: Tue, 19 May 2026 16:15:00 -0400 Subject: [PATCH 32/49] fix(buffers): charge Value::Timestamp for one nesting frame Value::Timestamp encodes as a nested google.protobuf.Timestamp message, so prost consumes one recursion frame entering it. The previous gate treated it as a free scalar, letting an event at the deepest allowed object branch (event-data depth 33 or metadata depth 32) sneak through when ending in a timestamp leaf and then trip the prost recursion limit on decode -- exactly the disk-buffer/native/vector-sink corruption this limit is meant to prevent. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/vector-core/src/event/ser.rs | 30 +++- .../src/event/test/serialization.rs | 148 +++++++++++++++++- 2 files changed, 169 insertions(+), 9 deletions(-) diff --git a/lib/vector-core/src/event/ser.rs b/lib/vector-core/src/event/ser.rs index 85dc2da7600de..41476877f3a89 100644 --- a/lib/vector-core/src/event/ser.rs +++ b/lib/vector-core/src/event/ser.rs @@ -20,16 +20,26 @@ pub(crate) const OBJECT_FRAME_COST: usize = 3; /// frames before reaching the child Value. pub(crate) const ARRAY_FRAME_COST: usize = 2; +/// Per-leaf prost recursion frame cost of a [`Value::Timestamp`]. +/// +/// Unlike other scalar variants, `Value::Timestamp` is encoded as a nested +/// `google.protobuf.Timestamp` message, so decoding it consumes one additional frame +/// beyond the enclosing `Value`. Without this cost a timestamp leaf at the deepest +/// allowed branch (event-data object depth 33 or metadata object depth 32) sneaks past +/// the gate and trips prost's recursion limit on decode. +pub(crate) const TIMESTAMP_FRAME_COST: usize = 1; + /// Maximum prost recursion frame cost for event data values (`Log.fields`, `Trace.fields`). /// /// Prost enforces a decode recursion limit of 100 (no limit on encode). Each nesting level -/// consumes 3 frames for [`Value::Object`] or 2 for [`Value::Array`], plus a fixed overhead -/// for the proto wrappers outside the Value tree. The event data path (`EventArray` → -/// `*Array` → Event → fields) has fewer wrappers than the metadata path, allowing a higher -/// frame budget. +/// consumes 3 frames for [`Value::Object`], 2 for [`Value::Array`], or 1 for a +/// [`Value::Timestamp`] leaf, plus a fixed overhead for the proto wrappers outside the +/// Value tree. The event data path (`EventArray` → `*Array` → Event → fields) has fewer +/// wrappers than the metadata path, allowing a higher frame budget. /// /// Object-only depth 33 (cost 99) roundtrips; depth 34 (cost 102) fails decode. Array-only -/// nesting is correspondingly looser: depth 49 (cost 98) is the highest that fits. +/// nesting is correspondingly looser: depth 49 (cost 98) is the highest that fits. A +/// `Value::Timestamp` leaf added at depth 33 raises the cost to 100 and fails decode. pub const MAX_VALUE_NESTING_FRAMES: usize = 99; /// Maximum prost recursion frame cost for event metadata values (via `metadata_full`). @@ -38,15 +48,18 @@ pub const MAX_VALUE_NESTING_FRAMES: usize = 99; /// proto wrapper message than the event data path due to the `Metadata` message, reducing /// the safe budget by 3 frames. /// -/// Object-only depth 32 (cost 96) roundtrips; depth 33 (cost 99) fails decode. +/// Object-only depth 32 (cost 96) roundtrips; depth 33 (cost 99) fails decode. A +/// `Value::Timestamp` leaf added at depth 32 raises the cost to 97 and fails decode. pub const MAX_METADATA_VALUE_NESTING_FRAMES: usize = 96; /// Walks a [`Value`] tree accumulating prost recursion frame cost, returning /// `Err(over_budget_cost)` as soon as any branch exceeds `budget`. /// /// Object levels weigh [`OBJECT_FRAME_COST`] frames each, array levels weigh -/// [`ARRAY_FRAME_COST`]; scalar leaves are free. Performs an early-exit traversal so -/// well-formed events incur a single descent of the deepest branch only. +/// [`ARRAY_FRAME_COST`], and timestamp leaves weigh [`TIMESTAMP_FRAME_COST`] (because +/// they decode into a nested `google.protobuf.Timestamp` message); other scalar leaves +/// are free. Performs an early-exit traversal so well-formed events incur a single +/// descent of the deepest branch only. /// /// # Errors /// @@ -59,6 +72,7 @@ pub(crate) fn check_value_nesting_cost( let level_cost = match value { Value::Object(_) => OBJECT_FRAME_COST, Value::Array(_) => ARRAY_FRAME_COST, + Value::Timestamp(_) => TIMESTAMP_FRAME_COST, _ => 0, }; let next = accumulated + level_cost; diff --git a/lib/vector-core/src/event/test/serialization.rs b/lib/vector-core/src/event/test/serialization.rs index 1d164702922b6..777f911fba57b 100644 --- a/lib/vector-core/src/event/test/serialization.rs +++ b/lib/vector-core/src/event/test/serialization.rs @@ -1,6 +1,7 @@ use super::*; use crate::config::log_schema; use bytes::{Buf, BufMut, BytesMut}; +use chrono::TimeZone; use prost::Message; use quickcheck::{QuickCheck, TestResult}; use regex::Regex; @@ -9,7 +10,7 @@ use vector_buffers::encoding::Encodable; use crate::event::ser::{ ARRAY_FRAME_COST, MAX_METADATA_VALUE_NESTING_FRAMES, MAX_VALUE_NESTING_FRAMES, - OBJECT_FRAME_COST, check_value_nesting_cost, + OBJECT_FRAME_COST, TIMESTAMP_FRAME_COST, check_value_nesting_cost, }; fn encode_value(value: T, buffer: &mut B) { @@ -158,6 +159,28 @@ fn create_nested_array(wrapping_levels: usize) -> Value { value } +/// Creates a Value with the specified number of nested Object wrapping levels around +/// the supplied leaf. Used to probe leaf-specific frame costs (e.g. `Value::Timestamp`). +fn create_nested_value_with_leaf(wrapping_levels: usize, leaf: Value) -> Value { + let mut value = leaf; + for _ in 0..wrapping_levels { + let mut map = ObjectMap::new(); + map.insert("nested".into(), value); + value = Value::Object(map); + } + value +} + +/// A fixed [`Value::Timestamp`] for use as a leaf in nesting tests. +fn ts_leaf() -> Value { + Value::Timestamp( + chrono::Utc + .timestamp_opt(1_700_000_000, 0) + .single() + .unwrap(), + ) +} + /// Create a [`LogEvent`] with event data at `value_depth` and metadata at `metadata_depth`. fn create_saturated_log(value_depth: usize, metadata_depth: usize) -> LogEvent { let mut event = LogEvent::default(); @@ -485,6 +508,129 @@ fn nesting_gate_handles_mixed_array_object_nesting() { ); } +/// Verify the gate rejects a `Value::Timestamp` leaf sitting at the deepest object +/// position the budget would otherwise allow, and that the underlying proto roundtrip +/// would in fact fail there — confirming the timestamp leaf is not free. +#[test] +fn nesting_gate_rejects_timestamp_leaf_at_max_object_depth() { + let roundtrip_log = |value: Value| -> bool { + let mut event = LogEvent::default(); + event.insert("data", value); + let array = EventArray::Logs(LogArray::from(vec![event])); + let proto_array = proto::EventArray::from(array); + let mut buf = BytesMut::with_capacity(65536); + proto_array.encode(&mut buf).unwrap(); + proto::EventArray::decode(buf.freeze()).is_ok() + }; + let roundtrip_metadata = |value: Value| -> bool { + let mut event = LogEvent::from("flat"); + *event.metadata_mut().value_mut() = value; + let array = EventArray::Logs(LogArray::from(vec![event])); + let proto_array = proto::EventArray::from(array); + let mut buf = BytesMut::with_capacity(65536); + proto_array.encode(&mut buf).unwrap(); + proto::EventArray::decode(buf.freeze()).is_ok() + }; + + // Event data: at object depth 33, a Bytes leaf decodes but a Timestamp leaf does not, + // because the Timestamp message consumes one more recursion frame. + let event_data_ts = create_nested_value_with_leaf(MAX_OBJECT_DEPTH_VALUE - 1, ts_leaf()); + assert!( + !roundtrip_log(event_data_ts.clone()), + "depth {MAX_OBJECT_DEPTH_VALUE} with Timestamp leaf is expected to fail prost decode" + ); + + let mut event = LogEvent::default(); + event.insert("data", event_data_ts); + let array = EventArray::Logs(LogArray::from(vec![event])); + let mut buf = BytesMut::with_capacity(65536); + assert!( + matches!( + array.encode(&mut buf), + Err(super::super::ser::EncodeError::NestingTooDeep { .. }) + ), + "gate should reject event-data Timestamp leaf at object depth {MAX_OBJECT_DEPTH_VALUE}", + ); + + // Metadata: same boundary, one shallower. + let metadata_ts = create_nested_value_with_leaf(MAX_OBJECT_DEPTH_METADATA, ts_leaf()); + assert!( + !roundtrip_metadata(metadata_ts.clone()), + "metadata depth {MAX_OBJECT_DEPTH_METADATA} with Timestamp leaf is expected to fail prost decode" + ); + + let mut event = LogEvent::from("flat"); + *event.metadata_mut().value_mut() = metadata_ts; + let array = EventArray::Logs(LogArray::from(vec![event])); + let mut buf = BytesMut::with_capacity(65536); + assert!( + matches!( + array.encode(&mut buf), + Err(super::super::ser::EncodeError::NestingTooDeep { .. }) + ), + "gate should reject metadata Timestamp leaf at object depth {MAX_OBJECT_DEPTH_METADATA}", + ); +} + +/// Verify the gate still admits Timestamp leaves one level shallower than the boundary +/// — they cost exactly one frame, no more — and that those payloads roundtrip cleanly +/// through prost. +#[test] +fn nesting_gate_accepts_timestamp_leaf_below_max_object_depth() { + // Event data: depth (max-1) Object + Timestamp leaf = (max-1)*3 + 1 frames. + let mut event = LogEvent::default(); + event.insert( + "data", + create_nested_value_with_leaf(MAX_OBJECT_DEPTH_VALUE - 2, ts_leaf()), + ); + let array = EventArray::Logs(LogArray::from(vec![event])); + let mut buf = BytesMut::with_capacity(65536); + assert!( + array.encode(&mut buf).is_ok(), + "gate should accept event-data Timestamp leaf at object depth {}", + MAX_OBJECT_DEPTH_VALUE - 1, + ); + assert!( + proto::EventArray::decode(buf.freeze()).is_ok(), + "prost should decode event-data Timestamp leaf at object depth {}", + MAX_OBJECT_DEPTH_VALUE - 1, + ); + + // Metadata: one shallower. + let mut event = LogEvent::from("flat"); + *event.metadata_mut().value_mut() = + create_nested_value_with_leaf(MAX_OBJECT_DEPTH_METADATA - 1, ts_leaf()); + let array = EventArray::Logs(LogArray::from(vec![event])); + let mut buf = BytesMut::with_capacity(65536); + assert!( + array.encode(&mut buf).is_ok(), + "gate should accept metadata Timestamp leaf at object depth {}", + MAX_OBJECT_DEPTH_METADATA - 1, + ); + assert!( + proto::EventArray::decode(buf.freeze()).is_ok(), + "prost should decode metadata Timestamp leaf at object depth {}", + MAX_OBJECT_DEPTH_METADATA - 1, + ); +} + +/// Unit-level check that `check_value_nesting_cost` charges `TIMESTAMP_FRAME_COST` +/// for a `Value::Timestamp` leaf, independent of nesting. +#[test] +fn check_value_nesting_cost_charges_timestamp_leaf() { + let ts = ts_leaf(); + assert!(check_value_nesting_cost(&ts, 0, TIMESTAMP_FRAME_COST).is_ok()); + assert!(check_value_nesting_cost(&ts, 0, TIMESTAMP_FRAME_COST - 1).is_err()); + + // A single object level containing a timestamp leaf: OBJECT_FRAME_COST + TIMESTAMP_FRAME_COST. + let mut map = ObjectMap::new(); + map.insert("ts".into(), ts); + let nested = Value::Object(map); + let expected = OBJECT_FRAME_COST + TIMESTAMP_FRAME_COST; + assert!(check_value_nesting_cost(&nested, 0, expected).is_ok()); + assert!(check_value_nesting_cost(&nested, 0, expected - 1).is_err()); +} + /// Verify flat events pass without issues. #[test] fn nesting_gate_accepts_flat_events() { From 0b639d4a6da697a3365d854f1a66761cd6f09c86 Mon Sep 17 00:00:00 2001 From: Orri Ganel Date: Tue, 16 Jun 2026 13:25:31 -0400 Subject: [PATCH 33/49] Update changelog.d/protobuf_nesting_depth_limit.fix.md Co-authored-by: Pavlos Rontidis --- changelog.d/protobuf_nesting_depth_limit.fix.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/protobuf_nesting_depth_limit.fix.md b/changelog.d/protobuf_nesting_depth_limit.fix.md index d53f69d899765..7491327472f8d 100644 --- a/changelog.d/protobuf_nesting_depth_limit.fix.md +++ b/changelog.d/protobuf_nesting_depth_limit.fix.md @@ -1,3 +1,3 @@ -Fixed disk buffer corruption caused by deeply nested events exceeding prost's protobuf recursion limit during decode. Events with nesting depth greater than 32 are now rejected at encode time across disk buffers, the native codec, and the `vector` sink's gRPC path, preventing unrecoverable buffer corruption. +Fixed unrecoverable disk buffer corruption and vector-to-vector retry loops caused by event data or metadata that protobuf could encode but prost could not decode. Vector now rejects only protobuf-unsafe nested payloads before disk buffer, native codec, or `vector` sink gRPC encoding, while preserving nested shapes that prost can safely decode. authors: connoryy From eec68b2a17c9601a3c0a9466bfceb935e852201e Mon Sep 17 00:00:00 2001 From: Orri Ganel Date: Tue, 16 Jun 2026 15:20:58 -0400 Subject: [PATCH 34/49] refactor(buffers): move pre-encode filtering to Bufferable::filter_unencodable The drop-unencodable behavior was bolted onto Encodable as pre_encode_drop_unencodable with a default no-op, even though serialization mechanics are unrelated to buffer-side filtering. Move it to Bufferable as filter_unencodable(self) -> Option, which: - Lives next to the other buffer concerns (event_count, AddBatchNotifier) rather than polluting Encodable. - Consumes self and returns Option, collapsing the previous &mut self + event_count() == 0 dance at every disk sender call site into a single let-else. - Defaults to passing the item through unchanged (None only when already empty), so types with nothing to filter need only a marker impl. The Bufferable blanket impl is removed to let EventArray override; the handful of test and example types that relied on it gain trivial explicit impls. EventArray::filter_unencodable now consumes self in each match arm, retains on the moved inner vec, and reassembles the variant -- replaces the consume-then-borrow pattern of the original implementation. Adds the regression and integration tests the prior commit lacked: - filter_unencodable_drops_only_over_budget_events pins partial-drop behavior with a mixed batch. - event_exceeds_max_nesting_cost_charges_timestamp_leaf covers the public per-event entry point used by the native codec and vector sink. - native_codec_rejects/roundtrips_timestamp_leaf_* tests exercise the Timestamp accounting through the native codec integration path. Docstrings on Bufferable::filter_unencodable and the EventArray Encodable::encode impl now cross-reference each other, documenting the two-policy layering: filter for graceful per-item drop, encode as defense-in-depth all-or-nothing. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/codecs/tests/native.rs | 82 ++++++++++++ lib/vector-buffers/benches/common.rs | 4 +- lib/vector-buffers/examples/buffer_perf.rs | 2 + lib/vector-buffers/src/encoding.rs | 11 -- lib/vector-buffers/src/lib.rs | 41 +++++- lib/vector-buffers/src/test/messages.rs | 6 +- .../src/topology/channel/sender.rs | 14 +- lib/vector-buffers/src/topology/test_util.rs | 2 + .../variants/disk_v2/tests/known_errors.rs | 7 +- .../variants/disk_v2/tests/model/record.rs | 4 +- lib/vector-core/src/event/ser.rs | 121 +++++++++++------- .../src/event/test/serialization.rs | 100 +++++++++++++++ 12 files changed, 319 insertions(+), 75 deletions(-) diff --git a/lib/codecs/tests/native.rs b/lib/codecs/tests/native.rs index 1ef92263211b1..7c499f104ad93 100644 --- a/lib/codecs/tests/native.rs +++ b/lib/codecs/tests/native.rs @@ -412,6 +412,88 @@ fn native_codec_rejects_overly_nested_metadata() { ); } +fn create_nested_value_with_leaf(wrapping_levels: usize, leaf: Value) -> Value { + let mut value = leaf; + for _ in 0..wrapping_levels { + let mut map = ObjectMap::new(); + map.insert("nested".into(), value); + value = Value::Object(map); + } + value +} + +fn ts_leaf() -> Value { + use chrono::TimeZone; + Value::Timestamp( + chrono::Utc + .timestamp_opt(1_700_000_000, 0) + .single() + .unwrap(), + ) +} + +#[test] +fn native_codec_rejects_timestamp_leaf_at_max_object_depth() { + // 32 wrapping objects + outer fields object + Timestamp leaf = cost 99 + 1 = 100, + // one frame over MAX_VALUE_NESTING_FRAMES. + let mut event = LogEvent::default(); + event.insert("data", create_nested_value_with_leaf(32, ts_leaf())); + let event = Event::Log(event); + + let mut serializer = NativeSerializerConfig.build(); + let mut buffer = BytesMut::with_capacity(8192); + + let result = serializer.encode(event, &mut buffer); + assert!( + result.is_err(), + "native codec should reject Timestamp leaf at object depth 33" + ); +} + +#[test] +fn native_codec_roundtrip_timestamp_leaf_below_max_object_depth() { + // 31 wrapping objects + outer fields object + Timestamp leaf = cost 96 + 1 = 97, + // comfortably under MAX_VALUE_NESTING_FRAMES. + let mut event = LogEvent::default(); + let value = create_nested_value_with_leaf(31, ts_leaf()); + event.insert("data", value.clone()); + let event = Event::Log(event); + + let mut serializer = NativeSerializerConfig.build(); + let mut buffer = BytesMut::with_capacity(8192); + + serializer + .encode(event, &mut buffer) + .expect("native codec should accept Timestamp leaf at object depth 32"); + + let deserializer = NativeDeserializerConfig.build(); + let decoded_events = deserializer + .parse(buffer.freeze(), LogNamespace::Legacy) + .expect("native codec should decode Timestamp leaf at object depth 32"); + + assert_eq!(decoded_events.len(), 1); + let decoded_log = decoded_events.into_iter().next().unwrap().into_log(); + assert_eq!(decoded_log.value().get("data"), Some(&value)); +} + +#[test] +fn native_codec_rejects_timestamp_leaf_in_max_depth_metadata() { + // Metadata at 32 object levels + Timestamp leaf = cost 96 + 1 = 97, one frame + // over MAX_METADATA_VALUE_NESTING_FRAMES. + let mut event = LogEvent::from("flat data"); + *event.metadata_mut().value_mut() = create_nested_value_with_leaf(32, ts_leaf()); + let event = Event::Log(event); + + let mut serializer = NativeSerializerConfig.build(); + let mut buffer = BytesMut::with_capacity(8192); + + let result = serializer.encode(event, &mut buffer); + assert!( + result.is_err(), + "native codec should reject metadata Timestamp leaf at object depth 32" + ); +} + #[test] fn native_codec_roundtrip_max_metadata_depth_event() { // Metadata at 32 object levels = 96 frame cost = MAX_METADATA_VALUE_NESTING_FRAMES. diff --git a/lib/vector-buffers/benches/common.rs b/lib/vector-buffers/benches/common.rs index 816e32ceb9f39..d36f75e7b0a2b 100644 --- a/lib/vector-buffers/benches/common.rs +++ b/lib/vector-buffers/benches/common.rs @@ -6,7 +6,7 @@ use metrics_util::{debugging::DebuggingRecorder, layers::Layer}; use tracing::Span; use tracing_subscriber::prelude::__tracing_subscriber_SubscriberExt; use vector_buffers::{ - BufferType, EventCount, + BufferType, Bufferable, EventCount, encoding::FixedEncodable, topology::{ builder::TopologyBuilder, @@ -55,6 +55,8 @@ impl EventCount for Message { } } +impl Bufferable for Message {} + impl Finalizable for Message { fn take_finalizers(&mut self) -> EventFinalizers { Default::default() // This benchmark doesn't need finalization diff --git a/lib/vector-buffers/examples/buffer_perf.rs b/lib/vector-buffers/examples/buffer_perf.rs index 08f4a5b95d636..38aff2d132e35 100644 --- a/lib/vector-buffers/examples/buffer_perf.rs +++ b/lib/vector-buffers/examples/buffer_perf.rs @@ -69,6 +69,8 @@ impl EventCount for VariableMessage { } } +impl Bufferable for VariableMessage {} + impl Finalizable for VariableMessage { fn take_finalizers(&mut self) -> EventFinalizers { std::mem::take(&mut self.finalizers) diff --git a/lib/vector-buffers/src/encoding.rs b/lib/vector-buffers/src/encoding.rs index ded360bdca58d..a9b1a4e4b0211 100644 --- a/lib/vector-buffers/src/encoding.rs +++ b/lib/vector-buffers/src/encoding.rs @@ -101,17 +101,6 @@ pub trait Encodable: Sized { /// guaranteed. fn encode(self, buffer: &mut B) -> Result<(), Self::EncodeError>; - /// Removes any sub-items from `self` that cannot be encoded (e.g. due to format-imposed - /// nesting depth limits), recording them as dropped via the appropriate telemetry, and - /// returns the number removed. - /// - /// Called by buffer senders prior to `encode` so that unencodable items can be reported - /// as `ComponentEventsDropped` rather than poisoning the buffer with an encode error. - /// The default implementation removes nothing. - fn pre_encode_drop_unencodable(&mut self) -> usize { - 0 - } - /// Gets the encoded size, in bytes, of this value, if available. /// /// Not all types can know ahead of time how many bytes they will occupy when encoded, hence the diff --git a/lib/vector-buffers/src/lib.rs b/lib/vector-buffers/src/lib.rs index 5f6ade35f9c78..fce729e4e9a0e 100644 --- a/lib/vector-buffers/src/lib.rs +++ b/lib/vector-buffers/src/lib.rs @@ -103,10 +103,43 @@ impl InMemoryBufferable for T where /// An item that can be buffered. /// /// This supertrait serves as the base trait for any item that can be pushed into a buffer. -pub trait Bufferable: InMemoryBufferable + Encodable {} - -// Blanket implementation for anything that is already bufferable. -impl Bufferable for T where T: InMemoryBufferable + Encodable {} +pub trait Bufferable: InMemoryBufferable + Encodable { + /// Drops any sub-items that cannot be persisted by the calling backend (e.g. due to + /// format-imposed nesting depth limits), reporting them as dropped via the appropriate + /// telemetry. Returns `None` if nothing remains worth writing. + /// + /// # Who calls this + /// + /// Only persistent backends with wire-format constraints invoke this — today that's + /// the disk-v2 sender (`SenderAdapter::send`/`try_send`). In-memory channels skip it + /// entirely (they hold the in-memory representation and have no nesting-limit risk). + /// A new backend with similar constraints should call this in the same place. + /// + /// # Default behaviour + /// + /// The default returns `Some(self)` if the item carries any events, and `None` if + /// it is already empty. This means an item that arrives empty (`event_count() == 0`) + /// is silently *not* persisted — preserving the pre-existing + /// "don't write empty records to disk" behaviour the call site used to enforce. + /// Types whose owners want empty items to be persisted must override this. + /// + /// # Skipping this call + /// + /// If a persistent backend writes an item without first calling `filter_unencodable`, + /// any sub-item that exceeds the format's limits will surface as a hard + /// [`Encodable::encode`] error and the *entire* item is rejected — including any + /// sibling sub-items that would otherwise have encoded fine. The filter is the only + /// path that produces graceful per-item drop with telemetry and a `Rejected` event + /// status; the encode-level check exists purely as defense-in-depth to ensure a + /// corrupt record cannot reach disk if a future caller forgets to filter. + fn filter_unencodable(self) -> Option { + if self.event_count() > 0 { + Some(self) + } else { + None + } + } +} /// Hook for observing items as they are sent into a `BufferSender`. pub trait BufferInstrumentation: Send + Sync + 'static { diff --git a/lib/vector-buffers/src/test/messages.rs b/lib/vector-buffers/src/test/messages.rs index 0eb57b195dd37..dae87511d159b 100644 --- a/lib/vector-buffers/src/test/messages.rs +++ b/lib/vector-buffers/src/test/messages.rs @@ -7,7 +7,11 @@ use vector_common::{ finalization::{AddBatchNotifier, BatchNotifier, EventFinalizer, EventFinalizers, Finalizable}, }; -use crate::{EventCount, encoding::FixedEncodable}; +use crate::{Bufferable, EventCount, encoding::FixedEncodable}; + +impl Bufferable for SizedRecord {} +impl Bufferable for UndecodableRecord {} +impl Bufferable for MultiEventRecord {} macro_rules! message_wrapper { ($id:ident: $ty:ty, $event_count:expr) => { diff --git a/lib/vector-buffers/src/topology/channel/sender.rs b/lib/vector-buffers/src/topology/channel/sender.rs index 65d9776721a7f..db5ddca555d83 100644 --- a/lib/vector-buffers/src/topology/channel/sender.rs +++ b/lib/vector-buffers/src/topology/channel/sender.rs @@ -40,14 +40,13 @@ impl SenderAdapter where T: Bufferable, { - pub(crate) async fn send(&mut self, mut item: T) -> crate::Result<()> { + pub(crate) async fn send(&mut self, item: T) -> crate::Result<()> { match self { Self::InMemory(tx) => tx.send(item).await.map_err(Into::into), Self::DiskV2(writer) => { - item.pre_encode_drop_unencodable(); - if item.event_count() == 0 { + let Some(item) = item.filter_unencodable() else { return Ok(()); - } + }; let mut writer = writer.lock().await; @@ -60,17 +59,16 @@ where } } - pub(crate) async fn try_send(&mut self, mut item: T) -> crate::Result> { + pub(crate) async fn try_send(&mut self, item: T) -> crate::Result> { match self { Self::InMemory(tx) => tx .try_send(item) .map(|()| None) .or_else(|e| Ok(Some(e.into_inner()))), Self::DiskV2(writer) => { - item.pre_encode_drop_unencodable(); - if item.event_count() == 0 { + let Some(item) = item.filter_unencodable() else { return Ok(None); - } + }; let mut writer = writer.lock().await; diff --git a/lib/vector-buffers/src/topology/test_util.rs b/lib/vector-buffers/src/topology/test_util.rs index 684aecc977764..65e9baa6c4636 100644 --- a/lib/vector-buffers/src/topology/test_util.rs +++ b/lib/vector-buffers/src/topology/test_util.rs @@ -120,6 +120,8 @@ impl EventCount for Sample { } } +impl Bufferable for Sample {} + #[derive(Debug)] #[allow(dead_code)] // The inner _is_ read by the `Debug` impl, but that's ignored pub struct BasicError(pub(crate) String); diff --git a/lib/vector-buffers/src/variants/disk_v2/tests/known_errors.rs b/lib/vector-buffers/src/variants/disk_v2/tests/known_errors.rs index 9f5ec073c00a7..89da8124c3ff2 100644 --- a/lib/vector-buffers/src/variants/disk_v2/tests/known_errors.rs +++ b/lib/vector-buffers/src/variants/disk_v2/tests/known_errors.rs @@ -19,8 +19,9 @@ use vector_common::{ use super::{create_buffer_v2_with_max_data_file_size, create_default_buffer_v2}; use crate::{ - EventCount, assert_buffer_size, assert_enough_bytes_written, assert_file_does_not_exist_async, - assert_file_exists_async, assert_reader_writer_v2_file_positions, await_timeout, + Bufferable, EventCount, assert_buffer_size, assert_enough_bytes_written, + assert_file_does_not_exist_async, assert_file_exists_async, + assert_reader_writer_v2_file_positions, await_timeout, encoding::{AsMetadata, Encodable}, test::{SizedRecord, UndecodableRecord, acknowledge, install_tracing_helpers, with_temp_dir}, variants::disk_v2::{ReaderError, backed_archive::BackedArchive, record::Record}, @@ -744,6 +745,8 @@ async fn reader_throws_error_when_record_is_undecodable_via_metadata() { } } + impl Bufferable for ControllableRecord {} + with_temp_dir(|dir| { let data_dir = dir.to_path_buf(); diff --git a/lib/vector-buffers/src/variants/disk_v2/tests/model/record.rs b/lib/vector-buffers/src/variants/disk_v2/tests/model/record.rs index 914ffa2759de8..632cded82216f 100644 --- a/lib/vector-buffers/src/variants/disk_v2/tests/model/record.rs +++ b/lib/vector-buffers/src/variants/disk_v2/tests/model/record.rs @@ -7,11 +7,13 @@ use vector_common::{ }; use crate::{ - EventCount, + Bufferable, EventCount, encoding::FixedEncodable, variants::disk_v2::{record::RECORD_HEADER_LEN, tests::align16}, }; +impl Bufferable for Record {} + #[derive(Debug)] pub struct EncodeError; diff --git a/lib/vector-core/src/event/ser.rs b/lib/vector-core/src/event/ser.rs index 41476877f3a89..f8ef2ee5c4017 100644 --- a/lib/vector-core/src/event/ser.rs +++ b/lib/vector-core/src/event/ser.rs @@ -2,7 +2,10 @@ use bytes::{Buf, BufMut}; use enumflags2::{BitFlags, FromBitsError, bitflags}; use prost::Message; use snafu::Snafu; -use vector_buffers::encoding::{AsMetadata, Encodable}; +use vector_buffers::{ + Bufferable, EventCount, + encoding::{AsMetadata, Encodable}, +}; use vector_common::internal_event::{self, ComponentEventsDropped, UNINTENTIONAL}; use vrl::value::Value; @@ -259,14 +262,27 @@ impl Encodable for EventArray { metadata.contains(EventEncodableMetadataFlags::DiskBufferV1CompatibilityMode) } + /// # Errors + /// + /// Returns `EncodeError::NestingTooDeep` if any contained event's value or metadata + /// exceeds the per-path frame budget ([`MAX_VALUE_NESTING_FRAMES`] / + /// [`MAX_METADATA_VALUE_NESTING_FRAMES`]). This is **all-or-nothing**: a single + /// over-budget event fails the entire batch, because a partially-encoded + /// `EventArray` reaching disk would trip prost's recursion limit on decode and + /// corrupt the buffer. + /// + /// Callers that want graceful per-item drop with telemetry and + /// `EventStatus::Rejected` must run [`Bufferable::filter_unencodable`] first. + /// `SenderAdapter::send`/`try_send` already does this on the disk-v2 path, so the + /// `NestingTooDeep` arm is unreachable from any current production call site — it + /// is defense-in-depth for a future caller that bypasses `SenderAdapter`. + /// + /// Returns `EncodeError::BufferTooSmall` if the buffer cannot hold the encoded + /// output. fn encode(self, buffer: &mut B) -> Result<(), Self::EncodeError> where B: BufMut, { - // Defense-in-depth: well-behaved callers run `pre_encode_drop_unencodable` - // first, but if any deeply-nested event reaches encode it would corrupt the - // disk buffer by encoding successfully and then failing prost's recursion - // limit on decode. check_event_array_nesting_cost(&self)?; proto::EventArray::from(self) @@ -274,48 +290,6 @@ impl Encodable for EventArray { .map_err(|_| EncodeError::BufferTooSmall) } - fn pre_encode_drop_unencodable(&mut self) -> usize { - let exceeds = - |value: &Value, budget: usize| check_value_nesting_cost(value, 0, budget).is_err(); - let mut dropped = 0; - match self { - EventArray::Logs(logs) => logs.retain(|log| { - let too_deep = exceeds(log.value(), MAX_VALUE_NESTING_FRAMES) - || exceeds(log.metadata().value(), MAX_METADATA_VALUE_NESTING_FRAMES); - if too_deep { - log.metadata().update_status(EventStatus::Rejected); - dropped += 1; - } - !too_deep - }), - EventArray::Traces(traces) => traces.retain(|trace| { - let too_deep = exceeds(trace.value(), MAX_VALUE_NESTING_FRAMES) - || exceeds(trace.metadata().value(), MAX_METADATA_VALUE_NESTING_FRAMES); - if too_deep { - trace.metadata().update_status(EventStatus::Rejected); - dropped += 1; - } - !too_deep - }), - EventArray::Metrics(metrics) => metrics.retain(|metric| { - let too_deep = - exceeds(metric.metadata().value(), MAX_METADATA_VALUE_NESTING_FRAMES); - if too_deep { - metric.metadata().update_status(EventStatus::Rejected); - dropped += 1; - } - !too_deep - }), - } - if dropped > 0 { - internal_event::emit(ComponentEventsDropped:: { - count: dropped, - reason: "Event nesting cost exceeds maximum for protobuf encoding.", - }); - } - dropped - } - fn decode(metadata: Self::Metadata, buffer: B) -> Result where B: Buf + Clone, @@ -333,3 +307,56 @@ impl Encodable for EventArray { } } } + +impl Bufferable for EventArray { + fn filter_unencodable(self) -> Option { + let exceeds = + |value: &Value, budget: usize| check_value_nesting_cost(value, 0, budget).is_err(); + let mut dropped = 0; + let filtered = match self { + EventArray::Logs(mut logs) => { + logs.retain(|log| { + let too_deep = exceeds(log.value(), MAX_VALUE_NESTING_FRAMES) + || exceeds(log.metadata().value(), MAX_METADATA_VALUE_NESTING_FRAMES); + if too_deep { + log.metadata().update_status(EventStatus::Rejected); + dropped += 1; + } + !too_deep + }); + EventArray::Logs(logs) + } + EventArray::Traces(mut traces) => { + traces.retain(|trace| { + let too_deep = exceeds(trace.value(), MAX_VALUE_NESTING_FRAMES) + || exceeds(trace.metadata().value(), MAX_METADATA_VALUE_NESTING_FRAMES); + if too_deep { + trace.metadata().update_status(EventStatus::Rejected); + dropped += 1; + } + !too_deep + }); + EventArray::Traces(traces) + } + EventArray::Metrics(mut metrics) => { + metrics.retain(|metric| { + let too_deep = + exceeds(metric.metadata().value(), MAX_METADATA_VALUE_NESTING_FRAMES); + if too_deep { + metric.metadata().update_status(EventStatus::Rejected); + dropped += 1; + } + !too_deep + }); + EventArray::Metrics(metrics) + } + }; + if dropped > 0 { + internal_event::emit(ComponentEventsDropped:: { + count: dropped, + reason: "Event nesting cost exceeds maximum for protobuf encoding.", + }); + } + (filtered.event_count() > 0).then_some(filtered) + } +} diff --git a/lib/vector-core/src/event/test/serialization.rs b/lib/vector-core/src/event/test/serialization.rs index 777f911fba57b..a79ec143be653 100644 --- a/lib/vector-core/src/event/test/serialization.rs +++ b/lib/vector-core/src/event/test/serialization.rs @@ -8,10 +8,12 @@ use regex::Regex; use similar_asserts::assert_eq; use vector_buffers::encoding::Encodable; +use crate::event::event_exceeds_max_nesting_cost; use crate::event::ser::{ ARRAY_FRAME_COST, MAX_METADATA_VALUE_NESTING_FRAMES, MAX_VALUE_NESTING_FRAMES, OBJECT_FRAME_COST, TIMESTAMP_FRAME_COST, check_value_nesting_cost, }; +use vector_buffers::Bufferable; fn encode_value(value: T, buffer: &mut B) { value.encode(buffer).expect("encoding should not fail"); @@ -614,6 +616,104 @@ fn nesting_gate_accepts_timestamp_leaf_below_max_object_depth() { ); } +/// Verify `filter_unencodable` keeps the valid events and drops only the over-budget +/// ones, returning a smaller `EventArray` rather than failing the whole batch. +#[test] +fn filter_unencodable_drops_only_over_budget_events() { + let good = || { + let mut event = LogEvent::default(); + event.insert("data", "ok"); + event + }; + let bad = || { + let mut event = LogEvent::default(); + event.insert("data", create_nested_value(MAX_OBJECT_DEPTH_VALUE)); + event + }; + + let logs = vec![good(), bad(), good(), bad(), good()]; + let array = EventArray::Logs(LogArray::from(logs)); + + let filtered = array + .filter_unencodable() + .expect("3 good events should survive filtering"); + assert_eq!(filtered.event_count(), 3, "only good events should remain"); + + let EventArray::Logs(surviving) = filtered else { + panic!("variant should be preserved"); + }; + for log in &surviving { + assert_eq!( + log.value().get("data").and_then(|v| v.as_bytes()), + Some(&bytes::Bytes::from_static(b"ok")), + "only good events should remain", + ); + } +} + +/// Verify that the public per-event entry point used by both the native codec and the +/// vector sink charges `Value::Timestamp` for one frame, just like the buffer gate. +/// Without this, a depth-33 object chain ending in a timestamp would pass the codec +/// check and fail prost decode on the receiving end. +#[test] +fn event_exceeds_max_nesting_cost_charges_timestamp_leaf() { + let log_at_max_with_ts = { + let mut event = LogEvent::default(); + event.insert( + "data", + create_nested_value_with_leaf(MAX_OBJECT_DEPTH_VALUE - 1, ts_leaf()), + ); + Event::Log(event) + }; + assert!( + event_exceeds_max_nesting_cost(&log_at_max_with_ts).is_some(), + "depth {MAX_OBJECT_DEPTH_VALUE} log with Timestamp leaf must be rejected", + ); + + let trace_at_max_with_ts = { + let mut trace = TraceEvent::default(); + trace.insert( + "data", + create_nested_value_with_leaf(MAX_OBJECT_DEPTH_VALUE - 1, ts_leaf()), + ); + Event::Trace(trace) + }; + assert!( + event_exceeds_max_nesting_cost(&trace_at_max_with_ts).is_some(), + "depth {MAX_OBJECT_DEPTH_VALUE} trace with Timestamp leaf must be rejected", + ); + + let metric_at_max_with_ts = { + let mut metric = Metric::new( + "test", + MetricKind::Incremental, + MetricValue::Counter { value: 1.0 }, + ); + *metric.metadata_mut().value_mut() = + create_nested_value_with_leaf(MAX_OBJECT_DEPTH_METADATA, ts_leaf()); + Event::Metric(metric) + }; + assert!( + event_exceeds_max_nesting_cost(&metric_at_max_with_ts).is_some(), + "metric with metadata-Timestamp leaf at depth {MAX_OBJECT_DEPTH_METADATA} must be rejected", + ); + + // And one shallower stays under the budget. + let log_below_max_with_ts = { + let mut event = LogEvent::default(); + event.insert( + "data", + create_nested_value_with_leaf(MAX_OBJECT_DEPTH_VALUE - 2, ts_leaf()), + ); + Event::Log(event) + }; + assert!( + event_exceeds_max_nesting_cost(&log_below_max_with_ts).is_none(), + "depth {} log with Timestamp leaf must be accepted", + MAX_OBJECT_DEPTH_VALUE - 1, + ); +} + /// Unit-level check that `check_value_nesting_cost` charges `TIMESTAMP_FRAME_COST` /// for a `Value::Timestamp` leaf, independent of nesting. #[test] From 35981225affcbd05a239abed3dd6452dfa59da22 Mon Sep 17 00:00:00 2001 From: Orri Ganel Date: Tue, 16 Jun 2026 16:30:01 -0400 Subject: [PATCH 35/49] fix(buffers): account for filter drops in buffer usage instrumentation When the disk-v2 sender filtered an over-budget EventArray inside SenderAdapter::send, BufferSender::send had already incremented received_event_count for the pre-filter item; the filtered events never got a corresponding dropped increment and never got read back, so buffer_size_events / buffer_size_bytes (computed as received minus left) stayed inflated for the lifetime of the buffer. A single over-budget event would make the buffer report one queued event forever. Plumb FilterDrops { events, bytes } back from SenderAdapter::send and try_send to BufferSender::send, which bumps the unintentional dropped counters by the filter delta. Now buffer_size = received - sent - dropped correctly reflects what is actually queued on disk. Side effects: - SenderAdapter::send returns crate::Result instead of crate::Result<()>. - SenderAdapter::try_send returns crate::Result> (filter_drops + rejected) instead of crate::Result>. - For WhenFull::DropNewest and WhenFull::Overflow, the rejected-item sizing used for dropped_intentional is now the post-filter sizing of the rejected item rather than the pre-filter item_sizing -- the filter portion has already been accounted for as an unintentional drop, so using pre-filter sizing here would double-count. - In-memory backends still skip filter_unencodable entirely; they only ever see FilterDrops::default() since they have no nesting-limit risk. Adds filter_drops_are_reported_as_unintentional_buffer_drops as a disk-v2 integration test pinning the new accounting: a partial filter (10 -> 3) reports received=10 and dropped=7; a full filter (5 -> 0) bumps received by 5 and dropped by 5 more. Stashing the SenderAdapter change confirms the test catches the regression (dropped=0 expected 7). Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/vector-buffers/src/lib.rs | 6 +- .../src/topology/channel/sender.rs | 133 +++++++++++---- .../variants/disk_v2/tests/filter_metrics.rs | 161 ++++++++++++++++++ .../src/variants/disk_v2/tests/mod.rs | 1 + 4 files changed, 268 insertions(+), 33 deletions(-) create mode 100644 lib/vector-buffers/src/variants/disk_v2/tests/filter_metrics.rs diff --git a/lib/vector-buffers/src/lib.rs b/lib/vector-buffers/src/lib.rs index fce729e4e9a0e..1d9774ead6190 100644 --- a/lib/vector-buffers/src/lib.rs +++ b/lib/vector-buffers/src/lib.rs @@ -112,8 +112,10 @@ pub trait Bufferable: InMemoryBufferable + Encodable { /// /// Only persistent backends with wire-format constraints invoke this — today that's /// the disk-v2 sender (`SenderAdapter::send`/`try_send`). In-memory channels skip it - /// entirely (they hold the in-memory representation and have no nesting-limit risk). - /// A new backend with similar constraints should call this in the same place. + /// entirely because they hold the in-memory representation and have no nesting-limit + /// risk. A new backend with similar constraints should call this in the same place + /// and surface the resulting `FilterDrops` to `BufferSender` so that buffer-usage + /// instrumentation stays consistent with what actually lands in the buffer. /// /// # Default behaviour /// diff --git a/lib/vector-buffers/src/topology/channel/sender.rs b/lib/vector-buffers/src/topology/channel/sender.rs index db5ddca555d83..8e209066adf25 100644 --- a/lib/vector-buffers/src/topology/channel/sender.rs +++ b/lib/vector-buffers/src/topology/channel/sender.rs @@ -24,6 +24,24 @@ pub enum SenderAdapter { DiskV2(Arc>>), } +/// Events/bytes dropped by `Bufferable::filter_unencodable` inside the backend +/// dispatch. Only the disk-v2 backend invokes the filter (it is the only one with +/// wire-format constraints); in-memory backends always return the default zero +/// value. +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct FilterDrops { + pub events: u64, + pub bytes: u64, +} + +/// Outcome of [`SenderAdapter::try_send`]. Carries both whatever the filter dropped +/// (which the caller still needs to account for in buffer instrumentation) and the +/// item that did not fit, if any (which the caller may forward to an overflow stage). +pub(crate) struct TrySendOutcome { + pub filter_drops: FilterDrops, + pub rejected: Option, +} + impl From> for SenderAdapter { fn from(v: LimitedSender) -> Self { Self::InMemory(v) @@ -40,17 +58,30 @@ impl SenderAdapter where T: Bufferable, { - pub(crate) async fn send(&mut self, item: T) -> crate::Result<()> { + pub(crate) async fn send(&mut self, item: T) -> crate::Result { match self { - Self::InMemory(tx) => tx.send(item).await.map_err(Into::into), + Self::InMemory(tx) => tx + .send(item) + .await + .map(|()| FilterDrops::default()) + .map_err(Into::into), Self::DiskV2(writer) => { + let pre_count = item.event_count() as u64; + let pre_size = item.size_of() as u64; let Some(item) = item.filter_unencodable() else { - return Ok(()); + return Ok(FilterDrops { + events: pre_count, + bytes: pre_size, + }); + }; + let drops = FilterDrops { + events: pre_count - item.event_count() as u64, + bytes: pre_size.saturating_sub(item.size_of() as u64), }; let mut writer = writer.lock().await; - writer.write_record(item).await.map(|_| ()).map_err(|e| { + writer.write_record(item).await.map(|_| drops).map_err(|e| { error!("Disk buffer writer has encountered an unrecoverable error."); e.into() @@ -59,24 +90,46 @@ where } } - pub(crate) async fn try_send(&mut self, item: T) -> crate::Result> { + pub(crate) async fn try_send(&mut self, item: T) -> crate::Result> { match self { - Self::InMemory(tx) => tx - .try_send(item) - .map(|()| None) - .or_else(|e| Ok(Some(e.into_inner()))), + Self::InMemory(tx) => Ok(TrySendOutcome { + filter_drops: FilterDrops::default(), + rejected: tx + .try_send(item) + .err() + .map(super::limited_queue::TrySendError::into_inner), + }), Self::DiskV2(writer) => { + let pre_count = item.event_count() as u64; + let pre_size = item.size_of() as u64; let Some(item) = item.filter_unencodable() else { - return Ok(None); + return Ok(TrySendOutcome { + filter_drops: FilterDrops { + events: pre_count, + bytes: pre_size, + }, + rejected: None, + }); + }; + let filter_drops = FilterDrops { + events: pre_count - item.event_count() as u64, + bytes: pre_size.saturating_sub(item.size_of() as u64), }; let mut writer = writer.lock().await; - writer.try_write_record(item).await.map_err(|e| { - error!("Disk buffer writer has encountered an unrecoverable error."); - - e.into() - }) + writer + .try_write_record(item) + .await + .map(|rejected| TrySendOutcome { + filter_drops, + rejected, + }) + .map_err(|e| { + error!("Disk buffer writer has encountered an unrecoverable error."); + + e.into() + }) } } } @@ -216,7 +269,8 @@ impl BufferSender { .as_ref() .map(|_| (item.event_count(), item.size_of())); - let mut was_dropped = false; + let mut rejected_sizing: Option<(usize, usize)> = None; + let filter_drops; if let Some(instrumentation) = self.usage_instrumentation.as_ref() && let Some((item_count, item_size)) = item_sizing @@ -225,33 +279,50 @@ impl BufferSender { .increment_received_event_count_and_byte_size(item_count as u64, item_size as u64); } match self.when_full { - WhenFull::Block => self.base.send(item).await?, + WhenFull::Block => filter_drops = self.base.send(item).await?, WhenFull::DropNewest => { - if self.base.try_send(item).await?.is_some() { - was_dropped = true; + let outcome = self.base.try_send(item).await?; + filter_drops = outcome.filter_drops; + if let Some(rejected) = outcome.rejected { + rejected_sizing = Some((rejected.event_count(), rejected.size_of())); } } WhenFull::Overflow => { - if let Some(item) = self.base.try_send(item).await? { - was_dropped = true; + let outcome = self.base.try_send(item).await?; + filter_drops = outcome.filter_drops; + if let Some(rejected) = outcome.rejected { + rejected_sizing = Some((rejected.event_count(), rejected.size_of())); self.overflow .as_mut() .unwrap_or_else(|| unreachable!("overflow must exist")) - .send(item, send_reference) + .send(rejected, send_reference) .await?; } } } - if let Some(instrumentation) = self.usage_instrumentation.as_ref() - && let Some((item_count, item_size)) = item_sizing - && was_dropped - { - instrumentation.increment_dropped_event_count_and_byte_size( - item_count as u64, - item_size as u64, - true, - ); + if let Some(instrumentation) = self.usage_instrumentation.as_ref() { + // Backend-filtered sub-items never reach the buffer; report them as an + // unintentional buffer drop so `buffer_size_*` (received - left) stays + // consistent with what is actually queued, rather than staying inflated + // by the filtered count forever. + if filter_drops.events > 0 || filter_drops.bytes > 0 { + instrumentation.increment_dropped_event_count_and_byte_size( + filter_drops.events, + filter_drops.bytes, + false, + ); + } + // Fullness-driven drops use the post-filter sizing of the rejected item, + // not the pre-filter sizing captured above — the filter portion has + // already been accounted for as an unintentional drop. + if let Some((rejected_count, rejected_size)) = rejected_sizing { + instrumentation.increment_dropped_event_count_and_byte_size( + rejected_count as u64, + rejected_size as u64, + true, + ); + } } if let Some(send_duration) = self.send_duration.as_ref() && let Some(send_reference) = send_reference diff --git a/lib/vector-buffers/src/variants/disk_v2/tests/filter_metrics.rs b/lib/vector-buffers/src/variants/disk_v2/tests/filter_metrics.rs new file mode 100644 index 0000000000000..9f292d3633314 --- /dev/null +++ b/lib/vector-buffers/src/variants/disk_v2/tests/filter_metrics.rs @@ -0,0 +1,161 @@ +//! Buffer-usage accounting around `Bufferable::filter_unencodable`. +//! +//! When the disk-v2 sender drops sub-items because they exceed protobuf nesting +//! limits, those drops must show up as unintentional buffer drops so that +//! `buffer_size_*` (received minus left) stays consistent with what is actually +//! queued on disk. Without that, a single rejected event makes the buffer report +//! one queued event forever. + +use std::{error, fmt}; + +use bytes::{Buf, BufMut}; +use vector_common::{ + byte_size_of::ByteSizeOf, + finalization::{AddBatchNotifier, BatchNotifier}, +}; + +use super::create_default_buffer_v2_with_usage; +use crate::{ + Bufferable, EventCount, WhenFull, + encoding::FixedEncodable, + test::{install_tracing_helpers, with_temp_dir}, + topology::channel::{BufferSender, SenderAdapter}, +}; + +/// A bufferable carrying a self-declared `event_count` of `events`, whose +/// `filter_unencodable` shrinks it to `post_filter` events (or drops it entirely +/// when `post_filter == 0`). Lets the test pin "before vs after filter" sizing +/// without needing the full `EventArray` machinery. +#[derive(Clone, Debug, PartialEq, Eq)] +struct FilterableBatch { + events: u32, + post_filter: u32, +} + +impl AddBatchNotifier for FilterableBatch { + fn add_batch_notifier(&mut self, batch: BatchNotifier) { + drop(batch); + } +} +impl ByteSizeOf for FilterableBatch { + fn allocated_bytes(&self) -> usize { + 0 + } +} +impl EventCount for FilterableBatch { + fn event_count(&self) -> usize { + self.events as usize + } +} + +#[derive(Debug)] +struct CodecError; +impl fmt::Display for CodecError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{self:?}") + } +} +impl error::Error for CodecError {} + +impl FixedEncodable for FilterableBatch { + type EncodeError = CodecError; + type DecodeError = CodecError; + fn encode(self, buf: &mut B) -> Result<(), Self::EncodeError> { + if buf.remaining_mut() < 8 { + return Err(CodecError); + } + buf.put_u32(self.events); + buf.put_u32(self.post_filter); + Ok(()) + } + fn decode(mut buf: B) -> Result { + Ok(FilterableBatch { + events: buf.get_u32(), + post_filter: buf.get_u32(), + }) + } + fn encoded_size(&self) -> Option { + Some(8) + } +} + +impl Bufferable for FilterableBatch { + fn filter_unencodable(self) -> Option { + if self.post_filter == 0 { + None + } else { + Some(FilterableBatch { + events: self.post_filter, + post_filter: self.post_filter, + }) + } + } +} + +/// A partial-filter drop on a disk-v2 send must show up as an unintentional buffer +/// drop, so `buffer_size_*` stays consistent with what actually landed on disk. +#[tokio::test] +async fn filter_drops_are_reported_as_unintentional_buffer_drops() { + let _a = install_tracing_helpers(); + + with_temp_dir(|dir| { + let data_dir = dir.to_path_buf(); + + async move { + let (writer, _reader, _ledger, usage) = + create_default_buffer_v2_with_usage::<_, FilterableBatch>(data_dir).await; + let mut sender = BufferSender::new(SenderAdapter::from(writer), WhenFull::Block); + sender.with_usage_instrumentation(usage.clone()); + + // 10 events arrive, filter keeps 3. + sender + .send( + FilterableBatch { + events: 10, + post_filter: 3, + }, + None, + ) + .await + .expect("send should succeed"); + + let snapshot = usage.snapshot(); + assert_eq!( + snapshot.received_event_count, 10, + "received reflects pre-filter sizing (the item arrived at the buffer boundary)", + ); + assert_eq!( + snapshot.dropped_event_count, 7, + "filter drops are reported as an unintentional buffer drop \ + so buffer_size stays consistent (received - dropped = 3 queued)", + ); + assert_eq!( + snapshot.dropped_event_count_intentional, 0, + "no buffer-fullness drops here", + ); + + // 5 events arrive, filter drops them all. + sender + .send( + FilterableBatch { + events: 5, + post_filter: 0, + }, + None, + ) + .await + .expect("send should succeed"); + + let snapshot = usage.snapshot(); + assert_eq!( + snapshot.received_event_count, 15, + "fully-filtered item still bumps received (it arrived at the boundary)", + ); + assert_eq!( + snapshot.dropped_event_count, 12, + "all 5 events from the fully-filtered item are reported as unintentional drops", + ); + } + }) + .await; +} diff --git a/lib/vector-buffers/src/variants/disk_v2/tests/mod.rs b/lib/vector-buffers/src/variants/disk_v2/tests/mod.rs index fa8ac8b11ad23..7d260e3c856aa 100644 --- a/lib/vector-buffers/src/variants/disk_v2/tests/mod.rs +++ b/lib/vector-buffers/src/variants/disk_v2/tests/mod.rs @@ -24,6 +24,7 @@ type FilesystemUnderTest = ProductionFilesystem; mod acknowledgements; mod basic; +mod filter_metrics; mod initialization; mod invariants; mod known_errors; From 860f40a3f83c26890699512eaf50ca953aea97c6 Mon Sep 17 00:00:00 2001 From: Orri Ganel Date: Tue, 16 Jun 2026 16:54:08 -0400 Subject: [PATCH 36/49] fix(console sink): keep per-event encoder failures from terminating the 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) --- src/sinks/console/sink.rs | 70 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 65 insertions(+), 5 deletions(-) diff --git a/src/sinks/console/sink.rs b/src/sinks/console/sink.rs index c20499fd55bc8..e18dc685d5c3c 100644 --- a/src/sinks/console/sink.rs +++ b/src/sinks/console/sink.rs @@ -37,10 +37,17 @@ where let finalizers = event.take_finalizers(); let mut bytes = BytesMut::new(); - self.encoder.encode(event, &mut bytes).map_err(|_| { - // Error is handled by `Encoder`. + if self.encoder.encode(event, &mut bytes).is_err() { + // Error is already logged + counted + emits `ComponentEventsDropped` + // by the `Encoder` framework. Mark this event's finalizers Errored so + // source-side acks reflect the drop, then continue with the next + // event. Surfacing the error here as fatal would terminate the sink + // on the first data-dependent encoder failure (e.g., a single event + // exceeding the native codec's nesting budget) and silently drop + // every subsequent valid event. finalizers.update_status(EventStatus::Errored); - })?; + continue; + } match self.output.write_all(&bytes).await { Err(error) => { @@ -66,9 +73,10 @@ where #[cfg(test)] mod test { use futures::future::ready; - use futures_util::stream; + use futures_util::stream::{self, StreamExt}; use vector_lib::{ - codecs::{JsonSerializerConfig, NewlineDelimitedEncoder}, + codecs::{JsonSerializerConfig, NativeSerializerConfig, NewlineDelimitedEncoder}, + event::{BatchNotifier, BatchStatus, ObjectMap, Value}, sink::VectorSink, }; @@ -100,4 +108,56 @@ mod test { ) .await; } + + /// A per-event encoder failure (e.g., a single event exceeding the native codec's + /// nesting budget) must not terminate the sink. The offending event is finalized + /// as `Errored`, the next event still goes through and is `Delivered`. + #[tokio::test] + async fn per_event_encoder_failure_does_not_terminate_sink() { + // Build a log whose value is an Object chain too deep for the native codec + // (object cost 34 * 3 = 102 frames, over the 99-frame value budget). + let mut deep_value = Value::from("x"); + for _ in 0..34 { + let mut m = ObjectMap::new(); + m.insert("nested".into(), deep_value); + deep_value = Value::Object(m); + } + + let (bad_batch, mut bad_rx) = BatchNotifier::new_with_receiver(); + let mut bad_event = LogEvent::default().with_batch_notifier(&bad_batch); + bad_event.insert("data", deep_value); + drop(bad_batch); + + let (good_batch, mut good_rx) = BatchNotifier::new_with_receiver(); + let good_event = LogEvent::from("ok").with_batch_notifier(&good_batch); + drop(good_batch); + + let encoder = Encoder::::new( + NewlineDelimitedEncoder::default().into(), + NativeSerializerConfig.build().into(), + ); + + let sink = Box::new(WriterSink { + output: Vec::new(), + transformer: Default::default(), + encoder, + }); + + let events = stream::iter(vec![Event::Log(bad_event), Event::Log(good_event)]).boxed(); + sink.run(events) + .await + .expect("sink should not return Err for a per-event encode failure"); + + assert_eq!( + bad_rx.try_recv(), + Ok(BatchStatus::Errored), + "the over-budget event must be finalized as Errored", + ); + assert_eq!( + good_rx.try_recv(), + Ok(BatchStatus::Delivered), + "the subsequent valid event must still be Delivered \ + (the sink must keep processing past a per-event encoder failure)", + ); + } } From f28b2a35e232b4b12df4bf63b5dddac4cf3ed1ee Mon Sep 17 00:00:00 2001 From: Orri Ganel Date: Tue, 16 Jun 2026 17:32:05 -0400 Subject: [PATCH 37/49] fix(buffers): skip filter_unencodable when disk is already full 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) --- .../src/topology/channel/sender.rs | 24 +++++++++++++++++-- .../variants/disk_v2/tests/filter_metrics.rs | 9 +++++++ .../src/variants/disk_v2/writer.rs | 8 ++++++- 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/lib/vector-buffers/src/topology/channel/sender.rs b/lib/vector-buffers/src/topology/channel/sender.rs index 8e209066adf25..9d08b060f9673 100644 --- a/lib/vector-buffers/src/topology/channel/sender.rs +++ b/lib/vector-buffers/src/topology/channel/sender.rs @@ -100,6 +100,28 @@ where .map(super::limited_queue::TrySendError::into_inner), }), Self::DiskV2(writer) => { + let mut writer = writer.lock().await; + + // If the disk buffer is already at its size limit, hand the item off + // to the caller unfiltered. The caller forwards it to the overflow + // stage in `WhenFull::Overflow` mode, and the overflow stage may be + // an in-memory buffer with no wire-format constraint — filtering + // here would needlessly drop sub-items that the overflow could + // accept. Holding the writer lock makes the check race-free against + // other writers (only writers grow the buffer; readers only shrink). + // + // Note: this handles the steady-state-full case. The narrower + // "buffer has slack but not enough for this specific record" case + // (`can_write_record` returning false inside `try_write_record`) + // still has the item filtered before the rejection — that requires + // knowing the encoded size pre-encode, which we cannot do cheaply. + if writer.is_buffer_full() { + return Ok(TrySendOutcome { + filter_drops: FilterDrops::default(), + rejected: Some(item), + }); + } + let pre_count = item.event_count() as u64; let pre_size = item.size_of() as u64; let Some(item) = item.filter_unencodable() else { @@ -116,8 +138,6 @@ where bytes: pre_size.saturating_sub(item.size_of() as u64), }; - let mut writer = writer.lock().await; - writer .try_write_record(item) .await diff --git a/lib/vector-buffers/src/variants/disk_v2/tests/filter_metrics.rs b/lib/vector-buffers/src/variants/disk_v2/tests/filter_metrics.rs index 9f292d3633314..0872e64b7eb55 100644 --- a/lib/vector-buffers/src/variants/disk_v2/tests/filter_metrics.rs +++ b/lib/vector-buffers/src/variants/disk_v2/tests/filter_metrics.rs @@ -159,3 +159,12 @@ async fn filter_drops_are_reported_as_unintentional_buffer_drops() { }) .await; } + +// Note: A regression test that exercises the "full disk hands item to overflow +// unfiltered" path is not included here because reliably driving the disk-v2 +// writer's `is_buffer_full()` to `true` under the minimum-size config takes +// careful tuning of record/buffer sizes (the writer's `can_write_record` check +// generally short-circuits writes *before* `total_buffer_size` reaches +// `max_buffer_size`). The fix in `SenderAdapter::try_send` is a single +// `is_buffer_full()` short-circuit before the filter runs; the existing +// disk-v2 tests cover the full-buffer behaviour at the writer level. diff --git a/lib/vector-buffers/src/variants/disk_v2/writer.rs b/lib/vector-buffers/src/variants/disk_v2/writer.rs index f12ce6ca94dae..f26c6d839cab8 100644 --- a/lib/vector-buffers/src/variants/disk_v2/writer.rs +++ b/lib/vector-buffers/src/variants/disk_v2/writer.rs @@ -990,7 +990,13 @@ where Ok(()) } - fn is_buffer_full(&self) -> bool { + /// Returns whether the buffer is currently at or above its configured size limit. + /// + /// Exposed so callers can decide what to do with an item *before* it is encoded — + /// notably, an over-budget `EventArray` headed for a `WhenFull::Overflow` topology + /// should be handed to the overflow stage unfiltered rather than have its sub-items + /// pruned for a write that will never happen. + pub(crate) fn is_buffer_full(&self) -> bool { let total_buffer_size = self.ledger.get_total_buffer_size() + self.unflushed_bytes; let max_buffer_size = self.config.max_buffer_size; total_buffer_size >= max_buffer_size From 8a22d863cab8414c8e5b5229043838372235545d Mon Sep 17 00:00:00 2001 From: Orri Ganel Date: Wed, 17 Jun 2026 11:30:05 -0400 Subject: [PATCH 38/49] fix(codecs): silent-drop over-budget events from the native encoder 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::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) --- lib/codecs/src/encoding/encoder.rs | 11 +++++ lib/codecs/src/encoding/format/native.rs | 26 ++++++++++-- lib/codecs/tests/native.rs | 32 ++++++++------ src/sinks/console/sink.rs | 22 +++++++--- src/sinks/util/encoding.rs | 53 +++++++++++++++++++++++- 5 files changed, 120 insertions(+), 24 deletions(-) diff --git a/lib/codecs/src/encoding/encoder.rs b/lib/codecs/src/encoding/encoder.rs index 2bbfba6cdbb55..73028b598e786 100644 --- a/lib/codecs/src/encoding/encoder.rs +++ b/lib/codecs/src/encoding/encoder.rs @@ -283,6 +283,17 @@ impl tokio_util::codec::Encoder for Encoder { self.serialize_at_start(event, &mut payload)?; + // If the inner serializer produced no output, the event was dropped + // internally (e.g., the native serializer rejecting an over-budget + // event with `EventStatus::Rejected` + `ComponentEventsDropped`). Skip + // framing so the caller observes an empty buffer and can finalize the + // dropped event accordingly, rather than seeing a stray delimiter for + // an event that produced no payload. + if payload.is_empty() { + buffer.unsplit(payload); + return Ok(()); + } + // Frame the serialized event. self.framer.encode((), &mut payload).map_err(|error| { emit(EncoderFramingError { error: &error }); diff --git a/lib/codecs/src/encoding/format/native.rs b/lib/codecs/src/encoding/format/native.rs index 3b14db87426ab..a3b2ae7b029f0 100644 --- a/lib/codecs/src/encoding/format/native.rs +++ b/lib/codecs/src/encoding/format/native.rs @@ -2,9 +2,10 @@ use bytes::BytesMut; use prost::Message; use serde::{Deserialize, Serialize}; use tokio_util::codec::Encoder; +use vector_common::internal_event::{ComponentEventsDropped, UNINTENTIONAL, emit}; use vector_core::{ config::DataType, - event::{Event, EventArray, event_exceeds_max_nesting_cost, proto}, + event::{Event, EventArray, EventStatus, event_exceeds_max_nesting_cost, proto}, schema, }; @@ -38,9 +39,26 @@ impl Encoder for NativeSerializer { fn encode(&mut self, event: Event, buffer: &mut BytesMut) -> Result<(), Self::Error> { if let Some((cost, budget)) = event_exceeds_max_nesting_cost(&event) { - return Err( - format!("event nesting cost ({cost}) exceeds protobuf budget ({budget})").into(), - ); + // Returning `Err` here would propagate through batched encoders + // (e.g. `src/sinks/util/encoding.rs`) as a fatal `InvalidData`, + // causing the entire request to be dropped and every other valid + // event in the batch to be lost. Instead drop just this event with + // proper telemetry and `EventStatus::Rejected`, write zero bytes, + // and return Ok so batched callers continue to the next event. + // + // Per-event callers (e.g. `WriterSink::run`) treat `Ok(())` with an + // empty buffer as an in-encoder drop and finalize accordingly. + let reason = format!("event nesting cost ({cost}) exceeds protobuf budget ({budget})"); + emit(ComponentEventsDropped:: { + count: 1, + reason: &reason, + }); + match event { + Event::Log(log) => log.metadata().update_status(EventStatus::Rejected), + Event::Metric(metric) => metric.metadata().update_status(EventStatus::Rejected), + Event::Trace(trace) => trace.metadata().update_status(EventStatus::Rejected), + } + return Ok(()); } let array = EventArray::from(event); let proto = proto::EventArray::from(array); diff --git a/lib/codecs/tests/native.rs b/lib/codecs/tests/native.rs index 7c499f104ad93..bac28c98beaaf 100644 --- a/lib/codecs/tests/native.rs +++ b/lib/codecs/tests/native.rs @@ -353,10 +353,12 @@ fn native_codec_rejects_overly_nested_event() { let mut serializer = NativeSerializerConfig.build(); let mut buffer = BytesMut::with_capacity(8192); - let result = serializer.encode(event, &mut buffer); + serializer + .encode(event, &mut buffer) + .expect("native codec must return Ok for over-budget events so batched encoders do not abort the whole batch"); assert!( - result.is_err(), - "native codec should reject events exceeding MAX_VALUE_NESTING_FRAMES" + buffer.is_empty(), + "native codec must write zero bytes for an over-budget event", ); } @@ -405,10 +407,12 @@ fn native_codec_rejects_overly_nested_metadata() { let mut serializer = NativeSerializerConfig.build(); let mut buffer = BytesMut::with_capacity(8192); - let result = serializer.encode(event, &mut buffer); + serializer + .encode(event, &mut buffer) + .expect("native codec must return Ok for over-budget metadata"); assert!( - result.is_err(), - "native codec should reject events with metadata exceeding MAX_METADATA_VALUE_NESTING_FRAMES" + buffer.is_empty(), + "native codec must write zero bytes for metadata exceeding MAX_METADATA_VALUE_NESTING_FRAMES", ); } @@ -443,10 +447,12 @@ fn native_codec_rejects_timestamp_leaf_at_max_object_depth() { let mut serializer = NativeSerializerConfig.build(); let mut buffer = BytesMut::with_capacity(8192); - let result = serializer.encode(event, &mut buffer); + serializer + .encode(event, &mut buffer) + .expect("native codec must return Ok for over-budget events"); assert!( - result.is_err(), - "native codec should reject Timestamp leaf at object depth 33" + buffer.is_empty(), + "native codec must write zero bytes for Timestamp leaf at object depth 33", ); } @@ -487,10 +493,12 @@ fn native_codec_rejects_timestamp_leaf_in_max_depth_metadata() { let mut serializer = NativeSerializerConfig.build(); let mut buffer = BytesMut::with_capacity(8192); - let result = serializer.encode(event, &mut buffer); + serializer + .encode(event, &mut buffer) + .expect("native codec must return Ok for over-budget metadata"); assert!( - result.is_err(), - "native codec should reject metadata Timestamp leaf at object depth 32" + buffer.is_empty(), + "native codec must write zero bytes for metadata Timestamp leaf at object depth 32", ); } diff --git a/src/sinks/console/sink.rs b/src/sinks/console/sink.rs index e18dc685d5c3c..2ee9adbb97a24 100644 --- a/src/sinks/console/sink.rs +++ b/src/sinks/console/sink.rs @@ -42,12 +42,21 @@ where // by the `Encoder` framework. Mark this event's finalizers Errored so // source-side acks reflect the drop, then continue with the next // event. Surfacing the error here as fatal would terminate the sink - // on the first data-dependent encoder failure (e.g., a single event - // exceeding the native codec's nesting budget) and silently drop + // on the first data-dependent encoder failure and silently drop // every subsequent valid event. finalizers.update_status(EventStatus::Errored); continue; } + if bytes.is_empty() { + // The encoder accepted ownership of the event but produced no + // output, meaning it dropped the event internally (e.g., the + // native codec rejecting an event whose nesting exceeds the + // protobuf recursion budget). The encoder has already emitted + // `ComponentEventsDropped` and set `EventStatus::Rejected` on + // the event's metadata; finalize accordingly and continue. + finalizers.update_status(EventStatus::Rejected); + continue; + } match self.output.write_all(&bytes).await { Err(error) => { @@ -109,9 +118,10 @@ mod test { .await; } - /// A per-event encoder failure (e.g., a single event exceeding the native codec's + /// A per-event encoder drop (e.g., a single event exceeding the native codec's /// nesting budget) must not terminate the sink. The offending event is finalized - /// as `Errored`, the next event still goes through and is `Delivered`. + /// as `Rejected` (the encoder dropped it internally), the next event still goes + /// through and is `Delivered`. #[tokio::test] async fn per_event_encoder_failure_does_not_terminate_sink() { // Build a log whose value is an Object chain too deep for the native codec @@ -150,8 +160,8 @@ mod test { assert_eq!( bad_rx.try_recv(), - Ok(BatchStatus::Errored), - "the over-budget event must be finalized as Errored", + Ok(BatchStatus::Rejected), + "the over-budget event must be finalized as Rejected (encoder dropped it)", ); assert_eq!( good_rx.try_recv(), diff --git a/src/sinks/util/encoding.rs b/src/sinks/util/encoding.rs index 63f8407cef53b..3e84be0fb1a2f 100644 --- a/src/sinks/util/encoding.rs +++ b/src/sinks/util/encoding.rs @@ -222,14 +222,14 @@ mod tests { use vector_lib::{ codecs::{ CharacterDelimitedEncoder, JsonSerializerConfig, LengthDelimitedEncoder, - NewlineDelimitedEncoder, TextSerializerConfig, + NativeSerializerConfig, NewlineDelimitedEncoder, TextSerializerConfig, encoding::{ProtobufSerializerConfig, ProtobufSerializerOptions}, }, event::LogEvent, internal_event::CountByteSize, json_size::JsonSize, }; - use vrl::value::{KeyString, Value}; + use vrl::value::{KeyString, ObjectMap, Value}; cfg_if! { if #[cfg(feature = "codecs-arrow")] { @@ -617,4 +617,53 @@ mod tests { contains_name_once("ComponentEventsDropped") .expect("ComponentEventsDropped should be emitted by the wrapper"); } + + /// An over-budget event in the middle of a batched native-codec request must + /// be silently dropped by the encoder without aborting the whole batch. + /// Surrounding valid events must still encode and reach the writer. + #[test] + fn test_encode_batch_native_drops_over_budget_event_without_aborting() { + let encoding = ( + Transformer::default(), + vector_lib::codecs::Encoder::::new( + NewlineDelimitedEncoder::default().into(), + NativeSerializerConfig.build().into(), + ), + ); + + let valid = || { + Event::Log(LogEvent::from(BTreeMap::from([( + KeyString::from("k"), + Value::from("ok"), + )]))) + }; + + // Build an over-budget value: 34 object levels (cost 102) > 99-frame budget. + let mut deep = Value::from("x"); + for _ in 0..34 { + let mut m = ObjectMap::new(); + m.insert("nested".into(), deep); + deep = Value::Object(m); + } + let mut over_budget = LogEvent::default(); + over_budget.insert("data", deep); + + let mut writer = Vec::new(); + let (bytes_written, _) = encoding + .encode_input(vec![valid(), Event::Log(over_budget), valid()], &mut writer) + .expect( + "batched native encoding must not abort the request \ + when a single event exceeds the nesting budget", + ); + + assert!( + bytes_written > 0, + "the two valid events must still produce output bytes", + ); + assert_eq!( + bytes_written, + writer.len(), + "bytes_written must match the writer's actual length", + ); + } } From 95ba0d0e12388c1f23ee3ada0799eb7e1a8201ad Mon Sep 17 00:00:00 2001 From: Orri Ganel Date: Wed, 17 Jun 2026 16:01:12 -0400 Subject: [PATCH 39/49] test(vector sink): pin PushEventsRequest decode boundary at the same 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) --- src/sinks/vector/sink.rs | 101 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/src/sinks/vector/sink.rs b/src/sinks/vector/sink.rs index c3af5c61d87e7..0f821c6dabbd4 100644 --- a/src/sinks/vector/sink.rs +++ b/src/sinks/vector/sink.rs @@ -149,3 +149,104 @@ where self.run_inner(input).await } } + +#[cfg(test)] +mod tests { + use bytes::BytesMut; + use prost::Message; + use vector_lib::event::{ + Event, LogEvent, MAX_METADATA_VALUE_NESTING_FRAMES, MAX_VALUE_NESTING_FRAMES, ObjectMap, + Value, event_exceeds_max_nesting_cost, + }; + + use super::EventWrapper; + use crate::proto::vector as proto_vector; + + fn build_nested_value(wrapping_levels: usize) -> Value { + let mut v = Value::from("leaf"); + for _ in 0..wrapping_levels { + let mut m = ObjectMap::new(); + m.insert("nested".into(), v); + v = Value::Object(m); + } + v + } + + /// Empirical check: an event sitting *exactly* at the value budget accepted by + /// `event_exceeds_max_nesting_cost` must roundtrip through the vector sink's + /// actual wire shape — `PushEventsRequest { events: [EventWrapper] }` — and + /// not fail decode at the receiver. If this test fails, the value budget is + /// too high for the gRPC path and needs to be reduced for the outer request + /// wrapper. + #[test] + fn push_events_request_decode_at_value_budget() { + // 32 nested objects under "data" key → 33 effective object levels in + // `log.value()` (one outer Object from the inserted key), cost = 99 = + // MAX_VALUE_NESTING_FRAMES. + let mut log = LogEvent::default(); + log.insert("data", build_nested_value(32)); + let event = Event::Log(log); + assert!( + event_exceeds_max_nesting_cost(&event).is_none(), + "test setup invariant: event must sit exactly at the value budget \ + (cost {MAX_VALUE_NESTING_FRAMES})", + ); + + let request = proto_vector::PushEventsRequest { + events: vec![EventWrapper::from(event)], + }; + + let mut buf = BytesMut::with_capacity(65536); + request.encode(&mut buf).expect("encode should succeed"); + + proto_vector::PushEventsRequest::decode(buf.freeze()) + .expect("PushEventsRequest decode should succeed at the accepted value budget"); + } + + /// Boundary check: one step past the value budget must fail decode through + /// the gRPC wire shape. Together with the at-budget test above this pins + /// `MAX_VALUE_NESTING_FRAMES` as the tight boundary for the vector-sink + /// path, identical to the disk-buffer / native-codec `EventArray` path. + #[test] + fn push_events_request_decode_one_past_value_budget_fails() { + // 33 nested objects under "data" → 34 effective object levels, cost 102. + let mut log = LogEvent::default(); + log.insert("data", build_nested_value(33)); + let event = Event::Log(log); + + let request = proto_vector::PushEventsRequest { + events: vec![EventWrapper::from(event)], + }; + + let mut buf = BytesMut::with_capacity(65536); + request.encode(&mut buf).expect("encode should succeed"); + + assert!( + proto_vector::PushEventsRequest::decode(buf.freeze()).is_err(), + "PushEventsRequest decode must fail one step past the value budget; \ + if this changes, the gate is no longer tight", + ); + } + + #[test] + fn push_events_request_decode_at_metadata_budget() { + let mut log = LogEvent::from("flat"); + *log.metadata_mut().value_mut() = build_nested_value(32); + let event = Event::Log(log); + assert!( + event_exceeds_max_nesting_cost(&event).is_none(), + "test setup invariant: metadata must sit exactly at the metadata \ + budget (cost {MAX_METADATA_VALUE_NESTING_FRAMES})", + ); + + let request = proto_vector::PushEventsRequest { + events: vec![EventWrapper::from(event)], + }; + + let mut buf = BytesMut::with_capacity(65536); + request.encode(&mut buf).expect("encode should succeed"); + + proto_vector::PushEventsRequest::decode(buf.freeze()) + .expect("PushEventsRequest decode should succeed at the accepted metadata budget"); + } +} From 806371d68efb5403f3a3656a95ad75a718da5f73 Mon Sep 17 00:00:00 2001 From: Orri Ganel Date: Wed, 17 Jun 2026 16:16:27 -0400 Subject: [PATCH 40/49] fix(buffers): report disk-v2 filter drops via the ledger's usage handle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 35981225a 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>` again. The `FilterDrops` and `TrySendOutcome` types added in 35981225a 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) --- .../src/topology/channel/sender.rs | 160 ++++++------------ .../src/variants/disk_v2/ledger.rs | 14 ++ .../variants/disk_v2/tests/filter_metrics.rs | 24 ++- .../src/variants/disk_v2/writer.rs | 10 ++ 4 files changed, 97 insertions(+), 111 deletions(-) diff --git a/lib/vector-buffers/src/topology/channel/sender.rs b/lib/vector-buffers/src/topology/channel/sender.rs index 9d08b060f9673..5187072504896 100644 --- a/lib/vector-buffers/src/topology/channel/sender.rs +++ b/lib/vector-buffers/src/topology/channel/sender.rs @@ -24,24 +24,6 @@ pub enum SenderAdapter { DiskV2(Arc>>), } -/// Events/bytes dropped by `Bufferable::filter_unencodable` inside the backend -/// dispatch. Only the disk-v2 backend invokes the filter (it is the only one with -/// wire-format constraints); in-memory backends always return the default zero -/// value. -#[derive(Clone, Copy, Debug, Default)] -pub(crate) struct FilterDrops { - pub events: u64, - pub bytes: u64, -} - -/// Outcome of [`SenderAdapter::try_send`]. Carries both whatever the filter dropped -/// (which the caller still needs to account for in buffer instrumentation) and the -/// item that did not fit, if any (which the caller may forward to an overflow stage). -pub(crate) struct TrySendOutcome { - pub filter_drops: FilterDrops, - pub rejected: Option, -} - impl From> for SenderAdapter { fn from(v: LimitedSender) -> Self { Self::InMemory(v) @@ -58,30 +40,30 @@ impl SenderAdapter where T: Bufferable, { - pub(crate) async fn send(&mut self, item: T) -> crate::Result { + pub(crate) async fn send(&mut self, item: T) -> crate::Result<()> { match self { - Self::InMemory(tx) => tx - .send(item) - .await - .map(|()| FilterDrops::default()) - .map_err(Into::into), + Self::InMemory(tx) => tx.send(item).await.map_err(Into::into), Self::DiskV2(writer) => { let pre_count = item.event_count() as u64; let pre_size = item.size_of() as u64; + let mut writer = writer.lock().await; + let Some(item) = item.filter_unencodable() else { - return Ok(FilterDrops { - events: pre_count, - bytes: pre_size, - }); + // The whole item was filtered out (e.g. every sub-item over the + // protobuf nesting budget). Report the drop directly via the + // ledger's usage handle so it shows up in the disk-v2 stage's + // `received` / `dropped` metrics — `BufferSender` does not carry + // its own handle for backends that `provides_instrumentation()`. + writer.track_dropped(pre_count, pre_size); + return Ok(()); }; - let drops = FilterDrops { - events: pre_count - item.event_count() as u64, - bytes: pre_size.saturating_sub(item.size_of() as u64), - }; - - let mut writer = writer.lock().await; + if item.event_count() as u64 != pre_count { + let dropped_events = pre_count - item.event_count() as u64; + let dropped_bytes = pre_size.saturating_sub(item.size_of() as u64); + writer.track_dropped(dropped_events, dropped_bytes); + } - writer.write_record(item).await.map(|_| drops).map_err(|e| { + writer.write_record(item).await.map(|_| ()).map_err(|e| { error!("Disk buffer writer has encountered an unrecoverable error."); e.into() @@ -90,15 +72,12 @@ where } } - pub(crate) async fn try_send(&mut self, item: T) -> crate::Result> { + pub(crate) async fn try_send(&mut self, item: T) -> crate::Result> { match self { - Self::InMemory(tx) => Ok(TrySendOutcome { - filter_drops: FilterDrops::default(), - rejected: tx - .try_send(item) - .err() - .map(super::limited_queue::TrySendError::into_inner), - }), + Self::InMemory(tx) => Ok(tx + .try_send(item) + .err() + .map(super::limited_queue::TrySendError::into_inner)), Self::DiskV2(writer) => { let mut writer = writer.lock().await; @@ -116,40 +95,26 @@ where // still has the item filtered before the rejection — that requires // knowing the encoded size pre-encode, which we cannot do cheaply. if writer.is_buffer_full() { - return Ok(TrySendOutcome { - filter_drops: FilterDrops::default(), - rejected: Some(item), - }); + return Ok(Some(item)); } let pre_count = item.event_count() as u64; let pre_size = item.size_of() as u64; let Some(item) = item.filter_unencodable() else { - return Ok(TrySendOutcome { - filter_drops: FilterDrops { - events: pre_count, - bytes: pre_size, - }, - rejected: None, - }); - }; - let filter_drops = FilterDrops { - events: pre_count - item.event_count() as u64, - bytes: pre_size.saturating_sub(item.size_of() as u64), + writer.track_dropped(pre_count, pre_size); + return Ok(None); }; + if item.event_count() as u64 != pre_count { + let dropped_events = pre_count - item.event_count() as u64; + let dropped_bytes = pre_size.saturating_sub(item.size_of() as u64); + writer.track_dropped(dropped_events, dropped_bytes); + } + + writer.try_write_record(item).await.map_err(|e| { + error!("Disk buffer writer has encountered an unrecoverable error."); - writer - .try_write_record(item) - .await - .map(|rejected| TrySendOutcome { - filter_drops, - rejected, - }) - .map_err(|e| { - error!("Disk buffer writer has encountered an unrecoverable error."); - - e.into() - }) + e.into() + }) } } } @@ -289,8 +254,7 @@ impl BufferSender { .as_ref() .map(|_| (item.event_count(), item.size_of())); - let mut rejected_sizing: Option<(usize, usize)> = None; - let filter_drops; + let mut was_dropped = false; if let Some(instrumentation) = self.usage_instrumentation.as_ref() && let Some((item_count, item_size)) = item_sizing @@ -299,50 +263,38 @@ impl BufferSender { .increment_received_event_count_and_byte_size(item_count as u64, item_size as u64); } match self.when_full { - WhenFull::Block => filter_drops = self.base.send(item).await?, + WhenFull::Block => self.base.send(item).await?, WhenFull::DropNewest => { - let outcome = self.base.try_send(item).await?; - filter_drops = outcome.filter_drops; - if let Some(rejected) = outcome.rejected { - rejected_sizing = Some((rejected.event_count(), rejected.size_of())); + if self.base.try_send(item).await?.is_some() { + was_dropped = true; } } WhenFull::Overflow => { - let outcome = self.base.try_send(item).await?; - filter_drops = outcome.filter_drops; - if let Some(rejected) = outcome.rejected { - rejected_sizing = Some((rejected.event_count(), rejected.size_of())); + if let Some(item) = self.base.try_send(item).await? { + was_dropped = true; self.overflow .as_mut() .unwrap_or_else(|| unreachable!("overflow must exist")) - .send(rejected, send_reference) + .send(item, send_reference) .await?; } } } - if let Some(instrumentation) = self.usage_instrumentation.as_ref() { - // Backend-filtered sub-items never reach the buffer; report them as an - // unintentional buffer drop so `buffer_size_*` (received - left) stays - // consistent with what is actually queued, rather than staying inflated - // by the filtered count forever. - if filter_drops.events > 0 || filter_drops.bytes > 0 { - instrumentation.increment_dropped_event_count_and_byte_size( - filter_drops.events, - filter_drops.bytes, - false, - ); - } - // Fullness-driven drops use the post-filter sizing of the rejected item, - // not the pre-filter sizing captured above — the filter portion has - // already been accounted for as an unintentional drop. - if let Some((rejected_count, rejected_size)) = rejected_sizing { - instrumentation.increment_dropped_event_count_and_byte_size( - rejected_count as u64, - rejected_size as u64, - true, - ); - } + // Backend filter drops are accounted directly through the backend's own + // usage handle (e.g. disk-v2's ledger), so they show up in the buffer + // stage's `received` / `dropped` metrics even when the `BufferSender` + // does not carry instrumentation. This block only reports fullness-driven + // drops captured via `was_dropped`. + if let Some(instrumentation) = self.usage_instrumentation.as_ref() + && let Some((item_count, item_size)) = item_sizing + && was_dropped + { + instrumentation.increment_dropped_event_count_and_byte_size( + item_count as u64, + item_size as u64, + true, + ); } if let Some(send_duration) = self.send_duration.as_ref() && let Some(send_reference) = send_reference diff --git a/lib/vector-buffers/src/variants/disk_v2/ledger.rs b/lib/vector-buffers/src/variants/disk_v2/ledger.rs index c166d443f2449..1e467454c897a 100644 --- a/lib/vector-buffers/src/variants/disk_v2/ledger.rs +++ b/lib/vector-buffers/src/variants/disk_v2/ledger.rs @@ -389,6 +389,20 @@ where .increment_received_event_count_and_byte_size(event_count, record_size); } + /// Tracks events that arrived at the buffer but were rejected before being + /// persisted (e.g. `Bufferable::filter_unencodable` dropping over-budget + /// sub-items). Bumps both `received` and the unintentional-`dropped` counter + /// on the usage handle so `buffer_size = received - sent - dropped` stays + /// consistent and operators can see the rejection in buffer-usage metrics. + /// `total_buffer_size` is intentionally left alone — these events never + /// reached disk. + pub fn track_dropped(&self, event_count: u64, byte_size: u64) { + self.usage_handle + .increment_received_event_count_and_byte_size(event_count, byte_size); + self.usage_handle + .increment_dropped_event_count_and_byte_size(event_count, byte_size, false); + } + /// Tracks the statistics of multiple successful reads. pub fn track_reads(&self, event_count: u64, total_record_size: u64) { self.decrement_total_buffer_size(total_record_size); diff --git a/lib/vector-buffers/src/variants/disk_v2/tests/filter_metrics.rs b/lib/vector-buffers/src/variants/disk_v2/tests/filter_metrics.rs index 0872e64b7eb55..cc37762d0f9d6 100644 --- a/lib/vector-buffers/src/variants/disk_v2/tests/filter_metrics.rs +++ b/lib/vector-buffers/src/variants/disk_v2/tests/filter_metrics.rs @@ -94,6 +94,13 @@ impl Bufferable for FilterableBatch { /// A partial-filter drop on a disk-v2 send must show up as an unintentional buffer /// drop, so `buffer_size_*` stays consistent with what actually landed on disk. +/// +/// Note: we deliberately do NOT attach `with_usage_instrumentation` to the +/// `BufferSender`. In production, `TopologyBuilder::build` skips that call for +/// disk-v2 because the stage `provides_instrumentation()` itself via the ledger. +/// This test reflects that production wiring: filter-drop accounting goes +/// through `Ledger::track_dropped`, and `usage` (returned by the helper) IS the +/// ledger's handle. #[tokio::test] async fn filter_drops_are_reported_as_unintentional_buffer_drops() { let _a = install_tracing_helpers(); @@ -105,9 +112,10 @@ async fn filter_drops_are_reported_as_unintentional_buffer_drops() { let (writer, _reader, _ledger, usage) = create_default_buffer_v2_with_usage::<_, FilterableBatch>(data_dir).await; let mut sender = BufferSender::new(SenderAdapter::from(writer), WhenFull::Block); - sender.with_usage_instrumentation(usage.clone()); - // 10 events arrive, filter keeps 3. + // 10 events arrive, filter keeps 3. Flush after each send so the + // ledger's `track_write` actually reaches the usage handle (it only + // fires when buffered writes are flushed to disk). sender .send( FilterableBatch { @@ -118,23 +126,25 @@ async fn filter_drops_are_reported_as_unintentional_buffer_drops() { ) .await .expect("send should succeed"); + sender.flush().await.expect("flush should succeed"); let snapshot = usage.snapshot(); assert_eq!( snapshot.received_event_count, 10, - "received reflects pre-filter sizing (the item arrived at the buffer boundary)", + "received counts both the 7 filter-dropped events (via track_dropped) \ + and the 3 events flushed to disk (via track_write)", ); assert_eq!( snapshot.dropped_event_count, 7, - "filter drops are reported as an unintentional buffer drop \ - so buffer_size stays consistent (received - dropped = 3 queued)", + "filter drops show up under the disk-v2 stage's unintentional dropped count \ + so buffer_size stays consistent (received - sent - dropped = 3 queued)", ); assert_eq!( snapshot.dropped_event_count_intentional, 0, "no buffer-fullness drops here", ); - // 5 events arrive, filter drops them all. + // 5 events arrive, filter drops them all (nothing reaches disk). sender .send( FilterableBatch { @@ -149,7 +159,7 @@ async fn filter_drops_are_reported_as_unintentional_buffer_drops() { let snapshot = usage.snapshot(); assert_eq!( snapshot.received_event_count, 15, - "fully-filtered item still bumps received (it arrived at the boundary)", + "fully-filtered item still bumps received via track_dropped", ); assert_eq!( snapshot.dropped_event_count, 12, diff --git a/lib/vector-buffers/src/variants/disk_v2/writer.rs b/lib/vector-buffers/src/variants/disk_v2/writer.rs index f26c6d839cab8..a0eaa59e61654 100644 --- a/lib/vector-buffers/src/variants/disk_v2/writer.rs +++ b/lib/vector-buffers/src/variants/disk_v2/writer.rs @@ -1002,6 +1002,16 @@ where total_buffer_size >= max_buffer_size } + /// Records sub-items that arrived at the buffer but were dropped before + /// reaching disk (e.g. `Bufferable::filter_unencodable` rejecting events + /// the protobuf decoder cannot handle). Delegates to the ledger's usage + /// handle so the rejection shows up under the disk-v2 stage's + /// `received` / `dropped` metrics in production, where the + /// `BufferSender` does not carry its own usage instrumentation. + pub(crate) fn track_dropped(&self, event_count: u64, byte_size: u64) { + self.ledger.track_dropped(event_count, byte_size); + } + /// Ensures this writer is ready to attempt writer the next record. #[instrument(skip(self), level = "debug")] async fn ensure_ready_for_write(&mut self) -> io::Result<()> { From 13999c5f581fde191a58850a858ca3a8ed52b6f9 Mon Sep 17 00:00:00 2001 From: Orri Ganel Date: Wed, 17 Jun 2026 16:25:10 -0400 Subject: [PATCH 41/49] fix(sinks): finalize socket-sink encoder drops with explicit status 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) --- src/sinks/util/tcp.rs | 33 ++++++++++++++++++++++++--------- src/sinks/util/unix.rs | 33 ++++++++++++++++++++++++--------- 2 files changed, 48 insertions(+), 18 deletions(-) diff --git a/src/sinks/util/tcp.rs b/src/sinks/util/tcp.rs index 3f4051a18c5f1..1c7db0bfca529 100644 --- a/src/sinks/util/tcp.rs +++ b/src/sinks/util/tcp.rs @@ -24,7 +24,7 @@ use crate::{ codecs::Transformer, common::backoff::ExponentialBackoff, dns, - event::Event, + event::{Event, EventStatus}, internal_events::{ ConnectionOpen, OpenGauge, SocketMode, SocketSendError, TcpSocketConnectionEstablished, TcpSocketConnectionShutdown, TcpSocketOutgoingConnectionError, @@ -287,17 +287,32 @@ where self.transformer.transform(&mut event); let mut bytes = BytesMut::new(); - // Errors are handled by `Encoder`. - if encoder.encode(event, &mut bytes).is_ok() { - let item = bytes.freeze(); - EncodedEvent { - item, + match encoder.encode(event, &mut bytes) { + Err(_) => { + // Error already counted/logged by the `Encoder` framework. + // Mark the dropped event's finalizers `Errored` so the + // source acks reflect the drop rather than letting them + // fall to the default delivered status when they go out + // of scope. + finalizers.update_status(EventStatus::Errored); + EncodedEvent::new(Bytes::new(), 0, JsonSize::zero()) + } + Ok(()) if bytes.is_empty() => { + // The encoder accepted ownership of the event but produced + // no output, meaning it dropped the event internally (e.g. + // the native codec rejecting an over-budget event). The + // encoder has already emitted `ComponentEventsDropped`; + // finalize as `Rejected` so end-to-end acknowledgements + // do not record the drop as a successful delivery. + finalizers.update_status(EventStatus::Rejected); + EncodedEvent::new(Bytes::new(), 0, JsonSize::zero()) + } + Ok(()) => EncodedEvent { + item: bytes.freeze(), finalizers, byte_size, json_byte_size, - } - } else { - EncodedEvent::new(Bytes::new(), 0, JsonSize::zero()) + }, } }) .peekable(); diff --git a/src/sinks/util/unix.rs b/src/sinks/util/unix.rs index cb9dadb934b3d..6754f2a9ca52a 100644 --- a/src/sinks/util/unix.rs +++ b/src/sinks/util/unix.rs @@ -26,7 +26,7 @@ use super::datagram::{DatagramSocket, send_datagrams}; use crate::{ codecs::Transformer, common::backoff::ExponentialBackoff, - event::{Event, Finalizable}, + event::{Event, EventStatus, Finalizable}, internal_events::{ ConnectionOpen, OpenGauge, SocketMode, UnixSocketConnectionEstablished, UnixSocketOutgoingConnectionError, UnixSocketSendError, @@ -222,17 +222,32 @@ where let finalizers = event.take_finalizers(); let mut bytes = BytesMut::new(); - // Errors are handled by `Encoder`. - if encoder.encode(event, &mut bytes).is_ok() { - let item = bytes.freeze(); - EncodedEvent { - item, + match encoder.encode(event, &mut bytes) { + Err(_) => { + // Error already counted/logged by the `Encoder` framework. + // Mark the dropped event's finalizers `Errored` so the + // source acks reflect the drop rather than letting them + // fall to the default delivered status when they go out + // of scope. + finalizers.update_status(EventStatus::Errored); + EncodedEvent::new(Bytes::new(), 0, JsonSize::zero()) + } + Ok(()) if bytes.is_empty() => { + // The encoder accepted ownership of the event but produced + // no output, meaning it dropped the event internally (e.g. + // the native codec rejecting an over-budget event). The + // encoder has already emitted `ComponentEventsDropped`; + // finalize as `Rejected` so end-to-end acknowledgements + // do not record the drop as a successful delivery. + finalizers.update_status(EventStatus::Rejected); + EncodedEvent::new(Bytes::new(), 0, JsonSize::zero()) + } + Ok(()) => EncodedEvent { + item: bytes.freeze(), finalizers, byte_size, json_byte_size, - } - } else { - EncodedEvent::new(Bytes::new(), 0, JsonSize::zero()) + }, } }) .peekable(); From e90dd4714bb488ef97f95a09cc307418fa51b988 Mon Sep 17 00:00:00 2001 From: Orri Ganel Date: Thu, 18 Jun 2026 11:45:42 -0400 Subject: [PATCH 42/49] fix(codecs): preserve framing for legitimate empty payloads The framer-skip introduced in 8a22d863c 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::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) --- lib/codecs/src/encoding/encoder.rs | 19 ++++++++----- lib/codecs/src/encoding/serializer.rs | 31 +++++++++++++++++++++ src/sinks/util/encoding.rs | 39 +++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 7 deletions(-) diff --git a/lib/codecs/src/encoding/encoder.rs b/lib/codecs/src/encoding/encoder.rs index 73028b598e786..3ca11e96d168c 100644 --- a/lib/codecs/src/encoding/encoder.rs +++ b/lib/codecs/src/encoding/encoder.rs @@ -283,13 +283,18 @@ impl tokio_util::codec::Encoder for Encoder { self.serialize_at_start(event, &mut payload)?; - // If the inner serializer produced no output, the event was dropped - // internally (e.g., the native serializer rejecting an over-budget - // event with `EventStatus::Rejected` + `ComponentEventsDropped`). Skip - // framing so the caller observes an empty buffer and can finalize the - // dropped event accordingly, rather than seeing a stray delimiter for - // an event that produced no payload. - if payload.is_empty() { + // If the inner serializer produced no output AND treats empty output as + // a drop signal (currently only the native protobuf serializer), the + // event was dropped internally. Skip framing so the caller observes an + // empty buffer and can finalize the dropped event accordingly, rather + // than seeing a stray delimiter or length prefix for an event that + // produced no payload. + // + // Text-based serializers (`Text`, `RawMessage`) legitimately emit zero + // bytes for events with no message field, and those events still need + // their framing (e.g. a newline) so downstream stream parsers see the + // expected delimiter. + if payload.is_empty() && self.serializer.empty_output_means_dropped() { buffer.unsplit(payload); return Ok(()); } diff --git a/lib/codecs/src/encoding/serializer.rs b/lib/codecs/src/encoding/serializer.rs index 431f0356c3aee..5d8dace701f61 100644 --- a/lib/codecs/src/encoding/serializer.rs +++ b/lib/codecs/src/encoding/serializer.rs @@ -432,6 +432,37 @@ pub enum Serializer { } impl Serializer { + /// Whether an empty serialized payload from this serializer indicates that + /// the event was dropped internally (rather than legitimately encoded to + /// zero bytes). + /// + /// Only the native protobuf serializer treats zero-byte output as a drop + /// signal: a valid event always produces at least the protobuf wrapper + /// bytes, so an empty payload there can only mean the encoder refused the + /// event (currently: over-budget protobuf nesting). Text-based serializers + /// (`Text`, `RawMessage`, etc.) legitimately emit zero bytes for events + /// with no message field, so callers must not treat empty output from + /// those as a drop. + pub fn empty_output_means_dropped(&self) -> bool { + match self { + Serializer::Native(_) => true, + Serializer::Avro(_) + | Serializer::Cef(_) + | Serializer::Csv(_) + | Serializer::Gelf(_) + | Serializer::Json(_) + | Serializer::Logfmt(_) + | Serializer::NativeJson(_) + | Serializer::Protobuf(_) + | Serializer::RawMessage(_) + | Serializer::Text(_) => false, + #[cfg(feature = "syslog")] + Serializer::Syslog(_) => false, + #[cfg(feature = "opentelemetry")] + Serializer::Otlp(_) => false, + } + } + /// Check if the serializer supports encoding an event to JSON via `Serializer::to_json_value`. pub fn supports_json(&self) -> bool { match self { diff --git a/src/sinks/util/encoding.rs b/src/sinks/util/encoding.rs index 3e84be0fb1a2f..0e6dde6eceb37 100644 --- a/src/sinks/util/encoding.rs +++ b/src/sinks/util/encoding.rs @@ -618,6 +618,45 @@ mod tests { .expect("ComponentEventsDropped should be emitted by the wrapper"); } + /// Legitimately empty payloads (text serializer with a log carrying no + /// `message` field) must keep their framing: the dropped-event shortcut + /// added for the native serializer must not swallow real empty records, + /// otherwise NDJSON consumers would lose the per-event newline. + #[test] + fn test_encode_batch_text_empty_message_keeps_newline_framing() { + let encoding = ( + Transformer::default(), + vector_lib::codecs::Encoder::::new( + NewlineDelimitedEncoder::default().into(), + TextSerializerConfig::default().build().into(), + ), + ); + + // Three logs: empty/middle/last. The middle log has no `message` so + // text serialization produces zero bytes; the framer still has to add + // a newline so the stream remains parseable. + let with_message = || { + Event::Log(LogEvent::from(BTreeMap::from([( + KeyString::from("message"), + Value::from("ok"), + )]))) + }; + let empty_message = || Event::Log(LogEvent::default()); + + let mut writer = Vec::new(); + encoding + .encode_input( + vec![with_message(), empty_message(), with_message()], + &mut writer, + ) + .expect("encode should succeed"); + + // Expected: "ok\n\nok\n" — first event framed with newline, middle + // (empty) event still framed with a bare newline, last event written + // without per-event framing but followed by the batch-suffix newline. + assert_eq!(String::from_utf8(writer).unwrap(), "ok\n\nok\n"); + } + /// An over-budget event in the middle of a batched native-codec request must /// be silently dropped by the encoder without aborting the whole batch. /// Surrounding valid events must still encode and reach the writer. From b172e6cb189b9eeb37be792d6253abd624db00c9 Mon Sep 17 00:00:00 2001 From: Orri Ganel Date: Thu, 18 Jun 2026 12:44:36 -0400 Subject: [PATCH 43/49] refactor(codecs): drop native-encoder nesting guard, scope fix to decode-owned paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../protobuf_nesting_depth_limit.fix.md | 2 +- lib/codecs/src/encoding/encoder.rs | 16 -- lib/codecs/src/encoding/format/native.rs | 25 +-- lib/codecs/src/encoding/serializer.rs | 31 --- lib/codecs/tests/native.rs | 206 +----------------- src/sinks/console/sink.rs | 80 +------ src/sinks/util/encoding.rs | 92 +------- src/sinks/util/tcp.rs | 33 +-- src/sinks/util/unix.rs | 33 +-- 9 files changed, 28 insertions(+), 490 deletions(-) diff --git a/changelog.d/protobuf_nesting_depth_limit.fix.md b/changelog.d/protobuf_nesting_depth_limit.fix.md index 7491327472f8d..e9b7dae534acf 100644 --- a/changelog.d/protobuf_nesting_depth_limit.fix.md +++ b/changelog.d/protobuf_nesting_depth_limit.fix.md @@ -1,3 +1,3 @@ -Fixed unrecoverable disk buffer corruption and vector-to-vector retry loops caused by event data or metadata that protobuf could encode but prost could not decode. Vector now rejects only protobuf-unsafe nested payloads before disk buffer, native codec, or `vector` sink gRPC encoding, while preserving nested shapes that prost can safely decode. +Fixed unrecoverable disk buffer corruption and vector-to-vector retry loops caused by event data or metadata that protobuf could encode but prost could not decode. Vector now drops only protobuf-unsafe nested payloads before disk buffer or `vector` sink gRPC encoding, while preserving nested shapes that prost can safely decode. authors: connoryy diff --git a/lib/codecs/src/encoding/encoder.rs b/lib/codecs/src/encoding/encoder.rs index 3ca11e96d168c..2bbfba6cdbb55 100644 --- a/lib/codecs/src/encoding/encoder.rs +++ b/lib/codecs/src/encoding/encoder.rs @@ -283,22 +283,6 @@ impl tokio_util::codec::Encoder for Encoder { self.serialize_at_start(event, &mut payload)?; - // If the inner serializer produced no output AND treats empty output as - // a drop signal (currently only the native protobuf serializer), the - // event was dropped internally. Skip framing so the caller observes an - // empty buffer and can finalize the dropped event accordingly, rather - // than seeing a stray delimiter or length prefix for an event that - // produced no payload. - // - // Text-based serializers (`Text`, `RawMessage`) legitimately emit zero - // bytes for events with no message field, and those events still need - // their framing (e.g. a newline) so downstream stream parsers see the - // expected delimiter. - if payload.is_empty() && self.serializer.empty_output_means_dropped() { - buffer.unsplit(payload); - return Ok(()); - } - // Frame the serialized event. self.framer.encode((), &mut payload).map_err(|error| { emit(EncoderFramingError { error: &error }); diff --git a/lib/codecs/src/encoding/format/native.rs b/lib/codecs/src/encoding/format/native.rs index a3b2ae7b029f0..c3af9544b766e 100644 --- a/lib/codecs/src/encoding/format/native.rs +++ b/lib/codecs/src/encoding/format/native.rs @@ -2,10 +2,9 @@ use bytes::BytesMut; use prost::Message; use serde::{Deserialize, Serialize}; use tokio_util::codec::Encoder; -use vector_common::internal_event::{ComponentEventsDropped, UNINTENTIONAL, emit}; use vector_core::{ config::DataType, - event::{Event, EventArray, EventStatus, event_exceeds_max_nesting_cost, proto}, + event::{Event, EventArray, proto}, schema, }; @@ -38,28 +37,6 @@ impl Encoder for NativeSerializer { type Error = vector_common::Error; fn encode(&mut self, event: Event, buffer: &mut BytesMut) -> Result<(), Self::Error> { - if let Some((cost, budget)) = event_exceeds_max_nesting_cost(&event) { - // Returning `Err` here would propagate through batched encoders - // (e.g. `src/sinks/util/encoding.rs`) as a fatal `InvalidData`, - // causing the entire request to be dropped and every other valid - // event in the batch to be lost. Instead drop just this event with - // proper telemetry and `EventStatus::Rejected`, write zero bytes, - // and return Ok so batched callers continue to the next event. - // - // Per-event callers (e.g. `WriterSink::run`) treat `Ok(())` with an - // empty buffer as an in-encoder drop and finalize accordingly. - let reason = format!("event nesting cost ({cost}) exceeds protobuf budget ({budget})"); - emit(ComponentEventsDropped:: { - count: 1, - reason: &reason, - }); - match event { - Event::Log(log) => log.metadata().update_status(EventStatus::Rejected), - Event::Metric(metric) => metric.metadata().update_status(EventStatus::Rejected), - Event::Trace(trace) => trace.metadata().update_status(EventStatus::Rejected), - } - return Ok(()); - } let array = EventArray::from(event); let proto = proto::EventArray::from(array); proto.encode(buffer)?; diff --git a/lib/codecs/src/encoding/serializer.rs b/lib/codecs/src/encoding/serializer.rs index 5d8dace701f61..431f0356c3aee 100644 --- a/lib/codecs/src/encoding/serializer.rs +++ b/lib/codecs/src/encoding/serializer.rs @@ -432,37 +432,6 @@ pub enum Serializer { } impl Serializer { - /// Whether an empty serialized payload from this serializer indicates that - /// the event was dropped internally (rather than legitimately encoded to - /// zero bytes). - /// - /// Only the native protobuf serializer treats zero-byte output as a drop - /// signal: a valid event always produces at least the protobuf wrapper - /// bytes, so an empty payload there can only mean the encoder refused the - /// event (currently: over-budget protobuf nesting). Text-based serializers - /// (`Text`, `RawMessage`, etc.) legitimately emit zero bytes for events - /// with no message field, so callers must not treat empty output from - /// those as a drop. - pub fn empty_output_means_dropped(&self) -> bool { - match self { - Serializer::Native(_) => true, - Serializer::Avro(_) - | Serializer::Cef(_) - | Serializer::Csv(_) - | Serializer::Gelf(_) - | Serializer::Json(_) - | Serializer::Logfmt(_) - | Serializer::NativeJson(_) - | Serializer::Protobuf(_) - | Serializer::RawMessage(_) - | Serializer::Text(_) => false, - #[cfg(feature = "syslog")] - Serializer::Syslog(_) => false, - #[cfg(feature = "opentelemetry")] - Serializer::Otlp(_) => false, - } - } - /// Check if the serializer supports encoding an event to JSON via `Serializer::to_json_value`. pub fn supports_json(&self) -> bool { match self { diff --git a/lib/codecs/tests/native.rs b/lib/codecs/tests/native.rs index bac28c98beaaf..0fb71ee02b932 100644 --- a/lib/codecs/tests/native.rs +++ b/lib/codecs/tests/native.rs @@ -12,11 +12,7 @@ use codecs::{ NativeSerializerConfig, decoding::format::Deserializer, encoding::format::Serializer, }; use similar_asserts::assert_eq; -use tokio_util::codec::Encoder; -use vector_core::{ - config::LogNamespace, - event::{Event, LogEvent, ObjectMap, Value}, -}; +use vector_core::{config::LogNamespace, event::Event}; #[test] fn pre_v24_fixtures_match() { @@ -326,203 +322,3 @@ fn rebuild_fixtures(proto: &str, deserializer: &dyn Deserializer, serializer: &m out.flush().expect("Could not write rebuilt data"); } } - -// --------------------------------------------------------------------------- -// Nesting cost guard integration tests for the native codec -// --------------------------------------------------------------------------- - -fn create_nested_log_event(wrapping_levels: usize) -> LogEvent { - let mut value = Value::from("innermost"); - for _ in 0..wrapping_levels { - let mut map = ObjectMap::new(); - map.insert("nested".into(), value); - value = Value::Object(map); - } - let mut event = LogEvent::default(); - event.insert("data", value); - event -} - -#[test] -fn native_codec_rejects_overly_nested_event() { - // 33 wrapping object levels + outer fields object = 34 object levels in Value tree - // (cost 102) > MAX_VALUE_NESTING_FRAMES (99). - let event = create_nested_log_event(33); - let event = Event::Log(event); - - let mut serializer = NativeSerializerConfig.build(); - let mut buffer = BytesMut::with_capacity(8192); - - serializer - .encode(event, &mut buffer) - .expect("native codec must return Ok for over-budget events so batched encoders do not abort the whole batch"); - assert!( - buffer.is_empty(), - "native codec must write zero bytes for an over-budget event", - ); -} - -#[test] -fn native_codec_roundtrip_max_depth_event() { - // 32 wrapping object levels + outer fields object = 33 object levels in Value tree - // (cost 99) = MAX_VALUE_NESTING_FRAMES. - let event = create_nested_log_event(32); - let original_data = event.value().get("data").cloned(); - let event = Event::Log(event); - - let mut serializer = NativeSerializerConfig.build(); - let mut buffer = BytesMut::with_capacity(8192); - - serializer - .encode(event, &mut buffer) - .expect("native codec should accept events at MAX_VALUE_NESTING_FRAMES"); - - let deserializer = NativeDeserializerConfig.build(); - let decoded_events = deserializer - .parse(buffer.freeze(), LogNamespace::Legacy) - .expect("native codec should decode events at MAX_VALUE_NESTING_FRAMES"); - - assert_eq!(decoded_events.len(), 1); - let decoded_log = decoded_events.into_iter().next().unwrap().into_log(); - assert_eq!(original_data.as_ref(), decoded_log.value().get("data"),); -} - -fn create_nested_value(wrapping_levels: usize) -> Value { - let mut value = Value::from("innermost"); - for _ in 0..wrapping_levels { - let mut map = ObjectMap::new(); - map.insert("nested".into(), value); - value = Value::Object(map); - } - value -} - -#[test] -fn native_codec_rejects_overly_nested_metadata() { - // Metadata at 33 object levels = 99 frame cost > MAX_METADATA_VALUE_NESTING_FRAMES (96). - let mut event = LogEvent::from("flat data"); - *event.metadata_mut().value_mut() = create_nested_value(33); - let event = Event::Log(event); - - let mut serializer = NativeSerializerConfig.build(); - let mut buffer = BytesMut::with_capacity(8192); - - serializer - .encode(event, &mut buffer) - .expect("native codec must return Ok for over-budget metadata"); - assert!( - buffer.is_empty(), - "native codec must write zero bytes for metadata exceeding MAX_METADATA_VALUE_NESTING_FRAMES", - ); -} - -fn create_nested_value_with_leaf(wrapping_levels: usize, leaf: Value) -> Value { - let mut value = leaf; - for _ in 0..wrapping_levels { - let mut map = ObjectMap::new(); - map.insert("nested".into(), value); - value = Value::Object(map); - } - value -} - -fn ts_leaf() -> Value { - use chrono::TimeZone; - Value::Timestamp( - chrono::Utc - .timestamp_opt(1_700_000_000, 0) - .single() - .unwrap(), - ) -} - -#[test] -fn native_codec_rejects_timestamp_leaf_at_max_object_depth() { - // 32 wrapping objects + outer fields object + Timestamp leaf = cost 99 + 1 = 100, - // one frame over MAX_VALUE_NESTING_FRAMES. - let mut event = LogEvent::default(); - event.insert("data", create_nested_value_with_leaf(32, ts_leaf())); - let event = Event::Log(event); - - let mut serializer = NativeSerializerConfig.build(); - let mut buffer = BytesMut::with_capacity(8192); - - serializer - .encode(event, &mut buffer) - .expect("native codec must return Ok for over-budget events"); - assert!( - buffer.is_empty(), - "native codec must write zero bytes for Timestamp leaf at object depth 33", - ); -} - -#[test] -fn native_codec_roundtrip_timestamp_leaf_below_max_object_depth() { - // 31 wrapping objects + outer fields object + Timestamp leaf = cost 96 + 1 = 97, - // comfortably under MAX_VALUE_NESTING_FRAMES. - let mut event = LogEvent::default(); - let value = create_nested_value_with_leaf(31, ts_leaf()); - event.insert("data", value.clone()); - let event = Event::Log(event); - - let mut serializer = NativeSerializerConfig.build(); - let mut buffer = BytesMut::with_capacity(8192); - - serializer - .encode(event, &mut buffer) - .expect("native codec should accept Timestamp leaf at object depth 32"); - - let deserializer = NativeDeserializerConfig.build(); - let decoded_events = deserializer - .parse(buffer.freeze(), LogNamespace::Legacy) - .expect("native codec should decode Timestamp leaf at object depth 32"); - - assert_eq!(decoded_events.len(), 1); - let decoded_log = decoded_events.into_iter().next().unwrap().into_log(); - assert_eq!(decoded_log.value().get("data"), Some(&value)); -} - -#[test] -fn native_codec_rejects_timestamp_leaf_in_max_depth_metadata() { - // Metadata at 32 object levels + Timestamp leaf = cost 96 + 1 = 97, one frame - // over MAX_METADATA_VALUE_NESTING_FRAMES. - let mut event = LogEvent::from("flat data"); - *event.metadata_mut().value_mut() = create_nested_value_with_leaf(32, ts_leaf()); - let event = Event::Log(event); - - let mut serializer = NativeSerializerConfig.build(); - let mut buffer = BytesMut::with_capacity(8192); - - serializer - .encode(event, &mut buffer) - .expect("native codec must return Ok for over-budget metadata"); - assert!( - buffer.is_empty(), - "native codec must write zero bytes for metadata Timestamp leaf at object depth 32", - ); -} - -#[test] -fn native_codec_roundtrip_max_metadata_depth_event() { - // Metadata at 32 object levels = 96 frame cost = MAX_METADATA_VALUE_NESTING_FRAMES. - let mut event = LogEvent::from("flat data"); - let metadata_value = create_nested_value(32); - *event.metadata_mut().value_mut() = metadata_value.clone(); - let event = Event::Log(event); - - let mut serializer = NativeSerializerConfig.build(); - let mut buffer = BytesMut::with_capacity(8192); - - serializer - .encode(event, &mut buffer) - .expect("native codec should accept events at MAX_METADATA_VALUE_NESTING_FRAMES"); - - let deserializer = NativeDeserializerConfig.build(); - let decoded_events = deserializer - .parse(buffer.freeze(), LogNamespace::Legacy) - .expect("native codec should decode events at MAX_METADATA_VALUE_NESTING_FRAMES"); - - assert_eq!(decoded_events.len(), 1); - let decoded_log = decoded_events.into_iter().next().unwrap().into_log(); - assert_eq!(decoded_log.metadata().value(), &metadata_value); -} diff --git a/src/sinks/console/sink.rs b/src/sinks/console/sink.rs index 2ee9adbb97a24..c20499fd55bc8 100644 --- a/src/sinks/console/sink.rs +++ b/src/sinks/console/sink.rs @@ -37,26 +37,10 @@ where let finalizers = event.take_finalizers(); let mut bytes = BytesMut::new(); - if self.encoder.encode(event, &mut bytes).is_err() { - // Error is already logged + counted + emits `ComponentEventsDropped` - // by the `Encoder` framework. Mark this event's finalizers Errored so - // source-side acks reflect the drop, then continue with the next - // event. Surfacing the error here as fatal would terminate the sink - // on the first data-dependent encoder failure and silently drop - // every subsequent valid event. + self.encoder.encode(event, &mut bytes).map_err(|_| { + // Error is handled by `Encoder`. finalizers.update_status(EventStatus::Errored); - continue; - } - if bytes.is_empty() { - // The encoder accepted ownership of the event but produced no - // output, meaning it dropped the event internally (e.g., the - // native codec rejecting an event whose nesting exceeds the - // protobuf recursion budget). The encoder has already emitted - // `ComponentEventsDropped` and set `EventStatus::Rejected` on - // the event's metadata; finalize accordingly and continue. - finalizers.update_status(EventStatus::Rejected); - continue; - } + })?; match self.output.write_all(&bytes).await { Err(error) => { @@ -82,10 +66,9 @@ where #[cfg(test)] mod test { use futures::future::ready; - use futures_util::stream::{self, StreamExt}; + use futures_util::stream; use vector_lib::{ - codecs::{JsonSerializerConfig, NativeSerializerConfig, NewlineDelimitedEncoder}, - event::{BatchNotifier, BatchStatus, ObjectMap, Value}, + codecs::{JsonSerializerConfig, NewlineDelimitedEncoder}, sink::VectorSink, }; @@ -117,57 +100,4 @@ mod test { ) .await; } - - /// A per-event encoder drop (e.g., a single event exceeding the native codec's - /// nesting budget) must not terminate the sink. The offending event is finalized - /// as `Rejected` (the encoder dropped it internally), the next event still goes - /// through and is `Delivered`. - #[tokio::test] - async fn per_event_encoder_failure_does_not_terminate_sink() { - // Build a log whose value is an Object chain too deep for the native codec - // (object cost 34 * 3 = 102 frames, over the 99-frame value budget). - let mut deep_value = Value::from("x"); - for _ in 0..34 { - let mut m = ObjectMap::new(); - m.insert("nested".into(), deep_value); - deep_value = Value::Object(m); - } - - let (bad_batch, mut bad_rx) = BatchNotifier::new_with_receiver(); - let mut bad_event = LogEvent::default().with_batch_notifier(&bad_batch); - bad_event.insert("data", deep_value); - drop(bad_batch); - - let (good_batch, mut good_rx) = BatchNotifier::new_with_receiver(); - let good_event = LogEvent::from("ok").with_batch_notifier(&good_batch); - drop(good_batch); - - let encoder = Encoder::::new( - NewlineDelimitedEncoder::default().into(), - NativeSerializerConfig.build().into(), - ); - - let sink = Box::new(WriterSink { - output: Vec::new(), - transformer: Default::default(), - encoder, - }); - - let events = stream::iter(vec![Event::Log(bad_event), Event::Log(good_event)]).boxed(); - sink.run(events) - .await - .expect("sink should not return Err for a per-event encode failure"); - - assert_eq!( - bad_rx.try_recv(), - Ok(BatchStatus::Rejected), - "the over-budget event must be finalized as Rejected (encoder dropped it)", - ); - assert_eq!( - good_rx.try_recv(), - Ok(BatchStatus::Delivered), - "the subsequent valid event must still be Delivered \ - (the sink must keep processing past a per-event encoder failure)", - ); - } } diff --git a/src/sinks/util/encoding.rs b/src/sinks/util/encoding.rs index 0e6dde6eceb37..63f8407cef53b 100644 --- a/src/sinks/util/encoding.rs +++ b/src/sinks/util/encoding.rs @@ -222,14 +222,14 @@ mod tests { use vector_lib::{ codecs::{ CharacterDelimitedEncoder, JsonSerializerConfig, LengthDelimitedEncoder, - NativeSerializerConfig, NewlineDelimitedEncoder, TextSerializerConfig, + NewlineDelimitedEncoder, TextSerializerConfig, encoding::{ProtobufSerializerConfig, ProtobufSerializerOptions}, }, event::LogEvent, internal_event::CountByteSize, json_size::JsonSize, }; - use vrl::value::{KeyString, ObjectMap, Value}; + use vrl::value::{KeyString, Value}; cfg_if! { if #[cfg(feature = "codecs-arrow")] { @@ -617,92 +617,4 @@ mod tests { contains_name_once("ComponentEventsDropped") .expect("ComponentEventsDropped should be emitted by the wrapper"); } - - /// Legitimately empty payloads (text serializer with a log carrying no - /// `message` field) must keep their framing: the dropped-event shortcut - /// added for the native serializer must not swallow real empty records, - /// otherwise NDJSON consumers would lose the per-event newline. - #[test] - fn test_encode_batch_text_empty_message_keeps_newline_framing() { - let encoding = ( - Transformer::default(), - vector_lib::codecs::Encoder::::new( - NewlineDelimitedEncoder::default().into(), - TextSerializerConfig::default().build().into(), - ), - ); - - // Three logs: empty/middle/last. The middle log has no `message` so - // text serialization produces zero bytes; the framer still has to add - // a newline so the stream remains parseable. - let with_message = || { - Event::Log(LogEvent::from(BTreeMap::from([( - KeyString::from("message"), - Value::from("ok"), - )]))) - }; - let empty_message = || Event::Log(LogEvent::default()); - - let mut writer = Vec::new(); - encoding - .encode_input( - vec![with_message(), empty_message(), with_message()], - &mut writer, - ) - .expect("encode should succeed"); - - // Expected: "ok\n\nok\n" — first event framed with newline, middle - // (empty) event still framed with a bare newline, last event written - // without per-event framing but followed by the batch-suffix newline. - assert_eq!(String::from_utf8(writer).unwrap(), "ok\n\nok\n"); - } - - /// An over-budget event in the middle of a batched native-codec request must - /// be silently dropped by the encoder without aborting the whole batch. - /// Surrounding valid events must still encode and reach the writer. - #[test] - fn test_encode_batch_native_drops_over_budget_event_without_aborting() { - let encoding = ( - Transformer::default(), - vector_lib::codecs::Encoder::::new( - NewlineDelimitedEncoder::default().into(), - NativeSerializerConfig.build().into(), - ), - ); - - let valid = || { - Event::Log(LogEvent::from(BTreeMap::from([( - KeyString::from("k"), - Value::from("ok"), - )]))) - }; - - // Build an over-budget value: 34 object levels (cost 102) > 99-frame budget. - let mut deep = Value::from("x"); - for _ in 0..34 { - let mut m = ObjectMap::new(); - m.insert("nested".into(), deep); - deep = Value::Object(m); - } - let mut over_budget = LogEvent::default(); - over_budget.insert("data", deep); - - let mut writer = Vec::new(); - let (bytes_written, _) = encoding - .encode_input(vec![valid(), Event::Log(over_budget), valid()], &mut writer) - .expect( - "batched native encoding must not abort the request \ - when a single event exceeds the nesting budget", - ); - - assert!( - bytes_written > 0, - "the two valid events must still produce output bytes", - ); - assert_eq!( - bytes_written, - writer.len(), - "bytes_written must match the writer's actual length", - ); - } } diff --git a/src/sinks/util/tcp.rs b/src/sinks/util/tcp.rs index 1c7db0bfca529..3f4051a18c5f1 100644 --- a/src/sinks/util/tcp.rs +++ b/src/sinks/util/tcp.rs @@ -24,7 +24,7 @@ use crate::{ codecs::Transformer, common::backoff::ExponentialBackoff, dns, - event::{Event, EventStatus}, + event::Event, internal_events::{ ConnectionOpen, OpenGauge, SocketMode, SocketSendError, TcpSocketConnectionEstablished, TcpSocketConnectionShutdown, TcpSocketOutgoingConnectionError, @@ -287,32 +287,17 @@ where self.transformer.transform(&mut event); let mut bytes = BytesMut::new(); - match encoder.encode(event, &mut bytes) { - Err(_) => { - // Error already counted/logged by the `Encoder` framework. - // Mark the dropped event's finalizers `Errored` so the - // source acks reflect the drop rather than letting them - // fall to the default delivered status when they go out - // of scope. - finalizers.update_status(EventStatus::Errored); - EncodedEvent::new(Bytes::new(), 0, JsonSize::zero()) - } - Ok(()) if bytes.is_empty() => { - // The encoder accepted ownership of the event but produced - // no output, meaning it dropped the event internally (e.g. - // the native codec rejecting an over-budget event). The - // encoder has already emitted `ComponentEventsDropped`; - // finalize as `Rejected` so end-to-end acknowledgements - // do not record the drop as a successful delivery. - finalizers.update_status(EventStatus::Rejected); - EncodedEvent::new(Bytes::new(), 0, JsonSize::zero()) - } - Ok(()) => EncodedEvent { - item: bytes.freeze(), + // Errors are handled by `Encoder`. + if encoder.encode(event, &mut bytes).is_ok() { + let item = bytes.freeze(); + EncodedEvent { + item, finalizers, byte_size, json_byte_size, - }, + } + } else { + EncodedEvent::new(Bytes::new(), 0, JsonSize::zero()) } }) .peekable(); diff --git a/src/sinks/util/unix.rs b/src/sinks/util/unix.rs index 6754f2a9ca52a..cb9dadb934b3d 100644 --- a/src/sinks/util/unix.rs +++ b/src/sinks/util/unix.rs @@ -26,7 +26,7 @@ use super::datagram::{DatagramSocket, send_datagrams}; use crate::{ codecs::Transformer, common::backoff::ExponentialBackoff, - event::{Event, EventStatus, Finalizable}, + event::{Event, Finalizable}, internal_events::{ ConnectionOpen, OpenGauge, SocketMode, UnixSocketConnectionEstablished, UnixSocketOutgoingConnectionError, UnixSocketSendError, @@ -222,32 +222,17 @@ where let finalizers = event.take_finalizers(); let mut bytes = BytesMut::new(); - match encoder.encode(event, &mut bytes) { - Err(_) => { - // Error already counted/logged by the `Encoder` framework. - // Mark the dropped event's finalizers `Errored` so the - // source acks reflect the drop rather than letting them - // fall to the default delivered status when they go out - // of scope. - finalizers.update_status(EventStatus::Errored); - EncodedEvent::new(Bytes::new(), 0, JsonSize::zero()) - } - Ok(()) if bytes.is_empty() => { - // The encoder accepted ownership of the event but produced - // no output, meaning it dropped the event internally (e.g. - // the native codec rejecting an over-budget event). The - // encoder has already emitted `ComponentEventsDropped`; - // finalize as `Rejected` so end-to-end acknowledgements - // do not record the drop as a successful delivery. - finalizers.update_status(EventStatus::Rejected); - EncodedEvent::new(Bytes::new(), 0, JsonSize::zero()) - } - Ok(()) => EncodedEvent { - item: bytes.freeze(), + // Errors are handled by `Encoder`. + if encoder.encode(event, &mut bytes).is_ok() { + let item = bytes.freeze(); + EncodedEvent { + item, finalizers, byte_size, json_byte_size, - }, + } + } else { + EncodedEvent::new(Bytes::new(), 0, JsonSize::zero()) } }) .peekable(); From f57988f6acfa2c4d9d579af8bdea3540f30abac5 Mon Sep 17 00:00:00 2001 From: Orri Ganel Date: Thu, 18 Jun 2026 14:01:41 -0400 Subject: [PATCH 44/49] docs(buffers): document over-budget overflow-routing limitation in try_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) --- .../src/topology/channel/sender.rs | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/lib/vector-buffers/src/topology/channel/sender.rs b/lib/vector-buffers/src/topology/channel/sender.rs index 5187072504896..9c45b0de3c3c0 100644 --- a/lib/vector-buffers/src/topology/channel/sender.rs +++ b/lib/vector-buffers/src/topology/channel/sender.rs @@ -88,16 +88,30 @@ where // here would needlessly drop sub-items that the overflow could // accept. Holding the writer lock makes the check race-free against // other writers (only writers grow the buffer; readers only shrink). - // - // Note: this handles the steady-state-full case. The narrower - // "buffer has slack but not enough for this specific record" case - // (`can_write_record` returning false inside `try_write_record`) - // still has the item filtered before the rejection — that requires - // knowing the encoded size pre-encode, which we cannot do cheaply. if writer.is_buffer_full() { return Ok(Some(item)); } + // KNOWN LIMITATION (accepted; tracked as a follow-up): past the + // steady-state-full check above, over-budget sub-items are filtered + // and dropped here even in `WhenFull::Overflow`, so a non-protobuf + // overflow stage (e.g. in-memory) never gets the chance to accept + // them. This surfaces two ways: + // 1. the item is partially over-budget and `try_write_record` + // below then rejects the *remainder* for fullness — the + // overflow receives the item minus the already-dropped events; + // 2. the item is fully over-budget — `filter_unencodable` returns + // `None` and the whole item is dropped before any capacity + // check, so nothing overflows. + // Routing unencodable items by `WhenFull` (drop in Block/DropNewest, + // overflow otherwise) is a `BufferSender`-level policy decision, + // whereas filtering lives here in the backend; reconciling the two + // is deferred. The window is narrow and atypical: it requires a + // disk-v2 stage in `Overflow` mode (disk is normally the terminal + // Block stage), a non-protobuf overflow target, an over-budget + // event (>32 nesting levels), and a downstream egress that could + // actually deliver it. In other topologies these events are dropped + // a stage later regardless. let pre_count = item.event_count() as u64; let pre_size = item.size_of() as u64; let Some(item) = item.filter_unencodable() else { From 20c5cbd4b8f47e949efecc14b0987ae9b0355d85 Mon Sep 17 00:00:00 2001 From: Erica <132393634+EricaJ6@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:45:15 -0700 Subject: [PATCH 45/49] fix(buffers): route unencodable items to overflow regardless of buffer occupancy. Signed-off-by: Justin O <28368774+jonodera97@users.noreply.github.com> --- .../protobuf_nesting_depth_limit.fix.md | 4 +- lib/vector-buffers/src/lib.rs | 59 ++++++- .../src/topology/channel/sender.rs | 70 ++++---- .../variants/disk_v2/tests/filter_metrics.rs | 156 ++++++++++++++++-- .../src/variants/disk_v2/writer.rs | 7 +- lib/vector-core/src/event/ser.rs | 6 + 6 files changed, 242 insertions(+), 60 deletions(-) diff --git a/changelog.d/protobuf_nesting_depth_limit.fix.md b/changelog.d/protobuf_nesting_depth_limit.fix.md index e9b7dae534acf..553462b1921ee 100644 --- a/changelog.d/protobuf_nesting_depth_limit.fix.md +++ b/changelog.d/protobuf_nesting_depth_limit.fix.md @@ -1,3 +1,3 @@ -Fixed unrecoverable disk buffer corruption and vector-to-vector retry loops caused by event data or metadata that protobuf could encode but prost could not decode. Vector now drops only protobuf-unsafe nested payloads before disk buffer or `vector` sink gRPC encoding, while preserving nested shapes that prost can safely decode. +Fixed unrecoverable disk buffer corruption and vector-to-vector retry loops caused by event data or metadata that protobuf could encode but prost could not decode. Vector now drops only protobuf-unsafe nested payloads before disk buffer or `vector` sink gRPC encoding, while preserving nested shapes that prost can safely decode. Under `when_full = "overflow"`, an event the buffer cannot encode is passed to the overflow stage intact rather than dropped, and does so regardless of how full the buffer currently is. -authors: connoryy +authors: connoryy, EricaJ6, jonodera97 diff --git a/lib/vector-buffers/src/lib.rs b/lib/vector-buffers/src/lib.rs index e927b4c8e7aed..81e3d0c0f2d55 100644 --- a/lib/vector-buffers/src/lib.rs +++ b/lib/vector-buffers/src/lib.rs @@ -12,6 +12,7 @@ #![allow(async_fn_in_trait)] #[macro_use] +extern crate tracing; mod buffer_usage_data; @@ -128,10 +129,62 @@ impl InMemoryBufferable for T where /// An item that can be buffered. /// /// This supertrait serves as the base trait for any item that can be pushed into a buffer. -pub trait Bufferable: InMemoryBufferable + Encodable + GroupedFinalizable {} +pub trait Bufferable: InMemoryBufferable + Encodable + GroupedFinalizable { + /// Drops any sub-items that cannot be persisted by the calling backend (e.g. due to + /// format-imposed nesting depth limits), reporting them as dropped via the appropriate + /// telemetry. Returns `None` if nothing remains worth writing. + /// + /// # Who calls this + /// + /// Only persistent backends with wire-format constraints invoke this — today that's + /// the disk-v2 sender (`SenderAdapter::send`/`try_send`). In-memory channels skip it + /// entirely because they hold the in-memory representation and have no nesting-limit + /// risk. A new backend with similar constraints should call this in the same place + /// and surface the resulting `FilterDrops` to `BufferSender` so that buffer-usage + /// instrumentation stays consistent with what actually lands in the buffer. + /// + /// # Default behaviour + /// + /// The default returns `Some(self)` if the item carries any events, and `None` if + /// it is already empty. This means an item that arrives empty (`event_count() == 0`) + /// is silently *not* persisted — preserving the pre-existing + /// "don't write empty records to disk" behaviour the call site used to enforce. + /// Types whose owners want empty items to be persisted must override this. + /// + /// # Skipping this call + /// + /// If a persistent backend writes an item without first calling `filter_unencodable`, + /// any sub-item that exceeds the format's limits will surface as a hard + /// [`Encodable::encode`] error and the *entire* item is rejected — including any + /// sibling sub-items that would otherwise have encoded fine. The filter is the only + /// path that produces graceful per-item drop with telemetry and a `Rejected` event + /// status; the encode-level check exists purely as defense-in-depth to ensure a + /// corrupt record cannot reach disk if a future caller forgets to filter. + fn filter_unencodable(self) -> Option { + if self.event_count() > 0 { + Some(self) + } else { + None + } + } -// Blanket implementation for anything that is already bufferable. -impl Bufferable for T where T: InMemoryBufferable + Encodable + GroupedFinalizable {} + /// Returns whether every sub-item can be persisted by a backend with wire-format + /// constraints, without consuming or modifying the item. + /// + /// This is the non-destructive counterpart to [`Bufferable::filter_unencodable`], and + /// exists so routing policy can be decided *before* any filtering happens. In + /// particular `WhenFull::Overflow` needs to know that an item can never reach disk, so + /// it can hand the item to the overflow stage intact rather than pruning sub-items for + /// a write that would not have succeeded at any buffer occupancy. + /// + /// The default returns `true`, which is correct for any type without format limits. + /// Implementors overriding [`Bufferable::filter_unencodable`] must override this too, + /// and the two must agree: this returns `false` exactly when `filter_unencodable` would + /// drop at least one sub-item. + fn is_fully_encodable(&self) -> bool { + true + } +} /// Hook for observing items as they are sent into a `BufferSender`. pub trait BufferInstrumentation: Send + Sync + 'static { diff --git a/lib/vector-buffers/src/topology/channel/sender.rs b/lib/vector-buffers/src/topology/channel/sender.rs index 520711e52806f..7914cd4b257b7 100644 --- a/lib/vector-buffers/src/topology/channel/sender.rs +++ b/lib/vector-buffers/src/topology/channel/sender.rs @@ -86,44 +86,21 @@ where pub(crate) async fn try_send(&mut self, item: T) -> crate::Result> { match self { - Self::InMemory(tx) => Ok(tx + Self::InMemory(tx) => tx .try_send(item) .map(|()| TryWriteOutcome::Written) .or_else(|e| Ok(TryWriteOutcome::Full(e.into_inner()))), Self::DiskV2(writer) => { let mut writer = writer.lock().await; - // If the disk buffer is already at its size limit, hand the item off - // to the caller unfiltered. The caller forwards it to the overflow - // stage in `WhenFull::Overflow` mode, and the overflow stage may be - // an in-memory buffer with no wire-format constraint — filtering - // here would needlessly drop sub-items that the overflow could - // accept. Holding the writer lock makes the check race-free against - // other writers (only writers grow the buffer; readers only shrink). - if writer.is_buffer_full() { - return Ok(TryWriteOutcome::Full(item)); - } - - // KNOWN LIMITATION (accepted; tracked as a follow-up): past the - // steady-state-full check above, over-budget sub-items are filtered - // and dropped here even in `WhenFull::Overflow`, so a non-protobuf - // overflow stage (e.g. in-memory) never gets the chance to accept - // them. This surfaces two ways: - // 1. the item is partially over-budget and `try_write_record` - // below then rejects the *remainder* for fullness — the - // overflow receives the item minus the already-dropped events; - // 2. the item is fully over-budget — `filter_unencodable` returns - // `None` and the whole item is dropped before any capacity - // check, so nothing overflows. - // Routing unencodable items by `WhenFull` (drop in Block/DropNewest, - // overflow otherwise) is a `BufferSender`-level policy decision, - // whereas filtering lives here in the backend; reconciling the two - // is deferred. The window is narrow and atypical: it requires a - // disk-v2 stage in `Overflow` mode (disk is normally the terminal - // Block stage), a non-protobuf overflow target, an over-budget - // event (>32 nesting levels), and a downstream egress that could - // actually deliver it. In other topologies these events are dropped - // a stage later regardless. + // Filtering here is unconditional and independent of current occupancy. + // Whether an unencodable item should be dropped or handed to an overflow + // stage is a `WhenFull` policy decision, so it is made in `BufferSender` + // before the item ever reaches this backend: `WhenFull::Overflow` diverts + // items failing `is_fully_encodable` straight to the overflow stage, and + // anything arriving here is therefore expected to be persistable. Keeping + // the filter unconditional means a given item is treated the same at 99% + // full as at 100% full. let pre_count = item.event_count() as u64; let pre_size = item.size_of() as u64; let Some(item) = item.filter_unencodable() else { @@ -323,18 +300,37 @@ impl BufferSender { TryWriteOutcome::Full(_) => UsageAccounting::DroppedNewest, TryWriteOutcome::Dropped => UsageAccounting::NotAccepted, }, - WhenFull::Overflow => match self.base.try_send(item).await? { - TryWriteOutcome::Written => UsageAccounting::Accepted, - TryWriteOutcome::Full(item) => { + WhenFull::Overflow => { + // An item the base stage can never encode is routed to the overflow stage + // intact, whatever the current occupancy. Deciding this here, rather than + // letting the backend filter it, is what makes the behaviour + // state-independent: previously an over-nested item was pruned while the + // disk had room and forwarded whole once the disk reported full, so the + // same item took different paths at 99% and 100%. The overflow stage may + // have no wire-format constraint at all (an in-memory stage does not), so + // pruning sub-items on its behalf would discard events it could accept. + if !item.is_fully_encodable() { self.overflow .as_mut() .unwrap_or_else(|| unreachable!("overflow must exist")) .send(item, send_reference) .await?; UsageAccounting::NotAccepted + } else { + match self.base.try_send(item).await? { + TryWriteOutcome::Written => UsageAccounting::Accepted, + TryWriteOutcome::Full(item) => { + self.overflow + .as_mut() + .unwrap_or_else(|| unreachable!("overflow must exist")) + .send(item, send_reference) + .await?; + UsageAccounting::NotAccepted + } + TryWriteOutcome::Dropped => UsageAccounting::NotAccepted, + } } - TryWriteOutcome::Dropped => UsageAccounting::NotAccepted, - }, + } }; // Backend filter drops are accounted directly through the backend's own diff --git a/lib/vector-buffers/src/variants/disk_v2/tests/filter_metrics.rs b/lib/vector-buffers/src/variants/disk_v2/tests/filter_metrics.rs index cc37762d0f9d6..efc5db9c470a4 100644 --- a/lib/vector-buffers/src/variants/disk_v2/tests/filter_metrics.rs +++ b/lib/vector-buffers/src/variants/disk_v2/tests/filter_metrics.rs @@ -6,20 +6,22 @@ //! queued on disk. Without that, a single rejected event makes the buffer report //! one queued event forever. -use std::{error, fmt}; +use std::{error, fmt, num::NonZeroUsize}; use bytes::{Buf, BufMut}; use vector_common::{ byte_size_of::ByteSizeOf, - finalization::{AddBatchNotifier, BatchNotifier}, + finalization::{ + AddBatchNotifier, BatchNotifier, EventFinalizers, Finalizable, MergeFinalizable, + }, }; use super::create_default_buffer_v2_with_usage; use crate::{ - Bufferable, EventCount, WhenFull, + Bufferable, EventCount, MemoryBufferSize, WhenFull, encoding::FixedEncodable, test::{install_tracing_helpers, with_temp_dir}, - topology::channel::{BufferSender, SenderAdapter}, + topology::channel::{BufferSender, SenderAdapter, limited}, }; /// A bufferable carrying a self-declared `event_count` of `events`, whose @@ -37,6 +39,22 @@ impl AddBatchNotifier for FilterableBatch { drop(batch); } } + +// This fixture carries no finalizers -- it exists only to pin event counts either side of +// the filter -- so both impls are inert, matching `add_batch_notifier` above. They are +// required because `Bufferable` gained a `GroupedFinalizable` bound, which is satisfied for +// any `MergeFinalizable` via a blanket impl. +impl Finalizable for FilterableBatch { + fn take_finalizers(&mut self) -> EventFinalizers { + EventFinalizers::default() + } +} + +impl MergeFinalizable for FilterableBatch { + fn merge_finalizers(&mut self, finalizers: EventFinalizers) { + drop(finalizers); + } +} impl ByteSizeOf for FilterableBatch { fn allocated_bytes(&self) -> usize { 0 @@ -80,6 +98,10 @@ impl FixedEncodable for FilterableBatch { } impl Bufferable for FilterableBatch { + fn is_fully_encodable(&self) -> bool { + self.post_filter == self.events + } + fn filter_unencodable(self) -> Option { if self.post_filter == 0 { None @@ -170,11 +192,121 @@ async fn filter_drops_are_reported_as_unintentional_buffer_drops() { .await; } -// Note: A regression test that exercises the "full disk hands item to overflow -// unfiltered" path is not included here because reliably driving the disk-v2 -// writer's `is_buffer_full()` to `true` under the minimum-size config takes -// careful tuning of record/buffer sizes (the writer's `can_write_record` check -// generally short-circuits writes *before* `total_buffer_size` reaches -// `max_buffer_size`). The fix in `SenderAdapter::try_send` is a single -// `is_buffer_full()` short-circuit before the filter runs; the existing -// disk-v2 tests cover the full-buffer behaviour at the writer level. +/// Under `WhenFull::Overflow`, an item the base stage cannot encode must reach the +/// overflow stage *intact* while the base stage still has room. +/// +/// This is the near-full half of the state-independence guarantee: the routing decision +/// is made from the item alone, so it does not matter how full the base stage is. +#[tokio::test] +async fn unencodable_item_overflows_intact_when_base_has_room() { + let _a = install_tracing_helpers(); + + with_temp_dir(|dir| { + let data_dir = dir.to_path_buf(); + + async move { + let (writer, _reader, _ledger, _usage) = + create_default_buffer_v2_with_usage::<_, FilterableBatch>(data_dir).await; + + let (overflow_tx, mut overflow_rx) = limited( + MemoryBufferSize::MaxEvents(NonZeroUsize::new(100).unwrap()), + None, + None, + ); + let mut sender = BufferSender::with_overflow( + SenderAdapter::from(writer), + BufferSender::new(SenderAdapter::from(overflow_tx), WhenFull::Block), + ); + + // The disk stage is empty, so it has ample room. The item is wholly + // unencodable, so it must still be handed to the overflow stage rather than + // filtered away. + sender + .send( + FilterableBatch { + events: 5, + post_filter: 0, + }, + None, + ) + .await + .expect("send should succeed"); + + let received = overflow_rx.next().await.expect("item must reach overflow"); + assert_eq!( + received, + FilterableBatch { + events: 5, + post_filter: 0, + }, + "overflow must receive the item intact, with no sub-items pruned", + ); + } + }) + .await; +} + +/// The already-full half of the same guarantee: an unencodable item reaches the overflow +/// stage intact when the base stage is at capacity, and by the same route. +/// +/// The base here is an in-memory stage rather than disk, because it can be driven to a +/// known-full state deterministically. Reliably forcing disk-v2's `is_buffer_full()` to +/// `true` under the minimum-size config requires careful record/buffer size tuning, since +/// `can_write_record` generally short-circuits writes before `total_buffer_size` reaches +/// `max_buffer_size`. That substitution is sound for this property: the unencodable-item +/// decision is taken in `BufferSender` from `Bufferable::is_fully_encodable` before any +/// backend is consulted, so the base stage's type and occupancy are both immaterial. That +/// is precisely the invariant being asserted. +#[tokio::test] +async fn unencodable_item_overflows_intact_when_base_is_full() { + let _a = install_tracing_helpers(); + + let (base_tx, _base_rx) = limited::( + MemoryBufferSize::MaxEvents(NonZeroUsize::new(1).unwrap()), + None, + None, + ); + let (overflow_tx, mut overflow_rx) = limited( + MemoryBufferSize::MaxEvents(NonZeroUsize::new(100).unwrap()), + None, + None, + ); + + let mut sender = BufferSender::with_overflow( + SenderAdapter::from(base_tx), + BufferSender::new(SenderAdapter::from(overflow_tx), WhenFull::Block), + ); + + // Fill the base stage so any further send would be rejected for fullness. + sender + .send( + FilterableBatch { + events: 1, + post_filter: 1, + }, + None, + ) + .await + .expect("first send should occupy the base stage"); + + sender + .send( + FilterableBatch { + events: 5, + post_filter: 0, + }, + None, + ) + .await + .expect("send should succeed"); + + let received = overflow_rx.next().await.expect("item must reach overflow"); + assert_eq!( + received, + FilterableBatch { + events: 5, + post_filter: 0, + }, + "a full base stage must not change how an unencodable item is routed", + ); +} diff --git a/lib/vector-buffers/src/variants/disk_v2/writer.rs b/lib/vector-buffers/src/variants/disk_v2/writer.rs index 017d0e88631bb..e4536ad70644f 100644 --- a/lib/vector-buffers/src/variants/disk_v2/writer.rs +++ b/lib/vector-buffers/src/variants/disk_v2/writer.rs @@ -1455,12 +1455,7 @@ where } /// Returns whether the buffer is currently at or above its configured size limit. - /// - /// Exposed so callers can decide what to do with an item *before* it is encoded — - /// notably, an over-budget `EventArray` headed for a `WhenFull::Overflow` topology - /// should be handed to the overflow stage unfiltered rather than have its sub-items - /// pruned for a write that will never happen. - pub(crate) fn is_buffer_full(&self) -> bool { + fn is_buffer_full(&self) -> bool { let total_buffer_size = self.ledger.get_total_buffer_size() + self.unflushed_bytes; let max_buffer_size = self.config.max_buffer_size; total_buffer_size >= max_buffer_size diff --git a/lib/vector-core/src/event/ser.rs b/lib/vector-core/src/event/ser.rs index f8ef2ee5c4017..9842dda171a5d 100644 --- a/lib/vector-core/src/event/ser.rs +++ b/lib/vector-core/src/event/ser.rs @@ -309,6 +309,12 @@ impl Encodable for EventArray { } impl Bufferable for EventArray { + /// Reuses the same budget walk as the encode-time gate, so the routing decision and + /// the eventual encode can never disagree about what is persistable. + fn is_fully_encodable(&self) -> bool { + check_event_array_nesting_cost(self).is_ok() + } + fn filter_unencodable(self) -> Option { let exceeds = |value: &Value, budget: usize| check_value_nesting_cost(value, 0, budget).is_err(); From 23da462d6411d296b059361fa5572dc98ea450d1 Mon Sep 17 00:00:00 2001 From: Erica <132393634+EricaJ6@users.noreply.github.com> Date: Thu, 13 Aug 2026 08:59:05 -0700 Subject: [PATCH 46/49] Update changelog.d/protobuf_nesting_depth_limit.fix.md Co-authored-by: Pavlos Rontidis --- changelog.d/protobuf_nesting_depth_limit.fix.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/protobuf_nesting_depth_limit.fix.md b/changelog.d/protobuf_nesting_depth_limit.fix.md index 553462b1921ee..2a56d7ed7a5c2 100644 --- a/changelog.d/protobuf_nesting_depth_limit.fix.md +++ b/changelog.d/protobuf_nesting_depth_limit.fix.md @@ -1,3 +1,3 @@ Fixed unrecoverable disk buffer corruption and vector-to-vector retry loops caused by event data or metadata that protobuf could encode but prost could not decode. Vector now drops only protobuf-unsafe nested payloads before disk buffer or `vector` sink gRPC encoding, while preserving nested shapes that prost can safely decode. Under `when_full = "overflow"`, an event the buffer cannot encode is passed to the overflow stage intact rather than dropped, and does so regardless of how full the buffer currently is. -authors: connoryy, EricaJ6, jonodera97 +authors: connoryy, EricaJ6, jonodera97, ganelo From e47d59861c55262f0538108f1d71c4897e8e9f90 Mon Sep 17 00:00:00 2001 From: Erica <132393634+EricaJ6@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:47:19 -0700 Subject: [PATCH 47/49] fix(buffers): gate overflow diversion on the base stage's encoding constraint --- .../protobuf_nesting_depth_limit.fix.md | 2 +- .../src/topology/channel/sender.rs | 27 ++++++-- .../variants/disk_v2/tests/filter_metrics.rs | 64 ++++++++++++++++++- .../src/event/test/serialization.rs | 35 +++++----- src/sinks/vector/sink.rs | 5 +- 5 files changed, 110 insertions(+), 23 deletions(-) diff --git a/changelog.d/protobuf_nesting_depth_limit.fix.md b/changelog.d/protobuf_nesting_depth_limit.fix.md index 2a56d7ed7a5c2..a4f0ea7d26c47 100644 --- a/changelog.d/protobuf_nesting_depth_limit.fix.md +++ b/changelog.d/protobuf_nesting_depth_limit.fix.md @@ -1,3 +1,3 @@ Fixed unrecoverable disk buffer corruption and vector-to-vector retry loops caused by event data or metadata that protobuf could encode but prost could not decode. Vector now drops only protobuf-unsafe nested payloads before disk buffer or `vector` sink gRPC encoding, while preserving nested shapes that prost can safely decode. Under `when_full = "overflow"`, an event the buffer cannot encode is passed to the overflow stage intact rather than dropped, and does so regardless of how full the buffer currently is. -authors: connoryy, EricaJ6, jonodera97, ganelo +authors: connoryy ganelo EricaJ6 jonodera97 diff --git a/lib/vector-buffers/src/topology/channel/sender.rs b/lib/vector-buffers/src/topology/channel/sender.rs index 7914cd4b257b7..1735cee9785ae 100644 --- a/lib/vector-buffers/src/topology/channel/sender.rs +++ b/lib/vector-buffers/src/topology/channel/sender.rs @@ -43,6 +43,22 @@ impl SenderAdapter where T: Bufferable, { + /// Whether this backend can only persist items satisfying [`Bufferable::is_fully_encodable`]. + /// + /// In-memory stages hold the in-memory representation and have no wire format, so they can + /// accept any item regardless of its nesting depth. Disk stages encode to protobuf on write + /// and cannot. + /// + /// Callers use this to avoid assuming a stage is constrained: an item that one stage cannot + /// encode may be perfectly storable by another, so the check must be asked of the specific + /// stage rather than applied to every topology. + pub(crate) fn requires_encodable_items(&self) -> bool { + match self { + Self::InMemory(_) => false, + Self::DiskV2(_) => true, + } + } + pub(crate) async fn send(&mut self, item: T) -> crate::Result> { match self { Self::InMemory(tx) => tx @@ -306,10 +322,13 @@ impl BufferSender { // letting the backend filter it, is what makes the behaviour // state-independent: previously an over-nested item was pruned while the // disk had room and forwarded whole once the disk reported full, so the - // same item took different paths at 99% and 100%. The overflow stage may - // have no wire-format constraint at all (an in-memory stage does not), so - // pruning sub-items on its behalf would discard events it could accept. - if !item.is_fully_encodable() { + // same item took different paths at 99% and 100%. + // + // The check is gated on the base stage actually having a wire-format + // constraint. A memory stage overflowing to disk can store an over-nested + // item perfectly well, so diverting it past memory would send an item the + // base could have kept to a stage that must drop it. + if self.base.requires_encodable_items() && !item.is_fully_encodable() { self.overflow .as_mut() .unwrap_or_else(|| unreachable!("overflow must exist")) diff --git a/lib/vector-buffers/src/variants/disk_v2/tests/filter_metrics.rs b/lib/vector-buffers/src/variants/disk_v2/tests/filter_metrics.rs index efc5db9c470a4..6440314193706 100644 --- a/lib/vector-buffers/src/variants/disk_v2/tests/filter_metrics.rs +++ b/lib/vector-buffers/src/variants/disk_v2/tests/filter_metrics.rs @@ -6,9 +6,10 @@ //! queued on disk. Without that, a single rejected event makes the buffer report //! one queued event forever. -use std::{error, fmt, num::NonZeroUsize}; +use std::{error, fmt, num::NonZeroUsize, time::Duration}; use bytes::{Buf, BufMut}; +use tokio::time::timeout; use vector_common::{ byte_size_of::ByteSizeOf, finalization::{ @@ -310,3 +311,64 @@ async fn unencodable_item_overflows_intact_when_base_is_full() { "a full base stage must not change how an unencodable item is routed", ); } + +/// A base stage without a wire-format constraint must keep an unencodable item rather than +/// pass it to the overflow stage. +/// +/// The encodability check is a property of the *base* stage, not of the item alone. In a +/// `memory -> disk` overflow topology the memory stage can hold an arbitrarily nested item +/// safely, so diverting it past memory would hand an item the base could have kept to a +/// stage that has no choice but to drop it. This is the mirror image of the +/// `disk -> memory` cases above and guards against reintroducing that assumption. +#[tokio::test] +async fn unencodable_item_stays_in_base_when_base_has_no_encoding_constraint() { + let _a = install_tracing_helpers(); + + let (base_tx, mut base_rx) = limited::( + MemoryBufferSize::MaxEvents(NonZeroUsize::new(100).unwrap()), + None, + None, + ); + let (overflow_tx, mut overflow_rx) = limited( + MemoryBufferSize::MaxEvents(NonZeroUsize::new(100).unwrap()), + None, + None, + ); + + let mut sender = BufferSender::with_overflow( + SenderAdapter::from(base_tx), + BufferSender::new(SenderAdapter::from(overflow_tx), WhenFull::Block), + ); + + // The base is in-memory and empty, so it can hold this item despite the item being + // unencodable for a protobuf-backed stage. + sender + .send( + FilterableBatch { + events: 5, + post_filter: 0, + }, + None, + ) + .await + .expect("send should succeed"); + + let received = timeout(Duration::from_secs(5), base_rx.next()) + .await + .expect("item must stay in the base stage rather than be diverted to overflow") + .expect("base stage should yield the item"); + assert_eq!( + received, + FilterableBatch { + events: 5, + post_filter: 0, + }, + "an unconstrained base stage must keep the item intact", + ); + assert!( + timeout(Duration::from_millis(50), overflow_rx.next()) + .await + .is_err(), + "the overflow stage must not be involved when the base can hold the item", + ); +} diff --git a/lib/vector-core/src/event/test/serialization.rs b/lib/vector-core/src/event/test/serialization.rs index ec45880016239..7140b22702add 100644 --- a/lib/vector-core/src/event/test/serialization.rs +++ b/lib/vector-core/src/event/test/serialization.rs @@ -187,7 +187,7 @@ fn ts_leaf() -> Value { /// Create a [`LogEvent`] with event data at `value_depth` and metadata at `metadata_depth`. fn create_saturated_log(value_depth: usize, metadata_depth: usize) -> LogEvent { let mut event = LogEvent::default(); - event.insert("data", create_nested_value(value_depth - 1)); + event.insert(event_path!("data"), create_nested_value(value_depth - 1)); *event.metadata_mut().value_mut() = create_nested_value(metadata_depth); event } @@ -195,7 +195,7 @@ fn create_saturated_log(value_depth: usize, metadata_depth: usize) -> LogEvent { /// Create a [`TraceEvent`] with event data at `value_depth` and metadata at `metadata_depth`. fn create_saturated_trace(value_depth: usize, metadata_depth: usize) -> TraceEvent { let mut trace = TraceEvent::default(); - trace.insert("data", create_nested_value(value_depth - 1)); + trace.insert(event_path!("data"), create_nested_value(value_depth - 1)); *trace.metadata_mut().value_mut() = create_nested_value(metadata_depth); trace } @@ -383,7 +383,7 @@ fn nesting_gate_rejects_above_max_depth() { fn per_path_boundaries() { let roundtrip_value = |value: Value| -> bool { let mut event = LogEvent::default(); - event.insert("data", value); + event.insert(event_path!("data"), value); let array = EventArray::Logs(LogArray::from(vec![event])); let proto_array = proto::EventArray::from(array); let mut buf = BytesMut::with_capacity(65536); @@ -455,7 +455,7 @@ fn nesting_gate_accepts_deep_array_nesting() { // An array depth 40 = 80 frames, comfortably under the 99-frame value budget but well // over the 33-depth limit the old uniform check would have applied. let mut event = LogEvent::default(); - event.insert("data", create_nested_array(40)); + event.insert(event_path!("data"), create_nested_array(40)); let array = EventArray::Logs(LogArray::from(vec![event])); let mut buf = BytesMut::with_capacity(65536); assert!( @@ -518,7 +518,7 @@ fn nesting_gate_handles_mixed_array_object_nesting() { fn nesting_gate_rejects_timestamp_leaf_at_max_object_depth() { let roundtrip_log = |value: Value| -> bool { let mut event = LogEvent::default(); - event.insert("data", value); + event.insert(event_path!("data"), value); let array = EventArray::Logs(LogArray::from(vec![event])); let proto_array = proto::EventArray::from(array); let mut buf = BytesMut::with_capacity(65536); @@ -544,7 +544,7 @@ fn nesting_gate_rejects_timestamp_leaf_at_max_object_depth() { ); let mut event = LogEvent::default(); - event.insert("data", event_data_ts); + event.insert(event_path!("data"), event_data_ts); let array = EventArray::Logs(LogArray::from(vec![event])); let mut buf = BytesMut::with_capacity(65536); assert!( @@ -583,7 +583,7 @@ fn nesting_gate_accepts_timestamp_leaf_below_max_object_depth() { // Event data: depth (max-1) Object + Timestamp leaf = (max-1)*3 + 1 frames. let mut event = LogEvent::default(); event.insert( - "data", + event_path!("data"), create_nested_value_with_leaf(MAX_OBJECT_DEPTH_VALUE - 2, ts_leaf()), ); let array = EventArray::Logs(LogArray::from(vec![event])); @@ -623,12 +623,15 @@ fn nesting_gate_accepts_timestamp_leaf_below_max_object_depth() { fn filter_unencodable_drops_only_over_budget_events() { let good = || { let mut event = LogEvent::default(); - event.insert("data", "ok"); + event.insert(event_path!("data"), "ok"); event }; let bad = || { let mut event = LogEvent::default(); - event.insert("data", create_nested_value(MAX_OBJECT_DEPTH_VALUE)); + event.insert( + event_path!("data"), + create_nested_value(MAX_OBJECT_DEPTH_VALUE), + ); event }; @@ -645,7 +648,9 @@ fn filter_unencodable_drops_only_over_budget_events() { }; for log in &surviving { assert_eq!( - log.value().get("data").and_then(|v| v.as_bytes()), + log.value() + .get(vrl::path!("data")) + .and_then(|v| v.as_bytes()), Some(&bytes::Bytes::from_static(b"ok")), "only good events should remain", ); @@ -661,7 +666,7 @@ fn event_exceeds_max_nesting_cost_charges_timestamp_leaf() { let log_at_max_with_ts = { let mut event = LogEvent::default(); event.insert( - "data", + event_path!("data"), create_nested_value_with_leaf(MAX_OBJECT_DEPTH_VALUE - 1, ts_leaf()), ); Event::Log(event) @@ -674,7 +679,7 @@ fn event_exceeds_max_nesting_cost_charges_timestamp_leaf() { let trace_at_max_with_ts = { let mut trace = TraceEvent::default(); trace.insert( - "data", + event_path!("data"), create_nested_value_with_leaf(MAX_OBJECT_DEPTH_VALUE - 1, ts_leaf()), ); Event::Trace(trace) @@ -703,7 +708,7 @@ fn event_exceeds_max_nesting_cost_charges_timestamp_leaf() { let log_below_max_with_ts = { let mut event = LogEvent::default(); event.insert( - "data", + event_path!("data"), create_nested_value_with_leaf(MAX_OBJECT_DEPTH_VALUE - 2, ts_leaf()), ); Event::Log(event) @@ -736,13 +741,13 @@ fn check_value_nesting_cost_charges_timestamp_leaf() { #[test] fn nesting_gate_accepts_flat_events() { let mut log = LogEvent::from("hello world"); - log.insert("foo", "bar"); + log.insert(event_path!("foo"), "bar"); let events = EventArray::Logs(LogArray::from(vec![log])); let mut buf = BytesMut::with_capacity(1024); assert!(events.encode(&mut buf).is_ok()); let mut trace = TraceEvent::default(); - trace.insert("foo", "bar"); + trace.insert(event_path!("foo"), "bar"); let events = EventArray::Traces(TraceArray::from(vec![trace])); let mut buf = BytesMut::with_capacity(1024); assert!(events.encode(&mut buf).is_ok()); diff --git a/src/sinks/vector/sink.rs b/src/sinks/vector/sink.rs index 0f821c6dabbd4..23be0fe96bed5 100644 --- a/src/sinks/vector/sink.rs +++ b/src/sinks/vector/sink.rs @@ -158,6 +158,7 @@ mod tests { Event, LogEvent, MAX_METADATA_VALUE_NESTING_FRAMES, MAX_VALUE_NESTING_FRAMES, ObjectMap, Value, event_exceeds_max_nesting_cost, }; + use vrl::event_path; use super::EventWrapper; use crate::proto::vector as proto_vector; @@ -184,7 +185,7 @@ mod tests { // `log.value()` (one outer Object from the inserted key), cost = 99 = // MAX_VALUE_NESTING_FRAMES. let mut log = LogEvent::default(); - log.insert("data", build_nested_value(32)); + log.insert(event_path!("data"), build_nested_value(32)); let event = Event::Log(log); assert!( event_exceeds_max_nesting_cost(&event).is_none(), @@ -211,7 +212,7 @@ mod tests { fn push_events_request_decode_one_past_value_budget_fails() { // 33 nested objects under "data" → 34 effective object levels, cost 102. let mut log = LogEvent::default(); - log.insert("data", build_nested_value(33)); + log.insert(event_path!("data"), build_nested_value(33)); let event = Event::Log(log); let request = proto_vector::PushEventsRequest { From f7ca90aea4bcd0e1561824725aa515fbc4ecddd2 Mon Sep 17 00:00:00 2001 From: Pavlos Rontidis Date: Mon, 17 Aug 2026 10:27:15 -0400 Subject: [PATCH 48/49] fix(buffers): use a common protobuf nesting budget --- lib/vector-core/src/event/mod.rs | 4 +- lib/vector-core/src/event/ser.rs | 110 +++---- .../src/event/test/serialization.rs | 297 +++++++++--------- src/sinks/vector/sink.rs | 39 ++- 4 files changed, 201 insertions(+), 249 deletions(-) diff --git a/lib/vector-core/src/event/mod.rs b/lib/vector-core/src/event/mod.rs index 54dd2bf7bb875..2348c787f2069 100644 --- a/lib/vector-core/src/event/mod.rs +++ b/lib/vector-core/src/event/mod.rs @@ -10,9 +10,7 @@ pub use log_event::LogEvent; pub use metadata::{DatadogMetricOriginMetadata, EventMetadata, Secrets, WithMetadata}; pub use metric::{Metric, MetricKind, MetricTags, MetricValue, StatisticKind}; pub use r#ref::{EventMutRef, EventRef}; -pub use ser::{ - MAX_METADATA_VALUE_NESTING_FRAMES, MAX_VALUE_NESTING_FRAMES, event_exceeds_max_nesting_cost, -}; +pub use ser::{MAX_VALUE_NESTING_FRAMES, event_exceeds_max_nesting_cost}; use serde::{Deserialize, Serialize}; pub use trace::TraceEvent; use vector_buffers::EventCount; diff --git a/lib/vector-core/src/event/ser.rs b/lib/vector-core/src/event/ser.rs index 9842dda171a5d..beddb0abe1b01 100644 --- a/lib/vector-core/src/event/ser.rs +++ b/lib/vector-core/src/event/ser.rs @@ -27,33 +27,23 @@ pub(crate) const ARRAY_FRAME_COST: usize = 2; /// /// Unlike other scalar variants, `Value::Timestamp` is encoded as a nested /// `google.protobuf.Timestamp` message, so decoding it consumes one additional frame -/// beyond the enclosing `Value`. Without this cost a timestamp leaf at the deepest -/// allowed branch (event-data object depth 33 or metadata object depth 32) sneaks past -/// the gate and trips prost's recursion limit on decode. +/// beyond the enclosing `Value`. Without this cost, a timestamp leaf under 32 object +/// levels would sneak past the gate at cost 96 and trip prost's recursion limit on +/// decode at cost 97. pub(crate) const TIMESTAMP_FRAME_COST: usize = 1; -/// Maximum prost recursion frame cost for event data values (`Log.fields`, `Trace.fields`). +/// Maximum prost recursion frame cost accepted for any arbitrary [`Value`]. /// /// Prost enforces a decode recursion limit of 100 (no limit on encode). Each nesting level /// consumes 3 frames for [`Value::Object`], 2 for [`Value::Array`], or 1 for a /// [`Value::Timestamp`] leaf, plus a fixed overhead for the proto wrappers outside the -/// Value tree. The event data path (`EventArray` → `*Array` → Event → fields) has fewer -/// wrappers than the metadata path, allowing a higher frame budget. +/// Value tree. /// -/// Object-only depth 33 (cost 99) roundtrips; depth 34 (cost 102) fails decode. Array-only -/// nesting is correspondingly looser: depth 49 (cost 98) is the highest that fits. A -/// `Value::Timestamp` leaf added at depth 33 raises the cost to 100 and fails decode. -pub const MAX_VALUE_NESTING_FRAMES: usize = 99; - -/// Maximum prost recursion frame cost for event metadata values (via `metadata_full`). -/// -/// The metadata path (`EventArray` → `*Array` → Event → `Metadata` → Value) has one more -/// proto wrapper message than the event data path due to the `Metadata` message, reducing -/// the safe budget by 3 frames. -/// -/// Object-only depth 32 (cost 96) roundtrips; depth 33 (cost 99) fails decode. A -/// `Value::Timestamp` leaf added at depth 32 raises the cost to 97 and fails decode. -pub const MAX_METADATA_VALUE_NESTING_FRAMES: usize = 96; +/// Some protobuf paths (`Log.fields` and `Trace.fields`) can carry 99 frames, but the +/// `Log.value` and metadata paths are only safe through 96. We use that highest common +/// safe limit for every value so validation does not depend on its event type, root type, +/// or destination protobuf field. +pub const MAX_VALUE_NESTING_FRAMES: usize = 96; /// Walks a [`Value`] tree accumulating prost recursion frame cost, returning /// `Err(over_budget_cost)` as soon as any branch exceeds `budget`. @@ -103,72 +93,54 @@ pub(crate) fn check_value_nesting_cost( /// Returns `Some((cost, budget))` identifying the path that violated its budget, or `None` /// if the event is within bounds. /// -/// Event data values (Log.fields, Trace.fields) are checked against -/// [`MAX_VALUE_NESTING_FRAMES`], while metadata values are checked against the stricter -/// [`MAX_METADATA_VALUE_NESTING_FRAMES`] because the `Metadata` proto message adds an -/// extra wrapper layer. +/// Every arbitrary value is checked against [`MAX_VALUE_NESTING_FRAMES`]. /// /// For metrics, only metadata is checked since metric values have a fixed structure. pub fn event_exceeds_max_nesting_cost(event: &Event) -> Option<(usize, usize)> { - match event { - Event::Log(log) => check_value_nesting_cost(log.value(), 0, MAX_VALUE_NESTING_FRAMES) + let check = |value: &Value| { + check_value_nesting_cost(value, 0, MAX_VALUE_NESTING_FRAMES) .map_err(|cost| (cost, MAX_VALUE_NESTING_FRAMES)) - .and_then(|()| { - check_value_nesting_cost( - log.metadata().value(), - 0, - MAX_METADATA_VALUE_NESTING_FRAMES, - ) - .map_err(|cost| (cost, MAX_METADATA_VALUE_NESTING_FRAMES)) - }) + }; + match event { + Event::Log(log) => check(log.value()) + .and_then(|()| check(log.metadata().value())) .err(), - Event::Trace(trace) => check_value_nesting_cost(trace.value(), 0, MAX_VALUE_NESTING_FRAMES) - .map_err(|cost| (cost, MAX_VALUE_NESTING_FRAMES)) - .and_then(|()| { - check_value_nesting_cost( - trace.metadata().value(), - 0, - MAX_METADATA_VALUE_NESTING_FRAMES, - ) - .map_err(|cost| (cost, MAX_METADATA_VALUE_NESTING_FRAMES)) - }) + Event::Trace(trace) => check(trace.value()) + .and_then(|()| check(trace.metadata().value())) .err(), - Event::Metric(metric) => check_value_nesting_cost( - metric.metadata().value(), - 0, - MAX_METADATA_VALUE_NESTING_FRAMES, - ) - .map_err(|cost| (cost, MAX_METADATA_VALUE_NESTING_FRAMES)) - .err(), + Event::Metric(metric) => check(metric.metadata().value()).err(), } } /// Checks all events in an `EventArray` for nesting cost violations. /// -/// Event data is checked against [`MAX_VALUE_NESTING_FRAMES`] and metadata against -/// [`MAX_METADATA_VALUE_NESTING_FRAMES`]. For metrics, only metadata is checked since -/// metric values have a fixed structure. +/// Every arbitrary value is checked against [`MAX_VALUE_NESTING_FRAMES`]. For metrics, +/// only metadata is checked since metric values have a fixed structure. fn check_event_array_nesting_cost(events: &EventArray) -> Result<(), EncodeError> { - let check = |value: &Value, budget: usize| { - check_value_nesting_cost(value, 0, budget) - .map_err(|cost| EncodeError::NestingTooDeep { cost, budget }) + let check = |value: &Value| { + check_value_nesting_cost(value, 0, MAX_VALUE_NESTING_FRAMES).map_err(|cost| { + EncodeError::NestingTooDeep { + cost, + budget: MAX_VALUE_NESTING_FRAMES, + } + }) }; match events { EventArray::Logs(logs) => { for log in logs { - check(log.value(), MAX_VALUE_NESTING_FRAMES)?; - check(log.metadata().value(), MAX_METADATA_VALUE_NESTING_FRAMES)?; + check(log.value())?; + check(log.metadata().value())?; } } EventArray::Traces(traces) => { for trace in traces { - check(trace.value(), MAX_VALUE_NESTING_FRAMES)?; - check(trace.metadata().value(), MAX_METADATA_VALUE_NESTING_FRAMES)?; + check(trace.value())?; + check(trace.metadata().value())?; } } EventArray::Metrics(metrics) => { for metric in metrics { - check(metric.metadata().value(), MAX_METADATA_VALUE_NESTING_FRAMES)?; + check(metric.metadata().value())?; } } } @@ -265,8 +237,7 @@ impl Encodable for EventArray { /// # Errors /// /// Returns `EncodeError::NestingTooDeep` if any contained event's value or metadata - /// exceeds the per-path frame budget ([`MAX_VALUE_NESTING_FRAMES`] / - /// [`MAX_METADATA_VALUE_NESTING_FRAMES`]). This is **all-or-nothing**: a single + /// exceeds [`MAX_VALUE_NESTING_FRAMES`]. This is **all-or-nothing**: a single /// over-budget event fails the entire batch, because a partially-encoded /// `EventArray` reaching disk would trip prost's recursion limit on decode and /// corrupt the buffer. @@ -317,13 +288,12 @@ impl Bufferable for EventArray { fn filter_unencodable(self) -> Option { let exceeds = - |value: &Value, budget: usize| check_value_nesting_cost(value, 0, budget).is_err(); + |value: &Value| check_value_nesting_cost(value, 0, MAX_VALUE_NESTING_FRAMES).is_err(); let mut dropped = 0; let filtered = match self { EventArray::Logs(mut logs) => { logs.retain(|log| { - let too_deep = exceeds(log.value(), MAX_VALUE_NESTING_FRAMES) - || exceeds(log.metadata().value(), MAX_METADATA_VALUE_NESTING_FRAMES); + let too_deep = exceeds(log.value()) || exceeds(log.metadata().value()); if too_deep { log.metadata().update_status(EventStatus::Rejected); dropped += 1; @@ -334,8 +304,7 @@ impl Bufferable for EventArray { } EventArray::Traces(mut traces) => { traces.retain(|trace| { - let too_deep = exceeds(trace.value(), MAX_VALUE_NESTING_FRAMES) - || exceeds(trace.metadata().value(), MAX_METADATA_VALUE_NESTING_FRAMES); + let too_deep = exceeds(trace.value()) || exceeds(trace.metadata().value()); if too_deep { trace.metadata().update_status(EventStatus::Rejected); dropped += 1; @@ -346,8 +315,7 @@ impl Bufferable for EventArray { } EventArray::Metrics(mut metrics) => { metrics.retain(|metric| { - let too_deep = - exceeds(metric.metadata().value(), MAX_METADATA_VALUE_NESTING_FRAMES); + let too_deep = exceeds(metric.metadata().value()); if too_deep { metric.metadata().update_status(EventStatus::Rejected); dropped += 1; diff --git a/lib/vector-core/src/event/test/serialization.rs b/lib/vector-core/src/event/test/serialization.rs index 7140b22702add..bc875032a887c 100644 --- a/lib/vector-core/src/event/test/serialization.rs +++ b/lib/vector-core/src/event/test/serialization.rs @@ -11,8 +11,8 @@ use vrl::event_path; use crate::event::event_exceeds_max_nesting_cost; use crate::event::ser::{ - ARRAY_FRAME_COST, MAX_METADATA_VALUE_NESTING_FRAMES, MAX_VALUE_NESTING_FRAMES, - OBJECT_FRAME_COST, TIMESTAMP_FRAME_COST, check_value_nesting_cost, + ARRAY_FRAME_COST, MAX_VALUE_NESTING_FRAMES, OBJECT_FRAME_COST, TIMESTAMP_FRAME_COST, + check_value_nesting_cost, }; use vector_buffers::Bufferable; @@ -116,30 +116,26 @@ fn type_serialization() { // - `Value::Object` level: Value + ValueMap + map_entry = 3 frames // - `Value::Array` level: Value + ValueArray = 2 frames // -// Each encoding path has a fixed proto-wrapper overhead before the Value tree starts: +// Encoding paths have different fixed proto-wrapper overhead before the Value tree: // -// - Event data path (Log.fields, Trace.fields): frame budget MAX_VALUE_NESTING_FRAMES (99) -// - Metadata path (metadata_full): frame budget MAX_METADATA_VALUE_NESTING_FRAMES (96) +// - `Log.fields` and `Trace.fields` can carry 99 Value frames. +// - `Log.value` and metadata can carry 96 Value frames. // -// The `per_path_boundaries` test verifies both budgets empirically via prost roundtrip. +// The gate uses the highest common safe limit, MAX_VALUE_NESTING_FRAMES (96), for every +// arbitrary Value. The boundary tests verify both that common limit and the extra +// headroom on the wider wire paths. // -// The saturated-event tests create events with ALL Value-carrying fields at their -// respective max frame cost simultaneously. The proto conversion code populates every +// The saturated-event tests create events with ALL Value-carrying fields at the common +// max frame cost 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. -/// Maximum number of object-only nesting levels that fit the event-data frame budget. +/// Maximum number of object-only nesting levels that fit the common Value budget. const MAX_OBJECT_DEPTH_VALUE: usize = MAX_VALUE_NESTING_FRAMES / OBJECT_FRAME_COST; -/// Maximum number of object-only nesting levels that fit the metadata frame budget. -const MAX_OBJECT_DEPTH_METADATA: usize = MAX_METADATA_VALUE_NESTING_FRAMES / OBJECT_FRAME_COST; - -/// Maximum number of array-only nesting levels that fit the event-data frame budget. +/// Maximum number of array-only nesting levels that fit the common Value budget. const MAX_ARRAY_DEPTH_VALUE: usize = MAX_VALUE_NESTING_FRAMES / ARRAY_FRAME_COST; -/// Maximum number of array-only nesting levels that fit the metadata frame budget. -const MAX_ARRAY_DEPTH_METADATA: usize = MAX_METADATA_VALUE_NESTING_FRAMES / ARRAY_FRAME_COST; - /// Creates a Value with the specified number of nested Object wrapping levels. /// /// Returns a Value that is `wrapping_levels` nested Objects deep, with a string leaf. @@ -184,115 +180,91 @@ fn ts_leaf() -> Value { ) } -/// Create a [`LogEvent`] with event data at `value_depth` and metadata at `metadata_depth`. -fn create_saturated_log(value_depth: usize, metadata_depth: usize) -> LogEvent { +/// Create a [`LogEvent`] with every arbitrary Value at `value_depth`. +fn create_saturated_log(value_depth: usize) -> LogEvent { let mut event = LogEvent::default(); event.insert(event_path!("data"), create_nested_value(value_depth - 1)); - *event.metadata_mut().value_mut() = create_nested_value(metadata_depth); + *event.metadata_mut().value_mut() = create_nested_value(value_depth); event } -/// Create a [`TraceEvent`] with event data at `value_depth` and metadata at `metadata_depth`. -fn create_saturated_trace(value_depth: usize, metadata_depth: usize) -> TraceEvent { +/// Create a [`TraceEvent`] with every arbitrary Value at `value_depth`. +fn create_saturated_trace(value_depth: usize) -> TraceEvent { let mut trace = TraceEvent::default(); trace.insert(event_path!("data"), create_nested_value(value_depth - 1)); - *trace.metadata_mut().value_mut() = create_nested_value(metadata_depth); + *trace.metadata_mut().value_mut() = create_nested_value(value_depth); trace } -/// Create a Metric with metadata at `metadata_depth`. +/// Create a Metric with metadata at `value_depth`. /// (Metric values have fixed structure — only metadata carries arbitrary Values.) -fn create_saturated_metric(metadata_depth: usize) -> Metric { +fn create_saturated_metric(value_depth: usize) -> Metric { let mut metric = Metric::new( "test", MetricKind::Incremental, MetricValue::Counter { value: 1.0 }, ); - *metric.metadata_mut().value_mut() = create_nested_value(metadata_depth); + *metric.metadata_mut().value_mut() = create_nested_value(value_depth); metric } -/// Build all three `EventArray` variants with each field at its respective max depth. -fn saturated_event_arrays( - value_depth: usize, - metadata_depth: usize, -) -> Vec<(&'static str, EventArray)> { +/// Build all three `EventArray` variants with every arbitrary Value at the same depth. +fn saturated_event_arrays(value_depth: usize) -> Vec<(&'static str, EventArray)> { vec![ ( "Log", - EventArray::Logs(LogArray::from(vec![create_saturated_log( - value_depth, - metadata_depth, - )])), + EventArray::Logs(LogArray::from(vec![create_saturated_log(value_depth)])), ), ( "Trace", - EventArray::Traces(TraceArray::from(vec![create_saturated_trace( - value_depth, - metadata_depth, - )])), + EventArray::Traces(TraceArray::from(vec![create_saturated_trace(value_depth)])), ), ( "Metric", EventArray::Metrics(MetricArray::from(vec![create_saturated_metric( - metadata_depth, + value_depth, )])), ), ] } /// Build all three Event variants for `EventWrapper` encoding. -fn saturated_events(value_depth: usize, metadata_depth: usize) -> Vec<(&'static str, Event)> { +fn saturated_events(value_depth: usize) -> Vec<(&'static str, Event)> { vec![ - ( - "Log", - Event::Log(create_saturated_log(value_depth, metadata_depth)), - ), - ( - "Trace", - Event::Trace(create_saturated_trace(value_depth, metadata_depth)), - ), + ("Log", Event::Log(create_saturated_log(value_depth))), + ("Trace", Event::Trace(create_saturated_trace(value_depth))), ( "Metric", - Event::Metric(create_saturated_metric(metadata_depth)), + Event::Metric(create_saturated_metric(value_depth)), ), ] } -/// Verify the frame budgets are exactly right: all event types roundtrip at the -/// max object-only depth, and at least one fails prost decode when either budget -/// is exceeded. +/// Verify that the common Value budget roundtrips through every protobuf path and that +/// increasing every Value by one object level exceeds at least one wire-path limit. #[test] -fn max_nesting_budgets_are_correct() { - let max_val = MAX_OBJECT_DEPTH_VALUE; - let max_meta = MAX_OBJECT_DEPTH_METADATA; - - // --- Both budgets at max must roundtrip for all event types --- - - for (name, array) in saturated_event_arrays(max_val, max_meta) { +fn max_nesting_budget_is_safe_for_all_paths() { + for (name, array) in saturated_event_arrays(MAX_OBJECT_DEPTH_VALUE) { let proto_array = proto::EventArray::from(array); let mut buf = BytesMut::with_capacity(65536); proto_array.encode(&mut buf).unwrap(); assert!( proto::EventArray::decode(buf.freeze()).is_ok(), - "EventArray decode FAILED for {name} at value depth {max_val}, metadata depth {max_meta}.", + "EventArray decode FAILED for {name} at the common Value budget.", ); } - for (name, event) in saturated_events(max_val, max_meta) { + for (name, event) in saturated_events(MAX_OBJECT_DEPTH_VALUE) { let wrapper = proto::EventWrapper::from(event); let mut buf = BytesMut::with_capacity(65536); wrapper.encode(&mut buf).unwrap(); assert!( proto::EventWrapper::decode(buf.freeze()).is_ok(), - "EventWrapper decode FAILED for {name} at value depth {max_val}, metadata depth {max_meta}.", + "EventWrapper decode FAILED for {name} at the common Value budget.", ); } - // --- Exceeding either budget must fail for at least one event type --- - - // Exceed value budget - let any_fails = saturated_event_arrays(max_val + 1, max_meta) + let any_fails = saturated_event_arrays(MAX_OBJECT_DEPTH_VALUE + 1) .into_iter() .any(|(_, array)| { let proto_array = proto::EventArray::from(array); @@ -302,30 +274,14 @@ fn max_nesting_budgets_are_correct() { }); assert!( any_fails, - "No path failed at object value depth {}. MAX_VALUE_NESTING_FRAMES could be raised.", - max_val + 1 - ); - - // Exceed metadata budget - let any_fails = saturated_event_arrays(max_val, max_meta + 1) - .into_iter() - .any(|(_, array)| { - let proto_array = proto::EventArray::from(array); - let mut buf = BytesMut::with_capacity(65536); - proto_array.encode(&mut buf).unwrap(); - proto::EventArray::decode(buf.freeze()).is_err() - }); - assert!( - any_fails, - "No path failed at object metadata depth {}. MAX_METADATA_VALUE_NESTING_FRAMES could be raised.", - max_meta + 1 + "No path failed one object level above MAX_VALUE_NESTING_FRAMES.", ); } /// Verify the nesting gate accepts all event types at the max object-only depth. #[test] fn nesting_gate_accepts_all_types_at_max_depth() { - for (name, array) in saturated_event_arrays(MAX_OBJECT_DEPTH_VALUE, MAX_OBJECT_DEPTH_METADATA) { + for (name, array) in saturated_event_arrays(MAX_OBJECT_DEPTH_VALUE) { let mut buf = BytesMut::with_capacity(65536); assert!( array.encode(&mut buf).is_ok(), @@ -334,46 +290,23 @@ fn nesting_gate_accepts_all_types_at_max_depth() { } } -/// Verify the nesting gate rejects when either object-only budget is exceeded. +/// Verify the nesting gate rejects every event type above the common Value budget. #[test] fn nesting_gate_rejects_above_max_depth() { - // Exceed value budget (Log and Trace have event data; Metric does not) - for (name, array) in - saturated_event_arrays(MAX_OBJECT_DEPTH_VALUE + 1, MAX_OBJECT_DEPTH_METADATA) - { - // Metric has no event data field, so it won't be rejected here - if name == "Metric" { - continue; - } - let mut buf = BytesMut::with_capacity(65536); - assert!( - matches!( - array.encode(&mut buf), - Err(super::super::ser::EncodeError::NestingTooDeep { .. }) - ), - "nesting gate should reject {name} at object value depth {}", - MAX_OBJECT_DEPTH_VALUE + 1, - ); - } - - // Exceed metadata budget - for (name, array) in - saturated_event_arrays(MAX_OBJECT_DEPTH_VALUE, MAX_OBJECT_DEPTH_METADATA + 1) - { + for (name, array) in saturated_event_arrays(MAX_OBJECT_DEPTH_VALUE + 1) { let mut buf = BytesMut::with_capacity(65536); assert!( matches!( array.encode(&mut buf), Err(super::super::ser::EncodeError::NestingTooDeep { .. }) ), - "nesting gate should reject {name} at object metadata depth {}", - MAX_OBJECT_DEPTH_METADATA + 1, + "nesting gate should reject {name} above the common Value budget", ); } } -/// Verify the per-path prost boundaries match the budgets for both object-only and -/// array-only nesting. +/// Verify that the wider `Log.fields` path has one level of headroom over the common +/// budget while the metadata path is tight, for both object-only and array-only values. /// /// Object-only `Log.fields`: depth 33 succeeds, 34 fails. /// Object-only `metadata_full`: depth 32 succeeds, 33 fails. @@ -401,59 +334,112 @@ fn per_path_boundaries() { proto::EventArray::decode(buf.freeze()).is_ok() }; - // Object-only Log.fields: the "data" key contributes one level on top of the inner - // nested value, so we subtract one when building the value. + // `Log.fields` accepts 33 object levels (cost 99), one more than the common limit. + // The "data" key contributes the outer object level. assert!( - roundtrip_value(create_nested_value(MAX_OBJECT_DEPTH_VALUE - 1)), - "Log.fields should succeed at object depth {MAX_OBJECT_DEPTH_VALUE}" + roundtrip_value(create_nested_value(MAX_OBJECT_DEPTH_VALUE)), + "Log.fields should succeed one object level above the common budget" ); assert!( - !roundtrip_value(create_nested_value(MAX_OBJECT_DEPTH_VALUE)), + !roundtrip_value(create_nested_value(MAX_OBJECT_DEPTH_VALUE + 1)), "Log.fields should fail at object depth {}", - MAX_OBJECT_DEPTH_VALUE + 1 + MAX_OBJECT_DEPTH_VALUE + 2 ); - // Object-only metadata_full: metadata Value is the root, no key on top. + // `metadata_full` is tight at the common limit of 32 object levels (cost 96). assert!( - roundtrip_metadata(create_nested_value(MAX_OBJECT_DEPTH_METADATA)), - "metadata_full should succeed at object depth {MAX_OBJECT_DEPTH_METADATA}" + roundtrip_metadata(create_nested_value(MAX_OBJECT_DEPTH_VALUE)), + "metadata_full should succeed at the common object-depth limit" ); assert!( - !roundtrip_metadata(create_nested_value(MAX_OBJECT_DEPTH_METADATA + 1)), + !roundtrip_metadata(create_nested_value(MAX_OBJECT_DEPTH_VALUE + 1)), "metadata_full should fail at object depth {}", - MAX_OBJECT_DEPTH_METADATA + 1 + MAX_OBJECT_DEPTH_VALUE + 1 ); - // Array-only Log.fields: array contributes 2 frames per level, so it fits more levels. + // The outer object plus 48 nested arrays costs 99 frames on `Log.fields`. assert!( - roundtrip_value(create_nested_array(MAX_ARRAY_DEPTH_VALUE - 1)), - "Log.fields should succeed at array depth {MAX_ARRAY_DEPTH_VALUE}" + roundtrip_value(create_nested_array(MAX_ARRAY_DEPTH_VALUE)), + "Log.fields should succeed with one array level of headroom" ); assert!( - !roundtrip_value(create_nested_array(MAX_ARRAY_DEPTH_VALUE)), + !roundtrip_value(create_nested_array(MAX_ARRAY_DEPTH_VALUE + 1)), "Log.fields should fail at array depth {}", - MAX_ARRAY_DEPTH_VALUE + 1 + MAX_ARRAY_DEPTH_VALUE + 2 ); - // Array-only metadata_full + // `metadata_full` is tight at 48 array levels (cost 96). assert!( - roundtrip_metadata(create_nested_array(MAX_ARRAY_DEPTH_METADATA)), - "metadata_full should succeed at array depth {MAX_ARRAY_DEPTH_METADATA}" + roundtrip_metadata(create_nested_array(MAX_ARRAY_DEPTH_VALUE)), + "metadata_full should succeed at the common array-depth limit" ); assert!( - !roundtrip_metadata(create_nested_array(MAX_ARRAY_DEPTH_METADATA + 1)), + !roundtrip_metadata(create_nested_array(MAX_ARRAY_DEPTH_VALUE + 1)), "metadata_full should fail at array depth {}", - MAX_ARRAY_DEPTH_METADATA + 1 + MAX_ARRAY_DEPTH_VALUE + 1 ); } -/// Verify that array-only nesting deeper than the object-only cap (33) is accepted by +/// Non-object log roots are encoded through `Log.value`, not the legacy `Log.fields` +/// map. Its lower wire limit establishes the common budget used for every Value. +#[test] +fn value_budget_matches_tightest_wire_path() { + let make_log = |array_depth| LogEvent::from(create_nested_array(array_depth)); + let raw_roundtrip = |log: LogEvent| { + let array = EventArray::Logs(LogArray::from(vec![log])); + let proto_array = proto::EventArray::from(array); + let mut buf = BytesMut::with_capacity(65536); + proto_array.encode(&mut buf).unwrap(); + proto::EventArray::decode(buf.freeze()).is_ok() + }; + + assert!( + raw_roundtrip(make_log(MAX_ARRAY_DEPTH_VALUE)), + "Log.value should roundtrip at its array-depth limit", + ); + assert!( + !raw_roundtrip(make_log(MAX_ARRAY_DEPTH_VALUE + 1)), + "Log.value should fail prost decoding past its array-depth limit", + ); + + let accepted = Event::Log(make_log(MAX_ARRAY_DEPTH_VALUE)); + assert!(event_exceeds_max_nesting_cost(&accepted).is_none()); + let accepted = EventArray::Logs(LogArray::from(vec![accepted.into_log()])); + let mut buf = BytesMut::with_capacity(65536); + accepted + .encode(&mut buf) + .expect("the last decodable Log.value depth should pass the gate"); + + let rejected = Event::Log(make_log(MAX_ARRAY_DEPTH_VALUE + 1)); + assert_eq!( + event_exceeds_max_nesting_cost(&rejected), + Some((98, MAX_VALUE_NESTING_FRAMES)), + ); + let rejected = EventArray::Logs(LogArray::from(vec![rejected.into_log()])); + assert!( + rejected.clone().filter_unencodable().is_none(), + "the buffer filter should drop an undecodable Log.value root", + ); + let mut buf = BytesMut::with_capacity(65536); + assert!( + matches!( + rejected.encode(&mut buf), + Err(super::super::ser::EncodeError::NestingTooDeep { + cost: 98, + budget: MAX_VALUE_NESTING_FRAMES, + }) + ), + "the encode-time gate should reject an undecodable Log.value root", + ); +} + +/// Verify that array-only nesting deeper than the object-only cap (32) is accepted by /// the gate — this is the regression that the frame-cost check addresses. Previously a /// uniform depth-33 cap dropped array-only events that prost would happily roundtrip. #[test] fn nesting_gate_accepts_deep_array_nesting() { - // An array depth 40 = 80 frames, comfortably under the 99-frame value budget but well - // over the 33-depth limit the old uniform check would have applied. + // Forty arrays below the outer log object cost 83 frames, comfortably under the + // 96-frame Value budget but over the old uniform depth limit. let mut event = LogEvent::default(); event.insert(event_path!("data"), create_nested_array(40)); let array = EventArray::Logs(LogArray::from(vec![event])); @@ -486,7 +472,7 @@ fn nesting_gate_handles_mixed_array_object_nesting() { }; // 38 alternating levels: 19 array (cost 38) + 19 object (cost 57) = 95 frames. - // Under the metadata budget of 96. Fits. + // Under the common Value budget of 96. Fits. let mut event = LogEvent::from("flat"); *event.metadata_mut().value_mut() = build_alternating(38); let array = EventArray::Logs(LogArray::from(vec![event])); @@ -497,7 +483,7 @@ fn nesting_gate_handles_mixed_array_object_nesting() { ); // 39 alternating levels: 20 array (cost 40) + 19 object (cost 57) = 97 frames. - // Over the metadata budget of 96. Fails. + // Over the common Value budget of 96. Fails. let mut event = LogEvent::from("flat"); *event.metadata_mut().value_mut() = build_alternating(39); let array = EventArray::Logs(LogArray::from(vec![event])); @@ -535,12 +521,13 @@ fn nesting_gate_rejects_timestamp_leaf_at_max_object_depth() { proto::EventArray::decode(buf.freeze()).is_ok() }; - // Event data: at object depth 33, a Bytes leaf decodes but a Timestamp leaf does not, - // because the Timestamp message consumes one more recursion frame. - let event_data_ts = create_nested_value_with_leaf(MAX_OBJECT_DEPTH_VALUE - 1, ts_leaf()); + // `Log.fields` can carry 33 object levels (cost 99), but a Timestamp leaf raises + // that cost to 100 and fails decode. The gate rejects it under the common limit too. + let event_data_ts = create_nested_value_with_leaf(MAX_OBJECT_DEPTH_VALUE, ts_leaf()); assert!( !roundtrip_log(event_data_ts.clone()), - "depth {MAX_OBJECT_DEPTH_VALUE} with Timestamp leaf is expected to fail prost decode" + "depth {} with Timestamp leaf is expected to fail prost decode", + MAX_OBJECT_DEPTH_VALUE + 1, ); let mut event = LogEvent::default(); @@ -552,14 +539,14 @@ fn nesting_gate_rejects_timestamp_leaf_at_max_object_depth() { array.encode(&mut buf), Err(super::super::ser::EncodeError::NestingTooDeep { .. }) ), - "gate should reject event-data Timestamp leaf at object depth {MAX_OBJECT_DEPTH_VALUE}", + "gate should reject event-data Timestamp leaf above the common budget", ); - // Metadata: same boundary, one shallower. - let metadata_ts = create_nested_value_with_leaf(MAX_OBJECT_DEPTH_METADATA, ts_leaf()); + // Metadata reaches its wire boundary at the common 32-object limit. + let metadata_ts = create_nested_value_with_leaf(MAX_OBJECT_DEPTH_VALUE, ts_leaf()); assert!( !roundtrip_metadata(metadata_ts.clone()), - "metadata depth {MAX_OBJECT_DEPTH_METADATA} with Timestamp leaf is expected to fail prost decode" + "metadata depth {MAX_OBJECT_DEPTH_VALUE} with Timestamp leaf is expected to fail prost decode" ); let mut event = LogEvent::from("flat"); @@ -571,7 +558,7 @@ fn nesting_gate_rejects_timestamp_leaf_at_max_object_depth() { array.encode(&mut buf), Err(super::super::ser::EncodeError::NestingTooDeep { .. }) ), - "gate should reject metadata Timestamp leaf at object depth {MAX_OBJECT_DEPTH_METADATA}", + "gate should reject metadata Timestamp leaf at object depth {MAX_OBJECT_DEPTH_VALUE}", ); } @@ -580,7 +567,7 @@ fn nesting_gate_rejects_timestamp_leaf_at_max_object_depth() { /// through prost. #[test] fn nesting_gate_accepts_timestamp_leaf_below_max_object_depth() { - // Event data: depth (max-1) Object + Timestamp leaf = (max-1)*3 + 1 frames. + // One object level below the common limit plus a Timestamp leaf. let mut event = LogEvent::default(); event.insert( event_path!("data"), @@ -602,18 +589,18 @@ fn nesting_gate_accepts_timestamp_leaf_below_max_object_depth() { // Metadata: one shallower. let mut event = LogEvent::from("flat"); *event.metadata_mut().value_mut() = - create_nested_value_with_leaf(MAX_OBJECT_DEPTH_METADATA - 1, ts_leaf()); + create_nested_value_with_leaf(MAX_OBJECT_DEPTH_VALUE - 1, ts_leaf()); let array = EventArray::Logs(LogArray::from(vec![event])); let mut buf = BytesMut::with_capacity(65536); assert!( array.encode(&mut buf).is_ok(), "gate should accept metadata Timestamp leaf at object depth {}", - MAX_OBJECT_DEPTH_METADATA - 1, + MAX_OBJECT_DEPTH_VALUE - 1, ); assert!( proto::EventArray::decode(buf.freeze()).is_ok(), "prost should decode metadata Timestamp leaf at object depth {}", - MAX_OBJECT_DEPTH_METADATA - 1, + MAX_OBJECT_DEPTH_VALUE - 1, ); } @@ -659,8 +646,8 @@ fn filter_unencodable_drops_only_over_budget_events() { /// Verify that the public per-event entry point used by both the native codec and the /// vector sink charges `Value::Timestamp` for one frame, just like the buffer gate. -/// Without this, a depth-33 object chain ending in a timestamp would pass the codec -/// check and fail prost decode on the receiving end. +/// Without this, a deep object chain ending in a timestamp could pass the codec check +/// and fail prost decode on the receiving end. #[test] fn event_exceeds_max_nesting_cost_charges_timestamp_leaf() { let log_at_max_with_ts = { @@ -696,12 +683,12 @@ fn event_exceeds_max_nesting_cost_charges_timestamp_leaf() { MetricValue::Counter { value: 1.0 }, ); *metric.metadata_mut().value_mut() = - create_nested_value_with_leaf(MAX_OBJECT_DEPTH_METADATA, ts_leaf()); + create_nested_value_with_leaf(MAX_OBJECT_DEPTH_VALUE, ts_leaf()); Event::Metric(metric) }; assert!( event_exceeds_max_nesting_cost(&metric_at_max_with_ts).is_some(), - "metric with metadata-Timestamp leaf at depth {MAX_OBJECT_DEPTH_METADATA} must be rejected", + "metric with metadata-Timestamp leaf at depth {MAX_OBJECT_DEPTH_VALUE} must be rejected", ); // And one shallower stays under the budget. diff --git a/src/sinks/vector/sink.rs b/src/sinks/vector/sink.rs index 23be0fe96bed5..20fd0937e7504 100644 --- a/src/sinks/vector/sink.rs +++ b/src/sinks/vector/sink.rs @@ -155,8 +155,7 @@ mod tests { use bytes::BytesMut; use prost::Message; use vector_lib::event::{ - Event, LogEvent, MAX_METADATA_VALUE_NESTING_FRAMES, MAX_VALUE_NESTING_FRAMES, ObjectMap, - Value, event_exceeds_max_nesting_cost, + Event, LogEvent, MAX_VALUE_NESTING_FRAMES, ObjectMap, Value, event_exceeds_max_nesting_cost, }; use vrl::event_path; @@ -181,11 +180,10 @@ mod tests { /// wrapper. #[test] fn push_events_request_decode_at_value_budget() { - // 32 nested objects under "data" key → 33 effective object levels in - // `log.value()` (one outer Object from the inserted key), cost = 99 = - // MAX_VALUE_NESTING_FRAMES. + // 31 nested objects under "data" key → 32 effective object levels in + // `log.value()` (one outer Object from the inserted key), cost = 96. let mut log = LogEvent::default(); - log.insert(event_path!("data"), build_nested_value(32)); + log.insert(event_path!("data"), build_nested_value(31)); let event = Event::Log(log); assert!( event_exceeds_max_nesting_cost(&event).is_none(), @@ -204,16 +202,18 @@ mod tests { .expect("PushEventsRequest decode should succeed at the accepted value budget"); } - /// Boundary check: one step past the value budget must fail decode through - /// the gRPC wire shape. Together with the at-budget test above this pins - /// `MAX_VALUE_NESTING_FRAMES` as the tight boundary for the vector-sink - /// path, identical to the disk-buffer / native-codec `EventArray` path. + /// An object-root log one step past the common budget still fits the wider + /// `Log.fields` wire path, but the sink gate applies the same limit to every Value. #[test] - fn push_events_request_decode_one_past_value_budget_fails() { - // 33 nested objects under "data" → 34 effective object levels, cost 102. + fn push_events_request_rejects_one_past_common_value_budget() { + // 32 nested objects under "data" → 33 effective object levels, cost 99. let mut log = LogEvent::default(); - log.insert(event_path!("data"), build_nested_value(33)); + log.insert(event_path!("data"), build_nested_value(32)); let event = Event::Log(log); + assert_eq!( + event_exceeds_max_nesting_cost(&event), + Some((MAX_VALUE_NESTING_FRAMES + 3, MAX_VALUE_NESTING_FRAMES)), + ); let request = proto_vector::PushEventsRequest { events: vec![EventWrapper::from(event)], @@ -222,22 +222,21 @@ mod tests { let mut buf = BytesMut::with_capacity(65536); request.encode(&mut buf).expect("encode should succeed"); - assert!( - proto_vector::PushEventsRequest::decode(buf.freeze()).is_err(), - "PushEventsRequest decode must fail one step past the value budget; \ - if this changes, the gate is no longer tight", + proto_vector::PushEventsRequest::decode(buf.freeze()).expect( + "the object-root wire path can decode 99 frames even though the common \ + Value gate rejects it", ); } #[test] - fn push_events_request_decode_at_metadata_budget() { + fn push_events_request_decode_with_metadata_at_value_budget() { let mut log = LogEvent::from("flat"); *log.metadata_mut().value_mut() = build_nested_value(32); let event = Event::Log(log); assert!( event_exceeds_max_nesting_cost(&event).is_none(), - "test setup invariant: metadata must sit exactly at the metadata \ - budget (cost {MAX_METADATA_VALUE_NESTING_FRAMES})", + "test setup invariant: metadata must sit exactly at the Value budget \ + (cost {MAX_VALUE_NESTING_FRAMES})", ); let request = proto_vector::PushEventsRequest { From 39b74b4267e04c9359bb97716501e8c81104e71c Mon Sep 17 00:00:00 2001 From: Pavlos Rontidis Date: Mon, 17 Aug 2026 14:50:30 -0400 Subject: [PATCH 49/49] Update changelog.d/protobuf_nesting_depth_limit.fix.md --- changelog.d/protobuf_nesting_depth_limit.fix.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/protobuf_nesting_depth_limit.fix.md b/changelog.d/protobuf_nesting_depth_limit.fix.md index a4f0ea7d26c47..9151acea087f4 100644 --- a/changelog.d/protobuf_nesting_depth_limit.fix.md +++ b/changelog.d/protobuf_nesting_depth_limit.fix.md @@ -1,3 +1,3 @@ -Fixed unrecoverable disk buffer corruption and vector-to-vector retry loops caused by event data or metadata that protobuf could encode but prost could not decode. Vector now drops only protobuf-unsafe nested payloads before disk buffer or `vector` sink gRPC encoding, while preserving nested shapes that prost can safely decode. Under `when_full = "overflow"`, an event the buffer cannot encode is passed to the overflow stage intact rather than dropped, and does so regardless of how full the buffer currently is. +Fixed an issue where unusually deeply nested event data or metadata could make disk buffers unreadable or cause vector-to-vector pipelines to retry indefinitely. Vector now detects affected events before buffering or sending while leaving safely nested events unchanged. When when_full = "overflow" is configured, the original event is routed intact to the overflow stage regardless of buffer occupancy; otherwise, only the affected event is dropped. authors: connoryy ganelo EricaJ6 jonodera97