use crate::{Error, Result};
use parking_lot::{Mutex, RwLock};
use rocksdb::{ReadOptions, WriteBatch, DB};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tracing::{debug, info, warn};
use super::branch::BranchManager;
use super::conflict::WriteConflictRegistry;
use super::time_travel::SnapshotManager;
pub const LOW_WATERMARK_KEY: &[u8] = b"vgc:low_watermark";
pub const ANCHOR_PREFIX: &[u8] = b"vgc:anchor:";
const DELETE_BATCH_KEYS: usize = 1024;
const SCAN_BOUND_FACTOR: usize = 8;
const MAX_MEMORY_ANCHORS: usize = 4096;
#[derive(Debug, Clone)]
pub struct VersionGcConfig {
pub retention_secs: Option<u64>,
pub interval_secs: u64,
pub max_versions_per_cycle: usize,
}
#[derive(Debug, Clone, Default)]
pub struct VersionGcCycleStats {
pub versions_deleted: u64,
pub index_entries_deleted: u64,
pub keys_scanned: u64,
pub finished_pass: bool,
pub horizon: Option<u64>,
}
#[derive(Debug, Clone, Default)]
pub struct VersionStorageStats {
pub version_keys: u64,
pub version_index_keys: u64,
pub approx_bytes: u64,
}
pub struct VersionGc {
db: Arc<DB>,
snapshot_manager: Arc<SnapshotManager>,
conflict_registry: Arc<WriteConflictRegistry>,
branch_manager: Arc<RwLock<Option<Arc<BranchManager>>>>,
timestamp: Arc<RwLock<u64>>,
config: VersionGcConfig,
cursor: Mutex<Option<Vec<u8>>>,
gc_lock: Mutex<()>,
memory_anchors: Mutex<Vec<(i64, u64)>>,
}
impl VersionGc {
pub fn new(
db: Arc<DB>,
snapshot_manager: Arc<SnapshotManager>,
conflict_registry: Arc<WriteConflictRegistry>,
branch_manager: Arc<RwLock<Option<Arc<BranchManager>>>>,
timestamp: Arc<RwLock<u64>>,
config: VersionGcConfig,
) -> Self {
if let Ok(Some(raw)) = db.get(LOW_WATERMARK_KEY) {
if let Some(bytes) = raw.get(0..8).and_then(|b| <[u8; 8]>::try_from(b).ok()) {
let watermark = u64::from_be_bytes(bytes);
snapshot_manager.set_gc_low_watermark(watermark);
let mut ts = timestamp.write();
if *ts < watermark {
*ts = watermark;
}
}
}
Self {
db,
snapshot_manager,
conflict_registry,
branch_manager,
timestamp,
config,
cursor: Mutex::new(None),
gc_lock: Mutex::new(()),
memory_anchors: Mutex::new(Vec::new()),
}
}
pub fn config(&self) -> &VersionGcConfig {
&self.config
}
pub fn record_time_anchor(&self) {
let now_secs = chrono::Utc::now().timestamp();
let ts = *self.timestamp.read();
{
let mut anchors = self.memory_anchors.lock();
anchors.push((now_secs, ts));
if anchors.len() > MAX_MEMORY_ANCHORS {
let excess = anchors.len() - MAX_MEMORY_ANCHORS;
anchors.drain(0..excess);
}
}
let key = anchor_key(now_secs);
if let Err(e) = self.db.put(&key, ts.to_be_bytes()) {
warn!("version-gc: failed to persist time anchor: {}", e);
}
}
pub fn run_cycle(&self) -> Result<VersionGcCycleStats> {
let _guard = self.gc_lock.lock();
self.record_time_anchor();
self.run_cycle_locked()
}
pub fn vacuum(&self) -> Result<u64> {
let _guard = self.gc_lock.lock();
self.record_time_anchor();
*self.cursor.lock() = None;
let mut total = 0u64;
loop {
let stats = self.run_cycle_locked()?;
total += stats.versions_deleted;
if stats.finished_pass || stats.horizon.is_none() {
break;
}
}
Ok(total)
}
fn run_cycle_locked(&self) -> Result<VersionGcCycleStats> {
let Some(retention_secs) = self.config.retention_secs else {
return Ok(VersionGcCycleStats {
finished_pass: true,
..Default::default()
});
};
let cutoff_secs = chrono::Utc::now().timestamp() - retention_secs as i64;
let Some(retention_ts) = self.retention_horizon(cutoff_secs)? else {
return Ok(VersionGcCycleStats {
finished_pass: true,
..Default::default()
});
};
let horizon = self.clamp_horizon(retention_ts)?;
if horizon > self.snapshot_manager.gc_low_watermark() {
self.db
.put(LOW_WATERMARK_KEY, horizon.to_be_bytes())
.map_err(|e| Error::storage(format!("version-gc: failed to persist watermark: {}", e)))?;
self.snapshot_manager.set_gc_low_watermark(horizon);
}
let delete_horizon = self.clamp_horizon(horizon)?;
let mut stats = self.collect_below(delete_horizon)?;
stats.horizon = Some(delete_horizon);
if stats.versions_deleted > 0 {
debug!(
horizon = delete_horizon,
deleted = stats.versions_deleted,
scanned = stats.keys_scanned,
finished = stats.finished_pass,
"version-gc cycle"
);
}
Ok(stats)
}
fn retention_horizon(&self, cutoff_secs: i64) -> Result<Option<u64>> {
let mut best = self
.snapshot_manager
.max_snapshot_ts_at_or_before_wallclock(cutoff_secs);
{
let anchors = self.memory_anchors.lock();
for (secs, ts) in anchors.iter() {
if *secs <= cutoff_secs {
best = Some(best.map_or(*ts, |b| b.max(*ts)));
}
}
}
let mut stale_anchor_keys: Vec<Vec<u8>> = Vec::new();
let mut best_anchor: Option<(Vec<u8>, u64)> = None;
let mut read_opts = ReadOptions::default();
read_opts.set_total_order_seek(true);
let iter = self.db.iterator_opt(
rocksdb::IteratorMode::From(ANCHOR_PREFIX, rocksdb::Direction::Forward),
read_opts,
);
for item in iter {
let (key, value) = item.map_err(|e| Error::storage(format!("version-gc anchor scan: {}", e)))?;
if !key.starts_with(ANCHOR_PREFIX) {
break;
}
let Some(secs) = parse_anchor_secs(&key) else { continue };
if secs > cutoff_secs {
break;
}
let Some(bytes) = value.get(0..8).and_then(|b| <[u8; 8]>::try_from(b).ok()) else {
continue;
};
let ts = u64::from_be_bytes(bytes);
if let Some((prev_key, _)) = best_anchor.take() {
stale_anchor_keys.push(prev_key);
}
best_anchor = Some((key.to_vec(), ts));
}
if let Some((_, ts)) = &best_anchor {
best = Some(best.map_or(*ts, |b| b.max(*ts)));
}
if !stale_anchor_keys.is_empty() {
let mut batch = WriteBatch::default();
for key in stale_anchor_keys {
batch.delete(&key);
}
self.db
.write(batch)
.map_err(|e| Error::storage(format!("version-gc anchor prune: {}", e)))?;
}
Ok(best)
}
fn clamp_horizon(&self, candidate: u64) -> Result<u64> {
let mut horizon = candidate;
if let Some(pin_min) = self.conflict_registry.min_pinned_snapshot() {
horizon = horizon.min(pin_min);
}
let manager = self.branch_manager.read().clone();
if let Some(mgr) = manager {
for branch in mgr.list_branches()? {
if branch.name == "main" || branch.created_from_snapshot == 0 {
continue;
}
horizon = horizon.min(branch.created_from_snapshot);
}
}
Ok(horizon)
}
fn collect_below(&self, horizon: u64) -> Result<VersionGcCycleStats> {
let mut stats = VersionGcCycleStats::default();
let max_deletes = self.config.max_versions_per_cycle.max(1);
let scan_budget = max_deletes.saturating_mul(SCAN_BOUND_FACTOR).max(65_536);
let start_key: Vec<u8> = self.cursor.lock().clone().unwrap_or_else(|| b"v:".to_vec());
let mut read_opts = ReadOptions::default();
read_opts.set_total_order_seek(true);
let iter = self.db.iterator_opt(
rocksdb::IteratorMode::From(&start_key, rocksdb::Direction::Forward),
read_opts,
);
let mut batch = WriteBatch::default();
let mut batch_keys = 0usize;
let mut group_prefix: Vec<u8> = Vec::new();
let mut group_versions: Vec<u64> = Vec::new();
let mut next_cursor: Option<Vec<u8>> = None;
let mut finished_pass = true;
let flush = |batch: &mut WriteBatch, batch_keys: &mut usize, db: &DB| -> Result<()> {
if *batch_keys > 0 {
let owned = std::mem::take(batch);
db.write(owned)
.map_err(|e| Error::storage(format!("version-gc delete batch: {}", e)))?;
*batch_keys = 0;
}
Ok(())
};
let index_key_for = |prefix: &[u8], ts: u64| -> Vec<u8> {
let mut ikey = Vec::with_capacity(prefix.len() + 26);
ikey.extend_from_slice(b"v_idx:");
ikey.extend_from_slice(&prefix[2..]);
ikey.extend_from_slice(format!("{:020}", u64::MAX - ts).as_bytes());
ikey
};
let process_group_fn = |prefix: &[u8],
versions: &mut Vec<u64>,
batch: &mut WriteBatch,
batch_keys: &mut usize,
stats: &mut VersionGcCycleStats|
-> Result<()> {
if versions.len() > 1 {
if let Some(keep) = versions.iter().copied().filter(|ts| *ts <= horizon).max() {
let mut keep_indexed: Option<u64> = None;
let keep_has_index = self
.db
.get(index_key_for(prefix, keep))
.map_err(|e| Error::storage(format!("version-gc index probe: {}", e)))?
.is_some();
if !keep_has_index {
let mut candidates: Vec<u64> = versions
.iter()
.copied()
.filter(|ts| *ts <= horizon && *ts != keep)
.collect();
candidates.sort_unstable_by(|a, b| b.cmp(a)); for ts in candidates {
let indexed = self
.db
.get(index_key_for(prefix, ts))
.map_err(|e| Error::storage(format!("version-gc index probe: {}", e)))?
.is_some();
if indexed {
keep_indexed = Some(ts);
break;
}
}
}
for ts in versions.iter().copied() {
if ts <= horizon && ts != keep && Some(ts) != keep_indexed {
let mut vkey = Vec::with_capacity(prefix.len() + 20);
vkey.extend_from_slice(prefix);
vkey.extend_from_slice(ts.to_string().as_bytes());
batch.delete(&vkey);
batch.delete(index_key_for(prefix, ts));
stats.versions_deleted += 1;
stats.index_entries_deleted += 1;
*batch_keys += 2;
}
}
if *batch_keys >= DELETE_BATCH_KEYS {
flush(batch, batch_keys, &self.db)?;
}
}
}
versions.clear();
Ok(())
};
for item in iter {
let (key, _value) = item.map_err(|e| Error::storage(format!("version-gc scan: {}", e)))?;
if !key.starts_with(b"v:") {
break;
}
let Some(last_colon) = key.iter().rposition(|b| *b == b':') else {
continue;
};
let prefix = &key[..=last_colon];
let Some(ts) = std::str::from_utf8(&key[last_colon + 1..])
.ok()
.and_then(|s| s.parse::<u64>().ok())
else {
continue; };
if prefix != group_prefix.as_slice() {
process_group_fn(
&group_prefix,
&mut group_versions,
&mut batch,
&mut batch_keys,
&mut stats,
)?;
if stats.versions_deleted >= max_deletes as u64 || stats.keys_scanned >= scan_budget as u64 {
next_cursor = Some(key.to_vec());
finished_pass = false;
break;
}
group_prefix = prefix.to_vec();
}
group_versions.push(ts);
stats.keys_scanned += 1;
}
if finished_pass {
process_group_fn(
&group_prefix,
&mut group_versions,
&mut batch,
&mut batch_keys,
&mut stats,
)?;
}
flush(&mut batch, &mut batch_keys, &self.db)?;
*self.cursor.lock() = next_cursor;
stats.finished_pass = finished_pass;
Ok(stats)
}
}
pub fn version_storage_stats(db: &DB) -> Result<VersionStorageStats> {
let mut stats = VersionStorageStats::default();
for (prefix, counter) in [(b"v:".as_slice(), 0usize), (b"v_idx:".as_slice(), 1usize)] {
let mut read_opts = ReadOptions::default();
read_opts.set_total_order_seek(true);
let iter = db.iterator_opt(
rocksdb::IteratorMode::From(prefix, rocksdb::Direction::Forward),
read_opts,
);
for item in iter {
let (key, value) = item.map_err(|e| Error::storage(format!("version stats scan: {}", e)))?;
if !key.starts_with(prefix) {
break;
}
if counter == 0 {
stats.version_keys += 1;
} else {
stats.version_index_keys += 1;
}
stats.approx_bytes += (key.len() + value.len()) as u64;
}
}
Ok(stats)
}
fn anchor_key(unix_secs: i64) -> Vec<u8> {
let mut key = Vec::with_capacity(ANCHOR_PREFIX.len() + 20);
key.extend_from_slice(ANCHOR_PREFIX);
key.extend_from_slice(format!("{:020}", unix_secs.max(0)).as_bytes());
key
}
fn parse_anchor_secs(key: &[u8]) -> Option<i64> {
std::str::from_utf8(key.get(ANCHOR_PREFIX.len()..)?)
.ok()?
.parse::<i64>()
.ok()
}
pub struct VersionGcWorker {
shutdown: Arc<AtomicBool>,
handle: Option<std::thread::JoinHandle<()>>,
}
impl VersionGcWorker {
pub fn start(gc: Arc<VersionGc>, interval_secs: u64) -> Self {
let shutdown = Arc::new(AtomicBool::new(false));
let shutdown_clone = Arc::clone(&shutdown);
let handle = std::thread::Builder::new()
.name("version-gc".into())
.spawn(move || {
info!(interval_secs, "version-gc worker started");
gc.record_time_anchor();
let slices_per_interval = interval_secs.saturating_mul(10).max(1);
'outer: loop {
for _ in 0..slices_per_interval {
if shutdown_clone.load(Ordering::Relaxed) {
break 'outer;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
if shutdown_clone.load(Ordering::Relaxed) {
break;
}
if let Err(e) = gc.run_cycle() {
warn!("version-gc cycle failed: {}", e);
}
}
debug!("version-gc worker stopped");
})
.ok();
Self { shutdown, handle }
}
pub fn stop(&mut self) {
self.shutdown.store(true, Ordering::SeqCst);
if let Some(handle) = self.handle.take() {
let _ = handle.join();
}
}
}
impl Drop for VersionGcWorker {
fn drop(&mut self) {
self.stop();
}
}