use super::*;
use hawdb_storage::column_group::shadow::{
encode_label_set, estimated_shadow_value_bytes, node_table_key, relationship_table_key,
ResidualRowBlob,
};
#[cfg(test)]
use hawdb_storage::column_group::shadow_metadata::DEFAULT_SHADOW_METADATA_BUDGET_BYTES;
#[cfg(test)]
use hawdb_storage::column_group::shadow_metadata::FIRST_DICTIONARY_COLUMN;
use hawdb_storage::column_group::shadow_metadata::{
shadow_table_layout, ShadowKeyDictionary, ShadowMetadataBudget, ShadowTableLayout,
TablePropertyTypes, SHADOW_KEY_DICTIONARY_FILE, SHADOW_PASS1_TABLE_OVERHEAD_BYTES,
};
use hawdb_storage::{
canonical::encode_residual_row_properties,
column_group::{
group::{ColumnGroupWriter, DEFAULT_GROUP_ROW_CAPACITY},
manifest::{
ColumnGroupArtifactDescriptor, ColumnGroupManifest, ColumnGroupTableDirectory,
ColumnGroupTableDirectoryRef, ColumnGroupTableKey, ColumnGroupTableKind,
PublishedColumnGroupCatalog,
},
shadow::{
ColumnarShadowCheckpointReport, ColumnarShadowCheckpointStatus,
ColumnarShadowRecoveryStatus, COLUMN_GROUP_SHADOW_DIR,
},
ColumnGroupError,
},
};
const LABEL_SET_COLUMN: PropertyId = PropertyId(0);
const SOURCE_COLUMN: PropertyId = PropertyId(1);
const TARGET_COLUMN: PropertyId = PropertyId(2);
const RESIDUAL_COLUMN: PropertyId = PropertyId(3);
const SHADOW_ROW_OVERHEAD_BYTES: u64 = 16;
const SHADOW_ENCODER_SCRATCH_MULTIPLIER: u64 = 2;
const SHADOW_STREAMED_FLUSH_ALLOWANCE_BYTES: u64 = 64 * 1024;
pub(super) use hawdb_storage::column_group::shadow::ColumnarShadowState;
#[cfg(test)]
use hawdb_storage::column_group::shadow::DEFAULT_SHADOW_BUFFER_BUDGET_BYTES;
fn shadow_error(error: ColumnGroupError) -> HawDBError {
HawDBError::from_storage_error_with_context(error, "columnar shadow")
}
#[cfg(test)]
fn decode_varint_u32(bytes: &[u8], position: &mut usize) -> Result<u32> {
let mut value = 0u32;
let mut shift = 0u32;
loop {
let byte = *bytes.get(*position).ok_or_else(|| {
HawDBError::Storage("columnar shadow: label set varint is truncated".to_string())
})?;
*position += 1;
let bits = u32::from(byte & 0x7f);
if shift >= 32 || (shift == 28 && bits > 0x0f) {
return Err(HawDBError::Storage(
"columnar shadow: label set varint overflows u32".to_string(),
));
}
value |= bits << shift;
if byte & 0x80 == 0 {
return Ok(value);
}
shift += 7;
}
}
#[cfg(test)]
fn decode_label_set(bytes: &[u8]) -> Result<BTreeSet<LabelId>> {
let mut labels = BTreeSet::new();
let mut position = 0usize;
while position < bytes.len() {
labels.insert(LabelId(decode_varint_u32(bytes, &mut position)?));
}
Ok(labels)
}
#[derive(Debug)]
pub struct ColumnarShadowAdmission {
_permit: Option<Box<dyn hawdb_storage::background::BackgroundWorkPermit>>,
allowance_bytes: Option<u64>,
}
impl ColumnarShadowAdmission {
#[doc(hidden)]
pub fn pre_admitted(allowance_bytes: u64) -> Self {
Self {
_permit: None,
allowance_bytes: Some(allowance_bytes),
}
}
fn owned(
permit: Box<dyn hawdb_storage::background::BackgroundWorkPermit>,
allowance_bytes: u64,
) -> Self {
Self {
_permit: Some(permit),
allowance_bytes: Some(allowance_bytes),
}
}
fn unmetered() -> Self {
Self {
_permit: None,
allowance_bytes: None,
}
}
fn admitted_budget_bytes(&self) -> u64 {
self.allowance_bytes.unwrap_or(0)
}
fn draw_for_streamed_flush(&self, metadata_bytes: u64) -> Result<()> {
let Some(allowance) = self.allowance_bytes else {
return Ok(());
};
let transient = SHADOW_STREAMED_FLUSH_ALLOWANCE_BYTES.saturating_add(metadata_bytes);
if transient > allowance {
return Err(HawDBError::Storage(format!(
"columnar shadow streamed flush needs {transient} bytes, \
exceeding its admitted {allowance} byte allowance"
)));
}
Ok(())
}
fn draw_for_flush(&self, flush_bytes: u64, metadata_bytes: u64) -> Result<()> {
let Some(allowance) = self.allowance_bytes else {
return Ok(());
};
let transient = flush_bytes
.saturating_mul(1 + SHADOW_ENCODER_SCRATCH_MULTIPLIER)
.saturating_add(metadata_bytes);
if transient > allowance {
return Err(HawDBError::Storage(format!(
"columnar shadow flush and metadata need {transient} bytes, exceeding their \
admitted {allowance} byte allowance"
)));
}
Ok(())
}
}
#[derive(Debug, Default)]
struct ShadowGroupBuffer {
ids: Vec<u64>,
typed: Vec<Vec<Value>>,
label_sets: Vec<Option<Vec<u8>>>,
sources: Vec<Value>,
targets: Vec<Value>,
residuals: Vec<Option<Vec<u8>>>,
estimated_bytes: u64,
}
struct BuiltShadowTables {
references: Vec<(ColumnGroupTableDirectoryRef, u64)>,
group_bytes_written: u64,
peak_builder_bytes: u64,
metadata_bytes_used: u64,
peak_metadata_bytes: u64,
flushed_group_count: usize,
oversized_row_group_count: usize,
}
struct ShadowCheckpointBuilder {
shadow_root: PathBuf,
generation: ManifestGeneration,
admission: ColumnarShadowAdmission,
buffer_budget_bytes: u64,
writer: ColumnGroupWriter,
layouts: BTreeMap<ColumnGroupTableKey, ShadowTableLayout>,
buffers: BTreeMap<ColumnGroupTableKey, ShadowGroupBuffer>,
descriptors: BTreeMap<ColumnGroupTableKey, Vec<ColumnGroupArtifactDescriptor>>,
next_group_index: BTreeMap<ColumnGroupTableKey, u64>,
buffered_bytes: u64,
metadata_budget: ShadowMetadataBudget,
peak_builder_bytes: u64,
flushed_group_count: usize,
oversized_row_group_count: usize,
group_bytes_written: u64,
}
impl ShadowCheckpointBuilder {
fn new(
shadow_root: PathBuf,
generation: ManifestGeneration,
admission: ColumnarShadowAdmission,
buffer_budget_bytes: u64,
layouts: BTreeMap<ColumnGroupTableKey, ShadowTableLayout>,
metadata_budget: ShadowMetadataBudget,
) -> Self {
let mut builder = Self {
shadow_root,
generation,
admission,
buffer_budget_bytes,
writer: ColumnGroupWriter::default(),
layouts,
buffers: BTreeMap::new(),
descriptors: BTreeMap::new(),
next_group_index: BTreeMap::new(),
buffered_bytes: 0,
metadata_budget,
peak_builder_bytes: 0,
flushed_group_count: 0,
oversized_row_group_count: 0,
group_bytes_written: 0,
};
builder.note_peak();
builder
}
fn note_peak(&mut self) {
let footprint = self
.metadata_budget
.used_bytes()
.saturating_add(self.buffered_bytes);
self.peak_builder_bytes = self.peak_builder_bytes.max(footprint);
}
fn append_node(&mut self, dictionary: &ShadowKeyDictionary, node: NodeRecord) -> Result<()> {
let NodeRecord {
id,
labels,
properties,
} = node;
let key = node_table_key(&labels);
let label_set = encode_label_set(&labels);
self.append_row(dictionary, key, id.0, Some(label_set), None, properties)
}
fn append_relationship(
&mut self,
dictionary: &ShadowKeyDictionary,
relationship: RelRecord,
) -> Result<()> {
let RelRecord {
id,
source,
target,
rel_type,
properties,
} = relationship;
let key = relationship_table_key(rel_type);
self.append_row(
dictionary,
key,
id.0,
None,
Some((source.0, target.0)),
properties,
)
}
fn estimate_row_bytes(
&self,
dictionary: &ShadowKeyDictionary,
table: ColumnGroupTableKey,
label_set: Option<&Vec<u8>>,
properties: &BTreeMap<String, Value>,
) -> u64 {
let mut estimated =
SHADOW_ROW_OVERHEAD_BYTES.saturating_add(label_set.map_or(0, |blob| blob.len() as u64));
for (key, value) in properties {
estimated = estimated.saturating_add(estimated_shadow_value_bytes(value));
let typed = dictionary
.id(key)
.is_some_and(|id| self.layouts[&table].typed_index().contains_key(&id));
if !typed {
estimated = estimated.saturating_add(8);
}
}
estimated
}
fn append_row(
&mut self,
dictionary: &ShadowKeyDictionary,
table: ColumnGroupTableKey,
id: u64,
label_set: Option<Vec<u8>>,
endpoints: Option<(u64, u64)>,
properties: BTreeMap<String, Value>,
) -> Result<()> {
let row_bytes = self.estimate_row_bytes(dictionary, table, label_set.as_ref(), &properties);
if row_bytes > self.buffer_budget_bytes {
let pending = self.buffers.keys().copied().collect::<Vec<_>>();
for pending_table in pending {
self.flush_table(pending_table)?;
}
return self.flush_streamed_single_row(
dictionary,
table,
id,
label_set,
endpoints,
&properties,
);
}
while self.buffered_bytes > 0
&& self.buffered_bytes.saturating_add(row_bytes) > self.buffer_budget_bytes
{
self.flush_largest_buffer()?;
}
let layout_typed_len = self.layouts[&table].typed().len();
let mut typed_cells = vec![Value::Null; layout_typed_len];
let mut residual_values = Vec::new();
for (key, value) in properties {
let property_id = dictionary.id(&key).ok_or_else(|| {
HawDBError::Storage(format!(
"columnar shadow pass 2 encountered property key {key:?} absent from pass 1"
))
})?;
match self.layouts[&table].typed_index().get(&property_id) {
Some(index) => {
typed_cells[*index] = value;
}
None => {
residual_values.push((property_id.0, value));
}
}
}
let residual = if residual_values.is_empty() {
None
} else {
residual_values.sort_by_key(|(key_id, _)| *key_id);
let residual_entries = residual_values
.iter()
.map(|(key_id, value)| (*key_id, value))
.collect::<Vec<_>>();
Some(
encode_residual_row_properties(&residual_entries)
.map_err(HawDBError::from_storage_error)?,
)
};
let buffer = self.buffers.entry(table).or_default();
if buffer.typed.is_empty() {
buffer.typed = vec![Vec::new(); layout_typed_len];
}
buffer.ids.push(id);
for (column, cell) in buffer.typed.iter_mut().zip(typed_cells) {
column.push(cell);
}
match table.kind {
ColumnGroupTableKind::Node => {
buffer.label_sets.push(label_set);
}
ColumnGroupTableKind::Relationship => {
let (source, target) = endpoints.expect("relationship row has endpoints");
buffer.sources.push(Value::Int(source as i64));
buffer.targets.push(Value::Int(target as i64));
}
ColumnGroupTableKind::Relational => {
return Err(HawDBError::Storage(
"columnar shadow does not cover relational tables".to_string(),
));
}
}
buffer.residuals.push(residual);
buffer.estimated_bytes = buffer.estimated_bytes.saturating_add(row_bytes);
let full = buffer.ids.len() >= DEFAULT_GROUP_ROW_CAPACITY as usize;
self.buffered_bytes = self.buffered_bytes.saturating_add(row_bytes);
self.note_peak();
if full {
self.flush_table(table)?;
}
Ok(())
}
fn flush_streamed_single_row(
&mut self,
dictionary: &ShadowKeyDictionary,
table: ColumnGroupTableKey,
id: u64,
label_set: Option<Vec<u8>>,
endpoints: Option<(u64, u64)>,
properties: &BTreeMap<String, Value>,
) -> Result<()> {
self.admission
.draw_for_streamed_flush(self.metadata_budget.used_bytes())?;
let mut typed_cells: Vec<(PropertyId, &Value)> = Vec::new();
let mut residual_entries: Vec<(u32, &Value)> = Vec::new();
for (key, value) in properties {
let property_id = dictionary.id(key).ok_or_else(|| {
HawDBError::Storage(format!(
"columnar shadow pass 2 encountered property key {key:?} absent from pass 1"
))
})?;
if self.layouts[&table]
.typed_index()
.contains_key(&property_id)
{
typed_cells.push((property_id, value));
} else {
residual_entries.push((property_id.0, value));
}
}
residual_entries.sort_by_key(|(key_id, _)| *key_id);
let endpoint_values = endpoints
.map(|(source, target)| (Value::Int(source as i64), Value::Int(target as i64)));
let mut value_columns = typed_cells;
if let Some((source, target)) = &endpoint_values {
value_columns.push((SOURCE_COLUMN, source));
value_columns.push((TARGET_COLUMN, target));
}
let residual_blob = if residual_entries.is_empty() {
None
} else {
Some(ResidualRowBlob::new(&residual_entries)?)
};
let label_slice: Option<&[u8]> = label_set.as_deref();
let mut byte_columns: Vec<(
PropertyId,
&dyn hawdb_storage::column_group::group::StreamedBlob,
)> = Vec::new();
if let Some(label) = &label_slice {
byte_columns.push((LABEL_SET_COLUMN, label));
}
if let Some(blob) = &residual_blob {
byte_columns.push((RESIDUAL_COLUMN, blob));
}
let kind_tag = match table.kind {
ColumnGroupTableKind::Node => "node",
ColumnGroupTableKind::Relationship => "relationship",
ColumnGroupTableKind::Relational => {
return Err(HawDBError::Storage(
"columnar shadow does not cover relational tables".to_string(),
));
}
};
let group_index = self.next_group_index.entry(table).or_insert(0);
let file_name = format!(
"group-{kind_tag}-{}-{}-{}.hawdb",
table.table_id, self.generation.0, group_index
);
let path = self.shadow_root.join(&file_name);
self.writer
.write_single_row_streaming(
&path,
*group_index,
self.generation,
id,
&value_columns,
&byte_columns,
)
.map_err(shadow_error)?;
*group_index += 1;
self.group_bytes_written = self
.group_bytes_written
.saturating_add(fs::metadata(&path)?.len());
self.flushed_group_count += 1;
self.oversized_row_group_count += 1;
let footprint = self
.metadata_budget
.used_bytes()
.saturating_add(self.buffered_bytes)
.saturating_add(SHADOW_STREAMED_FLUSH_ALLOWANCE_BYTES);
self.peak_builder_bytes = self.peak_builder_bytes.max(footprint);
self.descriptors.entry(table).or_default().push(
ColumnGroupArtifactDescriptor::inspect(&self.shadow_root, file_name, None)
.map_err(shadow_error)?,
);
Ok(())
}
fn flush_largest_buffer(&mut self) -> Result<()> {
let largest = self
.buffers
.iter()
.filter(|(_, buffer)| !buffer.ids.is_empty())
.max_by_key(|(_, buffer)| buffer.estimated_bytes)
.map(|(table, _)| *table);
match largest {
Some(table) => self.flush_table(table),
None => Ok(()),
}
}
fn flush_table(&mut self, table: ColumnGroupTableKey) -> Result<()> {
let Some(buffer) = self.buffers.remove(&table) else {
return Ok(());
};
if buffer.ids.is_empty() {
return Ok(());
}
self.admission
.draw_for_flush(buffer.estimated_bytes, self.metadata_budget.used_bytes())?;
let kind_tag = match table.kind {
ColumnGroupTableKind::Node => "node",
ColumnGroupTableKind::Relationship => "relationship",
ColumnGroupTableKind::Relational => {
return Err(HawDBError::Storage(
"columnar shadow does not cover relational tables".to_string(),
));
}
};
let group_index = self.next_group_index.entry(table).or_insert(0);
let file_name = format!(
"group-{kind_tag}-{}-{}-{}.hawdb",
table.table_id, self.generation.0, group_index
);
let mut value_columns: Vec<(PropertyId, Vec<Value>)> = self.layouts[&table]
.typed()
.iter()
.copied()
.zip(buffer.typed)
.collect();
let mut byte_columns: Vec<(PropertyId, Vec<Option<Vec<u8>>>)> = Vec::new();
match table.kind {
ColumnGroupTableKind::Node => {
byte_columns.push((LABEL_SET_COLUMN, buffer.label_sets));
}
ColumnGroupTableKind::Relationship => {
value_columns.push((SOURCE_COLUMN, buffer.sources));
value_columns.push((TARGET_COLUMN, buffer.targets));
}
ColumnGroupTableKind::Relational => unreachable!("rejected above"),
}
byte_columns.push((RESIDUAL_COLUMN, buffer.residuals));
let path = self.shadow_root.join(&file_name);
self.writer
.write_with_byte_columns(
&path,
*group_index,
self.generation,
&buffer.ids,
&value_columns,
&byte_columns,
)
.map_err(shadow_error)?;
*group_index += 1;
self.group_bytes_written = self
.group_bytes_written
.saturating_add(fs::metadata(&path)?.len());
self.flushed_group_count += 1;
self.buffered_bytes = self.buffered_bytes.saturating_sub(buffer.estimated_bytes);
self.descriptors.entry(table).or_default().push(
ColumnGroupArtifactDescriptor::inspect(&self.shadow_root, file_name, None)
.map_err(shadow_error)?,
);
Ok(())
}
fn finish(mut self) -> Result<BuiltShadowTables> {
let pending = self.buffers.keys().copied().collect::<Vec<_>>();
for table in pending {
self.flush_table(table)?;
}
let mut references = Vec::with_capacity(self.descriptors.len());
for (table, descriptors) in std::mem::take(&mut self.descriptors) {
let directory = ColumnGroupTableDirectory::new(table, self.generation, descriptors)
.map_err(shadow_error)?;
let _ = fs::remove_file(self.shadow_root.join(directory.file_name()));
let reference = directory
.write_immutable(&self.shadow_root)
.map_err(shadow_error)?;
let directory_bytes = fs::metadata(self.shadow_root.join(reference.file_name()))?.len();
references.push((reference, directory_bytes));
}
Ok(BuiltShadowTables {
references,
group_bytes_written: self.group_bytes_written,
peak_builder_bytes: self.peak_builder_bytes,
metadata_bytes_used: self.metadata_budget.used_bytes(),
peak_metadata_bytes: self.metadata_budget.peak_bytes(),
flushed_group_count: self.flushed_group_count,
oversized_row_group_count: self.oversized_row_group_count,
})
}
}
fn sweep_superseded_shadow_files(
shadow_root: &Path,
catalog: &PublishedColumnGroupCatalog,
) -> (usize, usize) {
let mut keep = BTreeSet::new();
keep.insert(hawdb_storage::column_group::manifest::COLUMN_GROUP_MANIFEST_FILE.to_string());
keep.insert(SHADOW_KEY_DICTIONARY_FILE.to_string());
for reference in catalog.manifest().tables() {
keep.insert(reference.file_name().to_string());
}
for directory in catalog.directories() {
for group in directory.groups() {
keep.insert(group.group_file().to_string());
if let Some(deletion_vector) = group.deletion_vector_file() {
keep.insert(deletion_vector.to_string());
}
}
}
let mut reclaimed = 0usize;
let mut failed = 0usize;
let Ok(entries) = fs::read_dir(shadow_root) else {
return (0, 0);
};
for entry in entries.flatten() {
let file_name = entry.file_name();
let Some(name) = file_name.to_str() else {
continue;
};
if !(name.ends_with(".hawdb") || name.ends_with(".hawdb.tmp")) {
continue;
}
if keep.contains(name) {
continue;
}
match fs::remove_file(entry.path()) {
Ok(()) => reclaimed += 1,
Err(_) => failed += 1,
}
}
(reclaimed, failed)
}
impl GraphStore {
pub(super) fn mark_columnar_node_dirty(&mut self, labels: &BTreeSet<LabelId>) {
if !self.columnar_shadow.enabled {
return;
}
let key = node_table_key(labels);
self.columnar_shadow.dirty.insert(key);
}
pub(super) fn mark_columnar_relationship_dirty(&mut self, rel_type: RelTypeId) {
if !self.columnar_shadow.enabled {
return;
}
let key = relationship_table_key(rel_type);
self.columnar_shadow.dirty.insert(key);
}
pub fn columnar_shadow_checkpoint_report(&self) -> Option<ColumnarShadowCheckpointReport> {
self.columnar_shadow.report.clone()
}
pub fn columnar_shadow_recovery_status(&self) -> ColumnarShadowRecoveryStatus {
self.columnar_shadow.recovery.clone()
}
pub(super) fn mount_columnar_shadow_for_recovery(&mut self) -> Result<()> {
self.columnar_shadow.enabled = true;
self.columnar_shadow.all_dirty = true;
let Some(durable) = &self.durable else {
return Ok(());
};
let shadow_root = durable.root_path.join(COLUMN_GROUP_SHADOW_DIR);
let read_only = durable.read_only;
let checkpoint_commit_epoch = durable.checkpoint_commit_epoch;
if !shadow_root.exists() {
return Ok(());
}
let opened = ColumnGroupManifest::open(&shadow_root)
.map_err(shadow_error)
.and_then(|catalog| match catalog {
None => Ok(None),
Some(catalog) => ShadowKeyDictionary::load(
&shadow_root,
self.columnar_shadow.metadata_budget_bytes,
)
.map(|_| Some(catalog)),
});
match opened {
Ok(None) => {}
Ok(Some(catalog)) => {
self.columnar_shadow.all_dirty =
catalog.manifest().source_commit_epoch() != checkpoint_commit_epoch;
self.columnar_shadow.catalog = Some(catalog);
self.columnar_shadow.recovery.validated = true;
}
Err(error @ HawDBError::FileDescriptors(_)) => return Err(error),
Err(error) => {
if !read_only {
let _ = fs::remove_dir_all(&shadow_root);
}
self.columnar_shadow.recovery.discarded = true;
self.columnar_shadow.recovery.error = Some(error.to_string());
}
}
Ok(())
}
pub fn columnar_shadow_admission_bytes(&self) -> u64 {
if !self.columnar_shadow.enabled {
return 0;
}
let dictionary_bytes = self
.durable
.as_ref()
.and_then(|durable| {
fs::metadata(
durable
.root_path
.join(COLUMN_GROUP_SHADOW_DIR)
.join(SHADOW_KEY_DICTIONARY_FILE),
)
.ok()
})
.map_or(0, |metadata| metadata.len());
self.columnar_shadow
.buffer_budget_bytes
.saturating_mul(1 + SHADOW_ENCODER_SCRATCH_MULTIPLIER)
.saturating_add(dictionary_bytes.saturating_mul(8))
.saturating_add(self.columnar_shadow.metadata_budget_bytes.saturating_mul(2))
}
fn acquire_columnar_shadow_admission(&self) -> Result<ColumnarShadowAdmission> {
let Some(governor) = &self.runtime_governor else {
return Ok(ColumnarShadowAdmission::unmetered());
};
let allowance = self.columnar_shadow_admission_bytes();
let request = hawdb_storage::background::BackgroundWorkRequest {
cpu_slots: 1,
memory_bytes: allowance,
io_slots: 1,
};
match governor.try_admit(request) {
Ok(permit) => Ok(ColumnarShadowAdmission::owned(permit, allowance)),
Err(error) => Err(HawDBError::Storage(format!(
"columnar shadow build admission denied: {error}"
))),
}
}
pub(super) fn record_columnar_shadow_checkpoint(
&mut self,
source_commit_epoch: u64,
admission: Option<ColumnarShadowAdmission>,
) {
if !self.columnar_shadow.enabled {
return;
}
let record_failure = |report: &mut Option<ColumnarShadowCheckpointReport>,
error: HawDBError| {
*report = Some(ColumnarShadowCheckpointReport {
status: ColumnarShadowCheckpointStatus::Failed {
error: error.to_string(),
},
source_commit_epoch,
..ColumnarShadowCheckpointReport::default()
});
};
let admission = match admission {
Some(admission) => admission,
None => match self.acquire_columnar_shadow_admission() {
Ok(admission) => admission,
Err(error) => {
record_failure(&mut self.columnar_shadow.report, error);
return;
}
},
};
if let Err(error) = self.publish_columnar_shadow_checkpoint(source_commit_epoch, admission)
{
record_failure(&mut self.columnar_shadow.report, error);
}
}
fn publish_columnar_shadow_checkpoint(
&mut self,
source_commit_epoch: u64,
admission: ColumnarShadowAdmission,
) -> Result<()> {
if !self.columnar_shadow.enabled {
return Ok(());
}
let started = std::time::Instant::now();
let Some(durable) = self.durable.as_ref() else {
return Ok(());
};
let shadow_root = durable.root_path.join(COLUMN_GROUP_SHADOW_DIR);
let previous = self.columnar_shadow.catalog.clone();
if previous.is_none() && shadow_root.exists() {
for entry in fs::read_dir(&shadow_root)? {
let entry = entry?;
if entry.file_name() == SHADOW_KEY_DICTIONARY_FILE {
continue;
}
let path = entry.path();
if path.is_dir() {
let _ = fs::remove_dir_all(&path);
} else {
let _ = fs::remove_file(&path);
}
}
}
fs::create_dir_all(&shadow_root)?;
let (mut dictionary, mut metadata_budget) =
ShadowKeyDictionary::load(&shadow_root, self.columnar_shadow.metadata_budget_bytes)?;
let dictionary_len_before = dictionary.len();
let all_dirty = self.columnar_shadow.all_dirty || previous.is_none();
let dirty_set = self.columnar_shadow.dirty.clone();
let is_dirty = |key: ColumnGroupTableKey| {
all_dirty
|| dirty_set.contains(&key)
|| previous
.as_ref()
.is_none_or(|catalog| catalog.manifest().table(key).is_none())
};
let build_result = (|| -> Result<(BuiltShadowTables, ManifestGeneration, Option<ManifestGeneration>, u64)> {
let mut table_types: BTreeMap<ColumnGroupTableKey, TablePropertyTypes> = BTreeMap::new();
self.try_visit_nodes_owned(None, |node| {
let key = node_table_key(&node.labels);
if is_dirty(key) {
if !table_types.contains_key(&key) {
metadata_budget
.charge(SHADOW_PASS1_TABLE_OVERHEAD_BYTES, "pass-1 table state")?;
}
table_types.entry(key).or_default().observe(
&node.properties,
&mut dictionary,
&mut metadata_budget,
)?;
}
Ok(GraphScanControl::Continue)
})?;
self.try_visit_relationships_owned(None, |relationship| {
let key = relationship_table_key(relationship.rel_type);
if is_dirty(key) {
if !table_types.contains_key(&key) {
metadata_budget
.charge(SHADOW_PASS1_TABLE_OVERHEAD_BYTES, "pass-1 table state")?;
}
table_types.entry(key).or_default().observe(
&relationship.properties,
&mut dictionary,
&mut metadata_budget,
)?;
}
Ok(GraphScanControl::Continue)
})?;
let mut layouts = BTreeMap::new();
for (table, types) in &table_types {
layouts.insert(*table, shadow_table_layout(types, &mut metadata_budget)?);
}
let parent_generation = previous
.as_ref()
.map(|catalog| catalog.manifest().generation());
let generation = ManifestGeneration(parent_generation.map_or(1, |parent| parent.0 + 1));
let admitted_budget_bytes = admission.admitted_budget_bytes();
let mut builder = ShadowCheckpointBuilder::new(
shadow_root.clone(),
generation,
admission,
self.columnar_shadow.buffer_budget_bytes,
layouts,
metadata_budget,
);
self.try_visit_nodes_owned(None, |node| {
if is_dirty(node_table_key(&node.labels)) {
builder.append_node(&dictionary, node)?;
}
Ok(GraphScanControl::Continue)
})?;
self.try_visit_relationships_owned(None, |relationship| {
if is_dirty(relationship_table_key(relationship.rel_type)) {
builder.append_relationship(&dictionary, relationship)?;
}
Ok(GraphScanControl::Continue)
})?;
let built = builder.finish()?;
Ok((built, generation, parent_generation, admitted_budget_bytes))
})();
let (built, generation, parent_generation, admitted_budget_bytes) = match build_result {
Ok(values) => values,
Err(error) => {
if let Ok(mut persist_budget) = ShadowMetadataBudget::new(u64::MAX, 0) {
let _ = dictionary.persist(&shadow_root, &mut persist_budget);
}
return Err(error);
}
};
let mut peak_builder_bytes = built.peak_builder_bytes;
let mut tables = Vec::new();
let mut reused_table_count = 0usize;
let mut metadata_bytes_written = 0u64;
if let Some(previous) = &previous {
for reference in previous.manifest().tables() {
if !is_dirty(reference.table()) {
tables.push(reference.clone());
reused_table_count += 1;
}
}
}
let dirty_table_count = built.references.len();
for (reference, directory_bytes) in built.references {
metadata_bytes_written = metadata_bytes_written.saturating_add(directory_bytes);
tables.push(reference);
}
if dictionary.len() != dictionary_len_before || dictionary_len_before == 0 {
let mut persist_budget =
ShadowMetadataBudget::new(u64::MAX, built.metadata_bytes_used)?;
metadata_bytes_written = metadata_bytes_written
.saturating_add(dictionary.persist(&shadow_root, &mut persist_budget)?);
peak_builder_bytes = peak_builder_bytes.max(persist_budget.peak_bytes());
debug_assert!(
persist_budget.peak_bytes() >= built.peak_metadata_bytes,
"dictionary serialization carries the build's metadata baseline"
);
}
let manifest =
ColumnGroupManifest::new(generation, parent_generation, source_commit_epoch, tables)
.map_err(shadow_error)?;
let table_count = manifest.tables().len();
let catalog = manifest.publish(&shadow_root).map_err(shadow_error)?;
metadata_bytes_written = metadata_bytes_written.saturating_add(
fs::metadata(
shadow_root.join(hawdb_storage::column_group::manifest::COLUMN_GROUP_MANIFEST_FILE),
)?
.len(),
);
let (reclaimed_file_count, reclaim_failed_count) =
sweep_superseded_shadow_files(&shadow_root, &catalog);
self.columnar_shadow.catalog = Some(catalog);
self.columnar_shadow.all_dirty = false;
self.columnar_shadow.dirty.clear();
self.columnar_shadow.report = Some(ColumnarShadowCheckpointReport {
status: ColumnarShadowCheckpointStatus::Published,
generation: generation.0,
source_commit_epoch,
table_count,
dirty_table_count,
reused_table_count,
group_bytes_written: built.group_bytes_written,
metadata_bytes_written,
peak_builder_bytes,
admitted_budget_bytes,
flushed_group_count: built.flushed_group_count,
oversized_row_group_count: built.oversized_row_group_count,
reclaimed_file_count,
reclaim_failed_count,
elapsed_micros: u64::try_from(started.elapsed().as_micros()).unwrap_or(u64::MAX),
});
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::any::TypeId;
#[test]
fn root_facade_preserves_storage_columnar_shadow_contract_identity() {
assert_eq!(
TypeId::of::<crate::column_group::shadow::ColumnarShadowCheckpointStatus>(),
TypeId::of::<hawdb_storage::column_group::shadow::ColumnarShadowCheckpointStatus>()
);
assert_eq!(
TypeId::of::<crate::column_group::shadow::ColumnarShadowCheckpointReport>(),
TypeId::of::<hawdb_storage::column_group::shadow::ColumnarShadowCheckpointReport>()
);
assert_eq!(
TypeId::of::<crate::column_group::shadow::ColumnarShadowRecoveryStatus>(),
TypeId::of::<hawdb_storage::column_group::shadow::ColumnarShadowRecoveryStatus>()
);
}
use hawdb_storage::{
canonical::decode_residual_row_properties, column_group::group::ColumnGroupReader,
};
fn unique_shadow_dir(name: &str) -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("hawdb_columnar_shadow_{name}_{nanos}"))
}
fn shadow_replay_config() -> WalReplayConfig {
WalReplayConfig {
graph_columnar_shadow_checkpoint: true,
..WalReplayConfig::default()
}
}
fn open_shadow_store(path: &Path, catalog: &mut Catalog) -> GraphStore {
GraphStore::open_with_durability_and_replay_config(
path,
catalog,
DurabilityPolicy::SyncOnEveryWrite,
shadow_replay_config(),
)
.unwrap()
}
fn properties(entries: &[(&str, Value)]) -> BTreeMap<String, Value> {
entries
.iter()
.map(|(key, value)| ((*key).to_string(), value.clone()))
.collect()
}
#[derive(Debug, Default)]
struct ShadowInference {
typed_column_count: usize,
residual_row_count: usize,
}
fn reconstruct_shadow(
root: &Path,
) -> (
BTreeMap<NodeId, NodeRecord>,
BTreeMap<RelId, RelRecord>,
ShadowInference,
) {
let shadow_root = root.join(COLUMN_GROUP_SHADOW_DIR);
let catalog = ColumnGroupManifest::open(&shadow_root).unwrap().unwrap();
let (dictionary, _) =
ShadowKeyDictionary::load(&shadow_root, DEFAULT_SHADOW_METADATA_BUDGET_BYTES).unwrap();
let mut nodes = BTreeMap::new();
let mut relationships = BTreeMap::new();
let mut inference = ShadowInference::default();
for directory in catalog.directories() {
let table = directory.table();
for descriptor in directory.groups() {
let reader =
ColumnGroupReader::open_path(&shadow_root.join(descriptor.group_file()))
.unwrap();
let ids = reader.read_ids().unwrap();
let residual = match reader.read_byte_column(RESIDUAL_COLUMN) {
Ok(residual) => residual,
Err(hawdb_storage::column_group::ColumnGroupError::PropertyMissing(_)) => {
vec![None; reader.directory().row_count as usize]
}
Err(error) => panic!("residual column read failed: {error}"),
};
let mut row_properties: Vec<BTreeMap<String, Value>> =
vec![BTreeMap::new(); ids.len()];
for (row, blob) in residual.iter().enumerate() {
let Some(blob) = blob else { continue };
inference.residual_row_count += 1;
for (key_id, value) in decode_residual_row_properties(blob).unwrap() {
let key = dictionary.key(PropertyId(key_id)).unwrap().to_string();
assert!(row_properties[row].insert(key, value).is_none());
}
}
for column in &reader.directory().columns {
if column.property_id.0 < FIRST_DICTIONARY_COLUMN {
continue;
}
inference.typed_column_count += 1;
let key = dictionary.key(column.property_id).unwrap().to_string();
let values = reader.read_column(column.property_id, None).unwrap();
for (row, value) in values.into_iter().enumerate() {
if !matches!(value, Value::Null) {
assert!(row_properties[row].insert(key.clone(), value).is_none());
}
}
}
match table.kind {
ColumnGroupTableKind::Node => {
let label_sets = reader.read_byte_column(LABEL_SET_COLUMN).unwrap();
for (row, id) in ids.iter().enumerate() {
let labels = decode_label_set(
label_sets[row].as_deref().expect("label set present"),
)
.unwrap();
assert_eq!(node_table_key(&labels), table);
let record = NodeRecord {
id: NodeId(*id),
labels,
properties: std::mem::take(&mut row_properties[row]),
};
assert!(nodes.insert(record.id, record).is_none());
}
}
ColumnGroupTableKind::Relationship => {
let sources = reader.read_column(SOURCE_COLUMN, None).unwrap();
let targets = reader.read_column(TARGET_COLUMN, None).unwrap();
let rel_type = RelTypeId(u32::try_from(table.table_id).unwrap());
for (row, id) in ids.iter().enumerate() {
let source = match sources[row] {
Value::Int(value) => NodeId(value as u64),
ref other => panic!("source column held {other:?}"),
};
let target = match targets[row] {
Value::Int(value) => NodeId(value as u64),
ref other => panic!("target column held {other:?}"),
};
let record = RelRecord {
id: RelId(*id),
source,
target,
rel_type,
properties: std::mem::take(&mut row_properties[row]),
};
assert!(relationships.insert(record.id, record).is_none());
}
}
ColumnGroupTableKind::Relational => panic!("shadow holds no relational table"),
}
}
}
(nodes, relationships, inference)
}
fn canonical_scan(
store: &GraphStore,
) -> (BTreeMap<NodeId, NodeRecord>, BTreeMap<RelId, RelRecord>) {
let nodes = store
.node_records_owned()
.map(|record| record.map(|node| (node.id, node)))
.collect::<Result<BTreeMap<_, _>>>()
.unwrap();
let relationships = store
.relationship_records_owned()
.map(|record| record.map(|relationship| (relationship.id, relationship)))
.collect::<Result<BTreeMap<_, _>>>()
.unwrap();
(nodes, relationships)
}
fn assert_shadow_equivalence(root: &Path, store: &GraphStore) -> ShadowInference {
let (expected_nodes, expected_relationships) = canonical_scan(store);
let (nodes, relationships, inference) = reconstruct_shadow(root);
assert_eq!(nodes, expected_nodes);
assert_eq!(relationships, expected_relationships);
inference
}
fn single_typed_string_layout(
dictionary: &mut ShadowKeyDictionary,
table: ColumnGroupTableKey,
key: &str,
) -> (
BTreeMap<ColumnGroupTableKey, ShadowTableLayout>,
ShadowMetadataBudget,
) {
let mut metadata_budget = ShadowMetadataBudget::new(
DEFAULT_SHADOW_METADATA_BUDGET_BYTES,
dictionary.estimated_bytes(),
)
.unwrap();
dictionary.intern(key, &mut metadata_budget).unwrap();
metadata_budget
.charge(SHADOW_PASS1_TABLE_OVERHEAD_BYTES, "test pass-1 state")
.unwrap();
let mut types = TablePropertyTypes::default();
types
.observe(
&BTreeMap::from([(key.to_string(), Value::String(String::new()))]),
dictionary,
&mut metadata_budget,
)
.unwrap();
(
BTreeMap::from([(
table,
shadow_table_layout(&types, &mut metadata_budget).unwrap(),
)]),
metadata_budget,
)
}
#[test]
fn shadow_builder_moves_owned_typed_payload_into_group_buffer() {
let root = unique_shadow_dir("owned_typed_payload");
fs::create_dir_all(&root).unwrap();
let table = ColumnGroupTableKey::new(ColumnGroupTableKind::Node, 0);
let mut dictionary = ShadowKeyDictionary::default();
let (layouts, metadata_budget) = single_typed_string_layout(&mut dictionary, table, "body");
let mut builder = ShadowCheckpointBuilder::new(
root.clone(),
ManifestGeneration(1),
ColumnarShadowAdmission::unmetered(),
DEFAULT_SHADOW_BUFFER_BUDGET_BYTES,
layouts,
metadata_budget,
);
let payload = "x".repeat(1024 * 1024);
let payload_ptr = payload.as_ptr();
builder
.append_node(
&dictionary,
NodeRecord {
id: NodeId(1),
labels: BTreeSet::new(),
properties: BTreeMap::from([("body".to_string(), Value::String(payload))]),
},
)
.unwrap();
let Value::String(buffered) = &builder.buffers[&table].typed[0][0] else {
panic!("typed body must remain a string")
};
assert_eq!(buffered.as_ptr(), payload_ptr);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn shadow_reconstruction_matches_canonical_scan_and_reuses_untouched_tables() {
let root = unique_shadow_dir("equivalence");
let mut catalog = Catalog::default();
let mut store = open_shadow_store(&root, &mut catalog);
let n1 = store
.create_node(
&mut catalog,
"Person",
properties(&[
("name", Value::String("alice".to_string())),
("age", Value::Int(30)),
("score", Value::Float(1.5)),
("flex", Value::Int(7)),
(
"tags",
Value::List(vec![Value::Int(1), Value::String("x".to_string())]),
),
]),
)
.unwrap();
let n2 = store
.create_node(
&mut catalog,
"Person",
properties(&[
("name", Value::String("bob".to_string())),
("age", Value::Int(41)),
("active", Value::Bool(true)),
("flex", Value::String("seven".to_string())),
("ghost", Value::Null),
]),
)
.unwrap();
let n3 = store
.create_node(
&mut catalog,
"Person",
properties(&[
("name", Value::String("cara".to_string())),
("score", Value::Float(-2.25)),
("active", Value::Bool(false)),
("meta", Value::Map(properties(&[("k", Value::Int(3))]))),
]),
)
.unwrap();
catalog.get_or_create_label("Extra");
let n4 = NodeId(store.next_node_id);
store.apply_create_node_with_labels(
&catalog,
n4,
BTreeSet::from([
catalog.label_id("Person").unwrap(),
catalog.label_id("Extra").unwrap(),
]),
properties(&[("name", Value::String("dora".to_string()))]),
);
store.commit_epoch += 1;
let u1 = NodeId(store.next_node_id);
store.apply_create_node_with_labels(
&catalog,
u1,
BTreeSet::new(),
properties(&[("kind", Value::String("floating".to_string()))]),
);
store.commit_epoch += 1;
store
.create_relationship(
&mut catalog,
n1,
n2,
"KNOWS",
properties(&[("since", Value::Int(2019))]),
)
.unwrap();
store
.create_relationship(
&mut catalog,
n2,
n3,
"KNOWS",
properties(&[("since", Value::Int(2021)), ("weight", Value::Float(0.5))]),
)
.unwrap();
store
.create_relationship(
&mut catalog,
n3,
n1,
"LIKES",
properties(&[("strength", Value::Int(2))]),
)
.unwrap();
store
.create_relationship(
&mut catalog,
n4,
n1,
"LIKES",
properties(&[("strength", Value::String("high".to_string()))]),
)
.unwrap();
store.checkpoint(&catalog).unwrap();
let inference = assert_shadow_equivalence(&root, &store);
assert_eq!(inference.typed_column_count, 7);
assert_eq!(inference.residual_row_count, 5);
let first_report = store.columnar_shadow_checkpoint_report().unwrap();
assert_eq!(first_report.generation, 1);
assert_eq!(first_report.table_count, 4);
assert_eq!(first_report.dirty_table_count, 4);
assert_eq!(first_report.reused_table_count, 0);
assert!(first_report.group_bytes_written > 0);
assert!(first_report.metadata_bytes_written > 0);
let shadow_root = root.join(COLUMN_GROUP_SHADOW_DIR);
let untouched_tables = [
ColumnGroupTableKey::new(ColumnGroupTableKind::Node, 0),
relationship_table_key(catalog.rel_type_id("LIKES").unwrap()),
];
let first_catalog = ColumnGroupManifest::open(&shadow_root).unwrap().unwrap();
let first_refs = untouched_tables
.iter()
.map(|table| {
let reference = first_catalog.manifest().table(*table).unwrap().clone();
let bytes = fs::read(shadow_root.join(reference.file_name())).unwrap();
(reference, bytes)
})
.collect::<Vec<_>>();
store
.set_node_properties_by_ids(
&mut catalog,
&[n1],
&[NodeSetAssignment {
property: "age".to_string(),
value: NodeSetValue::Value(Value::Int(31)),
}],
)
.unwrap();
store
.create_relationship(
&mut catalog,
n3,
n4,
"KNOWS",
properties(&[("since", Value::Int(2024))]),
)
.unwrap();
store.checkpoint(&catalog).unwrap();
assert_shadow_equivalence(&root, &store);
let second_report = store.columnar_shadow_checkpoint_report().unwrap();
assert_eq!(second_report.generation, 2);
assert_eq!(second_report.table_count, 4);
assert_eq!(second_report.dirty_table_count, 2);
assert_eq!(second_report.reused_table_count, 2);
assert!(second_report.group_bytes_written < first_report.group_bytes_written);
let second_catalog = ColumnGroupManifest::open(&shadow_root).unwrap().unwrap();
assert_eq!(
second_catalog.manifest().generation(),
ManifestGeneration(2)
);
for (reference, bytes) in &first_refs {
let reused = second_catalog.manifest().table(reference.table()).unwrap();
assert_eq!(reused, reference, "untouched table reference is reused");
assert_eq!(
&fs::read(shadow_root.join(reused.file_name())).unwrap(),
bytes,
"untouched table directory bytes are identical"
);
}
fs::remove_dir_all(root).unwrap();
}
#[test]
fn flag_off_checkpoints_produce_no_shadow_directory_or_report() {
let root = unique_shadow_dir("flag_off");
let mut catalog = Catalog::default();
let mut store = GraphStore::open_with_durability_and_replay_config(
&root,
&mut catalog,
DurabilityPolicy::SyncOnEveryWrite,
WalReplayConfig::default(),
)
.unwrap();
store
.create_node(
&mut catalog,
"Person",
properties(&[("name", Value::String("a".to_string()))]),
)
.unwrap();
store.checkpoint(&catalog).unwrap();
assert!(!root.join(COLUMN_GROUP_SHADOW_DIR).exists());
assert_eq!(store.columnar_shadow_checkpoint_report(), None);
assert_eq!(
store.columnar_shadow_recovery_status(),
ColumnarShadowRecoveryStatus::default()
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn restart_validates_the_shadow_and_replayed_mutations_mark_dirty_tables() {
let root = unique_shadow_dir("restart");
let mut catalog = Catalog::default();
let mut store = open_shadow_store(&root, &mut catalog);
let n1 = store
.create_node(
&mut catalog,
"Person",
properties(&[("name", Value::String("a".to_string()))]),
)
.unwrap();
let n2 = store
.create_node(
&mut catalog,
"City",
properties(&[("name", Value::String("b".to_string()))]),
)
.unwrap();
store
.create_relationship(&mut catalog, n1, n2, "IN", properties(&[]))
.unwrap();
store.checkpoint(&catalog).unwrap();
store
.set_node_properties_by_ids(
&mut catalog,
&[n1],
&[NodeSetAssignment {
property: "name".to_string(),
value: NodeSetValue::Value(Value::String("a2".to_string())),
}],
)
.unwrap();
drop(store);
let mut catalog = Catalog::default();
let mut store = open_shadow_store(&root, &mut catalog);
let status = store.columnar_shadow_recovery_status();
assert!(
status.validated,
"shadow catalog validates on reopen: {status:?}"
);
assert!(!status.discarded);
store.checkpoint(&catalog).unwrap();
let report = store.columnar_shadow_checkpoint_report().unwrap();
assert_eq!(report.generation, 2);
assert_eq!(report.table_count, 3);
assert_eq!(report.dirty_table_count, 1);
assert_eq!(report.reused_table_count, 2);
assert_shadow_equivalence(&root, &store);
drop(store);
let shadow_root = root.join(COLUMN_GROUP_SHADOW_DIR);
let referenced_group = |shadow_root: &Path| {
let catalog = ColumnGroupManifest::open(shadow_root).unwrap().unwrap();
let file = catalog.directories()[0].groups()[0]
.group_file()
.to_string();
shadow_root.join(file)
};
let group_file = referenced_group(&shadow_root);
let mut bytes = fs::read(&group_file).unwrap();
let footer_byte = bytes.len() - 10;
bytes[footer_byte] ^= 0x40;
fs::write(&group_file, &bytes).unwrap();
let mut catalog = Catalog::default();
let mut store = open_shadow_store(&root, &mut catalog);
let status = store.columnar_shadow_recovery_status();
assert!(status.discarded, "corrupt shadow is discarded: {status:?}");
assert!(status.error.is_some());
assert!(
!shadow_root.exists(),
"discarded shadow directory is removed"
);
store.checkpoint(&catalog).unwrap();
let report = store.columnar_shadow_checkpoint_report().unwrap();
assert_eq!(
report.generation, 1,
"shadow restarts its generation sequence"
);
assert_eq!(report.dirty_table_count, report.table_count);
assert_eq!(report.reused_table_count, 0);
assert_shadow_equivalence(&root, &store);
drop(store);
let group_file = referenced_group(&shadow_root);
let mut bytes = fs::read(&group_file).unwrap();
let footer_byte = bytes.len() - 10;
bytes[footer_byte] ^= 0x40;
fs::write(&group_file, &bytes).unwrap();
let mut catalog = Catalog::default();
let store = GraphStore::open_with_durability_and_replay_config(
&root,
&mut catalog,
DurabilityPolicy::SyncOnEveryWrite,
WalReplayConfig::default(),
)
.unwrap();
assert_eq!(
store.columnar_shadow_recovery_status(),
ColumnarShadowRecoveryStatus::default()
);
assert!(
shadow_root.exists(),
"flag-off open leaves the shadow untouched"
);
drop(store);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn each_mutation_kind_marks_its_shadow_table_dirty() {
let mut catalog = Catalog::default();
let mut store = GraphStore::default();
store.columnar_shadow.enabled = true;
let node_table = |label: &str, catalog: &Catalog| {
node_table_key(&BTreeSet::from([catalog.label_id(label).unwrap()]))
};
let a = store
.create_node(&mut catalog, "A", properties(&[("p", Value::Int(1))]))
.unwrap();
assert_eq!(
store.columnar_shadow.dirty,
BTreeSet::from([node_table("A", &catalog)])
);
store.columnar_shadow.dirty.clear();
store
.apply_wal_op(
&mut catalog,
WalOp::SetNodeProperty {
id: a,
property: "p".to_string(),
value: Value::Int(2),
},
)
.unwrap();
assert_eq!(
store.columnar_shadow.dirty,
BTreeSet::from([node_table("A", &catalog)])
);
store.columnar_shadow.dirty.clear();
catalog.get_or_create_label("B");
store.apply_create_node_with_labels(
&catalog,
a,
BTreeSet::from([catalog.label_id("B").unwrap()]),
properties(&[]),
);
assert_eq!(
store.columnar_shadow.dirty,
BTreeSet::from([node_table("A", &catalog), node_table("B", &catalog)])
);
store.columnar_shadow.dirty.clear();
store.apply_create_node_with_labels(
&catalog,
NodeId(store.next_node_id),
BTreeSet::new(),
properties(&[]),
);
assert_eq!(
store.columnar_shadow.dirty,
BTreeSet::from([ColumnGroupTableKey::new(ColumnGroupTableKind::Node, 0)])
);
store.columnar_shadow.dirty.clear();
let b = store
.create_node(&mut catalog, "B", properties(&[]))
.unwrap();
store.columnar_shadow.dirty.clear();
let r = store
.create_relationship(&mut catalog, a, b, "R", properties(&[]))
.unwrap();
let r_table = relationship_table_key(catalog.rel_type_id("R").unwrap());
assert_eq!(store.columnar_shadow.dirty, BTreeSet::from([r_table]));
store.columnar_shadow.dirty.clear();
store
.apply_wal_op(
&mut catalog,
WalOp::SetRelationshipProperty {
id: r,
property: "w".to_string(),
value: Value::Int(9),
},
)
.unwrap();
assert_eq!(store.columnar_shadow.dirty, BTreeSet::from([r_table]));
store.columnar_shadow.dirty.clear();
store
.apply_wal_op(&mut catalog, WalOp::DeleteRelationship { id: r })
.unwrap();
assert_eq!(store.columnar_shadow.dirty, BTreeSet::from([r_table]));
store.columnar_shadow.dirty.clear();
store
.apply_wal_op(&mut catalog, WalOp::DeleteNode { id: b })
.unwrap();
assert_eq!(
store.columnar_shadow.dirty,
BTreeSet::from([node_table("B", &catalog)])
);
store.columnar_shadow.dirty.clear();
store.columnar_shadow.enabled = false;
store
.create_node(&mut catalog, "A", properties(&[]))
.unwrap();
assert!(store.columnar_shadow.dirty.is_empty());
}
#[test]
fn shadow_recovery_budget_rejection_preserves_valid_artifacts_and_retries() {
use crate::file_descriptors::ProjectFileDescriptors;
use hawdb_core::error::FileDescriptorError;
let root = unique_shadow_dir("recovery_fd_retry");
let project = ProjectFileDescriptors::acquire_with_capacity_limit(&root, 32, true).unwrap();
let mut catalog = Catalog::default();
let mut store = GraphStore::open_with_durability_and_replay_config(
&root,
&mut catalog,
DurabilityPolicy::SyncOnEveryWrite,
WalReplayConfig {
max_open_files: 32,
strict_capacity_limit: true,
..shadow_replay_config()
},
)
.unwrap();
let node = store
.create_node(
&mut catalog,
"Person",
properties(&[("name", Value::String("kept".to_string()))]),
)
.unwrap();
store.checkpoint(&catalog).unwrap();
let shadow_root = root.join(COLUMN_GROUP_SHADOW_DIR);
let manifest = ColumnGroupManifest::open(&shadow_root).unwrap().unwrap();
let generation = manifest.manifest().generation();
let artifacts = std::fs::read_dir(&shadow_root)
.unwrap()
.map(|entry| {
let path = entry.unwrap().path();
(path.clone(), std::fs::read(path).unwrap())
})
.collect::<BTreeMap<_, _>>();
store.columnar_shadow.catalog = None;
store.columnar_shadow.recovery = ColumnarShadowRecoveryStatus::default();
let mut held = Vec::new();
for _ in 0..=32 {
match fs::File::create(root.join(format!("held-{}", held.len()))) {
Ok(file) => held.push(file),
Err(error) => {
assert!(matches!(
HawDBError::from(error),
HawDBError::FileDescriptors(FileDescriptorError::BudgetExceeded { .. })
));
break;
}
}
}
assert_eq!(project.metrics().open, 32);
let error = store.mount_columnar_shadow_for_recovery().unwrap_err();
assert!(
matches!(
error,
HawDBError::FileDescriptors(FileDescriptorError::BudgetExceeded { .. })
),
"shadow admission lost its resource cause: {error:?}"
);
assert!(!store.columnar_shadow_recovery_status().discarded);
assert!(store.columnar_shadow_recovery_status().error.is_none());
assert_eq!(project.metrics().open, 32);
assert_eq!(project.metrics().reserved, 0);
drop(held);
for (path, bytes) in artifacts {
assert_eq!(std::fs::read(path).unwrap(), bytes);
}
store.mount_columnar_shadow_for_recovery().unwrap();
assert!(store.columnar_shadow_recovery_status().validated);
assert_eq!(
store
.columnar_shadow
.catalog
.as_ref()
.unwrap()
.manifest()
.generation(),
generation
);
let (nodes, _) = canonical_scan(&store);
assert_eq!(
nodes[&node].properties["name"],
Value::String("kept".to_string())
);
drop(store);
assert_eq!(project.metrics().open, 0);
assert_eq!(project.metrics().reserved, 0);
drop(project);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn shadow_publish_failure_never_fails_the_canonical_checkpoint_and_retries() {
let root = unique_shadow_dir("shadow_failure");
let mut catalog = Catalog::default();
let mut store = open_shadow_store(&root, &mut catalog);
let n1 = store
.create_node(
&mut catalog,
"Person",
properties(&[("name", Value::String("a".to_string()))]),
)
.unwrap();
store
.create_node(
&mut catalog,
"City",
properties(&[("name", Value::String("b".to_string()))]),
)
.unwrap();
store.checkpoint(&catalog).unwrap();
assert_eq!(
store.columnar_shadow_checkpoint_report().unwrap().status,
ColumnarShadowCheckpointStatus::Published
);
store
.set_node_properties_by_ids(
&mut catalog,
&[n1],
&[NodeSetAssignment {
property: "name".to_string(),
value: NodeSetValue::Value(Value::String("a2".to_string())),
}],
)
.unwrap();
let person_table = node_table_key(&BTreeSet::from([catalog.label_id("Person").unwrap()]));
let shadow_root = root.join(COLUMN_GROUP_SHADOW_DIR);
let poison = shadow_root.join(format!("group-node-{}-2-0.hawdb", person_table.table_id));
fs::create_dir_all(&poison).unwrap();
store.checkpoint(&catalog).unwrap();
let report = store.columnar_shadow_checkpoint_report().unwrap();
assert!(
matches!(report.status, ColumnarShadowCheckpointStatus::Failed { .. }),
"expected a failed shadow report, got {report:?}"
);
assert!(store.columnar_shadow.dirty.contains(&person_table));
assert!(!store.columnar_shadow.all_dirty);
let (nodes, _) = canonical_scan(&store);
assert_eq!(
nodes[&n1].properties["name"],
Value::String("a2".to_string())
);
assert_eq!(
ColumnGroupManifest::open(&shadow_root)
.unwrap()
.unwrap()
.manifest()
.generation(),
ManifestGeneration(1)
);
fs::remove_dir_all(&poison).unwrap();
store.checkpoint(&catalog).unwrap();
let report = store.columnar_shadow_checkpoint_report().unwrap();
assert_eq!(report.status, ColumnarShadowCheckpointStatus::Published);
assert_eq!(report.generation, 2);
assert_eq!(report.dirty_table_count, 1);
assert_eq!(report.reused_table_count, 1);
assert!(store.columnar_shadow.dirty.is_empty());
assert_shadow_equivalence(&root, &store);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn large_builds_stay_under_the_buffer_budget_with_short_groups() {
let root = unique_shadow_dir("bounded");
let mut catalog = Catalog::default();
let mut store = open_shadow_store(&root, &mut catalog);
const BUDGET: u64 = 8 * 1024;
store.columnar_shadow.buffer_budget_bytes = BUDGET;
let mut node_ids = Vec::new();
for index in 0..600u32 {
let label = if index % 2 == 0 { "Alpha" } else { "Beta" };
let id = store
.create_node(
&mut catalog,
label,
properties(&[
(
"name",
Value::String(format!("row-{index:05}-{}", "x".repeat(48))),
),
("rank", Value::Int(i64::from(index))),
(
"flex",
if index % 3 == 0 {
Value::Int(i64::from(index))
} else {
Value::String("mixed".to_string())
},
),
]),
)
.unwrap();
node_ids.push(id);
}
for window in node_ids.windows(2).step_by(3) {
store
.create_relationship(
&mut catalog,
window[0],
window[1],
"LINKS",
properties(&[("weight", Value::Int(7))]),
)
.unwrap();
}
store
.create_node(
&mut catalog,
"Alpha",
properties(&[(
"name",
Value::String(format!("oversized-{}", "z".repeat(3 * BUDGET as usize))),
)]),
)
.unwrap();
store.checkpoint(&catalog).unwrap();
let report = store.columnar_shadow_checkpoint_report().unwrap();
assert!(report.peak_builder_bytes > 0);
assert_eq!(report.admitted_budget_bytes, 0, "unmetered build");
assert!(
report.peak_builder_bytes
<= BUDGET
+ DEFAULT_SHADOW_METADATA_BUDGET_BYTES
+ SHADOW_STREAMED_FLUSH_ALLOWANCE_BYTES,
"peak builder bytes {} exceed the budgeted footprint",
report.peak_builder_bytes
);
assert!(
report.peak_builder_bytes < store.columnar_shadow_admission_bytes(),
"peak builder bytes {} exceed the admission reservation",
report.peak_builder_bytes
);
assert_eq!(report.oversized_row_group_count, 1);
assert!(report.group_bytes_written > BUDGET);
assert!(
report.flushed_group_count > report.table_count,
"expected budget-driven short groups beyond one per table, got {}",
report.flushed_group_count
);
assert_shadow_equivalence(&root, &store);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn metadata_budget_rejects_new_schema_before_allocating_or_publishing() {
let root = unique_shadow_dir("metadata_budget");
let mut catalog = Catalog::default();
let mut store = open_shadow_store(&root, &mut catalog);
store.columnar_shadow.metadata_budget_bytes = 128;
store
.create_node(
&mut catalog,
"Wide",
properties(&[(
"property-key-too-wide-for-the-metadata-budget",
Value::Int(1),
)]),
)
.unwrap();
store.checkpoint(&catalog).unwrap();
let report = store.columnar_shadow_checkpoint_report().unwrap();
assert!(matches!(
report.status,
ColumnarShadowCheckpointStatus::Failed { ref error }
if error.contains("metadata bytes") && error.contains("enforced 256 byte budget")
));
assert!(store.columnar_shadow.all_dirty);
assert!(
ColumnGroupManifest::open(&root.join(COLUMN_GROUP_SHADOW_DIR))
.unwrap()
.is_none()
);
store.columnar_shadow.metadata_budget_bytes = DEFAULT_SHADOW_METADATA_BUDGET_BYTES;
store.checkpoint(&catalog).unwrap();
assert_eq!(
store.columnar_shadow_checkpoint_report().unwrap().status,
ColumnarShadowCheckpointStatus::Published
);
assert_shadow_equivalence(&root, &store);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn shadow_builds_take_exactly_one_admission_for_the_whole_build() {
let root = unique_shadow_dir("governor");
let mut catalog = Catalog::default();
let mut store = open_shadow_store(&root, &mut catalog);
let governor = hawdb_qos::RuntimeGovernor::detect(
hawdb_qos::RuntimeGovernorConfig::shared_host(),
hawdb_qos::IoConcurrencyBudget::new(2, 1),
);
store.set_runtime_governor(governor.clone());
store.columnar_shadow.buffer_budget_bytes = 4 * 1024;
for index in 0..200u32 {
store
.create_node(
&mut catalog,
"Metered",
properties(&[(
"name",
Value::String(format!("metered-{index:04}-{}", "y".repeat(32))),
)]),
)
.unwrap();
}
let admissions_before = governor.snapshot().admissions;
store.checkpoint(&catalog).unwrap();
let report = store.columnar_shadow_checkpoint_report().unwrap();
assert_eq!(report.status, ColumnarShadowCheckpointStatus::Published);
assert!(report.flushed_group_count > 1);
assert_eq!(governor.snapshot().admissions, admissions_before + 1);
assert_shadow_equivalence(&root, &store);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn pre_admitted_shadow_converges_under_a_constrained_governor() {
let root = unique_shadow_dir("constrained_governor");
let mut catalog = Catalog::default();
let mut store = open_shadow_store(&root, &mut catalog);
let governor = hawdb_qos::RuntimeGovernor::detect(
hawdb_qos::RuntimeGovernorConfig {
background_task_limit: std::num::NonZeroUsize::new(1),
..hawdb_qos::RuntimeGovernorConfig::shared_host()
},
hawdb_qos::IoConcurrencyBudget::new(2, 1),
);
store.set_runtime_governor(governor.clone());
for index in 0..40u32 {
store
.create_node(
&mut catalog,
"Constrained",
properties(&[("rank", Value::Int(i64::from(index)))]),
)
.unwrap();
}
let shadow_bytes = store.columnar_shadow_admission_bytes();
assert!(shadow_bytes > 0);
let _outer_permit = governor
.try_admit(
hawdb_qos::RuntimeWorkRequest::background_maintenance(shadow_bytes)
.with_io_slots(1),
)
.expect("outer maintenance permit is admitted");
store.checkpoint(&catalog).unwrap();
let report = store.columnar_shadow_checkpoint_report().unwrap();
assert!(
matches!(report.status, ColumnarShadowCheckpointStatus::Failed { .. }),
"un-annotated build under an exhausted governor fails fast: {report:?}"
);
store
.checkpoint_with_shadow_admission(
&catalog,
ColumnarShadowAdmission::pre_admitted(shadow_bytes),
)
.unwrap();
let report = store.columnar_shadow_checkpoint_report().unwrap();
assert_eq!(
report.status,
ColumnarShadowCheckpointStatus::Published,
"pre-admitted shadow converges under the held outer permit"
);
assert_shadow_equivalence(&root, &store);
fs::remove_dir_all(root).unwrap();
}
fn shadow_disk_footprint(shadow_root: &Path) -> (usize, u64) {
let mut files = 0usize;
let mut bytes = 0u64;
for entry in fs::read_dir(shadow_root).unwrap().flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if name.ends_with(".hawdb") || name.ends_with(".hawdb.tmp") {
files += 1;
bytes += entry.metadata().unwrap().len();
}
}
(files, bytes)
}
#[test]
fn superseded_shadow_artifacts_are_reclaimed_after_each_publish() {
let root = unique_shadow_dir("reclaim");
let mut catalog = Catalog::default();
let mut store = open_shadow_store(&root, &mut catalog);
let n1 = store
.create_node(
&mut catalog,
"Person",
properties(&[("name", Value::String("a".to_string()))]),
)
.unwrap();
store
.create_node(
&mut catalog,
"City",
properties(&[("name", Value::String("b".to_string()))]),
)
.unwrap();
let shadow_root = root.join(COLUMN_GROUP_SHADOW_DIR);
let mut footprints = Vec::new();
for round in 0..5u32 {
store
.set_node_properties_by_ids(
&mut catalog,
&[n1],
&[NodeSetAssignment {
property: "name".to_string(),
value: NodeSetValue::Value(Value::String(format!("round-{round}"))),
}],
)
.unwrap();
store.checkpoint(&catalog).unwrap();
footprints.push(shadow_disk_footprint(&shadow_root));
}
let report = store.columnar_shadow_checkpoint_report().unwrap();
assert_eq!(report.status, ColumnarShadowCheckpointStatus::Published);
assert!(report.reclaimed_file_count > 0, "old generations reclaimed");
assert_eq!(report.reclaim_failed_count, 0);
assert_eq!(
footprints[1].0, footprints[4].0,
"file count is bounded across dirty checkpoints: {footprints:?}"
);
let catalog_on_disk = ColumnGroupManifest::open(&shadow_root).unwrap().unwrap();
let mut keep = BTreeSet::new();
keep.insert(hawdb_storage::column_group::manifest::COLUMN_GROUP_MANIFEST_FILE.to_string());
keep.insert(SHADOW_KEY_DICTIONARY_FILE.to_string());
for reference in catalog_on_disk.manifest().tables() {
keep.insert(reference.file_name().to_string());
}
for directory in catalog_on_disk.directories() {
for group in directory.groups() {
keep.insert(group.group_file().to_string());
}
}
for entry in fs::read_dir(&shadow_root).unwrap().flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if name.ends_with(".hawdb") || name.ends_with(".hawdb.tmp") {
assert!(
keep.contains(name),
"unreferenced artifact {name} survived the sweep"
);
}
}
assert_shadow_equivalence(&root, &store);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn sweep_removal_failure_is_recorded_and_never_fails_the_publish() {
let root = unique_shadow_dir("reclaim_failure");
let mut catalog = Catalog::default();
let mut store = open_shadow_store(&root, &mut catalog);
let n1 = store
.create_node(
&mut catalog,
"Person",
properties(&[("name", Value::String("a".to_string()))]),
)
.unwrap();
store.checkpoint(&catalog).unwrap();
let shadow_root = root.join(COLUMN_GROUP_SHADOW_DIR);
let stubborn = shadow_root.join("group-node-999-1-0.hawdb");
fs::create_dir_all(stubborn.join("occupant")).unwrap();
store
.set_node_properties_by_ids(
&mut catalog,
&[n1],
&[NodeSetAssignment {
property: "name".to_string(),
value: NodeSetValue::Value(Value::String("a2".to_string())),
}],
)
.unwrap();
store.checkpoint(&catalog).unwrap();
let report = store.columnar_shadow_checkpoint_report().unwrap();
assert_eq!(report.status, ColumnarShadowCheckpointStatus::Published);
assert!(
report.reclaim_failed_count >= 1,
"failure recorded: {report:?}"
);
fs::remove_dir_all(&stubborn).unwrap();
fs::write(&stubborn, b"now a stale file").unwrap();
store
.set_node_properties_by_ids(
&mut catalog,
&[n1],
&[NodeSetAssignment {
property: "name".to_string(),
value: NodeSetValue::Value(Value::String("a3".to_string())),
}],
)
.unwrap();
store.checkpoint(&catalog).unwrap();
let report = store.columnar_shadow_checkpoint_report().unwrap();
assert_eq!(report.status, ColumnarShadowCheckpointStatus::Published);
assert_eq!(report.reclaim_failed_count, 0);
assert!(!stubborn.exists(), "retried sweep reclaimed the stale file");
assert_shadow_equivalence(&root, &store);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn varint_label_sets_round_trip() {
for labels in [
BTreeSet::new(),
BTreeSet::from([LabelId(0)]),
BTreeSet::from([LabelId(0), LabelId(1), LabelId(127), LabelId(128)]),
BTreeSet::from([LabelId(16_383), LabelId(16_384), LabelId(u32::MAX)]),
] {
let encoded = encode_label_set(&labels);
assert_eq!(decode_label_set(&encoded).unwrap(), labels);
}
assert!(decode_label_set(&[0x80]).is_err());
assert!(decode_label_set(&[0xff, 0xff, 0xff, 0xff, 0x7f]).is_err());
}
#[test]
fn oversized_legal_row_publishes_via_streaming_and_converges() {
let root = unique_shadow_dir("stream-oversized");
let mut catalog = Catalog::default();
let mut store = open_shadow_store(&root, &mut catalog);
const BUDGET: u64 = 8 * 1024;
store.columnar_shadow.buffer_budget_bytes = BUDGET;
const ROW_BYTES: usize = 1024 * 1024;
store
.create_node(
&mut catalog,
"Doc",
properties(&[
("body", Value::String("z".repeat(ROW_BYTES))),
(
"attachments",
Value::List(vec![Value::String("a".repeat(64 * 1024)), Value::Int(7)]),
),
]),
)
.unwrap();
store
.create_node(
&mut catalog,
"Doc",
properties(&[("body", Value::String("small".to_string()))]),
)
.unwrap();
store.checkpoint(&catalog).unwrap();
let report = store.columnar_shadow_checkpoint_report().unwrap();
assert_eq!(report.status, ColumnarShadowCheckpointStatus::Published);
assert_eq!(report.oversized_row_group_count, 1);
assert!(
report.peak_builder_bytes < ROW_BYTES as u64 / 4,
"peak {} suggests the oversized row was materialized",
report.peak_builder_bytes
);
assert_shadow_equivalence(&root, &store);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn dictionary_growth_past_reservation_fails_once_then_converges() {
let root = unique_shadow_dir("dictionary-converges");
let mut catalog = Catalog::default();
let mut store = open_shadow_store(&root, &mut catalog);
store.columnar_shadow.metadata_budget_bytes = 512;
for index in 0..24u32 {
store
.create_node(
&mut catalog,
"Memory",
properties(&[(
format!("distinct_property_key_number_{index:04}").as_str(),
Value::Int(i64::from(index)),
)]),
)
.unwrap();
}
let admission_before = store.columnar_shadow_admission_bytes();
store.checkpoint(&catalog).unwrap();
let first = store.columnar_shadow_checkpoint_report().unwrap();
let ColumnarShadowCheckpointStatus::Failed { error } = &first.status else {
panic!("dictionary growth past the reservation must fail this attempt");
};
assert!(
error.contains("metadata bytes, exceeding"),
"unexpected error: {error}"
);
let shadow_root = root.join(COLUMN_GROUP_SHADOW_DIR);
let mut dictionary_bytes = fs::metadata(shadow_root.join(SHADOW_KEY_DICTIONARY_FILE))
.unwrap()
.len();
assert!(dictionary_bytes > 0);
assert!(store.columnar_shadow_admission_bytes() > admission_before);
let mut published = false;
for _ in 0..16 {
store.checkpoint(&catalog).unwrap();
let report = store.columnar_shadow_checkpoint_report().unwrap();
match report.status {
ColumnarShadowCheckpointStatus::Published => {
published = true;
break;
}
ColumnarShadowCheckpointStatus::Failed { .. } => {
let grown = fs::metadata(shadow_root.join(SHADOW_KEY_DICTIONARY_FILE))
.unwrap()
.len();
assert!(
grown > dictionary_bytes,
"a failed attempt must make monotone dictionary progress"
);
dictionary_bytes = grown;
}
}
}
assert!(published, "bounded attempts must converge to publication");
assert_shadow_equivalence(&root, &store);
fs::remove_dir_all(root).unwrap();
}
}