use crate::analytics::ProjectedGraph;
use crate::error::{HawDBError, Result};
use crate::schema::{
AdvancedStatisticsFreshness, BasicGraphStatistics, Catalog, ConstraintId, GraphStatistics,
IndexId, IndexKind, IndexStatisticsSample, LabelId, PropertyId, PropertyType, RelTypeId,
SchemaObjectState, TableDescriptor, TableId, TableKind,
};
use crate::telemetry::TelemetrySink;
use crate::value::Value;
use hawdb_core::RuntimeTaskContext;
use hawdb_integrity::{checksum_u64, Sha256Digest};
use hawdb_storage::mutation::compact_transaction_graph_ops;
use hawdb_storage::projection::projection_document_id_for_node as search_projection_document_id_for_node;
#[derive(Debug)]
struct RuntimeGovernorBackgroundAdmission(hawdb_qos::RuntimeGovernor);
#[derive(Debug)]
struct RuntimeGovernorBackgroundPermit {
_permit: hawdb_qos::RuntimePermit,
}
impl hawdb_storage::background::BackgroundWorkPermit for RuntimeGovernorBackgroundPermit {}
struct RootStorageTelemetry(Arc<dyn TelemetrySink>);
impl std::fmt::Debug for RootStorageTelemetry {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("RootStorageTelemetry")
}
}
impl hawdb_storage::telemetry::StorageTelemetrySink for RootStorageTelemetry {
fn record_wal_append(&self, event: hawdb_storage::telemetry::WalAppendTelemetry) {
self.0.record_kernel(hawdb_telemetry::KernelTelemetry {
operation: hawdb_telemetry::KernelTelemetryOperation::WalAppend,
success: event.success,
elapsed_micros: event.elapsed_micros,
item_count: event.operation_count,
byte_count: event.byte_count,
fsync_micros: event.fsync_micros,
generation: Some(event.generation),
});
}
}
impl hawdb_storage::background::BackgroundWorkAdmission for RuntimeGovernorBackgroundAdmission {
fn try_admit(
&self,
request: hawdb_storage::background::BackgroundWorkRequest,
) -> std::result::Result<Box<dyn hawdb_storage::background::BackgroundWorkPermit>, String> {
self.0
.try_admit(hawdb_qos::RuntimeWorkRequest {
priority: hawdb_qos::RuntimeWorkPriority::Background,
kind: hawdb_qos::RuntimeWorkKind::Control,
cpu_slots: request.cpu_slots,
memory_bytes: request.memory_bytes,
io_slots: request.io_slots,
io_reservation_scope: hawdb_qos::RuntimeIoReservationScope::Task,
result_bytes: 0,
blocking: false,
})
.map(|permit| {
Box::new(RuntimeGovernorBackgroundPermit { _permit: permit })
as Box<dyn hawdb_storage::background::BackgroundWorkPermit>
})
.map_err(|error| error.to_string())
}
}
#[path = "store/append_tables.rs"]
mod append_tables;
#[path = "store/backup.rs"]
mod backup;
#[path = "store/derived_repair.rs"]
#[doc(hidden)]
pub mod derived_repair;
#[path = "store/doctor.rs"]
#[doc(hidden)]
pub mod doctor;
#[path = "store/durable.rs"]
mod durable;
#[path = "store/graph_apply.rs"]
mod graph_apply;
#[path = "store/graph_checkpoint.rs"]
mod graph_checkpoint;
#[doc(hidden)]
pub use graph_checkpoint::CheckpointPreparation;
#[path = "store/graph_checkpoint_candidate.rs"]
mod graph_checkpoint_candidate;
#[path = "store/graph_checkpoint_projection.rs"]
mod graph_checkpoint_projection;
#[doc(hidden)]
pub use graph_checkpoint_candidate::{
CheckpointCandidate, CheckpointDebtSnapshot, CheckpointReclamationContext,
CheckpointSourceIdentity,
};
use graph_checkpoint_projection::checkpoint_projected_graph_from_definition;
#[path = "store/graph_columnar_shadow.rs"]
mod graph_columnar_shadow;
#[path = "store/graph_commit.rs"]
mod graph_commit;
#[path = "store/graph_indexes.rs"]
mod graph_indexes;
#[path = "store/graph_mutation.rs"]
mod graph_mutation;
#[path = "store/graph_read.rs"]
mod graph_read;
#[doc(hidden)]
pub use graph_read::{
MaterializedNodePointRead, MaterializedNodePointSource, MaterializedNodeReadSource,
MaterializedReadHealth,
};
#[path = "store/read_dependencies.rs"]
mod read_dependencies;
pub use read_dependencies::ReadDependencyValidationFailures;
#[doc(hidden)]
pub use read_dependencies::{GraphReadDependencies, GraphReadDependencyError};
#[path = "store/graph_recovery.rs"]
mod graph_recovery;
#[path = "store/immutable_root.rs"]
mod immutable_root;
#[doc(hidden)]
pub use immutable_root::{
AdmittedBranchStore, BranchAdmissionError, BranchAdmissionRequest,
CapturedBranchAdmissionRequest, PreparedImmutableRootHandoff, SealedBranchSource,
};
#[path = "store/pending_nodes.rs"]
mod pending_nodes;
use pending_nodes::{PendingMatch, PendingNodes};
#[path = "store/projection_full_source.rs"]
mod projection_full_source;
#[path = "store/relational_index_shadow.rs"]
mod relational_index_shadow;
#[path = "store/relational_row_pages.rs"]
#[doc(hidden)]
pub mod relational_row_pages;
#[path = "store/search_projection_capture.rs"]
mod search_projection_capture;
#[path = "store/search_projection_change_log.rs"]
mod search_projection_change_log;
#[doc(hidden)]
pub use projection_full_source::{
ProjectionFullSourceCursor, ProjectionFullSourceItem, ProjectionFullSourceLimits,
};
#[path = "store/statistics_refresh.rs"]
mod statistics_refresh;
#[path = "store/statistics_state.rs"]
mod statistics_state;
use statistics_state::{
decrement_statistic_counter, BasicStatisticsState, CheckpointStatisticsState,
};
#[path = "store/wal_codec.rs"]
mod wal_codec;
use crate::file_io as fs;
pub use backup::restore_storage_backup;
use backup::{remove_source_scan_artifacts, validate_backup_files};
pub use derived_repair::{
DerivedArtifactBranchSource, DerivedArtifactHealth, DerivedArtifactHealthReport,
DerivedArtifactHealthState, DerivedArtifactKind, DerivedArtifactRebuildOptions,
DerivedArtifactRepairPlan, DerivedArtifactRepairReport, DERIVED_ARTIFACT_REPAIR_PROTOCOL,
};
pub use doctor::{
DatabaseDoctor, WalDoctorOptions, WalRepairAcknowledgement, WalTailRepairPlan,
WalTailRepairReason, WalTailRepairReport, WAL_DOCTOR_REPAIR_PROTOCOL,
};
use durable::{
load_published_canonical_adjacency, load_published_property_projection, CheckpointImage,
CheckpointManifestArtifacts, DerivedArtifactBuildConfig, DurableManifest, DurableOpenMode,
DurableStore, GraphManifestOpenBudget,
};
#[doc(hidden)]
pub use durable::{CheckpointWalTail, PreparedCheckpoint};
pub use graph_columnar_shadow::ColumnarShadowAdmission;
use graph_columnar_shadow::ColumnarShadowState;
use hawdb_storage::artifact_files::{
canonical_adjacency_artifact_generation_file, canonical_artifact_generation_file,
canonical_manifest_generation_file, checkpoint_generation_file,
cleanup_abandoned_checkpoint_preparations, has_storage_artifacts,
parse_append_manifest_generation_file, parse_append_segment_generation_file,
parse_generation_file, parse_relational_index_artifact_generation_file,
parse_relational_index_manifest_generation_file,
parse_relational_overflow_extent_generation_file, parse_relational_overflow_generation_file,
parse_relational_row_generation_file, parse_relational_row_page_artifact_generation_file,
property_projection_artifact_generation_file, property_projection_manifest_generation_file,
property_spill_artifact_generation_file, property_spill_manifest_generation_file,
relational_checkpoint_generation_file, storage_generation_for_file, store_id_for_path,
wal_generation_file,
};
pub use hawdb_storage::column_group::shadow::{
ColumnarShadowCheckpointReport, ColumnarShadowCheckpointStatus, ColumnarShadowRecoveryStatus,
COLUMN_GROUP_SHADOW_DIR,
};
#[cfg(test)]
use hawdb_storage::cow::COW_MAP_TARGET_SEGMENT_BYTES;
#[doc(hidden)]
pub use hawdb_storage::durability::{WalSyncGroupFlush, WalSyncGroupProgress};
use hawdb_storage::graph_constraints::{
validate_node_property_exists, validate_node_property_exists_constraints,
validate_node_property_exists_constraints_for_records, validate_node_record_constraints,
validate_property_schema_value, validate_property_schemas,
validate_property_schemas_for_records, validate_relationship_property_exists,
validate_relationship_property_exists_constraints,
validate_relationship_property_exists_constraints_for_records,
validate_relationship_record_constraints, validate_relationship_unique_constraints,
validate_relationship_unique_constraints_for_records, validate_unique_constraints,
validate_unique_constraints_for_records, validate_unique_property,
validate_unique_relationship_property, wal_ops_touched_records,
};
use hawdb_storage::graph_index_metrics::GraphIndexReadMetrics;
#[doc(hidden)]
pub use hawdb_storage::mutation::evaluate::{
apply_node_assignments_to_properties, evaluate_node_set_value,
};
use hawdb_storage::predicate::{
properties_contain_all, property_filter_matches, range_bounds_match,
};
use hawdb_storage::projection::artifact::{
decode_projected_graph_artifacts, split_projected_graph_artifact_checksum,
};
pub use hawdb_storage::relational::{
RelationalIndexArtifactMetadata, RelationalIndexGenerationArtifacts,
};
pub use hawdb_storage::scan::{ScanPrunedNodeScan, ScanPrunedRelationshipScan};
#[doc(hidden)]
pub use hawdb_storage::source_scan;
pub use hawdb_storage::source_scan::SourceScanRow;
#[cfg(test)]
#[doc(hidden)]
pub use hawdb_storage::statistics::compute_statistics;
#[doc(hidden)]
pub use hawdb_storage::statistics::{
composite_property_index_key, composite_property_index_unique_values, compute_basic_statistics,
compute_index_statistics_samples, compute_node_property_distinct_counts_from_index,
compute_relationship_property_distinct_counts_from_index, compute_statistics_for_catalog,
compute_statistics_with_basic, full_text_index_tokens, full_text_query_tokens,
graph_statistics_from_basic, recompute_node_property_index,
recompute_relationship_property_index, scalar_property_index_cardinality,
};
use hawdb_storage::statistics_refresh::{
node_property_supports_optimizer_statistics, MAX_BOUNDED_PATH_STAT_HOPS,
};
#[doc(hidden)]
pub use hawdb_storage::statistics_refresh::{
retain_supported_property_statistics, retain_valid_index_statistics_samples,
OptimizerStatisticsRefreshWork,
};
pub use hawdb_storage::statistics_refresh::{
OptimizerStatisticsRefreshOptions, OptimizerStatisticsRefreshReport,
};
#[cfg(test)]
use hawdb_storage::text::encode_properties;
#[cfg(test)]
use hawdb_storage::text::envelope::DURABLE_COMPRESSION_HEADER;
#[doc(hidden)]
pub use hawdb_storage::text::envelope::{
encode_durable_text, read_durable_text_bytes, read_durable_text_bytes_with_limit,
};
use hawdb_storage::{
adjacency::AdjacencyPostingList,
append_table::{
decode_append_wal_batch, encode_append_wal_batch, AppendDecodeLimits,
AppendGenerationReader, AppendMutationLimits, AppendPublicationConfig,
AppendPublicationState, AppendPublisher, AppendState,
},
backup::{
copy_backup_file, copy_file_with_checksum, file_checksum, validate_new_backup_destination,
},
canonical::{CanonicalEndpointDirection, CanonicalSegmentError},
durability::sync_parent_directory,
pressure::available_storage_space,
property_projection::PersistentPropertyProjectionDefinitionAdmission,
relational::{
decode_relational_checkpoint_file_with_index_load,
decode_relational_checkpoint_with_index_load, decode_relational_wal_batch,
encode_relational_checkpoint, RelationalCheckpointIndexLoad, RelationalDecodeLimits,
RelationalMutationLimits, RelationalOverflowConfig, RelationalOverflowPublicationConfig,
RelationalOverflowPublisher, RelationalRecoverySourceBuilder,
RelationalRowPageGenerationRequest, RelationalRowPagePublicationConfig,
RelationalRowPagePublisher, RelationalSparseLiveStage, RelationalState,
RelationalTransaction,
},
};
pub use hawdb_storage::{
adjacency::{
AdjacencyDirection, AdjacencyGroupConsistencyMismatch, AdjacencyGroupKey,
AdjacencyGroupStats, AdjacencyLayout, OrderedAdjacencyEntry,
},
append_table::{
AppendGeneratedRow, AppendMutationOutcome, AppendOrderMode, AppendSegmentReadOutput,
AppendStorageResidencyReport, AppendTableRow, AppendTableSchema, AppendTransaction,
AppendWrite,
},
backup::{
BackupFileEntry, BackupManifest, StorageBackupReport, StorageRestoreReport,
StorageScrubReport,
},
cache::{ManifestGeneration, SegmentBytes, SegmentCache, SegmentCacheSnapshot, StoreId},
canonical::{
CanonicalScanControl, CanonicalSegmentConfig, CanonicalSegmentManifest,
CanonicalSegmentReader, CanonicalSegmentWriter,
},
canonical_adjacency::{
CanonicalAdjacencyBuildReport, CanonicalAdjacencyConfig, CanonicalAdjacencyEntry,
CanonicalAdjacencyReadReport, CanonicalAdjacencyReader, CanonicalAdjacencyWriter,
},
config::{
DurabilityPolicy, DurableCompression, RecoveryMode, RelationalIndexMode,
StorageResidencyMode, WalReplayConfig,
},
mutation::{
ConnectedNodesCreate, GraphMutation, MatchedRelationshipCopyMerge,
MatchedRelationshipCreate, MatchedRelationshipMerge, MatchedRelationshipRetargetMerge,
MatchedRelationshipSourceRetargetMerge, MutationLimits, MutationSummary, NodeSetAssignment,
NodeSetValue, PropertyFilter, RelationshipDeleteRequest, RelationshipOnCreatePropertyValue,
RelationshipPropertiesUpdate, RelationshipPropertyUpdate, RelationshipSetAssignment,
RelationshipTargetNodeDelete,
},
pressure::{
StorageDebtController, StoragePressureReasonCode, StoragePressureSignals,
StoragePressureSnapshot, StoragePressureState, STORAGE_PRESSURE_DEFER_RATIO_PER_MILLION,
STORAGE_PRESSURE_SOFT_RATIO_PER_MILLION,
},
projection::{
ProjectedGraphDefinition, ProjectedGraphStatus, PropertyIndexProjectionRebuildAction,
SchemaMaintenanceAction, SchemaMaintenancePlanItem, SearchProjectionChangefeedReadiness,
SearchProjectionChangefeedStatus, SearchProjectionGraphChange, SearchProjectionMutationId,
StorageOpenTimings, StorageReclamationWatermark, StorageRecoveryReport,
StoreStableIdMapping,
},
property_projection::{
PersistentPropertyProjectionConfig, PersistentPropertyProjectionDefinition,
PersistentPropertyProjectionError, PersistentPropertyProjectionKind,
PersistentPropertyProjectionManifest, PersistentPropertyProjectionReader,
PersistentPropertyProjectionWriter,
},
property_spill::{PropertySpillConfig, PropertySpillManifest, PropertySpillReader},
relational::{
RelationalColumnSchema, RelationalIndexReadLimits, RelationalIndexReadReport,
RelationalIndexRecoveryReadReport, RelationalKey, RelationalRow, RelationalScalarType,
RelationalValue,
},
scan::{
FileSegmentRangeReader, ScanPredicate, ScanPruningReport, ScanPruningStrategy,
ScanPruningTargetKind, ScanSegmentAccessPlan, ScanSegmentFallback, ScanSegmentManifest,
SegmentRangeReader, SegmentReadError, SegmentReadExecutionError,
SegmentReadExecutionReport, SegmentReadExecutor, SegmentReadPayload, SegmentReadRange,
SegmentReadSchedule, SegmentReadScheduler, SegmentReadWave,
},
NodeId, NodeRecord, ProjectedNodeRecord, RelId, RelRecord,
};
use hawdb_storage::{
cow::{CowSegment, CowSegmentedMap},
projection::{ProjectedGraphArtifact, ProjectedGraphArtifactData},
};
pub use hawdb_storage::{
graph_index_metrics::{GraphIndexReadMetricsSnapshot, PersistentGraphIndexClass},
read_view::PublishedReadView,
};
use relational_index_shadow::RelationalIndexShadowState;
pub use relational_index_shadow::{
RelationalConstraintQualificationProbeReport, RelationalConstraintQualificationReport,
RelationalConstraintQualificationUse, RelationalIndexQualificationProbeKind,
RelationalIndexQualificationProbeReport, RelationalIndexReadViewBackendReport,
RelationalIndexReadViewReport, RelationalIndexShadowCheckpointReport,
RelationalIndexShadowCheckpointStatus, RelationalIndexShadowRecoveryStatus,
RelationalIndexViewQualificationOptions, RelationalIndexViewQualificationReport,
RELATIONAL_CONSTRAINT_QUALIFICATION_PROTOCOL, RELATIONAL_INDEX_VIEW_QUALIFICATION_PROTOCOL,
};
#[doc(hidden)]
pub use relational_index_shadow::{RelationalIndexProbeStatistics, RelationalTransactionIndexView};
pub use relational_row_pages::RelationalRowPageRecoveryStatus;
use relational_row_pages::RelationalRowPageState;
#[doc(hidden)]
pub use relational_row_pages::RelationalTransactionRowView;
use search_projection_change_log::SearchProjectionChangeLog;
#[doc(hidden)]
pub use search_projection_change_log::{
ProjectionFeedCursor, ProjectionFeedError, ProjectionFeedItem, ProjectionFeedLimits,
ProjectionFeedResource, ProjectionFeedStep, ProjectionFeedUsage,
};
use std::collections::{BTreeMap, BTreeSet};
use std::num::{NonZeroU64, NonZeroUsize};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering as AtomicOrdering};
use std::sync::Arc;
use wal_codec::binary::encode_binary_wal_record;
use wal_codec::frame::{
encode_binary_wal_header, frame_binary_wal_record, WAL_BINARY_FILE_HEADER_BYTES,
};
use wal_codec::{
quarantine_corrupt_wal, reject_corrupt_wal_record, WalCursorEvent, WalEntry, WalOp,
WalOpenOutcome, WalRecordCursor,
};
use hawdb_storage::durable_manifest::{safe_reclaim_commit_epoch, STORAGE_VERSION};
#[doc(hidden)]
pub const MANIFEST_FILE: &str = "manifest.hawdb";
const PROJECTED_GRAPHS_FILE: &str = "projected_graphs.hawdb";
const STABLE_ID_MAPPING_FILE: &str = "stable_ids.hawdb";
#[doc(hidden)]
pub use hawdb_storage::checkpoint::{relational_checkpoint_metadata, split_checkpoint_checksum};
const BACKUP_MANIFEST_FILE: &str = "backup.hawdb";
const CANONICAL_MANIFEST_MAX_BYTES: u64 = 256 * 1024 * 1024;
const PROPERTY_SPILL_MANIFEST_MAX_BYTES: u64 = 64 * 1024;
const PROPERTY_PROJECTION_MANIFEST_MAX_BYTES: u64 = 32 * 1024 * 1024;
const CHECKPOINT_TEMPORARY_SPACE_MULTIPLIER: u64 = 4;
const MIN_CHECKPOINT_TEMPORARY_SPACE_BYTES: u64 = 64 * 1024;
pub use hawdb_storage::consistency::DENSE_ADJACENCY_DEGREE_THRESHOLD;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[doc(hidden)]
pub enum CheckpointPublishStage {
CheckpointPersisted,
WalPrepared,
ManifestPublished,
BranchHeadPublished,
}
#[doc(hidden)]
const PROCESS_CRASH_POINT_ENV: &str = "HAWDB_TEST_PROCESS_CRASH_POINT";
fn process_crash_failpoint(point: &str) {
if std::env::var(PROCESS_CRASH_POINT_ENV).as_deref() == Ok(point) {
std::process::exit(86);
}
let _ = point;
}
#[cfg(any(test, feature = "test-support"))]
#[derive(Clone, Copy, PartialEq, Eq)]
#[doc(hidden)]
pub enum WalAppendFailure {
PartialWrite,
Rollback,
Sync,
}
#[cfg(any(test, feature = "test-support"))]
thread_local! {
static WAL_APPEND_FAILURE: std::cell::Cell<Option<WalAppendFailure>> = const {
std::cell::Cell::new(None)
};
}
#[cfg(any(test, feature = "test-support"))]
#[doc(hidden)]
pub fn set_wal_append_failpoint(failure: WalAppendFailure) {
WAL_APPEND_FAILURE.set(Some(failure));
}
#[cfg(any(test, feature = "test-support"))]
thread_local! {
static CHECKPOINT_FAILPOINT: std::cell::Cell<Option<CheckpointPublishStage>> = const {
std::cell::Cell::new(None)
};
}
fn checkpoint_publish_failpoint(stage: CheckpointPublishStage) -> Result<()> {
match stage {
CheckpointPublishStage::CheckpointPersisted => {
process_crash_failpoint("during_checkpoint_publication");
}
CheckpointPublishStage::ManifestPublished => {
process_crash_failpoint("after_manifest_publication");
}
CheckpointPublishStage::WalPrepared => {}
CheckpointPublishStage::BranchHeadPublished => {
process_crash_failpoint("after_branch_head_publication");
}
}
#[cfg(any(test, feature = "test-support"))]
if CHECKPOINT_FAILPOINT.with(|failpoint| failpoint.get()) == Some(stage) {
return Err(HawDBError::Storage(format!(
"injected checkpoint failure at {stage:?}"
)));
}
Ok(())
}
#[cfg(any(test, feature = "test-support"))]
#[doc(hidden)]
pub fn set_checkpoint_failpoint(stage: Option<CheckpointPublishStage>) {
CHECKPOINT_FAILPOINT.with(|failpoint| failpoint.set(stage));
}
#[cfg(test)]
thread_local! {
static GENERATION_RECLAMATION_REMOVE_FAILPOINT: std::cell::RefCell<Option<String>> =
const { std::cell::RefCell::new(None) };
}
fn remove_generation_reclamation_candidate(path: &Path) -> std::io::Result<()> {
#[cfg(test)]
{
let file_name = path.file_name().and_then(|name| name.to_str());
let should_fail = GENERATION_RECLAMATION_REMOVE_FAILPOINT
.with(|failpoint| failpoint.borrow().as_deref() == file_name);
if should_fail {
GENERATION_RECLAMATION_REMOVE_FAILPOINT.with(|failpoint| {
failpoint.borrow_mut().take();
});
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
format!(
"injected generation reclamation failure for {}",
path.display()
),
));
}
}
fs::remove_file(path)
}
#[cfg(test)]
fn set_generation_reclamation_remove_failpoint(file_name: Option<String>) {
GENERATION_RECLAMATION_REMOVE_FAILPOINT.with(|failpoint| {
*failpoint.borrow_mut() = file_name;
});
}
thread_local! {
static WAL_APPLY_FAILPOINT_REMAINING: std::cell::Cell<Option<usize>> = const {
std::cell::Cell::new(None)
};
static WAL_GROUP_SYNC_FAILPOINT: std::cell::Cell<bool> = const {
std::cell::Cell::new(false)
};
}
fn wal_apply_failpoint() -> Result<()> {
{
let should_fail = WAL_APPLY_FAILPOINT_REMAINING.with(|remaining| match remaining.get() {
Some(0) => true,
Some(value) => {
remaining.set(Some(value - 1));
false
}
None => false,
});
if should_fail {
return Err(HawDBError::Storage(
"injected failure while applying a durable WAL batch".to_string(),
));
}
}
Ok(())
}
#[doc(hidden)]
pub fn render_wal_records_for_test(path: &Path) -> std::io::Result<String> {
use std::io::{Error, ErrorKind};
let invalid = |reason: String| Error::new(ErrorKind::InvalidData, reason);
let mut cursor =
match WalRecordCursor::open(path, None).map_err(|error| invalid(error.to_string()))? {
WalOpenOutcome::Cursor(cursor) => cursor,
WalOpenOutcome::MissingHeader => {
return Err(invalid("WAL is missing its header".to_string()));
}
WalOpenOutcome::HeaderTorn { reason } | WalOpenOutcome::HeaderCorrupt { reason } => {
return Err(invalid(reason));
}
};
let mut out = String::new();
loop {
match cursor.next().map_err(|error| invalid(error.to_string()))? {
WalCursorEvent::Entry { entry, .. } => {
out.push_str(&entry.encode().map_err(|error| invalid(error.to_string()))?);
out.push('\n');
}
WalCursorEvent::Corrupt { offset, reason } => {
out.push_str(&format!("<corrupt at {offset}: {reason}>\n"));
break;
}
WalCursorEvent::TornTail { .. } | WalCursorEvent::Eof => break,
}
}
Ok(out)
}
#[doc(hidden)]
pub fn append_stale_generation_wal_fragment(path: &Path) -> Result<()> {
use std::io::Write as _;
let bytes = fs::read(path)?;
let (generation, _) = wal_codec::frame::decode_binary_wal_header(&bytes)?;
let position = bytes.len() as u64 - WAL_BINARY_FILE_HEADER_BYTES as u64;
let stale_generation = generation.wrapping_sub(1);
let framed = frame_binary_wal_record(
stale_generation,
b"recycled-region-record-from-a-previous-generation",
position,
);
let mut file = crate::file_io::OpenOptions::new().append(true).open(path)?;
file.write_all(&framed)?;
file.sync_all()?;
Ok(())
}
#[doc(hidden)]
pub fn set_wal_apply_failpoint(operations_before_failure: Option<usize>) {
WAL_APPLY_FAILPOINT_REMAINING.with(|remaining| remaining.set(operations_before_failure));
}
#[doc(hidden)]
pub fn set_wal_group_sync_failpoint(enabled: bool) {
WAL_GROUP_SYNC_FAILPOINT.with(|failpoint| failpoint.set(enabled));
}
fn wal_group_sync_failpoint() -> Result<()> {
if WAL_GROUP_SYNC_FAILPOINT.with(std::cell::Cell::take) {
return Err(HawDBError::Storage(
"injected WAL group sync failure".to_string(),
));
}
Ok(())
}
type PendingNode = (NodeId, LabelId, BTreeMap<String, Value>);
type PendingRelationship = (RelId, NodeId, NodeId, RelTypeId, BTreeMap<String, Value>);
pub type GraphSnapshotNodeImport = (NodeId, String, BTreeMap<String, Value>);
pub type GraphSnapshotRelationshipImport = (RelId, NodeId, NodeId, String, BTreeMap<String, Value>);
#[doc(hidden)]
pub struct HawDBSnapshotRowsImport {
pub stable_id_mapping: StoreStableIdMapping,
pub source_fingerprint: String,
pub nodes: Vec<GraphSnapshotNodeImport>,
pub relationships: Vec<GraphSnapshotRelationshipImport>,
pub relational_state: RelationalState,
pub target_has_only_engine_bootstrap: bool,
}
#[derive(Debug, Clone)]
struct RelationshipCandidate {
source: NodeId,
target: NodeId,
properties: BTreeMap<String, Value>,
}
struct RelationshipMatchRequest<'a> {
rel_type_id: RelTypeId,
source_label_id: Option<LabelId>,
source_filter: Option<&'a PropertyFilter>,
target_label_id: Option<LabelId>,
target_filter: Option<&'a PropertyFilter>,
rel_properties: &'a BTreeMap<String, Value>,
}
#[doc(hidden)]
pub use hawdb_storage::graph_index::{
CompositePropertyIndex, FullTextPropertyIndex, NodePropertyIndex, RelationshipPropertyIndex,
};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct KernelWriteBatch {
pub graph: Vec<GraphMutation>,
pub relational: RelationalTransaction,
pub append: AppendTransaction,
}
#[derive(Default)]
struct MutationCommitOptions<'a> {
relational: Option<RelationalTransaction>,
append: Option<AppendTransaction>,
preserve_single_create_wal: bool,
mvcc_read_epoch: Option<u64>,
captured_graph_ops: Option<&'a mut Vec<WalOp>>,
}
#[derive(Debug)]
#[doc(hidden)]
pub struct GraphMutationTransaction {
base_commit_epoch: u64,
catalog: Catalog,
store: GraphStore,
ops: Vec<WalOp>,
rows: Vec<BTreeMap<String, Value>>,
}
#[derive(Debug)]
#[doc(hidden)]
pub struct GraphMutationSavepoint {
catalog: Catalog,
store: GraphStore,
op_len: usize,
row_len: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[doc(hidden)]
pub struct GraphAdjacencyLockIdentity {
#[doc(hidden)]
pub node_id: NodeId,
#[doc(hidden)]
pub rel_type: Option<RelTypeId>,
#[doc(hidden)]
pub direction: AdjacencyDirection,
}
#[derive(Debug, Default)]
#[doc(hidden)]
pub struct GraphMutationLockFootprint {
#[doc(hidden)]
pub requires_database_lock: bool,
#[doc(hidden)]
pub allocates_node_ids: bool,
#[doc(hidden)]
pub allocates_relationship_ids: bool,
#[doc(hidden)]
pub node_label_names: BTreeSet<String>,
#[doc(hidden)]
pub exclusive_node_label_names: BTreeSet<String>,
#[doc(hidden)]
pub node_label_read_names: BTreeSet<String>,
#[doc(hidden)]
pub relationship_type_names: BTreeSet<String>,
#[doc(hidden)]
pub exclusive_relationship_type_names: BTreeSet<String>,
#[doc(hidden)]
pub node_writes: BTreeSet<NodeId>,
#[doc(hidden)]
pub relationship_writes: BTreeSet<RelId>,
#[doc(hidden)]
pub node_delete_guard_reads: BTreeSet<NodeId>,
#[doc(hidden)]
pub node_delete_guard_writes: BTreeSet<NodeId>,
#[doc(hidden)]
pub adjacency_writes: BTreeSet<GraphAdjacencyLockIdentity>,
}
#[doc(hidden)]
pub use hawdb_storage::mutation::{
ensure_additional_mutation_limits, ensure_mutation_commit_limits, estimated_properties_bytes,
estimated_value_bytes, remaining_mutation_affected_rows, remaining_mutation_operations,
};
use hawdb_storage::consistency::{
adjacency_consolidation_plan, adjacency_direction_sort_key, adjacency_layout_for_degree,
compute_degree_statistics_from_adjacency, compute_degree_statistics_from_relationships,
maintained_adjacency_groups, recompute_adjacency_groups,
};
pub use hawdb_storage::consistency::{
AdjacencyConsistencyReport, AdjacencyConsolidationCandidate, AdjacencyConsolidationPlan,
AdjacencyConsolidationReport, BasicStatisticsConsistencyReport,
DegreeStatisticsConsistencyReport, DegreeStatisticsEntry, DegreeStatisticsKey,
DistinctValueStatisticsConsistencyReport, PropertyIndexConsistencyReport,
};
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
#[doc(hidden)]
pub struct AdvancedStatisticsDirtyState {
node_topology: bool,
relationship_topology: bool,
node_properties: bool,
relationship_properties: bool,
mutation_operations: u64,
}
impl AdvancedStatisticsDirtyState {
fn mark_node_topology(&mut self) {
self.node_topology = true;
self.node_properties = true;
self.mutation_operations = self.mutation_operations.saturating_add(1);
}
fn mark_relationship_topology(&mut self) {
self.relationship_topology = true;
self.relationship_properties = true;
self.mutation_operations = self.mutation_operations.saturating_add(1);
}
fn mark_node_properties(&mut self) {
self.node_properties = true;
self.mutation_operations = self.mutation_operations.saturating_add(1);
}
fn mark_relationship_properties(&mut self) {
self.relationship_properties = true;
self.mutation_operations = self.mutation_operations.saturating_add(1);
}
fn is_empty(self) -> bool {
!self.node_topology
&& !self.relationship_topology
&& !self.node_properties
&& !self.relationship_properties
}
fn mutation_operations(self) -> u64 {
self.mutation_operations
}
}
#[derive(Debug, Default)]
pub struct GraphStore {
next_node_id: u64,
next_rel_id: u64,
commit_epoch: u64,
checkpoint_capture_started: Option<std::time::Instant>,
version_index: hawdb_storage::version::VersionIndex,
version_snapshot_pins: hawdb_storage::version::VersionSnapshotPins,
version_snapshot_pin: Option<hawdb_storage::version::VersionSnapshotPin>,
read_dependency_readers: Arc<AtomicUsize>,
read_dependency_validation_failures: Arc<read_dependencies::ValidationFailureCounters>,
read_summary_collapses: Arc<AtomicU64>,
nodes: crate::cow::CowNodeMap,
relationships: CowSegmentedMap<RelId, RelRecord>,
basic_statistics: BasicStatisticsState,
checkpoint_statistics: CheckpointStatisticsState,
advanced_statistics_dirty: AdvancedStatisticsDirtyState,
outgoing: CowSegmentedMap<(NodeId, RelTypeId), AdjacencyPostingList>,
incoming: CowSegmentedMap<(NodeId, RelTypeId), AdjacencyPostingList>,
property_index: NodePropertyIndex,
composite_property_index: CompositePropertyIndex,
full_text_property_index: FullTextPropertyIndex,
relationship_property_index: RelationshipPropertyIndex,
projected_graphs: CowSegment<BTreeMap<String, ProjectedGraphDefinition>>,
projected_graph_artifacts: crate::projection::artifact::CheckpointProjectedGraphRoot,
stable_id_mapping: CowSegment<StoreStableIdMapping>,
initial_import_source_fingerprint: Option<String>,
search_projection_database_identity: Option<hawdb_core::Uuid>,
search_projection_change_log_start_epoch: u64,
search_projection_graph_changes: SearchProjectionChangeLog,
search_projection_change_log_retained_bytes: usize,
max_search_projection_change_log_entries: Option<usize>,
max_search_projection_change_log_bytes: Option<usize>,
search_projection_primary_key_capture_limits:
hawdb_storage::relational::RelationalPrimaryKeyChangeCaptureLimits,
source_scan_manifest: CowSegment<Option<ScanSegmentManifest>>,
storage_recovery_report: StorageRecoveryReport,
canonical_base: Option<CanonicalSegmentReader>,
canonical_adjacency: Option<CanonicalAdjacencyReader>,
persistent_property_projection: Option<PersistentPropertyProjectionReader>,
graph_index_read_metrics: Arc<GraphIndexReadMetrics>,
canonical_base_out_of_core: bool,
node_tombstones: CowSegment<BTreeSet<NodeId>>,
relationship_tombstones: CowSegment<BTreeSet<RelId>>,
residency_mode: StorageResidencyMode,
auto_materialize_checkpoint_bytes: u64,
max_out_of_core_delta_bytes: Option<u64>,
post_wal_apply_poisoned: Arc<AtomicBool>,
integrity_poisoned: Arc<AtomicBool>,
relational_state: RelationalState,
append_state: AppendState,
append_mutation_limits: AppendMutationLimits,
append_publication_config: AppendPublicationConfig,
append_generation_reader: Option<AppendGenerationReader>,
relational_mutation_limits: RelationalMutationLimits,
relational_overflow_config: RelationalOverflowConfig,
columnar_shadow: ColumnarShadowState,
relational_index_shadow: RelationalIndexShadowState,
relational_row_pages: RelationalRowPageState,
projection_generations: Option<hawdb_storage::projection_generation::ProjectionGenerationStore>,
runtime_governor: Option<Arc<dyn hawdb_storage::background::BackgroundWorkAdmission>>,
branch_lease: Option<Arc<hawdb_storage::ownership::DatabaseDirectoryLease>>,
branch_root_pin: Option<Arc<crate::branch_root_pins::RootPin>>,
branch_runtime_owner: Option<Arc<crate::file_descriptors::AdmittedRuntimeOwner>>,
snapshot_file_context: Option<crate::file_descriptors::FileOpenContext>,
durable: Option<DurableStore>,
checkpoint_allocations: crate::background::CheckpointAllocationOwner,
}
impl hawdb_storage::graph_engine::GraphMutationEngine for GraphStore {
fn plan_schema_maintenance(
&self,
catalog: &hawdb_core::Catalog,
) -> Vec<hawdb_storage::projection::SchemaMaintenancePlanItem> {
GraphStore::plan_schema_maintenance(self, catalog)
}
fn run_schema_maintenance(
&mut self,
catalog: &mut hawdb_core::Catalog,
) -> hawdb_core::Result<Vec<hawdb_storage::projection::SchemaMaintenanceAction>> {
GraphStore::run_schema_maintenance(self, catalog)
}
fn rebuild_projected_graph_artifacts(
&mut self,
catalog: &hawdb_core::Catalog,
) -> hawdb_core::Result<()> {
GraphStore::rebuild_projected_graph_artifacts(self, catalog)
}
fn rebuild_bounded_property_index_projections(
&mut self,
catalog: &hawdb_core::Catalog,
max_estimated_operations: usize,
) -> Vec<hawdb_storage::projection::PropertyIndexProjectionRebuildAction> {
GraphStore::rebuild_bounded_property_index_projections(
self,
catalog,
max_estimated_operations,
)
}
fn scrub_storage(&mut self) -> hawdb_core::Result<hawdb_storage::backup::StorageScrubReport> {
GraphStore::scrub_storage(self)
}
fn backup_to(
&mut self,
catalog: &hawdb_core::Catalog,
destination: impl AsRef<std::path::Path>,
) -> hawdb_core::Result<hawdb_storage::backup::StorageBackupReport> {
GraphStore::backup_to(self, catalog, destination)
}
fn register_projected_graph(
&mut self,
name: &str,
definition: hawdb_storage::projection::ProjectedGraphDefinition,
) -> hawdb_core::Result<()> {
GraphStore::register_projected_graph(self, name, definition)
}
}
pub use hawdb_storage::scan::GraphScanControl;
#[doc(hidden)]
pub trait StoreTelemetry {
fn set_telemetry_sink(&mut self, telemetry: Option<std::sync::Arc<dyn TelemetrySink>>);
}
impl StoreTelemetry for GraphStore {
fn set_telemetry_sink(&mut self, telemetry: Option<std::sync::Arc<dyn TelemetrySink>>) {
GraphStore::set_telemetry_sink(self, telemetry);
}
}
#[doc(hidden)]
pub trait InternalGraphEngine {
fn ensure_usable(&self) -> Result<()>;
fn visit_adjacent_relationships_owned(
&self,
node_id: NodeId,
rel_type: Option<RelTypeId>,
direction: AdjacencyDirection,
consumer: impl FnMut(RelRecord) -> GraphScanControl,
) -> Result<GraphScanControl>;
fn visit_nodes_owned(
&self,
label_id: Option<LabelId>,
consumer: impl FnMut(NodeRecord) -> GraphScanControl,
) -> Result<GraphScanControl>;
fn relationship_owned(&self, id: RelId) -> Result<Option<RelRecord>>;
fn adjacency_group_stats(
&self,
node_id: NodeId,
rel_type: RelTypeId,
direction: AdjacencyDirection,
) -> AdjacencyGroupStats;
fn adjacency_group_stats_for_node(
&self,
node_id: NodeId,
direction: AdjacencyDirection,
) -> Vec<AdjacencyGroupStats>;
fn visit_nodes_by_property_owned(
&self,
label_id: LabelId,
property: &str,
values: &[Value],
consumer: impl FnMut(NodeRecord) -> GraphScanControl,
) -> Result<GraphScanControl>;
fn append_state(&self) -> &hawdb_storage::append_table::AppendState;
fn relational_state(&self) -> &hawdb_storage::relational::RelationalState;
fn poison_on_storage_error<T>(&self, result: &Result<T>);
}
impl InternalGraphEngine for GraphStore {
fn ensure_usable(&self) -> Result<()> {
GraphStore::ensure_usable(self)
}
fn visit_adjacent_relationships_owned(
&self,
node_id: NodeId,
rel_type: Option<RelTypeId>,
direction: AdjacencyDirection,
consumer: impl FnMut(RelRecord) -> GraphScanControl,
) -> Result<GraphScanControl> {
GraphStore::visit_adjacent_relationships_owned(self, node_id, rel_type, direction, consumer)
}
fn visit_nodes_owned(
&self,
label_id: Option<LabelId>,
consumer: impl FnMut(NodeRecord) -> GraphScanControl,
) -> Result<GraphScanControl> {
GraphStore::visit_nodes_owned(self, label_id, consumer)
}
fn relationship_owned(&self, id: RelId) -> Result<Option<RelRecord>> {
GraphStore::relationship_owned(self, id)
}
fn adjacency_group_stats(
&self,
node_id: NodeId,
rel_type: RelTypeId,
direction: AdjacencyDirection,
) -> AdjacencyGroupStats {
GraphStore::adjacency_group_stats(self, node_id, rel_type, direction)
}
fn adjacency_group_stats_for_node(
&self,
node_id: NodeId,
direction: AdjacencyDirection,
) -> Vec<AdjacencyGroupStats> {
GraphStore::adjacency_group_stats_for_node(self, node_id, direction)
}
fn visit_nodes_by_property_owned(
&self,
label_id: LabelId,
property: &str,
values: &[Value],
consumer: impl FnMut(NodeRecord) -> GraphScanControl,
) -> Result<GraphScanControl> {
GraphStore::visit_nodes_by_property_owned(self, label_id, property, values, consumer)
}
fn append_state(&self) -> &hawdb_storage::append_table::AppendState {
GraphStore::append_state(self)
}
fn relational_state(&self) -> &hawdb_storage::relational::RelationalState {
GraphStore::relational_state(self)
}
fn poison_on_storage_error<T>(&self, result: &Result<T>) {
GraphStore::poison_on_storage_error(self, result);
}
}
pub use hawdb_storage::relational::{
RelationalOverflowCompactionConfig, RelationalOverflowCompactionReport,
RelationalRowPageCompactionConfig, RelationalRowPageCompactionReport,
};
pub use hawdb_storage::{
relational_index_view::RelationalIndexStorageResidencyReport,
residency::{RelationalRowStorageResidencyReport, StorageResidencyReport},
};
pub use hawdb_storage::graph_overlay::{GraphNodeIterator, GraphRelationshipIterator};
fn canonical_segment_error(error: CanonicalSegmentError) -> HawDBError {
let refused = crate::reader_failure::is_admission_or_cancellation(&error);
match HawDBError::from_storage_error(error) {
HawDBError::Storage(message) if !refused => HawDBError::StorageIntegrity(message),
error => error,
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum SourceScanCandidateRead {
Rows {
graph_epoch: u64,
skipped_segment_count: usize,
report: SegmentReadExecutionReport,
rows: Vec<SourceScanRow>,
},
Fallback(ScanSegmentFallback),
}
#[derive(Debug, Clone, PartialEq)]
#[doc(hidden)]
pub enum SourceScanCandidateVisit {
Rows {
graph_epoch: u64,
skipped_segment_count: usize,
report: SegmentReadExecutionReport,
candidate_count: usize,
},
Fallback(ScanSegmentFallback),
}
#[derive(Debug, Clone, Copy)]
#[doc(hidden)]
pub struct SourceScanCandidateLimits {
io_depth: NonZeroUsize,
max_coalesced_bytes: NonZeroU64,
max_wave_bytes: NonZeroU64,
max_live_candidate_bytes: Option<usize>,
}
impl SourceScanCandidateLimits {
const fn unbounded(
io_depth: NonZeroUsize,
max_coalesced_bytes: NonZeroU64,
max_wave_bytes: NonZeroU64,
) -> Self {
Self {
io_depth,
max_coalesced_bytes,
max_wave_bytes,
max_live_candidate_bytes: None,
}
}
#[doc(hidden)]
pub const fn bounded(
io_depth: NonZeroUsize,
max_coalesced_bytes: NonZeroU64,
max_wave_bytes: NonZeroU64,
max_live_candidate_bytes: NonZeroUsize,
) -> Self {
Self {
io_depth,
max_coalesced_bytes,
max_wave_bytes,
max_live_candidate_bytes: Some(max_live_candidate_bytes.get()),
}
}
}
struct ScanPruningCandidate {
strategy: ScanPruningStrategy,
node_ids: crate::read_view::AdmittedKeySet<NodeId>,
exact_empty: bool,
}
impl ScanPruningCandidate {
fn exact(
strategy: ScanPruningStrategy,
node_ids: crate::read_view::AdmittedKeySet<NodeId>,
) -> Self {
Self {
strategy,
exact_empty: node_ids.is_empty(),
node_ids,
}
}
}
#[derive(Debug, Clone)]
struct RelationshipScanPruningCandidate {
strategy: ScanPruningStrategy,
rel_ids: BTreeSet<RelId>,
exact_empty: bool,
}
impl RelationshipScanPruningCandidate {
fn exact(strategy: ScanPruningStrategy, rel_ids: BTreeSet<RelId>) -> Self {
Self {
strategy,
exact_empty: rel_ids.is_empty(),
rel_ids,
}
}
}
impl GraphMutationTransaction {
#[doc(hidden)]
pub fn is_read_only(&self) -> bool {
self.ops.is_empty()
}
#[doc(hidden)]
pub fn catalog(&self) -> &Catalog {
&self.catalog
}
#[doc(hidden)]
pub fn store(&self) -> &GraphStore {
&self.store
}
#[doc(hidden)]
pub fn catalog_and_store_mut(&mut self) -> (&mut Catalog, &mut GraphStore) {
(&mut self.catalog, &mut self.store)
}
#[doc(hidden)]
pub fn savepoint(&self) -> GraphMutationSavepoint {
GraphMutationSavepoint {
catalog: self.catalog.clone(),
store: self.store.snapshot(),
op_len: self.ops.len(),
row_len: self.rows.len(),
}
}
#[doc(hidden)]
pub fn restore(&mut self, savepoint: GraphMutationSavepoint) {
self.catalog = savepoint.catalog;
self.store = savepoint.store;
self.ops.truncate(savepoint.op_len);
self.rows.truncate(savepoint.row_len);
}
#[doc(hidden)]
pub fn lock_footprint_since(
&self,
savepoint: &GraphMutationSavepoint,
) -> Result<GraphMutationLockFootprint> {
let mut footprint = GraphMutationLockFootprint::default();
collect_graph_lock_footprint(
&self.catalog,
&savepoint.store,
&self.store,
&self.ops[savepoint.op_len..],
&mut footprint,
)?;
Ok(footprint)
}
#[doc(hidden)]
pub fn stage_mutation_with_limits(
&mut self,
mutation: GraphMutation,
limits: MutationLimits,
) -> Result<MutationSummary> {
self.stage_mutations_with_limits(vec![mutation], limits)
}
#[doc(hidden)]
pub fn stage_mutations_with_limits(
&mut self,
mutations: Vec<GraphMutation>,
limits: MutationLimits,
) -> Result<MutationSummary> {
self.stage_mutations(mutations, limits, true)
}
#[doc(hidden)]
pub fn stage_mutation_without_commit_rows(
&mut self,
mutation: GraphMutation,
limits: MutationLimits,
) -> Result<MutationSummary> {
self.stage_mutations(vec![mutation], limits, false)
}
fn stage_mutations(
&mut self,
mutations: Vec<GraphMutation>,
limits: MutationLimits,
retain_commit_rows: bool,
) -> Result<MutationSummary> {
if mutations.is_empty() {
return Ok(MutationSummary {
rows: Vec::new(),
relational_mutation_outcomes: Vec::new(),
append_mutation_outcomes: Vec::new(),
});
}
let mut captured_ops = Vec::new();
let summary = self.store.commit_mutations_internal(
&mut self.catalog,
mutations,
limits,
MutationCommitOptions {
captured_graph_ops: Some(&mut captured_ops),
..MutationCommitOptions::default()
},
)?;
self.ops.extend(captured_ops);
if retain_commit_rows {
self.rows.extend(summary.rows.iter().cloned());
}
Ok(summary)
}
}
fn collect_graph_lock_footprint(
catalog: &Catalog,
before: &GraphStore,
store: &GraphStore,
ops: &[WalOp],
footprint: &mut GraphMutationLockFootprint,
) -> Result<()> {
for op in ops {
match op {
WalOp::CreateNode { id, label, .. } => {
footprint.allocates_node_ids = true;
footprint.node_writes.insert(*id);
footprint.node_label_names.insert(label.clone());
footprint.exclusive_node_label_names.insert(label.clone());
}
WalOp::CreateRelationship {
id,
source,
target,
rel_type,
..
} => {
footprint.allocates_relationship_ids = true;
footprint.relationship_writes.insert(*id);
footprint.relationship_type_names.insert(rel_type.clone());
footprint
.exclusive_relationship_type_names
.insert(rel_type.clone());
for endpoint in [*source, *target] {
for label_id in store
.node_owned(endpoint)?
.into_iter()
.flat_map(|node| node.labels)
{
if let Some(label) = catalog.label_name(label_id) {
footprint.node_label_read_names.insert(label.to_string());
}
}
}
record_relationship_endpoint_locks(
footprint,
*source,
*target,
catalog.rel_type_id(rel_type),
);
}
WalOp::SetNodeProperty { id, property, .. } => {
footprint.node_writes.insert(*id);
for label_id in store
.node_owned(*id)?
.into_iter()
.flat_map(|node| node.labels)
{
if let Some(label) = catalog.label_name(label_id) {
footprint.node_label_names.insert(label.to_string());
if catalog.unique_constraints().any(|constraint| {
constraint.subject == crate::schema::ConstraintSubject::Node(label_id)
&& constraint.property == *property
}) {
footprint
.exclusive_node_label_names
.insert(label.to_string());
}
}
}
}
WalOp::SetRelationshipProperty { id, property, .. } => {
footprint.relationship_writes.insert(*id);
if let Some(relationship) = store.relationship_owned(*id)?
&& let Some(rel_type) = catalog.rel_type_name(relationship.rel_type)
{
footprint
.relationship_type_names
.insert(rel_type.to_string());
if catalog.relationship_unique_constraints().any(|constraint| {
constraint.subject
== crate::schema::ConstraintSubject::Relationship(relationship.rel_type)
&& constraint.property == *property
}) {
footprint
.exclusive_relationship_type_names
.insert(rel_type.to_string());
}
}
}
WalOp::DeleteNode { id } => {
footprint.node_writes.insert(*id);
footprint.node_delete_guard_writes.insert(*id);
for label_id in before
.node_owned(*id)?
.into_iter()
.flat_map(|node| node.labels)
{
if let Some(label) = catalog.label_name(label_id) {
footprint.node_label_names.insert(label.to_string());
}
}
}
WalOp::DeleteRelationship { id } => {
footprint.relationship_writes.insert(*id);
let relationship = before.relationship_owned(*id)?.ok_or_else(|| {
HawDBError::Execution(format!(
"deleted relationship {} is missing while deriving transaction locks",
id.0
))
})?;
if let Some(rel_type) = catalog.rel_type_name(relationship.rel_type) {
footprint
.relationship_type_names
.insert(rel_type.to_string());
}
for endpoint in [relationship.source, relationship.target] {
for label_id in before
.node_owned(endpoint)?
.into_iter()
.flat_map(|node| node.labels)
{
if let Some(label) = catalog.label_name(label_id) {
footprint.node_label_read_names.insert(label.to_string());
}
}
}
record_relationship_endpoint_locks(
footprint,
relationship.source,
relationship.target,
Some(relationship.rel_type),
);
}
WalOp::Batch(ops) => {
collect_graph_lock_footprint(catalog, before, store, ops, footprint)?
}
WalOp::CreateNodeLabel { .. }
| WalOp::CreateRelationshipType { .. }
| WalOp::CreateNodeTable { .. }
| WalOp::CreateRelationshipTable { .. }
| WalOp::CreateProperty { .. }
| WalOp::AlterTableState { .. }
| WalOp::AlterPropertyState { .. }
| WalOp::GcTableDescriptor { .. }
| WalOp::GcPropertyDescriptor { .. }
| WalOp::CreateIndex { .. }
| WalOp::CreateCompositeIndex { .. }
| WalOp::CreateRangeIndex { .. }
| WalOp::CreateFullTextIndex { .. }
| WalOp::CreateUniqueConstraint { .. }
| WalOp::CreateNodePropertyExistsConstraint { .. }
| WalOp::CreateRelationshipUniqueConstraint { .. }
| WalOp::CreateRelationshipPropertyExistsConstraint { .. }
| WalOp::ProjectGraph { .. }
| WalOp::MarkInitialImportSource { .. }
| WalOp::Relational { .. }
| WalOp::RelationalSnapshot { .. }
| WalOp::Append { .. } => footprint.requires_database_lock = true,
}
}
Ok(())
}
fn record_relationship_endpoint_locks(
footprint: &mut GraphMutationLockFootprint,
source: NodeId,
target: NodeId,
rel_type: Option<RelTypeId>,
) {
footprint.node_delete_guard_reads.insert(source);
footprint.node_delete_guard_reads.insert(target);
footprint
.adjacency_writes
.insert(GraphAdjacencyLockIdentity {
node_id: source,
rel_type,
direction: AdjacencyDirection::Outgoing,
});
footprint
.adjacency_writes
.insert(GraphAdjacencyLockIdentity {
node_id: target,
rel_type,
direction: AdjacencyDirection::Incoming,
});
}
fn ensure_graph_read_flags_usable(
integrity_poisoned: &AtomicBool,
post_wal_apply_poisoned: &AtomicBool,
) -> Result<()> {
if integrity_poisoned.load(AtomicOrdering::Acquire) {
return Err(HawDBError::Storage(
"database handle is poisoned after a runtime storage integrity failure; close and reopen the database before issuing more operations"
.to_string(),
));
}
if post_wal_apply_poisoned.load(AtomicOrdering::Acquire) {
return Err(HawDBError::Storage(
"database handle is poisoned after a durable WAL batch failed during in-memory apply; close and reopen the database before issuing more operations"
.to_string(),
));
}
Ok(())
}
impl GraphStore {
pub fn in_memory() -> Self {
Self::default()
}
#[doc(hidden)]
pub fn ensure_usable(&self) -> Result<()> {
ensure_graph_read_flags_usable(&self.integrity_poisoned, &self.post_wal_apply_poisoned)?;
self.validate_authoritative_relational_index_open()?;
Ok(())
}
pub fn post_wal_apply_poisoned(&self) -> bool {
self.post_wal_apply_poisoned.load(AtomicOrdering::Acquire)
}
#[doc(hidden)]
pub fn projection_generation_store(
&self,
) -> Result<hawdb_storage::projection_generation::ProjectionGenerationStore> {
self.projection_generations.clone().ok_or_else(|| {
HawDBError::Storage(
"projection generation catalog requires a durable database".to_string(),
)
})
}
pub fn storage_handle_poisoned(&self) -> bool {
self.post_wal_apply_poisoned() || self.integrity_poisoned.load(AtomicOrdering::Acquire)
}
#[doc(hidden)]
pub fn poison_on_storage_error<T>(&self, result: &Result<T>) {
if matches!(result, Err(HawDBError::StorageIntegrity(_))) {
self.integrity_poisoned.store(true, AtomicOrdering::Release);
}
}
pub fn open(path: impl AsRef<Path>, catalog: &mut Catalog) -> Result<Self> {
Self::open_with_durability(path, catalog, DurabilityPolicy::default())
}
pub fn open_with_durability(
path: impl AsRef<Path>,
catalog: &mut Catalog,
durability: DurabilityPolicy,
) -> Result<Self> {
Self::open_with_options(
path,
catalog,
durability,
DurableOpenMode::CreateIfMissing,
WalReplayConfig::default(),
)
}
pub fn open_with_durability_and_recovery(
path: impl AsRef<Path>,
catalog: &mut Catalog,
durability: DurabilityPolicy,
recovery_mode: RecoveryMode,
) -> Result<Self> {
Self::open_with_options(
path,
catalog,
durability,
DurableOpenMode::CreateIfMissing,
WalReplayConfig {
recovery_mode,
..WalReplayConfig::default()
},
)
}
pub fn open_with_durability_and_replay_config(
path: impl AsRef<Path>,
catalog: &mut Catalog,
durability: DurabilityPolicy,
replay_config: WalReplayConfig,
) -> Result<Self> {
Self::open_with_options(
path,
catalog,
durability,
DurableOpenMode::CreateIfMissing,
replay_config,
)
}
pub fn open_read_only_with_durability(
path: impl AsRef<Path>,
catalog: &mut Catalog,
durability: DurabilityPolicy,
recovery_mode: RecoveryMode,
) -> Result<Self> {
Self::open_with_options(
path,
catalog,
durability,
DurableOpenMode::ExistingOnly,
WalReplayConfig {
recovery_mode,
..WalReplayConfig::default()
},
)
}
pub fn open_read_only_with_durability_and_replay_config(
path: impl AsRef<Path>,
catalog: &mut Catalog,
durability: DurabilityPolicy,
replay_config: WalReplayConfig,
) -> Result<Self> {
Self::open_with_options(
path,
catalog,
durability,
DurableOpenMode::ExistingOnly,
replay_config,
)
}
fn open_with_options(
path: impl AsRef<Path>,
catalog: &mut Catalog,
durability: DurabilityPolicy,
mode: DurableOpenMode,
replay_config: WalReplayConfig,
) -> Result<Self> {
if replay_config.recovery_mode == RecoveryMode::DoctorRepairTornTail {
return Err(HawDBError::Storage(
"WAL repair is not available through database open; use DatabaseDoctor to plan and explicitly apply repair before opening in strict mode"
.to_string(),
));
}
if matches!(mode, DurableOpenMode::ExistingOnly)
&& replay_config.recovery_mode == RecoveryMode::AutoRepairTornTail
{
return Err(HawDBError::Storage(
"automatic WAL tail repair requires a writable database open".to_string(),
));
}
let total_open_started = std::time::Instant::now();
if replay_config.max_graph_manifest_open_bytes == 0 {
return Err(HawDBError::Storage(
"max_graph_manifest_open_bytes must be non-zero".into(),
));
}
let _project_files = match mode {
DurableOpenMode::CreateIfMissing => {
crate::file_descriptors::ProjectFileDescriptors::acquire_with_capacity_limit(
path.as_ref(),
replay_config.max_open_files,
replay_config.strict_capacity_limit,
)?
}
DurableOpenMode::ExistingOnly => {
DurableStore::validate_read_only_path(path.as_ref())?;
crate::file_descriptors::ProjectFileDescriptors::acquire_existing_with_capacity_limit(
path.as_ref(),
replay_config.max_open_files,
replay_config.strict_capacity_limit,
)?
}
};
let _descriptor_operation = _project_files.io_context().internal_operation()?;
let durable_manifest_open_started = std::time::Instant::now();
let durable = match mode {
DurableOpenMode::CreateIfMissing => {
DurableStore::open(path.as_ref(), durability, replay_config)?
}
DurableOpenMode::ExistingOnly => DurableStore::open_existing_only(
path.as_ref(),
durability,
replay_config.segment_cache_capacity_bytes,
replay_config.max_graph_manifest_open_bytes,
replay_config.max_bytes,
replay_config.max_record_bytes,
replay_config.max_batch_operations,
)?,
};
let durable_manifest_open_micros = elapsed_micros(durable_manifest_open_started);
let (mut store, _) = Self::finish_open(
durable,
catalog,
replay_config,
durable_manifest_open_micros,
)?;
let activation_started = std::time::Instant::now();
store.activate_out_of_core_relational_rows()?;
store
.storage_recovery_report
.open_timings
.post_replay_open_micros = store
.storage_recovery_report
.open_timings
.post_replay_open_micros
.saturating_add(elapsed_micros(activation_started));
store.storage_recovery_report.open_timings.total_open_micros =
elapsed_micros(total_open_started);
Ok(store)
}
fn finish_open(
durable: DurableStore,
catalog: &mut Catalog,
replay_config: WalReplayConfig,
durable_manifest_open_micros: u64,
) -> Result<(Self, Catalog)> {
Self::finish_open_with_replay(
durable,
catalog,
replay_config,
durable_manifest_open_micros,
|store, catalog, replay_config| store.replay_wal(catalog, replay_config),
)
}
fn finish_open_with_replay(
durable: DurableStore,
catalog: &mut Catalog,
replay_config: WalReplayConfig,
durable_manifest_open_micros: u64,
replay: impl FnOnce(&mut Self, &mut Catalog, WalReplayConfig) -> Result<StorageRecoveryReport>,
) -> Result<(Self, Catalog)> {
let projection_generation_root = durable.root_path.join("projection-generations");
let projection_generations = if durable.read_only {
if projection_generation_root.exists() {
Some(
hawdb_storage::projection_generation::ProjectionGenerationStore::open_existing(
&projection_generation_root,
)
.map_err(HawDBError::from_storage_error)?,
)
} else {
None
}
} else {
Some(
hawdb_storage::projection_generation::ProjectionGenerationStore::open(
&projection_generation_root,
)
.map_err(HawDBError::from_storage_error)?,
)
};
let mut store = Self {
next_node_id: 0,
next_rel_id: 0,
commit_epoch: 0,
checkpoint_capture_started: None,
version_index: hawdb_storage::version::VersionIndex::default(),
version_snapshot_pins: Default::default(),
version_snapshot_pin: None,
read_dependency_readers: Arc::new(AtomicUsize::new(0)),
read_dependency_validation_failures: Arc::new(Default::default()),
read_summary_collapses: Arc::new(AtomicU64::new(0)),
nodes: crate::cow::CowNodeMap::default(),
relationships: CowSegmentedMap::default(),
basic_statistics: BasicStatisticsState::default(),
checkpoint_statistics: CheckpointStatisticsState::default(),
advanced_statistics_dirty: AdvancedStatisticsDirtyState::default(),
outgoing: CowSegmentedMap::default(),
incoming: CowSegmentedMap::default(),
property_index: CowSegmentedMap::default(),
composite_property_index: CowSegmentedMap::default(),
full_text_property_index: CowSegmentedMap::default(),
relationship_property_index: CowSegmentedMap::default(),
projected_graphs: CowSegment::default(),
projected_graph_artifacts: Default::default(),
stable_id_mapping: CowSegment::default(),
initial_import_source_fingerprint: None,
search_projection_database_identity: None,
search_projection_change_log_start_epoch: 0,
search_projection_graph_changes: SearchProjectionChangeLog::default(),
search_projection_change_log_retained_bytes: 0,
max_search_projection_change_log_entries: None,
max_search_projection_change_log_bytes: None,
search_projection_primary_key_capture_limits: Default::default(),
source_scan_manifest: CowSegment::default(),
storage_recovery_report: StorageRecoveryReport::default(),
canonical_base: None,
canonical_adjacency: None,
persistent_property_projection: None,
graph_index_read_metrics: Arc::new(GraphIndexReadMetrics::default()),
canonical_base_out_of_core: false,
node_tombstones: CowSegment::default(),
relationship_tombstones: CowSegment::default(),
residency_mode: replay_config.residency_mode,
auto_materialize_checkpoint_bytes: replay_config.auto_materialize_checkpoint_bytes,
max_out_of_core_delta_bytes: replay_config.max_out_of_core_delta_bytes,
post_wal_apply_poisoned: Arc::new(AtomicBool::new(false)),
integrity_poisoned: Arc::new(AtomicBool::new(false)),
relational_state: RelationalState::default(),
append_state: AppendState::default(),
append_mutation_limits: AppendMutationLimits::default(),
append_publication_config: AppendPublicationConfig::default(),
append_generation_reader: None,
relational_mutation_limits: RelationalMutationLimits::default(),
relational_overflow_config: RelationalOverflowConfig::default(),
columnar_shadow: ColumnarShadowState::default(),
relational_index_shadow: RelationalIndexShadowState::new(
replay_config.relational_index_mode,
),
relational_row_pages: RelationalRowPageState::default(),
projection_generations,
runtime_governor: None,
branch_lease: None,
branch_root_pin: None,
branch_runtime_owner: None,
snapshot_file_context: None,
durable: Some(durable),
checkpoint_allocations: Default::default(),
};
if replay_config
.relational_index_mode
.requires_authoritative_indexes()
{
store.relational_state.omit_materialized_index_postings();
}
let checkpoint_root_open_started = std::time::Instant::now();
store.load_checkpoint(catalog, replay_config)?;
store.mount_append_generation_for_recovery()?;
if replay_config.graph_columnar_shadow_checkpoint {
store.mount_columnar_shadow_for_recovery()?;
}
store.mount_relational_row_pages_for_recovery()?;
store.mount_relational_index_shadow_for_recovery();
let checkpoint_root_open_micros = elapsed_micros(checkpoint_root_open_started);
let checkpoint_catalog = catalog.clone();
let wal_replay_started = std::time::Instant::now();
let mut storage_recovery_report = replay(&mut store, catalog, replay_config)?;
let wal_replay_micros = elapsed_micros(wal_replay_started);
let post_replay_open_started = std::time::Instant::now();
store.validate_authoritative_relational_index_open()?;
store.validate_relationship_endpoints()?;
store.refresh_basic_statistics_epoch();
store.load_projected_graph_artifacts()?;
store.load_stable_id_mapping()?;
store.load_source_scan_manifest()?;
storage_recovery_report.open_timings = StorageOpenTimings {
durable_manifest_open_micros,
checkpoint_root_open_micros,
wal_replay_micros,
post_replay_open_micros: elapsed_micros(post_replay_open_started),
total_open_micros: 0,
};
store.storage_recovery_report = storage_recovery_report;
Ok((store, checkpoint_catalog))
}
fn open_for_derived_repair(
path: &Path,
mut replay_config: WalReplayConfig,
) -> Result<(Self, Catalog)> {
if replay_config.recovery_mode != RecoveryMode::Strict {
return Err(HawDBError::Storage(
"derived repair requires strict WAL replay".to_string(),
));
}
let directory = doctor::repair_directory(path)?;
let path = directory.as_path();
let project_files = doctor::repair_file_descriptors(path)?;
replay_config.max_open_files = project_files.metrics().limit;
if path
.parent()
.is_some_and(|parent| parent.file_name().is_some_and(|name| name == "branches"))
{
let id = path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| HawDBError::StorageIntegrity("repair branch has no UUID".into()))?;
let branch_id = crate::branch_catalog::BranchId::parse(id)
.map_err(HawDBError::from_storage_error)?;
let catalog_path = path.parent().unwrap().join("catalog.hawdb");
let catalog = crate::branch_catalog::read_catalog(&catalog_path)?;
let record = catalog
.branches
.iter()
.find(|record| record.id == branch_id)
.ok_or_else(|| {
HawDBError::StorageIntegrity("repair branch is absent from its catalog".into())
})?;
let head_path = path.join("branch.head");
let objects = path.parent().unwrap().join("objects");
return Self::admit_branch_for_derived_repair(BranchAdmissionRequest {
catalog_path: &catalog_path,
branch_id,
expected_metadata_revision: record.metadata_revision,
head_path: &head_path,
immutable_store_root: &objects,
durability: DurabilityPolicy::default(),
replay_config,
})
.map(|admitted| admitted.into_parts())
.map_err(|error| match error {
BranchAdmissionError::Recovery(error) => error,
error => HawDBError::from_storage_error(error),
});
}
let total_open_started = std::time::Instant::now();
let durable_manifest_open_started = std::time::Instant::now();
let durable = DurableStore::open_for_derived_repair(
path,
DurabilityPolicy::default(),
replay_config.segment_cache_capacity_bytes,
replay_config.max_graph_manifest_open_bytes,
replay_config.max_bytes,
replay_config.max_record_bytes,
replay_config.max_batch_operations,
)?;
let durable_manifest_open_micros = elapsed_micros(durable_manifest_open_started);
let mut recovered_catalog = Catalog::default();
let (mut store, _) = Self::finish_open(
durable,
&mut recovered_catalog,
replay_config,
durable_manifest_open_micros,
)?;
store.storage_recovery_report.open_timings.total_open_micros =
elapsed_micros(total_open_started);
Ok((store, recovered_catalog))
}
fn enable_derived_repair_writes(&mut self) -> Result<()> {
let durable = self.durable.as_mut().ok_or_else(|| {
HawDBError::Storage("derived repair requires durable storage".to_string())
})?;
durable.read_only = false;
Ok(())
}
#[doc(hidden)]
pub fn relational_state(&self) -> &RelationalState {
&self.relational_state
}
pub fn storage_version(&self) -> &'static str {
STORAGE_VERSION
}
pub fn is_out_of_core(&self) -> bool {
self.canonical_base_out_of_core
}
pub fn commit_epoch(&self) -> u64 {
self.commit_epoch
}
pub fn published_read_view(&self) -> PublishedReadView {
let checkpoint = self
.durable
.as_ref()
.filter(|durable| durable.checkpoint_encoded_len.is_some());
PublishedReadView::new(
self.commit_epoch,
checkpoint.map(|durable| durable.checkpoint_commit_epoch),
checkpoint.map(|durable| ManifestGeneration(durable.checkpoint_epoch)),
)
}
#[doc(hidden)]
pub fn begin_wal_sync_group(&mut self) -> Result<bool> {
let Some(durable) = &mut self.durable else {
return Ok(false);
};
durable.begin_wal_sync_group()
}
#[doc(hidden)]
pub fn wal_sync_group_progress(&self) -> WalSyncGroupProgress {
self.durable
.as_ref()
.map_or_else(WalSyncGroupProgress::default, |durable| {
durable.wal_sync_group_progress()
})
}
#[doc(hidden)]
pub fn wal_sync_group_active(&self) -> bool {
self.durable
.as_ref()
.is_some_and(DurableStore::wal_sync_group_active)
}
#[doc(hidden)]
pub fn finish_wal_sync_group(&mut self) -> Result<WalSyncGroupFlush> {
let Some(durable) = &mut self.durable else {
return Ok(WalSyncGroupFlush::default());
};
match durable.finish_wal_sync_group() {
Ok(flush) => Ok(flush),
Err(error) => {
self.post_wal_apply_poisoned
.store(true, AtomicOrdering::Release);
Err(error)
}
}
}
pub fn search_projection_change_log_start_epoch(&self) -> u64 {
self.search_projection_change_log_start_epoch
}
pub fn search_projection_graph_changes_after(
&self,
commit_epoch: u64,
) -> Vec<SearchProjectionGraphChange> {
self.search_projection_graph_changes
.iter()
.filter(|change| change.commit_epoch > commit_epoch)
.map(|change| change.as_ref().clone())
.collect()
}
#[doc(hidden)]
pub fn capture_projection_feed(
&self,
start_epoch: u64,
limits: ProjectionFeedLimits,
) -> std::result::Result<ProjectionFeedCursor, ProjectionFeedError> {
self.ensure_usable().map_err(ProjectionFeedError::Source)?;
ProjectionFeedCursor::capture(
&self.search_projection_graph_changes,
self.search_projection_change_log_start_epoch,
self.commit_epoch,
start_epoch,
limits,
)
}
pub fn search_projection_changes_after(
&self,
commit_epoch: u64,
) -> Vec<hawdb_storage::projection::SearchProjectionChange> {
self.search_projection_graph_changes_after(commit_epoch)
}
pub fn search_projection_changefeed_status(&self) -> SearchProjectionChangefeedStatus {
SearchProjectionChangefeedStatus {
graph_commit_epoch: self.commit_epoch,
resume_floor_commit_epoch: self.search_projection_change_log_start_epoch,
oldest_retained_mutation_id: self
.search_projection_graph_changes
.first()
.map(|change| change.mutation_id()),
newest_retained_mutation_id: self
.search_projection_graph_changes
.last()
.map(|change| change.mutation_id()),
first_rebuild_required_mutation_id: self
.search_projection_graph_changes
.iter()
.find(|change| change.relational_primary_key_changes.requires_rebuild())
.map(|change| change.mutation_id()),
retained_mutation_count: self.search_projection_graph_changes.len(),
retained_bytes: self.search_projection_change_log_retained_bytes,
max_retained_bytes: self.max_search_projection_change_log_bytes,
restart_recoverable: self.durable.is_some(),
}
}
pub fn set_max_search_projection_change_log_entries(&mut self, max_entries: Option<usize>) {
self.max_search_projection_change_log_entries = max_entries;
self.trim_search_projection_graph_change_log();
}
pub fn set_max_search_projection_change_log_bytes(&mut self, max_bytes: Option<usize>) {
self.max_search_projection_change_log_bytes = max_bytes;
self.trim_search_projection_graph_change_log();
}
pub fn set_search_projection_primary_key_capture_limits(
&mut self,
limits: hawdb_storage::relational::RelationalPrimaryKeyChangeCaptureLimits,
) {
self.search_projection_primary_key_capture_limits = limits;
for change in self.search_projection_graph_changes.iter_mut() {
if change.relational_primary_key_changes.exceeds_limits(limits) {
Arc::make_mut(change).relational_primary_key_changes =
hawdb_storage::relational::RelationalPrimaryKeyChangeCapture::RequiresRebuild {
reason: hawdb_storage::relational::RelationalPrimaryKeyChangeRebuildReason::CaptureLimitExceeded,
};
}
}
self.search_projection_change_log_retained_bytes = self
.search_projection_graph_changes
.iter()
.map(|change| change.estimated_retained_bytes())
.fold(0usize, usize::saturating_add);
self.trim_search_projection_graph_change_log();
}
pub fn set_telemetry_sink(&mut self, telemetry: Option<Arc<dyn TelemetrySink>>) {
if let Some(durable) = &mut self.durable {
durable.telemetry = telemetry.map(|sink| Arc::new(RootStorageTelemetry(sink)) as _);
}
}
pub fn stable_id_mapping(&self) -> Result<StoreStableIdMapping> {
self.durable.as_ref().map_or_else(
|| Ok((*self.stable_id_mapping).clone()),
DurableStore::materialize_stable_id_mapping,
)
}
#[doc(hidden)]
pub fn search_projection_database_identity(&self) -> Option<hawdb_core::Uuid> {
self.search_projection_database_identity
}
#[doc(hidden)]
pub fn set_search_projection_database_identity(&mut self, identity: hawdb_core::Uuid) {
self.search_projection_database_identity = Some(identity);
}
#[doc(hidden)]
pub fn search_projection_registry_root(&self) -> Option<&Path> {
self.durable.as_ref().map(|durable| durable.root_path())
}
#[doc(hidden)]
pub fn durable_root_path(&self) -> Option<&Path> {
self.durable.as_ref().map(|durable| durable.root_path())
}
pub fn initial_import_source_fingerprint(&self) -> Option<&str> {
self.initial_import_source_fingerprint.as_deref()
}
pub fn replace_stable_id_mapping(&mut self, mapping: StoreStableIdMapping) -> Result<()> {
self.replace_stable_id_mapping_for_epoch(mapping, self.commit_epoch)
}
fn replace_stable_id_mapping_for_epoch(
&mut self,
mapping: StoreStableIdMapping,
covered_commit_epoch: u64,
) -> Result<()> {
if self
.durable
.as_ref()
.is_some_and(|durable| durable.read_only)
{
return Err(HawDBError::Storage(
"stable id mapping persistence is not allowed in read-only mode".to_string(),
));
}
if let Some(durable) = &mut self.durable {
durable.write_stable_id_mapping(&mapping, covered_commit_epoch)?;
self.stable_id_mapping = CowSegment::default();
} else {
self.stable_id_mapping = mapping.into();
}
Ok(())
}
pub fn ensure_stable_id_mapping(
&mut self,
required_node_ids: &BTreeSet<NodeId>,
required_relationship_ids: &BTreeSet<RelId>,
) -> Result<StoreStableIdMapping> {
if self
.durable
.as_ref()
.is_some_and(|durable| durable.read_only)
{
return Err(HawDBError::Storage(
"stable id mapping persistence is not allowed in read-only mode".to_string(),
));
}
let existing = self.stable_id_mapping()?;
let mapping = StoreStableIdMapping {
node_stable_ids: required_node_ids
.iter()
.map(|id| {
(
*id,
existing
.node_stable_ids
.get(id)
.cloned()
.unwrap_or_else(|| generated_stable_id("node", id.0)),
)
})
.collect(),
relationship_stable_ids: required_relationship_ids
.iter()
.map(|id| {
(
*id,
existing
.relationship_stable_ids
.get(id)
.cloned()
.unwrap_or_else(|| generated_stable_id("relationship", id.0)),
)
})
.collect(),
};
if mapping != existing {
self.replace_stable_id_mapping_for_epoch(mapping.clone(), self.commit_epoch)?;
}
Ok(mapping)
}
#[doc(hidden)]
pub fn snapshot_for_read(&self) -> Self {
let mut snapshot = self.snapshot_with_history(false);
snapshot.version_index = self.version_index.read_baseline(self.commit_epoch);
snapshot
}
pub fn snapshot(&self) -> Self {
self.snapshot_with_history(true)
}
fn snapshot_with_history(&self, protect_history: bool) -> Self {
let pin_epoch = self
.version_snapshot_pin
.as_ref()
.map_or(self.commit_epoch, |pin| pin.epoch().min(self.commit_epoch));
Self {
next_node_id: self.next_node_id,
next_rel_id: self.next_rel_id,
commit_epoch: self.commit_epoch,
checkpoint_capture_started: self.checkpoint_capture_started,
version_index: self.version_index.clone(),
version_snapshot_pins: self.version_snapshot_pins.clone(),
version_snapshot_pin: Some(if protect_history {
self.version_snapshot_pins.pin(pin_epoch)
} else {
hawdb_storage::version::VersionSnapshotPin::read_only(pin_epoch)
}),
read_dependency_readers: Arc::clone(&self.read_dependency_readers),
read_dependency_validation_failures: Arc::clone(
&self.read_dependency_validation_failures,
),
read_summary_collapses: Arc::clone(&self.read_summary_collapses),
nodes: self.nodes.clone(),
relationships: self.relationships.clone(),
basic_statistics: self.basic_statistics.clone(),
checkpoint_statistics: self.checkpoint_statistics.clone(),
advanced_statistics_dirty: self.advanced_statistics_dirty,
outgoing: self.outgoing.clone(),
incoming: self.incoming.clone(),
property_index: self.property_index.clone(),
composite_property_index: self.composite_property_index.clone(),
full_text_property_index: self.full_text_property_index.clone(),
relationship_property_index: self.relationship_property_index.clone(),
projected_graphs: self.projected_graphs.clone(),
projected_graph_artifacts: self.projected_graph_artifacts.clone(),
stable_id_mapping: self.stable_id_mapping.clone(),
initial_import_source_fingerprint: self.initial_import_source_fingerprint.clone(),
search_projection_database_identity: self.search_projection_database_identity,
search_projection_change_log_start_epoch: self.search_projection_change_log_start_epoch,
search_projection_graph_changes: self.search_projection_graph_changes.clone(),
search_projection_change_log_retained_bytes: self
.search_projection_change_log_retained_bytes,
max_search_projection_change_log_entries: self.max_search_projection_change_log_entries,
max_search_projection_change_log_bytes: self.max_search_projection_change_log_bytes,
search_projection_primary_key_capture_limits: self
.search_projection_primary_key_capture_limits,
source_scan_manifest: self.source_scan_manifest.clone(),
storage_recovery_report: self.storage_recovery_report.clone(),
canonical_base: self.canonical_base.clone(),
canonical_adjacency: self.canonical_adjacency.clone(),
persistent_property_projection: self.persistent_property_projection.clone(),
graph_index_read_metrics: Arc::clone(&self.graph_index_read_metrics),
canonical_base_out_of_core: self.canonical_base_out_of_core,
node_tombstones: self.node_tombstones.clone(),
relationship_tombstones: self.relationship_tombstones.clone(),
residency_mode: self.residency_mode,
auto_materialize_checkpoint_bytes: self.auto_materialize_checkpoint_bytes,
max_out_of_core_delta_bytes: self.max_out_of_core_delta_bytes,
post_wal_apply_poisoned: Arc::clone(&self.post_wal_apply_poisoned),
integrity_poisoned: Arc::clone(&self.integrity_poisoned),
relational_state: self.relational_state.clone(),
append_state: self.append_state.clone(),
append_mutation_limits: self.append_mutation_limits,
append_publication_config: self.append_publication_config,
append_generation_reader: self.append_generation_reader.clone(),
relational_mutation_limits: self.relational_mutation_limits,
relational_overflow_config: self.relational_overflow_config,
columnar_shadow: self.columnar_shadow.clone(),
relational_index_shadow: self
.relational_index_shadow
.snapshot_at_epoch(self.commit_epoch),
relational_row_pages: self
.relational_row_pages
.snapshot_at_epoch(self.commit_epoch),
projection_generations: None,
runtime_governor: self.runtime_governor.clone(),
branch_lease: self.branch_lease.clone(),
branch_root_pin: self.branch_root_pin.clone(),
branch_runtime_owner: self.branch_runtime_owner.clone(),
snapshot_file_context: self.file_descriptor_context(),
durable: None,
checkpoint_allocations: self.checkpoint_allocations.clone(),
}
}
pub fn set_runtime_governor(&mut self, governor: hawdb_qos::RuntimeGovernor) {
self.runtime_governor = Some(Arc::new(RuntimeGovernorBackgroundAdmission(governor)));
}
pub fn register_projected_graph(
&mut self,
name: &str,
definition: ProjectedGraphDefinition,
) -> Result<()> {
self.append_durable_wal_single(WalOp::ProjectGraph {
name: name.to_string(),
node_labels: definition.node_labels.clone(),
rel_types: definition.rel_types.clone(),
relationship_predicates: definition.relationship_predicates.clone(),
})?;
self.apply_project_graph_definition(name.to_string(), definition)?;
self.finish_non_relational_commit();
Ok(())
}
pub fn projected_graph_definition(&self, name: &str) -> Option<&ProjectedGraphDefinition> {
self.projected_graphs.get(name)
}
pub fn projected_graph_artifact(
&self,
name: &str,
definition: &ProjectedGraphDefinition,
) -> Option<ProjectedGraph> {
let artifact = self.projected_graph_artifacts.get(name)?;
(artifact.commit_epoch == self.commit_epoch && &artifact.definition == definition).then(
|| {
ProjectedGraph::from_parts(
artifact.data.nodes.clone(),
artifact.data.csr_offsets.clone(),
artifact.data.csr_targets.clone(),
artifact.data.csc_offsets.clone(),
artifact.data.csc_sources.clone(),
)
.expect("validated projected graph artifact")
},
)
}
pub fn projected_graph_statuses(&self) -> Vec<ProjectedGraphStatus> {
self.projected_graphs
.iter()
.map(|(name, definition)| {
let artifact = self.projected_graph_artifacts.get(name);
let reusable = artifact.is_some_and(|artifact| {
artifact.commit_epoch == self.commit_epoch && artifact.definition == *definition
});
ProjectedGraphStatus {
name: name.clone(),
node_labels: definition.node_labels.clone(),
rel_types: definition.rel_types.clone(),
projection_epoch: artifact.map(|artifact| artifact.projection_epoch),
commit_epoch: artifact.map(|artifact| artifact.commit_epoch),
node_count: artifact.map(|artifact| artifact.data.node_count()),
edge_count: artifact.map(|artifact| artifact.data.edge_count()),
reusable,
}
})
.collect()
}
fn next_projection_epoch(&self) -> u64 {
let checkpoint_epoch = self
.durable
.as_ref()
.map(|durable| durable.checkpoint_epoch)
.unwrap_or_default();
let artifact_epoch = self
.projected_graph_artifacts
.values()
.map(|artifact| artifact.projection_epoch)
.max()
.unwrap_or_default();
checkpoint_epoch.max(artifact_epoch) + 1
}
}
#[doc(hidden)]
pub fn sync_parent_dir(path: &Path) -> Result<()> {
sync_parent_directory(path)?;
Ok(())
}
#[doc(hidden)]
pub use hawdb_storage::schema::{ensure_table_descriptor, reserve_schema_maintenance_budget};
fn validate_property_descriptor(
catalog: &Catalog,
store: &GraphStore,
table_id: TableId,
property: &str,
value_type: PropertyType,
nullable: bool,
) -> Result<()> {
validate_property_descriptor_with_table_state(
catalog, store, table_id, property, value_type, nullable, false,
)
}
fn validate_property_descriptor_with_table_state(
catalog: &Catalog,
store: &GraphStore,
table_id: TableId,
property: &str,
value_type: PropertyType,
nullable: bool,
force: bool,
) -> Result<()> {
let Some(table) = catalog.table_descriptor(table_id) else {
return Err(HawDBError::Storage(format!(
"property schema references missing table {}",
table_id.0
)));
};
if !force && table.state != SchemaObjectState::Public {
return Ok(());
}
match table.kind {
TableKind::Node => {
let Some(label_id) = catalog.label_id(&table.name) else {
return Ok(());
};
for node in store.nodes.values() {
if node.labels.contains(&label_id) {
validate_property_schema_value(
&table.name,
property,
value_type,
nullable,
node.properties.get(property),
&format!("node {}", node.id.0),
)?;
}
}
}
TableKind::Relationship => {
let Some(rel_type_id) = catalog.rel_type_id(&table.name) else {
return Ok(());
};
for relationship in store.relationships.values() {
if relationship.rel_type == rel_type_id {
validate_property_schema_value(
&table.name,
property,
value_type,
nullable,
relationship.properties.get(property),
&format!("relationship {}", relationship.id.0),
)?;
}
}
}
}
Ok(())
}
fn validate_table_descriptor(
catalog: &Catalog,
store: &GraphStore,
table_id: TableId,
) -> Result<()> {
for property in catalog
.property_descriptors()
.filter(|property| property.table_id == table_id)
{
if property.state == SchemaObjectState::Gc {
continue;
}
validate_property_descriptor_with_table_state(
catalog,
store,
table_id,
&property.name,
property.value_type,
property.nullable,
true,
)?;
}
Ok(())
}
#[doc(hidden)]
pub use hawdb_storage::wal::apply_wal_op_to_snapshot;
fn encode_projected_graph_artifacts(
catalog: &Catalog,
store: &GraphStore,
projection_epoch: u64,
) -> String {
hawdb_storage::projection::artifact::encode_projected_graph_artifacts(
projection_epoch,
store.commit_epoch,
store.projected_graphs.iter().map(|(name, definition)| {
let graph = projected_graph_from_definition(catalog, store, definition);
let data = ProjectedGraphArtifactData::new(
graph.nodes().to_vec(),
graph.csr_offsets().to_vec(),
graph.csr_targets().to_vec(),
graph.csc_offsets().to_vec(),
graph.csc_sources().to_vec(),
)
.expect("analytics graph has valid adjacency arrays");
(name.as_str(), definition, data)
}),
)
}
fn encode_projected_graph_artifacts_with_work_context(
catalog: &Catalog,
store: &GraphStore,
projection_epoch: u64,
work: &crate::background::CheckpointWorkContext,
) -> Result<hawdb_storage::projection::artifact::CheckpointProjectedGraphText> {
hawdb_storage::projection::artifact::encode_projected_graph_artifacts_with_work_context(
projection_epoch,
store.commit_epoch,
store.projected_graphs.iter().map(|(name, definition)| {
let data =
checkpoint_projected_graph_from_definition(catalog, store, definition, work)?;
Ok((name.as_str(), definition, data))
}),
work,
)
}
fn projected_graph_from_definition(
catalog: &Catalog,
store: &GraphStore,
definition: &ProjectedGraphDefinition,
) -> ProjectedGraph {
if definition.node_labels.is_empty() && definition.rel_types.is_empty() {
return ProjectedGraph::from_store(store, None);
}
let label_ids = definition
.node_labels
.iter()
.filter_map(|label| catalog.label_id(label))
.collect::<Vec<_>>();
if !definition.node_labels.is_empty() && label_ids.is_empty() {
return ProjectedGraph::empty();
}
let rel_type_ids = definition
.rel_types
.iter()
.filter_map(|rel_type| catalog.rel_type_id(rel_type))
.collect::<Vec<_>>();
if !definition.rel_types.is_empty() && rel_type_ids.is_empty() {
if label_ids.is_empty() {
return ProjectedGraph::from_store_without_edges(store);
}
return ProjectedGraph::from_store_labels_without_edges(store, &label_ids);
}
let relationship_predicates = definition
.relationship_predicates
.iter()
.filter_map(|(rel_type, predicate)| {
catalog
.rel_type_id(rel_type)
.map(|rel_type_id| (rel_type_id, predicate))
})
.collect::<BTreeMap<_, _>>();
ProjectedGraph::try_from_store_labels_and_rel_types_with_filters_and_layout(
store,
&label_ids,
&rel_type_ids,
|_| true,
|relationship| {
relationship_predicates
.get(&relationship.rel_type)
.is_none_or(|predicate| predicate.matches(&relationship.properties))
},
crate::analytics::ProjectionLayout::Bidirectional,
crate::analytics::ProjectionMemoryBudget::unlimited(),
)
.expect("unlimited checkpoint projection is admitted")
}
fn validate_changed_node_uniqueness(
store: &GraphStore,
catalog: &Catalog,
changes: &BTreeMap<NodeId, Option<NodeRecord>>,
) -> Result<()> {
for constraint in catalog.unique_constraints() {
let crate::schema::ConstraintSubject::Node(label_id) = constraint.subject else {
continue;
};
let mut changed_values = BTreeMap::<Value, NodeId>::new();
for node in changes.values().flatten() {
if !node.labels.contains(&label_id) {
continue;
}
let Some(value) = node.properties.get(&constraint.property) else {
continue;
};
if value == &Value::Null {
continue;
}
if let Some(previous) = changed_values.insert(value.clone(), node.id) {
let label = catalog.label_name(label_id).unwrap_or("<unknown>");
return Err(HawDBError::Storage(format!(
"unique constraint violation on :{label}({}) for nodes {} and {}",
constraint.property, previous.0, node.id.0
)));
}
}
for (value, changed_id) in changed_values {
let mut violation = None;
store.visit_nodes_owned(Some(label_id), |node| {
if changes.contains_key(&node.id) {
return GraphScanControl::Continue;
}
if node.properties.get(&constraint.property) == Some(&value) {
violation = Some(node.id);
GraphScanControl::Stop
} else {
GraphScanControl::Continue
}
})?;
if let Some(existing_id) = violation {
let label = catalog.label_name(label_id).unwrap_or("<unknown>");
return Err(HawDBError::Storage(format!(
"unique constraint violation on :{label}({}) for nodes {} and {}",
constraint.property, existing_id.0, changed_id.0
)));
}
}
}
Ok(())
}
fn validate_changed_relationship_uniqueness(
store: &GraphStore,
catalog: &Catalog,
changes: &BTreeMap<RelId, Option<RelRecord>>,
) -> Result<()> {
for constraint in catalog.relationship_unique_constraints() {
let crate::schema::ConstraintSubject::Relationship(rel_type_id) = constraint.subject else {
continue;
};
let mut changed_values = BTreeMap::<Value, RelId>::new();
for relationship in changes.values().flatten() {
if relationship.rel_type != rel_type_id {
continue;
}
let Some(value) = relationship.properties.get(&constraint.property) else {
continue;
};
if value == &Value::Null {
continue;
}
if let Some(previous) = changed_values.insert(value.clone(), relationship.id) {
let rel_type = catalog.rel_type_name(rel_type_id).unwrap_or("<unknown>");
return Err(HawDBError::Storage(format!(
"relationship unique constraint violation on :{rel_type}({}) for relationships {} and {}",
constraint.property, previous.0, relationship.id.0
)));
}
}
for (value, changed_id) in changed_values {
let mut violation = None;
store.visit_relationships_owned(Some(rel_type_id), |relationship| {
if changes.contains_key(&relationship.id) {
return GraphScanControl::Continue;
}
if relationship.properties.get(&constraint.property) == Some(&value) {
violation = Some(relationship.id);
GraphScanControl::Stop
} else {
GraphScanControl::Continue
}
})?;
if let Some(existing_id) = violation {
let rel_type = catalog.rel_type_name(rel_type_id).unwrap_or("<unknown>");
return Err(HawDBError::Storage(format!(
"relationship unique constraint violation on :{rel_type}({}) for relationships {} and {}",
constraint.property, existing_id.0, changed_id.0
)));
}
}
}
Ok(())
}
fn validate_unique_property_streaming(
store: &GraphStore,
catalog: &Catalog,
label_id: LabelId,
property: &str,
) -> Result<()> {
let mut validation_error = None;
store.visit_nodes_owned(Some(label_id), |node| {
let Some(value) = node.properties.get(property).cloned() else {
return GraphScanControl::Continue;
};
if value == Value::Null {
return GraphScanControl::Continue;
}
let mut duplicate = None;
match store.visit_nodes_owned(Some(label_id), |candidate| {
if candidate.id > node.id && candidate.properties.get(property) == Some(&value) {
duplicate = Some(candidate.id);
GraphScanControl::Stop
} else {
GraphScanControl::Continue
}
}) {
Ok(_) => {}
Err(error) => {
validation_error = Some(error);
return GraphScanControl::Stop;
}
}
if let Some(duplicate) = duplicate {
let label = catalog.label_name(label_id).unwrap_or("<unknown>");
validation_error = Some(HawDBError::Storage(format!(
"unique constraint violation on :{label}({property}) for nodes {} and {}",
node.id.0, duplicate.0
)));
GraphScanControl::Stop
} else {
GraphScanControl::Continue
}
})?;
validation_error.map_or(Ok(()), Err)
}
fn validate_unique_relationship_property_streaming(
store: &GraphStore,
catalog: &Catalog,
rel_type_id: RelTypeId,
property: &str,
) -> Result<()> {
let mut validation_error = None;
store.visit_relationships_owned(Some(rel_type_id), |relationship| {
let Some(value) = relationship.properties.get(property).cloned() else {
return GraphScanControl::Continue;
};
if value == Value::Null {
return GraphScanControl::Continue;
}
let mut duplicate = None;
match store.visit_relationships_owned(Some(rel_type_id), |candidate| {
if candidate.id > relationship.id
&& candidate.properties.get(property) == Some(&value)
{
duplicate = Some(candidate.id);
GraphScanControl::Stop
} else {
GraphScanControl::Continue
}
}) {
Ok(_) => {}
Err(error) => {
validation_error = Some(error);
return GraphScanControl::Stop;
}
}
if let Some(duplicate) = duplicate {
let rel_type = catalog.rel_type_name(rel_type_id).unwrap_or("<unknown>");
validation_error = Some(HawDBError::Storage(format!(
"relationship unique constraint violation on :{rel_type}({property}) for relationships {} and {}",
relationship.id.0, duplicate.0
)));
GraphScanControl::Stop
} else {
GraphScanControl::Continue
}
})?;
validation_error.map_or(Ok(()), Err)
}
fn generated_stable_id(kind: &str, physical_id: u64) -> Value {
Value::Map(BTreeMap::from([
(
"source".to_string(),
Value::String("hawdb-stable-id-v1".to_string()),
),
("kind".to_string(), Value::String(kind.to_string())),
(
"physical_id".to_string(),
Value::String(physical_id.to_string()),
),
]))
}
fn optional_label_id(catalog: &Catalog, label: &str) -> Option<LabelId> {
if label.is_empty() {
None
} else {
catalog.label_id(label)
}
}
fn pending_node_index(pending_nodes: &[PendingNode], id: NodeId) -> Option<usize> {
pending_nodes
.binary_search_by_key(&id, |(pending_id, _, _)| *pending_id)
.ok()
}
#[allow(clippy::too_many_arguments)]
fn apply_set_relationship_properties_mutation(
store: &GraphStore,
catalog: &Catalog,
ops: &mut Vec<WalOp>,
rows: &mut Vec<BTreeMap<String, Value>>,
pending_nodes: &[PendingNode],
pending_relationships: &mut [PendingRelationship],
update: RelationshipPropertiesUpdate,
limits: MutationLimits,
) -> Result<()> {
let (Some(source_label_id), Some(target_label_id), Some(rel_type_id)) = (
catalog.label_id(&update.source_label),
catalog.label_id(&update.target_label),
catalog.rel_type_id(&update.rel_type),
) else {
return Ok(());
};
let source_ids = store
.matching_node_ids_with_pending_bounded(
Some(source_label_id),
update.filter.as_ref(),
pending_nodes,
remaining_mutation_affected_rows(rows.len(), limits)?,
"max_mutation_affected_rows",
)?
.into_iter()
.collect::<BTreeSet<_>>();
for relationship in store.relationship_records_owned() {
let relationship = relationship?;
if relationship.rel_type != rel_type_id || !source_ids.contains(&relationship.source) {
continue;
}
if update
.rel_filter
.as_ref()
.map(|filter| {
!property_filter_matches(filter, relationship.id.0, &relationship.properties)
})
.unwrap_or(false)
{
continue;
}
let target_matches = store
.node_owned(relationship.target)?
.map(|target| {
target.labels.contains(&target_label_id)
&& update
.target_filter
.as_ref()
.map(|filter| {
property_filter_matches(filter, target.id.0, &target.properties)
})
.unwrap_or(true)
})
.unwrap_or(false);
if !target_matches {
continue;
}
ensure_additional_mutation_limits(
ops.len(),
rows.len(),
update.assignments.len(),
1,
limits,
)?;
for assignment in &update.assignments {
ops.push(WalOp::SetRelationshipProperty {
id: relationship.id,
property: assignment.property.clone(),
value: assignment.value.clone(),
});
}
rows.push(BTreeMap::from([(
"rel_id".to_string(),
Value::Int(relationship.id.0 as i64),
)]));
}
for (relationship_id, source, target, pending_rel_type_id, properties) in pending_relationships
{
if *pending_rel_type_id != rel_type_id || !source_ids.contains(source) {
continue;
}
if update
.rel_filter
.as_ref()
.map(|filter| !property_filter_matches(filter, relationship_id.0, properties))
.unwrap_or(false)
{
continue;
}
if !node_matches_label_and_filter(
store,
pending_nodes,
*target,
target_label_id,
update.target_filter.as_ref(),
)? {
continue;
}
ensure_additional_mutation_limits(ops.len(), rows.len(), 0, 1, limits)?;
for assignment in &update.assignments {
properties.insert(assignment.property.clone(), assignment.value.clone());
apply_pending_relationship_property(
ops,
*relationship_id,
&assignment.property,
assignment.value.clone(),
);
}
rows.push(BTreeMap::from([(
"rel_id".to_string(),
Value::Int(relationship_id.0 as i64),
)]));
}
Ok(())
}
fn node_matches_label_and_filter(
store: &GraphStore,
pending_nodes: &[PendingNode],
id: NodeId,
label_id: LabelId,
filter: Option<&PropertyFilter>,
) -> Result<bool> {
if let Some(node) = store.node_owned(id)? {
return Ok(node.labels.contains(&label_id)
&& filter
.map(|filter| property_filter_matches(filter, id.0, &node.properties))
.unwrap_or(true));
}
Ok(pending_node_index(pending_nodes, id)
.map(|index| &pending_nodes[index])
.map(|(_, pending_label_id, properties)| {
*pending_label_id == label_id
&& filter
.map(|filter| property_filter_matches(filter, id.0, properties))
.unwrap_or(true)
})
.unwrap_or(false))
}
fn relationships_with_pending_matching_bounded(
store: &GraphStore,
pending_nodes: &[PendingNode],
pending_relationships: &[PendingRelationship],
request: RelationshipMatchRequest<'_>,
max_relationships: usize,
) -> Result<Vec<RelationshipCandidate>> {
let mut relationships = Vec::new();
for relationship in store.relationship_records_owned() {
let relationship = relationship?;
if relationship.rel_type != request.rel_type_id
|| !properties_contain_all(&relationship.properties, request.rel_properties)
|| !node_matches_optional_label_and_filter_checked(
store,
pending_nodes,
relationship.source,
request.source_label_id,
request.source_filter,
)?
|| !node_matches_optional_label_and_filter_checked(
store,
pending_nodes,
relationship.target,
request.target_label_id,
request.target_filter,
)?
{
continue;
}
relationships.push(RelationshipCandidate {
source: relationship.source,
target: relationship.target,
properties: relationship.properties.clone(),
});
if relationships.len() > max_relationships {
return Err(HawDBError::Execution(format!(
"mutation would exceed max_mutation_affected_rows {max_relationships}"
)));
}
}
for (_, source, target, pending_rel_type_id, properties) in pending_relationships {
if *pending_rel_type_id != request.rel_type_id
|| !properties_contain_all(properties, request.rel_properties)
|| !node_matches_optional_label_and_filter_checked(
store,
pending_nodes,
*source,
request.source_label_id,
request.source_filter,
)?
|| !node_matches_optional_label_and_filter_checked(
store,
pending_nodes,
*target,
request.target_label_id,
request.target_filter,
)?
{
continue;
}
relationships.push(RelationshipCandidate {
source: *source,
target: *target,
properties: properties.clone(),
});
if relationships.len() > max_relationships {
return Err(HawDBError::Execution(format!(
"mutation would exceed max_mutation_affected_rows {max_relationships}"
)));
}
}
Ok(relationships)
}
fn node_matches_optional_label_and_filter_checked(
store: &GraphStore,
pending_nodes: &[PendingNode],
id: NodeId,
label_id: Option<LabelId>,
filter: Option<&PropertyFilter>,
) -> Result<bool> {
if let Some(node) = store.node_owned(id)? {
return Ok(
label_id.is_none_or(|label_id| node.labels.contains(&label_id))
&& filter
.is_none_or(|filter| property_filter_matches(filter, id.0, &node.properties)),
);
}
Ok(pending_node_index(pending_nodes, id)
.map(|index| &pending_nodes[index])
.is_some_and(|(_, pending_label_id, properties)| {
label_id.is_none_or(|label_id| *pending_label_id == label_id)
&& filter.is_none_or(|filter| property_filter_matches(filter, id.0, properties))
}))
}
fn apply_pending_relationship_property(ops: &mut [WalOp], id: RelId, property: &str, value: Value) {
for op in ops {
if let WalOp::CreateRelationship {
id: create_id,
properties,
..
} = op
&& *create_id == id
{
properties.insert(property.to_string(), value);
break;
}
}
}
fn pending_relationship_ids_for_nodes(
pending_relationships: &[PendingRelationship],
node_ids: &[NodeId],
) -> Vec<RelId> {
let node_ids = node_ids.iter().copied().collect::<BTreeSet<_>>();
pending_relationships
.iter()
.filter_map(|(relationship_id, source, target, _, _)| {
(node_ids.contains(source) || node_ids.contains(target)).then_some(*relationship_id)
})
.collect()
}
fn remove_pending_node(ops: &mut Vec<WalOp>, pending_nodes: &mut PendingNodes, id: NodeId) {
pending_nodes.remove(id);
ops.retain(|op| match op {
WalOp::CreateNode { id: node_id, .. } | WalOp::SetNodeProperty { id: node_id, .. } => {
*node_id != id
}
_ => true,
});
}
fn remove_pending_relationship(
ops: &mut Vec<WalOp>,
pending_relationships: &mut Vec<PendingRelationship>,
id: RelId,
) {
pending_relationships.retain(|(relationship_id, _, _, _, _)| *relationship_id != id);
ops.retain(|op| match op {
WalOp::CreateRelationship {
id: relationship_id,
..
}
| WalOp::SetRelationshipProperty {
id: relationship_id,
..
} => *relationship_id != id,
_ => true,
});
}
fn merge_relationship_row(
source: NodeId,
relationship: RelId,
target: NodeId,
created: bool,
) -> BTreeMap<String, Value> {
BTreeMap::from([
("source_node_id".to_string(), Value::Int(source.0 as i64)),
("target_node_id".to_string(), Value::Int(target.0 as i64)),
("rel_id".to_string(), Value::Int(relationship.0 as i64)),
("created".to_string(), Value::Bool(created)),
])
}
#[doc(hidden)]
pub fn read_durable_text(path: &Path, name: &str) -> Result<String> {
let bytes = fs::read(path)?;
read_durable_text_bytes(&bytes, name)
}
fn validate_search_projection_checkpoint_changes<'a>(
start_epoch: u64,
checkpoint_commit_epoch: u64,
changes: impl IntoIterator<Item = &'a SearchProjectionGraphChange>,
) -> Result<()> {
if start_epoch > checkpoint_commit_epoch {
return Err(HawDBError::Storage(format!(
"search projection change log start epoch {start_epoch} exceeds checkpoint commit epoch {checkpoint_commit_epoch}"
)));
}
let mut previous_epoch = start_epoch;
for change in changes {
if change.commit_epoch <= previous_epoch {
return Err(HawDBError::Storage(format!(
"search projection change commit epoch {} is not greater than previous epoch {previous_epoch}",
change.commit_epoch
)));
}
if change.commit_epoch > checkpoint_commit_epoch {
return Err(HawDBError::Storage(format!(
"search projection change commit epoch {} exceeds checkpoint commit epoch {checkpoint_commit_epoch}",
change.commit_epoch
)));
}
if !change
.upsert_node_ids
.windows(2)
.all(|pair| pair[0] < pair[1])
{
return Err(HawDBError::Storage(format!(
"search projection change at commit epoch {} has unordered or duplicate upsert node ids",
change.commit_epoch
)));
}
if !change
.delete_document_ids
.windows(2)
.all(|pair| pair[0] < pair[1])
{
return Err(HawDBError::Storage(format!(
"search projection change at commit epoch {} has unordered or duplicate delete document ids",
change.commit_epoch
)));
}
if let hawdb_storage::relational::RelationalPrimaryKeyChangeCapture::Captured {
tables,
..
} = &change.relational_primary_key_changes
{
if !tables.windows(2).all(|pair| pair[0].table < pair[1].table) {
return Err(HawDBError::Storage(format!(
"search projection change at commit epoch {} has unordered or duplicate relational tables",
change.commit_epoch
)));
}
for table in tables {
if table.primary_keys.is_empty()
|| !table.primary_keys.windows(2).all(|pair| pair[0] < pair[1])
{
return Err(HawDBError::Storage(format!(
"search projection change at commit epoch {} has empty, unordered, or duplicate primary keys for table {}",
change.commit_epoch, table.table
)));
}
}
}
previous_epoch = change.commit_epoch;
}
Ok(())
}
#[doc(hidden)]
pub fn checksum_bytes(bytes: &[u8]) -> u64 {
checksum_u64(bytes)
}
use hawdb_storage::artifact_binding::verify_integrity;
fn verify_file_integrity(
path: &Path,
expected_len: u64,
expected_checksum: u64,
expected_sha256: Sha256Digest,
artifact: &str,
) -> Result<()> {
let (actual_len, actual_checksum, actual_sha256) = file_checksum(path)?;
if actual_len != expected_len {
return Err(HawDBError::Storage(format!(
"{artifact} encoded length mismatch: expected {expected_len}, got {actual_len}"
)));
}
if actual_checksum != expected_checksum {
return Err(HawDBError::Storage(format!(
"{artifact} CRC32C mismatch: expected {expected_checksum}, got {actual_checksum}"
)));
}
if actual_sha256 != expected_sha256 {
return Err(HawDBError::Storage(format!(
"{artifact} SHA-256 mismatch: expected {expected_sha256}, got {actual_sha256}"
)));
}
Ok(())
}
fn elapsed_micros(started: std::time::Instant) -> u64 {
started.elapsed().as_micros().min(u64::MAX as u128) as u64
}
fn estimated_node_record_bytes(node: &NodeRecord) -> u64 {
estimated_node_record_bytes_with_visit(node, &mut || Ok::<_, std::convert::Infallible>(()))
.unwrap_or_else(|never| match never {})
}
fn estimated_node_record_bytes_with_visit<E>(
node: &NodeRecord,
visit: &mut impl FnMut() -> std::result::Result<(), E>,
) -> std::result::Result<u64, E> {
visit()?;
Ok(32u64
.saturating_add((node.labels.len() as u64).saturating_mul(4))
.saturating_add(crate::mutation::estimated_properties_bytes_with_visit(
&node.properties,
visit,
)?))
}
fn estimated_relationship_record_bytes(relationship: &RelRecord) -> u64 {
estimated_relationship_record_bytes_with_visit(relationship, &mut || {
Ok::<_, std::convert::Infallible>(())
})
.unwrap_or_else(|never| match never {})
}
fn estimated_relationship_record_bytes_with_visit<E>(
relationship: &RelRecord,
visit: &mut impl FnMut() -> std::result::Result<(), E>,
) -> std::result::Result<u64, E> {
visit()?;
Ok(
40u64.saturating_add(crate::mutation::estimated_properties_bytes_with_visit(
&relationship.properties,
visit,
)?),
)
}
impl hawdb_storage::graph_engine::GraphReadEngine for GraphStore {
fn storage_version(&self) -> &'static str {
GraphStore::storage_version(self)
}
fn commit_epoch(&self) -> u64 {
GraphStore::commit_epoch(self)
}
fn storage_handle_poisoned(&self) -> bool {
GraphStore::storage_handle_poisoned(self)
}
fn published_read_view(&self) -> hawdb_storage::read_view::PublishedReadView {
GraphStore::published_read_view(self)
}
fn basic_statistics(&self) -> hawdb_core::BasicGraphStatistics {
GraphStore::basic_statistics(self)
}
fn statistics(&self, catalog: &hawdb_core::Catalog) -> hawdb_core::GraphStatistics {
GraphStore::statistics(self, catalog)
}
fn supports_ordered_node_range(&self, label: LabelId, property: &str) -> bool {
self.canonical_base.is_none()
|| self
.persistent_property_projection
.as_ref()
.is_some_and(|projection| {
projection.manifest().supports(
label,
property,
PersistentPropertyProjectionKind::Range,
)
})
}
fn storage_recovery_report(&self) -> hawdb_storage::projection::StorageRecoveryReport {
GraphStore::storage_recovery_report(self)
}
fn segment_cache_snapshot(&self) -> Option<hawdb_storage::cache::SegmentCacheSnapshot> {
GraphStore::segment_cache_snapshot(self)
}
fn storage_residency_report(&self) -> hawdb_storage::residency::StorageResidencyReport {
GraphStore::storage_residency_report(self)
}
fn adjacency_consistency_report(
&self,
) -> hawdb_storage::consistency::AdjacencyConsistencyReport {
GraphStore::adjacency_consistency_report(self)
}
fn adjacency_consolidation_plan(
&self,
) -> hawdb_storage::consistency::AdjacencyConsolidationPlan {
GraphStore::adjacency_consolidation_plan(self)
}
fn degree_statistics_consistency_report(
&self,
) -> hawdb_storage::consistency::DegreeStatisticsConsistencyReport {
GraphStore::degree_statistics_consistency_report(self)
}
fn property_index_consistency_report(
&self,
catalog: &hawdb_core::Catalog,
) -> hawdb_storage::consistency::PropertyIndexConsistencyReport {
GraphStore::property_index_consistency_report(self, catalog)
}
fn columnar_shadow_checkpoint_report(
&self,
) -> Option<hawdb_storage::column_group::shadow::ColumnarShadowCheckpointReport> {
GraphStore::columnar_shadow_checkpoint_report(self)
}
fn columnar_shadow_recovery_status(
&self,
) -> hawdb_storage::column_group::shadow::ColumnarShadowRecoveryStatus {
GraphStore::columnar_shadow_recovery_status(self)
}
fn projected_graph_statuses(&self) -> Vec<hawdb_storage::projection::ProjectedGraphStatus> {
GraphStore::projected_graph_statuses(self)
}
fn storage_pressure_snapshot(
&self,
oldest_reader_commit_epoch: Option<u64>,
) -> hawdb_storage::pressure::StoragePressureSnapshot {
GraphStore::storage_pressure_snapshot(self, oldest_reader_commit_epoch)
}
fn append_storage_residency_report(
&self,
) -> hawdb_storage::append_table::AppendStorageResidencyReport {
GraphStore::append_storage_residency_report(self)
}
fn columnar_shadow_admission_bytes(&self) -> u64 {
GraphStore::columnar_shadow_admission_bytes(self)
}
fn relational_index_recovery_report(
&self,
) -> Option<&hawdb_storage::relational::RelationalIndexRecoveryReport> {
GraphStore::relational_index_recovery_report(self)
}
fn relational_index_shadow_checkpoint_report(
&self,
) -> Option<&hawdb_storage::relational::RelationalIndexShadowCheckpointReport> {
GraphStore::relational_index_shadow_checkpoint_report(self)
}
fn relational_index_shadow_recovery_status(
&self,
) -> &hawdb_storage::relational::RelationalIndexShadowRecoveryStatus {
GraphStore::relational_index_shadow_recovery_status(self)
}
fn search_projection_changefeed_status(
&self,
) -> hawdb_storage::projection::SearchProjectionChangefeedStatus {
GraphStore::search_projection_changefeed_status(self)
}
fn append_table_schema(
&self,
table: &str,
) -> Option<&hawdb_storage::append_table::AppendTableSchema> {
GraphStore::append_table_schema(self, table)
}
fn initial_import_source_fingerprint(&self) -> Option<&str> {
GraphStore::initial_import_source_fingerprint(self)
}
fn read_append_partition_bounded(
&self,
table: &str,
partition: &hawdb_storage::relational::RelationalKey,
after: Option<&hawdb_storage::relational::RelationalKey>,
max_rows: usize,
max_payload_bytes: usize,
) -> hawdb_core::Result<hawdb_storage::append_table::AppendSegmentReadOutput> {
GraphStore::read_append_partition_bounded(
self,
table,
partition,
after,
max_rows,
max_payload_bytes,
)
}
fn node_records_owned(&self) -> hawdb_storage::graph_overlay::GraphNodeIterator {
GraphStore::node_records_owned(self)
}
fn relationship_records_owned(
&self,
) -> hawdb_storage::graph_overlay::GraphRelationshipIterator {
GraphStore::relationship_records_owned(self)
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
#[path = "store/snapshot_retention_tests.rs"]
mod snapshot_retention_tests;
#[cfg(test)]
mod tests {
mod id_eq_mutation;
mod id_in_mutation;
mod pending_node_ids;
#[test]
fn derived_repair_borrows_existing_custom_descriptor_budget() {
let path = unique_test_dir("derived_repair_custom_descriptors");
let mut catalog = Catalog::default();
let mut store = GraphStore::open_with_durability_and_replay_config(
&path,
&mut catalog,
DurabilityPolicy::default(),
WalReplayConfig {
max_open_files: 64,
strict_capacity_limit: true,
..WalReplayConfig::default()
},
)
.unwrap();
store.checkpoint(&catalog).unwrap();
let domain =
crate::file_descriptors::ProjectFileDescriptors::acquire_existing(&path, 64).unwrap();
drop(store);
let (repaired, _) =
GraphStore::open_for_derived_repair(&path, WalReplayConfig::default()).unwrap();
assert_eq!(repaired.file_descriptor_metrics().unwrap().limit, 64);
assert!(matches!(
crate::file_descriptors::ProjectFileDescriptors::acquire_existing(&path, 256),
Err(HawDBError::FileDescriptors(
hawdb_core::error::FileDescriptorError::ConfigurationConflict {
configured: 64,
requested: 256
}
))
));
drop(repaired);
assert_eq!(domain.metrics().open, 0);
drop(domain);
fs::remove_dir_all(path).unwrap();
}
use super::{
canonical_adjacency_artifact_generation_file, canonical_manifest_generation_file,
checksum_bytes, compute_statistics, encode_durable_text, estimated_properties_bytes,
property_projection_artifact_generation_file, property_spill_artifact_generation_file,
read_durable_text, restore_storage_backup, retain_supported_property_statistics,
set_checkpoint_failpoint, set_generation_reclamation_remove_failpoint,
set_wal_apply_failpoint, source_scan, AdjacencyConsolidationPlan, AdjacencyDirection,
AdjacencyGroupStats, AdjacencyLayout, CheckpointPublishStage, ConnectedNodesCreate,
CowSegmentedMap, DatabaseDoctor, DegreeStatisticsEntry, DegreeStatisticsKey,
DurableCompression, DurableManifest, GraphScanControl, GraphStore, HawDBError, NodeId,
NodeRecord, NodeSetAssignment, NodeSetValue, OrderedAdjacencyEntry,
PersistentGraphIndexClass, ProjectedGraphDefinition, PropertyFilter, RelId, RelRecord,
RelTypeId, RelationalIndexStorageResidencyReport, RelationalRowStorageResidencyReport,
RelationshipDeleteRequest, ScanPruningStrategy, ScanPruningTargetKind,
SearchProjectionGraphChange, SourceScanCandidateLimits, SourceScanCandidateRead,
SourceScanCandidateVisit, SourceScanRow, StorageResidencyReport, WalDoctorOptions, WalOp,
COW_MAP_TARGET_SEGMENT_BYTES, DENSE_ADJACENCY_DEGREE_THRESHOLD, DURABLE_COMPRESSION_HEADER,
MANIFEST_FILE,
};
use crate::schema::{Catalog, GraphStatistics, LabelId, PropertyType, TableKind};
use crate::value::Value;
use hawdb_integrity::integrity_digest;
use hawdb_storage::{
config::{DurabilityPolicy, StorageResidencyMode, WalReplayConfig},
mutation::{GraphMutation, MutationLimits, RelationshipPropertyUpdate},
projection::ProjectedRelationshipPredicate,
relational::{
RelationalColumnSchema, RelationalHydrationBudget, RelationalInsertMode, RelationalKey,
RelationalRow, RelationalScalarType, RelationalTableSchema, RelationalTransaction,
RelationalValue, RelationalWrite,
},
scan::{ScanPredicate, ScanSegmentAccessPlan, ScanSegmentFallback, ScanSegmentManifest},
};
use std::any::TypeId;
use std::collections::{BTreeMap, BTreeSet};
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::num::{NonZeroU64, NonZeroUsize};
#[test]
fn current_epoch_version_baseline_bounds_history_and_preserves_older_sources() {
use crate::cow::CowPageWeight;
use crate::version::{VersionIndex, VersionKey, VersionStamp};
for durable in [false, true] {
let path = unique_test_dir("current_epoch_version_baseline");
let mut catalog = Catalog::default();
let mut store = if durable {
GraphStore::open(&path, &mut catalog).unwrap()
} else {
GraphStore::default()
};
store
.create_node(
&mut catalog,
"Memory",
properties([("value", Value::Int(0))]),
)
.unwrap();
store.checkpoint(&catalog).unwrap();
let reserve = VersionIndex::default().estimated_bytes();
let weight = VersionKey::GraphNode(NodeId(0)).cow_page_bytes()
+ std::mem::size_of::<VersionStamp>();
for id in 1..=64 {
let before = store.commit_epoch();
store
.commit_mutations(
&mut catalog,
vec![GraphMutation::CreateNode {
label: "Memory".into(),
properties: properties([("value", Value::Int(0))]),
}],
)
.unwrap();
assert_eq!(store.version_index.len(), 2);
assert_eq!(store.version_index.estimated_bytes(), reserve + weight);
assert_eq!(store.version_index.retained_history_bytes(), weight);
assert_eq!(
store
.version_index
.stamp(&VersionKey::Database)
.unwrap()
.commit_epoch,
before
);
assert_eq!(
store
.version_index
.stamp(&VersionKey::GraphNode(NodeId(id)))
.unwrap()
.commit_epoch,
before + 1
);
}
let update = |id, value| GraphMutation::SetNodeProperty {
label: "Memory".into(),
filter: Some(PropertyFilter::IdEq {
value: Value::Int(id),
}),
property: "value".into(),
value: Value::Int(value),
};
if durable {
let before = store.snapshot(); let wal = fs::read(active_wal_path(&path)).unwrap();
store
.durable
.as_mut()
.unwrap()
.set_wal_available_space_override(0);
assert!(store
.commit_mutations(&mut catalog, vec![update(0, 99)])
.is_err());
assert_eq!(store.commit_epoch(), before.commit_epoch());
assert_eq!(
store.version_index.estimated_bytes(),
before.version_index.estimated_bytes()
);
assert_eq!(store.version_index.retained_history_bytes(), weight);
assert!(store
.version_index
.shares_storage_with(&before.version_index));
assert_eq!(
store.version_index.stamp(&VersionKey::Database),
before.version_index.stamp(&VersionKey::Database)
);
assert_eq!(fs::read(active_wal_path(&path)).unwrap(), wal);
store
.durable
.as_mut()
.unwrap()
.set_wal_available_space_override(u64::MAX);
}
let source = store.snapshot();
let source_epoch = source.commit_epoch();
store
.commit_mutations(&mut catalog, vec![update(1, 10)])
.unwrap();
store
.commit_mutations(&mut catalog, vec![update(2, 20)])
.unwrap();
let mut disjoint = source.begin_mutation_transaction(&catalog);
let mut overlapping = source.begin_mutation_transaction(&catalog);
disjoint
.stage_mutation_with_limits(update(0, 30), MutationLimits::default())
.unwrap();
overlapping
.stage_mutation_with_limits(update(1, 99), MutationLimits::default())
.unwrap();
store
.commit_mutation_transaction_and_relational(
&mut catalog,
disjoint,
RelationalTransaction::default(),
MutationLimits::default(),
)
.unwrap();
assert_eq!(
store
.version_index
.stamp(&VersionKey::Database)
.unwrap()
.commit_epoch,
source_epoch
);
let epoch = store.commit_epoch();
let wal = durable.then(|| fs::read(active_wal_path(&path)).unwrap());
assert!(store
.commit_mutation_transaction_and_relational(
&mut catalog,
overlapping,
RelationalTransaction::default(),
MutationLimits::default()
)
.unwrap_err()
.is_retryable_transaction_conflict());
assert_eq!(store.commit_epoch(), epoch);
if let Some(wal) = wal {
assert_eq!(fs::read(active_wal_path(&path)).unwrap(), wal);
}
let descendant = source.snapshot();
drop(source);
store
.commit_mutations(&mut catalog, vec![update(2, 21)])
.unwrap();
assert_eq!(
store
.version_index
.stamp(&VersionKey::Database)
.unwrap()
.commit_epoch,
source_epoch
);
for id in 0..3 {
assert_eq!(
descendant
.node_owned(NodeId(id))
.unwrap()
.unwrap()
.properties["value"],
Value::Int(0)
);
}
drop(descendant);
let before = store.commit_epoch();
store
.commit_mutations(&mut catalog, vec![update(0, 31)])
.unwrap();
assert_eq!(store.version_index.len(), 2);
assert_eq!(store.version_index.retained_history_bytes(), weight);
assert_eq!(
store
.version_index
.stamp(&VersionKey::Database)
.unwrap()
.commit_epoch,
before
);
let epoch = store.commit_epoch();
if durable {
drop(store);
catalog = Catalog::default();
store = GraphStore::open(&path, &mut catalog).unwrap();
}
assert_eq!(store.commit_epoch(), epoch);
assert_eq!(store.node_count_for_label(None), 65);
for (id, value) in [(0, 31), (1, 10), (2, 21)] {
assert_eq!(
store.node_owned(NodeId(id)).unwrap().unwrap().properties["value"],
Value::Int(value)
);
}
drop(store);
if durable {
fs::remove_dir_all(path).unwrap();
}
}
}
#[test]
fn current_epoch_version_baseline_keeps_pinned_pressure_reclamation_available() {
use crate::cow::CowPageWeight;
use crate::version::{VersionIndex, VersionKey, VersionStamp};
let mut store = GraphStore::default();
let mut catalog = Catalog::default();
let reserve = VersionIndex::default().estimated_bytes();
let weight =
VersionKey::GraphNode(NodeId(0)).cow_page_bytes() + std::mem::size_of::<VersionStamp>();
store
.create_node(&mut catalog, "Memory", properties([]))
.unwrap();
store.version_index = VersionIndex::with_byte_limit(reserve + weight);
let insert = || GraphMutation::CreateNode {
label: "Memory".into(),
properties: properties([]),
};
store
.commit_mutations(&mut catalog, vec![insert()])
.unwrap();
store
.create_node(&mut catalog, "Memory", properties([]))
.unwrap();
let reader = store.snapshot();
store
.create_node(&mut catalog, "Memory", properties([]))
.unwrap();
assert_eq!(store.version_index.estimated_bytes(), reserve + weight);
store
.commit_mutations(&mut catalog, vec![insert()])
.unwrap();
assert_eq!(store.version_index.estimated_bytes(), reserve + weight);
assert_eq!(store.node_count_for_label(None), 5);
assert_eq!(reader.node_count_for_label(None), 3);
assert!(store
.version_index
.stamp(&VersionKey::GraphNode(NodeId(1)))
.is_none());
assert!(store
.version_index
.stamp(&VersionKey::GraphNode(NodeId(4)))
.is_some());
}
#[test]
fn retained_history_budget_refunds_wal_refusal_and_recovers_after_snapshot_drop() {
use crate::cow::CowPageWeight;
use crate::version::{VersionIndex, VersionKey, VersionStamp};
for durable in [false, true] {
let path = unique_test_dir("retained_history_budget");
let mut catalog = Catalog::default();
let mut store = if durable {
GraphStore::open(&path, &mut catalog).unwrap()
} else {
GraphStore::default()
};
store
.create_node(&mut catalog, "Memory", properties([]))
.unwrap();
store.checkpoint(&catalog).unwrap();
let reserve = VersionIndex::default().estimated_bytes();
let weight = VersionKey::GraphNode(NodeId(0)).cow_page_bytes()
+ std::mem::size_of::<VersionStamp>();
store.version_index =
VersionIndex::with_history_limit(reserve + 4 * weight, 4 * weight);
let insert = || GraphMutation::CreateNode {
label: "Memory".into(),
properties: properties([]),
};
let protection = store.snapshot();
store
.commit_mutations(&mut catalog, vec![insert()])
.unwrap();
assert_eq!(store.version_index.retained_history_bytes(), weight);
let old = store.snapshot();
if durable {
let epoch = store.commit_epoch();
let wal = fs::read(active_wal_path(&path)).unwrap();
store
.durable
.as_mut()
.unwrap()
.set_wal_available_space_override(0);
assert!(store
.commit_mutations(&mut catalog, vec![insert()])
.is_err());
assert_eq!(store.version_index.retained_history_bytes(), weight);
assert_eq!(store.commit_epoch(), epoch);
assert_eq!(fs::read(active_wal_path(&path)).unwrap(), wal);
store
.durable
.as_mut()
.unwrap()
.set_wal_available_space_override(u64::MAX);
}
store
.commit_mutations(&mut catalog, vec![insert()])
.unwrap();
assert_eq!(store.version_index.retained_history_bytes(), 3 * weight);
let epoch = store.commit_epoch();
let wal = durable.then(|| fs::read(active_wal_path(&path)).unwrap());
let error = store
.commit_mutations(&mut catalog, vec![insert()])
.unwrap_err();
assert!(
error
.to_string()
.contains("retained version history budget exhausted"),
"{error}"
);
assert_eq!(store.commit_epoch(), epoch);
assert_eq!(store.node_count_for_label(None), 3);
assert_eq!(old.node_count_for_label(None), 2);
assert_eq!(store.version_index.retained_history_bytes(), 3 * weight);
if let Some(wal) = wal {
assert_eq!(fs::read(active_wal_path(&path)).unwrap(), wal);
}
drop(old);
drop(protection);
store.checkpoint(&catalog).unwrap();
assert_eq!(store.version_index.retained_history_bytes(), 0);
store
.commit_mutations(&mut catalog, vec![insert()])
.unwrap();
let epoch = store.commit_epoch();
assert_eq!(store.node_count_for_label(None), 4);
drop(store);
if durable {
let mut reopened_catalog = Catalog::default();
let reopened = GraphStore::open(&path, &mut reopened_catalog).unwrap();
assert_eq!(reopened.commit_epoch(), epoch);
assert_eq!(reopened.node_count_for_label(None), 4);
assert_eq!(reopened.version_index.retained_history_bytes(), 0);
drop(reopened);
fs::remove_dir_all(path).unwrap();
}
}
}
#[test]
fn version_history_budget_rejects_growth_before_wal_and_recovers_after_unpin() {
use crate::cow::CowPageWeight;
use crate::version::{VersionIndex, VersionKey, VersionStamp};
for durable in [false, true] {
let path = unique_test_dir("version_history_budget");
let mut catalog = Catalog::default();
let mut store = if durable {
GraphStore::open(&path, &mut catalog).unwrap()
} else {
GraphStore::default()
};
store
.create_node(&mut catalog, "Memory", properties([]))
.unwrap();
store.checkpoint(&catalog).unwrap();
let reserve = VersionIndex::default().estimated_bytes();
let weight = VersionKey::GraphNode(NodeId(0)).cow_page_bytes()
+ std::mem::size_of::<VersionStamp>();
let limit = reserve + 2 * weight;
store.version_index = VersionIndex::with_byte_limit(limit);
let reader = store.snapshot();
let insert = || GraphMutation::CreateNode {
label: "Memory".into(),
properties: properties([]),
};
for _ in 0..2 {
store
.commit_mutations(&mut catalog, vec![insert()])
.unwrap();
}
assert_eq!(store.version_index.estimated_bytes(), limit);
let epoch = store.commit_epoch();
let wal = durable.then(|| fs::read(active_wal_path(&path)).unwrap());
let error = store
.commit_mutations(&mut catalog, vec![insert()])
.unwrap_err();
assert!(error
.to_string()
.contains("version index estimated payload budget exhausted"));
assert_eq!(store.commit_epoch(), epoch);
assert_eq!(store.node_count_for_label(None), 3);
assert_eq!(reader.node_count_for_label(None), 1);
assert_eq!(store.version_index.estimated_bytes(), limit);
if let Some(wal) = wal {
assert_eq!(fs::read(active_wal_path(&path)).unwrap(), wal);
}
store
.create_node(&mut catalog, "Memory", properties([]))
.unwrap();
assert_eq!(store.version_index.estimated_bytes(), limit);
assert!(store.version_index.stamp(&VersionKey::Database).is_some());
drop(reader);
store.checkpoint(&catalog).unwrap();
assert_eq!(store.version_index.estimated_bytes(), reserve);
store
.commit_mutations(&mut catalog, vec![insert()])
.unwrap();
assert_eq!(store.node_count_for_label(None), 5);
for _ in 0..8 {
store
.commit_mutations(&mut catalog, vec![insert()])
.unwrap();
assert!(store.version_index.estimated_bytes() <= limit);
}
let epoch = store.commit_epoch();
drop(store);
if durable {
let mut recovered_catalog = Catalog::default();
let recovered = GraphStore::open(&path, &mut recovered_catalog).unwrap();
assert_eq!(recovered.node_count_for_label(None), 13);
assert_eq!(recovered.commit_epoch(), epoch);
drop(recovered);
fs::remove_dir_all(path).unwrap();
}
}
}
#[test]
fn checkpoint_reclaims_obsolete_live_version_history_without_losing_rows_or_conflicts() {
use crate::version::VersionKey;
for durable in [false, true] {
let path = unique_test_dir("live_version_history");
let mut catalog = Catalog::default();
let mut store = if durable {
GraphStore::open(&path, &mut catalog).unwrap()
} else {
GraphStore::default()
};
let insert_batch = |store: &mut GraphStore, catalog: &mut Catalog| {
store
.commit_mutations(
catalog,
(0..8)
.map(|_| GraphMutation::CreateNode {
label: "Memory".into(),
properties: properties([("value", Value::Int(0))]),
})
.collect(),
)
.unwrap();
};
let protection = store.snapshot();
insert_batch(&mut store, &mut catalog);
insert_batch(&mut store, &mut catalog);
let older = store.snapshot();
insert_batch(&mut store, &mut catalog);
let newer = store.snapshot();
drop(protection);
store.checkpoint(&catalog).unwrap();
assert_eq!(store.version_index.len(), 16);
assert_eq!(
store.version_index.stamp(&VersionKey::GraphNode(NodeId(0))),
None
);
assert!(store
.version_index
.stamp(&VersionKey::GraphNode(NodeId(8)))
.is_some());
assert!(older
.version_index
.stamp(&VersionKey::GraphNode(NodeId(0)))
.is_some());
assert_eq!(older.node_count_for_label(None), 16);
drop(older);
store.checkpoint(&catalog).unwrap();
assert_eq!(store.version_index.len(), 8);
assert_eq!(newer.node_count_for_label(None), 24);
drop(newer);
store.checkpoint(&catalog).unwrap();
assert!(store.version_index.is_empty());
assert_eq!(store.node_count_for_label(None), 24);
let mut stale = store.begin_mutation_transaction(&catalog);
let update = |value| GraphMutation::SetNodeProperty {
label: "Memory".into(),
filter: None,
property: "value".into(),
value: Value::Int(value),
};
stale
.stage_mutation_with_limits(update(1), MutationLimits::default())
.unwrap();
store
.commit_mutations(&mut catalog, vec![update(2)])
.unwrap();
let epoch = store.commit_epoch;
assert!(store
.commit_mutation_transaction_and_relational(
&mut catalog,
stale,
RelationalTransaction::default(),
MutationLimits::default()
)
.unwrap_err()
.is_retryable_transaction_conflict());
assert_eq!(store.commit_epoch, epoch);
store.checkpoint(&catalog).unwrap();
assert!(store.version_index.is_empty());
if durable {
drop(store);
catalog = Catalog::default();
store = GraphStore::open(&path, &mut catalog).unwrap();
}
assert_eq!(store.node_count_for_label(None), 24);
for id in 0..24 {
assert_eq!(
store.node_owned(NodeId(id)).unwrap().unwrap().properties["value"],
Value::Int(2)
);
}
drop(store);
if durable {
fs::remove_dir_all(path).unwrap();
}
}
}
#[test]
fn direct_legacy_commits_invalidate_stale_optimistic_workspaces() {
for durable in [false, true] {
for mutation in ["property", "schema", "delete"] {
let path = unique_test_dir("legacy_version_barrier");
let mut catalog = Catalog::default();
let mut store = if durable {
GraphStore::open(&path, &mut catalog).unwrap()
} else {
GraphStore::default()
};
let id = store
.create_node(
&mut catalog,
"Memory",
properties([("value", Value::Int(0))]),
)
.unwrap();
let read_epoch = store.commit_epoch;
let mut stale = store.begin_mutation_transaction(&catalog);
stale
.stage_mutation_with_limits(
GraphMutation::SetNodeProperty {
label: "Memory".into(),
filter: None,
property: "value".into(),
value: Value::Int(1),
},
MutationLimits::default(),
)
.unwrap();
match mutation {
"property" => {
store
.set_node_property(&mut catalog, "Memory", None, "value", Value::Int(2))
.unwrap();
}
"schema" => {
store.create_node_label(&mut catalog, "Other").unwrap();
}
"delete" => {
store
.delete_nodes(&mut catalog, "Memory", None, false)
.unwrap();
}
_ => unreachable!(),
}
let wal = durable.then(|| fs::read(active_wal_path(&path)).unwrap());
let error = store
.commit_mutation_transaction_and_relational(
&mut catalog,
stale,
RelationalTransaction::default(),
MutationLimits::default(),
)
.unwrap_err();
assert!(
error.is_retryable_transaction_conflict(),
"{mutation}: {error}"
);
assert!(matches!(error, HawDBError::TransactionConflict {
read_epoch: actual_read, committed_epoch, ref key,
} if actual_read == read_epoch && committed_epoch == read_epoch + 1 && key == "database"));
assert_eq!(store.commit_epoch, read_epoch + 1);
if let Some(wal) = wal {
assert_eq!(fs::read(active_wal_path(&path)).unwrap(), wal);
drop(store);
catalog = Catalog::default();
store = GraphStore::open(&path, &mut catalog).unwrap();
assert_eq!(store.commit_epoch, read_epoch + 1);
}
if mutation == "delete" {
assert!(store.node_owned(id).unwrap().is_none());
} else {
let node = store.node_owned(id).unwrap().unwrap();
assert_eq!(
node.properties["value"],
Value::Int(if mutation == "property" { 2 } else { 0 })
);
}
assert_eq!(catalog.label_id("Other").is_some(), mutation == "schema");
drop(store);
if durable {
fs::remove_dir_all(path).unwrap();
}
}
}
}
#[test]
fn rejected_and_noop_legacy_mutations_do_not_invalidate_a_transaction() {
let mut store = GraphStore::default();
let mut catalog = Catalog::default();
let id = store
.create_node(
&mut catalog,
"Memory",
properties([("value", Value::Int(i64::MAX))]),
)
.unwrap();
let epoch = store.commit_epoch;
let mut transaction = store.begin_mutation_transaction(&catalog);
transaction
.stage_mutation_with_limits(
GraphMutation::SetNodeProperty {
label: "Memory".into(),
filter: None,
property: "value".into(),
value: Value::Int(7),
},
MutationLimits::default(),
)
.unwrap();
assert!(store
.add_int_node_property(&mut catalog, "Memory", None, "value", 1)
.is_err());
assert!(store
.set_node_property(&mut catalog, "Absent", None, "value", Value::Int(1))
.unwrap()
.is_empty());
assert_eq!(store.commit_epoch, epoch);
store
.commit_mutation_transaction_and_relational(
&mut catalog,
transaction,
RelationalTransaction::default(),
MutationLimits::default(),
)
.unwrap();
assert_eq!(
store.node_owned(id).unwrap().unwrap().properties["value"],
Value::Int(7)
);
}
#[test]
fn version_tombstone_checkpoint_waits_for_storage_snapshots_and_transactions() {
use crate::version::{VersionDisposition, VersionKey};
for durable in [false, true] {
let path = unique_test_dir("version_tombstone_pins");
let mut catalog = Catalog::default();
let mut store = if durable {
GraphStore::open(&path, &mut catalog).unwrap()
} else {
GraphStore::default()
};
let id = store
.create_node(
&mut catalog,
"Memory",
properties([("value", Value::Int(0))]),
)
.unwrap();
let key = VersionKey::GraphNode(id);
let source = store.snapshot();
let mut stale = source.begin_mutation_transaction(&catalog);
stale
.stage_mutation_with_limits(
GraphMutation::SetNodeProperty {
label: "Memory".into(),
filter: None,
property: "value".into(),
value: Value::Int(1),
},
MutationLimits::default(),
)
.unwrap();
store
.commit_mutations(
&mut catalog,
vec![GraphMutation::DeleteNode {
label: "Memory".into(),
filter: None,
detach: false,
}],
)
.unwrap();
let deleted = store.version_index.stamp(&key).unwrap();
assert_eq!(deleted.disposition, VersionDisposition::Tombstone);
store.checkpoint(&catalog).unwrap();
assert_eq!(store.version_index.stamp(&key), Some(deleted));
let error = store
.commit_mutation_transaction_and_relational(
&mut catalog,
stale,
RelationalTransaction::default(),
MutationLimits::default(),
)
.unwrap_err();
assert!(error.is_retryable_transaction_conflict());
let later = source.begin_mutation_transaction(&catalog);
drop(source);
store.checkpoint(&catalog).unwrap();
assert_eq!(store.version_index.stamp(&key), Some(deleted));
drop(later);
let equal_epoch = store.snapshot();
store.checkpoint(&catalog).unwrap();
assert_eq!(store.version_index.stamp(&key), Some(deleted));
let next_id = NodeId(store.next_node_id);
store
.commit_mutations(
&mut catalog,
vec![GraphMutation::CreateNode {
label: "Memory".into(),
properties: properties([("value", Value::Int(2))]),
}],
)
.unwrap();
let newer = store.snapshot();
let live_key = VersionKey::GraphNode(next_id);
let live = store.version_index.stamp(&live_key).unwrap();
store.checkpoint(&catalog).unwrap();
assert_eq!(store.version_index.stamp(&key), None);
assert_eq!(store.version_index.stamp(&live_key), Some(live));
assert_eq!(newer.version_index.stamp(&key), None);
assert_eq!(equal_epoch.version_index.stamp(&key), Some(deleted));
drop(equal_epoch);
drop(newer);
drop(store);
if durable {
fs::remove_dir_all(path).unwrap();
}
}
}
#[test]
fn version_tombstone_checkpoint_preserves_base_epoch_after_savepoint_restore() {
let mut store = GraphStore::default();
let mut catalog = Catalog::default();
let id = store
.create_node(
&mut catalog,
"Memory",
properties([("value", Value::Int(0))]),
)
.unwrap();
let key = crate::version::VersionKey::GraphNode(id);
let mut transaction = store.begin_mutation_transaction(&catalog);
for value in 1..=2 {
transaction
.stage_mutation_with_limits(
GraphMutation::SetNodeProperty {
label: "Memory".into(),
filter: None,
property: "value".into(),
value: Value::Int(value),
},
MutationLimits::default(),
)
.unwrap();
}
let savepoint = transaction.savepoint();
transaction
.stage_mutation_with_limits(
GraphMutation::SetNodeProperty {
label: "Memory".into(),
filter: None,
property: "value".into(),
value: Value::Int(3),
},
MutationLimits::default(),
)
.unwrap();
transaction.restore(savepoint);
store
.commit_mutations(
&mut catalog,
vec![GraphMutation::DeleteNode {
label: "Memory".into(),
filter: None,
detach: false,
}],
)
.unwrap();
let deleted = store.version_index.stamp(&key).unwrap();
store.checkpoint(&catalog).unwrap();
assert_eq!(store.version_index.stamp(&key), Some(deleted));
assert!(store
.commit_mutation_transaction_and_relational(
&mut catalog,
transaction,
RelationalTransaction::default(),
MutationLimits::default(),
)
.unwrap_err()
.is_retryable_transaction_conflict());
}
#[test]
fn version_tombstone_checkpoint_bounds_unpinned_delete_churn() {
let mut store = GraphStore::default();
let mut catalog = Catalog::default();
for value in 0..32 {
store
.commit_mutations(
&mut catalog,
vec![GraphMutation::CreateNode {
label: "Memory".into(),
properties: properties([("value", Value::Int(value))]),
}],
)
.unwrap();
store
.commit_mutations(
&mut catalog,
vec![GraphMutation::DeleteNode {
label: "Memory".into(),
filter: None,
detach: false,
}],
)
.unwrap();
assert!(store
.version_index
.stamp(&crate::version::VersionKey::GraphNode(NodeId(value as u64)))
.is_some());
store.checkpoint(&catalog).unwrap();
assert!(store.version_index.len() <= 1);
}
}
#[test]
fn user_snapshot_observes_fatal_single_and_batch_wal_errors_but_allows_recoverable_append() {
use super::{set_wal_append_failpoint, WalAppendFailure};
for batch in [false, true] {
for failure in [
WalAppendFailure::PartialWrite,
WalAppendFailure::Rollback,
WalAppendFailure::Sync,
] {
let path = unique_test_dir("snapshot_wal_failure");
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
let snapshot = store.snapshot();
let op = WalOp::CreateNode {
id: NodeId(0),
label: "Record".into(),
properties: BTreeMap::new(),
};
set_wal_append_failpoint(failure);
let result = if batch {
store.append_durable_wal_batch(std::slice::from_ref(&op))
} else {
store.append_durable_wal_single(op.clone())
};
assert!(result.is_err());
let fatal = failure != WalAppendFailure::PartialWrite;
assert_eq!(
matches!(result, Err(HawDBError::StorageIntegrity(_))),
fatal
);
assert_eq!(snapshot.ensure_usable().is_err(), fatal);
assert_eq!(store.ensure_usable().is_err(), fatal);
if !fatal {
store.append_durable_wal_single(op).unwrap();
}
drop(snapshot);
drop(store);
fs::remove_dir_all(path).unwrap();
}
}
}
#[test]
fn residency_facade_preserves_storage_type_identity() {
assert_eq!(
TypeId::of::<StorageResidencyReport>(),
TypeId::of::<hawdb_storage::residency::StorageResidencyReport>()
);
assert_eq!(
TypeId::of::<RelationalRowStorageResidencyReport>(),
TypeId::of::<hawdb_storage::residency::RelationalRowStorageResidencyReport>()
);
assert_eq!(
TypeId::of::<RelationalIndexStorageResidencyReport>(),
TypeId::of::<hawdb_storage::relational_index_view::RelationalIndexStorageResidencyReport>(
)
);
}
#[test]
fn owned_graph_overlay_preserves_snapshot_checkpoint_and_reopen() {
let path = unique_test_dir("owned_graph_overlay");
let open = |catalog: &mut Catalog| {
GraphStore::open_with_durability_and_replay_config(
&path,
catalog,
DurabilityPolicy::default(),
WalReplayConfig {
residency_mode: StorageResidencyMode::OutOfCore,
..WalReplayConfig::default()
},
)
.unwrap()
};
let mut catalog = Catalog::default();
let mut store = open(&mut catalog);
let mut ids = Vec::new();
for id in 0..3 {
ids.push(
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(id))]))
.unwrap(),
);
}
let first_rel = store
.create_relationship(&mut catalog, ids[0], ids[1], "LINKS", BTreeMap::new())
.unwrap();
store
.create_relationship(&mut catalog, ids[0], ids[2], "LINKS", BTreeMap::new())
.unwrap();
store.checkpoint(&catalog).unwrap();
assert!(store.nodes.is_empty());
assert!(store.relationships.is_empty());
store
.set_node_property(&mut catalog, "Memory", None, "revision", Value::Int(2))
.unwrap();
store
.commit_mutations(
&mut catalog,
vec![GraphMutation::SetRelationshipProperty {
source_label: "Memory".to_string(),
filter: None,
rel_type: "LINKS".to_string(),
target_label: "Memory".to_string(),
target_filter: None,
rel_filter: None,
property: "revision".to_string(),
value: Value::Int(2),
}],
)
.unwrap();
let filter = |id| PropertyFilter::Eq {
property: "id".to_string(),
value: Value::Int(id),
};
store
.delete_nodes(&mut catalog, "Memory", Some(&filter(2)), true)
.unwrap();
let added = store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(3))]))
.unwrap();
let added_rel = store
.create_relationship(&mut catalog, ids[0], added, "LINKS", BTreeMap::new())
.unwrap();
let expected_nodes = [ids[0], ids[1], added]
.into_iter()
.map(|id| store.node_owned(id).unwrap().unwrap())
.collect::<Vec<_>>();
let expected_rels = [first_rel, added_rel]
.into_iter()
.map(|id| store.relationship_owned(id).unwrap().unwrap())
.collect::<Vec<_>>();
let nodes: super::GraphNodeIterator = store.node_records_owned();
let nodes: hawdb_storage::graph_overlay::GraphNodeIterator = nodes;
let relationships: super::GraphRelationshipIterator = store.relationship_records_owned();
let relationships: hawdb_storage::graph_overlay::GraphRelationshipIterator = relationships;
store
.set_node_property(
&mut catalog,
"Memory",
Some(&filter(0)),
"revision",
Value::Int(3),
)
.unwrap();
store
.delete_nodes(&mut catalog, "Memory", Some(&filter(1)), true)
.unwrap();
assert_eq!(
nodes.collect::<crate::Result<Vec<_>>>().unwrap(),
expected_nodes
);
assert_eq!(
relationships.collect::<crate::Result<Vec<_>>>().unwrap(),
expected_rels
);
let expected_nodes = [ids[0], added]
.into_iter()
.map(|id| store.node_owned(id).unwrap().unwrap())
.collect::<Vec<_>>();
let expected_rels = vec![store.relationship_owned(added_rel).unwrap().unwrap()];
assert_eq!(
store
.node_records_owned()
.collect::<crate::Result<Vec<_>>>()
.unwrap(),
expected_nodes
);
assert_eq!(
store
.relationship_records_owned()
.collect::<crate::Result<Vec<_>>>()
.unwrap(),
expected_rels
);
store.checkpoint(&catalog).unwrap();
drop(store);
let mut catalog = Catalog::default();
let reopened = open(&mut catalog);
assert_eq!(
reopened
.node_records_owned()
.collect::<crate::Result<Vec<_>>>()
.unwrap(),
expected_nodes
);
assert_eq!(
reopened
.relationship_records_owned()
.collect::<crate::Result<Vec<_>>>()
.unwrap(),
expected_rels
);
drop(reopened);
fs::remove_dir_all(path).unwrap();
}
#[test]
fn background_storage_permit_retains_governor_resources_until_drop_and_unwind() {
use hawdb_qos::{
IoConcurrencyBudget, RuntimeGovernor, RuntimeGovernorConfig, RuntimeMemorySnapshot,
RuntimeResourceBudget, RuntimeResourceSnapshot,
};
use hawdb_storage::background::{BackgroundWorkAdmission, BackgroundWorkRequest};
let governor = RuntimeGovernor::new(
RuntimeGovernorConfig {
memory_budget_bytes: Some(1024),
result_budget_bytes: 0,
..RuntimeGovernorConfig::default()
},
RuntimeResourceSnapshot::from_parts(
RuntimeResourceBudget::from_limits(NonZeroUsize::new(4).unwrap(), None, None),
RuntimeMemorySnapshot::from_limits(Some(8192), Some(8192), None, None, None),
),
IoConcurrencyBudget::new(4, 1),
);
let admission = super::RuntimeGovernorBackgroundAdmission(governor.clone());
let request = BackgroundWorkRequest {
cpu_slots: 1,
memory_bytes: 512,
io_slots: 1,
};
for unwind in [false, true] {
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let permit = admission.try_admit(request).unwrap();
let snapshot = governor.snapshot();
assert_eq!(snapshot.active_background_tasks, 1);
assert_eq!(snapshot.active_cpu_slots, 1);
assert_eq!(snapshot.active_background_io_slots, 1);
assert_eq!(snapshot.admitted_memory_bytes, 512);
assert!(admission.try_admit(request).is_err());
if unwind {
panic!("injected background work panic");
}
drop(permit);
}));
assert_eq!(result.is_err(), unwind);
let snapshot = governor.snapshot();
assert_eq!(snapshot.active_background_tasks, 0);
assert_eq!(snapshot.active_cpu_slots, 0);
assert_eq!(snapshot.active_background_io_slots, 0);
assert_eq!(snapshot.admitted_memory_bytes, 0);
}
drop(admission.try_admit(request).unwrap());
assert_eq!(governor.snapshot().completions, 3);
}
fn property_projection_block_corrupt_offset(path: &std::path::Path, kind_tag: u8) -> u64 {
let encoded = fs::read(path).unwrap();
encoded
.windows(8)
.enumerate()
.find_map(|(offset, header)| {
(header == b"SKNIDX01" && encoded.get(offset.saturating_add(24)) == Some(&kind_tag))
.then_some(offset.saturating_add(25) as u64)
})
.expect("selected property projection block exists")
}
#[test]
fn read_snapshot_baseline_releases_history_without_losing_data_or_pins() {
use crate::version::VersionKey;
let mut catalog = Catalog::default();
let mut store = GraphStore::default();
store
.create_node(
&mut catalog,
"Memory",
properties([("value", Value::Int(0))]),
)
.unwrap();
let update = |value| GraphMutation::SetNodeProperty {
label: "Memory".into(),
filter: None,
property: "value".into(),
value: Value::Int(value),
};
let mut old_read_target = store.begin_mutation_transaction(&catalog);
let mut old_precise_target = store.begin_mutation_transaction(&catalog);
old_read_target
.stage_mutation_with_limits(update(1), MutationLimits::default())
.unwrap();
old_precise_target
.stage_mutation_with_limits(update(1), MutationLimits::default())
.unwrap();
store
.commit_mutations(
&mut catalog,
(0..8)
.map(|_| GraphMutation::CreateNode {
label: "Memory".into(),
properties: properties([("value", Value::Int(0))]),
})
.collect(),
)
.unwrap();
assert!(store.version_index.len() >= 8);
let mut precise = store.snapshot();
assert!(precise
.version_index
.shares_storage_with(&store.version_index));
let mut read = store.snapshot_for_read();
assert_eq!(read.version_index.len(), 1);
assert!(!read.version_index.shares_storage_with(&store.version_index));
assert!(read.nodes.shares_storage_with(&store.nodes));
let epoch = read.commit_epoch();
assert_eq!(
read.version_index
.stamp(&VersionKey::Database)
.unwrap()
.commit_epoch,
epoch
);
let mut read_catalog = catalog.clone();
let error = read
.commit_mutation_transaction_and_relational(
&mut read_catalog,
old_read_target,
RelationalTransaction::default(),
MutationLimits::default(),
)
.unwrap_err();
assert!(error.is_retryable_transaction_conflict());
assert_eq!(read.commit_epoch(), epoch);
precise
.commit_mutation_transaction_and_relational(
&mut catalog.clone(),
old_precise_target,
RelationalTransaction::default(),
MutationLimits::default(),
)
.unwrap();
drop(precise);
let descendant = read.snapshot_for_read();
drop(read);
store
.commit_mutations(&mut catalog, vec![update(2)])
.unwrap();
store.checkpoint(&catalog).unwrap();
assert!(store.version_index.is_empty());
assert_eq!(descendant.commit_epoch(), epoch);
assert_eq!(descendant.version_index.len(), 1);
for id in 0..9 {
assert_eq!(
descendant
.node_owned(NodeId(id))
.unwrap()
.unwrap()
.properties["value"],
Value::Int(0)
);
assert_eq!(
store.node_owned(NodeId(id)).unwrap().unwrap().properties["value"],
Value::Int(2)
);
}
drop(descendant);
store.checkpoint(&catalog).unwrap();
assert!(store.version_index.is_empty());
assert!(GraphStore::default()
.snapshot_for_read()
.version_index
.is_empty());
}
#[test]
fn snapshot_shares_segments_until_the_live_store_mutates_them() {
let mut catalog = Catalog::default();
let mut store = GraphStore::in_memory();
store
.create_node(
&mut catalog,
"Memory",
properties([("id", Value::String("memory-a".to_string()))]),
)
.unwrap();
let snapshot = store.snapshot();
assert!(store.nodes.shares_storage_with(&snapshot.nodes));
assert!(store
.relationships
.shares_storage_with(&snapshot.relationships));
store
.create_node(
&mut catalog,
"Memory",
properties([("id", Value::String("memory-b".to_string()))]),
)
.unwrap();
assert_eq!(snapshot.nodes.len(), 1);
assert_eq!(store.nodes.len(), 2);
assert!(!store.nodes.shares_storage_with(&snapshot.nodes));
assert!(store
.relationships
.shares_storage_with(&snapshot.relationships));
}
#[test]
fn wal_append_handle_is_reused_and_rotated_with_its_generation() {
let path = unique_test_dir("wal_append_handle_cache");
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
let durable = store.durable.as_ref().unwrap();
assert!(durable.wal_append_file.is_none());
assert_eq!(durable.wal_append_open_count, 0);
for id in 1..=2 {
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(id))]))
.unwrap();
}
let durable = store.durable.as_ref().unwrap();
assert!(durable.wal_append_file.is_some());
assert_eq!(durable.wal_append_open_count, 1);
let checkpoint_source = store.checkpoint_source();
assert!(checkpoint_source
.durable
.as_ref()
.unwrap()
.wal_append_file
.is_none());
drop(checkpoint_source);
store.checkpoint(&catalog).unwrap();
assert!(store.durable.as_ref().unwrap().wal_append_file.is_none());
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(3))]))
.unwrap();
let durable = store.durable.as_ref().unwrap();
assert!(durable.wal_append_file.is_some());
assert_eq!(durable.wal_append_open_count, 2);
drop(store);
let mut reopened = GraphStore::open(&path, &mut catalog).unwrap();
assert_eq!(reopened.durable.as_ref().unwrap().wal_append_open_count, 0);
reopened
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(4))]))
.unwrap();
assert_eq!(reopened.durable.as_ref().unwrap().wal_append_open_count, 1);
drop(reopened);
fs::remove_dir_all(path).unwrap();
}
#[test]
fn wal_free_space_pressure_rejects_before_opening_or_writing_the_wal() {
let path = unique_test_dir("wal_free_space_pressure");
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
store
.durable
.as_mut()
.unwrap()
.set_wal_available_space_override(0);
let error = store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(1))]))
.unwrap_err();
assert!(error.to_string().contains("reasons=free_space_reserve"));
let durable = store.durable.as_ref().unwrap();
assert_eq!(durable.wal_bytes, 0);
assert!(durable.wal_append_file.is_none());
assert!(!durable.wal_path.exists());
drop(store);
let mut reopened_catalog = Catalog::default();
let reopened = GraphStore::open(&path, &mut reopened_catalog).unwrap();
assert_eq!(reopened.scan_nodes(None).count(), 0);
drop(reopened);
fs::remove_dir_all(path).unwrap();
}
#[test]
fn wal_free_space_probe_is_amortized_across_small_appends() {
let path = unique_test_dir("wal_free_space_probe_watermark");
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
store
.durable
.as_mut()
.unwrap()
.set_wal_available_space_override(u64::MAX);
for id in 1..=2 {
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(id))]))
.unwrap();
}
assert_eq!(
store.durable.as_ref().unwrap().wal_free_space_probe_count(),
1
);
drop(store);
fs::remove_dir_all(path).unwrap();
}
#[test]
fn checkpoint_delta_pressure_matches_full_scan_through_mutations_and_reopen() {
fn verify(store: &GraphStore) {
assert_eq!(
store.estimated_delta_resident_bytes(),
store.estimated_delta_resident_bytes_reference()
);
}
for mode in [
StorageResidencyMode::Materialized,
StorageResidencyMode::OutOfCore,
] {
let path = unique_test_dir("checkpoint_delta_pressure");
let config = WalReplayConfig {
residency_mode: mode,
..WalReplayConfig::default()
};
let mut catalog = Catalog::default();
let mut store = GraphStore::open_with_durability_and_replay_config(
&path,
&mut catalog,
DurabilityPolicy::default(),
config,
)
.unwrap();
store
.create_property_index(&mut catalog, "Memory", "id")
.unwrap();
store
.create_composite_property_index(
&mut catalog,
"Memory",
&["id".into(), "body".into()],
)
.unwrap();
store
.create_full_text_property_index(&mut catalog, "Memory", "body")
.unwrap();
let mut ids = Vec::new();
for id in 0..1057 {
ids.push(
store
.create_node(
&mut catalog,
"Memory",
properties([
("id", Value::Int(id)),
("body", Value::String(format!("alpha beta {id}"))),
(
"detail",
Value::Map(BTreeMap::from([(
"flags".into(),
Value::List(vec![
Value::Null,
Value::Bool(true),
Value::List(vec![Value::String("雪".repeat(8))]),
]),
)])),
),
]),
)
.unwrap(),
);
verify(&store);
}
for target in ids.iter().take(130).skip(1) {
store
.create_relationship(
&mut catalog,
ids[0],
*target,
"LINK",
properties([("rank", Value::Int(1))]),
)
.unwrap();
verify(&store);
}
let old = store.snapshot();
let old_pressure = old.estimated_delta_resident_bytes();
for id in 0..17 {
store
.set_node_property(
&mut catalog,
"Memory",
Some(&PropertyFilter::Eq {
property: "id".into(),
value: Value::Int(id),
}),
"body",
Value::String(format!("gamma beta {id}")),
)
.unwrap();
verify(&store);
assert_eq!(old.estimated_delta_resident_bytes(), old_pressure);
}
store
.set_relationship_property(
&mut catalog,
RelationshipPropertyUpdate {
source_label: "Memory".into(),
filter: None,
rel_type: "LINK".into(),
target_label: "Memory".into(),
target_filter: None,
rel_filter: None,
property: "rank".into(),
value: Value::Int(2),
},
)
.unwrap();
verify(&store);
store
.delete_nodes(
&mut catalog,
"Memory",
Some(&PropertyFilter::Eq {
property: "id".into(),
value: Value::Int(1),
}),
true,
)
.unwrap();
verify(&store);
store.checkpoint(&catalog).unwrap();
verify(&store);
if mode == StorageResidencyMode::OutOfCore {
assert_eq!(store.checkpoint_debt_snapshot().unwrap().delta_bytes, 0);
}
store
.set_node_property(
&mut catalog,
"Memory",
Some(&PropertyFilter::Eq {
property: "id".into(),
value: Value::Int(2),
}),
"body",
Value::String("suffix gamma".into()),
)
.unwrap();
verify(&store);
let expected_nodes = store
.node_records_owned()
.collect::<hawdb_core::Result<Vec<_>>>()
.unwrap();
let expected_relationships = store
.relationship_records_owned()
.collect::<hawdb_core::Result<Vec<_>>>()
.unwrap();
drop(old);
drop(store);
let reopened = GraphStore::open_with_durability_and_replay_config(
&path,
&mut catalog,
DurabilityPolicy::default(),
config,
)
.unwrap();
verify(&reopened);
assert_eq!(
reopened
.node_records_owned()
.collect::<hawdb_core::Result<Vec<_>>>()
.unwrap(),
expected_nodes
);
assert_eq!(
reopened
.relationship_records_owned()
.collect::<hawdb_core::Result<Vec<_>>>()
.unwrap(),
expected_relationships
);
if mode == StorageResidencyMode::OutOfCore {
let debt = reopened.checkpoint_debt_snapshot().unwrap();
assert_eq!(
debt.delta_bytes,
reopened.estimated_delta_resident_bytes_reference()
);
assert!(debt.delta_bytes > 0);
}
drop(reopened);
fs::remove_dir_all(path).unwrap();
}
}
#[test]
fn durable_append_evaluates_delta_and_integrity_pressure_before_wal_write() {
let path = unique_test_dir("wal_full_pressure_signals");
let mut catalog = Catalog::default();
let mut store = GraphStore::open_with_durability_and_replay_config(
&path,
&mut catalog,
DurabilityPolicy::default(),
WalReplayConfig {
residency_mode: StorageResidencyMode::OutOfCore,
max_out_of_core_delta_bytes: None,
..WalReplayConfig::default()
},
)
.unwrap();
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(1))]))
.unwrap();
store.checkpoint(&catalog).unwrap();
store.max_out_of_core_delta_bytes = Some(1);
let wal_bytes = store.durable.as_ref().unwrap().wal_bytes;
let rejected_op = WalOp::CreateNode {
id: NodeId(1),
label: "Memory".to_string(),
properties: properties([("id", Value::Int(2))]),
};
let error = store
.append_durable_wal_single(rejected_op.clone())
.unwrap_err();
assert!(
error.to_string().contains("reasons=delta_hard_limit"),
"{error}"
);
assert_eq!(store.durable.as_ref().unwrap().wal_bytes, wal_bytes);
store.max_out_of_core_delta_bytes = None;
store
.post_wal_apply_poisoned
.store(true, std::sync::atomic::Ordering::Release);
let error = store.append_durable_wal_single(rejected_op).unwrap_err();
assert!(
error.to_string().contains("reasons=integrity_poisoned"),
"{error}"
);
assert_eq!(store.durable.as_ref().unwrap().wal_bytes, wal_bytes);
drop(store);
fs::remove_dir_all(path).unwrap();
}
#[test]
fn graph_and_relational_state_share_wal_epoch_and_checkpoint_publication() {
let path = unique_test_dir("unified_relational_commit");
let backup = unique_test_dir("unified_relational_backup");
let restored = unique_test_dir("unified_relational_restored");
let table = RelationalTableSchema {
name: "messages".to_string(),
columns: vec![
RelationalColumnSchema {
name: "id".to_string(),
scalar_type: RelationalScalarType::Text,
nullable: false,
default: None,
},
RelationalColumnSchema {
name: "body".to_string(),
scalar_type: RelationalScalarType::Text,
nullable: false,
default: None,
},
],
primary_key: vec!["id".to_string()],
unique_constraints: Vec::new(),
foreign_keys: Vec::new(),
indexes: Vec::new(),
};
{
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).expect("open durable store");
store
.commit_mutations_and_relational(
&mut catalog,
vec![GraphMutation::CreateNode {
label: "Marker".to_string(),
properties: properties([("id", Value::String("graph-1".to_string()))]),
}],
RelationalTransaction {
writes: vec![RelationalWrite::CreateTable(table)],
},
MutationLimits::default(),
)
.expect("commit graph and relational schema");
assert_eq!(store.commit_epoch(), 1);
assert_eq!(store.nodes.len(), 1);
assert!(store.relational_state().table_schema("messages").is_some());
}
{
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).expect("replay unified WAL");
assert_eq!(store.commit_epoch(), 1);
assert_eq!(store.nodes.len(), 1);
assert!(store.relational_state().table_schema("messages").is_some());
store
.commit_mutations_and_relational(
&mut catalog,
vec![GraphMutation::CreateNode {
label: "Marker".to_string(),
properties: properties([("id", Value::String("graph-2".to_string()))]),
}],
RelationalTransaction {
writes: vec![RelationalWrite::Insert {
table: "messages".to_string(),
rows: vec![RelationalRow::new(vec![
RelationalValue::Text("message-1".to_string()),
RelationalValue::Text("payload".repeat(1_024)),
])],
mode: RelationalInsertMode::Error,
}],
},
MutationLimits::default(),
)
.expect("commit graph and relational row");
assert_eq!(store.commit_epoch(), 2);
store.checkpoint(&catalog).expect("publish checkpoint");
assert!(path.join("relational.1.hawdb").exists());
assert_eq!(
store
.relational_state()
.file_backed_overflow_segment_count(),
1
);
store
.backup_to(&catalog, &backup)
.expect("back up relational checkpoint");
assert!(backup.join("relational.2.hawdb").exists());
}
{
let mut catalog = Catalog::default();
let store = GraphStore::open(&path, &mut catalog).expect("load unified checkpoint");
assert_eq!(store.commit_epoch(), 2);
assert_eq!(store.nodes.len(), 2);
assert_eq!(store.relational_state().row_count("messages"), 1);
assert_eq!(
store
.relational_state()
.file_backed_overflow_segment_count(),
1
);
let hydrated = store
.relational_state()
.hydrate_row(
"messages",
&RelationalKey(vec![RelationalValue::Text("message-1".to_string())]),
&mut RelationalHydrationBudget::default(),
)
.expect("hydrate file-backed row")
.expect("message row");
assert_eq!(
hydrated.values()[1],
RelationalValue::Text("payload".repeat(1_024))
);
}
restore_storage_backup(&backup, &restored).expect("restore unified backup");
{
let mut catalog = Catalog::default();
let store =
GraphStore::open(&restored, &mut catalog).expect("open restored checkpoint");
assert_eq!(store.commit_epoch(), 2);
assert_eq!(store.nodes.len(), 2);
assert_eq!(store.relational_state().row_count("messages"), 1);
assert_eq!(
store
.relational_state()
.file_backed_overflow_segment_count(),
1
);
}
fs::remove_dir_all(path).expect("remove test store");
fs::remove_dir_all(backup).expect("remove backup");
fs::remove_dir_all(restored).expect("remove restored store");
}
#[test]
fn mixed_commit_recovers_both_states_after_post_wal_apply_failure() {
let path = unique_test_dir("unified_relational_apply_failure");
{
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).expect("open durable store");
set_wal_apply_failpoint(Some(0));
let error = store
.commit_mutations_and_relational(
&mut catalog,
vec![GraphMutation::CreateNode {
label: "Marker".to_string(),
properties: properties([("id", Value::String("graph-1".to_string()))]),
}],
RelationalTransaction {
writes: vec![RelationalWrite::CreateTable(RelationalTableSchema {
name: "messages".to_string(),
columns: vec![RelationalColumnSchema {
name: "id".to_string(),
scalar_type: RelationalScalarType::Text,
nullable: false,
default: None,
}],
primary_key: vec!["id".to_string()],
unique_constraints: Vec::new(),
foreign_keys: Vec::new(),
indexes: Vec::new(),
})],
},
MutationLimits::default(),
)
.expect_err("injected apply failure must poison the handle");
set_wal_apply_failpoint(None);
assert!(error.to_string().contains("injected failure"));
assert!(store.post_wal_apply_poisoned());
assert_eq!(store.nodes.len(), 0);
assert!(store.relational_state().is_empty());
}
{
let mut catalog = Catalog::default();
let store = GraphStore::open(&path, &mut catalog).expect("recover canonical WAL batch");
assert_eq!(store.commit_epoch(), 1);
assert_eq!(store.nodes.len(), 1);
assert!(store.relational_state().table_schema("messages").is_some());
}
fs::remove_dir_all(path).expect("remove test store");
}
#[test]
fn relational_checkpoint_corruption_fails_scrub_and_reopen() {
let path = unique_test_dir("relational_checkpoint_corruption");
{
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).expect("open durable store");
store
.commit_relational_transaction(
&mut catalog,
RelationalTransaction {
writes: vec![RelationalWrite::CreateTable(RelationalTableSchema {
name: "messages".to_string(),
columns: vec![RelationalColumnSchema {
name: "id".to_string(),
scalar_type: RelationalScalarType::Text,
nullable: false,
default: None,
}],
primary_key: vec!["id".to_string()],
unique_constraints: Vec::new(),
foreign_keys: Vec::new(),
indexes: Vec::new(),
})],
},
)
.expect("create relational table");
store.checkpoint(&catalog).expect("publish checkpoint");
let relational_path = path.join("relational.1.hawdb");
let mut bytes = fs::read(&relational_path).expect("read relational checkpoint");
*bytes.last_mut().expect("relational checkpoint payload") ^= 0xff;
fs::write(&relational_path, bytes).expect("corrupt relational checkpoint");
let error = store
.scrub_storage()
.expect_err("scrub must reject relational corruption");
assert!(error.to_string().contains("relational checkpoint"));
}
let mut catalog = Catalog::default();
let error = GraphStore::open(&path, &mut catalog)
.expect_err("reopen must reject relational corruption");
assert!(error.to_string().contains("relational checkpoint"));
fs::remove_dir_all(path).expect("remove test store");
}
#[test]
fn active_snapshot_detaches_only_the_mutated_adjacency_posting_list() {
let mut catalog = Catalog::default();
let mut store = GraphStore::in_memory();
let source_a = store
.create_node(&mut catalog, "Source", BTreeMap::new())
.unwrap();
let source_b = store
.create_node(&mut catalog, "Source", BTreeMap::new())
.unwrap();
let target_a = store
.create_node(&mut catalog, "Target", BTreeMap::new())
.unwrap();
let target_b = store
.create_node(&mut catalog, "Target", BTreeMap::new())
.unwrap();
store
.create_relationship(
&mut catalog,
source_a,
target_a,
"LINKS_TO",
BTreeMap::new(),
)
.unwrap();
store
.create_relationship(
&mut catalog,
source_b,
target_a,
"LINKS_TO",
BTreeMap::new(),
)
.unwrap();
let rel_type = catalog.rel_type_id("LINKS_TO").unwrap();
let snapshot = store.snapshot();
assert!(store
.outgoing
.get(&(source_a, rel_type))
.unwrap()
.shares_pivot_with(snapshot.outgoing.get(&(source_a, rel_type)).unwrap()));
assert!(store
.outgoing
.get(&(source_b, rel_type))
.unwrap()
.shares_pivot_with(snapshot.outgoing.get(&(source_b, rel_type)).unwrap()));
store
.create_relationship(
&mut catalog,
source_a,
target_b,
"LINKS_TO",
BTreeMap::new(),
)
.unwrap();
assert!(!store
.outgoing
.get(&(source_a, rel_type))
.unwrap()
.shares_pivot_with(snapshot.outgoing.get(&(source_a, rel_type)).unwrap()));
assert!(store
.outgoing
.get(&(source_b, rel_type))
.unwrap()
.shares_pivot_with(snapshot.outgoing.get(&(source_b, rel_type)).unwrap()));
assert_eq!(store.outgoing_relationships(source_a, rel_type).count(), 2);
assert_eq!(
snapshot.outgoing_relationships(source_a, rel_type).count(),
1
);
}
#[test]
fn active_snapshot_buffers_dense_adjacency_mutation_in_a_mini_delta() {
let mut catalog = Catalog::default();
let mut store = GraphStore::in_memory();
let source = store
.create_node(&mut catalog, "Source", BTreeMap::new())
.unwrap();
let mut targets = Vec::new();
for _ in 0..=DENSE_ADJACENCY_DEGREE_THRESHOLD {
targets.push(
store
.create_node(&mut catalog, "Target", BTreeMap::new())
.unwrap(),
);
}
for target in targets.iter().take(DENSE_ADJACENCY_DEGREE_THRESHOLD) {
store
.create_relationship(&mut catalog, source, *target, "LINKS_TO", BTreeMap::new())
.unwrap();
}
let rel_type = catalog.rel_type_id("LINKS_TO").unwrap();
let snapshot = store.snapshot();
store
.create_relationship(
&mut catalog,
source,
targets[DENSE_ADJACENCY_DEGREE_THRESHOLD],
"LINKS_TO",
BTreeMap::new(),
)
.unwrap();
let live_posting = store.outgoing.get(&(source, rel_type)).unwrap();
let snapshot_posting = snapshot.outgoing.get(&(source, rel_type)).unwrap();
assert!(live_posting.shares_pivot_with(snapshot_posting));
assert_eq!(live_posting.mini_delta_len(), 1);
assert_eq!(snapshot_posting.mini_delta_len(), 0);
assert_eq!(
store.outgoing_relationships(source, rel_type).count(),
DENSE_ADJACENCY_DEGREE_THRESHOLD + 1
);
assert_eq!(
snapshot.outgoing_relationships(source, rel_type).count(),
DENSE_ADJACENCY_DEGREE_THRESHOLD
);
}
#[test]
fn bounded_adjacency_consolidation_preserves_snapshot_and_graph_epoch() {
let mut catalog = Catalog::default();
let mut store = GraphStore::in_memory();
let source = store
.create_node(&mut catalog, "Source", BTreeMap::new())
.unwrap();
let target_count = DENSE_ADJACENCY_DEGREE_THRESHOLD
+ hawdb_storage::adjacency::ADJACENCY_DELTA_CONSOLIDATION_ENTRIES;
let targets = (0..target_count)
.map(|_| {
store
.create_node(&mut catalog, "Target", BTreeMap::new())
.unwrap()
})
.collect::<Vec<_>>();
for target in targets.iter().take(DENSE_ADJACENCY_DEGREE_THRESHOLD) {
store
.create_relationship(&mut catalog, source, *target, "LINKS_TO", BTreeMap::new())
.unwrap();
}
let rel_type = catalog.rel_type_id("LINKS_TO").unwrap();
let snapshot = store.snapshot();
for target in targets.iter().skip(DENSE_ADJACENCY_DEGREE_THRESHOLD) {
store
.create_relationship(&mut catalog, source, *target, "LINKS_TO", BTreeMap::new())
.unwrap();
}
let commit_epoch = store.commit_epoch();
let plan = store.adjacency_consolidation_plan();
assert_eq!(plan.group_count, 1);
assert_eq!(
plan.delta_entry_count,
hawdb_storage::adjacency::ADJACENCY_DELTA_CONSOLIDATION_ENTRIES
);
let deferred =
store.consolidate_bounded_adjacency_deltas(plan.estimated_entries.saturating_sub(1));
assert_eq!(deferred.consolidated_group_count, 0);
assert_eq!(deferred.remaining, plan);
let report = store.consolidate_bounded_adjacency_deltas(plan.estimated_entries);
assert_eq!(report.planned, plan);
assert_eq!(report.consolidated_group_count, 1);
assert_eq!(
report.consolidated_delta_entry_count,
plan.delta_entry_count
);
assert_eq!(
report.consolidated_estimated_entries,
plan.estimated_entries
);
assert_eq!(report.remaining, AdjacencyConsolidationPlan::default());
assert_eq!(store.commit_epoch(), commit_epoch);
assert_eq!(
store.outgoing_relationships(source, rel_type).count(),
target_count
);
assert_eq!(
snapshot.outgoing_relationships(source, rel_type).count(),
DENSE_ADJACENCY_DEGREE_THRESHOLD
);
}
#[test]
fn active_snapshot_detaches_only_the_mutated_property_posting_list() {
let mut catalog = Catalog::default();
let mut store = GraphStore::in_memory();
store
.create_property_index(&mut catalog, "Memory", "topic")
.unwrap();
store
.create_node(
&mut catalog,
"Memory",
properties([("topic", Value::String("storage".to_string()))]),
)
.unwrap();
store
.create_node(
&mut catalog,
"Memory",
properties([("topic", Value::String("retrieval".to_string()))]),
)
.unwrap();
let label_id = catalog.label_id("Memory").unwrap();
let storage_key = (
label_id,
"topic".to_string(),
Value::String("storage".to_string()),
);
let retrieval_key = (
label_id,
"topic".to_string(),
Value::String("retrieval".to_string()),
);
let snapshot = store.snapshot();
assert!(store
.property_index
.get(&storage_key)
.unwrap()
.shares_storage_with(snapshot.property_index.get(&storage_key).unwrap()));
assert!(store
.property_index
.get(&retrieval_key)
.unwrap()
.shares_storage_with(snapshot.property_index.get(&retrieval_key).unwrap()));
store
.create_node(
&mut catalog,
"Memory",
properties([("topic", Value::String("storage".to_string()))]),
)
.unwrap();
assert!(!store
.property_index
.get(&storage_key)
.unwrap()
.shares_storage_with(snapshot.property_index.get(&storage_key).unwrap()));
assert!(store
.property_index
.get(&retrieval_key)
.unwrap()
.shares_storage_with(snapshot.property_index.get(&retrieval_key).unwrap()));
assert_eq!(
store
.seek_nodes_by_property(label_id, "topic", &Value::String("storage".to_string()),)
.count(),
2
);
assert_eq!(
snapshot
.seek_nodes_by_property(label_id, "topic", &Value::String("storage".to_string()),)
.count(),
1
);
}
#[test]
fn active_snapshot_detaches_only_the_mutated_map_page() {
let mut catalog = Catalog::default();
let mut store = GraphStore::in_memory();
for id in 0..1_100 {
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(id))]))
.unwrap();
}
let snapshot = store.snapshot();
let segment_count = store.nodes.segment_count();
assert!(segment_count >= 3);
assert_eq!(
store.nodes.shared_segment_count_with(&snapshot.nodes),
segment_count
);
store.apply_set_node_property(
&catalog,
NodeId(0),
"title".to_string(),
Value::String("updated".to_string()),
);
assert_eq!(store.nodes.segment_count(), segment_count);
assert_eq!(
store.nodes.shared_segment_count_with(&snapshot.nodes),
segment_count - 1
);
assert_eq!(
snapshot
.nodes
.get(&NodeId(0))
.unwrap()
.properties
.get("title"),
None
);
assert_eq!(
store.nodes.get(&NodeId(0)).unwrap().properties.get("title"),
Some(&Value::String("updated".to_string()))
);
}
#[test]
fn large_records_split_cow_pages_by_estimated_bytes() {
let mut catalog = Catalog::default();
let mut store = GraphStore::in_memory();
for id in 0..8 {
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(id))]))
.unwrap();
}
let snapshot = store.snapshot();
assert_eq!(store.nodes.segment_count(), 1);
store.apply_set_node_property(
&catalog,
NodeId(0),
"body".to_string(),
Value::String("x".repeat(COW_MAP_TARGET_SEGMENT_BYTES * 2)),
);
assert_eq!(snapshot.nodes.segment_count(), 1);
assert!(store.nodes.segment_count() >= 2);
assert_eq!(store.nodes.shared_segment_count_with(&snapshot.nodes), 0);
assert!(!snapshot
.nodes
.get(&NodeId(0))
.unwrap()
.properties
.contains_key("body"));
}
#[test]
fn replays_relationships_from_wal_and_rebuilds_adjacency() {
let path = unique_test_dir("rel_wal");
{
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
let source = store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(1))]))
.unwrap();
let target = store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(2))]))
.unwrap();
store
.create_relationship(
&mut catalog,
source,
target,
"RELATES_TO",
properties([("weight", Value::Int(7))]),
)
.unwrap();
}
{
let mut catalog = Catalog::default();
let store = GraphStore::open(&path, &mut catalog).unwrap();
let rel_type = catalog.rel_type_id("RELATES_TO").unwrap();
let rels = store
.outgoing_relationships(NodeId(0), rel_type)
.collect::<Vec<_>>();
assert_eq!(rels.len(), 1);
assert_eq!(rels[0].target, NodeId(1));
assert_eq!(rels[0].properties.get("weight"), Some(&Value::Int(7)));
}
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn backup_restore_publishes_one_verified_generation() {
let path = unique_test_dir("backup_source");
let backup = unique_test_dir("backup_image");
let restored = unique_test_dir("backup_restored");
let content = "backup-property-spill-".repeat(8_192);
let report = {
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
let source = store
.create_node(
&mut catalog,
"Memory",
properties([
("id", Value::String("memory-1".to_string())),
("content", Value::String(content.clone())),
]),
)
.unwrap();
let target = store
.create_node(
&mut catalog,
"Entity",
properties([("id", Value::String("entity-1".to_string()))]),
)
.unwrap();
store
.create_relationship(
&mut catalog,
source,
target,
"MENTIONS",
properties([("weight", Value::Int(7))]),
)
.unwrap();
store.backup_to(&catalog, &backup).unwrap()
};
assert!(report.generation > 0);
assert_eq!(report.file_count, 26);
assert!(backup
.join(hawdb_storage::append_table::append_generation_manifest_file(report.generation))
.exists());
assert!(backup
.join(
hawdb_storage::canonical::canonical_segment_descriptor_page_file(report.generation)
)
.exists());
assert!(backup
.join(
hawdb_storage::canonical::canonical_segment_descriptor_root_file(report.generation)
)
.exists());
assert!(backup
.join(
hawdb_storage::canonical_adjacency::canonical_adjacency_descriptor_page_file(
report.generation
)
)
.exists());
assert!(backup
.join(
hawdb_storage::canonical_adjacency::canonical_adjacency_descriptor_root_file(
report.generation
)
)
.exists());
assert!(backup
.join(
hawdb_storage::property_projection::property_projection_descriptor_page_file(
report.generation
)
)
.exists());
assert!(backup
.join(
hawdb_storage::property_projection::property_projection_descriptor_root_file(
report.generation
)
)
.exists());
assert!(backup
.join(
hawdb_storage::property_spill::property_spill_descriptor_page_file(
report.generation
)
)
.exists());
assert!(backup
.join(
hawdb_storage::property_spill::property_spill_descriptor_root_file(
report.generation
)
)
.exists());
let restore = restore_storage_backup(&backup, &restored).unwrap();
assert_eq!(restore.generation, report.generation);
assert_eq!(restore.manifest_checksum, report.manifest_checksum);
let mut catalog = Catalog::default();
let store = GraphStore::open(&restored, &mut catalog).unwrap();
assert_eq!(store.scan_nodes(None).count(), 2);
assert_eq!(store.scan_relationships(None).count(), 1);
assert_eq!(
store.node(NodeId(0)).unwrap().properties["content"],
Value::String(content)
);
assert_eq!(
store.relationship(RelId(0)).unwrap().properties["weight"],
Value::Int(7)
);
std::fs::remove_dir_all(path).unwrap();
std::fs::remove_dir_all(backup).unwrap();
drop(store);
std::fs::remove_dir_all(restored).unwrap();
}
#[test]
fn restore_rejects_corrupt_backup_before_destination_publication() {
let path = unique_test_dir("backup_corrupt_source");
let backup = unique_test_dir("backup_corrupt_image");
let restored = unique_test_dir("backup_corrupt_restored");
{
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
store
.create_node(
&mut catalog,
"Memory",
properties([("id", Value::String("memory-1".to_string()))]),
)
.unwrap();
store.backup_to(&catalog, &backup).unwrap();
}
let checkpoint = active_checkpoint_path(&backup);
let mut bytes = std::fs::read(&checkpoint).unwrap();
bytes[0] ^= 0xff;
std::fs::write(checkpoint, bytes).unwrap();
let error = restore_storage_backup(&backup, &restored).unwrap_err();
assert!(error.to_string().contains("verification failed"));
assert!(!restored.exists());
std::fs::remove_dir_all(path).unwrap();
std::fs::remove_dir_all(backup).unwrap();
}
#[test]
fn backup_and_restore_never_replace_existing_destinations() {
let path = unique_test_dir("backup_existing_source");
let backup = unique_test_dir("backup_existing_image");
let restored = unique_test_dir("backup_existing_restored");
std::fs::create_dir_all(&backup).unwrap();
std::fs::create_dir_all(&restored).unwrap();
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
let backup_error = store.backup_to(&catalog, &backup).unwrap_err();
assert!(
backup_error.to_string().contains("already exists"),
"unexpected backup error: {backup_error}"
);
std::fs::remove_dir_all(&backup).unwrap();
store.backup_to(&catalog, &backup).unwrap();
let restore_error = restore_storage_backup(&backup, &restored).unwrap_err();
assert!(restore_error.to_string().contains("already exists"));
drop(store);
std::fs::remove_dir_all(path).unwrap();
std::fs::remove_dir_all(backup).unwrap();
std::fs::remove_dir_all(restored).unwrap();
}
#[test]
fn checkpoint_segment_scan_rejects_a_manifest_behind_the_graph_snapshot() {
let mut catalog = Catalog::default();
let mut store = GraphStore::in_memory();
let manifest = ScanSegmentManifest::new(0, Vec::new()).unwrap();
assert!(matches!(
store.plan_checkpoint_segment_scan(Some(&manifest), &ScanPredicate::True),
ScanSegmentAccessPlan::Read(_)
));
store
.create_node(
&mut catalog,
"Source",
properties([("id", Value::String("source-1".to_string()))]),
)
.unwrap();
assert!(matches!(
store.plan_checkpoint_segment_scan(Some(&manifest), &ScanPredicate::True),
ScanSegmentAccessPlan::Fallback(ScanSegmentFallback::SnapshotEpochMismatch {
reader_epoch: 1,
manifest_epoch: 0,
})
));
assert!(matches!(
store.plan_checkpoint_segment_scan(None, &ScanPredicate::True),
ScanSegmentAccessPlan::Fallback(ScanSegmentFallback::NoManifest)
));
}
#[test]
fn checkpoint_publishes_source_scan_and_wal_mutation_invalidates_it() {
let path = unique_test_dir("source_scan_checkpoint");
{
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
store
.create_node(
&mut catalog,
"Source",
properties([
("id", Value::String("source-a".to_string())),
("space_id", Value::String("alpha".to_string())),
]),
)
.unwrap();
store.checkpoint(&catalog).unwrap();
assert!(matches!(
store.plan_published_source_scan(&ScanPredicate::Eq {
property: "space_id".to_string(),
value: Value::String("alpha".to_string()),
}),
ScanSegmentAccessPlan::Read(_)
));
let SourceScanCandidateRead::Rows { rows, report, .. } = store
.read_published_source_scan_candidates(
&ScanPredicate::Eq {
property: "space_id".to_string(),
value: Value::String("alpha".to_string()),
},
NonZeroUsize::new(2).unwrap(),
NonZeroU64::new(1024).unwrap(),
NonZeroU64::new(1024).unwrap(),
)
.unwrap()
else {
panic!("expected source scan payload read");
};
assert_eq!(report.range_count, 1);
assert_eq!(rows.len(), 1);
assert_eq!(
rows[0].properties["id"],
Value::String("source-a".to_string())
);
store
.create_node(
&mut catalog,
"Memory",
properties([("id", Value::String("memory-a".to_string()))]),
)
.unwrap();
assert!(matches!(
store.plan_published_source_scan(&ScanPredicate::True),
ScanSegmentAccessPlan::Fallback(ScanSegmentFallback::SnapshotEpochMismatch { .. })
));
}
{
let mut catalog = Catalog::default();
let store = GraphStore::open(&path, &mut catalog).unwrap();
assert!(matches!(
store.plan_published_source_scan(&ScanPredicate::True),
ScanSegmentAccessPlan::Fallback(ScanSegmentFallback::SnapshotEpochMismatch { .. })
));
}
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn checkpoint_publishes_digest_bound_canonical_segments() {
let path = unique_test_dir("canonical_segment_checkpoint");
{
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
let source = store
.create_node(
&mut catalog,
"Memory",
properties([("id", Value::String("memory-1".to_string()))]),
)
.unwrap();
let target = store
.create_node(
&mut catalog,
"Entity",
properties([("id", Value::String("entity-1".to_string()))]),
)
.unwrap();
store
.create_relationship(
&mut catalog,
source,
target,
"MENTIONS",
properties([("weight", Value::Int(5))]),
)
.unwrap();
store.checkpoint(&catalog).unwrap();
let manifest = store.canonical_segment_manifest().unwrap();
assert_eq!(manifest.node_count, 2);
assert_eq!(manifest.relationship_count, 1);
assert_eq!(
store.canonical_node_from_segments(source).unwrap(),
store.node(source).cloned()
);
assert_eq!(
store
.canonical_relationship_from_segments(RelId(0))
.unwrap(),
store.relationship(RelId(0)).cloned()
);
}
{
let mut catalog = Catalog::default();
let store = GraphStore::open(&path, &mut catalog).unwrap();
assert_eq!(store.canonical_segment_manifest().unwrap().node_count, 2);
assert_eq!(
store.canonical_node_from_segments(NodeId(1)).unwrap(),
store.node(NodeId(1)).cloned()
);
}
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn graph_manifest_open_budget_is_aggregate_across_published_roots() {
let path = unique_test_dir("graph_manifest_open_budget");
{
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
let source = store
.create_node(&mut catalog, "Memory", BTreeMap::new())
.unwrap();
let target = store
.create_node(&mut catalog, "Entity", BTreeMap::new())
.unwrap();
store
.create_relationship(&mut catalog, source, target, "MENTIONS", BTreeMap::new())
.unwrap();
store.checkpoint(&catalog).unwrap();
}
let canonical_manifest_bytes =
fs::metadata(path.join(canonical_manifest_generation_file(1)))
.unwrap()
.len();
{
let mut catalog = Catalog::default();
let store = GraphStore::open(&path, &mut catalog).unwrap();
let residency = store.storage_residency_report();
assert_eq!(
residency.graph_manifest_open_budget_bytes,
hawdb_storage::config::DEFAULT_MAX_GRAPH_MANIFEST_OPEN_BYTES
);
assert!(residency.graph_manifest_encoded_bytes > canonical_manifest_bytes);
}
let replay_config = WalReplayConfig {
max_graph_manifest_open_bytes: canonical_manifest_bytes,
..WalReplayConfig::default()
};
let mut catalog = Catalog::default();
let error = GraphStore::open_with_durability_and_replay_config(
&path,
&mut catalog,
DurabilityPolicy::default(),
replay_config,
)
.unwrap_err();
assert!(
error
.to_string()
.contains("aggregate encoded graph manifest bytes during open"),
"unexpected graph manifest admission error: {error}"
);
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn zero_graph_manifest_open_budget_is_rejected_before_initialization() {
let path = unique_test_dir("zero_graph_manifest_open_budget");
let replay_config = WalReplayConfig {
max_graph_manifest_open_bytes: 0,
..WalReplayConfig::default()
};
let mut catalog = Catalog::default();
let error = GraphStore::open_with_durability_and_replay_config(
&path,
&mut catalog,
DurabilityPolicy::default(),
replay_config,
)
.unwrap_err();
assert!(error
.to_string()
.contains("max_graph_manifest_open_bytes must be non-zero"));
assert!(!path.exists());
}
#[test]
fn checkpoint_uses_compact_adjacency_root_and_enforces_manifest_budget_before_selection() {
let path = unique_test_dir("checkpoint_graph_manifest_budget");
let replay_config = WalReplayConfig {
max_graph_manifest_open_bytes: 64 * 1024,
..WalReplayConfig::default()
};
let mut catalog = Catalog::default();
let mut store = GraphStore::open_with_durability_and_replay_config(
&path,
&mut catalog,
DurabilityPolicy::default(),
replay_config,
)
.unwrap();
store
.commit_mutations(
&mut catalog,
vec![GraphMutation::MergeConnectedNodes(ConnectedNodesCreate {
source_label: "Memory".to_string(),
source_properties: properties([("id", Value::String("source-0".to_string()))]),
rel_type: "MENTIONS".to_string(),
rel_properties: BTreeMap::new(),
target_label: "Entity".to_string(),
target_properties: properties([("id", Value::String("target-0".to_string()))]),
})],
)
.unwrap();
store.checkpoint(&catalog).unwrap();
assert_eq!(store.durable.as_ref().unwrap().checkpoint_epoch, 1);
let mutations = (1..=512)
.map(|ordinal| {
GraphMutation::MergeConnectedNodes(ConnectedNodesCreate {
source_label: "Memory".to_string(),
source_properties: properties([(
"id",
Value::String(format!("source-{ordinal}")),
)]),
rel_type: "MENTIONS".to_string(),
rel_properties: BTreeMap::new(),
target_label: "Entity".to_string(),
target_properties: properties([(
"id",
Value::String(format!("target-{ordinal}")),
)]),
})
})
.collect();
store.commit_mutations(&mut catalog, mutations).unwrap();
store.checkpoint(&catalog).unwrap();
assert_eq!(store.durable.as_ref().unwrap().checkpoint_epoch, 2);
assert!(
store
.durable
.as_ref()
.unwrap()
.graph_manifest_encoded_bytes()
<= replay_config.max_graph_manifest_open_bytes
);
store
.commit_mutations(
&mut catalog,
vec![GraphMutation::MergeConnectedNodes(ConnectedNodesCreate {
source_label: "Memory".to_string(),
source_properties: properties([(
"id",
Value::String("source-513".to_string()),
)]),
rel_type: "MENTIONS".to_string(),
rel_properties: BTreeMap::new(),
target_label: "Entity".to_string(),
target_properties: properties([(
"id",
Value::String("target-513".to_string()),
)]),
})],
)
.unwrap();
store
.durable
.as_mut()
.unwrap()
.set_graph_manifest_open_budget_bytes(1);
let error = store.checkpoint(&catalog).unwrap_err();
assert!(
error
.to_string()
.contains("aggregate encoded graph manifest bytes during open"),
"unexpected graph manifest checkpoint admission error: {error}"
);
assert_eq!(store.durable.as_ref().unwrap().checkpoint_epoch, 2);
let selected_manifest = DurableManifest::load(&path.join(MANIFEST_FILE)).unwrap();
assert_eq!(selected_manifest.checkpoint_epoch, 2);
drop(store);
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn graph_manifest_growth_is_rejected_before_unbounded_read() {
let path = unique_test_dir("graph_manifest_growth");
{
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
store
.create_node(&mut catalog, "Memory", BTreeMap::new())
.unwrap();
store.checkpoint(&catalog).unwrap();
}
let manifest_path = path.join(canonical_manifest_generation_file(1));
let original_len = fs::metadata(&manifest_path).unwrap().len();
OpenOptions::new()
.write(true)
.open(&manifest_path)
.unwrap()
.set_len(original_len.saturating_add(1024 * 1024))
.unwrap();
let mut catalog = Catalog::default();
let error = GraphStore::open(&path, &mut catalog).unwrap_err();
assert!(
error.to_string().contains("exceeding its admitted bound"),
"unexpected graph manifest growth error: {error}"
);
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn out_of_core_checkpoint_uses_generation_bound_dense_adjacency_and_fails_closed() {
use std::io::{Seek, SeekFrom};
let path = unique_test_dir("canonical_adjacency_checkpoint");
let replay_config = WalReplayConfig {
residency_mode: StorageResidencyMode::OutOfCore,
..WalReplayConfig::default()
};
let source;
let first_relationship;
let corrupt_offset;
{
let mut catalog = Catalog::default();
let mut store = GraphStore::open_with_durability_and_replay_config(
&path,
&mut catalog,
DurabilityPolicy::default(),
replay_config,
)
.unwrap();
source = store
.create_node(&mut catalog, "Memory", BTreeMap::new())
.unwrap();
first_relationship = RelId(0);
for index in 0..70 {
let target = store
.create_node(
&mut catalog,
"Entity",
properties([("ordinal", Value::Int(index))]),
)
.unwrap();
store
.create_relationship(&mut catalog, source, target, "MENTIONS", BTreeMap::new())
.unwrap();
}
store.checkpoint(&catalog).unwrap();
let adjacency = store
.durable
.as_ref()
.and_then(|durable| durable.canonical_adjacency.as_ref())
.unwrap();
let cache_before_scrub = store.segment_cache_snapshot().unwrap();
let scrub = adjacency.deep_scrub().unwrap();
assert_eq!(store.segment_cache_snapshot().unwrap(), cache_before_scrub);
assert!(!path.join("adjacency.1.manifest.hawdb").exists());
let descriptor_reader = hawdb_storage::graph_descriptor_tree::GraphDescriptorTreeRootReader::open(
hawdb_storage::graph_descriptor_tree::GraphDescriptorTreePaths::new(
path.join(hawdb_storage::canonical_adjacency::canonical_adjacency_descriptor_page_file(1)),
path.join(hawdb_storage::canonical_adjacency::canonical_adjacency_descriptor_root_file(1)),
),
hawdb_storage::graph_descriptor_tree::GraphDescriptorTreeBuildConfig::default(),
)
.unwrap();
assert_eq!(
descriptor_reader.root().descriptor_count,
scrub.descriptors_checked
);
assert_eq!(
descriptor_reader.root().source_commit_epoch,
store.commit_epoch
);
assert_eq!(descriptor_reader.report().page_payload_bytes_read, 0);
let mention_type = catalog.rel_type_id("MENTIONS").unwrap();
corrupt_offset = 24 + 48;
let mut relationship_ids = Vec::new();
store
.visit_adjacent_relationships_owned(
source,
Some(mention_type),
AdjacencyDirection::Outgoing,
|relationship| {
relationship_ids.push(relationship.id);
GraphScanControl::Continue
},
)
.unwrap();
assert_eq!(relationship_ids.len(), 70);
let mut ordered_prefix = Vec::new();
let control = store
.try_visit_ordered_adjacent_relationships_owned(
source,
Some(mention_type),
AdjacencyDirection::Outgoing,
1024 * 1024,
|relationship| {
ordered_prefix.push(relationship.id);
Ok(if ordered_prefix.len() == 5 {
GraphScanControl::Stop
} else {
GraphScanControl::Continue
})
},
)
.unwrap();
assert_eq!(control, GraphScanControl::Stop);
assert_eq!(ordered_prefix, (0..5).map(RelId).collect::<Vec<_>>());
let cold = store.storage_residency_report().graph_index_reads;
assert!(cold.adjacency_dense_blocks_read > 0);
assert!(cold.adjacency_descriptor_pages_visited > 0);
assert!(cold.adjacency_descriptor_page_bytes_decoded > 0);
assert!(cold.adjacency_descriptor_storage_bytes_read > 0);
assert!(cold.adjacency_descriptor_cache_misses > 0);
let target = store
.create_node(&mut catalog, "Entity", BTreeMap::new())
.unwrap();
store
.create_relationship(&mut catalog, source, target, "MENTIONS", BTreeMap::new())
.unwrap();
relationship_ids.clear();
store
.visit_adjacent_relationships_owned(
source,
Some(mention_type),
AdjacencyDirection::Outgoing,
|relationship| {
relationship_ids.push(relationship.id);
GraphScanControl::Continue
},
)
.unwrap();
assert_eq!(relationship_ids.len(), 71);
assert!(relationship_ids.contains(&first_relationship));
let warm = store
.storage_residency_report()
.graph_index_reads
.delta_since(cold);
assert!(warm.adjacency_descriptor_cache_hits > 0);
assert!(warm.adjacency_descriptor_page_bytes_decoded > 0);
assert_eq!(warm.adjacency_descriptor_storage_bytes_read, 0);
}
{
let mut file = OpenOptions::new()
.read(true)
.write(true)
.open(path.join(canonical_adjacency_artifact_generation_file(1)))
.unwrap();
file.seek(SeekFrom::Start(corrupt_offset)).unwrap();
file.write_all(&[0xff]).unwrap();
file.sync_all().unwrap();
}
{
let mut catalog = Catalog::default();
let store = GraphStore::open_with_durability_and_replay_config(
&path,
&mut catalog,
DurabilityPolicy::default(),
replay_config,
)
.unwrap();
let mention_type = catalog.rel_type_id("MENTIONS").unwrap();
let error = store
.visit_adjacent_relationships_owned(
source,
Some(mention_type),
AdjacencyDirection::Outgoing,
|_| GraphScanControl::Continue,
)
.unwrap_err();
assert!(
error.to_string().contains("content digest verification"),
"unexpected adjacency corruption error: {error}"
);
let poisoned = store
.visit_adjacent_relationships_owned(
source,
Some(mention_type),
AdjacencyDirection::Outgoing,
|_| GraphScanControl::Continue,
)
.unwrap_err();
assert!(poisoned.to_string().contains("poisoned"));
}
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn out_of_core_checkpoint_requires_adjacency_generation_binding() {
let path = unique_test_dir("canonical_adjacency_required");
let replay_config = WalReplayConfig {
residency_mode: StorageResidencyMode::OutOfCore,
..WalReplayConfig::default()
};
{
let mut catalog = Catalog::default();
let mut store = GraphStore::open_with_durability_and_replay_config(
&path,
&mut catalog,
DurabilityPolicy::default(),
replay_config,
)
.unwrap();
let source = store
.create_node(&mut catalog, "Memory", BTreeMap::new())
.unwrap();
let target = store
.create_node(&mut catalog, "Entity", BTreeMap::new())
.unwrap();
store
.create_relationship(&mut catalog, source, target, "MENTIONS", BTreeMap::new())
.unwrap();
store.checkpoint(&catalog).unwrap();
}
let manifest_path = path.join(MANIFEST_FILE);
let manifest = fs::read_to_string(&manifest_path).unwrap();
let mut body = manifest
.lines()
.filter(|line| !line.starts_with("checksum\t"))
.map(|line| {
if line.starts_with("canonical_adjacency_") {
let (field, _) = line.split_once('\t').unwrap();
format!("{field}\tnone")
} else {
line.to_string()
}
})
.collect::<Vec<_>>()
.join("\n");
body.push('\n');
fs::write(
&manifest_path,
format!("{body}checksum\t{}\n", checksum_bytes(body.as_bytes())),
)
.unwrap();
fs::remove_file(path.join(canonical_adjacency_artifact_generation_file(1))).unwrap();
fs::remove_file(
path.join(
hawdb_storage::canonical_adjacency::canonical_adjacency_descriptor_page_file(1),
),
)
.unwrap();
fs::remove_file(
path.join(
hawdb_storage::canonical_adjacency::canonical_adjacency_descriptor_root_file(1),
),
)
.unwrap();
let mut catalog = Catalog::default();
let error = GraphStore::open_with_durability_and_replay_config(
&path,
&mut catalog,
DurabilityPolicy::default(),
replay_config,
)
.unwrap_err();
assert!(error
.to_string()
.contains("canonical segments require canonical adjacency"));
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn out_of_core_property_spills_round_trip_and_fail_closed() {
use std::io::{Seek, SeekFrom};
let path = unique_test_dir("property_spill_checkpoint");
let replay_config = WalReplayConfig {
residency_mode: StorageResidencyMode::OutOfCore,
..WalReplayConfig::default()
};
let content = "property-spill-value-".repeat(8_192);
let node_id;
let corrupt_offset;
{
let mut catalog = Catalog::default();
let mut store = GraphStore::open_with_durability_and_replay_config(
&path,
&mut catalog,
DurabilityPolicy::default(),
replay_config,
)
.unwrap();
node_id = store
.create_node(
&mut catalog,
"Memory",
properties([("content", Value::String(content.clone()))]),
)
.unwrap();
let target = store
.create_node(&mut catalog, "Entity", BTreeMap::new())
.unwrap();
store
.create_relationship(
&mut catalog,
node_id,
target,
"MENTIONS",
properties([("context", Value::String(content.clone()))]),
)
.unwrap();
store.checkpoint(&catalog).unwrap();
store.scrub_storage().unwrap();
let spill_manifest = store.property_spill_manifest().unwrap();
assert_eq!(spill_manifest.value_count, 2);
assert!(spill_manifest.value_bytes > 64 * 1024);
assert_eq!(spill_manifest.source_commit_epoch, store.commit_epoch());
let descriptor_root =
hawdb_storage::graph_descriptor_tree::GraphDescriptorTreeRootReader::open_bound(
hawdb_storage::graph_descriptor_tree::GraphDescriptorTreePaths::new(
path.join(
hawdb_storage::property_spill::property_spill_descriptor_page_file(1),
),
path.join(
hawdb_storage::property_spill::property_spill_descriptor_root_file(1),
),
),
spill_manifest.descriptor_generation_artifacts(),
hawdb_storage::graph_descriptor_tree::GraphDescriptorTreeBuildConfig::default(),
)
.unwrap();
assert_eq!(
descriptor_root.root().descriptor_count,
spill_manifest.block_count
);
corrupt_offset = 40;
let canonical_manifest = store.canonical_segment_manifest().unwrap();
assert!(canonical_manifest.artifact_len < 16 * 1024);
assert_eq!(canonical_manifest.node_segment_count, 1);
assert_eq!(
store.node_owned(node_id).unwrap().unwrap().properties["content"],
Value::String(content.clone())
);
let mention_type = catalog.rel_type_id("MENTIONS").unwrap();
let mut contexts = Vec::new();
store
.visit_adjacent_relationships_owned(
node_id,
Some(mention_type),
AdjacencyDirection::Outgoing,
|relationship| {
contexts.push(relationship.properties["context"].clone());
GraphScanControl::Continue
},
)
.unwrap();
assert_eq!(contexts, vec![Value::String(content.clone())]);
}
{
let mut catalog = Catalog::default();
let store = GraphStore::open_with_durability_and_replay_config(
&path,
&mut catalog,
DurabilityPolicy::default(),
replay_config,
)
.unwrap();
assert_eq!(
store.node_owned(node_id).unwrap().unwrap().properties["content"],
Value::String(content)
);
}
{
let mut file = OpenOptions::new()
.read(true)
.write(true)
.open(path.join(property_spill_artifact_generation_file(1)))
.unwrap();
file.seek(SeekFrom::Start(corrupt_offset)).unwrap();
file.write_all(&[0xff]).unwrap();
file.sync_all().unwrap();
}
{
let mut catalog = Catalog::default();
let error = GraphStore::open_with_durability_and_replay_config(
&path,
&mut catalog,
DurabilityPolicy::default(),
replay_config,
)
.unwrap_err();
assert!(
error.to_string().contains("content digest verification"),
"unexpected property spill corruption error: {error}"
);
}
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn out_of_core_property_spill_requires_the_selected_descriptor_root() {
let path = unique_test_dir("property_spill_descriptor_root");
let replay_config = WalReplayConfig {
residency_mode: StorageResidencyMode::OutOfCore,
..WalReplayConfig::default()
};
{
let mut catalog = Catalog::default();
let mut store = GraphStore::open_with_durability_and_replay_config(
&path,
&mut catalog,
DurabilityPolicy::default(),
replay_config,
)
.unwrap();
store
.create_node(&mut catalog, "Memory", BTreeMap::new())
.unwrap();
store.checkpoint(&catalog).unwrap();
}
let descriptor_path =
path.join(hawdb_storage::property_spill::property_spill_descriptor_root_file(1));
let descriptor_bytes = fs::read(&descriptor_path).unwrap();
fs::remove_file(&descriptor_path).unwrap();
let missing = fs::File::open(&descriptor_path).unwrap_err();
assert_eq!(missing.kind(), std::io::ErrorKind::NotFound);
let mut catalog = Catalog::default();
let error = GraphStore::open_with_durability_and_replay_config(
&path,
&mut catalog,
DurabilityPolicy::default(),
replay_config,
)
.unwrap_err();
assert_eq!(error, HawDBError::Storage(missing.to_string()));
fs::write(&descriptor_path, descriptor_bytes).unwrap();
{
let mut catalog = Catalog::default();
let store = GraphStore::open_with_durability_and_replay_config(
&path,
&mut catalog,
DurabilityPolicy::default(),
replay_config,
)
.unwrap();
assert_eq!(store.node_count_for_label(None), 1);
}
fs::remove_dir_all(path).unwrap();
}
#[test]
fn out_of_core_property_projections_merge_wal_delta_and_fail_closed() {
use hawdb_storage::property_projection::PersistentPropertyProjectionKind;
use std::io::{Seek, SeekFrom};
let path = unique_test_dir("property_projection_checkpoint");
let replay_config = WalReplayConfig {
residency_mode: StorageResidencyMode::OutOfCore,
..WalReplayConfig::default()
};
let first_id;
let second_id;
let delta_id;
let corrupt_offset;
let composite_properties = vec!["key".to_string(), "rank".to_string()];
{
let mut catalog = Catalog::default();
let mut store = GraphStore::open_with_durability_and_replay_config(
&path,
&mut catalog,
DurabilityPolicy::default(),
replay_config,
)
.unwrap();
store
.create_range_property_index(&mut catalog, "Memory", "rank")
.unwrap();
store
.create_property_index(&mut catalog, "Memory", "key")
.unwrap();
store
.create_full_text_property_index(&mut catalog, "Memory", "content")
.unwrap();
store
.create_composite_property_index(&mut catalog, "Memory", &composite_properties)
.unwrap();
first_id = store
.create_node(
&mut catalog,
"Memory",
properties([
("key", Value::String("first".to_string())),
("rank", Value::Int(10)),
("content", Value::String("alpha beta".to_string())),
]),
)
.unwrap();
second_id = store
.create_node(
&mut catalog,
"Memory",
properties([
("key", Value::String("second".to_string())),
("rank", Value::Int(20)),
("content", Value::String("beta gamma".to_string())),
]),
)
.unwrap();
store
.create_node(
&mut catalog,
"Memory",
properties([
("key", Value::String("third".to_string())),
("rank", Value::Int(30)),
("content", Value::String("delta".to_string())),
]),
)
.unwrap();
store.checkpoint(&catalog).unwrap();
let label_id = catalog.label_id("Memory").unwrap();
let manifest = store.persistent_property_projection_manifest().unwrap();
assert!(manifest.supports(label_id, "key", PersistentPropertyProjectionKind::Equality));
assert!(manifest.supports(label_id, "rank", PersistentPropertyProjectionKind::Range));
assert!(manifest.supports(
label_id,
"content",
PersistentPropertyProjectionKind::FullText
));
assert!(manifest.supports_composite_equality(label_id, &composite_properties));
let graph_index_reads_before = store.storage_residency_report().graph_index_reads;
let read_snapshot = store.snapshot();
corrupt_offset = property_projection_block_corrupt_offset(
&path.join(property_projection_artifact_generation_file(1)),
4,
);
let lower = (Value::Int(15), true);
let upper = (Value::Int(25), true);
let mut range_ids = Vec::new();
store
.visit_nodes_by_property_range_owned(
label_id,
"rank",
Some(&lower),
Some(&upper),
|node| {
range_ids.push(node.id);
GraphScanControl::Continue
},
)
.unwrap();
assert_eq!(range_ids, vec![second_id]);
let mut equality_ids = Vec::new();
read_snapshot
.visit_nodes_by_property_owned(
label_id,
"key",
&[Value::String("second".to_string())],
|node| {
equality_ids.push(node.id);
GraphScanControl::Continue
},
)
.unwrap();
assert_eq!(equality_ids, vec![second_id]);
let mut full_text_ids = Vec::new();
store
.visit_nodes_by_full_text_property_owned(label_id, "content", "beta", |node| {
full_text_ids.push(node.id);
GraphScanControl::Continue
})
.unwrap();
assert_eq!(full_text_ids, vec![first_id, second_id]);
let mut composite_ids = Vec::new();
store
.visit_nodes_by_composite_property_owned(
label_id,
&[
("key".to_string(), Value::String("second".to_string())),
("rank".to_string(), Value::Int(20)),
],
|node| {
composite_ids.push(node.id);
GraphScanControl::Continue
},
)
.unwrap();
assert_eq!(composite_ids, vec![second_id]);
let graph_index_reads = store
.storage_residency_report()
.graph_index_reads
.delta_since(graph_index_reads_before);
assert_eq!(
graph_index_reads.operation_count(PersistentGraphIndexClass::NodeEquality),
1
);
assert_eq!(
graph_index_reads.operation_count(PersistentGraphIndexClass::NodeRange),
1
);
assert_eq!(
graph_index_reads.operation_count(PersistentGraphIndexClass::NodeFullText),
1
);
assert_eq!(
graph_index_reads.operation_count(PersistentGraphIndexClass::NodeCompositeEquality),
1
);
assert!(graph_index_reads.property_blocks_read >= 4);
assert!(graph_index_reads.property_bytes_read > 0);
let second_filter = PropertyFilter::IdEq {
value: Value::Int(second_id.0 as i64),
};
store
.set_node_property(
&mut catalog,
"Memory",
Some(&second_filter),
"key",
Value::String("updated".to_string()),
)
.unwrap();
store
.set_node_property(
&mut catalog,
"Memory",
Some(&second_filter),
"rank",
Value::Int(40),
)
.unwrap();
store
.set_node_property(
&mut catalog,
"Memory",
Some(&second_filter),
"content",
Value::String("omega".to_string()),
)
.unwrap();
let first_filter = PropertyFilter::IdEq {
value: Value::Int(first_id.0 as i64),
};
store
.delete_nodes(&mut catalog, "Memory", Some(&first_filter), false)
.unwrap();
delta_id = store
.create_node(
&mut catalog,
"Memory",
properties([
("key", Value::String("second".to_string())),
("rank", Value::Int(18)),
("content", Value::String("beta epsilon".to_string())),
]),
)
.unwrap();
range_ids.clear();
store
.visit_nodes_by_property_range_owned(
label_id,
"rank",
Some(&lower),
Some(&upper),
|node| {
range_ids.push(node.id);
GraphScanControl::Continue
},
)
.unwrap();
assert_eq!(range_ids, vec![delta_id]);
equality_ids.clear();
store
.visit_nodes_by_property_owned(
label_id,
"key",
&[Value::String("second".to_string())],
|node| {
equality_ids.push(node.id);
GraphScanControl::Continue
},
)
.unwrap();
assert_eq!(equality_ids, vec![delta_id]);
full_text_ids.clear();
store
.visit_nodes_by_full_text_property_owned(label_id, "content", "beta", |node| {
full_text_ids.push(node.id);
GraphScanControl::Continue
})
.unwrap();
assert_eq!(full_text_ids, vec![delta_id]);
composite_ids.clear();
store
.visit_nodes_by_composite_property_owned(
label_id,
&[
("key".to_string(), Value::String("second".to_string())),
("rank".to_string(), Value::Int(18)),
],
|node| {
composite_ids.push(node.id);
GraphScanControl::Continue
},
)
.unwrap();
assert_eq!(composite_ids, vec![delta_id]);
composite_ids.clear();
store
.visit_nodes_by_composite_property_owned(
label_id,
&[
("key".to_string(), Value::String("updated".to_string())),
("rank".to_string(), Value::Int(40)),
],
|node| {
composite_ids.push(node.id);
GraphScanControl::Continue
},
)
.unwrap();
assert_eq!(composite_ids, vec![second_id]);
}
{
let mut catalog = Catalog::default();
let store = GraphStore::open_with_durability_and_replay_config(
&path,
&mut catalog,
DurabilityPolicy::default(),
replay_config,
)
.unwrap();
let label_id = catalog.label_id("Memory").unwrap();
let lower = (Value::Int(15), true);
let upper = (Value::Int(25), true);
let mut range_ids = Vec::new();
store
.visit_nodes_by_property_range_owned(
label_id,
"rank",
Some(&lower),
Some(&upper),
|node| {
range_ids.push(node.id);
GraphScanControl::Continue
},
)
.unwrap();
assert_eq!(range_ids, vec![delta_id]);
let mut equality_ids = Vec::new();
store
.visit_nodes_by_property_owned(
label_id,
"key",
&[Value::String("second".to_string())],
|node| {
equality_ids.push(node.id);
GraphScanControl::Continue
},
)
.unwrap();
assert_eq!(equality_ids, vec![delta_id]);
let mut composite_ids = Vec::new();
store
.visit_nodes_by_composite_property_owned(
label_id,
&[
("key".to_string(), Value::String("second".to_string())),
("rank".to_string(), Value::Int(18)),
],
|node| {
composite_ids.push(node.id);
GraphScanControl::Continue
},
)
.unwrap();
assert_eq!(composite_ids, vec![delta_id]);
}
{
let mut file = OpenOptions::new()
.read(true)
.write(true)
.open(path.join(property_projection_artifact_generation_file(1)))
.unwrap();
file.seek(SeekFrom::Start(corrupt_offset)).unwrap();
file.write_all(&[0xff]).unwrap();
file.sync_all().unwrap();
}
{
let mut catalog = Catalog::default();
let store = GraphStore::open_with_durability_and_replay_config(
&path,
&mut catalog,
DurabilityPolicy::default(),
replay_config,
)
.unwrap();
let label_id = catalog.label_id("Memory").unwrap();
let error = store
.visit_nodes_by_composite_property_owned(
label_id,
&[
("key".to_string(), Value::String("second".to_string())),
("rank".to_string(), Value::Int(18)),
],
|_| GraphScanControl::Continue,
)
.unwrap_err();
assert!(
error.to_string().contains("content digest verification"),
"unexpected property projection corruption error: {error}"
);
}
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn out_of_core_relationship_property_projection_prunes_and_merges_wal_delta() {
use hawdb_storage::property_projection::PersistentPropertyProjectionKind;
use std::io::{Seek, SeekFrom};
let path = unique_test_dir("relationship_property_projection_checkpoint");
let replay_config = WalReplayConfig {
residency_mode: StorageResidencyMode::OutOfCore,
..WalReplayConfig::default()
};
let source;
let second_relationship;
let delta_relationship;
let corrupt_offset;
{
let mut catalog = Catalog::default();
let mut store = GraphStore::open_with_durability_and_replay_config(
&path,
&mut catalog,
DurabilityPolicy::default(),
replay_config,
)
.unwrap();
source = store
.create_node(
&mut catalog,
"Memory",
properties([("id", Value::String("source".to_string()))]),
)
.unwrap();
let targets = (0..3)
.map(|id| {
store
.create_node(&mut catalog, "Entity", properties([("id", Value::Int(id))]))
.unwrap()
})
.collect::<Vec<_>>();
let relationship_ids = [10_i64, 20, 30]
.into_iter()
.zip(&targets)
.map(|(rank, target)| {
store
.create_relationship(
&mut catalog,
source,
*target,
"LINKS_TO",
properties([("rank", Value::Int(rank))]),
)
.unwrap()
})
.collect::<Vec<_>>();
second_relationship = relationship_ids[1];
store.checkpoint(&catalog).unwrap();
let rel_type = catalog.rel_type_id("LINKS_TO").unwrap();
let manifest = store.persistent_property_projection_manifest().unwrap();
assert!(manifest.supports_relationship(
rel_type,
"rank",
PersistentPropertyProjectionKind::RelationshipEquality,
));
assert!(manifest.supports_relationship(
rel_type,
"rank",
PersistentPropertyProjectionKind::RelationshipRange,
));
let graph_index_reads_before = store.storage_residency_report().graph_index_reads;
corrupt_offset = property_projection_block_corrupt_offset(
&path.join(property_projection_artifact_generation_file(1)),
5,
);
let mut ids = Vec::new();
let (_, report) = store
.visit_adjacent_relationships_with_filter_owned(
source,
Some(rel_type),
AdjacencyDirection::Outgoing,
&PropertyFilter::Eq {
property: "rank".to_string(),
value: Value::Int(20),
},
|relationship| {
ids.push(relationship.id);
GraphScanControl::Continue
},
)
.unwrap();
assert_eq!(ids, vec![second_relationship]);
assert!(report.is_some_and(|report| {
report.pruned
&& matches!(
report.strategy,
ScanPruningStrategy::PropertyEq { ref property }
if property == "rank"
)
}));
let mut outgoing_ids = Vec::new();
store
.visit_adjacent_relationships_owned(
source,
Some(rel_type),
AdjacencyDirection::Outgoing,
|relationship| {
outgoing_ids.push(relationship.id);
GraphScanControl::Continue
},
)
.unwrap();
assert_eq!(outgoing_ids.len(), 3);
let mut incoming_ids = Vec::new();
store
.visit_adjacent_relationships_owned(
targets[1],
Some(rel_type),
AdjacencyDirection::Incoming,
|relationship| {
incoming_ids.push(relationship.id);
GraphScanControl::Continue
},
)
.unwrap();
assert_eq!(incoming_ids, vec![second_relationship]);
store
.set_relationship_property(
&mut catalog,
RelationshipPropertyUpdate {
source_label: "Memory".to_string(),
filter: Some(PropertyFilter::IdEq {
value: Value::Int(source.0 as i64),
}),
rel_type: "LINKS_TO".to_string(),
target_label: "Entity".to_string(),
target_filter: None,
rel_filter: Some(PropertyFilter::IdEq {
value: Value::Int(second_relationship.0 as i64),
}),
property: "rank".to_string(),
value: Value::Int(40),
},
)
.unwrap();
delta_relationship = store
.create_relationship(
&mut catalog,
source,
targets[1],
"LINKS_TO",
properties([("rank", Value::Int(20))]),
)
.unwrap();
ids.clear();
store
.visit_adjacent_relationships_with_filter_owned(
source,
Some(rel_type),
AdjacencyDirection::Outgoing,
&PropertyFilter::Eq {
property: "rank".to_string(),
value: Value::Int(20),
},
|relationship| {
ids.push(relationship.id);
GraphScanControl::Continue
},
)
.unwrap();
assert_eq!(ids, vec![delta_relationship]);
ids.clear();
store
.visit_adjacent_relationships_with_filter_owned(
source,
Some(rel_type),
AdjacencyDirection::Outgoing,
&PropertyFilter::Range {
property: "rank".to_string(),
lower: Some((Value::Int(35), true)),
upper: Some((Value::Int(45), true)),
},
|relationship| {
ids.push(relationship.id);
GraphScanControl::Continue
},
)
.unwrap();
assert_eq!(ids, vec![second_relationship]);
let graph_index_reads = store
.storage_residency_report()
.graph_index_reads
.delta_since(graph_index_reads_before);
assert_eq!(
graph_index_reads.operation_count(PersistentGraphIndexClass::RelationshipEquality),
2
);
assert_eq!(
graph_index_reads.operation_count(PersistentGraphIndexClass::RelationshipRange),
1
);
assert_eq!(
graph_index_reads.operation_count(PersistentGraphIndexClass::ForwardAdjacency),
1
);
assert_eq!(
graph_index_reads.operation_count(PersistentGraphIndexClass::ReverseAdjacency),
1
);
assert!(graph_index_reads.property_blocks_read >= 2);
assert!(graph_index_reads.property_bytes_read > 0);
assert!(graph_index_reads.adjacency_blocks_read >= 2);
assert!(graph_index_reads.adjacency_bytes_read > 0);
}
{
let mut catalog = Catalog::default();
let store = GraphStore::open_with_durability_and_replay_config(
&path,
&mut catalog,
DurabilityPolicy::default(),
replay_config,
)
.unwrap();
let rel_type = catalog.rel_type_id("LINKS_TO").unwrap();
let mut ids = Vec::new();
store
.visit_adjacent_relationships_with_filter_owned(
source,
Some(rel_type),
AdjacencyDirection::Outgoing,
&PropertyFilter::Eq {
property: "rank".to_string(),
value: Value::Int(20),
},
|relationship| {
ids.push(relationship.id);
GraphScanControl::Continue
},
)
.unwrap();
assert_eq!(ids, vec![delta_relationship]);
}
{
let mut file = OpenOptions::new()
.read(true)
.write(true)
.open(path.join(property_projection_artifact_generation_file(1)))
.unwrap();
file.seek(SeekFrom::Start(corrupt_offset)).unwrap();
file.write_all(&[0xff]).unwrap();
file.sync_all().unwrap();
}
{
let mut catalog = Catalog::default();
let store = GraphStore::open_with_durability_and_replay_config(
&path,
&mut catalog,
DurabilityPolicy::default(),
replay_config,
)
.unwrap();
let rel_type = catalog.rel_type_id("LINKS_TO").unwrap();
let error = store
.visit_adjacent_relationships_with_filter_owned(
source,
Some(rel_type),
AdjacencyDirection::Outgoing,
&PropertyFilter::Eq {
property: "rank".to_string(),
value: Value::Int(20),
},
|_| GraphScanControl::Continue,
)
.unwrap_err();
assert!(
error.to_string().contains("content digest verification"),
"unexpected relationship property projection corruption error: {error}"
);
}
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn persistent_graph_indexes_match_canonical_fallbacks_and_report_each_class() {
let path = unique_test_dir("persistent_graph_index_differential");
let replay_config = WalReplayConfig {
residency_mode: StorageResidencyMode::OutOfCore,
..WalReplayConfig::default()
};
let mut catalog = Catalog::default();
let mut store = GraphStore::open_with_durability_and_replay_config(
&path,
&mut catalog,
DurabilityPolicy::default(),
replay_config,
)
.unwrap();
store
.create_property_index(&mut catalog, "Memory", "key")
.unwrap();
store
.create_range_property_index(&mut catalog, "Memory", "rank")
.unwrap();
store
.create_full_text_property_index(&mut catalog, "Memory", "content")
.unwrap();
store
.create_composite_property_index(
&mut catalog,
"Memory",
&["key".to_string(), "rank".to_string()],
)
.unwrap();
let source = store
.create_node(
&mut catalog,
"Memory",
properties([
("key", Value::String("first".to_string())),
("rank", Value::Int(10)),
("content", Value::String("alpha beta".to_string())),
]),
)
.unwrap();
let second = store
.create_node(
&mut catalog,
"Memory",
properties([
("key", Value::String("second".to_string())),
("rank", Value::Int(20)),
("content", Value::String("beta gamma".to_string())),
]),
)
.unwrap();
store
.create_node(
&mut catalog,
"Memory",
properties([
("key", Value::String("third".to_string())),
("rank", Value::Int(30)),
("content", Value::String("delta".to_string())),
]),
)
.unwrap();
let targets = (0..3)
.map(|ordinal| {
store
.create_node(
&mut catalog,
"Entity",
properties([("ordinal", Value::Int(ordinal))]),
)
.unwrap()
})
.collect::<Vec<_>>();
let relationships = [10_i64, 20, 30]
.into_iter()
.zip(&targets)
.map(|(weight, target)| {
store
.create_relationship(
&mut catalog,
source,
*target,
"LINKS_TO",
properties([("weight", Value::Int(weight))]),
)
.unwrap()
})
.collect::<Vec<_>>();
store.checkpoint(&catalog).unwrap();
let mut oracle = store.snapshot();
oracle.persistent_property_projection = None;
oracle.canonical_adjacency = None;
let label_id = catalog.label_id("Memory").unwrap();
let rel_type = catalog.rel_type_id("LINKS_TO").unwrap();
let lower = (Value::Int(15), true);
let upper = (Value::Int(25), true);
let before = store.storage_residency_report().graph_index_reads;
let mut indexed_equality = Vec::new();
store
.visit_nodes_by_property_owned(
label_id,
"key",
&[Value::String("second".to_string())],
|node| {
indexed_equality.push(node.id);
GraphScanControl::Continue
},
)
.unwrap();
let mut oracle_equality = Vec::new();
oracle
.visit_nodes_by_property_owned(
label_id,
"key",
&[Value::String("second".to_string())],
|node| {
oracle_equality.push(node.id);
GraphScanControl::Continue
},
)
.unwrap();
assert_eq!(indexed_equality, oracle_equality);
assert_eq!(indexed_equality, vec![second]);
let mut indexed_range = Vec::new();
store
.visit_nodes_by_property_range_owned(
label_id,
"rank",
Some(&lower),
Some(&upper),
|node| {
indexed_range.push(node.id);
GraphScanControl::Continue
},
)
.unwrap();
let mut oracle_range = Vec::new();
oracle
.visit_nodes_by_property_range_owned(
label_id,
"rank",
Some(&lower),
Some(&upper),
|node| {
oracle_range.push(node.id);
GraphScanControl::Continue
},
)
.unwrap();
assert_eq!(indexed_range, oracle_range);
let mut indexed_full_text = Vec::new();
store
.visit_nodes_by_full_text_property_owned(label_id, "content", "beta", |node| {
indexed_full_text.push(node.id);
GraphScanControl::Continue
})
.unwrap();
let mut oracle_full_text = Vec::new();
oracle
.visit_nodes_by_full_text_property_owned(label_id, "content", "beta", |node| {
oracle_full_text.push(node.id);
GraphScanControl::Continue
})
.unwrap();
assert_eq!(indexed_full_text, oracle_full_text);
let predicates = [
("key".to_string(), Value::String("second".to_string())),
("rank".to_string(), Value::Int(20)),
];
let mut indexed_composite = Vec::new();
store
.visit_nodes_by_composite_property_owned(label_id, &predicates, |node| {
indexed_composite.push(node.id);
GraphScanControl::Continue
})
.unwrap();
let mut oracle_composite = Vec::new();
oracle
.visit_nodes_by_composite_property_owned(label_id, &predicates, |node| {
oracle_composite.push(node.id);
GraphScanControl::Continue
})
.unwrap();
assert_eq!(indexed_composite, oracle_composite);
let equality_filter = PropertyFilter::Eq {
property: "weight".to_string(),
value: Value::Int(20),
};
let range_filter = PropertyFilter::Range {
property: "weight".to_string(),
lower: Some(lower.clone()),
upper: Some(upper.clone()),
};
let mut indexed_relationship_equality = Vec::new();
store
.visit_adjacent_relationships_with_filter_owned(
source,
Some(rel_type),
AdjacencyDirection::Outgoing,
&equality_filter,
|relationship| {
indexed_relationship_equality.push(relationship.id);
GraphScanControl::Continue
},
)
.unwrap();
let mut oracle_relationship_equality = Vec::new();
oracle
.visit_adjacent_relationships_with_filter_owned(
source,
Some(rel_type),
AdjacencyDirection::Outgoing,
&equality_filter,
|relationship| {
oracle_relationship_equality.push(relationship.id);
GraphScanControl::Continue
},
)
.unwrap();
assert_eq!(indexed_relationship_equality, oracle_relationship_equality);
assert_eq!(indexed_relationship_equality, vec![relationships[1]]);
let mut indexed_relationship_range = Vec::new();
store
.visit_adjacent_relationships_with_filter_owned(
source,
Some(rel_type),
AdjacencyDirection::Outgoing,
&range_filter,
|relationship| {
indexed_relationship_range.push(relationship.id);
GraphScanControl::Continue
},
)
.unwrap();
let mut oracle_relationship_range = Vec::new();
oracle
.visit_adjacent_relationships_with_filter_owned(
source,
Some(rel_type),
AdjacencyDirection::Outgoing,
&range_filter,
|relationship| {
oracle_relationship_range.push(relationship.id);
GraphScanControl::Continue
},
)
.unwrap();
assert_eq!(indexed_relationship_range, oracle_relationship_range);
let mut indexed_forward = Vec::new();
store
.visit_adjacent_relationships_owned(
source,
Some(rel_type),
AdjacencyDirection::Outgoing,
|relationship| {
indexed_forward.push(relationship.id);
GraphScanControl::Continue
},
)
.unwrap();
let mut oracle_forward = Vec::new();
oracle
.visit_adjacent_relationships_owned(
source,
Some(rel_type),
AdjacencyDirection::Outgoing,
|relationship| {
oracle_forward.push(relationship.id);
GraphScanControl::Continue
},
)
.unwrap();
assert_eq!(indexed_forward, oracle_forward);
let mut indexed_reverse = Vec::new();
store
.visit_adjacent_relationships_owned(
targets[1],
Some(rel_type),
AdjacencyDirection::Incoming,
|relationship| {
indexed_reverse.push(relationship.id);
GraphScanControl::Continue
},
)
.unwrap();
let mut oracle_reverse = Vec::new();
oracle
.visit_adjacent_relationships_owned(
targets[1],
Some(rel_type),
AdjacencyDirection::Incoming,
|relationship| {
oracle_reverse.push(relationship.id);
GraphScanControl::Continue
},
)
.unwrap();
assert_eq!(indexed_reverse, oracle_reverse);
let reads = store
.storage_residency_report()
.graph_index_reads
.delta_since(before);
for class in PersistentGraphIndexClass::ALL {
assert_eq!(reads.operation_count(class), 1, "{}", class.as_str());
assert!(reads.blocks_read(class) > 0, "{}", class.as_str());
assert!(reads.bytes_read(class) > 0, "{}", class.as_str());
}
assert!(reads.property_blocks_read >= 6);
assert!(reads.property_bytes_read > 0);
assert!(reads.adjacency_blocks_read >= 2);
assert!(reads.adjacency_bytes_read > 0);
drop(oracle);
drop(store);
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn relationship_projection_estimate_fallback_reports_descriptor_io() {
let path = unique_test_dir("relationship_projection_estimate_fallback");
let replay_config = WalReplayConfig {
residency_mode: StorageResidencyMode::OutOfCore,
..WalReplayConfig::default()
};
let mut catalog = Catalog::default();
let mut store = GraphStore::open_with_durability_and_replay_config(
&path,
&mut catalog,
DurabilityPolicy::default(),
replay_config,
)
.unwrap();
let narrow_source = store
.create_node(&mut catalog, "Entity", BTreeMap::new())
.unwrap();
let wide_source = store
.create_node(&mut catalog, "Entity", BTreeMap::new())
.unwrap();
let targets = (0..5)
.map(|_| {
store
.create_node(&mut catalog, "Entity", BTreeMap::new())
.unwrap()
})
.collect::<Vec<_>>();
let expected = store
.create_relationship(
&mut catalog,
narrow_source,
targets[0],
"LINKS_TO",
properties([("weight", Value::Int(20))]),
)
.unwrap();
for target in &targets[1..] {
store
.create_relationship(
&mut catalog,
wide_source,
*target,
"LINKS_TO",
properties([("weight", Value::Int(20))]),
)
.unwrap();
}
store.checkpoint(&catalog).unwrap();
let rel_type = catalog.rel_type_id("LINKS_TO").unwrap();
let before = store.storage_residency_report().graph_index_reads;
let mut relationships = Vec::new();
store
.visit_adjacent_relationships_with_filter_owned(
narrow_source,
Some(rel_type),
AdjacencyDirection::Outgoing,
&PropertyFilter::Eq {
property: "weight".to_string(),
value: Value::Int(20),
},
|relationship| {
relationships.push(relationship.id);
GraphScanControl::Continue
},
)
.unwrap();
assert_eq!(relationships, vec![expected]);
let reads = store
.storage_residency_report()
.graph_index_reads
.delta_since(before);
assert_eq!(
reads.operation_count(PersistentGraphIndexClass::RelationshipEquality),
1
);
assert_eq!(
reads.blocks_read(PersistentGraphIndexClass::RelationshipEquality),
0
);
assert!(reads.property_descriptor_pages_visited > 0);
assert!(reads.property_descriptor_storage_bytes_read > 0);
assert_eq!(reads.property_blocks_read, 0);
assert_eq!(
reads.operation_count(PersistentGraphIndexClass::ForwardAdjacency),
1
);
assert!(reads.blocks_read(PersistentGraphIndexClass::ForwardAdjacency) > 0);
drop(store);
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn corrupted_source_scan_artifact_never_blocks_canonical_graph_recovery() {
let path = unique_test_dir("source_scan_corruption");
{
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
store
.create_node(
&mut catalog,
"Source",
properties([("id", Value::String("source-a".to_string()))]),
)
.unwrap();
store.checkpoint(&catalog).unwrap();
}
std::fs::write(path.join(source_scan::SOURCE_SCAN_PAYLOAD_FILE), b"corrupt").unwrap();
let mut catalog = Catalog::default();
let store = GraphStore::open(&path, &mut catalog).unwrap();
assert!(store.node(NodeId(0)).is_some());
assert!(matches!(
store.plan_published_source_scan(&ScanPredicate::True),
ScanSegmentAccessPlan::Fallback(ScanSegmentFallback::NoManifest)
));
assert!(!path.join(source_scan::SOURCE_SCAN_PAYLOAD_FILE).exists());
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn source_scan_reader_caches_independently_verified_segment_ranges() {
let path = unique_test_dir("source_scan_coalesced_ranges");
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
for id in 0..129 {
store
.create_node(
&mut catalog,
"Source",
properties([("id", Value::String(format!("source-{id}")))]),
)
.unwrap();
}
store.checkpoint(&catalog).unwrap();
let SourceScanCandidateRead::Rows { rows, report, .. } = store
.read_published_source_scan_candidates(
&ScanPredicate::True,
NonZeroUsize::new(2).unwrap(),
NonZeroU64::new(1024 * 1024).unwrap(),
NonZeroU64::new(1024 * 1024).unwrap(),
)
.unwrap()
else {
panic!("expected source scan payload read");
};
assert_eq!(rows.len(), 129);
assert_eq!(report.range_count, 2);
assert_eq!(report.wave_count, 1);
let first_cache = store.segment_cache_snapshot().unwrap();
assert_eq!(first_cache.entry_count, 2);
assert_eq!(first_cache.miss_count, 2);
let SourceScanCandidateRead::Rows { rows, .. } = store
.read_published_source_scan_candidates(
&ScanPredicate::True,
NonZeroUsize::new(2).unwrap(),
NonZeroU64::new(1024 * 1024).unwrap(),
NonZeroU64::new(1024 * 1024).unwrap(),
)
.unwrap()
else {
panic!("expected cached source scan payload read");
};
assert_eq!(rows.len(), 129);
let second_cache = store.segment_cache_snapshot().unwrap();
assert_eq!(second_cache.hit_count, 2);
assert_eq!(second_cache.resident_bytes, first_cache.resident_bytes);
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn source_scan_candidate_budget_tracks_one_live_segment() {
let path = unique_test_dir("source_scan_live_segment_budget");
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
for id in 0..=source_scan::SOURCE_SCAN_TARGET_ROWS {
store
.create_node(
&mut catalog,
"Source",
properties([("id", Value::String(format!("source-{id}")))]),
)
.unwrap();
}
store.checkpoint(&catalog).unwrap();
let SourceScanCandidateRead::Rows { rows, .. } = store
.read_published_source_scan_candidates(
&ScanPredicate::True,
NonZeroUsize::new(2).unwrap(),
NonZeroU64::new(1024 * 1024).unwrap(),
NonZeroU64::new(1024 * 1024).unwrap(),
)
.unwrap()
else {
panic!("expected source scan payload read");
};
let row_bytes = |row: &SourceScanRow| {
std::mem::size_of::<SourceScanRow>().saturating_add(
usize::try_from(estimated_properties_bytes(&row.properties)).unwrap_or(usize::MAX),
)
};
let first_segment_bytes = rows
.iter()
.take(source_scan::SOURCE_SCAN_TARGET_ROWS)
.fold(0usize, |total, row| total.saturating_add(row_bytes(row)));
let all_candidate_bytes = rows
.iter()
.fold(0usize, |total, row| total.saturating_add(row_bytes(row)));
assert!(all_candidate_bytes > first_segment_bytes);
let mut visited = 0usize;
let visit = store
.visit_published_source_scan_candidates_bounded(
&ScanPredicate::True,
SourceScanCandidateLimits::bounded(
NonZeroUsize::new(2).unwrap(),
NonZeroU64::new(1024 * 1024).unwrap(),
NonZeroU64::new(1024 * 1024).unwrap(),
NonZeroUsize::new(first_segment_bytes).unwrap(),
),
None,
&mut |_| {
visited = visited.saturating_add(1);
Ok(GraphScanControl::Continue)
},
)
.unwrap();
let SourceScanCandidateVisit::Rows {
candidate_count, ..
} = visit
else {
panic!("expected bounded source scan visit");
};
assert_eq!(visited, source_scan::SOURCE_SCAN_TARGET_ROWS + 1);
assert_eq!(candidate_count, visited);
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn source_scan_corruption_after_stream_admission_fails_closed() {
let path = unique_test_dir("source_scan_runtime_corruption");
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
store
.create_node(
&mut catalog,
"Source",
properties([("id", Value::String("source-a".to_string()))]),
)
.unwrap();
store.checkpoint(&catalog).unwrap();
let payload_path = path.join(source_scan::SOURCE_SCAN_PAYLOAD_FILE);
let mut payload = fs::read(&payload_path).unwrap();
let corrupt_at = payload.len() / 2;
payload[corrupt_at] ^= 0xff;
fs::write(&payload_path, payload).unwrap();
let error = store
.read_published_source_scan_candidates(
&ScanPredicate::True,
NonZeroUsize::new(2).unwrap(),
NonZeroU64::new(1024).unwrap(),
NonZeroU64::new(1024).unwrap(),
)
.expect_err("an admitted streaming read must fail closed on corruption");
assert!(matches!(error, HawDBError::StorageIntegrity(_)));
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn ordered_adjacency_entries_sort_by_neighbor_then_relationship_id() {
let mut catalog = Catalog::default();
let mut store = GraphStore::in_memory();
let source = store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(0))]))
.unwrap();
let target_one = store
.create_node(&mut catalog, "Entity", properties([("id", Value::Int(1))]))
.unwrap();
let target_two = store
.create_node(&mut catalog, "Entity", properties([("id", Value::Int(2))]))
.unwrap();
let target_three = store
.create_node(&mut catalog, "Entity", properties([("id", Value::Int(3))]))
.unwrap();
let rel_three = store
.create_relationship(
&mut catalog,
source,
target_three,
"MENTIONS",
BTreeMap::new(),
)
.unwrap();
let rel_one = store
.create_relationship(
&mut catalog,
source,
target_one,
"MENTIONS",
BTreeMap::new(),
)
.unwrap();
let rel_two = store
.create_relationship(
&mut catalog,
source,
target_two,
"MENTIONS",
BTreeMap::new(),
)
.unwrap();
let rel_type = catalog.rel_type_id("MENTIONS").unwrap();
let outgoing =
store.ordered_adjacency_entries(source, rel_type, AdjacencyDirection::Outgoing);
let incoming =
store.ordered_adjacency_entries(target_one, rel_type, AdjacencyDirection::Incoming);
assert_eq!(
outgoing,
vec![
OrderedAdjacencyEntry {
relationship_id: rel_one,
neighbor_id: target_one,
},
OrderedAdjacencyEntry {
relationship_id: rel_two,
neighbor_id: target_two,
},
OrderedAdjacencyEntry {
relationship_id: rel_three,
neighbor_id: target_three,
},
]
);
assert_eq!(
incoming,
vec![OrderedAdjacencyEntry {
relationship_id: rel_one,
neighbor_id: source,
}]
);
}
#[test]
fn ordered_adjacency_visitor_streams_without_degree_sized_sort_keys() {
let mut catalog = Catalog::default();
let mut store = GraphStore::in_memory();
let source = store
.create_node(&mut catalog, "Memory", BTreeMap::new())
.unwrap();
for _ in 0..3 {
let target = store
.create_node(&mut catalog, "Entity", BTreeMap::new())
.unwrap();
store
.create_relationship(&mut catalog, source, target, "MENTIONS", BTreeMap::new())
.unwrap();
}
let mention_type = catalog.rel_type_id("MENTIONS").unwrap();
let mut visited = Vec::new();
let control = store
.try_visit_ordered_adjacent_relationships_owned(
source,
Some(mention_type),
AdjacencyDirection::Outgoing,
1,
|relationship| {
visited.push(relationship.id);
Ok(if visited.len() == 2 {
GraphScanControl::Stop
} else {
GraphScanControl::Continue
})
},
)
.unwrap();
assert_eq!(control, GraphScanControl::Stop);
assert_eq!(visited, vec![RelId(0), RelId(1)]);
}
#[test]
fn ordered_adjacency_entries_for_node_sort_across_relationship_types() {
let mut catalog = Catalog::default();
let mut store = GraphStore::in_memory();
let source = store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(0))]))
.unwrap();
let target_one = store
.create_node(&mut catalog, "Entity", properties([("id", Value::Int(1))]))
.unwrap();
let target_two = store
.create_node(&mut catalog, "Entity", properties([("id", Value::Int(2))]))
.unwrap();
let rel_late_neighbor = store
.create_relationship(
&mut catalog,
source,
target_two,
"MENTIONS",
BTreeMap::new(),
)
.unwrap();
let rel_early_neighbor = store
.create_relationship(
&mut catalog,
source,
target_one,
"RELATES_TO",
BTreeMap::new(),
)
.unwrap();
let outgoing =
store.ordered_adjacency_entries_for_node(source, AdjacencyDirection::Outgoing);
let incoming =
store.ordered_adjacency_entries_for_node(target_one, AdjacencyDirection::Incoming);
assert_eq!(
outgoing,
vec![
OrderedAdjacencyEntry {
relationship_id: rel_early_neighbor,
neighbor_id: target_one,
},
OrderedAdjacencyEntry {
relationship_id: rel_late_neighbor,
neighbor_id: target_two,
},
]
);
assert_eq!(
incoming,
vec![OrderedAdjacencyEntry {
relationship_id: rel_early_neighbor,
neighbor_id: source,
}]
);
}
#[test]
fn adjacency_group_stats_classify_sparse_and_dense_groups() {
let mut catalog = Catalog::default();
let mut store = GraphStore::in_memory();
let source = store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(0))]))
.unwrap();
for target_index in 0..DENSE_ADJACENCY_DEGREE_THRESHOLD {
let target = store
.create_node(
&mut catalog,
"Entity",
properties([("id", Value::Int(target_index as i64))]),
)
.unwrap();
store
.create_relationship(&mut catalog, source, target, "MENTIONS", BTreeMap::new())
.unwrap();
}
let rel_type = catalog.rel_type_id("MENTIONS").unwrap();
let dense_stats =
store.adjacency_group_stats(source, rel_type, AdjacencyDirection::Outgoing);
let sparse_stats =
store.adjacency_group_stats(NodeId(1), rel_type, AdjacencyDirection::Incoming);
assert_eq!(dense_stats.degree, DENSE_ADJACENCY_DEGREE_THRESHOLD);
assert_eq!(dense_stats.layout, AdjacencyLayout::Dense);
assert_eq!(sparse_stats.degree, 1);
assert_eq!(sparse_stats.layout, AdjacencyLayout::Sparse);
}
#[test]
fn adjacency_group_stats_for_node_reports_each_relationship_type() {
let mut catalog = Catalog::default();
let mut store = GraphStore::in_memory();
let source = store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(0))]))
.unwrap();
let mentions_target = store
.create_node(&mut catalog, "Entity", properties([("id", Value::Int(1))]))
.unwrap();
let relates_target = store
.create_node(&mut catalog, "Entity", properties([("id", Value::Int(2))]))
.unwrap();
store
.create_relationship(
&mut catalog,
source,
mentions_target,
"MENTIONS",
BTreeMap::new(),
)
.unwrap();
store
.create_relationship(
&mut catalog,
source,
relates_target,
"RELATES_TO",
BTreeMap::new(),
)
.unwrap();
let mentions = catalog.rel_type_id("MENTIONS").unwrap();
let relates_to = catalog.rel_type_id("RELATES_TO").unwrap();
let stats = store.adjacency_group_stats_for_node(source, AdjacencyDirection::Outgoing);
assert_eq!(
stats,
vec![
AdjacencyGroupStats {
node_id: source,
rel_type: mentions,
direction: AdjacencyDirection::Outgoing,
degree: 1,
layout: AdjacencyLayout::Sparse,
},
AdjacencyGroupStats {
node_id: source,
rel_type: relates_to,
direction: AdjacencyDirection::Outgoing,
degree: 1,
layout: AdjacencyLayout::Sparse,
},
]
);
}
#[test]
fn adjacency_consistency_report_matches_full_recompute_after_relationship_mutations() {
let mut catalog = Catalog::default();
let mut store = GraphStore::in_memory();
let source = store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(0))]))
.unwrap();
let target_one = store
.create_node(&mut catalog, "Entity", properties([("id", Value::Int(1))]))
.unwrap();
let target_two = store
.create_node(&mut catalog, "Entity", properties([("id", Value::Int(2))]))
.unwrap();
store
.create_relationship(
&mut catalog,
source,
target_one,
"MENTIONS",
properties([("confidence", Value::Float(0.8))]),
)
.unwrap();
store
.create_relationship(
&mut catalog,
source,
target_two,
"MENTIONS",
properties([("confidence", Value::Float(0.9))]),
)
.unwrap();
let report = store.adjacency_consistency_report();
assert!(report.ready);
assert_eq!(report.relationship_count, 2);
assert_eq!(report.maintained_group_count, 3);
assert_eq!(report.recomputed_group_count, 3);
assert_eq!(report.missing_group_count, 0);
assert_eq!(report.extra_group_count, 0);
assert_eq!(report.mismatched_group_count, 0);
assert_eq!(report.dangling_relationship_count, 0);
assert!(report.mismatches.is_empty());
store
.delete_relationships(
&mut catalog,
RelationshipDeleteRequest {
source_label: "Memory".to_string(),
filter: Some(PropertyFilter::Eq {
property: "id".to_string(),
value: Value::Int(0),
}),
rel_type: "MENTIONS".to_string(),
target_label: "Entity".to_string(),
target_filter: Some(PropertyFilter::Eq {
property: "id".to_string(),
value: Value::Int(1),
}),
rel_filter: None,
},
)
.unwrap();
let report = store.adjacency_consistency_report();
assert!(report.ready);
assert_eq!(report.relationship_count, 1);
assert_eq!(report.maintained_group_count, 2);
assert_eq!(report.recomputed_group_count, 2);
assert!(report.mismatches.is_empty());
}
#[test]
fn adjacency_consistency_report_matches_full_recompute_after_detach_delete() {
let mut catalog = Catalog::default();
let mut store = GraphStore::in_memory();
let source = store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(0))]))
.unwrap();
let target = store
.create_node(&mut catalog, "Entity", properties([("id", Value::Int(1))]))
.unwrap();
store
.create_relationship(&mut catalog, source, target, "MENTIONS", BTreeMap::new())
.unwrap();
store
.delete_nodes(
&mut catalog,
"Memory",
Some(&PropertyFilter::Eq {
property: "id".to_string(),
value: Value::Int(0),
}),
true,
)
.unwrap();
let report = store.adjacency_consistency_report();
assert!(report.ready);
assert_eq!(report.relationship_count, 0);
assert_eq!(report.maintained_group_count, 0);
assert_eq!(report.recomputed_group_count, 0);
assert_eq!(report.dangling_relationship_count, 0);
assert!(report.mismatches.is_empty());
}
#[test]
fn checkpoints_relationships_and_truncates_wal() {
let path = unique_test_dir("rel_checkpoint");
{
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
let source = store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(1))]))
.unwrap();
let target = store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(2))]))
.unwrap();
store
.create_relationship(&mut catalog, source, target, "RELATES_TO", BTreeMap::new())
.unwrap();
store.checkpoint(&catalog).unwrap();
}
assert_eq!(read_test_wal(&path).unwrap(), "");
{
let mut catalog = Catalog::default();
let store = GraphStore::open(&path, &mut catalog).unwrap();
let rel_type = catalog.rel_type_id("RELATES_TO").unwrap();
let rels = store
.outgoing_relationships(NodeId(0), rel_type)
.collect::<Vec<_>>();
assert_eq!(rels.len(), 1);
assert_eq!(rels[0].source, NodeId(0));
assert_eq!(rels[0].target, NodeId(1));
}
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn search_projection_snapshots_preserve_payloads_across_append_trim_and_reopen() {
use crate::relational::RelationalPrimaryKeyChangeCaptureLimits;
let path = unique_test_dir("shared_search_projection_changes");
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
store
.commit_relational_transaction(
&mut catalog,
RelationalTransaction {
writes: vec![RelationalWrite::CreateTable(RelationalTableSchema {
name: "messages".to_string(),
columns: vec![RelationalColumnSchema {
name: "id".to_string(),
scalar_type: RelationalScalarType::Text,
nullable: false,
default: None,
}],
primary_key: vec!["id".to_string()],
unique_constraints: Vec::new(),
foreign_keys: Vec::new(),
indexes: Vec::new(),
})],
},
)
.unwrap();
for id in 10..140 {
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(id))]))
.unwrap();
}
for batch in 0..2 {
store
.commit_relational_transaction(
&mut catalog,
RelationalTransaction {
writes: vec![RelationalWrite::Insert {
table: "messages".to_string(),
mode: RelationalInsertMode::Error,
rows: (0..128)
.map(|key| {
RelationalRow::new(vec![RelationalValue::Text(format!(
"message-{batch}-{key:04}"
))])
})
.collect(),
}],
},
)
.unwrap();
}
let before_checkpoint = store.search_projection_changes_after(0);
store.checkpoint(&catalog).unwrap();
drop(store);
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
assert_eq!(store.search_projection_changes_after(0), before_checkpoint);
let snapshot = store.snapshot_for_read();
let before = snapshot.search_projection_changes_after(0);
let before_status = snapshot.search_projection_changefeed_status();
assert_eq!(
before
.last()
.unwrap()
.relational_primary_key_changes
.operation_count(),
128
);
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(1))]))
.unwrap();
assert_eq!(
&store.search_projection_changes_after(0)[..before.len()],
before
);
store.set_search_projection_primary_key_capture_limits(
RelationalPrimaryKeyChangeCaptureLimits {
max_entries: NonZeroUsize::new(1).unwrap(),
..RelationalPrimaryKeyChangeCaptureLimits::default()
},
);
assert!(store
.search_projection_changes_after(0)
.iter()
.find(|change| change.commit_epoch == snapshot.commit_epoch())
.unwrap()
.relational_primary_key_changes
.requires_rebuild());
assert_eq!(snapshot.search_projection_changes_after(0), before);
assert_eq!(
snapshot.search_projection_changefeed_status(),
before_status
);
store.set_max_search_projection_change_log_entries(Some(1));
let retained = store.search_projection_changes_after(0);
let floor = store.search_projection_change_log_start_epoch();
assert_eq!(retained.len(), 1);
assert_eq!(floor, snapshot.commit_epoch());
assert_eq!(snapshot.search_projection_changes_after(0), before);
store.checkpoint(&catalog).unwrap();
drop(store);
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
assert_eq!(store.search_projection_changes_after(0), retained);
assert_eq!(store.search_projection_change_log_start_epoch(), floor);
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(2))]))
.unwrap();
let with_wal = store.search_projection_changes_after(0);
drop(store);
let store = GraphStore::open(&path, &mut catalog).unwrap();
assert_eq!(store.search_projection_changes_after(0), with_wal);
assert_eq!(snapshot.search_projection_changes_after(0), before);
drop(store);
drop(snapshot);
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn checkpoints_search_projection_changes_for_restart_safe_catch_up() {
let path = unique_test_dir("search_projection_change_checkpoint");
{
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
store
.create_node(
&mut catalog,
"Memory",
properties([("id", Value::String("deleted-memory".to_string()))]),
)
.unwrap();
store
.delete_nodes(
&mut catalog,
"Memory",
Some(&PropertyFilter::Eq {
property: "id".to_string(),
value: Value::String("deleted-memory".to_string()),
}),
true,
)
.unwrap();
store.checkpoint(&catalog).unwrap();
}
let mut catalog = Catalog::default();
let store = GraphStore::open(&path, &mut catalog).unwrap();
assert_eq!(store.commit_epoch(), 2);
assert_eq!(store.search_projection_change_log_start_epoch(), 0);
assert_eq!(
store.search_projection_graph_changes_after(0),
vec![
SearchProjectionGraphChange {
commit_epoch: 1,
upsert_node_ids: vec![0],
delete_document_ids: Vec::new(),
relational_primary_key_changes:
hawdb_storage::relational::RelationalPrimaryKeyChangeCapture::Captured {
tables: Vec::new(),
encoded_bytes: 0,
},
},
SearchProjectionGraphChange {
commit_epoch: 2,
upsert_node_ids: Vec::new(),
delete_document_ids: vec!["memory:deleted-memory".to_string()],
relational_primary_key_changes:
hawdb_storage::relational::RelationalPrimaryKeyChangeCapture::Captured {
tables: Vec::new(),
encoded_bytes: 0,
},
},
]
);
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn checkpoint_requires_projection_change_boundary() {
let path = unique_test_dir("search_projection_checkpoint_boundary");
{
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
store
.create_node(
&mut catalog,
"Memory",
properties([("id", Value::String("memory-1".to_string()))]),
)
.unwrap();
store.checkpoint(&catalog).unwrap();
}
let checkpoint_path = active_checkpoint_path(&path);
rewrite_checksummed_file(
&checkpoint_path,
"search_projection_change_log_start_epoch\t0\n",
"",
"checkpoint",
);
rewrite_checksummed_file(
&checkpoint_path,
"search_projection_change\t1\t0\t\n",
"",
"checkpoint",
);
let mut catalog = Catalog::default();
let error = GraphStore::open(&path, &mut catalog).unwrap_err();
assert!(error
.to_string()
.contains("checkpoint search projection changes are missing their start epoch"));
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn rejects_checkpoint_relationship_with_missing_endpoint() {
let path = unique_test_dir("rel_checkpoint_missing_endpoint");
{
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
let source = store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(1))]))
.unwrap();
let target = store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(2))]))
.unwrap();
store
.create_relationship(&mut catalog, source, target, "RELATES_TO", BTreeMap::new())
.unwrap();
store.checkpoint(&catalog).unwrap();
}
let properties_one = crate::store::encode_properties(&properties([("id", Value::Int(1))]));
let properties_two = crate::store::encode_properties(&properties([("id", Value::Int(2))]));
let corrupt_records = format!(
"node\t0\t0\t{properties_one}\nnode\t1\t0\t{properties_two}\nrel\t0\t0\t99\t0\t"
);
rewrite_checksummed_file(
&active_checkpoint_path(&path),
"canonical_records\ttrue",
&corrupt_records,
"checkpoint",
);
let mut catalog = Catalog::default();
let error = GraphStore::open(&path, &mut catalog).unwrap_err();
assert!(error
.to_string()
.contains("relationship 0 references missing target node 99"));
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn checkpoint_publishes_manifest_with_epoch_boundary() {
let path = unique_test_dir("manifest_checkpoint");
{
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(1))]))
.unwrap();
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(2))]))
.unwrap();
store.checkpoint(&catalog).unwrap();
}
let checkpoint_bytes = std::fs::read(active_checkpoint_path(&path)).unwrap();
assert!(checkpoint_bytes.starts_with(DURABLE_COMPRESSION_HEADER.as_bytes()));
let header_end = checkpoint_bytes
.windows(2)
.position(|window| window == b"\n\n")
.unwrap();
let header = std::str::from_utf8(&checkpoint_bytes[..header_end]).unwrap();
assert!(header.contains("codec\tzstd\n"));
let checkpoint = read_durable_text(&active_checkpoint_path(&path), "checkpoint").unwrap();
assert!(checkpoint.contains("commit_epoch\t2\n"));
let manifest = std::fs::read_to_string(path.join("manifest.hawdb")).unwrap();
assert!(manifest.contains("HAWDB_MANIFEST_V1\n"));
assert!(manifest.contains("checkpoint_generation\t1\n"));
assert!(manifest.contains("wal_generation\t1\n"));
assert!(manifest.contains("checkpoint_epoch\t1\n"));
assert!(manifest.contains("checkpoint_commit_epoch\t2\n"));
assert!(manifest.contains("oldest_reader_commit_epoch\tnone\n"));
assert!(manifest.contains("safe_reclaim_commit_epoch\t2\n"));
assert!(manifest.contains("wal_replay_start_lsn\t3\n"));
assert!(manifest.contains("next_lsn\t3\n"));
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn prepared_checkpoint_rejects_stale_source_without_rotating_wal() {
let path = unique_test_dir("prepared_checkpoint_stale_source");
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(1))]))
.unwrap();
let source = store.checkpoint_source();
let prepared = source.prepare_checkpoint(&catalog).unwrap().unwrap();
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(2))]))
.unwrap();
let error = store
.publish_prepared_checkpoint(prepared, None)
.unwrap_err();
assert!(error
.to_string()
.contains("checkpoint source changed before publication"));
assert!(!path.join("checkpoint.1.hawdb").exists());
assert!(!path.join("wal.1.hawdb").exists());
assert!(!path
.join(hawdb_storage::canonical_adjacency::canonical_adjacency_descriptor_page_file(1))
.exists());
assert!(!path
.join(hawdb_storage::canonical_adjacency::canonical_adjacency_descriptor_root_file(1))
.exists());
assert!(!path.join(".checkpoint.1.prepare").exists());
drop(source);
drop(store);
let mut recovered_catalog = Catalog::default();
let recovered = GraphStore::open(&path, &mut recovered_catalog).unwrap();
assert_eq!(recovered.commit_epoch(), 2);
assert_eq!(
recovered.storage_recovery_report().checkpoint_commit_epoch,
None
);
let memory = recovered_catalog.label_id("Memory").unwrap();
assert_eq!(recovered.scan_nodes(Some(memory)).count(), 2);
drop(recovered);
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn checkpoint_wal_tail_preserves_every_post_snapshot_transaction() {
use super::{wal_generation_file, WalCursorEvent, WalOpenOutcome, WalRecordCursor};
let path = unique_test_dir("checkpoint_wal_tail");
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(1))]))
.unwrap();
let source = store.checkpoint_source();
let prepared = source.prepare_checkpoint(&catalog).unwrap().unwrap();
let authoritative = store.durable.as_ref().unwrap().wal_path.clone();
for id in 2..=5 {
store
.create_node(
&mut catalog,
"Memory",
properties([
("id", Value::Int(id)),
("body", Value::String("x".repeat(48 * 1024))),
]),
)
.unwrap();
}
let captured = store.checkpoint_source();
let before = std::fs::read(&authoritative).unwrap();
let receipt = captured.prepare_checkpoint_wal_tail(&prepared).unwrap();
assert_eq!(receipt.entries, 4);
assert_eq!(receipt.captured_commit_epoch, 5);
assert_eq!(std::fs::read(&authoritative).unwrap(), before);
assert_eq!(store.durable.as_ref().unwrap().checkpoint_epoch, 0);
let manifest = store
.durable
.as_ref()
.unwrap()
.checkpoint_manifest(
prepared.generation,
prepared.manifest_artifacts,
prepared.source_commit_epoch,
None,
prepared.source_scan_publication,
super::durable::CheckpointReplayBoundary {
start_lsn: prepared.source_next_lsn,
next_lsn: receipt.captured_next_lsn,
commit_epoch: receipt.captured_commit_epoch,
},
)
.unwrap();
assert_eq!(manifest.checkpoint_commit_epoch, 1);
assert_eq!(manifest.wal_replay_start_lsn, 2);
assert_eq!(manifest.next_lsn, 6);
let candidate = path.join(wal_generation_file(prepared.generation));
let WalOpenOutcome::Cursor(mut cursor) = WalRecordCursor::open(&candidate, None).unwrap()
else {
panic!("candidate suffix has no valid header");
};
assert_eq!(cursor.generation(), prepared.generation);
assert_eq!(cursor.start_lsn(), prepared.source_next_lsn);
for lsn in prepared.source_next_lsn..receipt.captured_next_lsn {
let WalCursorEvent::Entry { entry, .. } = cursor.next().unwrap() else {
panic!("candidate lost a complete transaction");
};
assert_eq!(entry.lsn, lsn);
}
assert!(matches!(cursor.next().unwrap(), WalCursorEvent::Eof));
drop(cursor);
store
.durable
.as_ref()
.unwrap()
.publish_checkpoint_sidecars(
&prepared.staging_path,
prepared.publish_projected_graph_artifacts,
prepared.source_scan_publication,
)
.unwrap();
manifest.write(&path.join(super::MANIFEST_FILE)).unwrap();
drop(captured);
drop(prepared);
drop(source);
drop(store);
let recovered = GraphStore::open(&path, &mut catalog).unwrap();
assert_eq!(recovered.commit_epoch(), 5);
assert_eq!(
recovered.storage_recovery_report().checkpoint_commit_epoch,
Some(1)
);
assert_eq!(recovered.scan_nodes(None).count(), 5);
drop(recovered);
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn checkpoint_candidate_private_recovery_keeps_existing_selectors_until_publication() {
use crate::config::RelationalIndexMode;
use crate::relational::{
RELATIONAL_INDEX_RECOVERY_MANIFEST_FILE, RELATIONAL_ROW_DELTA_MANIFEST_FILE,
};
let path = unique_test_dir("checkpoint_candidate_private_selectors");
let replay = WalReplayConfig {
relational_index_mode: RelationalIndexMode::Shadow,
..WalReplayConfig::default()
};
let mut catalog = Catalog::default();
let mut store = GraphStore::open_with_durability_and_replay_config(
&path,
&mut catalog,
DurabilityPolicy::default(),
replay,
)
.unwrap();
let row = |id: usize| {
RelationalRow::new(vec![
RelationalValue::Text(id.to_string()),
RelationalValue::Text(format!("complete row {id}: 雪")),
])
};
let insert = |id| RelationalTransaction {
writes: vec![RelationalWrite::Insert {
table: "documents".into(),
mode: RelationalInsertMode::Error,
rows: vec![row(id)],
}],
};
store
.commit_relational_transaction(
&mut catalog,
RelationalTransaction {
writes: vec![
RelationalWrite::CreateTable(RelationalTableSchema {
name: "documents".into(),
columns: ["id", "body"]
.into_iter()
.map(|name| RelationalColumnSchema {
name: name.into(),
scalar_type: RelationalScalarType::Text,
nullable: false,
default: None,
})
.collect(),
primary_key: vec!["id".into()],
unique_constraints: Vec::new(),
foreign_keys: Vec::new(),
indexes: Vec::new(),
}),
insert(0).writes.pop().unwrap(),
],
},
)
.unwrap();
store.checkpoint(&catalog).unwrap();
store
.commit_relational_transaction(&mut catalog, insert(1))
.unwrap();
drop(store);
let mut store = GraphStore::open_with_durability_and_replay_config(
&path,
&mut catalog,
DurabilityPolicy::default(),
replay,
)
.unwrap();
let source = store.checkpoint_source();
let mut candidate = source
.prepare_checkpoint_candidate(&catalog)
.unwrap()
.unwrap();
drop(source);
store
.commit_relational_transaction(&mut catalog, insert(2))
.unwrap();
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(7))]))
.unwrap();
let before_manifest = fs::read(path.join(MANIFEST_FILE)).unwrap();
let before_wal = fs::read(active_wal_path(&path)).unwrap();
let selectors = [
RELATIONAL_ROW_DELTA_MANIFEST_FILE,
RELATIONAL_INDEX_RECOVERY_MANIFEST_FILE,
]
.map(|name| (name, fs::read(path.join(name)).unwrap()));
let captured = store.checkpoint_source();
candidate.catch_up(&captured).unwrap();
drop(captured);
candidate.finish_catch_up().unwrap();
assert_eq!(fs::read(path.join(MANIFEST_FILE)).unwrap(), before_manifest);
assert_eq!(fs::read(active_wal_path(&path)).unwrap(), before_wal);
for (name, expected) in selectors {
assert_eq!(
fs::read(path.join(name)).unwrap(),
expected,
"private {name}"
);
}
drop(candidate);
let expected = (0..3).map(row).collect::<Vec<_>>();
assert_eq!(
store
.relational_state()
.rows("documents")
.map(|(_, row)| row.clone())
.collect::<Vec<_>>(),
expected
);
drop(store);
let recovered = GraphStore::open_with_durability_and_replay_config(
&path,
&mut catalog,
DurabilityPolicy::default(),
replay,
)
.unwrap();
assert_eq!(
recovered
.relational_state()
.rows("documents")
.map(|(_, row)| row.clone())
.collect::<Vec<_>>(),
expected
);
drop(recovered);
let read_only = GraphStore::open_read_only_with_durability_and_replay_config(
&path,
&mut catalog,
DurabilityPolicy::default(),
replay,
)
.unwrap();
assert_eq!(
read_only
.relational_state()
.rows("documents")
.map(|(_, row)| row.clone())
.collect::<Vec<_>>(),
expected
);
drop(read_only);
fs::remove_dir_all(path).unwrap();
}
fn private_checkpoint_recovery_publication(
mode: crate::config::RelationalIndexMode,
durability: DurabilityPolicy,
uncertain: bool,
) {
use crate::relational::{
RELATIONAL_INDEX_RECOVERY_MANIFEST_FILE, RELATIONAL_ROW_DELTA_MANIFEST_FILE,
};
let path = unique_test_dir(&format!(
"checkpoint_candidate_private_publish_{mode:?}_{durability:?}_{uncertain}"
));
let replay = WalReplayConfig {
relational_index_mode: mode,
residency_mode: StorageResidencyMode::OutOfCore,
..WalReplayConfig::default()
};
let mut catalog = Catalog::default();
let mut store = GraphStore::open_with_durability_and_replay_config(
&path,
&mut catalog,
durability,
WalReplayConfig {
relational_index_mode: crate::config::RelationalIndexMode::Shadow,
..replay
},
)
.unwrap();
let row = |id: usize| {
RelationalRow::new(vec![
RelationalValue::Text(id.to_string()),
RelationalValue::Text(format!("complete row {id}: 雪")),
])
};
let insert = |id| RelationalTransaction {
writes: vec![RelationalWrite::Insert {
table: "documents".into(),
mode: RelationalInsertMode::Error,
rows: vec![row(id)],
}],
};
store
.commit_relational_transaction(
&mut catalog,
RelationalTransaction {
writes: vec![
RelationalWrite::CreateTable(RelationalTableSchema {
name: "documents".into(),
columns: ["id", "body"]
.into_iter()
.map(|name| RelationalColumnSchema {
name: name.into(),
scalar_type: RelationalScalarType::Text,
nullable: false,
default: None,
})
.collect(),
primary_key: vec!["id".into()],
unique_constraints: Vec::new(),
foreign_keys: Vec::new(),
indexes: Vec::new(),
}),
insert(0).writes.pop().unwrap(),
],
},
)
.unwrap();
store.checkpoint(&catalog).unwrap();
store
.commit_relational_transaction(&mut catalog, insert(1))
.unwrap();
drop(store);
let mut store = GraphStore::open_with_durability_and_replay_config(
&path,
&mut catalog,
durability,
replay,
)
.unwrap();
let source = store.checkpoint_source();
let mut candidate = source
.prepare_checkpoint_candidate(&catalog)
.unwrap()
.unwrap();
drop(source);
store
.commit_relational_transaction(&mut catalog, insert(2))
.unwrap();
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(7))]))
.unwrap();
let before_manifest = fs::read(path.join(MANIFEST_FILE)).unwrap();
let before_wal = fs::read(active_wal_path(&path)).unwrap();
let selectors = [
RELATIONAL_ROW_DELTA_MANIFEST_FILE,
RELATIONAL_INDEX_RECOVERY_MANIFEST_FILE,
]
.map(|name| (name, fs::read(path.join(name)).unwrap()));
let captured = store.checkpoint_source();
candidate.catch_up(&captured).unwrap();
drop(captured);
candidate.finish_catch_up().unwrap();
assert_eq!(fs::read(path.join(MANIFEST_FILE)).unwrap(), before_manifest);
assert_eq!(fs::read(active_wal_path(&path)).unwrap(), before_wal);
for (name, expected) in selectors {
assert_eq!(
fs::read(path.join(name)).unwrap(),
expected,
"private {name}"
);
}
let epoch = store.commit_epoch();
let generation = candidate.prepared_generation_for_test();
let private_wal = path.join(super::wal_generation_file(generation));
if uncertain {
super::set_checkpoint_failpoint(Some(super::CheckpointPublishStage::ManifestPublished));
}
let published =
store.publish_checkpoint_candidate_deferred_reclamation(&mut candidate, None);
super::set_checkpoint_failpoint(None);
if uncertain {
assert!(matches!(published, Err(HawDBError::StorageIntegrity(_))));
assert!(store.ensure_usable().is_err());
} else {
published.unwrap();
assert_eq!(store.commit_epoch(), epoch);
assert_private_checkpoint_recovery_rows(&store, epoch, &row);
}
drop(candidate);
assert!(private_wal.exists());
assert!(path
.join(super::checkpoint_generation_file(generation))
.exists());
drop(store);
let read_only = GraphStore::open_read_only_with_durability_and_replay_config(
&path,
&mut catalog,
durability,
replay,
)
.unwrap();
assert_private_checkpoint_recovery_rows(&read_only, epoch, &row);
drop(read_only);
let recovered = GraphStore::open_with_durability_and_replay_config(
&path,
&mut catalog,
durability,
replay,
)
.unwrap();
assert_private_checkpoint_recovery_rows(&recovered, epoch, &row);
drop(recovered);
fs::remove_dir_all(path).unwrap();
}
fn assert_private_checkpoint_recovery_rows(
store: &GraphStore,
epoch: u64,
row: &impl Fn(usize) -> RelationalRow,
) {
use crate::relational::{
RelationalHydrationBudget, RelationalKey, RelationalRowPageSnapshotReadLimits,
};
assert_eq!(store.commit_epoch(), epoch);
assert_eq!(store.node_count_for_label(None), 1);
assert_eq!(
store
.node_owned(NodeId(0))
.unwrap()
.unwrap()
.properties
.get("id"),
Some(&Value::Int(7))
);
store
.validate_authoritative_relational_index_open()
.unwrap();
let reader = store
.open_relational_row_snapshot_reader()
.unwrap()
.unwrap();
let mut hydration = RelationalHydrationBudget::default();
for id in 0..3 {
let (actual, report) = reader
.point_projected(
"documents",
&RelationalKey(vec![RelationalValue::Text(id.to_string())]),
&[0, 1],
RelationalRowPageSnapshotReadLimits::default(),
&mut hydration,
&hawdb_core::RuntimeTaskContext::default(),
)
.unwrap();
let actual = actual
.unwrap()
.fields
.into_iter()
.map(|field| field.value)
.collect::<Vec<_>>();
assert_eq!(actual.as_slice(), row(id).values());
assert_eq!(report.identity.visible_commit_epoch, epoch);
let key = RelationalKey(vec![RelationalValue::Text(id.to_string())]);
let mut postings = Vec::new();
store
.visit_relational_index_read_view_prefix(
"documents",
crate::relational::RELATIONAL_PRIMARY_INDEX_NAME,
&key,
crate::relational::RelationalIndexReadLimits::default(),
|posting| {
postings.push(posting.clone());
true
},
)
.unwrap()
.unwrap();
assert_eq!(postings, vec![key]);
}
}
#[test]
fn checkpoint_candidate_seals_multiple_prefixes_on_the_same_base() {
use crate::config::RelationalIndexMode;
use crate::relational::{
RelationalHydrationBudget, RelationalKey, RelationalRowPageSnapshotReadLimits,
};
for mode in [
RelationalIndexMode::Shadow,
RelationalIndexMode::Authoritative,
] {
for durability in [
DurabilityPolicy::SyncOnEveryWrite,
DurabilityPolicy::SyncOnCheckpoint,
] {
let path = unique_test_dir("checkpoint_candidate_multiple_prefixes");
let replay = WalReplayConfig {
residency_mode: StorageResidencyMode::OutOfCore,
relational_index_mode: mode,
..WalReplayConfig::default()
};
let mut catalog = Catalog::default();
let mut store = GraphStore::open_with_durability_and_replay_config(
&path,
&mut catalog,
durability,
WalReplayConfig {
relational_index_mode: RelationalIndexMode::Shadow,
..replay
},
)
.unwrap();
let row = |id: usize| {
RelationalRow::new(vec![
RelationalValue::Text(id.to_string()),
RelationalValue::Text(format!("sealed complete row {id}: 雪")),
])
};
store
.commit_relational_transaction(
&mut catalog,
RelationalTransaction {
writes: vec![RelationalWrite::CreateTable(RelationalTableSchema {
name: "documents".into(),
columns: ["id", "body"]
.into_iter()
.map(|name| RelationalColumnSchema {
name: name.into(),
scalar_type: RelationalScalarType::Text,
nullable: false,
default: None,
})
.collect(),
primary_key: vec!["id".into()],
unique_constraints: Vec::new(),
foreign_keys: Vec::new(),
indexes: Vec::new(),
})],
},
)
.unwrap();
store.checkpoint(&catalog).unwrap();
drop(store);
let mut store = GraphStore::open_with_durability_and_replay_config(
&path,
&mut catalog,
durability,
replay,
)
.unwrap();
let source = store.checkpoint_source();
let base_epoch = source.commit_epoch();
let generation = source
.durable
.as_ref()
.unwrap()
.next_checkpoint_generation()
.unwrap();
let mut candidate = source
.prepare_checkpoint_candidate(&catalog)
.unwrap()
.unwrap();
drop(source);
let base_path = path.join(super::checkpoint_generation_file(generation));
let base_bytes = fs::read(&base_path).unwrap();
let manifest_before = fs::read(path.join(MANIFEST_FILE)).unwrap();
let mut previous_wal = Vec::new();
let check = |store: &GraphStore, count: usize| {
assert_eq!(store.commit_epoch(), base_epoch + 2 * count as u64);
assert_eq!(store.node_count_for_label(None), count);
store
.validate_authoritative_relational_index_open()
.unwrap();
let reader = store
.open_relational_row_snapshot_reader()
.unwrap()
.unwrap();
let mut hydration = RelationalHydrationBudget::default();
for id in 0..count {
assert_eq!(
store
.node_owned(NodeId(id as u64))
.unwrap()
.unwrap()
.properties
.get("id"),
Some(&Value::Int(id as i64))
);
let key = RelationalKey(vec![RelationalValue::Text(id.to_string())]);
let (actual, report) = reader
.point_projected(
"documents",
&key,
&[0, 1],
RelationalRowPageSnapshotReadLimits::default(),
&mut hydration,
&hawdb_core::RuntimeTaskContext::default(),
)
.unwrap();
let actual = actual
.unwrap()
.fields
.into_iter()
.map(|field| field.value)
.collect::<Vec<_>>();
assert_eq!(actual.as_slice(), row(id).values());
assert_eq!(report.identity.visible_commit_epoch, store.commit_epoch());
let mut postings = Vec::new();
store
.visit_relational_index_read_view_prefix(
"documents",
crate::relational::RELATIONAL_PRIMARY_INDEX_NAME,
&key,
crate::relational::RelationalIndexReadLimits::default(),
|posting| {
postings.push(posting.clone());
true
},
)
.unwrap()
.unwrap();
assert_eq!(postings, vec![key]);
}
};
for id in 0..4 {
store
.commit_relational_transaction(
&mut catalog,
RelationalTransaction {
writes: vec![RelationalWrite::Insert {
table: "documents".into(),
mode: RelationalInsertMode::Error,
rows: vec![row(id)],
}],
},
)
.unwrap();
store
.create_node(
&mut catalog,
"Memory",
properties([("id", Value::Int(id as i64))]),
)
.unwrap();
let captured = store.checkpoint_source();
let tail = candidate.catch_up(&captured).unwrap();
assert_eq!(tail.entries, 2 * (id + 1) as u64);
candidate.finish_catch_up().unwrap();
candidate.finish_catch_up().unwrap();
assert_eq!(candidate.catch_up(&captured).unwrap(), tail);
drop(captured);
assert_eq!(candidate.commit_epoch(), base_epoch + tail.entries);
assert_eq!(fs::read(&base_path).unwrap(), base_bytes);
assert_eq!(fs::read(path.join(MANIFEST_FILE)).unwrap(), manifest_before);
let wal = fs::read(path.join(super::wal_generation_file(generation))).unwrap();
assert!(wal.starts_with(&previous_wal));
assert!(wal.len() > previous_wal.len());
previous_wal = wal;
}
store
.publish_checkpoint_candidate_deferred_reclamation(&mut candidate, None)
.unwrap();
assert!(store.relational_row_pages.recovery_builder.is_none());
assert!(store.relational_index_shadow.recovery_builder.is_none());
check(&store, 4);
drop(candidate);
drop(store);
let read_only = GraphStore::open_read_only_with_durability_and_replay_config(
&path,
&mut catalog,
durability,
replay,
)
.unwrap();
check(&read_only, 4);
drop(read_only);
let recovered = GraphStore::open_with_durability_and_replay_config(
&path,
&mut catalog,
durability,
replay,
)
.unwrap();
check(&recovered, 4);
drop(recovered);
fs::remove_dir_all(path).unwrap();
}
}
}
#[test]
fn checkpoint_candidate_private_recovery_publishes_complete_selectors() {
for mode in [
crate::config::RelationalIndexMode::Shadow,
crate::config::RelationalIndexMode::Authoritative,
] {
for durability in [
DurabilityPolicy::SyncOnEveryWrite,
DurabilityPolicy::SyncOnCheckpoint,
] {
private_checkpoint_recovery_publication(mode, durability, false);
}
}
}
#[test]
fn checkpoint_candidate_private_recovery_uncertain_publication_retains_recovery() {
for mode in [
crate::config::RelationalIndexMode::Shadow,
crate::config::RelationalIndexMode::Authoritative,
] {
for durability in [
DurabilityPolicy::SyncOnEveryWrite,
DurabilityPolicy::SyncOnCheckpoint,
] {
private_checkpoint_recovery_publication(mode, durability, true);
}
}
}
#[test]
fn checkpoint_candidate_advances_across_captured_suffixes_without_rebuilding() {
for residency in [
StorageResidencyMode::Auto,
StorageResidencyMode::Materialized,
StorageResidencyMode::OutOfCore,
] {
let path = unique_test_dir("checkpoint_candidate_progress");
let mut catalog = Catalog::default();
let mut store = GraphStore::open_with_durability_and_replay_config(
&path,
&mut catalog,
DurabilityPolicy::default(),
WalReplayConfig {
residency_mode: residency,
..Default::default()
},
)
.unwrap();
for id in 1..=3 {
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(id))]))
.unwrap();
}
let reader = store.snapshot();
let source = store.checkpoint_source();
let mut candidate = source
.prepare_checkpoint_candidate(&catalog)
.unwrap()
.unwrap();
drop(source);
let before = std::fs::read(path.join(super::MANIFEST_FILE)).unwrap();
for id in 4..=5 {
store
.create_node(
&mut catalog,
"Memory",
properties([
("id", Value::Int(id)),
("body", Value::String("x".repeat(48 * 1024))),
]),
)
.unwrap();
}
assert!(store
.publish_checkpoint_candidate(&mut candidate, Some(3), &Default::default())
.is_err());
assert_eq!(
std::fs::read(path.join(super::MANIFEST_FILE)).unwrap(),
before
);
let captured = store.checkpoint_source();
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(6))]))
.unwrap();
let receipt = candidate.catch_up(&captured).unwrap();
assert_eq!(receipt.entries, 2);
assert_eq!(candidate.commit_epoch(), 5);
drop(captured);
assert!(store
.publish_checkpoint_candidate(&mut candidate, Some(3), &Default::default())
.is_err());
for id in 7..=9 {
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(id))]))
.unwrap();
let captured = store.checkpoint_source();
let receipt = candidate.catch_up(&captured).unwrap();
assert_eq!(receipt.entries, id as u64 - 3);
assert_eq!(candidate.commit_epoch(), id as u64);
}
let last_key = hawdb_storage::version::VersionKey::Database;
let last_stamp = store.version_index.stamp(&last_key);
assert!(last_stamp.is_some());
catalog = store
.publish_checkpoint_candidate(&mut candidate, Some(3), &Default::default())
.unwrap();
assert_eq!(store.commit_epoch(), 9);
assert_eq!(store.version_index.stamp(&last_key), last_stamp);
assert_eq!(
store
.node_records_owned()
.collect::<crate::error::Result<Vec<_>>>()
.unwrap()
.len(),
9
);
assert_eq!(
reader
.node_records_owned()
.collect::<crate::error::Result<Vec<_>>>()
.unwrap()
.len(),
3
);
for id in 0..9 {
assert_eq!(
store
.node_owned(NodeId(id))
.unwrap()
.unwrap()
.properties
.get("id"),
Some(&Value::Int(id as i64 + 1))
);
}
assert_eq!(reader.commit_epoch(), 3);
reader.ensure_usable().unwrap();
assert_eq!(
store
.storage_reclamation_watermark(Some(3))
.checkpoint_commit_epoch,
Some(3)
);
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(10))]))
.unwrap();
drop(candidate);
drop(reader);
drop(store);
let recovered = GraphStore::open_with_durability_and_replay_config(
&path,
&mut catalog,
DurabilityPolicy::default(),
WalReplayConfig {
residency_mode: residency,
..Default::default()
},
)
.unwrap();
assert_eq!(recovered.commit_epoch(), 10);
assert_eq!(
recovered
.node_records_owned()
.collect::<crate::error::Result<Vec<_>>>()
.unwrap()
.len(),
10
);
drop(recovered);
std::fs::remove_dir_all(path).unwrap();
}
}
#[test]
fn checkpoint_candidate_cancellation_between_replay_records_preserves_authority() {
use hawdb_core::{
RuntimeCancellationToken, RuntimeIoWaveController, RuntimeIoWaveError,
RuntimeIoWavePermit, RuntimeTaskContext,
};
use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
use std::sync::Arc;
#[derive(Debug)]
struct WaveLease(Arc<AtomicUsize>);
impl Drop for WaveLease {
fn drop(&mut self) {
self.0.fetch_sub(1, AtomicOrdering::SeqCst);
}
}
#[derive(Debug)]
struct CancelDuringReplay {
acquisitions: AtomicUsize,
in_flight: Arc<AtomicUsize>,
cancellation: RuntimeCancellationToken,
}
impl RuntimeIoWaveController for CancelDuringReplay {
fn try_acquire(
&self,
slots: NonZeroUsize,
task: &RuntimeTaskContext,
) -> std::result::Result<Option<Box<dyn RuntimeIoWavePermit>>, RuntimeIoWaveError>
{
self.acquire(slots, task).map(Some)
}
fn acquire(
&self,
slots: NonZeroUsize,
_task: &RuntimeTaskContext,
) -> std::result::Result<Box<dyn RuntimeIoWavePermit>, RuntimeIoWaveError> {
assert_eq!(slots.get(), 1);
if self.acquisitions.fetch_add(1, AtomicOrdering::SeqCst) + 1 == 6 {
self.cancellation.cancel();
}
assert_eq!(self.in_flight.fetch_add(1, AtomicOrdering::SeqCst), 0);
Ok(Box::new(WaveLease(Arc::clone(&self.in_flight))))
}
}
let path = unique_test_dir("checkpoint_candidate_cancel_replay");
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(1))]))
.unwrap();
let generation = store
.durable
.as_ref()
.unwrap()
.next_checkpoint_generation()
.unwrap();
let source = store.checkpoint_source();
let mut candidate = source
.prepare_checkpoint_candidate(&catalog)
.unwrap()
.unwrap();
drop(source);
for id in 2..=4 {
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(id))]))
.unwrap();
}
let captured = store.checkpoint_source();
let manifest = std::fs::read(path.join(super::MANIFEST_FILE)).unwrap();
let original_wal = store.durable.as_ref().unwrap().wal_path.clone();
let original_bytes = std::fs::read(&original_wal).unwrap();
let cancellation = RuntimeCancellationToken::new();
let controller = Arc::new(CancelDuringReplay {
acquisitions: AtomicUsize::new(0),
in_flight: Arc::new(AtomicUsize::new(0)),
cancellation: cancellation.clone(),
});
let task = RuntimeTaskContext::without_deadline(cancellation)
.with_io_wave_controller(controller.clone());
let result = candidate.catch_up_with_task_context(&captured, &task);
assert!(matches!(result, Err(HawDBError::Execution(_))));
assert_eq!(candidate.commit_epoch(), 2);
assert_eq!(controller.acquisitions.load(AtomicOrdering::SeqCst), 6);
assert_eq!(controller.in_flight.load(AtomicOrdering::SeqCst), 0);
assert!(store
.publish_checkpoint_candidate(&mut candidate, None, &BTreeSet::new())
.is_err());
store.ensure_usable().unwrap();
assert_eq!(std::fs::read(&original_wal).unwrap(), original_bytes);
assert_eq!(
std::fs::read(path.join(super::MANIFEST_FILE)).unwrap(),
manifest
);
let candidate_wal = path.join(super::wal_generation_file(generation));
assert!(
std::fs::metadata(&candidate_wal).unwrap().len()
> super::WAL_BINARY_FILE_HEADER_BYTES as u64
);
drop(candidate);
drop(captured);
assert!(!candidate_wal.exists());
assert!(!path
.join(super::checkpoint_generation_file(generation))
.exists());
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(5))]))
.unwrap();
drop(store);
let recovered = GraphStore::open(&path, &mut catalog).unwrap();
assert_eq!(recovered.commit_epoch(), 5);
for id in 0..5 {
assert_eq!(
recovered
.node_owned(NodeId(id))
.unwrap()
.unwrap()
.properties
.get("id"),
Some(&Value::Int(id as i64 + 1)),
);
}
drop(recovered);
std::fs::remove_dir_all(path).unwrap();
}
fn checkpoint_projection_fixture() -> (Catalog, GraphStore, Vec<(NodeId, NodeId, String)>) {
let mut catalog = Catalog::default();
let mut store = GraphStore::default();
let mut nodes = Vec::new();
let mut edges = Vec::new();
for id in 0..1025 {
nodes.push(
store
.create_node(
&mut catalog,
if id % 2 == 0 { "Memory" } else { "Source" },
properties([("id", Value::Int(id))]),
)
.unwrap(),
);
}
for index in 0..nodes.len() {
let mut add = |source, target, rel_type: &str| {
store
.create_relationship(&mut catalog, source, target, rel_type, BTreeMap::new())
.unwrap();
edges.push((source, target, rel_type.to_string()));
};
add(nodes[0], nodes[index], "LINKS");
if index % 3 == 0 {
add(nodes[0], nodes[index], "LINKS");
}
if index % 5 == 0 {
add(nodes[index], nodes[0], "BACK");
}
if index % 7 == 0 {
add(nodes[0], nodes[index], "OTHER");
}
}
(catalog, store, edges)
}
#[test]
fn checkpoint_units_projected_build_matches_independent_edges_and_analytics_filters() {
use crate::background::CheckpointWorkProbe;
use hawdb_qos::{LocalQosPolicy, LocalQosScheduler};
use std::sync::Arc;
let (catalog, store, edges) = checkpoint_projection_fixture();
let cases = [
(vec![], vec![]),
(vec!["Memory"], vec![]),
(vec!["Source", "Missing"], vec!["BACK", "Missing"]),
(vec!["Memory", "Memory", "Missing"], vec!["LINKS", "OTHER"]),
(vec!["Missing"], vec![]),
(vec![], vec!["Missing"]),
(vec!["Memory"], vec!["Missing"]),
];
let scheduler = LocalQosScheduler::new(LocalQosPolicy {
max_background_operations: Some(1),
max_total_background_operations: Some(4),
..LocalQosPolicy::default()
});
for (labels, rel_types) in cases {
let definition = ProjectedGraphDefinition {
node_labels: labels.iter().map(|label| (*label).to_string()).collect(),
rel_types: rel_types
.iter()
.map(|rel_type| (*rel_type).to_string())
.collect(),
relationship_predicates: Default::default(),
};
let ids = (0..1025)
.filter(|id| {
labels.is_empty()
|| labels.contains(&if id % 2 == 0 { "Memory" } else { "Source" })
})
.map(NodeId)
.collect::<Vec<_>>();
let indices = ids
.iter()
.enumerate()
.map(|(index, id)| (*id, index))
.collect::<BTreeMap<_, _>>();
let pairs = edges
.iter()
.filter(|(_, _, kind)| rel_types.is_empty() || rel_types.contains(&kind.as_str()))
.filter_map(|(source, target, _)| {
Some((*indices.get(source)?, *indices.get(target)?))
})
.collect::<BTreeSet<_>>();
let mut outgoing = Vec::new();
let mut incoming = Vec::new();
let mut outgoing_offsets = vec![0];
let mut incoming_offsets = vec![0];
for node in 0..ids.len() {
outgoing.extend(
pairs
.iter()
.filter(|(source, _)| *source == node)
.map(|(_, target)| *target),
);
incoming.extend(
pairs
.iter()
.filter(|(_, target)| *target == node)
.map(|(source, _)| *source),
);
outgoing_offsets.push(outgoing.len());
incoming_offsets.push(incoming.len());
}
let expected = super::ProjectedGraphArtifactData::new(
ids,
outgoing_offsets,
outgoing,
incoming_offsets,
incoming,
)
.unwrap();
let probe = Arc::new(CheckpointWorkProbe::default());
let actual = super::checkpoint_projected_graph_from_definition(
&catalog,
&store,
&definition,
&probe.context(scheduler.clone()),
)
.unwrap();
assert_eq!(actual, expected, "definition={definition:?}");
let analytics = super::projected_graph_from_definition(&catalog, &store, &definition);
assert_eq!(actual.nodes, analytics.nodes());
assert_eq!(actual.csr_offsets, analytics.csr_offsets());
assert_eq!(actual.csr_targets, analytics.csr_targets());
assert_eq!(actual.csc_offsets, analytics.csc_offsets());
assert_eq!(actual.csc_sources, analytics.csc_sources());
probe.assert_released(&scheduler);
}
}
#[test]
fn checkpoint_units_projected_build_applies_predicates_and_preserves_all_adjacency_arrays() {
use super::ProjectedGraphArtifactData;
use crate::background::CheckpointWorkProbe;
use hawdb_qos::{LocalQosPolicy, LocalQosScheduler};
use std::sync::atomic::Ordering;
use std::sync::Arc;
let mut catalog = Catalog::default();
let mut store = GraphStore::default();
let nodes = (0..4)
.map(|_| {
store
.create_node(&mut catalog, "Memory", BTreeMap::new())
.unwrap()
})
.collect::<Vec<_>>();
for (source, target, kind, confidence, tag) in [
(0, 1, "LINK", 0.7, "yes"),
(0, 2, "LINK", 0.69, "yes"),
(1, 3, "LINK", 0.8, "no"),
(3, 0, "BACK", 0.1, "no"),
(3, 0, "LINK", 0.9, "yes"),
] {
store
.create_relationship(
&mut catalog,
nodes[source],
nodes[target],
kind,
BTreeMap::from([
("confidence".into(), Value::Float(confidence)),
("tag".into(), Value::String(tag.into())),
]),
)
.unwrap();
}
let identity = store.checkpoint_source_identity();
let local = LocalQosScheduler::new(LocalQosPolicy {
max_background_operations: Some(1),
max_total_background_operations: Some(1),
..LocalQosPolicy::default()
});
for (labels, kinds) in [
(vec!["Memory"], vec!["LINK", "BACK"]),
(vec![], vec!["LINK"]),
(vec!["Memory"], vec![]),
(vec![], vec![]),
] {
let definition = ProjectedGraphDefinition {
node_labels: labels.iter().map(|name| (*name).into()).collect(),
rel_types: kinds.iter().map(|name| (*name).into()).collect(),
relationship_predicates: BTreeMap::from([
(
"LINK".into(),
ProjectedRelationshipPredicate::And(vec![
ProjectedRelationshipPredicate::Gte {
property: "confidence".into(),
value: Value::Float(0.7),
},
ProjectedRelationshipPredicate::Eq {
property: "tag".into(),
value: Value::String("yes".into()),
},
]),
),
(
"Missing".into(),
ProjectedRelationshipPredicate::Eq {
property: "missing".into(),
value: Value::Null,
},
),
]),
};
let expected = if labels.is_empty() && kinds.is_empty() {
ProjectedGraphArtifactData::new(
nodes.clone(),
vec![0, 2, 3, 3, 4],
vec![1, 2, 3, 0],
vec![0, 1, 2, 3, 4],
vec![3, 0, 0, 1],
)
.unwrap()
} else {
ProjectedGraphArtifactData::new(
nodes.clone(),
vec![0, 1, 1, 1, 2],
vec![1, 0],
vec![0, 1, 2, 2, 2],
vec![3, 0],
)
.unwrap()
};
let probe = Arc::new(CheckpointWorkProbe::default());
let actual = super::checkpoint_projected_graph_from_definition(
&catalog,
&store,
&definition,
&probe.context(local.clone()),
)
.unwrap();
assert_eq!(actual, expected);
let ordinary = super::projected_graph_from_definition(&catalog, &store, &definition);
assert_eq!(actual.nodes, ordinary.nodes());
assert_eq!(actual.csr_offsets, ordinary.csr_offsets());
assert_eq!(actual.csr_targets, ordinary.csr_targets());
assert_eq!(actual.csc_offsets, ordinary.csc_offsets());
assert_eq!(actual.csc_sources, ordinary.csc_sources());
let total = probe.completed.load(Ordering::SeqCst);
probe.assert_released(&local);
for cut in 1..=total {
let cancelled = Arc::new(CheckpointWorkProbe::default());
cancelled.cancel_after.store(cut, Ordering::SeqCst);
let error = super::checkpoint_projected_graph_from_definition(
&catalog,
&store,
&definition,
&cancelled.context(local.clone()),
)
.unwrap_err();
assert!(error.to_string().contains("checkpoint build stopped"));
assert_eq!(cancelled.completed.load(Ordering::SeqCst), cut);
cancelled.assert_released(&local);
assert_eq!(store.checkpoint_source_identity(), identity);
}
let retry = Arc::new(CheckpointWorkProbe::default());
assert_eq!(
super::checkpoint_projected_graph_from_definition(
&catalog,
&store,
&definition,
&retry.context(local.clone()),
)
.unwrap(),
expected
);
retry.assert_released(&local);
}
}
#[test]
fn checkpoint_units_projected_build_cancels_capture_hydration_and_flatten_then_retries() {
use crate::background::CheckpointWorkProbe;
use hawdb_qos::{LocalQosPolicy, LocalQosScheduler};
use std::sync::atomic::Ordering;
use std::sync::Arc;
let (catalog, store, _) = checkpoint_projection_fixture();
let nodes = store.nodes.len();
let relationships = store.relationships.len();
let definition = ProjectedGraphDefinition {
node_labels: Vec::new(),
rel_types: Vec::new(),
relationship_predicates: Default::default(),
};
let identity = store.checkpoint_source_identity();
let scheduler = LocalQosScheduler::new(LocalQosPolicy {
max_background_operations: Some(1),
max_total_background_operations: Some(4),
..LocalQosPolicy::default()
});
for cancel_after in [
17,
nodes + 17,
2 * nodes + 17,
3 * nodes + 1 + 17,
3 * nodes + relationships + 1 + 17,
3 * nodes + 2 * relationships + 2 + 17,
] {
let probe = Arc::new(CheckpointWorkProbe::default());
probe.cancel_after.store(cancel_after, Ordering::SeqCst);
let error = super::checkpoint_projected_graph_from_definition(
&catalog,
&store,
&definition,
&probe.context(scheduler.clone()),
)
.unwrap_err();
assert!(
error.to_string().contains("checkpoint build stopped"),
"cancel_after={cancel_after}: {error}"
);
assert_eq!(probe.completed.load(Ordering::SeqCst), cancel_after);
assert_eq!(probe.peak_units.load(Ordering::SeqCst), 1);
probe.assert_released(&scheduler);
assert_eq!(store.checkpoint_source_identity(), identity);
store.ensure_usable().unwrap();
}
let retry = Arc::new(CheckpointWorkProbe::default());
let actual = super::checkpoint_projected_graph_from_definition(
&catalog,
&store,
&definition,
&retry.context(scheduler.clone()),
)
.unwrap();
let reference = super::projected_graph_from_definition(&catalog, &store, &definition);
assert_eq!(actual.nodes, reference.nodes());
assert_eq!(actual.csr_offsets, reference.csr_offsets());
assert_eq!(actual.csr_targets, reference.csr_targets());
assert_eq!(actual.csc_offsets, reference.csc_offsets());
assert_eq!(actual.csc_sources, reference.csc_sources());
retry.assert_released(&scheduler);
}
#[test]
fn checkpoint_units_cancel_native_overlay_capture_before_io_and_retry_complete_graph() {
use crate::background::CheckpointWorkProbe;
use hawdb_qos::{LocalQosPolicy, LocalQosScheduler};
use std::sync::atomic::Ordering;
use std::sync::Arc;
let path = unique_test_dir("checkpoint_overlay_capture_cancel");
let open = |catalog: &mut Catalog| {
GraphStore::open_with_durability_and_replay_config(
&path,
catalog,
DurabilityPolicy::default(),
WalReplayConfig {
residency_mode: StorageResidencyMode::OutOfCore,
..WalReplayConfig::default()
},
)
.unwrap()
};
let mut catalog = Catalog::default();
let mut store = open(&mut catalog);
let mut nodes = Vec::new();
let mut relationships = Vec::new();
for id in 0..192 {
nodes.push(
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(id))]))
.unwrap(),
);
if id > 0 {
relationships.push(
store
.create_relationship(
&mut catalog,
nodes[id as usize - 1],
nodes[id as usize],
"LINKS",
properties([("id", Value::Int(id))]),
)
.unwrap(),
);
}
if id == 63 {
store.checkpoint(&catalog).unwrap();
assert!(store.canonical_base.is_some());
assert!(store.nodes.is_empty());
assert!(store.relationships.is_empty());
}
}
store
.set_node_properties_by_ids(
&mut catalog,
&[nodes[0]],
&[NodeSetAssignment {
property: "revision".to_string(),
value: super::NodeSetValue::Value(Value::Int(2)),
}],
)
.unwrap();
store
.delete_node_ids_with_limits(
&mut catalog,
&[nodes[20], nodes[150]],
true,
MutationLimits::default(),
)
.unwrap();
assert!(!store.node_tombstones.is_empty());
assert!(!store.relationship_tombstones.is_empty());
assert!(store.nodes.len() > 17);
assert!(store.relationships.len() > 17);
let expected_nodes = nodes
.into_iter()
.filter_map(|id| store.node_owned(id).unwrap())
.collect::<Vec<_>>();
let expected_relationships = relationships
.into_iter()
.filter_map(|id| store.relationship_owned(id).unwrap())
.collect::<Vec<_>>();
let identity = store.checkpoint_source_identity();
let durable = store.durable.as_ref().unwrap();
let wal_path = durable.wal_path.clone();
let manifest_path = durable.manifest_path().to_path_buf();
let wal = std::fs::read(&wal_path).unwrap();
let manifest = std::fs::read(&manifest_path).unwrap();
let scheduler = LocalQosScheduler::new(LocalQosPolicy {
max_background_operations: Some(1),
max_total_background_operations: Some(4),
..LocalQosPolicy::default()
});
let probe = Arc::new(CheckpointWorkProbe::default());
probe.cancel_after.store(17, Ordering::SeqCst);
let source = store.checkpoint_source();
let error = source
.prepare_checkpoint_candidate_with_work_context(
&catalog,
&probe.context(scheduler.clone()),
)
.unwrap_err();
assert!(error.to_string().contains("checkpoint build stopped"));
assert_eq!(probe.completed.load(Ordering::SeqCst), 17);
assert_eq!(probe.io_waves.load(Ordering::SeqCst), 0);
probe.assert_released(&scheduler);
source.ensure_usable().unwrap();
drop(source);
let probe = Arc::new(CheckpointWorkProbe::default());
probe.cancel_after.store(17, Ordering::SeqCst);
let error = store
.checkpoint_relationship_records_owned(&probe.context(scheduler.clone()))
.err()
.expect("relationship capture must also stop before collecting its delta");
assert!(error.to_string().contains("checkpoint build stopped"));
assert_eq!(probe.completed.load(Ordering::SeqCst), 17);
assert_eq!(probe.io_waves.load(Ordering::SeqCst), 0);
probe.assert_released(&scheduler);
assert_eq!(store.checkpoint_source_identity(), identity);
assert_eq!(std::fs::read(&wal_path).unwrap(), wal);
assert_eq!(std::fs::read(&manifest_path).unwrap(), manifest);
store.ensure_usable().unwrap();
let retry = Arc::new(CheckpointWorkProbe::default());
let work = retry.context(scheduler.clone());
assert_eq!(
store
.checkpoint_node_records_owned(&work)
.unwrap()
.collect::<crate::Result<Vec<_>>>()
.unwrap(),
expected_nodes,
);
assert_eq!(
store
.checkpoint_relationship_records_owned(&work)
.unwrap()
.collect::<crate::Result<Vec<_>>>()
.unwrap(),
expected_relationships,
);
let source = store.checkpoint_source();
let mut candidate = source
.prepare_checkpoint_candidate_with_work_context(&catalog, &work)
.unwrap()
.unwrap();
drop(source);
let captured = store.checkpoint_source();
candidate.catch_up(&captured).unwrap();
drop(captured);
store
.publish_checkpoint_candidate(&mut candidate, None, &Default::default())
.unwrap();
drop(candidate);
retry.assert_released(&scheduler);
drop(store);
let recovered = open(&mut catalog);
assert_eq!(
recovered
.node_records_owned()
.collect::<crate::Result<Vec<_>>>()
.unwrap(),
expected_nodes
);
assert_eq!(
recovered
.relationship_records_owned()
.collect::<crate::Result<Vec<_>>>()
.unwrap(),
expected_relationships
);
drop(recovered);
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn checkpoint_units_statistics_candidate_cancellation_preserves_authority_and_reopens() {
use crate::background::CheckpointWorkProbe;
use hawdb_qos::{LocalQosPolicy, LocalQosScheduler};
use std::sync::atomic::Ordering;
use std::sync::Arc;
for residency in [
StorageResidencyMode::Materialized,
StorageResidencyMode::OutOfCore,
] {
let path = unique_test_dir("checkpoint_statistics_cancel");
let open = |catalog: &mut Catalog| {
GraphStore::open_with_durability_and_replay_config(
&path,
catalog,
DurabilityPolicy::default(),
WalReplayConfig {
residency_mode: residency,
..WalReplayConfig::default()
},
)
.unwrap()
};
let mut catalog = Catalog::default();
let mut store = open(&mut catalog);
let index = store
.create_property_index(&mut catalog, "Memory", "rank")
.unwrap();
let mut ids = Vec::new();
for rank in 0..97i64 {
let node = store
.create_node(
&mut catalog,
"Memory",
properties([("rank", Value::Int(rank))]),
)
.unwrap();
if let Some(previous) = ids.last() {
store
.create_relationship(
&mut catalog,
*previous,
node,
"LINKS",
properties([("rank", Value::Int(rank))]),
)
.unwrap();
}
ids.push(node);
}
let expected_nodes = store
.node_records_owned()
.collect::<crate::Result<Vec<_>>>()
.unwrap();
let expected_relationships = store
.relationship_records_owned()
.collect::<crate::Result<Vec<_>>>()
.unwrap();
let expected_statistics = store.statistics(&catalog);
let identity = store.checkpoint_source_identity();
let durable = store.durable.as_ref().unwrap();
let wal_path = durable.wal_path.clone();
let manifest_path = durable.manifest_path().to_path_buf();
let wal = std::fs::read(&wal_path).unwrap();
let manifest = std::fs::read(&manifest_path).unwrap();
let scheduler = LocalQosScheduler::new(LocalQosPolicy {
max_background_operations: Some(1),
max_total_background_operations: Some(4),
..LocalQosPolicy::default()
});
if residency == StorageResidencyMode::Materialized {
let probe = Arc::new(CheckpointWorkProbe::default());
probe.cancel_after.store(17, Ordering::SeqCst);
let error = store
.checkpoint_statistics_with_work_context(
&catalog,
&probe.context(scheduler.clone()),
)
.unwrap_err();
assert!(error.to_string().contains("checkpoint build stopped"));
assert_eq!(probe.completed.load(Ordering::SeqCst), 17);
probe.assert_released(&scheduler);
}
let probe = Arc::new(CheckpointWorkProbe::default());
probe.cancel_after.store(17, Ordering::SeqCst);
let source = store.checkpoint_source();
let error = source
.prepare_checkpoint_candidate_with_work_context(
&catalog,
&probe.context(scheduler.clone()),
)
.unwrap_err();
assert!(error.to_string().contains("checkpoint build stopped"));
assert_eq!(probe.completed.load(Ordering::SeqCst), 17);
assert_eq!(probe.io_waves.load(Ordering::SeqCst), 0);
probe.assert_released(&scheduler);
source.ensure_usable().unwrap();
drop(source);
assert_eq!(store.checkpoint_source_identity(), identity);
assert_eq!(std::fs::read(&wal_path).unwrap(), wal);
assert_eq!(std::fs::read(&manifest_path).unwrap(), manifest);
store.ensure_usable().unwrap();
let retry = Arc::new(CheckpointWorkProbe::default());
let source = store.checkpoint_source();
let mut candidate = source
.prepare_checkpoint_candidate_with_work_context(
&catalog,
&retry.context(scheduler.clone()),
)
.unwrap()
.unwrap();
drop(source);
let captured = store.checkpoint_source();
candidate.catch_up(&captured).unwrap();
drop(captured);
store
.publish_checkpoint_candidate(&mut candidate, None, &Default::default())
.unwrap();
drop(candidate);
retry.assert_released(&scheduler);
drop(store);
let recovered = open(&mut catalog);
assert_eq!(
recovered
.node_records_owned()
.collect::<crate::Result<Vec<_>>>()
.unwrap(),
expected_nodes
);
assert_eq!(
recovered
.relationship_records_owned()
.collect::<crate::Result<Vec<_>>>()
.unwrap(),
expected_relationships
);
let statistics = recovered.statistics(&catalog);
assert_eq!(statistics.node_count, 97);
assert_eq!(statistics.relationship_count, 96);
assert_eq!(
statistics.index_samples[&index],
hawdb_core::IndexStatisticsSample::exact(97, 97)
);
if residency == StorageResidencyMode::Materialized {
assert_eq!(statistics, expected_statistics);
} else {
assert!(!statistics.advanced_statistics_complete);
}
let retry = Arc::new(CheckpointWorkProbe::default());
assert_eq!(
recovered
.checkpoint_statistics_with_work_context(
&catalog,
&retry.context(scheduler.clone())
)
.unwrap(),
statistics
);
retry.assert_released(&scheduler);
drop(recovered);
std::fs::remove_dir_all(path).unwrap();
}
}
#[derive(Debug)]
struct CancelCheckpointAtFile {
probe: std::sync::Arc<crate::background::CheckpointWorkProbe>,
path: std::path::PathBuf,
observed_file: std::sync::atomic::AtomicBool,
completed_after_file: std::sync::atomic::AtomicUsize,
cancel_after_file: usize,
file_bytes_at_cancellation: std::sync::atomic::AtomicU64,
}
impl hawdb_qos::QosTelemetrySink for CancelCheckpointAtFile {
fn record_qos(&self, event: hawdb_qos::QosTelemetryEvent) {
use std::sync::atomic::Ordering;
let completed = event.phase == hawdb_qos::QosTelemetryPhase::Completion;
self.probe.record_qos(event);
if completed && self.path.exists() {
self.observed_file.store(true, Ordering::SeqCst);
let completed = self.completed_after_file.fetch_add(1, Ordering::SeqCst) + 1;
if completed >= self.cancel_after_file {
self.file_bytes_at_cancellation.store(
std::fs::metadata(&self.path).unwrap().len(),
Ordering::SeqCst,
);
self.probe.cancellation.cancel();
}
}
}
}
#[test]
fn checkpoint_units_cancel_base_encoding_and_retry_without_changing_authority() {
use crate::background::{CheckpointWorkContext, CheckpointWorkProbe};
use hawdb_core::RuntimeTaskContext;
use hawdb_qos::{LocalQosPolicy, LocalQosScheduler};
use std::sync::atomic::Ordering;
use std::sync::Arc;
let path = unique_test_dir("checkpoint_base_unit_cancel");
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
for id in 0..64i64 {
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(id))]))
.unwrap();
}
store.checkpoint(&catalog).unwrap();
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(64))]))
.unwrap();
let identity = store.checkpoint_source_identity();
let generation = store
.durable
.as_ref()
.unwrap()
.next_checkpoint_generation()
.unwrap();
let wal_path = store.durable.as_ref().unwrap().wal_path.clone();
let manifest_path = store
.durable
.as_ref()
.unwrap()
.manifest_path()
.to_path_buf();
let wal = std::fs::read(&wal_path).unwrap();
let manifest = std::fs::read(&manifest_path).unwrap();
let scheduler = LocalQosScheduler::new(LocalQosPolicy {
max_background_operations: Some(1),
max_total_background_operations: Some(4),
..LocalQosPolicy::default()
});
let probe = Arc::new(CheckpointWorkProbe::default());
let private = path.join(super::canonical_artifact_generation_file(generation));
let observer = Arc::new(CancelCheckpointAtFile {
probe: probe.clone(),
path: private.with_extension("hawdb.tmp"),
observed_file: Default::default(),
completed_after_file: Default::default(),
cancel_after_file: 10,
file_bytes_at_cancellation: Default::default(),
});
scheduler.set_telemetry_sink(Some(observer.clone()));
let task = RuntimeTaskContext::without_deadline(probe.cancellation.clone())
.with_io_wave_controller(probe.clone());
let work = CheckpointWorkContext::new(task).with_scheduler(scheduler.clone());
let source = store.checkpoint_source();
let error = source
.prepare_checkpoint_candidate_with_work_context(&catalog, &work)
.unwrap_err();
assert!(error.to_string().contains("checkpoint build stopped"));
assert!(observer.observed_file.load(Ordering::SeqCst));
assert_eq!(observer.completed_after_file.load(Ordering::SeqCst), 10);
probe.assert_released(&scheduler);
assert_eq!(store.checkpoint_source_identity(), identity);
assert_eq!(std::fs::read(&wal_path).unwrap(), wal);
assert_eq!(std::fs::read(&manifest_path).unwrap(), manifest);
source.ensure_usable().unwrap();
store.ensure_usable().unwrap();
assert!(!private.exists());
assert!(!private.with_extension("hawdb.tmp").exists());
assert!(!path
.join(format!(".checkpoint.{generation}.prepare"))
.exists());
drop(source);
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(65))]))
.unwrap();
let retry = Arc::new(CheckpointWorkProbe::default());
let work = retry.context(scheduler.clone());
let source = store.checkpoint_source();
let mut candidate = source
.prepare_checkpoint_candidate_with_work_context(&catalog, &work)
.unwrap()
.unwrap();
drop(source);
let captured = store.checkpoint_source();
candidate.catch_up(&captured).unwrap();
drop(captured);
store
.publish_checkpoint_candidate(&mut candidate, None, &Default::default())
.unwrap();
drop(candidate);
retry.assert_released(&scheduler);
drop(store);
let recovered = GraphStore::open(&path, &mut catalog).unwrap();
let mut ids = recovered
.scan_nodes(None)
.map(|node| match node.properties["id"] {
Value::Int(id) => id,
_ => panic!("wrong recovered id"),
})
.collect::<Vec<_>>();
ids.sort_unstable();
assert_eq!(ids, (0..66i64).collect::<Vec<_>>());
assert_eq!(recovered.commit_epoch(), 66);
drop(recovered);
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn checkpoint_units_append_capture_candidate_stops_before_publication_and_reopens_all_rows() {
use crate::append_table::{
append_generation_manifest_file, append_segment_file, AppendOrderMode, AppendTableRow,
AppendTableSchema, AppendTransaction, AppendWrite,
};
use crate::background::{CheckpointWorkContext, CheckpointWorkProbe};
use hawdb_core::RuntimeTaskContext;
use hawdb_qos::{LocalQosPolicy, LocalQosScheduler};
use std::sync::atomic::Ordering;
use std::sync::Arc;
let path = unique_test_dir("checkpoint_append_capture_cancel");
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
let schema = AppendTableSchema {
name: "events".into(),
columns: [
("stream", RelationalScalarType::BigInt),
("sequence", RelationalScalarType::BigInt),
("payload", RelationalScalarType::Bytea),
]
.into_iter()
.map(|(name, scalar_type)| RelationalColumnSchema {
name: name.into(),
scalar_type,
nullable: false,
default: None,
})
.collect(),
partition_key: vec!["stream".into()],
order_key: vec!["sequence".into()],
order_mode: AppendOrderMode::CallerProvided,
};
let row = |sequence| {
RelationalRow::new(vec![
RelationalValue::BigInt(sequence % 7),
RelationalValue::BigInt(sequence),
RelationalValue::Bytea(vec![sequence as u8; 17]),
])
};
store
.append_transaction(AppendTransaction {
writes: vec![
AppendWrite::CreateTable {
schema: schema.clone(),
},
AppendWrite::Append {
table: "events".into(),
rows: (0..64).map(row).collect(),
},
],
})
.unwrap();
store.checkpoint(&catalog).unwrap();
store
.append_transaction(AppendTransaction {
writes: vec![AppendWrite::Append {
table: "events".into(),
rows: (64..128).map(row).collect(),
}],
})
.unwrap();
assert_eq!(store.append_state.live_rows(), 64);
let identity = store.checkpoint_source_identity();
let durable = store.durable.as_ref().unwrap();
let generation = durable.next_checkpoint_generation().unwrap();
let staging = path.join(format!(".checkpoint.{generation}.prepare"));
let wal_path = durable.wal_path.clone();
let manifest_path = durable.manifest_path().to_path_buf();
let wal = std::fs::read(&wal_path).unwrap();
let manifest = std::fs::read(&manifest_path).unwrap();
let scheduler = LocalQosScheduler::new(LocalQosPolicy {
max_background_operations: Some(1),
max_total_background_operations: Some(4),
..LocalQosPolicy::default()
});
let probe = Arc::new(CheckpointWorkProbe::default());
let observer = Arc::new(CancelCheckpointAtFile {
probe: probe.clone(),
path: staging.clone(),
observed_file: Default::default(),
completed_after_file: Default::default(),
cancel_after_file: 17,
file_bytes_at_cancellation: Default::default(),
});
scheduler.set_telemetry_sink(Some(observer.clone()));
let work = CheckpointWorkContext::new(
RuntimeTaskContext::without_deadline(probe.cancellation.clone())
.with_io_wave_controller(probe.clone()),
)
.with_scheduler(scheduler.clone());
let source = store.checkpoint_source();
let error = source
.prepare_checkpoint_candidate_with_work_context(&catalog, &work)
.unwrap_err();
assert!(error.to_string().contains("checkpoint build stopped"));
assert!(observer.observed_file.load(Ordering::SeqCst));
assert_eq!(observer.completed_after_file.load(Ordering::SeqCst), 17);
assert_eq!(probe.io_waves.load(Ordering::SeqCst), 0);
probe.assert_released(&scheduler);
source.ensure_usable().unwrap();
drop(source);
assert_eq!(store.checkpoint_source_identity(), identity);
assert_eq!(std::fs::read(&wal_path).unwrap(), wal);
assert_eq!(std::fs::read(&manifest_path).unwrap(), manifest);
assert!(!staging.exists());
assert!(!path.join(append_segment_file(generation)).exists());
assert!(!path
.join(append_generation_manifest_file(generation))
.exists());
store.ensure_usable().unwrap();
let retry = Arc::new(CheckpointWorkProbe::default());
let source = store.checkpoint_source();
let mut candidate = source
.prepare_checkpoint_candidate_with_work_context(
&catalog,
&retry.context(scheduler.clone()),
)
.unwrap()
.unwrap();
drop(source);
let captured = store.checkpoint_source();
candidate.catch_up(&captured).unwrap();
drop(captured);
store
.publish_checkpoint_candidate(&mut candidate, None, &Default::default())
.unwrap();
drop(candidate);
retry.assert_released(&scheduler);
drop(store);
let recovered = GraphStore::open(&path, &mut catalog).unwrap();
assert_eq!(recovered.append_state.schema("events"), Some(&schema));
let mut count = 0;
for stream in 0..7i64 {
let partition = RelationalKey(vec![RelationalValue::BigInt(stream)]);
let actual = recovered
.read_append_partition("events", &partition, None, 128)
.unwrap();
let expected = (0..128)
.filter(|sequence| sequence % 7 == stream)
.map(|sequence| AppendTableRow {
table: "events".into(),
partition_key: partition.clone(),
order_key: RelationalKey(vec![RelationalValue::BigInt(sequence)]),
row: row(sequence),
})
.collect::<Vec<_>>();
assert_eq!(actual.rows, expected);
count += actual.rows.len();
}
assert_eq!(count, 128);
drop(recovered);
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn checkpoint_units_cancel_native_tombstone_run_before_survivor_and_retry() {
use crate::background::{CheckpointWorkContext, CheckpointWorkProbe};
use hawdb_core::RuntimeTaskContext;
use hawdb_qos::{LocalQosPolicy, LocalQosScheduler};
use std::sync::atomic::Ordering;
use std::sync::Arc;
let path = unique_test_dir("checkpoint_tombstone_run_cancel");
let mut catalog = Catalog::default();
let mut store = GraphStore::open_with_durability_and_replay_config(
&path,
&mut catalog,
DurabilityPolicy::default(),
WalReplayConfig {
residency_mode: StorageResidencyMode::OutOfCore,
..WalReplayConfig::default()
},
)
.unwrap();
let mut ids = Vec::new();
for id in 0..512 {
ids.push(
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(id))]))
.unwrap(),
);
}
store.checkpoint(&catalog).unwrap();
store
.delete_node_ids_with_limits(&mut catalog, &ids[..511], true, MutationLimits::default())
.unwrap();
assert!(store.nodes.is_empty());
assert_eq!(store.node_tombstones.len(), 511);
let expected = store.node_owned(ids[511]).unwrap().unwrap();
let identity = store.checkpoint_source_identity();
let durable = store.durable.as_ref().unwrap();
let generation = durable.next_checkpoint_generation().unwrap();
let wal_path = durable.wal_path.clone();
let manifest_path = durable.manifest_path().to_path_buf();
let wal = std::fs::read(&wal_path).unwrap();
let manifest = std::fs::read(&manifest_path).unwrap();
let scheduler = LocalQosScheduler::new(LocalQosPolicy {
max_background_operations: Some(1),
max_total_background_operations: Some(4),
..LocalQosPolicy::default()
});
let probe = Arc::new(CheckpointWorkProbe::default());
let private = path.join(super::canonical_artifact_generation_file(generation));
let observer = Arc::new(CancelCheckpointAtFile {
probe: probe.clone(),
path: private.with_extension("hawdb.tmp"),
observed_file: Default::default(),
completed_after_file: Default::default(),
cancel_after_file: 17,
file_bytes_at_cancellation: Default::default(),
});
scheduler.set_telemetry_sink(Some(observer.clone()));
let task = RuntimeTaskContext::without_deadline(probe.cancellation.clone())
.with_io_wave_controller(probe.clone());
let work = CheckpointWorkContext::new(task).with_scheduler(scheduler.clone());
let source = store.checkpoint_source();
let error = source
.prepare_checkpoint_candidate_with_work_context(&catalog, &work)
.unwrap_err();
assert!(error.to_string().contains("checkpoint build stopped"));
assert!(observer.observed_file.load(Ordering::SeqCst));
assert_eq!(observer.completed_after_file.load(Ordering::SeqCst), 17);
assert_eq!(
observer.file_bytes_at_cancellation.load(Ordering::SeqCst),
24
);
probe.assert_released(&scheduler);
assert_eq!(store.checkpoint_source_identity(), identity);
assert_eq!(std::fs::read(&wal_path).unwrap(), wal);
assert_eq!(std::fs::read(&manifest_path).unwrap(), manifest);
assert!(!private.exists());
assert!(!observer.path.exists());
source.ensure_usable().unwrap();
store.ensure_usable().unwrap();
drop(source);
let retry = Arc::new(CheckpointWorkProbe::default());
let source = store.checkpoint_source();
let mut candidate = source
.prepare_checkpoint_candidate_with_work_context(
&catalog,
&retry.context(scheduler.clone()),
)
.unwrap()
.unwrap();
drop(source);
let captured = store.checkpoint_source();
candidate.catch_up(&captured).unwrap();
drop(captured);
store
.publish_checkpoint_candidate(&mut candidate, None, &Default::default())
.unwrap();
drop(candidate);
retry.assert_released(&scheduler);
drop(store);
let recovered = GraphStore::open(&path, &mut catalog).unwrap();
assert_eq!(
recovered
.node_records_owned()
.collect::<crate::Result<Vec<_>>>()
.unwrap(),
vec![expected]
);
assert_eq!(recovered.relationship_records_owned().count(), 0);
drop(recovered);
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn checkpoint_units_cancel_adjacency_and_retry_with_complete_relationships() {
assert_cancelled_derived_artifact_unit_preserves_authority("adjacency");
}
#[test]
fn checkpoint_units_cancel_property_projection_and_retry_with_complete_relationships() {
assert_cancelled_derived_artifact_unit_preserves_authority("property-index");
}
#[test]
fn checkpoint_units_cancel_source_scan_and_retry_with_complete_graph() {
assert_cancelled_derived_artifact_unit_preserves_authority("source-scan");
}
#[test]
fn checkpoint_units_cancel_projected_graph_and_retry_with_complete_arrays() {
assert_cancelled_derived_artifact_unit_preserves_authority("projected-graph");
}
fn assert_cancelled_derived_artifact_unit_preserves_authority(artifact: &str) {
use crate::background::{CheckpointWorkContext, CheckpointWorkProbe};
use hawdb_core::RuntimeTaskContext;
use hawdb_qos::{LocalQosPolicy, LocalQosScheduler};
use std::sync::atomic::Ordering;
use std::sync::Arc;
let path = unique_test_dir(&format!("checkpoint_{artifact}_unit_cancel"));
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
let label = if artifact == "source-scan" {
"Source"
} else {
"Memory"
};
let nodes = (0..64i64)
.map(|id| {
store
.create_node(&mut catalog, label, properties([("id", Value::Int(id))]))
.unwrap()
})
.collect::<Vec<_>>();
let mut relationships = nodes[1..]
.iter()
.enumerate()
.map(|(id, target)| {
store
.create_relationship(
&mut catalog,
nodes[0],
*target,
"LINKS",
properties([("id", Value::Int(id as i64))]),
)
.unwrap()
})
.collect::<Vec<_>>();
if artifact == "projected-graph" {
store
.register_projected_graph(
"CheckpointGraph",
crate::projection::ProjectedGraphDefinition {
node_labels: Vec::new(),
rel_types: Vec::new(),
relationship_predicates: Default::default(),
},
)
.unwrap();
}
store.checkpoint(&catalog).unwrap();
relationships.push(
store
.create_relationship(&mut catalog, nodes[1], nodes[0], "LINKS", BTreeMap::new())
.unwrap(),
);
let identity = store.checkpoint_source_identity();
let durable = store.durable.as_ref().unwrap();
let generation = durable.next_checkpoint_generation().unwrap();
let wal_path = durable.wal_path.clone();
let manifest_path = durable.manifest_path().to_path_buf();
let wal = std::fs::read(&wal_path).unwrap();
let manifest = std::fs::read(&manifest_path).unwrap();
let scheduler = LocalQosScheduler::new(LocalQosPolicy {
max_background_operations: Some(1),
max_total_background_operations: Some(4),
..LocalQosPolicy::default()
});
let probe = Arc::new(CheckpointWorkProbe::default());
let temporary = if artifact == "projected-graph" {
path.join(format!(".checkpoint.{generation}.prepare"))
.join(super::PROJECTED_GRAPHS_FILE)
.with_extension("hawdb.tmp")
} else if artifact == "source-scan" {
path.join(format!(".checkpoint.{generation}.prepare"))
.join(source_scan::SOURCE_SCAN_PAYLOAD_FILE)
.with_extension("hawdb.tmp")
} else {
path.join(format!(".{artifact}.{generation}.run.0.tmp"))
};
let observer = Arc::new(CancelCheckpointAtFile {
probe: probe.clone(),
path: temporary,
observed_file: Default::default(),
completed_after_file: Default::default(),
cancel_after_file: 1,
file_bytes_at_cancellation: Default::default(),
});
scheduler.set_telemetry_sink(Some(observer.clone()));
let task = RuntimeTaskContext::without_deadline(probe.cancellation.clone())
.with_io_wave_controller(probe.clone());
let work = CheckpointWorkContext::new(task).with_scheduler(scheduler.clone());
let source = store.checkpoint_source();
let error = source
.prepare_checkpoint_candidate_with_work_context(&catalog, &work)
.unwrap_err();
assert!(observer.observed_file.load(Ordering::SeqCst));
assert!(error.to_string().contains("checkpoint build stopped"));
probe.assert_released(&scheduler);
assert_eq!(store.checkpoint_source_identity(), identity);
assert_eq!(std::fs::read(&wal_path).unwrap(), wal);
assert_eq!(std::fs::read(&manifest_path).unwrap(), manifest);
assert!(!observer.path.exists());
assert!(!path
.join(format!(".checkpoint.{generation}.prepare"))
.exists());
source.ensure_usable().unwrap();
store.ensure_usable().unwrap();
drop(source);
relationships.push(
store
.create_relationship(&mut catalog, nodes[2], nodes[0], "LINKS", BTreeMap::new())
.unwrap(),
);
let retry = Arc::new(CheckpointWorkProbe::default());
let work = retry.context(scheduler.clone());
let source = store.checkpoint_source();
let mut candidate = source
.prepare_checkpoint_candidate_with_work_context(&catalog, &work)
.unwrap()
.unwrap();
drop(source);
let captured = store.checkpoint_source();
candidate.catch_up(&captured).unwrap();
drop(captured);
store
.publish_checkpoint_candidate(&mut candidate, None, &Default::default())
.unwrap();
drop(candidate);
retry.assert_released(&scheduler);
let expected = relationships
.into_iter()
.map(|id| store.relationship_owned(id).unwrap().unwrap())
.collect::<Vec<_>>();
let expected_nodes = nodes
.into_iter()
.map(|id| store.node_owned(id).unwrap().unwrap())
.collect::<Vec<_>>();
let epoch = store.commit_epoch();
drop(store);
let recovered = GraphStore::open(&path, &mut catalog).unwrap();
assert_eq!(recovered.commit_epoch(), epoch);
assert_eq!(recovered.scan_nodes(None).count(), expected_nodes.len());
let expected_node_ids = expected_nodes
.iter()
.map(|node| node.id)
.collect::<Vec<_>>();
for node in expected_nodes {
assert_eq!(recovered.node_owned(node.id).unwrap(), Some(node));
}
assert_eq!(recovered.scan_relationships(None).count(), expected.len());
for relationship in expected {
assert_eq!(
recovered.relationship_owned(relationship.id).unwrap(),
Some(relationship)
);
}
if artifact == "source-scan" {
let crate::scan::ScanSegmentAccessPlan::Read(plan) = recovered
.plan_published_source_scan(&ScanPredicate::Eq {
property: "id".into(),
value: Value::Int(31),
})
else {
panic!("successful retry must reopen a current Source sidecar");
};
assert_eq!(plan.segments.len(), 1);
assert_eq!(plan.segments[0].candidates.as_ref().unwrap().remaining(), 1);
}
if artifact == "projected-graph" {
let graph = recovered
.projected_graph_artifact(
"CheckpointGraph",
&crate::projection::ProjectedGraphDefinition {
node_labels: Vec::new(),
rel_types: Vec::new(),
relationship_predicates: Default::default(),
},
)
.expect("successful retry must reopen current projected arrays");
assert_eq!(graph.nodes(), expected_node_ids);
assert_eq!(
graph.csr_offsets(),
std::iter::once(0)
.chain([63, 64])
.chain(std::iter::repeat_n(65, 62))
.collect::<Vec<_>>()
);
assert_eq!(
graph.csr_targets(),
(1..64).chain([0, 0]).collect::<Vec<_>>()
);
assert_eq!(
graph.csc_offsets(),
std::iter::once(0).chain(2..=65).collect::<Vec<_>>()
);
assert_eq!(
graph.csc_sources(),
[1, 2]
.into_iter()
.chain(std::iter::repeat_n(0, 63))
.collect::<Vec<_>>()
);
}
drop(recovered);
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn checkpoint_candidate_rejects_lost_private_tail_and_cleans_owned_artifacts() {
let path = unique_test_dir("checkpoint_candidate_lost_tail");
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(1))]))
.unwrap();
let source = store.checkpoint_source();
let mut candidate = source
.prepare_checkpoint_candidate(&catalog)
.unwrap()
.unwrap();
drop(source);
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(2))]))
.unwrap();
let captured = store.checkpoint_source();
candidate.catch_up(&captured).unwrap();
drop(captured);
let old_manifest = std::fs::read(path.join(super::MANIFEST_FILE)).unwrap();
let old_wal = store.durable.as_ref().unwrap().wal_path.clone();
let old_bytes = std::fs::read(&old_wal).unwrap();
let generation = candidate.prepared_generation_for_test();
let candidate_path = path.join(super::wal_generation_file(generation));
std::fs::OpenOptions::new()
.write(true)
.open(&candidate_path)
.unwrap()
.set_len(super::WAL_BINARY_FILE_HEADER_BYTES as u64)
.unwrap();
assert!(matches!(
store.publish_checkpoint_candidate(&mut candidate, None, &Default::default()),
Err(HawDBError::StorageIntegrity(_))
));
store.ensure_usable().unwrap();
assert_eq!(
std::fs::read(path.join(super::MANIFEST_FILE)).unwrap(),
old_manifest
);
assert_eq!(std::fs::read(&old_wal).unwrap(), old_bytes);
drop(candidate);
assert!(!candidate_path.exists());
assert!(!path
.join(super::checkpoint_generation_file(generation))
.exists());
drop(store);
let recovered = GraphStore::open(&path, &mut catalog).unwrap();
assert_eq!(recovered.node_count_for_label(None), 2);
assert_eq!(recovered.commit_epoch(), 2);
drop(recovered);
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn checkpoint_candidate_uncertain_manifest_retains_complete_recovery_evidence() {
let path = unique_test_dir("checkpoint_candidate_uncertain_manifest");
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(1))]))
.unwrap();
let reader = store.snapshot();
let source = store.checkpoint_source();
let mut candidate = source
.prepare_checkpoint_candidate(&catalog)
.unwrap()
.unwrap();
drop(source);
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(2))]))
.unwrap();
let captured = store.checkpoint_source();
candidate.catch_up(&captured).unwrap();
drop(captured);
let generation = candidate.prepared_generation_for_test();
super::set_checkpoint_failpoint(Some(super::CheckpointPublishStage::ManifestPublished));
let publication =
store.publish_checkpoint_candidate(&mut candidate, Some(1), &Default::default());
super::set_checkpoint_failpoint(None);
assert!(matches!(publication, Err(HawDBError::StorageIntegrity(_))));
assert!(store.ensure_usable().is_err());
assert!(reader.ensure_usable().is_err());
drop(candidate);
assert!(path.join(super::wal_generation_file(generation)).exists());
assert!(path
.join(super::checkpoint_generation_file(generation))
.exists());
drop(reader);
drop(store);
let recovered = GraphStore::open(&path, &mut catalog).unwrap();
assert_eq!(recovered.commit_epoch(), 2);
assert_eq!(recovered.node_count_for_label(None), 2);
assert_eq!(
recovered.storage_recovery_report().checkpoint_commit_epoch,
Some(1)
);
drop(recovered);
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn selected_checkpoint_handoff_preserves_live_ownership_and_releases_snapshot_floor() {
for durability in [
DurabilityPolicy::SyncOnEveryWrite,
DurabilityPolicy::SyncOnCheckpoint,
] {
let path = unique_test_dir("selected_checkpoint_handoff");
let mut catalog = Catalog::default();
let mut store =
GraphStore::open_with_durability(&path, &mut catalog, durability).unwrap();
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(1))]))
.unwrap();
let reader = store.snapshot();
let base = store.checkpoint_source();
let mut candidate = base
.prepare_checkpoint_candidate(&catalog)
.unwrap()
.unwrap();
drop(base);
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(2))]))
.unwrap();
let expected = store.checkpoint_source_identity().unwrap();
let mut worker = store.checkpoint_source();
candidate.catch_up(&worker).unwrap();
assert!(worker
.publish_checkpoint_candidate_deferred_reclamation(&mut candidate, Some(1))
.unwrap_err()
.to_string()
.contains("finalized before publication"));
assert_eq!(worker.durable.as_ref().unwrap().checkpoint_epoch, 0);
candidate.finish_catch_up().unwrap();
candidate.finish_catch_up().unwrap();
let sealed_tail = candidate.catch_up(&worker).unwrap();
assert_eq!(sealed_tail.captured_commit_epoch, worker.commit_epoch());
assert_eq!(sealed_tail.entries, 1);
candidate.finish_catch_up().unwrap();
worker
.publish_checkpoint_candidate_deferred_reclamation(&mut candidate, Some(1))
.unwrap();
candidate
.reclaim_published_generations(&mut worker, &Default::default())
.unwrap();
assert!(worker.version_snapshot_pin.is_some());
assert!(store.version_snapshot_pin.is_none());
let retired = store.adopt_selected_checkpoint(worker, expected).unwrap();
assert!(store.version_snapshot_pin.is_none());
assert_eq!(store.commit_epoch(), 2);
assert_eq!(store.node_count_for_label(None), 2);
assert_eq!(reader.commit_epoch(), 1);
assert_eq!(reader.node_count_for_label(None), 1);
assert!(store
.version_index
.shares_storage_with(&retired.version_index));
drop(retired);
drop(candidate);
drop(reader);
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(3))]))
.unwrap();
store.reclaim_version_history();
assert_eq!(store.version_snapshot_pins.oldest_epoch(), None);
assert_eq!(store.version_index.retained_history_bytes(), 0);
assert_eq!(store.version_index.len(), 0);
drop(store);
let recovered = GraphStore::open(&path, &mut catalog).unwrap();
assert_eq!(recovered.commit_epoch(), 3);
assert_eq!(recovered.node_count_for_label(None), 3);
drop(recovered);
std::fs::remove_dir_all(path).unwrap();
}
}
#[test]
fn selected_checkpoint_handoff_rejects_an_equal_epoch_from_another_store() {
let left_path = unique_test_dir("selected_checkpoint_identity_left");
let right_path = unique_test_dir("selected_checkpoint_identity_right");
let mut left_catalog = Catalog::default();
let mut right_catalog = Catalog::default();
let mut left = GraphStore::open(&left_path, &mut left_catalog).unwrap();
let mut right = GraphStore::open(&right_path, &mut right_catalog).unwrap();
left.create_node(
&mut left_catalog,
"Memory",
properties([("id", Value::Int(1))]),
)
.unwrap();
right
.create_node(
&mut right_catalog,
"Memory",
properties([("id", Value::Int(2))]),
)
.unwrap();
let reader = left.snapshot();
let expected = left.checkpoint_source_identity().unwrap();
right.checkpoint(&right_catalog).unwrap();
let error = left.adopt_selected_checkpoint(right, expected).unwrap_err();
assert!(matches!(error, HawDBError::StorageIntegrity(_)));
assert!(left.ensure_usable().is_err());
assert!(reader.ensure_usable().is_err());
drop(reader);
drop(left);
let recovered = GraphStore::open(&left_path, &mut left_catalog).unwrap();
let id = left_catalog.label_id("Memory").unwrap();
let row = recovered.scan_nodes(Some(id)).next().unwrap();
assert_eq!(row.properties.get("id"), Some(&Value::Int(1)));
assert_eq!(recovered.commit_epoch(), 1);
drop(recovered);
std::fs::remove_dir_all(left_path).unwrap();
std::fs::remove_dir_all(right_path).unwrap();
}
#[test]
fn checkpoint_candidate_rejects_lost_authoritative_tail_and_poisons_live_owners() {
let path = unique_test_dir("checkpoint_candidate_source_tail_loss");
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(1))]))
.unwrap();
let base = store.checkpoint_source();
let mut candidate = base
.prepare_checkpoint_candidate(&catalog)
.unwrap()
.unwrap();
drop(base);
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(2))]))
.unwrap();
let reader = store.snapshot();
let source = store.checkpoint_source();
let wal_path = store.durable.as_ref().unwrap().wal_path.clone();
let complete_wal = std::fs::read(&wal_path).unwrap();
let manifest_path = store
.durable
.as_ref()
.unwrap()
.manifest_path()
.to_path_buf();
let manifest = std::fs::read(&manifest_path).unwrap();
std::fs::OpenOptions::new()
.write(true)
.open(&wal_path)
.unwrap()
.set_len(complete_wal.len() as u64 - 1)
.unwrap();
let error = candidate.catch_up(&source).unwrap_err();
assert!(matches!(error, HawDBError::StorageIntegrity(_)));
assert!(store.ensure_usable().is_err());
assert!(reader.ensure_usable().is_err());
assert!(source.ensure_usable().is_err());
assert_eq!(std::fs::read(&manifest_path).unwrap(), manifest);
assert_eq!(store.durable.as_ref().unwrap().checkpoint_epoch, 0);
drop(candidate);
drop(source);
drop(reader);
drop(store);
std::fs::write(&wal_path, &complete_wal).unwrap();
let recovered = GraphStore::open(&path, &mut catalog).unwrap();
assert_eq!(recovered.commit_epoch(), 2);
assert_eq!(recovered.node_count_for_label(None), 2);
drop(recovered);
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn checkpoint_candidate_keeps_only_post_snapshot_debt_age() {
let path = unique_test_dir("checkpoint_candidate_suffix_age");
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(1))]))
.unwrap();
let old_debt = std::time::Instant::now() - std::time::Duration::from_secs(60);
store.durable.as_mut().unwrap().wal_uncheckpointed_since = Some(old_debt);
let source = store.checkpoint_source();
let capture_floor = source.checkpoint_capture_started.unwrap();
let mut candidate = source
.prepare_checkpoint_candidate(&catalog)
.unwrap()
.unwrap();
drop(source);
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(2))]))
.unwrap();
let source = store.checkpoint_source();
candidate.catch_up(&source).unwrap();
drop(source);
store
.publish_checkpoint_candidate(&mut candidate, None, &Default::default())
.unwrap();
assert_eq!(
store.durable.as_ref().unwrap().wal_uncheckpointed_since,
Some(capture_floor)
);
assert_ne!(
store.durable.as_ref().unwrap().wal_uncheckpointed_since,
Some(old_debt)
);
drop(candidate);
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(3))]))
.unwrap();
assert_eq!(
store.durable.as_ref().unwrap().wal_uncheckpointed_since,
Some(capture_floor)
);
let source = store.checkpoint_source();
let mut candidate = source
.prepare_checkpoint_candidate(&catalog)
.unwrap()
.unwrap();
drop(source);
candidate.finish_catch_up().unwrap();
store
.publish_checkpoint_candidate_deferred_reclamation(&mut candidate, None)
.unwrap();
assert!(store
.durable
.as_ref()
.unwrap()
.wal_uncheckpointed_since
.is_none());
assert_eq!(store.checkpoint_debt_snapshot().unwrap().wal_age_millis, 0);
drop(candidate);
drop(store);
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn wal_checkpoint_debt_age_does_not_reset_on_continuous_writes() {
let path = unique_test_dir("wal_checkpoint_debt_age");
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(1))]))
.unwrap();
let started = std::time::Instant::now() - std::time::Duration::from_secs(60);
store.durable.as_mut().unwrap().wal_uncheckpointed_since = Some(started);
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(2))]))
.unwrap();
assert!(store.storage_pressure_snapshot(None).wal_age_millis >= 60_000);
assert_eq!(
store.durable.as_ref().unwrap().wal_uncheckpointed_since,
Some(started)
);
store.checkpoint(&catalog).unwrap();
assert_eq!(store.storage_pressure_snapshot(None).wal_age_millis, 0);
assert!(store
.durable
.as_ref()
.unwrap()
.wal_uncheckpointed_since
.is_none());
drop(store);
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn checkpoint_wal_tail_rejects_corruption_without_selecting_a_candidate() {
let path = unique_test_dir("checkpoint_wal_tail_corruption");
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(1))]))
.unwrap();
let source = store.checkpoint_source();
let prepared = source.prepare_checkpoint(&catalog).unwrap().unwrap();
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(2))]))
.unwrap();
let durable = store.durable.as_ref().unwrap();
let manifest_path = path.join(super::MANIFEST_FILE);
let manifest = std::fs::read(&manifest_path).unwrap();
let mut bytes = std::fs::read(&durable.wal_path).unwrap();
let last = bytes.len() - 1;
bytes[last] ^= 1;
std::fs::write(&durable.wal_path, &bytes).unwrap();
let error = store.prepare_checkpoint_wal_tail(&prepared).unwrap_err();
assert!(matches!(error, HawDBError::StorageIntegrity(_)));
assert_eq!(std::fs::read(&manifest_path).unwrap(), manifest);
assert_eq!(std::fs::read(&durable.wal_path).unwrap(), bytes);
assert!(!path
.join(format!("wal.{}.hawdb.tail.tmp", prepared.generation))
.exists());
drop(prepared);
drop(source);
drop(store);
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn writable_open_reclaims_abandoned_future_checkpoint_generation() {
let path = unique_test_dir("abandoned_prepared_checkpoint");
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(1))]))
.unwrap();
let source = store.checkpoint_source();
let prepared = source.prepare_checkpoint(&catalog).unwrap().unwrap();
assert!(path.join("checkpoint.1.hawdb").exists());
assert!(path.join("wal.1.hawdb").exists());
assert!(path.join(".checkpoint.1.prepare").exists());
drop(prepared);
drop(source);
drop(store);
let mut recovered_catalog = Catalog::default();
let recovered = GraphStore::open(&path, &mut recovered_catalog).unwrap();
assert_eq!(recovered.commit_epoch(), 1);
assert!(!path.join("checkpoint.1.hawdb").exists());
assert!(!path.join("wal.1.hawdb").exists());
assert!(!path.join(".checkpoint.1.prepare").exists());
drop(recovered);
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn checkpoint_publish_failpoints_recover_one_complete_generation() {
for (stage, expected_checkpoint_epoch) in [
(CheckpointPublishStage::CheckpointPersisted, None),
(CheckpointPublishStage::WalPrepared, None),
(CheckpointPublishStage::ManifestPublished, Some(1)),
] {
let path = unique_test_dir(&format!("checkpoint_failpoint_{stage:?}"));
{
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(1))]))
.unwrap();
set_checkpoint_failpoint(Some(stage));
let error = store.checkpoint(&catalog).unwrap_err();
set_checkpoint_failpoint(None);
assert!(error.to_string().contains("injected checkpoint failure"));
}
let mut catalog = Catalog::default();
let store = GraphStore::open(&path, &mut catalog).unwrap();
assert_eq!(store.commit_epoch(), 1);
assert_eq!(
store.storage_recovery_report().checkpoint_epoch,
expected_checkpoint_epoch
);
let memory = catalog.label_id("Memory").unwrap();
assert_eq!(store.scan_nodes(Some(memory)).count(), 1);
match expected_checkpoint_epoch {
Some(generation) => {
let durable = store.durable.as_ref().unwrap();
assert_eq!(
durable
.relational_row_generation_artifacts
.unwrap()
.generation,
generation
);
assert_eq!(
durable
.relational_overflow_generation_artifacts
.unwrap()
.generation,
generation
);
assert!(path
.join(
hawdb_storage::canonical::canonical_segment_descriptor_page_file(
generation
)
)
.exists());
assert!(path
.join(
hawdb_storage::canonical::canonical_segment_descriptor_root_file(
generation
)
)
.exists());
assert!(path
.join(hawdb_storage::canonical_adjacency::canonical_adjacency_descriptor_page_file(
generation
))
.exists());
assert!(path
.join(hawdb_storage::canonical_adjacency::canonical_adjacency_descriptor_root_file(
generation
))
.exists());
}
None => {
assert!(!path
.join(
hawdb_storage::relational::relational_row_page_manifest_generation_file(
1
)
)
.exists());
assert!(!path
.join(
hawdb_storage::relational::relational_overflow_manifest_generation_file(
1
)
)
.exists());
assert!(!path
.join(hawdb_storage::canonical::canonical_segment_descriptor_page_file(1))
.exists());
assert!(!path
.join(hawdb_storage::canonical::canonical_segment_descriptor_root_file(1))
.exists());
assert!(!path
.join(hawdb_storage::canonical_adjacency::canonical_adjacency_descriptor_page_file(1))
.exists());
assert!(!path
.join(hawdb_storage::canonical_adjacency::canonical_adjacency_descriptor_root_file(1))
.exists());
}
}
std::fs::remove_dir_all(path).unwrap();
}
}
#[test]
fn checkpoint_retirement_source_shares_failed_reclamation_and_retry_with_frontend() {
let path = unique_test_dir("checkpoint_retirement_shared_debt");
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
for id in 1..=2 {
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(id))]))
.unwrap();
store.checkpoint(&catalog).unwrap();
}
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(3))]))
.unwrap();
let source = store.checkpoint_source();
let mut candidate = source
.prepare_checkpoint_candidate(&catalog)
.unwrap()
.unwrap();
drop(source);
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(4))]))
.unwrap();
let mut final_source = store.checkpoint_source();
let expected = final_source.checkpoint_source_identity().unwrap();
candidate.catch_up(&final_source).unwrap();
candidate.finish_catch_up().unwrap();
final_source
.publish_checkpoint_candidate_deferred_reclamation(&mut candidate, None)
.unwrap();
let retired = store
.adopt_selected_checkpoint(final_source, expected)
.unwrap();
drop(retired);
let mut retirement_source = store.checkpoint_source();
set_generation_reclamation_remove_failpoint(Some("checkpoint.1.hawdb".to_string()));
let result =
candidate.reclaim_published_generations(&mut retirement_source, &BTreeSet::new());
set_generation_reclamation_remove_failpoint(None);
result.unwrap();
let pressure = store.storage_pressure_snapshot(None);
assert!(pressure.generation_reclamation_retry_required);
assert_eq!(pressure.generation_reclamation_pending_files, 1);
assert!(pressure.generation_reclamation_pending_bytes > 0);
assert!(path.join("checkpoint.1.hawdb").exists());
assert_eq!(store.commit_epoch(), 4);
assert_eq!(store.scan_nodes(None).count(), 4);
candidate
.reclaim_published_generations(&mut retirement_source, &BTreeSet::new())
.unwrap();
let pressure = store.storage_pressure_snapshot(None);
assert!(!pressure.generation_reclamation_retry_required);
assert_eq!(pressure.generation_reclamation_pending_files, 0);
assert_eq!(pressure.generation_reclamation_pending_bytes, 0);
assert!(!path.join("checkpoint.1.hawdb").exists());
drop(candidate);
drop(retirement_source);
drop(store);
let mut recovered_catalog = Catalog::default();
let recovered = GraphStore::open(&path, &mut recovered_catalog).unwrap();
assert_eq!(recovered.commit_epoch(), 4);
assert_eq!(recovered.scan_nodes(None).count(), 4);
drop(recovered);
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn checkpoint_deferred_reclamation_preserves_debt_and_rejects_other_runtime() {
let path = unique_test_dir("checkpoint_deferred_debt");
let other_path = unique_test_dir("checkpoint_deferred_other_runtime");
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
for id in 1..=2 {
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(id))]))
.unwrap();
store.checkpoint(&catalog).unwrap();
}
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(3))]))
.unwrap();
let mut selected = store.checkpoint_source();
let expected = selected.checkpoint_source_identity().unwrap();
let mut candidate = selected
.prepare_checkpoint_candidate(&catalog)
.unwrap()
.unwrap();
candidate.finish_catch_up().unwrap();
selected
.publish_checkpoint_candidate_deferred_reclamation(&mut candidate, None)
.unwrap();
drop(store.adopt_selected_checkpoint(selected, expected).unwrap());
let mut selected = store.checkpoint_source();
let stale_file = path.join("checkpoint.1.hawdb");
let stale_bytes = std::fs::read(&stale_file).unwrap();
candidate
.defer_published_generation_reclamation(&mut selected)
.unwrap();
let unknown = store.storage_pressure_snapshot(None);
assert!(unknown.generation_reclamation_retry_required);
assert_eq!(unknown.generation_reclamation_pending_files, 0);
assert_eq!(unknown.generation_reclamation_pending_bytes, 0);
assert_eq!(std::fs::read(&stale_file).unwrap(), stale_bytes);
set_generation_reclamation_remove_failpoint(Some("checkpoint.1.hawdb".to_string()));
let result = candidate.reclaim_published_generations(&mut selected, &BTreeSet::new());
set_generation_reclamation_remove_failpoint(None);
result.unwrap();
let known = store.storage_pressure_snapshot(None);
assert!(known.generation_reclamation_retry_required);
assert_eq!(known.generation_reclamation_pending_files, 1);
assert_eq!(
known.generation_reclamation_pending_bytes,
stale_bytes.len() as u64
);
candidate
.defer_published_generation_reclamation(&mut selected)
.unwrap();
let deferred = store.storage_pressure_snapshot(None);
assert!(deferred.generation_reclamation_retry_required);
assert_eq!(deferred.generation_reclamation_pending_files, 1);
assert_eq!(
deferred.generation_reclamation_pending_bytes,
known.generation_reclamation_pending_bytes
);
assert_eq!(std::fs::read(&stale_file).unwrap(), stale_bytes);
let mut other_catalog = Catalog::default();
let mut other = GraphStore::open(&other_path, &mut other_catalog).unwrap();
assert!(candidate
.defer_published_generation_reclamation(&mut other)
.is_err());
assert!(
!other
.storage_pressure_snapshot(None)
.generation_reclamation_retry_required
);
assert!(candidate
.reclaim_published_generations(&mut other, &BTreeSet::new())
.is_err());
assert_eq!(std::fs::read(&stale_file).unwrap(), stale_bytes);
candidate
.reclaim_published_generations(&mut selected, &BTreeSet::new())
.unwrap();
let cleared = store.storage_pressure_snapshot(None);
assert!(!cleared.generation_reclamation_retry_required);
assert_eq!(cleared.generation_reclamation_pending_files, 0);
assert_eq!(cleared.generation_reclamation_pending_bytes, 0);
assert!(!stale_file.exists());
drop(candidate);
drop(selected);
drop(store);
drop(other);
let reopened = GraphStore::open(&path, &mut catalog).unwrap();
assert_eq!(reopened.commit_epoch(), 3);
assert_eq!(reopened.scan_nodes(None).count(), 3);
drop(reopened);
std::fs::remove_dir_all(path).unwrap();
std::fs::remove_dir_all(other_path).unwrap();
}
#[test]
fn checkpoint_reclamation_failure_is_reported_and_retried_after_publication() {
let path = unique_test_dir("checkpoint_reclamation_retry");
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
for id in 1..=2 {
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(id))]))
.unwrap();
store.checkpoint(&catalog).unwrap();
}
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(3))]))
.unwrap();
set_generation_reclamation_remove_failpoint(Some("checkpoint.1.hawdb".to_string()));
let checkpoint_result = store.checkpoint(&catalog);
set_generation_reclamation_remove_failpoint(None);
checkpoint_result.unwrap();
assert_eq!(store.commit_epoch(), 3);
assert_eq!(store.durable.as_ref().unwrap().checkpoint_epoch, 3);
assert_eq!(store.scan_nodes(None).count(), 3);
assert!(path.join("checkpoint.1.hawdb").exists());
let pressure = store.storage_pressure_snapshot(None);
assert!(pressure.generation_reclamation_retry_required);
assert_eq!(pressure.generation_reclamation_pending_files, 1);
assert!(pressure.generation_reclamation_pending_bytes > 0);
assert!(pressure.reason_codes.contains(
&hawdb_storage::pressure::StoragePressureReasonCode::GenerationReclamationDebt
));
drop(store);
let mut recovered_catalog = Catalog::default();
let mut recovered = GraphStore::open(&path, &mut recovered_catalog).unwrap();
assert_eq!(recovered.commit_epoch(), 3);
assert_eq!(
recovered.storage_recovery_report().checkpoint_epoch,
Some(3)
);
assert_eq!(recovered.scan_nodes(None).count(), 3);
recovered.checkpoint(&recovered_catalog).unwrap();
assert!(!path.join("checkpoint.1.hawdb").exists());
let pressure = recovered.storage_pressure_snapshot(None);
assert!(!pressure.generation_reclamation_retry_required);
assert_eq!(pressure.generation_reclamation_pending_files, 0);
assert_eq!(pressure.generation_reclamation_pending_bytes, 0);
assert!(!pressure.reason_codes.contains(
&hawdb_storage::pressure::StoragePressureReasonCode::GenerationReclamationDebt
));
drop(recovered);
let mut reopened_catalog = Catalog::default();
let reopened = GraphStore::open(&path, &mut reopened_catalog).unwrap();
assert_eq!(reopened.commit_epoch(), 3);
assert_eq!(reopened.storage_recovery_report().checkpoint_epoch, Some(4));
assert_eq!(reopened.scan_nodes(None).count(), 3);
drop(reopened);
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn checkpoint_retains_one_previous_generation_before_reclaim() {
let path = unique_test_dir("checkpoint_generation_reclaim");
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
for id in 1..=3 {
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(id))]))
.unwrap();
store.checkpoint(&catalog).unwrap();
}
assert!(!path.join("checkpoint.1.hawdb").exists());
assert!(!path.join("wal.1.hawdb").exists());
assert!(!path.join("canonical.1.hawdb").exists());
assert!(!path.join("canonical.1.manifest.hawdb").exists());
assert!(!path
.join(hawdb_storage::canonical::canonical_segment_descriptor_page_file(1))
.exists());
assert!(!path
.join(hawdb_storage::canonical::canonical_segment_descriptor_root_file(1))
.exists());
assert!(!path.join("adjacency.1.hawdb").exists());
assert!(!path.join("adjacency.1.manifest.hawdb").exists());
assert!(!path
.join(hawdb_storage::canonical_adjacency::canonical_adjacency_descriptor_page_file(1))
.exists());
assert!(!path
.join(hawdb_storage::canonical_adjacency::canonical_adjacency_descriptor_root_file(1))
.exists());
assert!(!path.join("properties.1.hawdb").exists());
assert!(!path.join("properties.1.manifest.hawdb").exists());
assert!(!path.join("property-index.1.hawdb").exists());
assert!(!path.join("property-index.1.manifest.hawdb").exists());
assert!(!path
.join(hawdb_storage::relational::relational_row_page_manifest_generation_file(1))
.exists());
assert!(!path
.join(hawdb_storage::relational::relational_overflow_manifest_generation_file(1))
.exists());
assert!(path.join("checkpoint.2.hawdb").exists());
assert!(path.join("wal.2.hawdb").exists());
assert!(path.join("canonical.2.hawdb").exists());
assert!(path.join("canonical.2.manifest.hawdb").exists());
assert!(path
.join(hawdb_storage::canonical::canonical_segment_descriptor_page_file(2))
.exists());
assert!(path
.join(hawdb_storage::canonical::canonical_segment_descriptor_root_file(2))
.exists());
assert!(path.join("adjacency.2.hawdb").exists());
assert!(!path.join("adjacency.2.manifest.hawdb").exists());
assert!(path
.join(hawdb_storage::canonical_adjacency::canonical_adjacency_descriptor_page_file(2))
.exists());
assert!(path
.join(hawdb_storage::canonical_adjacency::canonical_adjacency_descriptor_root_file(2))
.exists());
assert!(path.join("properties.2.hawdb").exists());
assert!(path.join("properties.2.manifest.hawdb").exists());
assert!(path.join("property-index.2.hawdb").exists());
assert!(path.join("property-index.2.manifest.hawdb").exists());
assert!(path
.join(hawdb_storage::relational::relational_row_page_manifest_generation_file(2))
.exists());
assert!(path
.join(hawdb_storage::relational::relational_overflow_manifest_generation_file(2))
.exists());
assert!(path.join("checkpoint.3.hawdb").exists());
assert!(path.join("wal.3.hawdb").exists());
assert!(path.join("canonical.3.hawdb").exists());
assert!(path.join("canonical.3.manifest.hawdb").exists());
assert!(path
.join(hawdb_storage::canonical::canonical_segment_descriptor_page_file(3))
.exists());
assert!(path
.join(hawdb_storage::canonical::canonical_segment_descriptor_root_file(3))
.exists());
assert!(path.join("adjacency.3.hawdb").exists());
assert!(!path.join("adjacency.3.manifest.hawdb").exists());
assert!(path
.join(hawdb_storage::canonical_adjacency::canonical_adjacency_descriptor_page_file(3))
.exists());
assert!(path
.join(hawdb_storage::canonical_adjacency::canonical_adjacency_descriptor_root_file(3))
.exists());
assert!(path.join("properties.3.hawdb").exists());
assert!(path.join("properties.3.manifest.hawdb").exists());
assert!(path.join("property-index.3.hawdb").exists());
assert!(path.join("property-index.3.manifest.hawdb").exists());
assert!(path
.join(hawdb_storage::relational::relational_row_page_manifest_generation_file(3))
.exists());
assert!(path
.join(hawdb_storage::relational::relational_overflow_manifest_generation_file(3))
.exists());
drop(store);
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn reopen_after_checkpoint_uses_manifest_lsn_without_overwriting_wal() {
let path = unique_test_dir("manifest_reopen_lsn");
{
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(1))]))
.unwrap();
store.checkpoint(&catalog).unwrap();
}
{
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(2))]))
.unwrap();
}
let wal = read_test_wal(&path).unwrap();
assert!(wal.starts_with("2\tcreate_node\t"));
let mut catalog = Catalog::default();
let store = GraphStore::open(&path, &mut catalog).unwrap();
let label = catalog.label_id("Memory").unwrap();
let nodes = store.scan_nodes(Some(label)).collect::<Vec<_>>();
assert_eq!(nodes.len(), 2);
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn rejects_corrupt_manifest() {
let path = unique_test_dir("corrupt_manifest");
{
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(1))]))
.unwrap();
store.checkpoint(&catalog).unwrap();
}
let manifest_path = path.join("manifest.hawdb");
let manifest = std::fs::read_to_string(&manifest_path).unwrap();
std::fs::write(
&manifest_path,
manifest.replace("checkpoint_epoch\t1\n", "checkpoint_epoch\t2\n"),
)
.unwrap();
let mut catalog = Catalog::default();
let error = GraphStore::open(&path, &mut catalog).unwrap_err();
assert!(error.to_string().contains("manifest checksum mismatch"));
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn rejects_unsupported_manifest_storage_version() {
let path = unique_test_dir("manifest_storage_version");
{
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(1))]))
.unwrap();
store.checkpoint(&catalog).unwrap();
}
rewrite_checksummed_file(
&path.join("manifest.hawdb"),
"version\thawdb-storage-v1\n",
"version\thawdb-storage-v0\n",
"manifest",
);
let mut catalog = Catalog::default();
let error = GraphStore::open(&path, &mut catalog).unwrap_err();
assert!(error
.to_string()
.contains("unsupported storage version: hawdb-storage-v0"));
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn single_storage_format_rejects_unpublished_manifest() {
let path = unique_test_dir("unpublished_manifest_format");
{
let mut catalog = Catalog::default();
GraphStore::open(&path, &mut catalog).unwrap();
}
rewrite_checksummed_file(
&path.join("manifest.hawdb"),
"HAWDB_MANIFEST_V1\n",
"INVALID_MANIFEST_HEADER\n",
"manifest",
);
let mut catalog = Catalog::default();
let error = GraphStore::open(&path, &mut catalog).unwrap_err();
assert!(
error.to_string().contains("missing the V1 format header"),
"{error}"
);
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn single_storage_format_rejects_incomplete_v1_manifest() {
let path = unique_test_dir("incomplete_v1_manifest");
{
let mut catalog = Catalog::default();
GraphStore::open(&path, &mut catalog).unwrap();
}
rewrite_checksummed_file(
&path.join("manifest.hawdb"),
"checkpoint_generation\tnone\n",
"",
"manifest",
);
let mut catalog = Catalog::default();
let error = GraphStore::open(&path, &mut catalog).unwrap_err();
assert!(error
.to_string()
.contains("manifest is missing required field: checkpoint_generation"));
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn storage_owned_manifest_preserves_checkpoint_binding_and_no_write_rejection() {
let path = unique_test_dir("storage_owned_manifest");
{
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(1))]))
.unwrap();
store.checkpoint(&catalog).unwrap();
}
let manifest_path = path.join("manifest.hawdb");
let manifest: super::DurableManifest =
hawdb_storage::durable_manifest::DurableManifest::load(&manifest_path).unwrap();
let manifest: hawdb_storage::durable_manifest::DurableManifest = manifest;
assert_eq!(manifest.checkpoint_generation, Some(1));
assert!(manifest.relational_row_generation_artifacts.is_some());
assert!(manifest.relational_overflow_generation_artifacts.is_some());
assert_eq!(manifest.wal_path(&path), path.join("wal.1.hawdb"));
rewrite_checksummed_file(
&manifest_path,
"wal_generation\t1\n",
"wal_generation\t1\nwal_generation\t1\n",
"manifest",
);
let before = fs::read(&manifest_path).unwrap();
let expected = super::DurableManifest::load(&manifest_path)
.unwrap_err()
.to_string();
let mut catalog = Catalog::default();
let error = GraphStore::open(&path, &mut catalog).unwrap_err();
assert!(matches!(&error, HawDBError::Storage(_)));
assert!(error
.to_string()
.contains("duplicate field: wal_generation"));
assert_eq!(error.to_string(), expected);
assert_eq!(fs::read(&manifest_path).unwrap(), before);
fs::remove_dir_all(path).unwrap();
}
#[test]
fn single_storage_format_rejects_manifestless_artifacts() {
let path = unique_test_dir("artifacts_without_manifest");
std::fs::create_dir_all(&path).unwrap();
std::fs::write(path.join("checkpoint.hawdb"), b"unpublished format").unwrap();
let mut catalog = Catalog::default();
let error = GraphStore::open(&path, &mut catalog).unwrap_err();
assert!(error
.to_string()
.contains("storage artifacts but no durable manifest"));
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn rejects_unsupported_checkpoint_storage_version() {
let path = unique_test_dir("checkpoint_storage_version");
{
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(1))]))
.unwrap();
store.checkpoint(&catalog).unwrap();
}
rewrite_checksummed_file(
&active_checkpoint_path(&path),
"version\thawdb-storage-v1\n",
"version\thawdb-storage-v0\n",
"checkpoint",
);
let mut catalog = Catalog::default();
let error = GraphStore::open(&path, &mut catalog).unwrap_err();
assert!(error
.to_string()
.contains("unsupported storage version: hawdb-storage-v0"));
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn replays_property_index_from_wal() {
let path = unique_test_dir("property_index_wal");
{
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
store
.create_property_index(&mut catalog, "Memory", "id")
.unwrap();
store
.create_node(
&mut catalog,
"Memory",
properties([
("id", Value::Int(1)),
("title", Value::String("Graph foundations".to_string())),
]),
)
.unwrap();
}
{
let mut catalog = Catalog::default();
let store = GraphStore::open(&path, &mut catalog).unwrap();
let label = catalog.label_id("Memory").unwrap();
let nodes = store
.seek_nodes_by_property(label, "id", &Value::Int(1))
.collect::<Vec<_>>();
assert_eq!(nodes.len(), 1);
assert_eq!(
nodes[0].properties.get("title"),
Some(&Value::String("Graph foundations".to_string()))
);
}
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn rebuilds_property_index_from_checkpoint() {
let path = unique_test_dir("property_index_checkpoint");
{
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
store
.create_node(
&mut catalog,
"Memory",
properties([
("id", Value::Int(1)),
("title", Value::String("Graph foundations".to_string())),
]),
)
.unwrap();
store
.create_property_index(&mut catalog, "Memory", "title")
.unwrap();
store.checkpoint(&catalog).unwrap();
}
{
let mut catalog = Catalog::default();
let store = GraphStore::open(&path, &mut catalog).unwrap();
let label = catalog.label_id("Memory").unwrap();
let nodes = store
.seek_nodes_by_property(
label,
"title",
&Value::String("Graph foundations".to_string()),
)
.collect::<Vec<_>>();
assert_eq!(nodes.len(), 1);
assert_eq!(nodes[0].properties.get("id"), Some(&Value::Int(1)));
}
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn scan_pruning_uses_property_eq_index_for_unique_key() {
let mut catalog = Catalog::default();
let mut store = GraphStore::in_memory();
store
.create_node(
&mut catalog,
"Memory",
properties([
("stable_id", Value::String("memory:1".to_string())),
("title", Value::String("Graph foundations".to_string())),
]),
)
.unwrap();
store
.create_node(
&mut catalog,
"Memory",
properties([
("stable_id", Value::String("memory:2".to_string())),
("title", Value::String("Storage notes".to_string())),
]),
)
.unwrap();
store
.create_property_index(&mut catalog, "Memory", "stable_id")
.unwrap();
let label = catalog.label_id("Memory").unwrap();
let scan = store.scan_nodes_with_filter_pruning(
&catalog,
Some(label),
Some(&PropertyFilter::Eq {
property: "stable_id".to_string(),
value: Value::String("memory:2".to_string()),
}),
);
assert_eq!(scan.nodes.len(), 1);
assert_eq!(
scan.nodes[0].properties.get("title"),
Some(&Value::String("Storage notes".to_string()))
);
assert_eq!(
scan.report.strategy,
ScanPruningStrategy::PropertyEq {
property: "stable_id".to_string()
}
);
assert_eq!(scan.report.target_kind, ScanPruningTargetKind::Node);
assert_eq!(scan.report.label_id, Some(label));
assert_eq!(scan.report.rel_type_id, None);
assert!(scan.report.pruned);
assert_eq!(scan.report.candidate_count_before_filter, 1);
assert_eq!(scan.report.candidate_count_before_pruning, 2);
assert_eq!(scan.report.pruned_candidate_count, 1);
assert_eq!(scan.report.filtered_out_count, 0);
}
#[test]
fn scan_pruning_treats_in_values_as_enum_set() {
let mut catalog = Catalog::default();
let mut store = GraphStore::in_memory();
for state in ["active", "deleted", "forgotten"] {
store
.create_node(
&mut catalog,
"Memory",
properties([("state", Value::String(state.to_string()))]),
)
.unwrap();
}
store
.create_property_index(&mut catalog, "Memory", "state")
.unwrap();
let label = catalog.label_id("Memory").unwrap();
let scan = store.scan_nodes_with_filter_pruning(
&catalog,
Some(label),
Some(&PropertyFilter::In {
property: "state".to_string(),
values: vec![
Value::String("deleted".to_string()),
Value::String("forgotten".to_string()),
],
}),
);
let states = scan
.nodes
.iter()
.map(|node| node.properties.get("state").unwrap().clone())
.collect::<BTreeSet<_>>();
assert_eq!(
states,
BTreeSet::from([
Value::String("deleted".to_string()),
Value::String("forgotten".to_string()),
])
);
assert_eq!(
scan.report.strategy,
ScanPruningStrategy::PropertyIn {
property: "state".to_string()
}
);
assert_eq!(scan.report.candidate_count_before_filter, 2);
assert_eq!(scan.report.candidate_count_before_pruning, 3);
assert_eq!(scan.report.pruned_candidate_count, 1);
}
#[test]
fn scan_pruning_uses_property_exists_for_not_null_filter() {
let mut catalog = Catalog::default();
let mut store = GraphStore::in_memory();
store
.create_node(
&mut catalog,
"Memory",
properties([("confidence", Value::Float(0.9))]),
)
.unwrap();
store
.create_node(&mut catalog, "Memory", properties([]))
.unwrap();
store
.create_node(
&mut catalog,
"Memory",
properties([("confidence", Value::Null)]),
)
.unwrap();
store
.create_property_index(&mut catalog, "Memory", "confidence")
.unwrap();
let label = catalog.label_id("Memory").unwrap();
let scan = store.scan_nodes_with_filter_pruning(
&catalog,
Some(label),
Some(&PropertyFilter::IsNotNull {
property: "confidence".to_string(),
}),
);
assert_eq!(scan.nodes.len(), 1);
assert_eq!(
scan.report.strategy,
ScanPruningStrategy::PropertyExists {
property: "confidence".to_string()
}
);
assert!(scan.report.pruned);
assert!(!scan.report.exact_empty);
assert_eq!(scan.report.candidate_count_before_filter, 1);
assert_eq!(scan.report.candidate_count_before_pruning, 3);
assert_eq!(scan.report.pruned_candidate_count, 2);
assert_eq!(scan.report.filtered_out_count, 0);
}
#[test]
fn scan_pruning_uses_property_missing_or_null_for_null_filter() {
let mut catalog = Catalog::default();
let mut store = GraphStore::in_memory();
store
.create_node(
&mut catalog,
"Memory",
properties([("latest_at", Value::Int(10))]),
)
.unwrap();
store
.create_node(&mut catalog, "Memory", properties([]))
.unwrap();
store
.create_node(
&mut catalog,
"Memory",
properties([("latest_at", Value::Null)]),
)
.unwrap();
store
.create_property_index(&mut catalog, "Memory", "latest_at")
.unwrap();
let label = catalog.label_id("Memory").unwrap();
let scan = store.scan_nodes_with_filter_pruning(
&catalog,
Some(label),
Some(&PropertyFilter::IsNull {
property: "latest_at".to_string(),
}),
);
assert_eq!(scan.nodes.len(), 2);
assert_eq!(
scan.report.strategy,
ScanPruningStrategy::PropertyMissingOrNull {
property: "latest_at".to_string()
}
);
assert!(scan.report.pruned);
assert!(!scan.report.exact_empty);
assert_eq!(scan.report.candidate_count_before_filter, 2);
assert_eq!(scan.report.candidate_count_before_pruning, 3);
assert_eq!(scan.report.pruned_candidate_count, 1);
assert_eq!(scan.report.filtered_out_count, 0);
}
#[test]
fn scan_pruning_uses_default_if_null_eq_for_normalized_default_filter() {
let mut catalog = Catalog::default();
let mut store = GraphStore::in_memory();
store
.create_node(&mut catalog, "Thread", properties([]))
.unwrap();
store
.create_node(
&mut catalog,
"Thread",
properties([("space_id", Value::String(String::new()))]),
)
.unwrap();
store
.create_node(
&mut catalog,
"Thread",
properties([("space_id", Value::String("default".to_string()))]),
)
.unwrap();
store
.create_node(
&mut catalog,
"Thread",
properties([("space_id", Value::String("team".to_string()))]),
)
.unwrap();
store
.create_property_index(&mut catalog, "Thread", "space_id")
.unwrap();
let label = catalog.label_id("Thread").unwrap();
let scan = store.scan_nodes_with_filter_pruning(
&catalog,
Some(label),
Some(&PropertyFilter::DefaultIfNullOrEq {
property: "space_id".to_string(),
empty: Value::String(String::new()),
default: Value::String("default".to_string()),
value: Value::String("default".to_string()),
negated: false,
}),
);
assert_eq!(scan.nodes.len(), 3);
assert_eq!(
scan.report.strategy,
ScanPruningStrategy::PropertyDefaultIfNullEq {
property: "space_id".to_string()
}
);
assert!(scan.report.pruned);
assert_eq!(scan.report.candidate_count_before_filter, 3);
assert_eq!(scan.report.candidate_count_before_pruning, 4);
assert_eq!(scan.report.pruned_candidate_count, 1);
assert_eq!(scan.report.filtered_out_count, 0);
}
#[test]
fn scan_pruning_uses_default_if_null_not_eq_for_normalized_default_filter() {
let mut catalog = Catalog::default();
let mut store = GraphStore::in_memory();
store
.create_node(&mut catalog, "Thread", properties([]))
.unwrap();
store
.create_node(
&mut catalog,
"Thread",
properties([("space_id", Value::String(String::new()))]),
)
.unwrap();
store
.create_node(
&mut catalog,
"Thread",
properties([("space_id", Value::String("default".to_string()))]),
)
.unwrap();
store
.create_node(
&mut catalog,
"Thread",
properties([("space_id", Value::String("team".to_string()))]),
)
.unwrap();
store
.create_property_index(&mut catalog, "Thread", "space_id")
.unwrap();
let label = catalog.label_id("Thread").unwrap();
let scan = store.scan_nodes_with_filter_pruning(
&catalog,
Some(label),
Some(&PropertyFilter::DefaultIfNullOrEq {
property: "space_id".to_string(),
empty: Value::String(String::new()),
default: Value::String("default".to_string()),
value: Value::String("default".to_string()),
negated: true,
}),
);
assert_eq!(scan.nodes.len(), 1);
assert_eq!(
scan.report.strategy,
ScanPruningStrategy::PropertyDefaultIfNullNotEq {
property: "space_id".to_string()
}
);
assert!(scan.report.pruned);
assert_eq!(scan.report.candidate_count_before_filter, 1);
assert_eq!(scan.report.candidate_count_before_pruning, 4);
assert_eq!(scan.report.pruned_candidate_count, 3);
assert_eq!(scan.report.filtered_out_count, 0);
}
#[test]
fn scan_pruning_uses_smallest_candidate_in_and_filter() {
let mut catalog = Catalog::default();
let mut store = GraphStore::in_memory();
store
.create_node(
&mut catalog,
"Memory",
properties([
("stable_id", Value::String("memory:1".to_string())),
("state", Value::String("active".to_string())),
]),
)
.unwrap();
store
.create_node(
&mut catalog,
"Memory",
properties([
("stable_id", Value::String("memory:2".to_string())),
("state", Value::String("active".to_string())),
]),
)
.unwrap();
store
.create_node(
&mut catalog,
"Memory",
properties([
("stable_id", Value::String("memory:3".to_string())),
("state", Value::String("forgotten".to_string())),
]),
)
.unwrap();
store
.create_property_index(&mut catalog, "Memory", "state")
.unwrap();
store
.create_property_index(&mut catalog, "Memory", "stable_id")
.unwrap();
let label = catalog.label_id("Memory").unwrap();
let scan = store.scan_nodes_with_filter_pruning(
&catalog,
Some(label),
Some(&PropertyFilter::And(vec![
PropertyFilter::In {
property: "state".to_string(),
values: vec![
Value::String("active".to_string()),
Value::String("forgotten".to_string()),
],
},
PropertyFilter::Eq {
property: "stable_id".to_string(),
value: Value::String("memory:2".to_string()),
},
])),
);
assert_eq!(scan.nodes.len(), 1);
assert_eq!(
scan.report.strategy,
ScanPruningStrategy::PropertyEq {
property: "stable_id".to_string()
}
);
assert_eq!(scan.report.candidate_count_before_filter, 1);
assert_eq!(scan.report.candidate_count_before_pruning, 3);
assert_eq!(scan.report.pruned_candidate_count, 2);
}
#[test]
fn scan_pruning_unions_prunable_or_branches() {
let mut catalog = Catalog::default();
let mut store = GraphStore::in_memory();
for state in ["active", "deleted", "forgotten"] {
store
.create_node(
&mut catalog,
"Memory",
properties([("state", Value::String(state.to_string()))]),
)
.unwrap();
}
store
.create_property_index(&mut catalog, "Memory", "state")
.unwrap();
let label = catalog.label_id("Memory").unwrap();
let scan = store.scan_nodes_with_filter_pruning(
&catalog,
Some(label),
Some(&PropertyFilter::Or(vec![
PropertyFilter::Eq {
property: "state".to_string(),
value: Value::String("active".to_string()),
},
PropertyFilter::Eq {
property: "state".to_string(),
value: Value::String("forgotten".to_string()),
},
])),
);
assert_eq!(scan.nodes.len(), 2);
assert_eq!(scan.report.strategy, ScanPruningStrategy::OrUnion);
assert_eq!(scan.report.candidate_count_before_filter, 2);
assert_eq!(scan.report.candidate_count_before_pruning, 3);
assert_eq!(scan.report.pruned_candidate_count, 1);
assert!(scan.report.pruned);
}
#[test]
fn scan_pruning_reports_exact_empty_for_empty_in_filter() {
let mut catalog = Catalog::default();
let mut store = GraphStore::in_memory();
store
.create_node(
&mut catalog,
"Memory",
properties([("state", Value::String("active".to_string()))]),
)
.unwrap();
store
.create_property_index(&mut catalog, "Memory", "state")
.unwrap();
let label = catalog.label_id("Memory").unwrap();
let scan = store.scan_nodes_with_filter_pruning(
&catalog,
Some(label),
Some(&PropertyFilter::In {
property: "state".to_string(),
values: vec![],
}),
);
assert!(scan.nodes.is_empty());
assert_eq!(scan.report.strategy, ScanPruningStrategy::Empty);
assert!(scan.report.exact_empty);
assert_eq!(scan.report.candidate_count_before_filter, 0);
assert_eq!(scan.report.candidate_count_before_pruning, 1);
assert_eq!(scan.report.pruned_candidate_count, 1);
}
#[test]
fn scan_pruning_uses_property_range_for_numeric_and_iso_date_strings() {
let mut catalog = Catalog::default();
let mut store = GraphStore::in_memory();
store
.create_node(
&mut catalog,
"Memory",
properties([
("importance", Value::Float(0.2)),
("updated_at", Value::String("2026-07-01".to_string())),
]),
)
.unwrap();
store
.create_node(
&mut catalog,
"Memory",
properties([
("importance", Value::Float(0.7)),
("updated_at", Value::String("2026-07-15".to_string())),
]),
)
.unwrap();
store
.create_node(
&mut catalog,
"Memory",
properties([
("importance", Value::Float(0.9)),
("updated_at", Value::String("2026-08-01".to_string())),
]),
)
.unwrap();
store
.create_property_index(&mut catalog, "Memory", "importance")
.unwrap();
store
.create_property_index(&mut catalog, "Memory", "updated_at")
.unwrap();
let label = catalog.label_id("Memory").unwrap();
let numeric_scan = store.scan_nodes_with_filter_pruning(
&catalog,
Some(label),
Some(&PropertyFilter::Range {
property: "importance".to_string(),
lower: Some((Value::Float(0.5), true)),
upper: Some((Value::Float(0.8), true)),
}),
);
assert_eq!(numeric_scan.nodes.len(), 1);
assert_eq!(
numeric_scan.report.strategy,
ScanPruningStrategy::PropertyRange {
property: "importance".to_string()
}
);
assert_eq!(numeric_scan.report.candidate_count_before_filter, 1);
assert_eq!(numeric_scan.report.candidate_count_before_pruning, 3);
assert_eq!(numeric_scan.report.pruned_candidate_count, 2);
let date_scan = store.scan_nodes_with_filter_pruning(
&catalog,
Some(label),
Some(&PropertyFilter::Range {
property: "updated_at".to_string(),
lower: Some((Value::String("2026-07-01".to_string()), true)),
upper: Some((Value::String("2026-07-31".to_string()), true)),
}),
);
assert_eq!(date_scan.nodes.len(), 2);
assert_eq!(
date_scan.report.strategy,
ScanPruningStrategy::PropertyRange {
property: "updated_at".to_string()
}
);
assert_eq!(date_scan.report.candidate_count_before_filter, 2);
assert_eq!(date_scan.report.candidate_count_before_pruning, 3);
assert_eq!(date_scan.report.pruned_candidate_count, 1);
}
#[test]
fn relationship_scan_pruning_uses_property_equality_and_in_list() {
let mut catalog = Catalog::default();
let mut store = GraphStore::in_memory();
let source = store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(1))]))
.unwrap();
let target = store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(2))]))
.unwrap();
for state in ["active", "deleted", "archived"] {
store
.create_relationship(
&mut catalog,
source,
target,
"RELATES_TO",
properties([("lifecycle_state", Value::String(state.to_string()))]),
)
.unwrap();
}
let rel_type = catalog.rel_type_id("RELATES_TO").unwrap();
let eq_scan = store.scan_relationships_with_filter_pruning(
Some(rel_type),
Some(&PropertyFilter::Eq {
property: "lifecycle_state".to_string(),
value: Value::String("active".to_string()),
}),
);
assert_eq!(eq_scan.relationships.len(), 1);
assert_eq!(
eq_scan.report.strategy,
ScanPruningStrategy::PropertyEq {
property: "lifecycle_state".to_string()
}
);
assert_eq!(
eq_scan.report.target_kind,
ScanPruningTargetKind::Relationship
);
assert_eq!(eq_scan.report.label_id, None);
assert_eq!(eq_scan.report.rel_type_id, Some(rel_type));
assert_eq!(eq_scan.report.candidate_count_before_pruning, 3);
assert_eq!(eq_scan.report.candidate_count_before_filter, 1);
assert_eq!(eq_scan.report.pruned_candidate_count, 2);
let in_scan = store.scan_relationships_with_filter_pruning(
Some(rel_type),
Some(&PropertyFilter::In {
property: "lifecycle_state".to_string(),
values: vec![
Value::String("active".to_string()),
Value::String("archived".to_string()),
],
}),
);
assert_eq!(in_scan.relationships.len(), 2);
assert_eq!(
in_scan.report.strategy,
ScanPruningStrategy::PropertyIn {
property: "lifecycle_state".to_string()
}
);
assert_eq!(in_scan.report.candidate_count_before_filter, 2);
assert_eq!(in_scan.report.pruned_candidate_count, 1);
}
#[test]
fn relationship_scan_pruning_uses_property_range_and_tracks_updates() {
let mut catalog = Catalog::default();
let mut store = GraphStore::in_memory();
let source = store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(1))]))
.unwrap();
let target = store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(2))]))
.unwrap();
let old = store
.create_relationship(
&mut catalog,
source,
target,
"MENTIONS",
properties([("confidence", Value::Float(0.2))]),
)
.unwrap();
store
.create_relationship(
&mut catalog,
source,
target,
"MENTIONS",
properties([("confidence", Value::Float(0.7))]),
)
.unwrap();
store
.create_relationship(
&mut catalog,
source,
target,
"MENTIONS",
properties([("confidence", Value::Float(0.9))]),
)
.unwrap();
let rel_type = catalog.rel_type_id("MENTIONS").unwrap();
let range_scan = store.scan_relationships_with_filter_pruning(
Some(rel_type),
Some(&PropertyFilter::Range {
property: "confidence".to_string(),
lower: Some((Value::Float(0.5), true)),
upper: Some((Value::Float(0.8), true)),
}),
);
assert_eq!(range_scan.relationships.len(), 1);
assert_eq!(
range_scan.report.strategy,
ScanPruningStrategy::PropertyRange {
property: "confidence".to_string()
}
);
assert_eq!(range_scan.report.candidate_count_before_pruning, 3);
assert_eq!(range_scan.report.candidate_count_before_filter, 1);
assert_eq!(range_scan.report.pruned_candidate_count, 2);
store.apply_set_relationship_property(old, "confidence".to_string(), Value::Float(0.75));
let updated_scan = store.scan_relationships_with_filter_pruning(
Some(rel_type),
Some(&PropertyFilter::Range {
property: "confidence".to_string(),
lower: Some((Value::Float(0.5), true)),
upper: Some((Value::Float(0.8), true)),
}),
);
assert_eq!(updated_scan.relationships.len(), 2);
assert_eq!(updated_scan.report.candidate_count_before_filter, 2);
store.apply_delete_relationship(old);
let deleted_scan = store.scan_relationships_with_filter_pruning(
Some(rel_type),
Some(&PropertyFilter::Eq {
property: "confidence".to_string(),
value: Value::Float(0.75),
}),
);
assert!(deleted_scan.relationships.is_empty());
assert_eq!(deleted_scan.report.candidate_count_before_filter, 0);
assert_eq!(deleted_scan.report.pruned_candidate_count, 2);
}
#[test]
fn scan_pruning_falls_back_for_unindexed_string_contains() {
let mut catalog = Catalog::default();
let mut store = GraphStore::in_memory();
store
.create_node(
&mut catalog,
"Memory",
properties([("title", Value::String("Graph foundations".to_string()))]),
)
.unwrap();
store
.create_node(
&mut catalog,
"Memory",
properties([("title", Value::String("Storage notes".to_string()))]),
)
.unwrap();
store
.create_property_index(&mut catalog, "Memory", "title")
.unwrap();
let label = catalog.label_id("Memory").unwrap();
let scan = store.scan_nodes_with_filter_pruning(
&catalog,
Some(label),
Some(&PropertyFilter::Contains {
property: "title".to_string(),
value: "Graph".to_string(),
}),
);
assert_eq!(scan.nodes.len(), 1);
assert_eq!(scan.report.strategy, ScanPruningStrategy::FullLabelScan);
assert!(!scan.report.pruned);
assert_eq!(scan.report.candidate_count_before_filter, 2);
assert_eq!(scan.report.candidate_count_before_pruning, 2);
assert_eq!(scan.report.pruned_candidate_count, 0);
assert_eq!(scan.report.filtered_out_count, 1);
}
#[test]
fn list_values_round_trip_through_checkpoint() {
let path = unique_test_dir("list_value_checkpoint");
{
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
store
.create_node(
&mut catalog,
"Memory",
properties([(
"tags",
Value::List(vec![
Value::String("graph".to_string()),
Value::String("storage".to_string()),
]),
)]),
)
.unwrap();
store.checkpoint(&catalog).unwrap();
}
{
let mut catalog = Catalog::default();
let store = GraphStore::open(&path, &mut catalog).unwrap();
let node = store.scan_nodes(None).next().unwrap();
assert_eq!(
node.properties.get("tags"),
Some(&Value::List(vec![
Value::String("graph".to_string()),
Value::String("storage".to_string()),
]))
);
}
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn loads_projected_graph_artifact_from_checkpoint() {
let path = unique_test_dir("projected_graph_artifact_cache");
let definition = ProjectedGraphDefinition {
node_labels: vec!["Memory".to_string()],
rel_types: vec!["LINKS".to_string()],
relationship_predicates: BTreeMap::from([(
"LINKS".to_string(),
ProjectedRelationshipPredicate::Gte {
property: "confidence".to_string(),
value: Value::Float(0.7),
},
)]),
};
{
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
let source = store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(1))]))
.unwrap();
let target = store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(2))]))
.unwrap();
let excluded = store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(3))]))
.unwrap();
store
.create_relationship(
&mut catalog,
source,
target,
"LINKS",
properties([("confidence", Value::Float(0.7))]),
)
.unwrap();
store
.create_relationship(
&mut catalog,
source,
excluded,
"LINKS",
properties([("confidence", Value::Float(0.69))]),
)
.unwrap();
store
.register_projected_graph("MemoryGraph", definition.clone())
.unwrap();
store.checkpoint(&catalog).unwrap();
}
let artifact_bytes = std::fs::read(path.join("projected_graphs.hawdb")).unwrap();
assert!(artifact_bytes.starts_with(DURABLE_COMPRESSION_HEADER.as_bytes()));
{
let mut catalog = Catalog::default();
let store = GraphStore::open(&path, &mut catalog).unwrap();
let artifact = store
.projected_graph_artifact("MemoryGraph", &definition)
.unwrap();
assert_eq!(artifact.node_count(), 3);
assert_eq!(artifact.edge_count(), 1);
}
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn ignores_projected_graph_artifact_after_wal_replay_advances_epoch() {
let path = unique_test_dir("projected_graph_artifact_stale");
let definition = ProjectedGraphDefinition {
node_labels: vec!["Memory".to_string()],
rel_types: vec!["LINKS".to_string()],
relationship_predicates: BTreeMap::new(),
};
{
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
let source = store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(1))]))
.unwrap();
let target = store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(2))]))
.unwrap();
store
.create_relationship(&mut catalog, source, target, "LINKS", BTreeMap::new())
.unwrap();
store
.register_projected_graph("MemoryGraph", definition.clone())
.unwrap();
store.checkpoint(&catalog).unwrap();
}
{
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(3))]))
.unwrap();
}
{
let mut catalog = Catalog::default();
let store = GraphStore::open(&path, &mut catalog).unwrap();
assert!(store
.projected_graph_artifact("MemoryGraph", &definition)
.is_none());
let status = store
.projected_graph_statuses()
.into_iter()
.find(|status| status.name == "MemoryGraph")
.unwrap();
assert!(!status.reusable);
assert_eq!(status.projection_epoch, None);
assert_eq!(status.commit_epoch, None);
assert_eq!(status.node_count, None);
assert_eq!(status.edge_count, None);
}
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn doctor_repairs_torn_wal_tail_after_strict_open_rejects_it() {
let path = unique_test_dir("torn_wal");
{
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(1))]))
.unwrap();
}
std::fs::OpenOptions::new()
.append(true)
.open(active_wal_path(&path))
.unwrap()
.write_all(b"torn-entry-without-checksum")
.unwrap();
let mut catalog = Catalog::default();
let error = GraphStore::open(&path, &mut catalog).unwrap_err();
assert!(error
.to_string()
.contains("strict WAL recovery rejected torn tail"));
let plan =
DatabaseDoctor::plan_wal_tail_repair(&path, WalDoctorOptions::default()).unwrap();
assert_eq!(
plan.discarded_wal_tail_bytes,
b"torn-entry-without-checksum".len() as u64
);
DatabaseDoctor::apply_wal_tail_repair(
&path,
&plan,
plan.acknowledge_potential_data_loss(),
WalDoctorOptions::default(),
)
.unwrap();
let store = GraphStore::open(&path, &mut catalog).unwrap();
let label = catalog.label_id("Memory").unwrap();
let nodes = store.scan_nodes(Some(label)).collect::<Vec<_>>();
assert_eq!(nodes.len(), 1);
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn doctor_discards_torn_batch_wal_without_partial_path_recovery() {
let path = unique_test_dir("torn_batch_wal");
{
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
store
.create_connected_nodes(
&mut catalog,
ConnectedNodesCreate {
source_label: "Memory".to_string(),
source_properties: properties([("id", Value::Int(1))]),
rel_type: "MENTIONS".to_string(),
rel_properties: BTreeMap::new(),
target_label: "Entity".to_string(),
target_properties: properties([("id", Value::Int(10))]),
},
)
.unwrap();
}
let wal_path = active_wal_path(&path);
truncate_test_wal_tail(&wal_path, 8);
let plan =
DatabaseDoctor::plan_wal_tail_repair(&path, WalDoctorOptions::default()).unwrap();
DatabaseDoctor::apply_wal_tail_repair(
&path,
&plan,
plan.acknowledge_potential_data_loss(),
WalDoctorOptions::default(),
)
.unwrap();
let mut catalog = Catalog::default();
let store = GraphStore::open(&path, &mut catalog).unwrap();
assert!(store.scan_nodes(None).next().is_none());
assert!(catalog.rel_type_id("MENTIONS").is_none());
std::fs::remove_dir_all(path).unwrap();
}
fn truncate_test_wal_tail(wal_path: &std::path::Path, cut: u64) {
let file = std::fs::OpenOptions::new()
.write(true)
.open(wal_path)
.unwrap();
let len = file.metadata().unwrap().len();
file.set_len(len - cut).unwrap();
file.sync_all().unwrap();
}
#[test]
fn rejects_and_quarantines_checksum_corruption_before_valid_wal_suffix() {
let path = unique_test_dir("wal_middle_corruption");
{
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(1))]))
.unwrap();
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(2))]))
.unwrap();
}
let wal_path = active_wal_path(&path);
let mut wal = std::fs::read(&wal_path).unwrap();
let first_payload_offset = super::WAL_BINARY_FILE_HEADER_BYTES
+ super::wal_codec::frame::WAL_FRAGMENT_HEADER_BYTES;
wal[first_payload_offset] ^= 0xff;
std::fs::write(&wal_path, &wal).unwrap();
for _ in 0..3 {
let mut catalog = Catalog::default();
let error = GraphStore::open(&path, &mut catalog).unwrap_err();
assert!(error.to_string().contains("WAL corruption at byte offset"));
assert!(error.to_string().contains("checksum mismatch"));
}
assert_eq!(
std::fs::read_dir(path.join("quarantine")).unwrap().count(),
1
);
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn rejects_and_quarantines_checksum_corruption_at_wal_tail() {
let path = unique_test_dir("wal_tail_checksum_corruption");
{
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(1))]))
.unwrap();
}
let wal_path = active_wal_path(&path);
let mut corrupt_wal = std::fs::read(&wal_path).unwrap();
corrupt_wal[super::WAL_BINARY_FILE_HEADER_BYTES] ^= 0xff;
std::fs::write(&wal_path, &corrupt_wal).unwrap();
let mut catalog = Catalog::default();
let error = GraphStore::open(&path, &mut catalog).unwrap_err();
assert!(error.to_string().contains("WAL corruption at byte offset"));
assert!(error.to_string().contains("checksum mismatch"));
assert_eq!(std::fs::read(&wal_path).unwrap(), corrupt_wal);
assert_eq!(
std::fs::read_dir(path.join("quarantine")).unwrap().count(),
1
);
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn corrupt_wal_quarantine_respects_the_configured_byte_bound() {
let path = unique_test_dir("wal_quarantine_byte_bound");
{
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(1))]))
.unwrap();
}
let wal_path = active_wal_path(&path);
let valid_wal = std::fs::read(&wal_path).unwrap();
let wal_len = valid_wal.len() as u64;
let max_quarantine_bytes = wal_len.saturating_mul(2);
let payload_offset = super::WAL_BINARY_FILE_HEADER_BYTES
+ super::wal_codec::frame::WAL_FRAGMENT_HEADER_BYTES;
for variant in 0..3 {
let mut corrupt_wal = valid_wal.clone();
corrupt_wal[payload_offset + variant] ^= 0xff;
std::fs::write(&wal_path, corrupt_wal).unwrap();
let mut catalog = Catalog::default();
let error = GraphStore::open_with_durability_and_replay_config(
&path,
&mut catalog,
DurabilityPolicy::default(),
WalReplayConfig {
max_quarantine_bytes,
..WalReplayConfig::default()
},
)
.unwrap_err();
assert!(error.to_string().contains("checksum mismatch"));
let entries = std::fs::read_dir(path.join("quarantine"))
.unwrap()
.collect::<std::result::Result<Vec<_>, _>>()
.unwrap();
let retained_bytes = entries.iter().fold(0u64, |total, entry| {
total.saturating_add(entry.metadata().unwrap().len())
});
assert!(retained_bytes <= max_quarantine_bytes);
assert!(entries.len() <= 2);
}
assert_eq!(
std::fs::read_dir(path.join("quarantine")).unwrap().count(),
2
);
let corrupt_wal = std::fs::read(&wal_path).unwrap();
let mut catalog = Catalog::default();
let error = GraphStore::open_with_durability_and_replay_config(
&path,
&mut catalog,
DurabilityPolicy::default(),
WalReplayConfig {
max_quarantine_bytes: 0,
..WalReplayConfig::default()
},
)
.unwrap_err();
assert!(error.to_string().contains("checksum mismatch"));
assert_eq!(std::fs::read(&wal_path).unwrap(), corrupt_wal);
assert_eq!(
std::fs::read_dir(path.join("quarantine")).unwrap().count(),
0
);
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn rejects_and_quarantines_non_contiguous_wal_lsn() {
let path = unique_test_dir("wal_lsn_gap");
{
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(1))]))
.unwrap();
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(2))]))
.unwrap();
}
let wal_path = active_wal_path(&path);
let mut cursor = match super::WalRecordCursor::open(&wal_path, None).unwrap() {
super::WalOpenOutcome::Cursor(cursor) => cursor,
_ => panic!("test WAL is missing its header"),
};
let generation = cursor.generation();
let start_lsn = cursor.start_lsn();
let mut entries = Vec::new();
loop {
match cursor.next().unwrap() {
super::WalCursorEvent::Entry { entry, .. } => entries.push(entry),
super::WalCursorEvent::Eof => break,
_ => panic!("test WAL is damaged"),
}
}
entries.last_mut().unwrap().lsn += 1;
let mut rewritten = super::encode_binary_wal_header(generation, start_lsn);
for (index, entry) in entries.iter().enumerate() {
let payload = super::encode_binary_wal_record(entry, index as u64 + 1).unwrap();
let position = rewritten.len() as u64 - super::WAL_BINARY_FILE_HEADER_BYTES as u64;
rewritten.extend_from_slice(&super::frame_binary_wal_record(
generation, &payload, position,
));
}
std::fs::write(&wal_path, rewritten).unwrap();
let mut catalog = Catalog::default();
let error = GraphStore::open(&path, &mut catalog).unwrap_err();
assert!(error.to_string().contains("WAL LSN sequence mismatch"));
assert_eq!(
std::fs::read_dir(path.join("quarantine")).unwrap().count(),
1
);
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn mutation_rejects_over_depth_value_before_wal_append() {
fn value_at_depth(depth: usize) -> Value {
assert!(depth > 0);
(1..depth).fold(Value::Null, |value, _| Value::List(vec![value]))
}
let path = unique_test_dir("wal_value_depth_admission");
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
let accepted_id = store
.create_node(
&mut catalog,
"Memory",
properties([("payload", value_at_depth(32))]),
)
.unwrap();
let commit_epoch = store.commit_epoch();
let wal_path = active_wal_path(&path);
let wal_len = std::fs::metadata(&wal_path).unwrap().len();
let error = store
.create_node(
&mut catalog,
"Memory",
properties([("payload", value_at_depth(33))]),
)
.unwrap_err();
assert!(matches!(error, HawDBError::Semantic(_)));
assert!(error.to_string().contains("nesting exceeds 32"));
assert_eq!(store.commit_epoch(), commit_epoch);
assert_eq!(std::fs::metadata(&wal_path).unwrap().len(), wal_len);
store.checkpoint(&catalog).unwrap();
drop(store);
let mut reopened_catalog = Catalog::default();
let reopened = GraphStore::open(&path, &mut reopened_catalog).unwrap();
assert!(reopened.node_owned(accepted_id).unwrap().is_some());
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn manifest_fails_closed_for_missing_canonical_artifacts() {
for missing in ["checkpoint", "wal", "manifest"] {
let path = unique_test_dir(&format!("missing_{missing}"));
{
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(1))]))
.unwrap();
store.checkpoint(&catalog).unwrap();
}
match missing {
"checkpoint" => std::fs::remove_file(active_checkpoint_path(&path)).unwrap(),
"wal" => std::fs::remove_file(active_wal_path(&path)).unwrap(),
"manifest" => std::fs::remove_file(path.join("manifest.hawdb")).unwrap(),
_ => unreachable!(),
}
let mut catalog = Catalog::default();
let error = GraphStore::open(&path, &mut catalog).unwrap_err();
assert!(
error.to_string().contains("missing")
|| error.to_string().contains("no durable manifest")
);
std::fs::remove_dir_all(path).unwrap();
}
}
#[test]
fn doctor_recovers_complete_mem_shaped_batches_before_torn_tail() {
let path = unique_test_dir("mem_shaped_batch_recovery");
{
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
let old_memory = store
.create_node(
&mut catalog,
"Memory",
properties([
("id", Value::String("mem-old".to_string())),
("is_latest", Value::Bool(true)),
("updated_at", Value::Int(10)),
]),
)
.unwrap();
store.checkpoint(&catalog).unwrap();
store
.set_node_properties_by_ids(
&mut catalog,
&[old_memory],
&[
NodeSetAssignment {
property: "is_latest".to_string(),
value: NodeSetValue::Value(Value::Bool(false)),
},
NodeSetAssignment {
property: "updated_at".to_string(),
value: NodeSetValue::Value(Value::Int(20)),
},
],
)
.unwrap();
store
.create_connected_nodes(
&mut catalog,
ConnectedNodesCreate {
source_label: "Memory".to_string(),
source_properties: properties([
("id", Value::String("mem-new".to_string())),
("is_latest", Value::Bool(true)),
]),
rel_type: "EVOLVES".to_string(),
rel_properties: properties([
("content_relation", Value::String("replaces".to_string())),
("confidence", Value::Float(0.9)),
]),
target_label: "Memory".to_string(),
target_properties: properties([
("id", Value::String("mem-snapshot-old".to_string())),
("is_latest", Value::Bool(false)),
]),
},
)
.unwrap();
store
.create_connected_nodes(
&mut catalog,
ConnectedNodesCreate {
source_label: "Memory".to_string(),
source_properties: properties([(
"id",
Value::String("mem-torn-new".to_string()),
)]),
rel_type: "EVOLVES".to_string(),
rel_properties: properties([(
"content_relation",
Value::String("torn".to_string()),
)]),
target_label: "Memory".to_string(),
target_properties: properties([(
"id",
Value::String("mem-torn-old".to_string()),
)]),
},
)
.unwrap();
}
let wal_path = active_wal_path(&path);
truncate_test_wal_tail(&wal_path, 8);
let plan =
DatabaseDoctor::plan_wal_tail_repair(&path, WalDoctorOptions::default()).unwrap();
let repair = DatabaseDoctor::apply_wal_tail_repair(
&path,
&plan,
plan.acknowledge_potential_data_loss(),
WalDoctorOptions::default(),
)
.unwrap();
assert_eq!(repair.next_lsn_after_repair, 4);
assert!(repair.discarded_wal_tail_bytes > 0);
let mut catalog = Catalog::default();
let store = GraphStore::open(&path, &mut catalog).unwrap();
let report = store.storage_recovery_report();
assert!(report.durable);
assert_eq!(report.checkpoint_commit_epoch, Some(1));
assert_eq!(report.wal_replay_start_lsn, Some(2));
assert_eq!(report.next_lsn_after_replay, Some(4));
assert_eq!(report.replayed_wal_entries, 2);
assert!(!report.torn_tail_ignored);
assert!(report.torn_tail_reason.is_none());
assert_eq!(report.recovered_commit_epoch, 3);
let memory_label = catalog.label_id("Memory").unwrap();
let memories = store.scan_nodes(Some(memory_label)).collect::<Vec<_>>();
let ids = memories
.iter()
.filter_map(|node| node.properties.get("id").cloned())
.collect::<BTreeSet<_>>();
assert_eq!(
ids,
BTreeSet::from([
Value::String("mem-old".to_string()),
Value::String("mem-new".to_string()),
Value::String("mem-snapshot-old".to_string()),
])
);
let old = memories
.iter()
.find(|node| node.properties.get("id") == Some(&Value::String("mem-old".to_string())))
.unwrap();
assert_eq!(old.properties.get("is_latest"), Some(&Value::Bool(false)));
assert_eq!(old.properties.get("updated_at"), Some(&Value::Int(20)));
let evolves = catalog.rel_type_id("EVOLVES").unwrap();
let relationships = store.scan_relationships(Some(evolves)).collect::<Vec<_>>();
assert_eq!(relationships.len(), 1);
assert_eq!(
relationships[0].properties.get("content_relation"),
Some(&Value::String("replaces".to_string()))
);
std::fs::remove_dir_all(path).unwrap();
}
#[test]
fn adaptive_histograms_use_medium_sample_for_medium_cardinality() {
let nodes = histogram_nodes("score", 2_000);
let statistics = compute_statistics(&nodes, &CowSegmentedMap::default(), 1);
let histogram = statistics
.property_histograms
.get(&(LabelId(0), "score".to_string()))
.unwrap();
assert_eq!(statistics.histogram_sample_limit, 512);
assert_eq!(histogram.len(), 256);
assert_eq!(histogram.first(), Some(&Value::Int(0)));
assert_eq!(histogram.last(), Some(&Value::Int(1_999)));
assert_eq!(
statistics
.property_distinct_counts
.get(&(LabelId(0), "score".to_string())),
Some(&2_000)
);
assert_eq!(
statistics
.sampled_property_histograms
.get(&(LabelId(0), "score".to_string())),
Some(&true)
);
}
#[test]
fn adaptive_histograms_use_max_sample_for_large_cardinality() {
let nodes = histogram_nodes("score", 5_000);
let statistics = compute_statistics(&nodes, &CowSegmentedMap::default(), 1);
let histogram = statistics
.property_histograms
.get(&(LabelId(0), "score".to_string()))
.unwrap();
assert_eq!(histogram.len(), 512);
assert_eq!(histogram.first(), Some(&Value::Int(0)));
assert_eq!(histogram.last(), Some(&Value::Int(4_999)));
}
#[test]
fn incremental_basic_statistics_match_full_recompute_after_mutations() {
let mut catalog = Catalog::default();
let mut store = GraphStore::in_memory();
let source = store
.create_node(
&mut catalog,
"Memory",
properties([("id", Value::String("stat-source".to_string()))]),
)
.unwrap();
let target = store
.create_node(
&mut catalog,
"Entity",
properties([("id", Value::String("stat-target".to_string()))]),
)
.unwrap();
store
.create_relationship(
&mut catalog,
source,
target,
"MENTIONS",
properties([("confidence", Value::Float(0.9))]),
)
.unwrap();
let report = store.basic_statistics_consistency_report();
assert!(report.ready);
assert!(report.mismatched_fields.is_empty());
assert_eq!(report.incremental, report.recomputed);
assert_eq!(report.incremental.node_count, 2);
assert_eq!(report.incremental.relationship_count, 1);
assert_eq!(
report
.incremental
.label_counts
.get(&catalog.label_id("Memory").unwrap()),
Some(&1)
);
assert_eq!(
report
.incremental
.label_counts
.get(&catalog.label_id("Entity").unwrap()),
Some(&1)
);
assert_eq!(
report
.incremental
.rel_type_counts
.get(&catalog.rel_type_id("MENTIONS").unwrap()),
Some(&1)
);
store
.delete_nodes(
&mut catalog,
"Memory",
Some(&PropertyFilter::Eq {
property: "id".to_string(),
value: Value::String("stat-source".to_string()),
}),
true,
)
.unwrap();
let report = store.basic_statistics_consistency_report();
assert!(report.ready);
assert!(report.mismatched_fields.is_empty());
assert_eq!(report.incremental, report.recomputed);
assert_eq!(report.incremental.node_count, 1);
assert_eq!(report.incremental.relationship_count, 0);
assert!(!report
.incremental
.label_counts
.contains_key(&catalog.label_id("Memory").unwrap()));
assert!(!report
.incremental
.rel_type_counts
.contains_key(&catalog.rel_type_id("MENTIONS").unwrap()));
}
#[test]
fn degree_statistics_consistency_report_matches_full_recompute_after_mutations() {
let mut catalog = Catalog::default();
let mut store = GraphStore::in_memory();
let source = store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(0))]))
.unwrap();
let target_one = store
.create_node(&mut catalog, "Entity", properties([("id", Value::Int(1))]))
.unwrap();
let target_two = store
.create_node(&mut catalog, "Entity", properties([("id", Value::Int(2))]))
.unwrap();
store
.create_relationship(
&mut catalog,
source,
target_one,
"MENTIONS",
BTreeMap::new(),
)
.unwrap();
store
.create_relationship(
&mut catalog,
source,
target_two,
"MENTIONS",
BTreeMap::new(),
)
.unwrap();
let memory_label = catalog.label_id("Memory").unwrap();
let entity_label = catalog.label_id("Entity").unwrap();
let rel_type = catalog.rel_type_id("MENTIONS").unwrap();
let report = store.degree_statistics_consistency_report();
assert!(report.ready);
assert!(report.mismatched_keys.is_empty());
assert_eq!(report.maintained, report.recomputed);
assert_eq!(
report
.maintained
.get(&DegreeStatisticsKey {
label_id: memory_label,
rel_type,
direction: AdjacencyDirection::Outgoing,
})
.copied(),
Some(DegreeStatisticsEntry {
node_count: 1,
non_zero_node_count: 1,
relationship_count: 2,
max_degree: 2,
dense_node_count: 0,
})
);
assert_eq!(
report
.maintained
.get(&DegreeStatisticsKey {
label_id: entity_label,
rel_type,
direction: AdjacencyDirection::Incoming,
})
.copied(),
Some(DegreeStatisticsEntry {
node_count: 2,
non_zero_node_count: 2,
relationship_count: 2,
max_degree: 1,
dense_node_count: 0,
})
);
store
.delete_relationships(
&mut catalog,
RelationshipDeleteRequest {
source_label: "Memory".to_string(),
filter: None,
rel_type: "MENTIONS".to_string(),
target_label: "Entity".to_string(),
target_filter: Some(PropertyFilter::Eq {
property: "id".to_string(),
value: Value::Int(1),
}),
rel_filter: None,
},
)
.unwrap();
let report = store.degree_statistics_consistency_report();
assert!(report.ready);
assert_eq!(report.maintained, report.recomputed);
assert_eq!(
report
.maintained
.get(&DegreeStatisticsKey {
label_id: memory_label,
rel_type,
direction: AdjacencyDirection::Outgoing,
})
.map(|entry| entry.relationship_count),
Some(1)
);
}
#[test]
fn degree_statistics_consistency_report_counts_dense_groups() {
let mut catalog = Catalog::default();
let mut store = GraphStore::in_memory();
let source = store
.create_node(&mut catalog, "Memory", properties([("id", Value::Int(0))]))
.unwrap();
for id in 0..DENSE_ADJACENCY_DEGREE_THRESHOLD {
let target = store
.create_node(
&mut catalog,
"Entity",
properties([("id", Value::Int(id as i64))]),
)
.unwrap();
store
.create_relationship(&mut catalog, source, target, "MENTIONS", BTreeMap::new())
.unwrap();
}
let report = store.degree_statistics_consistency_report();
let memory_label = catalog.label_id("Memory").unwrap();
let rel_type = catalog.rel_type_id("MENTIONS").unwrap();
assert!(report.ready);
assert_eq!(
report
.maintained
.get(&DegreeStatisticsKey {
label_id: memory_label,
rel_type,
direction: AdjacencyDirection::Outgoing,
})
.map(|entry| (entry.max_degree, entry.dense_node_count)),
Some((DENSE_ADJACENCY_DEGREE_THRESHOLD as u64, 1))
);
}
#[test]
fn distinct_value_statistics_consistency_report_matches_index_after_mutations() {
let mut catalog = Catalog::default();
let mut store = GraphStore::in_memory();
store
.create_property_index(&mut catalog, "Memory", "unit_type")
.unwrap();
store
.create_property_index(&mut catalog, "Memory", "importance")
.unwrap();
let source = store
.create_node(
&mut catalog,
"Memory",
properties([
("id", Value::String("memory:1".to_string())),
("unit_type", Value::String("note".to_string())),
("importance", Value::Float(0.2)),
]),
)
.unwrap();
store
.create_node(
&mut catalog,
"Memory",
properties([
("id", Value::String("memory:2".to_string())),
("unit_type", Value::String("task".to_string())),
("importance", Value::Float(0.7)),
]),
)
.unwrap();
let target = store
.create_node(
&mut catalog,
"Entity",
properties([("id", Value::String("entity:1".to_string()))]),
)
.unwrap();
let weak = store
.create_relationship(
&mut catalog,
source,
target,
"MENTIONS",
properties([("confidence", Value::Float(0.2))]),
)
.unwrap();
store
.create_relationship(
&mut catalog,
source,
target,
"MENTIONS",
properties([("confidence", Value::Float(0.7))]),
)
.unwrap();
let report = store.distinct_value_statistics_consistency_report(&catalog);
let memory_label = catalog.label_id("Memory").unwrap();
let rel_type = catalog.rel_type_id("MENTIONS").unwrap();
assert!(report.ready);
assert_eq!(
report
.maintained_property_distinct_counts
.get(&(memory_label, "unit_type".to_string())),
Some(&2)
);
assert_eq!(
report
.maintained_property_distinct_counts
.get(&(memory_label, "importance".to_string())),
Some(&2)
);
assert_eq!(
report
.maintained_rel_property_distinct_counts
.get(&(rel_type, "confidence".to_string())),
Some(&2)
);
store
.set_node_property(
&mut catalog,
"Memory",
Some(&PropertyFilter::Eq {
property: "id".to_string(),
value: Value::String("memory:2".to_string()),
}),
"unit_type",
Value::String("note".to_string()),
)
.unwrap();
store.apply_set_relationship_property(weak, "confidence".to_string(), Value::Float(0.7));
let report = store.distinct_value_statistics_consistency_report(&catalog);
assert!(report.ready);
assert!(report.mismatched_property_keys.is_empty());
assert!(report.mismatched_rel_property_keys.is_empty());
assert_eq!(
report.maintained_property_distinct_counts,
report.recomputed_property_distinct_counts
);
assert_eq!(
report.maintained_rel_property_distinct_counts,
report.recomputed_rel_property_distinct_counts
);
assert_eq!(
report
.maintained_property_distinct_counts
.get(&(memory_label, "unit_type".to_string())),
Some(&1)
);
assert_eq!(
report
.maintained_rel_property_distinct_counts
.get(&(rel_type, "confidence".to_string())),
Some(&1)
);
store.apply_delete_relationship(weak);
let report = store.distinct_value_statistics_consistency_report(&catalog);
assert!(report.ready);
assert_eq!(
report
.maintained_rel_property_distinct_counts
.get(&(rel_type, "confidence".to_string())),
Some(&1)
);
}
#[test]
fn property_index_consistency_report_matches_full_scan_after_mutations() {
let mut catalog = Catalog::default();
let mut store = GraphStore::in_memory();
let source = store
.create_node(
&mut catalog,
"Memory",
properties([
("id", Value::String("memory:1".to_string())),
("unit_type", Value::String("note".to_string())),
]),
)
.unwrap();
store
.create_node(
&mut catalog,
"Memory",
properties([
("id", Value::String("memory:2".to_string())),
("unit_type", Value::String("task".to_string())),
]),
)
.unwrap();
let target = store
.create_node(
&mut catalog,
"Entity",
properties([("id", Value::String("entity:1".to_string()))]),
)
.unwrap();
let weak = store
.create_relationship(
&mut catalog,
source,
target,
"MENTIONS",
properties([("confidence", Value::Float(0.2))]),
)
.unwrap();
store
.create_relationship(
&mut catalog,
source,
target,
"MENTIONS",
properties([("confidence", Value::Float(0.7))]),
)
.unwrap();
let report = store.property_index_consistency_report(&catalog);
assert!(report.ready);
assert_eq!(
report.node_index_entry_count,
report.recomputed_node_index_entry_count
);
assert_eq!(
report.node_index_reference_count,
report.recomputed_node_index_reference_count
);
assert_eq!(
report.relationship_index_entry_count,
report.recomputed_relationship_index_entry_count
);
assert_eq!(
report.relationship_index_reference_count,
report.recomputed_relationship_index_reference_count
);
assert_eq!(report.missing_node_key_count, 0);
assert_eq!(report.extra_node_key_count, 0);
assert_eq!(report.mismatched_node_key_count, 0);
assert_eq!(report.missing_relationship_key_count, 0);
assert_eq!(report.extra_relationship_key_count, 0);
assert_eq!(report.mismatched_relationship_key_count, 0);
assert!(report.mismatched_node_keys.is_empty());
assert!(report.mismatched_relationship_keys.is_empty());
store
.set_node_property(
&mut catalog,
"Memory",
Some(&PropertyFilter::Eq {
property: "id".to_string(),
value: Value::String("memory:2".to_string()),
}),
"unit_type",
Value::String("note".to_string()),
)
.unwrap();
store.apply_set_relationship_property(weak, "confidence".to_string(), Value::Float(0.7));
store.apply_delete_relationship(weak);
let report = store.property_index_consistency_report(&catalog);
assert!(report.ready);
assert_eq!(
report.node_index_entry_count,
report.recomputed_node_index_entry_count
);
assert_eq!(
report.node_index_reference_count,
report.recomputed_node_index_reference_count
);
assert_eq!(
report.relationship_index_entry_count,
report.recomputed_relationship_index_entry_count
);
assert_eq!(
report.relationship_index_reference_count,
report.recomputed_relationship_index_reference_count
);
assert!(report.mismatched_node_keys.is_empty());
assert!(report.mismatched_relationship_keys.is_empty());
}
#[test]
fn statistics_track_relationship_property_distinct_counts() {
let relationships = (0..10)
.map(|id| {
(
RelId(id),
RelRecord {
id: RelId(id),
source: NodeId(id),
target: NodeId(id + 100),
rel_type: RelTypeId(0),
properties: properties([("weight", Value::Int((id % 4) as i64))]),
},
)
})
.collect::<BTreeMap<_, _>>()
.into();
let statistics = compute_statistics(&CowSegmentedMap::default(), &relationships, 1);
assert_eq!(
statistics
.rel_property_distinct_counts
.get(&(RelTypeId(0), "weight".to_string())),
Some(&4)
);
assert_eq!(
statistics
.rel_property_histograms
.get(&(RelTypeId(0), "weight".to_string()))
.and_then(|values| values.first()),
Some(&Value::Int(0))
);
assert_eq!(
statistics
.rel_property_histograms
.get(&(RelTypeId(0), "weight".to_string()))
.and_then(|values| values.last()),
Some(&Value::Int(3))
);
assert_eq!(
statistics
.sampled_rel_property_histograms
.get(&(RelTypeId(0), "weight".to_string())),
Some(&false)
);
}
#[test]
fn recovered_property_statistics_drop_ineligible_and_incomplete_groups() {
let mut catalog = Catalog::default();
let label_id = catalog.get_or_create_label("Metric");
let rel_type_id = catalog.get_or_create_rel_type("MEASURES");
let node_table = catalog.get_or_create_table(TableKind::Node, "Metric");
let relationship_table = catalog.get_or_create_table(TableKind::Relationship, "MEASURES");
catalog.get_or_create_property(node_table, "body", PropertyType::Text, true);
catalog.get_or_create_property(relationship_table, "note", PropertyType::Text, true);
let compact_node = (label_id, "score".to_string());
let text_node = (label_id, "body".to_string());
let incomplete_node = (label_id, "missing_marker".to_string());
let empty_node = (label_id, "empty_histogram".to_string());
let compact_relationship = (rel_type_id, "weight".to_string());
let text_relationship = (rel_type_id, "note".to_string());
let mut statistics = GraphStatistics {
property_distinct_counts: BTreeMap::from([
(compact_node.clone(), 2),
(text_node.clone(), 1),
(incomplete_node.clone(), 1),
(empty_node.clone(), 0),
]),
property_histograms: BTreeMap::from([
(compact_node.clone(), vec![Value::Int(1), Value::Int(2)]),
(
text_node.clone(),
vec![Value::String("payload".to_string())],
),
(incomplete_node, vec![Value::Int(3)]),
(empty_node.clone(), Vec::new()),
]),
sampled_property_histograms: BTreeMap::from([
(compact_node.clone(), false),
(text_node, false),
(empty_node, false),
]),
rel_property_distinct_counts: BTreeMap::from([
(compact_relationship.clone(), 1),
(text_relationship.clone(), 1),
]),
rel_property_histograms: BTreeMap::from([
(compact_relationship.clone(), vec![Value::Float(0.5)]),
(
text_relationship.clone(),
vec![Value::String("payload".to_string())],
),
]),
sampled_rel_property_histograms: BTreeMap::from([
(compact_relationship.clone(), false),
(text_relationship, false),
]),
..GraphStatistics::default()
};
retain_supported_property_statistics(&mut statistics, Some(&catalog));
assert_eq!(
statistics.property_distinct_counts,
BTreeMap::from([(compact_node.clone(), 2)])
);
assert_eq!(
statistics
.property_histograms
.keys()
.cloned()
.collect::<Vec<_>>(),
vec![compact_node.clone()]
);
assert_eq!(
statistics
.sampled_property_histograms
.keys()
.cloned()
.collect::<Vec<_>>(),
vec![compact_node]
);
assert_eq!(
statistics.rel_property_distinct_counts,
BTreeMap::from([(compact_relationship.clone(), 1)])
);
assert_eq!(
statistics
.rel_property_histograms
.keys()
.cloned()
.collect::<Vec<_>>(),
vec![compact_relationship]
);
}
#[test]
fn statistics_track_path_source_and_target_coverage() {
let nodes: crate::cow::CowNodeMap = [
(0, LabelId(0)),
(1, LabelId(0)),
(10, LabelId(1)),
(11, LabelId(1)),
(12, LabelId(1)),
(20, LabelId(1)),
(21, LabelId(1)),
]
.into_iter()
.map(|(id, label)| {
(
NodeId(id),
NodeRecord {
id: NodeId(id),
labels: BTreeSet::from([label]),
properties: BTreeMap::new(),
},
)
})
.collect::<BTreeMap<_, _>>()
.into();
let relationships = [
(0, 0, 10),
(1, 0, 11),
(2, 1, 11),
(3, 1, 12),
(4, 1, 12),
(5, 10, 20),
(6, 11, 20),
(7, 12, 21),
]
.into_iter()
.map(|(id, source, target)| {
(
RelId(id),
RelRecord {
id: RelId(id),
source: NodeId(source),
target: NodeId(target),
rel_type: RelTypeId(0),
properties: BTreeMap::new(),
},
)
})
.collect::<BTreeMap<_, _>>()
.into();
let statistics = compute_statistics(&nodes, &relationships, 1);
let path = (LabelId(0), RelTypeId(0), LabelId(1));
assert_eq!(statistics.path_counts.get(&path), Some(&5));
assert_eq!(statistics.path_source_distinct_counts.get(&path), Some(&2));
assert_eq!(statistics.path_target_distinct_counts.get(&path), Some(&3));
let two_hop_path = (LabelId(0), RelTypeId(0), LabelId(1), 2);
assert_eq!(statistics.bounded_path_counts.get(&two_hop_path), Some(&5));
assert_eq!(
statistics
.bounded_path_source_distinct_counts
.get(&two_hop_path),
Some(&2)
);
assert_eq!(
statistics
.bounded_path_target_distinct_counts
.get(&two_hop_path),
Some(&2)
);
}
#[test]
fn adaptive_histograms_track_relationship_sampling() {
let relationships = (0..2_000)
.map(|id| {
(
RelId(id),
RelRecord {
id: RelId(id),
source: NodeId(id),
target: NodeId(id + 100),
rel_type: RelTypeId(0),
properties: properties([("score", Value::Int(id as i64))]),
},
)
})
.collect::<BTreeMap<_, _>>()
.into();
let statistics = compute_statistics(&CowSegmentedMap::default(), &relationships, 1);
let histogram = statistics
.rel_property_histograms
.get(&(RelTypeId(0), "score".to_string()))
.unwrap();
assert_eq!(histogram.len(), 256);
assert_eq!(histogram.first(), Some(&Value::Int(0)));
assert_eq!(histogram.last(), Some(&Value::Int(1_999)));
assert_eq!(
statistics
.sampled_rel_property_histograms
.get(&(RelTypeId(0), "score".to_string())),
Some(&true)
);
}
fn properties<const N: usize>(entries: [(&str, Value); N]) -> BTreeMap<String, Value> {
entries
.into_iter()
.map(|(key, value)| (key.to_string(), value))
.collect()
}
fn histogram_nodes(property: &str, count: u64) -> CowSegmentedMap<NodeId, NodeRecord> {
(0..count)
.map(|id| {
(
NodeId(id),
NodeRecord {
id: NodeId(id),
labels: BTreeSet::from([LabelId(0)]),
properties: properties([(property, Value::Int(id as i64))]),
},
)
})
.collect::<BTreeMap<_, _>>()
.into()
}
fn unique_test_dir(name: &str) -> std::path::PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("hawdb_store_{name}_{nanos}"))
}
fn active_wal_path(path: impl AsRef<std::path::Path>) -> std::path::PathBuf {
active_generation_path(path.as_ref(), "wal_generation", "wal")
}
fn active_checkpoint_path(path: impl AsRef<std::path::Path>) -> std::path::PathBuf {
active_generation_path(path.as_ref(), "checkpoint_generation", "checkpoint")
}
fn read_test_wal(path: impl AsRef<std::path::Path>) -> std::io::Result<String> {
super::render_wal_records_for_test(&active_wal_path(path))
}
fn active_generation_path(
root: &std::path::Path,
manifest_field: &str,
prefix: &str,
) -> std::path::PathBuf {
let manifest = std::fs::read_to_string(root.join("manifest.hawdb")).unwrap();
assert!(manifest.contains("HAWDB_MANIFEST_V1\n"));
let generation = manifest.lines().find_map(|line| {
let (field, value) = line.split_once('\t')?;
(field == manifest_field && value != "none").then_some(value)
});
root.join(format!(
"{prefix}.{}.hawdb",
generation.expect("active generation must exist")
))
}
fn rewrite_checksummed_file(path: &std::path::Path, from: &str, to: &str, kind: &str) {
let was_compressed = std::fs::read(path)
.unwrap()
.starts_with(DURABLE_COMPRESSION_HEADER.as_bytes());
let text = if kind == "manifest" {
std::fs::read_to_string(path).unwrap()
} else {
read_durable_text(path, kind).unwrap()
};
let (body, _) = text.rsplit_once("checksum\t").unwrap();
let body = body.replace(from, to);
let checksum = checksum_bytes(body.as_bytes());
let rewritten = format!("{body}checksum\t{checksum}\n");
if was_compressed {
std::fs::write(
path,
encode_durable_text(&rewritten, DurableCompression::default()).unwrap(),
)
.unwrap();
} else {
std::fs::write(path, rewritten.as_bytes()).unwrap();
}
if path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("checkpoint."))
{
refresh_manifest_checkpoint_metadata(path);
}
let rewritten = if kind == "manifest" {
std::fs::read_to_string(path).unwrap()
} else {
read_durable_text(path, kind).unwrap()
};
assert!(
rewritten.contains(to),
"{kind} rewrite did not update storage version"
);
}
fn refresh_manifest_checkpoint_metadata(checkpoint_path: &std::path::Path) {
let root = checkpoint_path.parent().unwrap();
let manifest_path = root.join("manifest.hawdb");
let manifest = std::fs::read_to_string(&manifest_path).unwrap();
let (body, _) = manifest.rsplit_once("checksum\t").unwrap();
let checkpoint = std::fs::read(checkpoint_path).unwrap();
let encoded_len = checkpoint.len() as u64;
let integrity = integrity_digest(&checkpoint);
let encoded_checksum = integrity.crc32c.as_u64();
let encoded_sha256 = integrity.sha256;
let mut rewritten_body = String::new();
for line in body.lines() {
if line.starts_with("checkpoint_encoded_len\t") {
rewritten_body.push_str(&format!("checkpoint_encoded_len\t{encoded_len}\n"));
} else if line.starts_with("checkpoint_encoded_checksum\t") {
rewritten_body.push_str(&format!(
"checkpoint_encoded_checksum\t{encoded_checksum}\n"
));
} else if line.starts_with("checkpoint_encoded_sha256\t") {
rewritten_body.push_str(&format!("checkpoint_encoded_sha256\t{encoded_sha256}\n"));
} else {
rewritten_body.push_str(line);
rewritten_body.push('\n');
}
}
let checksum = checksum_bytes(rewritten_body.as_bytes());
std::fs::write(
manifest_path,
format!("{rewritten_body}checksum\t{checksum}\n"),
)
.unwrap();
}
#[test]
fn delta_validation_still_catches_touched_unique_violation() {
let path = unique_test_dir("delta_unique_violation");
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
store
.create_unique_constraint(&mut catalog, "T", "k")
.unwrap();
store
.create_node(&mut catalog, "T", properties([("k", Value::Int(1))]))
.unwrap();
let error = store
.create_node(&mut catalog, "T", properties([("k", Value::Int(1))]))
.unwrap_err();
assert!(error.to_string().contains("unique constraint"));
let _ = std::fs::remove_dir_all(&path);
}
#[test]
fn ddl_ops_fall_back_to_full_validation() {
let path = unique_test_dir("ddl_full_validation");
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
let first = store
.create_node(&mut catalog, "T", properties([("k", Value::Int(1))]))
.unwrap();
store
.create_node(&mut catalog, "T", properties([("k", Value::Int(1))]))
.unwrap();
let label_id = catalog.label_id("T").unwrap();
catalog.get_or_create_unique_constraint(label_id, "k");
let ops = [WalOp::Batch(vec![
WalOp::SetNodeProperty {
id: first,
property: "unrelated".to_string(),
value: Value::Int(9),
},
WalOp::CreateUniqueConstraint {
label: "T".to_string(),
property: "k".to_string(),
},
])];
let error = store
.validate_constraints_for_ops(&catalog, &ops)
.unwrap_err();
assert!(error.to_string().contains("unique constraint"));
let _ = std::fs::remove_dir_all(&path);
}
#[test]
fn delta_validation_catches_touched_vs_touched_unique_violation() {
let path = unique_test_dir("delta_touched_pair");
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
store
.create_unique_constraint(&mut catalog, "T", "k")
.unwrap();
let ops = [
WalOp::CreateNode {
id: NodeId(1),
label: "T".to_string(),
properties: properties([("k", Value::Int(1))]),
},
WalOp::CreateNode {
id: NodeId(2),
label: "T".to_string(),
properties: properties([("k", Value::Int(1))]),
},
];
let error = store
.validate_constraints_for_ops(&catalog, &ops)
.unwrap_err();
assert!(error.to_string().contains("unique constraint"));
let _ = std::fs::remove_dir_all(&path);
}
#[test]
fn delta_validation_catches_relationship_unique_violation() {
let path = unique_test_dir("delta_rel_unique");
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
let a = store
.create_node(&mut catalog, "T", properties([]))
.unwrap();
let b = store
.create_node(&mut catalog, "T", properties([]))
.unwrap();
store
.create_relationship_unique_constraint(&mut catalog, "R", "k")
.unwrap();
store
.create_relationship(&mut catalog, a, b, "R", properties([("k", Value::Int(1))]))
.unwrap();
let error = store
.create_relationship(&mut catalog, a, b, "R", properties([("k", Value::Int(1))]))
.unwrap_err();
assert!(error.to_string().contains("unique constraint"));
let _ = std::fs::remove_dir_all(&path);
}
#[test]
fn delta_unique_validation_handles_bulk_touched_sets() {
let path = unique_test_dir("delta_bulk_touched");
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
store
.create_unique_constraint(&mut catalog, "T", "k")
.unwrap();
let mut ids = Vec::new();
for i in 0..2000 {
ids.push(
store
.create_node(&mut catalog, "T", properties([("k", Value::Int(i))]))
.unwrap(),
);
}
let rewrites: Vec<WalOp> = ids
.iter()
.enumerate()
.map(|(i, id)| WalOp::SetNodeProperty {
id: *id,
property: "k".to_string(),
value: Value::Int(i as i64),
})
.collect();
store
.validate_constraints_for_ops(&catalog, &rewrites)
.unwrap();
let mut colliding = rewrites.clone();
colliding.push(WalOp::SetNodeProperty {
id: ids[0],
property: "k".to_string(),
value: Value::Int(1999),
});
let error = store
.validate_constraints_for_ops(&catalog, &colliding)
.unwrap_err();
assert!(error.to_string().contains("unique constraint"));
let _ = std::fs::remove_dir_all(&path);
}
#[test]
#[ignore = "timing probe for delta vs full unique validation"]
fn delta_unique_validation_scaling_probe() {
use crate::graph_constraints::TouchedRecords;
use std::time::Instant;
for n in [1_000usize, 2_000] {
let path = unique_test_dir(&format!("delta_unique_scaling_{n}"));
let mut catalog = Catalog::default();
let mut store = GraphStore::open(&path, &mut catalog).unwrap();
store
.create_unique_constraint(&mut catalog, "T", "k")
.unwrap();
let mut ids = Vec::with_capacity(n);
for i in 0..n {
ids.push(
store
.create_node(&mut catalog, "T", properties([("k", Value::Int(i as i64))]))
.unwrap(),
);
}
let single = TouchedRecords {
nodes: BTreeSet::from([ids[n - 1]]),
relationships: BTreeSet::new(),
};
let t = Instant::now();
super::validate_unique_constraints(&catalog, &store.nodes).unwrap();
let full_1 = t.elapsed();
let t = Instant::now();
super::validate_unique_constraints_for_records(&catalog, &store.nodes, &single)
.unwrap();
let delta_1 = t.elapsed();
let bulk = TouchedRecords {
nodes: ids.iter().copied().collect(),
relationships: BTreeSet::new(),
};
let t = Instant::now();
super::validate_unique_constraints_for_records(&catalog, &store.nodes, &bulk).unwrap();
let delta_n = t.elapsed();
eprintln!(
"n={n} K=1: full={full_1:?} delta={delta_1:?} | K=N: delta={delta_n:?} (full={full_1:?})"
);
let _ = std::fs::remove_dir_all(&path);
}
}
#[test]
fn wal_ops_touched_records_scopes_pure_data_and_rejects_ddl() {
use super::wal_ops_touched_records;
let data = [
WalOp::CreateNode {
id: NodeId(1),
label: "T".to_string(),
properties: BTreeMap::new(),
},
WalOp::SetNodeProperty {
id: NodeId(2),
property: "k".to_string(),
value: Value::Int(1),
},
];
let touched = wal_ops_touched_records(&data).unwrap();
assert!(touched.nodes.contains(&NodeId(1)));
assert!(touched.nodes.contains(&NodeId(2)));
assert!(touched.relationships.is_empty());
let ddl = [
WalOp::CreateNode {
id: NodeId(1),
label: "T".to_string(),
properties: BTreeMap::new(),
},
WalOp::CreateUniqueConstraint {
label: "T".to_string(),
property: "k".to_string(),
},
];
assert!(wal_ops_touched_records(&ddl).is_none());
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
#[path = "store/graph_checkpoint_projection_memory_tests.rs"]
mod checkpoint_projection_memory_tests;
#[cfg(all(test, not(target_arch = "wasm32")))]
#[path = "store/graph_checkpoint_projection_memory_related_tests.rs"]
mod checkpoint_projection_memory_related_tests;