Skip to content

Commit d9204c2

Browse files
fix: schema conflict (#1711)
scenario: field exists in schema `level` with data type `float64` subsequent batch: there are multiple events in the same ingestion batch, and a field exists with different data types in the same batch eg. ``` [{ "level":false }, { "level":"false" }, { "level":0 }] ``` the ingestion fails with schema conflict error fix: apply correct data type to each value so "level":false becomes level_bool, "level":"false" becomes level_utf8 and "level":0 remains level
1 parent 3220c1b commit d9204c2

2 files changed

Lines changed: 76 additions & 6 deletions

File tree

src/event/format/json.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,12 @@ impl EventFormat for Event {
109109

110110
// If there are conflicts, rename the fields in JSON values
111111
let value_arr = if !conflicts.is_empty() {
112-
rename_conflicting_fields_in_json(value_arr, &conflicts)
112+
rename_conflicting_fields_in_json(
113+
value_arr,
114+
&conflicts,
115+
stream_schema,
116+
schema_version,
117+
)
113118
} else {
114119
value_arr
115120
};

src/event/format/mod.rs

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -528,9 +528,21 @@ pub fn detect_schema_conflicts(
528528

529529
/// Renames fields in JSON values according to the provided field mapping.
530530
/// Used to resolve schema conflicts by renaming fields with conflicting types.
531+
/// Renames conflicting JSON fields to typed sibling columns, but only for
532+
/// records whose value is incompatible with the existing field type.
533+
///
534+
/// `field_mapping` is produced by `detect_schema_conflicts` and maps an
535+
/// original field name to its typed sibling name, e.g. `level -> level_utf8`.
536+
/// The mapping applies per value, not blindly to every record in the batch:
537+
/// if existing schema has `level: Float64`, then `{ "level": 30 }` stays as
538+
/// `level`, while `{ "level": "info" }` becomes `{ "level_utf8": "info" }`.
539+
/// This avoids routing compatible values into the sibling column and then
540+
/// failing validation, e.g. `level_utf8` expecting `Utf8` but receiving `30`.
531541
pub fn rename_conflicting_fields_in_json(
532542
values: Vec<Value>,
533543
field_mapping: &HashMap<String, String>,
544+
existing_schema: &HashMap<String, Arc<Field>>,
545+
schema_version: SchemaVersion,
534546
) -> Vec<Value> {
535547
if field_mapping.is_empty() {
536548
return values;
@@ -544,7 +556,13 @@ pub fn rename_conflicting_fields_in_json(
544556
.into_iter()
545557
.map(|(key, val)| {
546558
if let Some(new_key) = field_mapping.get(&key) {
547-
(new_key.clone(), val)
559+
if existing_schema.get(&key).is_some_and(|field| {
560+
value_compatible_with_type(&val, field.data_type(), schema_version)
561+
}) {
562+
(key, val)
563+
} else {
564+
(new_key.clone(), val)
565+
}
548566
} else {
549567
(key, val)
550568
}
@@ -776,8 +794,8 @@ mod tests {
776794
#[test]
777795
fn test_rename_conflicting_fields_in_json() {
778796
let values = vec![
779-
json!({"body_timestamp": "2025-01-01T00:00:00Z", "message": "hello"}),
780-
json!({"body_timestamp": "2025-01-02T00:00:00Z", "message": "world"}),
797+
json!({"body_timestamp": "not a timestamp", "message": "hello"}),
798+
json!({"body_timestamp": "also not a timestamp", "message": "world"}),
781799
];
782800

783801
let mut field_mapping = HashMap::new();
@@ -786,7 +804,22 @@ mod tests {
786804
"body_timestamp_timestamp_ms".to_string(),
787805
);
788806

789-
let renamed = rename_conflicting_fields_in_json(values, &field_mapping);
807+
let mut existing_schema: HashMap<String, Arc<Field>> = HashMap::new();
808+
existing_schema.insert(
809+
"body_timestamp".to_string(),
810+
Arc::new(Field::new(
811+
"body_timestamp",
812+
DataType::Timestamp(TimeUnit::Millisecond, None),
813+
true,
814+
)),
815+
);
816+
817+
let renamed = rename_conflicting_fields_in_json(
818+
values,
819+
&field_mapping,
820+
&existing_schema,
821+
SchemaVersion::V1,
822+
);
790823

791824
assert_eq!(renamed.len(), 2);
792825
assert!(renamed[0].get("body_timestamp_timestamp_ms").is_some());
@@ -799,12 +832,44 @@ mod tests {
799832
let values = vec![json!({"body_timestamp": "2025-01-01T00:00:00Z"})];
800833

801834
let field_mapping = HashMap::new();
802-
let renamed = rename_conflicting_fields_in_json(values.clone(), &field_mapping);
835+
let existing_schema = HashMap::new();
836+
let renamed = rename_conflicting_fields_in_json(
837+
values.clone(),
838+
&field_mapping,
839+
&existing_schema,
840+
SchemaVersion::V1,
841+
);
803842

804843
// Should return values unchanged
805844
assert_eq!(renamed, values);
806845
}
807846

847+
#[test]
848+
fn test_rename_conflicting_fields_in_json_only_incompatible_records() {
849+
let values = vec![json!({"level": 30}), json!({"level": "info"})];
850+
851+
let mut field_mapping = HashMap::new();
852+
field_mapping.insert("level".to_string(), "level_utf8".to_string());
853+
854+
let mut existing_schema: HashMap<String, Arc<Field>> = HashMap::new();
855+
existing_schema.insert(
856+
"level".to_string(),
857+
Arc::new(Field::new("level", DataType::Float64, true)),
858+
);
859+
860+
let renamed = rename_conflicting_fields_in_json(
861+
values,
862+
&field_mapping,
863+
&existing_schema,
864+
SchemaVersion::V1,
865+
);
866+
867+
assert_eq!(renamed[0].get("level"), Some(&json!(30)));
868+
assert!(renamed[0].get("level_utf8").is_none());
869+
assert_eq!(renamed[1].get("level_utf8"), Some(&json!("info")));
870+
assert!(renamed[1].get("level").is_none());
871+
}
872+
808873
#[test]
809874
fn test_detect_schema_conflicts_timestamp_vs_utf8() {
810875
// Existing schema has body_timestamp as Timestamp

0 commit comments

Comments
 (0)