use crate::vertex::id_indexer::IdManager;
pub const MAX_BASELINE_AGE_MS: u64 = 300_000;
pub const MIN_BASELINE_AGE_MS: u64 = 30_000;
pub const PAGE_MERGE_RATIO: f64 = 0.5;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FlushKind {
Skip,
Incremental,
Full,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FlushReason {
Idle,
DeltaPending,
CompactionMovedRows,
DeltaThreshold,
BaselineAge,
PageFragmentation,
}
impl FlushReason {
pub fn as_str(self) -> &'static str {
match self {
Self::Idle => "idle",
Self::DeltaPending => "delta_pending",
Self::CompactionMovedRows => "compaction_moved_rows",
Self::DeltaThreshold => "delta_threshold",
Self::BaselineAge => "baseline_age",
Self::PageFragmentation => "page_fragmentation",
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct FlushSignals {
pub delta_entries: usize,
pub live_rows: usize,
pub baseline_invalidated: bool,
pub millis_since_baseline: u64,
pub dirty_page_ratio: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FlushPlan {
pub kind: FlushKind,
pub reason: FlushReason,
pub merge_pages: bool,
}
pub fn decide(signals: FlushSignals) -> FlushPlan {
let fragmentation = if signals.dirty_page_ratio.is_finite() {
signals.dirty_page_ratio.clamp(0.0, 1.0)
} else {
0.0
};
let merge_pages = fragmentation >= PAGE_MERGE_RATIO;
if signals.baseline_invalidated {
return FlushPlan {
kind: FlushKind::Full,
reason: FlushReason::CompactionMovedRows,
merge_pages,
};
}
if signals.delta_entries >= IdManager::anchor_threshold_for_live(signals.live_rows) {
return FlushPlan {
kind: FlushKind::Full,
reason: FlushReason::DeltaThreshold,
merge_pages,
};
}
if signals.millis_since_baseline >= MAX_BASELINE_AGE_MS {
return FlushPlan {
kind: FlushKind::Full,
reason: FlushReason::BaselineAge,
merge_pages,
};
}
if signals.delta_entries == 0
&& signals.millis_since_baseline < MIN_BASELINE_AGE_MS
&& !merge_pages
{
return FlushPlan {
kind: FlushKind::Skip,
reason: FlushReason::Idle,
merge_pages: false,
};
}
FlushPlan {
kind: FlushKind::Incremental,
reason: if merge_pages {
FlushReason::PageFragmentation
} else {
FlushReason::DeltaPending
},
merge_pages,
}
}
pub fn select_whole_db_kind(plans: &[FlushPlan]) -> (FlushKind, [(FlushReason, usize); 6]) {
let mut counts = [
(FlushReason::Idle, 0usize),
(FlushReason::DeltaPending, 0usize),
(FlushReason::CompactionMovedRows, 0usize),
(FlushReason::DeltaThreshold, 0usize),
(FlushReason::BaselineAge, 0usize),
(FlushReason::PageFragmentation, 0usize),
];
for plan in plans {
if let Some(slot) = counts.iter_mut().find(|(reason, _)| *reason == plan.reason) {
slot.1 += 1;
}
}
let kind = if plans.iter().any(|plan| plan.kind == FlushKind::Full) {
FlushKind::Full
} else {
FlushKind::Incremental
};
(kind, counts)
}
pub fn check_single_table_kind(
table: &str,
table_kind: FlushKind,
global_kind: FlushKind,
reason: FlushReason,
) -> linkrs_core::StorageResult<()> {
if table_kind == FlushKind::Full && global_kind == FlushKind::Incremental {
return Err(linkrs_core::StorageError::invalid_operation(format!(
"vertex table '{table}' verdict is full (reason={}) inside a globally \
incremental checkpoint: single-table upgrades are forbidden; escalate \
the whole checkpoint through flush policy selection instead",
reason.as_str(),
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn quiet() -> FlushSignals {
FlushSignals {
delta_entries: 0,
live_rows: 100,
baseline_invalidated: false,
millis_since_baseline: 1_000,
dirty_page_ratio: 0.0,
}
}
#[test]
fn test_quiet_table_skips() {
let plan = decide(quiet());
assert_eq!(plan.kind, FlushKind::Skip);
assert_eq!(plan.reason, FlushReason::Idle);
assert!(!plan.merge_pages);
}
#[test]
fn test_invalidated_baseline_forces_full_first() {
let plan = decide(FlushSignals {
baseline_invalidated: true,
..quiet()
});
assert_eq!(plan.kind, FlushKind::Full);
assert_eq!(plan.reason, FlushReason::CompactionMovedRows);
}
#[test]
fn test_delta_threshold_forces_full() {
let live = 100usize;
let plan = decide(FlushSignals {
delta_entries: IdManager::anchor_threshold_for_live(live),
live_rows: live,
..quiet()
});
assert_eq!(plan.kind, FlushKind::Full);
assert_eq!(plan.reason, FlushReason::DeltaThreshold);
}
#[test]
fn test_old_baseline_forces_full() {
let plan = decide(FlushSignals {
millis_since_baseline: MAX_BASELINE_AGE_MS,
..quiet()
});
assert_eq!(plan.kind, FlushKind::Full);
assert_eq!(plan.reason, FlushReason::BaselineAge);
}
#[test]
fn test_fragmentation_merges_without_rewrite() {
let plan = decide(FlushSignals {
delta_entries: 5,
dirty_page_ratio: 0.8,
..quiet()
});
assert_eq!(plan.kind, FlushKind::Incremental);
assert_eq!(plan.reason, FlushReason::PageFragmentation);
assert!(plan.merge_pages);
}
#[test]
fn test_ratio_clamped_against_garbage() {
let plan = decide(FlushSignals {
dirty_page_ratio: f64::NAN,
..quiet()
});
assert_eq!(plan.kind, FlushKind::Skip);
let plan = decide(FlushSignals {
dirty_page_ratio: 2.0,
..quiet()
});
assert!(plan.merge_pages);
}
#[test]
fn test_reason_codes_stable() {
assert_eq!(FlushReason::DeltaThreshold.as_str(), "delta_threshold");
assert_eq!(FlushReason::BaselineAge.as_str(), "baseline_age");
}
#[test]
fn test_whole_db_kind_escalates_on_any_full() {
let incremental = FlushPlan {
kind: FlushKind::Incremental,
reason: FlushReason::DeltaPending,
merge_pages: false,
};
let (kind, counts) = select_whole_db_kind(&[incremental, incremental]);
assert_eq!(kind, FlushKind::Incremental);
assert_eq!(
counts
.iter()
.find(|(reason, _)| *reason == FlushReason::DeltaPending)
.map(|(_, count)| *count),
Some(2)
);
let full = FlushPlan {
kind: FlushKind::Full,
reason: FlushReason::BaselineAge,
merge_pages: false,
};
let (kind, _) = select_whole_db_kind(&[incremental, full]);
assert_eq!(kind, FlushKind::Full);
}
#[test]
fn test_single_table_upgrade_is_forbidden() {
assert!(check_single_table_kind(
"person",
FlushKind::Full,
FlushKind::Incremental,
FlushReason::BaselineAge,
)
.is_err());
assert!(check_single_table_kind(
"person",
FlushKind::Full,
FlushKind::Full,
FlushReason::BaselineAge,
)
.is_ok());
assert!(check_single_table_kind(
"person",
FlushKind::Incremental,
FlushKind::Incremental,
FlushReason::DeltaPending,
)
.is_ok());
}
}