Resolves every finding in docs/reviews/M0-M10-code-review-2026-06-08-pass2.md across the engine, network, server, and CLI crates: session restore, replication/CRDT, WAL format and recovery, storage indexes, query/ranking executors, cohort/community governance, and scatter-gather routing. Adds regression tests: - review_pass2_creator_search_filter - review_pass2_d_replication - review_pass2_query_for_session - review_pass2_storage_indexes_bitmap_cache - review_pass2_zone_a_sessions Verified: cargo clippy -D warnings and full test suite green across all crates.
304 lines
13 KiB
Rust
304 lines
13 KiB
Rust
//! Schema-aware database open logic.
|
|
|
|
use std::sync::{Arc, atomic::AtomicU64};
|
|
|
|
use roaring::RoaringBitmap;
|
|
|
|
use super::{
|
|
config::StorageMode,
|
|
state_rebuild::{rebuild_entity_state, rebuild_item_indexes},
|
|
storage_box::StorageBox,
|
|
wal_bridge::WalHandleWriter,
|
|
};
|
|
use crate::{
|
|
entities::{
|
|
CreatorItemsBitmap, HardNegIndex, InteractionLedger, PreferenceVectors, UserStateIndex,
|
|
},
|
|
ranking::{builtins::register_builtins, profile::RankingProfile, registry::ProfileRegistry},
|
|
schema::{DurabilityError, EntityId, Schema, TidalError, Timestamp},
|
|
signals::{NoopWalWriter, SignalLedger, SignalTypeId},
|
|
storage::{
|
|
InMemoryBackend,
|
|
indexes::{bitmap::BitmapIndex, range::RangeIndex},
|
|
vector::registry::EmbeddingSlotRegistry,
|
|
},
|
|
wal::{WalConfig, WalHandle},
|
|
};
|
|
|
|
/// Default preference-vector dimensionality used when no schema embedding slot
|
|
/// declares one.
|
|
///
|
|
/// The cold-start path ([`TidalDb::new`], no schema) and the
|
|
/// no-embedding-slot schema path both fall back to this single value so a future
|
|
/// change to the default cannot drift between the two open branches and the
|
|
/// schema-less constructor. Keep it in sync with the typical embedding width the
|
|
/// engine ships against.
|
|
pub const DEFAULT_PREFERENCE_DIM: usize = 128;
|
|
|
|
/// Resolve the preference-vector dimensionality from a schema.
|
|
///
|
|
/// Uses the first declared embedding slot's dimensions, falling back to
|
|
/// [`DEFAULT_PREFERENCE_DIM`] when the schema declares no embedding slot. This is
|
|
/// the single source of truth for the fallback used by every schema-aware open
|
|
/// branch.
|
|
pub fn preference_dim_for(schema: &Schema) -> usize {
|
|
schema
|
|
.embedding_slots()
|
|
.first()
|
|
.map_or(DEFAULT_PREFERENCE_DIM, |s| s.dimensions)
|
|
}
|
|
|
|
/// Bundle returned by [`TidalDb::open_with_schema`] to avoid a fragile tuple.
|
|
///
|
|
/// Each field is consumed by [`TidalDb::from_parts`] during construction.
|
|
pub struct OpenResult {
|
|
pub storage: StorageBox,
|
|
pub ledger: Arc<SignalLedger>,
|
|
pub wal: Option<WalHandle>,
|
|
pub last_seq: Arc<AtomicU64>,
|
|
pub profile_registry: ProfileRegistry,
|
|
pub category_index: BitmapIndex,
|
|
pub format_index: BitmapIndex,
|
|
pub creator_index: BitmapIndex,
|
|
pub tag_index: BitmapIndex,
|
|
pub duration_index: RangeIndex<u32>,
|
|
pub created_at_index: RangeIndex<u64>,
|
|
pub universe: RoaringBitmap,
|
|
pub embedding_registry: EmbeddingSlotRegistry,
|
|
pub schema_def: Schema,
|
|
// M3 entity state rebuilt from durable storage.
|
|
pub creator_items: CreatorItemsBitmap,
|
|
pub user_state: UserStateIndex,
|
|
pub hard_negatives: HardNegIndex,
|
|
pub interaction_ledger: InteractionLedger,
|
|
pub preference_vectors: PreferenceVectors,
|
|
/// Session journal events recovered on startup (for session crash recovery).
|
|
pub session_events: Vec<crate::wal::format::SessionWalEvent>,
|
|
}
|
|
|
|
impl super::TidalDb {
|
|
/// Open storage and ledger components for the given schema.
|
|
///
|
|
/// Called from `TidalDbBuilder::open()` when `with_schema()` was called.
|
|
/// Returns an `OpenResult` containing all components needed by `from_parts`.
|
|
#[allow(clippy::too_many_lines)] // Construction logic is inherently verbose; splitting would scatter initialization flow.
|
|
pub(crate) fn open_with_schema(
|
|
config: &super::Config,
|
|
schema: Schema,
|
|
schema_profiles: Vec<RankingProfile>,
|
|
) -> crate::Result<OpenResult> {
|
|
let last_seq = Arc::new(AtomicU64::new(0));
|
|
|
|
// Initialize profile registry with builtins.
|
|
let mut profile_registry = ProfileRegistry::new();
|
|
register_builtins(&mut profile_registry).map_err(|e| {
|
|
TidalError::internal("open", format!("failed to register builtin profiles: {e}"))
|
|
})?;
|
|
|
|
// Bind the schema so schema-defined profiles are validated against the
|
|
// actual signal vocabulary (builtins are exempt -- already registered,
|
|
// and they intentionally reference a superset of any one schema).
|
|
profile_registry.with_schema(schema.clone());
|
|
|
|
// Register schema-defined profiles, overriding matching builtins.
|
|
for profile in schema_profiles {
|
|
let name = profile.name.clone();
|
|
profile_registry.override_register(profile).map_err(|e| {
|
|
TidalError::internal(
|
|
"open",
|
|
format!("failed to register schema profile '{name}': {e}"),
|
|
)
|
|
})?;
|
|
}
|
|
|
|
// Initialize M2 indexes (empty -- populated as items are written).
|
|
let category_index = BitmapIndex::new("category");
|
|
let format_index = BitmapIndex::new("format");
|
|
let creator_index = BitmapIndex::new("creator");
|
|
let tag_index = BitmapIndex::new("tags");
|
|
let duration_index = RangeIndex::new("duration");
|
|
let created_at_index = RangeIndex::new("created_at");
|
|
let universe = RoaringBitmap::new();
|
|
let embedding_registry = EmbeddingSlotRegistry::new();
|
|
|
|
let schema_def = schema.clone();
|
|
|
|
match config.mode {
|
|
StorageMode::Ephemeral => {
|
|
let storage = StorageBox::Memory {
|
|
items: InMemoryBackend::default(),
|
|
users: InMemoryBackend::default(),
|
|
creators: InMemoryBackend::default(),
|
|
};
|
|
let ledger = Arc::new(SignalLedger::new(schema, Box::new(NoopWalWriter)));
|
|
|
|
// Read preference vector dimensionality from the schema.
|
|
let pref_dim = preference_dim_for(&schema_def);
|
|
|
|
Ok(OpenResult {
|
|
storage,
|
|
ledger,
|
|
wal: None,
|
|
last_seq,
|
|
profile_registry,
|
|
category_index,
|
|
format_index,
|
|
creator_index,
|
|
tag_index,
|
|
duration_index,
|
|
created_at_index,
|
|
universe,
|
|
embedding_registry,
|
|
schema_def,
|
|
creator_items: CreatorItemsBitmap::new(),
|
|
user_state: UserStateIndex::new(),
|
|
hard_negatives: HardNegIndex::new(),
|
|
interaction_ledger: InteractionLedger::new(),
|
|
preference_vectors: PreferenceVectors::new(pref_dim),
|
|
session_events: Vec::new(),
|
|
})
|
|
}
|
|
StorageMode::Persistent => {
|
|
let data_dir = config.data_dir.as_ref().ok_or_else(|| {
|
|
TidalError::internal("open", "persistent mode requires data_dir")
|
|
})?;
|
|
|
|
// Open fjall storage.
|
|
let fjall_storage =
|
|
crate::storage::FjallStorage::open(data_dir).map_err(TidalError::from)?;
|
|
let storage = StorageBox::Fjall(fjall_storage);
|
|
|
|
// Build WAL config. `WalConfig.dir` is the *parent* of the
|
|
// "wal/" subdirectory; the WAL therefore defaults to
|
|
// {data_dir}/wal. When the builder supplied an explicit `wal_dir`
|
|
// override, honor it via `wal_dir_override` so the WAL is created
|
|
// at exactly that path — and so the readers below
|
|
// (`config.resolved_wal_dir()`) point at the same place. The
|
|
// builder resolves `config.wal_dir` to the override-or-default at
|
|
// open time, so this threads one source of truth to the writer.
|
|
let wal_config = WalConfig {
|
|
dir: data_dir.clone(),
|
|
wal_dir_override: config.wal_dir.clone(),
|
|
..WalConfig::default()
|
|
};
|
|
|
|
let (wal, replayed_events, session_events) =
|
|
WalHandle::open(wal_config).map_err(|e| {
|
|
TidalError::Durability(DurabilityError {
|
|
message: format!("WAL open failed: {e}"),
|
|
})
|
|
})?;
|
|
|
|
// Build the WAL bridge for the ledger.
|
|
let wal_writer =
|
|
Box::new(WalHandleWriter::new(wal.sender(), Arc::clone(&last_seq)));
|
|
|
|
// Construct the ledger.
|
|
let ledger = Arc::new(SignalLedger::new(schema, wal_writer));
|
|
|
|
// Restore signal state from the last checkpoint.
|
|
if let Err(e) = ledger.restore(storage.items_engine()) {
|
|
tracing::warn!(
|
|
error = %e,
|
|
"signal ledger restore failed; starting from empty state"
|
|
);
|
|
}
|
|
|
|
// Replay WAL events that post-date the checkpoint.
|
|
for event in replayed_events {
|
|
let type_id = SignalTypeId::new(u16::from(event.signal_type));
|
|
let entity_id = EntityId::new(event.entity_id);
|
|
let weight = f64::from(event.weight);
|
|
let timestamp = Timestamp::from_nanos(event.timestamp_nanos);
|
|
ledger.apply_wal_event(type_id, entity_id, weight, timestamp);
|
|
}
|
|
|
|
// Rebuild in-memory entity state from durable storage.
|
|
// This reconstructs blocked/hidden/follows user state and
|
|
// interaction weights from persisted relationship edges. The
|
|
// creator-items bitmap is populated by `rebuild_item_indexes`
|
|
// below (shared with the write path) so it cannot drift.
|
|
let user_state = UserStateIndex::new();
|
|
let creator_items = CreatorItemsBitmap::new();
|
|
let interaction_ledger = InteractionLedger::new();
|
|
rebuild_entity_state(&storage, &user_state, &interaction_ledger)?;
|
|
|
|
// Restore the signal-driven UserStateIndex maps (completion /
|
|
// saved-timestamp / liked) from their durable Tag::UserState rows
|
|
// so the InProgress filter and DateSaved sort survive a restart.
|
|
if let Err(e) = user_state.restore(storage.items_engine()) {
|
|
tracing::warn!(
|
|
error = %e,
|
|
"user-state restore failed; InProgress/DateSaved start empty"
|
|
);
|
|
}
|
|
|
|
// Restore hard negatives from durable Tag::HardNeg rows so a
|
|
// skip/dislike-rejected item stays excluded after a crash.
|
|
let hard_negatives = HardNegIndex::new();
|
|
if let Err(e) = hard_negatives.restore(storage.items_engine()) {
|
|
tracing::warn!(
|
|
error = %e,
|
|
"hard-negative restore failed; rejected items may resurface"
|
|
);
|
|
}
|
|
|
|
// Rebuild item indexes (universe bitmap + bitmap/range indexes +
|
|
// creator-items) from persisted metadata so queries and /health
|
|
// work immediately. The shared `ItemIndexes::index` is the SAME
|
|
// path the live write uses, so a restart cannot diverge.
|
|
let mut universe = universe;
|
|
rebuild_item_indexes(
|
|
&storage,
|
|
&mut universe,
|
|
&super::state_rebuild::ItemIndexes {
|
|
category: &category_index,
|
|
format: &format_index,
|
|
creator: &creator_index,
|
|
tag: &tag_index,
|
|
duration: &duration_index,
|
|
created_at: &created_at_index,
|
|
creator_items: &creator_items,
|
|
},
|
|
)?;
|
|
|
|
// Read preference vector dimensionality from the schema.
|
|
let pref_dim = preference_dim_for(&schema_def);
|
|
|
|
// Restore preference vectors from their Tag::Preference checkpoint
|
|
// so personalized ranking does not snap to cold-start after a crash.
|
|
let preference_vectors = PreferenceVectors::new(pref_dim);
|
|
if let Err(e) = preference_vectors.restore(storage.items_engine()) {
|
|
tracing::warn!(
|
|
error = %e,
|
|
"preference vector restore failed; starting from cold-start"
|
|
);
|
|
}
|
|
|
|
Ok(OpenResult {
|
|
storage,
|
|
ledger,
|
|
wal: Some(wal),
|
|
last_seq,
|
|
profile_registry,
|
|
category_index,
|
|
format_index,
|
|
creator_index,
|
|
tag_index,
|
|
duration_index,
|
|
created_at_index,
|
|
universe,
|
|
embedding_registry,
|
|
schema_def,
|
|
creator_items,
|
|
user_state,
|
|
hard_negatives,
|
|
interaction_ledger,
|
|
preference_vectors,
|
|
session_events,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|