From dad772e9db7eef805608fd4bd8599f10bd77937a Mon Sep 17 00:00:00 2001 From: generall Date: Mon, 7 Sep 2026 17:59:44 +0200 Subject: [PATCH] Upgrade to paginated serverless ListCollections and KeywordIndex builder rust-client dev (#296) changed the serverless API: list_collections now takes a ListCollections request and returns a page with next_offset_token, and KeywordIndex became a struct with an optional prefix-matching flag. - list_matching walks all pages (100 per request) instead of assuming a single unpaginated response, so spaces with >20 collections are fully listed and cleared. - to_serverless_config forwards the upload YAML's keyword `prefix: true` onto the serverless index, which the previous unit-struct could not express. Co-Authored-By: Claude Fable 5.1 --- Cargo.lock | 4 ++-- src/serverless/collections.rs | 38 ++++++++++++++++++++++++++--------- src/serverless/convert.rs | 34 ++++++++++++++++++++++++++++++- 3 files changed, 64 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fd6dc49..d71f7a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1472,7 +1472,7 @@ dependencies = [ [[package]] name = "qdrant-client" version = "1.16.1-dev" -source = "git+https://github.com/qdrant/rust-client?branch=dev#b6bacf7cdc50dc0a64ed6ff7e6d159bc186e903a" +source = "git+https://github.com/qdrant/rust-client?branch=dev#31b5f12735cd5142d3f7468bc1cad52a4b80f7b2" dependencies = [ "anyhow", "derive_builder", @@ -1880,7 +1880,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.61.2", diff --git a/src/serverless/collections.rs b/src/serverless/collections.rs index a48a669..2829998 100644 --- a/src/serverless/collections.rs +++ b/src/serverless/collections.rs @@ -5,7 +5,9 @@ use std::collections::HashSet; use std::sync::Mutex; use anyhow::{Context, Result, bail}; -use qdrant_client::serverless::{CollectionConfig, CollectionSummary, QdrantServerless}; +use qdrant_client::serverless::{ + CollectionConfig, CollectionSummary, ListCollectionsBuilder, QdrantServerless, +}; use tokio::sync::Mutex as AsyncMutex; /// Name of the `i`-th collection under `prefix` (`benchmark-` + `0` → `benchmark-0`). @@ -13,19 +15,37 @@ pub fn collection_name(prefix: &str, index: usize) -> String { format!("{prefix}{index}") } +/// Largest page the serverless `ListCollections` API allows. +const LIST_PAGE_SIZE: u32 = 100; + /// All collections currently in the space whose name starts with `prefix`, -/// sorted by name. +/// sorted by name. Walks every page of the paginated listing. pub async fn list_matching( client: &QdrantServerless, prefix: &str, ) -> Result> { - let mut summaries: Vec = client - .list_collections() - .await - .context("list_collections")? - .into_iter() - .filter(|c| c.collection_name.starts_with(prefix)) - .collect(); + let mut summaries: Vec = Vec::new(); + let mut offset_token: Option = None; + loop { + let mut request = ListCollectionsBuilder::new().limit(LIST_PAGE_SIZE); + if let Some(token) = offset_token.take() { + request = request.offset_token(token); + } + let page = client + .list_collections(request) + .await + .context("list_collections")?; + summaries.extend( + page.collections + .into_iter() + .filter(|c| c.collection_name.starts_with(prefix)), + ); + match page.next_offset_token { + // Guard against a server that echoes an empty token for "no more pages". + Some(token) if !token.is_empty() => offset_token = Some(token), + _ => break, + } + } summaries.sort_by(|a, b| a.collection_name.cmp(&b.collection_name)); Ok(summaries) } diff --git a/src/serverless/convert.rs b/src/serverless/convert.rs index 5dc10eb..9c9bb25 100644 --- a/src/serverless/convert.rs +++ b/src/serverless/convert.rs @@ -45,7 +45,13 @@ pub fn to_serverless_config(upload: &UploadConfig) -> Result { continue; } let index: PayloadIndex = match field.kind { - PayloadType::Keyword => KeywordIndex.into(), + PayloadType::Keyword => { + let mut keyword = KeywordIndex::new(); + if field.prefix { + keyword = keyword.with_prefix(); + } + keyword.into() + } PayloadType::Integer => IntegerIndex::new() .lookup(true) .range(field.range_index) @@ -109,4 +115,30 @@ collection: assert_eq!(cfg.dense_vectors[""].size, 128); assert!(cfg.payload_indexes.contains_key("color")); } + + #[test] + fn keyword_prefix_flag_is_forwarded() { + let yaml = r#" +collection: + vectors: + - size: 4 + distance: dot + fields: + - name: plain + type: keyword + - name: prefixed + type: keyword + prefix: true +"#; + let upload = crate::config::parse(yaml, "test").unwrap(); + let cfg = to_serverless_config(&upload).unwrap(); + match &cfg.payload_indexes["plain"] { + PayloadIndex::Keyword(k) => assert!(k.prefix.is_none()), + other => panic!("expected keyword index, got {other:?}"), + } + match &cfg.payload_indexes["prefixed"] { + PayloadIndex::Keyword(k) => assert!(k.prefix.is_some()), + other => panic!("expected keyword index, got {other:?}"), + } + } }