use super::core::VertexTable;
use linkrs_core::StorageResult;
use std::collections::HashMap;
use std::sync::Arc;
pub const STABLE_ROW_IDS_ENABLED: bool = true;
pub struct CompactionCoordinator {
has_remapped: bool,
id_mapping: HashMap<u32, u32>,
journal: CompactionJournal,
}
#[derive(Debug, Clone, Default)]
pub struct CompactionJournal {
steps_executed: Vec<CompactionStep>,
committed: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CompactionStep {
Index,
Timestamp,
Column,
Edge,
}
impl CompactionJournal {
fn record(&mut self, step: CompactionStep) {
self.steps_executed.push(step);
}
fn mark_committed(&mut self) {
self.committed = true;
}
pub fn is_committed(&self) -> bool {
self.committed
}
pub fn steps(&self) -> &[CompactionStep] {
&self.steps_executed
}
pub fn extend(&mut self, other: &CompactionJournal) {
self.steps_executed
.extend(other.steps_executed.iter().copied());
self.committed = self.committed && other.committed;
}
pub fn record_edge_remap(&mut self) {
self.record(CompactionStep::Edge);
}
pub fn combine(journals: &[CompactionJournal]) -> CompactionJournal {
let mut out = CompactionJournal {
steps_executed: Vec::new(),
committed: true,
};
for journal in journals {
out.extend(journal);
}
if journals.is_empty() {
out.committed = false;
}
out
}
fn rollback_plan(&self) -> Vec<CompactionStep> {
if self.committed {
return Vec::new();
}
let mut plan = self.steps_executed.clone();
plan.reverse();
plan
}
}
impl CompactionCoordinator {
pub fn new() -> Self {
Self {
has_remapped: false,
id_mapping: HashMap::new(),
journal: CompactionJournal::default(),
}
}
}
impl Default for CompactionCoordinator {
fn default() -> Self {
Self::new()
}
}
impl CompactionCoordinator {
pub fn id_mapping(&self) -> &HashMap<u32, u32> {
&self.id_mapping
}
pub fn journal(&self) -> &CompactionJournal {
&self.journal
}
pub fn execute(&mut self, table: &mut VertexTable) -> StorageResult<()> {
self.journal = CompactionJournal::default();
let index_snapshot = table.id_indexer.snapshot_bytes();
let old_live_ids: Vec<u32> = table.id_indexer.live_ids();
self.id_mapping = table.id_indexer.compute_compact_mapping();
self.has_remapped = !self.id_mapping.is_empty();
if self.has_remapped {
let new_timestamps = self.build_remapped_timestamps(table, &old_live_ids);
let new_columns = match self.build_remapped_columns(table, &old_live_ids) {
Ok(columns) => columns,
Err(e) => {
self.rollback_index(table, &index_snapshot);
return Err(e);
}
};
self.journal.record(CompactionStep::Index);
let applied = table.id_indexer.compact()?;
debug_assert_eq!(applied, self.id_mapping);
self.journal.record(CompactionStep::Timestamp);
*table.timestamps.write() = new_timestamps;
self.journal.record(CompactionStep::Column);
table.columns = new_columns;
} else {
self.cleanup_orphaned_timestamps(table);
}
if !table.columns.resize(table.id_indexer.len()) {
log::warn!(
"compaction keeps column length: active snapshot blocks shrink to {} rows",
table.id_indexer.len()
);
}
self.journal.mark_committed();
Ok(())
}
fn rollback_index(&mut self, table: &mut VertexTable, snapshot: &[u8]) {
let _plan = self.journal.rollback_plan();
if let Err(e) = table.id_indexer.restore_snapshot(snapshot) {
log::warn!("compaction rollback failed to restore id indexer: {}", e);
}
}
fn build_remapped_timestamps(
&self,
table: &VertexTable,
old_live_ids: &[u32],
) -> super::super::VertexTimestamp {
let mut new_timestamps =
super::super::VertexTimestamp::with_capacity(table.id_indexer.len());
for &old_id in old_live_ids {
let new_id = self.id_mapping.get(&old_id).copied().unwrap_or(old_id);
if let Some(start_ts) = table.timestamps.read().get_start_ts(old_id) {
new_timestamps.insert(new_id, start_ts);
if let Some(end_ts) = table.timestamps.read().get_end_ts(old_id) {
if end_ts < crate::vertex::MAX_TIMESTAMP {
new_timestamps.remove(new_id, end_ts);
}
}
}
}
new_timestamps
}
fn build_remapped_columns(
&self,
table: &VertexTable,
old_live_ids: &[u32],
) -> StorageResult<super::super::ColumnStore> {
let new_columns = super::super::ColumnStore::with_capacity(table.id_indexer.len());
for prop in &table.schema.properties {
new_columns.add_column(prop.name.to_string(), prop.data_type.clone(), prop.nullable);
}
for prop in &table.schema.properties {
if let (Some(src), Some(dst)) = (
table.columns.get_column(&prop.name),
new_columns.get_column(&prop.name),
) {
dst.set_chunk_capacity(src.chunk_capacity());
dst.set_overflow_threshold(table.string_overflow_threshold);
}
}
for &old_id in old_live_ids {
let old_idx = old_id as usize;
let new_id = self.id_mapping.get(&old_id).copied().unwrap_or(old_id);
let new_idx = new_id as usize;
let values = table.columns.get(old_idx);
let pairs: Vec<(Arc<str>, linkrs_core::Value)> = values
.into_iter()
.filter_map(|(name, opt_val)| opt_val.map(|v| (name, v)))
.collect();
if !pairs.is_empty() {
new_columns.set(new_idx, &pairs)?;
}
new_columns.clone_row_state_from(&table.columns, old_idx, new_idx);
}
Ok(new_columns)
}
fn cleanup_orphaned_timestamps(&self, table: &mut VertexTable) {
let mut new_timestamps =
super::super::VertexTimestamp::with_capacity(table.id_indexer.len());
for idx in 0..table.timestamps.read().size() {
let idx_u32 = idx as u32;
if table.id_indexer.get_key(idx_u32).is_some() {
if let Some(start_ts) = table.timestamps.read().get_start_ts(idx_u32) {
new_timestamps.insert(idx_u32, start_ts);
if let Some(end_ts) = table.timestamps.read().get_end_ts(idx_u32) {
if end_ts < crate::vertex::MAX_TIMESTAMP {
new_timestamps.remove(idx_u32, end_ts);
}
}
}
}
}
*table.timestamps.write() = new_timestamps;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::StoragePropertyDef;
use crate::vertex::vertex_table::core::{VertexTable, VertexTableConfig};
use crate::vertex::{IdKey, VertexSchema};
use linkrs_core::{DataType, Value};
fn create_test_schema() -> VertexSchema {
VertexSchema {
label_id: 0,
label_name: "test".into(),
properties: vec![
StoragePropertyDef::new("id".into(), DataType::String),
StoragePropertyDef::new("name".into(), DataType::String),
StoragePropertyDef {
name: "age".into(),
data_type: DataType::Int,
nullable: true,
default_value: None,
},
],
primary_key_index: 0,
schema_version: 1,
}
}
#[test]
fn test_coordinator_empty_table() {
let schema = create_test_schema();
let mut table =
VertexTable::with_config(0, "test".into(), schema, VertexTableConfig::default());
let mut coordinator = CompactionCoordinator::new();
assert!(coordinator.execute(&mut table).is_ok());
assert!(!coordinator.has_remapped);
}
#[test]
fn test_coordinator_single_vertex() {
let schema = create_test_schema();
let mut table =
VertexTable::with_config(0, "test".into(), schema, VertexTableConfig::default());
table
.insert("v1", &[("name".into(), Value::string("Alice"))], 100)
.unwrap();
let mut coordinator = CompactionCoordinator::new();
assert!(coordinator.execute(&mut table).is_ok());
assert!(!coordinator.has_remapped);
assert_eq!(table.id_indexer.len(), 1);
}
#[test]
fn test_coordinator_remapping() {
let schema = create_test_schema();
let mut table =
VertexTable::with_config(0, "test".into(), schema, VertexTableConfig::default());
for i in 0..5 {
table
.insert(
&format!("v{}", i),
&[("name".into(), Value::string(format!("P{}", i)))],
100,
)
.unwrap();
}
assert_eq!(table.id_indexer.len(), 5);
let mut coordinator = CompactionCoordinator::new();
assert!(coordinator.execute(&mut table).is_ok());
assert!(!coordinator.has_remapped);
assert_eq!(table.id_indexer.len(), 5);
}
#[test]
fn test_journaled_remap_is_atomic_and_committed() {
let schema = create_test_schema();
let mut table =
VertexTable::with_config(0, "test".into(), schema, VertexTableConfig::default());
for i in 0..5 {
table
.insert(
&format!("v{}", i),
&[("name".into(), Value::string(format!("P{}", i)))],
100,
)
.unwrap();
}
table.id_indexer.remove(&IdKey::Text("v1".into()));
table.id_indexer.remove(&IdKey::Text("v3".into()));
let mut coordinator = CompactionCoordinator::new();
coordinator.execute(&mut table).unwrap();
assert!(coordinator.has_remapped);
assert!(coordinator.journal.committed);
assert!(coordinator.journal.rollback_plan().is_empty());
let mut expected = HashMap::new();
expected.insert(2u32, 1u32);
expected.insert(4u32, 2u32);
assert_eq!(*coordinator.id_mapping(), expected);
assert_eq!(table.id_indexer.len(), 3);
assert_eq!(table.columns.row_count(), 3);
for (key, name) in [("v0", "P0"), ("v2", "P2"), ("v4", "P4")] {
let id = table
.id_indexer
.get_index(&IdKey::Text(key.to_string()))
.expect("survivor keeps its key");
let record = table
.get_by_internal_id_offline(id, 100)
.expect("survivor stays readable");
assert_eq!(
record
.properties
.iter()
.find(|(k, _)| &**k == "name")
.unwrap()
.1,
Value::string(name)
);
}
}
#[test]
fn test_watermarked_compact_collects_mapping_and_preserves_data() {
let schema = create_test_schema();
let mut table =
VertexTable::with_config(0, "test".into(), schema, VertexTableConfig::default());
for i in 0..5 {
table
.insert(
&format!("w{}", i),
&[("name".into(), Value::string(format!("Q{}", i)))],
100,
)
.unwrap();
}
let id = table.get_internal_id_raw("w1").unwrap();
table.delete_by_internal_id(id, 200).unwrap();
let id = table.get_internal_id_raw("w3").unwrap();
table.delete_by_internal_id(id, 200).unwrap();
let (removed, mapping, _) = table.compact_with_cutoff_collect_mapping(200).unwrap();
assert_eq!(removed.len(), 2);
assert!(!mapping.is_empty());
for (key, name) in [("w0", "Q0"), ("w2", "Q2"), ("w4", "Q4")] {
let id = table
.id_indexer
.get_index(&IdKey::Text(key.to_string()))
.expect("survivor keeps its key");
let record = table
.get_by_internal_id_offline(id, 200)
.expect("survivor stays readable");
assert_eq!(
record
.properties
.iter()
.find(|(k, _)| &**k == "name")
.unwrap()
.1,
Value::string(name)
);
}
}
}