use crate::LixError;
use crate::gc::CheckpointGcState;
use crate::gc::load_checkpoint_gc_state;
use crate::storage_adapter::{SharedStorageAdapterRead, Storage, StorageReadOptions};
#[cfg(test)]
use crate::tracked_state::{TrackedStateDiffKind, TrackedStateDiffRow};
#[cfg(test)]
use crate::transaction::StagedCommitChangeBatchBuilder;
use super::context::SessionContext;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct CreateCheckpointReceipt {
pub commit_id: String,
}
const RECLAIM_YIELD_DENOMINATOR: u64 = 4;
const RECLAIM_MIN_INVENTORY: u64 = 64;
const RECLAIM_FAILURE_BACKOFF_CAP: u32 = 10;
const RECLAIM_MAX_STALENESS: u64 = 64;
impl<StorageImpl> SessionContext<StorageImpl>
where
StorageImpl: Storage + Clone + Send + Sync + 'static,
{
pub(crate) async fn create_checkpoint(&self) -> Result<CreateCheckpointReceipt, LixError> {
let checkpoint = self
.execute("SELECT commit_id FROM lix_create_checkpoint()", &[])
.await?;
let checkpoint_row = checkpoint.rows().first().ok_or_else(|| {
LixError::new(
LixError::CODE_INTERNAL_ERROR,
"checkpoint SQL function returned no commit ID",
)
})?;
let commit_id = checkpoint_row.get::<String>("commit_id")?;
Ok(CreateCheckpointReceipt { commit_id })
}
pub(super) async fn schedule_checkpoint_gc_after_commit(&self, checkpoint_sequence: u64) {
#[cfg(test)]
self.commit_coordinator
.record_checkpoint_gc_post_commit_hook();
let result = async {
let read = SharedStorageAdapterRead::new(
self.storage
.begin_read(StorageReadOptions::default())
.await?,
);
let gc_state = load_checkpoint_gc_state(&read).await?;
if gc_state.checkpoint_sequence < checkpoint_sequence {
return Err(LixError::new(
LixError::CODE_INTERNAL_ERROR,
"committed checkpoint GC sequence is not visible after commit",
));
}
if checkpoint_gc_due(gc_state)?
&& self
.commit_coordinator
.try_begin_checkpoint_gc(gc_state.checkpoint_sequence)
{
let gc_session = self.clone();
let gc_coordinator = self.commit_coordinator.clone();
if let Err(error) =
crate::background_task::spawn("lix-checkpoint-gc", move || async move {
gc_session.collect_checkpoint_garbage_best_effort().await;
gc_coordinator.finish_checkpoint_gc();
})
{
self.commit_coordinator.finish_checkpoint_gc();
return Err(error);
}
}
Ok::<_, LixError>(())
}
.await;
if let Err(error) = result {
tracing::warn!(error = %error, checkpoint_sequence, "post-commit checkpoint GC scheduling failed");
}
}
}
pub(crate) fn checkpoint_gc_due(state: CheckpointGcState) -> Result<bool, LixError> {
if !state.has_collectible_debt() {
return Ok(false);
}
let checkpoint_age = state
.checkpoint_sequence
.checked_sub(state.last_gc_sequence)
.ok_or_else(|| {
LixError::new(
LixError::CODE_INTERNAL_ERROR,
"checkpoint GC sequence is ahead of checkpoint sequence",
)
})?;
let yield_estimate = state.yield_per_interval_estimate.max(1);
let estimated_reclaimable = state
.collectible_interval_count
.saturating_mul(yield_estimate);
let shift = state
.consecutive_reclaim_failures
.min(u64::from(RECLAIM_FAILURE_BACKOFF_CAP)) as u32;
let backoff = 1u64.checked_shl(shift).unwrap_or(u64::MAX);
let inventory = state
.live_manifest_estimate
.max(RECLAIM_MIN_INVENTORY)
.saturating_mul(backoff);
let yield_due = estimated_reclaimable.saturating_mul(RECLAIM_YIELD_DENOMINATOR) >= inventory;
let age_limit = RECLAIM_MAX_STALENESS
.max(state.last_gc_sequence)
.saturating_mul(backoff);
let stale_due = checkpoint_age >= age_limit;
Ok(yield_due || stale_due)
}
#[cfg(test)]
fn push_selected_change(
selected_changes: &mut StagedCommitChangeBatchBuilder,
row: TrackedStateDiffRow,
kind: TrackedStateDiffKind,
) -> bool {
let created_at = match kind {
TrackedStateDiffKind::Added => row.updated_at,
TrackedStateDiffKind::Modified | TrackedStateDiffKind::Removed => row.created_at,
};
let source_membership_exact = created_at == row.created_at;
let deleted = row.deleted;
let source_commit_id = row.commit_id;
let change_id = row.change_id;
let updated_at = row.updated_at;
selected_changes.push(
row.identity,
source_commit_id,
change_id,
deleted,
created_at,
updated_at,
);
source_membership_exact
}
#[cfg(test)]
mod tests {
use super::{
RECLAIM_MIN_INVENTORY, RECLAIM_YIELD_DENOMINATOR, checkpoint_gc_due, push_selected_change,
};
use crate::LixError;
use crate::changelog::{ChangeId, CommitId};
use crate::common::LixTimestamp;
use crate::gc::CheckpointGcState;
use crate::row_pk::RowPk;
use crate::storage::Memory;
use crate::tracked_state::{
TrackedStateDiffIdentity, TrackedStateDiffKind, TrackedStateDiffRow, TrackedStateKey,
};
use crate::transaction::StagedCommitChangeBatchBuilder;
#[tokio::test]
async fn repository_global_branch_cannot_be_checkpointed_through_sql() {
let storage = Memory::new();
let _receipt = crate::engine::Engine::initialize(storage.clone())
.await
.expect("repository initializes");
let engine = crate::engine::Engine::new(storage)
.await
.expect("repository opens");
let session = engine
.open_session_at(crate::GLOBAL_BRANCH_ID)
.await
.expect("global session opens");
let error = session
.execute("SELECT commit_id FROM lix_create_checkpoint()", &[])
.await
.expect_err("global branch checkpoint must be rejected");
assert_eq!(error.code, LixError::CODE_INVALID_PARAM);
assert!(error.to_string().contains("global branch"));
}
#[tokio::test]
async fn sql_checkpoint_schedules_gc_only_after_outer_commit() {
let storage = Memory::new();
crate::engine::Engine::initialize(storage.clone())
.await
.expect("repository initializes");
let engine = crate::engine::Engine::new(storage)
.await
.expect("repository opens");
let session = engine.open_session().await.expect("session opens");
session
.execute(
"INSERT INTO lix_key_value (key, value) VALUES ('gc-hook', 'working')",
&[],
)
.await
.expect("working row commits");
assert_eq!(
session.commit_coordinator.checkpoint_gc_post_commit_hooks(),
0
);
let mut rolled_back = session
.begin_transaction()
.await
.expect("transaction begins");
rolled_back
.execute("SELECT commit_id FROM lix_create_checkpoint()", &[])
.await
.expect("checkpoint stages");
assert_eq!(
session.commit_coordinator.checkpoint_gc_post_commit_hooks(),
0,
"staging a checkpoint must not schedule maintenance"
);
rolled_back.rollback().await.expect("rollback succeeds");
assert_eq!(
session.commit_coordinator.checkpoint_gc_post_commit_hooks(),
0,
"rolling back a checkpoint must not schedule maintenance"
);
let mut committed = session
.begin_transaction()
.await
.expect("transaction begins");
committed
.execute("SELECT commit_id FROM lix_create_checkpoint()", &[])
.await
.expect("checkpoint stages");
committed.commit().await.expect("checkpoint commits");
assert_eq!(
session.commit_coordinator.checkpoint_gc_post_commit_hooks(),
1,
"a durable SQL checkpoint schedules maintenance exactly once"
);
}
#[test]
fn canonicalized_added_timestamp_declines_source_membership_certificate() {
let created_at = LixTimestamp::expect_parse("created_at", "2026-01-01T00:00:00Z");
let updated_at = LixTimestamp::expect_parse("updated_at", "2026-01-02T00:00:00Z");
let mut selected = StagedCommitChangeBatchBuilder::with_capacity(1);
let source_membership_exact = push_selected_change(
&mut selected,
TrackedStateDiffRow {
identity: TrackedStateDiffIdentity::from_key(TrackedStateKey {
schema_key: "test_schema".to_string(),
file_id: None,
row_pk: RowPk::single("row"),
}),
deleted: false,
created_at,
updated_at,
change_id: ChangeId::for_test_label("checkpoint-canonicalized-change"),
commit_id: CommitId::for_test_label("checkpoint-canonicalized-commit"),
},
TrackedStateDiffKind::Added,
);
let selected = if source_membership_exact {
selected.finish_source_certified()
} else {
selected.finish()
};
assert!(!selected.source_membership_certified());
assert_eq!(
selected.iter().next().expect("one selected row").created_at,
updated_at
);
}
fn state(sequence: u64, last_gc_sequence: u64) -> CheckpointGcState {
CheckpointGcState {
checkpoint_sequence: sequence,
last_gc_sequence,
collectible_interval_count: 1,
..CheckpointGcState::default()
}
}
#[test]
fn reclaim_keys_off_yield_against_inventory_not_checkpoint_count() {
let intervals = 8;
let inventory = 1_024;
let lean = CheckpointGcState {
checkpoint_sequence: 100_000 + intervals,
last_gc_sequence: 100_000,
collectible_interval_count: intervals,
live_manifest_estimate: inventory,
yield_per_interval_estimate: 4,
consecutive_reclaim_failures: 0,
};
assert!(
!checkpoint_gc_due(lean).expect("lean state should be valid"),
"8 intervals x 4 = 32 retirable against 1024 inventory must not sweep"
);
let rich = CheckpointGcState {
yield_per_interval_estimate: 64,
..lean
};
assert!(
checkpoint_gc_due(rich).expect("rich state should be valid"),
"8 intervals x 64 = 512 retirable against 1024 inventory must sweep"
);
let small = CheckpointGcState {
live_manifest_estimate: 64,
..lean
};
assert!(
checkpoint_gc_due(small).expect("small state should be valid"),
"the same debt against a small inventory must sweep"
);
}
#[test]
fn reclaim_fires_exactly_at_the_ratio_boundary() {
let inventory = 4_096;
let at_boundary = inventory / RECLAIM_YIELD_DENOMINATOR;
let mut just_under = CheckpointGcState {
checkpoint_sequence: 10_000_000 + at_boundary,
last_gc_sequence: 10_000_000,
collectible_interval_count: at_boundary - 1,
live_manifest_estimate: inventory,
yield_per_interval_estimate: 1,
consecutive_reclaim_failures: 0,
};
assert!(
!checkpoint_gc_due(just_under).expect("valid"),
"just under must refuse"
);
just_under.collectible_interval_count = at_boundary;
assert!(
checkpoint_gc_due(just_under).expect("valid"),
"at the boundary must fire"
);
}
#[test]
fn small_repositories_are_floored_not_swept_constantly() {
let tiny = CheckpointGcState {
checkpoint_sequence: 5,
last_gc_sequence: 0,
collectible_interval_count: 1,
live_manifest_estimate: 4,
yield_per_interval_estimate: 1,
consecutive_reclaim_failures: 0,
};
assert!(
!checkpoint_gc_due(tiny).expect("tiny state should be valid"),
"one interval against the {RECLAIM_MIN_INVENTORY}-manifest floor must not sweep"
);
}
#[test]
fn repeated_reclaim_failures_damp_the_retry() {
let base = CheckpointGcState {
checkpoint_sequence: 1_000,
last_gc_sequence: 0,
collectible_interval_count: 64,
live_manifest_estimate: 256,
yield_per_interval_estimate: 1,
consecutive_reclaim_failures: 0,
};
assert!(
checkpoint_gc_due(base).expect("valid"),
"fixture must be due with no failures, or the damping below is vacuous"
);
let damped = CheckpointGcState {
consecutive_reclaim_failures: 4,
..base
};
assert!(
!checkpoint_gc_due(damped).expect("valid"),
"repeated failures must stop re-arming a full sweep every checkpoint"
);
let recovered = CheckpointGcState {
collectible_interval_count: 64 * 32,
..damped
};
assert!(
checkpoint_gc_due(recovered).expect("valid"),
"damping must yield once the estimated reclaimable set grows enough"
);
}
#[test]
fn reclaim_cost_stays_within_a_constant_factor_of_what_it_reclaims() {
let mut state = CheckpointGcState::default();
let mut live = 0u64;
let mut garbage = 0u64;
let mut total_scanned = 0u64;
let mut total_reclaimed = 0u64;
let mut sweeps = 0u64;
let mut first_half_scanned = 0u64;
let mut first_half_reclaimed = 0u64;
let mut second_half_scanned = 0u64;
let mut second_half_reclaimed = 0u64;
for sequence in 1..=10_000 {
state.checkpoint_sequence = sequence;
state.add_collectible_interval(true);
live += 1;
garbage += 3;
if checkpoint_gc_due(state).expect("simulated state should be valid") {
sweeps += 1;
total_scanned += live + garbage;
total_reclaimed += garbage;
if sequence <= 5_000 {
first_half_scanned += live + garbage;
first_half_reclaimed += garbage;
} else {
second_half_scanned += live + garbage;
second_half_reclaimed += garbage;
}
let reclaimed = garbage;
garbage = 0;
state.mark_collected(reclaimed, live);
}
}
let first_half_ratio_x100 =
first_half_scanned.saturating_mul(100) / first_half_reclaimed.max(1);
let second_half_ratio_x100 =
second_half_scanned.saturating_mul(100) / second_half_reclaimed.max(1);
assert!(
sweeps > 0 && total_reclaimed > 0,
"the simulation must actually sweep and reclaim, or the bounds below are vacuous"
);
let ratio_x100 = total_scanned.saturating_mul(100) / total_reclaimed;
assert!(
ratio_x100 < 800,
"scanned {total_scanned} manifests to reclaim {total_reclaimed} commits \
across {sweeps} sweeps ({ratio_x100} per 100), above a constant factor"
);
assert!(
second_half_ratio_x100 <= first_half_ratio_x100.saturating_mul(2),
"cost factor grew from {first_half_ratio_x100} to {second_half_ratio_x100} \
per 100 as the repository aged; amortised cost is not bounded"
);
assert!(
sweeps < 10_000 / 4,
"ratio cadence scheduled {sweeps} sweeps in 10000 checkpoints"
);
}
#[test]
fn sparse_debt_still_collects_via_the_staleness_backstop() {
let sparse = CheckpointGcState {
checkpoint_sequence: 70,
last_gc_sequence: 0,
collectible_interval_count: 6,
live_manifest_estimate: 0,
yield_per_interval_estimate: 0,
consecutive_reclaim_failures: 0,
};
assert!(
6u64 * RECLAIM_YIELD_DENOMINATOR < RECLAIM_MIN_INVENTORY,
"fixture must be below the ratio, or this proves nothing about the backstop"
);
assert!(
checkpoint_gc_due(sparse).expect("valid"),
"sparse but real debt must still collect once it goes stale"
);
let mature = CheckpointGcState {
checkpoint_sequence: 8_100,
last_gc_sequence: 8_000,
..sparse
};
assert!(
!checkpoint_gc_due(mature).expect("valid"),
"a mature repository must not fall back to a fixed 64-checkpoint cadence"
);
}
#[test]
fn empty_debt_never_schedules_a_sweep() {
let state = CheckpointGcState {
checkpoint_sequence: u64::MAX,
last_gc_sequence: 0,
..CheckpointGcState::default()
};
assert!(!checkpoint_gc_due(state).expect("empty GC state should be valid"));
}
#[test]
fn invalid_gc_sequence_is_rejected() {
let state = state(2, 3);
assert!(checkpoint_gc_due(state).is_err());
}
}