Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelog.d/24773_support_more_avro_types.fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
The Avro codec now supports encoding and decoding Date, Fixed, TimeMillis, TimestampNanos, LocalTimestampNanos values.

authors: omwbennett
2 changes: 1 addition & 1 deletion lib/codecs/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ vector-config-macros = { path = "../vector-config-macros", default-features = fa
vector-core = { path = "../vector-core", default-features = false, features = ["vrl"] }
vector-vrl-functions.workspace = true
toml = { version = "0.9.8", optional = true }
uuid.workspace = true

[dev-dependencies]
criterion.workspace = true
Expand All @@ -74,7 +75,6 @@ similar-asserts = "1.7.0"
vector-core = { path = "../vector-core", default-features = false, features = ["vrl", "test"] }
rstest = "0.26.1"
tracing-test = "0.2.6"
uuid.workspace = true
vrl.workspace = true

[features]
Expand Down
25 changes: 6 additions & 19 deletions lib/codecs/src/decoding/format/avro.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,8 @@ impl From<&AvroDeserializerOptions> for AvroSerializerOptions {
pub struct AvroDeserializerOptions {
/// The Avro schema definition.
/// **Note**: The following [`apache_avro::types::Value`] variants are *not* supported:
/// * `Date`
/// * `Decimal`
/// * `Duration`
/// * `Fixed`
/// * `TimeMillis`
#[configurable(metadata(
docs::examples = r#"{ "type": "record", "name": "log", "fields": [{ "name": "message", "type": "string" }] }"#,
docs::additional_props_description = r#"Supports most avro data types, unsupported data types includes
Expand Down Expand Up @@ -189,10 +186,8 @@ pub fn try_from(value: AvroValue) -> vector_common::Result<VrlValue> {
Ok(VrlValue::Array(vector))
}
AvroValue::Boolean(boolean) => Ok(VrlValue::from(boolean)),
AvroValue::Bytes(bytes) => Ok(VrlValue::from(bytes)),
AvroValue::Date(_) => Err(vector_common::Error::from(
"AvroValue::Date is not supported",
)),
AvroValue::Bytes(bytes) => Ok(VrlValue::Bytes(Bytes::from(bytes))),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve non-UTF-8 Avro byte payloads

When an Avro bytes field contains arbitrary non-UTF-8 data, converting it to VrlValue::Bytes here makes the encoder's later apache_avro::to_value(log) pass serialize it through VRL's lossy string representation. For example, [0xff, 0x00] is silently re-encoded as [0xef, 0xbf, 0xbd, 0x00]; the new Fixed branch has the same problem and can instead fail when replacement bytes change the fixed length. Avro binary values are not restricted to UTF-8, so the serializer must preserve VRL bytes through a schema-aware conversion rather than the lossy Serde path.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Hi @omwbennett, apologies for the delay. I spend a little time on this PR but this is actually an important issue. If you are still interesting in completing this, happy to help reviewing. Otherwise, I think we want to split this into smaller PRs.

The fix here is to do the following:

(VrlValue::Bytes(bytes), Schema::Bytes) => Ok(AvroValue::Bytes(bytes.to_vec())),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Hi, thanks for taking another look. I can split this up into smaller PRs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Starting with #26000

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Next is the introduction of coerce_logical_types to support a few simpler logical types (date/timemillis): #26112
Will add support for the remaining logical types in a follow-up PR.

AvroValue::Date(days) => Ok(VrlValue::from(days)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Regenerate Splunk HEC's Avro support docs

For users configuring either Splunk HEC decoding path, website/cue/reference/components/sources/generated/splunk_hec.cue still says that Date, Fixed, and TimeMillis are unsupported at lines 110-114 and 654-658, even though this commit updates the same generated description in the other source pages. Regenerate this component page as well so the published options do not contradict the newly supported branches.

AGENTS.md reference: AGENTS.md:L212-L212

Useful? React with 👍 / 👎.

AvroValue::Decimal(_) => Err(vector_common::Error::from(
"AvroValue::Decimal is not supported",
)),
Expand All @@ -201,9 +196,7 @@ pub fn try_from(value: AvroValue) -> vector_common::Result<VrlValue> {
"AvroValue::Duration is not supported",
)),
AvroValue::Enum(_, string) => Ok(VrlValue::from(string)),
AvroValue::Fixed(_, _) => Err(vector_common::Error::from(
"AvroValue::Fixed is not supported",
)),
AvroValue::Fixed(_, bytes) => Ok(VrlValue::Bytes(Bytes::from(bytes))),
AvroValue::Float(float) => Ok(VrlValue::from_f64_or_zero(float as f64)),
AvroValue::Int(int) => Ok(VrlValue::from(int)),
AvroValue::Long(long) => Ok(VrlValue::from(long)),
Expand All @@ -220,9 +213,7 @@ pub fn try_from(value: AvroValue) -> vector_common::Result<VrlValue> {
.map(|v| VrlValue::Object(v.into_iter().collect())),
AvroValue::String(string) => Ok(VrlValue::from(string)),
AvroValue::TimeMicros(time_micros) => Ok(VrlValue::from(time_micros)),
AvroValue::TimeMillis(_) => Err(vector_common::Error::from(
"AvroValue::TimeMillis is not supported",
)),
AvroValue::TimeMillis(time_millis) => Ok(VrlValue::from(time_millis)),
AvroValue::TimestampMicros(ts_micros) => Ok(VrlValue::from(ts_micros)),
AvroValue::TimestampMillis(ts_millis) => Ok(VrlValue::from(ts_millis)),
AvroValue::Union(_, v) => try_from(*v),
Expand All @@ -232,12 +223,8 @@ pub fn try_from(value: AvroValue) -> vector_common::Result<VrlValue> {
AvroValue::BigDecimal(_) => Err(vector_common::Error::from(
"AvroValue::BigDecimal is not supported",
)),
AvroValue::TimestampNanos(_) => Err(vector_common::Error::from(
"AvroValue::TimestampNanos is not supported",
)),
AvroValue::LocalTimestampNanos(_) => Err(vector_common::Error::from(
"AvroValue::LocalTimestampNanos is not supported",
)),
AvroValue::TimestampNanos(ts_nanos) => Ok(VrlValue::from(ts_nanos)),
AvroValue::LocalTimestampNanos(ts_nanos) => Ok(VrlValue::from(ts_nanos)),
}
}

Expand Down
258 changes: 258 additions & 0 deletions lib/codecs/src/encoding/format/avro.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,109 @@ use vector_core::{config::DataType, event::Event, schema};

use crate::encoding::BuildError;

type AvroValue = apache_avro::types::Value;

/// `apache_avro::to_value` may serialize VRL values into Avro types which later
/// cannot be resolved against certain Avro types
/// (e.g. VRL integer (i64) -> Avro `Long` which cannot be resolved to Avro `Date`).
/// `coerce_logical_types` does a recursive pre-pass to fix such cases.
fn coerce_logical_types(
value: AvroValue,
schema: &apache_avro::Schema,
) -> vector_common::Result<AvroValue> {
use apache_avro::Schema;
match (value, schema) {
(AvroValue::Long(days), Schema::Date) => {
i32::try_from(days).map(AvroValue::Date).map_err(|_| {
vector_common::Error::from(format!(
"Avro date value {days} is out of range for i32"
))
})
}
(AvroValue::Long(millis), Schema::TimeMillis) => i32::try_from(millis)
.map(AvroValue::TimeMillis)
.map_err(|_| {
vector_common::Error::from(format!(
"Avro time-millis value {millis} is out of range for i32"
))
}),
(AvroValue::Record(fields), Schema::Record(record_schema)) => {
let fields = fields
.into_iter()
.map(|(name, value)| {
let value = match record_schema.lookup.get(&name) {
Some(index) => {
let field_schema = &record_schema.fields[*index].schema;
coerce_logical_types(value, field_schema)?
}
None => value,
};
Ok((name, value))
})
.collect::<vector_common::Result<Vec<_>>>()?;
Ok(AvroValue::Record(fields))
}
(AvroValue::Map(entries), Schema::Record(record_schema)) => {
let entries = entries
.into_iter()
.map(|(name, value)| {
let value = match record_schema.lookup.get(&name) {
Some(index) => {
let field_schema = &record_schema.fields[*index].schema;
coerce_logical_types(value, field_schema)?
}
None => value,
};
Ok((name, value))
})
.collect::<vector_common::Result<_>>()?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid rebuilding every Avro event during coercion

Although the final version no longer rebuilds ResolvedSchema, it still sends every event through this branch: apache_avro::to_value(log) produces a map for log events, and this collect allocates a replacement map after walking every field, recursively rebuilding nested maps and arrays even when the schema contains no Date or TimeMillis. Nullable unions additionally deep-clone their values before the final resolution traverses them again. In high-throughput pipelines with ordinary schemas or large messages, this adds per-event allocations and copies unrelated to the feature; skip the pass when the schema needs no coercion or mutate containers in place.

Useful? React with 👍 / 👎.

Ok(AvroValue::Map(entries))
}
(AvroValue::Array(items), Schema::Array(array_schema)) => items
.into_iter()
.map(|item| coerce_logical_types(item, &array_schema.items))
.collect::<Result<Vec<_>, _>>()
.map(AvroValue::Array),
(AvroValue::Map(entries), Schema::Map(map_schema)) => entries
.into_iter()
.map(|(key, value)| {
coerce_logical_types(value, &map_schema.types).map(|value| (key, value))
})
.collect::<vector_common::Result<_>>()
.map(AvroValue::Map),
(AvroValue::Union(index, value), Schema::Union(union_schema)) => {
let schema = union_schema
.variants()
.get(index as usize)
.unwrap_or(schema);
coerce_logical_types(*value, schema)
.map(|value| AvroValue::Union(index, Box::new(value)))
}
(value, Schema::Union(union_schema)) => {
if let Ok(resolved) = value.clone().resolve(schema) {
return Ok(resolved);
}

let mut last_err = None;
for (index, variant) in union_schema.variants().iter().enumerate() {
match coerce_logical_types(value.clone(), variant) {
Ok(coerced) if coerced.clone().resolve(variant).is_ok() => {
return Ok(AvroValue::Union(index as u32, Box::new(coerced)));
}
Ok(_) => {}
Err(err) => last_err = Some(err),
}
}

match last_err {
Some(err) => Err(err),
None => Ok(value),
}
}
(value, _) => Ok(value),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Resolve named schemas before coercing logical fields

When a record containing a date or time-millis field is reused through an Avro named-type reference, its field schema reaches this fallback as Schema::Ref, so the nested VRL integer remains AvroValue::Long. The subsequent schema resolution then rejects it (for example, a second field of type "Inner" fails with Expected Value::Date or Value::Int, got: Long(...)), meaning valid schemas using named records cannot encode the newly supported logical types. Resolve references during this recursive pass, using the root schema's names map, before descending into the referenced schema.

Useful? React with 👍 / 👎.

}
}

/// Config used to build a `AvroSerializer`.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AvroSerializerConfig {
Expand Down Expand Up @@ -71,6 +174,7 @@ impl Encoder<Event> for AvroSerializer {
fn encode(&mut self, event: Event, buffer: &mut BytesMut) -> Result<(), Self::Error> {
let log = event.into_log();
let value = apache_avro::to_value(log)?;
let value = coerce_logical_types(value, &self.schema)?;
let value = value.resolve(&self.schema)?;
let bytes = apache_avro::to_avro_datum(&self.schema, value)?;
buffer.put_slice(&bytes);
Expand Down Expand Up @@ -113,4 +217,158 @@ mod tests {

assert_eq!(bytes.freeze(), b"\0\x06bar".as_slice());
}

#[test]
fn coerce_date_fields_recursively() {
let schema = apache_avro::Schema::parse_str(indoc! {r#"
{
"type": "record",
"name": "Outer",
"fields": [
{
"name": "direct_date",
"type": {"type": "int", "logicalType": "date"}
},
{
"name": "inner",
"type": {
"type": "record",
"name": "Inner",
"fields": [
{
"name": "date",
"type": {"type": "int", "logicalType": "date"}
}
]
}
},
{
"name": "record_as_map",
"type": {
"type": "record",
"name": "MapBackedInner",
"fields": [
{
"name": "date",
"type": {"type": "int", "logicalType": "date"}
}
]
}
},
{
"name": "date_array",
"type": {
"type": "array",
"items": {"type": "int", "logicalType": "date"}
}
},
{
"name": "date_map",
"type": {
"type": "map",
"values": {"type": "int", "logicalType": "date"}
}
},
{
"name": "union_date",
"type": ["null", {"type": "int", "logicalType": "date"}]
},
{
"name": "fallback_union_date",
"type": [
"null",
{"type": "int", "logicalType": "date"},
"long"
]
},
{
"name": "logical_only_union_date",
"type": [
"null",
{"type": "int", "logicalType": "date"}
]
}
]
}
"#})
.unwrap();
let value = AvroValue::Record(vec![
("direct_date".to_owned(), AvroValue::Long(20_000)),
(
"inner".to_owned(),
AvroValue::Record(vec![("date".to_owned(), AvroValue::Long(20_001))]),
),
(
"record_as_map".to_owned(),
AvroValue::Map(
[("date".to_owned(), AvroValue::Long(20_002))]
.into_iter()
.collect(),
),
),
(
"date_array".to_owned(),
AvroValue::Array(vec![AvroValue::Long(20_003), AvroValue::Long(20_004)]),
),
(
"date_map".to_owned(),
AvroValue::Map(
[
("first".to_owned(), AvroValue::Long(20_005)),
("second".to_owned(), AvroValue::Long(20_006)),
]
.into_iter()
.collect(),
),
),
("union_date".to_owned(), AvroValue::Long(20_007)),
("fallback_union_date".to_owned(), AvroValue::Long(20_009)),
(
"logical_only_union_date".to_owned(),
AvroValue::Long(20_008),
),
]);

let value = coerce_logical_types(value, &schema).unwrap();
let value = value.resolve(&schema).unwrap();

assert!(matches!(
value,
AvroValue::Record(fields) if {
matches!(fields[0].1, AvroValue::Date(20_000))
&& matches!(
&fields[1].1,
AvroValue::Record(inner) if matches!(inner[0].1, AvroValue::Date(20_001))
)
&& matches!(
&fields[2].1,
AvroValue::Record(inner) if matches!(inner[0].1, AvroValue::Date(20_002))
)
&& matches!(
&fields[3].1,
AvroValue::Array(items)
if matches!(items.as_slice(), [AvroValue::Date(20_003), AvroValue::Date(20_004)])
)
&& matches!(
&fields[4].1,
AvroValue::Map(entries)
if matches!(entries.get("first"), Some(AvroValue::Date(20_005)))
&& matches!(entries.get("second"), Some(AvroValue::Date(20_006)))
)
&& matches!(
&fields[5].1,
AvroValue::Union(1, value) if matches!(value.as_ref(), AvroValue::Date(20_007))
)
&& matches!(
&fields[6].1,
AvroValue::Union(2, value)
if matches!(value.as_ref(), AvroValue::Long(20_009))
)
&& matches!(
&fields[7].1,
AvroValue::Union(1, value) if matches!(value.as_ref(), AvroValue::Date(20_008))
)
}
));
}
}
4 changes: 1 addition & 3 deletions lib/codecs/tests/avro.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,7 @@ use vector_core::{config::LogNamespace, event::Event};
#[case(true)]
#[case(false)]
fn roundtrip_avro_fixtures(
#[files("tests/data/avro/generated/*.avro")]
#[exclude(".*(date|fixed|time_millis).avro")]
path: PathBuf,
#[files("tests/data/avro/generated/*.avro")] path: PathBuf,
#[case] reserialize: bool,
) {
let schema_path = path.as_path().with_extension("avsc");
Expand Down
Loading
Loading