Files
tranquil-pds/crates/tranquil-store/src/consistency.rs
T
2026-06-02 17:29:21 +03:00

779 lines
25 KiB
Rust

use std::collections::HashSet;
use std::fmt;
use std::path::Path;
use crate::blockstore::CID_SIZE;
use crate::blockstore::hash_index::BlockIndex;
use crate::blockstore::{DataFileId, TranquilBlockStore};
use crate::clock::{Clock, SystemClock};
use crate::eventlog::{EventLog, EventSequence, SequenceContiguityResult};
use crate::io::{RealIO, StorageIO};
use crate::metastore::Metastore;
use crate::metastore::encoding::KeyBuilder;
use crate::metastore::event_keys::metastore_cursor_key;
use crate::metastore::keys::{KeyTag, UserHash};
use crate::metastore::partitions::Partition;
use crate::metastore::records::RecordValue;
use crate::metastore::repo_meta::RepoMetaValue;
const CLEAN_SHUTDOWN_MARKER: &str = ".clean_shutdown";
#[derive(Debug, Default)]
pub struct ConsistencyReport {
pub repos_checked: u64,
pub records_checked: u64,
pub user_blocks_checked: u64,
pub handles_checked: u64,
pub dangling_record_cids: Vec<DanglingCid>,
pub dangling_root_cids: Vec<DanglingRootCid>,
pub orphaned_user_repos: Vec<OrphanedUserRepo>,
pub inconsistent_handles: Vec<InconsistentHandle>,
pub orphan_data_files: Vec<DataFileId>,
pub orphan_hint_files: Vec<DataFileId>,
pub missing_indexed_files: Vec<DataFileId>,
pub deserialization_failures: u64,
pub eventlog_contiguity: Option<SequenceContiguityResult>,
pub cursor_ahead_of_eventlog: bool,
pub metastore_cursor: Option<EventSequence>,
pub eventlog_max_seq: Option<EventSequence>,
}
#[derive(Debug, Clone)]
pub struct DanglingCid {
pub user_hash: UserHash,
pub collection: String,
pub rkey: String,
pub cid_bytes: Vec<u8>,
}
#[derive(Debug, Clone)]
pub struct DanglingRootCid {
pub user_hash: UserHash,
pub root_cid_bytes: Vec<u8>,
}
#[derive(Debug, Clone)]
pub struct OrphanedUserRepo {
pub user_hash: UserHash,
}
#[derive(Debug, Clone)]
pub struct InconsistentHandle {
pub handle: String,
pub mapped_user_hash: UserHash,
pub problem: HandleProblem,
}
#[derive(Debug, Clone)]
pub enum HandleProblem {
NoRepoMeta,
HandleMismatch { repo_handle: String },
}
impl ConsistencyReport {
pub fn is_consistent(&self) -> bool {
self.dangling_record_cids.is_empty()
&& self.dangling_root_cids.is_empty()
&& self.orphaned_user_repos.is_empty()
&& self.inconsistent_handles.is_empty()
&& self.orphan_data_files.is_empty()
&& self.orphan_hint_files.is_empty()
&& self.missing_indexed_files.is_empty()
&& self.deserialization_failures == 0
&& self
.eventlog_contiguity
.as_ref()
.is_none_or(|c| c.is_contiguous())
&& !self.cursor_ahead_of_eventlog
}
pub fn has_repairable_issues(&self) -> bool {
!self.orphan_data_files.is_empty()
|| !self.orphan_hint_files.is_empty()
|| !self.missing_indexed_files.is_empty()
}
pub fn has_unrecoverable_issues(&self) -> bool {
!self.dangling_root_cids.is_empty()
|| !self.dangling_record_cids.is_empty()
|| self.deserialization_failures > 0
|| self.cursor_ahead_of_eventlog
}
pub fn log_findings(&self) {
if self.is_consistent() {
tracing::info!(
repos = self.repos_checked,
records = self.records_checked,
user_blocks = self.user_blocks_checked,
handles = self.handles_checked,
"consistency check passed"
);
return;
}
if !self.dangling_record_cids.is_empty() {
tracing::warn!(
count = self.dangling_record_cids.len(),
"records reference missing blocks"
);
}
if !self.dangling_root_cids.is_empty() {
tracing::warn!(
count = self.dangling_root_cids.len(),
"repo roots reference missing blocks"
);
}
if !self.orphaned_user_repos.is_empty() {
tracing::warn!(
count = self.orphaned_user_repos.len(),
"repos with user_blocks but no repo_meta"
);
}
if !self.inconsistent_handles.is_empty() {
tracing::warn!(
count = self.inconsistent_handles.len(),
"handle index inconsistencies"
);
}
if !self.orphan_data_files.is_empty() {
tracing::warn!(
count = self.orphan_data_files.len(),
files = ?self.orphan_data_files,
"orphan data files with no index references"
);
}
if !self.orphan_hint_files.is_empty() {
tracing::warn!(
count = self.orphan_hint_files.len(),
files = ?self.orphan_hint_files,
"orphan hint files with no matching data file"
);
}
if !self.missing_indexed_files.is_empty() {
tracing::warn!(
count = self.missing_indexed_files.len(),
files = ?self.missing_indexed_files,
"index references data files that are missing on disk"
);
}
if self.deserialization_failures > 0 {
tracing::error!(
count = self.deserialization_failures,
"metastore values failed to deserialize"
);
}
if let Some(c) = &self.eventlog_contiguity
&& !c.is_contiguous()
{
tracing::warn!(gaps = c.gaps.len(), "eventlog sequence gaps detected");
c.gaps.iter().take(5).for_each(|gap| {
tracing::warn!(
after_segment = %gap.after_segment,
expected = ?gap.expected_seq,
actual = ?gap.actual_seq,
"eventlog gap"
);
});
}
if self.cursor_ahead_of_eventlog {
tracing::error!(
cursor = ?self.metastore_cursor,
eventlog_max = ?self.eventlog_max_seq,
"metastore cursor is ahead of eventlog max sequence"
);
}
}
}
impl fmt::Display for ConsistencyReport {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.is_consistent() {
return write!(
f,
"consistent (repos={}, records={}, user_blocks={}, handles={})",
self.repos_checked,
self.records_checked,
self.user_blocks_checked,
self.handles_checked,
);
}
write!(
f,
"INCONSISTENT: dangling_roots={}, dangling_records={}, orphaned_repos={}, \
inconsistent_handles={}, orphan_files={}, orphan_hints={}, missing_indexed_files={}, \
deserialize_failures={}, eventlog_gaps={}, cursor_ahead={}",
self.dangling_root_cids.len(),
self.dangling_record_cids.len(),
self.orphaned_user_repos.len(),
self.inconsistent_handles.len(),
self.orphan_data_files.len(),
self.orphan_hint_files.len(),
self.missing_indexed_files.len(),
self.deserialization_failures,
self.eventlog_contiguity
.as_ref()
.map_or(0, |c| c.gaps.len()),
self.cursor_ahead_of_eventlog,
)
}
}
#[derive(Debug, Clone, Copy)]
pub struct ConsistencyCheckOptions {
pub check_block_references: bool,
pub check_handles: bool,
pub check_user_blocks: bool,
pub check_eventlog: bool,
pub check_orphan_files: bool,
pub check_missing_indexed_files: bool,
pub check_orphan_hint_files: bool,
}
impl Default for ConsistencyCheckOptions {
fn default() -> Self {
Self {
check_block_references: true,
check_handles: true,
check_user_blocks: true,
check_eventlog: true,
check_orphan_files: true,
check_missing_indexed_files: true,
check_orphan_hint_files: true,
}
}
}
pub fn verify_store_consistency<BS, BC, ES>(
blockstore: &TranquilBlockStore<BS, BC>,
metastore: &Metastore,
eventlog: &EventLog<ES>,
) -> ConsistencyReport
where
BS: StorageIO + Send + Sync + 'static,
BC: Clock,
ES: StorageIO + 'static,
{
verify_store_consistency_with_options(
blockstore,
metastore,
eventlog,
ConsistencyCheckOptions::default(),
)
}
pub fn verify_store_consistency_with_options<BS, BC, ES>(
blockstore: &TranquilBlockStore<BS, BC>,
metastore: &Metastore,
eventlog: &EventLog<ES>,
options: ConsistencyCheckOptions,
) -> ConsistencyReport
where
BS: StorageIO + Send + Sync + 'static,
BC: Clock,
ES: StorageIO + 'static,
{
let mut report = ConsistencyReport::default();
let block_index = blockstore.block_index();
let repo_data = metastore.partition(Partition::RepoData);
let known_user_hashes = if options.check_block_references {
let hashes = check_repo_root_cids(repo_data, block_index, &mut report);
check_record_cids(repo_data, block_index, &mut report);
hashes
} else if options.check_user_blocks {
collect_known_user_hashes(repo_data)
} else {
HashSet::new()
};
if options.check_user_blocks {
check_user_blocks(repo_data, &known_user_hashes, &mut report);
}
if options.check_handles {
check_handle_consistency(repo_data, &mut report);
}
if options.check_eventlog {
check_eventlog_contiguity(eventlog, &mut report);
check_cursor_vs_eventlog(repo_data, eventlog, &mut report);
}
if options.check_orphan_files {
check_orphan_data_files(blockstore, block_index, &mut report);
}
if options.check_missing_indexed_files {
check_missing_indexed_files(blockstore, block_index, &mut report);
}
if options.check_orphan_hint_files {
check_orphan_hint_files(blockstore, &mut report);
}
report
}
fn check_repo_root_cids(
repo_data: &fjall::Keyspace,
block_index: &BlockIndex,
report: &mut ConsistencyReport,
) -> HashSet<UserHash> {
let prefix = KeyBuilder::new().tag(KeyTag::REPO_META).build();
let mut known_user_hashes = HashSet::new();
repo_data.prefix(prefix.as_slice()).for_each(|guard| {
let Ok((key_bytes, value_bytes)) = guard.into_inner() else {
return;
};
report.repos_checked = report.repos_checked.saturating_add(1);
if let Some(h) = extract_user_hash(&key_bytes) {
known_user_hashes.insert(h);
}
let Some(meta) = RepoMetaValue::deserialize(&value_bytes) else {
tracing::warn!(
user_hash = ?extract_user_hash(&key_bytes),
"repo_meta value failed to deserialize"
);
report.deserialization_failures = report.deserialization_failures.saturating_add(1);
return;
};
if meta.repo_root_cid.is_empty() {
return;
}
let Some(cid_fixed) = try_cid_bytes_to_fixed(&meta.repo_root_cid) else {
let Some(user_hash) = extract_user_hash(&key_bytes) else {
return;
};
tracing::warn!(
%user_hash,
cid_len = meta.repo_root_cid.len(),
"repo_meta has non-standard CID length"
);
report.dangling_root_cids.push(DanglingRootCid {
user_hash,
root_cid_bytes: meta.repo_root_cid,
});
return;
};
if !block_index.has(&cid_fixed) {
let Some(user_hash) = extract_user_hash(&key_bytes) else {
return;
};
report.dangling_root_cids.push(DanglingRootCid {
user_hash,
root_cid_bytes: meta.repo_root_cid,
});
}
});
known_user_hashes
}
fn collect_known_user_hashes(repo_data: &fjall::Keyspace) -> HashSet<UserHash> {
let prefix = KeyBuilder::new().tag(KeyTag::REPO_META).build();
let mut hashes = HashSet::new();
repo_data.prefix(prefix.as_slice()).for_each(|guard| {
if let Ok((key_bytes, _)) = guard.into_inner()
&& let Some(h) = extract_user_hash(&key_bytes)
{
hashes.insert(h);
}
});
hashes
}
fn check_record_cids(
repo_data: &fjall::Keyspace,
block_index: &BlockIndex,
report: &mut ConsistencyReport,
) {
let prefix = KeyBuilder::new().tag(KeyTag::RECORDS).build();
repo_data.prefix(prefix.as_slice()).for_each(|guard| {
let Ok((key_bytes, value_bytes)) = guard.into_inner() else {
return;
};
report.records_checked = report.records_checked.saturating_add(1);
let Some(record) = RecordValue::deserialize(&value_bytes) else {
tracing::warn!(
user_hash = ?extract_user_hash(&key_bytes),
"record value failed to deserialize"
);
report.deserialization_failures = report.deserialization_failures.saturating_add(1);
return;
};
let Some(cid_fixed) = try_cid_bytes_to_fixed(&record.record_cid) else {
let (user_hash, collection, rkey) = parse_record_key(&key_bytes);
let Some(user_hash) = user_hash else {
return;
};
tracing::warn!(
%user_hash,
collection,
rkey,
cid_len = record.record_cid.len(),
"record has non-standard CID length"
);
report.dangling_record_cids.push(DanglingCid {
user_hash,
collection,
rkey,
cid_bytes: record.record_cid,
});
return;
};
if !block_index.has(&cid_fixed) {
let (user_hash, collection, rkey) = parse_record_key(&key_bytes);
let Some(user_hash) = user_hash else {
return;
};
report.dangling_record_cids.push(DanglingCid {
user_hash,
collection,
rkey,
cid_bytes: record.record_cid,
});
}
});
}
fn check_user_blocks(
repo_data: &fjall::Keyspace,
known_user_hashes: &HashSet<UserHash>,
report: &mut ConsistencyReport,
) {
let prefix = KeyBuilder::new().tag(KeyTag::USER_BLOCKS).build();
let mut seen_orphan_hashes: HashSet<UserHash> = HashSet::new();
repo_data.prefix(prefix.as_slice()).for_each(|guard| {
let Ok((key_bytes, _)) = guard.into_inner() else {
return;
};
report.user_blocks_checked = report.user_blocks_checked.saturating_add(1);
let Some(user_hash) = extract_user_hash(&key_bytes) else {
return;
};
if !known_user_hashes.contains(&user_hash) && seen_orphan_hashes.insert(user_hash) {
report
.orphaned_user_repos
.push(OrphanedUserRepo { user_hash });
}
});
}
fn check_handle_consistency(repo_data: &fjall::Keyspace, report: &mut ConsistencyReport) {
let prefix = KeyBuilder::new().tag(KeyTag::HANDLES).build();
repo_data.prefix(prefix.as_slice()).for_each(|guard| {
let Ok((key_bytes, value_bytes)) = guard.into_inner() else {
return;
};
report.handles_checked = report.handles_checked.saturating_add(1);
let handle = parse_handle_from_key(&key_bytes);
let Some(mapped_hash) = parse_user_hash_from_value(&value_bytes) else {
return;
};
let meta_key = crate::metastore::repo_meta::repo_meta_key(mapped_hash);
match repo_data.get(meta_key.as_slice()) {
Ok(Some(meta_bytes)) => {
let Some(meta) = RepoMetaValue::deserialize(&meta_bytes) else {
tracing::warn!(
%mapped_hash,
handle,
"repo_meta value failed to deserialize during handle check"
);
report.deserialization_failures =
report.deserialization_failures.saturating_add(1);
return;
};
let meta_handle_lower = meta.handle.to_lowercase();
let handle_lower = handle.to_lowercase();
if meta_handle_lower != handle_lower {
report.inconsistent_handles.push(InconsistentHandle {
handle,
mapped_user_hash: mapped_hash,
problem: HandleProblem::HandleMismatch {
repo_handle: meta.handle,
},
});
}
}
Ok(None) => {
report.inconsistent_handles.push(InconsistentHandle {
handle,
mapped_user_hash: mapped_hash,
problem: HandleProblem::NoRepoMeta,
});
}
Err(e) => {
tracing::warn!(error = %e, handle, "repo_meta lookup failed during handle check");
}
}
});
}
fn check_eventlog_contiguity<S: StorageIO + 'static>(
eventlog: &EventLog<S>,
report: &mut ConsistencyReport,
) {
let reader = eventlog.reader();
if let Err(e) = reader.refresh_segment_ranges() {
tracing::warn!(error = %e, "failed to refresh segment ranges for contiguity check");
return;
}
report.eventlog_contiguity = Some(reader.check_sequence_contiguity());
}
fn check_cursor_vs_eventlog<S: StorageIO + 'static>(
repo_data: &fjall::Keyspace,
eventlog: &EventLog<S>,
report: &mut ConsistencyReport,
) {
let cursor_key = metastore_cursor_key();
let cursor_seq = repo_data
.get(cursor_key.as_slice())
.ok()
.flatten()
.and_then(|bytes| {
let arr: [u8; 8] = bytes.as_ref().try_into().ok()?;
Some(match u64::from_be_bytes(arr) {
0 => EventSequence::BEFORE_ALL,
n => EventSequence::new(n),
})
});
let max_seq = eventlog.max_seq();
report.metastore_cursor = cursor_seq;
report.eventlog_max_seq = (max_seq != EventSequence::BEFORE_ALL).then_some(max_seq);
if let Some(cursor) = cursor_seq
&& max_seq != EventSequence::BEFORE_ALL
&& cursor > max_seq
{
report.cursor_ahead_of_eventlog = true;
}
}
fn check_orphan_data_files<BS: StorageIO + Send + Sync + 'static, BC: Clock>(
blockstore: &TranquilBlockStore<BS, BC>,
block_index: &BlockIndex,
report: &mut ConsistencyReport,
) {
let disk_files = match blockstore.list_data_files() {
Ok(files) => files,
Err(e) => {
tracing::warn!(error = %e, "failed to list data files for orphan check");
return;
}
};
let epoch = blockstore.epoch().current();
let now = blockstore.clock().wall_millis();
let indexed_files = block_index.liveness_by_file(epoch, now, 0);
let indexed_file_ids: HashSet<DataFileId> = indexed_files.keys().copied().collect();
let active_file_id = block_index.read_write_cursor().map(|c| c.file_id);
if active_file_id.is_none() && indexed_file_ids.is_empty() {
return;
}
disk_files.iter().for_each(|&fid| {
let is_active = active_file_id.is_some_and(|active| fid >= active);
if !is_active && !indexed_file_ids.contains(&fid) {
report.orphan_data_files.push(fid);
}
});
}
fn check_missing_indexed_files<BS: StorageIO + Send + Sync + 'static, BC: Clock>(
blockstore: &TranquilBlockStore<BS, BC>,
block_index: &BlockIndex,
report: &mut ConsistencyReport,
) {
let disk_files: HashSet<DataFileId> = match blockstore.list_data_files() {
Ok(files) => files.into_iter().collect(),
Err(e) => {
tracing::warn!(error = %e, "failed to list data files for missing-file check");
return;
}
};
let epoch = blockstore.epoch().current();
let now = blockstore.clock().wall_millis();
let indexed_files = block_index.liveness_by_file(epoch, now, 0);
indexed_files
.iter()
.filter(|(fid, _)| !disk_files.contains(fid))
.for_each(|(fid, _)| report.missing_indexed_files.push(*fid));
}
fn check_orphan_hint_files<BS: StorageIO + Send + Sync + 'static, BC: Clock>(
blockstore: &TranquilBlockStore<BS, BC>,
report: &mut ConsistencyReport,
) {
let data_files: HashSet<DataFileId> = match blockstore.list_data_files() {
Ok(files) => files.into_iter().collect(),
Err(e) => {
tracing::warn!(error = %e, "failed to list data files for orphan-hint check");
return;
}
};
let hint_files = match blockstore.list_hint_files() {
Ok(files) => files,
Err(e) => {
tracing::warn!(error = %e, "failed to list hint files for orphan-hint check");
return;
}
};
hint_files
.iter()
.filter(|fid| !data_files.contains(fid))
.for_each(|fid| report.orphan_hint_files.push(*fid));
}
fn try_cid_bytes_to_fixed(bytes: &[u8]) -> Option<[u8; CID_SIZE]> {
bytes.try_into().ok()
}
fn extract_user_hash(key_bytes: &[u8]) -> Option<UserHash> {
key_bytes
.get(1..9)?
.try_into()
.ok()
.map(|arr| UserHash::from_raw(u64::from_be_bytes(arr)))
}
fn parse_record_key(key_bytes: &[u8]) -> (Option<UserHash>, String, String) {
let user_hash = extract_user_hash(key_bytes);
let mut reader = crate::metastore::encoding::KeyReader::new(key_bytes);
let _ = reader.tag();
let _ = reader.u64();
let collection = reader.string().unwrap_or_default();
let rkey = reader.string().unwrap_or_default();
(user_hash, collection, rkey)
}
fn parse_handle_from_key(key_bytes: &[u8]) -> String {
let mut reader = crate::metastore::encoding::KeyReader::new(key_bytes);
let _ = reader.tag();
reader.string().unwrap_or_default()
}
fn parse_user_hash_from_value(value_bytes: &[u8]) -> Option<UserHash> {
value_bytes
.get(..8)?
.try_into()
.ok()
.map(|arr| UserHash::from_raw(u64::from_be_bytes(arr)))
}
pub fn repair_known_issues(
blockstore: &TranquilBlockStore<RealIO, SystemClock>,
report: &ConsistencyReport,
) -> RepairResult {
let mut result = RepairResult::default();
report.orphan_data_files.iter().for_each(|&file_id| {
let path = blockstore.data_file_path(file_id);
match std::fs::remove_file(&path) {
Ok(()) => {
tracing::info!(%file_id, "removed orphan data file");
result.orphan_files_removed = result.orphan_files_removed.saturating_add(1);
}
Err(e) => {
tracing::warn!(%file_id, error = %e, "failed to remove orphan data file");
result.repair_errors = result.repair_errors.saturating_add(1);
}
}
});
report.orphan_hint_files.iter().for_each(|&file_id| {
let path = blockstore.hint_file_path(file_id);
match std::fs::remove_file(&path) {
Ok(()) => {
tracing::info!(%file_id, "removed orphan hint file");
result.orphan_hints_removed = result.orphan_hints_removed.saturating_add(1);
}
Err(e) => {
tracing::warn!(%file_id, error = %e, "failed to remove orphan hint file");
result.repair_errors = result.repair_errors.saturating_add(1);
}
}
});
report.missing_indexed_files.iter().for_each(|&file_id| {
let purged = blockstore.block_index().purge_by_file_id(file_id);
tracing::info!(
%file_id,
purged,
"purged phantom index entries for missing data file"
);
result.phantom_index_entries_purged =
result.phantom_index_entries_purged.saturating_add(purged);
});
result
}
#[derive(Debug, Default)]
pub struct RepairResult {
pub orphan_files_removed: u64,
pub orphan_hints_removed: u64,
pub phantom_index_entries_purged: u64,
pub repair_errors: u64,
}
impl RepairResult {
pub fn had_errors(&self) -> bool {
self.repair_errors > 0
}
}
pub fn write_clean_shutdown_marker(data_dir: &Path) -> std::io::Result<()> {
let marker_path = data_dir.join(CLEAN_SHUTDOWN_MARKER);
let f = std::fs::File::create(&marker_path)?;
f.sync_all()?;
std::fs::File::open(data_dir)?.sync_all()
}
pub fn remove_clean_shutdown_marker(data_dir: &Path) -> std::io::Result<()> {
let marker_path = data_dir.join(CLEAN_SHUTDOWN_MARKER);
match std::fs::remove_file(&marker_path) {
Ok(()) => std::fs::File::open(data_dir)?.sync_all(),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
pub fn had_clean_shutdown(data_dir: &Path) -> bool {
data_dir.join(CLEAN_SHUTDOWN_MARKER).exists()
}