use std::collections::{BTreeMap, HashSet};
use super::packer::PackPlan;
use super::{Store, StoreError};
use crate::clock::nanos;
use crate::ids::FileId;
impl Store {
pub(crate) fn compact(&self, plan: &PackPlan) -> Result<u64, StoreError> {
if self.packs.is_none() || plan.merge_below == 0 {
return Ok(0);
}
let (named, pending) = {
let mut tx = self.catalog.snapshot()?;
let pending: HashSet<FileId> = tx.purge_pending()?.into_iter().collect();
(tx.named_files()?, pending)
};
let mut dates: BTreeMap<String, Vec<(FileId, u64)>> = BTreeMap::new();
for (file, info, _) in named {
if info.bytes < plan.merge_below && !pending.contains(&file) {
dates.entry(info.date).or_default().push((file, info.bytes));
}
}
let mut merged = 0;
for small in dates.into_values() {
let mut chosen = Vec::new();
let mut total = 0u64;
for (file, bytes) in small {
if total.saturating_add(bytes) > plan.sizes.file as u64 && chosen.len() >= 2 {
break;
}
total = total.saturating_add(bytes);
chosen.push(file);
}
if chosen.len() < 2 {
continue;
}
if let Some(copied) = self.copy_files(&chosen)?
&& self.swap(&copied)?
{
merged += 1;
}
}
Ok(merged)
}
pub(crate) fn remove_expired(&self, plan: &PackPlan) -> Result<(), StoreError> {
let Some(packs) = self.packs.as_deref() else {
return Ok(());
};
let now = nanos(self.clock.now());
let grace = i64::try_from(plan.grace.as_nanos()).unwrap_or(i64::MAX);
let (unnamed, pending) = {
let mut tx = self.catalog.snapshot()?;
let pending: HashSet<FileId> = tx.purge_pending()?.into_iter().collect();
let mut unnamed = Vec::new();
for file in tx.unnamed_files()? {
if let Some(info) = tx.pack_file(file)? {
unnamed.push((file, info.unnamed_at));
}
}
(unnamed, pending)
};
for (file, at) in unnamed {
let expired = at.is_some_and(|at| now.saturating_sub(at) >= grace);
if expired && !pending.contains(&file) {
self.remove_unnamed(packs, file)?;
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use time::Duration as Span;
use super::*;
use crate::backend::Packs;
use crate::clock::Clock;
use crate::retention::Retention;
use crate::store::Created;
use crate::store::packed::tests::DirPacks;
use crate::store::packer::tests::{Kept, held_once, with_packs};
use crate::store::testing::vcon;
use crate::store::tests::{Fixture, fixture};
const GRACE: std::time::Duration = std::time::Duration::from_secs(900);
fn plan() -> PackPlan {
PackPlan {
grace: GRACE,
..PackPlan::with_limit(1 << 20)
}
}
fn created(f: &Fixture, n: u8) -> (Kept, Vec<u8>) {
let (found, body) = vcon(
&format!("01a07da8-c2bb-83e5-b9a2-279e0d16bc{n:02x}"),
&format!("MERGE-SUBJECT-{n}"),
);
let Ok(Created::New(version)) = f.store.create(&f.caller, &found, &body) else {
panic!("a new vCon")
};
let sealed = f.store.objects.get(version).expect("its object");
(
Kept {
uuid: found.uuid,
version,
body,
},
sealed,
)
}
fn three_small_files(f: &mut Fixture) -> (std::sync::Arc<DirPacks>, Vec<(Kept, Vec<u8>)>) {
let packs = with_packs(f);
let apart = PackPlan {
merge_below: 0,
..plan()
};
let mut kept = Vec::new();
for n in 1..=3 {
kept.push(created(f, n));
f.store.pack(&apart).expect("packs");
}
assert_eq!(packs.list().expect("listed").len(), 3, "three files");
(packs, kept)
}
fn named(f: &Fixture) -> BTreeSet<[u8; 16]> {
let conn = f.store.conn_for_test();
let mut rows = conn
.prepare("SELECT file_id FROM parquet_file WHERE unnamed_at IS NULL")
.expect("prepare");
rows.query_map([], |r| r.get(0))
.expect("query")
.collect::<Result<_, _>>()
.expect("rows")
}
#[test]
fn small_files_of_a_date_are_merged_and_the_old_ones_go_after_the_grace() {
let mut f = fixture();
let (packs, kept) = three_small_files(&mut f);
let report = f.store.pack(&plan()).expect("packs");
assert_eq!(report.merged, 1, "one new file");
assert_eq!(named(&f).len(), 1, "the catalog names only it");
assert_eq!(packs.list().expect("listed").len(), 4, "the old ones stay");
let kept: Vec<Kept> = kept.into_iter().map(|(k, _)| k).collect();
held_once(&f, &packs, &kept, "merged");
f.clock.set(f.clock.now() + Span::seconds(899));
f.store.pack(&plan()).expect("packs");
assert_eq!(packs.list().expect("listed").len(), 4, "within the grace");
f.clock.set(f.clock.now() + Span::seconds(1));
f.store.pack(&plan()).expect("packs");
assert_eq!(packs.list().expect("listed").len(), 1, "after the grace");
held_once(&f, &packs, &kept, "after the grace");
}
#[test]
fn a_purge_deletes_the_merged_files_at_once() {
let mut f = fixture();
let (packs, kept) = three_small_files(&mut f);
f.store.pack(&plan()).expect("packs and merges");
let (gone, sealed) = &kept[0];
f.store
.delete(&f.caller, gone.uuid, Retention::Days(0))
.expect("deleted and purged");
for (name, bytes) in packs.files() {
assert!(
!bytes.windows(sealed.len()).any(|w| w == sealed),
"{name} still holds the purged row"
);
}
assert_eq!(packs.list().expect("listed").len(), 1, "one file left");
let rest: Vec<Kept> = kept[1..].iter().map(|(k, _)| k.clone()).collect();
held_once(&f, &packs, &rest, "after the purge");
}
#[test]
fn only_small_files_of_one_date_and_no_purge_are_merged() {
let mut f = fixture();
with_packs(&mut f);
created(&f, 1);
f.store.pack(&plan()).expect("packs");
assert_eq!(
f.store.compact(&plan()),
Ok(0),
"one file: nothing to merge"
);
f.clock.set(f.clock.now() + Span::days(1));
created(&f, 2);
let apart = PackPlan {
merge_below: 0,
..plan()
};
f.store.pack(&apart).expect("packs");
assert_eq!(
f.store.compact(&plan()),
Ok(0),
"two dates: nothing to merge"
);
created(&f, 3);
f.store.pack(&apart).expect("packs");
let tiny = PackPlan {
merge_below: 10,
..plan()
};
assert_eq!(f.store.compact(&tiny), Ok(0), "files over the threshold");
let ids: Vec<[u8; 16]> = named(&f).into_iter().collect();
f.store
.conn_for_test()
.execute("INSERT INTO purge_pending (file_id) VALUES (?1)", [ids[0]])
.expect("a purge waits on one");
let merged = f.store.compact(&plan()).expect("compacts");
assert!(merged <= 1, "at most the two not waited on: {merged}");
assert!(
named(&f).contains(&ids[0]),
"the file a purge waits on stays named"
);
}
#[test]
fn a_read_racing_a_compaction_returns_the_whole_body() {
let mut f = fixture();
let (_, kept) = three_small_files(&mut f);
let now = PackPlan {
grace: std::time::Duration::ZERO,
..plan()
};
let store = &f.store;
let read = std::thread::scope(|scope| {
let (go, wait) = std::sync::mpsc::channel::<()>();
let (done, finished) = std::sync::mpsc::channel::<()>();
*store.hooks.before_read.lock().expect("hook") = Some(Box::new(move || {
go.send(()).expect("the merge is told");
finished
.recv_timeout(std::time::Duration::from_secs(30))
.expect("the merge finishes");
}));
let merge = scope.spawn(move || {
wait.recv_timeout(std::time::Duration::from_secs(30))
.expect("the read is between its steps");
let ran = store.pack(&now).expect("packs and merges");
done.send(()).expect("the read is told");
ran
});
let read = store.get(&f.caller, kept[1].0.uuid);
assert_eq!(merge.join().expect("merged").merged, 1, "the merge ran");
read
});
assert_eq!(
read.map(|s| s.body),
Ok(kept[1].0.body.clone()),
"the whole body"
);
}
}