1use std::collections::{HashMap, HashSet, VecDeque};
4use std::sync::Arc;
5
6use async_trait::async_trait;
7use chrono::{DateTime, TimeZone, Utc};
8use rusqlite::OptionalExtension;
9use uuid::Uuid;
10
11use khive_storage::error::StorageError;
12use khive_storage::types::{
13 BatchWriteSummary, DeleteMode, DirectedNeighborHit, Direction, Edge, EdgeFilter, EdgeSeekPage,
14 EdgeSortField, GraphPath, GuardedBatchOutcome, GuardedBatchRefusal, GuardedWriteOutcome,
15 MissingEndpoints, NeighborHit, NeighborQuery, Page, PageRequest, PathNode, SeekCursor,
16 SeekPage, SortDirection, SortOrder, SqlStatement, SqlValue, TraversalExecutionBudget,
17 TraversalOptions, TraversalRequest,
18};
19use khive_storage::GraphStore;
20use khive_storage::LinkId;
21use khive_storage::StorageCapability;
22use khive_types::EdgeRelation;
23
24use crate::error::SqliteError;
25use crate::pool::ConnectionPool;
26use crate::sql_bridge::bind_params;
27use crate::writer_task::WriterTaskHandle;
28
29fn map_err(e: rusqlite::Error, op: &'static str) -> StorageError {
31 StorageError::driver(StorageCapability::Graph, op, e)
32}
33
34fn map_sqlite_err(e: SqliteError, op: &'static str) -> StorageError {
35 StorageError::driver(StorageCapability::Graph, op, e)
36}
37
38const NAMESPACE_COUNT_CHUNK_SIZE: usize = 500;
39
40const EDGE_NATURAL_KEY_CONFLICT_SET: &str = "weight = excluded.weight, \
56 updated_at = excluded.updated_at, \
57 deleted_at = NULL, \
58 metadata = excluded.metadata, \
59 target_backend = excluded.target_backend";
60
61fn endpoint_exists_clause(id_param: &str) -> String {
72 format!(
73 "EXISTS (SELECT 1 FROM entities WHERE id = {id_param} AND deleted_at IS NULL) \
74 OR EXISTS (SELECT 1 FROM notes WHERE id = {id_param} AND deleted_at IS NULL) \
75 OR EXISTS (SELECT 1 FROM events WHERE id = {id_param}) \
76 OR EXISTS (SELECT 1 FROM graph_edges WHERE id = {id_param} AND deleted_at IS NULL)"
77 )
78}
79
80pub fn edge_upsert_statement(edge: &Edge) -> SqlStatement {
84 let (source_id, target_id) =
85 canonical_edge_endpoints(edge.relation, edge.source_id, edge.target_id);
86 let metadata_str = edge
87 .metadata
88 .as_ref()
89 .map(|v| serde_json::to_string(v).unwrap_or_default());
90 SqlStatement {
91 sql: format!(
92 "INSERT INTO graph_edges \
93 (namespace, id, source_id, target_id, relation, weight, \
94 created_at, updated_at, deleted_at, metadata, target_backend) \
95 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) \
96 ON CONFLICT(namespace, id) DO UPDATE SET \
97 source_id = excluded.source_id, \
98 target_id = excluded.target_id, \
99 relation = excluded.relation, \
100 {EDGE_NATURAL_KEY_CONFLICT_SET} \
101 ON CONFLICT(namespace, source_id, target_id, relation) DO UPDATE SET \
102 {EDGE_NATURAL_KEY_CONFLICT_SET}"
103 ),
104 params: vec![
105 SqlValue::Text(edge.namespace.clone()),
106 SqlValue::Text(Uuid::from(edge.id).to_string()),
107 SqlValue::Text(source_id.to_string()),
108 SqlValue::Text(target_id.to_string()),
109 SqlValue::Text(edge.relation.to_string()),
110 SqlValue::Float(edge.weight),
111 SqlValue::Integer(edge.created_at.timestamp_micros()),
112 SqlValue::Integer(edge.updated_at.timestamp_micros()),
113 match edge.deleted_at {
114 Some(t) => SqlValue::Integer(t.timestamp_micros()),
115 None => SqlValue::Null,
116 },
117 match metadata_str {
118 Some(m) => SqlValue::Text(m),
119 None => SqlValue::Null,
120 },
121 match &edge.target_backend {
122 Some(b) => SqlValue::Text(b.clone()),
123 None => SqlValue::Null,
124 },
125 ],
126 label: Some("edge-upsert".to_string()),
127 }
128}
129
130#[allow(clippy::too_many_arguments)]
150pub fn edge_insert_guarded_by_endpoints_statement(
151 namespace: &str,
152 edge_id: Uuid,
153 source_id: Uuid,
154 target_id: Uuid,
155 relation: EdgeRelation,
156 weight: f64,
157 now: i64,
158 metadata: Option<&str>,
159) -> SqlStatement {
160 let src_exists = endpoint_exists_clause("?3");
161 let tgt_exists = endpoint_exists_clause("?4");
162 SqlStatement {
163 sql: format!(
164 "INSERT INTO graph_edges \
165 (namespace, id, source_id, target_id, relation, weight, \
166 created_at, updated_at, metadata) \
167 SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7, ?8 \
168 WHERE ({src_exists}) AND ({tgt_exists}) \
169 ON CONFLICT(namespace, source_id, target_id, relation) DO UPDATE SET \
170 {EDGE_NATURAL_KEY_CONFLICT_SET}"
171 ),
172 params: vec![
173 SqlValue::Text(namespace.to_string()),
174 SqlValue::Text(edge_id.to_string()),
175 SqlValue::Text(source_id.to_string()),
176 SqlValue::Text(target_id.to_string()),
177 SqlValue::Text(relation.as_str().to_string()),
178 SqlValue::Float(weight),
179 SqlValue::Integer(now),
180 match metadata {
181 Some(m) => SqlValue::Text(m.to_string()),
182 None => SqlValue::Null,
183 },
184 ],
185 label: Some("atomic-link-insert-edge-where-exists".to_string()),
186 }
187}
188
189pub fn edge_soft_delete_statement(id: Uuid, now: i64) -> SqlStatement {
191 SqlStatement {
192 sql: "UPDATE graph_edges SET deleted_at = ?2, updated_at = ?2 \
193 WHERE id = ?1 AND deleted_at IS NULL"
194 .to_string(),
195 params: vec![SqlValue::Text(id.to_string()), SqlValue::Integer(now)],
196 label: Some("edge-delete-soft".to_string()),
197 }
198}
199
200pub fn edge_hard_delete_statement(id: Uuid) -> SqlStatement {
202 SqlStatement {
203 sql: "DELETE FROM graph_edges WHERE id = ?1".to_string(),
204 params: vec![SqlValue::Text(id.to_string())],
205 label: Some("edge-delete-hard".to_string()),
206 }
207}
208
209pub fn purge_incident_edges_statement(node_id: Uuid) -> SqlStatement {
211 SqlStatement {
212 sql: "DELETE FROM graph_edges WHERE source_id = ?1 OR target_id = ?1".to_string(),
213 params: vec![SqlValue::Text(node_id.to_string())],
214 label: Some("edge-purge-incident".to_string()),
215 }
216}
217
218pub const EDGE_SYMMETRIC_CONFLICT_PROBE_SQL: &str = "SELECT id FROM graph_edges \
239 WHERE namespace = ?1 AND source_id = ?2 AND target_id = ?3 \
240 AND relation = ?4 AND id != ?5";
241
242pub const EDGE_SYMMETRIC_DELETE_NONCANONICAL_SQL: &str =
243 "DELETE FROM graph_edges WHERE namespace = ?1 AND id = ?2";
244
245pub const EDGE_SYMMETRIC_UPDATE_INPLACE_SQL: &str = "UPDATE graph_edges SET \
246 source_id = ?1, target_id = ?2, relation = ?3, \
247 weight = ?4, updated_at = ?5, metadata = ?6 \
248 WHERE namespace = ?7 AND id = ?8";
249
250pub fn edge_symmetric_conflict_probe_statement(
253 namespace: &str,
254 canon_src: Uuid,
255 canon_tgt: Uuid,
256 relation: EdgeRelation,
257 exclude_id: Uuid,
258) -> SqlStatement {
259 SqlStatement {
260 sql: EDGE_SYMMETRIC_CONFLICT_PROBE_SQL.to_string(),
261 params: vec![
262 SqlValue::Text(namespace.to_string()),
263 SqlValue::Text(canon_src.to_string()),
264 SqlValue::Text(canon_tgt.to_string()),
265 SqlValue::Text(relation.to_string()),
266 SqlValue::Text(exclude_id.to_string()),
267 ],
268 label: Some("edge-symmetric-conflict-probe".to_string()),
269 }
270}
271
272pub fn edge_symmetric_delete_noncanonical_statement(namespace: &str, id: Uuid) -> SqlStatement {
275 SqlStatement {
276 sql: EDGE_SYMMETRIC_DELETE_NONCANONICAL_SQL.to_string(),
277 params: vec![
278 SqlValue::Text(namespace.to_string()),
279 SqlValue::Text(id.to_string()),
280 ],
281 label: Some("edge-symmetric-delete-noncanonical".to_string()),
282 }
283}
284
285#[allow(clippy::too_many_arguments)]
288pub fn edge_symmetric_update_inplace_statement(
289 namespace: &str,
290 id: Uuid,
291 canon_src: Uuid,
292 canon_tgt: Uuid,
293 relation: EdgeRelation,
294 weight: f64,
295 updated_at_micros: i64,
296 metadata: Option<&str>,
297) -> SqlStatement {
298 SqlStatement {
299 sql: EDGE_SYMMETRIC_UPDATE_INPLACE_SQL.to_string(),
300 params: vec![
301 SqlValue::Text(canon_src.to_string()),
302 SqlValue::Text(canon_tgt.to_string()),
303 SqlValue::Text(relation.to_string()),
304 SqlValue::Float(weight),
305 SqlValue::Integer(updated_at_micros),
306 match metadata {
307 Some(m) => SqlValue::Text(m.to_string()),
308 None => SqlValue::Null,
309 },
310 SqlValue::Text(namespace.to_string()),
311 SqlValue::Text(id.to_string()),
312 ],
313 label: Some("edge-symmetric-update-inplace".to_string()),
314 }
315}
316
317pub fn edge_symmetric_delete_if_conflict_statement(
401 namespace: &str,
402 id: Uuid,
403 canon_src: Uuid,
404 canon_tgt: Uuid,
405 relation: EdgeRelation,
406) -> SqlStatement {
407 SqlStatement {
408 sql: "DELETE FROM graph_edges \
409 WHERE namespace = ?1 AND id = ?2 \
410 AND EXISTS ( \
411 SELECT 1 FROM graph_edges \
412 WHERE namespace = ?1 AND source_id = ?3 AND target_id = ?4 \
413 AND relation = ?5 AND id != ?2 \
414 )"
415 .to_string(),
416 params: vec![
417 SqlValue::Text(namespace.to_string()),
418 SqlValue::Text(id.to_string()),
419 SqlValue::Text(canon_src.to_string()),
420 SqlValue::Text(canon_tgt.to_string()),
421 SqlValue::Text(relation.to_string()),
422 ],
423 label: Some("edge-symmetric-delete-if-conflict".to_string()),
424 }
425}
426
427#[allow(clippy::too_many_arguments)]
428pub fn edge_symmetric_absorb_or_update_inplace_statement(
429 namespace: &str,
430 id: Uuid,
431 canon_src: Uuid,
432 canon_tgt: Uuid,
433 relation: EdgeRelation,
434 weight: f64,
435 updated_at_micros: i64,
436 metadata: Option<&str>,
437 target_backend: Option<&str>,
438) -> SqlStatement {
439 SqlStatement {
440 sql: "UPDATE graph_edges SET \
441 source_id = CASE WHEN id = ?2 THEN ?3 ELSE source_id END, \
442 target_id = CASE WHEN id = ?2 THEN ?4 ELSE target_id END, \
443 relation = CASE WHEN id = ?2 THEN ?5 ELSE relation END, \
444 weight = CASE WHEN id = ?2 THEN ?6 ELSE weight END, \
445 updated_at = CASE WHEN id = ?2 THEN ?7 ELSE updated_at END, \
446 deleted_at = CASE WHEN id = ?2 THEN NULL ELSE deleted_at END, \
447 metadata = CASE WHEN id = ?2 THEN ?8 ELSE metadata END, \
448 target_backend = CASE WHEN id = ?2 THEN ?9 ELSE target_backend END \
449 WHERE namespace = ?1 \
450 AND ( \
451 (id = ?2 AND changes() = 0) \
452 OR (source_id = ?3 AND target_id = ?4 AND relation = ?5 \
453 AND id != ?2 AND changes() = 1) \
454 )"
455 .to_string(),
456 params: vec![
457 SqlValue::Text(namespace.to_string()),
458 SqlValue::Text(id.to_string()),
459 SqlValue::Text(canon_src.to_string()),
460 SqlValue::Text(canon_tgt.to_string()),
461 SqlValue::Text(relation.to_string()),
462 SqlValue::Float(weight),
463 SqlValue::Integer(updated_at_micros),
464 match metadata {
465 Some(m) => SqlValue::Text(m.to_string()),
466 None => SqlValue::Null,
467 },
468 match target_backend {
469 Some(b) => SqlValue::Text(b.to_string()),
470 None => SqlValue::Null,
471 },
472 ],
473 label: Some("edge-symmetric-absorb-or-update-inplace".to_string()),
474 }
475}
476
477pub struct SqlGraphStore {
479 pool: Arc<ConnectionPool>,
480 is_file_backed: bool,
481 namespace: String,
485 writer_task: Option<WriterTaskHandle>,
486}
487
488impl SqlGraphStore {
489 pub fn new_scoped(
497 pool: Arc<ConnectionPool>,
498 is_file_backed: bool,
499 namespace: impl Into<String>,
500 ) -> Self {
501 let writer_task = pool.writer_task_handle().ok().flatten();
505
506 Self {
507 pool,
508 is_file_backed,
509 namespace: namespace.into(),
510 writer_task,
511 }
512 }
513
514 fn open_standalone_writer(&self) -> Result<rusqlite::Connection, StorageError> {
515 self.pool
516 .open_standalone_writer()
517 .map_err(|e| map_sqlite_err(e, "open_graph_writer"))
518 }
519
520 fn open_standalone_reader(&self) -> Result<rusqlite::Connection, StorageError> {
521 self.pool
522 .open_standalone_reader()
523 .map_err(|e| map_sqlite_err(e, "open_graph_reader"))
524 }
525
526 async fn with_writer<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
532 where
533 F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
534 R: Send + 'static,
535 {
536 if let Some(writer_task) = &self.writer_task {
537 return writer_task
538 .send(move |conn| f(conn).map_err(|e| map_err(e, op)))
539 .await;
540 }
541
542 if self.is_file_backed {
543 let conn = self.open_standalone_writer()?;
544 tokio::task::spawn_blocking(move || f(&conn).map_err(|e| map_err(e, op)))
545 .await
546 .map_err(|e| StorageError::driver(StorageCapability::Graph, op, e))?
547 } else {
548 let pool = Arc::clone(&self.pool);
549 tokio::task::spawn_blocking(move || {
550 let guard = pool.try_writer().map_err(|e| map_sqlite_err(e, op))?;
551 f(guard.conn()).map_err(|e| map_err(e, op))
552 })
553 .await
554 .map_err(|e| StorageError::driver(StorageCapability::Graph, op, e))?
555 }
556 }
557
558 async fn with_reader<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
559 where
560 F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
561 R: Send + 'static,
562 {
563 if self.is_file_backed {
564 let conn = self.open_standalone_reader()?;
565 tokio::task::spawn_blocking(move || f(&conn).map_err(|e| map_err(e, op)))
566 .await
567 .map_err(|e| StorageError::driver(StorageCapability::Graph, op, e))?
568 } else {
569 let pool = Arc::clone(&self.pool);
570 tokio::task::spawn_blocking(move || {
571 let guard = pool.reader().map_err(|e| map_sqlite_err(e, op))?;
572 f(guard.conn()).map_err(|e| map_err(e, op))
573 })
574 .await
575 .map_err(|e| StorageError::driver(StorageCapability::Graph, op, e))?
576 }
577 }
578}
579
580fn report_graph_usage(queries: &std::sync::atomic::AtomicU64, rows: &std::sync::atomic::AtomicU64) {
596 khive_storage::usage::count(
597 khive_storage::usage::UsageUnit::DbRoundTrips,
598 queries.load(std::sync::atomic::Ordering::Relaxed),
599 );
600 khive_storage::usage::count(
601 khive_storage::usage::UsageUnit::GraphHops,
602 rows.load(std::sync::atomic::Ordering::Relaxed),
603 );
604}
605
606fn read_edge(row: &rusqlite::Row<'_>) -> Result<Edge, rusqlite::Error> {
607 let namespace: String = row.get(0)?;
608 let id_str: String = row.get(1)?;
609 let source_str: String = row.get(2)?;
610 let target_str: String = row.get(3)?;
611 let relation_str: String = row.get(4)?;
612 let weight: f64 = row.get(5)?;
613 let created_micros: i64 = row.get(6)?;
614 let updated_micros: i64 = row.get(7)?;
615 let deleted_micros: Option<i64> = row.get(8)?;
616 let metadata_str: Option<String> = row.get(9)?;
617 let target_backend: Option<String> = row.get(10)?;
618
619 let id = parse_uuid(&id_str)?;
620 let source_id = parse_uuid(&source_str)?;
621 let target_id = parse_uuid(&target_str)?;
622 let created_at = micros_to_datetime(created_micros);
623 let relation = relation_str.parse::<EdgeRelation>().map_err(|e| {
624 rusqlite::Error::FromSqlConversionFailure(4, rusqlite::types::Type::Text, Box::new(e))
625 })?;
626 let metadata = match metadata_str {
627 Some(s) => {
628 let v = serde_json::from_str(&s).map_err(|e| {
629 rusqlite::Error::FromSqlConversionFailure(
630 9,
631 rusqlite::types::Type::Text,
632 Box::new(e),
633 )
634 })?;
635 Some(v)
636 }
637 None => None,
638 };
639
640 Ok(Edge {
641 id: id.into(),
642 namespace,
643 source_id,
644 target_id,
645 relation,
646 weight,
647 created_at,
648 updated_at: micros_to_datetime(updated_micros),
649 deleted_at: deleted_micros.map(micros_to_datetime),
650 metadata,
651 target_backend,
652 })
653}
654
655fn parse_uuid(s: &str) -> Result<Uuid, rusqlite::Error> {
656 Uuid::parse_str(s).map_err(|e| {
657 rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
658 })
659}
660
661fn neighbor_extra_clause(
667 query: &NeighborQuery,
668 start_param_idx: usize,
669) -> (String, String, Vec<Box<dyn rusqlite::types::ToSql>>) {
670 let mut conditions: Vec<String> = Vec::new();
671 let mut extra_params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
672 let mut param_idx = start_param_idx;
673
674 if let Some(ref rels) = query.relations {
675 if !rels.is_empty() {
676 let placeholders: Vec<String> = rels
677 .iter()
678 .map(|r| {
679 extra_params.push(Box::new(r.to_string()));
680 let p = format!("?{}", param_idx);
681 param_idx += 1;
682 p
683 })
684 .collect();
685 conditions.push(format!("relation IN ({})", placeholders.join(",")));
686 }
687 }
688
689 if let Some(min_w) = query.min_weight {
690 extra_params.push(Box::new(min_w));
691 conditions.push(format!("weight >= ?{}", param_idx));
692 param_idx += 1;
693 }
694
695 let where_extra = if conditions.is_empty() {
696 String::new()
697 } else {
698 format!(" WHERE {}", conditions.join(" AND "))
699 };
700
701 let limit_clause = if let Some(lim) = query.limit {
702 extra_params.push(Box::new(lim as i64));
703 format!(" LIMIT ?{}", param_idx)
704 } else {
705 String::new()
706 };
707
708 (where_extra, limit_clause, extra_params)
709}
710
711#[cfg(test)]
719static NEIGHBOR_SELECT_COUNT: std::sync::atomic::AtomicUsize =
720 std::sync::atomic::AtomicUsize::new(0);
721
722#[cfg(test)]
723fn count_neighbor_select() {
724 NEIGHBOR_SELECT_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
725}
726
727#[cfg(not(test))]
728fn count_neighbor_select() {}
729
730#[cfg(test)]
731pub(crate) fn reset_neighbor_select_count() {
732 NEIGHBOR_SELECT_COUNT.store(0, std::sync::atomic::Ordering::Relaxed);
733}
734
735#[cfg(test)]
736pub(crate) fn neighbor_select_count() -> usize {
737 NEIGHBOR_SELECT_COUNT.load(std::sync::atomic::Ordering::Relaxed)
738}
739
740fn micros_to_datetime(micros: i64) -> DateTime<Utc> {
741 Utc.timestamp_micros(micros)
742 .single()
743 .unwrap_or_else(Utc::now)
744}
745
746fn build_edge_filter_sql(
747 namespace: &str,
748 filter: &EdgeFilter,
749) -> (String, Vec<Box<dyn rusqlite::types::ToSql>>) {
750 build_edge_filter_sql_for_namespaces(&[namespace.to_string()], filter)
751}
752
753fn build_edge_filter_sql_for_namespaces(
754 namespaces: &[String],
755 filter: &EdgeFilter,
756) -> (String, Vec<Box<dyn rusqlite::types::ToSql>>) {
757 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = namespaces
758 .iter()
759 .map(|namespace| -> Box<dyn rusqlite::types::ToSql> { Box::new(namespace.clone()) })
760 .collect();
761 let namespace_condition = match namespaces.len() {
762 0 => "0".to_string(),
763 1 => "namespace = ?1".to_string(),
764 _ => {
765 let placeholders: Vec<String> =
766 (1..=namespaces.len()).map(|i| format!("?{i}")).collect();
767 format!("namespace IN ({})", placeholders.join(", "))
768 }
769 };
770 let mut conditions = vec![namespace_condition, "deleted_at IS NULL".to_string()];
771
772 if !filter.ids.is_empty() {
773 let placeholders: Vec<String> = filter
774 .ids
775 .iter()
776 .map(|id| {
777 params.push(Box::new(id.to_string()));
778 format!("?{}", params.len())
779 })
780 .collect();
781 conditions.push(format!("id IN ({})", placeholders.join(",")));
782 }
783
784 if !filter.source_ids.is_empty() {
785 let placeholders: Vec<String> = filter
786 .source_ids
787 .iter()
788 .map(|id| {
789 params.push(Box::new(id.to_string()));
790 format!("?{}", params.len())
791 })
792 .collect();
793 conditions.push(format!("source_id IN ({})", placeholders.join(",")));
794 }
795
796 if !filter.target_ids.is_empty() {
797 let placeholders: Vec<String> = filter
798 .target_ids
799 .iter()
800 .map(|id| {
801 params.push(Box::new(id.to_string()));
802 format!("?{}", params.len())
803 })
804 .collect();
805 conditions.push(format!("target_id IN ({})", placeholders.join(",")));
806 }
807
808 if !filter.relations.is_empty() {
809 let placeholders: Vec<String> = filter
810 .relations
811 .iter()
812 .map(|r| {
813 params.push(Box::new(r.to_string()));
814 format!("?{}", params.len())
815 })
816 .collect();
817 conditions.push(format!("relation IN ({})", placeholders.join(",")));
818 }
819
820 if let Some(min_w) = filter.min_weight {
821 params.push(Box::new(min_w));
822 conditions.push(format!("weight >= ?{}", params.len()));
823 }
824
825 if let Some(max_w) = filter.max_weight {
826 params.push(Box::new(max_w));
827 conditions.push(format!("weight <= ?{}", params.len()));
828 }
829
830 if let Some(ref time_range) = filter.created_at {
831 if let Some(start) = time_range.start {
832 params.push(Box::new(start.timestamp_micros()));
833 conditions.push(format!("created_at >= ?{}", params.len()));
834 }
835 if let Some(end) = time_range.end {
836 params.push(Box::new(end.timestamp_micros()));
837 conditions.push(format!("created_at < ?{}", params.len()));
838 }
839 }
840
841 let clause = format!(" WHERE {}", conditions.join(" AND "));
842 (clause, params)
843}
844
845fn edge_sort_col(field: &EdgeSortField) -> &'static str {
846 match field {
847 EdgeSortField::CreatedAt => "created_at",
848 EdgeSortField::Weight => "weight",
849 EdgeSortField::Relation => "relation",
850 }
851}
852
853fn canonical_edge_endpoints(
862 relation: EdgeRelation,
863 source_id: Uuid,
864 target_id: Uuid,
865) -> (Uuid, Uuid) {
866 if relation.is_symmetric() && target_id < source_id {
867 (target_id, source_id)
868 } else {
869 (source_id, target_id)
870 }
871}
872
873fn batch_upsert_edges(
881 conn: &rusqlite::Connection,
882 edges: &[Edge],
883 attempted: u64,
884) -> Result<BatchWriteSummary, rusqlite::Error> {
885 let mut affected = 0u64;
886
887 for edge in edges {
888 let statement = edge_upsert_statement(edge);
889 let mut stmt = conn.prepare(&statement.sql)?;
890 bind_params(&mut stmt, &statement.params)?;
891 stmt.raw_execute()?;
892 affected += 1;
893 }
894
895 Ok(BatchWriteSummary {
896 attempted,
897 affected,
898 failed: 0,
899 first_error: String::new(),
900 })
901}
902
903fn edge_endpoints_exist(
911 conn: &rusqlite::Connection,
912 source_id: Uuid,
913 target_id: Uuid,
914) -> Result<MissingEndpoints, rusqlite::Error> {
915 let src_exists = endpoint_exists_clause("?1");
916 let tgt_exists = endpoint_exists_clause("?2");
917 let sql = format!("SELECT ({src_exists}), ({tgt_exists})");
918 conn.query_row(
919 &sql,
920 rusqlite::params![source_id.to_string(), target_id.to_string()],
921 |row| {
922 let src_exists: bool = row.get(0)?;
923 let tgt_exists: bool = row.get(1)?;
924 Ok(MissingEndpoints {
925 source: !src_exists,
926 target: !tgt_exists,
927 })
928 },
929 )
930}
931
932fn edge_insert_guarded(
943 conn: &rusqlite::Connection,
944 statement: &SqlStatement,
945 source_id: Uuid,
946 target_id: Uuid,
947) -> Result<GuardedWriteOutcome, rusqlite::Error> {
948 let mut stmt = conn.prepare(&statement.sql)?;
949 bind_params(&mut stmt, &statement.params)?;
950 if stmt.raw_execute()? > 0 {
951 return Ok(GuardedWriteOutcome::Written);
952 }
953 #[cfg(test)]
961 tests::insert_probe_seam::hook((source_id, target_id));
962 let missing = edge_endpoints_exist(conn, source_id, target_id)?;
963 Ok(GuardedWriteOutcome::Refused(missing))
964}
965
966fn batch_upsert_edges_guarded(
974 conn: &rusqlite::Connection,
975 edges: &[Edge],
976 attempted: u64,
977) -> Result<GuardedBatchOutcome, rusqlite::Error> {
978 for (index, edge) in edges.iter().enumerate() {
979 let (source_id, target_id) =
980 canonical_edge_endpoints(edge.relation, edge.source_id, edge.target_id);
981 let missing = edge_endpoints_exist(conn, source_id, target_id)?;
982 if missing.any() {
983 return Ok(GuardedBatchOutcome {
984 summary: BatchWriteSummary {
985 attempted,
986 affected: 0,
987 failed: attempted,
988 first_error: format!(
989 "batch entry {index}: edge endpoint no longer exists at write time: source {source_id} or target {target_id}"
990 ),
991 },
992 refused: Some(GuardedBatchRefusal {
993 entry_index: index,
994 missing,
995 }),
996 });
997 }
998 }
999
1000 let mut affected = 0u64;
1001 for edge in edges {
1002 let statement = edge_upsert_statement(edge);
1003 let mut stmt = conn.prepare(&statement.sql)?;
1004 bind_params(&mut stmt, &statement.params)?;
1005 stmt.raw_execute()?;
1006 affected += 1;
1007 }
1008
1009 Ok(GuardedBatchOutcome {
1010 summary: BatchWriteSummary {
1011 attempted,
1012 affected,
1013 failed: 0,
1014 first_error: String::new(),
1015 },
1016 refused: None,
1017 })
1018}
1019
1020fn traversal_neighbor_sql(
1025 direction: Direction,
1026 relation_count: usize,
1027 has_min_weight: bool,
1028) -> String {
1029 let (node_column, endpoint_column, index) = match direction {
1030 Direction::Out => ("target_id", "source_id", "idx_graph_edges_ns_src_rel"),
1031 Direction::In => ("source_id", "target_id", "idx_graph_edges_ns_tgt_rel"),
1032 Direction::Both => unreachable!("Direction::Both is split into indexed Out/In seeks"),
1033 };
1034 let mut sql = format!(
1035 "SELECT {node_column}, id, weight \
1036 FROM graph_edges INDEXED BY {index} \
1037 WHERE namespace = ?1 AND {endpoint_column} = ?2 AND deleted_at IS NULL"
1038 );
1039 if relation_count > 0 {
1040 let placeholders = (0..relation_count)
1041 .map(|offset| format!("?{}", 4 + offset))
1042 .collect::<Vec<_>>()
1043 .join(",");
1044 sql.push_str(&format!(" AND relation IN ({placeholders})"));
1045 }
1046 if has_min_weight {
1047 sql.push_str(&format!(" AND weight >= ?{}", 4 + relation_count));
1048 }
1049 sql.push_str(" LIMIT ?3");
1050 sql
1051}
1052
1053fn traversal_timeout_error(budget: &TraversalExecutionBudget) -> StorageError {
1054 StorageError::Timeout {
1055 operation: format!(
1056 "traverse ({}ms execution budget)",
1057 budget.max_duration().as_millis()
1058 )
1059 .into(),
1060 }
1061}
1062
1063fn traversal_work_error(budget: &TraversalExecutionBudget) -> StorageError {
1064 StorageError::InvalidInput {
1065 capability: StorageCapability::Graph,
1066 operation: "traverse".into(),
1067 message: format!(
1068 "traversal work budget exceeded after {} adjacency rows; \
1069 narrow roots, depth, relations, or result limit",
1070 budget.work_limit()
1071 ),
1072 }
1073}
1074
1075#[derive(Clone, Copy)]
1076struct TraversalFrontierNode {
1077 node_id: Uuid,
1078 depth: usize,
1079 total_weight: f64,
1080}
1081
1082#[allow(clippy::too_many_arguments)]
1083fn run_bounded_traversal(
1084 conn: &rusqlite::Connection,
1085 roots: Vec<Uuid>,
1086 opts: TraversalOptions,
1087 include_roots: bool,
1088 namespace: String,
1089 origin: khive_storage::tx_registry::TxOrigin,
1090 budget: TraversalExecutionBudget,
1091 counted_rows: &std::sync::atomic::AtomicU64,
1092 counted_queries: &std::sync::atomic::AtomicU64,
1093) -> Result<Vec<GraphPath>, StorageError> {
1094 let progress_timed_out = Arc::new(std::sync::atomic::AtomicBool::new(false));
1095 let callback_timed_out = Arc::clone(&progress_timed_out);
1096 let callback_budget = budget.clone();
1097 #[cfg(test)]
1098 let progress_seam_root = roots.first().copied();
1099 conn.progress_handler(
1100 1_000,
1101 Some(move || {
1102 #[cfg(test)]
1103 if tests::traverse_progress_seam::hook(progress_seam_root) {
1104 callback_timed_out.store(true, std::sync::atomic::Ordering::Relaxed);
1105 return true;
1106 }
1107 let expired = callback_budget.is_expired();
1108 if expired {
1109 callback_timed_out.store(true, std::sync::atomic::Ordering::Relaxed);
1110 }
1111 expired
1112 }),
1113 )
1114 .map_err(|e| map_err(e, "traverse_progress_handler"))?;
1115
1116 let result = (|| {
1117 let result_limit = opts.effective_limit() as usize;
1118 let relation_count = opts.relations.as_ref().map_or(0, Vec::len);
1119 let directions = match opts.direction {
1120 Direction::Out => vec![Direction::Out],
1121 Direction::In => vec![Direction::In],
1122 Direction::Both => vec![Direction::Out, Direction::In],
1123 };
1124 let statements = directions
1125 .into_iter()
1126 .map(|direction| {
1127 traversal_neighbor_sql(direction, relation_count, opts.min_weight.is_some())
1128 })
1129 .collect::<Vec<_>>();
1130 let map_sql_error = |error| {
1131 if progress_timed_out.load(std::sync::atomic::Ordering::Relaxed) {
1132 traversal_timeout_error(&budget)
1133 } else {
1134 map_err(error, "traverse")
1135 }
1136 };
1137
1138 let mut all_paths = Vec::with_capacity(roots.len());
1139 for root_id in roots {
1140 let mut seen = HashSet::new();
1141 seen.insert(root_id);
1142 let mut frontier = VecDeque::from([TraversalFrontierNode {
1143 node_id: root_id,
1144 depth: 0,
1145 total_weight: 0.0,
1146 }]);
1147 let mut nodes = Vec::with_capacity(result_limit + usize::from(include_roots));
1148 if include_roots {
1149 nodes.push(PathNode {
1150 node_id: root_id,
1151 via_edge: None,
1152 depth: 0,
1153 name: None,
1154 kind: None,
1155 properties: None,
1156 weight: 0.0,
1157 });
1158 }
1159 let mut non_root_count = 0usize;
1160
1161 'root_walk: while non_root_count < result_limit {
1162 let Some(current) = frontier.pop_front() else {
1163 break;
1164 };
1165 if current.depth >= opts.max_depth {
1166 continue;
1167 }
1168 if budget.is_expired() {
1169 return Err(traversal_timeout_error(&budget));
1170 }
1171
1172 for sql in &statements {
1173 let row_cap = budget.remaining_work().saturating_add(1);
1174 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = vec![
1175 Box::new(namespace.clone()),
1176 Box::new(current.node_id.to_string()),
1177 Box::new(row_cap as i64),
1178 ];
1179 if let Some(relations) = &opts.relations {
1180 params.extend(relations.iter().map(|relation| {
1181 Box::new(relation.to_string()) as Box<dyn rusqlite::types::ToSql>
1182 }));
1183 }
1184 if let Some(min_weight) = opts.min_weight {
1185 params.push(Box::new(min_weight));
1186 }
1187 let param_refs = params
1188 .iter()
1189 .map(|param| param.as_ref())
1190 .collect::<Vec<&dyn rusqlite::types::ToSql>>();
1191
1192 let _snapshot = khive_storage::tx_registry::register_scoped(
1193 Some("graph_traverse_read".to_string()),
1194 origin.clone(),
1195 );
1196 let mut stmt = conn.prepare(sql).map_err(&map_sql_error)?;
1197 counted_queries.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1198 let mut rows = stmt.query(param_refs.as_slice()).map_err(&map_sql_error)?;
1199 while let Some(row) = rows.next().map_err(&map_sql_error)? {
1200 #[cfg(test)]
1203 tests::traverse_snapshot_seam::hook(current.node_id);
1204 counted_rows.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1205 if budget.is_expired() {
1206 return Err(traversal_timeout_error(&budget));
1207 }
1208 if !budget.try_consume_row() {
1209 return Err(traversal_work_error(&budget));
1210 }
1211 let node_str: String = row.get(0).map_err(&map_sql_error)?;
1212 let edge_str: String = row.get(1).map_err(&map_sql_error)?;
1213 let edge_weight: f64 = row.get(2).map_err(&map_sql_error)?;
1214 let node_id = parse_uuid(&node_str).map_err(&map_sql_error)?;
1215 if !seen.insert(node_id) {
1216 continue;
1217 }
1218 let via_edge = parse_uuid(&edge_str).map_err(&map_sql_error)?;
1219 let depth = current.depth + 1;
1220 let total_weight = current.total_weight + edge_weight;
1221 nodes.push(PathNode {
1222 node_id,
1223 via_edge: Some(via_edge),
1224 depth,
1225 name: None,
1226 kind: None,
1227 properties: None,
1228 weight: total_weight,
1229 });
1230 non_root_count += 1;
1231 if depth < opts.max_depth {
1232 frontier.push_back(TraversalFrontierNode {
1233 node_id,
1234 depth,
1235 total_weight,
1236 });
1237 }
1238 if non_root_count == result_limit {
1239 break 'root_walk;
1240 }
1241 }
1242 }
1243 }
1244
1245 if !nodes.is_empty() {
1246 let total_weight = nodes.iter().map(|node| node.weight).fold(0.0_f64, f64::max);
1247 all_paths.push(GraphPath {
1248 root_id,
1249 nodes,
1250 total_weight,
1251 });
1252 }
1253 }
1254 Ok(all_paths)
1255 })();
1256
1257 conn.progress_handler(0, None::<fn() -> bool>)
1258 .map_err(|e| map_err(e, "traverse_progress_handler_clear"))?;
1259 result
1260}
1261
1262#[async_trait]
1263impl GraphStore for SqlGraphStore {
1264 async fn upsert_edge(&self, edge: Edge) -> Result<(), StorageError> {
1265 let statement = edge_upsert_statement(&edge);
1266 self.with_writer("upsert_edge", move |conn| {
1267 let mut stmt = conn.prepare(&statement.sql)?;
1268 bind_params(&mut stmt, &statement.params)?;
1269 stmt.raw_execute()?;
1270 Ok(())
1271 })
1272 .await
1273 }
1274
1275 async fn upsert_edges(&self, edges: Vec<Edge>) -> Result<BatchWriteSummary, StorageError> {
1276 let attempted = edges.len() as u64;
1277
1278 if let Some(writer_task) = &self.writer_task {
1284 return writer_task
1285 .send(move |conn| {
1286 batch_upsert_edges(conn, &edges, attempted)
1287 .map_err(|e| map_err(e, "upsert_edges"))
1288 })
1289 .await;
1290 }
1291
1292 let origin = self.pool.origin();
1296 self.with_writer("upsert_edges", move |conn| {
1297 conn.execute_batch("BEGIN IMMEDIATE")?;
1298 let _tx_handle = khive_storage::tx_registry::register_scoped(
1299 Some("graph_upsert_edges".to_string()),
1300 origin,
1301 );
1302
1303 let summary = match batch_upsert_edges(conn, &edges, attempted) {
1304 Ok(summary) => summary,
1305 Err(e) => {
1306 let _ = conn.execute_batch("ROLLBACK");
1307 return Err(e);
1308 }
1309 };
1310
1311 if let Err(e) = conn.execute_batch("COMMIT") {
1312 let _ = conn.execute_batch("ROLLBACK");
1313 return Err(e);
1314 }
1315 Ok(summary)
1316 })
1317 .await
1318 }
1319
1320 async fn upsert_edge_guarded(&self, edge: Edge) -> Result<GuardedWriteOutcome, StorageError> {
1321 let (source_id, target_id) =
1322 canonical_edge_endpoints(edge.relation, edge.source_id, edge.target_id);
1323 let metadata_str = edge
1324 .metadata
1325 .as_ref()
1326 .map(|v| serde_json::to_string(v).unwrap_or_default());
1327 let statement = edge_insert_guarded_by_endpoints_statement(
1328 &edge.namespace,
1329 Uuid::from(edge.id),
1330 source_id,
1331 target_id,
1332 edge.relation,
1333 edge.weight,
1334 edge.created_at.timestamp_micros(),
1335 metadata_str.as_deref(),
1336 );
1337
1338 if let Some(writer_task) = &self.writer_task {
1344 return writer_task
1345 .send(move |conn| {
1346 edge_insert_guarded(conn, &statement, source_id, target_id)
1347 .map_err(|e| map_err(e, "upsert_edge_guarded"))
1348 })
1349 .await;
1350 }
1351
1352 let origin = self.pool.origin();
1358 self.with_writer("upsert_edge_guarded", move |conn| {
1359 conn.execute_batch("BEGIN IMMEDIATE")?;
1360 let _tx_handle = khive_storage::tx_registry::register_scoped(
1361 Some("graph_upsert_edge_guarded".to_string()),
1362 origin,
1363 );
1364
1365 let outcome = match edge_insert_guarded(conn, &statement, source_id, target_id) {
1366 Ok(outcome) => outcome,
1367 Err(e) => {
1368 let _ = conn.execute_batch("ROLLBACK");
1369 return Err(e);
1370 }
1371 };
1372
1373 if let Err(e) = conn.execute_batch("COMMIT") {
1374 let _ = conn.execute_batch("ROLLBACK");
1375 return Err(e);
1376 }
1377 Ok(outcome)
1378 })
1379 .await
1380 }
1381
1382 async fn upsert_edges_guarded(
1383 &self,
1384 edges: Vec<Edge>,
1385 ) -> Result<GuardedBatchOutcome, StorageError> {
1386 let attempted = edges.len() as u64;
1387
1388 if let Some(writer_task) = &self.writer_task {
1392 return writer_task
1393 .send(move |conn| {
1394 batch_upsert_edges_guarded(conn, &edges, attempted)
1395 .map_err(|e| map_err(e, "upsert_edges_guarded"))
1396 })
1397 .await;
1398 }
1399
1400 let origin = self.pool.origin();
1401 self.with_writer("upsert_edges_guarded", move |conn| {
1402 conn.execute_batch("BEGIN IMMEDIATE")?;
1403 let _tx_handle = khive_storage::tx_registry::register_scoped(
1404 Some("graph_upsert_edges_guarded".to_string()),
1405 origin,
1406 );
1407
1408 let summary = match batch_upsert_edges_guarded(conn, &edges, attempted) {
1409 Ok(summary) => summary,
1410 Err(e) => {
1411 let _ = conn.execute_batch("ROLLBACK");
1412 return Err(e);
1413 }
1414 };
1415
1416 if let Err(e) = conn.execute_batch("COMMIT") {
1417 let _ = conn.execute_batch("ROLLBACK");
1418 return Err(e);
1419 }
1420 Ok(summary)
1421 })
1422 .await
1423 }
1424
1425 async fn get_edge(&self, id: LinkId) -> Result<Option<Edge>, StorageError> {
1426 let id_str = Uuid::from(id).to_string();
1427
1428 self.with_reader("get_edge", move |conn| {
1429 let mut stmt = conn.prepare(
1430 "SELECT namespace, id, source_id, target_id, relation, weight, \
1431 created_at, updated_at, deleted_at, metadata, target_backend \
1432 FROM graph_edges WHERE id = ?1 AND deleted_at IS NULL",
1433 )?;
1434 let mut rows = stmt.query(rusqlite::params![id_str])?;
1435 match rows.next()? {
1436 Some(row) => Ok(Some(read_edge(row)?)),
1437 None => Ok(None),
1438 }
1439 })
1440 .await
1441 }
1442
1443 async fn get_edge_including_deleted(&self, id: LinkId) -> Result<Option<Edge>, StorageError> {
1444 let id_str = Uuid::from(id).to_string();
1445
1446 self.with_reader("get_edge_including_deleted", move |conn| {
1447 let mut stmt = conn.prepare(
1448 "SELECT namespace, id, source_id, target_id, relation, weight, \
1449 created_at, updated_at, deleted_at, metadata, target_backend \
1450 FROM graph_edges WHERE id = ?1",
1451 )?;
1452 let mut rows = stmt.query(rusqlite::params![id_str])?;
1453 match rows.next()? {
1454 Some(row) => Ok(Some(read_edge(row)?)),
1455 None => Ok(None),
1456 }
1457 })
1458 .await
1459 }
1460
1461 async fn edge_sequence(&self, id: Uuid) -> Result<Option<i64>, StorageError> {
1462 let id = id.to_string();
1463 self.with_reader("edge_sequence", move |conn| {
1464 conn.query_row(
1465 "SELECT seq FROM graph_edges_seq WHERE edge_id = ?1",
1466 rusqlite::params![id],
1467 |row| row.get(0),
1468 )
1469 .optional()
1470 })
1471 .await
1472 }
1473
1474 async fn edge_sequences(&self, ids: &[Uuid]) -> Result<Vec<(Uuid, i64)>, StorageError> {
1475 if ids.is_empty() {
1476 return Ok(Vec::new());
1477 }
1478 let ids = ids.to_vec();
1479 self.with_reader("edge_sequences", move |conn| {
1480 const CHUNK: usize = 900;
1481 let mut resolved = Vec::with_capacity(ids.len());
1482 for chunk in ids.chunks(CHUNK) {
1483 let placeholders = (1..=chunk.len())
1484 .map(|index| format!("?{index}"))
1485 .collect::<Vec<_>>()
1486 .join(", ");
1487 let sql = format!(
1488 "SELECT edge_id, seq FROM graph_edges_seq WHERE edge_id IN ({placeholders})"
1489 );
1490 let strings = chunk.iter().map(Uuid::to_string).collect::<Vec<_>>();
1491 let params = strings
1492 .iter()
1493 .map(|id| id as &dyn rusqlite::types::ToSql)
1494 .collect::<Vec<_>>();
1495 let mut stmt = conn.prepare(&sql)?;
1496 let rows = stmt.query_map(params.as_slice(), |row| {
1497 let id: String = row.get(0)?;
1498 Ok((parse_uuid(&id)?, row.get::<_, i64>(1)?))
1499 })?;
1500 resolved.extend(rows.collect::<Result<Vec<_>, _>>()?);
1501 }
1502 Ok(resolved)
1503 })
1504 .await
1505 }
1506
1507 async fn get_edge_by_natural_key_including_deleted(
1508 &self,
1509 namespace: &str,
1510 source_id: Uuid,
1511 target_id: Uuid,
1512 relation: EdgeRelation,
1513 ) -> Result<Option<Edge>, StorageError> {
1514 let namespace = namespace.to_string();
1515 let source_str = source_id.to_string();
1516 let target_str = target_id.to_string();
1517 let relation_str = relation.to_string();
1518
1519 self.with_reader("get_edge_by_natural_key_including_deleted", move |conn| {
1520 let mut stmt = conn.prepare(
1521 "SELECT namespace, id, source_id, target_id, relation, weight, \
1522 created_at, updated_at, deleted_at, metadata, target_backend \
1523 FROM graph_edges \
1524 WHERE namespace = ?1 AND source_id = ?2 AND target_id = ?3 AND relation = ?4",
1525 )?;
1526 let mut rows = stmt.query(rusqlite::params![
1527 namespace,
1528 source_str,
1529 target_str,
1530 relation_str
1531 ])?;
1532 match rows.next()? {
1533 Some(row) => Ok(Some(read_edge(row)?)),
1534 None => Ok(None),
1535 }
1536 })
1537 .await
1538 }
1539
1540 async fn get_edges(&self, ids: &[LinkId]) -> Result<Vec<Edge>, StorageError> {
1541 if ids.is_empty() {
1542 return Ok(Vec::new());
1543 }
1544 const CHUNK: usize = 900;
1546 let id_strs: Vec<String> = ids.iter().map(|id| Uuid::from(*id).to_string()).collect();
1547
1548 let mut result: Vec<Edge> = Vec::with_capacity(ids.len());
1549 for chunk in id_strs.chunks(CHUNK) {
1550 let chunk_owned: Vec<String> = chunk.to_vec();
1551 let edges = self
1552 .with_reader("get_edges", move |conn| {
1553 let placeholders: Vec<String> =
1554 (1..=chunk_owned.len()).map(|i| format!("?{}", i)).collect();
1555 let sql = format!(
1556 "SELECT namespace, id, source_id, target_id, relation, weight, \
1557 created_at, updated_at, deleted_at, metadata, target_backend \
1558 FROM graph_edges WHERE id IN ({}) AND deleted_at IS NULL",
1559 placeholders.join(",")
1560 );
1561 let mut stmt = conn.prepare(&sql)?;
1562 let params: Vec<&dyn rusqlite::types::ToSql> = chunk_owned
1563 .iter()
1564 .map(|s| s as &dyn rusqlite::types::ToSql)
1565 .collect();
1566 let rows = stmt.query_map(params.as_slice(), read_edge)?;
1567 let mut edges = Vec::new();
1568 for row in rows {
1569 edges.push(row?);
1570 }
1571 Ok(edges)
1572 })
1573 .await?;
1574 result.extend(edges);
1575 }
1576 Ok(result)
1577 }
1578
1579 async fn batch_neighbors(
1580 &self,
1581 sources: &[Uuid],
1582 query: NeighborQuery,
1583 ) -> Result<Vec<(Uuid, NeighborHit)>, StorageError> {
1584 use khive_storage::types::Direction;
1585
1586 if sources.is_empty() {
1587 return Ok(Vec::new());
1588 }
1589 let mut seen_sources = HashSet::with_capacity(sources.len());
1590 let unique_sources: Vec<Uuid> = sources
1591 .iter()
1592 .copied()
1593 .filter(|source| seen_sources.insert(*source))
1594 .collect();
1595 const CHUNK_SIZE: usize = 880;
1596
1597 let namespace = self.namespace.clone();
1598 let mut result: Vec<(Uuid, NeighborHit)> = Vec::new();
1599 let counted_queries = Arc::new(std::sync::atomic::AtomicU64::new(0));
1602 let counted_rows = Arc::new(std::sync::atomic::AtomicU64::new(0));
1603
1604 for chunk in unique_sources.chunks(CHUNK_SIZE) {
1605 let chunk_owned: Vec<Uuid> = chunk.to_vec();
1606 let query_clone = query.clone();
1607 let ns = namespace.clone();
1608 let closure_queries = Arc::clone(&counted_queries);
1609 let closure_rows = Arc::clone(&counted_rows);
1610
1611 let chunk_result = self
1612 .with_reader("batch_neighbors", move |conn| {
1613 let src_strs: Vec<String> = chunk_owned.iter().map(|u| u.to_string()).collect();
1614
1615 let sources_json = serde_json::to_string(&src_strs).map_err(|error| {
1616 rusqlite::Error::ToSqlConversionFailure(Box::new(error))
1617 })?;
1618
1619 let build_inner_sql =
1620 |direction_out: bool,
1621 q: &NeighborQuery|
1622 -> (String, Vec<String>, Option<f64>) {
1623 let (filter_col, node_col) = if direction_out {
1624 ("source_id", "target_id")
1625 } else {
1626 ("target_id", "source_id")
1627 };
1628
1629 let mut rel_params: Vec<String> = Vec::new();
1630 let mut conditions: Vec<String> = Vec::new();
1631 let mut param_idx = 3;
1632
1633 if let Some(ref rels) = q.relations {
1634 if !rels.is_empty() {
1635 let ps: Vec<String> = rels
1636 .iter()
1637 .map(|r| {
1638 rel_params.push(r.to_string());
1639 let p = format!("?{param_idx}");
1640 param_idx += 1;
1641 p
1642 })
1643 .collect();
1644 conditions
1645 .push(format!("edges.relation IN ({})", ps.join(",")));
1646 }
1647 }
1648
1649 let min_weight_val = if let Some(min_w) = q.min_weight {
1652 conditions.push(format!("edges.weight >= ?{param_idx}"));
1653 Some(min_w)
1654 } else {
1655 None
1656 };
1657
1658 let where_extra = if conditions.is_empty() {
1659 String::new()
1660 } else {
1661 format!(" AND {}", conditions.join(" AND "))
1662 };
1663
1664 let sql = format!(
1665 "SELECT requested.origin_id, edges.{node_col} AS node_id, \
1666 edges.id AS edge_id, edges.relation, edges.weight \
1667 FROM requested CROSS JOIN graph_edges AS edges \
1668 ON edges.{filter_col} = requested.origin_id \
1669 WHERE edges.namespace = ?1 \
1670 AND edges.deleted_at IS NULL{where_extra}",
1671 );
1672 (sql, rel_params, min_weight_val)
1673 };
1674
1675 let mut all_params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
1676 all_params.push(Box::new(ns.to_string()));
1677 all_params.push(Box::new(sources_json));
1678
1679 let (combined_inner, rel_params, min_weight_val) = match query_clone.direction {
1680 Direction::Out => build_inner_sql(true, &query_clone),
1681 Direction::In => build_inner_sql(false, &query_clone),
1682 Direction::Both => {
1683 let (out_sql, rel_params, min_weight_val) =
1684 build_inner_sql(true, &query_clone);
1685 let (in_sql, _, _) = build_inner_sql(false, &query_clone);
1686 (
1687 format!("{out_sql} UNION ALL {in_sql}"),
1688 rel_params,
1689 min_weight_val,
1690 )
1691 }
1692 };
1693
1694 for relation in rel_params {
1695 all_params.push(Box::new(relation));
1696 }
1697 if let Some(min_weight) = min_weight_val {
1698 all_params.push(Box::new(min_weight));
1699 }
1700 let limit_param_idx = all_params.len() + 1;
1701
1702 let full_sql = if let Some(lim) = query_clone.limit {
1710 all_params.push(Box::new(lim as i64));
1711 format!(
1712 "WITH requested(origin_id) AS (\
1713 SELECT value FROM json_each(?2)\
1714 ) SELECT origin_id, node_id, edge_id, relation, weight \
1715 FROM (SELECT *, ROW_NUMBER() OVER (PARTITION BY origin_id \
1716 ORDER BY weight DESC, node_id ASC) AS rn \
1717 FROM ({combined_inner})) WHERE rn <= ?{limit_param_idx}",
1718 )
1719 } else {
1720 format!(
1721 "WITH requested(origin_id) AS (\
1722 SELECT value FROM json_each(?2)\
1723 ) SELECT origin_id, node_id, edge_id, relation, weight \
1724 FROM ({combined_inner})",
1725 )
1726 };
1727
1728 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1729 all_params.iter().map(|p| p.as_ref()).collect();
1730
1731 let mut stmt = conn.prepare(&full_sql)?;
1732 closure_queries.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1733 let rows = stmt.query_map(param_refs.as_slice(), |row| {
1734 let origin_str: String = row.get(0)?;
1735 let nid_str: String = row.get(1)?;
1736 let eid_str: String = row.get(2)?;
1737 let relation_str: String = row.get(3)?;
1738 let weight: f64 = row.get(4)?;
1739 Ok((origin_str, nid_str, eid_str, relation_str, weight))
1740 })?;
1741
1742 let mut pairs = Vec::new();
1743 for row in rows {
1744 let (origin_str, nid_str, eid_str, relation_str, weight) = row?;
1745 closure_rows.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1746 let origin = parse_uuid(&origin_str)?;
1747 let node_id = parse_uuid(&nid_str)?;
1748 let edge_id = parse_uuid(&eid_str)?;
1749 let relation = relation_str.parse::<EdgeRelation>().map_err(|e| {
1750 rusqlite::Error::FromSqlConversionFailure(
1751 3,
1752 rusqlite::types::Type::Text,
1753 Box::new(e),
1754 )
1755 })?;
1756 pairs.push((
1757 origin,
1758 NeighborHit {
1759 node_id,
1760 edge_id,
1761 relation,
1762 weight,
1763 name: None,
1764 kind: None,
1765 entity_type: None,
1766 },
1767 ));
1768 }
1769 Ok(pairs)
1770 })
1771 .await;
1772 let pairs = match chunk_result {
1773 Ok(pairs) => pairs,
1774 Err(e) => {
1775 report_graph_usage(&counted_queries, &counted_rows);
1776 return Err(e);
1777 }
1778 };
1779 result.extend(pairs);
1780 }
1781 report_graph_usage(&counted_queries, &counted_rows);
1782
1783 let requested: HashSet<Uuid> = unique_sources.iter().copied().collect();
1784 let mut grouped: HashMap<Uuid, Vec<NeighborHit>> =
1785 HashMap::with_capacity(unique_sources.len());
1786 for (origin, hit) in result {
1787 if !requested.contains(&origin) {
1788 return Err(StorageError::Internal(format!(
1789 "batch_neighbors returned unrequested origin {origin}"
1790 )));
1791 }
1792 grouped.entry(origin).or_default().push(hit);
1793 }
1794
1795 for hits in grouped.values_mut() {
1796 hits.sort_by(|a, b| {
1797 b.weight
1798 .partial_cmp(&a.weight)
1799 .unwrap_or(std::cmp::Ordering::Equal)
1800 .then(a.node_id.cmp(&b.node_id))
1801 .then(a.edge_id.cmp(&b.edge_id))
1802 });
1803 }
1804
1805 let mut ordered = Vec::new();
1806 for &source in sources {
1807 if let Some(hits) = grouped.get(&source) {
1808 ordered.extend(hits.iter().cloned().map(|hit| (source, hit)));
1809 }
1810 }
1811 Ok(ordered)
1812 }
1813
1814 async fn delete_edge(&self, id: LinkId, mode: DeleteMode) -> Result<bool, StorageError> {
1815 let id = Uuid::from(id);
1816 let statement = match mode {
1817 DeleteMode::Soft => {
1818 edge_soft_delete_statement(id, chrono::Utc::now().timestamp_micros())
1819 }
1820 DeleteMode::Hard => edge_hard_delete_statement(id),
1821 };
1822 self.with_writer("delete_edge", move |conn| {
1823 let mut stmt = conn.prepare(&statement.sql)?;
1824 bind_params(&mut stmt, &statement.params)?;
1825 Ok(stmt.raw_execute()? > 0)
1826 })
1827 .await
1828 }
1829
1830 async fn query_edges(
1831 &self,
1832 filter: EdgeFilter,
1833 sort: Vec<SortOrder<EdgeSortField>>,
1834 page: PageRequest,
1835 ) -> Result<Page<Edge>, StorageError> {
1836 let namespace = self.namespace.clone();
1837 let limit_i64 = i64::from(page.limit);
1838 let offset_i64 = i64::try_from(page.offset).map_err(|_| StorageError::InvalidInput {
1839 capability: StorageCapability::Graph,
1840 operation: "query_edges".into(),
1841 message: format!(
1842 "PageRequest: offset must be <= i64::MAX, got {}",
1843 page.offset
1844 ),
1845 })?;
1846 self.with_reader("query_edges", move |conn| {
1847 let (where_clause, filter_params) = build_edge_filter_sql(&namespace, &filter);
1848
1849 let count_sql = format!("SELECT COUNT(*) FROM graph_edges{}", where_clause);
1850 let total: i64 = {
1851 let mut stmt = conn.prepare(&count_sql)?;
1852 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1853 filter_params.iter().map(|p| p.as_ref()).collect();
1854 stmt.query_row(param_refs.as_slice(), |row| row.get(0))?
1855 };
1856
1857 let order_clause = if sort.is_empty() {
1858 " ORDER BY created_at DESC".to_string()
1859 } else {
1860 let parts: Vec<String> = sort
1861 .iter()
1862 .map(|s| {
1863 let dir = match s.direction {
1864 SortDirection::Asc => "ASC",
1865 SortDirection::Desc => "DESC",
1866 };
1867 format!("{} {}", edge_sort_col(&s.field), dir)
1868 })
1869 .collect();
1870 format!(" ORDER BY {}", parts.join(", "))
1871 };
1872
1873 let (_, data_filter_params) = build_edge_filter_sql(&namespace, &filter);
1874 let mut all_params: Vec<Box<dyn rusqlite::types::ToSql>> = data_filter_params;
1875 all_params.push(Box::new(limit_i64));
1876 all_params.push(Box::new(offset_i64));
1877
1878 let limit_idx = all_params.len() - 1;
1879 let offset_idx = all_params.len();
1880
1881 let data_sql = format!(
1882 "SELECT namespace, id, source_id, target_id, relation, weight, \
1883 created_at, updated_at, deleted_at, metadata, target_backend \
1884 FROM graph_edges{}{} LIMIT ?{} OFFSET ?{}",
1885 where_clause, order_clause, limit_idx, offset_idx,
1886 );
1887
1888 let mut stmt = conn.prepare(&data_sql)?;
1889 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1890 all_params.iter().map(|p| p.as_ref()).collect();
1891 let rows = stmt.query_map(param_refs.as_slice(), read_edge)?;
1892
1893 let mut items = Vec::new();
1894 for row in rows {
1895 items.push(row?);
1896 }
1897
1898 Ok(Page {
1899 items,
1900 total: Some(total as u64),
1901 })
1902 })
1903 .await
1904 }
1905
1906 async fn count_edges(&self, filter: EdgeFilter) -> Result<u64, StorageError> {
1907 let namespace = self.namespace.clone();
1908 self.with_reader("count_edges", move |conn| {
1909 let (where_clause, params) = build_edge_filter_sql(&namespace, &filter);
1910 let sql = format!("SELECT COUNT(*) FROM graph_edges{}", where_clause);
1911 let mut stmt = conn.prepare(&sql)?;
1912 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1913 params.iter().map(|p| p.as_ref()).collect();
1914 let count: i64 = stmt.query_row(param_refs.as_slice(), |row| row.get(0))?;
1915 Ok(count as u64)
1916 })
1917 .await
1918 }
1919
1920 async fn count_edges_in_namespaces(
1921 &self,
1922 namespaces: &[String],
1923 filter: EdgeFilter,
1924 ) -> Result<u64, StorageError> {
1925 let namespaces: Vec<String> = namespaces
1926 .iter()
1927 .cloned()
1928 .collect::<HashSet<_>>()
1929 .into_iter()
1930 .collect();
1931 self.with_reader("count_edges_in_namespaces", move |conn| {
1932 let mut total = 0;
1933 for chunk in namespaces.chunks(NAMESPACE_COUNT_CHUNK_SIZE) {
1934 let (where_clause, params) = build_edge_filter_sql_for_namespaces(chunk, &filter);
1935 let sql = format!("SELECT COUNT(*) FROM graph_edges{where_clause}");
1936 let mut stmt = conn.prepare(&sql)?;
1937 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1938 params.iter().map(|p| p.as_ref()).collect();
1939 let count: i64 = stmt.query_row(param_refs.as_slice(), |row| row.get(0))?;
1940 total += count as u64;
1941 }
1942 Ok(total)
1943 })
1944 .await
1945 }
1946
1947 async fn count_edges_by_relation(&self) -> Result<Vec<(EdgeRelation, u64)>, StorageError> {
1948 let namespace = self.namespace.clone();
1949 self.with_reader("count_edges_by_relation", move |conn| {
1950 let sql = "SELECT relation, COUNT(*) FROM graph_edges \
1951 WHERE namespace = ?1 AND deleted_at IS NULL \
1952 GROUP BY relation";
1953 let mut stmt = conn.prepare(sql)?;
1954 let rows = stmt.query_map([&namespace], |row| {
1955 let relation_str: String = row.get(0)?;
1956 let count: i64 = row.get(1)?;
1957 Ok((relation_str, count))
1958 })?;
1959 let mut out = Vec::new();
1960 for row in rows {
1961 let (relation_str, count) = row?;
1962 let relation = relation_str.parse::<EdgeRelation>().map_err(|e| {
1963 rusqlite::Error::FromSqlConversionFailure(
1964 0,
1965 rusqlite::types::Type::Text,
1966 Box::new(e),
1967 )
1968 })?;
1969 out.push((relation, count as u64));
1970 }
1971 Ok(out)
1972 })
1973 .await
1974 }
1975
1976 async fn count_edges_by_relation_in_namespaces(
1977 &self,
1978 namespaces: &[String],
1979 ) -> Result<Vec<(EdgeRelation, u64)>, StorageError> {
1980 let namespaces: Vec<String> = namespaces
1981 .iter()
1982 .cloned()
1983 .collect::<HashSet<_>>()
1984 .into_iter()
1985 .collect();
1986 self.with_reader("count_edges_by_relation_in_namespaces", move |conn| {
1987 let mut totals = HashMap::new();
1988 for chunk in namespaces.chunks(NAMESPACE_COUNT_CHUNK_SIZE) {
1989 let (where_clause, params) =
1990 build_edge_filter_sql_for_namespaces(chunk, &EdgeFilter::default());
1991 let sql = format!(
1992 "SELECT relation, COUNT(*) FROM graph_edges{where_clause} GROUP BY relation"
1993 );
1994 let mut stmt = conn.prepare(&sql)?;
1995 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1996 params.iter().map(|p| p.as_ref()).collect();
1997 let rows = stmt.query_map(param_refs.as_slice(), |row| {
1998 let relation_str: String = row.get(0)?;
1999 let count: i64 = row.get(1)?;
2000 Ok((relation_str, count))
2001 })?;
2002 for row in rows {
2003 let (relation_str, count) = row?;
2004 let relation = relation_str.parse::<EdgeRelation>().map_err(|e| {
2005 rusqlite::Error::FromSqlConversionFailure(
2006 0,
2007 rusqlite::types::Type::Text,
2008 Box::new(e),
2009 )
2010 })?;
2011 *totals.entry(relation).or_insert(0) += count as u64;
2012 }
2013 }
2014 Ok(totals.into_iter().collect())
2015 })
2016 .await
2017 }
2018
2019 async fn query_edges_after(
2020 &self,
2021 filter: EdgeFilter,
2022 after: Option<Uuid>,
2023 limit: u32,
2024 ) -> Result<EdgeSeekPage, StorageError> {
2025 let namespace = self.namespace.clone();
2026 let limit_usize = limit as usize;
2027 let probe_limit_i64 = i64::from(limit) + 1;
2028 self.with_reader("query_edges_after", move |conn| {
2029 let (mut where_clause, mut params) = build_edge_filter_sql(&namespace, &filter);
2030 if let Some(cursor) = after {
2031 params.push(Box::new(cursor.to_string()));
2032 where_clause.push_str(&format!(" AND id > ?{}", params.len()));
2033 }
2034 params.push(Box::new(probe_limit_i64));
2035 let limit_idx = params.len();
2036
2037 let data_sql = format!(
2043 "SELECT namespace, id, source_id, target_id, relation, weight, \
2044 created_at, updated_at, deleted_at, metadata, target_backend \
2045 FROM graph_edges{} ORDER BY id ASC LIMIT ?{}",
2046 where_clause, limit_idx,
2047 );
2048
2049 let mut stmt = conn.prepare(&data_sql)?;
2050 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
2051 params.iter().map(|p| p.as_ref()).collect();
2052 let rows = stmt.query_map(param_refs.as_slice(), read_edge)?;
2053
2054 let mut items = Vec::new();
2055 for row in rows {
2056 items.push(row?);
2057 }
2058 let has_more = items.len() > limit_usize;
2059 if has_more {
2060 items.truncate(limit_usize);
2061 }
2062 let next_after = if has_more {
2063 items.last().map(|e| Uuid::from(e.id))
2064 } else {
2065 None
2066 };
2067
2068 Ok(EdgeSeekPage { items, next_after })
2069 })
2070 .await
2071 }
2072
2073 async fn query_edges_sequence_after(
2074 &self,
2075 filter: EdgeFilter,
2076 after: Option<SeekCursor>,
2077 limit: u32,
2078 ) -> Result<SeekPage<Edge>, StorageError> {
2079 if limit == 0 {
2080 return Ok(SeekPage::default());
2081 }
2082 let namespace = self.namespace.clone();
2083 let limit_usize = limit as usize;
2084 let probe_limit_i64 = i64::from(limit) + 1;
2085 self.with_reader("query_edges_sequence_after", move |conn| {
2086 let (mut where_clause, mut params) = build_edge_filter_sql(&namespace, &filter);
2087 if let Some(cursor) = after {
2088 params.push(Box::new(cursor.sequence));
2089 where_clause.push_str(&format!(" AND graph_edges_seq.seq > ?{}", params.len()));
2090 }
2091 params.push(Box::new(probe_limit_i64));
2092 let limit_idx = params.len();
2093 let sql = format!(
2096 "SELECT namespace, id, source_id, target_id, relation, weight, \
2097 created_at, updated_at, deleted_at, metadata, target_backend, \
2098 graph_edges_seq.seq \
2099 FROM graph_edges_seq CROSS JOIN graph_edges \
2100 ON graph_edges.id = graph_edges_seq.edge_id{where_clause} \
2101 ORDER BY graph_edges_seq.seq ASC LIMIT ?{limit_idx}"
2102 );
2103 let mut stmt = conn.prepare(&sql)?;
2104 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
2105 params.iter().map(|param| param.as_ref()).collect();
2106 let rows = stmt.query_map(param_refs.as_slice(), |row| {
2107 Ok((read_edge(row)?, row.get::<_, i64>(11)?))
2108 })?;
2109 let mut entries = rows.collect::<Result<Vec<_>, _>>()?;
2110 let has_more = entries.len() > limit_usize;
2111 if has_more {
2112 entries.truncate(limit_usize);
2113 }
2114 let next_after = if has_more {
2115 entries.last().map(|(edge, sequence)| SeekCursor {
2116 sequence: *sequence,
2117 id: Uuid::from(edge.id),
2118 })
2119 } else {
2120 None
2121 };
2122 let items = entries.into_iter().map(|(edge, _)| edge).collect();
2123 Ok(SeekPage { items, next_after })
2124 })
2125 .await
2126 }
2127
2128 async fn neighbors(
2129 &self,
2130 node_id: Uuid,
2131 query: NeighborQuery,
2132 ) -> Result<Vec<NeighborHit>, StorageError> {
2133 count_neighbor_select();
2134
2135 let namespace = self.namespace.clone();
2136 let node_str = node_id.to_string();
2137
2138 let counted_queries = Arc::new(std::sync::atomic::AtomicU64::new(0));
2139 let counted_rows = Arc::new(std::sync::atomic::AtomicU64::new(0));
2140 let closure_queries = Arc::clone(&counted_queries);
2141 let closure_rows = Arc::clone(&counted_rows);
2142 let result = self
2143 .with_reader("neighbors", move |conn| {
2144 let base_out = "SELECT target_id AS node_id, id AS edge_id, relation, weight \
2145 FROM graph_edges \
2146 WHERE namespace = ?1 AND source_id = ?2 AND deleted_at IS NULL";
2147 let base_in = "SELECT source_id AS node_id, id AS edge_id, relation, weight \
2148 FROM graph_edges \
2149 WHERE namespace = ?1 AND target_id = ?2 AND deleted_at IS NULL";
2150
2151 let sql = match query.direction {
2152 Direction::Out => base_out.to_string(),
2153 Direction::In => base_in.to_string(),
2154 Direction::Both => format!("{} UNION ALL {}", base_out, base_in),
2155 };
2156
2157 let (where_extra, limit_clause, extra_params) = neighbor_extra_clause(&query, 3);
2158
2159 let full_sql = format!(
2164 "SELECT node_id, edge_id, relation, weight FROM ({}){} \
2165 ORDER BY weight DESC, node_id ASC{}",
2166 sql, where_extra, limit_clause
2167 );
2168
2169 let mut stmt = conn.prepare(&full_sql)?;
2170
2171 let mut all_params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
2172 all_params.push(Box::new(namespace.clone()));
2173 all_params.push(Box::new(node_str.clone()));
2174 all_params.extend(extra_params);
2175
2176 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
2177 all_params.iter().map(|p| p.as_ref()).collect();
2178
2179 closure_queries.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2180 let rows = stmt.query_map(param_refs.as_slice(), |row| {
2181 let nid_str: String = row.get(0)?;
2182 let eid_str: String = row.get(1)?;
2183 let relation_str: String = row.get(2)?;
2184 let weight: f64 = row.get(3)?;
2185 Ok((nid_str, eid_str, relation_str, weight))
2186 })?;
2187
2188 let mut hits = Vec::new();
2189 for row in rows {
2190 let (nid_str, eid_str, relation_str, weight) = row?;
2191 closure_rows.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2192 let relation = relation_str.parse::<EdgeRelation>().map_err(|e| {
2193 rusqlite::Error::FromSqlConversionFailure(
2194 2,
2195 rusqlite::types::Type::Text,
2196 Box::new(e),
2197 )
2198 })?;
2199 hits.push(NeighborHit {
2200 node_id: parse_uuid(&nid_str)?,
2201 edge_id: parse_uuid(&eid_str)?,
2202 relation,
2203 weight,
2204 name: None,
2205 kind: None,
2206 entity_type: None,
2207 });
2208 }
2209
2210 Ok(hits)
2211 })
2212 .await;
2213
2214 report_graph_usage(&counted_queries, &counted_rows);
2215 result
2216 }
2217
2218 async fn neighbors_both_directions(
2223 &self,
2224 node_id: Uuid,
2225 query: NeighborQuery,
2226 ) -> Result<Vec<DirectedNeighborHit>, StorageError> {
2227 count_neighbor_select();
2228
2229 let namespace = self.namespace.clone();
2230 let node_str = node_id.to_string();
2231
2232 let counted_queries = Arc::new(std::sync::atomic::AtomicU64::new(0));
2233 let counted_rows = Arc::new(std::sync::atomic::AtomicU64::new(0));
2234 let closure_queries = Arc::clone(&counted_queries);
2235 let closure_rows = Arc::clone(&counted_rows);
2236 let result = self
2237 .with_reader("neighbors_both_directions", move |conn| {
2238 let base_out = "SELECT target_id AS node_id, id AS edge_id, relation, weight, \
2239 'out' AS dir \
2240 FROM graph_edges \
2241 WHERE namespace = ?1 AND source_id = ?2 AND deleted_at IS NULL";
2242 let base_in = "SELECT source_id AS node_id, id AS edge_id, relation, weight, \
2243 'in' AS dir \
2244 FROM graph_edges \
2245 WHERE namespace = ?1 AND target_id = ?2 AND deleted_at IS NULL";
2246 let sql = format!("{} UNION ALL {}", base_out, base_in);
2247
2248 let (where_extra, limit_clause, extra_params) = neighbor_extra_clause(&query, 3);
2249
2250 let full_sql = format!(
2259 "SELECT node_id, edge_id, relation, weight, dir FROM ({}){} \
2260 ORDER BY weight DESC, node_id ASC, \
2261 CASE dir WHEN 'out' THEN 0 ELSE 1 END ASC, edge_id ASC{}",
2262 sql, where_extra, limit_clause
2263 );
2264
2265 let mut stmt = conn.prepare(&full_sql)?;
2266
2267 let mut all_params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
2268 all_params.push(Box::new(namespace.clone()));
2269 all_params.push(Box::new(node_str.clone()));
2270 all_params.extend(extra_params);
2271
2272 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
2273 all_params.iter().map(|p| p.as_ref()).collect();
2274
2275 closure_queries.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2276 let rows = stmt.query_map(param_refs.as_slice(), |row| {
2277 let nid_str: String = row.get(0)?;
2278 let eid_str: String = row.get(1)?;
2279 let relation_str: String = row.get(2)?;
2280 let weight: f64 = row.get(3)?;
2281 let dir_str: String = row.get(4)?;
2282 Ok((nid_str, eid_str, relation_str, weight, dir_str))
2283 })?;
2284
2285 let mut hits = Vec::new();
2286 for row in rows {
2287 let (nid_str, eid_str, relation_str, weight, dir_str) = row?;
2288 closure_rows.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2289 let relation = relation_str.parse::<EdgeRelation>().map_err(|e| {
2290 rusqlite::Error::FromSqlConversionFailure(
2291 2,
2292 rusqlite::types::Type::Text,
2293 Box::new(e),
2294 )
2295 })?;
2296 let direction = if dir_str == "out" {
2297 Direction::Out
2298 } else {
2299 Direction::In
2300 };
2301 hits.push(DirectedNeighborHit {
2302 hit: NeighborHit {
2303 node_id: parse_uuid(&nid_str)?,
2304 edge_id: parse_uuid(&eid_str)?,
2305 relation,
2306 weight,
2307 name: None,
2308 kind: None,
2309 entity_type: None,
2310 },
2311 direction,
2312 });
2313 }
2314
2315 Ok(hits)
2316 })
2317 .await;
2318
2319 report_graph_usage(&counted_queries, &counted_rows);
2320 result
2321 }
2322
2323 async fn traverse(&self, request: TraversalRequest) -> Result<Vec<GraphPath>, StorageError> {
2324 request
2325 .validate()
2326 .map_err(|message| StorageError::InvalidInput {
2327 capability: StorageCapability::Graph,
2328 operation: "traverse".into(),
2329 message,
2330 })?;
2331 if request.roots.is_empty() {
2332 return Ok(Vec::new());
2333 }
2334
2335 let mut distinct_roots = HashSet::with_capacity(request.roots.len());
2336 let roots = request
2337 .roots
2338 .iter()
2339 .copied()
2340 .filter(|root| distinct_roots.insert(*root))
2341 .collect::<Vec<_>>();
2342 let opts = request.options;
2343 let include_roots = request.include_roots;
2344 let namespace = self.namespace.clone();
2345 let origin = self.pool.origin();
2346 let budget = request.execution_budget;
2347 let counted_rows = Arc::new(std::sync::atomic::AtomicU64::new(0));
2353 let counted_queries = Arc::new(std::sync::atomic::AtomicU64::new(0));
2354 let closure_rows = Arc::clone(&counted_rows);
2355 let closure_queries = Arc::clone(&counted_queries);
2356 let result = self
2357 .with_reader("traverse", move |conn| {
2358 Ok(run_bounded_traversal(
2359 conn,
2360 roots,
2361 opts,
2362 include_roots,
2363 namespace,
2364 origin,
2365 budget,
2366 closure_rows.as_ref(),
2367 closure_queries.as_ref(),
2368 ))
2369 })
2370 .await
2371 .and_then(|inner| inner);
2372
2373 khive_storage::usage::count(
2382 khive_storage::usage::UsageUnit::DbRoundTrips,
2383 counted_queries.load(std::sync::atomic::Ordering::Relaxed),
2384 );
2385 khive_storage::usage::count(
2386 khive_storage::usage::UsageUnit::GraphHops,
2387 counted_rows.load(std::sync::atomic::Ordering::Relaxed),
2388 );
2389
2390 result
2391 }
2392
2393 async fn purge_incident_edges(&self, node_id: Uuid) -> Result<u64, StorageError> {
2394 let statement = purge_incident_edges_statement(node_id);
2398 self.with_writer("purge_incident_edges", move |conn| {
2399 let mut stmt = conn.prepare(&statement.sql)?;
2400 bind_params(&mut stmt, &statement.params)?;
2401 Ok(stmt.raw_execute()? as u64)
2402 })
2403 .await
2404 }
2405}
2406
2407const GRAPH_DDL: &str = include_str!("../../sql/graph-ddl.sql");
2412
2413pub(crate) fn ensure_graph_schema(conn: &rusqlite::Connection) -> Result<(), rusqlite::Error> {
2414 conn.execute_batch(GRAPH_DDL)
2415}
2416
2417#[cfg(test)]
2418#[path = "graph_tests.rs"]
2419mod tests;