use std::collections::HashMap;
use dashmap::DashMap;
use tracing::info;
use crate::backend::VectorBackend;
use crate::vector_error::{VectorCoordinatorError, VectorCoordinatorResult, VectorError};
use linkrs_core::event_dispatch::{EventFilter, EventSubscriptions, SubscriptionId};
use linkrs_core::index_events::{IndexEvent, IndexEventCallback};
pub use simvec::types::{DistanceMetric, PointId, SearchQuery, SearchResult, VectorPoint};
use simvec::{
types::validate_distance_metric, CollectionConfig, FilterCondition, IndexMetadata,
PayloadSchemaType, VectorFilter,
};
use super::vector_sync::{CollectionGranularity, SearchOptions, VectorIndexLocation};
fn validate_metric(distance: DistanceMetric) -> VectorCoordinatorResult<()> {
validate_distance_metric(distance)
.map_err(|e| VectorCoordinatorError::Vector(VectorError::ConfigError(e)))
}
fn validate_metric_for_backend(
backend: &VectorBackend,
distance: DistanceMetric,
) -> VectorCoordinatorResult<()> {
validate_metric(distance)?;
let _ = backend;
Ok(())
}
pub struct VectorIndexManager {
backend: VectorBackend,
logical_indexes: DashMap<VectorIndexLocation, IndexMetadata>,
temp_collections: DashMap<TempKey, TempCollection>,
collection_overrides: DashMap<VectorIndexLocation, String>,
promote_backups: DashMap<String, String>,
publish_fences: DashMap<VectorIndexLocation, std::sync::Arc<tokio::sync::RwLock<()>>>,
granularity: parking_lot::RwLock<CollectionGranularity>,
index_callbacks: EventSubscriptions<IndexEvent>,
}
#[derive(Debug, Clone)]
pub struct TempCollection {
pub temp_name: String,
pub live_name: String,
pub config: CollectionConfig,
pub created_at: chrono::DateTime<chrono::Utc>,
}
pub type TempKey = (u64, String, String, u64);
#[derive(Debug, Clone)]
pub struct PublishOutcome {
pub backup: Option<String>,
pub copied_points: u64,
}
impl std::fmt::Debug for VectorIndexManager {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("VectorIndexManager")
.field("backend", &self.backend)
.field("logical_index_count", &self.logical_indexes.len())
.field("granularity", &*self.granularity.read())
.field("index_callbacks", &self.index_callbacks.len())
.finish()
}
}
impl VectorIndexManager {
pub fn new(backend: VectorBackend) -> Self {
Self {
backend,
logical_indexes: DashMap::new(),
temp_collections: DashMap::new(),
collection_overrides: DashMap::new(),
promote_backups: DashMap::new(),
publish_fences: DashMap::new(),
granularity: parking_lot::RwLock::new(CollectionGranularity::default()),
index_callbacks: EventSubscriptions::new(),
}
}
pub fn publish_fence_for(
&self,
space_id: u64,
tag_name: &str,
field_name: &str,
) -> std::sync::Arc<tokio::sync::RwLock<()>> {
let loc = VectorIndexLocation::new(space_id, tag_name, field_name);
self.publish_fences
.entry(loc)
.or_insert_with(|| std::sync::Arc::new(tokio::sync::RwLock::new(())))
.clone()
}
pub fn has_rebuild_temp(&self, space_id: u64, tag_name: &str, field_name: &str) -> bool {
self.temp_collections.iter().any(|entry| {
entry.key().0 == space_id && entry.key().1 == tag_name && entry.key().2 == field_name
})
}
pub fn register_index_callback(&self, callback: IndexEventCallback) -> SubscriptionId {
self.index_callbacks.add(callback)
}
pub fn register_index_callback_filtered(
&self,
callback: IndexEventCallback,
filter: EventFilter<IndexEvent>,
) -> SubscriptionId {
self.index_callbacks.add_filtered(callback, Some(filter))
}
pub fn unregister_index_callback(&self, id: SubscriptionId) -> bool {
self.index_callbacks.remove(id)
}
pub fn index_callback_count(&self) -> usize {
self.index_callbacks.len()
}
fn emit_index_event(&self, event: IndexEvent) {
self.index_callbacks.dispatch("index", &event);
}
pub fn emit_rebuild_event(&self, event: IndexEvent) {
self.emit_index_event(event);
}
pub fn backend(&self) -> &VectorBackend {
&self.backend
}
pub fn granularity(&self) -> CollectionGranularity {
*self.granularity.read()
}
pub fn set_granularity(&self, granularity: CollectionGranularity) {
*self.granularity.write() = granularity;
}
pub fn collection_name_for(&self, loc: &VectorIndexLocation) -> String {
if let Some(target) = self.collection_overrides.get(loc) {
return target.clone();
}
loc.to_collection_name_with(self.granularity())
}
pub fn live_collection_name(&self, loc: &VectorIndexLocation) -> String {
loc.to_collection_name_with(self.granularity())
}
pub fn group_id_for(&self, loc: &VectorIndexLocation) -> Option<String> {
loc.group_id_with(self.granularity())
}
pub fn is_disabled_engine(&self) -> bool {
self.backend.is_disabled()
}
pub async fn create_vector_index(
&self,
space_id: u64,
tag_name: &str,
field_name: &str,
vector_size: usize,
distance: DistanceMetric,
) -> VectorCoordinatorResult<String> {
validate_metric_for_backend(&self.backend, distance)?;
let loc = VectorIndexLocation::new(space_id, tag_name, field_name);
let collection_name = self.collection_name_for(&loc);
let logical_index_name = format!("vec_{space_id}_{tag_name}_{field_name}");
self.emit_index_event(IndexEvent::VectorBuildStarted {
index_name: logical_index_name.clone(),
});
if self.is_disabled_engine() {
let logical_key = VectorIndexLocation::new(space_id, tag_name, field_name);
let meta = IndexMetadata::new(
collection_name.clone(),
CollectionConfig::new(vector_size, distance),
);
self.logical_indexes.insert(logical_key, meta);
info!(
"Logical vector index created in disabled mode: space={} tag={} field={} in collection {}",
space_id, tag_name, field_name, collection_name
);
self.emit_index_event(IndexEvent::VectorBuildCompleted {
index_name: logical_index_name,
vectors_count: 0,
});
return Ok(collection_name);
}
let config = if self.backend.is_local() {
CollectionConfig::new(vector_size, distance)
} else {
let hnsw_config = simvec::HnswConfig::new(16, 100).with_payload_m(16);
CollectionConfig::new(vector_size, distance).with_hnsw(hnsw_config)
};
let config = self.backend.effective_collection_config(&config);
if !self.backend.index_exists(&collection_name) {
self.backend
.create_index(&collection_name, &config)
.await
.map_err(|e| VectorCoordinatorError::IndexCreationFailed {
tag_name: tag_name.to_string(),
field_name: field_name.to_string(),
reason: e.to_string(),
})?;
if self.granularity() == CollectionGranularity::Space {
if let Err(e) = self
.backend
.create_payload_index(&collection_name, "group_id", PayloadSchemaType::Keyword)
.await
{
tracing::warn!(
"Failed to create payload index for group_id in collection '{}': {}",
collection_name,
e
);
}
}
} else {
if let Some(existing_meta) = self.backend.get_index_metadata(&collection_name) {
let existing = &existing_meta.config;
if existing.vector_size != vector_size
|| existing.distance != distance
|| existing.index_type != config.index_type
|| format!("{:?}", existing.hnsw_config) != format!("{:?}", config.hnsw_config)
|| existing.quantization_config != config.quantization_config
|| format!("{:?}", existing.ivf_config) != format!("{:?}", config.ivf_config)
{
return Err(VectorCoordinatorError::CollectionConfigConflict {
collection_name: collection_name.clone(),
existing_size: existing.vector_size,
existing_dist: format!(
"{:?}/{:?}/{:?}/{:?}",
existing.distance,
existing.index_type,
existing.hnsw_config,
existing.quantization_config
),
requested_size: vector_size,
requested_dist: format!(
"{:?}/{:?}/{:?}/{:?}",
distance,
config.index_type,
config.hnsw_config,
config.quantization_config
),
});
}
}
}
let logical_key = VectorIndexLocation::new(space_id, tag_name, field_name);
let meta = IndexMetadata::new(collection_name.clone(), config);
self.logical_indexes.insert(logical_key, meta);
info!(
"Logical vector index created: space={} tag={} field={} in collection {}",
space_id, tag_name, field_name, collection_name
);
self.emit_index_event(IndexEvent::VectorBuildCompleted {
index_name: logical_index_name,
vectors_count: 0,
});
Ok(collection_name)
}
pub async fn create_index_with_config(
&self,
space_id: u64,
tag_name: &str,
field_name: &str,
config: CollectionConfig,
) -> VectorCoordinatorResult<String> {
validate_metric_for_backend(&self.backend, config.distance)?;
let config = self.backend.effective_collection_config(&config);
let loc = VectorIndexLocation::new(space_id, tag_name, field_name);
let collection_name = self.collection_name_for(&loc);
let logical_index_name = format!("vec_{space_id}_{tag_name}_{field_name}");
self.emit_index_event(IndexEvent::VectorBuildStarted {
index_name: logical_index_name.clone(),
});
if !self.backend.index_exists(&collection_name) {
self.backend
.create_index(&collection_name, &config)
.await
.map_err(|e| VectorCoordinatorError::IndexCreationFailed {
tag_name: tag_name.to_string(),
field_name: field_name.to_string(),
reason: e.to_string(),
})?;
if self.granularity() == CollectionGranularity::Space {
if let Err(e) = self
.backend
.create_payload_index(&collection_name, "group_id", PayloadSchemaType::Keyword)
.await
{
tracing::warn!(
"Failed to create payload index for group_id in collection '{}': {}",
collection_name,
e
);
}
}
} else {
if let Some(existing_meta) = self.backend.get_index_metadata(&collection_name) {
let existing = &existing_meta.config;
if existing.vector_size != config.vector_size
|| existing.distance != config.distance
|| existing.index_type != config.index_type
|| format!("{:?}", existing.hnsw_config) != format!("{:?}", config.hnsw_config)
|| existing.quantization_config != config.quantization_config
|| format!("{:?}", existing.ivf_config) != format!("{:?}", config.ivf_config)
{
return Err(VectorCoordinatorError::CollectionConfigConflict {
collection_name: collection_name.clone(),
existing_size: existing.vector_size,
existing_dist: format!(
"{:?}/{:?}/{:?}/{:?}",
existing.distance,
existing.index_type,
existing.hnsw_config,
existing.quantization_config
),
requested_size: config.vector_size,
requested_dist: format!(
"{:?}/{:?}/{:?}/{:?}",
config.distance,
config.index_type,
config.hnsw_config,
config.quantization_config
),
});
}
}
}
let logical_key = VectorIndexLocation::new(space_id, tag_name, field_name);
let meta = IndexMetadata::new(collection_name.clone(), config);
self.logical_indexes.insert(logical_key, meta);
info!(
"Logical vector index created with config: space={} tag={} field={} in collection {}",
space_id, tag_name, field_name, collection_name
);
self.emit_index_event(IndexEvent::VectorBuildCompleted {
index_name: logical_index_name,
vectors_count: 0,
});
Ok(collection_name)
}
pub async fn drop_vector_index(
&self,
space_id: u64,
tag_name: &str,
field_name: &str,
) -> VectorCoordinatorResult<()> {
let logical_key = VectorIndexLocation::new(space_id, tag_name, field_name);
let live_name = self.live_collection_name(&logical_key);
let resolved_name = self.collection_name_for(&logical_key);
self.logical_indexes.remove(&logical_key);
self.collection_overrides.remove(&logical_key);
self.discard_temps_for(space_id, tag_name, field_name).await;
if self.granularity() == CollectionGranularity::Field {
for name in [resolved_name, live_name] {
if name.is_empty() || !self.backend.index_exists(&name) {
continue;
}
if let Err(error) = self.backend.delete_collection(&name).await {
tracing::error!(
"Failed to reclaim vector collection '{}' (field granularity): {}",
name,
error
);
}
}
if let Some((_, backup)) =
self.promote_backups
.remove(&self.live_collection_name(&VectorIndexLocation::new(
space_id, tag_name, field_name,
)))
{
if self.backend.index_exists(&backup) {
if let Err(error) = self.backend.delete_collection(&backup).await {
tracing::error!(
"Failed to reclaim vector promote backup '{}': {}",
backup,
error
);
}
}
}
} else if self.backend.is_local() && self.backend.index_exists(&live_name) {
let remaining_siblings = self
.logical_indexes
.iter()
.filter(|entry| entry.value().name == live_name)
.count();
if remaining_siblings == 0 {
if let Err(error) = self.backend.delete_collection(&live_name).await {
tracing::error!(
"Failed to reclaim vector collection '{}': {}",
live_name,
error
);
}
} else if let Err(error) = self
.backend
.delete_by_filter(
&live_name,
VectorFilter::new().must(FilterCondition::match_value(
"group_id",
format!("{tag_name}_{field_name}"),
)),
)
.await
{
tracing::error!(
"Failed to purge dropped vector group '{tag_name}_{field_name}' from collection '{}': {}",
live_name,
error
);
}
}
info!(
"Logical vector index dropped: space={} tag={} field={}",
space_id, tag_name, field_name
);
self.emit_index_event(IndexEvent::VectorDropped {
index_name: format!("vec_{space_id}_{tag_name}_{field_name}"),
});
Ok(())
}
pub async fn drop_space_indexes(&self, space_id: u64) -> VectorCoordinatorResult<()> {
let keys: Vec<(u64, String, String)> = self
.logical_indexes
.iter()
.filter(|entry| entry.key().space_id == space_id)
.map(|entry| {
(
entry.key().space_id,
entry.key().tag_name.clone(),
entry.key().field_name.clone(),
)
})
.collect();
for (sid, tag, field) in keys {
self.drop_vector_index(sid, &tag, &field).await?;
}
Ok(())
}
pub async fn drop_tag_indexes(
&self,
space_id: u64,
tag_name: &str,
) -> VectorCoordinatorResult<()> {
let keys: Vec<(u64, String, String)> = self
.logical_indexes
.iter()
.filter(|entry| entry.key().space_id == space_id && entry.key().tag_name == tag_name)
.map(|entry| {
(
entry.key().space_id,
entry.key().tag_name.clone(),
entry.key().field_name.clone(),
)
})
.collect();
for (sid, tag, field) in keys {
self.drop_vector_index(sid, &tag, &field).await?;
}
Ok(())
}
pub async fn purge_index_data(
&self,
space_id: u64,
tag_name: &str,
field_name: &str,
) -> VectorCoordinatorResult<()> {
if self.is_disabled_engine() {
return Err(VectorCoordinatorError::EngineDisabled);
}
if self.has_rebuild_temp(space_id, tag_name, field_name) {
return Err(VectorCoordinatorError::RebuildBusy(format!(
"Refusing to purge vector index {space_id}.{tag_name}.{field_name} while a rebuild is running"
)));
}
let fence = self.publish_fence_for(space_id, tag_name, field_name);
let _fence_guard = fence.write().await;
let loc = VectorIndexLocation::new(space_id, tag_name, field_name);
let logical_key = loc.clone();
let Some(meta) = self
.logical_indexes
.get(&logical_key)
.map(|entry| entry.clone())
else {
return Err(VectorCoordinatorError::FieldNotIndexed {
tag_name: tag_name.to_string(),
field_name: field_name.to_string(),
});
};
let collection_name = self.collection_name_for(&loc);
if self.granularity() == CollectionGranularity::Field {
if self.backend.index_exists(&collection_name) {
self.backend.delete_collection(&collection_name).await?;
}
self.backend
.create_index(&collection_name, &meta.config)
.await?;
} else {
let Some(group_id) = self.group_id_for(&loc) else {
return Err(VectorCoordinatorError::Vector(VectorError::Internal(format!(
"space-granularity vector index {space_id}.{tag_name}.{field_name} has no group_id"
))));
};
self.backend
.delete_by_filter(
&collection_name,
VectorFilter::new().must(FilterCondition::match_value("group_id", group_id)),
)
.await?;
if let Err(error) = self
.backend
.create_payload_index(&collection_name, "group_id", PayloadSchemaType::Keyword)
.await
{
tracing::warn!(
"Failed to ensure payload index for group_id in collection '{}': {}",
collection_name,
error
);
}
}
Ok(())
}
fn temp_owner_suffix(tag_name: &str, field_name: &str) -> String {
format!("{tag_name}_{field_name}")
}
pub fn temp_name_for(
&self,
space_id: u64,
tag_name: &str,
field_name: &str,
generation: u64,
) -> String {
let loc = VectorIndexLocation::new(space_id, tag_name, field_name);
let live = self.live_collection_name(&loc);
let owner = (self.granularity() == CollectionGranularity::Space)
.then(|| Self::temp_owner_suffix(tag_name, field_name));
VectorBackend::temp_collection_name(&live, generation, owner.as_deref())
}
pub async fn create_temp_collection(
&self,
space_id: u64,
tag_name: &str,
field_name: &str,
generation: u64,
) -> VectorCoordinatorResult<String> {
if self.is_disabled_engine() {
return Err(VectorCoordinatorError::EngineDisabled);
}
let loc = VectorIndexLocation::new(space_id, tag_name, field_name);
let Some(meta) = self.logical_indexes.get(&loc).map(|entry| entry.clone()) else {
return Err(VectorCoordinatorError::FieldNotIndexed {
tag_name: tag_name.to_string(),
field_name: field_name.to_string(),
});
};
let live = self.live_collection_name(&loc);
let temp = self.temp_name_for(space_id, tag_name, field_name, generation);
let config = self.backend.effective_collection_config(&meta.config);
self.backend.drop_temp_collection(&temp).await?;
self.backend.create_temp_collection(&temp, &config).await?;
if self.granularity() == CollectionGranularity::Space {
if let Err(error) = self
.backend
.create_payload_index(&temp, "group_id", PayloadSchemaType::Keyword)
.await
{
tracing::warn!(
"Failed to create payload index for group_id in temp collection '{}': {}",
temp,
error
);
}
}
self.temp_collections.insert(
(
space_id,
tag_name.to_string(),
field_name.to_string(),
generation,
),
TempCollection {
temp_name: temp.clone(),
live_name: live,
config,
created_at: chrono::Utc::now(),
},
);
Ok(temp)
}
pub fn get_temp(
&self,
space_id: u64,
tag_name: &str,
field_name: &str,
generation: u64,
) -> Option<TempCollection> {
self.temp_collections
.get(&(
space_id,
tag_name.to_string(),
field_name.to_string(),
generation,
))
.map(|entry| entry.clone())
}
pub fn list_temps(&self) -> Vec<((u64, String, String, u64), TempCollection)> {
self.temp_collections
.iter()
.map(|entry| (entry.key().clone(), entry.value().clone()))
.collect()
}
pub fn resolve_write_target(
&self,
space_id: u64,
tag_name: &str,
field_name: &str,
generation: u64,
) -> String {
if let Some(temp) = self.get_temp(space_id, tag_name, field_name, generation) {
return temp.temp_name;
}
let loc = VectorIndexLocation::new(space_id, tag_name, field_name);
self.collection_name_for(&loc)
}
pub async fn drop_temp_collection(
&self,
space_id: u64,
tag_name: &str,
field_name: &str,
generation: u64,
) -> VectorCoordinatorResult<()> {
let key = (
space_id,
tag_name.to_string(),
field_name.to_string(),
generation,
);
let Some((_, temp)) = self.temp_collections.remove(&key) else {
return Ok(());
};
self.backend.drop_temp_collection(&temp.temp_name).await
}
pub async fn drop_temp_by_name(&self, temp: &str) -> VectorCoordinatorResult<()> {
self.backend.drop_temp_collection(temp).await
}
async fn discard_temps_for(&self, space_id: u64, tag_name: &str, field_name: &str) {
let keys: Vec<TempKey> = self
.temp_collections
.iter()
.filter(|entry| {
entry.key().0 == space_id
&& entry.key().1 == tag_name
&& entry.key().2 == field_name
})
.map(|entry| entry.key().clone())
.collect();
for key in keys {
if let Some((_, temp)) = self.temp_collections.remove(&key) {
if let Err(error) = self.backend.drop_temp_collection(&temp.temp_name).await {
tracing::warn!(
"Failed to discard rebuild temp '{}': {}",
temp.temp_name,
error
);
}
}
}
}
pub fn set_collection_override(&self, loc: &VectorIndexLocation, target: String) {
self.collection_overrides.insert(loc.clone(), target);
}
pub fn set_logical_collection_name(&self, loc: &VectorIndexLocation, target: String) {
if let Some(mut meta) = self.logical_indexes.get_mut(loc) {
meta.name = target;
}
}
pub fn clear_collection_override(&self, loc: &VectorIndexLocation) {
self.collection_overrides.remove(loc);
}
pub fn promote_backup_for(&self, live: &str) -> Option<String> {
self.promote_backups.get(live).map(|entry| entry.clone())
}
pub async fn publish_temp_collection(
&self,
space_id: u64,
tag_name: &str,
field_name: &str,
generation: u64,
copy_batch: usize,
) -> VectorCoordinatorResult<PublishOutcome> {
if self.is_disabled_engine() {
return Err(VectorCoordinatorError::EngineDisabled);
}
let loc = VectorIndexLocation::new(space_id, tag_name, field_name);
let Some(temp) = self.get_temp(space_id, tag_name, field_name, generation) else {
return Err(VectorCoordinatorError::Vector(VectorError::Internal(
format!("no rebuild temp registered for {space_id}.{tag_name}.{field_name} generation {generation}"),
)));
};
if self.granularity() == CollectionGranularity::Field {
return self.publish_temp_field(&loc, &temp, generation).await;
}
self.publish_temp_slice(&loc, &temp, generation, copy_batch)
.await
}
async fn publish_temp_field(
&self,
loc: &VectorIndexLocation,
temp: &TempCollection,
generation: u64,
) -> VectorCoordinatorResult<PublishOutcome> {
if self.backend.is_local() {
let backup_suffix = VectorBackend::promote_backup_suffix(generation);
self.backend
.promote_temp_collection(&temp.live_name, &temp.temp_name, &backup_suffix)
.await?;
let backup = format!("{}{}", temp.live_name, backup_suffix);
self.promote_backups
.insert(temp.live_name.clone(), backup.clone());
self.temp_collections.remove(&(
loc.space_id,
loc.tag_name.clone(),
loc.field_name.clone(),
generation,
));
return Ok(PublishOutcome {
backup: Some(backup),
copied_points: 0,
});
}
let previous = self.collection_name_for(loc);
if let Some(mut meta) = self.logical_indexes.get_mut(loc) {
meta.name = temp.temp_name.clone();
}
self.collection_overrides
.insert(loc.clone(), temp.temp_name.clone());
let rate_backup = if previous != temp.temp_name {
if VectorBackend::is_temp_collection(&previous) {
if let Some((_, older)) = self.promote_backups.remove(&temp.live_name) {
if older != previous && self.backend.index_exists(&older) {
if let Err(error) = self.backend.delete_collection(&older).await {
tracing::warn!(
"Failed to prune superseded promote backup '{}': {}",
older,
error
);
}
}
}
}
self.promote_backups
.insert(temp.live_name.clone(), previous.clone());
Some(previous)
} else {
None
};
self.temp_collections.remove(&(
loc.space_id,
loc.tag_name.clone(),
loc.field_name.clone(),
generation,
));
Ok(PublishOutcome {
backup: rate_backup,
copied_points: 0,
})
}
async fn publish_temp_slice(
&self,
loc: &VectorIndexLocation,
temp: &TempCollection,
generation: u64,
copy_batch: usize,
) -> VectorCoordinatorResult<PublishOutcome> {
let Some(group_id) = self.group_id_for(loc) else {
return Err(VectorCoordinatorError::Vector(VectorError::Internal(
format!(
"space-granularity vector index {}.{}.{} has no group_id",
loc.space_id, loc.tag_name, loc.field_name
),
)));
};
let batch = copy_batch.max(1);
let backup =
VectorBackend::temp_collection_name(&temp.live_name, generation, Some("slicebak"));
self.backend.drop_temp_collection(&backup).await?;
self.backend
.create_temp_collection(&backup, &temp.config)
.await?;
if let Err(error) = self
.backend
.create_payload_index(&backup, "group_id", PayloadSchemaType::Keyword)
.await
{
tracing::warn!(
"Failed to create payload index for group_id in slice backup '{}': {}",
backup,
error
);
}
if let Err(error) = self
.copy_group_slice(&temp.live_name, &backup, &group_id, batch)
.await
{
self.backend.drop_temp_collection(&backup).await.ok();
return Err(VectorCoordinatorError::Vector(VectorError::Internal(
format!("space-granularity publish backup of slice '{group_id}' failed: {error}"),
)));
}
self.backend
.delete_by_filter(
&temp.live_name,
VectorFilter::new()
.must(FilterCondition::match_value("group_id", group_id.clone())),
)
.await?;
let copied = match self
.copy_group_slice(&temp.temp_name, &temp.live_name, &group_id, batch)
.await
{
Ok(copied) => copied,
Err(error) => {
tracing::warn!(
"Space-granularity publish copy failed; restoring slice '{group_id}' from backup: {error}"
);
if let Err(restore) = self
.copy_group_slice(&backup, &temp.live_name, &group_id, batch)
.await
{
tracing::warn!(
"Failed to restore space-granularity slice '{group_id}' from backup '{}': {}",
backup,
restore
);
}
self.backend.drop_temp_collection(&backup).await.ok();
return Err(VectorCoordinatorError::Vector(VectorError::Internal(format!(
"space-granularity publish copy of slice '{group_id}' failed (backup restored): {error}"
))));
}
};
self.backend.drop_temp_collection(&temp.temp_name).await?;
self.backend.drop_temp_collection(&backup).await.ok();
self.temp_collections.remove(&(
loc.space_id,
loc.tag_name.clone(),
loc.field_name.clone(),
generation,
));
Ok(PublishOutcome {
backup: None,
copied_points: copied,
})
}
async fn copy_group_slice(
&self,
from: &str,
to: &str,
group_id: &str,
batch: usize,
) -> VectorCoordinatorResult<u64> {
let mut copied = 0u64;
let mut offset: Option<String> = None;
loop {
let (points, next) = self
.backend
.scroll(from, batch, offset.as_deref(), Some(true), Some(true))
.await?;
let slice_points: Vec<_> = points
.into_iter()
.filter(|point| {
point.payload.as_ref().is_some_and(|payload| {
payload.get("group_id")
== Some(&serde_json::Value::String(group_id.to_string()))
})
})
.collect();
copied += slice_points.len() as u64;
if !slice_points.is_empty() {
self.backend.upsert_batch(to, slice_points).await?;
}
offset = next;
if offset.is_none() {
break;
}
}
Ok(copied)
}
pub async fn restore_promote_backup(
&self,
space_id: u64,
tag_name: &str,
field_name: &str,
) -> VectorCoordinatorResult<String> {
let loc = VectorIndexLocation::new(space_id, tag_name, field_name);
if self.granularity() != CollectionGranularity::Field {
return Err(VectorCoordinatorError::Vector(VectorError::Internal(
"space-granularity publishes keep no backup; rebuild to converge".to_string(),
)));
}
let live = self.live_collection_name(&loc);
if self.backend.is_local() {
let restored = self.backend.restore_promote_backup(&live).await?;
self.promote_backups.remove(&live);
return Ok(restored);
}
let Some(backup) = self.promote_backups.get(&live).map(|entry| entry.clone()) else {
return Err(VectorCoordinatorError::Vector(VectorError::Internal(
format!("no promote backup retained for {space_id}.{tag_name}.{field_name}"),
)));
};
if !self.backend.index_exists(&backup) {
return Err(VectorCoordinatorError::Vector(VectorError::Internal(
format!("promote backup '{backup}' is gone; rebuild to converge"),
)));
}
if let Some(mut meta) = self.logical_indexes.get_mut(&loc) {
meta.name = backup.clone();
}
self.collection_overrides.insert(loc, backup.clone());
Ok(backup)
}
pub fn index_exists(&self, space_id: u64, tag_name: &str, field_name: &str) -> bool {
let logical_key = VectorIndexLocation::new(space_id, tag_name, field_name);
self.logical_indexes.contains_key(&logical_key)
}
pub fn set_index_name(
&self,
space_id: u64,
tag_name: &str,
field_name: &str,
index_name: &str,
) {
let logical_key = VectorIndexLocation::new(space_id, tag_name, field_name);
if let Some(mut meta) = self.logical_indexes.get_mut(&logical_key) {
meta.index_name = Some(index_name.to_string());
}
}
pub fn index_info(
&self,
space_id: u64,
tag_name: &str,
field_name: &str,
) -> Option<IndexMetadata> {
let logical_key = VectorIndexLocation::new(space_id, tag_name, field_name);
self.logical_indexes.get(&logical_key).map(|v| v.clone())
}
pub fn list_indexes(&self) -> Vec<super::vector_sync::IndexMetadataWrapper> {
self.logical_indexes
.iter()
.map(|pair| {
let location = pair.key();
super::vector_sync::IndexMetadataWrapper {
collection_name: pair.value().name.clone(),
space_id: location.space_id,
tag_name: location.tag_name.clone(),
field_name: location.field_name.clone(),
index_name: pair.value().index_name.clone(),
}
})
.collect()
}
pub fn register_logical_index(
&self,
space_id: u64,
tag_name: &str,
field_name: &str,
collection_name: String,
config: CollectionConfig,
user_index_name: Option<String>,
) {
let logical_key = VectorIndexLocation::new(space_id, tag_name, field_name);
let meta = if let Some(idx_name) = user_index_name {
IndexMetadata::with_index_name(collection_name, config, idx_name)
} else {
IndexMetadata::new(collection_name, config)
};
self.logical_indexes.insert(logical_key, meta);
}
pub async fn search(
&self,
collection: &str,
query: SearchQuery,
) -> VectorCoordinatorResult<Vec<SearchResult>> {
if self.is_disabled_engine() {
return Err(VectorCoordinatorError::EngineDisabled);
}
let results = self.backend.search(collection, query).await?;
Ok(results)
}
pub async fn search_stream(
&self,
collection: &str,
query: SearchQuery,
) -> VectorCoordinatorResult<
std::pin::Pin<
Box<dyn futures::Stream<Item = VectorCoordinatorResult<SearchResult>> + Send>,
>,
> {
if self.is_disabled_engine() {
return Err(VectorCoordinatorError::EngineDisabled);
}
let stream = self.backend.search_stream(collection, query).await?;
Ok(stream)
}
pub async fn scroll_stream(
&self,
collection: &str,
batch_size: usize,
with_payload: Option<bool>,
with_vector: Option<bool>,
) -> VectorCoordinatorResult<
std::pin::Pin<Box<dyn futures::Stream<Item = VectorCoordinatorResult<VectorPoint>> + Send>>,
> {
if self.is_disabled_engine() {
return Err(VectorCoordinatorError::EngineDisabled);
}
let stream = self
.backend
.scroll_stream(collection, batch_size, with_payload, with_vector)
.await?;
Ok(stream)
}
pub async fn search_with_options(
&self,
options: SearchOptions,
) -> VectorCoordinatorResult<Vec<SearchResult>> {
if self.is_disabled_engine() {
return Err(VectorCoordinatorError::EngineDisabled);
}
let loc =
VectorIndexLocation::new(options.space_id, &options.tag_name, &options.field_name);
let collection_name = self.collection_name_for(&loc);
let mut query = SearchQuery::new(options.query_vector, options.limit);
if let Some(threshold) = options.threshold {
query = query.with_score_threshold(threshold);
}
let mut filter = options.filter.unwrap_or_default();
if let Some(gid) = self.group_id_for(&loc) {
filter = filter.must(FilterCondition::match_value("group_id", gid));
}
query = query.with_filter(filter);
let results = self.search(&collection_name, query).await?;
Ok(results)
}
pub async fn search_by_location(
&self,
space_id: u64,
tag_name: &str,
field_name: &str,
query_vector: Vec<f32>,
limit: usize,
) -> VectorCoordinatorResult<Vec<SearchResult>> {
if self.is_disabled_engine() {
return Err(VectorCoordinatorError::EngineDisabled);
}
let loc = VectorIndexLocation::new(space_id, tag_name, field_name);
let collection_name = self.collection_name_for(&loc);
let mut query = SearchQuery::new(query_vector, limit);
if let Some(gid) = self.group_id_for(&loc) {
let filter = VectorFilter::new().must(FilterCondition::match_value("group_id", gid));
query = query.with_filter(filter);
}
self.search(&collection_name, query).await
}
pub async fn search_with_filter(
&self,
space_id: u64,
tag_name: &str,
field_name: &str,
query_vector: Vec<f32>,
limit: usize,
filter: VectorFilter,
) -> VectorCoordinatorResult<Vec<SearchResult>> {
if self.is_disabled_engine() {
return Err(VectorCoordinatorError::EngineDisabled);
}
let loc = VectorIndexLocation::new(space_id, tag_name, field_name);
let collection_name = self.collection_name_for(&loc);
let mut enriched = filter;
if let Some(gid) = self.group_id_for(&loc) {
enriched = enriched.must(FilterCondition::match_value("group_id", gid));
}
let query = SearchQuery::new(query_vector, limit).with_filter(enriched);
self.search(&collection_name, query).await
}
pub async fn search_with_threshold_and_filter(
&self,
mut options: SearchOptions,
threshold: f32,
filter: VectorFilter,
) -> VectorCoordinatorResult<Vec<SearchResult>> {
options.threshold = Some(threshold);
options.filter = Some(filter);
self.search_with_options(options).await
}
pub(crate) fn prepare_change_batch_for_collection(
&self,
collection_override: Option<&str>,
contexts: Vec<super::vector_sync::VectorChangeContext>,
) -> (
HashMap<String, Vec<VectorPoint>>,
HashMap<String, Vec<String>>,
) {
use super::vector_sync::VectorChangeType;
let mut upsert_by_collection: HashMap<String, Vec<VectorPoint>> = HashMap::new();
let mut delete_by_collection: HashMap<String, Vec<String>> = HashMap::new();
for ctx in contexts {
let collection_name = collection_override
.map(str::to_string)
.unwrap_or_else(|| self.collection_name_for(&ctx.location));
let point_id = ctx.data.id.to_string();
match ctx.change_type {
VectorChangeType::Insert => {
let vector = ctx.data.vector;
let mut json_payload: HashMap<String, serde_json::Value> = ctx
.data
.payload
.into_iter()
.filter_map(|(k, v)| {
serde_json::to_value(&v)
.ok()
.map(|json| (k.to_string(), json))
})
.collect();
if let Some(gid) = self.group_id_for(&ctx.location) {
json_payload.insert(
"group_id".into(),
serde_json::to_value(gid).unwrap_or(serde_json::Value::Null),
);
}
let point = VectorPoint::new(point_id, vector).with_payload(json_payload);
upsert_by_collection
.entry(collection_name)
.or_default()
.push(point);
}
VectorChangeType::Delete => {
delete_by_collection
.entry(collection_name)
.or_default()
.push(point_id);
}
}
}
(upsert_by_collection, delete_by_collection)
}
}