Skip to main content

codecs/encoding/format/
parquet.rs

1// Derivative's Debug impl generates 'let _ = field.fmt(f)' which triggers this lint.
2#![allow(clippy::let_underscore_must_use)]
3
4//! Parquet batch format codec for batched event encoding
5//!
6//! Provides Apache Parquet format encoding with schema file support and auto-inference.
7//! Reuses the Arrow record batch building logic from the Arrow IPC codec,
8//! then writes the batch as a complete Parquet file using `ArrowWriter`.
9
10use 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/// Compression algorithm and optional level for archive objects.
39#[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 compression. Level must be between 1 and 21.
47    Zstd {
48        /// Compression level (1–21). This is the range Vector supports; higher values compress more but are slower.
49        #[configurable(validation(range(min = 1, max = 21)))]
50        level: u8,
51    },
52    /// Gzip compression. Level must be between 1 and 9.
53    Gzip {
54        /// Compression level (1–9). This is the range Vector supports; higher values compress more but are slower.
55        #[configurable(validation(range(min = 1, max = 9)))]
56        level: u8,
57    },
58
59    /// Snappy compression (no level).
60    #[default]
61    Snappy,
62
63    /// LZ4 raw compression
64    Lz4,
65
66    /// No compression
67    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/// Schema handling mode.
90#[configurable_component]
91#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
92#[serde(rename_all = "snake_case")]
93pub enum ParquetSchemaMode {
94    /// Missing fields become null. Extra fields are silently dropped.
95    #[default]
96    Relaxed,
97    /// Missing fields become null. Extra fields cause an error.
98    Strict,
99    /// Auto infer schema based on the batch. No schema file needed.
100    AutoInfer,
101}
102
103/// Configuration for the Parquet serializer.
104///
105/// Encodes events as Apache Parquet columnar files, optimized for analytical queries
106/// via Athena, Trino, Spark, and other columnar query engines.
107///
108/// Either `schema_file` must be provided, or `schema_mode` must be set to `auto_infer`.
109#[configurable_component]
110#[derive(Clone, Debug, Default)]
111pub struct ParquetSerializerConfig {
112    /// Path to a native Parquet schema file (`.schema`).
113    ///
114    /// Required unless `schema_mode` is `auto_infer`. The file must contain a valid
115    /// Parquet message type definition.
116    #[serde(default)]
117    pub schema_file: Option<PathBuf>,
118
119    /// Compression codec applied per column page inside the Parquet file.
120    #[serde(default)]
121    #[configurable(derived)]
122    pub compression: ParquetCompression,
123
124    /// Controls how events with fields not present in the schema are handled.
125    #[serde(default)]
126    #[configurable(derived)]
127    pub schema_mode: ParquetSchemaMode,
128}
129
130impl ParquetSerializerConfig {
131    /// Resolve the Arrow schema from the configured schema source.
132    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    /// The data type of events that are accepted by `ParquetSerializer`.
152    pub fn input_type(&self) -> vector_core::config::DataType {
153        vector_core::config::DataType::Log
154    }
155
156    /// The schema required by the serializer.
157    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; // 10 MB
167    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
181/// Check the resolved Arrow schema for data types unsupported by the JSON-based
182/// encode path (`arrow::json::reader::ReaderBuilder`). Binary variants are
183/// accepted by Parquet/Arrow at the schema level but the JSON decoder rejects
184/// them at runtime, so we fail fast here at config time.
185fn 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/// Parquet batch serializer.
233#[derive(Derivative)]
234#[derivative(Debug, Clone)]
235pub struct ParquetSerializer {
236    schema: SchemaRef,
237    writer_props: Arc<WriterProperties>,
238    schema_mode: ParquetSchemaMode,
239    /// Pre-built set of schema field names for O(1) strict-mode lookups.
240    schema_field_names: HashSet<String>,
241
242    #[derivative(Debug = "ignore")]
243    events_dropped_handle: Registered<EventsDroppedError>,
244}
245
246impl ParquetSerializer {
247    /// Create a new `ParquetSerializer` from the given configuration.
248    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    /// Returns the MIME content type for Parquet data.
279    pub const fn content_type(&self) -> &'static str {
280        "application/vnd.apache.parquet"
281    }
282
283    /// Writes `record_batch` into `buffer` as a complete Parquet file.
284    ///
285    /// On failure, emits an [`ArrowWriterError`] internal event (which
286    /// increments `component_errors_total`) before returning the error.
287    /// The caller is responsible for emitting `events_dropped`.
288    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            // In strict mode, check for extra top-level fields not in the schema.
347            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    /// Attempt to modify schema to set timestamp fields as Timestamp instead of Utf8.
396    /// Only works for top-level fields.
397    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    /// Write a temporary Parquet schema file and return its path.
575    ///
576    /// `name` must be unique per test to avoid parallel-test races on the same file.
577    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    // ── AutoInfer mode ───────────────────────────────────────────────────────
590
591    #[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    // ── Schema file mode ─────────────────────────────────────────────────────
751
752    #[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    // ── Schema mode: strict / relaxed ────────────────────────────────────────
832
833    #[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        // Native Parquet "binary" without (STRING) annotation resolves to Arrow Binary,
934        // which is rejected at config time.
935        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}