1use std::collections::BTreeMap;
2use std::path::Path;
3use std::sync::Arc;
4
5use arrow::array::{
6 ArrayRef, Float32Array, Float64Array, PrimitiveArray, UInt32Array, UInt64Array,
7};
8use arrow::datatypes::{ArrowPrimitiveType, DataType, Field, Schema};
9use arrow::record_batch::RecordBatch;
10use log::{debug, info};
11
12use crate::catalog::RESERVED_PROPERTIES;
13use crate::graph::{
14 GraphEdge, GraphReadOptions, GraphWriteOptions, NodeIdWidth, RESERVED_METADATA_KEYS,
15 StoredGraph, WeightType,
16};
17use crate::metadata::FileInfo;
18use crate::traits::backend::StorageBackend;
19use crate::{StorageError, StorageResult};
20
21pub(crate) fn reject_reserved_user_properties(
27 properties: &BTreeMap<String, String>,
28) -> StorageResult<()> {
29 for key in properties.keys() {
30 if RESERVED_PROPERTIES.contains(&key.as_str()) {
31 return Err(StorageError::Invalid(format!(
32 "user property '{key}' is reserved (computed from the stored artifact)"
33 )));
34 }
35 }
36 Ok(())
37}
38
39fn uri_to_path(uri: &str) -> StorageResult<std::path::PathBuf> {
41 let url = url::Url::parse(uri)
42 .map_err(|e| StorageError::Invalid(format!("bad dataset URI `{uri}`: {e}")))?;
43 url.to_file_path().map_err(|_| {
44 StorageError::Invalid(format!("dataset URI is not a local file path: `{uri}`"))
45 })
46}
47
48pub(crate) fn with_collection_metadata(
53 batch: &RecordBatch,
54 kind: &str,
55 fixed: &[(&'static str, String)],
56 user_properties: &BTreeMap<String, String>,
57) -> StorageResult<RecordBatch> {
58 for key in user_properties.keys() {
59 if RESERVED_METADATA_KEYS.contains(&key.as_str()) {
60 return Err(StorageError::Invalid(format!(
61 "user property '{key}' is reserved by the collection metadata"
62 )));
63 }
64 }
65 let mut metadata = batch.schema().metadata().clone();
66 for (k, v) in fixed {
67 metadata.insert(k.to_string(), v.clone());
68 }
69 for (k, v) in user_properties {
70 metadata.insert(k.clone(), v.clone());
71 }
72 metadata.insert("kind".to_string(), kind.to_string());
73
74 let schema = Arc::new(batch.schema().as_ref().clone().with_metadata(metadata));
75 RecordBatch::try_new(schema, batch.columns().to_vec())
76 .map_err(|e| StorageError::Lance(e.to_string()))
77}
78
79pub(crate) fn validate_vector_space_schema(schema: &Schema) -> StorageResult<(i32, DataType)> {
84 let fields = schema.fields();
85 if fields.is_empty() {
86 return Err(StorageError::Invalid(
87 "vector-space schema has no columns".into(),
88 ));
89 }
90 let mut vector_dim: Option<(i32, DataType)> = None;
91 for f in fields {
92 if f.is_nullable() {
93 return Err(StorageError::Invalid(format!(
94 "vector-space schemas require non-nullable columns; '{}' is nullable",
95 f.name()
96 )));
97 }
98 match f.data_type() {
99 DataType::FixedSizeList(child, dim)
100 if matches!(child.data_type(), DataType::Float64 | DataType::Float32) =>
101 {
102 if vector_dim.is_none() {
103 vector_dim = Some((*dim, child.data_type().clone()));
104 }
105 }
106 DataType::Float32
107 | DataType::Float64
108 | DataType::UInt8
109 | DataType::UInt32
110 | DataType::UInt64
111 | DataType::Int64 => {}
112 other => {
113 return Err(StorageError::Invalid(format!(
114 "vector-space id/property column '{}' has unsupported type {other:?}; \
115 expected FixedSizeList<Float64|Float32> or a scalar column",
116 f.name()
117 )));
118 }
119 }
120 }
121 vector_dim.ok_or_else(|| {
122 StorageError::Invalid(
123 "vector-space schema requires at least one FixedSizeList<Float64|Float32> column"
124 .into(),
125 )
126 })
127}
128
129pub(crate) fn vector_space_collection_batch(
134 batch: &RecordBatch,
135 properties: &BTreeMap<String, String>,
136) -> StorageResult<(RecordBatch, i32)> {
137 reject_reserved_user_properties(properties)?;
138 if batch.num_rows() == 0 {
139 return Err(StorageError::Invalid(
140 "empty vector-space collections are not supported".into(),
141 ));
142 }
143 let (dim, _item_type) = validate_vector_space_schema(batch.schema().as_ref())?;
144 let stamped = with_collection_metadata(batch, "vector-space", &[], properties)?;
145 Ok((stamped, dim))
146}
147
148pub(crate) fn graph_record_batch(
157 edges: &[GraphEdge],
158 options: &GraphWriteOptions,
159) -> StorageResult<(RecordBatch, u64)> {
160 reject_reserved_user_properties(&options.properties)?;
161 if edges.is_empty() {
162 return Err(StorageError::Invalid(
163 "empty graph collections are not supported".into(),
164 ));
165 }
166 let weighted = edges[0].weight.is_some();
169 if edges.iter().any(|e| e.weight.is_some() != weighted) {
170 return Err(StorageError::Invalid(
171 "graph collection must be either fully weighted or topology-only; \
172 mixed edges are rejected"
173 .into(),
174 ));
175 }
176 let max_id = edges.iter().map(|e| e.src.max(e.dst)).max().unwrap_or(0);
179 let width = options.node_id_width;
180 if width == NodeIdWidth::U32 && max_id > u32::MAX as u64 {
181 return Err(StorageError::Overflow(format!(
182 "node id {max_id} exceeds u32::MAX; save with NodeIdWidth::U64"
183 )));
184 }
185 let num_nodes = match options.num_nodes {
186 Some(n) if n < max_id + 1 => {
187 return Err(StorageError::Invalid(format!(
188 "num_nodes {n} is smaller than the highest node id + 1 ({})",
189 max_id + 1
190 )));
191 }
192 Some(n) => n,
193 None => max_id + 1,
194 };
195 if let Some((lo, hi)) = options.weight_range
203 && !matches!(
204 lo.partial_cmp(&hi),
205 Some(std::cmp::Ordering::Less | std::cmp::Ordering::Equal)
206 )
207 {
208 return Err(StorageError::Invalid(format!(
209 "weight_range ({lo:?}, {hi:?}) is not a valid interval: expected low <= high"
210 )));
211 }
212 if weighted {
213 if let Some((lo, hi)) = options.weight_range {
214 for e in edges {
215 let w = e.weight.unwrap();
216 if !(lo..=hi).contains(&w) {
217 return Err(StorageError::Invalid(format!(
218 "graph weight {w} is outside the declared weight_range [{lo:?}, {hi:?}]"
219 )));
220 }
221 }
222 }
223 if options.weight_type == WeightType::F32 {
229 for e in edges {
230 let w = e.weight.unwrap();
231 let narrowed = w as f32;
232 if w.is_finite() && !narrowed.is_finite() {
233 return Err(StorageError::Overflow(format!(
234 "weight {w} exceeds the f32 range; save with WeightType::F64"
235 )));
236 }
237 let exact = f64::from(narrowed).to_bits() == w.to_bits()
238 || (w.is_nan() && narrowed.is_nan());
239 if !exact {
240 return Err(StorageError::Invalid(format!(
241 "weight {w} is not exactly representable as f32 and would be \
242 silently narrowed; save with WeightType::F64 or pre-narrow the \
243 value with `as f32`"
244 )));
245 }
246 }
247 }
248 }
249
250 let (src, dst): (ArrayRef, ArrayRef) = match width {
251 NodeIdWidth::U32 => (
252 Arc::new(UInt32Array::from_iter_values(
253 edges.iter().map(|e| e.src as u32),
254 )),
255 Arc::new(UInt32Array::from_iter_values(
256 edges.iter().map(|e| e.dst as u32),
257 )),
258 ),
259 NodeIdWidth::U64 => (
260 Arc::new(UInt64Array::from_iter_values(edges.iter().map(|e| e.src))),
261 Arc::new(UInt64Array::from_iter_values(edges.iter().map(|e| e.dst))),
262 ),
263 };
264 let mut fields = vec![
265 Field::new("src", width.data_type(), false),
266 Field::new("dst", width.data_type(), false),
267 ];
268 let mut columns: Vec<ArrayRef> = vec![src, dst];
269 if weighted {
270 fields.push(Field::new("weight", options.weight_type.data_type(), false));
271 let weights = edges.iter().map(|e| e.weight.unwrap());
272 columns.push(match options.weight_type {
273 WeightType::F32 => {
274 Arc::new(Float32Array::from_iter_values(weights.map(|w| w as f32))) as ArrayRef
275 }
276 WeightType::F64 => Arc::new(Float64Array::from_iter_values(weights)) as ArrayRef,
277 });
278 }
279 let batch = RecordBatch::try_new(Arc::new(Schema::new(fields)), columns)
280 .map_err(|e| StorageError::Lance(e.to_string()))?;
281 let batch = with_collection_metadata(
283 &batch,
284 "graph",
285 &[
286 ("node_id_width", width.as_str().to_string()),
287 ("weighted", weighted.to_string()),
288 ("num_nodes", num_nodes.to_string()),
289 ("weight_type", options.weight_type.as_str().to_string()),
290 ],
291 &options.properties,
292 )?;
293 Ok((batch, num_nodes))
294}
295
296pub(crate) fn stored_graph_from_batch_with_options(
307 batch: RecordBatch,
308 options: &GraphReadOptions,
309) -> StorageResult<StoredGraph> {
310 let schema = batch.schema();
311 let n_cols = schema.fields().len();
312 if !(2..=3).contains(&n_cols) {
313 return Err(StorageError::Invalid(format!(
314 "graph edge-list schema expects src/dst (and optionally weight) columns, \
315 found {n_cols}"
316 )));
317 }
318 if options.strict {
323 let expected = ["src", "dst", "weight"];
324 for (i, want) in expected.iter().enumerate().take(n_cols) {
325 if schema.field(i).name() != *want {
326 return Err(StorageError::Invalid(format!(
327 "strict graph read: column {i} is named '{}', expected '{want}' \
328 (a pre-collections triplet artifact?)",
329 schema.field(i).name()
330 )));
331 }
332 }
333 }
334 let width = match (schema.field(0).data_type(), schema.field(1).data_type()) {
335 (DataType::UInt32, DataType::UInt32) => NodeIdWidth::U32,
336 (DataType::UInt64, DataType::UInt64) => NodeIdWidth::U64,
337 (s, d) => {
338 return Err(StorageError::Invalid(format!(
339 "graph src/dst columns must share width UInt32|UInt64, found {s:?}/{d:?}"
340 )));
341 }
342 };
343 let weighted = n_cols == 3;
344 let weight_type = if weighted {
345 match schema.field(2).data_type() {
346 DataType::Float32 => WeightType::F32,
347 DataType::Float64 => WeightType::F64,
348 other => {
349 return Err(StorageError::Invalid(format!(
350 "graph weight column must be Float32|Float64, found {other:?}"
351 )));
352 }
353 }
354 } else {
355 schema
358 .metadata()
359 .get("weight_type")
360 .and_then(|v| WeightType::parse(v).ok())
361 .unwrap_or(WeightType::F64)
362 };
363
364 let ids = |col: usize, what: &'static str| -> StorageResult<Vec<u64>> {
365 let arr = batch.column(col);
366 if arr.null_count() != 0 {
367 return Err(StorageError::Invalid(format!(
368 "graph '{what}' column contains nulls"
369 )));
370 }
371 match width {
372 NodeIdWidth::U32 => {
373 let a = arr.as_any().downcast_ref::<UInt32Array>().ok_or_else(|| {
374 StorageError::Invalid(format!("graph '{what}' column type mismatch"))
375 })?;
376 Ok((0..a.len()).map(|i| a.value(i) as u64).collect())
377 }
378 NodeIdWidth::U64 => {
379 let a = arr.as_any().downcast_ref::<UInt64Array>().ok_or_else(|| {
380 StorageError::Invalid(format!("graph '{what}' column type mismatch"))
381 })?;
382 Ok((0..a.len()).map(|i| a.value(i)).collect())
383 }
384 }
385 };
386 let src = ids(0, "src")?;
387 let dst = ids(1, "dst")?;
388 let weights: Option<Vec<f64>> = if weighted {
389 let w = batch.column(2);
390 if w.null_count() != 0 {
391 return Err(StorageError::Invalid(
392 "graph 'weight' column contains nulls".into(),
393 ));
394 }
395 match weight_type {
396 WeightType::F32 => {
397 let a = w.as_any().downcast_ref::<Float32Array>().ok_or_else(|| {
399 StorageError::Invalid("graph 'weight' column type mismatch".into())
400 })?;
401 Some((0..a.len()).map(|i| f64::from(a.value(i))).collect())
402 }
403 WeightType::F64 => {
404 let a = w.as_any().downcast_ref::<Float64Array>().ok_or_else(|| {
405 StorageError::Invalid("graph 'weight' column type mismatch".into())
406 })?;
407 Some((0..a.len()).map(|i| a.value(i)).collect())
408 }
409 }
410 } else {
411 None
412 };
413
414 let max_id = src
417 .iter()
418 .zip(dst.iter())
419 .map(|(s, d)| (*s).max(*d))
420 .max()
421 .unwrap_or(0);
422 let stamped_num_nodes = schema
423 .metadata()
424 .get("num_nodes")
425 .and_then(|v| v.parse::<u64>().ok());
426 if options.strict && stamped_num_nodes.is_none() {
431 return Err(StorageError::Invalid(format!(
432 "strict graph read: dataset has no 'num_nodes' stamp; refusing to \
433 silently resize to max_id + 1 = {}",
434 max_id + 1
435 )));
436 }
437 let num_nodes = stamped_num_nodes.unwrap_or(max_id + 1);
438 let edges: Vec<GraphEdge> = match &weights {
439 Some(weights) => src
440 .iter()
441 .zip(dst.iter())
442 .zip(weights.iter())
443 .map(|((s, d), w)| GraphEdge::weighted(*s, *d, *w))
444 .collect(),
445 None => src
446 .iter()
447 .zip(dst.iter())
448 .map(|(s, d)| GraphEdge::unweighted(*s, *d))
449 .collect(),
450 };
451 if let Some(e) = edges
452 .iter()
453 .find(|e| e.src >= num_nodes || e.dst >= num_nodes)
454 {
455 return Err(StorageError::Invalid(format!(
456 "edge ({}, {}) out of bounds for {}-node graph",
457 e.src, e.dst, num_nodes
458 )));
459 }
460
461 Ok(StoredGraph {
462 edges,
463 node_id_width: width,
464 weight_type,
465 num_nodes,
466 weighted,
467 })
468}
469
470pub trait LanceStorage {
471 async fn write_lance_batch_async(&self, uri: String, batch: RecordBatch) -> StorageResult<()> {
477 info!("Writing Lance dataset (in-house v2.1) to {}", uri);
478 let path = uri_to_path(&uri)?;
479 tokio::task::spawn_blocking(move || crate::lancefmt::write_dataset(&batch, &path))
480 .await
481 .map_err(|e| StorageError::Io(format!("lancefmt writer task failed: {e}")))?
482 }
483
484 async fn read_lance_all_batches_async(&self, uri: String) -> StorageResult<RecordBatch> {
486 info!("Reading Lance dataset (in-house v2.1) from {}", uri);
487 let path = uri_to_path(&uri)?;
488 let combined = tokio::task::spawn_blocking(move || crate::lancefmt::scan_all(&path))
489 .await
490 .map_err(|e| StorageError::Io(format!("lancefmt reader task failed: {e}")))??;
491 debug!(
492 "Combined Lance batch for {:?} has {} rows",
493 uri,
494 combined.num_rows()
495 );
496 Ok(combined)
497 }
498
499 async fn save_primitive_column<T: ArrowPrimitiveType>(
506 &self,
507 key: &str,
508 field_name: &str,
509 values: Vec<T::Native>,
510 md_path: &Path,
511 ) -> StorageResult<()>
512 where
513 Self: StorageBackend,
514 {
515 self.validate_initialized(md_path)?;
516 let path = self.file_path(key);
517 let len = values.len();
518 info!("Saving {} values for {} (field {})", len, key, field_name);
519
520 let schema = Schema::new(vec![Field::new(field_name, T::DATA_TYPE, false)]).with_metadata(
525 std::collections::HashMap::from([("kind".to_string(), "vector-space".to_string())]),
526 );
527 let batch = RecordBatch::try_new(
528 Arc::new(schema),
529 vec![Arc::new(PrimitiveArray::<T>::from_iter_values(values)) as ArrayRef],
530 )
531 .map_err(|e| StorageError::Lance(e.to_string()))?;
532
533 crate::commit::with_commit_actor(&self.metadata_path(), || async {
534 let mut metadata = self.load_metadata().await?;
535 metadata = metadata.add_file(
536 key,
537 FileInfo::new(
538 crate::generations::artifact_file_name(self.get_name().as_str(), key),
539 "vector",
540 (len, 1),
541 None,
542 None,
543 )?,
544 );
545 self.save_metadata(&metadata).await
546 })
547 .await?;
548
549 let uri = Self::path_to_uri(&path)?;
550 self.write_lance_batch_async(uri, batch).await?;
551 info!("Vector {} saved successfully", key);
552 Ok(())
553 }
554}