Skip to main content

genegraph_storage/
lance_storage_graph.rs

1//! Lance storage backend for graph embeddings.
2//!
3//! Async-first implementation that matches the async `StorageBackend` trait:
4//! - All I/O is async, no internal `block_on` or runtime creation.
5//! - Callers (CLI, tests, services) are responsible for providing a Tokio runtime.
6
7use std::path::{Path, PathBuf};
8
9use arrow::array::{Array as ArrowArray, Float64Array, UInt32Array};
10use arrow::datatypes::{DataType, Float64Type, Int64Type, UInt32Type};
11use arrow::record_batch::RecordBatch;
12use log::{debug, info};
13use smartcore::linalg::basic::arrays::Array;
14use smartcore::linalg::basic::matrix::DenseMatrix;
15use sprs::CsMat;
16
17use crate::metadata::FileInfo;
18use crate::metadata::GeneMetadata;
19use crate::traits::backend::StorageBackend;
20use crate::traits::lance::LanceStorage;
21use crate::traits::metadata::Metadata;
22use crate::{StorageError, StorageResult};
23
24/// Checked `usize -> u32` conversion.
25///
26/// Values above `u32::MAX` must surface an error instead of being silently
27/// truncated into wrong stored data (issue #51).
28fn checked_u32_values(values: &[usize], what: &str) -> StorageResult<Vec<u32>> {
29    values
30        .iter()
31        .map(|&v| {
32            u32::try_from(v).map_err(|_| {
33                StorageError::Overflow(format!(
34                    "{} value {} exceeds u32::MAX and would be silently truncated",
35                    what, v
36                ))
37            })
38        })
39        .collect()
40}
41
42/// Lance-based storage backend for ArrowSpace graph embeddings.
43///
44/// Stores dense and sparse matrices as Lance datasets using a columnar format
45/// (`row`, `col`, `value` for sparse; `col_*` for dense) schema for efficient
46/// random and columnar access.
47///
48/// Metadata must be seeded before any `save_*` call so the storage directory
49/// is initialized; the example below is executed as a doc-test on every
50/// `cargo test` run so it cannot go stale.
51///
52/// # Examples
53///
54/// ```
55/// use genegraph_storage::lance_storage_graph::LanceStorageGraph;
56/// use genegraph_storage::metadata::GeneMetadata;
57/// use genegraph_storage::traits::backend::StorageBackend;
58/// use genegraph_storage::traits::metadata::Metadata;
59/// use smartcore::linalg::basic::arrays::{Array, Array2};
60/// use smartcore::linalg::basic::matrix::DenseMatrix;
61///
62/// let base = std::env::temp_dir().join(format!("genegraph_doc_{}", std::process::id()));
63/// # tokio::runtime::Runtime::new().unwrap().block_on(async {
64/// let storage = LanceStorageGraph::new(
65///     base.to_string_lossy().to_string(),
66///     "doc_example".to_string(),
67/// );
68///
69/// // some 2D data
70/// let dense: Vec<Vec<f64>> = vec![vec![0.1, 0.4], vec![0.5, 0.2], vec![0.03, 0.8]];
71/// let (nitems, nfeatures) = (dense.len(), dense[0].len());
72/// let data = DenseMatrix::<f64>::from_iterator(
73///     dense.iter().flatten().copied(),
74///     nitems,
75///     nfeatures,
76///     0,
77/// );
78///
79/// // seed metadata FIRST to initialize the storage directory
80/// let md = GeneMetadata::seed_metadata("doc_example", nitems, nfeatures, &storage)
81///     .await
82///     .unwrap();
83/// let md_path = storage.save_metadata(&md).await.unwrap();
84///
85/// // your data is saved in an efficient Lance format
86/// storage.save_dense("my_dataset", &data, &md_path).await.unwrap();
87///
88/// // Loading back
89/// let loaded = storage.load_dense("my_dataset").await.unwrap();
90/// assert_eq!(loaded.shape(), (nitems, nfeatures));
91/// # });
92/// # std::fs::remove_dir_all(&base).ok();
93/// ```
94#[derive(Debug, Clone)]
95pub struct LanceStorageGraph {
96    pub(crate) base: String,
97    pub(crate) name: String,
98}
99
100impl LanceStorageGraph {
101    /// Creates a new Lance storage backend.
102    ///
103    /// This is used for on-the-fly creation. For proper setup use `Genefold<...>::seed`.
104    ///
105    /// # Arguments
106    ///
107    /// * `base` - Base directory path for all storage files
108    /// * `name` - Name prefix for this storage instance
109    pub fn new(base: String, name: String) -> Self {
110        info!("Creating LanceStorage at base={}, name={}", base, name);
111        Self { base, name }
112    }
113
114    /// Spawn a LanceStorage from an existing seeded directory (with metadata.json)
115    pub async fn spawn(base_path: String) -> Result<(Self, GeneMetadata), StorageError> {
116        // Reuse the generic `exists` helper from the StorageBackend trait
117        let (exists, md_path) = Self::exists(&base_path);
118
119        // Replace assert! with proper error handling
120        if !exists || md_path.is_none() {
121            return Err(StorageError::Invalid(format!(
122                "Metadata does not exist in base path: {}",
123                base_path
124            )));
125        }
126
127        // Load metadata from the discovered metadata.json
128        let metadata = GeneMetadata::read(md_path.unwrap()).await?;
129
130        // Construct the LanceStorage using the metadata-provided nameid
131        let storage = Self::new(base_path.clone(), metadata.name_id.clone());
132        Ok((storage, metadata))
133    }
134
135    /// A handle over generation `gen` of this logical dataset: artifact
136    /// paths resolve as `{logical}__g{gen}_{key}.lance` and the metadata
137    /// path as `{logical}__g{gen}_metadata.json` — the per-generation
138    /// commit pointer (#93, RFC #81-P5). The logical identity stays
139    /// recoverable via [`crate::generations::logical_name`].
140    pub fn scoped_generation(&self, generation: u64) -> Self {
141        let logical = crate::generations::logical_name(&self.name);
142        Self::new(
143            self.base.clone(),
144            crate::generations::generation_name(logical, generation),
145        )
146    }
147}
148
149impl LanceStorage for LanceStorageGraph {}
150
151impl StorageBackend for LanceStorageGraph {
152    fn get_base(&self) -> String {
153        self.base.clone()
154    }
155
156    fn get_name(&self) -> String {
157        self.name.clone()
158    }
159
160    fn base_path(&self) -> PathBuf {
161        PathBuf::from(&self.base)
162    }
163
164    fn metadata_path(&self) -> PathBuf {
165        self.base_path()
166            .join(format!("{}_metadata.json", self.name))
167    }
168
169    /// Converts the base path for the store to a `file://` URI for Lance.
170    fn basepath_to_uri(&self) -> StorageResult<String> {
171        Self::path_to_uri(PathBuf::from(self.base.clone()).as_path())
172    }
173
174    /// Save dense matrix using Lance-optimized vector format.
175    ///
176    /// Each row of the matrix becomes a FixedSizeList entry for efficient vector operations.
177    /// This format is optimized for vector search and enables Lance's full-zip encoding.
178    ///
179    /// # Arguments
180    /// * `filename` - any name
181    /// * `matrix` - Dense matrix to save (N rows × F cols)
182    /// * `md_path` - Metadata file path for validation
183    async fn save_dense(
184        &self,
185        key: &str,
186        matrix: &DenseMatrix<f64>,
187        md_path: &Path,
188    ) -> StorageResult<()> {
189        self.validate_initialized(md_path)?;
190        let path = self.file_path(key);
191        let (n_rows, n_cols) = matrix.shape();
192
193        info!(
194            "Saving dense {} matrix: {} x {} at {:?}",
195            key, n_rows, n_cols, path
196        );
197
198        // Convert to Lance-optimized RecordBatch (FixedSizeList format)
199        let batch = self.to_dense_record_batch(matrix)?;
200
201        // Verify batch has correct number of rows
202        if batch.num_rows() != n_rows {
203            return Err(StorageError::Invalid(format!(
204                "RecordBatch has {} rows but matrix has {} rows",
205                batch.num_rows(),
206                n_rows
207            )));
208        }
209
210        {
211            // Write to Lance
212            let uri = Self::path_to_uri(&path)?;
213            self.write_lance_batch_async(uri, batch).await?;
214            let mut md = self.load_metadata().await?;
215            md = md.add_file(
216                key,
217                FileInfo::new(
218                    format!("{}_{}.lance", self.get_name(), key),
219                    "dense",
220                    matrix.shape(),
221                    None,
222                    None,
223                )?,
224            );
225            self.save_metadata(&md).await?;
226            info!("Dense {} matrix saved successfully", key);
227        }
228        Ok(())
229    }
230
231    /// Load dense matrix from Lance-optimized vector format.
232    ///
233    /// Reads FixedSizeList vectors and reconstructs a column-major DenseMatrix.
234    ///
235    /// # Arguments
236    /// * `filename` - any name previously assigned
237    ///
238    /// # Returns
239    /// Column-major DenseMatrix matching smartcore conventions
240    async fn load_dense(&self, key: &str) -> StorageResult<DenseMatrix<f64>> {
241        let path = self.file_path(key);
242        info!("Loading dense {} matrix from {:?}", key, path);
243
244        // Read all batches from Lance (may span multiple batches for large datasets)
245        let uri = Self::path_to_uri(&path)?;
246        let batch = self.read_lance_all_batches_async(uri).await?;
247
248        // Convert from FixedSizeList format to DenseMatrix
249        let matrix = self.from_dense_record_batch(&batch)?;
250
251        let (n_rows, n_cols) = matrix.shape();
252        info!("Loaded dense {} matrix: {} x {}", key, n_rows, n_cols);
253
254        Ok(matrix)
255    }
256
257    /// Load initial data using columnar format from a file path.
258    ///
259    /// Async test helper that avoids any internal blocking runtimes.
260    async fn load_dense_from_file(&self, path: &Path) -> StorageResult<DenseMatrix<f64>> {
261        info!("Loading dense matrix from file (async): {:?}", path);
262
263        if !path.exists() {
264            return Err(StorageError::Invalid(format!(
265                "Dense file does not exist: {:?}",
266                path
267            )));
268        }
269
270        let extension = path
271            .extension()
272            .and_then(|e| e.to_str())
273            .ok_or_else(|| StorageError::Invalid(format!("Invalid file path: {:?}", path)))?;
274
275        match extension {
276            "lance" => {
277                // Use a temporary LanceStorage rooted at the file's parent dir,
278                // same pattern as save_dense_to_file_async.
279                let parent = path
280                    .parent()
281                    .ok_or_else(|| {
282                        StorageError::Invalid(format!("Path has no parent: {:?}", path))
283                    })?
284                    .to_str()
285                    .ok_or_else(|| {
286                        StorageError::Invalid(format!("Non-UTF8 parent path for {:?}", path))
287                    })?
288                    .to_string();
289
290                let tmp_storage = Self::new(parent, String::from("tmp_storage"));
291
292                // Reuse the async Lance reader logic.
293                let uri = Self::path_to_uri(path)?;
294                let batch = tmp_storage.read_lance_all_batches_async(uri).await?;
295                let matrix = tmp_storage.from_dense_record_batch(&batch)?;
296                info!(
297                    "Loaded dense matrix from Lance: {} x {}",
298                    matrix.shape().0,
299                    matrix.shape().1
300                );
301                Ok(matrix)
302            }
303            "parquet" => {
304                use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
305
306                // Parquet readers require a synchronous `Read` impl; run the
307                // blocking open/read/concat on the dedicated blocking pool so
308                // the async executor thread is not stalled (issue #52).
309                let owned_path = path.to_path_buf();
310                let combined =
311                    tokio::task::spawn_blocking(move || -> StorageResult<RecordBatch> {
312                        let file = std::fs::File::open(&owned_path).map_err(|e| {
313                            StorageError::Io(format!("Failed to open parquet file: {}", e))
314                        })?;
315
316                        let builder =
317                            ParquetRecordBatchReaderBuilder::try_new(file).map_err(|e| {
318                                StorageError::Parquet(format!(
319                                    "Failed to create parquet reader: {}",
320                                    e
321                                ))
322                            })?;
323                        let reader = builder.build().map_err(|e| {
324                            StorageError::Parquet(format!("Failed to build parquet reader: {}", e))
325                        })?;
326
327                        let batches: Vec<RecordBatch> =
328                            reader.collect::<Result<Vec<_>, _>>().map_err(|e| {
329                                StorageError::Parquet(format!(
330                                    "Failed to read parquet batch: {}",
331                                    e
332                                ))
333                            })?;
334
335                        if batches.is_empty() {
336                            return Err(StorageError::Invalid(format!(
337                                "Empty parquet dataset at {:?}",
338                                owned_path
339                            )));
340                        }
341
342                        let schema = batches[0].schema();
343                        arrow::compute::concat_batches(&schema, &batches).map_err(|e| {
344                            StorageError::Parquet(format!(
345                                "Failed to concatenate parquet batches: {}",
346                                e
347                            ))
348                        })
349                    })
350                    .await
351                    .map_err(|e| {
352                        StorageError::Io(format!("Parquet reader task failed: {}", e))
353                    })??;
354
355                // 2. Detect layout: vector (FixedSizeList) vs old wide columnar (col_* Float64)
356                let schema = combined.schema();
357                let fields = schema.fields();
358                let is_vector = fields.len() == 1
359                    && matches!(
360                        fields[0].data_type(),
361                        DataType::FixedSizeList(inner, _)
362                            if matches!(inner.data_type(), DataType::Float64)
363                    );
364
365                let is_wide_col = !is_vector
366                    && !fields.is_empty()
367                    && fields
368                        .iter()
369                        .all(|f| matches!(f.data_type(), DataType::Float64))
370                    && fields.iter().any(|f| f.name().starts_with("col_"));
371
372                // 3. Build DenseMatrix from the RecordBatch
373                let matrix = if is_vector {
374                    // New format already: vector column (FixedSizeList<Float64>)
375                    // Reuse the same decoding as Lance.
376                    let parent = path
377                        .parent()
378                        .ok_or_else(|| {
379                            StorageError::Invalid(format!("Path has no parent: {:?}", path))
380                        })?
381                        .to_str()
382                        .ok_or_else(|| {
383                            StorageError::Invalid(format!("Non-UTF8 parent path for {:?}", path))
384                        })?
385                        .to_string();
386
387                    let tmp_storage = Self::new(parent, String::from("tmp_storage"));
388                    tmp_storage.from_dense_record_batch(&combined)?
389                } else if is_wide_col {
390                    // Old wide columnar: columns like col_0, col_1, ... as Float64
391                    let n_rows = combined.num_rows();
392                    let n_cols = combined.num_columns();
393                    if n_rows == 0 || n_cols == 0 {
394                        return Err(StorageError::Invalid(format!(
395                            "Cannot load empty wide-column parquet at {:?}",
396                            path
397                        )));
398                    }
399
400                    let mut data = Vec::with_capacity(n_rows * n_cols);
401                    for col_idx in 0..n_cols {
402                        let col = combined.column(col_idx);
403                        let arr = col.as_any().downcast_ref::<Float64Array>().ok_or_else(|| {
404                            StorageError::Invalid(format!(
405                                "Wide-column parquet expects Float64, got {:?} in column {}",
406                                col.data_type(),
407                                col_idx
408                            ))
409                        })?;
410                        // Build column-major storage: all rows for col 0, then col 1, ...
411                        for row_idx in 0..n_rows {
412                            data.push(arr.value(row_idx));
413                        }
414                    }
415
416                    DenseMatrix::new(n_rows, n_cols, data, true)
417                        .map_err(|e| StorageError::Invalid(e.to_string()))?
418                } else {
419                    return Err(StorageError::Invalid(format!(
420                        "Unsupported Parquet schema at {:?}: expected FixedSizeList<Float64> \
421                         or wide Float64 columns named col_*",
422                        path
423                    )));
424                };
425
426                info!(
427                    "Loaded dense matrix from Parquet: {} x {}",
428                    matrix.shape().0,
429                    matrix.shape().1
430                );
431
432                Ok(matrix)
433            }
434            _ => Err(StorageError::Invalid(format!(
435                "Unsupported file format: {}. Only .lance and .parquet are supported",
436                extension
437            ))),
438        }
439    }
440
441    fn file_path(&self, key: &str) -> PathBuf {
442        self.base_path()
443            .join(format!("{}_{}.lance", self.name, key))
444    }
445
446    // =========
447    // ASYNC API (matches StorageBackend)
448    // =========
449
450    async fn save_sparse(
451        &self,
452        key: &str,
453        matrix: &CsMat<f64>,
454        md_path: &Path,
455    ) -> StorageResult<()> {
456        self.validate_initialized(md_path)?;
457        let path = self.file_path(key);
458        info!(
459            "Saving sparse {} matrix: {} x {}, nnz={} at {:?}",
460            key,
461            matrix.rows(),
462            matrix.cols(),
463            matrix.nnz(),
464            path
465        );
466
467        let filetype = FileInfo::which_filetype(key)?;
468        {
469            let mut metadata = self.load_metadata().await?;
470            metadata = metadata.add_file(
471                key,
472                FileInfo::new(
473                    format!("{}_{}.lance", self.get_name(), key),
474                    filetype.as_str(),
475                    (matrix.rows(), matrix.cols()),
476                    Some(matrix.nnz()),
477                    None,
478                )?,
479            );
480            self.save_metadata(&metadata).await?;
481
482            let batch = self.to_sparse_record_batch(matrix)?;
483            let uri = Self::path_to_uri(&path)?;
484            self.write_lance_batch_async(uri, batch).await?;
485        }
486        info!("Sparse matrix {} saved successfully", filetype);
487        Ok(())
488    }
489
490    async fn load_sparse(&self, key: &str) -> StorageResult<CsMat<f64>> {
491        info!("Loading sparse {} matrix", key);
492
493        let metadata = self.load_metadata().await?;
494        let filetype = FileInfo::which_filetype(key)?;
495        let file_info = metadata
496            .files
497            .get(key)
498            .ok_or_else(|| StorageError::Invalid(format!("{key} not found in metadata")))?;
499
500        let expected_rows = file_info.rows;
501        let expected_cols = file_info.cols;
502        debug!(
503            "Expected dimensions from storage metadata: {} x {}",
504            expected_rows, expected_cols
505        );
506
507        let path = self.file_path(key);
508        let uri = Self::path_to_uri(&path)?;
509        let batch = self.read_lance_all_batches_async(uri).await?;
510        let matrix = self.from_sparse_record_batch(batch, expected_rows, expected_cols)?;
511        info!(
512            "Sparse {} matrix loaded: {} x {}, nnz={}",
513            filetype,
514            matrix.rows(),
515            matrix.cols(),
516            matrix.nnz()
517        );
518        Ok(matrix)
519    }
520
521    async fn save_lambdas(&self, lambdas: &[f64], md_path: &Path) -> StorageResult<()> {
522        info!("Saving {} lambda values", lambdas.len());
523        self.save_primitive_column::<Float64Type>("lambdas", "lambda", lambdas.to_vec(), md_path)
524            .await
525    }
526
527    async fn load_lambdas(&self) -> StorageResult<Vec<f64>> {
528        let path = self.file_path("lambdas");
529        info!("Loading lambda values from {:?}", path);
530
531        let uri = Self::path_to_uri(&path)?;
532        let batch = self.read_lance_all_batches_async(uri).await?;
533        let arr = batch
534            .column(0)
535            .as_any()
536            .downcast_ref::<Float64Array>()
537            .ok_or_else(|| StorageError::Invalid("lambda column type mismatch".into()))?;
538
539        let lambdas: Vec<f64> = (0..arr.len()).map(|i| arr.value(i)).collect();
540        info!("Loaded {} lambda values", lambdas.len());
541        Ok(lambdas)
542    }
543
544    async fn save_vector(&self, key: &str, vector: &[f64], md_path: &Path) -> StorageResult<()> {
545        info!("Saving {} values for vector {}", vector.len(), key);
546        self.save_primitive_column::<Float64Type>(key, "element", vector.to_vec(), md_path)
547            .await
548    }
549
550    async fn save_index(&self, key: &str, vector: &[usize], md_path: &Path) -> StorageResult<()> {
551        info!("Saving {} values for index {}", vector.len(), key);
552        // Checked cast: usize values above u32::MAX must not be silently
553        // truncated (issue #51).
554        let values = checked_u32_values(vector, "index")?;
555        self.save_primitive_column::<UInt32Type>(key, "id", values, md_path)
556            .await
557    }
558
559    async fn load_vector(&self, filename: &str) -> StorageResult<Vec<f64>> {
560        let path = self.file_path(filename);
561        info!("Loading vector {} from {:?}", filename, path);
562
563        let uri = Self::path_to_uri(&path)?;
564        let batch = self.read_lance_all_batches_async(uri).await?;
565        let arr = batch
566            .column(0)
567            .as_any()
568            .downcast_ref::<Float64Array>()
569            .ok_or_else(|| StorageError::Invalid("column type mismatch".into()))?;
570
571        let vector: Vec<f64> = (0..arr.len()).map(|i| arr.value(i)).collect();
572        info!("Loaded {} vector values for {}", vector.len(), filename);
573        Ok(vector)
574    }
575
576    async fn load_index(&self, filename: &str) -> StorageResult<Vec<usize>> {
577        let path = self.file_path(filename);
578        info!("Loading vector {} from {:?}", filename, path);
579
580        let uri = Self::path_to_uri(&path)?;
581        let batch = self.read_lance_all_batches_async(uri).await?;
582        let arr = batch
583            .column(0)
584            .as_any()
585            .downcast_ref::<UInt32Array>()
586            .ok_or_else(|| StorageError::Invalid("column type mismatch".into()))?;
587
588        let vector: Vec<usize> = (0..arr.len()).map(|i| arr.value(i) as usize).collect();
589        info!("Loaded {} vector values for {}", vector.len(), filename);
590        Ok(vector)
591    }
592
593    /// Save dense matrix to file in columnar format (col_0, col_1, ..., col_N)
594    ///
595    /// Async test helper that avoids any internal blocking runtimes.
596    async fn save_dense_to_file(data: &DenseMatrix<f64>, path: &Path) -> StorageResult<()> {
597        info!("Saving dense matrix to file (async): {:?}", path);
598
599        // Create missing parent directories instead of failing late in the
600        // format-specific writers.
601        let parent = path
602            .parent()
603            .filter(|p| !p.as_os_str().is_empty())
604            .ok_or_else(|| {
605                StorageError::Invalid(format!("path has no parent directory: {:?}", path))
606            })?;
607        tokio::fs::create_dir_all(parent)
608            .await
609            .map_err(|e| StorageError::Io(format!("create dir {:?}: {}", parent, e)))?;
610        let parent_str = parent
611            .to_str()
612            .ok_or_else(|| StorageError::Invalid(format!("non-UTF8 parent path for {:?}", path)))?
613            .to_string();
614
615        // Temporary storage, only used to build the record batch / write lance.
616        let tmp_storage = Self::new(parent_str, String::from("tmp_storage"));
617
618        let extension = path
619            .extension()
620            .and_then(|e| e.to_str())
621            .ok_or_else(|| StorageError::Invalid(format!("Invalid file path: {:?}", path)))?;
622
623        let (n_rows, n_cols) = data.shape();
624        info!("Saving matrix: {} rows x {} cols", n_rows, n_cols);
625
626        match extension {
627            "lance" => {
628                let batch = tmp_storage.to_dense_record_batch(data)?;
629                debug!(
630                    "Created RecordBatch with {} rows for Lance",
631                    batch.num_rows()
632                );
633
634                // Verify all rows are in the batch
635                if batch.num_rows() != n_rows {
636                    return Err(StorageError::Invalid(format!(
637                        "RecordBatch has {} rows but matrix has {} rows",
638                        batch.num_rows(),
639                        n_rows
640                    )));
641                }
642
643                let uri = Self::path_to_uri(path)?;
644                tmp_storage.write_lance_batch_async(uri, batch).await?;
645                info!("Saved dense matrix to Lance: {} x {}", n_rows, n_cols);
646                Ok(())
647            }
648            "parquet" => {
649                use parquet::arrow::ArrowWriter;
650                use parquet::file::properties::WriterProperties;
651                use std::fs::File;
652
653                let batch = tmp_storage.to_dense_record_batch(data)?;
654                debug!(
655                    "Created RecordBatch with {} rows for Parquet",
656                    batch.num_rows()
657                );
658
659                if batch.num_rows() != n_rows {
660                    return Err(StorageError::Invalid(format!(
661                        "RecordBatch has {} rows but matrix has {} rows",
662                        batch.num_rows(),
663                        n_rows
664                    )));
665                }
666
667                // The parquet writer is synchronous: run it on the blocking
668                // pool so the async executor thread is not stalled (see #52
669                // for the matching read path).
670                let owned_path = path.to_path_buf();
671                tokio::task::spawn_blocking(move || -> StorageResult<()> {
672                    let file = File::create(&owned_path).map_err(|e| {
673                        StorageError::Io(format!("Failed to create parquet file: {}", e))
674                    })?;
675
676                    let props = WriterProperties::builder()
677                        .set_compression(parquet::basic::Compression::SNAPPY)
678                        .build();
679
680                    let mut writer = ArrowWriter::try_new(file, batch.schema(), Some(props))
681                        .map_err(|e| {
682                            StorageError::Parquet(format!("Failed to create parquet writer: {}", e))
683                        })?;
684
685                    writer.write(&batch).map_err(|e| {
686                        StorageError::Parquet(format!("Failed to write batch: {}", e))
687                    })?;
688
689                    writer.close().map_err(|e| {
690                        StorageError::Parquet(format!("Failed to close writer: {}", e))
691                    })?;
692
693                    Ok(())
694                })
695                .await
696                .map_err(|e| StorageError::Io(format!("parquet writer task failed: {}", e)))??;
697
698                info!("Saved dense matrix to Parquet: {} x {}", n_rows, n_cols);
699                Ok(())
700            }
701            _ => Err(StorageError::Invalid(format!(
702                "Unsupported file format: {}. Only .lance and .parquet are supported",
703                extension
704            ))),
705        }
706    }
707
708    /// Save centroid_map (item-to-centroid assignments)
709    async fn save_centroid_map(&self, map: &[usize], md_path: &Path) -> StorageResult<()> {
710        info!("Saving {} centroid map entries", map.len());
711        // Checked cast: usize values above u32::MAX must not be silently
712        // truncated (issue #51).
713        let values = checked_u32_values(map, "centroid map")?;
714        self.save_primitive_column::<UInt32Type>("centroid_map", "centroid_id", values, md_path)
715            .await
716    }
717
718    /// Load centroid_map
719    async fn load_centroid_map(&self) -> StorageResult<Vec<usize>> {
720        let path = self.file_path("centroid_map");
721        info!("Loading centroid map from {:?}", path);
722
723        let uri = Self::path_to_uri(&path)?;
724        let batch = self.read_lance_all_batches_async(uri).await?;
725        let arr = batch
726            .column(0)
727            .as_any()
728            .downcast_ref::<UInt32Array>()
729            .ok_or_else(|| StorageError::Invalid("centroid_id column type mismatch".into()))?;
730
731        let map: Vec<usize> = (0..arr.len()).map(|i| arr.value(i) as usize).collect();
732        info!("Loaded {} centroid map entries", map.len());
733        Ok(map)
734    }
735
736    /// Save subcentroid_lambdas (tau values for subcentroids)
737    async fn save_subcentroid_lambdas(&self, lambdas: &[f64], md_path: &Path) -> StorageResult<()> {
738        info!("Saving {} subcentroid lambda values", lambdas.len());
739        self.save_primitive_column::<Float64Type>(
740            "subcentroid_lambdas",
741            "subcentroid_lambda",
742            lambdas.to_vec(),
743            md_path,
744        )
745        .await
746    }
747
748    /// Load subcentroid_lambdas
749    async fn load_subcentroid_lambdas(&self) -> StorageResult<Vec<f64>> {
750        let path = self.file_path("subcentroid_lambdas");
751        info!("Loading subcentroid lambda values from {:?}", path);
752
753        let uri = Self::path_to_uri(&path)?;
754        let batch = self.read_lance_all_batches_async(uri).await?;
755        let arr = batch
756            .column(0)
757            .as_any()
758            .downcast_ref::<Float64Array>()
759            .ok_or_else(|| {
760                StorageError::Invalid("subcentroid_lambda column type mismatch".into())
761            })?;
762
763        let lambdas: Vec<f64> = (0..arr.len()).map(|i| arr.value(i)).collect();
764        info!("Loaded {} subcentroid lambda values", lambdas.len());
765        Ok(lambdas)
766    }
767
768    /// Save subcentroids (dense matrix)
769    async fn save_subcentroids(
770        &self,
771        subcentroids: &DenseMatrix<f64>,
772        md_path: &Path,
773    ) -> StorageResult<()> {
774        self.validate_initialized(md_path)?;
775        let key = "sub_centroids";
776        let path = self.file_path(key);
777        let (n_rows, n_cols) = subcentroids.shape();
778        info!(
779            "Saving subcentroids matrix {} x {} at {:?}",
780            n_rows, n_cols, path
781        );
782
783        let batch = self.to_dense_record_batch(subcentroids)?;
784        {
785            let mut metadata = self.load_metadata().await?;
786            metadata = metadata.add_file(
787                key,
788                FileInfo::new(
789                    format!("{}_{}.lance", self.get_name(), key),
790                    "vector",
791                    subcentroids.shape(),
792                    None,
793                    None,
794                )?,
795            );
796            self.save_metadata(&metadata).await?;
797
798            let uri = Self::path_to_uri(&path)?;
799            self.write_lance_batch_async(uri, batch).await?;
800        }
801        debug!("Subcentroids matrix saved successfully");
802        Ok(())
803    }
804
805    /// Load subcentroids as Vec<Vec<f64>>
806    async fn load_subcentroids(&self) -> StorageResult<Vec<Vec<f64>>> {
807        let path = self.file_path("sub_centroids");
808        info!("Loading sub_centroids from {:?}", path);
809
810        let uri = Self::path_to_uri(&path)?;
811        let batch = self.read_lance_all_batches_async(uri).await?;
812        let matrix = self.from_dense_record_batch(&batch)?;
813
814        // Convert DenseMatrix to Vec<Vec<f64>>
815        let (n_rows, n_cols) = matrix.shape();
816        let mut result = Vec::with_capacity(n_rows);
817
818        for row_idx in 0..n_rows {
819            let row: Vec<f64> = (0..n_cols)
820                .map(|col_idx| *matrix.get((row_idx, col_idx)))
821                .collect();
822            result.push(row);
823        }
824
825        info!(
826            "Loaded sub_centroids: {} x {} as Vec<Vec<f64>>",
827            n_rows, n_cols
828        );
829        Ok(result)
830    }
831
832    /// Save item norms vector
833    async fn save_item_norms(&self, item_norms: &[f64], md_path: &Path) -> StorageResult<()> {
834        info!("Saving {} item norm values", item_norms.len());
835        self.save_primitive_column::<Float64Type>(
836            "item_norms",
837            "norm",
838            item_norms.to_vec(),
839            md_path,
840        )
841        .await
842    }
843
844    /// Load item norms vector
845    async fn load_item_norms(&self) -> StorageResult<Vec<f64>> {
846        let path = self.file_path("item_norms");
847        info!("Loading item norms from {:?}", path);
848
849        let uri = Self::path_to_uri(&path)?;
850        let batch = self.read_lance_all_batches_async(uri).await?;
851        let arr = batch
852            .column(0)
853            .as_any()
854            .downcast_ref::<Float64Array>()
855            .ok_or_else(|| StorageError::Invalid("norm column type mismatch".into()))?;
856
857        let norms: Vec<f64> = (0..arr.len()).map(|i| arr.value(i)).collect();
858        info!("Loaded {} item norm values", norms.len());
859        Ok(norms)
860    }
861
862    async fn save_cluster_assignments(
863        &self,
864        assignments: &[Option<usize>],
865        md_path: &Path,
866    ) -> StorageResult<()> {
867        info!("Saving {} cluster assignments", assignments.len());
868
869        // Convert Option<usize> to i64 (-1 for None)
870        let values: Vec<i64> = assignments
871            .iter()
872            .map(|opt| opt.map(|v| v as i64).unwrap_or(-1))
873            .collect();
874
875        self.save_primitive_column::<Int64Type>(
876            "cluster_assignments",
877            "cluster_id",
878            values,
879            md_path,
880        )
881        .await
882    }
883
884    async fn load_cluster_assignments(&self) -> StorageResult<Vec<Option<usize>>> {
885        use arrow::array::Int64Array;
886        let path = self.file_path("cluster_assignments");
887        info!("Loading cluster assignments from {:?}", path);
888
889        let uri = Self::path_to_uri(&path)?;
890        let batch = self.read_lance_all_batches_async(uri).await?;
891        let arr = batch
892            .column(0)
893            .as_any()
894            .downcast_ref::<Int64Array>()
895            .ok_or_else(|| StorageError::Invalid("cluster_id column type mismatch".into()))?;
896
897        let assignments: Vec<Option<usize>> = (0..arr.len())
898            .map(|i| {
899                let v = arr.value(i);
900                if v < 0 { None } else { Some(v as usize) }
901            })
902            .collect();
903        info!("Loaded {} cluster assignments", assignments.len());
904        Ok(assignments)
905    }
906}