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