1use std::collections::HashMap;
10use std::future::Future;
11use std::pin::Pin;
12use std::sync::Arc;
13
14use tokio::sync::RwLock;
15
16use futures::StreamExt as _;
17use qdrant_client::qdrant::{PointStruct, value::Kind};
18
19use crate::QdrantOps;
20use crate::vector_store::{VectorStore, VectorStoreError};
21
22pub type EmbedFuture = Pin<
24 Box<dyn Future<Output = Result<Vec<f32>, Box<dyn std::error::Error + Send + Sync>>> + Send>,
25>;
26
27pub trait Embeddable: Send + Sync {
33 fn key(&self) -> &str;
35
36 fn content_hash(&self) -> String;
40
41 fn embed_text(&self) -> &str;
43
44 fn to_payload(&self) -> serde_json::Value;
49}
50
51#[derive(Debug, Default, Clone)]
53pub struct SyncStats {
54 pub added: usize,
55 pub updated: usize,
56 pub removed: usize,
57 pub unchanged: usize,
58}
59
60#[derive(Debug, thiserror::Error)]
62#[non_exhaustive]
63pub enum EmbeddingRegistryError {
64 #[error("vector store error: {0}")]
65 VectorStore(#[from] VectorStoreError),
66
67 #[error("embedding error: {0}")]
68 Embedding(String),
69
70 #[error("serialization error: {0}")]
71 Serialization(String),
72
73 #[error("dimension probe failed: {0}")]
74 DimensionProbe(String),
75}
76
77impl From<Box<qdrant_client::QdrantError>> for EmbeddingRegistryError {
78 fn from(e: Box<qdrant_client::QdrantError>) -> Self {
79 Self::VectorStore(VectorStoreError::Collection(e.to_string()))
80 }
81}
82
83impl From<serde_json::Error> for EmbeddingRegistryError {
84 fn from(e: serde_json::Error) -> Self {
85 Self::Serialization(e.to_string())
86 }
87}
88
89fn normalize_model_name(name: &str) -> &str {
91 name.strip_suffix(":latest").unwrap_or(name)
92}
93
94async fn probe_dimension(
100 embed_fn: &impl Fn(&str) -> EmbedFuture,
101) -> Result<u64, EmbeddingRegistryError> {
102 crate::embed_probe::probe_vector_size(embed_fn("dimension probe"), None)
103 .await
104 .map_err(|e| EmbeddingRegistryError::DimensionProbe(e.to_string()))
105}
106
107fn model_has_changed(
113 existing: &HashMap<String, HashMap<String, String>>,
114 config_model: &str,
115) -> bool {
116 if config_model.is_empty() {
117 return false;
118 }
119 existing
120 .values()
121 .any(|stored| match stored.get("embedding_model") {
122 Some(m) => normalize_model_name(m) != normalize_model_name(config_model),
123 None => true,
125 })
126}
127
128#[derive(Clone)]
140pub struct EmbeddingRegistry {
141 ops: QdrantOps,
142 collection: String,
143 namespace: uuid::Uuid,
144 hashes: HashMap<String, String>,
145 pub concurrency: usize,
147 cached_dim: Arc<RwLock<Option<u64>>>,
150}
151
152impl std::fmt::Debug for EmbeddingRegistry {
153 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154 f.debug_struct("EmbeddingRegistry")
155 .field("collection", &self.collection)
156 .finish_non_exhaustive()
157 }
158}
159
160impl EmbeddingRegistry {
161 #[must_use]
163 pub fn new(ops: QdrantOps, collection: impl Into<String>, namespace: uuid::Uuid) -> Self {
164 Self {
165 ops,
166 collection: collection.into(),
167 namespace,
168 hashes: HashMap::new(),
169 concurrency: 4,
170 cached_dim: Arc::new(RwLock::new(None)),
171 }
172 }
173
174 #[tracing::instrument(name = "memory.embed_registry.sync", skip_all, err)]
185 pub async fn sync<T: Embeddable>(
186 &mut self,
187 items: &[T],
188 embedding_model: &str,
189 embed_fn: impl Fn(&str) -> EmbedFuture,
190 on_progress: Option<Box<dyn Fn(usize, usize) + Send>>,
191 ) -> Result<SyncStats, EmbeddingRegistryError> {
192 let mut stats = SyncStats::default();
193
194 self.ensure_collection(&embed_fn).await?;
195
196 let existing = self
197 .ops
198 .scroll_all(&self.collection, "key")
199 .await
200 .map_err(|e| {
201 EmbeddingRegistryError::VectorStore(VectorStoreError::Scroll(e.to_string()))
202 })?;
203
204 let mut current: HashMap<String, (String, &T)> = HashMap::with_capacity(items.len());
205 for item in items {
206 current.insert(item.key().to_owned(), (item.content_hash(), item));
207 }
208
209 let model_changed = model_has_changed(&existing, embedding_model);
210
211 if model_changed {
212 tracing::warn!("embedding model changed to '{embedding_model}', recreating collection");
213 self.recreate_collection(&embed_fn).await?;
214 }
215
216 let work_items = build_work_set(
217 ¤t,
218 &existing,
219 model_changed,
220 &mut stats,
221 &mut self.hashes,
222 );
223
224 let work_with_futures: Vec<(String, String, EmbedFuture, String, serde_json::Value)> =
227 work_items
228 .into_iter()
229 .map(|(key, hash, item)| {
230 let text = item.embed_text().to_owned();
231 let fut = embed_fn(&text);
232 let point_id = self.point_id(&key);
233 let payload = item.to_payload();
234 (key, hash, fut, point_id, payload)
235 })
236 .collect();
237
238 let points_to_upsert = embed_and_collect_points(
239 work_with_futures,
240 on_progress,
241 &existing,
242 embedding_model,
243 self.concurrency,
244 &mut stats,
245 &mut self.hashes,
246 )
247 .await?;
248
249 if !points_to_upsert.is_empty() {
250 self.ops
251 .upsert(&self.collection, points_to_upsert)
252 .await
253 .map_err(|e| {
254 EmbeddingRegistryError::VectorStore(VectorStoreError::Upsert(e.to_string()))
255 })?;
256 }
257
258 let orphan_ids: Vec<qdrant_client::qdrant::PointId> = existing
259 .keys()
260 .filter(|key| !current.contains_key(*key))
261 .map(|key| qdrant_client::qdrant::PointId::from(self.point_id(key).as_str()))
262 .collect();
263
264 if !orphan_ids.is_empty() {
265 stats.removed = orphan_ids.len();
266 self.ops
267 .delete_by_ids(&self.collection, orphan_ids)
268 .await
269 .map_err(|e| {
270 EmbeddingRegistryError::VectorStore(VectorStoreError::Delete(e.to_string()))
271 })?;
272 }
273
274 tracing::info!(
275 added = stats.added,
276 updated = stats.updated,
277 removed = stats.removed,
278 unchanged = stats.unchanged,
279 collection = &self.collection,
280 "embeddings synced"
281 );
282
283 Ok(stats)
284 }
285
286 #[tracing::instrument(name = "memory.embed_registry.search_raw", skip_all, err)]
309 pub async fn search_raw(
310 &self,
311 query: &str,
312 limit: usize,
313 embed_fn: impl Fn(&str) -> EmbedFuture,
314 ) -> Result<Vec<crate::ScoredVectorPoint>, EmbeddingRegistryError> {
315 static CLAMP_WARNED: std::sync::atomic::AtomicBool =
316 std::sync::atomic::AtomicBool::new(false);
317 crate::warn_if_search_limit_clamped("EmbeddingRegistry::search_raw", limit, &CLAMP_WARNED);
318 let limit = limit.clamp(1, crate::MAX_SEARCH_LIMIT);
319 let query_vec = embed_fn(query)
320 .await
321 .map_err(|e| EmbeddingRegistryError::Embedding(e.to_string()))?;
322
323 let collection_dim: Option<u64> = *self.cached_dim.read().await;
327
328 let collection_dim = if collection_dim.is_some() {
329 collection_dim
330 } else {
331 let probed = self
333 .ops
334 .get_collection_vector_size(&self.collection)
335 .await
336 .map_err(|e| {
337 EmbeddingRegistryError::VectorStore(VectorStoreError::Collection(e.to_string()))
338 })?;
339 if let Some(d) = probed {
340 self.set_cached_dim(d).await;
341 }
342 probed
343 };
344
345 if let Some(stored_dim) = collection_dim {
346 let query_dim = query_vec.len() as u64;
348 if query_dim != stored_dim {
349 return Err(EmbeddingRegistryError::DimensionProbe(format!(
350 "query vector dimension {query_dim} does not match collection '{}' \
351 dimension {stored_dim}; re-run sync to rebuild the collection",
352 self.collection
353 )));
354 }
355 }
356
357 let Ok(limit_u64) = u64::try_from(limit) else {
358 return Ok(Vec::new());
359 };
360
361 let results = self
362 .ops
363 .search(&self.collection, query_vec, limit_u64, None)
364 .await
365 .map_err(|e| {
366 EmbeddingRegistryError::VectorStore(VectorStoreError::Search(e.to_string()))
367 })?;
368
369 let scored: Vec<crate::ScoredVectorPoint> = results
370 .into_iter()
371 .map(|point| {
372 let payload: HashMap<String, serde_json::Value> = point
373 .payload
374 .into_iter()
375 .filter_map(|(k, v)| {
376 let json_val = match v.kind? {
377 Kind::StringValue(s) => serde_json::Value::String(s),
378 Kind::IntegerValue(i) => serde_json::Value::Number(i.into()),
379 Kind::BoolValue(b) => serde_json::Value::Bool(b),
380 Kind::DoubleValue(d) => {
381 serde_json::Number::from_f64(d).map(serde_json::Value::Number)?
382 }
383 _ => return None,
384 };
385 Some((k, json_val))
386 })
387 .collect();
388
389 let id = match point.id.and_then(|pid| pid.point_id_options) {
390 Some(qdrant_client::qdrant::point_id::PointIdOptions::Uuid(u)) => u,
391 Some(qdrant_client::qdrant::point_id::PointIdOptions::Num(n)) => n.to_string(),
392 None => String::new(),
393 };
394
395 crate::ScoredVectorPoint {
396 id,
397 score: point.score,
398 payload,
399 }
400 })
401 .collect();
402
403 Ok(scored)
404 }
405
406 fn point_id(&self, key: &str) -> String {
407 uuid::Uuid::new_v5(&self.namespace, key.as_bytes()).to_string()
408 }
409
410 #[tracing::instrument(name = "memory.embed_registry.get_vectors_by_keys", skip_all, err)]
421 pub async fn get_vectors_by_keys(
422 &self,
423 keys: &[String],
424 ) -> Result<HashMap<String, Vec<f32>>, EmbeddingRegistryError> {
425 if keys.is_empty() {
426 return Ok(HashMap::new());
427 }
428 let id_to_key: HashMap<String, String> =
429 keys.iter().map(|k| (self.point_id(k), k.clone())).collect();
430 let ids: Vec<String> = id_to_key.keys().cloned().collect();
431 let points = self.ops.get_points(&self.collection, ids).await?;
432 Ok(points
433 .into_iter()
434 .filter_map(|p| id_to_key.get(&p.id).map(|k| (k.clone(), p.vector)))
435 .collect())
436 }
437
438 #[tracing::instrument(
439 name = "memory.embed_registry.ensure_collection",
440 skip_all,
441 err,
442 level = "debug"
443 )]
444 async fn ensure_collection(
445 &self,
446 embed_fn: &impl Fn(&str) -> EmbedFuture,
447 ) -> Result<(), EmbeddingRegistryError> {
448 if !self
449 .ops
450 .collection_exists(&self.collection)
451 .await
452 .map_err(|e| {
453 EmbeddingRegistryError::VectorStore(VectorStoreError::Collection(e.to_string()))
454 })?
455 {
456 let vector_size = probe_dimension(embed_fn).await?;
458 self.ops
459 .ensure_collection(&self.collection, vector_size)
460 .await
461 .map_err(|e| {
462 EmbeddingRegistryError::VectorStore(VectorStoreError::Collection(e.to_string()))
463 })?;
464 tracing::info!(
465 collection = &self.collection,
466 dimensions = vector_size,
467 "created Qdrant collection"
468 );
469 self.set_cached_dim(vector_size).await;
470 return Ok(());
471 }
472
473 let existing_size = self
474 .ops
475 .get_collection_vector_size(&self.collection)
476 .await
477 .map_err(|e| {
478 EmbeddingRegistryError::VectorStore(VectorStoreError::Collection(e.to_string()))
479 })?;
480
481 let vector_size = probe_dimension(embed_fn).await?;
482
483 if existing_size == Some(vector_size) {
484 self.set_cached_dim(vector_size).await;
485 return Ok(());
486 }
487
488 tracing::warn!(
489 collection = &self.collection,
490 existing = ?existing_size,
491 required = vector_size,
492 "vector dimension mismatch, recreating collection"
493 );
494 self.ops
495 .delete_collection(&self.collection)
496 .await
497 .map_err(|e| {
498 EmbeddingRegistryError::VectorStore(VectorStoreError::Collection(e.to_string()))
499 })?;
500 self.ops
501 .ensure_collection(&self.collection, vector_size)
502 .await
503 .map_err(|e| {
504 EmbeddingRegistryError::VectorStore(VectorStoreError::Collection(e.to_string()))
505 })?;
506 tracing::info!(
507 collection = &self.collection,
508 dimensions = vector_size,
509 "created Qdrant collection"
510 );
511 self.set_cached_dim(vector_size).await;
512
513 Ok(())
514 }
515
516 #[tracing::instrument(
518 name = "memory.embed_registry.set_cached_dim",
519 skip_all,
520 level = "debug"
521 )]
522 async fn set_cached_dim(&self, dim: u64) {
523 *self.cached_dim.write().await = Some(dim);
524 }
525
526 #[tracing::instrument(
527 name = "memory.embed_registry.recreate_collection",
528 skip_all,
529 err,
530 level = "debug"
531 )]
532 async fn recreate_collection(
533 &self,
534 embed_fn: &impl Fn(&str) -> EmbedFuture,
535 ) -> Result<(), EmbeddingRegistryError> {
536 if self
537 .ops
538 .collection_exists(&self.collection)
539 .await
540 .map_err(|e| {
541 EmbeddingRegistryError::VectorStore(VectorStoreError::Collection(e.to_string()))
542 })?
543 {
544 self.ops
545 .delete_collection(&self.collection)
546 .await
547 .map_err(|e| {
548 EmbeddingRegistryError::VectorStore(VectorStoreError::Collection(e.to_string()))
549 })?;
550 tracing::info!(
551 collection = &self.collection,
552 "deleted collection for recreation"
553 );
554 }
555 self.ensure_collection(embed_fn).await
556 }
557}
558
559fn build_work_set<'a, T: Embeddable>(
565 current: &HashMap<String, (String, &'a T)>,
566 existing: &HashMap<String, HashMap<String, String>>,
567 model_changed: bool,
568 stats: &mut SyncStats,
569 hashes: &mut HashMap<String, String>,
570) -> Vec<(String, String, &'a T)> {
571 let mut work_items: Vec<(String, String, &'a T)> = Vec::new();
572 for (key, (hash, item)) in current {
573 let needs_update = if let Some(stored) = existing.get(key) {
574 model_changed || stored.get("content_hash").is_some_and(|h| h != hash)
575 } else {
576 true
577 };
578
579 if needs_update {
580 work_items.push((key.clone(), hash.clone(), *item));
581 } else {
582 stats.unchanged += 1;
583 hashes.insert(key.clone(), hash.clone());
584 }
585 }
586 work_items
587}
588
589#[tracing::instrument(
600 name = "memory.embed_registry.embed_and_collect_points",
601 skip_all,
602 err,
603 level = "debug"
604)]
605#[allow(clippy::too_many_arguments)]
606async fn embed_and_collect_points(
607 work_items: Vec<(String, String, EmbedFuture, String, serde_json::Value)>,
608 on_progress: Option<Box<dyn Fn(usize, usize) + Send>>,
609 existing: &HashMap<String, HashMap<String, String>>,
610 embedding_model: &str,
611 concurrency: usize,
612 stats: &mut SyncStats,
613 hashes: &mut HashMap<String, String>,
614) -> Result<Vec<PointStruct>, EmbeddingRegistryError> {
615 let total = work_items.len();
616 let concurrency = concurrency.max(1);
618
619 let mut stream =
621 futures::stream::iter(work_items.into_iter().map(
622 |(key, hash, fut, point_id, payload)| async move {
623 (key, hash, fut.await, point_id, payload)
624 },
625 ))
626 .buffer_unordered(concurrency);
627
628 let mut points_to_upsert = Vec::new();
629 let mut completed: usize = 0;
630 while let Some((key, hash, result, point_id, mut payload)) = stream.next().await {
631 let vector = match result {
632 Ok(v) => v,
633 Err(e) => {
634 tracing::warn!("failed to embed item '{key}': {e:#}");
635 continue;
636 }
637 };
638
639 if let Some(obj) = payload.as_object_mut() {
640 obj.insert(
641 "content_hash".into(),
642 serde_json::Value::String(hash.clone()),
643 );
644 obj.insert(
645 "embedding_model".into(),
646 serde_json::Value::String(embedding_model.to_owned()),
647 );
648 }
649 let payload_map = QdrantOps::json_to_payload(payload)?;
650
651 points_to_upsert.push(PointStruct::new(point_id, vector, payload_map));
652
653 if existing.contains_key(&key) {
654 stats.updated += 1;
655 } else {
656 stats.added += 1;
657 }
658 hashes.insert(key, hash);
659
660 completed += 1;
661 if let Some(ref cb) = on_progress {
662 cb(completed, total);
663 }
664 }
665 Ok(points_to_upsert)
666}
667
668#[cfg(test)]
669mod tests {
670 use super::*;
671
672 #[test]
673 fn normalize_no_suffix() {
674 assert_eq!(normalize_model_name("foo"), "foo");
675 }
676
677 #[test]
678 fn normalize_strips_latest() {
679 assert_eq!(normalize_model_name("foo:latest"), "foo");
680 }
681
682 #[test]
683 fn normalize_other_tag_unchanged() {
684 assert_eq!(normalize_model_name("foo:v2"), "foo:v2");
685 }
686
687 struct TestItem {
688 k: String,
689 text: String,
690 }
691
692 impl Embeddable for TestItem {
693 fn key(&self) -> &str {
694 &self.k
695 }
696
697 fn content_hash(&self) -> String {
698 let mut hasher = blake3::Hasher::new();
699 hasher.update(self.text.as_bytes());
700 hasher.finalize().to_hex().to_string()
701 }
702
703 fn embed_text(&self) -> &str {
704 &self.text
705 }
706
707 fn to_payload(&self) -> serde_json::Value {
708 serde_json::json!({"key": self.k, "text": self.text})
709 }
710 }
711
712 fn make_item(k: &str, text: &str) -> TestItem {
713 TestItem {
714 k: k.into(),
715 text: text.into(),
716 }
717 }
718
719 #[test]
720 fn registry_new_valid_url() {
721 let ops = QdrantOps::new("http://localhost:6334", None).unwrap();
722 let ns = uuid::Uuid::from_bytes([0u8; 16]);
723 let reg = EmbeddingRegistry::new(ops, "test_col", ns);
724 let dbg = format!("{reg:?}");
725 assert!(dbg.contains("EmbeddingRegistry"));
726 assert!(dbg.contains("test_col"));
727 }
728
729 #[test]
730 fn embeddable_content_hash_deterministic() {
731 let item = make_item("key", "some text");
732 assert_eq!(item.content_hash(), item.content_hash());
733 }
734
735 #[test]
736 fn embeddable_content_hash_changes() {
737 let a = make_item("key", "text a");
738 let b = make_item("key", "text b");
739 assert_ne!(a.content_hash(), b.content_hash());
740 }
741
742 #[test]
743 fn embeddable_payload_contains_key() {
744 let item = make_item("my-key", "desc");
745 let payload = item.to_payload();
746 assert_eq!(payload["key"], "my-key");
747 }
748
749 #[test]
750 fn sync_stats_default() {
751 let s = SyncStats::default();
752 assert_eq!(s.added, 0);
753 assert_eq!(s.updated, 0);
754 assert_eq!(s.removed, 0);
755 assert_eq!(s.unchanged, 0);
756 }
757
758 #[test]
759 fn sync_stats_debug() {
760 let s = SyncStats {
761 added: 1,
762 updated: 2,
763 removed: 3,
764 unchanged: 4,
765 };
766 let dbg = format!("{s:?}");
767 assert!(dbg.contains("added"));
768 }
769
770 #[tokio::test]
771 async fn search_raw_embed_fail_returns_error() {
772 let ops = QdrantOps::new("http://localhost:6334", None).unwrap();
773 let ns = uuid::Uuid::from_bytes([0u8; 16]);
774 let reg = EmbeddingRegistry::new(ops, "test", ns);
775 let embed_fn = |_: &str| -> EmbedFuture {
776 Box::pin(async {
777 Err(Box::new(std::io::Error::other("fail"))
778 as Box<dyn std::error::Error + Send + Sync>)
779 })
780 };
781 let result = reg.search_raw("query", 5, embed_fn).await;
782 assert!(result.is_err());
783 }
784
785 #[tokio::test]
791 async fn search_raw_dimension_mismatch_returns_error() {
792 let ops = QdrantOps::new("http://localhost:6334", None).unwrap();
793 let ns = uuid::Uuid::from_bytes([0u8; 16]);
794 let reg = EmbeddingRegistry::new(ops, "test_dim_guard", ns);
795
796 reg.set_cached_dim(4).await;
798
799 let embed_fn = |_: &str| -> EmbedFuture { Box::pin(async { Ok(vec![1.0_f32, 0.0]) }) };
801 let result = reg.search_raw("query", 5, embed_fn).await;
802 assert!(
803 matches!(result, Err(EmbeddingRegistryError::DimensionProbe(_))),
804 "expected DimensionProbe error on dimension mismatch, got: {result:?}"
805 );
806 }
807
808 #[tokio::test]
813 async fn search_raw_matching_dimension_passes_guard() {
814 let ops = QdrantOps::new("http://127.0.0.1:1", None).unwrap(); let ns = uuid::Uuid::from_bytes([0u8; 16]);
816 let reg = EmbeddingRegistry::new(ops, "test_dim_pass", ns);
817
818 reg.set_cached_dim(2).await;
820
821 let embed_fn = |_: &str| -> EmbedFuture { Box::pin(async { Ok(vec![1.0_f32, 0.0]) }) };
823 let result = reg.search_raw("query", 5, embed_fn).await;
824 assert!(
826 !matches!(result, Err(EmbeddingRegistryError::DimensionProbe(_))),
827 "guard must not fire when dimensions match"
828 );
829 }
830
831 #[tokio::test]
840 #[tracing_test::traced_test]
841 async fn search_raw_oversized_limit_does_not_panic() {
842 let ops = QdrantOps::new("http://127.0.0.1:1", None).unwrap(); let ns = uuid::Uuid::from_bytes([0u8; 16]);
844 let reg = EmbeddingRegistry::new(ops, "test_oversized_limit", ns);
845 reg.set_cached_dim(2).await;
846
847 let embed_fn = |_: &str| -> EmbedFuture { Box::pin(async { Ok(vec![1.0_f32, 0.0]) }) };
848 let result = reg.search_raw("query", usize::MAX, embed_fn).await;
849 assert!(
850 !matches!(result, Err(EmbeddingRegistryError::DimensionProbe(_))),
851 "clamp must not corrupt the dimension guard"
852 );
853 assert!(
854 logs_contain("requested search limit exceeds MAX_SEARCH_LIMIT"),
855 "expected the one-shot clamp warning to fire for an oversized limit"
856 );
857 }
858
859 #[tokio::test]
860 async fn get_vectors_by_keys_empty_input_short_circuits() {
861 let ops = QdrantOps::new("http://127.0.0.1:1", None).unwrap();
863 let ns = uuid::Uuid::from_bytes([0u8; 16]);
864 let reg = EmbeddingRegistry::new(ops, "test_empty_keys", ns);
865 let result = reg.get_vectors_by_keys(&[]).await.unwrap();
866 assert!(result.is_empty());
867 }
868
869 #[tokio::test]
870 async fn get_vectors_by_keys_unreachable_qdrant_errors() {
871 let ops = QdrantOps::new("http://127.0.0.1:1", None).unwrap();
872 let ns = uuid::Uuid::from_bytes([0u8; 16]);
873 let reg = EmbeddingRegistry::new(ops, "test_vec_fetch", ns);
874 let keys = vec!["skill-a".to_string(), "skill-b".to_string()];
875 let result = reg.get_vectors_by_keys(&keys).await;
876 assert!(
877 matches!(result, Err(EmbeddingRegistryError::VectorStore(_))),
878 "expected VectorStore error, got: {result:?}"
879 );
880 }
881
882 #[tokio::test]
883 async fn sync_with_unreachable_qdrant_fails() {
884 let ops = QdrantOps::new("http://127.0.0.1:1", None).unwrap();
885 let ns = uuid::Uuid::from_bytes([0u8; 16]);
886 let mut reg = EmbeddingRegistry::new(ops, "test", ns);
887 let items = vec![make_item("k", "text")];
888 let embed_fn = |_: &str| -> EmbedFuture { Box::pin(async { Ok(vec![0.1_f32, 0.2]) }) };
889 let result = reg.sync(&items, "model", embed_fn, None).await;
890 assert!(result.is_err());
891 }
892
893 fn make_existing(model: &str) -> HashMap<String, HashMap<String, String>> {
896 let mut point = HashMap::new();
897 point.insert("embedding_model".to_owned(), model.to_owned());
898 let mut map = HashMap::new();
899 map.insert("k1".to_owned(), point);
900 map
901 }
902
903 #[test]
904 fn model_has_changed_latest_vs_bare_is_false() {
905 let existing = make_existing("nomic-embed-text-v2-moe:latest");
907 assert!(!model_has_changed(&existing, "nomic-embed-text-v2-moe"));
908 }
909
910 #[test]
911 fn model_has_changed_same_model_is_false() {
912 let existing = make_existing("nomic-embed-text-v2-moe");
913 assert!(!model_has_changed(&existing, "nomic-embed-text-v2-moe"));
914 }
915
916 #[test]
917 fn model_has_changed_different_model_is_true() {
918 let existing = make_existing("all-minilm");
919 assert!(model_has_changed(&existing, "nomic-embed-text-v2-moe"));
920 }
921
922 #[test]
923 fn model_has_changed_empty_existing_is_false() {
924 assert!(!model_has_changed(&HashMap::new(), "any-model"));
925 }
926
927 #[test]
928 fn model_has_changed_absent_field_with_config_model_is_true() {
929 let mut point = HashMap::new();
931 point.insert("content_hash".to_owned(), "abc".to_owned());
932 let mut map = HashMap::new();
933 map.insert("k1".to_owned(), point);
934 assert!(model_has_changed(&map, "nomic-embed-text-v2-moe"));
935 }
936
937 #[test]
938 fn model_has_changed_absent_field_with_empty_config_model_is_false() {
939 let mut point = HashMap::new();
940 point.insert("content_hash".to_owned(), "abc".to_owned());
941 let mut map = HashMap::new();
942 map.insert("k1".to_owned(), point);
943 assert!(!model_has_changed(&map, ""));
944 }
945
946 #[test]
949 fn concurrency_zero_clamped_to_one() {
950 let ops = QdrantOps::new("http://localhost:6334", None).unwrap();
951 let ns = uuid::Uuid::from_bytes([0u8; 16]);
952 let mut reg = EmbeddingRegistry::new(ops, "test", ns);
953 reg.concurrency = 0;
954 assert_eq!(reg.concurrency.max(1), 1);
957 }
958
959 #[tokio::test]
963 #[ignore = "requires Docker for Qdrant"]
964 async fn on_progress_called_once_per_successful_embed() {
965 use std::sync::{
966 Arc,
967 atomic::{AtomicUsize, Ordering},
968 };
969 use testcontainers::GenericImage;
970 use testcontainers::core::{ContainerPort, WaitFor};
971 use testcontainers::runners::AsyncRunner;
972
973 let container = GenericImage::new("qdrant/qdrant", "v1.16.0")
974 .with_wait_for(WaitFor::message_on_stdout("gRPC listening"))
975 .with_wait_for(WaitFor::seconds(1))
976 .with_exposed_port(ContainerPort::Tcp(6334))
977 .start()
978 .await
979 .unwrap();
980 let port = container.get_host_port_ipv4(6334).await.unwrap();
981 let ops = QdrantOps::new(&format!("http://127.0.0.1:{port}"), None).unwrap();
982 let ns = uuid::Uuid::new_v4();
983 let mut reg = EmbeddingRegistry::new(ops, "test_progress", ns);
984
985 let items = [
986 make_item("a", "alpha"),
987 make_item("b", "beta"),
988 make_item("c", "gamma"),
989 ];
990 let call_count = Arc::new(AtomicUsize::new(0));
991 let last_done = Arc::new(AtomicUsize::new(0));
992 let last_total = Arc::new(AtomicUsize::new(0));
993 let cc = Arc::clone(&call_count);
994 let ld = Arc::clone(&last_done);
995 let lt = Arc::clone(&last_total);
996
997 let embed_fn =
998 |_: &str| -> EmbedFuture { Box::pin(async { Ok(vec![0.1_f32, 0.2, 0.3, 0.4]) }) };
999 let on_progress: Option<Box<dyn Fn(usize, usize) + Send>> =
1000 Some(Box::new(move |completed, total| {
1001 cc.fetch_add(1, Ordering::SeqCst);
1002 ld.store(completed, Ordering::SeqCst);
1003 lt.store(total, Ordering::SeqCst);
1004 }));
1005
1006 let stats = reg
1007 .sync(&items, "test-model", embed_fn, on_progress)
1008 .await
1009 .unwrap();
1010 let n = stats.added + stats.updated;
1011
1012 assert_eq!(
1013 call_count.load(Ordering::SeqCst),
1014 n,
1015 "on_progress call count"
1016 );
1017 assert_eq!(last_done.load(Ordering::SeqCst), n, "last completed");
1018 assert_eq!(last_total.load(Ordering::SeqCst), n, "total");
1019 }
1020
1021 #[tokio::test]
1023 #[ignore = "requires Docker for Qdrant"]
1024 async fn partial_embed_failure_skips_failed_item() {
1025 use testcontainers::GenericImage;
1026 use testcontainers::core::{ContainerPort, WaitFor};
1027 use testcontainers::runners::AsyncRunner;
1028
1029 let container = GenericImage::new("qdrant/qdrant", "v1.16.0")
1030 .with_wait_for(WaitFor::message_on_stdout("gRPC listening"))
1031 .with_wait_for(WaitFor::seconds(1))
1032 .with_exposed_port(ContainerPort::Tcp(6334))
1033 .start()
1034 .await
1035 .unwrap();
1036 let port = container.get_host_port_ipv4(6334).await.unwrap();
1037 let ops = QdrantOps::new(&format!("http://127.0.0.1:{port}"), None).unwrap();
1038 let ns = uuid::Uuid::new_v4();
1039 let mut reg = EmbeddingRegistry::new(ops, "test_partial", ns);
1040
1041 let items = [
1043 make_item("ok1", "ok text"),
1044 make_item("fail", "fail text"),
1045 make_item("ok2", "ok text 2"),
1046 ];
1047
1048 let embed_fn = |text: &str| -> EmbedFuture {
1049 if text.contains("fail") {
1050 Box::pin(async {
1051 Err(Box::new(std::io::Error::other("injected failure"))
1052 as Box<dyn std::error::Error + Send + Sync>)
1053 })
1054 } else {
1055 Box::pin(async { Ok(vec![0.1_f32, 0.2, 0.3, 0.4]) })
1056 }
1057 };
1058
1059 let stats = reg
1061 .sync(&items, "test-model", embed_fn, None)
1062 .await
1063 .unwrap();
1064 assert_eq!(
1065 stats.added, 2,
1066 "two items should be upserted, failed one skipped"
1067 );
1068 }
1069
1070 #[tokio::test]
1076 #[ignore = "requires Docker for Qdrant"]
1077 async fn search_raw_dimension_mismatch_returns_error_live() {
1078 use testcontainers::GenericImage;
1079 use testcontainers::core::{ContainerPort, WaitFor};
1080 use testcontainers::runners::AsyncRunner;
1081
1082 let container = GenericImage::new("qdrant/qdrant", "v1.16.0")
1083 .with_wait_for(WaitFor::message_on_stdout("gRPC listening"))
1084 .with_wait_for(WaitFor::seconds(1))
1085 .with_exposed_port(ContainerPort::Tcp(6334))
1086 .start()
1087 .await
1088 .unwrap();
1089 let port = container.get_host_port_ipv4(6334).await.unwrap();
1090 let ops = QdrantOps::new(&format!("http://127.0.0.1:{port}"), None).unwrap();
1091 let ns = uuid::Uuid::new_v4();
1092 let mut reg = EmbeddingRegistry::new(ops, "test_dim_guard_live", ns);
1093
1094 let items = [make_item("a", "alpha")];
1096 let embed_fn_4d =
1097 |_: &str| -> EmbedFuture { Box::pin(async { Ok(vec![1.0_f32, 0.0, 0.0, 0.0]) }) };
1098 reg.sync(&items, "model-4d", embed_fn_4d, None)
1099 .await
1100 .unwrap();
1101
1102 let embed_fn_2d = |_: &str| -> EmbedFuture { Box::pin(async { Ok(vec![1.0_f32, 0.0]) }) };
1104 let result = reg.search_raw("query", 5, embed_fn_2d).await;
1105 assert!(
1106 matches!(result, Err(EmbeddingRegistryError::DimensionProbe(_))),
1107 "dimension mismatch must return DimensionProbe error, not silent near-zero scores; got: {result:?}"
1108 );
1109 }
1110}