graphar_flight/export.rs
1//! Graph → lakehouse: export FalkorDB query results back to GraphAr files.
2//!
3//! The reverse of the ingest bridge. [`FalkorDbLoader`](graphar_falkordb) writes
4//! Arrow into FalkorDB; this reads a Cypher query's result rows back out as
5//! Arrow and writes them as a chunked GraphAr file (parquet/csv/orc/json/
6//! arrow-ipc) — so a graph computed in FalkorDB can land back in the lakehouse
7//! (and from there into Iceberg, object storage, etc.). Both directions of the
8//! columnar ↔ graph bridge now exist.
9
10use std::path::{Path, PathBuf};
11
12use graphar::FileType;
13
14use crate::error::Result;
15use crate::falkor::FalkorExecutor;
16
17/// Outcome of an [`export_cypher`] / [`export_cypher_chunked`] call.
18#[derive(Debug, Clone, Default)]
19pub struct ExportReport {
20 /// Rows written.
21 pub rows: usize,
22 /// Columns in the result schema.
23 pub columns: usize,
24 /// Chunk files written, in order (`chunk0.<ext>`, `chunk1.<ext>`, …).
25 ///
26 /// Empty for [`export_cypher`] (single-file, single-path export); populated
27 /// by [`export_cypher_chunked`]. Additive — single-file callers can keep
28 /// ignoring it.
29 pub chunks: Vec<PathBuf>,
30}
31
32/// Run `cypher` against the executor's graph and write the result rows as one
33/// GraphAr chunk file at `path` in `file_type`. Returns what was written.
34///
35/// The schema is inferred from FalkorDB's response (auto-string mode), so any
36/// `RETURN` shape works without pre-registration. For very large results this
37/// writes a single chunk; chunked/streamed export is a future extension.
38///
39/// ```no_run
40/// # async fn demo() -> graphar_flight::Result<()> {
41/// use graphar_flight::{FalkorExecutor, export::export_cypher};
42/// use graphar::FileType;
43///
44/// let mut exec = FalkorExecutor::connect("redis://127.0.0.1:6379", "social").await?;
45/// let report = export_cypher(
46/// &mut exec,
47/// "MATCH (n:Person) RETURN n._gar_id AS id, n.name AS name",
48/// "/tmp/people.parquet",
49/// FileType::Parquet,
50/// ).await?;
51/// println!("exported {} rows", report.rows);
52/// # Ok(()) }
53/// ```
54pub async fn export_cypher(
55 executor: &mut FalkorExecutor,
56 cypher: &str,
57 path: impl AsRef<Path>,
58 file_type: FileType,
59) -> Result<ExportReport> {
60 let batch = executor.query_auto(cypher).await?;
61 let rows = batch.num_rows();
62 let columns = batch.num_columns();
63 graphar::io::write_chunk(path, &[batch], &file_type)?;
64 Ok(ExportReport {
65 rows,
66 columns,
67 chunks: Vec::new(),
68 })
69}
70
71/// Run `cypher` and write the result as **multiple** GraphAr chunk files —
72/// `chunk0.<ext>`, `chunk1.<ext>`, … — under `dir`, each holding at most
73/// `chunk_rows` rows. This is the *chunked* export for huge results: instead of
74/// one monolithic file it lands the result the way GraphAr naturally tiles a
75/// vertex/edge collection (one file per row-range), so a `VertexReader` (or a
76/// plain `read_chunk` per file) can stream the chunks back without ever holding
77/// the whole result in memory.
78///
79/// Returns an [`ExportReport`] whose `chunks` lists the files written in order.
80///
81/// ## Streaming honesty — why this is slice-and-write, not pull-stream
82///
83/// FalkorDB's `GRAPH.QUERY` is a single RESP request/response: the server
84/// serializes the **entire** result set into one reply with no server-side
85/// cursor, and [`FalkorExecutor::query_auto`] therefore materializes it as one
86/// in-memory [`RecordBatch`](arrow_array::RecordBatch). There is no API to pull
87/// the result in pages, so we cannot bound the *read* side below the full
88/// result. (FalkorDB ≥ 4 exposes `GRAPH.QUERY … TIMEOUT`/result-set limits but
89/// no streaming cursor over RESP.)
90///
91/// What this *does* bound is the **write** side and downstream consumption: the
92/// fetched batch is zero-copy-sliced into `chunk_rows`-sized record batches
93/// (`RecordBatch::slice` shares the underlying Arrow buffers — no row copy), and
94/// each slice is written and dropped before the next, so peak *encoder* memory
95/// (parquet row-group buffers, CSV/JSON string buffers, etc.) is bounded by
96/// `chunk_rows` rather than the whole result, and each output file is bounded
97/// too. Reading back with `read_chunk` per file then streams chunk-by-chunk.
98///
99/// When the upstream is the *lakehouse* rather than FalkorDB, the Iceberg export
100/// path ([`export_cypher_to_iceberg`], feature `skade`) already appends in
101/// batches; this function is the GraphAr-file equivalent.
102///
103/// `chunk_rows` must be non-zero; `0` is treated as `1`. An empty result still
104/// writes a single empty `chunk0.<ext>` so consumers always find a chunk file.
105///
106/// ```no_run
107/// # async fn demo() -> graphar_flight::Result<()> {
108/// use graphar_flight::{FalkorExecutor, export::export_cypher_chunked};
109/// use graphar::FileType;
110///
111/// let mut exec = FalkorExecutor::connect("redis://127.0.0.1:6379", "social").await?;
112/// let report = export_cypher_chunked(
113/// &mut exec,
114/// "MATCH (n:Person) RETURN n._gar_id AS id, n.name AS name",
115/// "/tmp/people_chunks",
116/// FileType::Parquet,
117/// 100_000, // ≤ 100k rows per chunk file
118/// ).await?;
119/// println!("exported {} rows across {} chunks", report.rows, report.chunks.len());
120/// # Ok(()) }
121/// ```
122pub async fn export_cypher_chunked(
123 executor: &mut FalkorExecutor,
124 cypher: &str,
125 dir: impl AsRef<Path>,
126 file_type: FileType,
127 chunk_rows: usize,
128) -> Result<ExportReport> {
129 let batch = executor.query_auto(cypher).await?;
130 write_batch_chunked(&batch, dir, &file_type, chunk_rows)
131}
132
133/// Slice one in-memory [`RecordBatch`](arrow_array::RecordBatch) into
134/// `chunk_rows`-sized record batches and write each as `chunk{i}.<ext>` under
135/// `dir`. Split out from [`export_cypher_chunked`] so the chunking can be
136/// exercised (round-tripped) without a FalkorDB connection.
137///
138/// The slicing is zero-copy ([`RecordBatch::slice`](arrow_array::RecordBatch::slice)
139/// shares Arrow buffers); peak extra memory is one encoder's worth of
140/// `chunk_rows` rows at a time.
141pub fn write_batch_chunked(
142 batch: &arrow_array::RecordBatch,
143 dir: impl AsRef<Path>,
144 file_type: &FileType,
145 chunk_rows: usize,
146) -> Result<ExportReport> {
147 // `graphar::io::write_chunk` creates each path's parent directory, so `dir`
148 // itself is created on the first write — no explicit `create_dir_all` (and
149 // no need for `FlightSqlError: From<io::Error>`).
150 let dir = dir.as_ref();
151 let ext = file_type.extension();
152 let total = batch.num_rows();
153 let columns = batch.num_columns();
154 let step = chunk_rows.max(1);
155
156 let mut chunks = Vec::new();
157
158 if total == 0 {
159 // Always emit one (empty) chunk so consumers find a chunk0 file.
160 let path = dir.join(format!("chunk0.{ext}"));
161 graphar::io::write_chunk(&path, std::slice::from_ref(batch), file_type)?;
162 chunks.push(path);
163 return Ok(ExportReport {
164 rows: 0,
165 columns,
166 chunks,
167 });
168 }
169
170 let mut offset = 0;
171 let mut idx = 0;
172 while offset < total {
173 let len = step.min(total - offset);
174 // Zero-copy view of rows [offset, offset+len) — shares the source
175 // buffers, so no row data is duplicated to form the slice.
176 let slice = batch.slice(offset, len);
177 let path = dir.join(format!("chunk{idx}.{ext}"));
178 graphar::io::write_chunk(&path, &[slice], file_type)?;
179 chunks.push(path);
180 offset += len;
181 idx += 1;
182 }
183
184 Ok(ExportReport {
185 rows: total,
186 columns,
187 chunks,
188 })
189}
190
191// ── Graph → Iceberg (feature `skade`) ───────────────────────────────────────
192
193/// Export a Cypher query's results **straight into an Iceberg table** (the
194/// graph → lakehouse, Iceberg edition). Runs `cypher`, then appends the rows to
195/// `table_name` in the [`skade`] embedded Iceberg warehouse rooted at
196/// `warehouse_dir`, creating the table on first write.
197///
198/// knut and skade now both build on arrow 58, so the result is handed to skade
199/// directly ([`bridge_56_to_57`] is an identity clone) — no serialization, no
200/// temp file, no FFI. (The parquet-bridge variant
201/// [`append_arrow_to_iceberg_via_parquet`] is retained only for the comparison
202/// bench.)
203///
204/// ```no_run
205/// # #[cfg(feature = "skade")]
206/// # async fn demo() -> graphar_flight::Result<()> {
207/// use graphar_flight::{FalkorExecutor, export::export_cypher_to_iceberg};
208/// let mut exec = FalkorExecutor::connect("redis://127.0.0.1:6379", "social").await?;
209/// let report = export_cypher_to_iceberg(
210/// &mut exec,
211/// "MATCH (n:Person) RETURN n._gar_id AS id, n.name AS name",
212/// "/tmp/knut_warehouse",
213/// "people",
214/// ).await?;
215/// # let _ = report; Ok(()) }
216/// ```
217#[cfg(feature = "skade")]
218pub async fn export_cypher_to_iceberg(
219 executor: &mut FalkorExecutor,
220 cypher: &str,
221 warehouse_dir: impl AsRef<Path>,
222 table_name: &str,
223) -> Result<ExportReport> {
224 let batch = executor.query_auto(cypher).await?;
225 let rows = batch.num_rows();
226 let columns = batch.num_columns();
227 append_arrow_to_iceberg(warehouse_dir.as_ref(), table_name, batch).await?;
228 Ok(ExportReport {
229 rows,
230 columns,
231 chunks: Vec::new(),
232 })
233}
234
235/// Append one arrow-58 `RecordBatch` to an Iceberg table via skade.
236///
237/// knut and skade share arrow 58, so the batch is handed to skade directly
238/// ([`bridge_56_to_57`] is an identity clone — an Arc bump, no buffer copy, no
239/// FFI). Exposed (feature-gated) so the bridge can be exercised without a
240/// FalkorDB connection.
241#[cfg(feature = "skade")]
242pub async fn append_arrow_to_iceberg(
243 warehouse_dir: &Path,
244 table_name: &str,
245 batch: arrow_array::RecordBatch,
246) -> Result<usize> {
247 let rows = batch.num_rows();
248 if rows == 0 {
249 return Ok(0);
250 }
251 let batch57 = bridge_56_to_57(&batch)?;
252 append_batch57(warehouse_dir, table_name, &batch57).await?;
253 Ok(rows)
254}
255
256/// Same as [`append_arrow_to_iceberg`] but bridges through a temp **parquet**
257/// file instead of the C Data Interface. Kept so the `knut.skade_iceberg_export`
258/// (zero-copy) and `knut.skade_iceberg_export_parquet` benchers can quantify the
259/// difference; the zero-copy path is the default everywhere else.
260#[cfg(feature = "skade")]
261pub async fn append_arrow_to_iceberg_via_parquet(
262 warehouse_dir: &Path,
263 table_name: &str,
264 batch: arrow_array::RecordBatch,
265) -> Result<usize> {
266 use crate::error::FlightSqlError;
267
268 let rows = batch.num_rows();
269 let tmp = tempfile::Builder::new()
270 .prefix("knut-iceberg-")
271 .suffix(".parquet")
272 .tempfile()
273 .map_err(|e| FlightSqlError::Skade(format!("temp file: {e}")))?;
274 graphar::io::write_chunk(tmp.path(), &[batch], &FileType::Parquet)?;
275 let batches57 = read_parquet_as_skade(tmp.path())?;
276 drop(tmp);
277 let Some(first) = batches57.first() else {
278 return Ok(0);
279 };
280 let schema = skade::arrow_array::RecordBatch::schema(first);
281 let wh = skade::Warehouse::open(warehouse_dir)
282 .await
283 .map_err(|e| FlightSqlError::Skade(e.to_string()))?;
284 let mut table = wh
285 .table_or_create(table_name, &schema)
286 .await
287 .map_err(|e| FlightSqlError::Skade(e.to_string()))?;
288 table
289 .append(&batches57)
290 .await
291 .map_err(|e| FlightSqlError::Skade(e.to_string()))?;
292 Ok(rows)
293}
294
295/// Open the warehouse and append one arrow-57 batch to `table_name`.
296#[cfg(feature = "skade")]
297async fn append_batch57(
298 warehouse_dir: &Path,
299 table_name: &str,
300 batch57: &skade::arrow_array::RecordBatch,
301) -> Result<()> {
302 use crate::error::FlightSqlError;
303 let schema = skade::arrow_array::RecordBatch::schema(batch57);
304 let wh = skade::Warehouse::open(warehouse_dir)
305 .await
306 .map_err(|e| FlightSqlError::Skade(e.to_string()))?;
307 let mut table = wh
308 .table_or_create(table_name, &schema)
309 .await
310 .map_err(|e| FlightSqlError::Skade(e.to_string()))?;
311 table
312 .append(std::slice::from_ref(batch57))
313 .await
314 .map_err(|e| FlightSqlError::Skade(e.to_string()))?;
315 Ok(())
316}
317
318/// arrow → skade record-batch bridge — now an **identity clone**.
319///
320/// knut and skade both build on Apache Arrow 58, so
321/// `skade::arrow_array::RecordBatch` *is* `arrow_array::RecordBatch` (the same
322/// crate, same major). The old C-Data-Interface FFI transmute bridge
323/// (arrow 56 → arrow 57) is therefore gone: a plain `clone()` (Arc bumps, no
324/// buffer copy) hands skade the batch directly. Kept as a named seam so the
325/// export path and its round-trip test read the same.
326#[cfg(feature = "skade")]
327pub fn bridge_56_to_57(
328 batch: &arrow_array::RecordBatch,
329) -> Result<skade::arrow_array::RecordBatch> {
330 Ok(batch.clone())
331}
332
333/// Read a parquet file as skade's (arrow-57) record batches.
334#[cfg(feature = "skade")]
335fn read_parquet_as_skade(path: &Path) -> Result<Vec<skade::arrow_array::RecordBatch>> {
336 use crate::error::FlightSqlError;
337 use skade::parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
338
339 let file =
340 std::fs::File::open(path).map_err(|e| FlightSqlError::Skade(format!("open: {e}")))?;
341 let reader = ParquetRecordBatchReaderBuilder::try_new(file)
342 .map_err(|e| FlightSqlError::Skade(format!("parquet: {e}")))?
343 .build()
344 .map_err(|e| FlightSqlError::Skade(format!("parquet: {e}")))?;
345 let mut out = Vec::new();
346 for b in reader {
347 out.push(b.map_err(|e| FlightSqlError::Skade(format!("parquet read: {e}")))?);
348 }
349 Ok(out)
350}
351
352#[cfg(test)]
353mod chunk_tests {
354 use super::*;
355 use std::sync::Arc;
356
357 use arrow_array::{Array, Int64Array, RecordBatch, StringArray};
358 use arrow_schema::{DataType, Field, Schema};
359
360 /// A 7-row batch (id 0..6, name "n0".."n6").
361 fn sample_batch() -> RecordBatch {
362 RecordBatch::try_new(
363 Arc::new(Schema::new(vec![
364 Field::new("id", DataType::Int64, false),
365 Field::new("name", DataType::Utf8, false),
366 ])),
367 vec![
368 Arc::new(Int64Array::from((0..7).collect::<Vec<i64>>())),
369 Arc::new(StringArray::from(
370 (0..7).map(|i| format!("n{i}")).collect::<Vec<_>>(),
371 )),
372 ],
373 )
374 .unwrap()
375 }
376
377 #[test]
378 fn chunked_export_round_trips_and_files_are_bounded() {
379 // 7 rows, chunk_rows = 3 → ceil(7/3) = 3 files: 3 + 3 + 1 rows.
380 let batch = sample_batch();
381 let dir = tempfile::tempdir().unwrap();
382 let report = write_batch_chunked(&batch, dir.path(), &FileType::Parquet, 3).unwrap();
383
384 assert_eq!(report.rows, 7);
385 assert_eq!(report.columns, 2);
386 assert_eq!(report.chunks.len(), 3, "ceil(7/3) chunk files");
387
388 // Each file exists, is named chunk{i}.parquet, and holds ≤ chunk_rows rows.
389 let mut total = 0usize;
390 let mut all_ids = Vec::new();
391 let mut all_names = Vec::new();
392 for (i, path) in report.chunks.iter().enumerate() {
393 assert_eq!(
394 path.file_name().unwrap().to_str().unwrap(),
395 format!("chunk{i}.parquet"),
396 );
397 let batches = graphar::io::read_chunk(path, &FileType::Parquet).unwrap();
398 let rows: usize = batches.iter().map(|b| b.num_rows()).sum();
399 assert!(rows <= 3, "chunk {i} bounded by chunk_rows (got {rows})");
400 total += rows;
401 for b in &batches {
402 let ids = b
403 .column_by_name("id")
404 .unwrap()
405 .as_any()
406 .downcast_ref::<Int64Array>()
407 .unwrap();
408 let names = b
409 .column_by_name("name")
410 .unwrap()
411 .as_any()
412 .downcast_ref::<StringArray>()
413 .unwrap();
414 for r in 0..b.num_rows() {
415 all_ids.push(ids.value(r));
416 all_names.push(names.value(r).to_string());
417 }
418 }
419 }
420
421 // Round trip: every input row came back exactly once, in order.
422 assert_eq!(total, 7, "total rows read back == input rows");
423 assert_eq!(all_ids, (0..7).collect::<Vec<i64>>());
424 assert_eq!(
425 all_names,
426 (0..7).map(|i| format!("n{i}")).collect::<Vec<String>>(),
427 );
428 }
429
430 #[test]
431 fn chunk_rows_at_least_total_writes_single_file() {
432 let batch = sample_batch();
433 let dir = tempfile::tempdir().unwrap();
434 let report = write_batch_chunked(&batch, dir.path(), &FileType::Parquet, 100).unwrap();
435 assert_eq!(report.chunks.len(), 1, "one chunk when chunk_rows >= total");
436 assert_eq!(report.rows, 7);
437 }
438
439 #[test]
440 fn empty_result_writes_one_empty_chunk_preserving_schema() {
441 let empty = RecordBatch::new_empty(sample_batch().schema());
442 let dir = tempfile::tempdir().unwrap();
443 let report = write_batch_chunked(&empty, dir.path(), &FileType::Parquet, 3).unwrap();
444 assert_eq!(report.rows, 0);
445 assert_eq!(report.chunks.len(), 1, "empty result still emits chunk0");
446 let batches = graphar::io::read_chunk(&report.chunks[0], &FileType::Parquet).unwrap();
447 let rows: usize = batches.iter().map(|b| b.num_rows()).sum();
448 assert_eq!(rows, 0);
449 // The file exists and is readable (an empty parquet yields zero record
450 // batches on read-back; the point is the chunk0 file is always present
451 // so a consumer never hits a missing-file error for an empty result).
452 assert!(report.chunks[0].exists());
453 }
454
455 #[test]
456 fn chunk_rows_zero_is_treated_as_one() {
457 let batch = sample_batch();
458 let dir = tempfile::tempdir().unwrap();
459 let report = write_batch_chunked(&batch, dir.path(), &FileType::Parquet, 0).unwrap();
460 assert_eq!(report.chunks.len(), 7, "chunk_rows 0 → 1 row per file");
461 }
462}
463
464#[cfg(all(test, feature = "skade"))]
465mod skade_tests {
466 use super::*;
467 use std::sync::Arc;
468
469 #[test]
470 fn ffi_bridge_preserves_values_and_nulls() {
471 // arrow-58 batch with three types incl. a null → skade via the now
472 // identity bridge (knut + skade share arrow 58) → assert every value
473 // (and the null) survives the hand-off. Reads the result back through
474 // skade's typed arrays, which are the same arrow-58 types.
475 use arrow_array::{Float64Array, Int64Array, StringArray};
476 use arrow_schema::{DataType, Field, Schema};
477
478 let batch56 = arrow_array::RecordBatch::try_new(
479 Arc::new(Schema::new(vec![
480 Field::new("id", DataType::Int64, false),
481 Field::new("score", DataType::Float64, false),
482 Field::new("name", DataType::Utf8, true),
483 ])),
484 vec![
485 Arc::new(Int64Array::from(vec![10, 20, 30])),
486 Arc::new(Float64Array::from(vec![1.5, 2.5, 3.5])),
487 Arc::new(StringArray::from(vec![Some("a"), None, Some("c")])),
488 ],
489 )
490 .unwrap();
491
492 let batch57 = bridge_56_to_57(&batch56).unwrap();
493 assert_eq!(batch57.num_rows(), 3);
494 assert_eq!(batch57.num_columns(), 3);
495
496 let ids = batch57
497 .column(0)
498 .as_any()
499 .downcast_ref::<skade::arrow_array::Int64Array>()
500 .unwrap();
501 assert_eq!(ids.values(), &[10, 20, 30]);
502
503 let scores = batch57
504 .column(1)
505 .as_any()
506 .downcast_ref::<skade::arrow_array::Float64Array>()
507 .unwrap();
508 assert_eq!(scores.values(), &[1.5, 2.5, 3.5]);
509
510 use skade::arrow_array::Array;
511 let names = batch57
512 .column(2)
513 .as_any()
514 .downcast_ref::<skade::arrow_array::StringArray>()
515 .unwrap();
516 assert_eq!(names.value(0), "a");
517 assert!(names.is_null(1), "the null survived the bridge");
518 assert_eq!(names.value(2), "c");
519 }
520
521 #[tokio::test]
522 async fn append_and_read_back_via_iceberg() {
523 // Build an arrow-58 batch (no FalkorDB), append to a fresh warehouse,
524 // then read it back through skade — exercises the (now identity-clone)
525 // arrow bridge and the skade write/read path with no container.
526 use arrow_array::{Int64Array, StringArray};
527 use arrow_schema::{DataType, Field, Schema};
528
529 let batch = arrow_array::RecordBatch::try_new(
530 Arc::new(Schema::new(vec![
531 Field::new("id", DataType::Int64, false),
532 Field::new("name", DataType::Utf8, false),
533 ])),
534 vec![
535 Arc::new(Int64Array::from(vec![1, 2, 3])),
536 Arc::new(StringArray::from(vec!["a", "b", "c"])),
537 ],
538 )
539 .unwrap();
540
541 let dir = tempfile::tempdir().unwrap();
542 let n = append_arrow_to_iceberg(dir.path(), "people", batch)
543 .await
544 .unwrap();
545 assert_eq!(n, 3);
546
547 // Read it back from the Iceberg table.
548 let wh = skade::Warehouse::open(dir.path()).await.unwrap();
549 let table = wh.table("people").await.unwrap();
550 let count = table.count().await.unwrap();
551 assert_eq!(count, 3, "3 rows landed in the Iceberg table");
552 }
553}