mod cursor;
mod escape;
mod indexes;
mod point;
mod quant;
mod schema;
use std::error::Error;
use std::fs::{self, File};
use std::io::{BufWriter, Write};
use std::path::Path;
use qql::client::QdrantOps;
use qql::executor::Executor;
use qql_core::ast::ShardKey;
use qql_plan::PlannedOperation;
use qql_plan::semantic::{PlanPointId, PlanShardKey};
use qql_plan::types::{FilterExpression, PayloadSelectorReq, ScrollRequest, VectorSelectorReq};
use point::write_upsert_batch;
pub(crate) use cursor::{extract_scroll_page, json_to_plan_point_id};
pub(crate) use escape::format_ident;
pub(crate) use indexes::generate_index_statements;
pub(crate) use point::point_to_upsert_object;
pub(crate) use schema::generate_create_statement;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DumpStats {
pub written: usize,
pub skipped: usize,
pub batches: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DumpProgress {
pub collection: String,
pub written: usize,
pub skipped: usize,
pub batches: usize,
}
pub async fn dump_collection(
executor: &Executor,
collection: &str,
output_path: &str,
batch_size: u32,
progress: Option<&(dyn Fn(DumpProgress) + Sync)>,
) -> Result<DumpStats, Box<dyn Error>> {
let tmp_path = format!("{}.tmp", output_path);
let res = dump_collection_inner(executor, collection, &tmp_path, batch_size, progress).await;
match res {
Ok(stats) => {
if let Err(rename_err) = fs::rename(&tmp_path, output_path) {
if let Err(copy_err) = fs::copy(&tmp_path, output_path) {
return Err(format!(
"failed to finalize dump at '{}': rename error: {}; copy error: {}",
output_path, rename_err, copy_err
)
.into());
}
let _ = fs::remove_file(&tmp_path);
}
Ok(stats)
}
Err(err) => {
if Path::new(&tmp_path).exists() {
let _ = fs::remove_file(&tmp_path);
}
Err(err)
}
}
}
async fn dump_collection_inner(
executor: &Executor,
collection: &str,
output_path: &str,
batch_size: u32,
progress: Option<&(dyn Fn(DumpProgress) + Sync)>,
) -> Result<DumpStats, Box<dyn Error>> {
if batch_size == 0 {
return Err("batch_size must be >= 1".into());
}
let ops = executor.ops();
let exists = ops.collection_exists(collection).await?;
if !exists {
return Err(format!("collection '{}' does not exist", collection).into());
}
let info = ops.get_collection_info(collection).await?;
if let Some(parent) = Path::new(output_path).parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent)?;
}
let file = File::create(output_path)?;
let mut out = BufWriter::new(file);
writeln!(out, "-- QQL dump for {}", collection)?;
writeln!(out, "-- Generated by qql dump")?;
writeln!(out)?;
let create = generate_create_statement(collection, &info);
writeln!(out, "{};", create)?;
writeln!(out)?;
let indexes = generate_index_statements(collection, &info.schema.payload_indexes);
if !indexes.is_empty() {
for idx in &indexes {
writeln!(out, "{};", idx)?;
}
writeln!(out)?;
}
let shard_keys = list_shard_keys(ops, collection).await?;
for key in &shard_keys {
writeln!(
out,
"CREATE SHARD KEY {} ON COLLECTION {};",
key,
format_ident(collection)
)?;
}
if !shard_keys.is_empty() {
writeln!(out)?;
}
let shards: Vec<Option<&ShardKey>> = if shard_keys.is_empty() {
vec![None]
} else {
shard_keys.iter().map(Some).collect()
};
let mut written = 0usize;
let mut skipped = 0usize;
let mut batches = 0usize;
for shard in shards {
let mut pages =
ScrollPages::new(ops, collection, batch_size).shard(shard.map(PlanShardKey::from));
while let Some(points) = pages.next().await? {
let mut records = Vec::with_capacity(points.len());
for point in &points {
match point_to_upsert_object(point) {
Some(rec) => records.push(rec),
None => skipped += 1,
}
}
if !records.is_empty() {
write_upsert_batch(&mut out, collection, &records, shard)?;
written += records.len();
batches += 1;
if let Some(cb) = progress {
cb(DumpProgress {
collection: collection.to_string(),
written,
skipped,
batches,
});
}
}
}
}
writeln!(out)?;
writeln!(out, "-- Written: {}", written)?;
writeln!(out, "-- Skipped: {}", skipped)?;
writeln!(out, "-- Batches: {}", batches)?;
out.flush()?;
Ok(DumpStats {
written,
skipped,
batches,
})
}
pub(crate) async fn list_shard_keys(
ops: &dyn QdrantOps,
collection: &str,
) -> Result<Vec<ShardKey>, Box<dyn Error>> {
let op = PlannedOperation::ListShardKeys {
collection: collection.to_string(),
};
let response = ops.execute_planned(&op).await?;
Ok(response
.data
.shard_keys()
.map(parse_shard_key_list)
.unwrap_or_default())
}
pub(crate) fn parse_shard_key_list(keys: &[PlanShardKey]) -> Vec<ShardKey> {
let mut deduped: std::collections::HashMap<String, ShardKey> = std::collections::HashMap::new();
for key in keys {
let key = match key {
PlanShardKey::Keyword(text) => ShardKey::Keyword(text.clone()),
PlanShardKey::Number(number) => ShardKey::Number(*number),
};
deduped.insert(key.to_string(), key);
}
let mut out: Vec<(String, ShardKey)> = deduped.into_iter().collect();
out.sort_by(|a, b| a.0.cmp(&b.0));
out.into_iter().map(|(_, k)| k).collect()
}
pub(crate) struct ScrollPages<'a> {
ops: &'a dyn QdrantOps,
collection: String,
batch_size: u32,
filter: Option<FilterExpression>,
shard_key: Option<PlanShardKey>,
with_payload: PayloadSelectorReq,
with_vector: VectorSelectorReq,
after: Option<PlanPointId>,
fell_back: bool,
done: bool,
}
impl<'a> ScrollPages<'a> {
pub(crate) fn new(ops: &'a dyn QdrantOps, collection: &str, batch_size: u32) -> Self {
Self {
ops,
collection: collection.to_string(),
batch_size,
filter: None,
shard_key: None,
with_payload: PayloadSelectorReq::All(true),
with_vector: VectorSelectorReq::All(true),
after: None,
fell_back: false,
done: false,
}
}
pub(crate) fn filter(mut self, filter: Option<FilterExpression>) -> Self {
self.filter = filter;
self
}
pub(crate) fn shard(mut self, shard_key: Option<PlanShardKey>) -> Self {
self.shard_key = shard_key;
self
}
pub(crate) fn resume(mut self, after: Option<PlanPointId>) -> Self {
self.after = after;
self
}
pub(crate) fn select(
mut self,
with_payload: PayloadSelectorReq,
with_vector: VectorSelectorReq,
) -> Self {
self.with_payload = with_payload;
self.with_vector = with_vector;
self
}
pub(crate) fn cursor(&self) -> Option<&PlanPointId> {
self.after.as_ref()
}
pub(crate) async fn next(&mut self) -> Result<Option<Vec<serde_json::Value>>, Box<dyn Error>> {
if self.done {
return Ok(None);
}
let op = PlannedOperation::Scroll {
collection: self.collection.clone(),
request: ScrollRequest {
filter: self.filter.clone(),
offset: self.after.clone(),
limit: Some(self.batch_size as u64),
with_payload: Some(self.with_payload.clone()),
with_vector: Some(self.with_vector.clone()),
order_by: None,
shard_key: self.shard_key.clone(),
},
};
let response = self.ops.execute_planned(&op).await?;
let (points, next) = extract_scroll_page(&response);
let mut points = points;
if self.fell_back {
points = drop_resumed_point(points, self.after.as_ref());
}
if points.is_empty() {
self.done = true;
return Ok(None);
}
self.fell_back = next.is_none();
self.after = next_scroll_cursor(next, &points);
Ok(Some(points))
}
}
pub(crate) fn drop_resumed_point(
mut points: Vec<serde_json::Value>,
after: Option<&PlanPointId>,
) -> Vec<serde_json::Value> {
let duplicate = match (after, points.first()) {
(Some(cursor), Some(first)) => {
first.get("id").and_then(json_to_plan_point_id).as_ref() == Some(cursor)
}
_ => false,
};
if duplicate {
points.remove(0);
}
points
}
pub(crate) fn next_scroll_cursor(
next: Option<PlanPointId>,
points: &[serde_json::Value],
) -> Option<PlanPointId> {
next.or_else(|| {
points
.last()
.and_then(|p| p.get("id"))
.and_then(json_to_plan_point_id)
})
}
#[cfg(test)]
mod tests;