linkrs-storage 0.1.0

Storage engine with CSR and memory-mapped containers
Documentation
//! Incremental load orchestration with torn-commit recovery.

use super::super::core::EdgeStore;
use super::layout::manifest_path;
use crate::edge::node_group::TableShardManifest;
use linkrs_core::{StorageError, StorageResult};
use std::path::Path;

impl EdgeStore {
    /// Load an incremental checkpoint. Damage detection only: section and
    /// trailing-byte mismatches fail the load. Crash consistency comes from
    /// the commit protocol (group bases, sidecars and per-group shards before
    /// metadata, manifest published last with its tail embedded in
    /// `meta.bin`); a torn manifest file falls back to the embedded tail,
    /// covered by the crash-injection tests below instead of by
    /// the orphan audit. The orphan audit stays as file-damage detection: a
    /// nonzero mismatch means torn or corrupt files, not a recoverable crash
    /// window.
    ///
    /// Rebuild order is fixed: topology groups, then timestamp shards, then
    /// property shards, then the owner map last. The owner map derives from
    /// topology, so it must never be rebuilt before the rows it points at.
    pub(crate) fn load_incremental(&mut self, dir: &Path) -> StorageResult<()> {
        let manifest_file = manifest_path(dir);
        if !manifest_file.exists() {
            return Err(StorageError::io_error(format!(
                "missing group manifest: {}",
                manifest_file.display()
            )));
        }
        let manifest_bytes = std::fs::read(&manifest_file)
            .map_err(|e| StorageError::io_error(format!("Failed to read group manifest: {}", e)))?;
        let file_manifest = TableShardManifest::decode(&manifest_bytes)?;
        if file_manifest.group_bits != self.config.node_group_bits {
            return Err(StorageError::deserialize_error(format!(
                "group layout mismatch: file uses {} address bits, table uses {}; address width locks at creation with no online change branch, use the offline EdgeStore::reshard tool then checkpoint, see EdgeTableConfig::node_group_bits",
                file_manifest.group_bits, self.config.node_group_bits
            )));
        }

        let embedded = self.load_metadata_file(dir, &file_manifest)?;
        let manifest = if embedded != file_manifest {
            if embedded.group_bits != self.config.node_group_bits {
                return Err(StorageError::deserialize_error(format!(
                    "group layout mismatch: file uses {} address bits, table uses {}; address width locks at creation with no online change branch, use the offline EdgeStore::reshard tool then checkpoint, see EdgeTableConfig::node_group_bits",
                    embedded.group_bits, self.config.node_group_bits
                )));
            }
            log::debug!(
                "EdgeTable[{}] torn manifest file falls back to meta tail: file={:?} tail={:?}",
                self.label,
                file_manifest,
                embedded,
            );
            embedded
        } else {
            file_manifest
        };
        // Load replaces the table contents: drain topology rows first so the
        // non-empty guard on group reset only rejects mistaken resets, never
        // an intentional reload. Authority and property rows are rebuilt from
        // the shards below.
        for gid in self.out_csr.existing_group_ids() {
            if let Some(variant) = self.out_csr.group_variant_mut(gid) {
                variant.clear();
            }
        }
        for gid in self.in_csr.existing_group_ids() {
            if let Some(variant) = self.in_csr.group_variant_mut(gid) {
                variant.clear();
            }
        }
        self.out_csr.set_groups(&manifest.out_groups)?;
        self.in_csr.set_groups(&manifest.in_groups)?;
        self.load_group_set(dir, true, &manifest.out_groups, &manifest)?;
        self.load_group_set(dir, false, &manifest.in_groups, &manifest)?;
        self.load_timestamp_shards(dir, &manifest)?;
        self.load_property_shards(dir, &manifest)?;
        self.invalidate_incomplete_owner_groups(dir, &manifest);
        self.load_segment_stats(dir)?;
        self.load_form_profile(dir)?;
        let owner_stats = self.rebuild_owner_map_with_stats();
        if let Some(stats) = &self.stats_manager {
            stats.add_value_with_amount(
                linkrs_metrics::MetricType::EdgeRelocatedOrphans,
                owner_stats.relocated_orphans as u64,
            );
        }
        if owner_stats.relocated_orphans > 0 {
            log::info!(
                "EdgeTable[{}] rebuilt owner map: mapped={}, relocated_orphans={} to group {}",
                self.label_name,
                owner_stats.mapped,
                owner_stats.relocated_orphans,
                owner_stats.fallback_group,
            );
        } else {
            log::debug!(
                "EdgeTable[{}] rebuilt owner map: mapped={}",
                self.label_name,
                owner_stats.mapped,
            );
        }

        if self.next_edge_id.0 == 0 {
            // A zero counter cannot allocate safely: rebuild it from the
            // largest allocated id across both legs so single-leg tables
            // never reuse an id that is still live on the other leg. Empty
            // tables stay zero through the same scan.
            let out_max = self
                .out_csr
                .iter_all()
                .map(|(_, nbr)| nbr.edge_id.0 + 1)
                .max()
                .unwrap_or(0);
            let in_max = self
                .in_csr
                .iter_all()
                .map(|(_, nbr)| nbr.edge_id.0 + 1)
                .max()
                .unwrap_or(0);
            self.next_edge_id = linkrs_core::types::EdgeId(out_max.max(in_max));
        }
        // Damage detection for true corruption: torn manifest files already
        // recovered above via the embedded tail. A nonzero count here means
        // corrupt or regressed files and the load stays fail-closed.
        let (orphan_mappings, orphan_csr_rows, live_orphans) = self.copy_audit();
        if let Some(stats) = &self.stats_manager {
            stats.add_value_with_amount(
                linkrs_metrics::MetricType::EdgeOrphanMappings,
                orphan_mappings as u64,
            );
            stats.add_value_with_amount(
                linkrs_metrics::MetricType::EdgeOrphanRows,
                orphan_csr_rows as u64,
            );
            stats.add_value_with_amount(
                linkrs_metrics::MetricType::EdgeLiveAuthorityOrphans,
                live_orphans as u64,
            );
        }
        if orphan_mappings + orphan_csr_rows + live_orphans > 0 {
            // Storage-side refusal: corrupt or regressed checkpoint files fail
            // the open. This is never an import discard — import drops count
            // accepted/dropped rows per file, while this path refuses the
            // whole load with a storage error.
            log::warn!(
                "EdgeTable[{}] refuses checkpoint load with copy mismatches: \
                 orphan property mappings={}, orphan CSR rows={}, \
                 live authority orphans={}",
                self.label_name,
                orphan_mappings,
                orphan_csr_rows,
                live_orphans,
            );
            return Err(crate::StorageError::db_error(format!(
                "edge table {} loaded with copy mismatches: \
                 orphan property mappings={}, orphan CSR rows={}, \
                 live authority orphans={}",
                self.label_name, orphan_mappings, orphan_csr_rows, live_orphans,
            )));
        }
        self.properties_dirty = false;
        self.property_column_dirt.clear();
        self.out_csr.clear_all_dirty();
        self.in_csr.clear_all_dirty();
        // Reloaded state is a fresh checkpoint base: no switch is pending.
        self.migration_pending_checkpoint = false;
        // Pending staged schema changes are memory-only: a reload after any
        // crash is equivalent to aborting them, because the property store is
        // rebuilt from the published schema below.
        self.pending_add_column = None;
        self.pending_drop_column = None;
        self.pending_rename_column = None;
        self.wal_dir = None;
        let wal_ops = super::super::wal::read_ops(dir)?;
        for op in wal_ops {
            self.replay_one_wal_op(op)?;
        }
        let (orphan_mappings, orphan_csr_rows, live_orphans) = self.copy_audit();
        if orphan_mappings + orphan_csr_rows + live_orphans > 0 {
            // Storage-side refusal after WAL replay, same division as the
            // load audit above: replay damage refuses the open, never counts
            // as an import discard.
            log::warn!(
                "EdgeTable[{}] refuses WAL replay with copy mismatches: \
                 orphan property mappings={}, orphan CSR rows={}, \
                 live authority orphans={}",
                self.label_name,
                orphan_mappings,
                orphan_csr_rows,
                live_orphans,
            );
            return Err(crate::StorageError::db_error(format!(
                "edge table {} replayed WAL with copy mismatches: \
                 orphan property mappings={}, orphan CSR rows={}, \
                 live authority orphans={}",
                self.label_name, orphan_mappings, orphan_csr_rows, live_orphans,
            )));
        }
        self.wal_dir = Some(dir.to_path_buf());
        self.is_open = true;
        Ok(())
    }

