Skip to main content

manycastr/cli/writer/
parquet_writer.rs

1use crate::cli::writer::{MetadataArgs, WriteConfig};
2use crate::custom_module::manycastr::reply::ReplyData;
3use crate::custom_module::manycastr::{MeasurementReply, MeasurementType, ReplyBatch, TraceReply};
4use crate::{ALL_WORKERS, SINGLE_ORIGIN};
5use bimap::BiHashMap;
6use parquet::basic::{Compression as ParquetCompression, LogicalType, Repetition};
7use parquet::data_type::{ByteArray, FixedLenByteArray, FloatType, Int32Type};
8use parquet::file::properties::WriterProperties;
9use parquet::file::writer::SerializedFileWriter;
10use parquet::schema::types::{Type as SchemaType, TypePtr};
11use std::fs::File;
12use std::sync::Arc;
13use tokio::sync::mpsc::UnboundedReceiver;
14
15const ROW_BUFFER_CAPACITY: usize = 1_000_000; // Number of rows to buffer before writing (impacts RAM usage)
16const MAX_ROW_GROUP_ROW_COUNT: usize = 1_000_000;
17
18/// Write results to a Parquet file as they are received from the channel.
19/// This function processes the results in batches to optimize writing performance.
20///
21/// # Arguments
22/// * `rx` - The receiver channel that receives the results.
23/// * `config` - The configuration for writing results, including file handle, metadata, and measurement type.
24pub fn write_results_parquet(mut rx: UnboundedReceiver<ReplyBatch>, config: WriteConfig) {
25    let headers = get_parquet_header(config.m_type, config.is_sessions);
26    let schema = build_parquet_schema(headers.clone());
27
28    // Get metadata key-value pairs for the Parquet file
29    let key_value_tuples = get_parquet_metadata(config.metadata_args, &config.worker_map);
30
31    // Configure writer properties, including compression and metadata
32    let key_value_metadata: Vec<parquet::file::metadata::KeyValue> = key_value_tuples
33        .into_iter()
34        .map(|(key, value)| parquet::file::metadata::KeyValue::new(key, value))
35        .collect();
36
37    let props = Arc::new(
38        WriterProperties::builder()
39            .set_compression(ParquetCompression::ZSTD(Default::default()))
40            .set_key_value_metadata(Some(key_value_metadata)) // Use the clean metadata
41            .set_max_row_group_row_count(Some(MAX_ROW_GROUP_ROW_COUNT))
42            .build(),
43    );
44
45    let mut writer = SerializedFileWriter::new(config.output_file, schema.clone(), props)
46        .expect("Failed to create parquet writer");
47
48    tokio::spawn(async move {
49        let mut row_buffer: Vec<ParquetDataRow> = Vec::with_capacity(ROW_BUFFER_CAPACITY);
50
51        while let Some(task_result) = rx.recv().await {
52            if task_result == ReplyBatch::default() {
53                break; // End of stream
54            }
55
56            let rx_id = task_result.rx_id;
57            let origin_id = task_result.origin_id;
58            for reply in task_result.results {
59                let parquet_row = match reply.reply_data {
60                    Some(ReplyData::Measurement(m_reply)) => measurement_reply_to_parquet_row(
61                        m_reply,
62                        rx_id,
63                        config.m_type,
64                        &config.worker_map,
65                        origin_id,
66                    ),
67                    Some(ReplyData::Trace(trace_reply)) => trace_reply_to_parquet_row(
68                        trace_reply,
69                        rx_id,
70                        &config.worker_map,
71                        origin_id,
72                    ),
73                    _ => panic!("Unexpected reply data"),
74                };
75                row_buffer.push(parquet_row);
76            }
77
78            // If the buffer is full, write the batch to the file
79            if row_buffer.len() >= ROW_BUFFER_CAPACITY {
80                write_batch_to_parquet(&mut writer, &mut row_buffer, &headers)
81                    .expect("Failed to write batch to Parquet file");
82                row_buffer.clear();
83            }
84        }
85
86        // Write any remaining rows in the buffer
87        if !row_buffer.is_empty() {
88            write_batch_to_parquet(&mut writer, &mut row_buffer, &headers)
89                .expect("Failed to write final batch to Parquet file");
90        }
91
92        // Add end_time on close
93        writer.append_key_value_metadata(parquet::file::metadata::KeyValue::new(
94            "end_time".to_string(),
95            chrono::Utc::now().to_rfc3339(),
96        ));
97        writer.close().expect("Failed to close Parquet writer");
98        rx.close();
99    });
100}
101
102/// Returns a vector of key-value pairs containing the metadata of the measurement.
103pub fn get_parquet_metadata(
104    args: MetadataArgs<'_>,
105    worker_map: &BiHashMap<u32, String>,
106) -> Vec<(String, String)> {
107    let mut md = Vec::new();
108
109    // Version of the Parquet output format (bump when making incompatible changes)
110    md.push(("format_version".to_string(), "1".to_string()));
111    md.push((
112        "tool_version".to_string(),
113        env!("CARGO_PKG_VERSION").to_string(),
114    ));
115
116    md.push((
117        "measurement_type".to_string(),
118        args.m_type.as_str().to_string(),
119    ));
120
121    let start_time = chrono::DateTime::from_timestamp(args.start_time as i64, 0)
122        .map(|t| t.to_rfc3339())
123        .unwrap_or_default();
124    md.push(("start_time".to_string(), start_time));
125
126    if args.is_responsive {
127        md.push(("responsive_mode".to_string(), "true".to_string()));
128    }
129
130    md.push(("hitlist_path".to_string(), args.hitlist.to_string()));
131    md.push((
132        "hitlist_length".to_string(),
133        args.hitlist_length.to_string(),
134    ));
135    md.push(("hitlist_shuffled".to_string(), args.is_shuffle.to_string()));
136    md.push(("probing_rate".to_string(), args.probing_rate.to_string()));
137    md.push(("worker_interval_ms".to_string(), args.interval.to_string()));
138    md.push((
139        "probe_interval_s".to_string(),
140        args.probe_interval.to_string(),
141    ));
142    md.push((
143        "number_of_probes".to_string(),
144        args.number_of_probes.to_string(),
145    ));
146    if let Some(record) = args.record {
147        md.push(("record".to_string(), record.to_string()));
148    }
149    if let Some(url) = args.url {
150        md.push(("url".to_string(), url.to_string()));
151    }
152
153    let worker_hostnames: Vec<&String> = args.all_workers.right_values().collect();
154    md.push((
155        "connected_workers".to_string(),
156        serde_json::to_string(&worker_hostnames).unwrap_or_default(),
157    ));
158    md.push((
159        "connected_workers_count".to_string(),
160        args.all_workers.len().to_string(),
161    ));
162
163    // Structured origin definitions; this mapping is required to interpret the origin_id column
164    let configurations = args
165        .configurations
166        .iter()
167        .map(|c| {
168            let worker = if c.worker_id == ALL_WORKERS {
169                "ALL".to_string()
170            } else {
171                worker_map
172                    .get_by_left(&c.worker_id)
173                    .unwrap_or(&String::from("Unknown"))
174                    .to_string()
175            };
176            serde_json::json!({
177                "worker": worker,
178                "origin_id": c.origin.as_ref().map_or(0, |o| o.origin_id),
179                "src": c.origin.as_ref().and_then(|o| o.src).map(|s| s.to_string()),
180                "sport": c.origin.as_ref().map_or(0, |o| o.sport),
181                "dport": c.origin.as_ref().map_or(0, |o| o.dport),
182                "protocol": c.origin.as_ref().map(|o| o.p_type().to_string()),
183            })
184        })
185        .collect::<Vec<_>>();
186
187    md.push((
188        "configurations".to_string(),
189        serde_json::to_string(&configurations).unwrap_or_default(),
190    ));
191
192    md
193}
194
195/// Represents a row of data in the Parquet file format.
196/// Fields used depend on the measurement type and configuration (unused fields stay None).
197#[derive(Default)]
198pub struct ParquetDataRow {
199    /// Hostname of the probe receiver.
200    rx: Option<String>,
201    /// Source address of the reply as 16-byte IPv4-mapped-IPv6 (RFC 4291).
202    addr: Option<[u8; 16]>,
203    /// Time-to-live (TTL) value of the reply.
204    ttl: Option<u8>,
205    /// Hostname of the probe sender.
206    tx: Option<String>,
207    /// Round-trip time in milliseconds, computed on the worker (a signed offset in LACeS mode).
208    rtt: Option<f32>,
209    /// DNS TXT CHAOS record value.
210    chaos_data: Option<String>,
211    /// Origin ID for multi-origin measurements (source address, ports).
212    origin_id: Option<u8>,
213    /// Traceroute: destination address of the trace as 16-byte IPv4-mapped-IPv6 (RFC 4291).
214    trace_dst: Option<[u8; 16]>,
215    /// Traceroute: TTL value used to trigger this reply.
216    hop_count: Option<u8>,
217    /// Feed: session of the probe that triggered this reply (0 = no session).
218    session: Option<u32>,
219}
220
221/// Converts a MeasurementReply into a ParquetDataRow for writing to a Parquet file.
222fn measurement_reply_to_parquet_row(
223    result: MeasurementReply,
224    rx_worker_id: u32,
225    m_type: MeasurementType,
226    worker_map: &BiHashMap<u32, String>,
227    origin_id: u32,
228) -> ParquetDataRow {
229    let mut row = ParquetDataRow {
230        rx: worker_map.get_by_left(&rx_worker_id).cloned(),
231        addr: result.src.map(|s| s.to_ipv6_mapped_bytes()),
232        ttl: Some(result.ttl as u8),
233        chaos_data: result.chaos,
234        origin_id: (origin_id != SINGLE_ORIGIN).then_some(origin_id as u8),
235        ..Default::default()
236    };
237
238    match m_type {
239        MeasurementType::AnycastLatency => {
240            row.rtt = Some(result.rtt);
241        }
242        MeasurementType::Catchment => {
243            // Catchment mapping is minimal (rx, addr, ttl)
244        }
245        MeasurementType::AnycastTraceroute
246        | MeasurementType::Tracemap
247        | MeasurementType::FeedTrace => {
248            panic!("Received MeasurementReply during a traceroute measurement")
249        }
250        MeasurementType::Laces | MeasurementType::Feed => {
251            row.tx = worker_map.get_by_left(&result.tx_id).cloned();
252            // CHAOS replies carry no transmit timestamp, so there is no RTT to report
253            if row.chaos_data.is_none() {
254                row.rtt = Some(result.rtt);
255            }
256            // Feed replies are attributed to the session of the target that triggered them
257            if m_type == MeasurementType::Feed {
258                row.session = Some(result.session_id);
259            }
260        }
261    }
262
263    row
264}
265
266/// Converts a TraceReply into a ParquetDataRow for writing to a Parquet file.
267fn trace_reply_to_parquet_row(
268    reply: TraceReply,
269    rx_worker_id: u32,
270    worker_map: &BiHashMap<u32, String>,
271    origin_id: u32,
272) -> ParquetDataRow {
273    // Unresponsive hops have no calculated RTT, and no worker received a reply
274    let (rx, rtt) = if reply.hop_addr.is_some() {
275        (
276            worker_map.get_by_left(&rx_worker_id).cloned(),
277            Some(reply.rtt),
278        )
279    } else {
280        (None, None)
281    };
282
283    ParquetDataRow {
284        rx,
285        addr: reply.hop_addr.map(|a| a.to_ipv6_mapped_bytes()),
286        ttl: Some(reply.ttl as u8),
287        tx: worker_map.get_by_left(&reply.tx_id).cloned(),
288        rtt,
289        trace_dst: reply.trace_dst.map(|a| a.to_ipv6_mapped_bytes()),
290        hop_count: Some(reply.hop_count as u8),
291        origin_id: (origin_id != SINGLE_ORIGIN).then_some(origin_id as u8),
292        ..Default::default()
293    }
294}
295
296/// Returns the fixed superset of columns for a measurement type.
297/// The `session` column is only included for feed measurements with sessions enabled.
298pub fn get_parquet_header(m_type: MeasurementType, is_sessions: bool) -> Vec<&'static str> {
299    match m_type {
300        MeasurementType::AnycastTraceroute | MeasurementType::Tracemap => {
301            vec![
302                "rx",
303                "addr",
304                "ttl",
305                "tx",
306                "trace_dst",
307                "hop_count",
308                "rtt",
309                "chaos_data",
310                "origin_id",
311            ]
312        }
313        MeasurementType::FeedTrace => {
314            vec![
315                "rx",
316                "addr",
317                "ttl",
318                "tx",
319                "trace_dst",
320                "probe_ttl",
321                "rtt",
322                "chaos_data",
323                "origin_id",
324            ]
325        }
326        MeasurementType::AnycastLatency => {
327            vec!["rx", "addr", "ttl", "rtt", "chaos_data", "origin_id"]
328        }
329        MeasurementType::Catchment => vec!["rx", "addr", "ttl", "chaos_data", "origin_id"],
330        MeasurementType::Laces => {
331            vec!["rx", "addr", "ttl", "tx", "rtt", "chaos_data", "origin_id"]
332        }
333        MeasurementType::Feed => {
334            let mut header = vec!["rx", "addr", "ttl", "tx", "rtt", "chaos_data", "origin_id"];
335            if is_sessions {
336                header.push("session");
337            }
338            header
339        }
340    }
341}
342
343/// Creates a parquet data schema from the headers based on the measurement type and configuration.
344///
345/// # Arguments
346/// * `headers` - Used headers (based on measurement type)
347pub fn build_parquet_schema(headers: Vec<&str>) -> TypePtr {
348    let mut fields = Vec::new();
349
350    for &header in &headers {
351        let field = match header {
352            "rx" | "tx" | "chaos_data" => {
353                SchemaType::primitive_type_builder(header, parquet::basic::Type::BYTE_ARRAY)
354                    .with_repetition(Repetition::OPTIONAL)
355                    .with_logical_type(Some(LogicalType::String))
356                    .build()
357                    .unwrap()
358            }
359            "addr" | "trace_dst" => SchemaType::primitive_type_builder(
360                header,
361                parquet::basic::Type::FIXED_LEN_BYTE_ARRAY,
362            )
363            .with_repetition(Repetition::OPTIONAL)
364            .with_length(16)
365            .build()
366            .unwrap(),
367            "ttl" | "origin_id" | "hop_count" | "probe_ttl" => {
368                SchemaType::primitive_type_builder(header, parquet::basic::Type::INT32)
369                    .with_repetition(Repetition::OPTIONAL)
370                    .with_logical_type(Some(LogicalType::integer(8, false)))
371                    .build()
372                    .unwrap()
373            }
374            "session" => SchemaType::primitive_type_builder(header, parquet::basic::Type::INT32)
375                .with_repetition(Repetition::OPTIONAL)
376                .with_logical_type(Some(LogicalType::integer(16, false)))
377                .build()
378                .unwrap(),
379            "rtt" => SchemaType::primitive_type_builder(header, parquet::basic::Type::FLOAT)
380                .with_repetition(Repetition::OPTIONAL)
381                .build()
382                .unwrap(),
383            _ => panic!("Unknown header column: {header}"),
384        };
385        fields.push(Arc::new(field));
386    }
387
388    Arc::new(
389        SchemaType::group_type_builder("schema")
390            .with_fields(fields)
391            .build()
392            .unwrap(),
393    )
394}
395
396/// Writes a batch of ParquetDataRow to the Parquet file using the provided writer.
397/// The batch is sorted by reply source address for better compression.
398pub fn write_batch_to_parquet(
399    writer: &mut SerializedFileWriter<File>,
400    batch: &mut [ParquetDataRow],
401    headers: &[&str],
402) -> Result<(), parquet::errors::ParquetError> {
403    batch.sort_unstable_by_key(|row| row.addr);
404
405    let mut row_group_writer = writer.next_row_group()?;
406
407    for &header in headers {
408        if let Some(mut col_writer) = row_group_writer.next_column()? {
409            match header {
410                "rx" | "tx" | "chaos_data" => {
411                    let mut values = Vec::with_capacity(batch.len());
412                    let def_levels: Vec<i16> = batch
413                        .iter()
414                        .map(|row| {
415                            let opt_val = match header {
416                                "rx" => row.rx.as_ref(),
417                                "tx" => row.tx.as_ref(),
418                                "chaos_data" => row.chaos_data.as_ref(),
419                                _ => None,
420                            };
421                            if let Some(val) = opt_val {
422                                values.push(ByteArray::from(val.as_str()));
423                                1
424                            } else {
425                                0
426                            }
427                        })
428                        .collect();
429                    col_writer
430                        .typed::<parquet::data_type::ByteArrayType>()
431                        .write_batch(&values, Some(&def_levels), None)?;
432                }
433                "addr" | "trace_dst" => {
434                    let mut values: Vec<FixedLenByteArray> = Vec::with_capacity(batch.len());
435                    let def_levels: Vec<i16> = batch
436                        .iter()
437                        .map(|row| {
438                            let opt_val = match header {
439                                "addr" => row.addr.as_ref(),
440                                "trace_dst" => row.trace_dst.as_ref(),
441                                _ => None,
442                            };
443                            if let Some(val) = opt_val {
444                                values.push(ByteArray::from(val.as_slice()).into());
445                                1
446                            } else {
447                                0
448                            }
449                        })
450                        .collect();
451                    col_writer
452                        .typed::<parquet::data_type::FixedLenByteArrayType>()
453                        .write_batch(&values, Some(&def_levels), None)?;
454                }
455                "ttl" | "origin_id" | "hop_count" | "probe_ttl" => {
456                    let mut values = Vec::with_capacity(batch.len());
457                    let def_levels: Vec<i16> = batch
458                        .iter()
459                        .map(|row| {
460                            let opt_val: Option<u8> = match header {
461                                "ttl" => row.ttl,
462                                "origin_id" => row.origin_id,
463                                "hop_count" | "probe_ttl" => row.hop_count, // TODO use single name for consistency
464                                _ => None,
465                            };
466                            if let Some(val) = opt_val {
467                                values.push(val as i32);
468                                1
469                            } else {
470                                0
471                            }
472                        })
473                        .collect();
474                    col_writer.typed::<Int32Type>().write_batch(
475                        &values,
476                        Some(&def_levels),
477                        None,
478                    )?;
479                }
480                "session" => {
481                    let mut values = Vec::with_capacity(batch.len());
482                    let def_levels: Vec<i16> = batch
483                        .iter()
484                        .map(|row| {
485                            if let Some(val) = row.session {
486                                values.push(val as i32);
487                                1
488                            } else {
489                                0
490                            }
491                        })
492                        .collect();
493                    col_writer.typed::<Int32Type>().write_batch(
494                        &values,
495                        Some(&def_levels),
496                        None,
497                    )?;
498                }
499                "rtt" => {
500                    let mut values = Vec::with_capacity(batch.len());
501                    let def_levels: Vec<i16> = batch
502                        .iter()
503                        .map(|row| {
504                            if let Some(val) = row.rtt {
505                                values.push(val);
506                                1
507                            } else {
508                                0
509                            }
510                        })
511                        .collect();
512                    col_writer.typed::<FloatType>().write_batch(
513                        &values,
514                        Some(&def_levels),
515                        None,
516                    )?;
517                }
518                _ => {}
519            }
520            col_writer.close()?;
521        }
522    }
523    row_group_writer.close()?;
524    Ok(())
525}