1#![deny(missing_docs)]
4
5pub mod catalog;
7pub mod catalog_api;
8mod cluster_state;
9pub mod columnar_api;
10mod dataframe_api;
11pub mod options;
12pub mod owned_session;
13pub mod owned_sql;
14mod sql_api;
15mod txn_manager;
16
17pub use crate::catalog::{CachedTableInfo, Catalog};
18pub use crate::catalog_api::{
19 CatalogInfo, CatalogManifestCatalog, CatalogManifestColumn, CatalogManifestCompression,
20 CatalogManifestDataSourceFormat, CatalogManifestDataType, CatalogManifestDelta,
21 CatalogManifestIndex, CatalogManifestIndexMethod, CatalogManifestNamespace,
22 CatalogManifestRowIdMode, CatalogManifestStorage, CatalogManifestStorageType,
23 CatalogManifestTable, CatalogManifestTableType, CatalogManifestVectorMetric, ColumnDefinition,
24 ColumnInfo, CreateCatalogRequest, CreateNamespaceRequest, CreateTableRequest, IndexInfo,
25 NamespaceInfo, StorageInfo, TableInfo, CATALOG_MANIFEST_DELTA_FORMAT,
26};
27pub use crate::columnar_api::{
28 ColumnarIndexInfo, ColumnarIndexType, ColumnarRowIterator, EmbeddedConfig, StorageMode,
29};
30pub use crate::options::DatabaseOptions;
31pub use crate::owned_session::{EmbeddedOwnedSessionFactory, OwnedEmbeddedTransaction};
32pub use crate::owned_sql::{OwnedSqlRowOutcome, OwnedSqlStreamPlan};
33pub use crate::sql_api::{SqlStreamingResult, StreamingQueryResult, StreamingRows};
34pub use crate::txn_manager::{TransactionInfo, TransactionManager};
35pub use alopex_dataframe::{DataFrame, JoinKeys, JoinType, SortOptions};
36pub use alopex_sql::{DataSourceFormat, TableType};
37pub type SqlResult = alopex_sql::SqlResult;
39pub use alopex_core::kv::{OwnedReadOptions, OwnedReadSession, OwnedTransactionSession};
40use alopex_core::vector::hnsw::{HnswTransactionState, SearchStats as HnswSearchStats};
41use alopex_core::{
42 columnar::{
43 kvs_bridge::ColumnarKvsBridge, memory::InMemorySegmentStore, segment_v2::SegmentConfigV2,
44 },
45 kv::any::AnyKVTransaction,
46 kv::memory::MemoryKV,
47 kv::{AnyKV, RangeChangeJournalCapability},
48 validate_dimensions, HnswIndex, KVStore, KVTransaction, Key, LargeValueKind, LargeValueMeta,
49 LargeValueReader, LargeValueWriter, StorageFactory, VectorType, DEFAULT_CHUNK_SIZE,
50};
51pub use alopex_core::{HnswConfig, HnswSearchResult, HnswStats, MemoryStats, Metric, TxnMode};
52pub use alopex_sql::executor::QueryRowIterator;
54use alopex_sql::storage::LocalRangeChangeJournal;
55use std::collections::HashMap;
56use std::convert::TryInto;
57use std::fs;
58use std::path::{Path, PathBuf};
59use std::result;
60use std::sync::atomic::{AtomicU64, Ordering};
61use std::sync::{Arc, RwLock};
62
63pub type Result<T> = result::Result<T, Error>;
65
66#[derive(Debug, thiserror::Error)]
68pub enum Error {
69 #[error("core error: {0}")]
71 Core(#[from] alopex_core::Error),
72 #[error("{0}")]
75 ReadAt(#[from] alopex_core::ReadAtError),
76 #[error("{0}")]
78 Sql(#[from] alopex_sql::SqlError),
79 #[error("{0}")]
81 DataFrame(#[from] alopex_dataframe::DataFrameError),
82 #[error("transaction is completed")]
84 TxnCompleted,
85 #[error("カタログが見つかりません: {0}")]
87 CatalogNotFound(String),
88 #[error("カタログは既に存在します: {0}")]
90 CatalogAlreadyExists(String),
91 #[error("カタログが空ではありません: {0}")]
93 CatalogNotEmpty(String),
94 #[error("ネームスペースが見つかりません: {0}.{1}")]
96 NamespaceNotFound(String, String),
97 #[error("ネームスペースは既に存在します: {0}.{1}")]
99 NamespaceAlreadyExists(String, String),
100 #[error("ネームスペースが空ではありません: {0}.{1}")]
102 NamespaceNotEmpty(String, String),
103 #[error("table not found: {0}")]
105 TableNotFound(String),
106 #[error("テーブルは既に存在します: {0}")]
108 TableAlreadyExists(String),
109 #[error("インデックスが見つかりません: {0}")]
111 IndexNotFound(String),
112 #[error("default オブジェクトは削除できません: {0}")]
114 CannotDeleteDefault(String),
115 #[error("managed テーブルにはスキーマが必要です")]
117 SchemaRequired,
118 #[error("external テーブルには storage_root が必要です")]
120 StorageRootRequired,
121 #[error("トランザクションは読み取り専用です")]
123 TxnReadOnly,
124 #[error("invalid transaction id: {0}")]
126 InvalidTransactionId(String),
127 #[error("not in in-memory columnar mode")]
129 NotInMemoryMode,
130 #[error("unsupported data source format: {0}")]
132 UnsupportedDataSourceFormat(String),
133 #[error("catalog lock poisoned")]
135 CatalogLockPoisoned,
136 #[error("cluster state lock poisoned")]
138 ClusterStateLockPoisoned,
139}
140
141impl Error {
142 pub fn sql_error_code(&self) -> Option<&'static str> {
144 match self {
145 Self::Sql(err) => Some(err.code()),
146 _ => None,
147 }
148 }
149}
150
151pub struct Database {
153 pub(crate) store: Arc<AnyKV>,
155 pub(crate) sql_catalog: Arc<RwLock<alopex_sql::catalog::PersistentCatalog<AnyKV>>>,
156 pub(crate) hnsw_cache: RwLock<HashMap<String, Arc<HnswIndex>>>,
157 pub(crate) vector_cache: RwLock<Option<HashMap<Key, CachedVector>>>,
158 pub(crate) table_info_cache: RwLock<HashMap<String, CachedTableInfo>>,
160 pub(crate) table_info_cache_epoch: AtomicU64,
162 pub(crate) columnar_mode: StorageMode,
163 pub(crate) columnar_bridge: ColumnarKvsBridge,
164 pub(crate) columnar_memory: Option<InMemorySegmentStore>,
165 pub(crate) segment_config: SegmentConfigV2,
166 pub(crate) cluster_state: RwLock<cluster_state::EmbeddedClusterState>,
167}
168
169pub(crate) fn disk_data_dir_path(path: &Path) -> std::path::PathBuf {
170 if path.extension().is_some_and(|e| e == "alopex") {
171 path.with_extension("alopex.d")
174 } else {
175 path.to_path_buf()
176 }
177}
178
179#[cfg(not(target_arch = "wasm32"))]
180fn read_file_version_from_storage(path: &Path) -> alopex_core::storage::format::FileVersion {
181 use alopex_core::storage::format::{FileHeader, FileVersion, HEADER_SIZE};
182 use std::io::Read;
183
184 let Some(file_path) = resolve_format_file_path(path) else {
185 return FileVersion::CURRENT;
186 };
187
188 let mut header_bytes = [0u8; HEADER_SIZE];
189 let Ok(mut file) = fs::File::open(file_path) else {
190 return FileVersion::CURRENT;
191 };
192 if file.read_exact(&mut header_bytes).is_err() {
193 return FileVersion::CURRENT;
194 }
195 match FileHeader::from_bytes(&header_bytes) {
196 Ok(header) => header.version,
197 Err(_) => FileVersion::CURRENT,
198 }
199}
200
201#[cfg(target_arch = "wasm32")]
202fn read_file_version_from_storage(_path: &Path) -> alopex_core::storage::format::FileVersion {
203 alopex_core::storage::format::FileVersion::CURRENT
204}
205
206#[cfg(not(target_arch = "wasm32"))]
207fn resolve_format_file_path(path: &Path) -> Option<PathBuf> {
208 if path.is_file() {
209 return Some(path.to_path_buf());
210 }
211
212 if path.is_dir() {
213 if let Some(ext) = path.extension() {
214 if ext == "d" {
215 let candidate = path.with_extension("");
216 if candidate.is_file() {
217 return Some(candidate);
218 }
219 }
220 }
221
222 if let Ok(entries) = fs::read_dir(path) {
223 for entry in entries.flatten() {
224 let entry_path = entry.path();
225 if entry_path.extension().is_some_and(|ext| ext == "alopex") && entry_path.is_file()
226 {
227 return Some(entry_path);
228 }
229 }
230 }
231 }
232
233 None
234}
235
236impl Database {
237 pub fn open(path: &Path) -> Result<Self> {
239 let data_dir = disk_data_dir_path(path);
240 let store = StorageFactory::create(alopex_core::StorageMode::Disk {
241 path: data_dir,
242 config: None,
243 })
244 .map_err(Error::Core)?;
245 let mut db = Self::init(store, StorageMode::Disk, None, SegmentConfigV2::default());
246 db.load_sql_catalog()?;
247 Ok(db)
248 }
249
250 pub fn new() -> Self {
252 let store = AnyKV::Memory(MemoryKV::new());
253 Self::init(
254 store,
255 StorageMode::InMemory,
256 None,
257 SegmentConfigV2::default(),
258 )
259 }
260
261 pub fn open_in_memory() -> Result<Self> {
263 Self::open_in_memory_with_options(DatabaseOptions::in_memory())
264 }
265
266 pub fn open_in_memory_with_options(opts: DatabaseOptions) -> Result<Self> {
268 if !opts.memory_mode() {
269 return Err(Error::Core(alopex_core::Error::InvalidFormat(
270 "memory_mode must be enabled for in-memory open".into(),
271 )));
272 }
273 let store = StorageFactory::create(opts.to_storage_mode(None)).map_err(Error::Core)?;
274 let mut db = Self::init(
275 store,
276 StorageMode::InMemory,
277 opts.memory_limit(),
278 SegmentConfigV2::default(),
279 );
280 db.load_sql_catalog()?;
281 Ok(db)
282 }
283
284 pub fn open_with_uri(uri: &str) -> Result<Self> {
300 if uri.starts_with("s3://") {
302 #[cfg(feature = "s3")]
303 {
304 return Self::open_s3(uri);
305 }
306 #[cfg(not(feature = "s3"))]
307 {
308 return Err(Error::Core(alopex_core::Error::InvalidFormat(
309 "S3 support requires the 's3' feature".into(),
310 )));
311 }
312 }
313
314 let path = if let Some(stripped) = uri.strip_prefix("file://") {
316 stripped
317 } else {
318 uri
319 };
320
321 Self::open(Path::new(path))
322 }
323
324 #[cfg(feature = "s3")]
343 pub fn open_s3(uri: &str) -> Result<Self> {
344 let s3_config = alopex_core::S3Config::from_uri(uri).map_err(Error::Core)?;
345 let store = StorageFactory::create(alopex_core::StorageMode::S3 { config: s3_config })
346 .map_err(Error::Core)?;
347 let mut db = Self::init(store, StorageMode::Disk, None, SegmentConfigV2::default());
348 db.load_sql_catalog()?;
349 Ok(db)
350 }
351
352 pub(crate) fn init(
353 store: AnyKV,
354 columnar_mode: StorageMode,
355 memory_limit: Option<usize>,
356 segment_config: SegmentConfigV2,
357 ) -> Self {
358 let store = Arc::new(store);
359 let sql_catalog = Arc::new(RwLock::new(alopex_sql::catalog::PersistentCatalog::new(
360 store.clone(),
361 )));
362 let columnar_bridge = ColumnarKvsBridge::new(store.clone());
363 let columnar_memory = if matches!(columnar_mode, StorageMode::InMemory) {
364 Some(InMemorySegmentStore::new(memory_limit.map(|v| v as u64)))
365 } else {
366 None
367 };
368
369 Self {
370 store,
371 sql_catalog,
372 hnsw_cache: RwLock::new(HashMap::new()),
373 vector_cache: RwLock::new(None),
374 table_info_cache: RwLock::new(HashMap::new()),
375 table_info_cache_epoch: AtomicU64::new(0),
376 columnar_mode,
377 columnar_bridge,
378 columnar_memory,
379 segment_config,
380 cluster_state: RwLock::new(cluster_state::EmbeddedClusterState::default()),
381 }
382 }
383
384 pub fn cluster_status_snapshot(&self) -> Result<alopex_cluster::ClusterStatusSnapshot> {
386 let state = self
387 .cluster_state
388 .read()
389 .map_err(|_| Error::ClusterStateLockPoisoned)?;
390 Ok(state.status_snapshot(self.table_info_cache_epoch()))
391 }
392
393 pub fn routing_diagnostics(&self) -> Result<alopex_cluster::RoutingDiagnostics> {
395 let state = self
396 .cluster_state
397 .read()
398 .map_err(|_| Error::ClusterStateLockPoisoned)?;
399 Ok(state.routing_diagnostics(self.table_info_cache_epoch()))
400 }
401
402 pub(crate) fn record_routing<C: alopex_sql::Catalog + ?Sized>(
403 &self,
404 catalog: &C,
405 statement: &alopex_sql::Statement,
406 statement_index: usize,
407 ) {
408 let Ok(mut state) = self.cluster_state.write() else {
409 return;
410 };
411 state.record_routing(
412 catalog,
413 statement,
414 statement_index,
415 self.table_info_cache_epoch(),
416 );
417 }
418
419 fn load_sql_catalog(&mut self) -> Result<()> {
420 use alopex_sql::catalog::CatalogError;
421
422 let loaded = match alopex_sql::catalog::PersistentCatalog::load(self.store.clone()) {
423 Ok(catalog) => catalog,
424 Err(CatalogError::Kv(alopex_core::Error::NotFound)) => {
425 alopex_sql::catalog::PersistentCatalog::new(self.store.clone())
426 }
427 Err(other) => return Err(Error::Sql(other.into())),
428 };
429
430 self.sql_catalog = Arc::new(RwLock::new(loaded));
431 Ok(())
432 }
433
434 fn hnsw_cache_get(&self, name: &str) -> Option<Arc<HnswIndex>> {
435 let cache = self.hnsw_cache.read().expect("hnsw cache lock poisoned");
436 cache.get(name).cloned()
437 }
438
439 fn hnsw_cache_insert(&self, name: &str, index: HnswIndex) -> Arc<HnswIndex> {
440 let index = Arc::new(index);
441 let mut cache = self.hnsw_cache.write().expect("hnsw cache lock poisoned");
442 cache.insert(name.to_string(), Arc::clone(&index));
443 index
444 }
445
446 fn hnsw_cache_remove(&self, name: &str) {
447 let mut cache = self.hnsw_cache.write().expect("hnsw cache lock poisoned");
448 cache.remove(name);
449 }
450
451 pub fn table_info_cache_epoch(&self) -> u64 {
453 self.table_info_cache_epoch.load(Ordering::Relaxed)
454 }
455
456 pub fn get_cached_table_info(
458 &self,
459 catalog_name: &str,
460 namespace_name: &str,
461 table_name: &str,
462 ) -> Option<CachedTableInfo> {
463 let cache = self
464 .table_info_cache
465 .read()
466 .expect("table info cache lock poisoned");
467 let key = format!("{}.{}.{}", catalog_name, namespace_name, table_name);
468 cache.get(&key).cloned()
469 }
470
471 pub fn cache_table_info(
473 &self,
474 catalog_name: &str,
475 namespace_name: &str,
476 table_name: &str,
477 info: CachedTableInfo,
478 ) {
479 let mut cache = self
480 .table_info_cache
481 .write()
482 .expect("table info cache lock poisoned");
483 let key = format!("{}.{}.{}", catalog_name, namespace_name, table_name);
484 cache.insert(key, info);
485 }
486
487 pub fn invalidate_table_info_cache(&self) {
489 self.table_info_cache_epoch.fetch_add(1, Ordering::Relaxed);
490 let mut cache = self
491 .table_info_cache
492 .write()
493 .expect("table info cache lock poisoned");
494 cache.clear();
495 }
496
497 pub fn flush(&self) -> Result<()> {
506 self.store.flush().map_err(Error::Core)?;
507 self.store.converge().map_err(Error::Core)
508 }
509
510 pub fn converge(&self) -> Result<()> {
512 self.store.converge().map_err(Error::Core)
513 }
514
515 pub fn close(&self) -> Result<()> {
520 self.store.close().map_err(Error::Core)
521 }
522
523 pub fn container_path(&self) -> Option<PathBuf> {
528 self.store.container_path().map(Path::to_path_buf)
529 }
530
531 pub fn file_format_version(&self) -> alopex_core::storage::format::FileVersion {
533 use alopex_core::storage::format::FileVersion;
534
535 self.store
536 .file_format_storage_dir()
537 .map_or(FileVersion::CURRENT, read_file_version_from_storage)
538 }
539
540 pub fn memory_usage(&self) -> Option<MemoryStats> {
542 match self.store.as_ref() {
543 AnyKV::Memory(kv) => Some(kv.memory_stats()),
544 _ => None,
545 }
546 }
547
548 pub fn persist_to_disk(&self, wal_path: &Path) -> Result<()> {
555 if !matches!(self.store.as_ref(), AnyKV::Memory(_)) {
556 return Err(Error::NotInMemoryMode);
557 }
558 let data_dir = disk_data_dir_path(wal_path);
559 if wal_path.exists() || data_dir.exists() {
560 return Err(Error::Core(alopex_core::Error::PathExists(
561 wal_path.to_path_buf(),
562 )));
563 }
564
565 let tmp_dir = data_dir.with_extension("d.tmp");
569 if tmp_dir.exists() {
570 return Err(Error::Core(alopex_core::Error::PathExists(tmp_dir)));
571 }
572
573 let snapshot = self.snapshot_pairs()?;
574 let is_container_path = wal_path.extension().is_some_and(|e| e == "alopex");
575 let write_result = (|| -> Result<()> {
576 let converge = if is_container_path {
580 alopex_core::lsm::ConvergePolicy::Always {
581 container: wal_path.to_path_buf(),
582 }
583 } else {
584 alopex_core::lsm::ConvergePolicy::Never
585 };
586 let config = alopex_core::lsm::LsmKVConfig {
587 converge,
588 prune_sidecar_on_drop: false,
591 ..alopex_core::lsm::LsmKVConfig::default()
592 };
593
594 let store = StorageFactory::create(alopex_core::StorageMode::Disk {
595 path: tmp_dir.clone(),
596 config: Some(config),
597 })
598 .map_err(Error::Core)?;
599
600 let mut txn = store.begin(TxnMode::ReadWrite).map_err(Error::Core)?;
601 for (key, value) in snapshot {
602 txn.put(key, value).map_err(Error::Core)?;
603 }
604 txn.commit_self().map_err(Error::Core)?;
605 store.converge().map_err(Error::Core)?;
606
607 Ok(())
608 })();
609
610 if let Err(e) = write_result {
611 let _ = fs::remove_dir_all(&tmp_dir);
612 let _ = fs::remove_file(wal_path);
613 return Err(e);
614 }
615
616 if let Err(e) = fs::rename(&tmp_dir, &data_dir) {
617 let _ = fs::remove_dir_all(&tmp_dir);
618 let _ = fs::remove_file(wal_path);
619 return Err(Error::Core(e.into()));
620 }
621 Ok(())
622 }
623
624 pub fn clone_to_memory(&self) -> Result<Self> {
626 let snapshot = self.snapshot_pairs()?;
627 let cloned = Database::open_in_memory()?;
628 if snapshot.is_empty() {
629 return Ok(cloned);
630 }
631
632 let mut txn = cloned.begin(TxnMode::ReadWrite)?;
633 for (key, value) in snapshot {
634 txn.put(&key, &value)?;
635 }
636 txn.commit()?;
637 Ok(cloned)
638 }
639
640 pub fn clear(&self) -> Result<()> {
642 let keys: Vec<Key> = self.snapshot_pairs()?.into_iter().map(|(k, _)| k).collect();
643 if keys.is_empty() {
644 return Ok(());
645 }
646 let mut txn = self.begin(TxnMode::ReadWrite)?;
647 for key in keys {
648 txn.delete(&key)?;
649 }
650 txn.commit()
651 }
652
653 pub fn set_memory_limit(&self, bytes: Option<usize>) {
655 if let AnyKV::Memory(kv) = self.store.as_ref() {
656 kv.txn_manager().set_memory_limit(bytes);
657 }
658 }
659
660 pub fn snapshot(&self) -> Vec<(Key, Vec<u8>)> {
662 self.snapshot_pairs().unwrap_or_default()
663 }
664
665 fn snapshot_pairs(&self) -> Result<Vec<(Key, Vec<u8>)>> {
666 let mut txn = self.store.begin(TxnMode::ReadOnly).map_err(Error::Core)?;
667 let pairs: Vec<(Key, Vec<u8>)> = txn.scan_prefix(b"").map_err(Error::Core)?.collect();
668 txn.commit_self().map_err(Error::Core)?;
669 Ok(pairs)
670 }
671
672 pub fn create_hnsw_index(&self, name: &str, config: HnswConfig) -> Result<()> {
674 let mut txn = self.store.begin(TxnMode::ReadWrite).map_err(Error::Core)?;
675 let index = HnswIndex::create(name, config).map_err(Error::Core)?;
676 index.save(&mut txn).map_err(Error::Core)?;
677 txn.commit_self().map_err(Error::Core)?;
678 self.hnsw_cache_insert(name, index);
679 Ok(())
680 }
681
682 pub fn drop_hnsw_index(&self, name: &str) -> Result<()> {
684 let mut txn = self.store.begin(TxnMode::ReadWrite).map_err(Error::Core)?;
685 let index = HnswIndex::load(name, &mut txn).map_err(Error::Core)?;
686 index.drop(&mut txn).map_err(Error::Core)?;
687 txn.commit_self().map_err(Error::Core)?;
688 self.hnsw_cache_remove(name);
689 Ok(())
690 }
691
692 pub fn get_hnsw_stats(&self, name: &str) -> Result<HnswStats> {
694 if let Some(index) = self.hnsw_cache_get(name) {
695 return Ok(index.stats());
696 }
697 let mut txn = self.store.begin(TxnMode::ReadOnly).map_err(Error::Core)?;
698 let index = HnswIndex::load(name, &mut txn).map_err(Error::Core)?;
699 let stats = index.stats();
700 self.hnsw_cache_insert(name, index);
701 Ok(stats)
702 }
703
704 pub fn compact_hnsw_index(&self, name: &str) -> Result<alopex_core::vector::CompactionResult> {
706 let mut txn = self.store.begin(TxnMode::ReadWrite).map_err(Error::Core)?;
707 let mut index = HnswIndex::load(name, &mut txn).map_err(Error::Core)?;
708 let result = index.compact().map_err(Error::Core)?;
709 index.save(&mut txn).map_err(Error::Core)?;
710 txn.commit_self().map_err(Error::Core)?;
711 self.hnsw_cache_insert(name, index);
712 Ok(result)
713 }
714
715 pub fn search_hnsw(
717 &self,
718 name: &str,
719 query: &[f32],
720 k: usize,
721 ef_search: Option<usize>,
722 ) -> Result<(Vec<HnswSearchResult>, HnswSearchStats)> {
723 let profile = std::env::var_os("ALOPEX_PROFILE_HNSW").is_some();
724 let total_start = if profile {
725 Some(std::time::Instant::now())
726 } else {
727 None
728 };
729 if let Some(index) = self.hnsw_cache_get(name) {
730 let search_start = if profile {
731 Some(std::time::Instant::now())
732 } else {
733 None
734 };
735 let result = index.search(query, k, ef_search).map_err(Error::Core)?;
736 if let (true, Some(total_start), Some(search_start)) =
737 (profile, total_start, search_start)
738 {
739 let search_time = search_start.elapsed();
740 let total_time = total_start.elapsed();
741 eprintln!(
742 "alopex.hnsw_search cache=hit name={} k={} ef_search={:?} search_ms={:.2} total_ms={:.2}",
743 name,
744 k,
745 ef_search,
746 search_time.as_secs_f64() * 1000.0,
747 total_time.as_secs_f64() * 1000.0
748 );
749 }
750 return Ok(result);
751 }
752
753 let load_start = if profile {
754 Some(std::time::Instant::now())
755 } else {
756 None
757 };
758 let mut txn = self.store.begin(TxnMode::ReadOnly).map_err(Error::Core)?;
759 let index = HnswIndex::load(name, &mut txn).map_err(Error::Core)?;
760 let load_time = load_start.map(|start| start.elapsed());
761 let index = self.hnsw_cache_insert(name, index);
762 let search_start = if profile {
763 Some(std::time::Instant::now())
764 } else {
765 None
766 };
767 let result = index.search(query, k, ef_search).map_err(Error::Core)?;
768 if let (true, Some(total_start), Some(search_start)) = (profile, total_start, search_start)
769 {
770 let search_time = search_start.elapsed();
771 let total_time = total_start.elapsed();
772 let load_time_ms = load_time
773 .map(|elapsed| elapsed.as_secs_f64() * 1000.0)
774 .unwrap_or(0.0);
775 eprintln!(
776 "alopex.hnsw_search cache=miss name={} k={} ef_search={:?} load_ms={:.2} search_ms={:.2} total_ms={:.2}",
777 name,
778 k,
779 ef_search,
780 load_time_ms,
781 search_time.as_secs_f64() * 1000.0,
782 total_time.as_secs_f64() * 1000.0
783 );
784 }
785 Ok(result)
786 }
787
788 pub fn create_blob_writer(
790 &self,
791 path: &Path,
792 total_len: u64,
793 chunk_size: Option<u32>,
794 ) -> Result<LargeValueWriter> {
795 let meta = LargeValueMeta {
796 kind: LargeValueKind::Blob,
797 total_len,
798 chunk_size: chunk_size.unwrap_or(DEFAULT_CHUNK_SIZE),
799 };
800 LargeValueWriter::create(path, meta).map_err(Error::Core)
801 }
802
803 pub fn create_typed_writer(
805 &self,
806 path: &Path,
807 type_id: u16,
808 total_len: u64,
809 chunk_size: Option<u32>,
810 ) -> Result<LargeValueWriter> {
811 let meta = LargeValueMeta {
812 kind: LargeValueKind::Typed(type_id),
813 total_len,
814 chunk_size: chunk_size.unwrap_or(DEFAULT_CHUNK_SIZE),
815 };
816 LargeValueWriter::create(path, meta).map_err(Error::Core)
817 }
818
819 pub fn open_large_value(&self, path: &Path) -> Result<LargeValueReader> {
821 LargeValueReader::open(path).map_err(Error::Core)
822 }
823
824 pub fn begin(&self, mode: TxnMode) -> Result<Transaction<'_>> {
826 let mut txn = self.store.begin(mode).map_err(Error::Core)?;
827 let journal = if mode == TxnMode::ReadWrite
828 && self.store.range_change_journal_capability()
829 == RangeChangeJournalCapability::Supported
830 {
831 let scope = {
832 let catalog = self.sql_catalog.read().expect("catalog lock poisoned");
833 sql_api::local_journal_scope(&*catalog)
834 };
835 Some(LocalRangeChangeJournal::capture(&mut txn, scope).map_err(Error::Core)?)
836 } else {
837 None
838 };
839 Ok(Transaction {
840 inner: Some(txn),
841 db: self,
842 hnsw_indices: HashMap::new(),
843 overlay: alopex_sql::catalog::CatalogOverlay::new(),
844 vector_cache_updates: HashMap::new(),
845 vector_cache_deletes: Vec::new(),
846 vector_cache_invalidated: false,
847 catalog_modified: false,
848 journal,
849 })
850 }
851}
852
853impl Default for Database {
854 fn default() -> Self {
855 Self::new()
856 }
857}
858
859pub struct Transaction<'a> {
861 inner: Option<AnyKVTransaction<'a>>,
862 db: &'a Database,
863 hnsw_indices: HashMap<String, (HnswIndex, alopex_core::vector::hnsw::HnswTransactionState)>,
864 overlay: alopex_sql::catalog::CatalogOverlay,
865 vector_cache_updates: HashMap<Key, CachedVector>,
866 vector_cache_deletes: Vec<Key>,
867 vector_cache_invalidated: bool,
868 pub(crate) catalog_modified: bool,
870 journal: Option<LocalRangeChangeJournal>,
872}
873
874#[derive(Debug, Clone, PartialEq)]
876pub struct SearchResult {
877 pub key: Key,
879 pub metadata: Vec<u8>,
881 pub score: f32,
883}
884
885const VECTOR_INDEX_KEY: &[u8] = b"__alopex_vector_index";
886
887impl<'a> Transaction<'a> {
888 pub(crate) fn catalog_overlay(&self) -> &alopex_sql::catalog::CatalogOverlay {
889 &self.overlay
890 }
891
892 pub(crate) fn catalog_overlay_mut(&mut self) -> &mut alopex_sql::catalog::CatalogOverlay {
893 &mut self.overlay
894 }
895
896 pub(crate) fn txn_mode(&self) -> Result<TxnMode> {
897 let txn = self.inner.as_ref().ok_or(Error::TxnCompleted)?;
898 Ok(txn.mode())
899 }
900 pub fn get(&mut self, key: &[u8]) -> Result<Option<Vec<u8>>> {
902 self.inner_mut()?.get(&key.to_vec()).map_err(Error::Core)
903 }
904
905 pub fn put(&mut self, key: &[u8], value: &[u8]) -> Result<()> {
907 self.vector_cache_invalidated = true;
908 self.vector_cache_updates.clear();
909 self.vector_cache_deletes.clear();
910 self.inner_mut()?
911 .put(key.to_vec(), value.to_vec())
912 .map_err(Error::Core)
913 }
914
915 pub fn delete(&mut self, key: &[u8]) -> Result<()> {
917 self.vector_cache_deletes.push(key.to_vec());
918 self.inner_mut()?.delete(key.to_vec()).map_err(Error::Core)
919 }
920
921 pub fn scan_prefix(
925 &mut self,
926 prefix: &[u8],
927 ) -> Result<Box<dyn Iterator<Item = (Key, Vec<u8>)> + '_>> {
928 self.inner_mut()?.scan_prefix(prefix).map_err(Error::Core)
929 }
930
931 pub fn upsert_to_hnsw(
933 &mut self,
934 index_name: &str,
935 key: &[u8],
936 vector: &[f32],
937 metadata: &[u8],
938 ) -> Result<()> {
939 self.ensure_write_txn()?;
940 let (index, state) = self.hnsw_entry_mut(index_name)?;
941 index
942 .upsert_staged(key, vector, metadata, state)
943 .map_err(Error::Core)
944 }
945
946 pub fn delete_from_hnsw(&mut self, index_name: &str, key: &[u8]) -> Result<bool> {
948 self.ensure_write_txn()?;
949 let (index, state) = self.hnsw_entry_mut(index_name)?;
950 index.delete_staged(key, state).map_err(Error::Core)
951 }
952
953 pub fn upsert_vector(
957 &mut self,
958 key: &[u8],
959 metadata: &[u8],
960 vector: &[f32],
961 metric: Metric,
962 ) -> Result<()> {
963 if vector.is_empty() {
964 return Err(Error::Core(alopex_core::Error::InvalidFormat(
965 "vector cannot be empty".into(),
966 )));
967 }
968 let vt = VectorType::new(vector.len(), metric);
969 vt.validate(vector).map_err(Error::Core)?;
970
971 let payload = encode_vector_entry(vt, metadata, vector);
972 let txn = self.inner_mut()?;
973 txn.put(key.to_vec(), payload).map_err(Error::Core)?;
974
975 let mut keys = self.load_vector_index()?;
976 if !keys.iter().any(|k| k == key) {
977 keys.push(key.to_vec());
978 self.persist_vector_index(&keys)?;
979 }
980
981 let cached = cached_vector_from_entry(metric, metadata.to_vec(), vector.to_vec());
982 self.vector_cache_updates.insert(key.to_vec(), cached);
983 self.vector_cache_deletes.retain(|k| k != key);
984 Ok(())
985 }
986
987 pub fn get_vector(&mut self, key: &[u8], metric: Metric) -> Result<Option<Vec<f32>>> {
992 let txn = self.inner_mut()?;
993 let key_vec = key.to_vec();
994 let Some(raw) = txn.get(&key_vec).map_err(Error::Core)? else {
995 return Ok(None);
996 };
997 let decoded = decode_vector_entry(&raw).map_err(Error::Core)?;
998 if decoded.metric != metric {
999 return Err(Error::Core(alopex_core::Error::UnsupportedMetric {
1000 metric: metric.as_str().to_string(),
1001 }));
1002 }
1003 Ok(Some(decoded.vector))
1004 }
1005
1006 pub fn get_vectors(&mut self, keys: &[Key], metric: Metric) -> Result<Vec<Option<Vec<f32>>>> {
1014 let txn = self.inner_mut()?;
1015 let mut out = Vec::with_capacity(keys.len());
1016 for key in keys {
1017 let Some(raw) = txn.get(key).map_err(Error::Core)? else {
1018 out.push(None);
1019 continue;
1020 };
1021 let decoded = decode_vector_entry(&raw).map_err(Error::Core)?;
1022 if decoded.metric != metric {
1023 return Err(Error::Core(alopex_core::Error::UnsupportedMetric {
1024 metric: metric.as_str().to_string(),
1025 }));
1026 }
1027 out.push(Some(decoded.vector));
1028 }
1029 Ok(out)
1030 }
1031
1032 pub fn search_similar(
1037 &mut self,
1038 query_vector: &[f32],
1039 metric: Metric,
1040 top_k: usize,
1041 filter_keys: Option<&[Key]>,
1042 ) -> Result<Vec<SearchResult>> {
1043 if top_k == 0 {
1044 return Ok(Vec::new());
1045 }
1046
1047 let profile = std::env::var_os("ALOPEX_PROFILE_SEARCH_SIMILAR").is_some();
1048 let total_start = if profile {
1049 Some(std::time::Instant::now())
1050 } else {
1051 None
1052 };
1053 let query_norm_sq = query_vector.iter().map(|v| v * v).sum::<f32>();
1054 let query_norm = if matches!(metric, Metric::Cosine) {
1055 query_norm_sq.sqrt()
1056 } else {
1057 0.0
1058 };
1059 let inv_query_norm = if query_norm == 0.0 {
1060 0.0
1061 } else {
1062 1.0 / query_norm
1063 };
1064
1065 if filter_keys.is_none() && self.txn_mode()? == TxnMode::ReadOnly {
1066 let cache = self
1067 .db
1068 .vector_cache
1069 .read()
1070 .expect("vector cache lock poisoned");
1071 if let Some(cache) = cache.as_ref() {
1072 if cache.is_empty() {
1073 return Ok(Vec::new());
1074 }
1075 let keys_len = cache.len();
1076 let mut rows = Vec::with_capacity(keys_len);
1077 let mut score_time = std::time::Duration::ZERO;
1078 for (key, cached) in cache.iter() {
1079 if cached.metric != metric {
1080 return Err(Error::Core(alopex_core::Error::UnsupportedMetric {
1081 metric: metric.as_str().to_string(),
1082 }));
1083 }
1084 validate_dimensions(cached.vector.len(), query_vector.len())
1085 .map_err(Error::Core)?;
1086 let score_start = if profile {
1087 Some(std::time::Instant::now())
1088 } else {
1089 None
1090 };
1091 let dot = dot_product(query_vector, &cached.vector);
1092 let score = match metric {
1093 Metric::Cosine => {
1094 if cached.inv_norm == 0.0 || inv_query_norm == 0.0 {
1095 0.0
1096 } else {
1097 dot * cached.inv_norm * inv_query_norm
1098 }
1099 }
1100 Metric::L2 => {
1101 let dist_sq = query_norm_sq + cached.norm_sq - 2.0 * dot;
1102 -dist_sq.sqrt()
1103 }
1104 Metric::InnerProduct => dot,
1105 };
1106 if let Some(score_start) = score_start {
1107 score_time += score_start.elapsed();
1108 }
1109 rows.push(SearchResult {
1110 key: key.clone(),
1111 metadata: cached.metadata.clone(),
1112 score,
1113 });
1114 }
1115
1116 let rows_total = rows.len();
1117 let sort_start = if profile {
1118 Some(std::time::Instant::now())
1119 } else {
1120 None
1121 };
1122 if rows.len() > top_k {
1123 rows.select_nth_unstable_by(top_k - 1, |a, b| {
1124 b.score.total_cmp(&a.score).then_with(|| a.key.cmp(&b.key))
1125 });
1126 rows.truncate(top_k);
1127 }
1128 rows.sort_by(|a, b| b.score.total_cmp(&a.score).then_with(|| a.key.cmp(&b.key)));
1129 if let (true, Some(total_start), Some(sort_start)) =
1130 (profile, total_start, sort_start)
1131 {
1132 let sort_time = sort_start.elapsed();
1133 let total_time = total_start.elapsed();
1134 eprintln!(
1135 "alopex.search_similar keys={} results={} top_k={} load_keys_ms={:.2} get_ms={:.2} decode_ms={:.2} score_ms={:.2} sort_ms={:.2} total_ms={:.2}",
1136 keys_len,
1137 rows_total,
1138 top_k,
1139 0.0,
1140 0.0,
1141 0.0,
1142 score_time.as_secs_f64() * 1000.0,
1143 sort_time.as_secs_f64() * 1000.0,
1144 total_time.as_secs_f64() * 1000.0
1145 );
1146 }
1147 return Ok(rows);
1148 }
1149 }
1150
1151 let (keys, load_keys_time) = if profile {
1152 let start = std::time::Instant::now();
1153 let keys = match filter_keys {
1154 Some(keys) => keys.to_vec(),
1155 None => self.load_vector_index()?,
1156 };
1157 (keys, start.elapsed())
1158 } else {
1159 let keys = match filter_keys {
1160 Some(keys) => keys.to_vec(),
1161 None => self.load_vector_index()?,
1162 };
1163 (keys, std::time::Duration::ZERO)
1164 };
1165 if keys.is_empty() {
1166 return Ok(Vec::new());
1167 }
1168
1169 let keys_len = keys.len();
1170 let mut rows = Vec::with_capacity(keys.len());
1171 let txn = self.inner_mut()?;
1172 let mut get_time = std::time::Duration::ZERO;
1173 let mut decode_time = std::time::Duration::ZERO;
1174 let mut score_time = std::time::Duration::ZERO;
1175 if profile {
1176 for key in keys {
1177 let get_start = std::time::Instant::now();
1178 let Some(raw) = txn.get(&key).map_err(Error::Core)? else {
1179 get_time += get_start.elapsed();
1180 continue;
1181 };
1182 get_time += get_start.elapsed();
1183 let decode_start = std::time::Instant::now();
1184 let decoded = decode_vector_entry_view(&raw).map_err(Error::Core)?;
1185 decode_time += decode_start.elapsed();
1186 if decoded.metric != metric {
1187 return Err(Error::Core(alopex_core::Error::UnsupportedMetric {
1188 metric: metric.as_str().to_string(),
1189 }));
1190 }
1191 validate_dimensions(decoded.dim, query_vector.len()).map_err(Error::Core)?;
1192 let score_start = std::time::Instant::now();
1193 let score =
1194 score_from_bytes(metric, query_vector, query_norm, decoded.vector_bytes)?;
1195 score_time += score_start.elapsed();
1196 rows.push(SearchResult {
1197 key,
1198 metadata: decoded.metadata,
1199 score,
1200 });
1201 }
1202 } else {
1203 for key in keys {
1204 let Some(raw) = txn.get(&key).map_err(Error::Core)? else {
1205 continue;
1206 };
1207 let decoded = decode_vector_entry_view(&raw).map_err(Error::Core)?;
1208 if decoded.metric != metric {
1209 return Err(Error::Core(alopex_core::Error::UnsupportedMetric {
1210 metric: metric.as_str().to_string(),
1211 }));
1212 }
1213 validate_dimensions(decoded.dim, query_vector.len()).map_err(Error::Core)?;
1214 let score =
1215 score_from_bytes(metric, query_vector, query_norm, decoded.vector_bytes)?;
1216 rows.push(SearchResult {
1217 key,
1218 metadata: decoded.metadata,
1219 score,
1220 });
1221 }
1222 }
1223
1224 let rows_total = rows.len();
1225 let sort_start = if profile {
1226 Some(std::time::Instant::now())
1227 } else {
1228 None
1229 };
1230 if rows.len() > top_k {
1231 rows.select_nth_unstable_by(top_k - 1, |a, b| {
1232 b.score.total_cmp(&a.score).then_with(|| a.key.cmp(&b.key))
1233 });
1234 rows.truncate(top_k);
1235 }
1236 rows.sort_by(|a, b| b.score.total_cmp(&a.score).then_with(|| a.key.cmp(&b.key)));
1237 if let (true, Some(total_start), Some(sort_start)) = (profile, total_start, sort_start) {
1238 let sort_time = sort_start.elapsed();
1239 let total_time = total_start.elapsed();
1240 eprintln!(
1241 "alopex.search_similar keys={} results={} top_k={} load_keys_ms={:.2} get_ms={:.2} decode_ms={:.2} score_ms={:.2} sort_ms={:.2} total_ms={:.2}",
1242 keys_len,
1243 rows_total,
1244 top_k,
1245 load_keys_time.as_secs_f64() * 1000.0,
1246 get_time.as_secs_f64() * 1000.0,
1247 decode_time.as_secs_f64() * 1000.0,
1248 score_time.as_secs_f64() * 1000.0,
1249 sort_time.as_secs_f64() * 1000.0,
1250 total_time.as_secs_f64() * 1000.0
1251 );
1252 }
1253 Ok(rows)
1254 }
1255
1256 fn load_vector_index(&mut self) -> Result<Vec<Key>> {
1257 let txn = self.inner_mut()?;
1258 let Some(raw) = txn.get(&VECTOR_INDEX_KEY.to_vec()).map_err(Error::Core)? else {
1259 return Ok(Vec::new());
1260 };
1261 decode_index(&raw).map_err(Error::Core)
1262 }
1263
1264 fn persist_vector_index(&mut self, keys: &[Key]) -> Result<()> {
1265 let txn = self.inner_mut()?;
1266 let encoded = encode_index(keys)?;
1267 txn.put(VECTOR_INDEX_KEY.to_vec(), encoded)
1268 .map_err(Error::Core)
1269 }
1270
1271 pub fn commit(mut self) -> Result<()> {
1273 {
1274 let txn = self.inner.as_mut().ok_or(Error::TxnCompleted)?;
1275 for (index, state) in self.hnsw_indices.values_mut() {
1276 index.commit_staged(txn, state).map_err(Error::Core)?;
1277 }
1278 let mut catalog = self.db.sql_catalog.write().expect("catalog lock poisoned");
1279 catalog
1280 .persist_overlay(txn, &self.overlay)
1281 .map_err(|err| Error::Sql(err.into()))?;
1282 if let Some(journal) = self.journal.take() {
1283 journal.stage(txn).map_err(Error::Core)?;
1284 }
1285
1286 let vector_cache_invalidated = self.vector_cache_invalidated;
1287 let vector_cache_updates = std::mem::take(&mut self.vector_cache_updates);
1288 let vector_cache_deletes = std::mem::take(&mut self.vector_cache_deletes);
1289 if vector_cache_invalidated {
1290 let mut cache = self
1291 .db
1292 .vector_cache
1293 .write()
1294 .expect("vector cache lock poisoned");
1295 *cache = None;
1296 } else if !vector_cache_updates.is_empty() || !vector_cache_deletes.is_empty() {
1297 let needs_rebuild = {
1298 let cache = self
1299 .db
1300 .vector_cache
1301 .read()
1302 .expect("vector cache lock poisoned");
1303 cache.is_none()
1304 };
1305 if needs_rebuild {
1306 let rebuilt = build_vector_cache_from_txn(txn).map_err(Error::Core)?;
1307 let mut cache = self
1308 .db
1309 .vector_cache
1310 .write()
1311 .expect("vector cache lock poisoned");
1312 *cache = Some(rebuilt);
1313 } else {
1314 let mut cache = self
1315 .db
1316 .vector_cache
1317 .write()
1318 .expect("vector cache lock poisoned");
1319 if let Some(cache) = cache.as_mut() {
1320 for key in vector_cache_deletes {
1321 cache.remove(&key);
1322 }
1323 for (key, cached) in vector_cache_updates {
1324 cache.insert(key, cached);
1325 }
1326 }
1327 }
1328 }
1329 }
1330 let txn = self.inner.take().ok_or(Error::TxnCompleted)?;
1331 let hnsw_indices = std::mem::take(&mut self.hnsw_indices);
1332 txn.commit_self().map_err(Error::Core)?;
1333 if !hnsw_indices.is_empty() {
1334 let mut cache = self
1335 .db
1336 .hnsw_cache
1337 .write()
1338 .expect("hnsw cache lock poisoned");
1339 for (name, (index, _state)) in hnsw_indices {
1340 cache.insert(name, Arc::new(index));
1341 }
1342 }
1343
1344 let overlay = std::mem::take(&mut self.overlay);
1346 let catalog_modified = self.catalog_modified;
1347 let mut catalog = self.db.sql_catalog.write().expect("catalog lock poisoned");
1348 catalog.apply_overlay(overlay);
1349 drop(catalog); if catalog_modified {
1352 self.db.invalidate_table_info_cache();
1353 }
1354 Ok(())
1355 }
1356
1357 pub fn rollback_in_place(&mut self) -> Result<()> {
1359 let txn = self.inner.as_mut().ok_or(Error::TxnCompleted)?;
1360 txn.rollback_in_place().map_err(Error::Core)?;
1361 for (index, state) in self.hnsw_indices.values_mut() {
1362 let _ = index.rollback(state);
1363 }
1364 self.hnsw_indices.clear();
1365 self.overlay = alopex_sql::catalog::CatalogOverlay::default();
1366 self.inner = None;
1367 Ok(())
1368 }
1369
1370 pub fn rollback(mut self) -> Result<()> {
1372 if let Some(txn) = self.inner.take() {
1373 for (index, state) in self.hnsw_indices.values_mut() {
1374 let _ = index.rollback(state);
1375 }
1376 self.hnsw_indices.clear();
1377 txn.rollback_self().map_err(Error::Core)
1378 } else {
1379 Err(Error::TxnCompleted)
1380 }
1381 }
1382
1383 fn inner_mut(&mut self) -> Result<&mut AnyKVTransaction<'a>> {
1384 self.inner.as_mut().ok_or(Error::TxnCompleted)
1385 }
1386
1387 fn hnsw_entry_mut(&mut self, name: &str) -> Result<&mut (HnswIndex, HnswTransactionState)> {
1388 if !self.hnsw_indices.contains_key(name) {
1389 let index = {
1390 let txn = self.inner_mut()?;
1391 HnswIndex::load(name, txn).map_err(Error::Core)?
1392 };
1393 self.hnsw_indices
1394 .insert(name.to_string(), (index, HnswTransactionState::default()));
1395 }
1396 Ok(self.hnsw_indices.get_mut(name).unwrap())
1397 }
1398
1399 fn ensure_write_txn(&self) -> Result<()> {
1400 let txn = self.inner.as_ref().ok_or(Error::TxnCompleted)?;
1401 if txn.mode() != TxnMode::ReadWrite {
1402 return Err(Error::Core(alopex_core::Error::TxnReadOnly));
1403 }
1404 Ok(())
1405 }
1406}
1407
1408impl OwnedEmbeddedTransaction {
1409 pub fn upsert_vector(
1411 &mut self,
1412 key: &[u8],
1413 metadata: &[u8],
1414 vector: &[f32],
1415 metric: Metric,
1416 ) -> Result<()> {
1417 if vector.is_empty() {
1418 return Err(Error::Core(alopex_core::Error::InvalidFormat(
1419 "vector cannot be empty".into(),
1420 )));
1421 }
1422 let vector_type = VectorType::new(vector.len(), metric);
1423 vector_type.validate(vector).map_err(Error::Core)?;
1424 let key = key.to_vec();
1425 let payload = encode_vector_entry(vector_type, metadata, vector);
1426
1427 self.session
1428 .with_transaction(|transaction| {
1429 transaction.put(key.clone(), payload)?;
1430 let mut keys = match transaction.get(&VECTOR_INDEX_KEY.to_vec())? {
1431 Some(raw) => decode_index(&raw)?,
1432 None => Vec::new(),
1433 };
1434 if !keys.iter().any(|entry| entry == &key) {
1435 keys.push(key.clone());
1436 transaction.put(VECTOR_INDEX_KEY.to_vec(), encode_index(&keys)?)?;
1437 }
1438 Ok(())
1439 })
1440 .map_err(Error::Core)?;
1441 self.vector_cache_invalidated = true;
1442 Ok(())
1443 }
1444
1445 pub fn get_vector(&mut self, key: &[u8], metric: Metric) -> Result<Option<Vec<f32>>> {
1447 self.session
1448 .with_transaction(|transaction| {
1449 let Some(raw) = transaction.get(&key.to_vec())? else {
1450 return Ok(None);
1451 };
1452 let decoded = decode_vector_entry(&raw)?;
1453 if decoded.metric != metric {
1454 return Err(alopex_core::Error::UnsupportedMetric {
1455 metric: metric.as_str().to_string(),
1456 });
1457 }
1458 Ok(Some(decoded.vector))
1459 })
1460 .map_err(Error::Core)
1461 }
1462
1463 pub fn get_vectors(&mut self, keys: &[Key], metric: Metric) -> Result<Vec<Option<Vec<f32>>>> {
1465 self.session
1466 .with_transaction(|transaction| {
1467 let mut output = Vec::with_capacity(keys.len());
1468 for key in keys {
1469 let Some(raw) = transaction.get(key)? else {
1470 output.push(None);
1471 continue;
1472 };
1473 let decoded = decode_vector_entry(&raw)?;
1474 if decoded.metric != metric {
1475 return Err(alopex_core::Error::UnsupportedMetric {
1476 metric: metric.as_str().to_string(),
1477 });
1478 }
1479 output.push(Some(decoded.vector));
1480 }
1481 Ok(output)
1482 })
1483 .map_err(Error::Core)
1484 }
1485
1486 pub fn search_similar(
1488 &mut self,
1489 query_vector: &[f32],
1490 metric: Metric,
1491 top_k: usize,
1492 filter_keys: Option<&[Key]>,
1493 ) -> Result<Vec<SearchResult>> {
1494 if top_k == 0 {
1495 return Ok(Vec::new());
1496 }
1497 let query_norm_sq = query_vector.iter().map(|value| value * value).sum::<f32>();
1498 let query_norm = if metric == Metric::Cosine {
1499 query_norm_sq.sqrt()
1500 } else {
1501 0.0
1502 };
1503 let keys = match filter_keys {
1504 Some(keys) => keys.to_vec(),
1505 None => self
1506 .session
1507 .with_transaction(|transaction| {
1508 match transaction.get(&VECTOR_INDEX_KEY.to_vec())? {
1509 Some(raw) => decode_index(&raw),
1510 None => Ok(Vec::new()),
1511 }
1512 })
1513 .map_err(Error::Core)?,
1514 };
1515 let mut rows = self
1516 .session
1517 .with_transaction(|transaction| {
1518 let mut rows = Vec::with_capacity(keys.len());
1519 for key in &keys {
1520 let Some(raw) = transaction.get(key)? else {
1521 continue;
1522 };
1523 let decoded = decode_vector_entry_view(&raw)?;
1524 if decoded.metric != metric {
1525 return Err(alopex_core::Error::UnsupportedMetric {
1526 metric: metric.as_str().to_string(),
1527 });
1528 }
1529 validate_dimensions(decoded.dim, query_vector.len())?;
1530 let score =
1531 score_from_bytes(metric, query_vector, query_norm, decoded.vector_bytes)?;
1532 rows.push(SearchResult {
1533 key: key.clone(),
1534 metadata: decoded.metadata,
1535 score,
1536 });
1537 }
1538 Ok(rows)
1539 })
1540 .map_err(Error::Core)?;
1541 if rows.len() > top_k {
1542 rows.select_nth_unstable_by(top_k - 1, |left, right| {
1543 right
1544 .score
1545 .total_cmp(&left.score)
1546 .then_with(|| left.key.cmp(&right.key))
1547 });
1548 rows.truncate(top_k);
1549 }
1550 rows.sort_by(|left, right| {
1551 right
1552 .score
1553 .total_cmp(&left.score)
1554 .then_with(|| left.key.cmp(&right.key))
1555 });
1556 Ok(rows)
1557 }
1558
1559 pub fn upsert_to_hnsw(
1561 &mut self,
1562 index_name: &str,
1563 key: &[u8],
1564 vector: &[f32],
1565 metadata: &[u8],
1566 ) -> Result<()> {
1567 self.ensure_owned_write_transaction()?;
1568 let (index, state) = self.hnsw_entry_mut(index_name)?;
1569 index
1570 .upsert_staged(key, vector, metadata, state)
1571 .map_err(Error::Core)
1572 }
1573
1574 pub fn delete_from_hnsw(&mut self, index_name: &str, key: &[u8]) -> Result<bool> {
1576 self.ensure_owned_write_transaction()?;
1577 let (index, state) = self.hnsw_entry_mut(index_name)?;
1578 index.delete_staged(key, state).map_err(Error::Core)
1579 }
1580
1581 fn ensure_owned_write_transaction(&self) -> Result<()> {
1582 let mode = self
1583 .session
1584 .with_transaction(|transaction| Ok(transaction.mode()))
1585 .map_err(Error::Core)?;
1586 if mode == TxnMode::ReadOnly {
1587 return Err(Error::Core(alopex_core::Error::TxnReadOnly));
1588 }
1589 Ok(())
1590 }
1591
1592 fn hnsw_entry_mut(
1593 &mut self,
1594 index_name: &str,
1595 ) -> Result<&mut (HnswIndex, HnswTransactionState)> {
1596 if !self.hnsw_indices.contains_key(index_name) {
1597 let index = self
1598 .session
1599 .with_transaction(|transaction| {
1600 let mut transaction =
1601 alopex_core::kv::OwnedKVTransactionAdapter::new(transaction);
1602 HnswIndex::load(index_name, &mut transaction)
1603 })
1604 .map_err(Error::Core)?;
1605 self.hnsw_indices.insert(
1606 index_name.to_string(),
1607 (index, HnswTransactionState::default()),
1608 );
1609 }
1610 Ok(self
1611 .hnsw_indices
1612 .get_mut(index_name)
1613 .expect("HNSW entry inserted above"))
1614 }
1615}
1616
1617impl<'a> Drop for Transaction<'a> {
1618 fn drop(&mut self) {
1619 if let Some(txn) = self.inner.take() {
1620 for (index, state) in self.hnsw_indices.values_mut() {
1621 let _ = index.rollback(state);
1622 }
1623 self.hnsw_indices.clear();
1624 let _ = txn.rollback_self();
1625 }
1626 }
1627}
1628
1629fn metric_to_byte(metric: Metric) -> u8 {
1630 match metric {
1631 Metric::Cosine => 0,
1632 Metric::L2 => 1,
1633 Metric::InnerProduct => 2,
1634 }
1635}
1636
1637fn byte_to_metric(byte: u8) -> result::Result<Metric, alopex_core::Error> {
1638 match byte {
1639 0 => Ok(Metric::Cosine),
1640 1 => Ok(Metric::L2),
1641 2 => Ok(Metric::InnerProduct),
1642 other => Err(alopex_core::Error::UnsupportedMetric {
1643 metric: format!("unknown({other})"),
1644 }),
1645 }
1646}
1647
1648fn encode_vector_entry(vector_type: VectorType, metadata: &[u8], vector: &[f32]) -> Vec<u8> {
1649 let dim = vector_type.dim() as u32;
1650 let meta_len = metadata.len() as u32;
1651 let mut buf = Vec::with_capacity(1 + 4 + 4 + metadata.len() + std::mem::size_of_val(vector));
1652 buf.push(metric_to_byte(vector_type.metric()));
1653 buf.extend_from_slice(&dim.to_le_bytes());
1654 buf.extend_from_slice(&meta_len.to_le_bytes());
1655 buf.extend_from_slice(metadata);
1656 for v in vector {
1657 buf.extend_from_slice(&v.to_le_bytes());
1658 }
1659 buf
1660}
1661
1662struct DecodedEntry {
1663 metric: Metric,
1664 vector: Vec<f32>,
1665}
1666
1667#[derive(Clone)]
1668struct CachedVector {
1669 metric: Metric,
1670 metadata: Vec<u8>,
1671 vector: Vec<f32>,
1672 norm_sq: f32,
1673 inv_norm: f32,
1674}
1675
1676struct VectorEntryView<'a> {
1677 metric: Metric,
1678 dim: usize,
1679 metadata: Vec<u8>,
1680 vector_bytes: &'a [u8],
1681}
1682
1683fn decode_vector_entry(bytes: &[u8]) -> result::Result<DecodedEntry, alopex_core::Error> {
1684 if bytes.len() < 9 {
1685 return Err(alopex_core::Error::InvalidFormat(
1686 "vector entry too short".into(),
1687 ));
1688 }
1689 let metric = byte_to_metric(bytes[0])?;
1690 let dim = u32::from_le_bytes(bytes[1..5].try_into().unwrap()) as usize;
1691 let meta_len = u32::from_le_bytes(bytes[5..9].try_into().unwrap()) as usize;
1692
1693 let header = 9;
1694 let expected_len = header + meta_len + dim * std::mem::size_of::<f32>();
1695 if bytes.len() < expected_len {
1696 return Err(alopex_core::Error::InvalidFormat(
1697 "vector entry truncated".into(),
1698 ));
1699 }
1700
1701 let mut vector = Vec::with_capacity(dim);
1702 let vec_bytes = &bytes[header + meta_len..expected_len];
1703 for chunk in vec_bytes.as_chunks::<4>().0 {
1704 vector.push(f32::from_le_bytes(*chunk));
1705 }
1706
1707 Ok(DecodedEntry { metric, vector })
1708}
1709
1710fn decode_vector_entry_view(
1711 bytes: &[u8],
1712) -> result::Result<VectorEntryView<'_>, alopex_core::Error> {
1713 if bytes.len() < 9 {
1714 return Err(alopex_core::Error::InvalidFormat(
1715 "vector entry too short".into(),
1716 ));
1717 }
1718 let metric = byte_to_metric(bytes[0])?;
1719 let dim = u32::from_le_bytes(bytes[1..5].try_into().unwrap()) as usize;
1720 let meta_len = u32::from_le_bytes(bytes[5..9].try_into().unwrap()) as usize;
1721
1722 let header = 9;
1723 let expected_len = header + meta_len + dim * std::mem::size_of::<f32>();
1724 if bytes.len() < expected_len {
1725 return Err(alopex_core::Error::InvalidFormat(
1726 "vector entry truncated".into(),
1727 ));
1728 }
1729
1730 let metadata = bytes[header..header + meta_len].to_vec();
1731 let vector_bytes = &bytes[header + meta_len..expected_len];
1732
1733 Ok(VectorEntryView {
1734 metric,
1735 dim,
1736 metadata,
1737 vector_bytes,
1738 })
1739}
1740
1741fn vector_bytes_to_vec(bytes: &[u8]) -> Vec<f32> {
1742 let mut vector = Vec::with_capacity(bytes.len() / 4);
1743 for chunk in bytes.as_chunks::<4>().0 {
1744 vector.push(f32::from_le_bytes(*chunk));
1745 }
1746 vector
1747}
1748
1749fn cached_vector_from_entry(metric: Metric, metadata: Vec<u8>, vector: Vec<f32>) -> CachedVector {
1750 let norm_sq = vector.iter().map(|v| v * v).sum::<f32>();
1751 let inv_norm = if norm_sq == 0.0 {
1752 0.0
1753 } else {
1754 1.0 / norm_sq.sqrt()
1755 };
1756 CachedVector {
1757 metric,
1758 metadata,
1759 vector,
1760 norm_sq,
1761 inv_norm,
1762 }
1763}
1764
1765fn build_vector_cache_from_txn<'a>(
1766 txn: &mut AnyKVTransaction<'a>,
1767) -> result::Result<HashMap<Key, CachedVector>, alopex_core::Error> {
1768 let Some(raw) = txn.get(&VECTOR_INDEX_KEY.to_vec())? else {
1769 return Ok(HashMap::new());
1770 };
1771 let keys = decode_index(&raw)?;
1772 let mut cache = HashMap::with_capacity(keys.len());
1773 for key in keys {
1774 let Some(raw) = txn.get(&key)? else {
1775 continue;
1776 };
1777 let decoded = decode_vector_entry_view(&raw)?;
1778 let vector = vector_bytes_to_vec(decoded.vector_bytes);
1779 let cached = cached_vector_from_entry(decoded.metric, decoded.metadata, vector);
1780 cache.insert(key, cached);
1781 }
1782 Ok(cache)
1783}
1784
1785fn dot_product(query: &[f32], item: &[f32]) -> f32 {
1786 #[cfg(target_arch = "x86_64")]
1787 {
1788 if std::is_x86_feature_detected!("avx") {
1789 unsafe {
1791 return dot_product_avx(query, item);
1792 }
1793 }
1794 }
1795 dot_product_scalar(query, item)
1796}
1797
1798fn dot_product_scalar(query: &[f32], item: &[f32]) -> f32 {
1799 query.iter().zip(item.iter()).map(|(q, v)| q * v).sum()
1800}
1801
1802#[cfg(target_arch = "x86_64")]
1803#[target_feature(enable = "avx")]
1804unsafe fn dot_product_avx(query: &[f32], item: &[f32]) -> f32 {
1805 use std::arch::x86_64::*;
1806
1807 let len = query.len();
1808 let mut i = 0;
1809 let mut acc = _mm256_setzero_ps();
1810 let q_ptr = query.as_ptr();
1811 let v_ptr = item.as_ptr();
1812 while i + 8 <= len {
1813 let q = _mm256_loadu_ps(q_ptr.add(i));
1814 let v = _mm256_loadu_ps(v_ptr.add(i));
1815 acc = _mm256_add_ps(acc, _mm256_mul_ps(q, v));
1816 i += 8;
1817 }
1818
1819 let mut tmp = [0f32; 8];
1820 _mm256_storeu_ps(tmp.as_mut_ptr(), acc);
1821 let mut sum = tmp.iter().sum::<f32>();
1822 while i < len {
1823 sum += *q_ptr.add(i) * *v_ptr.add(i);
1824 i += 1;
1825 }
1826 sum
1827}
1828
1829fn score_from_slice(metric: Metric, query: &[f32], query_norm: f32, item: &[f32]) -> f32 {
1830 match metric {
1831 Metric::Cosine => {
1832 if query_norm == 0.0 {
1833 return 0.0;
1834 }
1835 let mut dot = 0.0;
1836 let mut item_norm_sq = 0.0;
1837 for (q, v) in query.iter().zip(item.iter()) {
1838 dot += q * v;
1839 item_norm_sq += v * v;
1840 }
1841 let item_norm = item_norm_sq.sqrt();
1842 if item_norm == 0.0 {
1843 0.0
1844 } else {
1845 dot / (query_norm * item_norm)
1846 }
1847 }
1848 Metric::L2 => {
1849 let mut dist_sq = 0.0;
1850 for (q, v) in query.iter().zip(item.iter()) {
1851 let d = q - v;
1852 dist_sq += d * d;
1853 }
1854 -dist_sq.sqrt()
1855 }
1856 Metric::InnerProduct => query.iter().zip(item.iter()).map(|(q, v)| q * v).sum(),
1857 }
1858}
1859
1860fn score_from_bytes(
1861 metric: Metric,
1862 query: &[f32],
1863 query_norm: f32,
1864 vector_bytes: &[u8],
1865) -> result::Result<f32, alopex_core::Error> {
1866 let len = vector_bytes.len() / 4;
1867 #[cfg(target_endian = "little")]
1868 {
1869 let ptr = vector_bytes.as_ptr();
1870 if (ptr as usize).is_multiple_of(std::mem::align_of::<f32>()) {
1871 let items = unsafe { std::slice::from_raw_parts(ptr as *const f32, len) };
1872 return Ok(score_from_slice(metric, query, query_norm, items));
1873 }
1874 }
1875
1876 let mut iter = vector_bytes.as_chunks::<4>().0.iter();
1878 let score = match metric {
1879 Metric::Cosine => {
1880 if query_norm == 0.0 {
1881 0.0
1882 } else {
1883 let mut dot = 0.0;
1884 let mut item_norm_sq = 0.0;
1885 for (q, chunk) in query.iter().zip(&mut iter) {
1886 let v = f32::from_le_bytes(*chunk);
1887 dot += q * v;
1888 item_norm_sq += v * v;
1889 }
1890 let item_norm = item_norm_sq.sqrt();
1891 if item_norm == 0.0 {
1892 0.0
1893 } else {
1894 dot / (query_norm * item_norm)
1895 }
1896 }
1897 }
1898 Metric::L2 => {
1899 let mut dist_sq = 0.0;
1900 for (q, chunk) in query.iter().zip(&mut iter) {
1901 let v = f32::from_le_bytes(*chunk);
1902 let d = q - v;
1903 dist_sq += d * d;
1904 }
1905 -dist_sq.sqrt()
1906 }
1907 Metric::InnerProduct => query
1908 .iter()
1909 .zip(&mut iter)
1910 .map(|(q, chunk)| q * f32::from_le_bytes(*chunk))
1911 .sum(),
1912 };
1913 Ok(score)
1914}
1915
1916fn encode_index(keys: &[Key]) -> result::Result<Vec<u8>, alopex_core::Error> {
1917 let mut buf = Vec::new();
1918 let count = keys.len() as u32;
1919 buf.extend_from_slice(&count.to_le_bytes());
1920 for key in keys {
1921 let len: u32 = key
1922 .len()
1923 .try_into()
1924 .map_err(|_| alopex_core::Error::InvalidFormat("key too long".into()))?;
1925 buf.extend_from_slice(&len.to_le_bytes());
1926 buf.extend_from_slice(key);
1927 }
1928 Ok(buf)
1929}
1930
1931fn decode_index(bytes: &[u8]) -> result::Result<Vec<Key>, alopex_core::Error> {
1932 if bytes.len() < 4 {
1933 return Err(alopex_core::Error::InvalidFormat("index too short".into()));
1934 }
1935 let count = u32::from_le_bytes(bytes[0..4].try_into().unwrap()) as usize;
1936 let mut pos = 4;
1937 let mut keys = Vec::with_capacity(count);
1938 for _ in 0..count {
1939 if pos + 4 > bytes.len() {
1940 return Err(alopex_core::Error::InvalidFormat("index truncated".into()));
1941 }
1942 let len = u32::from_le_bytes(bytes[pos..pos + 4].try_into().unwrap()) as usize;
1943 pos += 4;
1944 if pos + len > bytes.len() {
1945 return Err(alopex_core::Error::InvalidFormat(
1946 "index key truncated".into(),
1947 ));
1948 }
1949 keys.push(bytes[pos..pos + len].to_vec());
1950 pos += len;
1951 }
1952 Ok(keys)
1953}
1954
1955#[cfg(test)]
1956mod tests {
1957 use super::*;
1958 use std::sync::mpsc;
1959 use std::thread;
1960 use tempfile::tempdir;
1961
1962 #[test]
1963 fn test_open_and_crud() {
1964 let dir = tempdir().unwrap();
1965 let path = dir.path().join("test.db");
1966 let db = Database::open(&path).unwrap();
1967
1968 let mut txn = db.begin(TxnMode::ReadWrite).unwrap();
1969 txn.put(b"key1", b"value1").unwrap();
1970 txn.commit().unwrap();
1971
1972 let mut txn2 = db.begin(TxnMode::ReadOnly).unwrap();
1973 let val = txn2.get(b"key1").unwrap();
1974 assert_eq!(val, Some(b"value1".to_vec()));
1975 }
1976
1977 #[test]
1978 fn test_not_found() {
1979 let db = Database::new();
1980 let mut txn = db.begin(TxnMode::ReadOnly).unwrap();
1981 let val = txn.get(b"non-existent-key").unwrap();
1982 assert!(val.is_none());
1983 }
1984
1985 #[cfg(not(target_arch = "wasm32"))]
1986 #[test]
1987 fn test_file_format_version_reads_alopex_header() {
1988 use alopex_core::storage::format::{AlopexFileWriter, FileFlags, FileVersion};
1989
1990 let dir = tempdir().unwrap();
1991 let path = dir.path().join("format-test.alopex");
1992 let expected = FileVersion::new(0, 0, 1);
1993
1994 let writer = AlopexFileWriter::new(path.clone(), expected, FileFlags(0)).unwrap();
1995 writer.finalize().unwrap();
1996
1997 let db = Database::open(&path).unwrap();
1998 assert_eq!(db.file_format_version(), expected);
1999 }
2000
2001 #[test]
2002 fn test_crash_recovery_replays_wal() {
2003 let dir = tempdir().unwrap();
2004 let path = dir.path().join("replay.db");
2005
2006 {
2007 let db = Database::open(&path).unwrap();
2008 let mut txn = db.begin(TxnMode::ReadWrite).unwrap();
2009 txn.put(b"k1", b"v1").unwrap();
2010 txn.commit().unwrap();
2011
2012 let mut uncommitted = db.begin(TxnMode::ReadWrite).unwrap();
2013 uncommitted.put(b"k2", b"v2").unwrap();
2014 }
2016
2017 let db = Database::open(&path).unwrap();
2018 let mut txn = db.begin(TxnMode::ReadOnly).unwrap();
2019 assert_eq!(txn.get(b"k1").unwrap(), Some(b"v1".to_vec()));
2020 assert_eq!(txn.get(b"k2").unwrap(), None);
2021 }
2022
2023 #[test]
2024 fn test_txn_closed() {
2025 let db = Database::new();
2026 let mut txn = db.begin(TxnMode::ReadWrite).unwrap();
2027 txn.put(b"k1", b"v1").unwrap();
2028 txn.commit().unwrap();
2029 }
2035
2036 #[test]
2037 fn test_concurrency_conflict() {
2038 let db = std::sync::Arc::new(Database::new());
2039 let mut t0 = db.begin(TxnMode::ReadWrite).unwrap();
2040 t0.put(b"k1", b"v0").unwrap();
2041 t0.commit().unwrap();
2042
2043 let (tx1, rx1) = mpsc::channel();
2044 let (tx2, rx2) = mpsc::channel();
2045
2046 let db1 = db.clone();
2047 let t1 = thread::spawn(move || {
2048 let mut txn1 = db1.begin(TxnMode::ReadWrite).unwrap();
2049 let val = txn1.get(b"k1").unwrap();
2050 assert_eq!(val.unwrap(), b"v0");
2051 tx1.send(()).unwrap();
2052 rx2.recv().unwrap();
2053 txn1.put(b"k1", b"v1").unwrap();
2054 let result = txn1.commit();
2055 assert!(matches!(
2056 result,
2057 Err(Error::Core(alopex_core::Error::TxnConflict))
2058 ));
2059 });
2060
2061 let db2 = db.clone();
2062 let t2 = thread::spawn(move || {
2063 rx1.recv().unwrap();
2064 let mut txn2 = db2.begin(TxnMode::ReadWrite).unwrap();
2065 txn2.put(b"k1", b"v2").unwrap();
2066 assert!(txn2.commit().is_ok());
2067 tx2.send(()).unwrap();
2068 });
2069
2070 t1.join().unwrap();
2071 t2.join().unwrap();
2072
2073 let mut txn3 = db.begin(TxnMode::ReadOnly).unwrap();
2074 let val = txn3.get(b"k1").unwrap();
2075 assert_eq!(val.unwrap(), b"v2");
2076 }
2077
2078 #[test]
2079 fn test_flush_and_reopen_via_embedded_api() {
2080 let dir = tempdir().unwrap();
2081 let path = dir.path().join("persist.db");
2082 {
2083 let db = Database::open(&path).unwrap();
2084 let mut txn = db.begin(TxnMode::ReadWrite).unwrap();
2085 txn.put(b"k1", b"v1").unwrap();
2086 txn.commit().unwrap();
2087 db.flush().unwrap();
2088 }
2089
2090 let db = Database::open(&path).unwrap();
2091 let mut txn = db.begin(TxnMode::ReadOnly).unwrap();
2092 assert_eq!(txn.get(b"k1").unwrap(), Some(b"v1".to_vec()));
2093 }
2094
2095 #[test]
2096 fn test_large_value_blob_roundtrip() {
2097 let dir = tempdir().unwrap();
2098 let path = dir.path().join("blob.lv");
2099 let payload = b"hello large value";
2100
2101 {
2102 let db = Database::new();
2103 let mut writer = db
2104 .create_blob_writer(&path, payload.len() as u64, Some(16))
2105 .unwrap();
2106 writer.write_chunk(&payload[..5]).unwrap();
2107 writer.write_chunk(&payload[5..]).unwrap();
2108 writer.finish().unwrap();
2109 }
2110
2111 let db = Database::new();
2112 let mut reader = db.open_large_value(&path).unwrap();
2113 let mut buf = Vec::new();
2114 while let Some((_info, chunk)) = reader.next_chunk().unwrap() {
2115 buf.extend_from_slice(&chunk);
2116 }
2117 assert_eq!(buf, payload);
2118 }
2119
2120 #[test]
2121 fn upsert_and_search_same_txn() {
2122 let db = Database::new();
2123 let mut txn = db.begin(TxnMode::ReadWrite).unwrap();
2124 txn.upsert_vector(b"k1", b"meta1", &[1.0, 0.0], Metric::Cosine)
2125 .unwrap();
2126
2127 let results = txn
2128 .search_similar(&[1.0, 0.0], Metric::Cosine, 1, None)
2129 .unwrap();
2130 assert_eq!(results.len(), 1);
2131 assert_eq!(results[0].key, b"k1");
2132 assert_eq!(results[0].metadata, b"meta1");
2133 txn.commit().unwrap();
2134 }
2135
2136 #[test]
2137 fn upsert_and_search_across_txn() {
2138 let db = Database::new();
2139 {
2140 let mut txn = db.begin(TxnMode::ReadWrite).unwrap();
2141 txn.upsert_vector(b"k1", b"meta1", &[1.0, 1.0], Metric::Cosine)
2142 .unwrap();
2143 txn.commit().unwrap();
2144 }
2145
2146 let mut ro = db.begin(TxnMode::ReadOnly).unwrap();
2147 let results = ro
2148 .search_similar(&[1.0, 1.0], Metric::Cosine, 1, None)
2149 .unwrap();
2150 assert_eq!(results.len(), 1);
2151 assert_eq!(results[0].key, b"k1");
2152 }
2153
2154 #[test]
2155 fn read_only_upsert_rejected() {
2156 let db = Database::new();
2157 let mut ro = db.begin(TxnMode::ReadOnly).unwrap();
2158 let err = ro
2159 .upsert_vector(b"k1", b"m", &[1.0, 0.0], Metric::Cosine)
2160 .unwrap_err();
2161 assert!(matches!(err, Error::Core(alopex_core::Error::TxnReadOnly)));
2162 }
2163
2164 #[test]
2165 fn dimension_mismatch_on_search() {
2166 let db = Database::new();
2167 {
2168 let mut txn = db.begin(TxnMode::ReadWrite).unwrap();
2169 txn.upsert_vector(b"k1", b"m", &[1.0, 0.0], Metric::Cosine)
2170 .unwrap();
2171 txn.commit().unwrap();
2172 }
2173 let mut ro = db.begin(TxnMode::ReadOnly).unwrap();
2174 let err = ro
2175 .search_similar(&[1.0, 0.0, 1.0], Metric::Cosine, 1, None)
2176 .unwrap_err();
2177 assert!(matches!(
2178 err,
2179 Error::Core(alopex_core::Error::DimensionMismatch { .. })
2180 ));
2181 }
2182}