    /// Rebuild groups with missing companions as empty without cross-epoch
    /// stitching.
    ///
    /// The manifest is the sole validity bit: a group whose topology base
    /// holds edges but whose timestamp companion is absent saw a crash
    /// between data files of one epoch. New topology with old timestamps or
    /// old properties must never load together, so the group is cleared,
    /// its residuals are removed, and it stays writable as an empty group.
    /// Property companions are required only when the table carries
    /// columnar properties; inline forms and property-less schemas
    /// legitimately have no property files.
    pub(crate) fn invalidate_incomplete_owner_groups(
        &mut self,
        dir: &Path,
        manifest: &TableShardManifest,
    ) {
        use super::layout::{props_group_path, ts_group_path};
        use crate::edge::CsrBase;
        let owners = self.owner_list_for_load(manifest);
        let needs_props = !self.schema.properties.is_empty()
            && !matches!(
                self.schema.record_form,
                crate::edge::RecordForm::Pure | crate::edge::RecordForm::Bundled
            );
        let use_out = self.schema.oe_strategy != crate::edge::EdgeStrategy::None;
        for gid in owners {
            let edge_count = if use_out {
                self.out_csr
                    .group_variant(gid as usize)
                    .map(|v| v.edge_count())
                    .unwrap_or(0)
            } else {
                self.in_csr
                    .group_variant(gid as usize)
                    .map(|v| v.edge_count())
                    .unwrap_or(0)
            };
            if edge_count == 0 {
                continue;
            }
            if !ts_group_path(dir, gid).exists() {
                self.remove_invalid_group_files(dir, gid);
                if use_out {
                    if let Some(v) = self.out_csr.group_variant_mut(gid as usize) {
                        v.clear();
                    }
                    self.out_csr.clear_group_dirty(gid as usize);
                    self.out_csr.clear_group_append_log(gid as usize);
                } else {
                    if let Some(v) = self.in_csr.group_variant_mut(gid as usize) {
                        v.clear();
                    }
                    self.in_csr.clear_group_dirty(gid as usize);
                    self.in_csr.clear_group_append_log(gid as usize);
                }
                continue;
            }
            if needs_props && !props_group_path(dir, gid).exists() {
                self.remove_invalid_group_files(dir, gid);
                if use_out {
                    if let Some(v) = self.out_csr.group_variant_mut(gid as usize) {
                        v.clear();
                    }
                    self.out_csr.clear_group_dirty(gid as usize);
                    self.out_csr.clear_group_append_log(gid as usize);
                } else {
                    if let Some(v) = self.in_csr.group_variant_mut(gid as usize) {
                        v.clear();
                    }
                    self.in_csr.clear_group_dirty(gid as usize);
                    self.in_csr.clear_group_append_log(gid as usize);
                }
            }
        }
    }
}