1use crate::backend::StorageBackend;
19use crate::backend::table_names;
20use crate::backend::types::{FilterExpr, Scalar, ScalarIndexType, ScanRequest};
21use crate::storage::arrow_convert::build_timestamp_column_from_eid_map;
22use anyhow::{Result, anyhow};
23use arrow_array::builder::{LargeBinaryBuilder, StringBuilder};
24use arrow_array::{Array, ArrayRef, BooleanArray, RecordBatch, UInt64Array};
25use arrow_schema::{DataType, Field, Schema as ArrowSchema, TimeUnit};
26use sha3::{Digest, Sha3_256};
27use std::collections::HashMap;
28use std::sync::Arc;
29use uni_common::Properties;
30use uni_common::core::id::{Eid, UniId, Vid};
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum EndpointSide {
39 Src,
40 Dst,
41 Either,
42}
43
44#[derive(Debug)]
50pub struct MainEdgeDataset {
51 _base_uri: String,
52}
53
54impl MainEdgeDataset {
55 pub fn new(base_uri: &str) -> Self {
57 Self {
58 _base_uri: base_uri.to_string(),
59 }
60 }
61
62 pub fn compute_edge_uid(
75 src_uid: &UniId,
76 dst_uid: &UniId,
77 edge_type: &str,
78 props: &Properties,
79 ) -> UniId {
80 let mut hasher = Sha3_256::new();
81
82 hasher.update(b"src:");
85 hasher.update(src_uid.as_bytes());
86 hasher.update(b"\0");
87 hasher.update(b"dst:");
88 hasher.update(dst_uid.as_bytes());
89 hasher.update(b"\0");
90
91 hasher.update(b"type:");
93 hasher.update(edge_type.as_bytes());
94 hasher.update(b"\0");
95
96 let mut sorted_keys: Vec<_> = props.keys().collect();
98 sorted_keys.sort();
99 for key in sorted_keys {
100 if let Some(val) = props.get(key) {
101 hasher.update(key.as_bytes());
102 hasher.update(b":");
103 hasher.update(val.to_string().as_bytes());
104 hasher.update(b"\0");
105 }
106 }
107
108 let result = hasher.finalize();
109 UniId::from_bytes(result.into())
110 }
111
112 pub fn get_arrow_schema() -> Arc<ArrowSchema> {
114 Arc::new(ArrowSchema::new(vec![
115 Field::new("_eid", DataType::UInt64, false),
116 Field::new("src_vid", DataType::UInt64, false),
117 Field::new("dst_vid", DataType::UInt64, false),
118 Field::new("type", DataType::Utf8, false),
119 Field::new("props_json", DataType::LargeBinary, true),
120 Field::new("_deleted", DataType::Boolean, false),
121 Field::new("_version", DataType::UInt64, false),
122 Field::new(
123 "_created_at",
124 DataType::Timestamp(TimeUnit::Nanosecond, Some("UTC".into())),
125 true,
126 ),
127 Field::new(
128 "_updated_at",
129 DataType::Timestamp(TimeUnit::Nanosecond, Some("UTC".into())),
130 true,
131 ),
132 ]))
133 }
134
135 pub fn table_name() -> &'static str {
137 "edges"
138 }
139
140 pub fn build_record_batch(
147 edges: &[(Eid, Vid, Vid, String, Properties, bool, u64)],
148 created_at: Option<&HashMap<Eid, i64>>,
149 updated_at: Option<&HashMap<Eid, i64>>,
150 ) -> Result<RecordBatch> {
151 let arrow_schema = Self::get_arrow_schema();
152 let mut columns: Vec<ArrayRef> = Vec::with_capacity(arrow_schema.fields().len());
153
154 let eids: Vec<u64> = edges
156 .iter()
157 .map(|(e, _, _, _, _, _, _)| e.as_u64())
158 .collect();
159 columns.push(Arc::new(UInt64Array::from(eids)));
160
161 let src_vids: Vec<u64> = edges
163 .iter()
164 .map(|(_, s, _, _, _, _, _)| s.as_u64())
165 .collect();
166 columns.push(Arc::new(UInt64Array::from(src_vids)));
167
168 let dst_vids: Vec<u64> = edges
170 .iter()
171 .map(|(_, _, d, _, _, _, _)| d.as_u64())
172 .collect();
173 columns.push(Arc::new(UInt64Array::from(dst_vids)));
174
175 let mut type_builder = StringBuilder::new();
177 for (_, _, _, edge_type, _, _, _) in edges.iter() {
178 type_builder.append_value(edge_type);
179 }
180 columns.push(Arc::new(type_builder.finish()));
181
182 let mut props_json_builder = LargeBinaryBuilder::new();
184 for (_, _, _, _, props, _, _) in edges.iter() {
185 let jsonb_bytes = {
186 let json_val = serde_json::to_value(props).unwrap_or(serde_json::json!({}));
187 let uni_val: uni_common::Value = json_val.into();
188 uni_common::cypher_value_codec::encode(&uni_val)
189 };
190 props_json_builder.append_value(&jsonb_bytes);
191 }
192 columns.push(Arc::new(props_json_builder.finish()));
193
194 let deleted: Vec<bool> = edges.iter().map(|(_, _, _, _, _, d, _)| *d).collect();
196 columns.push(Arc::new(BooleanArray::from(deleted)));
197
198 let versions: Vec<u64> = edges.iter().map(|(_, _, _, _, _, _, v)| *v).collect();
200 columns.push(Arc::new(UInt64Array::from(versions)));
201
202 let eids = edges.iter().map(|(e, _, _, _, _, _, _)| *e);
204 columns.push(build_timestamp_column_from_eid_map(
205 eids.clone(),
206 created_at,
207 ));
208 columns.push(build_timestamp_column_from_eid_map(eids, updated_at));
209
210 RecordBatch::try_new(arrow_schema, columns).map_err(|e| anyhow!(e))
211 }
212
213 pub async fn write_batch(backend: &dyn StorageBackend, batch: RecordBatch) -> Result<()> {
219 let table_name = table_names::main_edge_table_name();
220 crate::storage::manager::write_batch_with_lance_conflict_retry(backend, table_name, batch)
221 .await
222 }
223
224 pub async fn ensure_default_indexes(backend: &dyn StorageBackend) -> Result<()> {
229 let table_name = table_names::main_edge_table_name();
230 let indices = backend.list_indexes(table_name).await?;
231
232 let has_index = |col: &str| {
233 indices
234 .iter()
235 .any(|idx| idx.columns.contains(&col.to_string()))
236 };
237
238 for (column, idx_type) in [
239 ("_eid", ScalarIndexType::BTree),
240 ("src_vid", ScalarIndexType::BTree),
241 ("dst_vid", ScalarIndexType::BTree),
242 ("type", ScalarIndexType::BTree),
243 ] {
244 if has_index(column) {
245 continue;
246 }
247 log::info!("Creating {} index on main_edges", column);
248 if let Err(e) = backend
249 .create_scalar_index(table_name, &[column], idx_type, None)
250 .await
251 {
252 log::warn!("Failed to create {} index on main_edges: {}", column, e);
253 }
254 }
255
256 Ok(())
257 }
258
259 pub async fn exists_by_eid(backend: &dyn StorageBackend, eid: Eid) -> Result<bool> {
265 let filter = FilterExpr::equals("_eid", Scalar::UInt(eid.as_u64()));
266 let batches = Self::execute_query(backend, filter, Some(vec!["_eid"])).await?;
267 Ok(!batches.is_empty() && batches.iter().any(|b| b.num_rows() > 0))
268 }
269
270 async fn execute_query(
274 backend: &dyn StorageBackend,
275 filter: FilterExpr,
276 columns: Option<Vec<&str>>,
277 ) -> Result<Vec<RecordBatch>> {
278 let table_name = table_names::main_edge_table_name();
279
280 if !backend.table_exists(table_name).await? {
281 return Ok(Vec::new());
282 }
283
284 let mut request = ScanRequest::all(table_name).with_filter(filter);
285 if let Some(cols) = columns {
286 request = request.with_columns(cols.into_iter().map(String::from).collect());
287 }
288
289 backend.scan(request).await
290 }
291
292 pub async fn find_props_by_eid(
319 backend: &dyn StorageBackend,
320 eid: Eid,
321 version: Option<u64>,
322 ) -> Result<Option<Properties>> {
323 let filter = super::with_version_bound(
334 FilterExpr::equals("_eid", Scalar::UInt(eid.as_u64())),
335 version,
336 );
337 let batches = Self::execute_query(
338 backend,
339 filter,
340 Some(vec!["props_json", "_version", "_deleted"]),
341 )
342 .await?;
343
344 if batches.is_empty() {
345 return Ok(None);
346 }
347
348 let mut best_props: Option<Properties> = None;
350 let mut best_version: u64 = 0;
351 let mut best_deleted = false;
352
353 for batch in &batches {
354 let props_col = batch.column_by_name("props_json");
355 let version_col = batch.column_by_name("_version");
356 let deleted_col = batch
357 .column_by_name("_deleted")
358 .and_then(|c| c.as_any().downcast_ref::<arrow_array::BooleanArray>());
359
360 if let (Some(props_arr), Some(ver_arr)) = (
361 props_col.and_then(|c| c.as_any().downcast_ref::<arrow_array::LargeBinaryArray>()),
362 version_col.and_then(|c| c.as_any().downcast_ref::<UInt64Array>()),
363 ) {
364 for i in 0..batch.num_rows() {
365 let version = if ver_arr.is_null(i) {
366 0
367 } else {
368 ver_arr.value(i)
369 };
370
371 if version >= best_version {
372 best_version = version;
373 best_deleted = deleted_col.is_some_and(|d| d.value(i));
374 best_props = if best_deleted {
375 Some(Properties::new())
376 } else {
377 Some(Self::parse_props_json(props_arr, i)?)
378 };
379 }
380 }
381 }
382 }
383
384 if best_deleted {
385 return Ok(None);
386 }
387 Ok(best_props)
388 }
389
390 fn parse_props_json(arr: &arrow_array::LargeBinaryArray, idx: usize) -> Result<Properties> {
392 if arr.is_null(idx) || arr.value(idx).is_empty() {
393 return Ok(Properties::new());
394 }
395 let bytes = arr.value(idx);
396 let uni_val = uni_common::cypher_value_codec::decode(bytes)
397 .map_err(|e| anyhow!("Failed to decode CypherValue: {}", e))?;
398 let json_val: serde_json::Value = uni_val.into();
399 serde_json::from_value(json_val).map_err(|e| anyhow!("Failed to parse props_json: {}", e))
400 }
401
402 pub async fn find_edges_by_type_names(
411 backend: &dyn StorageBackend,
412 type_names: &[&str],
413 endpoint_filter: Option<(EndpointSide, &[Vid])>,
414 ) -> Result<Vec<(Eid, Vid, Vid, String, Properties)>> {
415 if type_names.is_empty() {
416 return Ok(Vec::new());
417 }
418
419 let base_filter = FilterExpr::all([
420 FilterExpr::not_deleted(),
421 FilterExpr::one_of(
422 "type",
423 type_names.iter().map(|t| Scalar::Str((*t).to_string())),
424 ),
425 ]);
426
427 let mut edges = Vec::new();
428 match endpoint_filter {
429 None => {
430 let batches = Self::execute_query(backend, base_filter.clone(), None).await?;
432 for batch in &batches {
433 Self::extract_edges_with_type_from_batch(batch, &mut edges)?;
434 }
435 }
436 Some((_, [])) => {}
437 Some((side, vids)) => {
438 const VID_CHUNK: usize = 8192;
440 for chunk in vids.chunks(VID_CHUNK) {
441 let ids = || chunk.iter().map(|v| Scalar::UInt(v.as_u64()));
442 let endpoint_clause = match side {
443 EndpointSide::Src => FilterExpr::one_of("src_vid", ids()),
444 EndpointSide::Dst => FilterExpr::one_of("dst_vid", ids()),
445 EndpointSide::Either => FilterExpr::any_of([
446 FilterExpr::one_of("src_vid", ids()),
447 FilterExpr::one_of("dst_vid", ids()),
448 ]),
449 };
450 let filter = FilterExpr::all([base_filter.clone(), endpoint_clause]);
451 let batches = Self::execute_query(backend, filter, None).await?;
452 for batch in &batches {
453 Self::extract_edges_with_type_from_batch(batch, &mut edges)?;
454 }
455 }
456 }
457 }
458
459 Ok(edges)
460 }
461
462 fn extract_edges_with_type_from_batch(
464 batch: &RecordBatch,
465 edges: &mut Vec<(Eid, Vid, Vid, String, Properties)>,
466 ) -> Result<()> {
467 let Some(eid_arr) = batch
468 .column_by_name("_eid")
469 .and_then(|c| c.as_any().downcast_ref::<UInt64Array>())
470 else {
471 return Ok(());
472 };
473 let Some(src_arr) = batch
474 .column_by_name("src_vid")
475 .and_then(|c| c.as_any().downcast_ref::<UInt64Array>())
476 else {
477 return Ok(());
478 };
479 let Some(dst_arr) = batch
480 .column_by_name("dst_vid")
481 .and_then(|c| c.as_any().downcast_ref::<UInt64Array>())
482 else {
483 return Ok(());
484 };
485 let type_arr = batch
486 .column_by_name("type")
487 .and_then(|c| c.as_any().downcast_ref::<arrow_array::StringArray>());
488 let props_arr = batch
489 .column_by_name("props_json")
490 .and_then(|c| c.as_any().downcast_ref::<arrow_array::LargeBinaryArray>());
491
492 for i in 0..batch.num_rows() {
493 if eid_arr.is_null(i) || src_arr.is_null(i) || dst_arr.is_null(i) {
494 continue;
495 }
496
497 let eid = Eid::new(eid_arr.value(i));
498 let src_vid = Vid::new(src_arr.value(i));
499 let dst_vid = Vid::new(dst_arr.value(i));
500 let edge_type = type_arr
501 .filter(|arr| !arr.is_null(i))
502 .map(|arr| arr.value(i).to_string())
503 .unwrap_or_default();
504 let props = props_arr
505 .map(|arr| Self::parse_props_json(arr, i))
506 .transpose()?
507 .unwrap_or_default();
508
509 edges.push((eid, src_vid, dst_vid, edge_type, props));
510 }
511
512 Ok(())
513 }
514}
515
516#[cfg(test)]
517mod tests {
518 use super::*;
519
520 #[test]
521 fn test_main_edge_schema() {
522 let schema = MainEdgeDataset::get_arrow_schema();
523 assert_eq!(schema.fields().len(), 9);
524 assert!(schema.field_with_name("_eid").is_ok());
525 assert!(schema.field_with_name("src_vid").is_ok());
526 assert!(schema.field_with_name("dst_vid").is_ok());
527 assert!(schema.field_with_name("type").is_ok());
528 assert!(schema.field_with_name("props_json").is_ok());
529 assert!(schema.field_with_name("_deleted").is_ok());
530 assert!(schema.field_with_name("_version").is_ok());
531 assert!(schema.field_with_name("_created_at").is_ok());
532 assert!(schema.field_with_name("_updated_at").is_ok());
533 }
534
535 #[test]
536 fn test_build_record_batch() {
537 use uni_common::Value;
538 let mut props = HashMap::new();
539 props.insert("weight".to_string(), Value::Float(0.5));
540
541 let edges = vec![(
542 Eid::new(1),
543 Vid::new(1),
544 Vid::new(2),
545 "KNOWS".to_string(),
546 props,
547 false,
548 1u64,
549 )];
550
551 let batch = MainEdgeDataset::build_record_batch(&edges, None, None).unwrap();
552 assert_eq!(batch.num_rows(), 1);
553 assert_eq!(batch.num_columns(), 9);
554 }
555
556 #[test]
557 fn test_build_record_batch_multiple_edges() {
558 use uni_common::Value;
559
560 let edges = vec![
561 (
562 Eid::new(1),
563 Vid::new(1),
564 Vid::new(2),
565 "KNOWS".to_string(),
566 HashMap::from([("since".to_string(), Value::Int(2020))]),
567 false,
568 1u64,
569 ),
570 (
571 Eid::new(2),
572 Vid::new(2),
573 Vid::new(3),
574 "WORKS_AT".to_string(),
575 HashMap::new(),
576 false,
577 2u64,
578 ),
579 (
580 Eid::new(3),
581 Vid::new(1),
582 Vid::new(3),
583 "KNOWS".to_string(),
584 HashMap::new(),
585 true, 3u64,
587 ),
588 ];
589
590 let batch = MainEdgeDataset::build_record_batch(&edges, None, None).unwrap();
591 assert_eq!(batch.num_rows(), 3);
592 assert_eq!(batch.num_columns(), 9);
593
594 let type_col = batch
596 .column_by_name("type")
597 .unwrap()
598 .as_any()
599 .downcast_ref::<arrow_array::StringArray>()
600 .unwrap();
601 assert_eq!(type_col.value(0), "KNOWS");
602 assert_eq!(type_col.value(1), "WORKS_AT");
603 assert_eq!(type_col.value(2), "KNOWS");
604 }
605
606 #[test]
607 fn test_build_record_batch_with_timestamps() {
608 let edges = vec![(
609 Eid::new(1),
610 Vid::new(1),
611 Vid::new(2),
612 "KNOWS".to_string(),
613 HashMap::new(),
614 false,
615 1u64,
616 )];
617
618 let mut created_at: HashMap<Eid, i64> = HashMap::new();
619 created_at.insert(Eid::new(1), 1_000_000_000);
620
621 let mut updated_at: HashMap<Eid, i64> = HashMap::new();
622 updated_at.insert(Eid::new(1), 2_000_000_000);
623
624 let batch =
625 MainEdgeDataset::build_record_batch(&edges, Some(&created_at), Some(&updated_at))
626 .unwrap();
627 assert_eq!(batch.num_rows(), 1);
628
629 let created_col = batch.column_by_name("_created_at").unwrap();
631 assert!(!created_col.is_null(0), "created_at should be populated");
632 }
633
634 #[tokio::test]
639 async fn test_edge_key_reads_respect_tombstone_winner() {
640 use crate::backend::lance::LanceDbBackend;
641 use uni_common::Value;
642
643 let dir = tempfile::TempDir::new().unwrap();
644 let be = LanceDbBackend::connect(dir.path().to_str().unwrap(), None)
645 .await
646 .unwrap();
647 let backend: &dyn StorageBackend = &be;
648
649 let mut props = HashMap::new();
650 props.insert("weight".to_string(), Value::Float(0.5));
651
652 let live = MainEdgeDataset::build_record_batch(
654 &[(
655 Eid::new(1),
656 Vid::new(1),
657 Vid::new(2),
658 "KNOWS".to_string(),
659 props.clone(),
660 false,
661 1u64,
662 )],
663 None,
664 None,
665 )
666 .unwrap();
667 MainEdgeDataset::write_batch(backend, live).await.unwrap();
668
669 assert!(
671 MainEdgeDataset::find_props_by_eid(backend, Eid::new(1), None)
672 .await
673 .unwrap()
674 .is_some()
675 );
676
677 let dead = MainEdgeDataset::build_record_batch(
679 &[(
680 Eid::new(1),
681 Vid::new(1),
682 Vid::new(2),
683 "KNOWS".to_string(),
684 props,
685 true,
686 2u64,
687 )],
688 None,
689 None,
690 )
691 .unwrap();
692 MainEdgeDataset::write_batch(backend, dead).await.unwrap();
693
694 assert_eq!(
695 MainEdgeDataset::find_props_by_eid(backend, Eid::new(1), None)
696 .await
697 .unwrap(),
698 None,
699 "deleted (highest-version) winner must not resurrect edge props"
700 );
701 }
702}