use crate::error::store_err;
use dashmap::DashMap;
use klieo_core::error::MemoryError;
use qdrant_client::{
qdrant::{CreateCollectionBuilder, Distance, VectorParamsBuilder},
Qdrant,
};
use secrecy::{ExposeSecret, SecretString};
use std::sync::Arc;
use tokio::sync::OnceCell;
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct QdrantConfig {
url: String,
api_key: Option<SecretString>,
collection_prefix: String,
allow_plaintext_remote: bool,
embedder_id: String,
}
impl QdrantConfig {
pub fn new(url: impl Into<String>) -> Self {
Self {
url: url.into(),
api_key: None,
collection_prefix: "klieo_facts".to_string(),
allow_plaintext_remote: false,
embedder_id: "default".to_string(),
}
}
pub fn with_api_key(mut self, key: impl Into<SecretString>) -> Self {
self.api_key = Some(key.into());
self
}
pub fn with_collection_prefix(mut self, prefix: impl Into<String>) -> Self {
self.collection_prefix = prefix.into();
self
}
pub fn allow_plaintext_remote(mut self) -> Self {
self.allow_plaintext_remote = true;
self
}
pub fn with_embedder_id(mut self, id: impl Into<String>) -> Self {
self.embedder_id = id.into();
self
}
pub fn embedder_id(&self) -> &str {
&self.embedder_id
}
pub(crate) fn url(&self) -> &str {
&self.url
}
pub(crate) fn api_key(&self) -> Option<&str> {
self.api_key
.as_ref()
.map(|s: &SecretString| s.expose_secret())
}
pub(crate) fn collection_prefix(&self) -> &str {
&self.collection_prefix
}
}
#[derive(Clone)]
pub(crate) struct QdrantHandle {
pub(crate) client: Arc<Qdrant>,
pub(crate) collection_prefix: String,
bootstrapped: Arc<DashMap<String, Arc<OnceCell<()>>>>,
}
impl QdrantHandle {
pub(crate) fn connect(cfg: &QdrantConfig) -> Result<Self, MemoryError> {
check_plaintext_scheme(cfg.url(), cfg.allow_plaintext_remote)?;
let mut builder = Qdrant::from_url(cfg.url());
if let Some(key) = cfg.api_key() {
builder = builder.api_key(key.to_string());
}
let client = builder.build().map_err(store_err)?;
Ok(Self {
client: Arc::new(client),
collection_prefix: cfg.collection_prefix().to_string(),
bootstrapped: Arc::new(DashMap::new()),
})
}
pub(crate) async fn ensure_collection(
&self,
name: &str,
vector_dim: u64,
) -> Result<(), MemoryError> {
let key = format!("{name}/{vector_dim}");
let cell = self
.bootstrapped
.entry(key)
.or_insert_with(|| Arc::new(OnceCell::new()))
.clone();
cell.get_or_try_init(|| self.bootstrap_collection(name, vector_dim))
.await?;
Ok(())
}
async fn bootstrap_collection(&self, name: &str, vector_dim: u64) -> Result<(), MemoryError> {
let exists = self
.client
.collection_exists(name)
.await
.map_err(store_err)?;
if exists {
return self.verify_existing_dim(name, vector_dim).await;
}
let req = CreateCollectionBuilder::new(name)
.vectors_config(VectorParamsBuilder::new(vector_dim, Distance::Cosine));
self.client
.create_collection(req)
.await
.map_err(store_err)?;
Ok(())
}
async fn verify_existing_dim(&self, name: &str, expected_dim: u64) -> Result<(), MemoryError> {
let info = self.client.collection_info(name).await.map_err(store_err)?;
let actual = info
.result
.and_then(|i| i.config)
.and_then(|c| c.params)
.and_then(|p| p.vectors_config)
.and_then(|vc| vc.config)
.and_then(single_vector_dim);
match actual {
Some(actual) if actual != expected_dim => Err(MemoryError::Embedding(format!(
"qdrant collection `{name}` has dimension {actual}, but the configured \
embedder produces {expected_dim}-dim vectors; a model change needs a new \
collection or a re-index"
))),
_ => Ok(()),
}
}
}
fn single_vector_dim(config: qdrant_client::qdrant::vectors_config::Config) -> Option<u64> {
use qdrant_client::qdrant::vectors_config::Config;
match config {
Config::Params(params) => Some(params.size),
Config::ParamsMap(_) => None,
}
}
fn check_plaintext_scheme(url: &str, allow_plaintext_remote: bool) -> Result<(), MemoryError> {
if !url.starts_with("http://") {
return Ok(());
}
if allow_plaintext_remote {
return Ok(());
}
let after_scheme = url.trim_start_matches("http://");
let authority = after_scheme.split('/').next().unwrap_or("");
let host = if let Some(end) = authority.find(']') {
&authority[..=end]
} else {
authority.split(':').next().unwrap_or("")
};
if matches!(host, "localhost" | "127.0.0.1" | "[::1]" | "::1") {
return Ok(());
}
Err(MemoryError::Store(format!(
"QdrantConfig URL `{url}` is plaintext http:// to a non-loopback host; \
the api-key is sent in cleartext. Use https://, switch to a loopback \
binding, or set QdrantConfig::allow_plaintext_remote() if a sealed \
pod network (mTLS/sidecar) handles the encryption."
)))
}
#[cfg(test)]
mod tests {
use super::*;
use qdrant_client::qdrant::vectors_config::Config;
use qdrant_client::qdrant::{VectorParams, VectorParamsMap};
use std::sync::atomic::{AtomicUsize, Ordering};
#[test]
fn single_vector_dim_reads_unnamed_vector_size() {
let config = Config::Params(VectorParams {
size: 768,
..Default::default()
});
assert_eq!(single_vector_dim(config), Some(768));
}
#[test]
fn single_vector_dim_skips_named_vector_collections() {
let config = Config::ParamsMap(VectorParamsMap {
map: Default::default(),
});
assert_eq!(single_vector_dim(config), None);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn once_cell_runs_init_exactly_once_across_concurrent_callers() {
let counter = Arc::new(AtomicUsize::new(0));
let cell: Arc<OnceCell<()>> = Arc::new(OnceCell::new());
let mut handles = Vec::new();
for _ in 0..16 {
let cell = cell.clone();
let counter = counter.clone();
handles.push(tokio::spawn(async move {
cell.get_or_try_init(|| async {
counter.fetch_add(1, Ordering::SeqCst);
tokio::task::yield_now().await;
Ok::<_, MemoryError>(())
})
.await
.unwrap();
}));
}
for handle in handles {
handle.await.unwrap();
}
assert_eq!(counter.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn bootstrapped_keys_are_collection_plus_dim() {
let key_a = format!("{}/{}", "klieo_facts:dev", 768u64);
let key_b = format!("{}/{}", "klieo_facts:dev", 1024u64);
assert_ne!(key_a, key_b);
}
#[test]
fn check_plaintext_scheme_rejects_remote_http() {
assert!(check_plaintext_scheme("http://qdrant.example.com:6333", false).is_err());
assert!(check_plaintext_scheme("http://10.0.0.1:6333", false).is_err());
}
#[test]
fn check_plaintext_scheme_allows_loopback_http() {
assert!(check_plaintext_scheme("http://localhost:6333", false).is_ok());
assert!(check_plaintext_scheme("http://127.0.0.1:6333", false).is_ok());
assert!(check_plaintext_scheme("http://[::1]:6333", false).is_ok());
}
#[test]
fn check_plaintext_scheme_allows_https() {
assert!(check_plaintext_scheme("https://qdrant.example.com:6333", false).is_ok());
}
#[test]
fn check_plaintext_scheme_honours_opt_in() {
assert!(check_plaintext_scheme("http://qdrant.example.com:6333", true).is_ok());
}
}