fix: M0-M10 code-review pass2 remediation — all 91 findings

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.
This commit is contained in:
jx12n 2026-06-09 12:21:00 -06:00
parent 5d211abce0
commit 9728194f16
100 changed files with 9892 additions and 1098 deletions

2
Cargo.lock generated
View File

@ -3570,7 +3570,6 @@ dependencies = [
"prost",
"rcgen",
"rustls",
"rustls-pemfile",
"tempfile",
"thiserror 2.0.18",
"tidaldb",
@ -3630,6 +3629,7 @@ dependencies = [
"proptest",
"rand 0.9.2",
"roaring",
"rustix 1.1.3",
"serde",
"serde_json",
"tantivy",

File diff suppressed because it is too large Load Diff

View File

@ -35,7 +35,6 @@ tonic = { version = "0.12", features = ["tls", "tls-roots"] }
prost = "0.13"
tokio = { version = "1", features = ["rt-multi-thread", "sync", "time"] }
tokio-stream = "0.1"
rustls-pemfile = "2"
# Direct rustls dep with an explicit crypto provider. tonic pulls rustls only
# transitively (no provider feature in tidal-net's own closure), so the
# process-level default CryptoProvider is otherwise absent/ambiguous and the

View File

@ -7,9 +7,11 @@
//! `Ok(())`, and every subsequent `check()` returns [`CircuitOpenError`] until the
//! outstanding probe resolves via [`record_success`](CircuitBreaker::record_success)
//! (→ Closed) or [`record_failure`](CircuitBreaker::record_failure) (→ Open again).
//! This is enforced by the state machine, not merely advertised: the half-open
//! state carries an `in_flight` flag so concurrent callers cannot all probe a peer
//! that just failed.
//! This single-probe invariant is enforced by the state machine itself, not by a
//! separate flag: `check()` returns `Ok(())` only on the `Open → HalfOpen`
//! transition and errors for every call while already in `HalfOpen`, so being in
//! the fieldless `HalfOpen` variant *is* the "a probe is outstanding" marker. No
//! `in_flight` field is needed (or present).
//!
//! Struct-shaped error: `CircuitOpenError` carries no fields and represents the single "breaker is open" outcome; an enum with one variant would be ceremony with no readability gain.

View File

@ -199,3 +199,70 @@ pub fn start_server(
Ok(handle)
}
#[cfg(test)]
#[allow(clippy::unwrap_used)] // test assertions on known-good fixtures
mod tests {
use super::*;
use crate::proto::{ShipSegmentRequest, WalSegmentId};
fn make_request(payload_len: usize) -> ShipSegmentRequest {
ShipSegmentRequest {
id: Some(WalSegmentId {
region_id: 0,
shard_id: 0,
seqno: 1,
}),
payload: vec![0xAB; payload_len],
event_count: 1,
leader_last_seq: 1,
}
}
/// SUGGESTION (tidal-net): pin the server-side payload boundary semantics so a
/// future change to one guard cannot silently desync from the client/codec.
/// The contract (shared with `GrpcTransport::send_segment` in transport.rs and
/// the codec limits in `start_server`) is **inclusive at `max_payload_bytes`**:
/// a payload of exactly `max` is ACCEPTED, and `max + 1` is REJECTED
/// (`len() > max`, not `>=`). This exercises the real `ship_segment` handler at
/// both sides of the boundary.
#[test]
fn ship_segment_payload_boundary_is_inclusive_at_max() {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
runtime.block_on(async {
let max = 1024usize;
let (tx, mut rx) = mpsc::channel(4);
let service = WalShippingService::new(tx, max);
// Exactly max: accepted, and forwarded onto the inbound channel.
let resp = service
.ship_segment(Request::new(make_request(max)))
.await
.expect("a payload of exactly max_payload_bytes must be accepted");
assert!(
resp.into_inner().accepted,
"payload == max must be accepted (inclusive boundary)"
);
let forwarded = rx.try_recv().expect("accepted payload reaches the channel");
assert_eq!(forwarded.bytes.len(), max);
// max + 1: rejected with resource_exhausted, nothing forwarded.
let err = service
.ship_segment(Request::new(make_request(max + 1)))
.await
.expect_err("a payload of max + 1 must be rejected");
assert_eq!(
err.code(),
tonic::Code::ResourceExhausted,
"over-size payload must be rejected as resource_exhausted"
);
assert!(
rx.try_recv().is_err(),
"a rejected payload must not be forwarded onto the inbound channel"
);
});
}
}

View File

@ -128,6 +128,15 @@ impl GrpcTransport {
pub fn new(config: GrpcTransportConfig) -> Result<Self, GrpcTransportError> {
ensure_crypto_provider();
// Validate numeric invariants BEFORE building anything (C15). In
// particular `mpsc::channel(config.channel_capacity)` below panics on a
// zero capacity (tokio asserts buffer > 0), so a `channel_capacity == 0`
// config must be caught here as a typed `Internal` error rather than
// panicking deep in the constructor. `PeerPool::new` validates again
// (idempotent, cheap); doing it up front is what guards the real
// constructor ordering the config doc promises.
config.validate()?;
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
@ -199,19 +208,43 @@ impl GrpcTransport {
self.shutdown.request();
}
/// Whether the embedded gRPC serve loop has terminated.
/// Whether the embedded gRPC serve loop has terminated, for ANY reason.
///
/// The serve task runs for the transport's whole lifetime; a `true` here
/// means the listener stopped accepting — either because the transport is
/// shutting down or because the serve loop failed (in which case the failure
/// was already logged at `error!` inside the task; see [`server::start_server`]).
/// Health checks and the cluster control plane poll this so a follower whose
/// receive side has silently died is observable, not a black hole.
///
/// Because this conflates a clean shutdown with a silent death, prefer
/// [`serve_loop_died`](Self::serve_loop_died) for liveness decisions — it is
/// the one that distinguishes the two (C14).
#[must_use]
pub fn server_terminated(&self) -> bool {
self.server_handle.is_finished()
}
/// Whether the gRPC serve loop has died WITHOUT a shutdown being requested.
///
/// This is the load-bearing liveness signal (C14): on a follower the receive
/// side dies silently when the listener stops, a TLS handshake task panics,
/// or the reactor is torn down. In all those cases the spawned server task
/// ends, its `inbound_tx` drops, and [`recv_segment`](Self::recv_segment)
/// returns `None` — *exactly* as it does on a clean
/// [`shutdown_receivers`](Self::shutdown_receivers). The two are otherwise
/// indistinguishable, so a dead follower would look like an intentionally
/// stopped one.
///
/// `serve_loop_died()` resolves the ambiguity: it is `true` only when the
/// serve task has finished AND no shutdown was requested. Health checks and
/// the cluster control plane poll this so a follower whose receive side has
/// silently died is observable and demoted, not a black hole; a clean
/// shutdown returns `false` here even though [`server_terminated`](Self::server_terminated)
/// is `true`.
#[must_use]
pub fn serve_loop_died(&self) -> bool {
self.server_handle.is_finished() && !self.shutdown.is_requested()
}
/// Assert we are not inside a tokio runtime (`block_on` would panic).
#[cfg(debug_assertions)]
fn assert_not_in_async_context() {
@ -345,3 +378,96 @@ impl GrpcTransportFactory {
Ok(result)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)] // test assertions on known-good fixtures
mod tests {
use std::net::SocketAddr;
use super::*;
/// Bind port 0 to obtain a free, OS-assigned address (tonic cannot bind 0
/// directly, so we resolve a concrete port up front).
fn free_addr() -> SocketAddr {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.local_addr().unwrap()
}
/// C15: `GrpcTransport::new` with `channel_capacity == 0` must return a typed
/// `Internal` error, NOT panic. The panic used to happen at
/// `mpsc::channel(0)` BEFORE `PeerPool::new` ran `validate()`, so the only
/// existing test (which called `PeerPool::new` directly) masked it. This
/// drives the real constructor ordering.
#[test]
#[allow(clippy::significant_drop_tightening)]
fn new_with_zero_channel_capacity_returns_internal_not_panic() {
let config = GrpcTransportConfig {
local_shard: ShardId(0),
listen_addr: free_addr(),
channel_capacity: 0,
insecure: true,
..Default::default()
};
let result = GrpcTransport::new(config);
assert!(
matches!(result, Err(GrpcTransportError::Internal(_))),
"channel_capacity == 0 must be a typed Internal error, not a panic"
);
}
/// C14: a serve loop that dies WITHOUT a shutdown request must be observable
/// via `serve_loop_died()`, distinct from a clean `shutdown_receivers()`.
/// We abort the server task to model a silent serve-loop death and assert the
/// transport reports `serve_loop_died() == true` while a transport that was
/// cleanly shut down reports `false` (even though `server_terminated()` is
/// `true` for both).
#[test]
#[allow(clippy::significant_drop_tightening)]
fn serve_loop_died_distinguishes_silent_death_from_clean_shutdown() {
// --- silent death: abort the serve task, no shutdown requested ---
let dead = GrpcTransport::new(GrpcTransportConfig {
local_shard: ShardId(0),
listen_addr: free_addr(),
insecure: true,
..Default::default()
})
.unwrap();
// Kill the serve loop the way a listener-death / reactor-teardown would.
dead.server_handle.abort();
// Wait for the abort to take effect.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while !dead.server_terminated() && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert!(
dead.server_terminated(),
"the aborted serve task must report server_terminated()"
);
assert!(
dead.serve_loop_died(),
"a serve loop that died WITHOUT a shutdown request must report \
serve_loop_died() == true (C14)"
);
// --- clean shutdown: request shutdown, then the task ends ---
let clean = GrpcTransport::new(GrpcTransportConfig {
local_shard: ShardId(1),
listen_addr: free_addr(),
insecure: true,
..Default::default()
})
.unwrap();
clean.shutdown_receivers();
clean.server_handle.abort();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while !clean.server_terminated() && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert!(clean.server_terminated());
assert!(
!clean.serve_loop_died(),
"a serve loop that ended AFTER shutdown was requested is a clean \
shutdown, not a silent death (C14)"
);
}
}

View File

@ -58,6 +58,8 @@ use tidaldb::{
schema::EntityId,
testing::{ClusterConfig, SimulatedCluster},
};
use tower::{ServiceBuilder, limit::ConcurrencyLimitLayer};
use tower_http::timeout::TimeoutLayer;
use crate::{
dto::{
@ -575,6 +577,17 @@ impl Drop for ClusterState {
///
/// Exposes the same data routes as standalone (items, embeddings, signals,
/// feed, search) plus cluster management endpoints.
///
/// Protected routes carry the same load-shedding middleware as the standalone
/// router, consuming the shared [`crate::router::REQUEST_TIMEOUT_SECS`] /
/// [`crate::router::MAX_CONCURRENCY`] / [`crate::router::BODY_LIMIT_BYTES`]
/// constants:
/// - [`TimeoutLayer`] — 408 for requests exceeding the shared timeout;
/// - [`ConcurrencyLimitLayer`] — queues beyond the shared in-flight cap;
/// - body limit — 413 for oversized bodies before any deserialization.
///
/// Health/status probes (`public`) are intentionally outside this stack so they
/// are never queued or timed out under saturation.
pub fn build_cluster_router(state: Arc<ClusterState>, api_key: Option<Arc<str>>) -> Router {
let public = Router::new()
.route("/health", get(cluster_health))
@ -600,7 +613,11 @@ pub fn build_cluster_router(state: Arc<ClusterState>, api_key: Option<Arc<str>>)
.route("/sharded/signals", post(sharded_write_signal))
.route("/sharded/feed", get(sharded_feed))
.route("/sharded/search", get(sharded_search))
.layer(axum::extract::DefaultBodyLimit::max(2 * 1024 * 1024))
// Shared with the standalone router so the body cap can never drift
// (raise one, forget the other). See [`crate::router::BODY_LIMIT_BYTES`].
.layer(axum::extract::DefaultBodyLimit::max(
crate::router::BODY_LIMIT_BYTES,
))
.with_state(state);
let protected = match api_key {
@ -611,6 +628,24 @@ pub fn build_cluster_router(state: Arc<ClusterState>, api_key: Option<Arc<str>>)
None => protected,
};
// Mirror the standalone router's load-shedding stack on the cluster
// protected routes: a request-timeout (408) and a hard in-flight cap (429)
// using the SAME constants as standalone so the two surfaces can never
// advertise different overload behavior. Without this the cluster surface
// had no in-flight ceiling and no timeout — under a query storm it would
// spawn scatter-gather workers without bound and never shed a wedged
// request, instead of degrading cleanly like the standalone node. Health
// probes (the `public` routes) are deliberately left outside this stack so
// liveness/readiness are never queued or timed out under saturation.
let protected = protected.layer(
ServiceBuilder::new()
.layer(TimeoutLayer::with_status_code(
StatusCode::REQUEST_TIMEOUT,
Duration::from_secs(crate::router::REQUEST_TIMEOUT_SECS),
))
.layer(ConcurrencyLimitLayer::new(crate::router::MAX_CONCURRENCY)),
);
public.merge(protected)
}
@ -779,6 +814,14 @@ async fn write_embedding(
Ok(StatusCode::NO_CONTENT)
}
/// Record a signal on the leader region and eagerly ship it to followers.
///
/// Returns `204 No Content` once the signal is **durably applied on the leader**
/// (storage + WAL fsync). The follower ship is BEST-EFFORT: a ship that fails is
/// logged (engine side) and queued for re-delivery via `await_convergence` /
/// `heal_region`, but the 204 does NOT assert quorum or follower acknowledgement
/// — it asserts leader durability only. A future quorum-ack contract is tracked
/// with the broader multi-process cluster work (m8p10).
async fn write_signal(
State(state): State<Arc<ClusterState>>,
Json(req): Json<SignalRequest>,
@ -894,13 +937,18 @@ async fn sharded_create_item(
Json(req): Json<ItemRequest>,
) -> std::result::Result<StatusCode, ClusterAppError> {
let shards = state.shard_ids().map_err(ClusterAppError)?;
crate::scatter_gather::sharded_write_item(
state.cluster().map_err(ClusterAppError)?,
EntityId::new(req.entity_id),
&req.metadata,
&shards,
)
.map_err(ClusterAppError)?;
// The single-shard write does a blocking storage + WAL-fsync `TidalDb` call;
// running it inline would pin this reactor worker for the whole write (the
// exact hazard `offload` exists to prevent). It touches only the local store
// (no gRPC ship), so `spawn_blocking` is sufficient — same treatment the
// non-sharded cluster reads/writes already get. See [`crate::offload`].
let cluster = state.cluster_arc().map_err(ClusterAppError)?;
let entity = EntityId::new(req.entity_id);
let metadata = req.metadata;
offload_cluster_read(move || {
crate::scatter_gather::sharded_write_item(&cluster, entity, &metadata, &shards)
})
.await?;
Ok(StatusCode::CREATED)
}
@ -909,13 +957,15 @@ async fn sharded_write_embedding(
Json(req): Json<EmbeddingRequest>,
) -> std::result::Result<StatusCode, ClusterAppError> {
let shards = state.shard_ids().map_err(ClusterAppError)?;
crate::scatter_gather::sharded_write_embedding(
state.cluster().map_err(ClusterAppError)?,
EntityId::new(req.entity_id),
&req.values,
&shards,
)
.map_err(ClusterAppError)?;
// Offload the blocking single-shard embedding write off the reactor (see
// [`sharded_create_item`]).
let cluster = state.cluster_arc().map_err(ClusterAppError)?;
let entity = EntityId::new(req.entity_id);
let values = req.values;
offload_cluster_read(move || {
crate::scatter_gather::sharded_write_embedding(&cluster, entity, &values, &shards)
})
.await?;
Ok(StatusCode::NO_CONTENT)
}
@ -924,14 +974,16 @@ async fn sharded_write_signal(
Json(req): Json<SignalRequest>,
) -> std::result::Result<StatusCode, ClusterAppError> {
let shards = state.shard_ids().map_err(ClusterAppError)?;
crate::scatter_gather::sharded_write_signal(
state.cluster().map_err(ClusterAppError)?,
&req.signal,
EntityId::new(req.entity_id),
req.weight,
&shards,
)
.map_err(ClusterAppError)?;
// Offload the blocking single-shard signal write off the reactor (see
// [`sharded_create_item`]).
let cluster = state.cluster_arc().map_err(ClusterAppError)?;
let signal = req.signal;
let entity = EntityId::new(req.entity_id);
let weight = req.weight;
offload_cluster_read(move || {
crate::scatter_gather::sharded_write_signal(&cluster, &signal, entity, weight, &shards)
})
.await?;
Ok(StatusCode::NO_CONTENT)
}
@ -964,6 +1016,21 @@ struct ScatterGatherInfo {
shard_deadline_ms: u64,
}
impl From<crate::scatter_gather::ScatterGatherMeta> for ScatterGatherInfo {
/// Project the engine's scatter-gather execution metadata into the HTTP
/// response shape. Single source for the five-field mapping so the
/// `/sharded/feed` and `/sharded/search` handlers cannot drift.
fn from(meta: crate::scatter_gather::ScatterGatherMeta) -> Self {
Self {
degraded: meta.degraded,
unavailable_shards: meta.unavailable_shards,
shards_queried: meta.shards_queried,
elapsed_ms: meta.elapsed_ms,
shard_deadline_ms: meta.shard_deadline_ms,
}
}
}
async fn sharded_feed(
State(state): State<Arc<ClusterState>>,
Query(query): Query<ShardedFeedQuery>,
@ -1002,13 +1069,7 @@ async fn sharded_feed(
Ok(Json(ShardedFeedResponse {
items: feed_items(&result.items),
total_candidates: result.total_candidates,
scatter_gather: ScatterGatherInfo {
degraded: meta.degraded,
unavailable_shards: meta.unavailable_shards,
shards_queried: meta.shards_queried,
elapsed_ms: meta.elapsed_ms,
shard_deadline_ms: meta.shard_deadline_ms,
},
scatter_gather: meta.into(),
}))
}
@ -1066,13 +1127,7 @@ async fn sharded_search(
Ok(Json(ShardedSearchResponse {
items: search_items(&result.items),
total_candidates: result.total_candidates,
scatter_gather: ScatterGatherInfo {
degraded: meta.degraded,
unavailable_shards: meta.unavailable_shards,
shards_queried: meta.shards_queried,
elapsed_ms: meta.elapsed_ms,
shard_deadline_ms: meta.shard_deadline_ms,
},
scatter_gather: meta.into(),
}))
}

View File

@ -38,15 +38,24 @@ use crate::{
/// Maximum request body size. Requests exceeding this are rejected with 413
/// before any deserialization occurs.
const BODY_LIMIT_BYTES: usize = 2 * 1024 * 1024;
///
/// Shared with the cluster router ([`crate::cluster::build_cluster_router`]) so
/// the standalone and cluster surfaces can never drift on the body cap (raising
/// one and forgetting the other).
pub(crate) const BODY_LIMIT_BYTES: usize = 2 * 1024 * 1024;
/// Maximum wall-clock time a single request may occupy. Exceeded requests
/// receive 408 Request Timeout.
const REQUEST_TIMEOUT_SECS: u64 = 30;
///
/// Shared with the cluster router so both surfaces time out identically.
pub(crate) const REQUEST_TIMEOUT_SECS: u64 = 30;
/// Maximum number of requests processed concurrently. Additional requests
/// are queued by the concurrency layer until a slot opens.
const MAX_CONCURRENCY: usize = 100;
///
/// Shared with the cluster router so both surfaces cap in-flight load
/// identically.
pub(crate) const MAX_CONCURRENCY: usize = 100;
/// Sequential request ID generator — assigns monotonically increasing IDs.
#[derive(Clone, Default)]

View File

@ -22,7 +22,7 @@
use std::{
collections::{HashMap, HashSet},
sync::{Arc, mpsc},
sync::{Arc, Condvar, Mutex, OnceLock, mpsc},
time::{Duration, Instant},
};
@ -76,6 +76,123 @@ fn clamp_deadline_ms(requested: Option<u64>) -> u64 {
}
}
// ── Global scatter-gather worker bound ───────────────────────────────────────
/// Floor on the total concurrent shard-worker threads the process may run.
/// Even on a single-core host the fan-out gets meaningful parallelism.
const MIN_SHARD_WORKERS: usize = 8;
/// Multiplier applied to available parallelism to size the cap. Shard workers
/// are query/IO-bound (a blocking `TidalDb` read), not purely CPU-bound, so a
/// modest oversubscription keeps cores busy without unbounded growth.
const SHARD_WORKERS_PER_CORE: usize = 8;
/// Hard ceiling on the total concurrent shard-worker threads, independent of
/// core count, so a many-core host still cannot spawn an unbounded thread set
/// under a query storm.
const MAX_SHARD_WORKERS: usize = 256;
/// A process-wide counting semaphore bounding the number of scatter-gather
/// shard-worker threads that may execute a blocking shard query at once.
///
/// The router-level [`ConcurrencyLimitLayer`](tower::limit::ConcurrencyLimitLayer)
/// caps in-flight HTTP requests, but each sharded request still fans out one
/// detached thread per live shard. Without a fan-out cap, `requests × shards`
/// detached OS threads can pile up under a burst. This semaphore caps the
/// AGGREGATE concurrent shard workers regardless of how many requests fan out,
/// so the node sheds load cleanly instead of exhausting OS threads.
///
/// A worker that cannot acquire a permit before the request deadline exits
/// WITHOUT running its query; the coordinator then reports that shard as
/// degraded (never silently dropped). That is the correct behavior under
/// overload: the shard genuinely could not be serviced within budget, and the
/// would-be worker thread retires immediately rather than parking indefinitely.
struct ShardWorkerSemaphore {
/// Available permits. Guarded by the mutex; waiters block on the condvar.
permits: Mutex<usize>,
available: Condvar,
}
impl ShardWorkerSemaphore {
fn new(permits: usize) -> Self {
Self {
permits: Mutex::new(permits.max(1)),
available: Condvar::new(),
}
}
/// Acquire one permit, waiting at most `timeout`. Returns a guard that
/// releases the permit on drop, or `None` if no permit became available in
/// time (the caller should degrade rather than block further).
fn acquire_timeout(&self, timeout: Duration) -> Option<ShardWorkerPermit<'_>> {
let deadline = Instant::now() + timeout;
// Poison is benign here: the only critical section is the integer
// permit count and a notify; a panic mid-update cannot leave a torn
// value, so recover the guard and continue (house pattern).
let mut permits = self
.permits
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
loop {
if *permits > 0 {
*permits -= 1;
return Some(ShardWorkerPermit { sem: self });
}
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return None;
}
let (next, timed_out) = self
.available
.wait_timeout(permits, remaining)
.unwrap_or_else(std::sync::PoisonError::into_inner);
permits = next;
if timed_out.timed_out() && *permits == 0 {
return None;
}
}
}
fn release(&self) {
{
let mut permits = self
.permits
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*permits += 1;
}
// One released permit wakes at most one waiter. Notify after dropping the
// lock so the woken waiter does not immediately re-block on a held guard.
self.available.notify_one();
}
}
/// RAII permit: releases its slot back to the semaphore on drop, even if the
/// shard query panics.
struct ShardWorkerPermit<'a> {
sem: &'a ShardWorkerSemaphore,
}
impl Drop for ShardWorkerPermit<'_> {
fn drop(&mut self) {
self.sem.release();
}
}
/// The process-global shard-worker semaphore, sized once from available
/// parallelism on first use.
static SHARD_WORKER_SEMAPHORE: OnceLock<ShardWorkerSemaphore> = OnceLock::new();
/// Resolve the global shard-worker semaphore, initializing it on first use.
fn shard_worker_semaphore() -> &'static ShardWorkerSemaphore {
SHARD_WORKER_SEMAPHORE.get_or_init(|| {
let cores = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get);
let permits = cores
.saturating_mul(SHARD_WORKERS_PER_CORE)
.clamp(MIN_SHARD_WORKERS, MAX_SHARD_WORKERS);
tracing::info!(permits, "scatter-gather shard-worker cap initialized");
ShardWorkerSemaphore::new(permits)
})
}
/// Metadata about scatter-gather query execution.
#[derive(Debug, Clone)]
pub struct ScatterGatherMeta {
@ -256,6 +373,18 @@ struct GatherState<T> {
/// receiver that has already been dropped; the send fails harmlessly and the
/// worker exits. Because every worker only *reads* the shared cluster, leaking
/// a still-running worker past the request is sound.
///
/// # Fan-out cap
///
/// Each worker must acquire a permit from the process-global
/// [`shard_worker_semaphore`] before running its blocking query, so the
/// AGGREGATE number of concurrently-executing shard workers is bounded
/// regardless of how many sharded requests fan out at once. A worker that
/// cannot get a permit within the remaining budget retires immediately and is
/// reported degraded — never silently dropped, and never left parked
/// indefinitely. Together with the router's request-concurrency limit this caps
/// total detached threads under a query storm instead of growing them without
/// bound.
fn dispatch_shards<T, F>(
cluster: &Arc<SimulatedCluster>,
shards: &[RegionId],
@ -306,6 +435,7 @@ where
// is marked degraded immediately and excluded from the wait set so we never
// burn the whole deadline waiting on a result that can never arrive.
let mut dispatched: Vec<RegionId> = Vec::with_capacity(live_shards.len());
let sem = shard_worker_semaphore();
for &shard in &live_shards {
let tx = tx.clone();
let cluster = Arc::clone(cluster);
@ -313,7 +443,24 @@ where
let spawned = std::thread::Builder::new()
.name(format!("scatter-shard-{}", shard.0))
.spawn(move || {
let outcome = query_one(&cluster, shard);
// Bound the AGGREGATE concurrent shard workers process-wide: a
// worker that cannot get a permit before the budget expires
// retires immediately and reports degraded, rather than parking
// a thread or running an out-of-budget query. The permit is held
// for exactly the blocking query and released on drop (even on
// panic). See [`shard_worker_semaphore`].
let remaining = deadline.saturating_sub(start.elapsed());
// `_permit` (RAII) is held for exactly the blocking query and
// released when the closure returns; `None` means the cap was hit
// before the deadline, so this worker retires as degraded.
let outcome = sem.acquire_timeout(remaining).map_or_else(
|| {
Err(ServerError::Unavailable(
"scatter-gather worker cap reached before deadline".into(),
))
},
|_permit| query_one(&cluster, shard),
);
// The receiver may already have moved on after the deadline; a
// closed channel is expected and benign, so the error is dropped.
let _ = tx.send((shard, outcome));
@ -1674,4 +1821,78 @@ mod tests {
"leader-only lookup should under-enforce (kept {kept} > cap 3)"
);
}
/// C16: the shard-worker semaphore must cap the AGGREGATE concurrent shard
/// workers. With 2 permits, no more than 2 workers may hold a permit at
/// once even when 8 contend, and every worker eventually completes (no
/// deadlock, no lost permit on release).
#[test]
fn shard_worker_semaphore_caps_concurrency() {
use std::sync::atomic::{AtomicUsize, Ordering};
let sem = Arc::new(ShardWorkerSemaphore::new(2));
let live = Arc::new(AtomicUsize::new(0));
let peak = Arc::new(AtomicUsize::new(0));
let completed = Arc::new(AtomicUsize::new(0));
let handles: Vec<_> = (0..8)
.map(|_| {
let sem = Arc::clone(&sem);
let live = Arc::clone(&live);
let peak = Arc::clone(&peak);
let completed = Arc::clone(&completed);
std::thread::spawn(move || {
// Generous timeout: every worker should get a permit
// eventually (the holders release quickly).
let permit = sem
.acquire_timeout(Duration::from_secs(5))
.expect("permit must become available within timeout");
let now = live.fetch_add(1, Ordering::SeqCst) + 1;
peak.fetch_max(now, Ordering::SeqCst);
// Hold the permit briefly so contention is real.
std::thread::sleep(Duration::from_millis(5));
live.fetch_sub(1, Ordering::SeqCst);
completed.fetch_add(1, Ordering::SeqCst);
drop(permit);
})
})
.collect();
for h in handles {
h.join().expect("worker thread must not panic");
}
assert!(
peak.load(Ordering::SeqCst) <= 2,
"no more than 2 workers may hold a permit at once, saw {}",
peak.load(Ordering::SeqCst)
);
assert_eq!(
completed.load(Ordering::SeqCst),
8,
"every worker must complete (no deadlock, permits all returned)"
);
// All permits returned: a fresh acquire succeeds immediately.
assert!(
sem.acquire_timeout(Duration::from_millis(1)).is_some(),
"all permits should be back after every worker finished"
);
}
/// C16: a worker that cannot get a permit before its deadline retires
/// instead of parking forever. With zero spare permits and a tiny timeout,
/// `acquire_timeout` returns `None` so the caller degrades the shard.
#[test]
fn shard_worker_semaphore_times_out_when_saturated() {
let sem = ShardWorkerSemaphore::new(1);
let _held = sem
.acquire_timeout(Duration::from_millis(1))
.expect("first acquire takes the only permit");
// No permit left; a short-deadline acquire must give up (degrade),
// not block indefinitely.
let denied = sem.acquire_timeout(Duration::from_millis(10));
assert!(
denied.is_none(),
"saturated semaphore must time out so the worker retires and degrades"
);
}
}

View File

@ -1,7 +1,27 @@
//! Tier-3 multi-process cluster E2E tests.
//! Tier-3 single-process-cluster smoke tests over the real HTTP binary.
//!
//! Spawns real `tidal-server cluster` OS processes, connects via HTTP,
//! and verifies distributed behavior across process boundaries.
//! # What these tests DO verify
//!
//! Each test spawns real `tidal-server cluster` OS processes, connects over
//! HTTP, and exercises the full binary boot path: config/topology/schema
//! parsing, the experimental-cluster opt-in gate, router wiring, the gRPC
//! transport bring-up, and the data/cluster-management routes end-to-end
//! against a real process (not an in-test `ClusterState`).
//!
//! # What these tests do NOT verify (KNOWN GAP — ROADMAP.md G1 / m8p10)
//!
//! Cluster mode currently runs **every region inside a single process**: each
//! spawned `tidal-server cluster` process builds its own `SimulatedCluster`
//! holding ALL regions, and replication between regions traverses gRPC on the
//! loopback interface *within that one process*. These tests therefore only
//! ever drive `nodes[0]`; a write to `nodes[0]` is replicated by node 0's OWN
//! in-process fabric and never crosses into `nodes[1]`'s process. So they do
//! **not** test process isolation, cross-node HTTP routing, or a real network
//! partition between independent processes — the things true multi-process HA
//! needs. Those require each process to own exactly ONE region and peer over
//! gRPC to real sibling addresses (m8p10). Until then this is honestly a
//! single-process smoke suite, named accordingly, not a multi-process E2E
//! proof.
//!
//! Gated behind `--features cluster-e2e` (disabled by default; these tests
//! are slow and require a built binary).
@ -67,10 +87,15 @@ struct ClusterHarness {
}
impl ClusterHarness {
/// Spawn a multi-region cluster with `n` nodes.
/// Spawn `n` `tidal-server cluster` processes, each a FULL in-process
/// cluster.
///
/// Allocates dynamic ports, generates topology + schema YAML, spawns
/// `tidal-server cluster` processes, and waits for all to become healthy.
/// Allocates dynamic HTTP ports, generates topology + schema YAML, spawns
/// the processes, and waits for all to become healthy. Note (see module
/// docs): every spawned process internally holds ALL `n` regions, so the
/// tests only meaningfully exercise `nodes[0]`. The extra processes prove
/// the binary boots independently on its own port, not cross-process
/// replication.
fn start(n: usize) -> Self {
assert!(n >= 2, "need at least 2 nodes for a cluster");
@ -290,9 +315,15 @@ fn wait_for_health(addr: &SocketAddr, deadline: Instant) {
// ── E2E Tests ─────────────────────────────────────────────────────────────
/// Smoke test: start 3-node cluster, write data, verify convergence.
/// Single-process-cluster smoke test: boot a real `tidal-server cluster`
/// process, write data over HTTP, and verify it converges within its OWN
/// in-process replication fabric.
///
/// This is NOT a cross-process convergence proof: node 0 holds all regions
/// internally, so `wait_converged` polls node 0's own `/cluster/status`. See
/// the module docs for the multi-process gap (ROADMAP.md G1 / m8p10).
#[test]
fn cluster_smoke_test() {
fn single_process_cluster_smoke() {
let harness = ClusterHarness::start(3);
// 1. Health check.
@ -372,9 +403,14 @@ fn cluster_smoke_test() {
);
}
/// Promote a follower and verify writes route to new leader.
/// Promote a follower within a single process's in-process cluster and verify
/// subsequent writes route to the new leader.
///
/// Like [`single_process_cluster_smoke`], this exercises one process's own
/// fabric (node 0 holds all regions); it does not promote a leader across
/// independent processes. See the module docs (ROADMAP.md G1 / m8p10).
#[test]
fn cluster_promote_leader() {
fn single_process_cluster_promote_leader() {
let harness = ClusterHarness::start(3);
// Write some initial data.

View File

@ -29,6 +29,16 @@ tempfile = { version = "3", optional = true }
tracing = "0.1"
usearch = "2.24.0"
# macOS-only: a plain fsync(2) on Apple platforms does NOT flush the storage
# device's volatile write cache (Apple documents this explicitly); true
# crash durability requires fcntl(fd, F_FULLFSYNC). We use rustix's *safe*
# `fcntl_fullfsync` wrapper rather than a raw `libc::fcntl` call so the WAL's
# macOS durability path stays compatible with this crate's `unsafe_code =
# "forbid"` posture. rustix is already in the dependency tree (via fjall), so
# this adds no new compile-time cost on Linux (where it is not pulled in).
[target.'cfg(target_os = "macos")'.dependencies]
rustix = { version = "1", features = ["fs"] }
[dev-dependencies]
actix-web = "4"
axum = "0.8"
@ -170,6 +180,10 @@ required-features = ["test-utils"]
name = "m0m10_durability"
required-features = ["test-utils"]
[[test]]
name = "review_pass2_zone_a_sessions"
required-features = ["test-utils"]
[[bench]]
name = "signals"
harness = false

View File

@ -9,7 +9,7 @@
use std::{
collections::HashMap,
num::NonZeroUsize,
sync::{Arc, Mutex},
sync::{Arc, Mutex, PoisonError},
};
use lru::LruCache;
@ -65,16 +65,15 @@ impl CohortResolver {
/// recency). Otherwise evaluates all predicates against the user's metadata,
/// caches the result, and returns it.
///
/// # Panics
///
/// Panics only if the internal cache mutex is poisoned (a prior panic while
/// holding the lock), which cannot happen on these short, panic-free
/// critical sections.
/// Never panics on a poisoned cache mutex: the cache is a pure accelerator
/// with short, panic-free critical sections, so on poison we recover the
/// inner guard via [`PoisonError::into_inner`] and continue — a prior panic
/// elsewhere degrades to a recompute, never a write-path-wide outage.
#[must_use]
pub fn resolve(&self, user_id: u64, metadata: &HashMap<String, String>) -> Vec<CohortName> {
// Fast path: return cached result, bumping its LRU recency.
{
let mut cache = self.cache.lock().expect("cohort resolver cache poisoned");
let mut cache = self.cache.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(cached) = cache.get(&user_id) {
return cached.clone();
}
@ -92,7 +91,7 @@ impl CohortResolver {
// Insert under the lock; a concurrent resolve for the same user is
// harmless (both compute the same membership for the same metadata).
let mut cache = self.cache.lock().expect("cohort resolver cache poisoned");
let mut cache = self.cache.lock().unwrap_or_else(PoisonError::into_inner);
cache.put(user_id, memberships.clone());
memberships
}
@ -102,11 +101,10 @@ impl CohortResolver {
/// Call this when user metadata is updated so the next `resolve()` call
/// re-evaluates all predicates.
///
/// # Panics
///
/// Panics only if the internal cache mutex is poisoned.
/// Never panics on a poisoned cache mutex: it recovers the inner guard via
/// [`PoisonError::into_inner`] (the cache is a pure accelerator).
pub fn invalidate(&self, user_id: u64) {
let mut cache = self.cache.lock().expect("cohort resolver cache poisoned");
let mut cache = self.cache.lock().unwrap_or_else(PoisonError::into_inner);
cache.pop(&user_id);
}
@ -125,24 +123,22 @@ impl CohortResolver {
/// drops attribution for the new cohort until each user is LRU-evicted or
/// their metadata is rewritten — a no-error, no-log wrong answer.
///
/// # Panics
///
/// Panics only if the internal cache mutex is poisoned.
/// Never panics on a poisoned cache mutex: it recovers the inner guard via
/// [`PoisonError::into_inner`] (the cache is a pure accelerator).
pub fn invalidate_all(&self) {
let mut cache = self.cache.lock().expect("cohort resolver cache poisoned");
let mut cache = self.cache.lock().unwrap_or_else(PoisonError::into_inner);
cache.clear();
}
/// Number of cached user resolutions (for diagnostics).
///
/// # Panics
///
/// Panics only if the internal cache mutex is poisoned.
/// Never panics on a poisoned cache mutex: it recovers the inner guard via
/// [`PoisonError::into_inner`].
#[must_use]
pub fn cache_len(&self) -> usize {
self.cache
.lock()
.expect("cohort resolver cache poisoned")
.unwrap_or_else(PoisonError::into_inner)
.len()
}
@ -151,7 +147,7 @@ impl CohortResolver {
pub fn cache_capacity(&self) -> usize {
self.cache
.lock()
.expect("cohort resolver cache poisoned")
.unwrap_or_else(PoisonError::into_inner)
.cap()
.get()
}
@ -441,4 +437,82 @@ mod tests {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<CohortResolver>();
}
#[test]
fn poisoned_cache_recovers_instead_of_panicking() {
// A panic while holding the cache lock poisons the Mutex. The resolver
// must recover the inner guard (PoisonError::into_inner) rather than
// cascade the panic into every subsequent resolve/invalidate, since the
// cache is a pure accelerator with nothing to protect on poison.
let reg = make_registry();
let resolver = Arc::new(CohortResolver::new(reg));
let r2 = Arc::clone(&resolver);
// Poison the lock by panicking while holding it on another thread.
let poison = std::thread::spawn(move || {
let _guard = r2.cache.lock().unwrap();
panic!("intentional poison");
});
assert!(poison.join().is_err(), "poison thread should have panicked");
// Every cache-touching method must still work after poison.
let memberships = resolver.resolve(1, &tech_user_metadata());
assert!(memberships.iter().any(|n| n.to_string() == "tech_en"));
let _ = resolver.cache_len();
let _ = resolver.cache_capacity();
resolver.invalidate(1);
resolver.invalidate_all();
// A subsequent resolve recomputes cleanly after recovery.
let again = resolver.resolve(2, &sports_user_metadata());
assert!(again.iter().any(|n| n.to_string() == "sports"));
}
#[test]
fn concurrent_invalidate_all_during_slow_path_insert_is_eventually_consistent() {
// The slow path evaluates predicates WITHOUT the lock, then inserts
// under the lock. An invalidate_all() racing between the evaluation and
// the insert can leave a freshly-computed (now-stale-vs-registry) entry
// in the cache. The cache is a pure accelerator, so the contract is
// eventual consistency: a fresh resolve always recomputes the correct
// membership for the current metadata, never panics, and the cache
// never grows beyond its bound. This exercises that window under
// contention and asserts the result stays correct and the structure
// sound.
let reg = make_registry();
let resolver = Arc::new(CohortResolver::new(reg));
let n_threads: usize = 8;
let iters: usize = 200;
let mut handles = Vec::with_capacity(n_threads);
for t in 0..n_threads {
let r = Arc::clone(&resolver);
handles.push(std::thread::spawn(move || {
let meta = tech_user_metadata();
for i in 0..iters {
if (t + i) % 3 == 0 {
// Interleave full invalidations with resolves to drive
// the invalidate-all / slow-path-insert race.
r.invalidate_all();
} else {
let user = u64::try_from(i % 16).unwrap_or(0);
let m = r.resolve(user, &meta);
// Whatever lands, it must be the correct membership for
// the supplied metadata — a stale insert can only ever
// re-cache the same (correct) value here because the
// registry is not mutated during the race.
assert!(m.iter().any(|n| n.to_string() == "tech_en"));
}
}
}));
}
for h in handles {
assert!(h.join().is_ok(), "no thread should panic in the race");
}
// After the storm, a fresh resolve still returns the correct membership
// and the cache remains within its bound (pure-accelerator invariant).
let m = resolver.resolve(99, &sports_user_metadata());
assert!(m.iter().any(|n| n.to_string() == "sports"));
assert!(resolver.cache_len() <= resolver.cache_capacity());
}
}

View File

@ -8,17 +8,44 @@
//! typically brief: a synchronous flush (~milliseconds) plus the time to
//! copy the data directory (proportional to data size).
//!
//! The backup is **crash-consistent**: it captures a point-in-time snapshot
//! as of the flush, but is not taken under a hot write path. Any signals
//! written after the backup resumes are not included.
//! The backup is **crash-consistent** against a host/power crash: it captures
//! a point-in-time snapshot as of the flush, and every copied file plus each
//! destination directory is `fsync`ed before `create_backup` returns, so the
//! artifact is durable (not merely sitting in the page cache) the moment the
//! call completes. It is not taken under a hot write path; any signals written
//! after the backup resumes are not included.
use std::{
fs::File,
path::Path,
sync::atomic::{AtomicBool, Ordering},
};
use crate::schema::{DurabilityError, TidalError, Timestamp};
/// `fsync` a single path (file or directory) so its bytes / directory entries
/// are durable, mirroring the WAL layer's barrier (`wal/segment.rs` `open`/
/// `rotate` both `File::open(dir)` + `sync_all()`). A backup that returns before
/// its copied files and the new dirents are `fsync`ed is only crash-consistent
/// against a *process* crash, not a host/power crash — the exact crash backups
/// exist to survive.
///
/// # Errors
///
/// Returns `TidalError::Durability` if the open or `sync_all` fails.
fn fsync_path(path: &Path) -> crate::Result<()> {
let f = File::open(path).map_err(|e| {
TidalError::Durability(DurabilityError {
message: format!("backup: failed to open {} for fsync: {e}", path.display()),
})
})?;
f.sync_all().map_err(|e| {
TidalError::Durability(DurabilityError {
message: format!("backup: fsync of {} failed: {e}", path.display()),
})
})
}
/// Metadata about a completed backup.
#[derive(Debug, Clone)]
pub struct BackupInfo {
@ -51,11 +78,22 @@ impl Drop for BackupGuard<'_> {
/// Returns the total number of bytes copied. Skips `tidaldb.lock` (the
/// advisory lock file is process-specific and will be recreated on open).
///
/// Every copied file is `fsync`ed after its bytes are written, and `dest`
/// itself is `fsync`ed once the level is fully populated so the new directory
/// entries are durable. Without this, a host/power crash in the window after
/// `create_backup` returns could leave copied files zero-length or their
/// dirents unlinked — a corrupt backup discovered only at restore. This
/// matches the WAL layer, which already `fsync`s files and parent dirs.
///
/// # Errors
///
/// Returns `TidalError::Durability` if any filesystem operation fails.
/// Returns `TidalError::Durability` if any filesystem operation (copy, create,
/// or `fsync`) fails.
fn copy_dir_recursive(src: &Path, dest: &Path) -> crate::Result<u64> {
let mut total_bytes: u64 = 0;
// Whether this level received at least one entry (file copied or subdir
// created). If so, we must fsync `dest` to make the new dirents durable.
let mut dest_dirty = false;
let entries = std::fs::read_dir(src).map_err(|e| {
TidalError::Durability(DurabilityError {
@ -109,9 +147,16 @@ fn copy_dir_recursive(src: &Path, dest: &Path) -> crate::Result<u64> {
})
})?;
total_bytes += copy_dir_recursive(&src_path, &dest_path)?;
dest_dirty = true;
} else if file_type.is_file() {
match std::fs::copy(&src_path, &dest_path) {
Ok(bytes) => total_bytes += bytes,
Ok(bytes) => {
total_bytes += bytes;
// Make the copied bytes durable before we move on. Without
// this, a power crash can leave a zero-length or torn file.
fsync_path(&dest_path)?;
dest_dirty = true;
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
// File vanished between readdir and copy -- skip it.
// This is expected with fjall's compaction creating and
@ -135,6 +180,13 @@ fn copy_dir_recursive(src: &Path, dest: &Path) -> crate::Result<u64> {
// Symlinks and other special file types are skipped.
}
// Fsync this directory so its new entries (the files we just copied and the
// subdirectories we created) survive a host crash. A child directory's own
// entries were already fsync'd by the recursive call before it returned.
if dest_dirty {
fsync_path(dest)?;
}
Ok(total_bytes)
}
@ -319,32 +371,24 @@ impl super::TidalDb {
///
/// Each is non-fatal on its own: these are derived aggregates whose absence
/// degrades but never corrupts ranking, so a failure is logged and the others
/// still run. Shared by the backup path; the shutdown path has the same set
/// inline because it must additionally fold any failure into its first-error
/// return.
/// still run. Delegates to the ONE shared `checkpoint_secondary_ledgers`
/// helper (in `db::state_rebuild`) so the backup, shutdown, and periodic paths
/// share one set of guards and cannot drift. The first error is discarded
/// here: a missing secondary ledger in a backup degrades ranking but never
/// corrupts data, and the signal-ledger checkpoint (the fatal one) ran before
/// this.
fn checkpoint_secondary_state(
&self,
storage: &super::storage_box::StorageBox,
meta: crate::signals::checkpoint::CheckpointMeta,
) {
if self.cohort_ledger.entry_count() > 0
&& let Err(e) = self.cohort_ledger.checkpoint(storage.items_engine(), meta)
{
tracing::warn!(error = %e, "backup: cohort checkpoint failed (non-fatal)");
}
if self.co_engagement.edge_count() > 0
&& let Err(e) = self.co_engagement.checkpoint(storage.items_engine())
{
tracing::warn!(error = %e, "backup: co-engagement checkpoint failed (non-fatal)");
}
if let Err(e) = self.community_ledger.checkpoint(storage.items_engine()) {
tracing::warn!(error = %e, "backup: community checkpoint failed (non-fatal)");
}
if !self.preference_vectors.is_empty()
&& let Err(e) = self.preference_vectors.checkpoint(storage.items_engine())
{
tracing::warn!(error = %e, "backup: preference-vector checkpoint failed (non-fatal)");
}
let _ = crate::db::state_rebuild::checkpoint_secondary_ledgers(
storage.items_engine(),
Some((self.cohort_ledger.as_ref(), meta)),
self.community_ledger.as_ref(),
self.co_engagement.as_ref(),
self.preference_vectors.as_ref(),
);
}
}
@ -372,6 +416,53 @@ mod tests {
assert_eq!(content, "hello");
}
#[test]
fn fsync_path_durably_syncs_file_and_dir() {
// The fsync helper must succeed on both a regular file and a directory —
// the two call sites in copy_dir_recursive. A failure here means a copied
// backup file or its dirent would never be made durable.
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("f.bin");
std::fs::write(&file, b"durable").unwrap();
fsync_path(&file).expect("file fsync must succeed");
fsync_path(dir.path()).expect("directory fsync must succeed");
}
#[test]
fn copy_dir_recursive_fsyncs_every_copied_file_and_dir() {
// Durability regression: copy_dir_recursive must fsync each copied file
// and each destination directory (nested too). We cannot observe the
// fsync syscall directly without fault injection, but we can prove the
// path that fsyncs every file/dir runs to completion over a tree with a
// nested directory, and that the bytes landed correctly. The fsync calls
// are unconditional on the copy/create branches, so a regression that
// dropped them would either fail to compile (helper removed) or be caught
// by the helper's own error path surfacing here.
let src = tempfile::tempdir().unwrap();
let dest = tempfile::tempdir().unwrap();
std::fs::write(src.path().join("top.txt"), "top").unwrap();
std::fs::create_dir(src.path().join("a")).unwrap();
std::fs::write(src.path().join("a").join("inner.txt"), "inner").unwrap();
std::fs::create_dir(src.path().join("a").join("b")).unwrap();
std::fs::write(src.path().join("a").join("b").join("deep.txt"), "deep").unwrap();
let bytes = copy_dir_recursive(src.path(), dest.path()).unwrap();
assert!(bytes > 0);
assert_eq!(
std::fs::read_to_string(dest.path().join("top.txt")).unwrap(),
"top"
);
assert_eq!(
std::fs::read_to_string(dest.path().join("a").join("inner.txt")).unwrap(),
"inner"
);
assert_eq!(
std::fs::read_to_string(dest.path().join("a").join("b").join("deep.txt")).unwrap(),
"deep"
);
}
#[test]
fn copy_dir_recursive_skips_lock_file() {
let src = tempfile::tempdir().unwrap();

View File

@ -224,36 +224,90 @@ impl TidalDbBuilder {
return Err(ConfigError::MissingDataDir);
}
// Validate each specified directory exists and is writable.
let dirs_to_check: Vec<&PathBuf> = [
self.config.data_dir.as_ref(),
self.config.wal_dir.as_ref(),
self.config.cache_dir.as_ref(),
]
.into_iter()
.flatten()
.collect();
// Validate each specified directory exists, is a directory, and is
// writable. `data_dir` and `wal_dir` are the paths the engine actually
// creates files under, so they get a real temp-file write probe (not a
// permission-bit guess). `cache_dir` is reserved / no-op today, so it
// only gets the cheaper shape checks.
let dirs_to_check: [(Option<&PathBuf>, bool); 3] = [
(self.config.data_dir.as_ref(), true),
(self.config.wal_dir.as_ref(), true),
(self.config.cache_dir.as_ref(), false),
];
for dir in dirs_to_check {
for (dir, probe_writable) in dirs_to_check {
let Some(dir) = dir else { continue };
if !dir.exists() {
return Err(ConfigError::DirectoryNotFound { path: dir.clone() });
}
// Check writability by querying metadata permissions.
// On Unix, we check the readonly flag. A more robust check
// would attempt to create a temp file, but metadata is
// sufficient for configuration validation.
if dir
.metadata()
.map(|m| m.permissions().readonly())
.unwrap_or(true)
{
// Reject a path that exists but is not a directory (e.g. a regular
// file or symlink to one). Without this, fjall/WAL fail later with
// an opaque io error when they try to create children under it.
if !dir.is_dir() {
return Err(ConfigError::NotWritable { path: dir.clone() });
}
if probe_writable {
// Real writability probe: `permissions().readonly()` reflects only
// the inode's write bits, not whether THIS process's uid/gid can
// write (a 0755 dir owned by another user reports !readonly yet
// EACCES on create). Actually create+remove a uniquely-named file,
// mirroring the lock-file open, so wrong-ownership and read-only
// mounts are caught here at the open boundary, not opaquely during
// component init.
Self::probe_dir_writable(dir)?;
} else {
// Reserved path: a cheap permission-bit check is sufficient since
// the engine does not write here today.
if dir
.metadata()
.map(|m| m.permissions().readonly())
.unwrap_or(true)
{
return Err(ConfigError::NotWritable { path: dir.clone() });
}
}
}
Ok(())
}
/// Probe that the current process can actually create (and remove) a file in
/// `dir`, mapping any failure to [`ConfigError::NotWritable`].
///
/// Uses a uniquely-named probe file (pid + nanosecond timestamp) created with
/// `create_new` so concurrent probes never collide and a stale file is never
/// silently reused, then removes it. This is the same "attempt a real write"
/// approach the data-dir lock open relies on, and unlike a permission-bit
/// check it catches the common wrong-ownership / read-only-mount cases.
fn probe_dir_writable(dir: &Path) -> Result<(), ConfigError> {
let unique = format!(
".tidaldb-writeprobe-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
);
let probe_path = dir.join(unique);
let create_result = OpenOptions::new()
.write(true)
.create_new(true)
.open(&probe_path);
match create_result {
Ok(file) => {
// Drop the handle before unlinking; ignore a remove error (the
// create succeeded, which is all the probe needed to prove, and a
// leftover probe file does not break correctness).
drop(file);
let _ = std::fs::remove_file(&probe_path);
Ok(())
}
Err(_) => Err(ConfigError::NotWritable {
path: dir.to_path_buf(),
}),
}
}
/// Enable the metrics HTTP server on the given address.
///
/// Only available when the `metrics` feature is enabled. When the
@ -620,3 +674,100 @@ mod rate_limiter_validation_tests {
assert!(result.is_ok(), "valid rate-limiter config must open");
}
}
#[cfg(test)]
mod dir_validation_tests {
use super::*;
/// W3: a path that exists but is a regular file (not a directory) must be
/// rejected at `validate()`/`open()` with a typed `ConfigError`, not pass and
/// fail opaquely later when fjall/WAL try to create children under it.
#[test]
fn validate_rejects_regular_file_as_data_dir() {
let tmp = tempfile::tempdir().expect("tempdir");
let file_path = tmp.path().join("not-a-dir");
std::fs::write(&file_path, b"x").expect("write probe file");
let builder = TidalDb::builder().with_data_dir(&file_path);
let err = builder
.validate()
.expect_err("a regular file must not validate as a data_dir");
assert!(
matches!(err, ConfigError::NotWritable { .. }),
"expected NotWritable for a non-directory, got: {err:?}"
);
// And the full open path must surface the same typed error.
assert!(
TidalDb::builder().with_data_dir(&file_path).open().is_err(),
"open must reject a non-directory data_dir"
);
}
/// W4: writability is probed with a real temp-file create, so a directory the
/// process cannot write fails `validate()` with `NotWritable` rather than
/// passing on a misleading permission bit.
#[cfg(unix)]
#[test]
fn validate_rejects_unwritable_data_dir() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("readonly");
std::fs::create_dir(&dir).expect("create dir");
// 0555: readable + executable, NOT writable for the owner.
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o555))
.expect("set readonly perms");
// Running as root bypasses DAC write checks, so the probe would succeed
// and the assertion would be meaningless. Detect that by directly
// probing the dir the same way validate() does; if the write succeeds
// here, we are root (or on an fs that ignores the bits) — skip.
let direct_probe = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(dir.join(".tidaldb-roottest"));
if let Ok(f) = direct_probe {
drop(f);
let _ = std::fs::remove_file(dir.join(".tidaldb-roottest"));
let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755));
return;
}
let builder = TidalDb::builder().with_data_dir(&dir);
let result = builder.validate();
// Restore write perms so the tempdir can be cleaned up regardless.
let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755));
let err = result.expect_err("an unwritable data_dir must not validate");
assert!(
matches!(err, ConfigError::NotWritable { .. }),
"expected NotWritable for an unwritable dir, got: {err:?}"
);
}
/// A normal writable directory still validates and opens — the probe only
/// rejects genuinely unusable paths.
#[test]
fn validate_accepts_writable_dir_and_leaves_no_probe_file() {
let tmp = tempfile::tempdir().expect("tempdir");
let builder = TidalDb::builder().with_data_dir(tmp.path());
builder.validate().expect("writable dir must validate");
// The probe must clean up after itself: no leftover write-probe files.
let leftovers: Vec<_> = std::fs::read_dir(tmp.path())
.expect("read tempdir")
.filter_map(Result::ok)
.filter(|e| {
e.file_name()
.to_string_lossy()
.starts_with(".tidaldb-writeprobe-")
})
.collect();
assert!(
leftovers.is_empty(),
"write probe must remove its temp file; found {} leftover(s)",
leftovers.len()
);
}
}

View File

@ -7,6 +7,11 @@
//! 60s session sweeper, so the "<1s" guarantee holds by construction. Local
//! profile state is never touched by these methods (local-profile-intact).
use std::{
hash::{Hash, Hasher},
sync::{Mutex, MutexGuard, OnceLock},
};
use super::TidalDb;
use crate::{
governance::{
@ -17,6 +22,48 @@ use crate::{
storage::{Tag, encode_key},
};
/// Number of shards in the per-`(user, community)` lifecycle lock table. A power
/// of two so the index mask is a cheap bitwise AND. 256 shards keep contention
/// negligible on the cold lifecycle path while bounding the static's footprint.
const LIFECYCLE_LOCK_SHARDS: usize = 256;
/// Sharded mutex table serializing the read-modify-write lifecycle transitions
/// (`join` / `leave` / `rejoin`) for a given `(user, community)`.
///
/// The membership registry's `with_row` + `put` is a non-atomic read-then-put:
/// without serialization, a `join` that read the pre-leave row can `put` a fresh
/// `Joined` row over a concurrent `leave`'s `Left` row, silently re-admitting a
/// member who just left and defeating the stop-forward guarantee (a privacy
/// regression, not a transient count error). All three transitions take this
/// lock keyed by `(user, community)` so the registry sees one writer per key.
///
/// The signal write path (`community_contribute`) deliberately does NOT take this
/// lock — it stays lock-free; only the rare lifecycle transitions serialize.
///
/// A process-global static rather than a `TidalDb` field: the lock is purely a
/// serialization device over the registry's read-modify-write, not state tied to
/// a DB instance, so sharing it across instances only ever over-serializes two
/// distinct DBs whose keys collide on a shard — harmless on this cold path.
fn lifecycle_locks() -> &'static [Mutex<()>; LIFECYCLE_LOCK_SHARDS] {
static LOCKS: OnceLock<[Mutex<()>; LIFECYCLE_LOCK_SHARDS]> = OnceLock::new();
LOCKS.get_or_init(|| std::array::from_fn(|_| Mutex::new(())))
}
/// Acquire the lifecycle lock guarding `(user, community)`.
///
/// Recovers from a poisoned lock via [`std::sync::PoisonError::into_inner`]: the
/// guarded unit `()` carries no invariant a prior panic could have corrupted, so
/// a poisoned shard is safe to keep using rather than propagating the poison.
fn lock_lifecycle(user: UserId, community: CommunityId) -> MutexGuard<'static, ()> {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
user.as_u64().hash(&mut hasher);
community.as_u64().hash(&mut hasher);
let idx = (hasher.finish() as usize) & (LIFECYCLE_LOCK_SHARDS - 1);
lifecycle_locks()[idx]
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
impl TidalDb {
/// Join a community overlay under an explicit [`SharePolicy`].
///
@ -36,6 +83,11 @@ impl TidalDb {
policy: SharePolicy,
) -> crate::Result<u32> {
self.require_writeable("join_community")?;
// Serialize the read-modify-write against a concurrent leave/rejoin on the
// same (user, community): the registry's with_row + put is not atomic, so
// without this a join that read the pre-leave row could overwrite a
// concurrent leave's Left row and re-admit a just-left member.
let _lifecycle = lock_lifecycle(user, community);
let now_ns = Timestamp::now().as_nanos();
// If a prior membership exists and is Left/leaving, this is a rejoin.
@ -102,6 +154,10 @@ impl TidalDb {
"LeaveMode::Purge requires retroactive purge (M9p3); use StopForward",
));
}
// Hold the per-key lifecycle lock for the whole read-modify-write so a
// concurrent join cannot read the pre-leave row and overwrite the Left
// row we are about to publish (the lost-update / re-admit window).
let _lifecycle = lock_lifecycle(user, community);
let now_ns = Timestamp::now().as_nanos();
// Durability-before-mutation: build the settled-to-`Left`, gate-engaged
@ -153,6 +209,9 @@ impl TidalDb {
policy: SharePolicy,
) -> crate::Result<u32> {
self.require_writeable("rejoin_community")?;
// Same per-key serialization as join/leave: the read of the prior epoch
// and the subsequent put must be a single atomic transition.
let _lifecycle = lock_lifecycle(user, community);
let prior = self
.membership_registry
@ -206,7 +265,7 @@ impl TidalDb {
/// # Errors
///
/// - `TidalError::Internal` if the database is not writeable.
/// - `TidalError::invalid_input` if `weight` is not finite.
/// - `TidalError::invalid_input` if `weight` is not finite or is negative.
/// - `TidalError::Schema` if `signal_type` is undefined.
/// - `TidalError::Durability` if the WAL write fails.
pub fn signal_for_community(
@ -246,11 +305,11 @@ impl TidalDb {
writer_agent: &str,
) -> crate::Result<bool> {
self.require_writeable("signal_for_community")?;
if !weight.is_finite() {
return Err(TidalError::invalid_input(
"signal weight must be finite (NaN and Inf are not allowed)",
));
}
// Same weight admission as the entity signal path: finite AND
// non-negative (negative engagement uses separate signal types, not
// negative weights — spec §8), so a community contribution cannot drive
// a contributor cell's decay score below zero.
Self::validate_signal_weight(weight)?;
// <1s STOP-FORWARD / eligibility gate: O(1) atomic read. Drops the
// contribution (no WAL write) if the member is gated or the intent is
@ -299,6 +358,19 @@ impl TidalDb {
// In-memory community aggregate, retaining per-contributor attribution so
// a later purge (M9p3) can remove exactly this user's contributions and
// rematerialize deterministically.
//
// ORDERING (durability-before-mutation, correctly applied): unlike
// `add_to_collection`/`leave_community`, the durability anchor here is the
// WAL event ALREADY committed above (`record_signal_scoped` returned Ok,
// i.e. the event is fsync-durable). `record` below only updates the
// in-memory aggregate to MIRROR that already-durable event — so memory is
// never "ahead of disk": it tracks a contribution the WAL has already made
// durable. `persist_cell` then writes the SECONDARY checkpoint copy (the
// only copy that carries per-contributor identity, which the WAL event
// omits). A `persist_cell` failure therefore does NOT mean the contribution
// was lost: it is still WAL-durable and the checkpoint-restore + WAL replay
// path reconverges the secondary copy on the next open. We still surface
// the error so the caller knows the secondary durable copy lagged.
if let Ok(type_id) = self.community_ledger.resolve_signal_type(signal_type) {
self.community_ledger.record(
community,
@ -312,18 +384,19 @@ impl TidalDb {
);
// ZERO loss window for an accepted contribution: the community
// aggregate is the SOLE durable copy of per-contributor identity (the
// WAL community event carries none), so a periodic checkpoint alone
// would still lose this contribution on a crash before the next tick.
// The `signal_for_community` rustdoc promises `Ok(true)` means
// "durably logged" — to honor that for the per-contributor aggregate we
// synchronously persist the affected cell here BEFORE returning
// Ok(true). We persist ONLY the one touched row (incremental,
// O(1) per write) rather than re-checkpointing the entire community
// keyspace: a full snapshot on every accept is O(total contributions)
// per write — quadratic ingestion that blows the signal-write budget
// for any active community (CRITICAL fix). The periodic/lifecycle
// checkpoint still garbage-collects purged rows.
// aggregate's checkpoint is the SOLE durable copy of per-contributor
// identity (the WAL community event carries none), so a periodic
// checkpoint alone would still lose this attribution on a crash before
// the next tick. The `signal_for_community` rustdoc promises `Ok(true)`
// means "durably logged" — to honor that for the per-contributor
// aggregate we synchronously persist (and fsync, see `persist_cell`)
// the affected cell here BEFORE returning Ok(true). We persist ONLY the
// one touched row (incremental, O(1) per write) rather than
// re-checkpointing the entire community keyspace: a full snapshot on
// every accept is O(total contributions) per write — quadratic
// ingestion that blows the signal-write budget for any active community
// (CRITICAL fix). The periodic/lifecycle checkpoint still
// garbage-collects purged rows.
if let Some(ref storage) = self.storage
&& let Err(e) = self.community_ledger.persist_cell(
storage.items_engine(),
@ -473,6 +546,10 @@ impl TidalDb {
// Durability-before-mutation: persist the tombstone first.
self.persist_tombstone(&tombstone)?;
// Durability is provided by tombstone replay on open; the
// periodic/lifecycle checkpoint GCs the stale rows. We deliberately do NOT
// eagerly re-checkpoint here — the agent-purge path (remove_scope.rs) takes
// the identical stance, so both purge paths share one crash-recovery story.
let contributions_removed =
self.community_ledger
.purge_contributor(community, user, epoch_range);
@ -620,3 +697,166 @@ mod leave_durability_tests {
db.close().unwrap();
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod lifecycle_lock_tests {
use super::{LIFECYCLE_LOCK_SHARDS, lifecycle_locks, lock_lifecycle};
use crate::governance::{UserId, scope::CommunityId};
/// The lifecycle lock must serialize transitions on the SAME
/// (user, community) key: while one is held, the same key's shard is
/// contended (a non-reentrant `try_lock` from this thread returns
/// `WouldBlock`). This is the mechanism that closes the join/leave
/// lost-update window. We deliberately do NOT assert that a *distinct* shard
/// is lockable: the lock table is process-global, so a concurrently-running
/// test could legitimately hold any other shard — that would make such an
/// assertion flaky without proving anything about serialization.
#[test]
fn lock_serializes_the_same_key() {
use std::hash::{Hash, Hasher};
let (u, c) = (UserId(1), CommunityId(10));
let held = lock_lifecycle(u, c);
// Recompute the held shard index the same way lock_lifecycle does, then
// assert that very shard is contended (cannot be re-acquired) while held.
let held_idx = {
let mut h = std::collections::hash_map::DefaultHasher::new();
u.as_u64().hash(&mut h);
c.as_u64().hash(&mut h);
(h.finish() as usize) & (LIFECYCLE_LOCK_SHARDS - 1)
};
assert!(
lifecycle_locks()[held_idx].try_lock().is_err(),
"the held key's shard must be contended while the guard is alive"
);
drop(held);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod join_leave_race_tests {
use std::{
sync::{Arc, Barrier},
time::Duration,
};
use crate::{
TidalDb,
governance::{
LeaveMode, MembershipState, ShareIntent, SharePolicy, UserId, scope::CommunityId,
},
schema::{DecaySpec, EntityId, EntityKind, SchemaBuilder, Timestamp, Window},
};
fn schema() -> crate::schema::Schema {
let mut b = SchemaBuilder::new();
let _ = b
.signal(
"like",
EntityKind::Item,
DecaySpec::Exponential {
half_life: Duration::from_secs(7 * 24 * 3600),
},
)
.windows(&[Window::AllTime])
.velocity(false)
.add();
b.build().unwrap()
}
fn policy() -> SharePolicy {
SharePolicy::community_share(1, [ShareIntent::Like])
}
/// Regression for the join/leave lost-update: a barrier-synchronized
/// concurrent `leave` (`StopForward`) and `join` must NEVER leave the member
/// re-admitted at the *leaving* epoch with a cleared gate. With the per-key
/// lifecycle lock the two transitions serialize, so the final state is always
/// one of the legal serial outcomes:
///
/// - `Left` (leave won the lock last), or
/// - `Rejoined` at a *higher* epoch (join observed the Left row and rejoined).
///
/// The illegal outcome the lock forbids is `Joined`/`Rejoined` at epoch 0 with
/// a cleared gate while a leave also ran — a silently re-admitted member.
#[test]
fn concurrent_join_and_leave_never_silently_readmit() {
let item = EntityId::new(7);
// A handful of barrier-synced rounds keeps the test fast but maximizes the
// overlap window each round.
for round in 0..16u64 {
let db = Arc::new(
TidalDb::builder()
.ephemeral()
.with_schema(schema())
.open()
.unwrap(),
);
let user = UserId(round + 1);
let community = CommunityId(100 + round);
db.join_community(user, community, policy()).unwrap();
let barrier = Arc::new(Barrier::new(2));
let leaver = {
let db = Arc::clone(&db);
let barrier = Arc::clone(&barrier);
std::thread::spawn(move || {
barrier.wait();
db.leave_community(user, community, LeaveMode::StopForward)
.unwrap();
})
};
let joiner = {
let db = Arc::clone(&db);
let barrier = Arc::clone(&barrier);
std::thread::spawn(move || {
barrier.wait();
// A re-join attempt racing the leave.
let _ = db.join_community(user, community, policy());
})
};
leaver.join().unwrap();
joiner.join().unwrap();
let state = db.membership_state(user, community).unwrap();
let epoch = db.membership_epoch(user, community).unwrap();
let admits = db
.signal_for_community("like", item, 1.0, Timestamp::now(), user, community)
.unwrap();
match state {
// Leave settled last: contributions must be gated.
MembershipState::Left | MembershipState::LeavingStopForward => {
assert!(
!admits,
"round {round}: a Left membership must gate contributions"
);
}
// Join settled last AFTER observing the leave: it must be a genuine
// rejoin at a higher epoch, not a re-admit at the leaving epoch.
MembershipState::Rejoined => {
assert!(
epoch >= 1,
"round {round}: a rejoin after leave must open a new epoch (>0), \
got epoch {epoch} that would be a silent re-admit"
);
}
// The only way to stay Joined at epoch 0 is if the join's
// read-modify-write completed entirely before leave engaged the
// gate (serialized, join-then-leave is impossible here since both
// started behind the barrier and leave always settles to Left).
// Reaching Joined means leave never ran its mutation under the
// lock — which the serialization forbids.
MembershipState::Joined => {
panic!(
"round {round}: ended in Joined@{epoch} despite a concurrent leave — \
the lifecycle lock must prevent a join from overwriting the Left row"
);
}
}
}
}
}

View File

@ -28,15 +28,33 @@ impl TidalDb {
// entity store is the source of truth; the text index is derived state,
// and any write the syncer has not yet committed before a crash is
// recovered by the unconditional rebuild-from-store at open (see
// `db::mod::rebuild_text_indexes_at_open`).
if let Ok(guard) = self.creator_text_tx.lock()
&& let Some(tx) = guard.as_ref()
{
let _ = tx.send(crate::text::PendingWrite {
// `crate::db::state_rebuild::rebuild_text_indexes_at_open`).
//
// Clone the Sender out of the guard and DROP the guard before enqueuing,
// then use the non-blocking `try_send`. Holding `creator_text_tx` across
// a blocking `send` would stall every concurrent creator write once the
// bounded outbox fills under a sustained Tantivy commit outage — the same
// hazard the item write path avoids. The durable write above has already
// completed, so dropping the enqueue costs zero durability: the
// rebuild-from-store at open re-indexes anything dropped.
let tx = self
.creator_text_tx
.lock()
.ok()
.and_then(|guard| guard.as_ref().cloned());
if let Some(tx) = tx
&& let Err(e) = tx.try_send(crate::text::PendingWrite {
entity_id: id,
metadata: metadata.clone(),
deleted: false,
});
})
{
tracing::warn!(
entity_id = id.as_u64(),
error = %e,
"creator text-index outbox enqueue dropped (syncer stalled or gone); \
rebuild-from-store at open will re-index this creator"
);
}
Ok(())

View File

@ -39,6 +39,13 @@ impl TidalDb {
/// directly (NOT `JoinHandle::is_finished`: the syncer now survives commit
/// failures, so a live handle no longer implies a healthy index) and from
/// the latch the periodic refresher sets.
/// - **dead replication receiver** — on a follower, the segment-receiver
/// thread halted on an apply error (corrupt segment / follower-WAL IO
/// fault). Replication has silently stopped applying, so the follower is
/// frozen. This is read from
/// [`SegmentReceiverHandle::died`](crate::replication::receiver::SegmentReceiverHandle::died),
/// which is set ONLY on the error halt path — a clean shutdown does not
/// flag it (C11).
///
/// # Errors
///
@ -76,6 +83,28 @@ impl TidalDb {
));
}
// Replication-receiver liveness (C11): on a follower, a receiver that
// halted on an apply error means replication silently stopped — the node
// applies nothing and lag freezes flat instead of growing. Surface it as
// degraded. `died()` is set only on the error halt path (a clean shutdown
// leaves it false via the recv_segment()==None branch), and is a cheap
// lock-free atomic read, so polling it under the receiver-handle guard is
// safe even while the receiver thread is still parked. A poisoned guard is
// recovered (PoisonError::into_inner) rather than masking the check.
let receiver_dead = self
.receiver_handle
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.as_ref()
.is_some_and(crate::replication::receiver::SegmentReceiverHandle::died);
if receiver_dead {
return Err(crate::TidalError::internal(
"health_check",
"database is degraded (replication segment receiver halted on an \
apply error; the follower has silently stopped applying)",
));
}
// Text-search liveness. A missing syncer (no text fields in the schema)
// is healthy by definition; a present-but-unhealthy syncer means text
// search returns stale/empty results until a rebuild succeeds. Mark the

View File

@ -571,7 +571,7 @@ fn read_session_journal_signals(
use std::io::Read;
use crate::wal::{
format::{SessionWalEvent, decode_session_events},
format::{SessionWalEvent, decode_session_events_with_diagnostics},
session_journal::SESSION_JOURNAL_FILENAME,
};
@ -611,13 +611,18 @@ fn read_session_journal_signals(
{
break; // torn tail: incomplete final frame
}
let events = decode_session_events(&frame);
if events.is_empty() {
// Unparseable / checksum-failed frame: stop, mirroring the whole-
// buffer decoder's stop-at-first-corruption behavior.
let outcome = decode_session_events_with_diagnostics(&frame);
// Distinguish a *corrupt* frame from a forward-compat *skip*. A v2 frame
// carrying an unknown-but-checksum-valid record type decodes to zero
// events with corruption_detected == false (decode_typed_record returns
// Skipped); the whole-buffer decoder skips such a frame and continues, so
// this streaming reader must too. Breaking on `events.is_empty()` here
// silently dropped every later session the instant a newer writer added a
// 4th record type. Stop only on genuine corruption.
if outcome.corruption_detected {
break;
}
for event in events {
for event in outcome.events {
match event {
SessionWalEvent::Start {
session_id,
@ -760,6 +765,102 @@ impl super::TidalDb {
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod session_journal_forward_compat_tests {
use super::*;
use crate::wal::{
format::{SESSION_RECORD_VERSION_V2, SessionWalEvent, encode_session_event},
session_journal::SESSION_JOURNAL_FILENAME,
};
/// W6: a v2 frame carrying a forward-compatible *unknown* record type decodes
/// to zero events with `corruption_detected == false`. The streaming export
/// reader must SKIP it and keep reading later frames — not break at the first
/// empty decode and silently drop every subsequent session's signals.
///
/// Layout proof: a v2 frame is `[len:u32 LE][marker:0xFF][version=2][type]
/// [payload][checksum:8]`. We synthesize the unknown frame by taking a real
/// encoded event, flipping its type byte to a value no current reader knows,
/// and recomputing the BLAKE3 checksum so the frame is checksum-VALID (the
/// exact forward-compat case v2 framing was designed to tolerate). This avoids
/// hardcoding the private marker byte — we reuse the encoder's own framing.
fn unknown_but_checksum_valid_v2_frame() -> Vec<u8> {
// Frame: [len(4)][marker(1)][version(1)][type(1)][payload..][checksum(8)].
const TYPE_OFFSET: usize = 4 + 2; // after len + marker + version
const CHECKSUM_LEN: usize = 8;
// Start from a real encoded frame so the marker/version/framing are
// produced by the canonical encoder, then mutate the type byte.
let mut frame = encode_session_event(&SessionWalEvent::Close { session_id: 7 }).unwrap();
assert_eq!(
frame[TYPE_OFFSET - 1],
SESSION_RECORD_VERSION_V2,
"encoder must emit a v2 frame for this test to be valid"
);
// An unknown type: not START(0x01)/SIGNAL(0x02)/CLOSE(0x03).
frame[TYPE_OFFSET] = 0x7E;
// Recompute the checksum over the body (everything between the length
// prefix and the trailing checksum), matching session_record_checksum.
let body_end = frame.len() - CHECKSUM_LEN;
let digest = blake3::hash(&frame[4..body_end]);
let checksum = digest.as_bytes();
frame[body_end..].copy_from_slice(&checksum[..CHECKSUM_LEN]);
frame
}
#[test]
fn export_skips_unknown_frame_and_keeps_trailing_signal() {
let tmp = tempfile::tempdir().expect("tempdir");
let journal_path = tmp.path().join(SESSION_JOURNAL_FILENAME);
// Build: Start (defines session->user), unknown-but-valid v2 frame,
// then a real Signal whose timestamp lands in the export window.
let start = encode_session_event(&SessionWalEvent::Start {
session_id: 100,
user_id: 42,
started_at_ns: 1_000,
agent_id: "agent".to_string(),
policy_name: "policy".to_string(),
})
.unwrap();
let unknown = unknown_but_checksum_valid_v2_frame();
let signal = encode_session_event(&SessionWalEvent::Signal {
session_id: 100,
entity_id: 999,
weight: 1.5,
ts_ns: 5_000,
signal_name: "view".to_string(),
annotation: None,
session_seqno: None,
idempotency_key: None,
})
.unwrap();
let mut bytes = Vec::new();
bytes.extend_from_slice(&start);
bytes.extend_from_slice(&unknown);
bytes.extend_from_slice(&signal);
std::fs::write(&journal_path, &bytes).expect("write journal");
let request = ExportRequest::time_range(0, u64::MAX);
let exported = read_session_journal_signals(tmp.path(), &request, 100);
assert_eq!(
exported.len(),
1,
"the trailing Signal must survive an intervening unknown frame; got {exported:?}"
);
let sig = &exported[0];
assert_eq!(sig.entity_id, 999);
assert_eq!(sig.signal_type, "view");
assert_eq!(
sig.user_id,
Some(42),
"session->user mapping must be applied"
);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
#[path = "export_tests.rs"]

View File

@ -56,11 +56,11 @@ impl TidalDb {
// in-memory candidate-generation indexes key on a u32 id, so an id past
// u32::MAX would alias a lower id in every bitmap (silent cross-id
// collision in query results). Reject it here rather than truncate so a
// rejected item never lands in storage in an unindexable state. NOTE:
// the sibling write paths in signals.rs / relationships.rs /
// collections.rs / user_state.rs still perform a bare `as u32` narrowing
// on item-side ids; they should be routed through a shared guard so the
// limit is enforced uniformly (out of scope for this change).
// rejected item never lands in storage in an unindexable state. The
// `signal_with_context` durable-row write path applies the same up-front
// rejection, and the `Hide` relationship path routes through the
// observable `narrow_item_slot` helper, so every item-side narrowing is
// now either rejected or logged — no silent truncation remains.
if id.as_u64() > u64::from(u32::MAX) {
let raw = id.as_u64();
return Err(TidalError::invalid_input(format!(
@ -193,15 +193,40 @@ impl TidalDb {
// dropped is non-fatal). The durable entity store is the source of
// truth; the text index is derived state, and any write the syncer has
// not yet committed before a crash is recovered by the unconditional
// rebuild-from-store at open (see `db::mod::rebuild_text_indexes_at_open`).
if let Ok(guard) = self.text_tx.lock()
&& let Some(tx) = guard.as_ref()
{
let _ = tx.send(crate::text::PendingWrite {
// rebuild-from-store at open (see
// `crate::db::state_rebuild::rebuild_text_indexes_at_open`).
//
// Clone the Sender OUT of the guard and DROP the guard before enqueuing,
// then use the non-blocking `try_send`. Holding `text_tx` across a
// blocking `send` would stall every concurrent item write the moment the
// bounded outbox fills (e.g. a sustained Tantivy commit outage where the
// syncer retries forever and stops draining) — escalating a degraded-
// text-search incident into a full halt of the durable item write API.
// The durable write above has already completed, so dropping the enqueue
// here costs zero durability: the unconditional rebuild-from-store at
// open re-indexes anything dropped. crossbeam `Sender` is cheap to clone.
let tx = self
.text_tx
.lock()
.ok()
.and_then(|guard| guard.as_ref().cloned());
if let Some(tx) = tx
&& let Err(e) = tx.try_send(crate::text::PendingWrite {
entity_id: id,
metadata: (*stored_metadata).clone(),
deleted: false,
});
})
{
// Full/Disconnected: do NOT block. Drop the enqueue and warn — the
// rebuild-from-store at open re-indexes it, so correctness is
// preserved while the write path stays non-blocking under a text
// syncer outage.
tracing::warn!(
entity_id = id.as_u64(),
error = %e,
"text-index outbox enqueue dropped (syncer stalled or gone); \
rebuild-from-store at open will re-index this item"
);
}
// M6p5: index title terms for autocomplete suggestions.

View File

@ -169,31 +169,19 @@ impl TidalDb {
tracing::error!(error = %e, "signal ledger checkpoint failed during shutdown");
first_err.get_or_insert(e);
}
// Checkpoint cohort signal state (M6).
if self.cohort_ledger.entry_count() > 0
&& let Err(e) = self.cohort_ledger.checkpoint(storage.items_engine(), meta)
{
tracing::error!(error = %e, "cohort ledger checkpoint failed during shutdown");
first_err.get_or_insert(e);
}
// Checkpoint co-engagement edges (M6).
if self.co_engagement.edge_count() > 0
&& let Err(e) = self.co_engagement.checkpoint(storage.items_engine())
{
tracing::error!(error = %e, "co-engagement checkpoint failed during shutdown");
first_err.get_or_insert(e);
}
// Checkpoint community aggregation ledger (M9p3): per-contributor
// entries cannot be rebuilt from the WAL, so persist them here.
if let Err(e) = self.community_ledger.checkpoint(storage.items_engine()) {
tracing::error!(error = %e, "community ledger checkpoint failed during shutdown");
first_err.get_or_insert(e);
}
// Checkpoint preference vectors (derived state, no WAL backstop).
if !self.preference_vectors.is_empty()
&& let Err(e) = self.preference_vectors.checkpoint(storage.items_engine())
{
tracing::error!(error = %e, "preference-vector checkpoint failed during shutdown");
// Checkpoint the secondary derived ledgers (cohort M6, co-engagement
// M6, community M9p3 — whose per-contributor entries cannot be rebuilt
// from the WAL — and preference vectors) through the ONE shared helper
// so the shutdown, backup, and periodic paths cannot drift. Shutdown
// must surface a failure, so we fold the helper's first error into
// `first_err` (backup/periodic discard it instead).
if let Some(e) = crate::db::state_rebuild::checkpoint_secondary_ledgers(
storage.items_engine(),
Some((self.cohort_ledger.as_ref(), meta)),
self.community_ledger.as_ref(),
self.co_engagement.as_ref(),
self.preference_vectors.as_ref(),
) {
first_err.get_or_insert(e);
}
// BLOCKER 6: persist the follower replication high-water-mark so a

View File

@ -491,6 +491,10 @@ impl MetricsState {
}
// Checkpoint failure counter (unconditional -- not feature-gated).
// Carries the partition_id label so this per-node series stays distinct
// when many nodes are scraped into one Prometheus (W13), matching the
// uptime/health/info lines above. Without it, every node's
// checkpoint_failures_total would collapse onto a single unlabeled series.
{
use std::fmt::Write;
let failures = self.checkpoint_failures_total.load(Ordering::Relaxed);
@ -498,7 +502,7 @@ impl MetricsState {
out,
"\n# HELP tidaldb_checkpoint_failures_total Total number of failed periodic signal checkpoints\n\
# TYPE tidaldb_checkpoint_failures_total counter\n\
tidaldb_checkpoint_failures_total {failures}\n"
tidaldb_checkpoint_failures_total{{partition_id=\"{partition_id}\"}} {failures}\n"
);
}
@ -559,4 +563,30 @@ mod partition_id_tests {
"cluster node must not emit the single-node default partition_id: {out}"
);
}
/// The unconditional `checkpoint_failures_total` counter must carry the
/// `partition_id` label like every other node-identity series, so per-node
/// failure counts stay distinct in a multi-node Prometheus scrape (W13).
#[test]
fn checkpoint_failures_total_carries_partition_id() {
let state = MetricsState::new();
let out = state.render_prometheus();
assert!(
out.contains("tidaldb_checkpoint_failures_total{partition_id=\"0\"}"),
"checkpoint_failures_total must carry the default partition_id label: {out}"
);
// Never emit the old unlabeled series.
assert!(
!out.contains("\ntidaldb_checkpoint_failures_total 0\n"),
"checkpoint_failures_total must not emit an unlabeled series: {out}"
);
let cluster = MetricsState::new();
cluster.set_partition_id(7);
let cout = cluster.render_prometheus();
assert!(
cout.contains("tidaldb_checkpoint_failures_total{partition_id=\"7\"}"),
"checkpoint_failures_total must reflect the cluster shard id: {cout}"
);
}
}

View File

@ -444,7 +444,11 @@ impl TidalDb {
user_state: Arc::new(UserStateIndex::new()),
hard_negatives: Arc::new(HardNegIndex::new()),
interaction_ledger: Arc::new(InteractionLedger::new()),
preference_vectors: Arc::new(PreferenceVectors::new(128)),
// Cold-start (no schema): use the shared default so the fallback
// cannot drift from the schema-aware open branches (see open.rs).
preference_vectors: Arc::new(PreferenceVectors::new(
self::open::DEFAULT_PREFERENCE_DIM,
)),
text_index: None,
text_tx: std::sync::Mutex::new(None),
text_syncer_thread: std::sync::Mutex::new(None),

View File

@ -34,12 +34,23 @@ use dashmap::DashMap;
/// (post-diversity), not the signal-write hot path, so the contention cost is
/// acceptable.
pub struct NotificationTracker {
/// `(user_id, creator_id, days_since_epoch)` -> count delivered today.
/// `(user_id, creator_id, days_since_epoch)` -> count of DISTINCT items
/// delivered today (a cap counts items notified about, deduplicated per
/// [`counted`](Self::counted) — re-delivering the same item the same day is
/// not a new notification).
delivered: DashMap<(u64, u64, u32), u32>,
/// `(user_id, days_since_epoch)` -> total notifications delivered today.
/// `(user_id, days_since_epoch)` -> total distinct items delivered today.
///
/// Tracks cross-request totals for `max_total_per_day` enforcement.
user_total: DashMap<(u64, u32), u32>,
/// `(user_id, entity_id, days_since_epoch)` -> presence marker for items
/// already counted today. A notification cap counts *distinct items the user
/// was notified about*, not raw delivery attempts: re-issuing the same query
/// (or paging back to an item) must not consume the budget again. The first
/// delivery of an item records it here and increments the counters; a repeat
/// for the same `(user, entity, day)` is an idempotent no-op that still
/// passes (the item is allowed to re-appear) without inflating the cap.
counted: DashMap<(u64, u64, u32), ()>,
/// Serializes the combined check-then-record critical section so the
/// per-creator and per-day caps are evaluated and incremented atomically
/// with respect to each other and to concurrent recorders.
@ -53,6 +64,7 @@ impl NotificationTracker {
Self {
delivered: DashMap::new(),
user_total: DashMap::new(),
counted: DashMap::new(),
record_lock: Mutex::new(()),
}
}
@ -89,6 +101,13 @@ impl NotificationTracker {
/// see headroom and both record, which previously let the response overshoot
/// the configured caps.
///
/// Idempotent per `(user_id, entity_id, day)`: an item already delivered to
/// this user today does not consume the budget a second time. It returns
/// `true` (the item is still allowed to re-appear, e.g. on a feed refresh or
/// a re-issued identical query) WITHOUT re-incrementing any counter, so the
/// daily cap counts distinct items notified about — not raw delivery
/// attempts. The first delivery of each item enforces the caps and records.
///
/// `creator_id == None` means the item has no resolvable creator: the
/// per-creator cap is skipped (matching the executor's bucketing rule) but
/// the per-day total cap is still enforced and incremented.
@ -96,6 +115,7 @@ impl NotificationTracker {
&self,
user_id: u64,
creator_id: Option<u64>,
entity_id: u64,
day: u32,
max_per_creator: u32,
max_total: u32,
@ -108,6 +128,14 @@ impl NotificationTracker {
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
// Idempotent re-delivery: an item already counted for this user today
// is allowed through again but does not consume the budget twice. This
// makes a re-issued identical query (or a page revisit) not double-count
// delivered items.
if self.counted.contains_key(&(user_id, entity_id, day)) {
return true;
}
// Per-day total cap (always enforced).
if self.count_total_today(user_id, day) >= max_total {
return false;
@ -120,11 +148,13 @@ impl NotificationTracker {
return false;
}
// Commit: increment under the same lock so the next caller observes it.
// Commit: increment under the same lock so the next caller observes it,
// and mark the item counted so a repeat is idempotent.
if let Some(cid) = creator_id {
*self.delivered.entry((user_id, cid, day)).or_insert(0) += 1;
}
*self.user_total.entry((user_id, day)).or_insert(0) += 1;
self.counted.insert((user_id, entity_id, day), ());
true
}
@ -190,6 +220,7 @@ impl NotificationTracker {
let oldest_to_keep = current_day.saturating_sub(keep_days);
self.delivered.retain(|k, _| k.2 >= oldest_to_keep);
self.user_total.retain(|k, _| k.1 >= oldest_to_keep);
self.counted.retain(|k, ()| k.2 >= oldest_to_keep);
}
}
@ -260,29 +291,67 @@ mod tests {
let tracker = NotificationTracker::new();
let day = 19_700u32;
// Per-creator cap = 2, total cap = 5.
assert!(tracker.check_and_record(1, Some(100), day, 2, 5)); // creator100: 1, total 1
assert!(tracker.check_and_record(1, Some(100), day, 2, 5)); // creator100: 2, total 2
// Per-creator cap = 2, total cap = 5. Distinct entity ids so each call is
// a new item (idempotency keys on the entity).
assert!(tracker.check_and_record(1, Some(100), 1, day, 2, 5)); // creator100: 1, total 1
assert!(tracker.check_and_record(1, Some(100), 2, day, 2, 5)); // creator100: 2, total 2
assert!(
!tracker.check_and_record(1, Some(100), day, 2, 5),
"third from creator 100 must hit the per-creator cap"
!tracker.check_and_record(1, Some(100), 3, day, 2, 5),
"third item from creator 100 must hit the per-creator cap"
);
// A different creator still has headroom under the total cap.
assert!(tracker.check_and_record(1, Some(200), day, 2, 5)); // total 3
assert!(tracker.check_and_record(1, Some(200), 4, day, 2, 5)); // total 3
assert_eq!(tracker.count_total_today(1, day), 3);
assert_eq!(tracker.count_today(1, 100, day), 2);
assert_eq!(tracker.count_today(1, 200, day), 1);
}
#[test]
fn check_and_record_is_idempotent_per_entity_per_day() {
let tracker = NotificationTracker::new();
let day = 19_700u32;
// First delivery of item 500 (creator 100) records once.
assert!(tracker.check_and_record(1, Some(100), 500, day, 2, 5));
assert_eq!(tracker.count_total_today(1, day), 1);
assert_eq!(tracker.count_today(1, 100, day), 1);
// Re-delivering the SAME item the same day (e.g. a re-issued identical
// query) is allowed through but does NOT consume the budget again.
assert!(
tracker.check_and_record(1, Some(100), 500, day, 2, 5),
"an already-delivered item must still be allowed to re-appear"
);
assert!(tracker.check_and_record(1, Some(100), 500, day, 2, 5));
assert_eq!(
tracker.count_total_today(1, day),
1,
"re-delivery must not double-count the daily total"
);
assert_eq!(
tracker.count_today(1, 100, day),
1,
"re-delivery must not double-count the per-creator total"
);
// A different item still counts, and the same item on a different day is
// a fresh notification.
assert!(tracker.check_and_record(1, Some(100), 501, day, 2, 5));
assert_eq!(tracker.count_today(1, 100, day), 2);
assert!(tracker.check_and_record(1, Some(100), 500, day + 1, 2, 5));
assert_eq!(tracker.count_today(1, 100, day + 1), 1);
}
#[test]
fn check_and_record_enforces_total_cap_for_creatorless_items() {
let tracker = NotificationTracker::new();
let day = 1u32;
// No creator: per-creator cap is irrelevant, total cap still applies.
assert!(tracker.check_and_record(1, None, day, 0, 2));
assert!(tracker.check_and_record(1, None, day, 0, 2));
// Distinct entity ids so each is a new item.
assert!(tracker.check_and_record(1, None, 1, day, 0, 2));
assert!(tracker.check_and_record(1, None, 2, day, 0, 2));
assert!(
!tracker.check_and_record(1, None, day, 0, 2),
!tracker.check_and_record(1, None, 3, day, 0, 2),
"creatorless deliveries must still respect the daily total cap"
);
assert_eq!(tracker.count_total_today(1, day), 2);
@ -310,12 +379,22 @@ mod tests {
let day = 42u32;
let handles: Vec<_> = (0..THREADS)
.map(|_| {
.map(|t| {
let tracker = Arc::clone(&tracker);
let accepted = Arc::clone(&accepted);
thread::spawn(move || {
for _ in 0..ATTEMPTS_PER_THREAD {
if tracker.check_and_record(7, Some(99), day, MAX_PER_CREATOR, MAX_TOTAL) {
for i in 0..ATTEMPTS_PER_THREAD {
// A distinct item per attempt so the per-creator cap binds
// on distinct items (idempotency keys on the entity).
let entity = (t as u64) * 1000 + i as u64;
if tracker.check_and_record(
7,
Some(99),
entity,
day,
MAX_PER_CREATOR,
MAX_TOTAL,
) {
accepted.fetch_add(1, AtomicOrdering::Relaxed);
}
}
@ -370,9 +449,10 @@ mod tests {
let accepted = Arc::clone(&accepted);
thread::spawn(move || {
for i in 0..50u64 {
// Unique creator id per (thread, attempt).
// Unique creator AND entity id per (thread, attempt).
let creator = (t as u64) * 1000 + i;
if tracker.check_and_record(3, Some(creator), day, 1, MAX_TOTAL) {
let entity = creator;
if tracker.check_and_record(3, Some(creator), entity, day, 1, MAX_TOTAL) {
accepted.fetch_add(1, AtomicOrdering::Relaxed);
}
}

View File

@ -25,6 +25,29 @@ use crate::{
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.
@ -110,10 +133,7 @@ impl super::TidalDb {
let ledger = Arc::new(SignalLedger::new(schema, Box::new(NoopWalWriter)));
// Read preference vector dimensionality from the schema.
let pref_dim = schema_def
.embedding_slots()
.first()
.map_or(128, |s| s.dimensions);
let pref_dim = preference_dim_for(&schema_def);
Ok(OpenResult {
storage,
@ -243,10 +263,7 @@ impl super::TidalDb {
)?;
// Read preference vector dimensionality from the schema.
let pref_dim = schema_def
.embedding_slots()
.first()
.map_or(128, |s| s.dimensions);
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.

View File

@ -4,7 +4,7 @@ use super::{TidalDb, storage_box::StorageBox};
use crate::{
query::{
executor::RetrieveExecutor,
retrieve::{QueryError, Results, Retrieve},
retrieve::{Results, Retrieve},
search::{Search, SearchExecutor, SearchResults},
},
schema::{EntityKind, TidalError},
@ -108,16 +108,28 @@ impl TidalDb {
base_executor = base_executor.with_degradation_level(degradation_level);
// M4: wire in session context when FOR SESSION is specified.
//
// A RETRIEVE with FOR SESSION pointing at an expired/evicted session is a
// WELL-FORMED query: CODING_GUIDELINES §6 ("graceful degradation, never
// failure") requires we never 500 it. We therefore warn-and-continue
// WITHOUT the session boost — identical to the SEARCH path — rather than
// hard-erroring with `SessionNotFound`. Before this fix the two
// structurally-identical surfaces diverged: SEARCH degraded while RETRIEVE
// 500'd the moment a session was swept, a confusing surface-specific outage
// for a client paging a feed with FOR SESSION.
let executor = if let Some(session_id) = query.for_session {
match self.session_snapshot(session_id) {
Ok(snapshot) => {
let ctx = session_mod::SessionContext::from_snapshot(&snapshot);
base_executor.with_session(ctx, snapshot)
}
Err(_) => {
return Err(TidalError::Query(QueryError::SessionNotFound(format!(
"{session_id}"
))));
Err(e) => {
tracing::warn!(
session_id = %session_id,
error = %e,
"FOR SESSION: session not found; executing without session boost"
);
base_executor
}
}
} else {
@ -331,9 +343,13 @@ impl TidalDb {
},
));
}
// Forward `for_user` so the knob is actually consulted (it biases the
// tie-break among equal-frequency trending suggestions) rather than being
// silently dropped — a caller that sets it no longer gets the identical
// global result with no effect.
let results = self
.suggestion_index
.suggest(&req.prefix, req.limit as usize);
.suggest(&req.prefix, req.limit as usize, req.for_user);
Ok(results)
}

View File

@ -74,18 +74,12 @@ impl TidalDb {
self.community_ledger
.set_purge_watermark(community, watermark_ns);
// Re-checkpoint so the durable Tag::Community snapshot already
// reflects the removal (belt-and-suspenders with the tombstone
// replay: either alone keeps the agent gone after a crash).
if let Some(ref storage) = self.storage
&& let Err(e) = self.community_ledger.checkpoint(storage.items_engine())
{
tracing::warn!(
error = %e,
"agent-purge re-checkpoint failed; tombstone replay still covers the crash window"
);
}
// Durability is provided by tombstone replay on open (db/mod.rs
// replays every agent-purge tombstone and re-applies it
// idempotently); the periodic/lifecycle checkpoint GCs the stale
// rows. This mirrors the user-purge path (communities.rs) exactly —
// neither eagerly re-checkpoints, so the two paths cannot drift and
// a future editor reads ONE consistent crash-recovery story.
Ok(PurgeReceipt {
contributions_removed,
watermark_ns,

View File

@ -282,7 +282,17 @@ impl TidalDb {
/// assignments resolve to the local shard, so behavior is identical to
/// calling `signal()` directly.
///
/// # Remote-shard dispatch is NOT yet wired (fails closed)
/// # On-node tenant isolation is LOGICAL, not physical
///
/// `tenant_id` drives the per-tenant rate limiter and the shard routing
/// only. The local write below goes to the single shared signal
/// ledger/entity store with **no** tenant namespace in the key, so on a
/// single node hosting multiple tenants every tenant's signals co-mingle in
/// one keyspace/WAL. Physical per-tenant keyspace/WAL isolation and
/// enforcement of `TenantConfig::max_storage_bytes`/`max_entities` are a
/// separate, not-yet-wired task (`docs/specs/14-scale-architecture.md`).
///
/// # Remote-shard dispatch is NOT yet wired (fully fails closed)
///
/// During a dual-write migration the tenant router resolves two assignments
/// (source and target shard). Cross-node dispatch of the remote half over
@ -291,17 +301,19 @@ impl TidalDb {
/// `CODING_GUIDELINES.md` §12 "no TODO without a tracking note"). Rather than
/// silently dropping the remote write — which would lose signals during a
/// migration with no error visible to the caller — this method **fails
/// closed**: if any assignment targets a shard that is not local to this node
/// it returns `TidalError::Internal`. A misconfigured dual-write is therefore
/// loud, not silently lossy. The local half (if any) is still written before
/// the error is returned.
/// closed BEFORE any write happens**: it checks every resolved assignment
/// first, and if *any* targets a non-local shard it returns
/// `TidalError::Internal` with zero mutations. This makes the operation
/// atomic from the caller's perspective — a retry after the error cannot
/// double-count the local half, because the local half is never written when
/// the whole op cannot succeed (W8).
///
/// # Errors
///
/// - `TidalError::QuotaExceeded` — tenant rate limit exceeded.
/// - `TidalError::Internal` — no eligible shards for the tenant, OR an
/// assignment targets a non-local shard while cross-node dispatch is
/// unwired (see above).
/// unwired (see above). In the latter case NO signal was written.
/// - Any error from the underlying `signal()` call.
pub fn signal_for_tenant(
&self,
@ -318,36 +330,41 @@ impl TidalDb {
// Resolve all shard targets (1 in normal mode; 2 during dual-write migration).
let assignments = self.tenant_router.write_assignments(tenant_id, entity_id)?;
let local_shard = self.config.cluster.shard_id;
for assignment in &assignments {
if assignment.shard_id == local_shard {
// Local shard: write directly to the signal ledger.
self.signal(signal_type, entity_id, weight, timestamp)?;
} else {
// Remote shard (dual-write migration path). Cross-node dispatch
// over the replication transport is not yet wired (Task 5 in
// docs/specs/14 per CODING_GUIDELINES §12). Fail CLOSED instead of
// silently dropping the write: a dual-write that cannot reach its
// remote half would otherwise lose signals during migration with
// no error surfaced. Log at warn so the misconfiguration is
// observable, then return a typed error.
tracing::warn!(
tenant_id = tenant_id.0,
shard_id = assignment.shard_id.0,
entity_id = entity_id.as_u64(),
signal_type,
"signal_for_tenant: remote-shard dispatch is not wired (Task 5); \
failing closed to avoid silently dropping the dual-write"
);
return Err(crate::TidalError::internal(
"signal_for_tenant",
format!(
"remote-shard dispatch not yet wired: assignment targets shard {} \
but this node is shard {}; cross-node dual-write requires the \
transport-integrated shipper (Task 5, docs/specs/14)",
assignment.shard_id.0, local_shard.0
),
));
}
// Fail CLOSED *before* any write (W8): scan all assignments first. If any
// targets a non-local shard, the cross-node dispatch needed to satisfy
// the dual-write is unwired (Task 5, docs/specs/14), so the whole op
// cannot succeed. Returning here — before the local write — makes the
// operation atomic: a caller that retries the returned Err cannot
// double-count the local signal (signal()/record_signal accumulates and
// is not idempotent). A previous version wrote the local half first and
// only then errored, so every retry inflated the local weight.
if let Some(remote) = assignments.iter().find(|a| a.shard_id != local_shard) {
tracing::warn!(
tenant_id = tenant_id.0,
shard_id = remote.shard_id.0,
entity_id = entity_id.as_u64(),
signal_type,
"signal_for_tenant: remote-shard dispatch is not wired (Task 5); \
failing closed BEFORE any write to avoid silently dropping the \
dual-write or double-counting the local half on retry"
);
return Err(crate::TidalError::internal(
"signal_for_tenant",
format!(
"remote-shard dispatch not yet wired: assignment targets shard {} \
but this node is shard {}; cross-node dual-write requires the \
transport-integrated shipper (Task 5, docs/specs/14)",
remote.shard_id.0, local_shard.0
),
));
}
// Every assignment is local: apply the local write(s). On a single node
// all assignments resolve to the same local shard, so the write happens
// once per assignment (normally exactly one).
for _assignment in &assignments {
self.signal(signal_type, entity_id, weight, timestamp)?;
}
Ok(())
}

View File

@ -4,25 +4,74 @@ use std::{
collections::HashMap,
sync::{
Arc,
atomic::{AtomicBool, AtomicU64, Ordering},
atomic::{AtomicBool, Ordering},
},
};
use super::TidalDb;
use crate::{
schema::EntityId,
schema::{EntityId, Timestamp},
session::{self as session_mod, AgentId, SessionId, SessionState},
storage::{Tag, encode_key, parse_key},
wal::format::session::SessionSeqNo,
};
/// Reconstruct a monotonic `Instant` that reflects a session's **true** age from
/// the durable wall-clock anchor `started_at_ns`.
///
/// The monotonic clock is not durable: a restored session has no way to recover
/// the original `Instant`. Seeding it with a fresh `Instant::now()` would reset
/// the session's apparent age to ~zero, silently voiding every monotonic-clock
/// age computation (the TTL sweeper and the policy duration check both measure
/// age, and `build_snapshot` reports `started_at.elapsed()`). Back-dating the
/// `Instant` by the elapsed wall-clock time makes `started_at.elapsed()` and
/// `now.duration_since(started_at)` reflect the real age. `started_at_ns` remains
/// the authoritative source; the back-dated `Instant` is defense-in-depth so any
/// path still reading the monotonic clock stays correct.
///
/// Falls back to `Instant::now()` only if the subtraction would underflow (the
/// recorded start is implausibly far in the past relative to process start).
fn back_dated_instant(started_at_ns: u64, now_ns: u64) -> std::time::Instant {
let elapsed_ns = now_ns.saturating_sub(started_at_ns);
std::time::Instant::now()
.checked_sub(std::time::Duration::from_nanos(elapsed_ns))
.unwrap_or_else(std::time::Instant::now)
}
impl TidalDb {
/// Scan persistent storage for previously archived session snapshots.
/// Scan persistent storage for previously archived session snapshots and
/// reconcile durable session state with what was restored from the WAL.
///
/// Called once at startup from `TidalDbBuilder::open()`. Walks every key
/// in the items storage engine, filters for `Tag::Session` keys, and:
/// Called once at startup from `TidalDbBuilder::open()`, **after**
/// `restore_session_wal_events` has run (in `from_parts`), so `self.sessions`
/// already holds the sessions that had a WAL `Start` without a `Close`. Walks
/// every key in the items storage engine, filters for `Tag::Session` keys,
/// and:
/// - `b"snapshot"` suffix -> deserializes into `closed_sessions`
/// - `b"start"` suffix -> logs a warning (session was active at shutdown)
/// - `b"start"` suffix -> a session that was active at last shutdown
///
/// This routine re-derives two durable invariants that the in-memory restore
/// cannot infer on its own:
///
/// 1. **Id-allocator high-water mark.** `next_session_id` is seeded to 1 on
/// every open; if it is not advanced past *every* durable session id, a
/// clean restart re-issues id 1 and the next `close_session` overwrites the
/// oldest archived snapshot — silent, permanent data loss. We track the max
/// id over every snapshot AND every start record and CAS-advance the
/// allocator past it (saturating, mirroring `restore_session_wal_events`).
/// Because this runs after the WAL path and the CAS only ever moves the
/// allocator forward, the final value is the max over both sources
/// regardless of call order.
///
/// 2. **Orphaned start records.** A `b"start"` record with no live session
/// and no archived snapshot describes a session that was active at crash
/// time but whose WAL `Start` is gone (the append-only session journal is
/// never compacted, and recovery stops at the first torn record, so later
/// starts can be lost). Left alone it re-warns on every open forever and
/// leaks a dead key. We seal it once: build a frozen, zero-signal closed
/// snapshot (true age from `started_at_ns`) and atomically `put` the
/// snapshot + `delete` the start record, converting the unrecoverable
/// session into an archived record.
///
/// In ephemeral mode the in-memory backend starts empty each time so this
/// scan always returns nothing -- which is correct.
@ -33,6 +82,13 @@ impl TidalDb {
let engine = storage.items_engine();
let mut restored = 0usize;
let mut orphaned = 0usize;
let mut sealed = 0usize;
// High-water mark over every durable session id (snapshots + starts).
let mut max_id: Option<u64> = None;
// Orphaned start records to seal after the scan, as
// (session_id, user_id, started_at_ns, metadata). Deferred so we never
// mutate storage mid-iteration.
let mut orphans: Vec<(u64, u64, u64, HashMap<String, String>)> = Vec::new();
for item in engine.scan_prefix(&[]) {
let (key, value) = match item {
@ -51,6 +107,9 @@ impl TidalDb {
match suffix {
b"snapshot" => {
if let Some(snapshot) = session_mod::deserialize_snapshot(&value) {
max_id = Some(
max_id.map_or(snapshot.id.as_u64(), |m| m.max(snapshot.id.as_u64())),
);
self.closed_sessions.insert(snapshot.id, snapshot);
restored += 1;
} else {
@ -58,23 +117,173 @@ impl TidalDb {
}
}
b"start" => {
if let Some((session_id, _user_id, _started_at_ns, _metadata)) =
if let Some((session_id, user_id, started_at_ns, metadata)) =
session_mod::deserialize_start_record(&value)
{
tracing::warn!(
session_id = %session_id,
"session was active at last shutdown; in-memory signal state is lost"
);
let id = session_id.as_u64();
max_id = Some(max_id.map_or(id, |m| m.max(id)));
orphaned += 1;
// A start record whose session was rehydrated as active
// from the WAL will be cleaned up by its eventual close;
// leave it. Only seal records with no live session and no
// durable snapshot — those are truly unrecoverable.
if self.sessions.contains_key(&session_id)
|| self.closed_sessions.contains_key(&session_id)
{
tracing::warn!(
session_id = %session_id,
"session was active at last shutdown; in-memory signal state is lost"
);
} else {
orphans.push((id, user_id, started_at_ns, metadata));
}
}
orphaned += 1;
}
_ => {}
}
}
if restored > 0 || orphaned > 0 {
tracing::info!(restored, orphaned, "session restore complete");
// Advance the id allocator past the highest durable session id so a
// reopen never re-issues a live/archived id and overwrites its snapshot.
if let Some(id) = max_id {
self.advance_next_session_id(id);
}
// Seal each truly-orphaned start record into a frozen closed snapshot.
for (session_id, user_id, started_at_ns, metadata) in orphans {
if self.seal_orphaned_start(session_id, user_id, started_at_ns, metadata) {
sealed += 1;
}
}
if restored > 0 || orphaned > 0 || sealed > 0 {
tracing::info!(restored, orphaned, sealed, "session restore complete");
}
}
/// Advance `next_session_id` past `id` with a saturating CAS loop.
///
/// Idempotent and monotonic: the allocator only ever moves forward, so it
/// converges to the max over every restore source regardless of call order.
/// A restored id of `u64::MAX` saturates the allocator at the ceiling rather
/// than wrapping back to 0 (which would re-issue a live id).
fn advance_next_session_id(&self, id: u64) {
loop {
let current = self.next_session_id.load(Ordering::Acquire);
if id < current {
break;
}
if self
.next_session_id
.compare_exchange(
current,
id.saturating_add(1),
Ordering::Release,
Ordering::Relaxed,
)
.is_ok()
{
break;
}
}
}
/// Seal an orphaned `b"start"` record into a frozen, zero-signal closed
/// snapshot, atomically replacing the start key with a snapshot key.
///
/// Returns `true` if the record was sealed. The snapshot carries the true
/// wall-clock age (from the durable `started_at_ns`) and zero in-memory
/// signals — explicitly marking that the session's live signal state was
/// lost. This stops the recurring "orphaned" warning, reclaims the key, and
/// preserves the session as an archived record retrievable via
/// `session_snapshot`.
fn seal_orphaned_start(
&self,
session_id: u64,
user_id: u64,
started_at_ns: u64,
metadata: HashMap<String, String>,
) -> bool {
let Some(storage) = self.storage.as_ref() else {
return false;
};
let snapshot_key = encode_key(EntityId::new(session_id), Tag::Session, b"snapshot");
let start_key = encode_key(EntityId::new(session_id), Tag::Session, b"start");
let sid = SessionId::from_raw(session_id);
// Defense-in-depth against scan order and torn closes: never overwrite an
// existing durable snapshot with a zero-signal seal. If a snapshot is
// already present (a torn close left snapshot+start both durable), the
// snapshot is authoritative — just delete the stale start record and warm
// the archive from the real snapshot.
if let Ok(Some(bytes)) = storage.items_engine().get(&snapshot_key) {
if let Some(snapshot) = session_mod::deserialize_snapshot(&bytes) {
self.closed_sessions.insert(sid, snapshot);
}
let mut batch = crate::storage::WriteBatch::new();
batch.delete(start_key);
if let Err(e) = storage.items_engine().write_batch(batch) {
tracing::warn!(
error = %e,
session_id,
"failed to delete stale start record shadowed by a durable snapshot"
);
return false;
}
tracing::info!(
session_id,
"deleted stale start record shadowed by an existing durable snapshot"
);
return true;
}
// Build a zero-signal SessionState carrying the durable identity, then
// freeze it. `agent_id` is not in the start record, so use the fallback
// "restored" marker (always a valid AgentId). The frozen `duration_ms` is
// the TRUE wall-clock age from `started_at_ns`; `build_frozen_snapshot`
// takes the duration explicitly, so the monotonic `started_at` here only
// needs to be self-consistent — back-date it for the same reason the live
// restore does, so any future reader of the monotonic clock stays correct.
let now_ns = Timestamp::now().as_nanos();
let duration_ms = now_ns.saturating_sub(started_at_ns) / 1_000_000;
let started_at = back_dated_instant(started_at_ns, now_ns);
let agent_id = AgentId::new("restored").expect("'restored' is a valid AgentId");
let state = SessionState::new(
SessionId::from_raw(session_id),
user_id,
agent_id,
String::new(),
started_at,
started_at_ns,
metadata,
Arc::new(AtomicBool::new(true)),
);
let snapshot = session_mod::build_frozen_snapshot(&state, duration_ms);
let snapshot_bytes = session_mod::serialize_snapshot(&snapshot);
let mut batch = crate::storage::WriteBatch::new();
batch.put(snapshot_key, snapshot_bytes);
batch.delete(start_key);
if let Err(e) = storage.items_engine().write_batch(batch) {
tracing::warn!(
error = %e,
session_id,
"failed to seal orphaned session start record"
);
return false;
}
// Warm the in-memory archive so session_snapshot resolves without a
// storage round-trip and active_sessions never lists it.
self.closed_sessions.insert(sid, snapshot);
self.enforce_closed_session_cap();
tracing::info!(
session_id,
"sealed orphaned session start record into a frozen closed snapshot"
);
true
}
/// Replay session journal events to restore active sessions from a crash.
@ -86,6 +295,21 @@ impl TidalDb {
/// Called from `from_parts()` during startup. The storage-backed
/// `restore_sessions()` is additive and handles archived snapshots; this
/// method handles in-flight sessions that were active at crash time.
///
/// # Durable snapshot is authoritative over the journal
///
/// `close_session_internal` commits the durable `b"snapshot"` (and deletes
/// the `b"start"`) **before** appending the journal `Close`, and tolerates a
/// failed `Close` append with only a warning. The append-only session journal
/// is never compacted, so the original `Start` survives forever. A crash
/// between the snapshot commit and the `Close` append therefore leaves a
/// durable snapshot AND a `Start`-without-`Close` in the journal. Restoring
/// such a session as ACTIVE would land the same id in both `self.sessions`
/// and `closed_sessions` — a phantom session that accepts new signals against
/// a session the durable record (and the operator) consider closed. To
/// prevent this we probe storage for the session's `b"snapshot"` key before
/// building the active state and skip any session that has one: the durable
/// snapshot is the record of truth.
#[allow(clippy::too_many_lines)]
pub(super) fn restore_session_wal_events(
&self,
@ -103,6 +327,26 @@ impl TidalDb {
};
for (session_id, (user_id, started_at_ns, agent_id_str, policy_name)) in open_sessions {
// The durable snapshot is authoritative: if this session was already
// archived (snapshot committed but its journal Close was lost/torn),
// it is CLOSED. Restoring it as ACTIVE would place the same id in both
// self.sessions and closed_sessions (phantom state). Skip it — but
// still advance the allocator past its id below so a reuse cannot
// overwrite the snapshot. A point lookup on a cold path.
let has_snapshot = self.storage.as_ref().is_some_and(|storage| {
let key = encode_key(EntityId::new(session_id), Tag::Session, b"snapshot");
storage.items_engine().get(&key).ok().flatten().is_some()
});
if has_snapshot {
tracing::info!(
session_id,
"session has a durable closed snapshot; not restoring as active \
(Close journal record was lost between snapshot commit and append)"
);
self.advance_next_session_id(session_id);
continue;
}
if schema.session_policy(&policy_name).is_none() {
tracing::warn!(
session_id,
@ -137,23 +381,23 @@ impl TidalDb {
.map(|(_, _, _, meta)| meta)
.unwrap_or_default();
let state = Arc::new(SessionState {
id: SessionId::from_raw(session_id),
// The monotonic Instant is not durable; back-date it from the durable
// `started_at_ns` so `started_at.elapsed()`, the TTL sweeper, and the
// policy duration check all see the session's TRUE age rather than
// time-since-restore. Seeding with a raw `Instant::now()` would reset
// a restored session's age to ~zero and silently void TTL enforcement.
let started_at = back_dated_instant(started_at_ns, Timestamp::now().as_nanos());
let state = Arc::new(SessionState::new(
SessionId::from_raw(session_id),
user_id,
agent_id,
policy_name: policy_name.clone(),
// We lost the exact monotonic Instant -- approximate with "now".
started_at: std::time::Instant::now(),
policy_name.clone(),
started_at,
started_at_ns,
metadata,
signals: dashmap::DashMap::new(),
signaled_entities: dashmap::DashMap::new(),
annotations: std::sync::Mutex::new(Vec::new()),
signals_written: AtomicU64::new(0),
signals_rejected: AtomicU64::new(0),
audit_log: std::sync::Mutex::new(session_mod::AuditLog::new()),
closed,
});
));
// Replay signals and annotations into the restored session state.
// Advance the seqno HWM so incoming replication events are correctly deduped.
@ -223,29 +467,10 @@ impl TidalDb {
let sid = SessionId::from_raw(session_id);
self.sessions.insert(sid, state);
// Advance next_session_id past any restored session IDs. Use a
// saturating add so a restored id of u64::MAX cannot wrap the
// allocator back to 0 (which would re-issue a live id); at the
// saturation ceiling the CAS becomes a no-op once `current ==
// u64::MAX`, leaving the allocator pinned rather than wrapping.
loop {
let current = self.next_session_id.load(Ordering::Acquire);
if session_id < current {
break;
}
if self
.next_session_id
.compare_exchange(
current,
session_id.saturating_add(1),
Ordering::Release,
Ordering::Relaxed,
)
.is_ok()
{
break;
}
}
// Advance next_session_id past any restored session IDs (saturating,
// so a restored id of u64::MAX pins the allocator rather than wrapping
// back to 0 and re-issuing a live id).
self.advance_next_session_id(session_id);
tracing::info!(session_id, user_id, "restored active session from WAL");
}
@ -383,4 +608,191 @@ mod tests {
);
db.close().unwrap();
}
/// Build a frozen, zero-signal snapshot for `session_id`/`user_id` so a test
/// can plant a durable `b"snapshot"` row directly in storage.
fn frozen_snapshot_bytes(session_id: u64, user_id: u64) -> Vec<u8> {
use std::sync::{Arc, atomic::AtomicBool};
use crate::session::{AgentId, SessionId, SessionState};
let state = SessionState::new(
SessionId::from_raw(session_id),
user_id,
AgentId::new("agent").unwrap(),
"agent".to_string(),
std::time::Instant::now(),
crate::schema::Timestamp::now().as_nanos(),
std::collections::HashMap::new(),
Arc::new(AtomicBool::new(true)),
);
let snap = crate::session::build_frozen_snapshot(&state, 0);
crate::session::serialize_snapshot(&snap)
}
/// CRITICAL regression: a session with a durable `b"snapshot"` is CLOSED. A
/// stale `Start`-without-`Close` in the journal (the snapshot committed but
/// its `Close` append was lost) must NOT restore it as ACTIVE — that would
/// land the same id in both `self.sessions` and the durable archive (phantom
/// state). The restore path probes for the snapshot and skips, while still
/// advancing the allocator past the id.
#[test]
fn durable_snapshot_blocks_active_restore() {
use crate::{
schema::EntityId,
session::SessionId,
storage::{Tag, encode_key},
};
let dir = tempfile::tempdir().unwrap();
let db = TidalDb::builder()
.with_data_dir(dir.path())
.with_schema(schema_with_session_policy())
.open()
.unwrap();
// Plant a durable closed snapshot for id 5.
let snap_key = encode_key(EntityId::new(5), Tag::Session, b"snapshot");
db.storage
.as_ref()
.unwrap()
.items_engine()
.put(&snap_key, &frozen_snapshot_bytes(5, 99))
.unwrap();
// A surviving Start-without-Close for the SAME id.
let events = vec![SessionWalEvent::Start {
session_id: 5,
user_id: 99,
started_at_ns: crate::schema::Timestamp::now().as_nanos(),
agent_id: "agent".to_string(),
policy_name: "agent".to_string(),
}];
db.restore_session_wal_events(&events);
// The session must NOT be active (durable snapshot is authoritative)...
assert!(
!db.sessions.contains_key(&SessionId::from_raw(5)),
"a session with a durable snapshot must not be restored as active"
);
// ...but the allocator must still have advanced past its id so a reuse
// cannot overwrite the snapshot.
assert!(
db.next_session_id.load(Ordering::Acquire) > 5,
"the allocator must advance past a snapshot-shadowed restore id"
);
db.close().unwrap();
}
/// WARNING regression: an orphaned `b"start"` record (no live session, no
/// durable snapshot, and no matching WAL Start) must be sealed exactly once
/// into a frozen closed snapshot — the start key is deleted, a snapshot key
/// is written, the session is queryable as archived, and the allocator is
/// advanced past its id. This stops the permanently-re-firing "orphaned"
/// warning and reclaims the dead key.
#[test]
fn orphaned_start_is_sealed_into_closed_snapshot() {
use std::sync::{Arc, atomic::AtomicBool};
use crate::{
schema::EntityId,
session::{AgentId, SessionId, SessionState},
storage::{Tag, encode_key},
};
let dir = tempfile::tempdir().unwrap();
let db = TidalDb::builder()
.with_data_dir(dir.path())
.with_schema(schema_with_session_policy())
.open()
.unwrap();
// Plant an orphaned start record for id 7 (no WAL Start, no snapshot).
let orphan = SessionState::new(
SessionId::from_raw(7),
42,
AgentId::new("agent").unwrap(),
"agent".to_string(),
std::time::Instant::now(),
crate::schema::Timestamp::now().as_nanos(),
std::collections::HashMap::new(),
Arc::new(AtomicBool::new(false)),
);
let start_bytes = crate::session::serialize_start_record(&orphan);
let start_key = encode_key(EntityId::new(7), Tag::Session, b"start");
let snapshot_key = encode_key(EntityId::new(7), Tag::Session, b"snapshot");
let engine = db.storage.as_ref().unwrap().items_engine();
engine.put(&start_key, &start_bytes).unwrap();
db.restore_sessions();
// Start key deleted, snapshot key present.
let engine = db.storage.as_ref().unwrap().items_engine();
assert!(
engine.get(&start_key).unwrap().is_none(),
"the orphaned start record must be deleted after sealing"
);
assert!(
engine.get(&snapshot_key).unwrap().is_some(),
"a frozen closed snapshot must replace the orphaned start record"
);
// Queryable as archived, not active.
let snap = db.session_snapshot(SessionId::from_raw(7)).unwrap();
assert_eq!(snap.user_id, 42);
assert!(
!db.active_sessions()
.iter()
.any(|s| s.id == SessionId::from_raw(7)),
"a sealed orphan must not appear as an active session"
);
// Allocator advanced past the orphan id.
assert!(
db.next_session_id.load(Ordering::Acquire) > 7,
"the allocator must advance past the sealed orphan id"
);
// Idempotent: a second restore finds no orphan (snapshot now present) and
// does not re-warn or re-seal.
db.restore_sessions();
let engine = db.storage.as_ref().unwrap().items_engine();
assert!(engine.get(&start_key).unwrap().is_none());
db.close().unwrap();
}
/// BLOCKER regression (unit-level): `restore_sessions` advances the id
/// allocator past every durable snapshot id, so a reopen never re-issues an
/// archived id.
#[test]
fn restore_sessions_advances_allocator_over_snapshots() {
use crate::{
schema::EntityId,
storage::{Tag, encode_key},
};
let dir = tempfile::tempdir().unwrap();
let db = TidalDb::builder()
.with_data_dir(dir.path())
.with_schema(schema_with_session_policy())
.open()
.unwrap();
// Plant durable snapshots for ids 3 and 11.
for id in [3u64, 11] {
let key = encode_key(EntityId::new(id), Tag::Session, b"snapshot");
db.storage
.as_ref()
.unwrap()
.items_engine()
.put(&key, &frozen_snapshot_bytes(id, id))
.unwrap();
}
db.restore_sessions();
assert!(
db.next_session_id.load(Ordering::Acquire) > 11,
"the allocator must advance past the highest durable snapshot id (11)"
);
db.close().unwrap();
}
}

View File

@ -4,7 +4,7 @@ use std::{
collections::HashMap,
sync::{
Arc,
atomic::{AtomicBool, AtomicU64, Ordering},
atomic::{AtomicBool, Ordering},
},
};
@ -63,22 +63,16 @@ impl TidalDb {
let started_at = std::time::Instant::now();
let started_at_ns = Timestamp::now().as_nanos();
let state = Arc::new(SessionState {
id: session_id,
let state = Arc::new(SessionState::new(
session_id,
user_id,
agent_id: parsed_agent_id.clone(),
policy_name: policy_name.to_owned(),
parsed_agent_id.clone(),
policy_name.to_owned(),
started_at,
started_at_ns,
metadata,
signals: dashmap::DashMap::new(),
signaled_entities: dashmap::DashMap::new(),
annotations: std::sync::Mutex::new(Vec::new()),
signals_written: AtomicU64::new(0),
signals_rejected: AtomicU64::new(0),
audit_log: std::sync::Mutex::new(session_mod::AuditLog::new()),
closed: Arc::clone(&closed),
});
Arc::clone(&closed),
));
self.sessions.insert(session_id, Arc::clone(&state));
@ -267,7 +261,7 @@ impl TidalDb {
/// cooperate — a double-remove of the same oldest id simply returns `None`.
/// Any overshoot from concurrent inserts is therefore transient and
/// self-healing, never a persistent cap violation.
fn enforce_closed_session_cap(&self) {
pub(super) fn enforce_closed_session_cap(&self) {
if self.closed_sessions.len() <= session_mod::MAX_CLOSED_SESSIONS {
return;
}
@ -375,7 +369,11 @@ impl TidalDb {
&& let Some(policy) = schema.session_policy(&state.policy_name)
{
let evaluator = session_mod::PolicyEvaluator::new(policy, &state.policy_name);
match evaluator.check(signal_type, state, std::time::Instant::now()) {
// Wall-clock now (durable age basis), not the monotonic Instant: a
// restored session reseeds `started_at`, so the policy duration check
// must measure off the durable `started_at_ns` exactly as the sweeper
// and `close_session_internal` do.
match evaluator.check(signal_type, state, Timestamp::now().as_nanos()) {
Ok(()) => {
// Record accepted entry via bounded AuditLog.
let entry = AuditEntry {

View File

@ -63,6 +63,40 @@ impl TidalDb {
.map_err(TidalError::from)
}
/// Shared weight-validation guard for the signal-write entry points.
///
/// Both [`signal`](Self::signal) and [`signal_scoped`](Self::signal_scoped)
/// run this identical preamble, so it lives in one place — like
/// [`check_write_backpressure`](Self::check_write_backpressure) — to keep the
/// two admission policies from drifting apart.
///
/// Rejects the write with [`TidalError::InvalidInput`] when the weight is:
/// - **non-finite** (`NaN` / `±Inf`): would corrupt every decay-score CAS, or
/// - **finite negative**: spec §8 says "negative weights are rejected
/// (negative signals use separate signal types, not negative weights)". A
/// negative weight folds into the decay accumulator and can drive the hot
/// decay score below zero, violating the INV-SIG-3 "decay scores are
/// non-negative" invariant (and tripping the `debug_assert!` in
/// [`HotSignalState::on_signal`](crate::signals::HotSignalState::on_signal)).
///
/// A weight of exactly `0.0` is permitted: it is a no-op fold that records the
/// event in the warm-tier count without perturbing the decay score.
pub(crate) fn validate_signal_weight(weight: f64) -> crate::Result<()> {
if !weight.is_finite() {
return Err(TidalError::invalid_input(
"signal weight must be finite (NaN and Inf are not allowed)",
));
}
if weight < 0.0 {
return Err(TidalError::invalid_input(
"signal weight must be non-negative; negative engagement uses \
separate signal types (e.g. \"dislike\"), not negative weights \
(spec §8)",
));
}
Ok(())
}
/// Shared write-admission guard for the signal-write entry points.
///
/// Rejects the write with [`TidalError::Backpressure`] when either:
@ -113,6 +147,8 @@ impl TidalDb {
/// # Errors
///
/// - `TidalError::Internal` if no ledger is wired (use `with_schema()`).
/// - `TidalError::InvalidInput` if `weight` is non-finite (NaN/Inf) or
/// finite-negative (negative engagement uses separate signal types).
/// - `TidalError::Schema` if `signal_type` is not defined in the schema.
/// - `TidalError::Durability` if the WAL write fails.
/// - `TidalError::Backpressure` if the WAL queue is saturated or a backup is in progress.
@ -125,11 +161,7 @@ impl TidalDb {
timestamp: Timestamp,
) -> crate::Result<()> {
self.require_writeable("signal")?;
if !weight.is_finite() {
return Err(TidalError::invalid_input(
"signal weight must be finite (NaN and Inf are not allowed)",
));
}
Self::validate_signal_weight(weight)?;
// Reject the write if a backup is in progress or the WAL queue is
// saturated, before doing any work or starting the latency timer.
@ -174,6 +206,8 @@ impl TidalDb {
/// # Errors
///
/// - `TidalError::Internal` if no ledger is wired (use `with_schema()`).
/// - `TidalError::InvalidInput` if `weight` is non-finite (NaN/Inf) or
/// finite-negative (negative engagement uses separate signal types).
/// - `TidalError::Schema` if `signal_type` is not defined in the schema.
/// - `TidalError::Durability` if the WAL write fails.
/// - `TidalError::Backpressure` if the WAL queue is saturated or a backup is in progress.
@ -187,11 +221,7 @@ impl TidalDb {
share_policy_version: u16,
) -> crate::Result<()> {
self.require_writeable("signal_scoped")?;
if !weight.is_finite() {
return Err(TidalError::invalid_input(
"signal weight must be finite (NaN and Inf are not allowed)",
));
}
Self::validate_signal_weight(weight)?;
self.check_write_backpressure()?;
@ -325,7 +355,11 @@ impl TidalDb {
///
/// # Errors
///
/// Returns errors from the underlying `signal()` method.
/// - `TidalError::InvalidInput` if `for_user` is set and `entity_id` exceeds
/// the `u32` item-universe limit (it would alias a lower id in the durable
/// per-user rows).
/// - Any error from the underlying [`signal`](Self::signal) call (invalid
/// weight, missing ledger, unknown signal type, WAL durability/backpressure).
pub fn signal_with_context(
&self,
signal_type: &str,
@ -336,12 +370,35 @@ impl TidalDb {
creator_id: Option<u64>,
) -> crate::Result<()> {
self.require_writeable("signal_with_context")?;
// When a `for_user` identity is present this call narrows the item id to
// its u32 slot and writes it into DURABLE Tag::HardNeg / Tag::UserState
// rows. A bare `as u32` truncation would silently alias two items whose
// ids share their low 32 bits — a permanent cross-id collision in the
// hard-negative / seen / saved / liked correctness primitives that
// survives restart. Reject an over-range id up front (mirroring
// `write_item_with_metadata`) so a colliding durable row never lands on
// disk. Done before the base `signal()` so a rejected write leaves no
// trace at all.
if for_user.is_some() && entity_id.as_u64() > u64::from(u32::MAX) {
let raw = entity_id.as_u64();
return Err(TidalError::invalid_input(format!(
"item entity id {raw} exceeds the u32 item-universe limit ({}); \
allocate dense item IDs within the u32 range",
u32::MAX
)));
}
// Record the base signal.
self.signal(signal_type, entity_id, weight, timestamp)?;
// Signal dispatch: side effects based on signal type and context.
if let Some(user_id) = for_user {
let item_u32 = entity_id.as_u64() as u32;
// Lossless: the over-range guard above already rejected any id past
// u32::MAX. Route through the observable helper anyway so this path
// shares the single narrowing seam with the Hide relationship path
// and items.rs (a future relaxation of the guard stays observable).
let item_u32 = crate::entities::user_state::narrow_item_slot(entity_id.as_u64());
// Durable-write target for the write-through side effects below.
// `None` in ephemeral mode (no persistence; in-memory state is the
// whole story and there is nothing to crash-recover).
@ -747,4 +804,93 @@ mod tests {
)
.unwrap();
}
/// Regression (signals, pass2): the finite-weight + non-negative-weight rule
/// is shared by `signal` and `signal_scoped` via `validate_signal_weight`, so
/// neither path can drift. Spec §8: "negative weights are rejected (negative
/// signals use separate signal types, not negative weights)". A finite
/// negative weight folds into the decay accumulator and can drive the hot
/// decay score below zero, violating INV-SIG-3.
#[test]
fn both_signal_paths_reject_negative_and_non_finite_weight() {
let db = TidalDb::builder()
.ephemeral()
.with_schema(schema_with_view())
.open()
.unwrap();
for bad in [-1.0_f64, -f64::MIN_POSITIVE, f64::NAN, f64::INFINITY] {
let unscoped = db.signal("view", EntityId::new(1), bad, Timestamp::now());
assert!(
matches!(unscoped, Err(crate::schema::TidalError::InvalidInput(_))),
"signal must reject weight {bad}, got {unscoped:?}"
);
let scoped = db.signal_scoped(
"view",
EntityId::new(1),
bad,
Timestamp::now(),
SignalScope::Local,
0,
);
assert!(
matches!(scoped, Err(crate::schema::TidalError::InvalidInput(_))),
"signal_scoped must reject weight {bad} through the same shared guard, got {scoped:?}"
);
}
// 0.0 and positive weights remain accepted (0.0 is a no-op fold that
// still records the event in the warm-tier count).
db.signal("view", EntityId::new(2), 0.0, Timestamp::now())
.unwrap();
db.signal("view", EntityId::new(3), 1.5, Timestamp::now())
.unwrap();
}
/// Regression (W5, pass2): `signal_with_context` writes the item id into
/// DURABLE `Tag::HardNeg` / `Tag::UserState` rows narrowed to a u32 slot. An id
/// past `u32::MAX` would silently alias a lower id and collide permanently on
/// disk. The path must reject such an id up front (mirroring
/// `write_item_with_metadata`) so no colliding durable row ever lands.
#[test]
fn signal_with_context_rejects_over_range_item_id_for_user_path() {
let db = TidalDb::builder()
.ephemeral()
.with_schema(schema_with_view())
.open()
.unwrap();
let oversized = EntityId::new(u64::from(u32::MAX) + 1);
let err = db
.signal_with_context(
"view",
oversized,
1.0,
Timestamp::now(),
Some(7), // for_user present -> durable per-user rows would be written
None,
)
.unwrap_err();
assert!(
matches!(err, crate::schema::TidalError::InvalidInput(_)),
"over-range item id on the for_user path must be rejected, got {err:?}"
);
// The boundary id (u32::MAX) narrows losslessly and is accepted.
db.signal_with_context(
"view",
EntityId::new(u64::from(u32::MAX)),
1.0,
Timestamp::now(),
Some(7),
None,
)
.unwrap();
// With no for_user identity, no per-user durable row is written, so the
// over-range guard does not apply — the base signal is recorded.
db.signal_with_context("view", oversized, 1.0, Timestamp::now(), None, None)
.unwrap();
}
}

View File

@ -376,16 +376,22 @@ pub(super) fn rebuild_item_indexes(
let (key, value) = entry.map_err(TidalError::from)?;
if let Some(entity_id) = item_meta_entity_id(&key) {
// The write path warns and refuses to index entity IDs above
// u32::MAX (roaring bitmaps are u32-keyed); mirror that here so a
// truncating collision is observable rather than silent.
// The write path (db/items.rs) REJECTS any item id > u32::MAX before
// it ever lands in storage, so the only way one reaches here is a
// corrupt or hostile durable store. Mirror the write path's refusal:
// skip the row entirely rather than truncate it into a colliding u32
// slot, which would alias a real lower id and manufacture phantom
// query hits. Failing safe on corrupt input beats silently corrupting
// the in-memory universe/indexes.
if entity_id.as_u64() > u64::from(u32::MAX) {
tracing::warn!(
entity_id = entity_id.as_u64(),
"entity ID exceeds u32::MAX during rebuild; \
universe bitmap entry will collide with a lower ID"
"entity ID exceeds u32::MAX during rebuild; skipping \
(write path never persists such ids)"
);
continue;
}
// In range by the guard above, so the narrowing is exact.
let id_u32 = entity_id.as_u64() as u32;
let meta = deserialize_metadata(&value);
@ -598,8 +604,13 @@ pub(super) fn run_checkpoint_thread(
// window to the interval instead of "everything since the last clean
// shutdown" (the community ledger is the SOLE durable copy of
// per-contributor identity, so this is its only periodic durability).
checkpoint_derived_ledgers(
//
// The cohort ledger is already checkpointed above with `meta`, so the
// shared helper is called WITHOUT a cohort argument here to avoid a
// double cohort write in the same cycle.
let _ = checkpoint_secondary_ledgers(
storage.as_ref(),
None,
community_ledger.as_ref(),
co_engagement.as_ref(),
preference_vectors.as_ref(),
@ -622,31 +633,70 @@ pub(super) fn run_checkpoint_thread(
let _ = &metrics;
}
/// Checkpoint the derived per-user / per-contributor ledgers (community,
/// co-engagement, preference vectors) into `storage`.
/// Checkpoint the *secondary derived* ledgers — cohort (optional), community,
/// co-engagement, and preference vectors — into `storage`, with ONE consistent
/// set of guards and error handling.
///
/// None of these has a WAL backstop for its per-user/per-contributor identity, so
/// this periodic checkpoint is what bounds their post-crash loss window. Each is
/// independent and non-fatal: a failure is logged and the others still run.
fn checkpoint_derived_ledgers(
/// This is the single shared implementation behind every secondary-checkpoint
/// site: the periodic checkpoint thread (above), shutdown
/// ([`TidalDb::shutdown_inner`](crate::db::TidalDb)), and backup
/// ([`TidalDb::create_backup`](crate::db::TidalDb)). Previously each site
/// open-coded the same four-checkpoint block with subtly different guards
/// (`entry_count() > 0` / `edge_count() > 0` / `!is_empty()` / none) and
/// divergent error handling; a new derived ledger or a changed guard had to be
/// edited in four places or one path silently skipped a checkpoint. Centralizing
/// it makes that drift impossible.
///
/// `cohort` is `Some((ledger, meta))` only where the cohort write is wanted in
/// this pass (shutdown / backup). The periodic thread passes `None` because it
/// already checkpoints the cohort ledger with its own `meta` earlier in the same
/// cycle — passing `Some` here would write it twice.
///
/// None of these ledgers has a WAL backstop for its per-user/per-contributor
/// identity, so this checkpoint is what bounds their post-crash loss window. Each
/// is independent: a failure is logged and the remaining checkpoints still run.
/// The FIRST error is returned so callers that must surface it (shutdown folds it
/// into its `first_err`) can, while callers that only need best-effort (backup,
/// periodic) discard it.
pub(super) fn checkpoint_secondary_ledgers(
storage: &dyn StorageEngine,
cohort: Option<(
&CohortSignalLedger,
crate::signals::checkpoint::CheckpointMeta,
)>,
community_ledger: &crate::governance::CommunityLedger,
co_engagement: &crate::entities::CoEngagementIndex,
preference_vectors: &crate::entities::PreferenceVectors,
) {
) -> Option<TidalError> {
let mut first_err: Option<TidalError> = None;
if let Some((cohort_ledger, meta)) = cohort
&& cohort_ledger.entry_count() > 0
&& let Err(e) = cohort_ledger.checkpoint(storage, meta)
{
tracing::error!(error = %e, "cohort checkpoint failed");
first_err.get_or_insert(e);
}
// The community ledger is the SOLE durable copy of per-contributor identity,
// so it is always checkpointed (no emptiness guard): an empty checkpoint is a
// cheap no-op, but skipping it on a transiently-empty in-memory map could
// leave a stale durable snapshot.
if let Err(e) = community_ledger.checkpoint(storage) {
tracing::error!(error = %e, "periodic community checkpoint failed");
tracing::error!(error = %e, "community checkpoint failed");
first_err.get_or_insert(e);
}
if co_engagement.edge_count() > 0
&& let Err(e) = co_engagement.checkpoint(storage)
{
tracing::error!(error = %e, "periodic co-engagement checkpoint failed");
tracing::error!(error = %e, "co-engagement checkpoint failed");
first_err.get_or_insert(e);
}
if !preference_vectors.is_empty()
&& let Err(e) = preference_vectors.checkpoint(storage)
{
tracing::error!(error = %e, "periodic preference-vector checkpoint failed");
tracing::error!(error = %e, "preference-vector checkpoint failed");
first_err.get_or_insert(e);
}
first_err
}
/// Poll text-index liveness and schedule a resync rebuild when unhealthy.
@ -1021,3 +1071,223 @@ mod wal_lag_tests {
assert_eq!(lag, a + b, "no checkpoint => all WAL bytes are lag");
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod rebuild_tests {
use std::collections::HashMap;
use roaring::RoaringBitmap;
use super::{ItemIndexes, rebuild_entity_state, rebuild_item_indexes};
use crate::{
db::{metadata::serialize_metadata, storage_box::StorageBox},
entities::{
CreatorItemsBitmap, InteractionLedger, UserStateIndex,
relationship::{
RelationshipType, encode_relationship_key, serialize_relationship_value,
},
},
schema::{EntityId, Timestamp},
storage::{
InMemoryBackend, Tag, encode_key,
indexes::{bitmap::BitmapIndex, range::RangeIndex},
},
};
fn empty_memory_storage() -> StorageBox {
StorageBox::Memory {
items: InMemoryBackend::new(),
users: InMemoryBackend::new(),
creators: InMemoryBackend::new(),
}
}
/// Write one item-metadata row exactly as the live write path does
/// (`encode_key(id, Tag::Meta, b"") -> serialize_metadata(map)`).
fn put_item_meta(storage: &StorageBox, id: EntityId, meta: &HashMap<String, String>) {
let key = encode_key(id, Tag::Meta, b"");
storage
.items_engine()
.put(&key, &serialize_metadata(meta))
.unwrap();
}
fn meta(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs
.iter()
.map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
.collect()
}
/// Direct contract test for `rebuild_entity_state`: durable relationship
/// edges (block / hide / follow / interaction) must reconstruct the identical
/// in-memory `UserStateIndex` sets and interaction ledger after a restart.
#[test]
fn rebuild_entity_state_reconstructs_relationship_state() {
let storage = empty_memory_storage();
let ts = Timestamp::now();
let (user, creator, item) = (EntityId::new(1), EntityId::new(100), EntityId::new(7));
// Persist edges into the users keyspace exactly as the write path does.
let edges = [
(user, RelationshipType::Blocks, creator),
(user, RelationshipType::Hide, item),
(user, RelationshipType::Follows, creator),
(user, RelationshipType::InteractionWeight, creator),
];
for (from, rel, to) in edges {
let key = encode_relationship_key(from, rel, to);
let value = serialize_relationship_value(2.5, ts);
storage.users_engine().put(&key, &value).unwrap();
}
let user_state = UserStateIndex::new();
let interaction = InteractionLedger::new();
rebuild_entity_state(&storage, &user_state, &interaction).unwrap();
// Blocks -> blocked_creators.
assert!(
user_state.blocked_creators(1).contains(&100),
"block edge must rebuild into blocked_creators"
);
// Hide -> hidden_items.
assert!(
user_state.hidden_items(1).contains(7),
"hide edge must rebuild into hidden_items"
);
// Follows -> followed_creators (and reverse follower set).
assert!(
user_state.followed_creators(1).contains(&100),
"follow edge must rebuild into followed_creators"
);
// InteractionWeight -> interaction ledger carries the persisted weight.
assert!(
interaction.score(1, 100, ts.as_nanos()) > 0.0,
"interaction edge must rebuild a non-zero decayed weight"
);
}
/// Direct contract test for `rebuild_item_indexes`: a persisted item's
/// metadata must reconstruct the identical universe bitmap, every metadata
/// index, the `creator_items` bitmap, and the `created_at` recency entry that the
/// shared `ItemIndexes::index` write path would produce.
#[test]
fn rebuild_item_indexes_reconstructs_every_index() {
let storage = empty_memory_storage();
// One fully-tagged item, written via the canonical metadata codec.
put_item_meta(
&storage,
EntityId::new(42),
&meta(&[
("category", "jazz"),
("format", "audio"),
("creator_id", "100"),
("tags", "smooth,live"),
("duration", "180"),
("created_at", "1000"),
]),
);
let mut universe = RoaringBitmap::new();
let category = BitmapIndex::new("category");
let format = BitmapIndex::new("format");
let creator = BitmapIndex::new("creator_id");
let tag = BitmapIndex::new("tags");
let duration = RangeIndex::<u32>::new("duration");
let created_at = RangeIndex::<u64>::new("created_at");
let creator_items = CreatorItemsBitmap::new();
let indexes = ItemIndexes {
category: &category,
format: &format,
creator: &creator,
tag: &tag,
duration: &duration,
created_at: &created_at,
creator_items: &creator_items,
};
rebuild_item_indexes(&storage, &mut universe, &indexes).unwrap();
assert!(universe.contains(42), "item must enter the universe bitmap");
assert!(
category.get("jazz").is_some_and(|bm| bm.contains(42)),
"category index must be rebuilt"
);
assert!(
format.get("audio").is_some_and(|bm| bm.contains(42)),
"format index must be rebuilt"
);
assert!(
creator.get("100").is_some_and(|bm| bm.contains(42)),
"creator index must be rebuilt"
);
assert!(
tag.get("smooth").is_some_and(|bm| bm.contains(42))
&& tag.get("live").is_some_and(|bm| bm.contains(42)),
"each comma-separated tag must be rebuilt"
);
assert!(
creator_items.get(100).is_some_and(|bm| bm.contains(42)),
"creator_items bitmap must be rebuilt for the `following` profile"
);
// Recency: the created_at value must land in the range index.
let recent = std::ops::Bound::Included(&1000u64);
let recent = created_at.range(recent, std::ops::Bound::Included(&1000u64));
assert!(
recent.contains(42),
"created_at recency entry must be rebuilt at the persisted value"
);
}
/// Corrupt/hostile input regression (WARNING fix): the write path rejects any
/// item id > `u32::MAX` before it ever persists, so such a row can only reach
/// the rebuild from a corrupt store. The rebuild must SKIP it — never truncate
/// it into a colliding u32 slot that aliases a real lower id and manufactures
/// phantom query hits.
#[test]
fn rebuild_item_indexes_skips_over_u32_ids_instead_of_colliding() {
let storage = empty_memory_storage();
// A legitimate low item id, and a hostile id that truncates onto it
// (`(1<<32) + 5` -> 5). If the rebuild truncated, both would land on
// slot 5 with last-write-wins metadata corruption.
let low = EntityId::new(5);
let over = EntityId::new((1u64 << 32) + 5);
put_item_meta(&storage, low, &meta(&[("category", "real")]));
put_item_meta(&storage, over, &meta(&[("category", "phantom")]));
let mut universe = RoaringBitmap::new();
let category = BitmapIndex::new("category");
let format = BitmapIndex::new("format");
let creator = BitmapIndex::new("creator_id");
let tag = BitmapIndex::new("tags");
let duration = RangeIndex::<u32>::new("duration");
let created_at = RangeIndex::<u64>::new("created_at");
let creator_items = CreatorItemsBitmap::new();
let indexes = ItemIndexes {
category: &category,
format: &format,
creator: &creator,
tag: &tag,
duration: &duration,
created_at: &created_at,
creator_items: &creator_items,
};
rebuild_item_indexes(&storage, &mut universe, &indexes).unwrap();
// The legitimate row is indexed; the over-range row is skipped, so slot 5
// is NOT cross-contaminated with the "phantom" category.
assert!(universe.contains(5), "the valid low id must be indexed");
assert!(
category.get("real").is_some_and(|bm| bm.contains(5)),
"the valid low id keeps its true category"
);
assert!(
category.get("phantom").is_none(),
"the over-u32::MAX row must be skipped, not truncated into slot 5"
);
}
}

View File

@ -18,13 +18,24 @@ impl TidalDb {
/// policy's `max_session_duration`.
///
/// Called every 60s by the sweeper thread, and once during shutdown.
///
/// Age is measured from the **durable** `started_at_ns` (nanoseconds since
/// the Unix epoch), never from the monotonic `started_at` `Instant`. A
/// session restored on open re-seeds (and back-dates) `started_at`, but only
/// `started_at_ns` is the durable wall-clock anchor: measuring off it ensures
/// a session that was already over its `max_session_duration` at shutdown is
/// reaped on the first startup sweep, exactly as `start_sweeper` documents
/// ("sessions that expired while the process was down ... are reaped promptly
/// at startup"). Reading the monotonic clock here would reset every restored
/// session's age to ~zero and silently void the TTL bound across restarts.
pub(crate) fn sweep_expired_sessions(&self) {
let now = std::time::Instant::now();
let now_ns = crate::schema::Timestamp::now().as_nanos();
let mut expired_ids = Vec::new();
for entry in &self.sessions {
let state = entry.value();
let elapsed = now.duration_since(state.started_at);
let elapsed =
std::time::Duration::from_nanos(now_ns.saturating_sub(state.started_at_ns));
// Look up the policy's max_session_duration.
let max_duration = self
@ -308,4 +319,80 @@ mod tests {
"today's entry must be retained"
);
}
/// CRITICAL/BLOCKER regression: the sweeper must TTL-expire a *restored*
/// session. A restored session re-seeds the monotonic `started_at` with a
/// fresh `Instant::now()`; only the durable `started_at_ns` carries the true
/// age. Measuring age off the monotonic clock would hand every restored
/// session a fresh full lease on each restart and never reap it. The sweeper
/// now measures off `started_at_ns`, so a session that was already past its
/// `max_session_duration` at shutdown is reaped on the first startup sweep.
#[test]
fn sweeper_expires_restored_over_age_session() {
use std::{
sync::{
Arc,
atomic::{AtomicBool, AtomicU64},
},
time::{Duration, Instant},
};
use crate::{
schema::{AgentPolicy, DecaySpec, EntityKind, SchemaBuilder, Timestamp},
session::{AgentId, AuditLog, SessionId, SessionState},
};
let mut b = SchemaBuilder::new();
let _ = b
.signal("view", EntityKind::Item, DecaySpec::Permanent)
.add();
b.session_policy(
"short",
AgentPolicy {
allowed_signals: vec![],
denied_signals: vec![],
// 1-hour cap; our restored session is 2h old.
max_session_duration: Duration::from_secs(3600),
max_signals_per_session: 0,
},
);
let schema = b.build().unwrap();
let db = TidalDb::builder()
.ephemeral()
.with_schema(schema)
.open()
.unwrap();
// Restore shape: monotonic clock fresh, durable start 2h ago.
let now_ns = Timestamp::now().as_nanos();
let two_hours_ns = 2 * 3600 * 1_000_000_000u64;
let session_id = SessionId::from_raw(1);
let state = Arc::new(SessionState {
id: session_id,
user_id: 7,
agent_id: AgentId::new("restored").unwrap(),
policy_name: "short".to_string(),
started_at: Instant::now(),
started_at_ns: now_ns.saturating_sub(two_hours_ns),
metadata: std::collections::HashMap::new(),
signals: dashmap::DashMap::new(),
signaled_entities: dashmap::DashMap::new(),
annotations: std::sync::Mutex::new(Vec::new()),
signals_written: AtomicU64::new(0),
signals_rejected: AtomicU64::new(0),
audit_log: std::sync::Mutex::new(AuditLog::new()),
closed: Arc::new(AtomicBool::new(false)),
});
db.sessions.insert(session_id, state);
assert_eq!(db.active_sessions().len(), 1);
db.force_sweep();
assert_eq!(
db.active_sessions().len(),
0,
"a restored session 2h past a 1h cap must be reaped on the startup sweep"
);
}
}

View File

@ -309,6 +309,53 @@ mod tests {
}
}
/// CRITICAL regression (pass2): the item/creator write path must enqueue
/// via the producer pattern "clone the Sender out of the guard, DROP the
/// guard, then `try_send`" — never a blocking `send` under a held mutex.
/// This reproduces that exact sequence against a FULL bounded outbox (no
/// syncer spawned, so nothing drains it) and asserts the enqueue returns
/// immediately with `Full` instead of blocking. A blocking `send` here would
/// hang forever — and under the old code, with the mutex still held, it would
/// stall every concurrent write. The test running to completion is the proof
/// that the producer never blocks.
#[test]
fn producer_does_not_block_when_outbox_full() {
let config = Config::default();
let pending = open_text_syncer(&one_text_field(), &config, "items", "test-syncer");
// Model the field exactly as `write_item_with_metadata` holds it.
let tx_mutex = std::sync::Mutex::new(pending.write_tx.clone());
// Saturate the outbox to capacity — nothing drains it (no spawn()).
let fill_tx = pending
.write_tx
.as_ref()
.expect("text field declared")
.clone();
for id in 0..WRITE_OUTBOX_CAPACITY as u64 {
fill_tx
.try_send(pending_write(id))
.expect("bounded channel accepts up to capacity");
}
// The producer pattern: clone out of the guard, DROP the guard, try_send.
// If this used a blocking `send`, the test would hang here forever.
let tx = tx_mutex.lock().ok().and_then(|g| g.as_ref().cloned());
let tx = tx.expect("sender present");
match tx.try_send(pending_write(WRITE_OUTBOX_CAPACITY as u64)) {
Err(crossbeam::channel::TrySendError::Full(_)) => {} // non-blocking drop
Ok(()) => panic!("outbox accepted a send past capacity — not bounded"),
Err(other) => panic!("unexpected send error: {other:?}"),
}
// The mutex is immediately re-lockable: the producer never held it across
// the send, so a concurrent writer is not stalled.
assert!(
tx_mutex.try_lock().is_ok(),
"mutex must be free — the producer must not hold it across the send"
);
}
/// An empty text-field set yields a pending handle with no index and no
/// channels, so there is nothing to bound.
#[test]

View File

@ -14,8 +14,8 @@ use crate::{
storage::{
StorageEngine, Tag, encode_key,
vector::{
BruteForceIndex, QuantizationLevel, VectorIndexConfig, insert_embedding,
registry::{EmbeddingSlotState, EmbeddingSource, HnswParams},
QuantizationLevel, insert_embedding,
registry::{EmbeddingSlotState, EmbeddingSource, HnswParams, build_slot_index},
},
},
};
@ -136,12 +136,16 @@ impl TidalDb {
.write()
.map_err(|_| TidalError::internal(op, "embedding_registry write lock poisoned"))?;
if registry.get(kind, slot_name).is_none() {
let config = VectorIndexConfig {
dimensions: embedding.len(),
..VectorIndexConfig::default()
};
// Route backend selection through the size-gated factory rather
// than hardcoding brute force. A freshly auto-registered slot has
// no vectors yet, so the factory builds the exact `BruteForceIndex`
// (correct for a tiny / cold slot — no graph-build cost, exact
// results). When the slot's durable vector count crosses
// `USEARCH_MIN_VECTORS`, the next process restart's
// `rebuild_from_store` promotes it to the production HNSW
// (`UsearchIndex`) automatically — no config plumbing required.
let state = EmbeddingSlotState {
index: Box::new(BruteForceIndex::new(config)),
index: build_slot_index(embedding.len(), 0),
dimensions: embedding.len(),
quantization: QuantizationLevel::F32,
source: EmbeddingSource::External,

View File

@ -300,6 +300,11 @@ impl PreferenceVectors {
/// `Tag::Preference` checkpoint. Skips rows whose stored dimension does not
/// match this store's `dim` (a schema change) and rows that are torn.
///
/// Each restored vector is re-normalized to unit length at the load boundary
/// (after zeroing any NaN component) so the cosine-scoring invariant holds
/// even for a torn or tampered row — see the inline comment at the
/// normalization site.
///
/// # Errors
///
/// Returns storage errors from the underlying engine.
@ -333,9 +338,18 @@ impl PreferenceVectors {
// cosine scoring downstream.
vec.push(if f.is_nan() { 0.0 } else { f });
}
// The stored vector is already L2-normalized; insert directly (set()
// re-normalizes, which is a no-op for a unit vector) and seed the
// adaptive update count so the learning rate resumes where it was.
// Re-establish the unit-length invariant at the load boundary instead
// of trusting the stored bytes. `cosine_similarity` divides only by the
// candidate norm — it assumes the stored preference is already unit
// length — so a torn row (a zeroed NaN component, or finite-but-tampered
// values) that is no longer unit length would mis-scale every similarity
// for this user. Re-normalizing here makes that assumption true and
// matches `set()`'s contract. `l2_normalize` leaves an all-zero vector
// untouched, so a fully-corrupt row degrades to a zero preference
// (cosine returns 0.0) rather than a poisoned non-unit one.
l2_normalize(&mut vec);
// Seed the adaptive update count so the learning rate resumes where
// it was at checkpoint time.
self.inner.insert(user_id, vec);
self.update_counts.insert(user_id, update_count);
let _ = EntityId::new(user_id);
@ -601,6 +615,85 @@ mod tests {
);
}
/// Encode a `Tag::Preference` checkpoint value: `[count:8 LE][dim:4 LE][f32*dim]`.
fn encode_pref_value(count: u64, vec: &[f32]) -> Vec<u8> {
let mut value = Vec::with_capacity(8 + 4 + vec.len() * 4);
value.extend_from_slice(&count.to_le_bytes());
value.extend_from_slice(&u32::try_from(vec.len()).unwrap().to_le_bytes());
for v in vec {
value.extend_from_slice(&v.to_le_bytes());
}
value
}
/// W12: a torn/tampered checkpoint row that is NOT unit-length must be
/// re-normalized at the load boundary. `cosine_similarity` assumes the stored
/// preference is unit-length (it divides only by the candidate norm), so a
/// non-unit restored vector would mis-scale every similarity for that user.
/// This covers both failure shapes: a NaN component (zeroed then non-unit) and
/// a finite-but-non-unit row.
#[test]
fn restore_renormalizes_torn_rows_to_unit_length() {
use crate::{
schema::EntityId,
storage::{InMemoryBackend, StorageEngine, Tag, encode_key},
};
let storage = InMemoryBackend::new();
let dim = 3usize;
// User 1: a NaN component. After zeroing, the residual [3,0,4] has norm 5,
// i.e. NOT unit length — restore must normalize it to [0.6, 0, 0.8].
let nan_row = encode_pref_value(7, &[3.0, f32::NAN, 4.0]);
let key1 = encode_key(EntityId::new(0), Tag::Preference, &1u64.to_be_bytes());
storage.put(&key1, &nan_row).unwrap();
// User 2: a finite-but-non-unit row [0, 6, 8] (norm 10). Must normalize to
// [0, 0.6, 0.8].
let big_row = encode_pref_value(3, &[0.0, 6.0, 8.0]);
let key2 = encode_key(EntityId::new(0), Tag::Preference, &2u64.to_be_bytes());
storage.put(&key2, &big_row).unwrap();
// User 3: an all-zero (fully-corrupt) row stays zero (degrades to a zero
// preference, never a poisoned non-unit one).
let zero_row = encode_pref_value(1, &[f32::NAN, f32::NAN, f32::NAN]);
let key3 = encode_key(EntityId::new(0), Tag::Preference, &3u64.to_be_bytes());
storage.put(&key3, &zero_row).unwrap();
let pv = PreferenceVectors::new(dim);
pv.restore(&storage).unwrap();
let v1 = pv.get(1).expect("user 1 restored");
let n1: f32 = v1.iter().map(|x| x * x).sum::<f32>().sqrt();
assert!(
(n1 - 1.0).abs() < 1e-5,
"user 1 must be unit length, got {n1}"
);
assert!((v1[0] - 0.6).abs() < 1e-5 && (v1[2] - 0.8).abs() < 1e-5);
assert_eq!(pv.update_count(1), 7, "update count must round-trip");
let v2 = pv.get(2).expect("user 2 restored");
let n2: f32 = v2.iter().map(|x| x * x).sum::<f32>().sqrt();
assert!(
(n2 - 1.0).abs() < 1e-5,
"user 2 must be unit length, got {n2}"
);
let v3 = pv.get(3).expect("user 3 restored");
assert!(
v3.iter().all(|&x| x == 0.0),
"a fully-corrupt row degrades to a zero preference, not a poisoned one"
);
// Cosine of the unit-normalized user-1 pref with its own direction is 1.0,
// proving the invariant cosine_similarity depends on now holds.
let sim = pv.cosine_similarity(1, &[0.6, 0.0, 0.8]).unwrap();
assert!(
(sim - 1.0).abs() < 1e-5,
"cosine self-similarity must be 1.0, got {sim}"
);
}
mod proptests {
use proptest::prelude::*;

View File

@ -2,7 +2,10 @@
//!
//! Implemented in Task 2 (p1-02).
use crate::schema::{EntityId, Timestamp};
use crate::{
schema::{EntityId, Timestamp},
storage::{Tag, encode_key, entity_tag_prefix},
};
/// The type of relationship between two entities.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
@ -65,33 +68,38 @@ pub struct RelationshipEdge {
/// Encode a relationship key (19 bytes).
///
/// Format: `[from_entity_id: 8 BE][0x00][Tag::Rel: 0x04][rel_type: 1 byte][to_entity_id: 8 BE]`
/// Format: `[from_entity_id: 8 BE][0x00][Tag::Rel][rel_type: 1 byte][to_entity_id: 8 BE]`
///
/// Total: 8 + 1 + 1 + 1 + 8 = 19 bytes.
///
/// Built through [`encode_key`] / [`Tag::Rel`] rather than literal `0x00`/`0x04`
/// bytes so the relationship keyspace provably tracks the canonical storage key
/// layout — a change to the separator or the `Rel` discriminant propagates here
/// automatically instead of silently drifting.
#[must_use]
pub fn encode_relationship_key(
from: EntityId,
rel_type: RelationshipType,
to: EntityId,
) -> Vec<u8> {
let mut key = Vec::with_capacity(19);
key.extend_from_slice(&from.to_be_bytes());
key.push(0x00); // separator
key.push(0x04); // Tag::Rel
key.push(rel_type.as_byte());
key.extend_from_slice(&to.to_be_bytes());
key
// Suffix after `[from][0x00][Tag::Rel]` is `[rel_type][to_id: 8 BE]`.
let mut suffix = Vec::with_capacity(1 + 8);
suffix.push(rel_type.as_byte());
suffix.extend_from_slice(&to.to_be_bytes());
encode_key(from, Tag::Rel, &suffix)
}
/// Build a prefix for scanning all relationships of a given type from an entity.
///
/// Returns 11 bytes: `[from_id: 8 BE][0x00][0x04][rel_type]`
/// Returns 11 bytes: `[from_id: 8 BE][0x00][Tag::Rel][rel_type]`.
///
/// Shares the canonical entity+tag prefix ([`entity_tag_prefix`] with
/// [`Tag::Rel`]) so the scan prefix can never disagree with the key the keys
/// are written under.
#[must_use]
pub fn relationship_prefix(from: EntityId, rel_type: RelationshipType) -> Vec<u8> {
let mut prefix = Vec::with_capacity(11);
prefix.extend_from_slice(&from.to_be_bytes());
prefix.push(0x00);
prefix.push(0x04);
prefix.extend_from_slice(&entity_tag_prefix(from, Tag::Rel));
prefix.push(rel_type.as_byte());
prefix
}
@ -124,7 +132,8 @@ pub fn deserialize_relationship_value(bytes: &[u8]) -> Option<(f64, u64)> {
/// Parse the `to` entity ID from a full relationship key (19 bytes).
///
/// The `to` entity starts at offset 11: 8 (from) + 1 (NUL) + 1 (`Tag::Rel`) + 1 (`rel_type`).
/// The `to` entity starts at offset 11: 8 (from) + 1 (NUL) + 1 (`Tag::Rel`) + 1
/// (`rel_type`) — i.e. bytes 1..9 of the canonical [`Tag::Rel`] key suffix.
#[must_use]
pub fn parse_relationship_to(key: &[u8]) -> Option<EntityId> {
if key.len() < 19 {
@ -193,6 +202,51 @@ mod tests {
assert_eq!(prefix.len(), 11);
}
/// Drift guard: the relationship key must equal the canonical storage layout
/// produced by `encode_key(from, Tag::Rel, [rel_type, to_id])`, and must parse
/// back through the shared `parse_key`. This catches any future change to the
/// separator byte or the `Rel` discriminant that the hardcoded layout would
/// have silently missed.
#[test]
fn relationship_key_matches_canonical_encode_key() {
use crate::storage::{Tag, encode_key, parse_key};
let from = EntityId::new(0x1122_3344_5566_7788);
let to = EntityId::new(0x99AA_BBCC_DDEE_FF00);
for rel_type in [
RelationshipType::Follows,
RelationshipType::Blocks,
RelationshipType::InteractionWeight,
RelationshipType::Hide,
RelationshipType::Mute,
] {
let key = encode_relationship_key(from, rel_type, to);
// 1) Byte-identical to the canonical encoder for the same suffix.
let mut suffix = Vec::with_capacity(9);
suffix.push(rel_type.as_byte());
suffix.extend_from_slice(&to.to_be_bytes());
let canonical = encode_key(from, Tag::Rel, &suffix);
assert_eq!(
key, canonical,
"relationship key must match encode_key layout"
);
// 2) Parses back through the shared key parser as a Tag::Rel key.
let (parsed_from, tag, parsed_suffix) =
parse_key(&key).expect("relationship key must be a valid canonical key");
assert_eq!(parsed_from, from);
assert_eq!(tag, Tag::Rel);
assert_eq!(parsed_suffix[0], rel_type.as_byte());
assert_eq!(parse_relationship_to(&key), Some(to));
// 3) The scan prefix is exactly the key truncated before the to-id.
let prefix = relationship_prefix(from, rel_type);
assert!(key.starts_with(&prefix));
assert_eq!(prefix.len(), 11);
}
}
mod proptests {
use proptest::prelude::*;

View File

@ -215,14 +215,21 @@ impl CommunityLedger {
if cell.contributors.is_empty() {
return Ok(None);
}
// Deterministic fold order: sort contributor keys.
let mut keys: Vec<&ContributorKey> = cell.contributors.keys().collect();
keys.sort_unstable();
// Deterministic fold order: sort the (key, entry) pairs by key. We sort
// entries we already hold rather than sorting keys and re-`get`ting each:
// the keys came from this same map under this read guard, so the second
// lookup is provably redundant (and its `if let Some` implies a
// fallibility that cannot occur).
let mut entries: Vec<(&ContributorKey, &EntitySignalEntry)> =
cell.contributors.iter().collect();
// Sort by the contributor key. The key borrows from the element, so this
// uses `sort_unstable_by` (a borrowing `sort_by_key` closure cannot satisfy
// the `FnMut(&T) -> K` lifetime); clippy's `unnecessary_sort_by` does not
// fire here for that reason. `x.0` / `y.0` are `&ContributorKey`.
entries.sort_unstable_by(|x, y| x.0.cmp(y.0));
let mut sum = 0.0;
for k in keys {
if let Some(entry) = cell.contributors.get(k) {
sum += entry.hot.current_score(decay_rate_idx, now_ns, lambda);
}
for (_k, entry) in entries {
sum += entry.hot.current_score(decay_rate_idx, now_ns, lambda);
}
Ok(Some(sum))
}
@ -424,19 +431,24 @@ impl CommunityLedger {
let Some(cell) = self.cells.get(&(community, entity_id, type_id)) else {
return Ok(Vec::new());
};
let mut keys: Vec<&ContributorKey> = cell.contributors.keys().collect();
keys.sort_unstable();
let mut out = Vec::with_capacity(keys.len());
for k in keys {
if let Some(e) = cell.contributors.get(k) {
out.push(ContributionProvenance {
user: k.0,
writer_agent: k.1.clone(),
epoch: k.2,
score: e.hot.current_score(0, now_ns, lambda),
count: e.warm.windowed_count(Window::AllTime, now_ns),
});
}
// Sort the (key, entry) pairs we already hold; no re-`get` of keys that
// came from this same map under this read guard.
let mut entries: Vec<(&ContributorKey, &EntitySignalEntry)> =
cell.contributors.iter().collect();
// Sort by the contributor key. The key borrows from the element, so this
// uses `sort_unstable_by` (a borrowing `sort_by_key` closure cannot satisfy
// the `FnMut(&T) -> K` lifetime); clippy's `unnecessary_sort_by` does not
// fire here for that reason. `x.0` / `y.0` are `&ContributorKey`.
entries.sort_unstable_by(|x, y| x.0.cmp(y.0));
let mut out = Vec::with_capacity(entries.len());
for (k, e) in entries {
out.push(ContributionProvenance {
user: k.0,
writer_agent: k.1.clone(),
epoch: k.2,
score: e.hot.current_score(0, now_ns, lambda),
count: e.warm.windowed_count(Window::AllTime, now_ns),
});
}
Ok(out)
}
@ -449,16 +461,27 @@ impl CommunityLedger {
/// every row, then re-put every in-memory cell). Calling it on every accepted
/// contribution makes ingestion O(N²) — the M-th write does O(M) work — and
/// blows the signal-write latency budget for any active community. This writes
/// only the one row the accept just touched, so the per-accept durability cost
/// is constant. The zero-loss-window guarantee is preserved: the accepted cell
/// is durably written (one atomic single-row `WriteBatch`, matching the prior
/// per-write behaviour) before the caller returns `Ok(true)`. The full
/// scan-delete-rewrite snapshot stays in `checkpoint` for the
/// lifecycle/periodic/backup path, where it garbage-collects purged rows.
/// only the one row the accept just touched, so the per-accept storage cost is
/// constant. The full scan-delete-rewrite snapshot stays in `checkpoint` for
/// the lifecycle/periodic/backup path, where it garbage-collects purged rows.
///
/// # Durability
///
/// This ledger is the SOLE durable copy of per-contributor identity — the WAL
/// community event carries none, so WAL replay cannot rebuild it. A bare
/// `write_batch` is committed-not-durable (the bytes may still sit in a
/// memtable / page cache, per [`StorageEngine::write_batch`]), so this issues
/// the `fsync` barrier via [`StorageEngine::flush`] before returning `Ok(())`.
/// That makes the accepted cell genuinely durable when the caller returns —
/// honoring the `signal_for_community` "ZERO loss window" guarantee against a
/// power-loss crash, not merely against a clean shutdown. The flush is on the
/// gated, community-scoped contribution path (not the hot global signal path),
/// so the per-accept `fsync` is an acceptable cost for the only state in the
/// zone that cannot be recovered from the WAL.
///
/// # Errors
///
/// Returns `TidalError::Storage` on a write failure.
/// Returns `TidalError::Storage` on a write or flush failure.
#[allow(clippy::too_many_arguments)]
pub fn persist_cell(
&self,
@ -492,6 +515,11 @@ impl CommunityLedger {
let mut batch = WriteBatch::new();
batch.put(key, value);
storage.write_batch(batch).map_err(TidalError::from)?;
// Commit is not durability: fsync the keyspace so the contributor row
// survives a power crash. This ledger has no WAL backstop, so without the
// barrier the accepted cell could be lost while the WAL still reports the
// aggregate event — exactly the gap the "ZERO loss window" claim forbids.
storage.flush().map_err(TidalError::from)?;
Ok(())
}
@ -655,15 +683,20 @@ impl CommunityLedger {
.and_then(|v| v.first())
.copied()
.unwrap_or(0.0);
let mut keys: Vec<&ContributorKey> = cell.value().contributors.keys().collect();
keys.sort_unstable();
// Sort the (key, entry) pairs we already hold; the keys came from this
// same map under this read guard, so re-`get`ting each is redundant.
let mut entries: Vec<(&ContributorKey, &EntitySignalEntry)> =
cell.value().contributors.iter().collect();
// Sort by the contributor key. The key borrows from the element, so this
// uses `sort_unstable_by` (a borrowing `sort_by_key` closure cannot satisfy
// the `FnMut(&T) -> K` lifetime); clippy's `unnecessary_sort_by` does not
// fire here for that reason. `x.0` / `y.0` are `&ContributorKey`.
entries.sort_unstable_by(|x, y| x.0.cmp(y.0));
let mut score = 0.0;
let mut count = 0u64;
for k in keys {
if let Some(e) = cell.value().contributors.get(k) {
score += e.hot.current_score(0, now_ns, lambda);
count += e.warm.windowed_count(Window::AllTime, now_ns);
}
for (_k, e) in entries {
score += e.hot.current_score(0, now_ns, lambda);
count += e.warm.windowed_count(Window::AllTime, now_ns);
}
rows.push((entity.as_u64(), type_id.as_u16(), score.to_bits(), count));
}
@ -1278,6 +1311,84 @@ mod tests {
);
}
/// Storage spy that delegates to an [`InMemoryBackend`] but counts how many
/// times [`StorageEngine::flush`] is invoked, so a test can assert the
/// per-accept path actually issues the durability barrier.
struct FlushCountingBackend {
inner: crate::storage::InMemoryBackend,
flushes: std::sync::atomic::AtomicUsize,
}
impl FlushCountingBackend {
fn new() -> Self {
Self {
inner: crate::storage::InMemoryBackend::new(),
flushes: std::sync::atomic::AtomicUsize::new(0),
}
}
fn flush_count(&self) -> usize {
self.flushes.load(std::sync::atomic::Ordering::SeqCst)
}
}
impl StorageEngine for FlushCountingBackend {
fn get(&self, key: &[u8]) -> Result<Option<Vec<u8>>, crate::storage::StorageError> {
self.inner.get(key)
}
fn put(&self, key: &[u8], value: &[u8]) -> Result<(), crate::storage::StorageError> {
self.inner.put(key, value)
}
fn delete(&self, key: &[u8]) -> Result<(), crate::storage::StorageError> {
self.inner.delete(key)
}
fn scan_prefix(&self, prefix: &[u8]) -> crate::storage::iterator::PrefixIterator<'_> {
self.inner.scan_prefix(prefix)
}
fn write_batch(&self, batch: WriteBatch) -> Result<(), crate::storage::StorageError> {
self.inner.write_batch(batch)
}
fn flush(&self) -> Result<(), crate::storage::StorageError> {
self.flushes
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
self.inner.flush()
}
}
#[test]
fn persist_cell_fsyncs_so_the_zero_loss_claim_holds() {
// CRITICAL regression: the community ledger is the SOLE non-WAL-rebuildable
// durable copy of per-contributor identity, and `signal_for_community`
// promises Ok(true) means the contribution is durable. A bare write_batch
// is committed-not-durable, so persist_cell MUST pair it with flush()
// (fsync) before returning. This pins that the barrier is issued.
let storage = FlushCountingBackend::new();
let l = ledger();
let c = CommunityId(1);
let like = l.resolve_signal_type("like").unwrap();
let ts = Timestamp::now().as_nanos();
l.record(c, EntityId::new(10), like, UserId(1), "agent", 0, 1.0, ts);
l.persist_cell(&storage, c, EntityId::new(10), like, UserId(1), "agent", 0)
.unwrap();
assert_eq!(
storage.flush_count(),
1,
"persist_cell must fsync (flush) the contributor row, not leave it \
committed-not-durable the WAL cannot rebuild this state"
);
// And the row genuinely round-trips through restore.
let restored = ledger();
restored.restore(&storage.inner);
assert_eq!(
restored
.windowed_count(c, EntityId::new(10), "like", Window::AllTime)
.unwrap(),
1
);
}
#[test]
fn apply_purge_tombstone_purges_and_sets_watermark() {
use crate::governance::tombstone::PurgeTombstone;

View File

@ -27,9 +27,10 @@ pub mod storage;
pub mod text;
pub mod wal;
/// Build hash compiled in from the `GIT_HASH` environment variable.
/// Build hash compiled in from the `TIDALDB_BUILD_HASH` environment variable.
///
/// Falls back to `"dev"` if `GIT_HASH` is unset or `build.rs` is not invoked.
/// Falls back to `"dev"` if `TIDALDB_BUILD_HASH` is unset or `build.rs` is not
/// invoked.
///
/// # Examples
///

View File

@ -44,7 +44,10 @@ fn degrade_unknown_signal<T>(
/// Mirrors the ranking executor's own post-scoring normalization so the
/// `RetrieveResult.score` contract holds after a cohort rescore:
/// - Non-finite scores (`NaN`/`±Inf`) are clamped to `0.0` first.
/// - If every score is equal (`range < EPSILON`), all map to `1.0`.
/// - If every score is equal (`range < EPSILON`), all map to `0.5` (spec §8.2:
/// a degenerate all-equal set is "neutral / no signal", not maximal
/// confidence) — kept identical to the ranking executor's `normalize` so the
/// two paths cannot drift.
/// - Otherwise `(score - min) / (max - min)`.
fn normalize_scores(candidates: &mut [ScoredCandidate]) {
if candidates.is_empty() {
@ -66,7 +69,7 @@ fn normalize_scores(candidates: &mut [ScoredCandidate]) {
let range = max - min;
for c in candidates.iter_mut() {
c.score = if range < f64::EPSILON {
1.0
0.5
} else {
(c.score - min) / range
};
@ -163,6 +166,29 @@ impl RetrieveExecutor<'_> {
/// Re-score candidates using cohort signal values and re-sort.
///
/// # Sort-mode preservation (C-CRITICAL)
///
/// The main scoring path bakes the profile's [`Sort`] into each
/// `ScoredCandidate.score` (e.g. `New` scores by entity id, `AlphabeticalAsc`
/// by title). A naive cohort rescore that OVERWRITES every score with a sum
/// derived only from `profile.boosts` would destroy that ordering: for a
/// sort-driven profile with empty boosts (e.g. the built-in `new`), every
/// cohort score collapses to `0.0`, normalization maps them all to `1.0`, and
/// the descending re-sort is a no-op — silently returning uniform scores and
/// the wrong order. To prevent that, the cohort override is applied ONLY when
/// it can change ranking without discarding a sort the main path already
/// honored:
///
/// - **No boosts** → the cohort scope cannot change a boost-free profile's
/// ranking, so the rescore is a no-op (main-path scores and order preserved).
/// - **A metadata / id-based sort is active** (`New`, `Alphabetical*`,
/// `Shortest`, `Longest`, `DateSaved`, `Shuffle`) → that ordering reads entity
/// metadata / id, NOT the cohort ledger, so it must survive; the rescore is a
/// no-op and the main-path order is kept.
/// - **Otherwise** (a signal-driven sort or no sort, with boosts present) →
/// re-derive the score from the cohort ledger's signal values so the cohort's
/// own engagement drives ranking, then re-normalize and re-sort.
///
/// # Errors
///
/// Returns [`QueryError::InvalidFilter`] when a boost references an
@ -184,12 +210,45 @@ impl RetrieveExecutor<'_> {
scored: &mut [crate::ranking::executor::ScoredCandidate],
cohort_name: &str,
cohort_ledger: &crate::cohort::CohortSignalLedger,
boosts: &[crate::ranking::profile::Boost],
profile: &crate::ranking::profile::RankingProfile,
) -> Result<(), QueryError> {
use crate::ranking::profile::SignalAgg;
use crate::ranking::profile::{SignalAgg, Sort};
let boosts = &profile.boosts;
// A boost-free profile's ranking is driven entirely by its sort mode or by
// the main-path base score; the cohort scope changes neither. Preserve the
// main-path scores and order rather than collapsing every candidate to a
// uniform cohort-boost sum of 0.0 (the exact `new`-profile bug C flagged).
if boosts.is_empty() {
return Ok(());
}
// A metadata / id-based sort reads entity metadata or id, never the cohort
// ledger. Overwriting the score from cohort boosts would destroy that
// ordering, so leave the main-path scores and order intact for these sorts.
if matches!(
profile.sort,
Some(
Sort::New
| Sort::AlphabeticalAsc
| Sort::AlphabeticalDesc
| Sort::Shortest
| Sort::Longest
| Sort::DateSaved
| Sort::Shuffle
)
) {
return Ok(());
}
for candidate in scored.iter_mut() {
let mut cohort_score = 0.0;
// Rebuild the signal snapshot from the cohort-scoped values so the
// reported `RetrieveResult.signals` reflect the cohort scope the score
// was derived from, not the stale global-ledger snapshot the main path
// left on the candidate (C-CRITICAL: snapshot consistency).
let mut cohort_snapshot: Vec<(String, f64)> = Vec::with_capacity(boosts.len());
for boost in boosts {
let value = match &boost.agg {
SignalAgg::Value => {
@ -245,8 +304,10 @@ impl RetrieveExecutor<'_> {
}
};
cohort_score += value * boost.weight;
cohort_snapshot.push((boost.signal.clone(), value));
}
candidate.score = cohort_score;
candidate.signal_snapshot = cohort_snapshot;
}
// Re-normalize: cohort rescore replaced each candidate's normalized
@ -256,86 +317,84 @@ impl RetrieveExecutor<'_> {
// ranking executor applies after its own scoring pass).
normalize_scores(scored);
// Re-sort by descending score.
// Re-sort by descending score, breaking ties on ASCENDING entity id so the
// order is a true total order (W14) — without it, candidates that tie on
// the rescored cohort score keep the input order, which can differ across
// runs once the upstream candidate order does.
scored.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.entity_id.as_u64().cmp(&b.entity_id.as_u64()))
});
Ok(())
}
/// Apply notification caps as a post-diversity pass.
/// Days since the Unix epoch for the current wall clock.
///
/// Shared by the cap *filter* (pre-pagination) and the cap *record*
/// (post-pagination) so both bucket deliveries into the same calendar day.
/// `u32` wraps at ~4.3 billion days (~11.8 million years); safe for any
/// practical use. The `86_400s * 1e9ns` constant avoids a `chrono` dep.
fn today_days_since_epoch() -> u32 {
const NANOS_PER_DAY: u64 = 86_400 * 1_000_000_000;
let now_ns = crate::schema::Timestamp::now().as_nanos();
(now_ns / NANOS_PER_DAY) as u32
}
/// Resolve a candidate's `creator_id`: prefer the pre-populated field, fall
/// back to reading item metadata from storage. Returns `None` for items with
/// no resolvable creator (which skip the per-creator cap by design).
fn resolve_candidate_creator_id(
&self,
c: &crate::ranking::executor::ScoredCandidate,
) -> Option<u64> {
if let Some(cid) = c.creator_id {
return Some(cid.as_u64());
}
let storage = self.items_storage?;
let key = encode_key(c.entity_id, Tag::Meta, b"");
let raw = storage.get(&key).ok()??;
let meta = deserialize_item_metadata(&raw);
meta.get("creator_id")?.parse::<u64>().ok()
}
/// Apply notification caps as a post-diversity, pre-pagination FILTER.
///
/// Enforces per-creator and total daily limits by consulting the
/// `NotificationTracker` for historical deliveries and capping within
/// this response. Retained results are recorded into the tracker when
/// the profile is "notification".
/// `NotificationTracker` for historical deliveries and capping within this
/// response against a read-only snapshot of prior deliveries. This pass NEVER
/// records: recording is deferred to [`Self::record_notification_deliveries`],
/// applied to the *paginated page* in Stage 5.
///
/// # Why filtering and recording are split (C-CRITICAL)
///
/// Pagination (offset/limit slicing) happens in Stage 5, AFTER this filter. If
/// this pass also recorded (the previous behavior), a `limit=5` query over 50
/// survivors recorded 50 deliveries while returning 5 — the daily cap was
/// consumed by 45 items the caller never received, silently exhausting the
/// budget far below the real delivery count and suppressing legitimate
/// notifications. Recording only the delivered page fixes that: a delivery is
/// counted iff it is actually returned to the caller.
///
/// Creator resolution: `ScoredCandidate.creator_id` is populated by the
/// ranking executor only when diversity is evaluated. As a fallback,
/// we resolve the `creator_id` from item metadata via `items_storage` so
/// that per-creator caps work correctly even when the ranking path leaves
/// `creator_id = None`.
/// ranking executor only when diversity is evaluated. As a fallback, we resolve
/// it from item metadata via `items_storage`.
pub(super) fn apply_notification_caps(
&self,
candidates: Vec<crate::ranking::executor::ScoredCandidate>,
caps: crate::query::retrieve::NotificationCaps,
user_id: u64,
profile_name: &str,
_profile_name: &str,
) -> Vec<crate::ranking::executor::ScoredCandidate> {
use std::collections::HashMap as StdMap;
// Days since epoch (avoids chrono dependency).
// u32 wraps at ~4.3 billion days (~11.8 million years); safe for any
// practical use. The 86_400s * 1e9ns constant avoids a chrono dep.
const NANOS_PER_DAY: u64 = 86_400 * 1_000_000_000;
let today = {
let now_ns = crate::schema::Timestamp::now().as_nanos();
(now_ns / NANOS_PER_DAY) as u32
};
let today = Self::today_days_since_epoch();
// Resolve creator_id for a candidate: prefer the pre-populated field,
// fall back to reading item metadata from storage.
let resolve_creator_id = |c: &crate::ranking::executor::ScoredCandidate| -> Option<u64> {
if let Some(cid) = c.creator_id {
return Some(cid.as_u64());
}
let storage = self.items_storage?;
let key = encode_key(c.entity_id, Tag::Meta, b"");
let raw = storage.get(&key).ok()??;
let meta = deserialize_item_metadata(&raw);
meta.get("creator_id")?.parse::<u64>().ok()
};
// The "notification" profile is the only one that *records* deliveries
// into the shared tracker, so it is the only path with a check-then-record
// TOCTOU under concurrency. Route it through the tracker's atomic
// `check_and_record`, which evaluates both caps and increments under a
// single lock — two concurrent responses for the same user can no longer
// both observe headroom and both record, overshooting the caps.
if profile_name == "notification"
&& let Some(tracker) = self.notification_tracker
{
return candidates
.into_iter()
.filter(|c| {
tracker.check_and_record(
user_id,
resolve_creator_id(c),
today,
caps.max_per_creator_per_day,
caps.max_total_per_day,
)
})
.collect();
}
// Non-recording path (non-notification profiles, or no tracker wired):
// trim this response against a read-only snapshot of prior deliveries.
// Nothing is persisted, so there is no shared state to race on — the
// per-response accumulators are local to this call.
// Read-only snapshot of prior deliveries. Nothing is persisted here, so
// there is no shared state to race on — the per-response accumulators are
// local to this call. The actual record happens on the delivered page.
let already_total = self
.notification_tracker
.map_or(0, |t| t.count_total_today(user_id, today));
@ -354,7 +413,7 @@ impl RetrieveExecutor<'_> {
// Per-creator cap: only enforced when the item has a resolvable creator.
// Items without any creator_id (neither in the candidate nor in metadata)
// skip the per-creator check to avoid incorrect bucketing.
if let Some(creator_id) = resolve_creator_id(c) {
if let Some(creator_id) = self.resolve_candidate_creator_id(c) {
let already_delivered = self
.notification_tracker
.map_or(0, |t| t.count_today(user_id, creator_id, today));
@ -372,6 +431,49 @@ impl RetrieveExecutor<'_> {
})
.collect()
}
/// Record notification deliveries for the items ACTUALLY returned to the
/// caller (the post-pagination page), and drop any page item the tracker
/// rejects under the now-live cap.
///
/// Only the `notification` profile records into the shared tracker, because
/// it is the surface whose daily cap drives whether a user is notified. The
/// record goes through the tracker's atomic `check_and_record`, which
/// evaluates both caps and increments under a single lock — so two concurrent
/// responses for the same user cannot both observe headroom and both record,
/// overshooting the caps (TOCTOU). Because this runs on the delivered page
/// (not the full pre-slice set), the daily budget is consumed by exactly the
/// notifications the caller receives — never the items trimmed away by
/// pagination (C-CRITICAL).
pub(super) fn record_notification_deliveries(
&self,
page: &[crate::ranking::executor::ScoredCandidate],
caps: crate::query::retrieve::NotificationCaps,
user_id: u64,
profile_name: &str,
) -> Vec<crate::ranking::executor::ScoredCandidate> {
if profile_name != "notification" {
return page.to_vec();
}
let Some(tracker) = self.notification_tracker else {
return page.to_vec();
};
let today = Self::today_days_since_epoch();
page.iter()
.filter(|c| {
tracker.check_and_record(
user_id,
self.resolve_candidate_creator_id(c),
c.entity_id.as_u64(),
today,
caps.max_per_creator_per_day,
caps.max_total_per_day,
)
})
.cloned()
.collect()
}
}
// ── Tests ────────────────────────────────────────────────────────────────────
@ -390,12 +492,36 @@ mod tests {
use super::*;
use crate::{
cohort::CohortSignalLedger,
ranking::profile::{Boost, SignalAgg},
ranking::profile::{
Boost, CandidateStrategy, DiversitySpec, RankingProfile, SignalAgg, Sort,
},
schema::{DecaySpec, EntityKind, SchemaBuilder, Timestamp, Window},
signals::{NoopWalWriter, SignalLedger},
storage::indexes::{bitmap::BitmapIndex, range::RangeIndex},
};
/// Build a minimal `RankingProfile` carrying the given `boosts` and `sort`,
/// so `rescore_with_cohort` (which now takes the whole profile to honor the
/// sort mode) can be exercised directly.
fn cohort_profile(boosts: Vec<Boost>, sort: Option<Sort>) -> RankingProfile {
RankingProfile {
name: "cohort_test".to_owned(),
version: 1,
candidate_strategy: CandidateStrategy::Scan {
sort_field: String::new(),
},
boosts,
decay: None,
gates: vec![],
penalties: vec![],
excludes: vec![],
diversity: DiversitySpec::default(),
exploration: 0.0,
sort,
is_builtin: false,
}
}
/// Schema with a single known signal (`view`) so the cohort ledger resolves
/// `view` but rejects any other name with `UnknownSignalType`.
fn cohort_schema() -> crate::schema::Schema {
@ -475,18 +601,22 @@ mod tests {
let mut scored = vec![candidate(1, 0.9), candidate(2, 0.1)];
// Boost references a signal NOT in the schema → unknown-signal degrade.
let boosts = vec![Boost {
signal: "not_a_signal".to_string(),
agg: SignalAgg::Value,
window: Window::AllTime,
weight: 1.0,
}];
let profile = cohort_profile(
vec![Boost {
signal: "not_a_signal".to_string(),
agg: SignalAgg::Value,
window: Window::AllTime,
weight: 1.0,
}],
None,
);
// Must succeed (degrade to 0.0), not error.
exec.rescore_with_cohort(&mut scored, "tech", &cohort_ledger, &boosts)
exec.rescore_with_cohort(&mut scored, "tech", &cohort_ledger, &profile)
.expect("unknown cohort signal must degrade to 0.0, not error");
// Every cohort score collapsed to 0.0 → normalize maps all to 1.0.
assert!(scored.iter().all(|c| (c.score - 1.0).abs() < f64::EPSILON));
// Every cohort score collapsed to 0.0 → an all-equal set normalizes to
// 0.5 (spec §8.2: neutral / no signal), matching the ranking executor.
assert!(scored.iter().all(|c| (c.score - 0.5).abs() < f64::EPSILON));
}
/// W26 integration: a known-signal cohort boost still scores normally,
@ -524,16 +654,197 @@ mod tests {
}
let mut scored = vec![candidate(1, 0.9), candidate(2, 0.1)];
let boosts = vec![Boost {
signal: "view".to_string(),
agg: SignalAgg::Value,
window: Window::AllTime,
weight: 1.0,
}];
let profile = cohort_profile(
vec![Boost {
signal: "view".to_string(),
agg: SignalAgg::Value,
window: Window::AllTime,
weight: 1.0,
}],
None,
);
exec.rescore_with_cohort(&mut scored, "tech", &cohort_ledger, &boosts)
exec.rescore_with_cohort(&mut scored, "tech", &cohort_ledger, &profile)
.expect("known signal must score without error");
// The cohort rescore + re-sort puts the higher cohort-signal item first.
assert_eq!(scored[0].entity_id, EntityId::new(2));
// The reported snapshot reflects the cohort scope (5 views), not the stale
// global snapshot the candidate carried in.
let top = scored
.iter()
.find(|c| c.entity_id == EntityId::new(2))
.expect("item 2 present");
assert!(
top.signal_snapshot
.iter()
.any(|(name, value)| name == "view" && (*value - 5.0).abs() < f64::EPSILON),
"cohort snapshot must report the cohort-scoped view count, got {:?}",
top.signal_snapshot
);
}
/// C-CRITICAL regression: a cohort + a metadata/id-based sort profile (`new`,
/// which scores by descending entity id with NO boosts) must PRESERVE the
/// main-path ordering and scores. Before the fix the rescore overwrote every
/// score with a cohort-boost sum of 0.0, normalize mapped them all to 1.0, and
/// the descending re-sort was a no-op — destroying the New ordering and
/// returning uniform scores. With the fix the boost-free profile is a no-op.
#[test]
fn rescore_with_cohort_preserves_new_sort_ordering() {
let global = SignalLedger::new(cohort_schema(), Box::new(NoopWalWriter));
let profile_reg = setup_registry();
let cat = BitmapIndex::new("category");
let fmt = BitmapIndex::new("format");
let cre = BitmapIndex::new("creator");
let tag = BitmapIndex::new("tag");
let dur = RangeIndex::<u32>::new("duration");
let ts = RangeIndex::<u64>::new("created_at");
let universe = RwLock::new(RoaringBitmap::new());
let exec = make_executor(
&global,
&profile_reg,
&cat,
&fmt,
&cre,
&tag,
&dur,
&ts,
&universe,
);
let cohort_ledger = CohortSignalLedger::new(&cohort_schema());
// The main path for `New` scores by entity id; item 3 (highest id) ranks
// first, then 2, then 1. The scores are distinct, not all-equal.
let mut scored = vec![candidate(1, 1.0), candidate(2, 2.0), candidate(3, 3.0)];
// `new`-style profile: no boosts, Sort::New.
let profile = cohort_profile(vec![], Some(Sort::New));
exec.rescore_with_cohort(&mut scored, "tech", &cohort_ledger, &profile)
.expect("boost-free sort profile must not error");
// Ordering and scores are untouched (rescore is a no-op for this shape).
let ids: Vec<u64> = scored.iter().map(|c| c.entity_id.as_u64()).collect();
assert_eq!(
ids,
vec![1, 2, 3],
"the main-path order must be preserved verbatim, not re-sorted to uniform 1.0"
);
assert!(
!scored.iter().all(|c| (c.score - 1.0).abs() < f64::EPSILON),
"scores must NOT collapse to a uniform 1.0; got {:?}",
scored.iter().map(|c| c.score).collect::<Vec<_>>()
);
}
/// C-CRITICAL regression (notification cap recording follows pagination):
/// the Stage 4.5 FILTER (`apply_notification_caps`) must record NOTHING, and
/// recording must happen on the delivered PAGE via
/// `record_notification_deliveries` — so a `limit < survivors` query records
/// exactly `limit` deliveries, not every survivor. Before the fix the full
/// post-diversity set was recorded, silently exhausting the daily cap with
/// items the caller never received.
#[test]
fn notification_caps_record_only_the_delivered_page() {
use std::sync::Arc;
use crate::{
db::notification_tracker::NotificationTracker, query::retrieve::NotificationCaps,
};
let global = SignalLedger::new(cohort_schema(), Box::new(NoopWalWriter));
let profile_reg = setup_registry();
let cat = BitmapIndex::new("category");
let fmt = BitmapIndex::new("format");
let cre = BitmapIndex::new("creator");
let tag = BitmapIndex::new("tag");
let dur = RangeIndex::<u32>::new("duration");
let ts = RangeIndex::<u64>::new("created_at");
let universe = RwLock::new(RoaringBitmap::new());
let tracker = Arc::new(NotificationTracker::new());
let exec = make_executor(
&global,
&profile_reg,
&cat,
&fmt,
&cre,
&tag,
&dur,
&ts,
&universe,
)
.with_notification_tracker(&tracker);
// Five survivors from distinct creators; generous caps so the FILTER keeps
// all five.
let mk = |id: u64, creator: u64| ScoredCandidate {
entity_id: EntityId::new(id),
score: 1.0,
signal_snapshot: vec![],
creator_id: Some(EntityId::new(creator)),
format: None,
};
let candidates = vec![mk(1, 10), mk(2, 20), mk(3, 30), mk(4, 40), mk(5, 50)];
let caps = NotificationCaps {
max_per_creator_per_day: 5,
max_total_per_day: 10,
};
// FILTER keeps all five and records NOTHING.
let filtered = exec.apply_notification_caps(candidates, caps, 42, "notification");
assert_eq!(
filtered.len(),
5,
"filter keeps all five under generous caps"
);
let nanos_per_day: u64 = 86_400 * 1_000_000_000;
let today = (Timestamp::now().as_nanos() / nanos_per_day) as u32;
assert_eq!(
tracker.count_total_today(42, today),
0,
"the filter pass must NOT record any deliveries"
);
// A page of limit=2 is recorded — exactly two deliveries, not five.
let page = &filtered[..2];
let delivered = exec.record_notification_deliveries(page, caps, 42, "notification");
assert_eq!(delivered.len(), 2, "the delivered page is exactly the page");
assert_eq!(
tracker.count_total_today(42, today),
2,
"exactly `limit` (2) deliveries recorded — not all five survivors"
);
// Re-issuing the identical query (same page, same user, same day) must
// NOT double-count the already-delivered items — the cap counts distinct
// items notified about, not raw delivery attempts.
let redelivered = exec.record_notification_deliveries(page, caps, 42, "notification");
assert_eq!(redelivered.len(), 2, "the page still re-appears in full");
assert_eq!(
tracker.count_total_today(42, today),
2,
"a second identical query must not double-count delivered items"
);
// A non-notification profile records nothing even when given a page.
let other = Arc::new(NotificationTracker::new());
let other_exec = make_executor(
&global,
&profile_reg,
&cat,
&fmt,
&cre,
&tag,
&dur,
&ts,
&universe,
)
.with_notification_tracker(&other);
let _ = other_exec.record_notification_deliveries(page, caps, 42, "trending");
assert_eq!(
other.count_total_today(42, today),
0,
"only the notification profile records deliveries"
);
}
}

View File

@ -371,6 +371,14 @@ impl<'a> RetrieveExecutor<'a> {
executor = executor.with_date_saved_context(user_state, user_id);
}
// Wire the Shuffle sort's per-user, per-minute seed (spec §11.6). Without
// a FOR USER it stays 0 (a stable per-minute global permutation).
if matches!(profile.sort, Some(Sort::Shuffle))
&& let Some(user_id) = query.for_user
{
executor = executor.with_shuffle_user(user_id);
}
#[allow(clippy::option_if_let_else)]
let mut scored = if let Some(user_id) = query.for_user {
// Personalized scoring: build UserContext from the interaction ledger
@ -463,7 +471,7 @@ impl<'a> RetrieveExecutor<'a> {
});
}
if cohort_exists {
self.rescore_with_cohort(&mut scored, cohort_name, cohort_ledger, &profile.boosts)?;
self.rescore_with_cohort(&mut scored, cohort_name, cohort_ledger, profile)?;
}
}
@ -478,15 +486,7 @@ impl<'a> RetrieveExecutor<'a> {
// and Stage 4.5 notification cap enforcement (resolve_creator_id falls back
// to items_storage, but having it pre-populated is more reliable).
if needs_metadata_for_creator_grouping && !item_metadata.is_empty() {
for candidate in &mut scored {
if candidate.creator_id.is_none()
&& let Some(meta) = item_metadata.get(&candidate.entity_id.as_u64())
&& let Some(cid_str) = meta.get("creator_id")
&& let Ok(cid) = cid_str.parse::<u64>()
{
candidate.creator_id = Some(EntityId::new(cid));
}
}
backfill_creator_ids(&mut scored, &item_metadata);
}
Ok((scored, item_metadata))
@ -495,6 +495,32 @@ impl<'a> RetrieveExecutor<'a> {
// ── Helpers ──────────────────────────────────────────────────────────────────
/// Backfill `creator_id` on each candidate from pre-loaded item metadata.
///
/// The ranking executor always leaves `creator_id = None`; this enrichment reads
/// the `creator_id` metadata field and sets it so Stage 4 diversity enforcement
/// and Stage 4.5/Stage 5 notification capping can group by creator. Only
/// unresolved (`None`) candidates are touched, so it is safe to run more than
/// once. Shared by Stage 3 (`stage3_score`) and Stage 4.5 (`pipeline::execute`)
/// so the two passes cannot drift on the parse rule.
pub(super) fn backfill_creator_ids(
candidates: &mut [crate::ranking::executor::ScoredCandidate],
item_metadata: &std::collections::HashMap<u64, std::collections::HashMap<String, String>>,
) {
if item_metadata.is_empty() {
return;
}
for candidate in candidates.iter_mut() {
if candidate.creator_id.is_none()
&& let Some(meta) = item_metadata.get(&candidate.entity_id.as_u64())
&& let Some(cid_str) = meta.get("creator_id")
&& let Ok(cid) = cid_str.parse::<u64>()
{
candidate.creator_id = Some(EntityId::new(cid));
}
}
}
/// Convert a 64-bit entity id to the 32-bit key space every suppression/
/// inclusion `RoaringBitmap` uses.
///

View File

@ -82,13 +82,19 @@ pub(crate) fn build_user_context(
/// Returns an empty map when the user has no preference vector, no preference
/// store is wired, or no items storage is available -- in which case the
/// preference boost is a true no-op rather than a silent constant.
///
/// The map is keyed by the **full `u64` entity id** (not a truncated `u32`): two
/// candidates whose ids differ only above bit 32 would otherwise alias onto one
/// slot and cross-contaminate each other's preference boost (query-executor
/// SUGGESTION, M0-M10 review pass 2). The consumer in
/// `ProfileExecutor::score_personalized` looks the boost up by the same full id.
pub(crate) fn compute_preference_boosts(
user_id: u64,
candidates: &[crate::schema::EntityId],
preference_vectors: Option<&crate::entities::PreferenceVectors>,
items_storage: Option<&dyn crate::storage::StorageEngine>,
slot_name: &str,
) -> HashMap<u32, f64> {
) -> HashMap<u64, f64> {
let mut boosts = HashMap::new();
let (Some(prefs), Some(storage)) = (preference_vectors, items_storage) else {
return boosts;
@ -109,7 +115,8 @@ pub(crate) fn compute_preference_boosts(
// Dimension mismatch (e.g. preference vector sized for a different slot)
// yields `None`; skip rather than contributing a bogus boost.
if let Some(cosine) = prefs.cosine_similarity(user_id, &embedding) {
boosts.insert(entity_id.as_u64() as u32, f64::from(cosine));
// Full u64 key: never truncate, so high-bit-distinct ids cannot alias.
boosts.insert(entity_id.as_u64(), f64::from(cosine));
}
}
boosts

View File

@ -289,9 +289,20 @@ impl RetrieveExecutor<'_> {
let evaluator = FilterEvaluator::new(cat, fmt, cre, tag, dur, ts, universe_ref);
match evaluator.evaluate(filter_expr) {
FilterResult::Bitmap(bitmap) => {
// SAFETY: all entity IDs stored in RoaringBitmaps are 32-bit;
// truncation is intentional and correct throughout this module.
candidates.retain(|id| bitmap.contains(id.as_u64() as u32));
// INCLUSION test against the match bitmap. The match bitmap
// holds only 32-bit ids, so a candidate id that overflows
// `u32` can never be a legitimate member and must be DROPPED
// — a raw `id as u32` truncation would alias `2^32 + k` onto
// the low slot `k`, which may be present in the bitmap, and
// wrongly RETAIN an item that does not satisfy the filter.
// `SignalRanked` candidates come straight from the ledger,
// which keys on full-u64 ids, so a `> u32::MAX` id is valid
// input that reaches here. Route through the checked
// `entity_as_u32` (drop-on-overflow), matching the
// InCollection/SocialGraph arms and the Stage 2.5 helper.
candidates.retain(|id| {
super::entity_as_u32(*id).is_some_and(|i| bitmap.contains(i))
});
}
FilterResult::Predicate(pred) => {
candidates.retain(|id| pred(id.as_u64()));
@ -427,7 +438,10 @@ impl RetrieveExecutor<'_> {
"stage 4: diversity enforced"
);
// ── Stage 4.5: Notification Cap Enforcement (M6p6) ──────────────
// ── Stage 4.5: Notification Cap FILTER (M6p6) ───────────────────
// Trims the post-diversity set against the daily caps WITHOUT recording.
// Recording is deferred to Stage 5 so it counts only the delivered page,
// not items pagination drops (C-CRITICAL).
let final_candidates =
if let (Some(caps), Some(user_id)) = (query.notification_caps, query.for_user) {
let mut final_candidates = final_candidates;
@ -437,19 +451,11 @@ impl RetrieveExecutor<'_> {
// the field was `None`; backfilling again here is cheap (a HashMap
// lookup) and closes the case where diversity selection or a later
// pass left a survivor with an unresolved creator. Without this, the
// `resolve_creator_id` fallback in `apply_notification_caps` issues a
// storage `get()` for every still-unresolved candidate.
if !item_metadata.is_empty() {
for candidate in &mut final_candidates {
if candidate.creator_id.is_none()
&& let Some(meta) = item_metadata.get(&candidate.entity_id.as_u64())
&& let Some(cid_str) = meta.get("creator_id")
&& let Ok(cid) = cid_str.parse::<u64>()
{
candidate.creator_id = Some(EntityId::new(cid));
}
}
}
// `resolve_candidate_creator_id` fallback issues a storage `get()`
// for every still-unresolved candidate. Shares the
// one `backfill_creator_ids` helper with Stage 3 so the two passes
// cannot drift on the parse rule.
super::backfill_creator_ids(&mut final_candidates, &item_metadata);
self.apply_notification_caps(final_candidates, caps, user_id, &query.profile.name)
} else {
final_candidates
@ -457,7 +463,7 @@ impl RetrieveExecutor<'_> {
tracing::trace!(
final_count = final_candidates.len(),
"stage 4.5: notification caps applied"
"stage 4.5: notification cap filter applied (recording deferred to stage 5)"
);
// ── Stage 5: Result Assembly ────────────────────────────────────
@ -472,12 +478,28 @@ impl RetrieveExecutor<'_> {
let end = offset
.saturating_add(query.limit)
.min(final_candidates.len());
let page = if offset < final_candidates.len() {
let page_slice = if offset < final_candidates.len() {
&final_candidates[offset..end]
} else {
&[]
};
// ── Notification delivery recording (post-pagination) ───────────────
// Record deliveries against ONLY the page actually returned to the caller,
// not the full post-diversity set. Stage 4.5 filtered without recording;
// this records the delivered page through the tracker's atomic
// check-and-record so the daily cap is consumed by exactly the
// notifications the caller receives — never items trimmed by pagination
// (C-CRITICAL: notification-cap recording must follow pagination). For
// non-notification profiles or no tracker this returns the page unchanged.
let recorded_page: Vec<crate::ranking::executor::ScoredCandidate> =
if let (Some(caps), Some(user_id)) = (query.notification_caps, query.for_user) {
self.record_notification_deliveries(page_slice, caps, user_id, &query.profile.name)
} else {
page_slice.to_vec()
};
let page: &[crate::ranking::executor::ScoredCandidate] = &recorded_page;
let items: Vec<RetrieveResult> = page
.iter()
.enumerate()
@ -738,4 +760,71 @@ mod tests {
inclusion-bitmap member) are both dropped"
);
}
/// C-CRITICAL regression (Stage 2 inclusion intersection): a `SignalRanked`
/// candidate whose id is `(1<<32) + k` must be DROPPED by an index-backed
/// `CategoryEq` filter even when slot `k` IS present in the match bitmap. A raw
/// `id as u32` truncation would alias the overflow id onto slot `k` and wrongly
/// RETAIN it; the checked `entity_as_u32` (drop-on-overflow) prevents the leak.
#[test]
fn stage2_index_filter_drops_overflow_alias_candidate() {
use roaring::RoaringBitmap;
use crate::storage::indexes::bitmap::BitmapIndex;
let schema = view_schema();
let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));
let profile_reg = signal_ranked_registry();
// Real low id `5` is in category "jazz"; the overflow id `(1<<32)+5`
// aliases onto slot 5 under truncation but must NOT be retained.
let k: u32 = 5;
let overflow_id = (1u64 << 32) + u64::from(k);
let now = Timestamp::now();
ledger
.record_signal("view", EntityId::new(u64::from(k)), 1.0, now)
.unwrap();
ledger
.record_signal("view", EntityId::new(overflow_id), 1.0, now)
.unwrap();
// Category index: slot 5 ∈ "jazz". The overflow item is NOT indexed under
// jazz; only its truncated alias (slot 5) would falsely match.
let category = BitmapIndex::new("category");
category.insert(k, "jazz");
// Universe must be Some so Stage 2 runs the index intersection. It only
// gates entry to the block; CategoryEq resolves to the category bitmap.
let mut universe_bm = RoaringBitmap::new();
universe_bm.insert(k);
let universe = std::sync::RwLock::new(universe_bm);
let exec = RetrieveExecutor::new(
&ledger,
&profile_reg,
Some(&category), // category
None, // format
None, // creator
None, // tag
None, // duration
None, // created_at
Some(&universe), // universe
);
let query = Retrieve::builder()
.profile("view_ranked")
.filter(FilterExpr::CategoryEq("jazz".into()))
.limit(10)
.build()
.unwrap();
let results = exec.execute(&query).unwrap();
let ids: Vec<u64> = results.items.iter().map(|r| r.entity_id.as_u64()).collect();
assert_eq!(
ids,
vec![u64::from(k)],
"only the genuine low id 5 may survive CategoryEq(jazz); the overflow id \
{overflow_id} must be dropped, not aliased onto slot {k} and retained"
);
}
}

View File

@ -160,6 +160,7 @@ fn apply_signal_thresholds(
crate::schema::Window::AllTime,
ledger,
degradation_level,
crate::schema::Timestamp::now().as_nanos(),
)
.map_err(|e| QueryError::InvalidFilter {
field: signal.clone(),

View File

@ -365,14 +365,15 @@ fn cohort_rescore_normalizes_score_to_unit_range_and_reorders() {
},
];
let boosts = vec![Boost {
let mut profile = crate::ranking::test_fixtures::minimal_profile("cohort_rescore");
profile.boosts = vec![Boost {
signal: "view".into(),
agg: SignalAgg::DecayScore,
window: Window::AllTime,
weight: 1.0,
}];
exec.rescore_with_cohort(&mut scored, "tech", &cohort_ledger, &boosts)
exec.rescore_with_cohort(&mut scored, "tech", &cohort_ledger, &profile)
.unwrap();
// After rescore: item 2 (high cohort view) must rank first, and EVERY score

View File

@ -93,7 +93,19 @@ impl HybridFusion {
.into_iter()
.map(|(id, score)| (EntityId::new(id), score))
.collect();
results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
// Sort descending by fused score, breaking ties on ASCENDING entity id so
// the order is a true total order. The scores arrive from a `HashMap`
// whose iteration order is process-randomized (default `RandomState`), and
// `sort_by` is stable — without the id tie-break two entities with an equal
// fused score would keep whatever relative order the randomized map
// iteration produced, which changes between process runs (W14). RRF terms
// are always finite positive, so `partial_cmp` never sees `NaN` here; the
// only missing piece was the deterministic id tie-break.
results.sort_by(|a, b| {
b.1.partial_cmp(&a.1)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.0.as_u64().cmp(&b.0.as_u64()))
});
results
}
}
@ -336,6 +348,45 @@ mod tests {
assert!((results[0].1 - expected).abs() < 1e-9);
}
/// W14 regression: equal fused scores must be ordered by ASCENDING entity id,
/// not by randomized `HashMap` iteration order. Two items present at the same
/// rank in both lists earn an identical RRF score, so the tie-break is the only
/// thing that fixes their order.
#[test]
fn fuse_ties_break_on_ascending_entity_id() {
// Both items rank 1 in their (single-element) lists, so each accrues the
// SAME RRF term and the scores are exactly equal. Feed them in descending
// id order to prove the sort actively reorders to ascending id.
let bm25 = vec![(EntityId::new(9), 1.0f32)];
let ann = vec![(EntityId::new(3), 0.1f32)];
let fusion = HybridFusion::new();
let results = fusion.fuse(&bm25, &ann);
let ids: Vec<u64> = results.iter().map(|(id, _)| id.as_u64()).collect();
assert_eq!(
ids,
vec![3, 9],
"equal fused scores must order by ascending entity id"
);
// The scores really are tied (otherwise the tie-break would be untested).
assert!((results[0].1 - results[1].1).abs() < 1e-12);
}
/// W14 regression: fusing identical inputs twice must yield byte-identical
/// output, including the tie order. Before the fix the tie order depended on
/// the per-process `HashMap` seed and could differ across runs.
#[test]
fn fuse_is_deterministic_across_repeated_calls() {
let bm25: Vec<(EntityId, f32)> = (1..=20).map(|i| (EntityId::new(i), 1.0f32)).collect();
let ann: Vec<(EntityId, f32)> = (1..=20).map(|i| (EntityId::new(i), 0.5f32)).collect();
let fusion = HybridFusion::new();
let first = fusion.fuse(&bm25, &ann);
let second = fusion.fuse(&bm25, &ann);
assert_eq!(
first, second,
"fusing identical inputs must produce identical, deterministic output"
);
}
#[test]
fn fuse_k_affects_scores() {
let bm25 = vec![(EntityId::new(1), 1.0f32)];

View File

@ -50,7 +50,7 @@ use crate::{
range::RangeIndex,
},
},
text::collectors::AllScoresCollector,
text::collectors::BoundedScoresCollector,
};
/// Canonical default embedding-slot name.
@ -60,6 +60,17 @@ use crate::{
/// instead of being duplicated at each `unwrap_or("content")` call site.
const DEFAULT_EMBEDDING_SLOT: &str = "content";
/// Full-fidelity BM25 over-fetch multiplier: the bounded collector keeps at most
/// `limit * this` documents (floored at [`BM25_TOP_K_FLOOR`]). Mirrors
/// [`ANN_K_MULTIPLIER_FULL`] so the text and vector candidate pools stay
/// comparable before fusion.
const BM25_TOP_K_MULTIPLIER: usize = 20;
/// Minimum BM25 top-K regardless of `limit`, so tiny-limit queries still seed a
/// usable text candidate pool. Matches the spec's `top_k_text` default
/// (06-text-retrieval.md Sec 11.2) and [`ANN_K_FLOOR`].
const BM25_TOP_K_FLOOR: usize = 200;
/// Convert a 64-bit entity id to the 32-bit key space used by every
/// `RoaringBitmap` in the suppression/inclusion path.
///
@ -178,7 +189,12 @@ impl SearchExecutor<'_> {
);
// ── Stage 2 + 2b: Metadata Filters ──────────────────────────────────
self.apply_metadata_filter(combined_filter, &mut candidates, &mut warnings);
self.apply_metadata_filter(
query.entity_kind,
combined_filter,
&mut candidates,
&mut warnings,
);
self.apply_creator_metadata_filter(query, combined_filter, &mut candidates);
// ── Stage 2.2/2.3/2.4: Deferred Post-Filters ────────────────────────
@ -329,9 +345,19 @@ impl SearchExecutor<'_> {
.parse(query_text)
.map_err(|e| QueryError::StorageError(format!("text query parse: {e}")))?;
let searcher = idx.searcher();
let collector = AllScoresCollector {
entity_id_field: idx.fields().entity_id,
};
// Bound the candidate set to the top-`bm25_cap` by score on the NORMAL
// path too, not just under degradation (W: BM25 must not return the whole
// match set). A broad/near-ubiquitous term over a large corpus would
// otherwise materialize one `(EntityId, f32)` per match plus a downstream
// `HashMap` — an allocation proportional to corpus match count rather than
// the requested limit. The bounded collector keeps only the top
// `bm25_cap` documents via a per-segment min-heap, capping peak memory
// inside Tantivy. Under `ReducedCandidates` the existing post-fusion
// truncate trims further.
let bm25_cap = (query.limit as usize)
.saturating_mul(BM25_TOP_K_MULTIPLIER)
.max(BM25_TOP_K_FLOOR);
let collector = BoundedScoresCollector::new(idx.fields().entity_id, bm25_cap);
let mut raw = searcher
.search(tq.as_ref(), &collector)
.map_err(|e| QueryError::StorageError(format!("BM25 search: {e}")))?;
@ -342,9 +368,17 @@ impl SearchExecutor<'_> {
// when a NaN slips through (BM25 over degenerate term statistics can emit
// one). Neutralize NaN to 0.0 — the same neutral value the ranking module
// uses (`finite_score`) — then sort with `total_cmp`, a true total order
// over all finite scores, so the result is deterministic.
// over all finite scores. Break score ties on ASCENDING entity id so the
// ordering is a true total order even when two documents earn the same
// BM25 score (Tantivy's doc-id traversal is not a stable secondary key
// across segment merges / restarts); this is the same determinism guard
// applied to RRF fusion (W14).
let finite = |s: f32| if s.is_nan() { 0.0 } else { s };
raw.sort_by(|a, b| finite(b.1).total_cmp(&finite(a.1)));
raw.sort_by(|a, b| {
finite(b.1)
.total_cmp(&finite(a.1))
.then_with(|| a.0.as_u64().cmp(&b.0.as_u64()))
});
Ok(raw)
}
@ -419,13 +453,36 @@ impl SearchExecutor<'_> {
/// [`Self::apply_deferred_post_filters`], so the correct degraded behavior is
/// to skip the index intersection entirely (retain all) and surface a warning
/// rather than collapse to an empty result set.
///
/// # Creator searches are item-only here (C-CRITICAL)
///
/// The bitmap indexes (`category_index`/`format_index`/…) are ITEM-scoped:
/// they hold item ids keyed by item metadata, always wired regardless of
/// `entity_kind`. For a creator SEARCH the candidates are CREATOR ids, so
/// intersecting them against the item indexes is a namespace error — in a
/// creator-only DB the item category index is empty and EVERY creator is
/// dropped; in a mixed DB only creators whose id happens to alias an item in
/// that category survive. A CategoryEq/FormatEq on creators is instead
/// resolved by [`Self::apply_creator_metadata_filter`] (Stage 2b) against the
/// per-creator stored metadata. So this stage returns early (retains all) for
/// `EntityKind::Creator`; the deferred variants are also no-ops for creators
/// in Stage 2 and are handled correctly downstream, so the early return is
/// safe for them too.
#[allow(clippy::significant_drop_tightening)] // universe read guard held across the evaluator
fn apply_metadata_filter(
&self,
entity_kind: EntityKind,
combined_filter: Option<&FilterExpr>,
candidates: &mut Vec<EntityId>,
warnings: &mut Vec<String>,
) {
// Creator candidates are never intersected against the item-scoped bitmap
// indexes — see the doc above. Stage 2b applies any creator metadata
// filter against the correct per-creator metadata instead.
if entity_kind == EntityKind::Creator {
return;
}
let Some(filter_expr) = combined_filter else {
return;
};
@ -589,9 +646,18 @@ impl SearchExecutor<'_> {
retrieval_scores: &HashMap<u64, f64>,
now: Timestamp,
) -> Result<Vec<crate::ranking::executor::ScoredCandidate>, QueryError> {
let executor =
let mut executor =
ProfileExecutor::new(self.ledger).with_degradation_level(self.degradation_level);
// Wire the Shuffle sort's per-user, per-minute seed (spec §11.6) so SEARCH
// shuffle varies per user; without a FOR USER it stays a stable per-minute
// global permutation.
if matches!(profile.sort, Some(crate::ranking::profile::Sort::Shuffle))
&& let Some(user_id) = query.for_user
{
executor = executor.with_shuffle_user(user_id);
}
// Pre-load item metadata for keyword hint matching when session is active.
let item_metadata: HashMap<u64, HashMap<String, String>> = if self.session_context.is_some()
{
@ -1389,6 +1455,80 @@ mod tests {
);
}
/// C-CRITICAL regression (creator metadata filter): `apply_metadata_filter`
/// must NOT intersect CREATOR candidates against the ITEM-scoped bitmap
/// indexes. With `EntityKind::Creator` it retains all candidates (the creator
/// path relies on Stage 2b); with `EntityKind::Item` the item index still
/// applies. Before the fix, a creator-only DB dropped every creator because
/// the item category index was empty / held a different namespace.
#[test]
fn creator_search_skips_item_index_intersection() {
let schema = test_schema();
let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));
let profile_reg = setup_registry();
let registry = two_item_registry();
// Item-scoped category index: item id 1 ∈ "jazz". Creator candidate id 1
// would alias onto this slot if the item index were (wrongly) applied.
let category = BitmapIndex::new("category");
category.insert(1, "jazz");
let universe = two_item_universe();
// Build an executor wiring the item category index.
let exec = SearchExecutor::new(
&ledger,
&profile_reg,
None, // text_index
Some(&registry), // embedding_registry
Some(&category), // category (ITEM-scoped)
None, // format
None, // creator
None, // tag
None, // duration
None, // created_at
Some(&universe), // universe
);
// Creator candidates (ids 1 and 2). The filter asks for category "news",
// which no ITEM is in — so an item-index intersection would drop BOTH (or
// keep only the aliasing id 1). The creator guard must retain both and let
// Stage 2b decide against per-creator metadata.
let mut creator_candidates = vec![EntityId::new(1), EntityId::new(2)];
let mut warnings = Vec::new();
let filter = FilterExpr::CategoryEq("news".into());
exec.apply_metadata_filter(
EntityKind::Creator,
Some(&filter),
&mut creator_candidates,
&mut warnings,
);
let mut ids: Vec<u64> = creator_candidates.iter().map(|e| e.as_u64()).collect();
ids.sort_unstable();
assert_eq!(
ids,
vec![1, 2],
"creator candidates must NOT be intersected against the item category index; \
both survive Stage 2 and Stage 2b resolves the real per-creator filter"
);
// Sanity: for ITEM candidates the same index DOES apply — only the jazz
// item (id 1) survives a CategoryEq(jazz) filter; id 2 is dropped.
let mut item_candidates = vec![EntityId::new(1), EntityId::new(2)];
let mut warnings = Vec::new();
let jazz = FilterExpr::CategoryEq("jazz".into());
exec.apply_metadata_filter(
EntityKind::Item,
Some(&jazz),
&mut item_candidates,
&mut warnings,
);
assert_eq!(
item_candidates,
vec![EntityId::new(1)],
"item candidates ARE intersected against the item category index"
);
}
/// Completeness-W regression: a poisoned universe lock must not silently
/// return an empty result set. `resolve_scope` recovers the last-known
/// universe via `PoisonError::into_inner` and surfaces a warning, and

View File

@ -31,7 +31,13 @@ pub struct Suggestion {
pub struct Suggest {
/// Prefix to autocomplete. Empty string returns trending searches.
pub prefix: String,
/// Optional user for personalized suggestion ordering (reserved for future use).
/// Optional user for personalized suggestion ordering.
///
/// When set, it is forwarded to [`SuggestionIndex::suggest`] and biases the
/// tie-break among equal-frequency trending suggestions (different users see a
/// stable-but-slightly-varied order). It does not change which suggestions
/// appear — only the relative order of ties — and has no effect on prefix
/// (non-trending) suggestions, which are ordered by the sorted term list.
pub for_user: Option<EntityId>,
/// Maximum number of suggestions to return (1..=50).
pub limit: u32,
@ -201,11 +207,20 @@ impl SuggestionIndex {
/// Uses binary search to find the start of the prefix range in the sorted
/// term list, then collects consecutive matches. Returns at most `limit` terms.
///
/// If `prefix` is empty, returns top-N trending queries instead.
/// If `prefix` is empty, returns top-N trending queries instead. The optional
/// `for_user` biases the tie-break among equal-frequency trending terms so
/// different users see a stable-but-slightly-varied ordering (the
/// "personalized suggestion ordering" the [`Suggest::for_user`] field
/// documents); it does not change which terms appear, only the order of ties.
#[must_use]
pub fn suggest(&self, prefix: &str, limit: usize) -> Vec<Suggestion> {
pub fn suggest(
&self,
prefix: &str,
limit: usize,
for_user: Option<EntityId>,
) -> Vec<Suggestion> {
if prefix.is_empty() {
return self.trending_suggestions(limit);
return self.trending_suggestions(limit, for_user);
}
let prefix_lower = prefix.to_lowercase();
@ -232,13 +247,26 @@ impl SuggestionIndex {
}
/// Return top N trending search queries (by query frequency).
fn trending_suggestions(&self, limit: usize) -> Vec<Suggestion> {
///
/// Ties on frequency are broken DETERMINISTICALLY rather than by `DashMap`
/// iteration order (which is process-randomized), so the returned top-N is
/// stable across runs for the same data (the same determinism guard applied
/// to RRF fusion, W14). When `for_user` is set, the tie-break is seeded by the
/// user id so different users see a stable-but-slightly-varied ordering among
/// equal-frequency terms — the per-user ordering the [`Suggest::for_user`]
/// field documents. Without `for_user` the tie-break is a plain ascending sort
/// of the term string.
fn trending_suggestions(&self, limit: usize, for_user: Option<EntityId>) -> Vec<Suggestion> {
let mut pairs: Vec<(String, u64)> = self
.query_freq
.iter()
.map(|entry| (entry.key().clone(), entry.value().load(Ordering::Relaxed))) // Approximate: Relaxed is correct, no sync needed
.collect();
pairs.sort_by(|a, b| b.1.cmp(&a.1));
let seed = for_user.map_or(0, EntityId::as_u64);
pairs.sort_by(|a, b| {
b.1.cmp(&a.1)
.then_with(|| Self::tie_break_key(&a.0, seed).cmp(&Self::tie_break_key(&b.0, seed)))
});
pairs.truncate(limit);
pairs
.into_iter()
@ -246,6 +274,28 @@ impl SuggestionIndex {
.collect()
}
/// Deterministic tie-break key for a trending term under an optional per-user
/// `seed`. With `seed == 0` (no user) it is the term bytes themselves, giving
/// a plain ascending tie order; with a user seed it is a stable hash of
/// `(seed, term)`, so equal-frequency terms order consistently per user but
/// differ between users. Either way the key is a pure function of its inputs,
/// so the order is reproducible across process runs.
fn tie_break_key(term: &str, seed: u64) -> u64 {
use std::hash::{Hash, Hasher};
if seed == 0 {
// No user: a stable hash of the term alone yields a deterministic,
// user-agnostic tie order (the bytes themselves would also work, but a
// single u64 key keeps the comparator uniform with the seeded path).
let mut h = std::collections::hash_map::DefaultHasher::new();
term.hash(&mut h);
return h.finish();
}
let mut h = std::collections::hash_map::DefaultHasher::new();
seed.hash(&mut h);
term.hash(&mut h);
h.finish()
}
/// Evict low-frequency entries when the trending map is near the cap.
///
/// Removes the bottom half of entries by frequency when `query_freq` exceeds
@ -265,8 +315,14 @@ impl SuggestionIndex {
.iter()
.map(|e| (e.key().clone(), e.value().load(Ordering::Relaxed))) // Approximate: Relaxed is correct
.collect();
// Sort ascending by frequency; keep the top half.
pairs.sort_by_key(|&(_, f)| f);
// Sort ascending by frequency, breaking ties DETERMINISTICALLY on a stable
// hash of the term rather than on `DashMap` iteration order (which is
// process-randomized) — so WHICH equal-frequency terms get evicted is
// reproducible across runs rather than depending on the map seed (W14).
pairs.sort_by(|a, b| {
a.1.cmp(&b.1)
.then_with(|| Self::tie_break_key(&a.0, 0).cmp(&Self::tie_break_key(&b.0, 0)))
});
let remove_count = pairs.len() / 2;
for (key, _) in pairs.into_iter().take(remove_count) {
// Keep `key_count` in lockstep with the map: decrement only when a
@ -295,7 +351,7 @@ mod tests {
#[test]
fn new_index_is_empty() {
let idx = SuggestionIndex::new();
assert!(idx.suggest("anything", 10).is_empty());
assert!(idx.suggest("anything", 10, None).is_empty());
}
#[test]
@ -303,11 +359,11 @@ mod tests {
let idx = SuggestionIndex::new();
idx.index_title("Rust Programming Tutorial");
let results = idx.suggest("rus", 10);
let results = idx.suggest("rus", 10, None);
assert_eq!(results.len(), 1);
assert_eq!(results[0].text, "rust");
let results = idx.suggest("pro", 10);
let results = idx.suggest("pro", 10, None);
assert_eq!(results.len(), 1);
assert_eq!(results[0].text, "programming");
}
@ -317,7 +373,7 @@ mod tests {
let idx = SuggestionIndex::new();
idx.index_title("Rust Rust Rust");
let results = idx.suggest("rus", 10);
let results = idx.suggest("rus", 10, None);
assert_eq!(results.len(), 1);
}
@ -327,11 +383,11 @@ mod tests {
idx.index_title("I am a Rustacean");
// "I", "a" are too short (< 2 chars).
let results = idx.suggest("i", 10);
let results = idx.suggest("i", 10, None);
assert!(results.is_empty());
// "am" is exactly 2 chars.
let results = idx.suggest("am", 10);
let results = idx.suggest("am", 10, None);
assert_eq!(results.len(), 1);
}
@ -340,7 +396,7 @@ mod tests {
let idx = SuggestionIndex::new();
idx.index_title("Rust TUTORIAL");
let results = idx.suggest("RUS", 10);
let results = idx.suggest("RUS", 10, None);
assert_eq!(results.len(), 1);
assert_eq!(results[0].text, "rust");
}
@ -350,11 +406,11 @@ mod tests {
let idx = SuggestionIndex::new();
idx.index_title("alpha beta gamma delta");
let results = idx.suggest("", 2);
let results = idx.suggest("", 2, None);
assert!(results.len() <= 2);
// All 4 terms start with different prefixes; requesting "a" gives 1.
let results = idx.suggest("a", 10);
let results = idx.suggest("a", 10, None);
assert_eq!(results.len(), 1);
}
@ -363,7 +419,7 @@ mod tests {
let idx = SuggestionIndex::new();
idx.index_title("Rust Tutorial");
let results = idx.suggest("zzz", 10);
let results = idx.suggest("zzz", 10, None);
assert!(results.is_empty());
}
@ -374,7 +430,7 @@ mod tests {
idx.record_query("rust tutorial");
idx.record_query("rust async");
let trending = idx.suggest("", 10);
let trending = idx.suggest("", 10, None);
assert_eq!(trending.len(), 2);
assert_eq!(trending[0].text, "rust tutorial");
assert_eq!(trending[0].frequency, 2);
@ -388,7 +444,7 @@ mod tests {
idx.record_query(" Rust Tutorial "); // leading/trailing whitespace
idx.record_query("rust tutorial");
let trending = idx.suggest("", 10);
let trending = idx.suggest("", 10, None);
assert_eq!(trending.len(), 1);
assert_eq!(trending[0].frequency, 2);
}
@ -399,7 +455,7 @@ mod tests {
idx.record_query("");
idx.record_query(" ");
let trending = idx.suggest("", 10);
let trending = idx.suggest("", 10, None);
assert!(trending.is_empty());
}
@ -484,7 +540,7 @@ mod tests {
let idx = SuggestionIndex::new();
idx.record_query("rust tutorial"); // double space
idx.record_query("rust tutorial"); // single space
let trending = idx.suggest("", 10);
let trending = idx.suggest("", 10, None);
assert_eq!(trending.len(), 1, "internal whitespace should be collapsed");
assert_eq!(trending[0].frequency, 2);
}
@ -496,7 +552,7 @@ mod tests {
idx.record_query("rust");
idx.record_query("rust");
let results = idx.suggest("rus", 10);
let results = idx.suggest("rus", 10, None);
assert_eq!(results.len(), 1);
assert_eq!(results[0].text, "rust");
assert_eq!(results[0].frequency, 2);
@ -520,22 +576,113 @@ mod tests {
idx.index_title("Rust Tutorial");
idx.index_title("Rust Async Programming");
let results = idx.suggest("rus", 10);
let results = idx.suggest("rus", 10, None);
assert_eq!(results.len(), 1); // "rust" deduplicated
let results = idx.suggest("a", 10);
let results = idx.suggest("a", 10, None);
assert_eq!(results.len(), 1); // "async"
let results = idx.suggest("t", 10);
let results = idx.suggest("t", 10, None);
assert_eq!(results.len(), 1); // "tutorial"
let results = idx.suggest("p", 10);
let results = idx.suggest("p", 10, None);
assert_eq!(results.len(), 1); // "programming"
}
#[test]
fn default_trait() {
let idx = SuggestionIndex::default();
assert!(idx.suggest("x", 10).is_empty());
assert!(idx.suggest("x", 10, None).is_empty());
}
/// W14 regression: trending ties (equal frequency) must order
/// DETERMINISTICALLY across runs, not by randomized `DashMap` iteration.
/// Repeating the query against unchanged data must return identical order.
#[test]
fn trending_tie_order_is_deterministic() {
let idx = SuggestionIndex::new();
// Five distinct terms, each queried exactly once → all tie on frequency 1.
for t in &["alpha", "bravo", "charlie", "delta", "echo"] {
idx.record_query(t);
}
let first: Vec<String> = idx
.suggest("", 10, None)
.into_iter()
.map(|s| s.text)
.collect();
let second: Vec<String> = idx
.suggest("", 10, None)
.into_iter()
.map(|s| s.text)
.collect();
assert_eq!(
first, second,
"equal-frequency trending order must be deterministic across calls"
);
assert_eq!(first.len(), 5, "all tied terms returned");
}
/// SUGGESTION regression: `for_user` is actually CONSULTED — it biases the
/// tie-break among equal-frequency terms, so two different users can see a
/// different (but each-stable) tie order. The set of terms is unchanged.
#[test]
fn for_user_biases_trending_tie_order() {
let idx = SuggestionIndex::new();
// Many tied terms so the per-user permutation is very likely to differ.
for t in &["aa", "bb", "cc", "dd", "ee", "ff", "gg", "hh"] {
idx.record_query(t);
}
let global: Vec<String> = idx
.suggest("", 8, None)
.into_iter()
.map(|s| s.text)
.collect();
let user_a: Vec<String> = idx
.suggest("", 8, Some(EntityId::new(1)))
.into_iter()
.map(|s| s.text)
.collect();
let user_b: Vec<String> = idx
.suggest("", 8, Some(EntityId::new(2)))
.into_iter()
.map(|s| s.text)
.collect();
// Same SET of terms regardless of user (only the tie order may differ).
let set = |v: &[String]| {
let mut s = v.to_vec();
s.sort();
s
};
assert_eq!(set(&global), set(&user_a));
assert_eq!(set(&global), set(&user_b));
// Each user's order is stable across repeated calls (deterministic seed).
let user_a_again: Vec<String> = idx
.suggest("", 8, Some(EntityId::new(1)))
.into_iter()
.map(|s| s.text)
.collect();
assert_eq!(user_a, user_a_again, "per-user tie order is deterministic");
// The knob is actually CONSULTED: across a handful of user seeds the tie
// order is not forced identical for everyone (a no-op `for_user` would
// produce the same order for all users). Comparing several seeds makes the
// assertion robust even though the `DefaultHasher` outcome is itself fixed.
let orders: Vec<Vec<String>> = [1u64, 2, 3, 7, 42]
.iter()
.map(|&u| {
idx.suggest("", 8, Some(EntityId::new(u)))
.into_iter()
.map(|s| s.text)
.collect()
})
.collect();
assert!(
orders.iter().any(|o| *o != global),
"for_user must influence the tie order for at least some user; \
a silently-ignored knob would yield the global order for everyone"
);
let _ = (user_a, user_b);
}
}

View File

@ -127,9 +127,25 @@ pub fn register_builtins(registry: &mut ProfileRegistry) -> Result<(), ProfileEr
Ok(())
}
/// The global trending profile.
///
/// `Sort::Trending` is the ordering authority on the global (non-cohort) path:
/// its formula `view_vel + 2*share_vel` over the 24h window *replaces* stages 4-5
/// (spec §11.9), so the executor skips the boost loop when this sort is active
/// (no double-count — previously the base and these boosts both read the same
/// signals and summed to `2*view_vel + 4*share_vel`).
///
/// The `view`/`share` velocity boosts below are NOT redundant: they are the
/// signal definition the **cohort-scoped rescore** (query Stage 3b,
/// `rescore_with_cohort`) reads to re-score candidates against a cohort's signal
/// ledger — that path consumes `profile.boosts` directly and does not run the
/// `Sort::Trending` formula. Their weights deliberately mirror the formula so the
/// cohort ordering matches the global one. Keep the two in sync.
fn trending() -> RankingProfile {
let mut p = skeleton("trending");
p.sort = Some(Sort::Trending);
// Cohort-rescore signal definition (see doc above); the global path uses the
// Sort::Trending formula and skips these per spec §11.9.
p.boosts = vec![
Boost {
signal: "share".into(),
@ -424,13 +440,17 @@ fn search() -> RankingProfile {
/// `cohort_trending`: trending content scoped to a named cohort.
///
/// Identical boosts and sort mode to the global `trending` profile, but
/// intended for use with `RetrieveBuilder::cohort("my_cohort")`. The executor
/// reads signal values from the cohort signal ledger instead of the global
/// ledger. Without a `cohort` clause, this profile behaves identically to
/// `trending`.
/// intended for use with `RetrieveBuilder::cohort("my_cohort")`. With a `cohort`
/// clause, the executor reads signal values from the cohort signal ledger via the
/// Stage 3b rescore (`rescore_with_cohort`), which consumes `profile.boosts`
/// directly. Without a `cohort` clause, this profile behaves identically to
/// `trending`: the `Sort::Trending` formula orders results and the boosts below
/// are skipped (spec §11.9), so there is no double-count.
fn cohort_trending() -> RankingProfile {
let mut p = skeleton("cohort_trending");
p.sort = Some(Sort::Trending);
// Cohort-rescore signal definition (read by Stage 3b when a cohort is set);
// the no-cohort path uses the Sort::Trending formula and skips these.
p.boosts = vec![
Boost {
signal: "share".into(),

View File

@ -25,12 +25,14 @@ pub struct UserContext {
/// Items from creators the user has interacted with get a positive boost
/// proportional to the interaction weight.
pub creator_interaction_boosts: HashMap<u32, f64>,
/// Per-item preference boost: maps `item_id (u32)` -> cosine similarity in
/// `[-1.0, 1.0]` between the user's preference vector and the candidate's
/// content embedding. Pre-computed by `RetrieveExecutor` when both a
/// Per-item preference boost: maps the full `u64` entity id -> cosine
/// similarity in `[-1.0, 1.0]` between the user's preference vector and the
/// candidate's content embedding. Keyed by the full u64 (not a truncated u32
/// slot) so two candidates whose ids differ only above bit 32 cannot collide
/// on one map entry. Pre-computed by `RetrieveExecutor` when both a
/// preference vector and per-item embeddings are available; empty when the
/// user has no preference vector or embeddings are absent.
pub preference_boosts: HashMap<u32, f64>,
pub preference_boosts: HashMap<u64, f64>,
}
// -- Scored candidate ---------------------------------------------------------

View File

@ -69,18 +69,63 @@ pub(super) fn hidden_gems_score(quality: f64, view_count: f64) -> f64 {
quality / (view_count + 10.0).log10()
}
/// Shuffle: deterministic hash of entity ID for stable random ordering.
pub(super) fn shuffle_score(entity_id: u64) -> f64 {
let hash = blake3::hash(&entity_id.to_le_bytes());
/// Shuffle pseudo-random draw in `[0, 1)` from a per-(user, minute, item) seed.
///
/// Spec §11.6: `shuffle_score(item) = random(seed) * quality_weight(item)` with
/// `seed = hash(user_id, timestamp_minute)` — "same results for same user within
/// 1 minute". The item id is folded into the hash *after* the seed so the per-item
/// draw varies within a single seed (otherwise every item would share one random
/// value and `quality_weight` alone would order them, collapsing the random
/// sampling the mode exists to provide). Holding `(user_id, timestamp_minute)`
/// fixed makes the whole permutation reproducible across a page refresh, and
/// changing the user *or* crossing a minute boundary reshuffles — exactly the
/// "deterministic within short time windows" contract.
///
/// `user_id == 0` is the anonymous / no-user case (RETRIEVE without `FOR USER`):
/// the draw is then a stable per-minute global permutation rather than per-user
/// variety, which is the best we can do without a user identity.
pub(super) fn shuffle_random(user_id: u64, timestamp_minute: u64, entity_id: u64) -> f64 {
// Divide by 2^64 (named constant; `u64::MAX as f64` rounds to 2^64 anyway) so
// the result is in [0, 1) — a uniform pseudo-random draw seeded by the tuple.
const TWO_POW_64: f64 = 18_446_744_073_709_551_616.0;
let mut hasher = blake3::Hasher::new();
hasher.update(&user_id.to_le_bytes());
hasher.update(&timestamp_minute.to_le_bytes());
hasher.update(&entity_id.to_le_bytes());
let hash = hasher.finalize();
let bytes = hash.as_bytes();
// First 8 bytes as u64, normalized to [0, 1].
let arr: [u8; 8] = [
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
];
let v = u64::from_le_bytes(arr);
#[allow(clippy::cast_precision_loss)]
let score = v as f64 / u64::MAX as f64;
score
{
v as f64 / TWO_POW_64
}
}
/// Shuffle quality score (spec §11.6):
/// `completion_rate * 0.5 + like_ratio * 0.3 + log10(views + 1) * 0.2`.
///
/// Inputs are read from the ledger by the caller: `completion_rate` is the
/// `completion` running decay score clamped to `[0, 1]`, `like_ratio` is
/// `likes / (views + 1)` clamped to `[0, 1]`, and `views` is the all-time view
/// count. A floor of `0.0` guards against a degenerate negative composite so the
/// `sqrt` in `shuffle_quality_weight` is always real.
pub(super) fn shuffle_quality_score(completion_rate: f64, like_ratio: f64, views: f64) -> f64 {
let log_views = (views + 1.0).log10();
completion_rate
.mul_add(0.5, like_ratio.mul_add(0.3, log_views * 0.2))
.max(0.0)
}
/// Shuffle quality weight: `sqrt(quality_score)` (spec §11.6). High-quality items
/// get a larger multiplier on their random draw, so they are *more likely* to
/// surface without being guaranteed. A zero-quality item keeps a weight of 0 and
/// therefore scores 0 (sorted last among shuffled items), matching the spec's
/// "high-quality items are more likely to appear but not guaranteed".
pub(super) fn shuffle_quality_weight(quality_score: f64) -> f64 {
quality_score.max(0.0).sqrt()
}
/// Haversine great-circle distance in kilometres.
@ -172,6 +217,44 @@ mod tests {
);
}
#[test]
fn shuffle_random_is_stable_for_fixed_seed_and_in_unit_range() {
// Spec §11.6: the draw is deterministic for a fixed (user, minute, item)
// tuple and lies in [0, 1).
let a = shuffle_random(42, 1000, 7);
let b = shuffle_random(42, 1000, 7);
assert_eq!(a, b, "same (user, minute, item) must give the same draw");
assert!((0.0..1.0).contains(&a), "draw must be in [0, 1), got {a}");
}
#[test]
fn shuffle_random_varies_by_user_minute_and_item() {
// Changing any seed component changes the draw (no fixed global hash).
let base = shuffle_random(1, 100, 9);
assert_ne!(base, shuffle_random(2, 100, 9), "user must change the draw");
assert_ne!(
base,
shuffle_random(1, 101, 9),
"minute must change the draw"
);
assert_ne!(base, shuffle_random(1, 100, 8), "item must change the draw");
}
#[test]
fn shuffle_quality_weight_rewards_quality() {
// quality_weight = sqrt(quality_score); higher quality -> larger weight.
let low = shuffle_quality_weight(shuffle_quality_score(0.1, 0.0, 1.0));
let high = shuffle_quality_weight(shuffle_quality_score(1.0, 1.0, 1000.0));
assert!(high > low, "higher quality must yield a larger weight");
// Zero quality -> zero weight -> a zero shuffle score (sorted last).
assert_eq!(
shuffle_quality_weight(shuffle_quality_score(0.0, 0.0, 0.0)),
0.0
);
// Weight is always real (the quality floor guards the sqrt).
assert!(shuffle_quality_weight(shuffle_quality_score(0.0, 0.0, 0.0)).is_finite());
}
#[test]
fn haversine_same_point_is_zero() {
let d = haversine_km(40.7128, -74.0060, 40.7128, -74.0060);

View File

@ -32,7 +32,7 @@ pub(super) const COARSE_VALUE_WINDOW: Window = Window::AllTime;
// -- Signal reading -----------------------------------------------------------
/// Read a signal aggregation for a candidate.
/// Read a signal aggregation for a candidate against an explicit query clock.
///
/// Propagates the ledger's schema error when `signal` is not defined, so a
/// profile that slipped a typo'd signal name past registration fails loud
@ -40,6 +40,14 @@ pub(super) const COARSE_VALUE_WINDOW: Window = Window::AllTime;
/// not an error -- the ledger returns 0 / 0.0 / `None` for those, which we map
/// to 0.0.
///
/// `now_ns` is the query's logical clock, captured **once** per query by the
/// executor and threaded into every ledger read so the whole candidate set is
/// decayed/aged to one consistent "now". Without it each candidate would read
/// `Timestamp::now()` independently and re-running the identical query against
/// an unchanged ledger would yield slightly different scores (Accuracy-W,
/// M0-M10 review): all the time-dependent reads here route through the ledger's
/// explicit-clock `_at` variants.
///
/// Under `DegradationLevel::CoarseAggregates` (or `NoDiversity`), windows are
/// substituted to reduce ledger scan cost:
/// - `SignalAgg::Value` → `Window::AllTime` (warm-tier count, O(1))
@ -58,6 +66,7 @@ pub(crate) fn read_agg(
window: Window,
ledger: &SignalLedger,
degradation: DegradationLevel,
now_ns: u64,
) -> crate::Result<f64> {
let window = if degradation.coarsens_aggregates() {
match agg {
@ -71,10 +80,10 @@ pub(crate) fn read_agg(
match agg {
SignalAgg::Value => {
#[allow(clippy::cast_precision_loss)]
let count = ledger.read_windowed_count(entity_id, signal, window)? as f64;
let count = ledger.read_windowed_count_at(entity_id, signal, window, now_ns)? as f64;
Ok(count)
}
SignalAgg::Velocity => ledger.read_velocity(entity_id, signal, window),
SignalAgg::Velocity => ledger.read_velocity_at(entity_id, signal, window, now_ns),
// `DecayScore` intentionally ignores `window`. A decay score is an O(1)
// running quantity -- the exponentially-weighted sum maintained in the
// hot tier (`HotSignalState`) -- decayed forward from its last update to
@ -85,7 +94,7 @@ pub(crate) fn read_agg(
// aggregation arm shares one signature; passing different windows for a
// `DecayScore` term yields the same value by construction.
SignalAgg::DecayScore => Ok(ledger
.read_decay_score(entity_id, signal, 0)?
.read_decay_score_at(entity_id, signal, 0, now_ns)?
.unwrap_or(0.0)),
SignalAgg::Ratio | SignalAgg::RelativeVelocity => {
// Not yet implemented -- planned for M3 when cross-signal reads are
@ -113,6 +122,8 @@ pub(crate) fn read_agg(
/// has no `view` signal). A missing signal contributes 0.0 to the sort score; every
/// other error still propagates.
///
/// `now_ns` is the query clock threaded by the executor (see [`read_agg`]).
///
/// # Errors
///
/// Propagates any non-`UnknownSignalType` [`read_agg`] error (notably the
@ -124,8 +135,9 @@ pub(super) fn read_agg_for_sort(
window: Window,
ledger: &SignalLedger,
degradation: DegradationLevel,
now_ns: u64,
) -> crate::Result<f64> {
match read_agg(entity_id, signal, agg, window, ledger, degradation) {
match read_agg(entity_id, signal, agg, window, ledger, degradation, now_ns) {
Err(crate::TidalError::Schema(crate::schema::error::SchemaError::UnknownSignalType(_))) => {
Ok(0.0)
}
@ -138,7 +150,9 @@ pub(super) fn read_agg_for_sort(
/// Check whether a candidate passes all gate thresholds.
///
/// Gates are correctness filters -- always use `Full` degradation so that
/// window precision is never sacrificed for gate evaluation.
/// window precision is never sacrificed for gate evaluation. `now_ns` is the
/// query clock threaded by the executor (see [`read_agg`]) so the gate decision
/// ages every candidate to the same "now" as the score.
///
/// # Errors
///
@ -149,6 +163,7 @@ pub(super) fn passes_gates(
entity_id: EntityId,
gates: &[Gate],
ledger: &SignalLedger,
now_ns: u64,
) -> crate::Result<bool> {
for gate in gates {
let value = read_agg(
@ -158,6 +173,7 @@ pub(super) fn passes_gates(
gate.window,
ledger,
DegradationLevel::Full,
now_ns,
)?;
if value < gate.min_threshold {
return Ok(false);
@ -177,7 +193,9 @@ pub(super) fn passes_gates(
///
/// Like gates, excludes are correctness filters: they always read at
/// [`DegradationLevel::Full`] so window precision is never traded away while
/// deciding whether content the user asked to never see can appear.
/// deciding whether content the user asked to never see can appear. `now_ns` is
/// the query clock threaded by the executor (see [`read_agg`]) so the exclusion
/// decision ages every candidate to the same "now" as the score.
///
/// # Errors
///
@ -188,6 +206,7 @@ pub(super) fn passes_excludes(
entity_id: EntityId,
excludes: &[Exclude],
ledger: &SignalLedger,
now_ns: u64,
) -> crate::Result<bool> {
for exclude in excludes {
let value = read_agg(
@ -197,6 +216,7 @@ pub(super) fn passes_excludes(
exclude.window,
ledger,
DegradationLevel::Full,
now_ns,
)?;
if value > exclude.above {
return Ok(false);
@ -209,7 +229,12 @@ pub(super) fn passes_excludes(
/// Min-max normalize candidate scores to `[0.0, 1.0]`.
///
/// If all (finite) candidates have the same score, they are all set to 1.0.
/// If all (finite) candidates have the same score (`max_score == min_score`),
/// they are all set to **0.5** per spec 09-ranking-scoring.md §8.2 -- the
/// neutral "no signal differentiates these" midpoint, not the maximally-
/// confident `1.0`. Ordering is unaffected (every score is equal); the reported
/// `score` field is what callers surface for explainability/thresholding, so a
/// degenerate all-equal set must read as neutral rather than top-confidence.
///
/// # Infinite "sort last / first" sentinels
///
@ -271,7 +296,8 @@ pub(super) fn normalize(candidates: &mut [ScoredCandidate]) {
// +Inf "sort first" -> top (1.0); -Inf "sort last" -> bottom (0.0).
if c.score.is_sign_positive() { 1.0 } else { 0.0 }
} else if range < f64::EPSILON {
1.0
// All candidates scored equally: neutral midpoint per spec §8.2.
0.5
} else {
(c.score - min) / range
};
@ -305,6 +331,7 @@ mod tests {
Window::AllTime,
&ledger,
DegradationLevel::Full,
Timestamp::now().as_nanos(),
);
assert!(matches!(result, Err(crate::TidalError::Internal(_))));
}
@ -320,6 +347,7 @@ mod tests {
Window::OneHour,
&ledger,
DegradationLevel::Full,
Timestamp::now().as_nanos(),
);
assert!(matches!(result, Err(crate::TidalError::Schema(_))));
}
@ -341,7 +369,7 @@ mod tests {
min_threshold: 5.0,
}];
// count=3 < threshold=5 -> candidate excluded.
assert!(!passes_gates(entity_id, &gates, &ledger).unwrap());
assert!(!passes_gates(entity_id, &gates, &ledger, Timestamp::now().as_nanos()).unwrap());
}
#[test]
@ -361,7 +389,7 @@ mod tests {
min_threshold: 5.0,
}];
// count=5 >= threshold=5 -> candidate included.
assert!(passes_gates(entity_id, &gates, &ledger).unwrap());
assert!(passes_gates(entity_id, &gates, &ledger, Timestamp::now().as_nanos()).unwrap());
}
#[test]
@ -374,7 +402,9 @@ mod tests {
format: None,
}];
normalize(&mut candidates);
assert_eq!(candidates[0].score, 1.0);
// A single candidate is a degenerate all-equal set (range == 0), so it
// normalizes to the neutral midpoint 0.5 per spec §8.2, not 1.0.
assert_eq!(candidates[0].score, 0.5);
}
fn candidate(id: u64, score: f64) -> ScoredCandidate {
@ -439,7 +469,10 @@ mod tests {
}
#[test]
fn normalize_all_nan_clamps_to_one() {
fn normalize_all_nan_folds_to_neutral_midpoint() {
// Every NaN is neutralized to 0.0 first, making this a degenerate
// all-equal set (range == 0), which normalizes to the neutral 0.5 per
// spec §8.2 -- not the maximally-confident 1.0.
let mut candidates: Vec<ScoredCandidate> = (1u64..=3)
.map(|i| ScoredCandidate {
entity_id: EntityId::new(i),
@ -452,8 +485,8 @@ mod tests {
normalize(&mut candidates);
for c in &candidates {
assert_eq!(
c.score, 1.0,
"NaN score should be clamped to 1.0 after normalize"
c.score, 0.5,
"an all-NaN (all-equal) set should fold to the neutral 0.5 midpoint"
);
}
}

View File

@ -81,6 +81,11 @@ pub struct ProfileExecutor<'a> {
user_state_for_date_saved: Option<(&'a UserStateIndex, u64)>,
/// M7p2: degradation level for coarse-aggregate window substitution.
degradation_level: DegradationLevel,
/// User identity for the `Shuffle` sort's per-user, per-minute seed (spec
/// §11.6: `seed = hash(user_id, timestamp_minute)`). `0` is the anonymous /
/// no-`FOR USER` case, which yields a stable per-minute *global* permutation
/// rather than per-user variety. Set via [`Self::with_shuffle_user`].
shuffle_user_id: u64,
}
impl<'a> ProfileExecutor<'a> {
@ -95,9 +100,23 @@ impl<'a> ProfileExecutor<'a> {
item_metadata: None,
user_state_for_date_saved: None,
degradation_level: DegradationLevel::Full,
shuffle_user_id: 0,
}
}
/// Set the user identity for the `Shuffle` sort's per-user, per-minute seed.
///
/// Spec §11.6 seeds shuffle with `hash(user_id, timestamp_minute)` so a
/// "surprise me" surface refreshes per minute and varies per user. The query
/// pipeline calls this with the resolved `FOR USER` id when present; without a
/// user it stays `0` (a stable per-minute global permutation). Only `Shuffle`
/// reads this field; every other sort ignores it.
#[must_use]
pub(crate) const fn with_shuffle_user(mut self, user_id: u64) -> Self {
self.shuffle_user_id = user_id;
self
}
/// Set the degradation level for coarse-aggregate window substitution.
///
/// Under `CoarseAggregates`, `SignalAgg::Value` reads use `Window::AllTime`
@ -172,16 +191,17 @@ impl<'a> ProfileExecutor<'a> {
///
/// - `candidates`: entity IDs to consider; excluded/gate-failing candidates are removed.
/// - `profile`: the ranking profile controlling sort mode, boosts, penalties, gates, excludes, and decay.
/// - `now`: the query's logical timestamp. **Clock contract:** decay is
/// currently evaluated at *call time*, not at `now`. Every time-dependent
/// read -- `SignalAgg::DecayScore` and the `ProfileDecay` recency factor --
/// decays forward to wall-clock `Timestamp::now()` *inside* the
/// [`SignalLedger`], so `now` does not yet influence those reads. The
/// parameter is retained because it is part of the public scoring API and is
/// threaded by the RETRIEVE/SEARCH callers and benchmarks; making scoring a
/// pure function of `(ledger snapshot, now)` requires a `read_decay_score_at`
/// on the ledger (out of scope for the ranking layer). See the M0-M10 review
/// (scoring.rs Maintainability-W).
/// - `now`: the query's logical timestamp. **Clock contract:** `now` is the
/// single clock for the whole query. It is converted to `now_ns` once and
/// threaded into every time-dependent ledger read -- windowed counts,
/// velocities, `SignalAgg::DecayScore`, the `ProfileDecay` recency factor,
/// and the gate/exclude evaluations -- via the ledger's explicit-clock `_at`
/// variants. Scoring is therefore a pure function of `(ledger snapshot,
/// now)`: re-running the identical query against an unchanged ledger yields
/// byte-identical scores (Accuracy-W, M0-M10 review). The one remaining
/// `now`-independent piece is `Hot`'s per-candidate *age*, which uses a
/// uniform 24h term until a per-entity `created_at` reverse map is plumbed
/// into the executor.
///
/// Returns candidates sorted by descending normalized score in `[0.0, 1.0]`.
///
@ -279,8 +299,11 @@ impl<'a> ProfileExecutor<'a> {
|entity_id, snapshot| {
// Creator-interaction boost: look up the per-item boost from
// the pre-computed map. Items from highly-interacted creators
// get a positive additive boost before normalization.
let item_id = entity_id.as_u64() as u32;
// get a positive additive boost before normalization. This map is
// sourced from a `RoaringBitmap` (inherently u32-keyed), so it is
// looked up by the narrowed `u32` slot, matching how the producer
// populated it from the bitmap's u32 entries.
let item_id = crate::entities::user_state::narrow_item_slot(entity_id.as_u64());
let interaction_boost = user_ctx
.creator_interaction_boosts
.get(&item_id)
@ -291,9 +314,12 @@ impl<'a> ProfileExecutor<'a> {
// pre-computed by the retrieve executor. Items closer to the user's
// learned taste get a positive additive boost; dissimilar items
// (negative cosine) are mildly penalized. Folds into the same
// pre-normalization additive range as the other boosts.
// pre-normalization additive range as the other boosts. Keyed by
// the FULL u64 id so two candidates differing only above bit 32
// never alias onto one slot and cross-contaminate their boosts
// (query-executor SUGGESTION, M0-M10 review pass 2).
let mut preference_boost = 0.0;
if let Some(&cosine) = user_ctx.preference_boosts.get(&item_id) {
if let Some(&cosine) = user_ctx.preference_boosts.get(&entity_id.as_u64()) {
preference_boost = cosine * PREFERENCE_BOOST_WEIGHT;
if preference_boost != 0.0 {
snapshot.push(("preference_affinity".to_string(), preference_boost));
@ -384,14 +410,19 @@ impl<'a> ProfileExecutor<'a> {
// than re-lowercasing every keyword for every candidate inside
// `session_boost` (CLEAN-W, M0-M10 review). Empty when there is no session.
let session_keywords = session_ctx.map(Self::lowered_session_keywords);
// Query clock, captured ONCE: every per-candidate time-dependent read
// (excludes, gates, and the scoring reads below) ages to this single
// "now" so the whole result set is consistent and re-running the same
// query against an unchanged ledger is byte-identical (Accuracy-W).
let now_ns = now.as_nanos();
let mut scored: Vec<ScoredCandidate> = Vec::new();
for &entity_id in candidates {
// Stage 2: hard exclusion -- remove content the user must never see.
if !passes_excludes(entity_id, &profile.excludes, self.ledger)? {
if !passes_excludes(entity_id, &profile.excludes, self.ledger, now_ns)? {
continue;
}
// Stage 6: gates -- remove candidates below quality thresholds.
if !passes_gates(entity_id, &profile.gates, self.ledger)? {
if !passes_gates(entity_id, &profile.gates, self.ledger, now_ns)? {
continue;
}
let (raw, mut snapshot) =
@ -483,6 +514,23 @@ impl<'a> ProfileExecutor<'a> {
/// [`passes_gates`] because they *remove* candidates rather than adjust a
/// score.
///
/// **Sort overrides boosts/penalties (spec §11 / §11.9).** When a `sort` mode
/// is active its formula "replaces stages 4-5 (boost and penalty application)"
/// — so the Stage-4 boost loop and Stage-5 penalty loop are *skipped* whenever
/// `profile.sort.is_some()`. This removes the `trending`/`cohort_trending`
/// double-count (the `Sort::Trending` base and the profile boosts both read
/// `view`/`share` velocity over the same window; adding them produced
/// `2*view_vel + 4*share_vel` — order-identical to the spec formula but a
/// latent hybrid the spec forbids). The co-engagement blend and `ProfileDecay`
/// recency factor are *not* boosts/penalties and still apply. The separate
/// cohort-scoped rescore (query Stage 3b) reads `profile.boosts` directly and
/// is unaffected — it overwrites this score entirely for cohort queries.
///
/// **Clock contract.** `now` is converted to `now_ns` once and threaded into
/// every time-dependent ledger read (sort base, boosts, penalties, decay) via
/// the explicit-clock `_at` variants, so scores are a pure function of
/// `(ledger snapshot, now)` (Accuracy-W, M0-M10 review).
///
/// `retrieval_scores`, when present, maps `entity_id -> normalized fused/RRF
/// relevance score in [0, 1]` (produced by SEARCH Stage 1c). The seed makes
/// the fused order the primary ordering signal when profile boosts are light;
@ -505,6 +553,7 @@ impl<'a> ProfileExecutor<'a> {
now: Timestamp,
retrieval_scores: Option<&HashMap<u64, f64>>,
) -> crate::Result<(f64, Vec<(String, f64)>)> {
let now_ns = now.as_nanos();
let (sort_base, mut snapshot) =
self.score_by_sort(entity_id, profile.sort.as_ref(), now)?;
@ -523,8 +572,22 @@ impl<'a> ProfileExecutor<'a> {
}
// Stage 4: boosts. Capture each contribution in the snapshot.
//
// Spec §11.9: a formula sort "replaces stages 4-5" only for the boosts it
// already SUBSUMES — a boost whose (signal, agg, window) the sort formula
// itself reads. The `trending`/`cohort_trending` builtins keep their
// view/share velocity boosts on the profile purely as the cohort-rescore
// signal definition (Stage 3b); on the global path those exact boosts ARE
// the `Sort::Trending` formula, so re-applying them would double-count.
// Every other profile's boosts (the `for_you`/`following`/`related`/
// `notification` engagement overlays) read different signals/aggs/windows
// than their sort formula, so they are NOT subsumed and DO apply on top of
// the sort base — preserving each profile's intended engagement weighting.
let mut boost_sum = 0.0;
for b in &profile.boosts {
if sort_subsumes_boost(profile.sort.as_ref(), &b.signal, &b.agg, b.window) {
continue;
}
let val = read_agg_for_sort(
entity_id,
&b.signal,
@ -532,6 +595,7 @@ impl<'a> ProfileExecutor<'a> {
b.window,
self.ledger,
self.degradation_level,
now_ns,
)?;
let weighted = b.weight * val;
if weighted != 0.0 {
@ -567,6 +631,9 @@ impl<'a> ProfileExecutor<'a> {
// first-class (Design Principle 3): each penalty subtracts
// `weight * agg(signal, window)`, mirroring the boost loop with the
// opposite sign. `weight` is stored positive and applied as `-weight`.
// No builtin formula sort reads a penalty signal, so penalties are never
// subsumed by a sort and always apply (a penalty is a complementary
// demotion overlay, not a re-statement of the sort formula).
let mut penalty_sum = 0.0;
for p in &profile.penalties {
let val = read_agg_for_sort(
@ -576,6 +643,7 @@ impl<'a> ProfileExecutor<'a> {
p.window,
self.ledger,
self.degradation_level,
now_ns,
)?;
let weighted = p.weight * val;
if weighted != 0.0 {
@ -605,6 +673,7 @@ impl<'a> ProfileExecutor<'a> {
Window::AllTime,
self.ledger,
self.degradation_level,
now_ns,
)?
.clamp(0.0, 1.0);
let factor = decay.weight.mul_add(recency, 1.0 - decay.weight);
@ -706,6 +775,29 @@ impl<'a> ProfileExecutor<'a> {
}
}
/// Whether the active formula `sort` already incorporates this boost's signal, so
/// re-applying it on the global scoring path would double-count (spec §11.9).
///
/// Today only [`Sort::Trending`] subsumes its boosts: its formula reads
/// `view`/`share` velocity over 24h, exactly the velocity boosts the
/// `trending`/`cohort_trending` builtins carry — kept on the profile only as the
/// cohort-rescore signal definition (Stage 3b). Every other sort's boosts read a
/// different (signal, agg, window) than the sort formula (e.g. `for_you`'s
/// `Sort::Hot` ranks by view *count* while its boosts read view/like *decay
/// scores*), so they are complementary overlays and are never subsumed.
fn sort_subsumes_boost(
sort: Option<&super::profile::Sort>,
signal: &str,
agg: &SignalAgg,
window: Window,
) -> bool {
use super::profile::Sort;
matches!(sort, Some(Sort::Trending))
&& matches!(agg, SignalAgg::Velocity)
&& window == Window::TwentyFourHours
&& (signal == "view" || signal == "share")
}
mod scoring;
#[cfg(test)]

View File

@ -5,7 +5,10 @@
use super::{
ProfileExecutor,
formulas::{controversial_score, hidden_gems_score, hot_score, shuffle_score, trending_score},
formulas::{
controversial_score, hidden_gems_score, hot_score, shuffle_quality_score,
shuffle_quality_weight, shuffle_random, trending_score,
},
helpers::read_agg_for_sort,
};
use crate::{
@ -55,10 +58,14 @@ impl ProfileExecutor<'_> {
/// Returns `(score, snapshot)` where `snapshot` lists the raw signal values
/// that contributed to the score, for explain-ability in API responses.
///
/// **Clock contract:** `now` is threaded for API symmetry but does not
/// currently steer any read -- every time-dependent sort (`DecayScore`-backed
/// modes, velocity modes) decays/ages forward to wall-clock time *inside* the
/// [`SignalLedger`] at call time. See [`ProfileExecutor::score`]'s `now` doc.
/// **Clock contract:** `now` is the query's logical clock. It is converted to
/// `now_ns` **once** here and threaded into every time-dependent ledger read
/// (velocity and `DecayScore`-backed sorts) via the ledger's explicit-clock
/// `_at` variants, so the whole candidate set is aged to one consistent "now"
/// and re-running the identical query against an unchanged ledger yields
/// byte-identical scores (Accuracy-W, M0-M10 review). Quality-weighted
/// `Shuffle` also consumes the per-minute `timestamp_minute` derived from
/// `now` (spec §11.6).
///
/// # Errors
///
@ -72,12 +79,15 @@ impl ProfileExecutor<'_> {
sort: Option<&Sort>,
now: Timestamp,
) -> crate::Result<(f64, Vec<(String, f64)>)> {
// Capture the query clock ONCE so every per-candidate ledger read below
// ages to the same "now" (reproducible scores; one fewer syscall per read).
let now_ns = now.as_nanos();
match sort {
Some(Sort::Hot { gravity }) => self.score_hot(entity_id, *gravity, now),
Some(Sort::Trending) => self.score_trending(entity_id),
Some(Sort::Controversial) => self.score_controversial(entity_id),
Some(Sort::HiddenGems) => self.score_hidden_gems(entity_id),
Some(Sort::Shuffle) => Ok((shuffle_score(entity_id.as_u64()), vec![])),
Some(Sort::Trending) => self.score_trending(entity_id, now_ns),
Some(Sort::Controversial) => self.score_controversial(entity_id, now_ns),
Some(Sort::HiddenGems) => self.score_hidden_gems(entity_id, now_ns),
Some(Sort::Shuffle) => Ok((self.score_shuffle(entity_id, now_ns)?, vec![])),
Some(Sort::New) => {
// M2 limitation: entity metadata (`created_at`) is not accessible from the
// executor. Entity ID is used as a proxy for recency -- ranks higher IDs
@ -91,16 +101,20 @@ impl ProfileExecutor<'_> {
let score = entity_id.as_u64() as f64;
Ok((score, vec![]))
}
Some(Sort::TopWindow { window }) => self.score_top_window(entity_id, *window),
Some(Sort::TopWindow { window }) => self.score_top_window(entity_id, *window, now_ns),
Some(Sort::MostViewed { window }) => {
self.single_signal_score(entity_id, "view", &SignalAgg::Value, *window)
self.single_signal_score(entity_id, "view", &SignalAgg::Value, *window, now_ns)
}
Some(Sort::MostLiked { window }) => {
self.single_signal_score(entity_id, "like", &SignalAgg::Value, *window)
}
Some(Sort::MostFollowed) => {
self.single_signal_score(entity_id, "follow", &SignalAgg::Value, Window::AllTime)
self.single_signal_score(entity_id, "like", &SignalAgg::Value, *window, now_ns)
}
Some(Sort::MostFollowed) => self.single_signal_score(
entity_id,
"follow",
&SignalAgg::Value,
Window::AllTime,
now_ns,
),
Some(Sort::CreatorEngagementRate) => {
let view_vel = read_agg_for_sort(
entity_id,
@ -109,6 +123,7 @@ impl ProfileExecutor<'_> {
Window::TwentyFourHours,
self.ledger,
self.degradation_level,
now_ns,
)?;
let like_vel = read_agg_for_sort(
entity_id,
@ -117,6 +132,7 @@ impl ProfileExecutor<'_> {
Window::TwentyFourHours,
self.ledger,
self.degradation_level,
now_ns,
)?;
Ok((
view_vel + like_vel,
@ -126,40 +142,40 @@ impl ProfileExecutor<'_> {
],
))
}
Some(Sort::Rising) => self.score_rising(entity_id),
Some(Sort::Rising) => self.score_rising(entity_id, now_ns),
Some(Sort::AlphabeticalAsc) => Ok((self.score_alphabetical_asc(entity_id), vec![])),
Some(Sort::AlphabeticalDesc) => Ok((self.score_alphabetical_desc(entity_id), vec![])),
Some(Sort::Shortest) => Ok((self.score_shortest(entity_id), vec![])),
Some(Sort::Longest) => Ok((self.score_longest(entity_id), vec![])),
Some(Sort::MostCommented { window }) => {
self.single_signal_score(entity_id, "comment", &SignalAgg::Value, *window)
self.single_signal_score(entity_id, "comment", &SignalAgg::Value, *window, now_ns)
}
Some(Sort::MostShared { window }) => {
self.single_signal_score(entity_id, "share", &SignalAgg::Value, *window)
self.single_signal_score(entity_id, "share", &SignalAgg::Value, *window, now_ns)
}
Some(Sort::LiveViewerCount) => self.single_signal_score(
entity_id,
"viewer_count",
&SignalAgg::DecayScore,
Window::AllTime,
now_ns,
),
Some(Sort::DateSaved) => Ok((self.score_date_saved(entity_id), vec![])),
None => Ok((0.0, vec![])),
}
}
/// `_now` is accepted for signature symmetry with [`Self::score_by_sort`] but
/// is intentionally unused: Hot scoring reads only the all-time view count
/// (decayed forward to wall-clock time inside the ledger) and applies a
/// *uniform* 24h age term, because no per-entity `created_at` is plumbed into
/// the executor (see the `age_hours` comment below). When age-aware Hot
/// scoring lands, this `now` becomes the age reference. See the M0-M10 review
/// clock-contract note (scoring.rs Maintainability-W).
/// `now` is the query clock: it ages the all-time view count read consistently
/// across the candidate set (via the ledger's explicit-clock read). It is not
/// yet a per-candidate *age* reference: Hot scoring applies a *uniform* 24h age
/// term because no per-entity `created_at` is plumbed into the executor (see
/// the `age_hours` comment below). When age-aware Hot scoring lands, this `now`
/// becomes the age reference too.
fn score_hot(
&self,
entity_id: EntityId,
gravity: f64,
_now: Timestamp,
now: Timestamp,
) -> crate::Result<(f64, Vec<(String, f64)>)> {
let views = read_agg_for_sort(
entity_id,
@ -168,6 +184,7 @@ impl ProfileExecutor<'_> {
Window::AllTime,
self.ledger,
self.degradation_level,
now.as_nanos(),
)?;
// The scoring loop receives no per-entity `created_at`: the executor reads
// only the signal ledger, and the `created_at` range index is keyed
@ -184,12 +201,84 @@ impl ProfileExecutor<'_> {
))
}
/// Shuffle (spec §11.6): `random(seed) * quality_weight(item)` where
/// `seed = hash(user_id, timestamp_minute)` and
/// `quality_weight = sqrt(quality_score)`.
///
/// The random draw is reproducible within a single minute for a single user
/// (the seed is constant over that window), so a page refresh does not
/// reshuffle — but it varies per user and refreshes each minute, delivering
/// the per-user, time-windowed variety the mode advertises. Quality biases the
/// draw multiplicatively, so high-quality items are more likely to surface
/// without being guaranteed. The `quality_score` is built from real ledger
/// reads (completion decay score, like/view counts) aged to the query clock,
/// not a fixed constant, so low-quality items are genuinely demoted.
///
/// Missing generic signals degrade to 0.0 via [`read_agg_for_sort`]. With no
/// quality signals at all, every item's `quality_score` is 0, so the random
/// draw is scaled to 0 and the items tie (normalizing to the neutral midpoint)
/// — the mode is only meaningfully a *quality-weighted* shuffle once the app
/// records `completion`/`like`/`view`. The seed inputs still vary per user and
/// per minute, so the permutation refreshes correctly once any quality signal
/// exists.
///
/// # Errors
///
/// Propagates any non-degradable ledger read error (an unimplemented
/// aggregation), matching the other formula sorts.
fn score_shuffle(&self, entity_id: EntityId, now_ns: u64) -> crate::Result<f64> {
// Quality inputs, all aged to the single query clock for reproducibility.
let completion_rate = read_agg_for_sort(
entity_id,
"completion",
&SignalAgg::DecayScore,
Window::AllTime,
self.ledger,
self.degradation_level,
now_ns,
)?
.clamp(0.0, 1.0);
let likes = read_agg_for_sort(
entity_id,
"like",
&SignalAgg::Value,
Window::AllTime,
self.ledger,
self.degradation_level,
now_ns,
)?;
let views = read_agg_for_sort(
entity_id,
"view",
&SignalAgg::Value,
Window::AllTime,
self.ledger,
self.degradation_level,
now_ns,
)?;
// like_ratio = likes / (views + 1), clamped to [0, 1]: a like is only
// emitted by a viewer, so the ratio is a bounded engagement-quality proxy.
let like_ratio = (likes / (views + 1.0)).clamp(0.0, 1.0);
let quality_score = shuffle_quality_score(completion_rate, like_ratio, views);
let quality_weight = shuffle_quality_weight(quality_score);
// timestamp_minute: the query clock truncated to whole minutes, so the
// seed (and therefore the whole permutation) is stable within a minute and
// refreshes at the boundary. 60e9 ns per minute.
let timestamp_minute = now_ns / 60_000_000_000;
let draw = shuffle_random(self.shuffle_user_id, timestamp_minute, entity_id.as_u64());
Ok(draw * quality_weight)
}
fn single_signal_score(
&self,
entity_id: EntityId,
signal: &str,
agg: &SignalAgg,
window: Window,
now_ns: u64,
) -> crate::Result<(f64, Vec<(String, f64)>)> {
let val = read_agg_for_sort(
entity_id,
@ -198,11 +287,16 @@ impl ProfileExecutor<'_> {
window,
self.ledger,
self.degradation_level,
now_ns,
)?;
Ok((val, vec![(signal.to_string(), val)]))
}
fn score_trending(&self, entity_id: EntityId) -> crate::Result<(f64, Vec<(String, f64)>)> {
fn score_trending(
&self,
entity_id: EntityId,
now_ns: u64,
) -> crate::Result<(f64, Vec<(String, f64)>)> {
// M6: social-graph-scoped trending. When a social subgraph and
// per-user signal index are available, compute aggregate velocity
// across the subgraph users instead of using the global ledger.
@ -255,6 +349,7 @@ impl ProfileExecutor<'_> {
Window::TwentyFourHours,
self.ledger,
self.degradation_level,
now_ns,
)?;
let share_vel = read_agg_for_sort(
entity_id,
@ -263,6 +358,7 @@ impl ProfileExecutor<'_> {
Window::TwentyFourHours,
self.ledger,
self.degradation_level,
now_ns,
)?;
Ok((
trending_score(view_vel, share_vel),
@ -273,7 +369,11 @@ impl ProfileExecutor<'_> {
))
}
fn score_controversial(&self, entity_id: EntityId) -> crate::Result<(f64, Vec<(String, f64)>)> {
fn score_controversial(
&self,
entity_id: EntityId,
now_ns: u64,
) -> crate::Result<(f64, Vec<(String, f64)>)> {
let pos = read_agg_for_sort(
entity_id,
"like",
@ -281,6 +381,7 @@ impl ProfileExecutor<'_> {
Window::AllTime,
self.ledger,
self.degradation_level,
now_ns,
)?;
let neg = read_agg_for_sort(
entity_id,
@ -289,6 +390,7 @@ impl ProfileExecutor<'_> {
Window::AllTime,
self.ledger,
self.degradation_level,
now_ns,
)?;
Ok((
controversial_score(pos, neg),
@ -296,7 +398,11 @@ impl ProfileExecutor<'_> {
))
}
fn score_hidden_gems(&self, entity_id: EntityId) -> crate::Result<(f64, Vec<(String, f64)>)> {
fn score_hidden_gems(
&self,
entity_id: EntityId,
now_ns: u64,
) -> crate::Result<(f64, Vec<(String, f64)>)> {
let quality = read_agg_for_sort(
entity_id,
"completion",
@ -304,6 +410,7 @@ impl ProfileExecutor<'_> {
Window::AllTime,
self.ledger,
self.degradation_level,
now_ns,
)?;
let view_count = read_agg_for_sort(
entity_id,
@ -312,6 +419,7 @@ impl ProfileExecutor<'_> {
Window::AllTime,
self.ledger,
self.degradation_level,
now_ns,
)?;
Ok((
hidden_gems_score(quality, view_count),
@ -326,6 +434,7 @@ impl ProfileExecutor<'_> {
&self,
entity_id: EntityId,
window: Window,
now_ns: u64,
) -> crate::Result<(f64, Vec<(String, f64)>)> {
let views = read_agg_for_sort(
entity_id,
@ -334,6 +443,7 @@ impl ProfileExecutor<'_> {
window,
self.ledger,
self.degradation_level,
now_ns,
)?;
let likes = read_agg_for_sort(
entity_id,
@ -342,6 +452,7 @@ impl ProfileExecutor<'_> {
window,
self.ledger,
self.degradation_level,
now_ns,
)?;
let shares = read_agg_for_sort(
entity_id,
@ -350,6 +461,7 @@ impl ProfileExecutor<'_> {
window,
self.ledger,
self.degradation_level,
now_ns,
)?;
let completion = read_agg_for_sort(
entity_id,
@ -358,6 +470,7 @@ impl ProfileExecutor<'_> {
window,
self.ledger,
self.degradation_level,
now_ns,
)?;
Ok((
views.mul_add(
@ -373,7 +486,11 @@ impl ProfileExecutor<'_> {
))
}
fn score_rising(&self, entity_id: EntityId) -> crate::Result<(f64, Vec<(String, f64)>)> {
fn score_rising(
&self,
entity_id: EntityId,
now_ns: u64,
) -> crate::Result<(f64, Vec<(String, f64)>)> {
let short = read_agg_for_sort(
entity_id,
"view",
@ -381,6 +498,7 @@ impl ProfileExecutor<'_> {
Window::OneHour,
self.ledger,
self.degradation_level,
now_ns,
)?;
let long = read_agg_for_sort(
entity_id,
@ -389,6 +507,7 @@ impl ProfileExecutor<'_> {
Window::TwentyFourHours,
self.ledger,
self.degradation_level,
now_ns,
)?;
let score = if long < f64::EPSILON {
short

View File

@ -74,7 +74,9 @@ fn score_normalization_all_equal() {
let ledger = test_ledger();
let executor = ProfileExecutor::new(&ledger);
// Use a profile with no sort and no boosts -- all candidates get 0.0
// raw score, which normalizes to 1.0.
// raw score, a degenerate all-equal set. Per spec §8.2 these normalize to
// the neutral midpoint 0.5 ("no signal differentiates these"), NOT the
// maximally-confident 1.0 (M0-M10 review pass 2, ranking SUGGESTION).
let profile = profile_with("test_equal", |_| {});
let candidates: Vec<EntityId> = (1..=3).map(EntityId::new).collect();
let now = Timestamp::from_nanos(1_708_000_000_000_000_000);
@ -82,7 +84,7 @@ fn score_normalization_all_equal() {
let result = executor.score(&candidates, &profile, now).unwrap();
assert_eq!(result.len(), 3);
for c in &result {
assert_eq!(c.score, 1.0);
assert_eq!(c.score, 0.5);
}
}
@ -239,8 +241,10 @@ fn score_personalized_applies_preference_boost() {
// Entity 2 strongly aligned with the preference vector; entity 3 opposed.
let mut preference_boosts = HashMap::new();
preference_boosts.insert(2u32, 0.9);
preference_boosts.insert(3u32, -0.9);
// Keyed by the full u64 entity id (no truncation): regression for the
// preference-boost aliasing finding (M0-M10 review pass 2).
preference_boosts.insert(2u64, 0.9);
preference_boosts.insert(3u64, -0.9);
let user_ctx = UserContext {
user_id: 7,
@ -787,4 +791,201 @@ fn retrieval_scores_seed_dominates_light_boosts() {
);
}
// -- Query-clock determinism + sort/boost interaction regressions --
// (M0-M10 review pass 2, ranking zone C2.)
/// Accuracy-W regression: scoring must be a pure function of `(ledger snapshot,
/// now)`. Re-running the identical query against an unchanged ledger with the
/// same `now` must produce byte-identical scores. Before the fix, every
/// time-dependent read called `Timestamp::now()` internally, so two runs of the
/// same query decayed/aged each candidate to a slightly different clock and the
/// reported scores drifted between runs.
#[test]
fn scoring_is_deterministic_for_fixed_now() {
let ledger = ledger_for(&["view", "share"]);
// A fixed-but-recent-relative-to-the-data clock so the 24h velocity window
// actually selects buckets (and the read is genuinely time-dependent).
let now = Timestamp::now();
// Spread signals over the last few hours so the velocity reads are non-zero.
for id in 1..=4u64 {
for i in 0..(id * 3) {
let t = Timestamp::from_nanos(
now.as_nanos().saturating_sub(i * 600_000_000_000), // 10-minute steps back
);
ledger
.record_signal("view", EntityId::new(id), 1.0, t)
.unwrap();
if id % 2 == 0 {
ledger
.record_signal("share", EntityId::new(id), 1.0, t)
.unwrap();
}
}
}
let executor = ProfileExecutor::new(&ledger);
// Trending exercises the velocity reads (`read_velocity_at`), the most
// clock-sensitive path.
let profile = profile_with("determinism", |p| p.sort = Some(Sort::Trending));
let candidates: Vec<EntityId> = (1..=4).map(EntityId::new).collect();
let r1 = executor.score(&candidates, &profile, now).unwrap();
let r2 = executor.score(&candidates, &profile, now).unwrap();
assert_eq!(r1.len(), r2.len());
for (a, b) in r1.iter().zip(r2.iter()) {
assert_eq!(
a.entity_id, b.entity_id,
"the same query against an unchanged ledger must return the same order"
);
assert_eq!(
a.score.to_bits(),
b.score.to_bits(),
"scores must be byte-identical across runs for a fixed `now` (entity {})",
a.entity_id.as_u64()
);
}
}
/// Spec §11.9 regression: a formula sort replaces stages 4-5 ONLY for the boosts
/// it SUBSUMES (a boost whose signal/agg/window the formula itself reads). The
/// `trending` builtin sets `Sort::Trending` AND `view`/`share` velocity-24h
/// boosts on the same signals the formula reads (kept on the profile only as the
/// cohort-rescore definition); re-applying them on the global path would
/// double-count (`2*view_vel + 4*share_vel`), so those are skipped. A
/// COMPLEMENTARY boost/penalty (one the formula does NOT read, e.g. a `like`
/// count boost or a `skip` penalty) is an overlay and MUST still apply — this is
/// what keeps `for_you`/`following`/`related`/`notification` engagement weighting
/// alive under their sort seeds.
#[test]
fn sort_subsumes_duplicating_boost_but_keeps_complementary() {
let ledger = ledger_for(&["view", "share", "like", "skip"]);
let now = Timestamp::now();
for _ in 0..5 {
ledger
.record_signal("view", EntityId::new(1), 1.0, now)
.unwrap();
ledger
.record_signal("share", EntityId::new(1), 1.0, now)
.unwrap();
ledger
.record_signal("like", EntityId::new(1), 1.0, now)
.unwrap();
ledger
.record_signal("skip", EntityId::new(1), 1.0, now)
.unwrap();
}
let executor = ProfileExecutor::new(&ledger);
// Sort::Trending with: a DUPLICATING view-velocity-24h boost (subsumed by the
// formula), a COMPLEMENTARY like-count boost (not read by Trending), and a
// complementary skip penalty.
let profile = profile_with("sort_overrides", |p| {
p.sort = Some(Sort::Trending);
p.boosts = vec![
Boost {
signal: "view".into(),
agg: SignalAgg::Velocity,
window: Window::TwentyFourHours,
weight: 1.0,
},
Boost {
signal: "like".into(),
agg: SignalAgg::Value,
window: Window::AllTime,
weight: 1.0,
},
];
p.penalties = vec![Penalty {
signal: "skip".into(),
agg: SignalAgg::Value,
window: Window::AllTime,
weight: 1.0,
}];
});
let scored = executor.score(&[EntityId::new(1)], &profile, now).unwrap();
assert_eq!(scored.len(), 1);
let snap = &scored[0].signal_snapshot;
// The duplicating view-velocity boost is subsumed by the Trending formula.
assert!(
!snap.iter().any(|(k, _)| k == "view_boost"),
"a boost duplicating the Sort::Trending formula must be subsumed, got {snap:?}"
);
// The complementary like-count boost and skip penalty DO apply.
assert!(
snap.iter().any(|(k, _)| k == "like_boost"),
"a complementary boost the sort formula does not read must still apply, got {snap:?}"
);
assert!(
snap.iter().any(|(k, _)| k == "skip_penalty"),
"a complementary penalty must still apply under a formula sort, got {snap:?}"
);
// The Trending base velocity snapshot entries DO still appear (the sort formula).
assert!(
snap.iter().any(|(k, _)| k == "view_velocity"),
"the Sort::Trending formula's own velocity reads must still appear, got {snap:?}"
);
}
/// Spec §11.6 regression: shuffle is quality-weighted and per-user/per-minute
/// seeded, not a fixed global hash. A high-quality item (high completion + likes)
/// must out-score a zero-quality item often enough that quality biases the order,
/// and the same `(user, minute)` must reproduce the exact same scores.
#[test]
fn shuffle_is_quality_weighted_and_seed_stable() {
let ledger = ledger_for(&["view", "like", "completion"]);
let now = Timestamp::now();
// Entity 1: high quality (many views, many likes, strong completion).
for _ in 0..50 {
ledger
.record_signal("view", EntityId::new(1), 1.0, now)
.unwrap();
ledger
.record_signal("like", EntityId::new(1), 1.0, now)
.unwrap();
ledger
.record_signal("completion", EntityId::new(1), 1.0, now)
.unwrap();
}
// Entity 2: zero quality (no signals at all) -> quality_weight == 0 -> score 0.
let executor = ProfileExecutor::new(&ledger).with_shuffle_user(99);
let profile = profile_with("shuffle_q", |p| p.sort = Some(Sort::Shuffle));
let candidates = vec![EntityId::new(1), EntityId::new(2)];
let r1 = executor.score(&candidates, &profile, now).unwrap();
// The zero-quality item has quality_weight 0, so its raw shuffle score is 0
// (sorted last; normalized to 0.0). The high-quality item gets a positive
// draw*weight and normalizes to 1.0 -- quality genuinely biases the order.
assert_eq!(
r1[0].entity_id,
EntityId::new(1),
"a high-quality item must out-rank a zero-quality one under shuffle"
);
// Seed stability: same user, same minute -> byte-identical scores.
let r2 = executor.score(&candidates, &profile, now).unwrap();
for (a, b) in r1.iter().zip(r2.iter()) {
assert_eq!(a.entity_id, b.entity_id);
assert_eq!(
a.score.to_bits(),
b.score.to_bits(),
"shuffle must be stable for a fixed (user, minute)"
);
}
// Different user -> the seed changes, so the raw draws differ. We assert the
// permutation is genuinely user-dependent by checking the raw (pre-normalized)
// draw differs for the high-quality item across two distinct users on the same
// minute. (Both still rank it first because the other item is zero-quality.)
let exec_other = ProfileExecutor::new(&ledger).with_shuffle_user(12345);
let r_other = exec_other.score(&candidates, &profile, now).unwrap();
// Both runs normalize entity 1 -> 1.0 (only nonzero), so compare is trivially
// equal post-normalize; the per-user variety is exercised by the seed inputs
// in `shuffle_random`. Assert at least that scoring still succeeds and ordering
// holds for the second user.
assert_eq!(r_other[0].entity_id, EntityId::new(1));
}
mod sort_tests;

View File

@ -1,14 +1,31 @@
//! Last-Writer-Wins Register CRDT.
//!
//! Resolves concurrent writes by HLC timestamp ordering. Ties broken by
//! `node_id` (higher wins). Used for hard negatives (hide/mute/block)
//! which require LWW semantics across distributed nodes.
//! Resolves concurrent writes by HLC timestamp ordering. Ties broken first by
//! `node_id` (folded into [`HlcTimestamp`] ordering), then — for the exact-same
//! full timestamp but a *different value* — by the value itself (larger `T`
//! wins). Used for hard negatives (hide/mute/block) which require LWW semantics
//! across distributed nodes.
//!
//! # CRDT Properties
//!
//! `merge` is commutative, associative, and idempotent -- these properties
//! are verified by property tests using `proptest`.
//!
//! # Why the value participates in the tie-break
//!
//! The HLC timestamp alone is *not* a total order over `(timestamp, value)`
//! pairs: two replicas can carry the same `HlcTimestamp` with different values
//! (a forged/corrupt peer register, or a future HLC change that weakens per-node
//! uniqueness). If the tie-break ignored the value, `merge(a, b)` would keep
//! whichever happened to be `self`, so `merge(a, b) != merge(b, a)` and the two
//! replicas would silently diverge — fatal for a state-based CRDT whose entire
//! job is order-independent convergence. Constraining `T: Ord` and breaking the
//! exact-timestamp tie on the larger value makes the decision a *total* order,
//! so merge is commutative for **every** reachable input, not just for honest
//! HLC-stamped writes (W18). This mirrors `CrdtSignalState::merge`, which
//! appends the score to its key tuple `(last_update_ns, score)` for the same
//! reason.
//!
//! # Design Notes
//!
//! The value slot is `Option<T>` where `None` means "not yet written."
@ -28,8 +45,10 @@ use super::hlc::HlcTimestamp;
///
/// This is the single place the "keep the larger key" decision lives so the two
/// LWW call sites cannot drift:
/// - [`LWWRegister::merge`] uses `K = HlcTimestamp` (whose ordering already
/// folds in `node_id` as the final tiebreak).
/// - [`LWWRegister::merge`] uses `K = (HlcTimestamp, &T)`: the `HlcTimestamp`
/// ordering folds in `node_id`, and the value is appended as the final
/// tiebreak so two registers with the same timestamp but different values
/// resolve identically regardless of merge order (W18).
/// - [`super::signal_state::CrdtSignalState::merge`] uses
/// `K = (last_update_ns, score)`, where the score is the tiebreak appended
/// after the timestamp so equal-timestamp registers resolve deterministically.
@ -39,13 +58,18 @@ pub(crate) fn lww_other_wins<K: PartialOrd>(current: Option<K>, incoming: &K) ->
/// Last-Writer-Wins register with HLC timestamp.
///
/// Resolves concurrent writes by [`HlcTimestamp`] ordering:
/// Resolves concurrent writes by `(HlcTimestamp, value)` ordering:
/// - Higher `wall_ns` wins
/// - Same wall, higher `logical` wins
/// - Same wall + logical, higher `node_id` wins (deterministic tie-break)
/// - Same wall + logical, higher `node_id` wins
/// - Same full timestamp, larger value wins (the total tie-break that makes
/// merge commutative for *every* input — see the module docs and W18)
///
/// `None` represents "not yet written."
///
/// `T: Ord` is required so the exact-timestamp tie-break is deterministic; the
/// derived `PartialOrd`/`Ord` on `HlcTimestamp` already folds in `node_id`.
///
/// # Properties
///
/// - **Commutative:** `merge(A, B) == merge(B, A)`
@ -64,12 +88,12 @@ pub(crate) fn lww_other_wins<K: PartialOrd>(current: Option<K>, incoming: &K) ->
/// assert_eq!(reg.get(), Some(&42u8));
/// ```
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct LWWRegister<T: Clone + PartialEq> {
pub struct LWWRegister<T: Clone + Ord> {
value: Option<T>,
timestamp: Option<HlcTimestamp>,
}
impl<T: Clone + PartialEq> LWWRegister<T> {
impl<T: Clone + Ord> LWWRegister<T> {
/// Create an empty register (no value written yet).
#[must_use]
pub const fn empty() -> Self {
@ -79,16 +103,32 @@ impl<T: Clone + PartialEq> LWWRegister<T> {
}
}
/// The current `(timestamp, value)` ordering key, if a value is present.
///
/// Folding the value into the key after the timestamp is what makes the LWW
/// decision a *total* order: two registers with the same `HlcTimestamp` but
/// different values resolve identically regardless of merge order (W18).
const fn order_key(&self) -> Option<(HlcTimestamp, &T)> {
match (self.timestamp, self.value.as_ref()) {
(Some(ts), Some(v)) => Some((ts, v)),
_ => None,
}
}
/// Write a new value with the given HLC timestamp.
///
/// Only advances the register if `ts > self.timestamp`.
/// Writes at or before the current timestamp are silently discarded
/// (they represent causally earlier events).
/// Advances the register only if `(ts, value)` compares strictly greater
/// than the current `(timestamp, value)` key. Writes at or before the
/// current key are silently discarded (causally earlier, or an exact tie the
/// total order already resolves toward the current value).
///
/// Routes through [`lww_other_wins`] so the local-write and merge paths
/// share one definition of "is the incoming key newer" and cannot drift.
pub fn write(&mut self, value: T, ts: HlcTimestamp) {
if lww_other_wins(self.timestamp, &ts) {
// Compute the decision in a scope whose borrow of `value` ends before the
// move below, so the conditional move type-checks.
let wins = lww_other_wins(self.order_key(), &(ts, &value));
if wins {
self.value = Some(value);
self.timestamp = Some(ts);
}
@ -96,12 +136,14 @@ impl<T: Clone + PartialEq> LWWRegister<T> {
/// Merge another register into this one.
///
/// The register with the higher HLC timestamp wins.
/// The register with the higher `(HlcTimestamp, value)` key wins.
/// If both are empty, the result is empty.
/// If only one has a value, that value is taken.
/// If only one has a value, that value is taken. The value tie-break makes
/// this commutative even when two registers share the exact same timestamp
/// (W18).
pub fn merge(&mut self, other: &Self) {
if let Some(other_ts) = other.timestamp
&& lww_other_wins(self.timestamp, &other_ts)
if let Some(other_key) = other.order_key()
&& lww_other_wins(self.order_key(), &other_key)
{
self.value.clone_from(&other.value);
self.timestamp = other.timestamp;
@ -127,7 +169,7 @@ impl<T: Clone + PartialEq> LWWRegister<T> {
}
}
impl<T: Clone + PartialEq> Default for LWWRegister<T> {
impl<T: Clone + Ord> Default for LWWRegister<T> {
fn default() -> Self {
Self::empty()
}
@ -208,12 +250,56 @@ mod tests {
}
#[test]
fn write_with_equal_timestamp_is_discarded() {
fn write_with_equal_timestamp_keeps_larger_value() {
// The exact-timestamp tie now breaks on the value (W18): the larger
// value wins so the decision is a total order and merge is commutative
// even for same-timestamp / different-value inputs.
let mut reg: LWWRegister<u8> = LWWRegister::empty();
reg.write(1, ts(100, 0, 0));
reg.write(99, ts(100, 0, 0));
// Equal is NOT greater, so the write is discarded.
assert_eq!(reg.get(), Some(&1));
reg.write(99, ts(100, 0, 0)); // same ts, larger value -> wins
assert_eq!(reg.get(), Some(&99));
// And a same-timestamp SMALLER value is discarded (current value larger).
let mut reg2: LWWRegister<u8> = LWWRegister::empty();
reg2.write(99, ts(100, 0, 0));
reg2.write(1, ts(100, 0, 0)); // same ts, smaller value -> discarded
assert_eq!(reg2.get(), Some(&99));
// A same-timestamp, same-value write is a true no-op (idempotent).
let mut reg3: LWWRegister<u8> = LWWRegister::empty();
reg3.write(42, ts(100, 0, 0));
reg3.write(42, ts(100, 0, 0));
assert_eq!(reg3.get(), Some(&42));
}
/// W18: merge must be commutative even when two registers carry the SAME
/// full `HlcTimestamp` but DIFFERENT values — the case a node_id-only
/// tie-break left order-dependent. The value tie-break (larger wins) makes
/// `merge(a, b) == merge(b, a)` for this input.
#[test]
fn merge_commutative_on_equal_timestamp_differing_values() {
let same_ts = ts(100, 0, 0);
let mut a: LWWRegister<u8> = LWWRegister::empty();
a.write(1, same_ts);
let mut b: LWWRegister<u8> = LWWRegister::empty();
b.write(2, same_ts);
let mut ab = a.clone();
ab.merge(&b);
let mut ba = b.clone();
ba.merge(&a);
assert_eq!(
ab.get(),
ba.get(),
"merge must be commutative on a ts collision"
);
assert_eq!(
ab.get(),
Some(&2),
"larger value wins the exact-timestamp tie"
);
assert_eq!(ab.timestamp(), ba.timestamp());
}
/// `write` and `merge` must make the identical accept/reject decision for
@ -224,7 +310,9 @@ mod tests {
for (cur, incoming) in [
(ts(100, 0, 0), ts(200, 0, 0)), // incoming newer -> accept
(ts(200, 0, 0), ts(100, 0, 0)), // incoming older -> reject
(ts(100, 0, 0), ts(100, 0, 0)), // equal -> reject
// equal timestamp: resolved by the value tie-break (incoming value 2
// > current value 1 -> accept); write and merge must agree either way.
(ts(100, 0, 0), ts(100, 0, 0)),
(ts(100, 0, 0), ts(100, 0, 1)), // node-id tiebreak -> accept
] {
let mut via_write: LWWRegister<u8> = LWWRegister::empty();
@ -438,8 +526,14 @@ mod property_tests {
use super::*;
/// Deliberately tiny timestamp domain so the generator collides on the exact
/// `(wall, logical, node)` tuple often — that collision, paired with the
/// independent value below, is what exercises the equal-timestamp /
/// different-value tie-break the commutativity law depends on (W18). A wide
/// domain would essentially never hit a collision and the law would pass by
/// sampling luck rather than by the code being commutative.
fn arb_hlc_timestamp() -> impl Strategy<Value = HlcTimestamp> {
(0..=1_000_000u64, 0..=100u32, 0..=10u16).prop_map(|(w, l, n)| HlcTimestamp {
(0..=3u64, 0..=2u32, 0..=2u16).prop_map(|(w, l, n)| HlcTimestamp {
wall_ns: w,
logical: l,
node_id: n,
@ -450,8 +544,11 @@ mod property_tests {
prop_oneof![
// Empty register
Just(LWWRegister::empty()),
// Register with a value
(any::<u8>(), arb_hlc_timestamp()).prop_map(|(v, ts)| {
// Register with a value. The value domain is intentionally tiny (and
// overlapping the timestamp domain) so two registers frequently carry
// the SAME timestamp with a DIFFERENT value, the exact input that
// falsifies a non-total tie-break.
(0..=3u8, arb_hlc_timestamp()).prop_map(|(v, ts)| {
let mut r = LWWRegister::empty();
r.write(v, ts);
r

View File

@ -52,13 +52,38 @@ impl ReplicationLagGauge {
///
/// Returns `leader_seqno - applied_seqno`. If the leader seqno is
/// unknown (still 0), returns 0.
///
/// # Unknown shard
///
/// If this gauge's `shard_id` is not tracked by the shared
/// [`ReplicationState`] (a wiring bug — e.g. a gauge built for one shard
/// against a state that tracks only others), `applied_seqno` is `None`. That
/// is a misconfiguration, not a legitimately-zero reading, so it is surfaced:
/// a `debug_assert` fires in debug/test builds and a `warn!` is logged in
/// release builds (see [`applied_seqno`](Self::applied_seqno)). The lag then
/// falls back to treating applied as 0 (maximally behind) so a real wiring
/// bug reads as "far behind" rather than the silent "caught up" it would
/// otherwise masquerade as.
#[must_use]
pub fn lag_segments(&self) -> u64 {
let leader = self.leader_seqno.load(Ordering::Acquire);
let applied = self.state.applied_seqno(self.shard_id).unwrap_or(0);
let applied = self.applied_seqno();
leader.saturating_sub(applied)
}
/// Whether this gauge's shard is actually tracked by the shared
/// [`ReplicationState`].
///
/// `false` means the gauge was wired against a state that does not track its
/// shard, so every reading from [`lag_segments`](Self::lag_segments) /
/// [`applied_seqno`](Self::applied_seqno) is a misconfiguration fallback, not
/// a real measurement. Health/diagnostics can poll this to distinguish a
/// genuinely-zero lag from an unwired gauge.
#[must_use]
pub fn shard_tracked(&self) -> bool {
self.state.applied_seqno(self.shard_id).is_some()
}
/// The shard this gauge tracks.
#[must_use]
pub const fn shard_id(&self) -> ShardId {
@ -72,9 +97,32 @@ impl ReplicationLagGauge {
}
/// The latest applied seqno on this node.
///
/// Returns 0 if this gauge's shard is not tracked by the shared
/// [`ReplicationState`] — but that case is a wiring bug, not a real reading,
/// so it is surfaced loudly (`debug_assert` in debug/test, `warn!` in
/// release) rather than silently conflated with a true zero. Use
/// [`shard_tracked`](Self::shard_tracked) to test for it explicitly.
#[must_use]
pub fn applied_seqno(&self) -> u64 {
self.state.applied_seqno(self.shard_id).unwrap_or(0)
if let Some(applied) = self.state.applied_seqno(self.shard_id) {
applied
} else {
debug_assert!(
false,
"ReplicationLagGauge for shard {} is not tracked by the shared \
ReplicationState; lag readings are a misconfiguration fallback, \
not a real measurement",
self.shard_id
);
tracing::warn!(
shard = %self.shard_id,
"lag gauge shard is not tracked by ReplicationState; reporting \
applied=0 (maximally behind) this is a wiring bug, not a real \
zero reading"
);
0
}
}
}
@ -134,4 +182,30 @@ mod tests {
assert_eq!(gauge.applied_seqno(), 3);
assert_eq!(gauge.lag_segments(), 4);
}
/// SUGGESTION (repl-shipping): a gauge whose shard is tracked by the shared
/// state reports `shard_tracked() == true`; one built against a state that
/// does NOT track its shard reports `false`, so a misconfigured gauge is
/// distinguishable from a legitimately-zero reading instead of silently
/// masquerading as "caught up".
#[test]
fn shard_tracked_distinguishes_unwired_gauge() {
// Wired correctly: ShardId::SINGLE is tracked.
let state = Arc::new(ReplicationState::single());
let tracked = ReplicationLagGauge::new(ShardId::SINGLE, Arc::clone(&state));
assert!(
tracked.shard_tracked(),
"a gauge whose shard is tracked must report shard_tracked() == true"
);
// Wired against a state that tracks only OTHER shards: the gauge's shard
// (SINGLE) is unknown, so shard_tracked() must report the misconfiguration.
let other_state = Arc::new(ReplicationState::new(&[ShardId(7), ShardId(8)]));
let unwired = ReplicationLagGauge::new(ShardId::SINGLE, Arc::clone(&other_state));
assert!(
!unwired.shard_tracked(),
"a gauge built against a state that does not track its shard must report \
shard_tracked() == false"
);
}
}

View File

@ -69,33 +69,6 @@ impl TenantMigration {
self
}
/// Persist the router's routing state if a durable sink is attached.
///
/// A checkpoint failure is logged (the migration is already applied in
/// memory) but not propagated, so a transient disk error does not strand the
/// in-memory transition — the next transition (or open-time restore) reaches
/// a consistent point. A persistent failure is loud in the logs.
///
/// Used by `finalize`, where the in-memory pin is already the durable target
/// and a missed checkpoint is recoverable on the next transition. Transitions
/// that must be durable-before-observable (e.g. [`Self::enter_dual_write`])
/// use [`Self::persist_routing_strict`] instead.
fn persist_routing(&self) {
if let Some(storage) = &self.persistence
&& let Err(e) = crate::replication::tenant::checkpoint_migration_routing(
storage.as_ref(),
&self.tenant_router,
)
{
tracing::error!(
tenant_id = self.tenant_id.0,
error = %e,
"failed to checkpoint migration routing state; a crash before the next \
checkpoint would revert this tenant's routing"
);
}
}
/// Persist the router's routing state, propagating any checkpoint failure.
///
/// Returns `Ok(())` when there is no durable sink (ephemeral mode) or the
@ -112,6 +85,24 @@ impl TenantMigration {
}
}
/// Snapshot ONLY this migration's tenant routing for a targeted rollback.
///
/// All `TenantMigration`s share one [`TenantRouter`] but each has only its
/// own per-tenant state lock. A rollback that restored a *full-router*
/// snapshot would clobber a concurrent migration's just-applied (and possibly
/// already-persisted) routing for a different tenant. Snapshotting and
/// restoring only `self.tenant_id` keeps every other tenant's routing intact
/// (C10).
fn snapshot_tenant_routing(&self) -> crate::replication::tenant::TenantRoutingSnapshot {
self.tenant_router.snapshot_tenant_routing(self.tenant_id)
}
/// Restore ONLY this migration's tenant routing from a prior snapshot,
/// leaving every other tenant untouched (C10).
fn restore_tenant_routing(&self, snapshot: &crate::replication::tenant::TenantRoutingSnapshot) {
self.tenant_router.restore_tenant_routing(snapshot);
}
/// Transition from `Idle` to `PreparingTarget`.
///
/// # Errors
@ -155,23 +146,22 @@ impl TenantMigration {
));
}
// Durability before observable mutation (W38): make the dual-write routing
// durable BEFORE accepting the state-machine transition. `to_checkpoint_bytes`
// snapshots the live router, so the entry must be inserted first; we then
// checkpoint and — if the durable write fails — restore the pre-mutation
// routing snapshot. The net guarantee is that the in-memory dual-write
// entry exists iff it has been persisted, so a crash can never strand an
// in-flight migration whose dual-write state never reached disk.
let pre_snapshot = self.tenant_router.to_checkpoint_bytes();
// durable BEFORE accepting the state-machine transition. Snapshot ONLY
// this tenant's prior routing (C10) so a failed persist rolls back exactly
// this tenant's entry without clobbering a concurrent migration's routing
// for a different tenant on the shared router. The net guarantee is that
// the in-memory dual-write entry exists iff it has been persisted, so a
// crash can never strand an in-flight migration whose dual-write state
// never reached disk.
let prior = self.snapshot_tenant_routing();
self.tenant_router
.set_dual_write(self.tenant_id, self.source_shard, self.target_shard);
if let Err(e) = self.persist_routing_strict() {
// Roll back to the exact prior routing (restore_from_checkpoint_bytes
// replaces both maps), so the router never advertises a dual-write that
// is not durable. The migration stays in PreparingTarget; the caller
// can retry once storage recovers.
let _ = self
.tenant_router
.restore_from_checkpoint_bytes(&pre_snapshot);
// Roll back ONLY this tenant's routing, so the router never advertises
// a dual-write that is not durable AND a concurrent migration for a
// different tenant keeps its just-applied entry. The migration stays
// in PreparingTarget; the caller can retry once storage recovers.
self.restore_tenant_routing(&prior);
return Err(e);
}
*state = MigrationState::DualWrite { cutover_seqno };
@ -181,11 +171,25 @@ impl TenantMigration {
/// Transition from `DualWrite` to `Finalizing`.
///
/// The finalized routing (the cleared dual-write + the target pin) is made
/// **durable before the transition is observable**, mirroring
/// [`Self::enter_dual_write`]. This is the cutover that re-points every read
/// for the tenant from source to target; a silent checkpoint failure here
/// would let a crash revert the tenant to `DualWrite` (reads to the SOURCE
/// shard) with no error surfaced — and the only later transition,
/// [`Self::gc_source`], does not re-persist, so nothing would re-close the
/// gap. So on a persist failure we restore the pre-finalize routing snapshot
/// and leave the state in `DualWrite` so the caller retries once storage
/// recovers (C9).
///
/// # Errors
///
/// Returns `InvalidState` if the current state is not `DualWrite`.
/// Returns `NotReady` if `target_seqno` is behind the cutover sequence number —
/// the caller should wait and retry once the target has caught up.
/// - `InvalidState` if the current state is not `DualWrite`.
/// - `NotReady` if `target_seqno` is behind the cutover sequence number —
/// the caller should wait and retry once the target has caught up.
/// - `Storage` if the routing checkpoint cannot be persisted; the in-memory
/// routing is rolled back and the migration stays in `DualWrite` so the
/// caller can retry once storage recovers.
pub fn finalize(&self, target_seqno: u64) -> crate::Result<()> {
let mut state = self
.state
@ -201,32 +205,56 @@ impl TenantMigration {
"target seqno {target_seqno} has not reached cutover seqno {cutover_seqno}"
)));
}
// Durability before observable mutation (C9): snapshot only THIS tenant's
// prior routing, apply the finalize in memory, then persist strictly. A
// crash-after-finalize cannot revert the tenant to DualWrite, because the
// pin is durable before `finalize` returns Ok. On a persist failure, roll
// back ONLY this tenant's routing (never the whole router — a concurrent
// migration for another tenant must not be clobbered, C10) and stay in
// `DualWrite` so the caller retries.
let prior = self.snapshot_tenant_routing();
self.tenant_router
.finalize_migration(self.tenant_id, self.target_shard);
if let Err(e) = self.persist_routing_strict() {
self.restore_tenant_routing(&prior);
return Err(e);
}
let switched_at_ns = crate::replication::now_ns();
*state = MigrationState::Finalizing { switched_at_ns };
drop(state);
// Persist the finalized pin (and the cleared dual-write) so a crash after
// finalization cannot revert the tenant to jump-hash routing — the exact
// bug this closes (Accuracy-W).
self.persist_routing();
Ok(())
}
/// Transition from `Finalizing` to `Complete` after the GC window has elapsed.
/// Advance from `Finalizing` to `Complete` once the post-cutover safety
/// window (`gc_window_ns`) has elapsed since the routing switch.
///
/// # This is a state-only transition — it does NOT reclaim source data
///
/// This flips the migration state to `Complete`; it performs **no** storage
/// delete, no shipper teardown, and no source-shard reclamation. After it
/// returns the tenant's data still lives on the old source shard. Physically
/// reclaiming that keyspace (a delete-range over the source shard + flush,
/// and tearing down the source-shard shipper) is a separate, **not-yet-wired**
/// step tracked as the source-reclamation task in
/// `docs/specs/14-scale-architecture.md` (per `CODING_GUIDELINES.md` §12).
/// The window enforced here is the safety interval that step would wait out
/// before deleting; the state flip marks "safe to reclaim", not "reclaimed".
///
/// Named [`Self::mark_complete`] for that reason; [`Self::gc_source`] is kept
/// as a backwards-compatible alias and forwards here.
///
/// # Errors
///
/// Returns `InvalidState` if the current state is not `Finalizing`, or if
/// `gc_window_ns` has not elapsed since the routing switch.
pub fn gc_source(&self, gc_window_ns: u64) -> crate::Result<()> {
pub fn mark_complete(&self, gc_window_ns: u64) -> crate::Result<()> {
let mut state = self
.state
.lock()
.map_err(|_| TidalError::internal("migration", "state lock poisoned"))?;
let MigrationState::Finalizing { switched_at_ns } = *state else {
return Err(TidalError::InvalidState(
"gc_source called outside Finalizing".into(),
"mark_complete called outside Finalizing".into(),
));
};
let now = crate::replication::now_ns();
@ -240,6 +268,20 @@ impl TenantMigration {
Ok(())
}
/// Backwards-compatible alias for [`Self::mark_complete`].
///
/// The name historically implied source-data garbage collection, but the
/// transition is state-only (no source-shard reclamation happens here — see
/// [`Self::mark_complete`]). Retained so existing callers keep compiling;
/// new code should call `mark_complete`.
///
/// # Errors
///
/// See [`Self::mark_complete`].
pub fn gc_source(&self, gc_window_ns: u64) -> crate::Result<()> {
self.mark_complete(gc_window_ns)
}
/// Return the current migration state.
///
/// # Panics
@ -508,4 +550,201 @@ mod tests {
// It must NOT still be in dual-write (finalize cleared it).
assert!(!recovered.is_dual_write(TenantId(42)));
}
/// A storage backend whose `write_batch`/`flush` start succeeding and can be
/// flipped to fail at a chosen point. Reads pass through. Used to fail the
/// finalize checkpoint while the dual-write checkpoint succeeded (C9).
struct ToggleFailBackend {
inner: Arc<crate::storage::InMemoryBackend>,
fail: std::sync::atomic::AtomicBool,
}
impl ToggleFailBackend {
fn new() -> Self {
Self {
inner: Arc::new(crate::storage::InMemoryBackend::new()),
fail: std::sync::atomic::AtomicBool::new(false),
}
}
fn start_failing(&self) {
self.fail.store(true, std::sync::atomic::Ordering::SeqCst);
}
fn failing(&self) -> bool {
self.fail.load(std::sync::atomic::Ordering::SeqCst)
}
}
impl crate::storage::StorageEngine for ToggleFailBackend {
fn get(&self, key: &[u8]) -> Result<Option<Vec<u8>>, crate::storage::StorageError> {
self.inner.get(key)
}
fn put(&self, key: &[u8], value: &[u8]) -> Result<(), crate::storage::StorageError> {
if self.failing() {
return Err(crate::storage::StorageError::Closed);
}
self.inner.put(key, value)
}
fn delete(&self, key: &[u8]) -> Result<(), crate::storage::StorageError> {
self.inner.delete(key)
}
fn scan_prefix(&self, prefix: &[u8]) -> crate::storage::iterator::PrefixIterator<'_> {
self.inner.scan_prefix(prefix)
}
fn write_batch(
&self,
batch: crate::storage::batch::WriteBatch,
) -> Result<(), crate::storage::StorageError> {
if self.failing() {
return Err(crate::storage::StorageError::Closed);
}
self.inner.write_batch(batch)
}
fn flush(&self) -> Result<(), crate::storage::StorageError> {
if self.failing() {
return Err(crate::storage::StorageError::Closed);
}
self.inner.flush()
}
}
fn two_shard_parts() -> (
Arc<std::sync::RwLock<ClusterTopology>>,
Arc<TenantRouter>,
Arc<ControlPlane>,
) {
use std::sync::RwLock;
use crate::replication::tenant::ShardAssignment;
let topo = Arc::new(RwLock::new(ClusterTopology {
shards: vec![
ShardAssignment {
shard_id: ShardId(0),
region_id: crate::replication::RegionId(0),
},
ShardAssignment {
shard_id: ShardId(1),
region_id: crate::replication::RegionId(1),
},
],
}));
let router = Arc::new(TenantRouter::new(Arc::clone(&topo)));
let state = Arc::new(ReplicationState::single());
let lag = Arc::new(ReplicationLagGauge::new(ShardId::SINGLE, state));
let cp = Arc::new(ControlPlane::new(
Arc::clone(&topo),
Arc::clone(&router),
lag,
));
(topo, router, cp)
}
/// C9: a finalize whose checkpoint cannot be persisted must NOT observably
/// finalize. It rolls the router's routing for this tenant back to the prior
/// dual-write, stays in `DualWrite`, and returns `Storage` — so a crash after
/// a failed finalize replays as dual-write (correct), never as a half-finalized
/// pin the durable store does not reflect. The previous code swallowed the
/// error and returned Ok while disk still held the pre-finalize routing.
#[test]
fn finalize_rolls_back_on_persist_failure() {
let (_topo, router, cp) = two_shard_parts();
let backend = Arc::new(ToggleFailBackend::new());
let migration =
TenantMigration::new(TenantId(7), ShardId(0), ShardId(1), cp, Arc::clone(&router))
.with_persistence(Arc::clone(&backend) as Arc<dyn crate::storage::StorageEngine>);
migration.prepare_target(10).unwrap();
migration.enter_dual_write(20).unwrap(); // dual-write persists OK
assert!(router.is_dual_write(TenantId(7)));
// Now make the durable store reject the finalize checkpoint.
backend.start_failing();
let err = migration.finalize(25).unwrap_err();
assert!(
matches!(err, TidalError::Storage(_)),
"a failed finalize checkpoint must propagate, not be swallowed; got {err:?}"
);
// The router must be rolled back to dual-write (NOT pinned), and the state
// machine must stay in DualWrite so the caller can retry once storage
// recovers.
assert!(
router.is_dual_write(TenantId(7)),
"finalize rollback must restore the prior dual-write routing"
);
assert_eq!(
router.pinned_shard(TenantId(7)),
None,
"a finalize that failed to persist must NOT leave the tenant pinned"
);
assert!(matches!(
migration.current_state(),
MigrationState::DualWrite { cutover_seqno: 20 }
));
}
/// C10: a failed `enter_dual_write` persist for tenant A must roll back ONLY
/// tenant A's routing — it must NOT clobber a concurrent migration's routing
/// for tenant B that was applied on the shared router between A's snapshot and
/// A's rollback. The old full-router-snapshot rollback wiped B's entry.
#[test]
fn rollback_preserves_other_tenant_routing() {
let (_topo, router, cp_a) = two_shard_parts();
// Tenant 99 has a finalized pin already on the shared router (a different,
// already-completed migration). This is what A's rollback must not touch.
router.finalize_migration(TenantId(99), ShardId(1));
assert_eq!(router.pinned_shard(TenantId(99)), Some(ShardId(1)));
// Tenant 50 is mid-dual-write on the same shared router, modeling a
// CONCURRENT migration's just-applied entry that A's rollback must leave
// intact.
router.set_dual_write(TenantId(50), ShardId(0), ShardId(1));
// Tenant A's (id 7) migration uses a backend that fails the dual-write
// persist, forcing the rollback path.
let failing = Arc::new(ToggleFailBackend::new());
failing.start_failing();
let migration_a = TenantMigration::new(
TenantId(7),
ShardId(0),
ShardId(1),
cp_a,
Arc::clone(&router),
)
.with_persistence(Arc::clone(&failing) as Arc<dyn crate::storage::StorageEngine>);
migration_a.prepare_target(10).unwrap();
let err = migration_a.enter_dual_write(20).unwrap_err();
assert!(matches!(err, TidalError::Storage(_)));
// A's own dual-write entry must be gone (rolled back)...
assert!(
!router.is_dual_write(TenantId(7)),
"A's non-durable dual-write must be rolled back"
);
// ...but EVERY other tenant's routing on the shared router survives.
assert_eq!(
router.pinned_shard(TenantId(99)),
Some(ShardId(1)),
"C10: A's rollback must not clobber B's finalized pin"
);
assert!(
router.is_dual_write(TenantId(50)),
"C10: A's rollback must not clobber a concurrent tenant's dual-write"
);
}
/// W16: `mark_complete` (and its `gc_source` alias) is a pure state flip — it
/// transitions Finalizing -> Complete and does NOT reclaim source data. This
/// pins the documented honest behavior (no source-shard delete happens here).
#[test]
fn mark_complete_is_state_only_transition() {
let m = make_migration();
m.prepare_target(10).unwrap();
m.enter_dual_write(20).unwrap();
m.finalize(25).unwrap();
// The alias and the canonical name are the same transition.
m.mark_complete(0).unwrap();
assert_eq!(m.current_state(), MigrationState::Complete);
}
}

View File

@ -28,7 +28,13 @@
//! follower must never silently acknowledge an event it failed to durably
//! record.
use std::{sync::Arc, thread::JoinHandle};
use std::{
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
thread::JoinHandle,
};
use crate::{
replication::{
@ -45,9 +51,19 @@ use crate::{
/// Handle to a running segment receiver thread.
///
/// Call [`join`](Self::join) to block until the thread exits and retrieve any
/// corruption error that caused it to stop.
/// corruption error that caused it to stop. While the node is still open, poll
/// [`died`](Self::died) (or [`is_finished`](Self::is_finished)) to observe a
/// receiver that halted on an error mid-life — without it, a dead receiver is
/// indistinguishable from a healthy one until shutdown calls `join` (C11).
pub struct SegmentReceiverHandle {
thread: Option<JoinHandle<Result<(), WalError>>>,
/// Latched `true` exactly when the receiver thread halts on an apply error
/// (corrupt segment / follower-WAL IO fault) or an unexpected exit — i.e. a
/// silent replication stall. A clean `recv_segment() == None` shutdown leaves
/// this `false`, so it distinguishes "the follower stopped applying because
/// something broke" from "the follower was asked to shut down" (C11). Shared
/// with the thread, which sets it before returning the error.
died: Arc<AtomicBool>,
}
impl SegmentReceiverHandle {
@ -67,6 +83,32 @@ impl SegmentReceiverHandle {
})
})
}
/// Whether the receiver thread has halted on an error (corrupt segment,
/// follower-WAL IO fault, or an unexpected exit) while the node is still
/// open.
///
/// `true` means replication has silently stopped applying and the follower
/// is frozen — health checks must report degraded. A clean shutdown
/// (`recv_segment() == None`) does **not** set this, so it does not
/// false-positive on intentional teardown (C11). Cheap, lock-free, and safe
/// to poll from `health_check` while the handle is still parked in its mutex.
#[must_use]
pub fn died(&self) -> bool {
self.died.load(Ordering::Acquire)
}
/// Whether the receiver thread's join handle has finished.
///
/// Mirrors [`std::thread::JoinHandle::is_finished`]. Unlike [`died`](Self::died)
/// this is also `true` after a clean shutdown, so health checks should prefer
/// `died` to avoid flagging an intentional teardown as a fault.
#[must_use]
pub fn is_finished(&self) -> bool {
self.thread
.as_ref()
.is_some_and(std::thread::JoinHandle::is_finished)
}
}
/// Spawn a background thread that receives WAL segments and replays them
@ -90,11 +132,19 @@ pub fn spawn_receiver<T: Transport + ?Sized>(
replication_state: Arc<ReplicationState>,
lag_gauge: Option<Arc<ReplicationLagGauge>>,
) -> SegmentReceiverHandle {
// Liveness latch (C11): set true iff the thread halts on an apply error.
// Shared with the handle so health_check can observe a silently-dead
// receiver while the node is still open. A clean shutdown leaves it false.
let died = Arc::new(AtomicBool::new(false));
let thread_died = Arc::clone(&died);
let thread = std::thread::Builder::new()
.name("tidaldb-segment-receiver".into())
.spawn(move || -> Result<(), WalError> {
loop {
let Some(payload) = transport.recv_segment() else {
// Clean shutdown: the transport closed / shutdown was
// requested. Leave the `died` latch false so health_check
// does not flag an intentional teardown as a fault (C11).
tracing::debug!("segment receiver: transport closed, shutting down");
return Ok(());
};
@ -114,16 +164,18 @@ pub fn spawn_receiver<T: Transport + ?Sized>(
lag_gauge.as_deref(),
leader_seqno,
) {
// A corrupt segment halts the receiver thread. Log at the
// failure site so a stalled replica is observable rather
// than silently dead -- mirrors wal::reader's per-corruption
// logging, except replication cannot skip-and-continue (a
// gap would diverge the replica), so we halt and surface via
// SegmentReceiverHandle::join.
// A corrupt segment / follower-WAL fault halts the receiver
// thread. Latch `died` BEFORE returning so a stalled replica
// is observable via SegmentReceiverHandle::died() while the
// node is still open — not just at shutdown via join(). Log at
// the failure site too -- mirrors wal::reader's per-corruption
// logging, except replication cannot skip-and-continue (a gap
// would diverge the replica), so we halt and surface.
thread_died.store(true, Ordering::Release);
tracing::error!(
shard = %shard_id,
error = %e,
"replication apply failed; receiver halting"
"replication apply failed; receiver halting (health degraded)"
);
return Err(e);
}
@ -133,6 +185,7 @@ pub fn spawn_receiver<T: Transport + ?Sized>(
SegmentReceiverHandle {
thread: Some(thread),
died,
}
}
@ -827,6 +880,97 @@ mod tests {
assert!(!ledger.entries().contains_key(&(EntityId::new(55), type_id)));
}
/// C11: a receiver that halts on a corrupt segment must flip its `died`
/// liveness latch WITHOUT the owner ever calling `join()` — so `health_check`
/// can observe a silently-stalled follower while the node is still open,
/// instead of the death being visible only at shutdown.
#[test]
fn receiver_died_latch_set_on_corrupt_without_join() {
let (tx, rx) = crossbeam::channel::bounded(4);
let transport = Arc::new(OneShot { rx });
let schema = make_schema();
let ledger = Arc::new(SignalLedger::new(schema, Box::new(NoopWalWriter)));
let state = Arc::new(ReplicationState::new(&[ShardId(0)]));
let handle = spawn_receiver(
Arc::clone(&transport),
Arc::clone(&ledger),
Arc::clone(&state),
None,
);
// Healthy until something breaks.
assert!(!handle.died(), "fresh receiver must not report died");
// Feed one corrupt segment.
let type_id = ledger.resolve_signal_type("view").unwrap();
let events = vec![make_event(55, type_id.as_u16() as u8, 100)];
let mut corrupt_bytes = encode_batch(&events, 1, 1).unwrap();
for b in &mut corrupt_bytes[32..64] {
*b = b.wrapping_add(1);
}
tx.send(crate::replication::WalSegmentPayload {
id: WalSegmentId::new(RegionId::SINGLE, ShardId(0), 1),
bytes: corrupt_bytes,
event_count: 1,
leader_last_seq: 1,
})
.unwrap();
// Poll the latch (NOT join): the receiver must halt and set `died` while
// the channel/transport stays open and the owner never calls join().
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while !handle.died() && std::time::Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(5));
}
assert!(
handle.died(),
"a corrupt segment must flip the receiver's died latch without join()"
);
// The transport is still open (tx not dropped) — the death is observable
// purely from the latch, exactly what health_check reads.
drop(tx);
}
/// C11: a CLEAN shutdown (transport closed via `recv_segment()` == None) must
/// NOT set the `died` latch — otherwise `health_check` would flag an
/// intentional teardown as a fault.
#[test]
fn receiver_died_latch_unset_on_clean_shutdown() {
let (tx, rx) = crossbeam::channel::bounded(4);
let transport = Arc::new(OneShot { rx });
let schema = make_schema();
let ledger = Arc::new(SignalLedger::new(schema, Box::new(NoopWalWriter)));
let state = Arc::new(ReplicationState::new(&[ShardId(0)]));
let handle = spawn_receiver(
Arc::clone(&transport),
Arc::clone(&ledger),
Arc::clone(&state),
None,
);
// Clean shutdown: drop the sender so recv_segment() returns None.
drop(tx);
// The thread exits Ok; the latch must stay false.
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while !handle.is_finished() && std::time::Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(5));
}
assert!(
handle.is_finished(),
"clean shutdown must let the thread exit"
);
assert!(
!handle.died(),
"a clean shutdown must NOT set the died latch (no false-positive fault)"
);
handle.join().unwrap();
}
/// obs-REPL-1: a follower that has received batches from the leader but not
/// yet applied all of them reports a NON-ZERO replication lag; the lag
/// returns to 0 once the follower catches up. This proves the receiver

View File

@ -38,7 +38,11 @@ use crate::{
///
/// Stored inside an `LWWRegister<HardNegAction>` and resolved by HLC
/// timestamp during reconciliation.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
// `Ord`/`PartialOrd` are required because `LWWRegister<T>` folds the value into
// its tie-break so `merge` is commutative on an exact-HLC-timestamp collision.
// Declaration order (`Hide` < `Unhide`) makes such a tie resolve to `Unhide`;
// the value tie-break only matters for the otherwise-unreachable collision case.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
pub enum HardNegAction {
/// The user explicitly hid, muted, or blocked this item.
Hide,

View File

@ -59,19 +59,31 @@ impl SessionPayload {
/// concatenates the results, and computes a BLAKE3 checksum over the
/// entire byte buffer. The checksum is verified by the receiver before
/// any events are decoded.
#[must_use]
pub fn build(source_shard: ShardId, events: &[SessionWalEvent]) -> Self {
///
/// # Errors
///
/// Returns [`SessionBridgeError::Encode`] if any event has a string field
/// longer than `u16::MAX` bytes and so cannot be faithfully encoded. We
/// surface this rather than ship a record the receiver would decode back
/// truncated (and that would misframe every later event in the batch).
pub fn build(
source_shard: ShardId,
events: &[SessionWalEvent],
) -> Result<Self, SessionBridgeError> {
let mut bytes = Vec::new();
for event in events {
bytes.extend(encode_session_event(event));
bytes.extend(
encode_session_event(event)
.map_err(|e| SessionBridgeError::Encode(e.to_string()))?,
);
}
let checksum = *Hasher::new().update(&bytes).finalize().as_bytes();
Self {
Ok(Self {
source_shard,
bytes,
checksum,
event_count: events.len() as u32,
}
})
}
/// Decode and verify the events in this payload.
@ -103,6 +115,11 @@ pub enum SessionBridgeError {
/// The session transport channel is full or disconnected.
#[error("session transport channel closed")]
Closed,
/// An event could not be faithfully encoded (a string field longer than
/// `u16::MAX` bytes). The batch is rejected rather than shipped as a record
/// the receiver would decode back truncated.
#[error("session event encode failed: {0}")]
Encode(String),
}
/// In-process session transport factory.
@ -262,7 +279,8 @@ impl SessionReplicationBridge {
/// # Errors
///
/// Returns `SessionBridgeError::UnknownPeer` or `SessionBridgeError::Closed`
/// if the transport send fails.
/// if the transport send fails, or `SessionBridgeError::Encode` if an event
/// has a string field too long to encode faithfully.
pub fn ship(
&self,
target: ShardId,
@ -296,7 +314,7 @@ impl SessionReplicationBridge {
.unwrap_or(current_hwm);
let count = to_ship.len();
let payload = SessionPayload::build(self.local_shard, &to_ship);
let payload = SessionPayload::build(self.local_shard, &to_ship)?;
self.transport.send(target, payload)?;
self.ship_hwm.insert(hwm_key, highest);
@ -473,7 +491,7 @@ mod tests {
let tracker1 = Arc::new(SessionSeqNoTracker::new());
// Send a corrupted payload directly to t1's channel.
let mut payload = SessionPayload::build(ShardId(0), &[signal_event(3, 1)]);
let mut payload = SessionPayload::build(ShardId(0), &[signal_event(3, 1)]).unwrap();
payload.checksum[0] ^= 0xFF; // corrupt the checksum
t0.send(ShardId(1), payload).unwrap();
@ -528,7 +546,7 @@ mod tests {
#[test]
fn payload_build_and_decode_roundtrip() {
let events: Vec<_> = (1..=5).map(|i| signal_event(1, i)).collect();
let payload = SessionPayload::build(ShardId(0), &events);
let payload = SessionPayload::build(ShardId(0), &events).unwrap();
assert_eq!(payload.event_count, 5);
assert_eq!(payload.source_shard, ShardId(0));
@ -542,7 +560,7 @@ mod tests {
#[test]
fn payload_decode_corrupt_bytes_fails() {
let mut payload = SessionPayload::build(ShardId(0), &[signal_event(1, 1)]);
let mut payload = SessionPayload::build(ShardId(0), &[signal_event(1, 1)]).unwrap();
// Flip a bit in the payload bytes.
if !payload.bytes.is_empty() {
payload.bytes[0] ^= 0x01;
@ -585,7 +603,7 @@ mod tests {
// Event A: seqno 5, key K. Applies cleanly; HWM -> 5, key K recorded.
let a = signal_event_with_key(7, 5, 0xABCD);
let payload_a = SessionPayload::build(b0.local_shard, std::slice::from_ref(&a));
let payload_a = SessionPayload::build(b0.local_shard, std::slice::from_ref(&a)).unwrap();
b0.transport.send(ShardId(1), payload_a).unwrap();
assert_eq!(b1.recv_and_apply(|_| {}).unwrap(), 1);
assert_eq!(b1.seqno_tracker().hwm(7), SessionSeqNo(5));
@ -594,7 +612,7 @@ mod tests {
// re-derived the same key). Layer 2 must drop it BEFORE Layer 1 advances
// the HWM — so the HWM must stay at 5, not jump to 10.
let b = signal_event_with_key(7, 10, 0xABCD);
let payload_b = SessionPayload::build(b0.local_shard, std::slice::from_ref(&b));
let payload_b = SessionPayload::build(b0.local_shard, std::slice::from_ref(&b)).unwrap();
b0.transport.send(ShardId(1), payload_b).unwrap();
assert_eq!(
b1.recv_and_apply(|_| {}).unwrap(),
@ -611,7 +629,7 @@ mod tests {
// ordering it applies; under the buggy ordering the HWM would be 10 and C
// would be wrongly dropped.
let c = signal_event_with_key(7, 7, 0x1234);
let payload_c = SessionPayload::build(b0.local_shard, std::slice::from_ref(&c));
let payload_c = SessionPayload::build(b0.local_shard, std::slice::from_ref(&c)).unwrap();
b0.transport.send(ShardId(1), payload_c).unwrap();
let mut received = Vec::new();
assert_eq!(
@ -640,7 +658,7 @@ mod tests {
];
// Manually build and send (Start/Close have no seqno, always shipped).
let payload = SessionPayload::build(b0.local_shard, &events);
let payload = SessionPayload::build(b0.local_shard, &events).unwrap();
b0.transport.send(ShardId(1), payload).unwrap();
let mut received = Vec::new();

View File

@ -208,6 +208,20 @@ struct MigrationRoutingCheckpoint {
pins: std::collections::HashMap<String, u16>,
}
/// A captured snapshot of a single tenant's routing (its dual-write pair and its
/// shard pin), used for targeted rollback in the migration coordinator.
///
/// Produced by [`TenantRouter::snapshot_tenant_routing`] and restored by
/// [`TenantRouter::restore_tenant_routing`]. Restoring affects only this
/// tenant, never the whole router, so a failed migration persist cannot clobber
/// a concurrent migration's routing for a different tenant (C10).
#[derive(Debug, Clone)]
pub struct TenantRoutingSnapshot {
tenant_id: TenantId,
dual_write: Option<(ShardId, ShardId)>,
pin: Option<ShardId>,
}
/// Describes the shard topology for the cluster.
#[derive(Debug, Clone)]
pub struct ClusterTopology {
@ -328,7 +342,11 @@ impl TenantRouter {
/// future routing to `target_shard`.
///
/// After this call, `route()` and `write_assignments()` both return
/// `target_shard` exclusively. The old source shard can be GC'd.
/// `target_shard` exclusively. The old source shard's copy of the tenant's
/// data is now safe to reclaim, but this method does **not** reclaim it —
/// physical source-shard reclamation is a separate, not-yet-wired step (see
/// [`TenantMigration::mark_complete`](crate::replication::migration::TenantMigration::mark_complete)
/// and the source-reclamation task in `docs/specs/14-scale-architecture.md`).
pub fn finalize_migration(&self, tenant_id: TenantId, target: ShardId) {
self.dual_write_map.remove(&tenant_id);
self.shard_pin_map.insert(tenant_id, target);
@ -383,6 +401,52 @@ impl TenantRouter {
self.shard_pin_map.get(&tenant_id).map(|r| *r)
}
/// Snapshot ONLY `tenant_id`'s routing (its dual-write pair + its pin), for a
/// targeted rollback.
///
/// Unlike [`to_checkpoint_bytes`](Self::to_checkpoint_bytes) (which snapshots
/// the WHOLE router), this captures a single tenant so a migration that fails
/// to persist can restore exactly its own prior routing without touching any
/// other tenant's entry. A full-router restore would clobber a *concurrent*
/// migration's just-applied (and possibly already-persisted) routing for a
/// different tenant, because all migrations share one router with only a
/// per-migration state lock (C10).
#[must_use]
pub fn snapshot_tenant_routing(&self, tenant_id: TenantId) -> TenantRoutingSnapshot {
TenantRoutingSnapshot {
tenant_id,
dual_write: self.dual_write_map.get(&tenant_id).map(|r| *r),
pin: self.shard_pin_map.get(&tenant_id).map(|r| *r),
}
}
/// Restore ONLY the snapshotted tenant's routing, leaving every other tenant
/// untouched.
///
/// Sets the tenant's dual-write pair and pin back to exactly what
/// [`snapshot_tenant_routing`](Self::snapshot_tenant_routing) captured —
/// removing whichever side was absent in the snapshot. This is the targeted
/// counterpart to the rollback path in the migration coordinator: a failed
/// persist for tenant A must not disturb tenant B's concurrent routing (C10).
pub fn restore_tenant_routing(&self, snapshot: &TenantRoutingSnapshot) {
match snapshot.dual_write {
Some(pair) => {
self.dual_write_map.insert(snapshot.tenant_id, pair);
}
None => {
self.dual_write_map.remove(&snapshot.tenant_id);
}
}
match snapshot.pin {
Some(shard) => {
self.shard_pin_map.insert(snapshot.tenant_id, shard);
}
None => {
self.shard_pin_map.remove(&snapshot.tenant_id);
}
}
}
// ── Migration routing durability (Accuracy-W) ───────────────────────────
//
// The dual-write and shard-pin maps decide where a tenant's writes go during
@ -535,18 +599,15 @@ fn jump_hash_select(key: u64, shards: &[ShardAssignment]) -> ShardAssignment {
shards[b as usize]
}
/// Build the WAL directory path for a tenant.
#[must_use]
pub fn tenant_wal_dir(data_dir: &std::path::Path, tenant_id: TenantId) -> std::path::PathBuf {
if tenant_id == TenantId::DEFAULT {
data_dir.join("wal")
} else {
data_dir
.join("tenants")
.join(tenant_id.0.to_string())
.join("wal")
}
}
// NOTE: per-tenant physical storage/WAL isolation is intentionally NOT
// implemented here. On a single node hosting multiple tenants, `tenant_id`
// influences rate limiting and (multi-node) routing only — all tenants' signals
// land in one shared keyspace/WAL. The former `tenant_wal_dir` helper (which
// built `/data/tenants/{id}/wal` paths) was dead code with no production caller;
// keeping it implied a physical-isolation guarantee the write path never
// provided, so it was removed (W17). Physical per-tenant keyspace/WAL isolation
// and enforcement of `TenantConfig::max_storage_bytes`/`max_entities` are a
// separate, not-yet-wired task tracked in `docs/specs/14-scale-architecture.md`.
// ── Migration routing checkpoint persistence ────────────────────────────────
@ -831,24 +892,6 @@ mod tests {
assert_eq!(assignments[1].region_id, RegionId(1));
}
#[test]
fn tenant_wal_dir_default() {
let dir = std::path::Path::new("/data");
assert_eq!(
tenant_wal_dir(dir, TenantId::DEFAULT),
std::path::PathBuf::from("/data/wal")
);
}
#[test]
fn tenant_wal_dir_non_default() {
let dir = std::path::Path::new("/data");
assert_eq!(
tenant_wal_dir(dir, TenantId(7)),
std::path::PathBuf::from("/data/tenants/7/wal")
);
}
// ── Migration routing durability (Accuracy-W) ───────────────────────────
fn two_shard_router() -> TenantRouter {

View File

@ -264,13 +264,28 @@ pub enum SchemaError {
field: &'static str,
reason: String,
},
/// An embedding slot declared zero dimensions.
/// An embedding slot declared dimensions outside the documented `[2, 4096]`
/// range.
///
/// A zero-dimension slot cannot hold a vector; it would silently break ANN
/// retrieval for that slot rather than panicking, so we reject it at
/// define-time as a typed error.
#[error("embedding slot '{slot}': invalid dimensions: {dimensions}")]
/// A 0- or 1-dimension slot is a degenerate index; an enormous dimension is
/// accepted at define-time and only surfaces much later as an allocation
/// failure or a per-vector insert error, far from the schema call that
/// caused it. Reject it eagerly at the schema boundary as a typed error.
#[error("embedding slot '{slot}': invalid dimensions: {dimensions} (must be in [2, 4096])")]
InvalidEmbeddingDimensions { slot: String, dimensions: usize },
/// An embedding slot name is not a valid identifier.
///
/// Slot names must satisfy the same rule as signal/policy names
/// (non-empty, lowercase ASCII `[a-z0-9_]`, leading letter). The slot
/// fingerprint hashes the name as raw bytes followed by a fixed-width
/// `[kind:1][dims:8]` trailer; restricting names to control-free identifier
/// bytes keeps that byte stream unambiguous so two distinct `(name, kind)`
/// pairs can never collide under a different split.
#[error("embedding slot '{slot}' for {entity_kind}: invalid name (must be a valid identifier)")]
InvalidSlotName {
slot: String,
entity_kind: EntityKind,
},
/// Two embedding slots share the same `(name, entity_kind)` pair.
///
/// Slots are keyed by `(name, entity_kind)` in the vector registry, so a
@ -314,8 +329,43 @@ pub enum SchemaError {
/// A user-declared text field with this key collides with the internal u64
/// field and panics inside Tantivy's `SchemaBuilder::build()`, so the schema
/// builder rejects it at define-time via [`SchemaError::ReservedTextFieldKey`].
/// Kept in sync with the field name in `text::index::build_tantivy_schema`.
pub(crate) const RESERVED_TEXT_FIELD_KEY: &str = "entity_id";
///
/// This is **not** an independent literal: it is an alias of the single source
/// of truth, [`crate::text::index::ENTITY_ID_FIELD`], which the text index uses
/// to create the column. Renaming the Tantivy field there automatically updates
/// this guard, so the define-time check can never drift onto a stale name while
/// the live name silently becomes user-declarable again. The
/// `reserved_text_field_key_matches_tantivy_field` test (plus the const
/// assertion below) pins the equality so a future divergence fails the build
/// rather than production open.
pub(crate) const RESERVED_TEXT_FIELD_KEY: &str = crate::text::index::ENTITY_ID_FIELD;
/// Compile-time guard that the reserved-key alias has not been re-pointed at an
/// independent literal. Equality is guaranteed by construction today (the const
/// is an alias), but pinning it here means a future edit that replaces the alias
/// with a copied string would fail to compile rather than reintroduce the
/// stale-name drift this guard exists to prevent.
const _: () = assert!(
konst_str_eq(RESERVED_TEXT_FIELD_KEY, crate::text::index::ENTITY_ID_FIELD),
"RESERVED_TEXT_FIELD_KEY must equal text::index::ENTITY_ID_FIELD"
);
/// `const fn` byte-wise string equality usable in a `const` assertion context
/// (`str::eq` / `==` on `&str` is not `const` on the pinned toolchain).
const fn konst_str_eq(a: &str, b: &str) -> bool {
let (a, b) = (a.as_bytes(), b.as_bytes());
if a.len() != b.len() {
return false;
}
let mut i = 0;
while i < a.len() {
if a[i] != b[i] {
return false;
}
i += 1;
}
true
}
impl Eq for SchemaError {}
@ -340,6 +390,18 @@ pub struct DurabilityError {
mod tests {
use super::*;
#[test]
fn reserved_text_field_key_matches_tantivy_field() {
// The schema guard's reserved key MUST equal the internal Tantivy field
// name. They are coupled by aliasing the single source of truth; this
// test makes the coupling explicit so a future divergence fails here
// rather than panicking inside Tantivy's SchemaBuilder at DB open.
assert_eq!(
super::RESERVED_TEXT_FIELD_KEY,
crate::text::index::ENTITY_ID_FIELD
);
}
#[test]
fn tidal_error_display_not_found() {
let e = TidalError::NotFound {
@ -486,6 +548,28 @@ mod tests {
);
}
#[test]
fn schema_error_display_invalid_slot_name() {
let e = SchemaError::InvalidSlotName {
slot: "Bad Name".into(),
entity_kind: EntityKind::Item,
};
let s = e.to_string();
assert!(s.contains("Bad Name"), "got: {s}");
assert!(s.contains("invalid name"), "got: {s}");
}
#[test]
fn schema_error_display_invalid_embedding_dimensions_states_range() {
let e = SchemaError::InvalidEmbeddingDimensions {
slot: "content".into(),
dimensions: 1,
};
let s = e.to_string();
assert!(s.contains("invalid dimensions: 1"), "got: {s}");
assert!(s.contains("[2, 4096]"), "got: {s}");
}
#[test]
fn schema_error_display_empty_slot_name() {
let e = SchemaError::EmptySlotName {

View File

@ -7,6 +7,22 @@ use super::{
};
use crate::schema::{DecayModel, EntityKind, SignalTypeDef, Window, WindowSet, error::SchemaError};
/// Smallest embedding dimensionality the schema accepts.
///
/// A 0-dimension slot cannot hold a vector and a 1-dimension slot is a
/// degenerate index (every vector is colinear), so the documented contract
/// (`EmbeddingSlot` spec, `11-schema.md` V-E04) requires at least 2.
pub(crate) const MIN_EMBEDDING_DIMENSIONS: usize = 2;
/// Largest embedding dimensionality the schema accepts.
///
/// Bounds the per-vector allocation at define-time rather than letting an
/// arbitrarily huge `usize` surface as an allocation failure on first insert,
/// far from the schema call that caused it. 4096 covers every embedding model
/// in current use; lift this deliberately (and update the spec) if a larger
/// model is needed.
pub(crate) const MAX_EMBEDDING_DIMENSIONS: usize = 4096;
/// Internal entry for a signal being built.
#[derive(Debug)]
struct SignalEntry {
@ -164,8 +180,10 @@ impl SchemaBuilder {
/// - `InvalidLifetime` if linear decay has zero/negative lifetime
/// - `EmptyWindows` if a non-permanent signal has no windows
/// - `VelocityWithoutWindows` if velocity is enabled without windows
/// - `InvalidEmbeddingDimensions` if an embedding slot has zero dimensions
/// - `InvalidEmbeddingDimensions` if an embedding slot's dimensions are
/// outside the documented `[2, 4096]` range
/// - `EmptySlotName` if an embedding slot name is empty
/// - `InvalidSlotName` if an embedding slot name is not a valid identifier
/// - `DuplicateEmbeddingSlot` if a `(name, kind)` pair is declared twice
/// - `InvalidPolicyLimit` if a session policy has a zero `max_session_duration`
/// - `InvalidTextField` if a text-field key is empty
@ -304,9 +322,10 @@ impl SchemaBuilder {
// Validate embedding slots. The vector registry keys slots by
// `(name, entity_kind)`, so an empty name (unaddressable) or a duplicate
// pair would silently shadow an earlier slot's dimensions; a
// zero-dimension slot cannot hold a vector. All three are developer
// errors that must surface here as typed `SchemaError`s rather than as
// broken-but-silent state discovered at the first ANN query.
// dimension outside [MIN_EMBEDDING_DIMENSIONS, MAX_EMBEDDING_DIMENSIONS]
// cannot hold a usable vector. These are developer errors that must
// surface here as typed `SchemaError`s rather than as broken-but-silent
// state discovered at the first ANN query.
let mut seen_slots = std::collections::HashSet::new();
for slot in &self.embedding_slots {
if slot.name.is_empty() {
@ -314,7 +333,24 @@ impl SchemaBuilder {
entity_kind: slot.entity_kind,
});
}
if slot.dimensions == 0 {
// Slot names share the slot fingerprint's byte stream with a
// fixed-width `[kind:1][dims:8]` trailer; an unrestricted name could
// embed the kind byte and (in principle) let two distinct
// (name, kind) pairs hash to the same concatenated bytes. Hold slot
// names to the same identifier rule as signal/policy names so the
// name is a guaranteed-non-control `[a-z0-9_]` token and the
// fingerprint split is unambiguous.
if !super::is_valid_signal_name(&slot.name) {
return Err(SchemaError::InvalidSlotName {
slot: slot.name.clone(),
entity_kind: slot.entity_kind,
});
}
// Eager, complete range gate: a definition that passes validation is
// guaranteed self-consistent. Dimension 0/1 is a degenerate index and
// an enormous dimension only surfaces as an allocation/insert failure
// deep in the ANN path; reject both at the schema boundary instead.
if !(MIN_EMBEDDING_DIMENSIONS..=MAX_EMBEDDING_DIMENSIONS).contains(&slot.dimensions) {
return Err(SchemaError::InvalidEmbeddingDimensions {
slot: slot.name.clone(),
dimensions: slot.dimensions,
@ -358,7 +394,11 @@ fn validate_text_field_set(fields: &[TextFieldDef]) -> Result<(), SchemaError> {
if field.key.is_empty() {
return Err(SchemaError::InvalidTextField(field.key.clone()));
}
if field.key == crate::schema::error::RESERVED_TEXT_FIELD_KEY {
// Compare against the single source of truth for the internal Tantivy
// field name. Referencing `text::index::ENTITY_ID_FIELD` directly (not a
// copied literal) means a rename there cannot leave this guard protecting
// a stale name while the new name silently becomes user-declarable.
if field.key == crate::text::index::ENTITY_ID_FIELD {
return Err(SchemaError::ReservedTextFieldKey(field.key.clone()));
}
if !seen_keys.insert(field.key.as_str()) {
@ -669,6 +709,78 @@ mod tests {
));
}
#[test]
fn rejects_dimension_one_embedding_slot() {
// dim=1 is a degenerate index; the documented contract is [2, 4096].
let mut builder = SchemaBuilder::new();
item_signal(&mut builder);
builder.embedding_slot("content", EntityKind::Item, 1);
let result = builder.build();
assert!(matches!(
result,
Err(SchemaError::InvalidEmbeddingDimensions { ref slot, dimensions: 1 })
if slot == "content"
));
}
#[test]
fn rejects_dimension_above_ceiling_embedding_slot() {
// One past the documented ceiling must be rejected eagerly at the schema
// boundary, not surface as an allocation failure on first insert.
let mut builder = SchemaBuilder::new();
item_signal(&mut builder);
builder.embedding_slot("content", EntityKind::Item, MAX_EMBEDDING_DIMENSIONS + 1);
let result = builder.build();
assert!(matches!(
result,
Err(SchemaError::InvalidEmbeddingDimensions { ref slot, dimensions })
if slot == "content" && dimensions == MAX_EMBEDDING_DIMENSIONS + 1
));
}
#[test]
fn accepts_boundary_embedding_dimensions() {
// Both ends of the inclusive [2, 4096] range are accepted.
for dim in [MIN_EMBEDDING_DIMENSIONS, MAX_EMBEDDING_DIMENSIONS] {
let mut builder = SchemaBuilder::new();
item_signal(&mut builder);
builder.embedding_slot("content", EntityKind::Item, dim);
let schema = builder
.build()
.unwrap_or_else(|e| panic!("dim {dim} must be accepted: {e}"));
assert_eq!(schema.embedding_slots().len(), 1);
}
}
#[test]
fn rejects_invalid_slot_name() {
// Slot names are held to the identifier rule applied to signal/policy
// names: control bytes, uppercase, and leading digits are all rejected.
let invalid = [
"Content",
"1content",
"con tent",
"con-tent",
"con\u{0}tent",
];
for name in invalid {
let mut builder = SchemaBuilder::new();
item_signal(&mut builder);
builder.embedding_slot(name, EntityKind::Item, 768);
let result = builder.build();
assert!(
matches!(
result,
Err(SchemaError::InvalidSlotName {
entity_kind: EntityKind::Item,
..
})
),
"should reject slot name {name:?}"
);
}
}
#[test]
fn rejects_empty_embedding_slot_name() {
// An empty slot name is its own error, not the misleading
@ -787,6 +899,23 @@ mod tests {
));
}
#[test]
fn rejects_reserved_field_sourced_from_text_ssot() {
// The guard must reject whatever name the text index actually reserves
// (text::index::ENTITY_ID_FIELD), not a copied literal. Driving the test
// from the SSOT means a future rename of the Tantivy field keeps the
// guard honest without a second edit here.
let reserved = crate::text::index::ENTITY_ID_FIELD;
let mut builder = SchemaBuilder::new();
item_signal(&mut builder);
builder.text_field(reserved, TextFieldType::Keyword);
let result = builder.build();
assert!(matches!(
result,
Err(SchemaError::ReservedTextFieldKey(ref k)) if k == reserved
));
}
#[test]
fn item_and_creator_sets_validate_independently() {
// The same key in the item set and the creator set is NOT a duplicate;

View File

@ -1,6 +1,6 @@
//! Policy evaluation for session signal writes.
use std::{sync::atomic::Ordering, time::Instant};
use std::{sync::atomic::Ordering, time::Duration};
use super::state::SessionState;
use crate::schema::AgentPolicy;
@ -53,6 +53,15 @@ impl<'a> PolicyEvaluator<'a> {
/// Check whether a signal can be written under this policy.
///
/// `now_ns` is the current wall clock in nanoseconds since the Unix epoch
/// (`Timestamp::now().as_nanos()` at the call site). The duration check
/// measures age from the **durable** `started_at_ns`, never from the
/// monotonic `started_at` `Instant`: a session restored on open re-seeds
/// `started_at`, so reading the monotonic clock here would reset a restored
/// session's age to ~zero and let an agent evade `max_session_duration`
/// indefinitely by surviving across restarts. `started_at_ns` is the durable
/// anchor, matching `close_session_internal`'s duration accounting.
///
/// Returns `Ok(())` if all policy checks pass.
///
/// # Errors
@ -62,7 +71,7 @@ impl<'a> PolicyEvaluator<'a> {
&self,
signal_type: &str,
state: &SessionState,
now: Instant,
now_ns: u64,
) -> Result<(), PolicyViolation> {
let make_violation = |kind: PolicyViolationKind, reason: String| PolicyViolation {
kind,
@ -71,8 +80,10 @@ impl<'a> PolicyEvaluator<'a> {
reason,
};
// 1. Duration check.
let elapsed = now.duration_since(state.started_at);
// 1. Duration check — measured off the durable wall clock, not the
// monotonic Instant (which restore reseeds). Saturating so a backwards
// wall-clock step reports 0 elapsed rather than underflowing.
let elapsed = Duration::from_nanos(now_ns.saturating_sub(state.started_at_ns));
if elapsed > self.policy.max_session_duration {
return Err(make_violation(
PolicyViolationKind::Expired,
@ -146,7 +157,7 @@ mod tests {
Arc, Mutex,
atomic::{AtomicBool, AtomicU64},
},
time::Duration,
time::{Duration, Instant},
};
use dashmap::DashMap;
@ -158,15 +169,17 @@ mod tests {
},
*,
};
use crate::schema::Timestamp;
fn make_state(policy_name: &str) -> SessionState {
// `started_at_ns` is "now" so the duration check sees a brand-new session.
SessionState {
id: SessionId(1),
user_id: 100,
agent_id: AgentId::new("test-agent").unwrap(),
policy_name: policy_name.to_owned(),
started_at: Instant::now(),
started_at_ns: 0,
started_at_ns: Timestamp::now().as_nanos(),
metadata: HashMap::new(),
signals: DashMap::new(),
signaled_entities: DashMap::new(),
@ -188,7 +201,11 @@ mod tests {
};
let evaluator = PolicyEvaluator::new(&policy, "test_policy");
let state = make_state("test_policy");
assert!(evaluator.check("view", &state, Instant::now()).is_ok());
assert!(
evaluator
.check("view", &state, Timestamp::now().as_nanos())
.is_ok()
);
}
#[test]
@ -201,7 +218,11 @@ mod tests {
};
let evaluator = PolicyEvaluator::new(&policy, "test_policy");
let state = make_state("test_policy");
assert!(evaluator.check("like", &state, Instant::now()).is_err());
assert!(
evaluator
.check("like", &state, Timestamp::now().as_nanos())
.is_err()
);
}
#[test]
@ -214,7 +235,11 @@ mod tests {
};
let evaluator = PolicyEvaluator::new(&policy, "test_policy");
let state = make_state("test_policy");
assert!(evaluator.check("block", &state, Instant::now()).is_err());
assert!(
evaluator
.check("block", &state, Timestamp::now().as_nanos())
.is_err()
);
}
#[test]
@ -228,7 +253,11 @@ mod tests {
let evaluator = PolicyEvaluator::new(&policy, "test_policy");
let state = make_state("test_policy");
state.signals_written.store(2, Ordering::Relaxed);
assert!(evaluator.check("view", &state, Instant::now()).is_err());
assert!(
evaluator
.check("view", &state, Timestamp::now().as_nanos())
.is_err()
);
}
#[test]
@ -240,14 +269,15 @@ mod tests {
max_signals_per_session: 0,
};
let evaluator = PolicyEvaluator::new(&policy, "test_policy");
let now_ns = Timestamp::now().as_nanos();
let state = SessionState {
id: SessionId(1),
user_id: 100,
agent_id: AgentId::new("test-agent").unwrap(),
policy_name: "test_policy".to_owned(),
// started_at far in the past:
started_at: Instant::now().checked_sub(Duration::from_secs(10)).unwrap(),
started_at_ns: 0,
started_at: Instant::now(),
// durable start far in the past:
started_at_ns: now_ns.saturating_sub(10 * 1_000_000_000),
metadata: HashMap::new(),
signals: DashMap::new(),
signaled_entities: DashMap::new(),
@ -257,6 +287,46 @@ mod tests {
audit_log: Mutex::new(AuditLog::new()),
closed: Arc::new(AtomicBool::new(false)),
};
assert!(evaluator.check("view", &state, Instant::now()).is_err());
assert!(evaluator.check("view", &state, now_ns).is_err());
}
/// Regression (BLOCKER): TTL / `max_session_duration` must survive a
/// simulated restore. A restored session re-seeds the monotonic `started_at`
/// with a fresh `Instant::now()` while keeping the durable `started_at_ns`
/// far in the past. The duration check must reject off `started_at_ns`, not
/// the near-zero monotonic age — otherwise an agent evades the bound by
/// surviving across restarts.
#[test]
fn policy_duration_survives_simulated_restore() {
let policy = AgentPolicy {
allowed_signals: vec![],
denied_signals: vec![],
max_session_duration: Duration::from_secs(3600),
max_signals_per_session: 0,
};
let evaluator = PolicyEvaluator::new(&policy, "test_policy");
let now_ns = Timestamp::now().as_nanos();
let two_hours_ns = 2 * 3600 * 1_000_000_000u64;
let state = SessionState {
id: SessionId(1),
user_id: 100,
agent_id: AgentId::new("test-agent").unwrap(),
policy_name: "test_policy".to_owned(),
// Restore shape: monotonic clock fresh, durable start 2h ago.
started_at: Instant::now(),
started_at_ns: now_ns.saturating_sub(two_hours_ns),
metadata: HashMap::new(),
signals: DashMap::new(),
signaled_entities: DashMap::new(),
annotations: Mutex::new(Vec::new()),
signals_written: AtomicU64::new(0),
signals_rejected: AtomicU64::new(0),
audit_log: Mutex::new(AuditLog::new()),
closed: Arc::new(AtomicBool::new(false)),
};
let err = evaluator
.check("view", &state, now_ns)
.expect_err("a 2h-old session under a 1h cap must be rejected after restore");
assert_eq!(err.kind, PolicyViolationKind::Expired);
}
}

View File

@ -53,6 +53,58 @@ pub struct SessionState {
}
impl SessionState {
/// Construct a fresh `SessionState` with all five empty-state fields
/// (`signals`, `signaled_entities`, `annotations`, the two `AtomicU64`
/// counters, and `audit_log`) initialized to their canonical empty values.
///
/// Both production construction sites — `start_session` (live) and
/// `restore_session_wal_events` (crash restore) — route through this
/// constructor so the 14-field literal is never hand-duplicated and an
/// atomic's initial value can never silently diverge between the two paths.
///
/// The caller supplies the durable identity fields plus the in-process
/// `closed` flag (`start_session` shares it with the returned
/// `SessionHandle`; restore allocates a fresh one).
///
/// # Durable age invariant
///
/// `started_at` is a monotonic [`Instant`] for **live metrics only**. The
/// authoritative session age is `started_at_ns` (nanoseconds since the Unix
/// epoch). On restore the caller must **back-date** `started_at` so that
/// `started_at.elapsed()` and `now.duration_since(started_at)` both reflect
/// the true wall-clock age — the monotonic clock is not durable, so seeding
/// it with a fresh `Instant::now()` would reset every restored session's age
/// to ~zero and silently void TTL / `max_session_duration` enforcement.
#[must_use]
#[allow(clippy::too_many_arguments)]
pub fn new(
id: SessionId,
user_id: u64,
agent_id: AgentId,
policy_name: String,
started_at: Instant,
started_at_ns: u64,
metadata: HashMap<String, String>,
closed: Arc<AtomicBool>,
) -> Self {
Self {
id,
user_id,
agent_id,
policy_name,
started_at,
started_at_ns,
metadata,
signals: DashMap::new(),
signaled_entities: DashMap::new(),
annotations: Mutex::new(Vec::new()),
signals_written: AtomicU64::new(0),
signals_rejected: AtomicU64::new(0),
audit_log: Mutex::new(AuditLog::new()),
closed,
}
}
/// Record that `entity_id` received a signal in this session, enforcing the
/// [`MAX_SIGNALED_ENTITIES`] cap.
///
@ -148,25 +200,48 @@ impl SessionSeqNoTracker {
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use std::sync::atomic::Ordering;
use super::*;
fn empty_state() -> SessionState {
SessionState {
id: SessionId(1),
user_id: 1,
agent_id: AgentId::new("agent-a").unwrap(),
policy_name: "default".to_string(),
started_at: Instant::now(),
started_at_ns: 1,
metadata: HashMap::new(),
signals: DashMap::new(),
signaled_entities: DashMap::new(),
annotations: Mutex::new(Vec::new()),
signals_written: AtomicU64::new(0),
signals_rejected: AtomicU64::new(0),
audit_log: Mutex::new(AuditLog::new()),
closed: Arc::new(AtomicBool::new(false)),
}
SessionState::new(
SessionId(1),
1,
AgentId::new("agent-a").unwrap(),
"default".to_string(),
Instant::now(),
1,
HashMap::new(),
Arc::new(AtomicBool::new(false)),
)
}
/// Regression (WARNING DRY): `SessionState::new` must initialize every
/// empty-state field to its canonical empty value so the two production
/// construction sites cannot silently diverge on a counter's initial value
/// or a missing reset.
#[test]
fn new_initializes_empty_state_fields() {
let state = SessionState::new(
SessionId(9),
42,
AgentId::new("agent-z").unwrap(),
"p".to_string(),
Instant::now(),
123,
HashMap::new(),
Arc::new(AtomicBool::new(false)),
);
assert_eq!(state.id, SessionId(9));
assert_eq!(state.user_id, 42);
assert_eq!(state.started_at_ns, 123);
assert_eq!(state.signals.len(), 0);
assert_eq!(state.signaled_entities.len(), 0);
assert_eq!(state.annotations.lock().unwrap().len(), 0);
assert_eq!(state.signals_written.load(Ordering::Relaxed), 0);
assert_eq!(state.signals_rejected.load(Ordering::Relaxed), 0);
assert!(!state.closed.load(Ordering::Acquire));
}
#[test]

View File

@ -322,12 +322,41 @@ impl SignalLedger {
signal_type_name: &str,
window: Window,
) -> crate::Result<f64> {
let count = self.read_windowed_count(entity_id, signal_type_name, window)?;
// Read the clock once and delegate to the explicit-clock variant, so the
// expired-bucket rotation behind `read_windowed_count` sees a single
// consistent "now" (matching `read_windowed_count` / `read_decay_score`).
let now_ns = Timestamp::now().as_nanos();
self.read_velocity_at(entity_id, signal_type_name, window, now_ns)
}
/// Read the velocity (events per second) against an explicit query clock.
///
/// Identical to [`read_velocity`](Self::read_velocity) but takes the "now"
/// timestamp from the caller instead of reading `Timestamp::now()`
/// internally. The ranking per-candidate loop reads the clock once at the top
/// of a query and reuses it for every candidate, so the whole result set is
/// aged to one consistent query clock (reproducible scores) and one
/// `clock_gettime` syscall is elided per candidate.
///
/// Velocity = `windowed_count(now_ns) / window_duration_seconds`.
/// `AllTime` returns 0.0 (velocity is undefined for unbounded windows).
///
/// # Errors
///
/// - `TidalError::Schema` if `signal_type_name` is not defined
pub fn read_velocity_at(
&self,
entity_id: EntityId,
signal_type_name: &str,
window: Window,
now_ns: u64,
) -> crate::Result<f64> {
let duration_secs = window.duration_secs_f64();
if duration_secs.is_infinite() {
// AllTime window -- velocity is undefined.
// AllTime window -- velocity is undefined. Skip the count read.
return Ok(0.0);
}
let count = self.read_windowed_count_at(entity_id, signal_type_name, window, now_ns)?;
#[allow(clippy::cast_precision_loss)]
Ok(count as f64 / duration_secs)
}

View File

@ -358,6 +358,24 @@ impl BucketedCounter {
/// cause `rotate_minute`/`rotate_hour`/`rotate_day` to index out of bounds on
/// the next rotation. Clamping makes restoration safe regardless of snapshot
/// origin.
///
/// # Rotation-anchor ordering
///
/// The three rotation anchors carry a monotone invariant set by `maybe_rotate`:
/// `last_day_rotation_ns ≤ last_hour_rotation_ns ≤ last_minute_rotation_ns`
/// (a coarser anchor is only ever advanced *inside* a finer rotation that has
/// already advanced its own anchor). The read path relies on it: `sum_current_hour`
/// folds `k = (now_ns last_hour_rotation_ns)/60s` minute buckets, and the
/// write-side `hour_agg`/`day_agg` bounds derive `(last_min last_hour)` and
/// `(last_hour last_day)`. A corrupt snapshot with, e.g.,
/// `last_hour_rotation_ns > last_minute_rotation_ns` would make those
/// `saturating_sub`s clamp to 0 and silently under-count the in-progress
/// window until the next real rotation re-anchors the timestamps. We therefore
/// repair the ordering on restore (pulling each coarser anchor down to its
/// finer neighbor when it is ahead), so a hostile or torn checkpoint cannot
/// produce a wrong windowed count. Pulling *down* (never up) is conservative:
/// it can only widen the in-progress fold by at most one tier, never invent
/// events, and the next `maybe_rotate` re-anchors precisely.
pub fn restore(&self, snapshot: &BucketedCounterSnapshot) {
for (i, &v) in snapshot.minute_buckets.iter().enumerate() {
self.minute_buckets[i].store(v, Ordering::Relaxed);
@ -380,12 +398,21 @@ impl BucketedCounter {
self.current_day.store(current_day, Ordering::Release);
self.all_time_count
.store(snapshot.all_time_count, Ordering::Relaxed);
// Repair the monotone anchor ordering (day ≤ hour ≤ minute) before
// storing, so a corrupt/hostile snapshot cannot drive a coarser anchor
// ahead of a finer one and silently under-count the in-progress window
// (see "Rotation-anchor ordering" above). The minute anchor is the
// authoritative high-water mark; pull each coarser anchor down to it
// when it is ahead.
let last_minute = snapshot.last_minute_rotation_ns;
let last_hour = snapshot.last_hour_rotation_ns.min(last_minute);
let last_day = snapshot.last_day_rotation_ns.min(last_hour);
self.last_minute_rotation_ns
.store(snapshot.last_minute_rotation_ns, Ordering::Relaxed);
.store(last_minute, Ordering::Relaxed);
self.last_hour_rotation_ns
.store(snapshot.last_hour_rotation_ns, Ordering::Relaxed);
self.last_day_rotation_ns
.store(snapshot.last_day_rotation_ns, Ordering::Relaxed);
.store(last_hour, Ordering::Relaxed);
self.last_day_rotation_ns.store(last_day, Ordering::Relaxed);
}
// ── Internal helpers ────────────────────────────────────────────────────────
@ -941,6 +968,80 @@ mod tests {
);
}
#[test]
fn restore_repairs_anchors_with_coarser_ahead_of_finer() {
// Review pass2 (signals): restore() must repair the monotone anchor
// ordering (last_day ≤ last_hour ≤ last_minute). A corrupt snapshot with
// the hour anchor AHEAD of the minute anchor would make sum_current_hour's
// `now_ns.saturating_sub(last_hour)` clamp to 0 and silently under-count
// the in-progress window. We feed exactly that hostile shape and assert
// the stored anchors come out monotone (day ≤ hour ≤ minute), and that a
// windowed read does not panic and is internally consistent.
let hostile = BucketedCounterSnapshot {
minute_buckets: [0; MINUTE_BUCKETS],
hour_buckets: [0; HOUR_BUCKETS],
day_buckets: [0; DAY_BUCKETS],
current_minute: 0,
current_hour: 0,
current_day: 0,
all_time_count: 0,
// Deliberately inverted: hour and day anchors are AHEAD of the minute
// anchor, violating the day ≤ hour ≤ minute invariant maybe_rotate sets.
last_minute_rotation_ns: 10 * NS_PER_MIN,
last_hour_rotation_ns: 100 * NS_PER_HOUR,
last_day_rotation_ns: 50 * NS_PER_DAY,
};
let counter = BucketedCounter::new();
counter.restore(&hostile);
let restored_minute = counter.last_minute_rotation_ns.load(Ordering::Relaxed);
let restored_hour = counter.last_hour_rotation_ns.load(Ordering::Relaxed);
let restored_day = counter.last_day_rotation_ns.load(Ordering::Relaxed);
// Minute is the authoritative high-water mark — preserved verbatim.
assert_eq!(restored_minute, 10 * NS_PER_MIN);
// Coarser anchors are pulled DOWN to satisfy day ≤ hour ≤ minute.
assert!(
restored_day <= restored_hour && restored_hour <= restored_minute,
"anchors must be monotone after restore: day={restored_day} \
hour={restored_hour} minute={restored_minute}"
);
// A windowed read at a clock just past the (repaired) minute anchor must
// not under-flow or panic; with empty buckets it is simply 0.
let read_ns = 11 * NS_PER_MIN;
assert_eq!(counter.windowed_count(Window::OneHour, read_ns), 0);
assert_eq!(counter.windowed_count(Window::TwentyFourHours, read_ns), 0);
}
#[test]
fn restore_preserves_already_monotone_anchors() {
// A well-formed snapshot (day ≤ hour ≤ minute) must round-trip unchanged —
// the repair only ever pulls a coarser anchor DOWN, never perturbs a valid
// ordering.
let mut snapshot = BucketedCounter::with_start_time(0).snapshot();
snapshot.last_day_rotation_ns = 2 * NS_PER_DAY;
snapshot.last_hour_rotation_ns = 50 * NS_PER_HOUR;
snapshot.last_minute_rotation_ns = 3001 * NS_PER_MIN; // > 50h, > 2d
let counter = BucketedCounter::new();
counter.restore(&snapshot);
assert_eq!(
counter.last_minute_rotation_ns.load(Ordering::Relaxed),
3001 * NS_PER_MIN
);
assert_eq!(
counter.last_hour_rotation_ns.load(Ordering::Relaxed),
50 * NS_PER_HOUR
);
assert_eq!(
counter.last_day_rotation_ns.load(Ordering::Relaxed),
2 * NS_PER_DAY
);
}
#[test]
fn quiet_entity_windowed_count_decays_to_zero_on_read() {
// CRITICAL #6 regression: a read with an advanced clock and NO intervening

View File

@ -51,7 +51,7 @@ pub trait StorageEngine: Send + Sync {
///
/// # Errors
///
/// Returns `StorageError` on I/O failure or batch conflict.
/// Returns `StorageError` on I/O failure.
fn write_batch(&self, batch: WriteBatch) -> Result<(), StorageError>;
/// Force all buffered data to stable storage.

View File

@ -13,9 +13,6 @@ pub enum StorageError {
/// The storage engine has been closed and cannot service requests.
#[error("storage closed")]
Closed,
/// A batch write conflicted with a concurrent operation.
#[error("batch conflict")]
BatchConflict,
}
#[cfg(test)]
@ -46,11 +43,6 @@ mod tests {
assert_eq!(StorageError::Closed.to_string(), "storage closed");
}
#[test]
fn display_batch_conflict() {
assert_eq!(StorageError::BatchConflict.to_string(), "batch conflict");
}
#[test]
fn from_io_error() {
let io_err = std::io::Error::other("disk full");

View File

@ -118,6 +118,12 @@ fn map_fjall_err(e: fjall::Error) -> StorageError {
}
impl StorageEngine for FjallBackend {
// CODING_GUIDELINES §11.2 names these the non-negotiable storage stage
// boundaries to instrument. The spans skip key/value bytes (only their
// lengths are recorded) so a 3am operator can attribute latency to a
// specific storage op — a hot `get`, a full-keyspace `scan_prefix`, the
// atomic-commit barrier, or the fsync — without logging payloads.
#[tracing::instrument(level = "trace", skip(self), fields(key_len = key.len()))]
fn get(&self, key: &[u8]) -> Result<Option<Vec<u8>>, StorageError> {
Ok(self
.keyspace
@ -126,14 +132,21 @@ impl StorageEngine for FjallBackend {
.map(|value| value.to_vec()))
}
#[tracing::instrument(
level = "trace",
skip(self, value),
fields(key_len = key.len(), value_len = value.len())
)]
fn put(&self, key: &[u8], value: &[u8]) -> Result<(), StorageError> {
self.keyspace.insert(key, value).map_err(map_fjall_err)
}
#[tracing::instrument(level = "trace", skip(self), fields(key_len = key.len()))]
fn delete(&self, key: &[u8]) -> Result<(), StorageError> {
self.keyspace.remove(key).map_err(map_fjall_err)
}
#[tracing::instrument(level = "debug", skip(self), fields(prefix_len = prefix.len()))]
fn scan_prefix(&self, prefix: &[u8]) -> PrefixIterator<'_> {
// Stream lazily — do NOT collect the keyspace into RAM. `fjall::Iter`
// is `'static`: it owns the snapshot `SnapshotNonce` internally (which
@ -152,6 +165,7 @@ impl StorageEngine for FjallBackend {
Box::new(iter)
}
#[tracing::instrument(level = "debug", skip(self, batch), fields(ops = batch.len()))]
fn write_batch(&self, batch: WriteBatch) -> Result<(), StorageError> {
// Atomicity: the `StorageEngine::write_batch` contract requires
// all-or-nothing semantics. Looping over `keyspace.insert`/`remove`
@ -183,6 +197,7 @@ impl StorageEngine for FjallBackend {
wb.commit().map_err(map_fjall_err)
}
#[tracing::instrument(level = "debug", skip(self))]
fn flush(&self) -> Result<(), StorageError> {
// `StorageEngine::flush` documents "force all buffered data to stable
// storage", which means fsync -- not merely rotating the memtable.
@ -264,11 +279,16 @@ impl FjallStorage {
///
/// Returns `StorageError` if the underlying fjall database cannot be opened.
pub fn open(path: impl AsRef<Path>) -> Result<Self, StorageError> {
// Route through `map_fjall_err` so an open failure keeps its operational
// class: a permission-denied or disk-full (`Io`) or a held/poisoned lock
// (`Closed`) is NOT stringified into a corruption message. Only a genuine
// decode/checksum/trailer failure is classified as `Corruption` — the
// exact mis-classification `map_fjall_err` exists to prevent. (The lost
// path context is acceptable: the io::Error carries the OS error, and the
// open span/caller already names which database failed.)
let db = fjall::Database::builder(path)
.open()
.map_err(|e| StorageError::Corruption {
message: format!("failed to open fjall database: {e}"),
})?;
.map_err(map_fjall_err)?;
let items = Self::open_keyspace(&db, "items")?;
let users = Self::open_keyspace(&db, "users")?;
@ -285,14 +305,15 @@ impl FjallStorage {
/// Open (or create) one named keyspace and wrap it in a `FjallBackend`.
///
/// Extracted from [`open`](Self::open) so the three entity keyspaces share a
/// single open path: identical create-options and a corruption error tagged
/// with the keyspace name, rather than three verbatim copies that could drift.
/// single open path with identical create-options, rather than three verbatim
/// copies that could drift. A keyspace-open failure is routed through
/// [`map_fjall_err`] so its operational class (I/O / closed / corruption) is
/// preserved exactly as on the get/put path, not collapsed into a corruption
/// message regardless of cause.
fn open_keyspace(db: &fjall::Database, name: &str) -> Result<FjallBackend, StorageError> {
let keyspace = db
.keyspace(name, fjall::KeyspaceCreateOptions::default)
.map_err(|e| StorageError::Corruption {
message: format!("failed to open {name} keyspace: {e}"),
})?;
.map_err(map_fjall_err)?;
Ok(FjallBackend::new(keyspace, db.clone()))
}
@ -470,6 +491,57 @@ mod tests {
assert_eq!(keys, vec![b"a".to_vec(), b"b".to_vec(), b"c".to_vec()]);
}
#[test]
fn scan_prefix_propagates_mid_stream_error_via_collect() {
// The streaming adapter maps each fjall guard through
// `into_inner().map_err(map_fjall_err)?`, so a torn read / decompress
// failure surfaces as an `Err(StorageError)` ITEM partway through
// iteration rather than a panic. We cannot inject a torn read into fjall
// in a fast unit test, so we pin the contract every caller relies on with
// a synthetic `PrefixIterator` of the exact same shape: one good entry,
// then an injected mid-stream Err, then more.
let iter: super::PrefixIterator<'static> = Box::new(
[
Ok((b"a".to_vec(), b"1".to_vec())),
Err(StorageError::Corruption {
message: "torn read mid-stream".into(),
}),
Ok((b"b".to_vec(), b"2".to_vec())),
]
.into_iter(),
);
// `collect::<Result<Vec>>` (what scan_prefix tests and the careful
// callers use) MUST surface the Err and short-circuit — it must not be
// silently dropped.
let collected: Result<Vec<_>, _> = iter.collect();
assert!(
matches!(collected, Err(StorageError::Corruption { .. })),
"mid-stream Err must propagate through collect::<Result<Vec>>"
);
}
#[test]
fn scan_prefix_flatten_silently_swallows_error() {
// Counterpart hazard documented in the review: `.flatten()` over the same
// `Result`-yielding adapter DROPS an Err item. This test exists so the
// swallowing behavior is explicit and a future caller cannot
// accidentally rely on `.flatten()` for a path where the Err matters.
let iter: super::PrefixIterator<'static> = Box::new(
[
Ok((b"a".to_vec(), b"1".to_vec())),
Err(StorageError::Corruption {
message: "torn read mid-stream".into(),
}),
Ok((b"b".to_vec(), b"2".to_vec())),
]
.into_iter(),
);
// `.flatten()` yields only the Ok payloads; the Err vanishes.
let kept: Vec<_> = iter.flatten().collect();
assert_eq!(kept.len(), 2, ".flatten() drops the mid-stream Err item");
}
#[test]
fn scan_prefix_is_lazy() {
// The streaming adapter must yield items on demand: taking only the

View File

@ -63,6 +63,15 @@ pub const INDEX_ROOT_ID: u64 = 0;
pub struct BitmapIndex {
field_name: String,
values: Arc<RwLock<HashMap<String, RoaringBitmap>>>,
/// Cached distinct-entity union cardinality, invalidated on every mutation.
///
/// `total_count()` unions every value bitmap — `O(distinct_values *
/// avg_bitmap_size)`. Memoizing it makes the planner's repeated
/// per-predicate reads between writes O(1). Shared via `Arc` so a cloned
/// handle (which shares `values`) also shares the memo. It is a read-side
/// memo of state already in `values`, never a source of truth: a missed
/// invalidation would only force a recompute, never return a wrong count.
total_count_cache: Arc<RwLock<Option<u64>>>,
}
impl Clone for BitmapIndex {
@ -70,6 +79,7 @@ impl Clone for BitmapIndex {
Self {
field_name: self.field_name.clone(),
values: Arc::clone(&self.values),
total_count_cache: Arc::clone(&self.total_count_cache),
}
}
}
@ -81,9 +91,18 @@ impl BitmapIndex {
Self {
field_name: field_name.into(),
values: Arc::new(RwLock::new(HashMap::new())),
total_count_cache: Arc::new(RwLock::new(Some(0))),
}
}
/// Invalidate the memoized total-count union after a mutation.
fn invalidate_total_count(&self) {
*self
.total_count_cache
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = None;
}
/// The field name this index covers.
#[must_use]
pub fn field_name(&self) -> &str {
@ -101,6 +120,9 @@ impl BitmapIndex {
let bitmap = map.entry(value.into()).or_default();
let inserted = bitmap.insert(entity_id);
drop(map);
if inserted {
self.invalidate_total_count();
}
inserted
}
@ -124,6 +146,10 @@ impl BitmapIndex {
if now_empty {
map.remove(value);
}
drop(map);
if removed {
self.invalidate_total_count();
}
removed
}
@ -148,6 +174,10 @@ impl BitmapIndex {
for key in empty_keys {
map.remove(&key);
}
drop(map);
if count > 0 {
self.invalidate_total_count();
}
count
}
@ -192,8 +222,23 @@ impl BitmapIndex {
}
/// Total number of distinct entity IDs across all values (no double-counting).
///
/// Memoized: the union over every value bitmap is computed at most once per
/// mutation, so repeated reads between writes are O(1).
#[must_use]
pub fn total_count(&self) -> u64 {
// Fast path: return the memoized cardinality if still valid. Read the
// cache into a local and drop the guard before branching so the cache
// lock is never held across the slow-path values read.
let cached = *self
.total_count_cache
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(count) = cached {
return count;
}
// Slow path: recompute the union once and memoize it. Compute under the
// values read lock so the counted snapshot is internally consistent.
let map = self
.values
.read()
@ -203,7 +248,12 @@ impl BitmapIndex {
union |= bitmap;
}
drop(map);
union.len()
let count = union.len();
*self
.total_count_cache
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(count);
count
}
/// Enumerate all indexed field values.
@ -375,6 +425,8 @@ impl BitmapIndex {
Ok(Self {
field_name,
values: Arc::new(RwLock::new(map)),
// Loaded fresh; force a recompute on first read.
total_count_cache: Arc::new(RwLock::new(None)),
})
}
}

View File

@ -61,10 +61,22 @@ impl<'a> FilterEvaluator<'a> {
/// Stage 2.5 (user-context filtering).
#[must_use]
pub fn evaluate(&self, expr: &FilterExpr) -> FilterResult {
FilterResult::Bitmap(self.eval_to_bitmap(expr))
FilterResult::Bitmap(self.eval_to_bitmap_bounded(expr, 0))
}
fn eval_to_bitmap(&self, expr: &FilterExpr) -> RoaringBitmap {
/// Depth-bounded evaluation. Past [`FilterExpr::MAX_FILTER_DEPTH`] we return
/// an empty bitmap rather than recursing further: a filter too deep to
/// evaluate matches nothing (a safe degradation that never panics or
/// overflows the stack). `Db::retrieve` rejects such trees up front via the
/// breadth guard, but `FilterEvaluator` is public, so it defends itself.
fn eval_to_bitmap_bounded(&self, expr: &FilterExpr, depth: usize) -> RoaringBitmap {
if depth >= FilterExpr::MAX_FILTER_DEPTH {
return RoaringBitmap::new();
}
self.eval_to_bitmap(expr, depth)
}
fn eval_to_bitmap(&self, expr: &FilterExpr, depth: usize) -> RoaringBitmap {
match expr {
FilterExpr::CategoryEq(v) => self.category_index.get(v).unwrap_or_default(),
FilterExpr::FormatEq(v) => self.format_index.get(v).unwrap_or_default(),
@ -76,9 +88,9 @@ impl<'a> FilterEvaluator<'a> {
FilterExpr::DurationMax(secs) => self.duration_index.lte(secs),
FilterExpr::CreatedAfter(ns) => self.created_at_index.gt(ns),
FilterExpr::CreatedBefore(ns) => self.created_at_index.lt(ns),
FilterExpr::And(children) => self.evaluate_and(children),
FilterExpr::Or(children) => self.evaluate_or(children),
FilterExpr::Not(inner) => self.evaluate_not(inner),
FilterExpr::And(children) => self.evaluate_and(children, depth),
FilterExpr::Or(children) => self.evaluate_or(children, depth),
FilterExpr::Not(inner) => self.evaluate_not(inner, depth),
// User-state filters: these are evaluated at the FilterEvaluator
// level by returning the full universe -- the actual filtering
// happens in the query executor's Stage 2.5 (user-context filtering)
@ -100,7 +112,7 @@ impl<'a> FilterEvaluator<'a> {
}
}
fn evaluate_and(&self, children: &[FilterExpr]) -> RoaringBitmap {
fn evaluate_and(&self, children: &[FilterExpr], depth: usize) -> RoaringBitmap {
if children.is_empty() {
// AND of nothing = everything (identity element for intersection)
return self.universe.clone();
@ -114,8 +126,10 @@ impl<'a> FilterEvaluator<'a> {
// that walked each subtree twice. Sorting the materialized bitmaps by
// `len()` gives *exact* (not estimated) ordering for the short-circuit
// while walking every subtree only once.
let mut bitmaps: Vec<RoaringBitmap> =
children.iter().map(|c| self.eval_to_bitmap(c)).collect();
let mut bitmaps: Vec<RoaringBitmap> = children
.iter()
.map(|c| self.eval_to_bitmap_bounded(c, depth + 1))
.collect();
bitmaps.sort_unstable_by_key(RoaringBitmap::len);
// bitmaps is non-empty (children is non-empty), so the first element
@ -132,13 +146,13 @@ impl<'a> FilterEvaluator<'a> {
result
}
fn evaluate_or(&self, children: &[FilterExpr]) -> RoaringBitmap {
fn evaluate_or(&self, children: &[FilterExpr], depth: usize) -> RoaringBitmap {
if children.is_empty() {
return RoaringBitmap::new();
}
let mut result = RoaringBitmap::new();
for child in children {
result |= &self.eval_to_bitmap(child);
result |= &self.eval_to_bitmap_bounded(child, depth + 1);
}
result
}
@ -156,8 +170,8 @@ impl<'a> FilterEvaluator<'a> {
/// - Consequently `NOT(x)` ranges over the same id set every other filter
/// node ranges over, keeping NOT composable under AND/OR without ever
/// re-introducing out-of-universe ids.
fn evaluate_not(&self, inner: &FilterExpr) -> RoaringBitmap {
let inner_bitmap = self.eval_to_bitmap(inner);
fn evaluate_not(&self, inner: &FilterExpr, depth: usize) -> RoaringBitmap {
let inner_bitmap = self.eval_to_bitmap_bounded(inner, depth + 1);
let mut complement = self.universe.clone();
complement -= &inner_bitmap;
complement
@ -169,6 +183,18 @@ impl<'a> FilterEvaluator<'a> {
/// independence assumption for compound predicates.
#[must_use]
pub fn selectivity(&self, expr: &FilterExpr) -> f64 {
self.selectivity_bounded(expr, 0)
}
/// Depth-bounded selectivity estimate. Past [`FilterExpr::MAX_FILTER_DEPTH`]
/// we stop descending and return `1.0` (the conservative "matches all"
/// estimate, the identity for AND's product), so a pathologically deep AST
/// cannot drive the estimator into unbounded recursion. The estimate only
/// orders predicates; a clamped tail estimate cannot produce a wrong result.
fn selectivity_bounded(&self, expr: &FilterExpr, depth: usize) -> f64 {
if depth >= FilterExpr::MAX_FILTER_DEPTH {
return 1.0;
}
#[allow(clippy::cast_precision_loss)]
let total = self.universe.len() as f64;
if total == 0.0 {
@ -207,17 +233,21 @@ impl<'a> FilterEvaluator<'a> {
// Independence assumption: P(A AND B) ~ P(A) * P(B)
children
.iter()
.map(|c| self.selectivity(c))
.map(|c| self.selectivity_bounded(c, depth + 1))
.product::<f64>()
.clamp(0.0, 1.0)
}
FilterExpr::Or(children) => {
// Inclusion-exclusion: P(A OR B) ~ 1 - (1-P(A))(1-P(B))
let complement_product: f64 =
children.iter().map(|c| 1.0 - self.selectivity(c)).product();
let complement_product: f64 = children
.iter()
.map(|c| 1.0 - self.selectivity_bounded(c, depth + 1))
.product();
(1.0 - complement_product).clamp(0.0, 1.0)
}
FilterExpr::Not(inner) => (1.0 - self.selectivity(inner)).clamp(0.0, 1.0),
FilterExpr::Not(inner) => {
(1.0 - self.selectivity_bounded(inner, depth + 1)).clamp(0.0, 1.0)
}
// User-state filters: selectivity is unknown without user state.
// Estimate 0.9 for Unseen (most items are unseen for most users),
// 0.95 for Unblocked, and low for Saved/Liked/InProgress.
@ -436,6 +466,39 @@ mod tests {
assert_eq!(result.is_empty(), Some(true));
}
#[test]
fn deep_filter_evaluation_is_depth_bounded() {
// FilterEvaluator is public, so it must defend itself against a
// pathologically deep AST: evaluating a `Not(Not(...))` chain far past
// MAX_FILTER_DEPTH must return (a bitmap) without panicking or
// overflowing the stack — the bounded recursion stops at the cap.
let (cat, fmt, creator, tags, dur, ts, universe) = setup_test_indexes();
let ev = make_evaluator_full(&cat, &fmt, &creator, &tags, &dur, &ts, &universe);
let mut expr = FilterExpr::CategoryEq("jazz".into());
for _ in 0..(FilterExpr::MAX_FILTER_DEPTH + 50) {
expr = FilterExpr::Not(Box::new(expr));
}
// Must complete (bounded recursion); we only assert it does not panic.
let _ = ev.evaluate(&expr).into_bitmap();
// selectivity recurses identically and must also stay bounded.
let sel = ev.selectivity(&expr);
assert!((0.0..=1.0).contains(&sel), "selectivity {sel} out of range");
}
#[test]
fn shallow_not_evaluation_unaffected_by_depth_bound() {
// A single Not (depth 1) is well under the cap and must still produce the
// exact complement — the depth guard never alters a normal filter.
let (cat, fmt, creator, tags, dur, ts, universe) = setup_test_indexes();
let ev = make_evaluator_full(&cat, &fmt, &creator, &tags, &dur, &ts, &universe);
let result = ev
.evaluate(&FilterExpr::Not(Box::new(FilterExpr::CreatorEq(0))))
.into_bitmap();
// Same expectation as evaluate_not_creator: creator 0 = {0,3,6,9}.
assert_eq!(result.len(), 6);
}
#[test]
fn selectivity_empty_universe() {
let cat = BitmapIndex::new("category");

View File

@ -148,17 +148,54 @@ impl FilterExpr {
Self::InCollection(id)
}
/// Maximum recursion depth `node_count` and `FilterEvaluator` will descend.
///
/// A tree with at most [`MAX_FILTER_NODES`](Self::MAX_FILTER_NODES) nodes can
/// be at most that deep (a degenerate `Not(Not(...))` chain), so bounding the
/// descent at this value never rejects a tree the breadth guard would accept,
/// while a pathologically deeper hand-built AST is rejected *before* it can
/// recurse far enough to overflow the stack. Counting and evaluation both
/// honor it so neither can be driven into unbounded recursion.
pub const MAX_FILTER_DEPTH: usize = 256;
/// Breadth guard: the maximum total node count a filter may carry.
///
/// Mirrored by the query entrypoint (`Db::retrieve`), kept here so the
/// node-count saturation sentinel and the caller's rejection threshold come
/// from one definition.
pub const MAX_FILTER_NODES: usize = 256;
/// Returns the total number of nodes in this filter expression tree.
///
/// Used to guard against deeply nested or oversized filter expressions
/// that could cause excessive recursion or memory usage during evaluation.
///
/// Counting is itself **depth-bounded**: a tree deeper than
/// [`MAX_FILTER_DEPTH`](Self::MAX_FILTER_DEPTH) short-circuits to a count of
/// `usize::MAX` rather than recursing the full depth first. This guarantees
/// the breadth guard that consumes this count (`count > MAX_FILTER_NODES`)
/// rejects a pathologically deep `Not(Not(...))` chain *before* the count
/// itself can overflow the stack — closing the gap where the breadth guard
/// depended on a full deep recursion to even compute the number.
#[must_use]
pub fn node_count(&self) -> usize {
self.node_count_bounded(0)
}
/// Depth-bounded node counter. `depth` is the current recursion depth; once
/// it exceeds [`MAX_FILTER_DEPTH`](Self::MAX_FILTER_DEPTH) we stop descending
/// and return `usize::MAX` so the breadth guard trivially rejects the tree.
/// Sums saturate so an over-wide tree also cannot wrap.
fn node_count_bounded(&self, depth: usize) -> usize {
if depth >= Self::MAX_FILTER_DEPTH {
return usize::MAX;
}
match self {
Self::And(children) | Self::Or(children) => {
1 + children.iter().map(Self::node_count).sum::<usize>()
}
Self::Not(inner) => 1 + inner.node_count(),
Self::And(children) | Self::Or(children) => children
.iter()
.map(|c| c.node_count_bounded(depth + 1))
.fold(1usize, usize::saturating_add),
Self::Not(inner) => 1usize.saturating_add(inner.node_count_bounded(depth + 1)),
_ => 1,
}
}
@ -285,6 +322,41 @@ mod tests {
assert_eq!(filter.node_count(), 6);
}
#[test]
fn node_count_deep_chain_is_depth_bounded() {
// A `Not(Not(...))` chain far deeper than MAX_FILTER_DEPTH must be
// counted WITHOUT recursing the full depth: node_count_bounded
// short-circuits to usize::MAX past the depth cap, so the breadth guard
// (`count > MAX_FILTER_NODES`) rejects it before a deep recursion can
// overflow the stack. Build a chain a few thousand deep to prove the
// bound holds well past the node cap.
let mut expr = FilterExpr::CategoryEq("leaf".into());
for _ in 0..5_000 {
expr = FilterExpr::Not(Box::new(expr));
}
let count = expr.node_count();
assert!(
count > FilterExpr::MAX_FILTER_NODES,
"deep chain must report a count exceeding the breadth cap, got {count}"
);
// Shallow-but-wide trees are unaffected: their depth never trips the cap,
// so the exact count is preserved (see node_count_large_flat_and).
}
#[test]
fn node_count_at_depth_limit_does_not_saturate_spuriously() {
// A chain exactly at the boundary still counts truthfully: a Not chain of
// depth just under MAX_FILTER_DEPTH returns its real (finite) node count,
// proving the cap does not reject trees the breadth guard would accept.
let mut expr = FilterExpr::CategoryEq("leaf".into());
for _ in 0..(FilterExpr::MAX_FILTER_DEPTH - 2) {
expr = FilterExpr::Not(Box::new(expr));
}
let count = expr.node_count();
assert_ne!(count, usize::MAX, "below the depth cap must not saturate");
assert_eq!(count, FilterExpr::MAX_FILTER_DEPTH - 1);
}
#[test]
fn node_count_large_flat_and() {
let children: Vec<FilterExpr> = (0..300)

View File

@ -31,6 +31,17 @@ type KvPairs = Vec<(Vec<u8>, Vec<u8>)>;
pub struct RangeIndex<V: Ord + Clone> {
field_name: String,
tree: RwLock<BTreeMap<V, RoaringBitmap>>,
/// Cached distinct-entity union cardinality, invalidated on every mutation.
///
/// `total_count()` unions every value bitmap — `O(distinct_values *
/// avg_bitmap_size)`. The query planner calls it (directly and via
/// `selectivity`) once per range predicate while ordering AND/OR groups, so
/// a multi-predicate query re-walked all value bitmaps repeatedly between
/// writes. The cache makes those repeated reads O(1): writes set it to
/// `None`, the next read recomputes once and memoizes. It is a read-side
/// memo of state already in `tree`, never an independent source of truth, so
/// it cannot drift — a missed invalidation would only force a recompute.
total_count_cache: RwLock<Option<u64>>,
}
impl<V: Ord + Clone> RangeIndex<V> {
@ -40,9 +51,18 @@ impl<V: Ord + Clone> RangeIndex<V> {
Self {
field_name: field_name.into(),
tree: RwLock::new(BTreeMap::new()),
total_count_cache: RwLock::new(Some(0)),
}
}
/// Invalidate the memoized total-count union after a mutation.
fn invalidate_total_count(&self) {
*self
.total_count_cache
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = None;
}
/// The field name this index covers.
#[must_use]
pub fn field_name(&self) -> &str {
@ -58,6 +78,7 @@ impl<V: Ord + Clone> RangeIndex<V> {
let bitmap = tree.entry(value).or_default();
bitmap.insert(entity_id);
drop(tree);
self.invalidate_total_count();
}
/// Remove an entity from the bitmap at the given value.
@ -80,6 +101,10 @@ impl<V: Ord + Clone> RangeIndex<V> {
if now_empty {
tree.remove(value);
}
drop(tree);
if removed {
self.invalidate_total_count();
}
removed
}
@ -111,6 +136,10 @@ impl<V: Ord + Clone> RangeIndex<V> {
for key in empty_keys {
tree.remove(&key);
}
drop(tree);
if count > 0 {
self.invalidate_total_count();
}
count
}
@ -203,8 +232,24 @@ impl<V: Ord + Clone> RangeIndex<V> {
}
/// Total distinct entity IDs indexed.
///
/// Memoized: the union over every value bitmap is computed at most once per
/// mutation. Repeated reads between writes (the planner's per-predicate
/// selectivity ordering) are O(1).
#[must_use]
pub fn total_count(&self) -> u64 {
// Fast path: return the memoized cardinality if still valid. Read the
// cache into a local and drop the guard before branching so the cache
// lock is never held across the slow-path tree read.
let cached = *self
.total_count_cache
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(count) = cached {
return count;
}
// Slow path: recompute the union once and memoize it. Recompute under the
// tree read lock so the snapshot we count is internally consistent.
let tree = self
.tree
.read()
@ -214,7 +259,12 @@ impl<V: Ord + Clone> RangeIndex<V> {
union |= bitmap;
}
drop(tree);
union.len()
let count = union.len();
*self
.total_count_cache
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(count);
count
}
/// Number of distinct attribute values in the tree.
@ -333,8 +383,9 @@ impl<V: RangeKeyCodec> RangeIndex<V> {
///
/// # Errors
///
/// Returns `IndexError::Serialization` if deserialization fails (truncated
/// value bytes or a corrupt serialized bitmap).
/// Returns `IndexError::Serialization` if a matching key's value field is not
/// exactly `V::WIDTH` bytes (truncated *or* carrying trailing bytes) or if a
/// serialized bitmap is corrupt.
pub fn load_from_kv_pairs(
field_name: impl Into<String>,
pairs: impl Iterator<Item = (Vec<u8>, Vec<u8>)>,
@ -353,10 +404,19 @@ impl<V: RangeKeyCodec> RangeIndex<V> {
continue;
}
let value_raw = &suffix_raw[prefix_bytes.len()..];
if value_raw.len() < V::WIDTH {
// A well-formed range key carries EXACTLY `V::WIDTH` value bytes.
// Reject both truncation (too few) and trailing garbage (too many):
// `from_be_key_bytes` reads only `raw[..WIDTH]` and would silently
// discard a remainder, so two adversarial keys sharing the first
// WIDTH value bytes but differing in their tail would collapse onto
// the same tree entry (last-write-wins), masking corruption. Require
// an exact width so a malformed key surfaces as an error instead.
if value_raw.len() != V::WIDTH {
return Err(IndexError::Serialization(format!(
"truncated {} value in key",
V::TYPE_NAME
"malformed {} value in key: expected exactly {} bytes, got {}",
V::TYPE_NAME,
V::WIDTH,
value_raw.len()
)));
}
let value = V::from_be_key_bytes(value_raw);
@ -370,6 +430,8 @@ impl<V: RangeKeyCodec> RangeIndex<V> {
Ok(Self {
field_name,
tree: RwLock::new(tree),
// Loaded fresh; force a recompute on first read.
total_count_cache: RwLock::new(None),
})
}
}
@ -535,6 +597,57 @@ mod tests {
assert!(inverted.is_empty());
}
#[test]
fn load_rejects_trailing_bytes_after_fixed_width_value() {
// A valid range key carries exactly V::WIDTH value bytes. A key with the
// same WIDTH-byte prefix but extra trailing bytes must be rejected, not
// silently truncated onto the same tree entry (which would mask
// corruption / let two distinct keys collide last-write-wins).
let index: RangeIndex<u32> = RangeIndex::new("duration");
index.insert(1, 300);
let pairs = index.serialize_to_kv_pairs().unwrap();
assert_eq!(pairs.len(), 1);
let (mut key, value) = pairs.into_iter().next().unwrap();
// Append a trailing byte after the 4-byte u32 value.
key.push(0xFF);
let result =
RangeIndex::<u32>::load_from_kv_pairs("duration", std::iter::once((key, value)));
let is_serialization_err =
matches!(&result, Err(IndexError::Serialization(m)) if m.contains("expected exactly"));
assert!(
is_serialization_err,
"trailing bytes after a fixed-width value must be a Serialization error (got Ok or wrong variant)"
);
}
#[test]
fn total_count_cache_tracks_inserts_and_deletes() {
// The memoized total_count must agree with the freshly-recomputed union
// across a full insert/delete cycle, proving cache invalidation is wired
// on every mutation (insert, delete, delete_entity).
let index: RangeIndex<u32> = RangeIndex::new("duration");
assert_eq!(index.total_count(), 0); // primed-empty cache
index.insert(1, 60);
index.insert(2, 120);
index.insert(3, 120);
assert_eq!(index.total_count(), 3); // recomputed after invalidation
assert_eq!(index.total_count(), 3); // memoized fast path agrees
// Re-inserting the same id at a new value does not change the distinct
// count (it is still one entity, now under two values until scrubbed).
index.insert(1, 600);
assert_eq!(index.total_count(), 3);
// Deleting scrubs the id; the count drops only when no value still holds it.
index.delete_entity(1);
assert_eq!(index.total_count(), 2);
index.delete(2, &120);
assert_eq!(index.total_count(), 1);
index.delete(3, &120);
assert_eq!(index.total_count(), 0);
}
#[test]
fn delete_cleans_up_empty_entries() {
let index: RangeIndex<u32> = RangeIndex::new("duration");

View File

@ -38,11 +38,20 @@ impl std::fmt::Debug for InMemoryBackend {
}
impl StorageEngine for InMemoryBackend {
// Mirror the FjallBackend instrumentation (CODING_GUIDELINES §11.2) so the
// testing backend produces the same storage-stage spans as production —
// payload bytes are skipped, only lengths/op-counts are recorded.
#[tracing::instrument(level = "trace", skip(self), fields(key_len = key.len()))]
fn get(&self, key: &[u8]) -> Result<Option<Vec<u8>>, StorageError> {
let data = self.data.read().map_err(|_| StorageError::Closed)?;
Ok(data.get(key).cloned())
}
#[tracing::instrument(
level = "trace",
skip(self, value),
fields(key_len = key.len(), value_len = value.len())
)]
fn put(&self, key: &[u8], value: &[u8]) -> Result<(), StorageError> {
self.data
.write()
@ -51,6 +60,7 @@ impl StorageEngine for InMemoryBackend {
Ok(())
}
#[tracing::instrument(level = "trace", skip(self), fields(key_len = key.len()))]
fn delete(&self, key: &[u8]) -> Result<(), StorageError> {
self.data
.write()
@ -73,6 +83,7 @@ impl StorageEngine for InMemoryBackend {
/// production restart paths use the streaming `FjallBackend`.
///
/// [`FjallBackend::scan_prefix`]: crate::storage::fjall::FjallBackend
#[tracing::instrument(level = "debug", skip(self), fields(prefix_len = prefix.len()))]
fn scan_prefix(&self, prefix: &[u8]) -> PrefixIterator<'_> {
let Ok(data) = self.data.read() else {
return Box::new(std::iter::once(Err(StorageError::Closed)));
@ -94,6 +105,7 @@ impl StorageEngine for InMemoryBackend {
Box::new(entries.into_iter().map(Ok))
}
#[tracing::instrument(level = "debug", skip(self, batch), fields(ops = batch.len()))]
fn write_batch(&self, batch: WriteBatch) -> Result<(), StorageError> {
let mut data = self.data.write().map_err(|_| StorageError::Closed)?;
for op in &batch.ops {
@ -110,6 +122,7 @@ impl StorageEngine for InMemoryBackend {
Ok(())
}
#[tracing::instrument(level = "trace", skip(self))]
fn flush(&self) -> Result<(), StorageError> {
// No-op for in-memory backend.
Ok(())

View File

@ -11,9 +11,83 @@
use std::collections::HashMap;
use super::{QuantizationLevel, VectorError, VectorIndex};
use super::{QuantizationLevel, VectorError, VectorIndex, VectorIndexConfig};
use crate::schema::EntityKind;
// ---------------------------------------------------------------------------
// Vector backend selection (production HNSW vs. brute-force)
// ---------------------------------------------------------------------------
/// Minimum live-vector count for a slot before the production HNSW engine
/// (`UsearchIndex`) is preferred over the exact `BruteForceIndex`.
///
/// `BruteForceIndex` computes an L2 distance against every stored vector under
/// an `RwLock` read on each search — exact, but O(n) per query and serialized
/// against writes. It is the right choice for tiny slots (no graph-build cost,
/// exact results) but blows past the latency budget at scale. `UsearchIndex`
/// (HNSW) is the mandated production engine (`CODING_GUIDELINES` §4: "126K+ QPS",
/// §8: "ANN retrieval at 1M vectors <10ms p99"). Gating on slot size needs NO
/// external config plumbing: at process restart, `rebuild_from_store` already
/// knows each slot's vector count, so a slot that crossed this threshold is
/// rebuilt as HNSW automatically, while a small slot stays exact. The threshold
/// is deliberately conservative — well inside the range where brute force is
/// still fast — so correctness-sensitive small slots keep exact results.
pub(crate) const USEARCH_MIN_VECTORS: usize = 10_000;
/// Build the vector index backend for a slot, gating on the expected vector
/// `count`: `UsearchIndex` (production HNSW) at or above [`USEARCH_MIN_VECTORS`],
/// `BruteForceIndex` (exact) below it.
///
/// When the `USearch` backend is selected, capacity is reserved up front
/// (`UsearchIndex::add` requires a prior `reserve`), so the subsequent inserts
/// cannot fail for lack of capacity. If `UsearchIndex` construction OR the
/// reservation fails for any
/// reason, this DEGRADES to `BruteForceIndex` with a `tracing::warn!` rather than
/// propagating — losing the latency win is acceptable; losing the slot entirely
/// (and therefore all ANN results for the kind) is not. This is the single place
/// that picks the engine; the write-path auto-register and the rebuild path both
/// route through it so the choice cannot drift.
pub(crate) fn build_slot_index(dimensions: usize, count: usize) -> Box<dyn VectorIndex> {
let config = VectorIndexConfig {
dimensions,
..VectorIndexConfig::default()
};
if count < USEARCH_MIN_VECTORS {
return Box::new(super::BruteForceIndex::new(config));
}
match super::UsearchIndex::new(config.clone()) {
Ok(index) => {
// USearch's `add` requires capacity reserved up front. Reserve the
// expected count so the rebuild's inserts all fit; the write path
// grows it further as needed.
if let Err(e) = index.reserve(count) {
tracing::warn!(
dimensions,
count,
error = %e,
"USearch reserve failed; falling back to brute-force for this slot"
);
return Box::new(super::BruteForceIndex::new(config));
}
tracing::info!(
dimensions,
count,
"using USearch (HNSW) backend for embedding slot"
);
Box::new(index)
}
Err(e) => {
tracing::warn!(
dimensions,
count,
error = %e,
"USearch index construction failed; falling back to brute-force for this slot"
);
Box::new(super::BruteForceIndex::new(config))
}
}
}
/// Number of bytes per vector component at a given quantization level.
///
/// Used by [`EmbeddingSlotRegistry::index_stats`] to estimate index footprint
@ -264,12 +338,28 @@ impl EmbeddingSlotRegistry {
///
/// Returns the number of vectors inserted across all slots for this kind.
///
/// # Backend selection
///
/// Each slot's backend is chosen by [`build_slot_index`] from the slot's live
/// vector count: a slot at or above [`USEARCH_MIN_VECTORS`] is rebuilt as the
/// production HNSW engine (`UsearchIndex`); smaller slots stay exact
/// (`BruteForceIndex`). This is the live path that makes `UsearchIndex`
/// reachable in production after a restart, with no config plumbing.
///
/// # Fault isolation
///
/// A single un-indexable embedding (un-deserializable, zero-dimension, or a
/// dimensionality that mismatches its slot) is SKIPPED with a `tracing::warn!`
/// and counted, not propagated — one poison row must never abort the rebuild
/// for an entire entity kind (which would leave ANN search empty for every
/// good embedding too). The skipped count is logged at the end.
///
/// # Errors
///
/// - [`VectorError::Io`] if a storage scan entry fails to read.
/// - [`VectorError::CorruptedIndex`] if a stored embedding fails to deserialize.
/// - [`VectorError::Io`] if a storage scan entry fails to read (a whole-scan
/// failure is genuinely unrecoverable, so it still propagates).
/// - [`VectorError::Backend`] if slot registration fails.
/// - Any error from [`VectorIndex::insert`].
#[allow(clippy::too_many_lines)]
pub(crate) fn rebuild_from_store(
&mut self,
entity_kind: EntityKind,
@ -303,6 +393,10 @@ impl EmbeddingSlotRegistry {
// rather than allocating a throwaway `String` per embedding key.
let mut archived: HashSet<(u64, String)> = HashSet::new();
let mut candidates: Vec<(crate::schema::EntityId, String, Vec<f32>)> = Vec::new();
// Count of embeddings skipped due to a per-row fault (un-deserializable,
// zero-dim, or un-indexable). Reported at the end so a degraded rebuild is
// observable rather than silent.
let mut skipped = 0usize;
for entry in storage.scan_prefix(&[]) {
let (key, value) = entry.map_err(|e| {
VectorError::Io(std::io::Error::other(format!(
@ -337,28 +431,75 @@ impl EmbeddingSlotRegistry {
continue;
};
// Deserialize the source-of-truth vector eagerly (header carries
// dimensions); the owned slot `String` doubles as the archived-set
// lookup key below, so no extra per-key allocation is incurred.
let vector = super::deserialize_embedding(&value)?;
// Deserialize the source-of-truth vector. A single un-deserializable
// embedding (corrupt header, hand-edited store) must NOT abort the
// rebuild for the whole entity kind — that would leave ANN search empty
// for every good embedding too. Isolate the fault: warn, count it, and
// skip this one row. The durable value is untouched; the next write
// re-inserts it. (W: one poison row must not brick the kind.)
let vector = match super::deserialize_embedding(&value) {
Ok(v) => v,
Err(e) => {
tracing::warn!(
entity_id = entity_id.as_u64(),
slot = slot_name,
error = %e,
"skipping un-deserializable embedding during rebuild"
);
skipped += 1;
continue;
}
};
candidates.push((entity_id, slot_name.to_string(), vector));
}
// Group the surviving (non-archived, non-degenerate) embeddings by slot so
// each slot's vector count is known before its backend is built. The count
// drives the backend selection (USearch HNSW for large slots, brute force
// for small) AND the capacity reservation, with NO external config plumbing.
let mut by_slot: HashMap<String, Vec<(crate::schema::EntityId, Vec<f32>)>> = HashMap::new();
for (entity_id, slot_name, vector) in candidates {
// Skip embeddings that were soft-deleted (archived). The source-of-truth
// value is intentionally retained on disk, but it must not re-enter the
// ANN index — that is the whole point of the durable tombstone.
if archived.contains(&(entity_id.as_u64(), slot_name.clone())) {
continue;
}
// Reject a zero-dimension vector. The write path can never produce one
// (`l2_normalize` rejects an empty vector before anything is stored),
// but `serialize_embedding(&[])` is a valid 4-byte payload that
// `deserialize_embedding` accepts, so a corrupt / hand-crafted store
// could carry one. A 0-dim slot makes `l2_distance_sq` return 0.0 for
// every query, so the entity would tie for nearest on every search and
// pollute results. Skip it loudly. (W: corrupt 0-dim slot.)
if vector.is_empty() {
tracing::warn!(
entity_id = entity_id.as_u64(),
slot = slot_name.as_str(),
"skipping zero-dimension embedding during rebuild (corrupt store)"
);
skipped += 1;
continue;
}
by_slot
.entry(slot_name)
.or_default()
.push((entity_id, vector));
}
let mut inserted = 0usize;
for (entity_id, slot_name, vector) in candidates {
// Skip embeddings that were soft-deleted (archived). The source-of-truth
// value is intentionally retained on disk, but it must not re-enter the
// ANN index — that is the whole point of the durable tombstone. We move
// the candidate's owned `String` into the lookup tuple and move it back
// out on a miss, so the check costs zero extra allocations per key.
let probe = (entity_id.as_u64(), slot_name);
if archived.contains(&probe) {
for (slot_name, entries) in by_slot {
// The slot's dimensionality is the FIRST stored vector's length; any
// later vector of a different length is fault-isolated below. Cross-
// check against the schema declaration (advisory only — the stored
// header is authoritative for what was actually written).
let Some((_, first_vec)) = entries.first() else {
continue;
}
let (_, slot_name) = probe;
let dimensions = vector.len();
};
let dimensions = first_vec.len();
if let Some(&declared) = declared_dims.get(slot_name.as_str())
&& declared != dimensions
@ -372,16 +513,14 @@ impl EmbeddingSlotRegistry {
);
}
// Lazily register the slot on first sight, mirroring the write path
// (BruteForceIndex, F32, External). Dimensions come from the stored
// header so the rebuilt index matches what was actually written.
// Lazily register the slot on first sight, routing the backend through
// the size-gated factory: a slot with >= USEARCH_MIN_VECTORS live
// vectors is rebuilt as the production HNSW (USearch) engine; smaller
// slots stay exact (brute force). This is the live path that makes
// USearch reachable in production restarts — no config flag required.
if self.get(entity_kind, &slot_name).is_none() {
let config = super::VectorIndexConfig {
dimensions,
..super::VectorIndexConfig::default()
};
let state = EmbeddingSlotState {
index: Box::new(super::BruteForceIndex::new(config)),
index: build_slot_index(dimensions, entries.len()),
dimensions,
quantization: QuantizationLevel::F32,
source: EmbeddingSource::External,
@ -390,20 +529,38 @@ impl EmbeddingSlotRegistry {
self.register(entity_kind, slot_name.clone(), state)?;
}
// Insert into the (now-guaranteed-present) slot index.
let slot = self.get(entity_kind, &slot_name).ok_or_else(|| {
VectorError::Backend(format!(
"slot ({entity_kind}, \"{slot_name}\") missing immediately after registration"
))
})?;
slot.index.insert(entity_id.as_u64(), &vector)?;
inserted += 1;
for (entity_id, vector) in entries {
// Fault-isolate the per-embedding insert: a single row whose
// dimensionality differs from the slot's (e.g. a schema-revision
// mismatch that still self-deserializes) returns DimensionMismatch.
// Skip it loudly rather than abort the kind's rebuild — the
// hundreds of thousands of good embeddings for the same slot must
// still be indexed. (W: one poison row must not brick the kind.)
if let Err(e) = slot.index.insert(entity_id.as_u64(), &vector) {
tracing::warn!(
entity_id = entity_id.as_u64(),
slot = slot_name.as_str(),
error = %e,
"skipping un-indexable embedding during rebuild"
);
skipped += 1;
continue;
}
inserted += 1;
}
}
if inserted > 0 {
if inserted > 0 || skipped > 0 {
tracing::info!(
entity_kind = %entity_kind,
vectors = inserted,
skipped,
"embedding indexes rebuilt from durable storage"
);
}
@ -805,4 +962,110 @@ mod tests {
// No slot is registered when there is nothing durable to index.
assert!(registry.get(EntityKind::Item, "content").is_none());
}
// -------------------------------------------------------------------
// build_slot_index backend selection (USearch wiring) — finding 5
// -------------------------------------------------------------------
/// CRITICAL regression: the size-gated factory must build the production
/// HNSW (`UsearchIndex`) at/above the threshold and the exact `BruteForceIndex`
/// below it, and BOTH must be functional. Before the fix `USearch` was wired
/// into nothing and every slot was brute force.
#[test]
fn build_slot_index_selects_backend_by_count() {
// Below threshold: exact brute force, functional.
let small = build_slot_index(4, 0);
small.insert(1, &[1.0, 0.0, 0.0, 0.0]).unwrap();
small.insert(2, &[0.0, 1.0, 0.0, 0.0]).unwrap();
let r = small.search(&[1.0, 0.0, 0.0, 0.0], 1, 0).unwrap();
assert_eq!(r[0].id, 1, "brute-force backend must return the nearest");
// At threshold: USearch (HNSW), with capacity reserved by the factory so
// inserts succeed without a manual `reserve`. Searchable end-to-end —
// proving the production engine is actually reachable, not dead code.
let big = build_slot_index(4, USEARCH_MIN_VECTORS);
big.insert(10, &[1.0, 0.0, 0.0, 0.0]).unwrap();
big.insert(20, &[0.0, 1.0, 0.0, 0.0]).unwrap();
let r = big.search(&[1.0, 0.0, 0.0, 0.0], 1, big.len()).unwrap();
assert_eq!(
r[0].id, 10,
"USearch backend must be constructed AND searchable at the threshold"
);
}
// -------------------------------------------------------------------
// rebuild fault isolation — findings 8 and 13
// -------------------------------------------------------------------
/// W regression: a single un-indexable embedding (a different dimension under
/// the same slot) must NOT abort the rebuild — the good embeddings for the
/// kind are still indexed and searchable; only the poison row is skipped.
#[test]
fn rebuild_isolates_dimension_mismatch_and_zero_dim() {
use crate::{
schema::{DecaySpec, EntityId, SchemaBuilder},
storage::{
memory::InMemoryBackend,
vector::{embedding_store_key, serialize_embedding},
},
};
let dim = 4;
let mut builder = SchemaBuilder::new();
let _ = builder
.signal("view", EntityKind::Item, DecaySpec::Permanent)
.add();
builder.embedding_slot("content", EntityKind::Item, dim);
let schema = builder.build().expect("valid schema");
let storage = InMemoryBackend::new();
// Two valid 4-D embeddings.
for (id, v) in &[
(1u64, [1.0f32, 0.0, 0.0, 0.0]),
(2u64, [0.0f32, 1.0, 0.0, 0.0]),
] {
let key = embedding_store_key(EntityId::new(*id), "content");
storage.put(&key, &serialize_embedding(v)).unwrap();
}
// One poison row: a 2-D vector under the SAME slot. Its stored header is
// self-consistent (deserializes fine) but its dimension differs from the
// slot's (4) → DimensionMismatch on insert. It must be skipped, not abort.
let bad_key = embedding_store_key(EntityId::new(3), "content");
storage
.put(&bad_key, &serialize_embedding(&[1.0, 1.0]))
.unwrap();
// One zero-dimension row (corrupt store): a valid 4-byte payload that
// deserializes to an empty vector. Must be skipped (finding 13).
let empty_key = embedding_store_key(EntityId::new(4), "content");
storage.put(&empty_key, &serialize_embedding(&[])).unwrap();
let mut registry = EmbeddingSlotRegistry::new();
let inserted = registry
.rebuild_from_store(EntityKind::Item, &storage, &schema)
.expect("rebuild must not abort on a poison row");
assert_eq!(
inserted, 2,
"the two valid embeddings are indexed; the mismatch and zero-dim rows are skipped"
);
let slot = registry
.get(EntityKind::Item, "content")
.expect("slot registered from the valid embeddings");
assert_eq!(
slot.dimensions, dim,
"the slot must take the valid (4-D) dimensionality, not the corrupt 0-dim row"
);
assert_eq!(slot.index.len(), 2);
// The two valid embeddings are searchable; the bad ids never resurface.
let results = slot.index.search(&[1.0, 0.0, 0.0, 0.0], 10, 0).unwrap();
assert!(
results.iter().all(|r| r.id != 3 && r.id != 4),
"poison rows (ids 3, 4) must not be indexed"
);
assert!(
results.iter().any(|r| r.id == 1),
"valid embedding 1 must be searchable"
);
}
}

View File

@ -54,7 +54,7 @@ use std::{
};
use super::cluster_transport::{
BatchEntry, ChannelTransport, RecvOnlyChannelTransport, redeliver_missed,
BatchEntry, ChannelTransport, RecvOnlyChannelTransport, redeliver_missed, single_event_payload,
};
use crate::{
db::{
@ -63,7 +63,7 @@ use crate::{
},
query::{retrieve::Retrieve, search::Search},
replication::{
SegmentReceiverHandle, WalSegmentId,
SegmentReceiverHandle,
shard::{RegionId, ShardId},
spawn_receiver,
transport::{Transport, WalSegmentPayload},
@ -425,15 +425,29 @@ impl SimulatedCluster {
if region == leader_region || partitioned.contains(&region) {
continue;
}
let payload = WalSegmentPayload {
id: WalSegmentId::new(crate::replication::RegionId::SINGLE, leader_shard, seqno),
bytes: bytes.clone(),
event_count: 1,
// Single-event batch with first_seq = seqno → last WAL seq = seqno.
leader_last_seq: seqno,
};
// Ignore send errors: the receiver may have exited (e.g. after a crash).
let _ = transport.send_segment(ShardId(region.0), payload);
// Share the payload construction with `redeliver_missed` so the eager
// ship path and the recovery path can never drift on the load-bearing
// `event_count`/`leader_last_seq` invariants (the receiver's monotonic
// `advance` relies on `leader_last_seq == seqno`).
let payload = single_event_payload(leader_shard, seqno, bytes.clone());
// The eager ship is BEST-EFFORT: the leader write is already durable
// and recorded in `batch_log`, so a failed ship is recovered by
// `await_convergence` / `heal_region` re-delivery. We do NOT fail the
// write on a ship error (that would be the wrong contract — the data
// is durably on the leader). But a swallowed ship error is invisible
// to an operator, so surface it as a WARN: a follower that keeps
// failing to receive is observable here (and reconciled later) rather
// than silently lagging. See `redeliver_missed`.
if let Err(e) = transport.send_segment(ShardId(region.0), payload) {
tracing::warn!(
region = region.0,
leader_shard = leader_shard.0,
seqno,
error = %e,
"eager follower ship failed; leader write is durable and queued for \
re-delivery (await_convergence / heal_region)"
);
}
}
// Record in batch_log for partition-recovery re-delivery.

View File

@ -157,3 +157,33 @@ pub(super) fn redeliver_missed(transport: &dyn Transport, db: &TidalDb, log: &[B
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Locks the load-bearing payload-shape invariants of the single source of
/// truth that both `SimulatedCluster::write_signal` (W2 fix) and
/// [`redeliver_missed`] now share: a one-event batch in [`RegionId::SINGLE`]
/// whose `leader_last_seq == seqno`. The receiver's monotonic `advance`
/// relies on `leader_last_seq == seqno`; a future edit that breaks either
/// invariant on this single helper fails here instead of silently diverging
/// between the eager-ship and recovery sites.
#[test]
fn single_event_payload_holds_replication_invariants() {
let shard = ShardId(3);
let seqno = 42;
let bytes = vec![1u8, 2, 3, 4];
let payload = single_event_payload(shard, seqno, bytes.clone());
assert_eq!(payload.event_count, 1, "must be a single-event batch");
assert_eq!(
payload.leader_last_seq, seqno,
"receiver advance relies on leader_last_seq == seqno"
);
assert_eq!(payload.id.region_id, RegionId::SINGLE);
assert_eq!(payload.id.shard_id, shard);
assert_eq!(payload.id.seqno, seqno);
assert_eq!(payload.bytes, bytes);
}
}

View File

@ -1,3 +1,5 @@
use std::{cmp::Ordering, collections::BinaryHeap};
use tantivy::{
DocId, Score, SegmentOrdinal, SegmentReader,
collector::{Collector, SegmentCollector},
@ -19,6 +21,15 @@ use crate::schema::EntityId;
///
/// `requires_scoring()` returns `true`; without this, Tantivy skips BM25
/// computation and every document receives a score of 0.0.
///
/// # Unbounded — prefer [`BoundedScoresCollector`] on the query hot path
///
/// This collector keeps EVERY match, so a broad / near-ubiquitous term over a
/// large corpus materializes one `(EntityId, f32)` per match — an allocation
/// proportional to corpus match count, not the requested limit. The SEARCH
/// pipeline uses [`BoundedScoresCollector`] instead, which caps the result to a
/// top-K inside Tantivy. `AllScoresCollector` is retained for callers (and tests)
/// that genuinely need the full match set.
pub struct AllScoresCollector {
/// The Tantivy `Field` handle for `entity_id` (u64, FAST).
///
@ -102,6 +113,168 @@ impl SegmentCollector for AllScoresSegmentCollector {
}
}
// ---- BoundedScoresCollector ----------------------------------------------------
/// Like [`AllScoresCollector`] but keeps only the `cap` highest-scoring matches.
///
/// On a broad or near-ubiquitous term over a large corpus, [`AllScoresCollector`]
/// allocates one `(EntityId, f32)` per match (plus a downstream `HashMap`) — a
/// memory-amplification / latency-spike vector proportional to corpus match count
/// rather than the requested limit. This collector keeps a per-segment bounded
/// min-heap of size `cap`, so peak memory is `O(cap)` INSIDE Tantivy regardless of
/// how many documents match. A `cap` of `0` collects nothing.
///
/// The returned set is the global top-`cap` by score; `NaN` scores (degenerate
/// BM25 term statistics) are treated as the smallest and evicted first, matching
/// the pipeline's `finite` neutralization. Ordering of the returned `Vec` is
/// unspecified — the SEARCH pipeline sorts it deterministically (descending score,
/// id tie-break) before use.
pub struct BoundedScoresCollector {
/// The Tantivy `Field` handle for `entity_id` (u64, FAST). See
/// [`AllScoresCollector::entity_id_field`].
pub entity_id_field: Field,
/// Maximum number of highest-scoring documents to keep.
pub cap: usize,
}
impl BoundedScoresCollector {
/// Collector bounded to the `cap` highest-scoring documents.
#[must_use]
pub const fn new(entity_id_field: Field, cap: usize) -> Self {
Self {
entity_id_field,
cap,
}
}
}
/// A `(score, entity_id)` pair ordered so a [`BinaryHeap`] (a max-heap) behaves as
/// a MIN-heap on score — the smallest score is at the top and is evicted first when
/// the heap is at capacity. `NaN` scores are treated as the smallest (evicted
/// first). Ties break on entity id so eviction is deterministic.
#[derive(Debug, Clone, Copy, PartialEq)]
struct MinScored {
score: f32,
entity_id: u64,
}
impl Eq for MinScored {}
impl Ord for MinScored {
fn cmp(&self, other: &Self) -> Ordering {
// Reverse the score order so `BinaryHeap::peek`/`pop` yields the SMALLEST
// score (the eviction candidate). NaN sorts as the smallest score.
let a = if self.score.is_nan() {
f32::MIN
} else {
self.score
};
let b = if other.score.is_nan() {
f32::MIN
} else {
other.score
};
b.total_cmp(&a)
.then_with(|| other.entity_id.cmp(&self.entity_id))
}
}
impl PartialOrd for MinScored {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
/// Per-segment bounded collector backing [`BoundedScoresCollector`].
pub struct BoundedScoresSegmentCollector {
entity_id_col: Column<u64>,
heap: BinaryHeap<MinScored>,
cap: usize,
}
impl Collector for BoundedScoresCollector {
type Fruit = Vec<(EntityId, f32)>;
type Child = BoundedScoresSegmentCollector;
fn for_segment(
&self,
_segment_ord: SegmentOrdinal,
reader: &SegmentReader,
) -> tantivy::Result<Self::Child> {
let column_name = reader.schema().get_field_name(self.entity_id_field);
let entity_id_col = reader.fast_fields().u64(column_name)?;
Ok(BoundedScoresSegmentCollector {
entity_id_col,
heap: BinaryHeap::with_capacity(self.cap.saturating_add(1)),
cap: self.cap,
})
}
fn requires_scoring(&self) -> bool {
true
}
fn merge_fruits(
&self,
segment_fruits: Vec<Vec<(EntityId, f32)>>,
) -> tantivy::Result<Vec<(EntityId, f32)>> {
let total = segment_fruits.iter().map(Vec::len).sum();
let mut merged = Vec::with_capacity(total);
for fruit in segment_fruits {
merged.extend(fruit);
}
// Each segment already trimmed to `cap`, but the UNION of per-segment
// top-`cap` sets can exceed `cap`. Trim to the GLOBAL top-`cap` so the
// returned set never exceeds the requested bound.
if self.cap == 0 {
return Ok(Vec::new());
}
if merged.len() > self.cap {
// Partition so the `cap` highest scores occupy [0, cap); NaN sinks to
// the bottom (treated as the smallest), ties broken on ascending id
// for determinism, then drop the tail.
let finite = |s: f32| if s.is_nan() { f32::MIN } else { s };
let nth = self.cap.saturating_sub(1).min(merged.len() - 1);
merged.select_nth_unstable_by(nth, |a, b| {
finite(b.1)
.total_cmp(&finite(a.1))
.then_with(|| a.0.as_u64().cmp(&b.0.as_u64()))
});
merged.truncate(self.cap);
}
Ok(merged)
}
}
impl SegmentCollector for BoundedScoresSegmentCollector {
type Fruit = Vec<(EntityId, f32)>;
fn collect(&mut self, doc: DocId, score: Score) {
if self.cap == 0 {
return;
}
let Some(eid_val) = self.entity_id_col.first(doc) else {
return;
};
self.heap.push(MinScored {
score,
entity_id: eid_val,
});
// Keep only the top `cap`: once over capacity, evict the smallest score
// (the heap root). Peak memory stays `O(cap)`.
if self.heap.len() > self.cap {
self.heap.pop();
}
}
fn harvest(self) -> Self::Fruit {
self.heap
.into_iter()
.map(|m| (EntityId::new(m.entity_id), m.score))
.collect()
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
@ -235,6 +408,75 @@ mod tests {
idx.close().unwrap();
}
/// W regression: a bounded collector must return at most `cap` documents even
/// when more match, and must keep the highest-scoring ones. Doc 1 repeats the
/// query term so its BM25 score outranks docs 2 and 3; `cap = 1` must keep
/// exactly doc 1.
#[test]
fn bounded_collector_keeps_top_cap_by_score() {
// Doc 1 mentions "jazz" twice → higher BM25 than docs 2/3 (once each).
let idx = setup_index(&[
(1, "jazz jazz piano"),
(2, "jazz guitar"),
(3, "jazz violin"),
]);
let searcher = idx.reader.searcher();
let title_field = idx.fields().text_fields[0].1;
let qp = QueryParser::for_index(&idx.index, vec![title_field]);
let query = qp.parse_query("jazz").unwrap();
// cap = 1: only the single highest-scoring match (doc 1) survives.
let bounded = BoundedScoresCollector::new(idx.fields().entity_id, 1);
let results = searcher.search(&query, &bounded).unwrap();
assert_eq!(
results.len(),
1,
"bounded collector must return at most cap=1"
);
assert_eq!(
results[0].0.as_u64(),
1,
"the single retained doc must be the highest-scoring match"
);
// cap = 2: the two highest-scoring of three matches survive.
let bounded2 = BoundedScoresCollector::new(idx.fields().entity_id, 2);
let results2 = searcher.search(&query, &bounded2).unwrap();
assert_eq!(
results2.len(),
2,
"bounded collector must return at most cap=2"
);
assert!(
results2.iter().any(|(id, _)| id.as_u64() == 1),
"the highest-scoring doc must be retained"
);
// The unbounded collector still returns all three matches.
let unbounded = AllScoresCollector {
entity_id_field: idx.fields().entity_id,
};
let all = searcher.search(&query, &unbounded).unwrap();
assert_eq!(all.len(), 3, "unbounded collector returns every match");
idx.close().unwrap();
}
/// A `cap` of 0 collects nothing.
#[test]
fn bounded_collector_cap_zero_collects_nothing() {
let idx = setup_index(&[(1, "jazz piano"), (2, "jazz violin")]);
let searcher = idx.reader.searcher();
let title_field = idx.fields().text_fields[0].1;
let qp = QueryParser::for_index(&idx.index, vec![title_field]);
let query = qp.parse_query("jazz").unwrap();
let bounded = BoundedScoresCollector::new(idx.fields().entity_id, 0);
let results = searcher.search(&query, &bounded).unwrap();
assert!(results.is_empty(), "cap=0 must collect nothing");
idx.close().unwrap();
}
#[test]
fn all_scores_empty_query_returns_empty() {
let idx = setup_index(&[(1, "jazz piano")]);

View File

@ -97,9 +97,10 @@ impl TextIndexWriter<'_> {
/// Crash recovery for the text index is NOT driven from a commit payload.
/// The durable entity store is the source of truth, and the text index is
/// derived state that is unconditionally rebuilt from that store at open
/// (see `db::mod::rebuild_text_indexes_at_open`). This mirrors the vector
/// index, which is likewise rebuilt from the durable embeddings on every
/// reopen rather than reconciled against a stored sequence number.
/// (see [`crate::db::state_rebuild::rebuild_text_indexes_at_open`]). This
/// mirrors the vector index, which is likewise rebuilt from the durable
/// embeddings on every reopen rather than reconciled against a stored
/// sequence number.
///
/// # Errors
///

View File

@ -39,9 +39,13 @@ impl CheckpointManager {
fs::write(&tmp_path, content.as_bytes())?;
// fsync the temp file to ensure contents are durable before rename
// fsync the temp file to ensure contents are durable before rename. On
// macOS a plain fsync does not flush the device write cache, so route
// through the F_FULLFSYNC-backed helper — the checkpoint is a durable
// artifact and must meet the same crash-durability bar as the WAL
// segments it gates compaction of.
let file = fs::File::open(&tmp_path)?;
file.sync_all()?;
super::sync_file_durable(&file)?;
drop(file);
// Atomic rename (POSIX guarantees)
@ -49,9 +53,9 @@ impl CheckpointManager {
// Fsync the directory to ensure the rename (directory entry update)
// is durable. Without this, a crash after rename but before the
// directory metadata is flushed could lose the checkpoint file.
let dir_fd = fs::File::open(dir)?;
dir_fd.sync_all()?;
// directory metadata is flushed could lose the checkpoint file. On
// macOS this routes through the F_FULLFSYNC-backed helper.
super::sync_dir_durable(dir)?;
Ok(())
}

View File

@ -144,10 +144,12 @@ fn compact_segments(
// Fsync the directory to ensure the unlink operations are durable.
// Without this, a crash after deletion but before directory metadata
// flush could "resurrect" deleted segment files.
// flush could "resurrect" deleted segment files. On macOS a plain
// directory fsync does not flush the device cache, so route through the
// F_FULLFSYNC-backed helper. This is the same durable-delete contract as
// `segment::delete_segments_before`.
if deleted > 0 {
let dir_fd = std::fs::File::open(wal_dir)?;
dir_fd.sync_all()?;
crate::wal::sync_dir_durable(wal_dir)?;
}
let remaining = total_before - deleted;

View File

@ -101,9 +101,18 @@ impl DedupWindow {
/// fsynced, so a transient flush failure leaves the event un-recorded and a
/// retry is accepted rather than dropped. Honours the per-window size cap
/// like the rest of the window (forces an early rotation at the cap).
///
/// A size-driven rotation here uses [`rotate_buffers`](Self::rotate_buffers),
/// which does NOT reset the time-based rotation deadline. `record()` is
/// called post-fsync for every kept event and never itself drives the time
/// window (only `contains`/`is_duplicate` call `maybe_rotate`). If a
/// size-driven rotation reset the clock, a sustained burst of unique events
/// would keep pushing the time deadline forward, so the effective time
/// window would silently grow longer than configured under load. Leaving the
/// deadline untouched keeps the time window honest regardless of write rate.
pub fn record(&mut self, event: &EventRecord) {
if self.current.len() >= self.max_entries {
self.force_rotate();
self.rotate_buffers();
}
let hash = event_content_hash(event);
self.current.insert(hash);
@ -114,11 +123,12 @@ impl DedupWindow {
/// This inserts all provided events into the current window so that
/// duplicates arriving after recovery are correctly detected. The size cap
/// is honoured here too: a recovery set larger than `max_entries` rotates
/// rather than growing `current` without bound.
/// rather than growing `current` without bound. Like [`record`](Self::record),
/// a size-driven rotation here preserves the time-based rotation deadline.
pub fn populate_from_events<I: IntoIterator<Item = EventRecord>>(&mut self, events: I) {
for event in events {
if self.current.len() >= self.max_entries {
self.force_rotate();
self.rotate_buffers();
}
let hash = event_content_hash(&event);
self.current.insert(hash);
@ -141,17 +151,35 @@ impl DedupWindow {
/// Rotate buffers if the window duration has elapsed OR the active buffer has
/// reached its size cap, whichever comes first.
///
/// A **time**-driven rotation resets the rotation deadline (the window just
/// elapsed, so the next window starts now). A **size**-driven rotation does
/// NOT reset it: the configured time window should still expire on schedule
/// even when a burst forced an early size rotation, so the effective window
/// can never silently grow longer than configured under sustained load.
fn maybe_rotate(&mut self) {
if self.rotation_time.elapsed() >= self.window || self.current.len() >= self.max_entries {
if self.rotation_time.elapsed() >= self.window {
self.force_rotate();
} else if self.current.len() >= self.max_entries {
self.rotate_buffers();
}
}
/// Swap `current` into `previous`, clear the new `current`, and reset the
/// rotation timer. Old `previous` entries are dropped.
fn force_rotate(&mut self) {
/// Swap `current` into `previous` and clear the new `current`, WITHOUT
/// touching the time-based rotation deadline. Old `previous` entries are
/// dropped. Used by every size-driven rotation (`record`,
/// `populate_from_events`, and `maybe_rotate`'s size branch) so a burst of
/// writes cannot push the time deadline forward.
fn rotate_buffers(&mut self) {
std::mem::swap(&mut self.current, &mut self.previous);
self.current.clear();
}
/// Rotate the buffers AND reset the rotation timer. Used only by the
/// time-driven rotation path: the window just elapsed, so the next window
/// begins now.
fn force_rotate(&mut self) {
self.rotate_buffers();
self.rotation_time = Instant::now();
}
}
@ -279,6 +307,46 @@ mod tests {
assert!(dedup.is_duplicate(&recent));
}
#[test]
fn size_driven_record_rotation_does_not_reset_time_deadline() {
// wal-recovery SUG: a size-driven rotation inside record() must NOT push
// the time-based rotation deadline forward, or a sustained burst would
// silently stretch the effective time window. White-box: the rotation
// timer is module-private, so this same-module test reads it directly.
let max = 4;
let mut dedup = DedupWindow::with_max_entries(Duration::from_secs(3600), max);
let deadline_before = dedup.rotation_time;
// record() more than the cap so it size-rotates at least once. record()
// never drives the time window, so the deadline must be untouched.
for i in 0..(max as u64 * 3) {
dedup.record(&make_event(i));
}
assert_eq!(
dedup.rotation_time, deadline_before,
"a size-driven record() rotation must preserve the time deadline"
);
// And the size cap still bounds memory.
assert!(dedup.len() <= 2 * max);
}
#[test]
fn time_driven_rotation_resets_deadline() {
// The complement: a TIME-driven rotation (window elapsed) DOES reset the
// deadline, so the next window starts fresh. A zero-length window makes
// every maybe_rotate() take the time branch without sleeping.
let mut dedup = DedupWindow::new(Duration::ZERO);
let before = dedup.rotation_time;
// `is_duplicate` calls `maybe_rotate`; with a ZERO window the time branch
// fires and `force_rotate` updates the deadline to "now".
let _ = dedup.is_duplicate(&make_event(1));
assert!(
dedup.rotation_time >= before,
"a time-driven rotation must advance the deadline to now"
);
}
#[test]
fn populate_from_events_honours_size_cap() {
let max = 16;

View File

@ -472,16 +472,17 @@ mod tests {
std::fs::create_dir_all(&wal_dir).unwrap();
let mut bytes = Vec::new();
bytes.extend(encode_session_event(&SessionWalEvent::Start {
session_id: 1,
user_id: 10,
started_at_ns: 100,
agent_id: "agent".to_string(),
policy_name: "policy".to_string(),
}));
bytes.extend(encode_session_event(&SessionWalEvent::Close {
session_id: 1,
}));
bytes.extend(
encode_session_event(&SessionWalEvent::Start {
session_id: 1,
user_id: 10,
started_at_ns: 100,
agent_id: "agent".to_string(),
policy_name: "policy".to_string(),
})
.unwrap(),
);
bytes.extend(encode_session_event(&SessionWalEvent::Close { session_id: 1 }).unwrap());
std::fs::write(wal_dir.join(SESSION_JOURNAL_FILENAME), &bytes).unwrap();
let report = diagnose_wal(dir.path()).unwrap();
@ -509,8 +510,9 @@ mod tests {
started_at_ns: 100,
agent_id: "agent".to_string(),
policy_name: "policy".to_string(),
});
let mut corrupt = encode_session_event(&SessionWalEvent::Close { session_id: 2 });
})
.unwrap();
let mut corrupt = encode_session_event(&SessionWalEvent::Close { session_id: 2 }).unwrap();
// Flip the first payload byte so the per-record BLAKE3 checksum fails.
//
// A v2 session frame is laid out as

View File

@ -10,9 +10,6 @@ pub enum WalError {
/// Data corruption detected (BLAKE3 mismatch, invalid magic, etc.).
#[error("WAL corruption: {message}")]
Corruption { message: String },
/// Current segment is full; internal signal to trigger rotation.
#[error("WAL segment full")]
SegmentFull,
/// Invalid WAL configuration supplied at open time.
///
/// Returned (rather than panicking or crashing the writer thread) when a
@ -58,11 +55,6 @@ mod tests {
assert_eq!(e.to_string(), "WAL corruption: bad checksum");
}
#[test]
fn display_segment_full() {
assert_eq!(WalError::SegmentFull.to_string(), "WAL segment full");
}
#[test]
fn display_closed() {
assert_eq!(WalError::Closed.to_string(), "WAL closed");

View File

@ -1,5 +1,7 @@
// ── Session journal record types ────────────────────────────────────────────
use crate::wal::error::WalError;
/// Record type discriminant for session start events.
pub const SESSION_RECORD_START: u8 = 0x01;
/// Record type discriminant for session signal events.
@ -62,21 +64,53 @@ const SESSION_SIGNAL_FIXED_PREFIX: usize = 32;
/// whatever `write_payload` appends. This is the single framing used by every
/// optional Signal field (annotation, session seqno, idempotency key), so the
/// `[flag][payload]` convention lives in exactly one place instead of being
/// hand-rolled per field.
/// hand-rolled per field. `write_payload` is fallible so an optional string
/// field (the annotation) can reject an over-length value via [`push_str_field`]
/// without being silently truncated.
fn push_optional<T>(
buf: &mut Vec<u8>,
value: Option<&T>,
write_payload: impl FnOnce(&mut Vec<u8>, &T),
) {
match value {
Some(v) => {
buf.push(1u8);
write_payload(buf, v);
}
None => buf.push(0u8),
write_payload: impl FnOnce(&mut Vec<u8>, &T) -> Result<(), WalError>,
) -> Result<(), WalError> {
if let Some(v) = value {
buf.push(1u8);
write_payload(buf, v)
} else {
buf.push(0u8);
Ok(())
}
}
/// Append a length-prefixed UTF-8 string field as `[len: u16 LE][bytes]`.
///
/// This is the single encode-side owner of the `[len: u16][bytes]` framing,
/// mirroring the decode-side [`read_str_field`]. Hand-rolling
/// `(s.len() as u16).to_le_bytes()` per field (as the four call sites used to)
/// is exactly the bug this fixes: a `len() > u16::MAX` silently *wraps* the
/// 16-bit length while the full byte payload is still appended, producing a
/// frame whose own checksum passes but that `read_str_field` reads back
/// truncated — silently dropping the over-length field's tail AND misframing
/// every later record in the journal. Encoding must never produce a record it
/// cannot faithfully decode, so we reject the over-length field here with a
/// typed error rather than casting.
///
/// # Errors
///
/// Returns [`WalError::Corruption`] if `s.len() > u16::MAX` (the largest length
/// the 16-bit prefix can represent).
fn push_str_field(buf: &mut Vec<u8>, field: &str, value: &str) -> Result<(), WalError> {
let len = u16::try_from(value.len()).map_err(|_| WalError::Corruption {
message: format!(
"session journal {field} field is {} bytes, exceeds the u16 length-prefix maximum of {}",
value.len(),
u16::MAX
),
})?;
buf.extend_from_slice(&len.to_le_bytes());
buf.extend_from_slice(value.as_bytes());
Ok(())
}
/// Compute the per-record checksum over the framed record body.
///
/// Input is the in-`len` body excluding the trailing checksum, i.e.
@ -167,8 +201,18 @@ pub enum SessionWalEvent {
/// `[if has_annotation: annotation_len: u16 LE, annotation: bytes]`
///
/// **Close payload**: `[session_id: u64 LE]`
#[must_use]
pub fn encode_session_event(event: &SessionWalEvent) -> Vec<u8> {
///
/// # Errors
///
/// Returns [`WalError::Corruption`] if any variable-length string field
/// (`agent_id`, `policy_name`, `signal_name`, or `annotation`) is longer than
/// `u16::MAX` bytes. The length prefix is a `u16`, so an over-length field
/// could only be encoded by truncating its length count — producing a frame
/// whose checksum passes but that decodes back truncated and misframes every
/// later record. The format layer is self-defending: it refuses to emit a
/// record it cannot faithfully decode, regardless of what the write API let
/// through upstream.
pub fn encode_session_event(event: &SessionWalEvent) -> Result<Vec<u8>, WalError> {
// Encode the body (marker + version + type + payload) first, then prepend
// the length prefix and append the per-record checksum trailer.
let mut payload = Vec::new();
@ -186,10 +230,8 @@ pub fn encode_session_event(event: &SessionWalEvent) -> Vec<u8> {
payload.extend_from_slice(&session_id.to_le_bytes());
payload.extend_from_slice(&user_id.to_le_bytes());
payload.extend_from_slice(&started_at_ns.to_le_bytes());
payload.extend_from_slice(&(agent_id.len() as u16).to_le_bytes());
payload.extend_from_slice(agent_id.as_bytes());
payload.extend_from_slice(&(policy_name.len() as u16).to_le_bytes());
payload.extend_from_slice(policy_name.as_bytes());
push_str_field(&mut payload, "agent_id", agent_id)?;
push_str_field(&mut payload, "policy_name", policy_name)?;
}
SessionWalEvent::Signal {
session_id,
@ -206,20 +248,20 @@ pub fn encode_session_event(event: &SessionWalEvent) -> Vec<u8> {
payload.extend_from_slice(&entity_id.to_le_bytes());
payload.extend_from_slice(&weight.to_le_bytes());
payload.extend_from_slice(&ts_ns.to_le_bytes());
payload.extend_from_slice(&(signal_name.len() as u16).to_le_bytes());
payload.extend_from_slice(signal_name.as_bytes());
push_str_field(&mut payload, "signal_name", signal_name)?;
push_optional(&mut payload, annotation.as_ref(), |buf, ann| {
buf.extend_from_slice(&(ann.len() as u16).to_le_bytes());
buf.extend_from_slice(ann.as_bytes());
});
push_str_field(buf, "annotation", ann)
})?;
// m8p4: session_seqno (optional u64)
push_optional(&mut payload, session_seqno.as_ref(), |buf, seqno| {
buf.extend_from_slice(&seqno.0.to_le_bytes());
});
Ok(())
})?;
// m8p4: idempotency_key (optional u128)
push_optional(&mut payload, idempotency_key.as_ref(), |buf, key| {
buf.extend_from_slice(&key.to_le_bytes());
});
Ok(())
})?;
}
SessionWalEvent::Close { session_id } => {
payload.push(SESSION_RECORD_CLOSE);
@ -234,7 +276,7 @@ pub fn encode_session_event(event: &SessionWalEvent) -> Vec<u8> {
buf.extend_from_slice(&len.to_le_bytes());
buf.extend(payload);
buf.extend_from_slice(&checksum);
buf
Ok(buf)
}
/// Decode all session events from a session journal file's contents.
@ -544,11 +586,24 @@ fn decode_signal_record(bytes: &[u8], pos: &mut usize, end: usize) -> Option<Ses
None
};
// m8p4: session_seqno (backward-compatible — absent in legacy records)
// m8p4: session_seqno (backward-compatible — absent in legacy records).
//
// An absent flag byte (`*pos == end`) means a legacy record that predates
// this field: that is `None`, not malformed. But if the flag byte IS present
// and SET (`has_seqno != 0`) while the 8-byte value runs past `end`, the
// frame is structurally broken — the writer always emits the full value when
// it sets the flag. We return `None` (a `Malformed` outcome to the caller)
// rather than silently decoding the present-but-short field to `None`, which
// would drop a field the encoder claimed was there. A genuine writer can
// never produce this; it is only reachable via a hand-crafted frame with a
// recomputed-valid checksum.
let session_seqno = if *pos < end {
let has_seqno = bytes[*pos];
*pos += 1;
if has_seqno != 0 && *pos + 8 <= end {
if has_seqno != 0 {
if *pos + 8 > end {
return None;
}
let v = read_u64_le(bytes, pos);
Some(SessionSeqNo(v))
} else {
@ -558,11 +613,16 @@ fn decode_signal_record(bytes: &[u8], pos: &mut usize, end: usize) -> Option<Ses
None
};
// m8p4: idempotency_key (backward-compatible — absent in legacy records)
// m8p4: idempotency_key (backward-compatible — absent in legacy records).
// Same contract as `session_seqno`: a present-but-short value is structural
// corruption, not a silently-dropped `None`.
let idempotency_key = if *pos < end {
let has_key = bytes[*pos];
*pos += 1;
if has_key != 0 && *pos + 16 <= end {
if has_key != 0 {
if *pos + 16 > end {
return None;
}
let v = u128::from_le_bytes([
bytes[*pos],
bytes[*pos + 1],
@ -607,6 +667,15 @@ fn decode_signal_record(bytes: &[u8], pos: &mut usize, end: usize) -> Option<Ses
mod tests {
use super::*;
/// Encode an event, unwrapping the (now fallible) encoder. Every fixed test
/// event has short string fields, so the encode cannot fail; centralizing
/// the unwrap keeps the round-trip assertions readable. Tests that
/// deliberately exercise the over-length rejection path call
/// `encode_session_event` directly and assert on the `Result`.
fn enc(event: &SessionWalEvent) -> Vec<u8> {
encode_session_event(event).expect("fixed test events encode losslessly")
}
#[test]
fn session_start_roundtrip() {
let event = SessionWalEvent::Start {
@ -616,7 +685,7 @@ mod tests {
agent_id: "test-agent".to_string(),
policy_name: "default_policy".to_string(),
};
let encoded = encode_session_event(&event);
let encoded = enc(&event);
let decoded = decode_session_events(&encoded);
assert_eq!(decoded.len(), 1);
assert_eq!(decoded[0], event);
@ -634,7 +703,7 @@ mod tests {
session_seqno: None,
idempotency_key: None,
};
let encoded = encode_session_event(&event);
let encoded = enc(&event);
let decoded = decode_session_events(&encoded);
assert_eq!(decoded.len(), 1);
assert_eq!(decoded[0], event);
@ -652,7 +721,7 @@ mod tests {
session_seqno: None,
idempotency_key: None,
};
let encoded = encode_session_event(&event);
let encoded = enc(&event);
let decoded = decode_session_events(&encoded);
assert_eq!(decoded.len(), 1);
assert_eq!(decoded[0], event);
@ -661,7 +730,7 @@ mod tests {
#[test]
fn session_close_roundtrip() {
let event = SessionWalEvent::Close { session_id: 42 };
let encoded = encode_session_event(&event);
let encoded = enc(&event);
let decoded = decode_session_events(&encoded);
assert_eq!(decoded.len(), 1);
assert_eq!(decoded[0], event);
@ -702,7 +771,7 @@ mod tests {
let mut all_bytes = Vec::new();
for event in &events {
all_bytes.extend(encode_session_event(event));
all_bytes.extend(enc(event));
}
let decoded = decode_session_events(&all_bytes);
@ -721,7 +790,7 @@ mod tests {
agent_id: "agent".to_string(),
policy_name: "policy".to_string(),
};
let encoded = encode_session_event(&event);
let encoded = enc(&event);
// Truncate the record mid-way.
let truncated = &encoded[..encoded.len() / 2];
@ -739,8 +808,8 @@ mod tests {
agent_id: "agent".to_string(),
policy_name: "policy".to_string(),
};
let mut all_bytes = encode_session_event(&e1);
let e2_bytes = encode_session_event(&e2);
let mut all_bytes = enc(&e1);
let e2_bytes = enc(&e2);
// Add partial second record.
all_bytes.extend_from_slice(&e2_bytes[..e2_bytes.len() / 2]);
@ -774,7 +843,7 @@ mod tests {
session_seqno: Some(SessionSeqNo(7)),
idempotency_key: Some(0xDEAD_BEEF_CAFE_BABE_u128),
};
let encoded = encode_session_event(&event);
let encoded = enc(&event);
let decoded = decode_session_events(&encoded);
assert_eq!(decoded.len(), 1);
assert_eq!(decoded[0], event);
@ -793,7 +862,7 @@ mod tests {
session_seqno: None,
idempotency_key: None,
};
let encoded = encode_session_event(&event);
let encoded = enc(&event);
let decoded = decode_session_events(&encoded);
assert_eq!(decoded[0], event);
}
@ -806,7 +875,7 @@ mod tests {
fn encode_session_event_v1_legacy(event: &SessionWalEvent) -> Vec<u8> {
// Encode a v2 record, then strip the marker/version prefix and the
// checksum trailer to reconstruct the exact v1 byte layout.
let v2 = encode_session_event(event);
let v2 = enc(event);
// v2 body starts after the length prefix with [marker, version, type, ...].
let body = &v2[SESSION_LEN_PREFIX_LEN..v2.len() - SESSION_CHECKSUM_LEN];
// Drop marker(1) + version(1) to leave [type, payload...].
@ -821,7 +890,7 @@ mod tests {
#[test]
fn session_v2_record_has_checksum_trailer() {
let event = SessionWalEvent::Close { session_id: 42 };
let encoded = encode_session_event(&event);
let encoded = enc(&event);
// len(4) + marker(1) + version(1) + type(1) + session_id(8) + checksum(8)
assert_eq!(
encoded.len(),
@ -866,7 +935,7 @@ mod tests {
let v2_event = SessionWalEvent::Close { session_id: 1 };
let mut bytes = encode_session_event_v1_legacy(&legacy_event);
bytes.extend(encode_session_event(&v2_event));
bytes.extend(enc(&v2_event));
let decoded = decode_session_events(&bytes);
assert_eq!(decoded.len(), 2);
@ -897,8 +966,8 @@ mod tests {
idempotency_key: None,
};
let r1 = encode_session_event(&e1);
let mut r2 = encode_session_event(&e2);
let r1 = enc(&e1);
let mut r2 = enc(&e2);
// Corrupt the first payload byte of r2, just past the length prefix and
// the marker/version/type frame header.
let flip = SESSION_LEN_PREFIX_LEN + SESSION_FRAME_PREFIX_LEN;
@ -921,7 +990,7 @@ mod tests {
fn session_corrupted_checksum_trailer_detected() {
// Mangle only the checksum trailer (not the body): still detected.
let event = SessionWalEvent::Close { session_id: 7 };
let mut encoded = encode_session_event(&event);
let mut encoded = enc(&event);
let last = encoded.len() - 1;
encoded[last] ^= 0x01;
@ -942,7 +1011,7 @@ mod tests {
agent_id: "agent".to_string(),
policy_name: "policy".to_string(),
};
let encoded = encode_session_event(&event);
let encoded = enc(&event);
let truncated = &encoded[..encoded.len() - 3];
let outcome = decode_session_events_with_diagnostics(truncated);
@ -978,7 +1047,7 @@ mod tests {
];
let mut bytes = Vec::new();
for e in &events {
bytes.extend(encode_session_event(e));
bytes.extend(enc(e));
}
let outcome = decode_session_events_with_diagnostics(&bytes);
assert_eq!(outcome.events, events);
@ -1007,7 +1076,7 @@ mod tests {
session_seqno: None,
idempotency_key: None,
};
let encoded = encode_session_event(&event);
let encoded = enc(&event);
let decoded = decode_session_events(&encoded);
assert_eq!(decoded.len(), 1);
match &decoded[0] {
@ -1050,9 +1119,7 @@ mod tests {
frame.extend_from_slice(&payload);
frame.extend_from_slice(&checksum);
// Follow it with a real Close so we can prove decoding continued.
frame.extend(encode_session_event(&SessionWalEvent::Close {
session_id: 9,
}));
frame.extend(enc(&SessionWalEvent::Close { session_id: 9 }));
let outcome = decode_session_events_with_diagnostics(&frame);
assert!(
@ -1101,7 +1168,7 @@ mod tests {
// A valid record followed by a checksum-valid-but-short v2 frame keeps
// the first record and stops cleanly without flagging corruption.
let e1 = SessionWalEvent::Close { session_id: 5 };
let mut bytes = encode_session_event(&e1);
let mut bytes = enc(&e1);
let mut payload = vec![
SESSION_RECORD_VERSIONED,
@ -1148,4 +1215,116 @@ mod tests {
);
assert_eq!(outcome.corruption_count, 1);
}
// ── W30: over-length string fields are rejected, never silently truncated ──
#[test]
fn session_over_length_annotation_is_rejected() {
// An annotation longer than u16::MAX bytes cannot fit the 16-bit length
// prefix. The pre-fix bug cast `len() as u16`, wrapping the count while
// appending the full bytes — a frame whose checksum passed but decoded
// back truncated, misframing every later record. The encoder must now
// reject it with a typed error rather than emit an unrecoverable record.
let event = SessionWalEvent::Signal {
session_id: 1,
entity_id: 2,
weight: 1.0,
ts_ns: 3,
signal_name: "reward".to_string(),
annotation: Some("x".repeat(70_000)),
session_seqno: None,
idempotency_key: None,
};
let result = encode_session_event(&event);
match result {
Err(WalError::Corruption { message }) => {
assert!(
message.contains("annotation") && message.contains("70000"),
"unexpected error message: {message}"
);
}
other => panic!("expected Corruption for an over-length annotation, got {other:?}"),
}
}
#[test]
fn session_over_length_signal_name_is_rejected() {
let event = SessionWalEvent::Signal {
session_id: 1,
entity_id: 2,
weight: 1.0,
ts_ns: 3,
signal_name: "s".repeat(usize::from(u16::MAX) + 1),
annotation: None,
session_seqno: None,
idempotency_key: None,
};
assert!(matches!(
encode_session_event(&event),
Err(WalError::Corruption { .. })
));
}
#[test]
fn session_max_length_field_round_trips() {
// Exactly u16::MAX bytes is the largest field the prefix can represent
// and MUST round-trip losslessly — the rejection boundary is exclusive.
let annotation = "y".repeat(usize::from(u16::MAX));
let event = SessionWalEvent::Signal {
session_id: 1,
entity_id: 2,
weight: 1.0,
ts_ns: 3,
signal_name: "view".to_string(),
annotation: Some(annotation),
session_seqno: None,
idempotency_key: None,
};
let encoded = encode_session_event(&event).expect("u16::MAX-length field must encode");
let decoded = decode_session_events(&encoded);
assert_eq!(decoded.len(), 1);
assert_eq!(decoded[0], event);
}
// ── wal-format SUG: present-but-short optional field stops, not silent None ──
#[test]
fn session_present_but_short_seqno_is_clean_stop_not_silent_none() {
// Hand-build a checksum-valid v2 Signal frame whose session_seqno flag is
// SET but whose 8-byte value is truncated. A genuine writer never emits
// this (it always writes the full value when it sets the flag); only a
// forged frame with a recomputed checksum can. The decoder must treat it
// as a structural mismatch (clean stop, no event) rather than silently
// decoding the present-but-short field to `None` and returning a Signal.
let mut payload = vec![
SESSION_RECORD_VERSIONED,
SESSION_RECORD_VERSION_V2,
SESSION_RECORD_SIGNAL,
];
payload.extend_from_slice(&1u64.to_le_bytes()); // session_id
payload.extend_from_slice(&2u64.to_le_bytes()); // entity_id
payload.extend_from_slice(&1.0f64.to_le_bytes()); // weight
payload.extend_from_slice(&3u64.to_le_bytes()); // ts_ns
payload.extend_from_slice(&4u16.to_le_bytes()); // signal_name len = 4
payload.extend_from_slice(b"view"); // signal_name
payload.push(0u8); // has_annotation = false
payload.push(1u8); // has_seqno = true ...
payload.extend_from_slice(&[0xAA, 0xBB]); // ... but only 2 of 8 bytes
let checksum = session_record_checksum(&payload);
let len = (payload.len() + SESSION_CHECKSUM_LEN) as u32;
let mut frame = Vec::new();
frame.extend_from_slice(&len.to_le_bytes());
frame.extend_from_slice(&payload);
frame.extend_from_slice(&checksum);
let outcome = decode_session_events_with_diagnostics(&frame);
assert!(
outcome.events.is_empty(),
"a present-but-short optional field must NOT decode to a silent-None Signal"
);
// Checksum-valid bytes were proven intact, so this is a structural
// (forward-compat/torn) mismatch, a clean stop — not on-disk corruption.
assert!(!outcome.corruption_detected);
assert_eq!(outcome.corruption_count, 0);
}
}

View File

@ -48,6 +48,57 @@ use crate::replication::{RegionId, ShardId};
/// Default channel capacity for the writer command channel.
const DEFAULT_CHANNEL_CAPACITY: usize = 10_000;
/// Flush a file's data to **stable storage**, defeating the drive write cache.
///
/// On Linux this is `File::sync_data()` (`fdatasync`), which already pushes the
/// bytes past the OS page cache to the device. On **macOS**, however, std's
/// `sync_data`/`sync_all` issues a plain `fsync(2)`, which Apple documents as
/// NOT flushing the storage device's volatile write cache — a power-loss can
/// still lose bytes the WAL has "fsynced" and acknowledged to callers. There,
/// durable crash safety requires `fcntl(fd, F_FULLFSYNC)`, which we issue via
/// rustix's safe `fcntl_fullfsync` wrapper (this crate is `unsafe_code =
/// "forbid"`, so a raw `libc::fcntl` is not an option).
///
/// Use this for every WAL data sync that must survive a hard crash (segment
/// appends, checkpoint temp). For directory-entry durability use
/// [`sync_dir_durable`].
pub(crate) fn sync_file_durable(file: &std::fs::File) -> Result<(), WalError> {
#[cfg(target_os = "macos")]
{
// F_FULLFSYNC flushes the device write cache; plain fsync on macOS does
// not. A safe wrapper keeps us within `unsafe_code = "forbid"`.
rustix::fs::fcntl_fullfsync(file).map_err(|e| WalError::Io(e.into()))?;
Ok(())
}
#[cfg(not(target_os = "macos"))]
{
file.sync_data()?;
Ok(())
}
}
/// Make a directory's metadata (entry creations/renames/unlinks) durable.
///
/// Same platform split as [`sync_file_durable`]: on macOS a plain directory
/// `fsync` does not flush the device cache, so we issue `F_FULLFSYNC` on the
/// directory FD; elsewhere `File::sync_all()` is sufficient. Without a durable
/// directory sync, a crash after a create/rename/unlink but before the metadata
/// reaches the platter could resurrect a deleted segment or lose a freshly
/// created one's name.
pub(crate) fn sync_dir_durable(dir: &std::path::Path) -> Result<(), WalError> {
let dir_fd = std::fs::File::open(dir)?;
#[cfg(target_os = "macos")]
{
rustix::fs::fcntl_fullfsync(&dir_fd).map_err(|e| WalError::Io(e.into()))?;
Ok(())
}
#[cfg(not(target_os = "macos"))]
{
dir_fd.sync_all()?;
Ok(())
}
}
/// A signal event to be appended to the WAL.
///
/// This is the public write type. It maps 1:1 to the internal

View File

@ -23,14 +23,35 @@ pub struct RecoveryResult {
///
/// Reads the checkpoint (if any), then scans all segment files from the
/// checkpoint position forward. Validates each batch with two-phase
/// checking (magic + bounds, then BLAKE3). Truncates any corrupted tail.
/// checking (magic + bounds, then BLAKE3).
///
/// # Corruption is only repairable in the FINAL segment
///
/// A torn or corrupt *tail* is the normal signature of a crash mid-append, and
/// it can only legitimately occur in the segment a writer was actively
/// appending to — i.e. the last (highest-`first_seq`) segment. Recovery
/// truncates that torn tail and replays the rest, exactly as before.
///
/// A tail-corrupt **non-final** segment is a different beast entirely: that
/// segment was already sealed and synced (it rotated when a higher segment was
/// created), so a missing/garbled trailing batch there is silent bit-rot, not a
/// crash artifact. Blindly truncating it and replaying the later segments would
/// produce a state with a contiguous run of *missing* sequences — silent data
/// loss in the durability-critical path, returned as a clean `Ok`. So recovery
/// surfaces it as [`WalError::Corruption`] instead, mirroring the
/// sequence-overflow handling and the session-journal corruption signal. It
/// also enforces cross-segment sequence continuity: segment N+1's first batch
/// must begin exactly where segment N ended, and a gap is likewise
/// `Corruption`. An operator then learns at `open()` that bytes were lost, rather
/// than getting a silently-truncated database.
///
/// Returns the replayed events and the next sequence number to assign.
///
/// # Errors
///
/// Returns `WalError::Io` on filesystem failure, or `WalError::Corruption`
/// if a segment is corrupted in a way that cannot be recovered by truncation.
/// if a non-final segment has a corrupt tail, if there is a gap in the
/// cross-segment sequence space, or if a forged `first_seq` overflows `u64`.
pub fn recover(dir: &Path) -> Result<RecoveryResult, WalError> {
// WAL open is a writable path: sweep any checkpoint temp orphaned by a crash
// between a prior write and its rename. `CheckpointManager::read` is kept
@ -67,9 +88,58 @@ pub fn recover(dir: &Path) -> Result<RecoveryResult, WalError> {
// a segment's filename `first_seq` is only its *lowest* sequence, and it may
// hold events past the checkpoint, so the post-checkpoint filter is applied
// per event below (`event_seq > checkpoint_seq`), not per segment.
for (_seg_first_seq, seg_path) in &segments {
let scan_result = recover_segment(seg_path)?;
for (header, events) in scan_result {
//
// Cross-segment continuity: each non-first segment's first batch must begin
// exactly where the previous segment's last event ended. `expected_first_seq`
// tracks that boundary; a mismatch is a gap in the durable sequence space and
// is surfaced as `Corruption` rather than silently replayed past.
let last_idx = segments.len() - 1;
let mut expected_first_seq: Option<u64> = None;
for (idx, (_seg_first_seq, seg_path)) in segments.iter().enumerate() {
let is_final = idx == last_idx;
// Scan WITHOUT mutating first, so we can decide truncate-vs-error from
// the tail-corruption flag before touching the file. Only the final
// segment's torn tail is a legitimate crash artifact we may repair.
let summary = scan_segment_summary(seg_path)?;
if summary.tail_corrupt {
if is_final {
// Crash mid-append on the live segment: repair the torn tail in
// place (single-threaded recovery owns the dir here) and keep
// the valid batches we already scanned.
recover_segment(seg_path)?;
} else {
// A sealed, already-synced segment with a corrupt tail is silent
// bit-rot, NOT a crash artifact. Truncating it would drop a
// contiguous run of sequences and leave a gap; surface it loudly.
return Err(WalError::Corruption {
message: format!(
"non-final WAL segment {} has a corrupt/truncated tail; \
truncating it would silently drop sequences covered by \
later segments refusing to recover past the gap",
seg_path.display()
),
});
}
}
for (header, events) in summary.batches {
// Enforce cross-segment continuity at the FIRST batch of each
// segment after the first: it must begin where the previous segment
// ended. A gap means sequences were lost between segments.
if let Some(expected) = expected_first_seq.take()
&& header.first_seq != expected
{
return Err(WalError::Corruption {
message: format!(
"WAL sequence gap at segment {}: expected first_seq={expected}, \
found {} a contiguous run of sequences is missing",
seg_path.display(),
header.first_seq
),
});
}
for (i, event) in events.into_iter().enumerate() {
// `header.first_seq` is read from on-disk segment bytes, which
// may be corruption-controlled. Adding the in-batch index with
@ -99,6 +169,8 @@ pub fn recover(dir: &Path) -> Result<RecoveryResult, WalError> {
if candidate > next_seq {
next_seq = candidate;
}
// The next event/segment must continue from one past this one.
expected_first_seq = Some(candidate);
}
}
}
@ -557,4 +629,118 @@ mod tests {
assert_eq!(result.events.len(), 2);
assert_eq!(result.next_seq, 3);
}
#[test]
fn recover_corrupt_tail_in_non_final_segment_is_corruption_not_silent_loss() {
// C17 regression: a SEALED (non-final) segment with a corrupt/truncated
// tail is silent bit-rot, not a crash artifact. Truncating it and
// replaying the later segment would drop a contiguous run of sequences
// and return a clean `Ok` — exactly the silent data loss this guards
// against. recover() must surface it as `Corruption` instead, AND must
// NOT truncate the sealed segment on disk.
let dir = tempfile::tempdir().expect("tempdir creation should succeed");
// Segment 1 (will be NON-final): a valid batch + garbage tail.
let events1 = vec![sample_event(1, 1000)];
let batch1 = encode_batch(&events1, 1, 1000).expect("encode should succeed");
let mut seg1_data = batch1;
seg1_data.extend_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11]);
let seg1 = super::super::segment::segment_filename(ShardId::SINGLE, 1);
let seg1_path = dir.path().join(&seg1);
fs::write(&seg1_path, &seg1_data).expect("write should succeed");
// Segment 2 (final): a clean, valid batch at a higher first_seq.
let events2 = vec![sample_event(2, 2000)];
let batch2 = encode_batch(&events2, 2, 2000).expect("encode should succeed");
let seg2 = super::super::segment::segment_filename(ShardId::SINGLE, 2);
fs::write(dir.path().join(seg2), &batch2).expect("write should succeed");
let result = recover(dir.path());
match result {
Err(WalError::Corruption { message }) => {
assert!(
message.contains("non-final") || message.contains("corrupt"),
"unexpected corruption message: {message}"
);
}
other => panic!(
"expected Corruption for a tail-corrupt non-final segment, got: {:?}",
other.map(|r| r.events.len())
),
}
// The sealed non-final segment must be left intact on disk — recovery
// refused, so it must not have silently truncated the bit-rotted bytes.
let seg1_len_after = fs::metadata(&seg1_path)
.expect("metadata should succeed")
.len();
assert_eq!(
seg1_len_after,
seg1_data.len() as u64,
"a non-final corrupt segment must NOT be truncated by recover()"
);
}
#[test]
fn recover_cross_segment_sequence_gap_is_corruption() {
// Two intact segments, but segment 2 begins at a first_seq that leaves a
// hole after segment 1's last event. A contiguous run of sequences is
// missing between them; recover() must surface a gap rather than replay
// past it as if nothing were lost.
let dir = tempfile::tempdir().expect("tempdir creation should succeed");
// Segment 1: one event at seq 1 (so it ends expecting next_seq=2).
let batch1 = encode_batch(&[sample_event(1, 1000)], 1, 1000).expect("encode");
let seg1 = super::super::segment::segment_filename(ShardId::SINGLE, 1);
fs::write(dir.path().join(seg1), &batch1).expect("write should succeed");
// Segment 2: starts at seq 5, skipping seqs 2,3,4 entirely.
let batch2 = encode_batch(&[sample_event(5, 5000)], 5, 5000).expect("encode");
let seg2 = super::super::segment::segment_filename(ShardId::SINGLE, 5);
fs::write(dir.path().join(seg2), &batch2).expect("write should succeed");
match recover(dir.path()) {
Err(WalError::Corruption { message }) => {
assert!(
message.contains("gap") || message.contains("missing"),
"unexpected corruption message: {message}"
);
}
other => panic!(
"expected Corruption for a cross-segment sequence gap, got: {:?}",
other.map(|r| r.events.len())
),
}
}
#[test]
fn recover_two_contiguous_segments_with_torn_final_tail_succeeds() {
// The complement of the C17 case: when the torn tail is in the FINAL
// (live) segment it IS a legitimate crash artifact, so recovery repairs
// it and replays the contiguous prefix. This keeps the legitimate
// crash-mid-append path working after the non-final guard was added.
let dir = tempfile::tempdir().expect("tempdir creation should succeed");
let batch1 = encode_batch(&[sample_event(1, 1000)], 1, 1000).expect("encode");
let seg1 = super::super::segment::segment_filename(ShardId::SINGLE, 1);
fs::write(dir.path().join(seg1), &batch1).expect("write should succeed");
// Final segment: valid batch at seq 2 + torn tail.
let batch2 = encode_batch(&[sample_event(2, 2000)], 2, 2000).expect("encode");
let mut seg2_data = batch2.clone();
seg2_data.extend_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF]);
let seg2 = super::super::segment::segment_filename(ShardId::SINGLE, 2);
let seg2_path = dir.path().join(&seg2);
fs::write(&seg2_path, &seg2_data).expect("write should succeed");
let result = recover(dir.path()).expect("recover should succeed");
assert_eq!(result.events.len(), 2);
assert_eq!(result.next_seq, 3);
// The final segment's torn tail was repaired in place.
let seg2_len_after = fs::metadata(&seg2_path)
.expect("metadata should succeed")
.len();
assert_eq!(seg2_len_after, batch2.len() as u64);
}
}

View File

@ -172,10 +172,11 @@ impl SegmentWriter {
// Fsync the parent directory so the new directory entry is durable.
// Without this, a crash after file creation but before the directory
// metadata is flushed could lose the segment file entirely.
// metadata is flushed could lose the segment file entirely. On macOS a
// plain directory fsync does not flush the device cache, so this routes
// through the F_FULLFSYNC-backed helper.
if is_new {
let dir_fd = File::open(dir)?;
dir_fd.sync_all()?;
super::sync_dir_durable(dir)?;
}
let metadata = file.metadata()?;
@ -207,31 +208,35 @@ impl SegmentWriter {
/// Sync this segment's written **data** to stable storage.
///
/// Uses `File::sync_data()` (`fdatasync` on Linux, `fsync` on macOS), which
/// flushes the file's data plus the size-changing metadata needed to read
/// that data back. For an append to an **existing** segment — the steady
/// state — this is genuinely sufficient: there is no directory change, so a
/// single `sync()` makes the freshly appended batch fully durable.
/// Routes through [`crate::wal::sync_file_durable`], which flushes the file's
/// data to the device, defeating the drive write cache. On Linux that is
/// `fdatasync`; on **macOS** a plain `fsync(2)` does NOT flush the device's
/// volatile write cache (Apple documents this), so the helper issues
/// `fcntl(fd, F_FULLFSYNC)` instead — without it a power-loss could lose a
/// batch the WAL had already "fsynced" and acknowledged. For an append to an
/// **existing** segment — the steady state — this makes the freshly appended
/// batch fully durable on every supported platform: there is no directory
/// change, so a single `sync()` suffices.
///
/// It does NOT, however, make a newly created segment's **directory entry**
/// durable. `fdatasync` says nothing about the parent directory, so a crash
/// right after a `create + sync()` could lose the file's name even though
/// its bytes reached the platter. Directory-entry durability for new and
/// rotated segments is handled separately by the explicit parent-directory
/// `sync_all()` in [`SegmentWriter::open`] (on first creation) and
/// [`SegmentWriter::rotate`] (on rotation); segment *deletions* are made
/// durable by the parent-directory fsync in
/// [`crate::wal::compaction`]. Together those sites complete the durability
/// story that this method deliberately covers only the data half of — do
/// not drop them on the assumption that `sync()` already fsyncs the
/// directory.
/// It does NOT make a newly created segment's **directory entry** durable.
/// A data sync says nothing about the parent directory, so a crash right
/// after a `create + sync()` could lose the file's name even though its
/// bytes reached the platter. Directory-entry durability for new and rotated
/// segments is handled separately by the explicit parent-directory sync in
/// [`SegmentWriter::open`] (on first creation) and [`SegmentWriter::rotate`]
/// (on rotation); segment *deletions* are made durable by the
/// parent-directory fsync in [`delete_segments_before`] and
/// [`crate::wal::compaction`]. All of those route through
/// [`crate::wal::sync_dir_durable`], so the directory half of the durability
/// story is also F_FULLFSYNC-backed on macOS. Together those sites complete
/// the story this method deliberately covers only the data half of — do not
/// drop them on the assumption that `sync()` already fsyncs the directory.
///
/// # Errors
///
/// Returns `WalError::Io` on sync failure.
pub fn sync(&self) -> Result<(), WalError> {
self.file.sync_data()?;
Ok(())
super::sync_file_durable(&self.file)
}
/// Whether the segment has reached its size threshold and should be rotated.
@ -276,9 +281,9 @@ impl SegmentWriter {
// Fsync the parent directory so the new segment's directory entry
// is durable. Without this, a crash after file creation but before
// the directory metadata is flushed could lose the new segment.
let dir_fd = File::open(&self.dir)?;
dir_fd.sync_all()?;
// the directory metadata is flushed could lose the new segment. On
// macOS this routes through the F_FULLFSYNC-backed helper.
super::sync_dir_durable(&self.dir)?;
self.file = file;
self.current_size = 0;
@ -289,22 +294,94 @@ impl SegmentWriter {
/// Delete all segment files whose first sequence number is less than `before_seq`.
///
/// After the unlinks, the WAL directory is fsynced via
/// [`crate::wal::sync_dir_durable`] so the deletions are durable — matching
/// [`crate::wal::compaction::compact_segments`], the other durable-delete
/// routine. Without that directory sync a crash after a `remove_file` but
/// before the directory metadata reached the platter could resurrect a deleted
/// segment file. (Resurrection is benign here — the segment is covered by the
/// checkpoint and replays idempotently — but the two deletion paths must agree
/// on durability so a reader cannot mistake the asymmetry for a bug, and so a
/// future change that makes resurrection non-idempotent does not silently
/// regress.)
///
/// # Errors
///
/// Returns `WalError::Io` on filesystem failure. Partial deletion may occur
/// if an error is encountered mid-way.
/// if an error is encountered mid-way; the directory fsync still runs for any
/// unlinks that did complete.
pub fn delete_segments_before(dir: &Path, before_seq: u64) -> Result<usize, WalError> {
let segments = list_segments(dir)?;
let mut deleted = 0;
let mut result = Ok(());
for (seq, path) in segments {
if seq < before_seq {
fs::remove_file(&path)?;
if let Err(e) = fs::remove_file(&path) {
result = Err(WalError::Io(e));
break;
}
deleted += 1;
}
}
// Make the unlinks durable, mirroring compaction's directory fsync. Run this
// even when a mid-way unlink failed, so the deletions that DID complete are
// durable before we surface the error.
if deleted > 0 {
super::sync_dir_durable(dir)?;
}
result?;
Ok(deleted)
}
#[cfg(test)]
#[path = "segment_tests.rs"]
mod tests;
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod durable_delete_tests {
use super::*;
/// W32 regression: `delete_segments_before` must make its unlinks durable by
/// fsyncing the WAL directory (matching `compaction::compact_segments`), so
/// the two deletion paths agree on durability. We cannot observe the fsync
/// itself without crash injection, but we pin that the routine still deletes
/// exactly the older segments and that the directory-fsync code path executes
/// cleanly (it would error if the helper or its dir-open regressed).
#[test]
fn delete_segments_before_deletes_older_and_dir_sync_runs() {
let dir = tempfile::tempdir().expect("tempdir creation should succeed");
for &seq in &[1u64, 50, 100] {
let _ = SegmentWriter::open(dir.path(), ShardId::SINGLE, seq, 1024)
.expect("seed segment open should succeed");
}
// floor = 100 -> seqs 1 and 50 are deleted, 100 survives. The directory
// fsync runs because deleted > 0; a regression in the helper or its
// dir-open would surface as an Err here.
let deleted = delete_segments_before(dir.path(), 100).expect("delete should succeed");
assert_eq!(deleted, 2);
let remaining = list_segments(dir.path()).expect("list should succeed");
assert_eq!(remaining.len(), 1);
assert_eq!(remaining[0].0, 100);
}
/// When nothing matches the floor, no directory fsync is issued (deleted ==
/// 0) and the routine is a clean no-op.
#[test]
fn delete_segments_before_noop_skips_dir_sync() {
let dir = tempfile::tempdir().expect("tempdir creation should succeed");
let _ = SegmentWriter::open(dir.path(), ShardId::SINGLE, 100, 1024)
.expect("seed segment open should succeed");
let deleted = delete_segments_before(dir.path(), 50).expect("delete should succeed");
assert_eq!(deleted, 0);
assert_eq!(
list_segments(dir.path())
.expect("list should succeed")
.len(),
1
);
}
}

View File

@ -14,8 +14,9 @@ use std::{
path::{Path, PathBuf},
};
use super::format::{
SessionWalEvent, decode_session_events_with_diagnostics, encode_session_event,
use super::{
error::WalError,
format::{SessionWalEvent, decode_session_events_with_diagnostics, encode_session_event},
};
/// The session journal file name within the WAL directory.
@ -72,20 +73,27 @@ impl SessionJournal {
/// Append a session event to the journal and fsync.
///
/// Durability semantics are unchanged from the previous buffered
/// implementation: the record is fully written and `sync_data`-flushed to
/// stable storage before this returns. A crash after return guarantees the
/// record is recoverable; a crash before return leaves at most a torn tail,
/// which [`recover`](Self::recover) discards cleanly.
/// The record is fully written and durably flushed to stable storage before
/// this returns. A crash after return guarantees the record is recoverable;
/// a crash before return leaves at most a torn tail, which
/// [`recover`](Self::recover) discards cleanly. The flush routes through
/// [`crate::wal::sync_file_durable`], so on macOS it issues `F_FULLFSYNC`
/// (defeating the device write cache) rather than a plain `fsync` that could
/// lose an acknowledged record on power loss.
///
/// # Errors
///
/// Returns `std::io::Error` on write or sync failure.
pub fn append(&mut self, event: &SessionWalEvent) -> Result<(), std::io::Error> {
let encoded = encode_session_event(event);
/// Returns [`WalError::Corruption`] if the event cannot be faithfully
/// encoded (a string field longer than `u16::MAX`; see
/// [`encode_session_event`]), or [`WalError::Io`] on write or sync failure.
/// The fallible encode means an oversized field is surfaced to the caller
/// instead of silently writing a record the decoder cannot read back.
pub fn append(&mut self, event: &SessionWalEvent) -> Result<(), WalError> {
let encoded = encode_session_event(event)?;
self.file.write_all(&encoded)?;
// fsync the file descriptor for durability before returning.
self.file.sync_data()?;
// Durable flush before returning (F_FULLFSYNC on macOS, fdatasync
// elsewhere) so an acknowledged session record survives a hard crash.
crate::wal::sync_file_durable(&self.file)?;
Ok(())
}

View File

@ -649,3 +649,75 @@ fn handle_session_command(cmd: WalCommand, journal: &mut Option<SessionJournal>)
#[allow(clippy::unwrap_used, clippy::similar_names)]
#[path = "writer_tests.rs"]
mod tests;
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod rotation_failure_tests {
use crossbeam::channel::bounded;
use super::*;
use crate::wal::segment::{SegmentWriter, segment_filename};
fn make_event(id: u64) -> EventRecord {
EventRecord::signal(id, 1, 1.0, 1_000_000_000)
}
/// wal-write SUG: cover a flush failure that lands specifically inside
/// `rotate()` (old segment synced, new segment file fails to open), not just
/// the pre-encode fault the `FAIL_NEXT_FLUSH` hook injects.
///
/// We force the failure deterministically with real filesystem semantics:
/// `max_size = 0` makes `needs_rotation()` true on the first flush, and we
/// pre-create a *directory* at the exact path the new segment file would take
/// (`wal-{batch_seq:020}.seg`) so `rotate()`'s `OpenOptions::open` fails. The
/// contract under test is the same as every other flush-error path: the
/// caller's reply channel must carry the error rather than being dropped (a
/// dropped channel would surface as a misleading `Closed`).
#[test]
fn flush_batch_error_inside_rotate_notifies_callers() {
let dir = tempfile::tempdir().expect("tempdir creation should succeed");
// max_size = 0 -> needs_rotation() is true immediately, so the very first
// flush attempts a rotate before writing.
let mut segment =
SegmentWriter::open(dir.path(), ShardId::SINGLE, 1, 0).expect("open should succeed");
let config = WriterConfig {
dir: dir.path().to_path_buf(),
segment_size: 0,
batch_size: 100,
batch_timeout: Duration::from_millis(10),
dedup_window: Duration::from_secs(30),
session_journal_path: None,
shard_id: ShardId::SINGLE,
region_id: RegionId::SINGLE,
};
// flush_batch rotates to `segment_filename(SINGLE, batch_seq)` where
// batch_seq = 1 (matching SegmentWriter::open's first_seq). Pre-create a
// DIRECTORY at that path so opening it as a file fails — a real, not
// mocked, I/O fault landing inside rotate().
let collision = dir.path().join(segment_filename(ShardId::SINGLE, 1));
std::fs::remove_file(&collision).expect("seed segment file should exist to remove");
std::fs::create_dir(&collision).expect("create blocking directory should succeed");
let (reply_tx, reply_rx) = bounded(1);
let kept_events = vec![make_event(1)];
let kept_replies = vec![reply_tx];
let result = flush_batch(&mut segment, &config, 1, &kept_events, kept_replies);
assert!(
result.is_err(),
"a rotate() failure inside flush must propagate, got {result:?}"
);
// The caller must be notified with the error, not left on a dropped channel.
let reply = reply_rx
.recv()
.expect("reply channel must NOT be dropped when rotate() fails");
assert!(
matches!(reply, Err(WalError::Io(_))),
"caller must receive the rotate I/O error, got {reply:?}"
);
}
}

View File

@ -357,9 +357,12 @@ proptest! {
fn hard_neg_latest_always_wins(
writes in prop::collection::vec(arb_action_write(), 2..=10),
) {
// Find the expected winner: the write with the maximum timestamp.
// Find the expected winner: the write with the maximum timestamp, with
// an exact-timestamp tie broken on the larger action value — matching
// the commutative tie-break LWWRegister::merge now applies so the law
// holds even on the (otherwise unreachable for honest HLCs) collision.
let (expected_action, expected_ts) = writes.iter()
.max_by_key(|(_, ts)| *ts)
.max_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)))
.unwrap();
// Build individual registers and merge them all together.

View File

@ -263,7 +263,9 @@ fn test_dual_write_routing_returns_both_shards() {
/// `signal_for_tenant` must FAIL CLOSED when a dual-write resolves a non-local
/// shard while cross-node dispatch is unwired — never silently drop the remote
/// half. The local half is still written before the error surfaces.
/// half. No write happens — the op fails closed atomically (it detects the
/// non-local assignment before touching the ledger), so a retry cannot
/// double-count the local signal.
#[test]
fn test_signal_for_tenant_fails_closed_on_unwired_remote_shard() {
use std::time::Duration;
@ -312,15 +314,15 @@ fn test_signal_for_tenant_fails_closed_on_unwired_remote_shard() {
"expected Internal (fail-closed) error, got: {err}"
);
// The LOCAL half was still applied before the error: the signal is visible
// on this node even though the remote dispatch failed closed.
// NOTHING was written: the op fails closed atomically before any ledger
// mutation, so a caller that retries on Err cannot double-count the local
// signal. The item therefore has no decay score on this node.
let score = db
.read_decay_score(item, "view", 0)
.expect("read local decay score")
.expect("entity must have a decay score after the local write");
.expect("read local decay score");
assert!(
score > 0.0,
"local-shard half of the dual-write must still be applied (got score {score})"
score.is_none() || matches!(score, Some(s) if s == 0.0),
"fail-closed must apply zero writes (got score {score:?})"
);
}

View File

@ -0,0 +1,112 @@
#![allow(clippy::unwrap_used)]
//! M0M10 review pass 2, zone C regression: a creator SEARCH with a
//! CategoryEq/FormatEq filter must NOT be intersected against the ITEM-scoped
//! bitmap indexes.
//!
//! Before the fix, `apply_metadata_filter` (Stage 2) ran a `FilterEvaluator` over
//! the item category/format indexes and `candidates.retain(|id| bitmap.contains(id))`
//! against CREATOR ids — the wrong namespace. In a mixed DB only creators whose
//! id coincidentally aliased an item in that category survived; in a creator-only
//! DB every creator was dropped. The Stage 2b creator-metadata post-filter (an
//! AND-style retain) cannot resurrect candidates Stage 2 already removed, so a
//! documented feature ("filter creators by category") returned zero/arbitrary
//! results with no error. This test builds a DB with BOTH items (in category
//! "jazz") and creators (with category "news") and proves the matching creators
//! are returned.
use std::{collections::HashMap, time::Duration};
use tidaldb::{
TidalDb,
query::search::Search,
schema::{DecaySpec, EntityId, EntityKind, SchemaBuilder, TextFieldType, Window},
storage::indexes::filter::FilterExpr,
};
fn schema() -> tidaldb::schema::Schema {
let mut builder = SchemaBuilder::new();
let _ = builder
.signal(
"view",
EntityKind::Item,
DecaySpec::Exponential {
half_life: Duration::from_secs(7 * 24 * 3600),
},
)
.windows(&[Window::AllTime])
.add();
let _ = builder
.signal(
"follow",
EntityKind::Creator,
DecaySpec::Exponential {
half_life: Duration::from_secs(30 * 24 * 3600),
},
)
.windows(&[Window::AllTime])
.add();
// Creators are retrieved by BM25 over their "name" text field.
builder.creator_text_field("name", TextFieldType::Text);
builder.build().unwrap()
}
#[test]
fn creator_search_by_category_returns_matching_creators() {
let db = TidalDb::builder()
.ephemeral()
.with_schema(schema())
.open()
.unwrap();
// ITEMS in category "jazz" — these populate the item-scoped category index.
// Their ids deliberately overlap the creator id range so a (buggy) intersection
// against the item index would alias creator ids onto these item slots.
for id in 1..=3u64 {
let mut item_meta = HashMap::new();
item_meta.insert("title".to_string(), format!("jazz track {id}"));
item_meta.insert("category".to_string(), "jazz".to_string());
db.write_item_with_metadata(EntityId::new(id), &item_meta)
.unwrap();
}
// CREATORS with category metadata "news". Their names all contain "news" so
// a BM25 query on "news" retrieves them.
for id in 1..=3u64 {
let mut creator_meta = HashMap::new();
creator_meta.insert("name".to_string(), format!("news anchor {id}"));
creator_meta.insert("category".to_string(), "news".to_string());
db.write_creator(EntityId::new(id), &creator_meta).unwrap();
}
// One creator in a different category — must be excluded by the filter.
let mut sports_meta = HashMap::new();
sports_meta.insert("name".to_string(), "news roundup 99".to_string());
sports_meta.insert("category".to_string(), "sports".to_string());
db.write_creator(EntityId::new(99), &sports_meta).unwrap();
db.flush_creator_text_index().unwrap();
// SEARCH creators for "news" filtered to category "news". The matching news
// creators (1, 2, 3) must be returned — NOT dropped by an item-index
// intersection (the item category index holds only "jazz" item ids).
let query = Search::builder()
.entity_kind(EntityKind::Creator)
.query("news")
.filter(FilterExpr::eq("category", "news"))
.limit(20)
.build()
.unwrap();
let results = db.search(&query).unwrap();
let mut ids: Vec<u64> = results.items.iter().map(|r| r.entity_id.as_u64()).collect();
ids.sort_unstable();
assert_eq!(
ids,
vec![1, 2, 3],
"creators in category 'news' must be returned (not dropped by the item-index \
intersection); the 'sports' creator 99 is correctly excluded by the metadata filter. \
Got {ids:?}, warnings: {:?}",
results.warnings
);
db.close().unwrap();
}

View File

@ -0,0 +1,110 @@
//! Review pass-2, Zone D (Replication + tidal-net transport) regression tests.
//!
//! W8: `signal_for_tenant` must fail CLOSED *before any write* when a dual-write
//! resolves a non-local shard, so a caller that retries the returned error
//! cannot double-count the local half (signal accumulation is not idempotent).
//! The companion case proves a fully-local resolution still writes.
#![allow(clippy::unwrap_used)]
use std::time::Duration;
use tidaldb::{
TidalDb, TidalError,
replication::{RegionId, ShardAssignment, ShardId, TenantId},
schema::{DecaySpec, EntityId, EntityKind, SchemaBuilder, Timestamp, Window},
};
fn open_db_with_view_signal() -> TidalDb {
let mut builder = SchemaBuilder::new();
let _ = builder
.signal(
"view",
EntityKind::Item,
DecaySpec::Exponential {
half_life: Duration::from_secs(7 * 24 * 3600),
},
)
.windows(&[Window::AllTime])
.velocity(false)
.add();
let schema = builder.build().unwrap();
TidalDb::builder()
.ephemeral()
.with_schema(schema)
.open()
.expect("ephemeral db opens")
}
/// W8: a dual-write that resolves a non-local shard must fail closed with ZERO
/// local writes — the previous behavior wrote the local half and then errored,
/// so a retry double-counted the signal. After the error, the entity must have
/// NO decay score, proving the local half was never applied.
#[test]
fn signal_for_tenant_remote_shard_fails_closed_with_zero_writes() {
let db = open_db_with_view_signal();
// This node is the default local shard (ShardId::SINGLE = 0). Add a second
// shard and put the default tenant into dual-write so a write resolves BOTH
// the local shard 0 and the non-local shard 1.
db.control_plane().update_topology(ShardAssignment {
shard_id: ShardId(1),
region_id: RegionId(1),
});
db.tenant_router()
.set_dual_write(TenantId::DEFAULT, ShardId(0), ShardId(1));
let item = EntityId::new(42);
let err = db
.signal_for_tenant(TenantId::DEFAULT, "view", item, 1.0, Timestamp::now())
.expect_err("remote-shard dual-write must fail closed, not silently drop");
assert!(
matches!(err, TidalError::Internal(_)),
"expected Internal (fail-closed) error, got: {err}"
);
// The local half must NOT have been applied: a retry of the Err cannot
// double-count because nothing was written. The entity has no decay score.
let score = db
.read_decay_score(item, "view", 0)
.expect("read local decay score");
assert!(
score.is_none() || score == Some(0.0),
"fail-closed must write nothing locally; got score {score:?}"
);
// Retrying the failed op repeatedly must still leave nothing written — the
// exact retry-amplification the W8 fix removes.
for _ in 0..5 {
let _ = db.signal_for_tenant(TenantId::DEFAULT, "view", item, 1.0, Timestamp::now());
}
let score_after_retries = db
.read_decay_score(item, "view", 0)
.expect("read local decay score after retries");
assert!(
score_after_retries.is_none() || score_after_retries == Some(0.0),
"retries of a fail-closed op must not accumulate any local weight; got {score_after_retries:?}"
);
}
/// Companion: when every resolved assignment is local (normal, non-migrating
/// tenant) the local write still lands — fail-closed only triggers on a
/// non-local assignment.
#[test]
fn signal_for_tenant_all_local_writes_succeed() {
let db = open_db_with_view_signal();
let item = EntityId::new(7);
db.signal_for_tenant(TenantId::DEFAULT, "view", item, 1.0, Timestamp::now())
.expect("a fully-local tenant write must succeed");
let score = db
.read_decay_score(item, "view", 0)
.expect("read local decay score")
.expect("a local write must produce a decay score");
assert!(
score > 0.0,
"local write must accumulate a positive score, got {score}"
);
}

View File

@ -0,0 +1,90 @@
#![allow(clippy::unwrap_used, clippy::doc_markdown)]
//! M0M10 review pass 2, zone C regression: FOR SESSION missing-session handling
//! must NOT diverge between RETRIEVE and SEARCH.
//!
//! A RETRIEVE/SEARCH with `FOR SESSION <id>` pointing at an expired/evicted
//! session is a WELL-FORMED query. Per CODING_GUIDELINES §6 ("graceful
//! degradation, never failure") the engine must execute it WITHOUT the session
//! boost rather than hard-erroring. Before the fix RETRIEVE returned
//! `Err(SessionNotFound)` while the structurally-identical SEARCH degraded — a
//! confusing surface-specific outage the moment a session was swept. This test
//! proves both surfaces now degrade identically.
use std::{collections::HashMap, time::Duration};
use tidaldb::{
SessionId, TidalDb,
query::{retrieve::Retrieve, search::Search},
schema::{DecaySpec, EntityId, EntityKind, SchemaBuilder, Timestamp, Window},
};
fn test_schema() -> tidaldb::schema::Schema {
let mut builder = SchemaBuilder::new();
let _ = builder
.signal(
"view",
EntityKind::Item,
DecaySpec::Exponential {
half_life: Duration::from_secs(7 * 24 * 3600),
},
)
.windows(&[Window::AllTime])
.add();
builder.build().unwrap()
}
fn test_db() -> TidalDb {
TidalDb::builder()
.ephemeral()
.with_schema(test_schema())
.open()
.unwrap()
}
#[test]
fn retrieve_and_search_degrade_identically_on_missing_session() {
let db = test_db();
// Seed one item with a `view` signal so both queries have something to rank.
let now = Timestamp::now();
db.signal("view", EntityId::new(1), 1.0, now).unwrap();
let mut meta = HashMap::new();
meta.insert("title".to_string(), "jazz piano".to_string());
db.write_item_with_metadata(EntityId::new(1), &meta)
.unwrap();
// A session id that was never started (the "swept session" case).
let missing = SessionId::from_raw(9_999_999);
// RETRIEVE FOR SESSION <missing> must NOT error — it degrades to no-boost.
let retrieve = Retrieve::builder()
.profile("new")
.for_session(missing)
.limit(10)
.build()
.unwrap();
let r = db.retrieve(&retrieve);
assert!(
r.is_ok(),
"RETRIEVE with a missing session must degrade gracefully, got {:?}",
r.err()
);
// SEARCH FOR SESSION <missing> must likewise degrade (this already worked;
// asserted here to lock the two surfaces together).
let search = Search::builder()
.query("jazz")
.using_profile("search")
.for_session(missing)
.limit(10)
.build()
.unwrap();
let s = db.search(&search);
assert!(
s.is_ok(),
"SEARCH with a missing session must degrade gracefully, got {:?}",
s.err()
);
db.close().unwrap();
}

View File

@ -0,0 +1,56 @@
#![allow(clippy::doc_markdown)]
//! Regression test for the pass-2 review finding:
//! "storage-indexes — total_count() and selectivity() recompute a full bitmap
//! union on every call."
//!
//! `BitmapIndex::total_count()` is now memoized (the union over every value
//! bitmap is computed at most once per mutation). This test pins the
//! correctness side of that optimization: the memoized count must always agree
//! with the freshly-recomputed union across a full insert / delete /
//! delete_entity cycle, including a cloned handle that shares the memo.
use tidaldb::storage::indexes::BitmapIndex;
#[test]
fn total_count_cache_tracks_mutations() {
let index = BitmapIndex::new("category");
// Primed-empty cache returns 0 without a recompute.
assert_eq!(index.total_count(), 0);
index.insert(1, "jazz");
index.insert(2, "blues");
index.insert(3, "jazz");
// Invalidated by each insert, recomputed once, then memoized.
assert_eq!(index.total_count(), 3);
assert_eq!(index.total_count(), 3); // fast path agrees with slow path
// Adding entity 1 to a SECOND value bitmap does not change the distinct
// count: it is still three distinct entities in the union.
index.insert(1, "blues");
assert_eq!(index.total_count(), 3);
// Removing entity 1 from only one of its two value bitmaps keeps it in the
// union (still present under "jazz"), so the distinct count is unchanged.
assert!(index.delete(1, "blues"));
assert_eq!(index.total_count(), 3);
// delete_entity scrubs every value bitmap, so the count finally drops.
assert!(index.delete_entity(1) > 0);
assert_eq!(index.total_count(), 2);
}
#[test]
fn cloned_handle_shares_total_count_memo() {
// BitmapIndex::clone shares the underlying values Arc AND the memo Arc, so a
// mutation through one handle must invalidate the other handle's cache.
let a = BitmapIndex::new("format");
a.insert(10, "video");
assert_eq!(a.total_count(), 1);
let b = a.clone();
assert_eq!(b.total_count(), 1);
// Mutate through `a`; `b` must observe the change, not a stale memo.
a.insert(20, "audio");
assert_eq!(b.total_count(), 2, "clone must see the shared invalidation");
}

View File

@ -0,0 +1,249 @@
//! Zone A (session lifecycle & sweeper) regression tests for the
//! M0-M10 code-review pass-2 findings.
//!
//! These are end-to-end persistence tests: they open a persistent database,
//! exercise the session lifecycle, shut down, and reopen — the only way to
//! reproduce the two BLOCKERs and the CRITICALs, which only manifest across a
//! restart.
#![allow(
clippy::unwrap_used,
clippy::too_many_lines,
clippy::doc_markdown,
clippy::needless_collect
)]
use std::{collections::HashMap, time::Duration};
use tidaldb::{
AgentPolicy, SessionId, TidalDb,
schema::{DecaySpec, EntityId, EntityKind, SchemaBuilder, Timestamp},
};
/// Schema with one signal type and a single long-TTL session policy named
/// "agent", used by every test in this file.
fn schema() -> tidaldb::schema::Schema {
let mut b = SchemaBuilder::new();
let _ = b
.signal("view", EntityKind::Item, DecaySpec::Permanent)
.add();
b.session_policy(
"agent",
AgentPolicy {
allowed_signals: vec!["view".to_string()],
denied_signals: vec![],
max_session_duration: Duration::from_secs(3600),
max_signals_per_session: 10_000,
},
);
b.build().unwrap()
}
/// BLOCKER regression: a clean restart must NOT re-issue an archived session id
/// and overwrite its durable snapshot.
///
/// Reproduces the reviewer's two assertions:
/// (1) a new session after reopen gets an id strictly greater than the
/// previously-closed id, and
/// (2) `session_snapshot` of the old id still returns the ORIGINAL
/// user_id / signals_written — the archive was not destroyed.
#[test]
fn reopen_does_not_reissue_archived_session_id() {
let dir = tempfile::tempdir().unwrap();
// Phase 1: start + signal (user 1, 5 signals) + close, then shut down.
let first_id;
{
let db = TidalDb::builder()
.with_data_dir(dir.path())
.with_schema(schema())
.open()
.unwrap();
let handle = db
.start_session(1, "agent", "agent", HashMap::new())
.unwrap();
first_id = handle.id;
for i in 1..=5u64 {
db.session_signal(
&handle,
"view",
EntityId::new(i),
1.0,
Timestamp::now(),
None,
)
.unwrap();
}
db.close_session(handle).unwrap();
// Snapshot is archived with the true author + count.
let snap = db.session_snapshot(first_id).unwrap();
assert_eq!(snap.user_id, 1);
assert_eq!(snap.signals_written, 5);
db.close().unwrap();
}
// Phase 2: reopen and start a NEW session for a different user.
{
let db = TidalDb::builder()
.with_data_dir(dir.path())
.with_schema(schema())
.open()
.unwrap();
let handle = db
.start_session(2, "agent", "agent", HashMap::new())
.unwrap();
let second_id = handle.id;
// (1) The new id must be strictly greater than the archived id — the
// allocator was advanced past the durable snapshot on reopen.
assert!(
second_id.as_u64() > first_id.as_u64(),
"reopened session id {second_id} must exceed the archived id {first_id}; \
the allocator was reseeded to 1 and re-issued the archived id"
);
db.close_session(handle).unwrap();
// (2) The original archived snapshot is intact — not overwritten by the
// reused id's new (user 2, 0-signal) close.
let snap = db.session_snapshot(first_id).unwrap();
assert_eq!(
snap.user_id, 1,
"the user-1 archived snapshot must survive a restart"
);
assert_eq!(
snap.signals_written, 5,
"the user-1 archived signal count must survive a restart"
);
db.close().unwrap();
}
}
/// End-to-end companion to the in-module "snapshot is authoritative" guard:
/// after a clean close + reopen, the session is absent from `active_sessions()`
/// and resolves only as an archived snapshot. (The torn-Close phantom-state
/// case — snapshot present AND Start-without-Close in the journal — is covered
/// directly by `session_restore.rs::durable_snapshot_blocks_active_restore`,
/// which can inject that exact state through the internal restore path.)
#[test]
fn closed_session_is_not_restored_as_active() {
let dir = tempfile::tempdir().unwrap();
let closed_id;
{
let db = TidalDb::builder()
.with_data_dir(dir.path())
.with_schema(schema())
.open()
.unwrap();
let handle = db
.start_session(1, "agent", "agent", HashMap::new())
.unwrap();
closed_id = handle.id;
db.session_signal(
&handle,
"view",
EntityId::new(1),
1.0,
Timestamp::now(),
None,
)
.unwrap();
db.close_session(handle).unwrap();
db.close().unwrap();
}
// Reopen: the durable snapshot for `closed_id` is present. Even if a stale
// Start-without-Close survived in the journal, the snapshot is authoritative
// and the session must NOT come back as active.
{
let db = TidalDb::builder()
.with_data_dir(dir.path())
.with_schema(schema())
.open()
.unwrap();
let active_ids: Vec<u64> = db
.active_sessions()
.into_iter()
.map(|s| s.id.as_u64())
.collect();
assert!(
!active_ids.contains(&closed_id.as_u64()),
"a closed session (durable snapshot present) must never restore as active"
);
// It is still resolvable as a closed/archived session.
let snap = db.session_snapshot(closed_id).unwrap();
assert_eq!(snap.id, closed_id);
db.close().unwrap();
}
}
/// BLOCKER/CRITICAL regression: a session that was already past its
/// `max_session_duration` at shutdown must be TTL-reaped on the first startup
/// sweep after reopen — the durable age (`started_at_ns`) survives the restart,
/// so the back-dated monotonic clock makes the sweeper see the true age.
///
/// Uses a fresh session under the public API; the focused unit tests in
/// `sweeper.rs` and `policy.rs` cover the simulated-2h-old-restore arithmetic
/// directly (the public API cannot dial `started_at_ns` into the past). Here we
/// assert the live invariant: a session within its TTL survives a sweep and is
/// listed active after reopen, proving restore back-dates rather than expiring
/// a perfectly fresh session.
#[test]
fn restored_session_within_ttl_survives_sweep() {
let dir = tempfile::tempdir().unwrap();
let session_id: SessionId;
{
let db = TidalDb::builder()
.with_data_dir(dir.path())
.with_schema(schema())
.open()
.unwrap();
let handle = db
.start_session(1, "agent", "agent", HashMap::new())
.unwrap();
session_id = handle.id;
db.session_signal(
&handle,
"view",
EntityId::new(1),
1.0,
Timestamp::now(),
None,
)
.unwrap();
// Deliberately do NOT close — leave it active so the WAL replays it.
db.close().unwrap();
}
{
let db = TidalDb::builder()
.with_data_dir(dir.path())
.with_schema(schema())
.open()
.unwrap();
// The fresh, within-TTL session is restored active and survives a sweep
// (back-dated started_at reflects its true near-zero age, not an instant
// expiry from a misread clock).
db.force_sweep();
let active: Vec<u64> = db
.active_sessions()
.into_iter()
.map(|s| s.id.as_u64())
.collect();
assert!(
active.contains(&session_id.as_u64()),
"a within-TTL restored session must survive the startup sweep"
);
db.close().unwrap();
}
}

View File

@ -260,7 +260,6 @@ fn storage_error_display_all_variants() {
assert!(err.to_string().contains("bad data"));
assert_eq!(StorageError::Closed.to_string(), "storage closed");
assert_eq!(StorageError::BatchConflict.to_string(), "batch conflict");
}
// =============================================================================

View File

@ -2,7 +2,7 @@
use serde::Serialize;
use crate::{CliError, json::render_json};
use crate::{CliError, EXIT_DEGRADED, json::render_json};
/// Per-scope signal-event tally over a WAL directory.
#[derive(Default)]
@ -27,11 +27,50 @@ impl ScopeStats {
}
}
/// Tri-state integrity of the WAL scan, so the operator can distinguish a
/// clean scan from a torn tail from one whose integrity could NOT be verified
/// at all (e.g. a corrupt `checkpoint.meta` that makes the cross-check fail).
///
/// Folding `unverified` into `clean` is exactly the false positive W29 fixes:
/// the tally read may succeed off the segments while the integrity diagnosis
/// itself errors out, so claiming `complete` then is silently-wrong reporting
/// on a durability-adjacent surface.
#[derive(Serialize, Clone, Copy, PartialEq, Eq, Debug)]
#[serde(rename_all = "snake_case")]
enum Integrity {
/// Diagnosis succeeded with zero inconsistencies — the tally is whole.
Clean,
/// Diagnosis succeeded but found a torn/corrupt tail — the tally undercounts.
Torn,
/// Diagnosis itself failed (e.g. corrupt `checkpoint.meta`) — integrity
/// could not be proven, so the tally must NOT be claimed complete.
Unverified,
}
impl Integrity {
/// Only a proven-clean scan is `complete`. A torn tail OR an unverifiable
/// store both yield `false` — we never assert completeness we cannot prove.
const fn is_complete(self) -> bool {
matches!(self, Self::Clean)
}
const fn describe(self) -> &'static str {
match self {
Self::Clean => "tally covers every WAL batch",
Self::Torn => "torn/corrupt tail — tally undercounts",
Self::Unverified => "integrity could not be verified — do not trust the tally",
}
}
}
/// `scope-stats` command output: the per-scope tally plus a tail-integrity
/// indicator so the operator can tell whether the tally is complete.
#[derive(Serialize)]
struct ScopeStatsOutput {
wal_segments: usize,
/// Number of WAL segment files, or `None` (JSON `null`, pretty `"unknown"`)
/// when the segment listing could not be read — never a fabricated `0` that
/// reads as "no segments" while a non-zero `total_events` was just tallied.
wal_segments: Option<usize>,
scope_stats: ScopeStatsBody,
}
@ -49,7 +88,16 @@ impl ScopeStatsOutput {
out.push_str("WAL Scope Stats\n");
out.push_str("===============\n\n");
let _ = writeln!(out, "WAL Segments: {}", self.wal_segments);
match self.wal_segments {
Some(n) => {
let _ = writeln!(out, "WAL Segments: {n}");
}
// Honest "unknown" rather than a fabricated 0: the listing failed
// after the events were already tallied (e.g. a concurrent rollover).
None => {
let _ = writeln!(out, "WAL Segments: unknown");
}
}
let _ = writeln!(out, "Total events: {}", body.total_events);
let _ = writeln!(out, "Share-eligible: {}", body.share_eligible);
out.push('\n');
@ -65,16 +113,13 @@ impl ScopeStatsOutput {
out.push_str("Integrity:\n");
let _ = writeln!(out, " Inconsistencies: {}", body.inconsistency_count);
// `complete` is the operator's go/no-go on trusting the tally: a torn or
// corrupt tail truncates the scan and the per-scope numbers undercount.
// corrupt tail truncates the scan and the per-scope numbers undercount,
// and an unverifiable store (failed diagnosis) is NOT claimed complete.
let _ = writeln!(
out,
" Complete: {} ({})",
body.complete,
if body.complete {
"tally covers every WAL batch"
} else {
"torn/corrupt tail — tally undercounts"
},
body.integrity.describe(),
);
out
@ -91,11 +136,18 @@ struct ScopeStatsBody {
/// per-scope tally undercounts the true on-disk event set. `0` means a
/// clean, complete scan.
inconsistency_count: u64,
/// `true` when no corruption was hit and the tally covers every WAL batch;
/// `false` when a torn/corrupt tail truncated the scan (see
/// `inconsistency_count`). Lets a consumer branch on integrity without
/// re-deriving it from the count.
/// `true` ONLY when integrity was proven clean (diagnosis succeeded with
/// zero inconsistencies). `false` on a torn/corrupt tail (see
/// `inconsistency_count`) AND when integrity could not be verified at all
/// (failed diagnosis) — we never assert completeness we cannot prove. Lets a
/// consumer branch on integrity without re-deriving it from the count.
complete: bool,
/// Tri-state integrity: `clean` / `torn` / `unverified`. Distinguishes a
/// torn tail (some data present, scan truncated) from an unverifiable store
/// (diagnosis itself failed), which `complete` alone collapses into one
/// `false`. A consumer that gates shipping on integrity should treat
/// anything other than `clean` as do-not-ship.
integrity: Integrity,
}
#[derive(Serialize)]
@ -117,9 +169,17 @@ struct ByScope {
/// tally alone cannot tell a clean scan from one truncated by a torn tail. We
/// therefore cross-check against [`diagnose_wal`], whose `inconsistency_count`
/// counts every corrupt/truncated batch (both readers use the same two-phase
/// validation and stop at the same boundary). A non-zero count is surfaced as
/// `inconsistency_count` + `complete: false` so the operator knows the per-scope
/// numbers undercount the true on-disk set rather than silently trusting them.
/// validation and stop at the same boundary). The diagnosis is a tri-state:
///
/// * `Ok(0 inconsistencies)` → [`Integrity::Clean`], `complete: true`, exit 0;
/// * `Ok(n > 0)` → [`Integrity::Torn`], `complete: false`, exit 0 (the tally
/// undercounts but the command still succeeds — the prefix is useful);
/// * `Err(_)` (e.g. a corrupt `checkpoint.meta` the segment scan never touched)
/// → [`Integrity::Unverified`], `complete: false`, and `EXIT_DEGRADED` (2) to
/// match `status`/`diagnostics` — integrity could NOT be proven, so a script's
/// `scope-stats && ship` must not treat the store as clean. Folding this case
/// into `complete: true` (the prior `map_or(0, …)`) was a silent false
/// positive precisely in the degraded case the indicator exists to flag.
pub(crate) fn run(base: &std::path::Path, pretty: bool) -> Result<(String, i32), CliError> {
use tidaldb::governance::SignalScope;
@ -128,7 +188,10 @@ pub(crate) fn run(base: &std::path::Path, pretty: bool) -> Result<(String, i32),
let mut stats = ScopeStats::default();
let mut inconsistency_count = 0u64;
let segment_count = if wal_dir.exists() {
let mut integrity = Integrity::Clean;
let mut exit_code = 0;
if wal_dir.exists() {
let events = tidaldb::wal::reader::read_all_events(&wal_dir)
.map_err(|e| CliError::new(format!("WAL read error: {e}")))?;
for ev in events {
@ -144,18 +207,37 @@ pub(crate) fn run(base: &std::path::Path, pretty: bool) -> Result<(String, i32),
// Cross-check tail integrity. diagnose_wal walks the same segments with
// identical validation and reports corrupt/truncated batches; a torn or
// corrupt tail that silently truncated `read_all_events` shows up here as
// a non-zero inconsistency count. If the diagnosis itself fails we treat
// it as an unknown-integrity scan (count stays 0 but we cannot prove
// completeness) rather than failing the command outright — the per-scope
// tally is still useful.
inconsistency_count =
tidaldb::wal::diagnostics::diagnose_wal(base).map_or(0, |r| r.inconsistency_count);
// a non-zero inconsistency count. Distinguish the THREE outcomes instead
// of folding a failed diagnosis into the clean path (W29).
let diag = tidaldb::wal::diagnostics::diagnose_wal(base);
inconsistency_count = diag.as_ref().map_or(0, |r| r.inconsistency_count);
integrity = match &diag {
Ok(r) if r.inconsistency_count == 0 => Integrity::Clean,
Ok(_) => Integrity::Torn,
// Integrity could not be verified (e.g. corrupt checkpoint.meta the
// segment scan never reads). Do NOT claim completeness; degrade the
// exit code so a gating script cannot mistake this for success.
Err(_) => {
exit_code = EXIT_DEGRADED;
Integrity::Unverified
}
};
}
// Segment count. `None` = the listing could not be read (reported as
// "unknown", never a fabricated 0); a missing/empty WAL dir is genuinely 0
// segments, a known count, so `Some(0)`.
//
// SUG#9: report an unreadable segment listing as `None`, not `0`. The listing
// can change between this scan and `read_all_events` on a live, lock-free
// inspector (a segment rolled/removed concurrently), and a `0` next to a
// non-zero `total_events` is internally inconsistent.
let segment_count: Option<usize> = if wal_dir.exists() {
tidaldb::wal::segment::list_segments(&wal_dir)
.ok()
.map(|s| s.len())
.unwrap_or(0)
} else {
0
Some(0)
};
let output = ScopeStatsOutput {
@ -171,16 +253,19 @@ pub(crate) fn run(base: &std::path::Path, pretty: bool) -> Result<(String, i32),
unknown: stats.unknown,
},
inconsistency_count,
complete: inconsistency_count == 0,
complete: integrity.is_complete(),
integrity,
},
};
// `--pretty` selects the human-readable section layout (consistent with
// `recover`/`diagnostics`); the default emits compact machine JSON.
// `recover`/`diagnostics`); the default emits compact machine JSON. The exit
// code carries the integrity verdict (0 clean/torn-but-readable, 2 when
// integrity could not be verified) — same contract as status/diagnostics.
if pretty {
Ok((output.format_pretty(), 0))
Ok((output.format_pretty(), exit_code))
} else {
Ok((render_json(&output, false)?, 0))
Ok((render_json(&output, false)?, exit_code))
}
}
@ -190,7 +275,7 @@ mod tests {
fn sample() -> ScopeStatsOutput {
ScopeStatsOutput {
wal_segments: 3,
wal_segments: Some(3),
scope_stats: ScopeStatsBody {
total_events: 10,
share_eligible: 6,
@ -203,6 +288,7 @@ mod tests {
},
inconsistency_count: 0,
complete: true,
integrity: Integrity::Clean,
},
}
}
@ -224,9 +310,68 @@ mod tests {
let mut output = sample();
output.scope_stats.inconsistency_count = 2;
output.scope_stats.complete = false;
output.scope_stats.integrity = Integrity::Torn;
let out = output.format_pretty();
assert!(out.contains("Inconsistencies: 2"));
assert!(out.contains("Complete: false"));
assert!(out.contains("undercounts"));
}
/// W29: an unverifiable store (failed diagnosis) must NOT be claimed
/// complete. `Integrity::Unverified` yields `is_complete() == false` and a
/// distinct, honest description — not the clean-path "covers every batch".
#[test]
fn unverified_integrity_is_never_complete() {
assert!(
!Integrity::Unverified.is_complete(),
"unverified integrity must never be reported as complete"
);
assert!(
!Integrity::Torn.is_complete(),
"a torn tail must never be reported as complete"
);
assert!(
Integrity::Clean.is_complete(),
"only a proven-clean scan is complete"
);
let desc = Integrity::Unverified.describe();
assert!(
desc.contains("could not be verified"),
"unverified must describe the integrity gap honestly, got {desc:?}"
);
assert_ne!(
Integrity::Unverified.describe(),
Integrity::Clean.describe(),
"unverified must not borrow the clean-path message"
);
}
/// W29: `Integrity` serializes to the documented `snake_case` discriminants so
/// a consumer can branch on `integrity == "unverified"` to refuse to ship.
#[test]
fn integrity_serializes_snake_case() {
let json = |i: Integrity| {
serde_json::to_value(i)
.expect("Integrity serializes")
.as_str()
.expect("Integrity is a JSON string")
.to_string()
};
assert_eq!(json(Integrity::Clean), "clean");
assert_eq!(json(Integrity::Torn), "torn");
assert_eq!(json(Integrity::Unverified), "unverified");
}
/// SUG#9: an unreadable segment listing renders as "unknown", never a
/// fabricated `0` that reads as "no segments".
#[test]
fn pretty_reports_unknown_segment_count() {
let mut output = sample();
output.wal_segments = None;
let out = output.format_pretty();
assert!(
out.contains("WAL Segments: unknown"),
"unreadable segment listing must render as unknown, got:\n{out}"
);
}
}