1#![allow(clippy::let_underscore_must_use)]
3
4use std::collections::HashSet;
11use std::path::PathBuf;
12use std::sync::Arc;
13
14use arrow::datatypes::{DataType, Field, Schema, SchemaRef};
15use arrow::error::ArrowError;
16use arrow::json::reader::infer_json_schema_from_iterator;
17use arrow::record_batch::RecordBatch;
18use bytes::{BufMut, BytesMut};
19use derivative::Derivative;
20use parquet::arrow::ArrowWriter;
21use parquet::basic::ZstdLevel;
22use parquet::basic::{Compression as ParquetCodecCompression, GzipLevel};
23use parquet::file::properties::WriterProperties;
24use std::io::{Error, ErrorKind};
25use tracing::warn;
26use vector_common::internal_event::{
27 ComponentEventsDropped, Count, InternalEventHandle, Registered, UNINTENTIONAL, emit, register,
28};
29use vector_config::configurable_component;
30use vector_core::event::Event;
31
32use super::arrow::{ArrowEncodingError, build_record_batch};
33use crate::encoding::format::arrow::vector_log_events_to_json_values;
34use crate::internal_events::{ArrowWriterError, JsonSerializationError, SchemaGenerationError};
35
36type EventsDroppedError = ComponentEventsDropped<'static, UNINTENTIONAL>;
37
38#[configurable_component]
40#[derive(Default, Copy, Clone, Debug, PartialEq)]
41#[configurable(metadata(
42 docs::enum_tag_description = "Compression codec applied per column page inside the Parquet file."
43))]
44#[serde(tag = "algorithm", rename_all = "snake_case")]
45pub enum ParquetCompression {
46 Zstd {
48 #[configurable(validation(range(min = 1, max = 21)))]
50 level: u8,
51 },
52 Gzip {
54 #[configurable(validation(range(min = 1, max = 9)))]
56 level: u8,
57 },
58
59 #[default]
61 Snappy,
62
63 Lz4,
65
66 None,
68}
69
70impl TryFrom<ParquetCompression> for ParquetCodecCompression {
71 type Error = parquet::errors::ParquetError;
72 fn try_from(
73 value: ParquetCompression,
74 ) -> Result<ParquetCodecCompression, parquet::errors::ParquetError> {
75 match value {
76 ParquetCompression::None => Ok(ParquetCodecCompression::UNCOMPRESSED),
77 ParquetCompression::Snappy => Ok(ParquetCodecCompression::SNAPPY),
78 ParquetCompression::Zstd { level } => Ok(ParquetCodecCompression::ZSTD(
79 ZstdLevel::try_new(level.into())?,
80 )),
81 ParquetCompression::Gzip { level } => Ok(ParquetCodecCompression::GZIP(
82 GzipLevel::try_new(level.into())?,
83 )),
84 ParquetCompression::Lz4 => Ok(ParquetCodecCompression::LZ4_RAW),
85 }
86 }
87}
88
89#[configurable_component]
91#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
92#[serde(rename_all = "snake_case")]
93pub enum ParquetSchemaMode {
94 #[default]
96 Relaxed,
97 Strict,
99 AutoInfer,
101}
102
103#[configurable_component]
110#[derive(Clone, Debug, Default)]
111pub struct ParquetSerializerConfig {
112 #[serde(default)]
117 pub schema_file: Option<PathBuf>,
118
119 #[serde(default)]
121 #[configurable(derived)]
122 pub compression: ParquetCompression,
123
124 #[serde(default)]
126 #[configurable(derived)]
127 pub schema_mode: ParquetSchemaMode,
128}
129
130impl ParquetSerializerConfig {
131 fn resolve_schema(&self) -> Result<Schema, Box<dyn std::error::Error + Send + Sync>> {
133 if self.schema_mode == ParquetSchemaMode::AutoInfer {
134 return Ok(Schema::empty());
135 }
136
137 let path = self
138 .schema_file
139 .as_ref()
140 .ok_or("schema_file is required unless schema_mode is auto_infer")?;
141
142 let content = read_schema_file(path, "schema_file")?;
143 let parquet_type = parquet::schema::parser::parse_message_type(&content)
144 .map_err(|e| format!("Failed to parse Parquet schema: {e}"))?;
145 let schema_desc = parquet::schema::types::SchemaDescriptor::new(Arc::new(parquet_type));
146 let arrow_schema = parquet::arrow::parquet_to_arrow_schema(&schema_desc, None)
147 .map_err(|e| format!("Failed to convert Parquet schema to Arrow: {e}"))?;
148 Ok(arrow_schema)
149 }
150
151 pub fn input_type(&self) -> vector_core::config::DataType {
153 vector_core::config::DataType::Log
154 }
155
156 pub fn schema_requirement(&self) -> vector_core::schema::Requirement {
158 vector_core::schema::Requirement::empty()
159 }
160}
161
162fn read_schema_file(
163 path: &std::path::Path,
164 field_name: &str,
165) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
166 const MAX_SCHEMA_FILE_SIZE: u64 = 10 * 1024 * 1024; let display = path.display();
168 let metadata = std::fs::metadata(path)
169 .map_err(|e| format!("Failed to read {field_name} '{display}': {e}"))?;
170 if metadata.len() > MAX_SCHEMA_FILE_SIZE {
171 return Err(format!(
172 "{field_name} '{display}' is too large ({} bytes, max {MAX_SCHEMA_FILE_SIZE})",
173 metadata.len()
174 )
175 .into());
176 }
177 std::fs::read_to_string(path)
178 .map_err(|e| format!("Failed to read {field_name} '{display}': {e}").into())
179}
180
181fn reject_unsupported_arrow_types(
186 schema: &Schema,
187) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
188 fn check_field(field: &Field, path: &str, bad: &mut Vec<String>) {
189 let name = if path.is_empty() {
190 field.name().to_string()
191 } else {
192 format!("{path}.{}", field.name())
193 };
194 match field.data_type() {
195 DataType::Binary | DataType::LargeBinary | DataType::FixedSizeBinary(_) => {
196 bad.push(format!("'{name}' ({:?})", field.data_type()));
197 }
198 DataType::Struct(fields) => {
199 for f in fields {
200 check_field(f, &name, bad);
201 }
202 }
203 DataType::List(inner) | DataType::LargeList(inner) => {
204 check_field(inner, &name, bad);
205 }
206 DataType::Map(entries_field, _) => {
207 if let DataType::Struct(kv) = entries_field.data_type() {
208 for f in kv {
209 check_field(f, &name, bad);
210 }
211 }
212 }
213 _ => {}
214 }
215 }
216
217 let mut bad = Vec::new();
218 for field in schema.fields() {
219 check_field(field, "", &mut bad);
220 }
221 if !bad.is_empty() {
222 return Err(format!(
223 "Schema contains binary field(s) unsupported by the JSON-based Arrow encoder: {}. \
224 Use Utf8 for base64/hex-encoded data instead.",
225 bad.join(", ")
226 )
227 .into());
228 }
229 Ok(())
230}
231
232#[derive(Derivative)]
234#[derivative(Debug, Clone)]
235pub struct ParquetSerializer {
236 schema: SchemaRef,
237 writer_props: Arc<WriterProperties>,
238 schema_mode: ParquetSchemaMode,
239 schema_field_names: HashSet<String>,
241
242 #[derivative(Debug = "ignore")]
243 events_dropped_handle: Registered<EventsDroppedError>,
244}
245
246impl ParquetSerializer {
247 pub fn new(
249 config: ParquetSerializerConfig,
250 ) -> Result<Self, Box<dyn std::error::Error + Send + Sync + 'static>> {
251 let schema = config.resolve_schema()?;
252 reject_unsupported_arrow_types(&schema)?;
253 let schema_ref = SchemaRef::new(schema);
254
255 let schema_field_names = schema_ref
256 .fields()
257 .iter()
258 .map(|f| f.name().clone())
259 .collect::<HashSet<_>>();
260
261 let writer_props = Arc::new(
262 WriterProperties::builder()
263 .set_compression(config.compression.try_into()?)
264 .build(),
265 );
266
267 Ok(Self {
268 schema: schema_ref,
269 writer_props,
270 schema_mode: config.schema_mode,
271 schema_field_names,
272 events_dropped_handle: register(EventsDroppedError::from(
273 "Events could not be serialized to parquet",
274 )),
275 })
276 }
277
278 pub const fn content_type(&self) -> &'static str {
280 "application/vnd.apache.parquet"
281 }
282
283 fn write_record_batch(
289 record_batch: &RecordBatch,
290 buffer: &mut BytesMut,
291 writer_props: &WriterProperties,
292 ) -> Result<(), parquet::errors::ParquetError> {
293 let mut writer = ArrowWriter::try_new(
294 buffer.writer(),
295 Arc::clone(record_batch.schema_ref()),
296 Some(writer_props.clone()),
297 )
298 .inspect_err(|e| {
299 emit(ArrowWriterError { error: e });
300 })?;
301
302 writer.write(record_batch).inspect_err(|e| {
303 emit(ArrowWriterError { error: e });
304 })?;
305
306 writer.close().inspect_err(|e| {
307 emit(ArrowWriterError { error: e });
308 })?;
309
310 Ok(())
311 }
312}
313
314impl tokio_util::codec::Encoder<Vec<Event>> for ParquetSerializer {
315 type Error = vector_common::Error;
316
317 fn encode(&mut self, events: Vec<Event>, buffer: &mut BytesMut) -> Result<(), Self::Error> {
318 if events.is_empty() {
319 return Ok(());
320 }
321
322 let json_values = match vector_log_events_to_json_values(&events) {
323 Ok(values) => values,
324 Err(e) => {
325 emit(JsonSerializationError { error: &e });
326 return Err(Box::new(e));
327 }
328 };
329
330 let non_log_count = events.len() - json_values.len();
331
332 if non_log_count > 0 {
333 warn!(
334 message = "Non-log events dropped by Parquet encoder ",
335 %non_log_count,
336 internal_log_rate_secs = 10,
337 );
338 self.events_dropped_handle.emit(Count(non_log_count))
339 }
340
341 if json_values.is_empty() {
342 return Ok(());
343 }
344
345 match self.schema_mode {
346 ParquetSchemaMode::Strict => {
348 for event in &events {
349 if let Some(log) = event.maybe_as_log()
350 && let Some(object_map) = log.as_map()
351 {
352 for top_level in object_map.keys() {
353 if !self.schema_field_names.contains(top_level.as_str()) {
354 return Err(Box::new(ArrowEncodingError::SchemaFetchError {
355 message: format!(
356 "Strict schema mode: event contains field '{top_level}' not in schema",
357 ),
358 }));
359 }
360 }
361 }
362 }
363 }
364 ParquetSchemaMode::AutoInfer => {
365 let schema = ParquetSchemaGenerator::infer_schema(&json_values)?;
366 self.schema = Arc::new(ParquetSchemaGenerator::try_normalize_schema(
367 &events, schema,
368 ));
369 }
370 ParquetSchemaMode::Relaxed => {}
371 }
372
373 let record_batch =
374 build_record_batch(Arc::clone(&self.schema), &json_values).map_err(Box::new)?;
375
376 Self::write_record_batch(&record_batch, buffer, &self.writer_props).map_err(Box::new)?;
377
378 Ok(())
379 }
380}
381
382pub struct ParquetSchemaGenerator {}
383
384impl ParquetSchemaGenerator {
385 pub fn infer_schema(events: &[serde_json::Value]) -> Result<Schema, Error> {
386 let schema = infer_json_schema_from_iterator(events.iter().map(Ok::<_, ArrowError>))
387 .map_err(|e| {
388 emit(SchemaGenerationError { error: &e });
389 Error::new(ErrorKind::InvalidData, e.to_string())
390 })?;
391
392 Ok(schema)
393 }
394
395 fn try_normalize_schema(events: &[Event], schema: Schema) -> Schema {
398 let mut ts_seen: HashSet<String> = HashSet::new();
399 let mut non_ts_seen: HashSet<String> = HashSet::new();
400
401 for event in events.iter().filter_map(Event::maybe_as_log) {
402 if let Some(object_map) = event.as_map() {
403 for (path, value) in object_map {
404 if value.is_timestamp() {
405 ts_seen.insert(path.to_string());
406 } else if !value.is_null() {
407 non_ts_seen.insert(path.to_string());
408 }
409 }
410 }
411 }
412
413 let new_fields: Vec<Field> = schema
414 .fields()
415 .iter()
416 .map(|f| {
417 if ts_seen.contains(f.name()) && !non_ts_seen.contains(f.name()) {
418 Field::new(
419 f.name(),
420 DataType::Timestamp(
421 arrow::datatypes::TimeUnit::Microsecond,
422 Some("UTC".into()),
423 ),
424 f.is_nullable(),
425 )
426 } else {
427 f.as_ref().clone()
428 }
429 })
430 .collect();
431
432 Schema::new_with_metadata(new_fields, schema.metadata().clone())
433 }
434}
435
436#[cfg(test)]
437mod tests {
438 use super::*;
439 use bytes::Bytes;
440 use parquet::file::reader::{FileReader, SerializedFileReader};
441 use parquet::record::reader::RowIter;
442 use tokio_util::codec::Encoder;
443 use vector_core::event::LogEvent;
444 use vrl::event_path;
445
446 fn create_event<V>(fields: Vec<(&str, V)>) -> Event
447 where
448 V: Into<vector_core::event::Value>,
449 {
450 let mut log = LogEvent::default();
451 for (key, value) in fields {
452 log.insert(&vrl::path::parse_target_path(key).unwrap(), value.into());
453 }
454 Event::Log(log)
455 }
456
457 fn assert_parquet_magic(data: &[u8]) {
458 assert!(data.len() >= 4, "Output too short to be valid Parquet");
459 assert_eq!(&data[..4], b"PAR1", "Missing Parquet magic bytes");
460 }
461
462 fn parquet_row_count(data: &[u8]) -> usize {
463 let reader =
464 SerializedFileReader::new(Bytes::copy_from_slice(data)).expect("Invalid Parquet file");
465 let iter = RowIter::from_file_into(Box::new(reader));
466 iter.count()
467 }
468
469 fn parquet_column_names(data: &[u8]) -> Vec<String> {
470 let reader =
471 SerializedFileReader::new(Bytes::copy_from_slice(data)).expect("Invalid Parquet file");
472 let schema = reader.metadata().file_metadata().schema_descr();
473 schema
474 .columns()
475 .iter()
476 .map(|c| c.name().to_string())
477 .collect()
478 }
479
480 fn parse_timestamp(s: &str) -> chrono::DateTime<chrono::Utc> {
481 chrono::DateTime::parse_from_rfc3339(s)
482 .expect("invalid test timestamp")
483 .with_timezone(&chrono::Utc)
484 }
485
486 fn demo_log_event(
487 message: &str,
488 timestamp: chrono::DateTime<chrono::Utc>,
489 status_code: i64,
490 response_time_secs: f64,
491 ) -> Event {
492 use vector_core::event::Value;
493 let mut log = LogEvent::default();
494 log.insert(event_path!("host"), "localhost");
495 log.insert(event_path!("message"), message);
496 log.insert(event_path!("service"), "vector");
497 log.insert(event_path!("source_type"), "demo_logs");
498 log.insert(event_path!("timestamp"), Value::Timestamp(timestamp));
499 log.insert(event_path!("random_time"), Value::Timestamp(timestamp));
500 log.insert(event_path!("status_code"), Value::Integer(status_code));
501 log.insert(event_path!("response_time_secs"), response_time_secs);
502 Event::Log(log)
503 }
504
505 fn sample_events() -> Vec<Event> {
506 const EVENTS: [(&str, &str, i64, f64); 5] = [
507 (
508 "GET /api/v1/health HTTP/1.1",
509 "2026-03-05T20:49:08.037194Z",
510 200,
511 0.037,
512 ),
513 (
514 "POST /api/v1/ingest HTTP/1.1",
515 "2026-03-05T20:49:09.038051Z",
516 201,
517 0.013,
518 ),
519 (
520 "GET /metrics HTTP/1.1",
521 "2026-03-05T20:49:10.036612Z",
522 200,
523 0.022,
524 ),
525 (
526 "DELETE /api/v1/resource HTTP/1.1",
527 "2026-03-05T20:49:11.537131Z",
528 404,
529 0.005,
530 ),
531 (
532 "PATCH /api/v1/config HTTP/1.1",
533 "2026-03-05T20:49:12.037491Z",
534 500,
535 0.091,
536 ),
537 ];
538 EVENTS
539 .iter()
540 .map(|(msg, ts, status, rt)| demo_log_event(msg, parse_timestamp(ts), *status, *rt))
541 .collect()
542 }
543
544 fn encode_autoinfer_and_read_schema(
545 events: Vec<Event>,
546 ) -> (arrow::datatypes::SchemaRef, usize) {
547 use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
548
549 let mut serializer = ParquetSerializer::new(ParquetSerializerConfig {
550 schema_mode: ParquetSchemaMode::AutoInfer,
551 ..Default::default()
552 })
553 .expect("AutoInfer serializer should be created without a static schema");
554
555 let mut buffer = BytesMut::new();
556 serializer
557 .encode(events, &mut buffer)
558 .expect("encoding should succeed");
559
560 let data = buffer.freeze();
561 assert_parquet_magic(&data);
562
563 let builder = ParquetRecordBatchReaderBuilder::try_new(data)
564 .expect("should build ParquetRecordBatchReaderBuilder");
565 let schema = builder.schema().clone();
566 let num_rows: usize = builder
567 .build()
568 .expect("should build reader")
569 .map(|b| b.expect("batch read error").num_rows())
570 .sum();
571 (schema, num_rows)
572 }
573
574 fn write_temp_schema(name: &str, content: &str) -> std::path::PathBuf {
578 use std::io::Write;
579 let path = std::env::temp_dir().join(format!(
580 "vector_parquet_test_{}_{}.schema",
581 std::process::id(),
582 name,
583 ));
584 let mut f = std::fs::File::create(&path).expect("Failed to create schema file");
585 write!(f, "{content}").expect("Failed to write schema");
586 path
587 }
588
589 #[test]
592 fn encode_input_produces_parquet_output() {
593 let events = sample_events();
594 let n_events = events.len();
595 let (schema, num_rows) = encode_autoinfer_and_read_schema(events);
596
597 assert_eq!(num_rows, n_events, "row count should match event count");
598
599 for field_name in &["timestamp", "random_time"] {
600 let field = schema
601 .field_with_name(field_name)
602 .unwrap_or_else(|_| panic!("field '{field_name}' should exist in schema"));
603 assert!(
604 matches!(
605 field.data_type(),
606 DataType::Timestamp(arrow::datatypes::TimeUnit::Microsecond, Some(tz)) if tz.as_ref() == "UTC"
607 ),
608 "'{field_name}' should be Timestamp(Microsecond, UTC), got {:?}",
609 field.data_type()
610 );
611 }
612
613 let status_field = schema
614 .field_with_name("status_code")
615 .expect("status_code field should exist");
616 assert_eq!(status_field.data_type(), &DataType::Int64);
617
618 let rt_field = schema
619 .field_with_name("response_time_secs")
620 .expect("response_time_secs field should exist");
621 assert_eq!(rt_field.data_type(), &DataType::Float64);
622
623 for field_name in &["host", "message", "service", "source_type"] {
624 let field = schema
625 .field_with_name(field_name)
626 .unwrap_or_else(|_| panic!("field '{field_name}' should exist in schema"));
627 assert_eq!(field.data_type(), &DataType::Utf8);
628 }
629 }
630
631 #[test]
632 fn test_parquet_empty_events() {
633 let mut serializer = ParquetSerializer::new(ParquetSerializerConfig {
634 schema_mode: ParquetSchemaMode::AutoInfer,
635 ..Default::default()
636 })
637 .expect("AutoInfer serializer should succeed");
638
639 let events: Vec<Event> = vec![];
640 let mut buffer = BytesMut::new();
641 serializer
642 .encode(events, &mut buffer)
643 .expect("Empty events should succeed");
644
645 assert!(buffer.is_empty(), "Buffer should be empty for empty events");
646 }
647
648 #[test]
649 fn test_parquet_compression_variants() {
650 let events = vec![create_event(vec![("msg", "hello world")])];
651
652 let compressions = vec![
653 ParquetCompression::None,
654 ParquetCompression::Snappy,
655 ParquetCompression::Zstd { level: 1 },
656 ParquetCompression::Gzip { level: 1 },
657 ParquetCompression::Lz4,
658 ];
659
660 for compression in compressions {
661 let mut serializer = ParquetSerializer::new(ParquetSerializerConfig {
662 schema_mode: ParquetSchemaMode::AutoInfer,
663 compression,
664 ..Default::default()
665 })
666 .expect("Failed to create serializer");
667
668 let mut buffer = BytesMut::new();
669 serializer
670 .encode(events.clone(), &mut buffer)
671 .unwrap_or_else(|e| panic!("Encoding with {:?} failed: {}", compression, e));
672
673 let data = buffer.freeze();
674 assert_parquet_magic(&data);
675 assert_eq!(
676 parquet_row_count(&data),
677 1,
678 "Wrong row count for {:?}",
679 compression
680 );
681 }
682 }
683
684 #[test]
685 fn test_parquet_output_has_footer() {
686 let mut serializer = ParquetSerializer::new(ParquetSerializerConfig {
687 schema_mode: ParquetSchemaMode::AutoInfer,
688 ..Default::default()
689 })
690 .expect("AutoInfer serializer should succeed");
691
692 let events = vec![create_event(vec![("msg", "test")])];
693 let mut buffer = BytesMut::new();
694 serializer.encode(events, &mut buffer).unwrap();
695
696 let data = buffer.freeze();
697 let len = data.len();
698 assert!(len >= 8, "Parquet output too short");
699 assert_eq!(
700 &data[len - 4..],
701 b"PAR1",
702 "Parquet footer magic bytes missing"
703 );
704 }
705
706 #[test]
707 fn test_writer_props_arc_shared() {
708 let serializer = ParquetSerializer::new(ParquetSerializerConfig {
709 schema_mode: ParquetSchemaMode::AutoInfer,
710 ..Default::default()
711 })
712 .expect("AutoInfer serializer should succeed");
713 let cloned = serializer.clone();
714
715 assert_eq!(Arc::strong_count(&serializer.writer_props), 2);
716 drop(cloned);
717 assert_eq!(Arc::strong_count(&serializer.writer_props), 1);
718 }
719
720 #[test]
721 fn test_mixed_log_and_non_log_events() {
722 use vector_core::event::{Metric, MetricKind, MetricValue};
723
724 let mut serializer = ParquetSerializer::new(ParquetSerializerConfig {
725 schema_mode: ParquetSchemaMode::AutoInfer,
726 ..Default::default()
727 })
728 .expect("AutoInfer serializer should succeed");
729
730 let metric = Metric::new(
731 "cpu.usage",
732 MetricKind::Absolute,
733 MetricValue::Gauge { value: 42.0 },
734 );
735 let events = vec![
736 create_event(vec![("msg", "hello")]),
737 Event::Metric(metric),
738 create_event(vec![("msg", "world")]),
739 ];
740
741 let mut buffer = BytesMut::new();
742 serializer
743 .encode(events, &mut buffer)
744 .expect("Mixed batch should succeed (non-log events dropped)");
745
746 assert_parquet_magic(&buffer);
747 assert_eq!(parquet_row_count(&buffer), 2);
748 }
749
750 #[test]
753 fn test_parquet_schema_file() {
754 let schema_path = write_temp_schema(
755 "schema_file",
756 "message logs {\n required binary name (STRING);\n optional int64 age;\n}",
757 );
758
759 let config: ParquetSerializerConfig = serde_json::from_value(serde_json::json!({
760 "schema_file": schema_path.to_str().unwrap()
761 }))
762 .expect("Config should deserialize");
763
764 let mut serializer =
765 ParquetSerializer::new(config).expect("Should create serializer from schema file");
766
767 let mut log = LogEvent::default();
768 log.insert(event_path!("name"), "alice");
769
770 let mut buffer = BytesMut::new();
771 serializer
772 .encode(vec![Event::Log(log)], &mut buffer)
773 .expect("Encoding with schema file should succeed");
774
775 let data = buffer.freeze();
776 assert_parquet_magic(&data);
777 assert_eq!(parquet_row_count(&data), 1);
778
779 let columns = parquet_column_names(&data);
780 assert_eq!(columns, vec!["name", "age"]);
781 }
782
783 #[test]
784 fn test_parquet_schema_file_not_found_error() {
785 let config: ParquetSerializerConfig = serde_json::from_value(serde_json::json!({
786 "schema_file": "/nonexistent/path/schema.parquet"
787 }))
788 .expect("Config should deserialize");
789
790 let result = ParquetSerializer::new(config);
791 assert!(result.is_err(), "Missing schema file should error");
792 assert!(
793 result.unwrap_err().to_string().contains("Failed to read"),
794 "Error should mention file read failure"
795 );
796 }
797
798 #[test]
799 fn test_parquet_schema_file_invalid_syntax_error() {
800 let schema_path = write_temp_schema(
801 "invalid_syntax",
802 "this is not valid parquet schema syntax !!!",
803 );
804
805 let config: ParquetSerializerConfig = serde_json::from_value(serde_json::json!({
806 "schema_file": schema_path.to_str().unwrap()
807 }))
808 .expect("Config should deserialize");
809
810 let result = ParquetSerializer::new(config);
811 assert!(result.is_err(), "Invalid Parquet schema should error");
812 assert!(
813 result
814 .unwrap_err()
815 .to_string()
816 .contains("Failed to parse Parquet schema"),
817 "Error should mention parsing failure"
818 );
819 }
820
821 #[test]
822 fn test_parquet_no_schema_error() {
823 let config = ParquetSerializerConfig::default();
824 let result = ParquetSerializer::new(config);
825 assert!(
826 result.is_err(),
827 "Should fail without schema_file or auto_infer"
828 );
829 }
830
831 #[test]
834 fn test_parquet_strict_mode_rejects_extra_fields() {
835 let schema_path = write_temp_schema(
836 "strict_rejects",
837 "message logs {\n required binary name (STRING);\n}",
838 );
839
840 let mut serializer = ParquetSerializer::new(ParquetSerializerConfig {
841 schema_file: Some(schema_path),
842 schema_mode: ParquetSchemaMode::Strict,
843 ..Default::default()
844 })
845 .expect("Failed to create strict serializer");
846
847 let events = vec![create_event(vec![("name", "alice"), ("city", "paris")])];
848 let mut buffer = BytesMut::new();
849 let result = serializer.encode(events, &mut buffer);
850 assert!(result.is_err(), "Strict mode should reject extra fields");
851 assert!(result.unwrap_err().to_string().contains("city"));
852 }
853
854 #[test]
855 fn test_parquet_strict_mode_allows_schema_fields() {
856 let schema_path = write_temp_schema(
857 "strict_allows",
858 "message logs {\n required binary name (STRING);\n required binary level (STRING);\n}",
859 );
860
861 let mut serializer = ParquetSerializer::new(ParquetSerializerConfig {
862 schema_file: Some(schema_path),
863 schema_mode: ParquetSchemaMode::Strict,
864 ..Default::default()
865 })
866 .expect("Failed to create strict serializer");
867
868 let mut log = LogEvent::default();
869 log.insert(event_path!("name"), "test");
870 log.insert(event_path!("level"), "info");
871
872 let mut buffer = BytesMut::new();
873 assert!(
874 serializer
875 .encode(vec![Event::Log(log)], &mut buffer)
876 .is_ok(),
877 "Strict mode should pass when all fields match schema"
878 );
879 }
880
881 #[test]
882 fn test_parquet_relaxed_mode_drops_extra_fields() {
883 let schema_path = write_temp_schema(
884 "relaxed_drops",
885 "message logs {\n required binary name (STRING);\n}",
886 );
887
888 let mut serializer = ParquetSerializer::new(ParquetSerializerConfig {
889 schema_file: Some(schema_path),
890 schema_mode: ParquetSchemaMode::Relaxed,
891 ..Default::default()
892 })
893 .expect("Failed to create relaxed serializer");
894
895 let events = vec![create_event(vec![("name", "alice"), ("city", "paris")])];
896 let mut buffer = BytesMut::new();
897 serializer
898 .encode(events, &mut buffer)
899 .expect("Relaxed mode should drop extra fields silently");
900
901 let data = buffer.freeze();
902 assert_parquet_magic(&data);
903 assert_eq!(parquet_row_count(&data), 1);
904 let columns = parquet_column_names(&data);
905 assert_eq!(columns, vec!["name"]);
906 }
907
908 #[test]
909 fn test_parquet_type_mismatch_returns_error() {
910 let schema_path =
911 write_temp_schema("type_mismatch", "message logs {\n required int64 name;\n}");
912
913 let mut serializer = ParquetSerializer::new(ParquetSerializerConfig {
914 schema_file: Some(schema_path),
915 schema_mode: ParquetSchemaMode::Relaxed,
916 ..Default::default()
917 })
918 .expect("Failed to create serializer");
919
920 let events = vec![create_event(vec![("name", "not_an_integer")])];
921 let mut buffer = BytesMut::new();
922 let result = serializer.encode(events, &mut buffer);
923 assert!(result.is_err(), "Type mismatch should return an error");
924 let err = result.unwrap_err().to_string();
925 assert!(
926 err.contains("Int64"),
927 "Error should mention the expected type, got: {err}"
928 );
929 }
930
931 #[test]
932 fn test_parquet_schema_file_binary_without_string_annotation_rejected() {
933 let schema_path = write_temp_schema(
936 "binary_rejected",
937 "message logs {\n required binary name (STRING);\n optional binary raw_data;\n}",
938 );
939
940 let config: ParquetSerializerConfig = serde_json::from_value(serde_json::json!({
941 "schema_file": schema_path.to_str().unwrap()
942 }))
943 .expect("Config should deserialize");
944
945 let result = ParquetSerializer::new(config);
946 assert!(
947 result.is_err(),
948 "Parquet binary without STRING annotation should be rejected"
949 );
950 assert!(
951 result.unwrap_err().to_string().contains("raw_data"),
952 "Error should name the offending field"
953 );
954 }
955}