Skip to main content

trueno_graph/storage/
parquet.rs

1//! Parquet I/O for graph persistence
2//!
3//! Based on `DuckDB` (Raasveldt et al., SIGMOD 2019) columnar storage patterns.
4//!
5//! # Format
6//!
7//! Graphs are stored as two Parquet files:
8//! - `{path}_edges.parquet`: (source, target, weight)
9//! - `{path}_nodes.parquet`: (`node_id`, name)
10
11use super::{CsrGraph, NodeId};
12use anyhow::{Context, Result};
13use arrow::array::{Float32Array, StringArray, UInt32Array};
14use arrow::datatypes::{DataType, Field, Schema};
15use arrow::record_batch::RecordBatch;
16use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
17use parquet::arrow::arrow_writer::ArrowWriter;
18use parquet::file::properties::WriterProperties;
19use std::fs::File;
20use std::path::Path;
21use std::sync::Arc;
22
23impl CsrGraph {
24    /// Write graph to Parquet files
25    ///
26    /// Creates two files:
27    /// - `{path}_edges.parquet`: Edge list (source, target, weight)
28    /// - `{path}_nodes.parquet`: Node metadata (`node_id`, name)
29    ///
30    /// # Errors
31    ///
32    /// Returns error if file I/O fails or Arrow conversion fails
33    #[allow(clippy::unused_async)] // Async API for future I/O operations
34    pub async fn write_parquet<P: AsRef<Path>>(&self, path: P) -> Result<()> {
35        let base_path = path.as_ref();
36
37        // Write edges
38        self.write_edges_parquet(base_path)?;
39
40        // Write nodes (metadata)
41        self.write_nodes_parquet(base_path)?;
42
43        Ok(())
44    }
45
46    /// Read graph from Parquet files
47    ///
48    /// # Errors
49    ///
50    /// Returns error if files don't exist or Arrow conversion fails
51    #[allow(clippy::unused_async)] // Async API for future I/O operations
52    pub async fn read_parquet<P: AsRef<Path>>(path: P) -> Result<Self> {
53        let base_path = path.as_ref();
54
55        // Read edges
56        let edges = Self::read_edges_parquet(base_path)?;
57
58        // Build graph from edge list
59        let mut graph = Self::from_edge_list(&edges)?;
60
61        // Read node names
62        let node_names = Self::read_nodes_parquet(base_path)?;
63        for (node_id, name) in node_names {
64            graph.set_node_name(node_id, name);
65        }
66
67        Ok(graph)
68    }
69
70    fn write_edges_parquet(&self, base_path: &Path) -> Result<()> {
71        let edges_path = format!("{}_edges.parquet", base_path.display());
72
73        // Convert CSR to edge list arrays
74        let mut sources = Vec::new();
75        let mut targets = Vec::new();
76        let mut weights = Vec::new();
77
78        for (src, target_nodes, edge_weights) in self.iter_adjacency() {
79            for (dst, weight) in target_nodes.iter().zip(edge_weights.iter()) {
80                sources.push(src.0);
81                targets.push(*dst);
82                weights.push(*weight);
83            }
84        }
85
86        // Create Arrow schema
87        let schema = Arc::new(Schema::new(vec![
88            Field::new("source", DataType::UInt32, false),
89            Field::new("target", DataType::UInt32, false),
90            Field::new("weight", DataType::Float32, false),
91        ]));
92
93        // Create Arrow arrays
94        let source_array = Arc::new(UInt32Array::from(sources));
95        let target_array = Arc::new(UInt32Array::from(targets));
96        let weight_array = Arc::new(Float32Array::from(weights));
97
98        // Create RecordBatch
99        let batch =
100            RecordBatch::try_new(schema.clone(), vec![source_array, target_array, weight_array])
101                .context("Failed to create RecordBatch")?;
102
103        // Write to Parquet
104        let file =
105            File::create(&edges_path).with_context(|| format!("Failed to create {edges_path}"))?;
106
107        let props = WriterProperties::builder()
108            .set_compression(parquet::basic::Compression::ZSTD(parquet::basic::ZstdLevel::try_new(
109                3,
110            )?))
111            .build();
112
113        let mut writer = ArrowWriter::try_new(file, schema, Some(props))?;
114        writer.write(&batch)?;
115        writer.close()?;
116
117        Ok(())
118    }
119
120    fn write_nodes_parquet(&self, base_path: &Path) -> Result<()> {
121        let nodes_path = format!("{}_nodes.parquet", base_path.display());
122
123        // Collect node IDs and names
124        let mut node_ids = Vec::new();
125        let mut names = Vec::new();
126
127        for node_id in 0..self.num_nodes() {
128            #[allow(clippy::cast_possible_truncation)] // Graphs >4B nodes not supported yet
129            let node_u32 = node_id as u32;
130            node_ids.push(node_u32);
131            let name = self
132                .get_node_name(NodeId(node_u32))
133                .unwrap_or(&format!("node_{node_id}"))
134                .to_string();
135            names.push(name);
136        }
137
138        // Create Arrow schema
139        let schema = Arc::new(Schema::new(vec![
140            Field::new("node_id", DataType::UInt32, false),
141            Field::new("name", DataType::Utf8, false),
142        ]));
143
144        // Create Arrow arrays
145        let node_id_array = Arc::new(UInt32Array::from(node_ids));
146        let name_array = Arc::new(StringArray::from(names));
147
148        // Create RecordBatch
149        let batch = RecordBatch::try_new(schema.clone(), vec![node_id_array, name_array])
150            .context("Failed to create nodes RecordBatch")?;
151
152        // Write to Parquet
153        let file =
154            File::create(&nodes_path).with_context(|| format!("Failed to create {nodes_path}"))?;
155
156        let props = WriterProperties::builder()
157            .set_compression(parquet::basic::Compression::ZSTD(parquet::basic::ZstdLevel::try_new(
158                3,
159            )?))
160            .build();
161
162        let mut writer = ArrowWriter::try_new(file, schema, Some(props))?;
163        writer.write(&batch)?;
164        writer.close()?;
165
166        Ok(())
167    }
168
169    fn read_edges_parquet(base_path: &Path) -> Result<Vec<(NodeId, NodeId, f32)>> {
170        let edges_path = format!("{}_edges.parquet", base_path.display());
171
172        let file =
173            File::open(&edges_path).with_context(|| format!("Failed to open {edges_path}"))?;
174
175        let reader = ParquetRecordBatchReaderBuilder::try_new(file)?.build()?;
176
177        let mut edges = Vec::new();
178
179        for batch_result in reader {
180            let batch: RecordBatch = batch_result?;
181
182            let sources = batch
183                .column(0)
184                .as_any()
185                .downcast_ref::<UInt32Array>()
186                .context("Invalid source column type")?;
187
188            let targets = batch
189                .column(1)
190                .as_any()
191                .downcast_ref::<UInt32Array>()
192                .context("Invalid target column type")?;
193
194            let weights = batch
195                .column(2)
196                .as_any()
197                .downcast_ref::<Float32Array>()
198                .context("Invalid weight column type")?;
199
200            for i in 0..batch.num_rows() {
201                edges.push((NodeId(sources.value(i)), NodeId(targets.value(i)), weights.value(i)));
202            }
203        }
204
205        Ok(edges)
206    }
207
208    fn read_nodes_parquet(base_path: &Path) -> Result<Vec<(NodeId, String)>> {
209        let nodes_path = format!("{}_nodes.parquet", base_path.display());
210
211        let file =
212            File::open(&nodes_path).with_context(|| format!("Failed to open {nodes_path}"))?;
213
214        let reader = ParquetRecordBatchReaderBuilder::try_new(file)?.build()?;
215
216        let mut nodes = Vec::new();
217
218        for batch_result in reader {
219            let batch: RecordBatch = batch_result?;
220
221            let node_ids = batch
222                .column(0)
223                .as_any()
224                .downcast_ref::<UInt32Array>()
225                .context("Invalid node_id column type")?;
226
227            let names = batch
228                .column(1)
229                .as_any()
230                .downcast_ref::<StringArray>()
231                .context("Invalid name column type")?;
232
233            for i in 0..batch.num_rows() {
234                nodes.push((NodeId(node_ids.value(i)), names.value(i).to_string()));
235            }
236        }
237
238        Ok(nodes)
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use tempfile::tempdir;
246
247    #[tokio::test]
248    async fn test_parquet_roundtrip() {
249        let dir = tempdir().unwrap();
250        let path = dir.path().join("test_graph");
251
252        // Create graph
253        let mut graph = CsrGraph::new();
254        graph.add_edge(NodeId(0), NodeId(1), 1.0).unwrap();
255        graph.add_edge(NodeId(0), NodeId(2), 2.0).unwrap();
256        graph.add_edge(NodeId(1), NodeId(2), 3.0).unwrap();
257
258        graph.set_node_name(NodeId(0), "main".to_string());
259        graph.set_node_name(NodeId(1), "parse_args".to_string());
260        graph.set_node_name(NodeId(2), "validate".to_string());
261
262        // Write to Parquet
263        graph.write_parquet(&path).await.unwrap();
264
265        // Read back
266        let loaded = CsrGraph::read_parquet(&path).await.unwrap();
267
268        // Verify structure
269        assert_eq!(loaded.num_nodes(), graph.num_nodes());
270        assert_eq!(loaded.num_edges(), graph.num_edges());
271
272        // Verify edges
273        assert_eq!(loaded.outgoing_neighbors(NodeId(0)).unwrap(), &[1, 2]);
274
275        // Verify node names
276        assert_eq!(loaded.get_node_name(NodeId(0)), Some("main"));
277        assert_eq!(loaded.get_node_name(NodeId(1)), Some("parse_args"));
278        assert_eq!(loaded.get_node_name(NodeId(2)), Some("validate"));
279    }
280
281    #[tokio::test]
282    async fn test_empty_graph_parquet() {
283        let dir = tempdir().unwrap();
284        let path = dir.path().join("empty_graph");
285
286        let graph = CsrGraph::new();
287        graph.write_parquet(&path).await.unwrap();
288
289        let loaded = CsrGraph::read_parquet(&path).await.unwrap();
290        assert_eq!(loaded.num_nodes(), 0);
291        assert_eq!(loaded.num_edges(), 0);
292    }
293}