fix: M0-M10 remediation — deferred post-filters, WAL hardening, docker build fixes
- Extract shared deferred post-filter (InCollection/MinSignal/MaxSignal/ NearLocation/SocialGraph) into query/executor/post_filter.rs so RETRIEVE and SEARCH apply identical predicates; SEARCH previously ignored four variants - Harden WAL writer/compaction/dedup paths - Brute-force vector store fixes + tests - Governance community ledger updates - Pin docker builders to bookworm (glibc/libmvec match) and add protobuf-compiler for tidal-net's tonic-build - Add M0-M10 code-review document
This commit is contained in:
parent
3bcfb3c576
commit
69ae6e64a1
@ -3,7 +3,9 @@
|
||||
# Runs a simulated 3-region cluster (us-east, eu-west, ap-south) behind a
|
||||
# single HTTP surface on port 9500. See docs/runbooks/cluster.md for the
|
||||
# operational API.
|
||||
FROM rust:1.91 AS builder
|
||||
# Pin the builder to bookworm so its glibc matches the bookworm-slim runtime;
|
||||
# the default tag tracks trixie, whose `libmvec.so.1` is absent on bookworm.
|
||||
FROM rust:1.91-bookworm AS builder
|
||||
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
@ -23,7 +25,9 @@ COPY applications/iknowyou/engine/Cargo.toml applications/iknowyou/engine/Cargo.
|
||||
# Copy full workspace.
|
||||
COPY . .
|
||||
|
||||
RUN apt-get update && apt-get install -y g++ && rm -rf /var/lib/apt/lists/*
|
||||
# g++ builds the usearch C++ HNSW core; protobuf-compiler (protoc) is required by
|
||||
# tidal-net's build script (tonic-build compiles the WAL-shipping .proto).
|
||||
RUN apt-get update && apt-get install -y g++ protobuf-compiler && rm -rf /var/lib/apt/lists/*
|
||||
RUN cargo build -p tidal-server --release
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
@ -1,6 +1,12 @@
|
||||
FROM rust:1.91-slim AS builder
|
||||
# Pin the builder to bookworm so its glibc matches the bookworm-slim runtime.
|
||||
# The default slim tag tracks Debian trixie, whose vectorized-math `libmvec.so.1`
|
||||
# is absent on bookworm and aborts the binary at startup.
|
||||
FROM rust:1.91-slim-bookworm AS builder
|
||||
WORKDIR /build
|
||||
RUN apt-get update && apt-get install -y pkg-config libssl-dev g++ && rm -rf /var/lib/apt/lists/*
|
||||
# protobuf-compiler (protoc) is required by tidal-net's build script (tonic-build
|
||||
# compiles the WAL-shipping .proto); tidal-server depends on tidal-net for gRPC
|
||||
# cluster replication. g++ builds the usearch C++ HNSW core.
|
||||
RUN apt-get update && apt-get install -y pkg-config libssl-dev g++ protobuf-compiler && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy workspace manifests first for layer caching.
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
|
||||
@ -1,6 +1,13 @@
|
||||
FROM rust:1.91 AS builder
|
||||
# Pin the builder to bookworm so its glibc matches the bookworm-slim runtime;
|
||||
# the default tag tracks trixie, whose `libmvec.so.1` is absent on bookworm.
|
||||
FROM rust:1.91-bookworm AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# protobuf-compiler (protoc) is required by tidal-net's build script (tonic-build
|
||||
# compiles the WAL-shipping .proto); tidal-server depends on tidal-net for gRPC
|
||||
# cluster replication. The full rust image already ships g++/pkg-config/libssl.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends protobuf-compiler && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy workspace manifests first for layer caching.
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY tidal/Cargo.toml tidal/Cargo.toml
|
||||
|
||||
1754
docs/reviews/M0-M10-code-review.md
Normal file
1754
docs/reviews/M0-M10-code-review.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -442,11 +442,15 @@ pub(super) fn run_checkpoint_thread(
|
||||
|
||||
// Compact WAL segments covered by the checkpoint.
|
||||
// This runs AFTER the checkpoint is durable, so deleted
|
||||
// segments are guaranteed to be redundant.
|
||||
// segments are guaranteed to be redundant. The WAL writer thread
|
||||
// is still live here, so we MUST use the online variant, which
|
||||
// never deletes the segment the writer is appending to (deleting
|
||||
// it would unlink the inode out from under the open FD and lose
|
||||
// post-checkpoint writes on Linux).
|
||||
if let Some(ref dir) = wal_dir
|
||||
&& seq > 0
|
||||
{
|
||||
match crate::wal::compaction::compact_wal(dir, seq) {
|
||||
match crate::wal::compaction::compact_wal_online(dir, seq) {
|
||||
Ok(result) => {
|
||||
#[cfg(feature = "metrics")]
|
||||
{
|
||||
|
||||
@ -26,7 +26,7 @@ use crate::{
|
||||
ledger::types::EntitySignalEntry,
|
||||
warm::BucketedCounter,
|
||||
},
|
||||
storage::{StorageEngine, Tag, encode_key, entity_tag_prefix},
|
||||
storage::{StorageEngine, Tag, WriteBatch, encode_key, entity_tag_prefix},
|
||||
};
|
||||
|
||||
/// The writer that produced a contribution.
|
||||
@ -401,27 +401,38 @@ impl CommunityLedger {
|
||||
///
|
||||
/// Returns `TidalError::Storage` on a write failure.
|
||||
pub fn checkpoint(&self, storage: &dyn StorageEngine) -> crate::Result<()> {
|
||||
// Clear any stale entries first so a purged contributor does not linger.
|
||||
// This ledger is the SOLE durable copy of per-contributor state — it is
|
||||
// not rebuildable from the WAL — so the old→new snapshot swap MUST be
|
||||
// all-or-nothing. The previous implementation issued individual
|
||||
// `delete()`s of every stale row followed by individual `put()`s of the
|
||||
// current cells; a crash or I/O error between the deletes and the puts
|
||||
// left the durable snapshot partially erased, silently losing community
|
||||
// contributions on a normal shutdown-checkpoint. We stage every
|
||||
// stale-key delete and every current-cell put into a single WriteBatch
|
||||
// and commit once, matching the cohort/co-engagement checkpoints, so the
|
||||
// backend applies the whole replacement atomically.
|
||||
let prefix = entity_tag_prefix(EntityId::new(0), Tag::Community);
|
||||
let stale: Vec<_> = storage
|
||||
.scan_prefix(&prefix)
|
||||
.flatten()
|
||||
.map(|(k, _)| k)
|
||||
.collect();
|
||||
for k in stale {
|
||||
storage.delete(&k).map_err(TidalError::from)?;
|
||||
let mut batch = WriteBatch::new();
|
||||
|
||||
// Stage deletion of any stale entries so a purged contributor does not
|
||||
// linger after the swap.
|
||||
for item in storage.scan_prefix(&prefix) {
|
||||
let (key, _) = item.map_err(TidalError::from)?;
|
||||
batch.delete(key);
|
||||
}
|
||||
|
||||
// Stage the current snapshot.
|
||||
for cell in &self.cells {
|
||||
let (community, entity, type_id) = *cell.key();
|
||||
for ((user, writer, epoch), entry) in &cell.value().contributors {
|
||||
let suffix =
|
||||
encode_contributor_suffix(community, *user, *epoch, entity, type_id, writer);
|
||||
let key = encode_key(EntityId::new(0), Tag::Community, &suffix);
|
||||
storage
|
||||
.put(&key, &serialize_entry(entity, type_id, entry))
|
||||
.map_err(TidalError::from)?;
|
||||
batch.put(key, serialize_entry(entity, type_id, entry));
|
||||
}
|
||||
}
|
||||
|
||||
storage.write_batch(batch).map_err(TidalError::from)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -869,6 +880,103 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Count the durable `Tag::Community` rows in a backend — i.e. how many
|
||||
/// contributor cells the checkpoint actually persisted.
|
||||
fn durable_community_row_count(storage: &crate::storage::InMemoryBackend) -> usize {
|
||||
let prefix = entity_tag_prefix(EntityId::new(0), Tag::Community);
|
||||
storage.scan_prefix(&prefix).flatten().count()
|
||||
}
|
||||
|
||||
/// Total number of in-memory contributor cells across every community cell.
|
||||
fn in_memory_contributor_count(l: &CommunityLedger) -> usize {
|
||||
l.cells.iter().map(|c| c.value().contributors.len()).sum()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkpoint_persists_exactly_the_in_memory_cells() {
|
||||
// The durable snapshot must contain one row per in-memory contributor
|
||||
// cell — no more, no fewer. This is the all-or-nothing-swap invariant:
|
||||
// every cell is persisted and nothing stale lingers.
|
||||
use crate::storage::InMemoryBackend;
|
||||
|
||||
let storage = InMemoryBackend::new();
|
||||
let l = ledger();
|
||||
let c = CommunityId(1);
|
||||
let like = l.resolve_signal_type("like").unwrap();
|
||||
let save = l.resolve_signal_type("save").unwrap();
|
||||
let ts = Timestamp::now().as_nanos();
|
||||
|
||||
// Two contributors across two items and two signal types.
|
||||
for (user, item, tid) in [
|
||||
(UserId(1), EntityId::new(10), like),
|
||||
(UserId(1), EntityId::new(20), save),
|
||||
(UserId(2), EntityId::new(10), like),
|
||||
] {
|
||||
l.record(c, item, tid, user, "agent", 0, 1.0, ts);
|
||||
}
|
||||
|
||||
l.checkpoint(&storage).unwrap();
|
||||
|
||||
let durable = durable_community_row_count(&storage);
|
||||
let in_mem = in_memory_contributor_count(&l);
|
||||
assert_eq!(
|
||||
durable, in_mem,
|
||||
"durable rows ({durable}) must exactly equal in-memory cells ({in_mem})"
|
||||
);
|
||||
assert_eq!(durable, 3, "expected 3 contributor rows persisted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recheckpoint_after_purge_replaces_snapshot_atomically() {
|
||||
// After a purge shrinks the in-memory state, a re-checkpoint must leave
|
||||
// the durable snapshot with *exactly* the surviving cells — no stale
|
||||
// rows for the purged contributor. Because the swap is a single atomic
|
||||
// WriteBatch (stale deletes + current puts), the durable row count
|
||||
// always matches the in-memory cell count, never the pre-purge count.
|
||||
use crate::storage::InMemoryBackend;
|
||||
|
||||
let storage = InMemoryBackend::new();
|
||||
let l = ledger();
|
||||
let c = CommunityId(1);
|
||||
let like = l.resolve_signal_type("like").unwrap();
|
||||
let ts = Timestamp::now().as_nanos();
|
||||
|
||||
// Three contributors, then checkpoint.
|
||||
for u in 1..=3u64 {
|
||||
l.record(c, EntityId::new(5), like, UserId(u), "", 0, 1.0, ts);
|
||||
}
|
||||
l.checkpoint(&storage).unwrap();
|
||||
assert_eq!(durable_community_row_count(&storage), 3);
|
||||
|
||||
// Purge user 2, then re-checkpoint. The durable snapshot must shed the
|
||||
// purged row and hold only the two survivors.
|
||||
let removed = l.purge_contributor(c, UserId(2), 0..=u32::MAX);
|
||||
assert_eq!(removed, 1);
|
||||
l.checkpoint(&storage).unwrap();
|
||||
|
||||
let durable = durable_community_row_count(&storage);
|
||||
assert_eq!(
|
||||
durable,
|
||||
in_memory_contributor_count(&l),
|
||||
"re-checkpoint must leave exactly the surviving cells"
|
||||
);
|
||||
assert_eq!(
|
||||
durable, 2,
|
||||
"purged contributor's stale row must not linger after re-checkpoint"
|
||||
);
|
||||
|
||||
// And a fresh restore reflects exactly the post-purge state.
|
||||
let restored = ledger();
|
||||
restored.restore(&storage);
|
||||
assert_eq!(
|
||||
restored
|
||||
.windowed_count(c, EntityId::new(5), "like", Window::AllTime)
|
||||
.unwrap(),
|
||||
2,
|
||||
"restored aggregate must reflect the purge, not the pre-purge snapshot"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_purge_tombstone_purges_and_sets_watermark() {
|
||||
use crate::governance::tombstone::PurgeTombstone;
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
|
||||
pub mod candidate_gen;
|
||||
pub mod personalization;
|
||||
pub mod post_filter;
|
||||
pub mod social_filter;
|
||||
pub mod user_filter;
|
||||
|
||||
|
||||
@ -9,9 +9,8 @@ use std::{collections::HashSet, time::Instant};
|
||||
|
||||
use roaring::RoaringBitmap;
|
||||
|
||||
use super::{RetrieveExecutor, candidate_gen, social_filter, user_filter};
|
||||
use super::{RetrieveExecutor, candidate_gen, post_filter, user_filter};
|
||||
use crate::{
|
||||
db::deserialize_metadata as deserialize_item_metadata,
|
||||
query::{
|
||||
retrieve::{Cursor, QueryError, Results, Retrieve, RetrieveResult, Signal},
|
||||
stats::QueryStats,
|
||||
@ -21,14 +20,11 @@ use crate::{
|
||||
profile::{CandidateStrategy, Sort},
|
||||
},
|
||||
schema::{EntityId, Timestamp},
|
||||
storage::{
|
||||
Tag, encode_key,
|
||||
indexes::{
|
||||
storage::indexes::{
|
||||
bitmap::BitmapIndex,
|
||||
filter::{FilterEvaluator, FilterExpr, FilterResult},
|
||||
range::RangeIndex,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
impl RetrieveExecutor<'_> {
|
||||
@ -239,110 +235,22 @@ impl RetrieveExecutor<'_> {
|
||||
|
||||
tracing::trace!(candidates = candidates.len(), "stage 2: filter applied");
|
||||
|
||||
// ── Stage 2.2: InCollection Post-Filter (M6p4) ──────────────────
|
||||
// ── Stage 2.2/2.3/2.4: Deferred Post-Filters ────────────────────
|
||||
// InCollection (M6p4), MinSignal/MaxSignal thresholds (M6p3), and
|
||||
// NearLocation geo (M6p3). These evaluate to the full universe at the
|
||||
// FilterEvaluator level and must be applied here against the live
|
||||
// collection index, ledger, and item metadata. Shared verbatim with the
|
||||
// SEARCH pipeline via `post_filter::apply_deferred_post_filters` so the
|
||||
// two surfaces cannot drift (a missing arm there silently leaked items
|
||||
// that violated the filter).
|
||||
if let Some(ref filter_expr) = combined_filter {
|
||||
let coll_filters = user_filter::extract_collection_filters(filter_expr);
|
||||
if !coll_filters.is_empty()
|
||||
&& let Some(coll_idx) = self.collection_index
|
||||
{
|
||||
for cf in &coll_filters {
|
||||
if let FilterExpr::InCollection(cid) = cf {
|
||||
if let Some(bm) = coll_idx.bitmap(*cid) {
|
||||
candidates.retain(|id| bm.contains(id.as_u64() as u32));
|
||||
} else {
|
||||
// Collection not found or empty -- retain nothing.
|
||||
candidates.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::trace!(
|
||||
candidates = candidates.len(),
|
||||
"stage 2.2: InCollection filters applied"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stage 2.3: Signal Threshold Post-Filters (M6p3) ─────────────
|
||||
if let Some(ref filter_expr) = combined_filter {
|
||||
let sig_filters = user_filter::extract_signal_threshold_filters(filter_expr);
|
||||
if !sig_filters.is_empty() {
|
||||
for sf in &sig_filters {
|
||||
// An unknown signal name in a threshold filter must fail the
|
||||
// query loudly rather than silently retaining/dropping every
|
||||
// candidate, so the fallible `read_agg` is evaluated up front
|
||||
// and its error mapped to a `QueryError`.
|
||||
let (signal, threshold, keep_if_ge) = match sf {
|
||||
FilterExpr::MinSignal { signal, threshold } => (signal, *threshold, true),
|
||||
FilterExpr::MaxSignal { signal, threshold } => (signal, *threshold, false),
|
||||
_ => continue,
|
||||
let ctx = post_filter::PostFilterCtx {
|
||||
ledger: self.ledger,
|
||||
collection_index: self.collection_index,
|
||||
items_storage: self.items_storage,
|
||||
degradation_level: self.degradation_level,
|
||||
};
|
||||
let mut kept = Vec::with_capacity(candidates.len());
|
||||
for &eid in &candidates {
|
||||
let val = crate::ranking::executor::helpers::read_agg(
|
||||
eid,
|
||||
signal,
|
||||
&crate::ranking::profile::SignalAgg::Value,
|
||||
crate::schema::Window::AllTime,
|
||||
self.ledger,
|
||||
self.degradation_level,
|
||||
)
|
||||
.map_err(|e| QueryError::InvalidFilter {
|
||||
field: signal.clone(),
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
if (keep_if_ge && val >= threshold) || (!keep_if_ge && val <= threshold) {
|
||||
kept.push(eid);
|
||||
}
|
||||
}
|
||||
candidates = kept;
|
||||
}
|
||||
tracing::trace!(
|
||||
candidates = candidates.len(),
|
||||
"stage 2.3: signal threshold filters applied"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stage 2.4: Geographic Post-Filter (M6p3) ────────────────────
|
||||
if let Some(ref filter_expr) = combined_filter {
|
||||
let geo_filters = user_filter::extract_geo_filters(filter_expr);
|
||||
if !geo_filters.is_empty()
|
||||
&& let Some(storage) = self.items_storage
|
||||
{
|
||||
candidates.retain(|&eid| {
|
||||
let key = encode_key(eid, Tag::Meta, b"");
|
||||
let meta = storage
|
||||
.get(&key)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|b| deserialize_item_metadata(&b))
|
||||
.unwrap_or_default();
|
||||
geo_filters.iter().all(|gf| match gf {
|
||||
FilterExpr::NearLocation {
|
||||
lat,
|
||||
lng,
|
||||
radius_km,
|
||||
} => {
|
||||
let item_lat = meta.get("latitude").and_then(|v| v.parse::<f64>().ok());
|
||||
let item_lng =
|
||||
meta.get("longitude").and_then(|v| v.parse::<f64>().ok());
|
||||
match (item_lat, item_lng) {
|
||||
(Some(ilat), Some(ilng)) => {
|
||||
crate::ranking::executor::formulas::haversine_km(
|
||||
*lat, *lng, ilat, ilng,
|
||||
) <= *radius_km
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
_ => true,
|
||||
})
|
||||
});
|
||||
tracing::trace!(
|
||||
candidates = candidates.len(),
|
||||
"stage 2.4: geographic filters applied"
|
||||
);
|
||||
}
|
||||
post_filter::apply_deferred_post_filters(filter_expr, &mut candidates, &ctx)?;
|
||||
}
|
||||
|
||||
// ── Stage 2.5: User-Context Filtering (M3) ─────────────────────
|
||||
@ -400,13 +308,16 @@ impl RetrieveExecutor<'_> {
|
||||
depth,
|
||||
} => {
|
||||
// Social graph: constrain to items from followed creators
|
||||
// (depth=1) or expanded community items (depth=2).
|
||||
if let Some(ci) = self.creator_items {
|
||||
let sg_bitmap = social_filter::social_graph_bitmap(
|
||||
*uid, *depth, user_state, ci,
|
||||
// (depth=1) or expanded community items (depth=2). Shared
|
||||
// with the SEARCH pipeline so neither surface silently
|
||||
// ignores a `social_graph` filter.
|
||||
post_filter::apply_social_graph_filter(
|
||||
*uid,
|
||||
*depth,
|
||||
&mut candidates,
|
||||
user_state,
|
||||
self.creator_items,
|
||||
);
|
||||
candidates.retain(|id| sg_bitmap.contains(id.as_u64() as u32));
|
||||
}
|
||||
}
|
||||
// Other variants are not user-state inclusion filters.
|
||||
_ => {}
|
||||
|
||||
249
tidal/src/query/executor/post_filter.rs
Normal file
249
tidal/src/query/executor/post_filter.rs
Normal file
@ -0,0 +1,249 @@
|
||||
//! Shared deferred post-filter application for RETRIEVE and SEARCH.
|
||||
//!
|
||||
//! The deferred filter variants — `InCollection`, `MinSignal`/`MaxSignal`,
|
||||
//! `NearLocation`, and `SocialGraph` — evaluate to the *full universe* at the
|
||||
//! [`FilterEvaluator`] level (see [`crate::storage::indexes::filter::expr`]).
|
||||
//! They carry no index-backed predicate and must be applied as **post-filters**
|
||||
//! after candidate generation/retrieval, against the live ledger, item
|
||||
//! metadata, collection index, and social graph.
|
||||
//!
|
||||
//! Both the RETRIEVE pipeline (Stages 2.2/2.3/2.4 and the `SocialGraph` arm of
|
||||
//! 2.5) and the SEARCH pipeline (Stage 2.5) apply the *same* predicates. Before
|
||||
//! this module existed, SEARCH applied only the user-state inclusion filters
|
||||
//! (`Saved`/`Liked`/`InProgress`) and silently ignored the four deferred
|
||||
//! variants above — a well-formed SEARCH with `min_signal`/`near_location`/
|
||||
//! `in_collection`/`social_graph` returned items that violated the filter, with
|
||||
//! no error and nothing logged. Extracting the predicates here means RETRIEVE
|
||||
//! and SEARCH cannot drift again: both call one function.
|
||||
//!
|
||||
//! [`FilterEvaluator`]: crate::storage::indexes::filter::FilterEvaluator
|
||||
|
||||
use crate::{
|
||||
entities::{CreatorItemsBitmap, UserStateIndex},
|
||||
load::DegradationLevel,
|
||||
query::{
|
||||
executor::{social_filter, user_filter},
|
||||
retrieve::QueryError,
|
||||
},
|
||||
schema::EntityId,
|
||||
signals::SignalLedger,
|
||||
storage::{StorageEngine, Tag, encode_key, indexes::filter::FilterExpr},
|
||||
};
|
||||
|
||||
/// Borrowed infrastructure needed to apply the index/ledger/metadata-backed
|
||||
/// deferred post-filters (`InCollection`, `MinSignal`/`MaxSignal`,
|
||||
/// `NearLocation`).
|
||||
///
|
||||
/// The collection index and item storage are optional because a filter is only
|
||||
/// enforced when the corresponding index/store is wired. When that handle is
|
||||
/// missing the corresponding filter is a no-op (the candidate set is left
|
||||
/// unchanged) — matching the RETRIEVE pipeline's pre-extraction behavior
|
||||
/// byte-for-byte and the graceful-degradation contract the rest of the executor
|
||||
/// follows. The real `TidalDb` paths always wire the collection index and item
|
||||
/// storage, so the no-op branch is only reachable from minimally-constructed
|
||||
/// executors (e.g. unit tests that exercise other stages).
|
||||
///
|
||||
/// `SocialGraph` is *not* applied through this context: it lives in each
|
||||
/// pipeline's user-context block via [`apply_social_graph_filter`], so its
|
||||
/// `UserStateIndex`/`CreatorItemsBitmap` handles are passed there directly.
|
||||
pub(crate) struct PostFilterCtx<'a> {
|
||||
/// Signal ledger — required for `MinSignal`/`MaxSignal`. Always present.
|
||||
pub ledger: &'a SignalLedger,
|
||||
/// Collection index — required for `InCollection`.
|
||||
pub collection_index: Option<&'a crate::entities::CollectionIndex>,
|
||||
/// Item metadata storage — required for `NearLocation` (lat/lng lookup).
|
||||
pub items_storage: Option<&'a dyn StorageEngine>,
|
||||
/// Load-degradation level, threaded into ledger reads for `MinSignal`/`MaxSignal`.
|
||||
pub degradation_level: DegradationLevel,
|
||||
}
|
||||
|
||||
/// Apply the `InCollection`, `MinSignal`/`MaxSignal`, and `NearLocation`
|
||||
/// deferred post-filters to `candidates`, in that order.
|
||||
///
|
||||
/// This is the shared core of RETRIEVE Stages 2.2/2.3/2.4 and the equivalent
|
||||
/// SEARCH stage. The `SocialGraph` arm is intentionally **not** applied here:
|
||||
/// it lives inside each pipeline's user-context block (after the
|
||||
/// `Saved`/`Liked`/`InProgress` inclusion arms) so the RETRIEVE ordering stays
|
||||
/// byte-for-byte identical. Call [`apply_social_graph_filter`] from that block.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`QueryError::InvalidFilter`] if a `MinSignal`/`MaxSignal` filter
|
||||
/// names a signal the schema does not define — the same loud-failure contract
|
||||
/// RETRIEVE has: an unknown signal must fail the query, never silently retain or
|
||||
/// drop every candidate.
|
||||
pub(crate) fn apply_deferred_post_filters(
|
||||
filter_expr: &FilterExpr,
|
||||
candidates: &mut Vec<EntityId>,
|
||||
ctx: &PostFilterCtx<'_>,
|
||||
) -> Result<(), QueryError> {
|
||||
apply_in_collection(filter_expr, candidates, ctx.collection_index);
|
||||
apply_signal_thresholds(filter_expr, candidates, ctx.ledger, ctx.degradation_level)?;
|
||||
apply_near_location(filter_expr, candidates, ctx.items_storage);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// RETRIEVE Stage 2.2 / SEARCH equivalent: retain only items in every named
|
||||
/// collection. A missing/empty *collection* retains nothing for that filter —
|
||||
/// an `InCollection(c)` for a collection with no items matches the empty set,
|
||||
/// never the full set. An absent *index handle* is a no-op (matching RETRIEVE's
|
||||
/// pre-extraction behavior); the real DB path always wires it.
|
||||
fn apply_in_collection(
|
||||
filter_expr: &FilterExpr,
|
||||
candidates: &mut Vec<EntityId>,
|
||||
collection_index: Option<&crate::entities::CollectionIndex>,
|
||||
) {
|
||||
let coll_filters = user_filter::extract_collection_filters(filter_expr);
|
||||
if coll_filters.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(coll_idx) = collection_index else {
|
||||
// No collection index wired: leave candidates unchanged, matching the
|
||||
// RETRIEVE pipeline's `if !empty && let Some(idx)` guard byte-for-byte.
|
||||
return;
|
||||
};
|
||||
for cf in &coll_filters {
|
||||
if let FilterExpr::InCollection(cid) = cf {
|
||||
if let Some(bm) = coll_idx.bitmap(*cid) {
|
||||
// SAFETY: all entity IDs stored in RoaringBitmaps are 32-bit;
|
||||
// truncation is intentional and correct throughout the executor.
|
||||
candidates.retain(|id| bm.contains(id.as_u64() as u32));
|
||||
} else {
|
||||
// Collection not found or empty -- retain nothing.
|
||||
candidates.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::trace!(
|
||||
candidates = candidates.len(),
|
||||
"post-filter: InCollection applied"
|
||||
);
|
||||
}
|
||||
|
||||
/// RETRIEVE Stage 2.3 / SEARCH equivalent: retain only items whose `AllTime`
|
||||
/// windowed count for the named signal satisfies the threshold (>= for
|
||||
/// `MinSignal`, <= for `MaxSignal`). Thresholds are conjunctive.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`QueryError::InvalidFilter`] when the signal name is not in the
|
||||
/// schema (`read_agg` propagates the schema error); this fails the query loudly
|
||||
/// rather than silently retaining or dropping every candidate.
|
||||
fn apply_signal_thresholds(
|
||||
filter_expr: &FilterExpr,
|
||||
candidates: &mut Vec<EntityId>,
|
||||
ledger: &SignalLedger,
|
||||
degradation_level: DegradationLevel,
|
||||
) -> Result<(), QueryError> {
|
||||
let sig_filters = user_filter::extract_signal_threshold_filters(filter_expr);
|
||||
if sig_filters.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
for sf in &sig_filters {
|
||||
let (signal, threshold, keep_if_ge) = match sf {
|
||||
FilterExpr::MinSignal { signal, threshold } => (signal, *threshold, true),
|
||||
FilterExpr::MaxSignal { signal, threshold } => (signal, *threshold, false),
|
||||
_ => continue,
|
||||
};
|
||||
let mut kept = Vec::with_capacity(candidates.len());
|
||||
for &eid in &*candidates {
|
||||
let val = crate::ranking::executor::helpers::read_agg(
|
||||
eid,
|
||||
signal,
|
||||
&crate::ranking::profile::SignalAgg::Value,
|
||||
crate::schema::Window::AllTime,
|
||||
ledger,
|
||||
degradation_level,
|
||||
)
|
||||
.map_err(|e| QueryError::InvalidFilter {
|
||||
field: signal.clone(),
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
if (keep_if_ge && val >= threshold) || (!keep_if_ge && val <= threshold) {
|
||||
kept.push(eid);
|
||||
}
|
||||
}
|
||||
*candidates = kept;
|
||||
}
|
||||
tracing::trace!(
|
||||
candidates = candidates.len(),
|
||||
"post-filter: signal thresholds applied"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// RETRIEVE Stage 2.4 / SEARCH equivalent: retain only items within `radius_km`
|
||||
/// of every `NearLocation` filter, via Haversine distance against the item's
|
||||
/// `latitude`/`longitude` metadata. Items missing coordinates (or with no
|
||||
/// metadata) are excluded. An absent *item storage* handle is a no-op (matching
|
||||
/// RETRIEVE's pre-extraction behavior); the real DB path always wires it.
|
||||
fn apply_near_location(
|
||||
filter_expr: &FilterExpr,
|
||||
candidates: &mut Vec<EntityId>,
|
||||
items_storage: Option<&dyn StorageEngine>,
|
||||
) {
|
||||
let geo_filters = user_filter::extract_geo_filters(filter_expr);
|
||||
if geo_filters.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(storage) = items_storage else {
|
||||
// No item storage wired: leave candidates unchanged, matching the
|
||||
// RETRIEVE pipeline's `if !empty && let Some(storage)` guard.
|
||||
return;
|
||||
};
|
||||
candidates.retain(|&eid| {
|
||||
let key = encode_key(eid, Tag::Meta, b"");
|
||||
let meta = storage
|
||||
.get(&key)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|b| crate::db::deserialize_metadata(&b))
|
||||
.unwrap_or_default();
|
||||
geo_filters.iter().all(|gf| match gf {
|
||||
FilterExpr::NearLocation {
|
||||
lat,
|
||||
lng,
|
||||
radius_km,
|
||||
} => {
|
||||
let item_lat = meta.get("latitude").and_then(|v| v.parse::<f64>().ok());
|
||||
let item_lng = meta.get("longitude").and_then(|v| v.parse::<f64>().ok());
|
||||
match (item_lat, item_lng) {
|
||||
(Some(ilat), Some(ilng)) => {
|
||||
crate::ranking::executor::formulas::haversine_km(*lat, *lng, ilat, ilng)
|
||||
<= *radius_km
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
_ => true,
|
||||
})
|
||||
});
|
||||
tracing::trace!(
|
||||
candidates = candidates.len(),
|
||||
"post-filter: NearLocation applied"
|
||||
);
|
||||
}
|
||||
|
||||
/// Apply the `SocialGraph` inclusion arm: when the filter contains a
|
||||
/// `SocialGraph { user_id, depth }` and both `user_state` and `creator_items`
|
||||
/// are wired, retain only items reachable from the user's social graph.
|
||||
///
|
||||
/// Separated from [`apply_deferred_post_filters`] so each pipeline can call it
|
||||
/// at the exact position inside its user-context block (after the
|
||||
/// `Saved`/`Liked`/`InProgress` arms), preserving RETRIEVE's ordering.
|
||||
pub(crate) fn apply_social_graph_filter(
|
||||
user_id: u64,
|
||||
depth: u8,
|
||||
candidates: &mut Vec<EntityId>,
|
||||
user_state: &UserStateIndex,
|
||||
creator_items: Option<&CreatorItemsBitmap>,
|
||||
) {
|
||||
let Some(ci) = creator_items else {
|
||||
return;
|
||||
};
|
||||
let sg_bitmap = social_filter::social_graph_bitmap(user_id, depth, user_state, ci);
|
||||
// SAFETY: all entity IDs stored in RoaringBitmaps are 32-bit; truncation is
|
||||
// intentional and correct throughout the executor (a >u32 id can never be a
|
||||
// bitmap member, so it correctly fails this inclusion test).
|
||||
candidates.retain(|id| sg_bitmap.contains(id.as_u64() as u32));
|
||||
}
|
||||
@ -23,8 +23,7 @@ use roaring::RoaringBitmap;
|
||||
use super::{
|
||||
super::{
|
||||
executor_helpers::{
|
||||
build_user_context, evaluate_metadata_filter, extract_user_state_filters,
|
||||
has_creator_metadata_filter,
|
||||
build_user_context, evaluate_metadata_filter, has_creator_metadata_filter,
|
||||
},
|
||||
scope::ScopeResolver,
|
||||
types::{Search, SearchResultItem, SearchResults},
|
||||
@ -162,6 +161,9 @@ impl SearchExecutor<'_> {
|
||||
self.apply_metadata_filter(query, &mut candidates);
|
||||
self.apply_creator_metadata_filter(query, &mut candidates);
|
||||
|
||||
// ── Stage 2.2/2.3/2.4: Deferred Post-Filters ────────────────────────
|
||||
self.apply_deferred_post_filters(query, &mut candidates)?;
|
||||
|
||||
// ── Stage 2.5: User-Context Filtering ────────────────────────────────
|
||||
self.apply_user_context_filter(query, &mut candidates);
|
||||
|
||||
@ -438,6 +440,38 @@ impl SearchExecutor<'_> {
|
||||
}
|
||||
|
||||
/// Stage 2.5: user-context filtering (suppression + inclusion).
|
||||
/// Apply the deferred post-filters (`InCollection`, `MinSignal`/`MaxSignal`,
|
||||
/// `NearLocation`) that evaluate to the full universe at the
|
||||
/// `FilterEvaluator` level and so must run as Stage 2.x post-filters. Shared
|
||||
/// verbatim with the RETRIEVE pipeline via
|
||||
/// `post_filter::apply_deferred_post_filters` — before SEARCH called this, it
|
||||
/// silently ignored these filters and returned items that violated them.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Propagates `QueryError` from the shared applier (e.g. an unknown signal
|
||||
/// name referenced by a Min/MaxSignal threshold).
|
||||
fn apply_deferred_post_filters(
|
||||
&self,
|
||||
query: &Search,
|
||||
candidates: &mut Vec<EntityId>,
|
||||
) -> Result<(), QueryError> {
|
||||
if let Some(ref filter_expr) = query.combined_filter() {
|
||||
let ctx = crate::query::executor::post_filter::PostFilterCtx {
|
||||
ledger: self.ledger,
|
||||
collection_index: self.collection_index,
|
||||
items_storage: self.items_storage,
|
||||
degradation_level: self.degradation_level,
|
||||
};
|
||||
crate::query::executor::post_filter::apply_deferred_post_filters(
|
||||
filter_expr,
|
||||
candidates,
|
||||
&ctx,
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_user_context_filter(&self, query: &Search, candidates: &mut Vec<EntityId>) {
|
||||
let Some(user_id) = query.for_user else {
|
||||
return;
|
||||
@ -465,9 +499,13 @@ impl SearchExecutor<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
// User-state inclusion filters (Saved, Liked, InProgress).
|
||||
// User-state inclusion filters (Saved, Liked, InProgress, SocialGraph).
|
||||
// Routed through the canonical RETRIEVE extractor (which includes
|
||||
// SocialGraph) so both surfaces share one collector and cannot drift.
|
||||
if let Some(filter_expr) = query.combined_filter() {
|
||||
for uf in extract_user_state_filters(&filter_expr) {
|
||||
for uf in
|
||||
crate::query::executor::user_filter::extract_user_state_filters(&filter_expr)
|
||||
{
|
||||
match uf {
|
||||
FilterExpr::Saved(uid) => {
|
||||
let saved = user_state.saved_bitmap(*uid);
|
||||
@ -485,6 +523,23 @@ impl SearchExecutor<'_> {
|
||||
user_state.is_in_progress(*uid, id.as_u64(), *threshold)
|
||||
});
|
||||
}
|
||||
FilterExpr::SocialGraph {
|
||||
user_id: uid,
|
||||
depth,
|
||||
} => {
|
||||
// Constrain to items from the user's social graph
|
||||
// (followed creators at depth=1, community at
|
||||
// depth=2). Shared with the RETRIEVE pipeline so
|
||||
// neither surface silently ignores `social_graph`.
|
||||
// Before this arm, the `_ => {}` below swallowed it.
|
||||
crate::query::executor::post_filter::apply_social_graph_filter(
|
||||
*uid,
|
||||
*depth,
|
||||
candidates,
|
||||
user_state,
|
||||
self.creator_items,
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@ -778,6 +833,40 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
/// Like [`vector_executor`] but wires the `universe` bitmap. A FILTER clause
|
||||
/// makes Stage 2's `FilterEvaluator` return `self.universe` for deferred
|
||||
/// variants (InCollection/MinSignal/…), so the deferred-post-filter tests
|
||||
/// must populate the universe with the retrieved item ids or the metadata
|
||||
/// stage would intersect against an empty universe and drop everything.
|
||||
fn filtered_executor<'a>(
|
||||
ledger: &'a SignalLedger,
|
||||
profile_reg: &'a ProfileRegistry,
|
||||
registry: &'a RwLock<EmbeddingSlotRegistry>,
|
||||
universe: &'a RwLock<RoaringBitmap>,
|
||||
) -> SearchExecutor<'a> {
|
||||
SearchExecutor::new(
|
||||
ledger,
|
||||
profile_reg,
|
||||
None, // text_index
|
||||
Some(registry), // embedding_registry
|
||||
None, // category
|
||||
None, // format
|
||||
None, // creator
|
||||
None, // tag
|
||||
None, // duration
|
||||
None, // created_at
|
||||
Some(universe), // universe
|
||||
)
|
||||
}
|
||||
|
||||
/// Universe bitmap holding the two ANN-retrieved item ids (1 and 2).
|
||||
fn two_item_universe() -> RwLock<RoaringBitmap> {
|
||||
let mut bm = RoaringBitmap::new();
|
||||
bm.insert(1);
|
||||
bm.insert(2);
|
||||
RwLock::new(bm)
|
||||
}
|
||||
|
||||
/// CRITICAL regression: the top SEARCH result must be the most *relevant*
|
||||
/// item (closest vector), not the most-*liked* one. Before the fix the fused
|
||||
/// relevance score was discarded and ordering collapsed to signal popularity.
|
||||
@ -915,4 +1004,254 @@ mod tests {
|
||||
assert!(results.items.is_empty());
|
||||
assert!(results.next_cursor.is_none());
|
||||
}
|
||||
|
||||
// ── Deferred post-filter regression coverage ─────────────────────────────
|
||||
//
|
||||
// SEARCH previously applied only Saved/Liked/InProgress and silently ignored
|
||||
// MinSignal/MaxSignal/NearLocation/InCollection/SocialGraph: a well-formed
|
||||
// SEARCH with those filters returned items that VIOLATED the filter, with no
|
||||
// error. These tests prove SEARCH now EXCLUDES the violating items, exactly
|
||||
// like RETRIEVE. Each retrieves two equidistant items via the ANN path and
|
||||
// asserts the filter drops the one that does not satisfy it.
|
||||
|
||||
// `EntityId`, `Timestamp`, `Tag`, `encode_key`, and `FilterExpr` are already
|
||||
// in scope via `use super::*`; only the entities and storage backend below
|
||||
// are new for these tests.
|
||||
use crate::{
|
||||
entities::{
|
||||
CollectionId, CollectionIndex, CreatorItemsBitmap, HardNegIndex, InteractionLedger,
|
||||
UserStateIndex,
|
||||
},
|
||||
storage::{StorageEngine, memory::InMemoryBackend},
|
||||
};
|
||||
|
||||
/// Items-only length-prefixed metadata encoding that `db::deserialize_metadata`
|
||||
/// (used by the NearLocation post-filter) reads back. Mirrors
|
||||
/// `db::metadata::serialize_metadata`, which is not visible outside `db`.
|
||||
fn encode_meta(pairs: &[(&str, &str)]) -> Vec<u8> {
|
||||
let mut buf = Vec::new();
|
||||
buf.extend_from_slice(&(pairs.len() as u32).to_le_bytes());
|
||||
for (k, v) in pairs {
|
||||
buf.extend_from_slice(&(k.len() as u32).to_le_bytes());
|
||||
buf.extend_from_slice(k.as_bytes());
|
||||
buf.extend_from_slice(&(v.len() as u32).to_le_bytes());
|
||||
buf.extend_from_slice(v.as_bytes());
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
/// Two equidistant items on the unit vector so the ANN path retrieves both;
|
||||
/// the deferred filter, not relevance, decides which survives.
|
||||
fn two_item_registry() -> RwLock<EmbeddingSlotRegistry> {
|
||||
RwLock::new(registry_with_slot(
|
||||
"content",
|
||||
&[(1, [1.0, 0.0]), (2, [1.0, 0.0])],
|
||||
))
|
||||
}
|
||||
|
||||
fn ids_of(results: &SearchResults) -> Vec<u64> {
|
||||
let mut v: Vec<u64> = results.items.iter().map(|r| r.entity_id.as_u64()).collect();
|
||||
v.sort_unstable();
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_min_signal_excludes_under_threshold() {
|
||||
let schema = test_schema();
|
||||
let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));
|
||||
let profile_reg = setup_registry();
|
||||
let registry = two_item_registry();
|
||||
|
||||
// Item 1 has 5 likes (>= 3), item 2 has 1 like (< 3).
|
||||
let now = Timestamp::now();
|
||||
for _ in 0..5 {
|
||||
ledger
|
||||
.record_signal("like", EntityId::new(1), 1.0, now)
|
||||
.unwrap();
|
||||
}
|
||||
ledger
|
||||
.record_signal("like", EntityId::new(2), 1.0, now)
|
||||
.unwrap();
|
||||
|
||||
let universe = two_item_universe();
|
||||
let exec = filtered_executor(&ledger, &profile_reg, ®istry, &universe);
|
||||
let query = Search::builder()
|
||||
.vector(vec![1.0f32, 0.0])
|
||||
.using_profile("search")
|
||||
.filter(FilterExpr::min_signal("like", 3.0))
|
||||
.limit(10)
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let results = exec.execute(&query).unwrap();
|
||||
assert_eq!(
|
||||
ids_of(&results),
|
||||
vec![1],
|
||||
"min_signal(like,3) must drop item 2 (1 like); before the fix both were returned"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_max_signal_excludes_over_threshold() {
|
||||
let schema = test_schema();
|
||||
let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));
|
||||
let profile_reg = setup_registry();
|
||||
let registry = two_item_registry();
|
||||
|
||||
// Item 1 has 1 view (<= 2), item 2 has 5 views (> 2).
|
||||
let now = Timestamp::now();
|
||||
ledger
|
||||
.record_signal("view", EntityId::new(1), 1.0, now)
|
||||
.unwrap();
|
||||
for _ in 0..5 {
|
||||
ledger
|
||||
.record_signal("view", EntityId::new(2), 1.0, now)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let universe = two_item_universe();
|
||||
let exec = filtered_executor(&ledger, &profile_reg, ®istry, &universe);
|
||||
let query = Search::builder()
|
||||
.vector(vec![1.0f32, 0.0])
|
||||
.using_profile("search")
|
||||
.filter(FilterExpr::max_signal("view", 2.0))
|
||||
.limit(10)
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let results = exec.execute(&query).unwrap();
|
||||
assert_eq!(
|
||||
ids_of(&results),
|
||||
vec![1],
|
||||
"max_signal(view,2) must drop item 2 (5 views); before the fix both were returned"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_unknown_signal_threshold_errors_loudly() {
|
||||
// An unknown signal name in a threshold must fail the query, never
|
||||
// silently retain or drop every candidate.
|
||||
let schema = test_schema();
|
||||
let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));
|
||||
let profile_reg = setup_registry();
|
||||
let registry = two_item_registry();
|
||||
|
||||
let universe = two_item_universe();
|
||||
let exec = filtered_executor(&ledger, &profile_reg, ®istry, &universe);
|
||||
let query = Search::builder()
|
||||
.vector(vec![1.0f32, 0.0])
|
||||
.using_profile("search")
|
||||
.filter(FilterExpr::min_signal("not_a_signal", 1.0))
|
||||
.limit(10)
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let err = exec.execute(&query).expect_err("unknown signal must error");
|
||||
assert!(matches!(err, QueryError::InvalidFilter { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_near_location_excludes_out_of_radius() {
|
||||
let schema = test_schema();
|
||||
let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));
|
||||
let profile_reg = setup_registry();
|
||||
let registry = two_item_registry();
|
||||
|
||||
// Item 1 in NYC, item 2 in LA.
|
||||
let storage = InMemoryBackend::new();
|
||||
let put_geo = |id: u64, lat: &str, lng: &str| {
|
||||
let key = encode_key(EntityId::new(id), Tag::Meta, b"");
|
||||
storage
|
||||
.put(&key, &encode_meta(&[("latitude", lat), ("longitude", lng)]))
|
||||
.unwrap();
|
||||
};
|
||||
put_geo(1, "40.7128", "-74.0060");
|
||||
put_geo(2, "34.0522", "-118.2437");
|
||||
|
||||
let universe = two_item_universe();
|
||||
let exec = filtered_executor(&ledger, &profile_reg, ®istry, &universe)
|
||||
.with_items_storage(&storage);
|
||||
let query = Search::builder()
|
||||
.vector(vec![1.0f32, 0.0])
|
||||
.using_profile("search")
|
||||
.filter(FilterExpr::near_location(40.7128, -74.0060, 50.0))
|
||||
.limit(10)
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let results = exec.execute(&query).unwrap();
|
||||
assert_eq!(
|
||||
ids_of(&results),
|
||||
vec![1],
|
||||
"near_location(NYC,50km) must drop item 2 (LA); before the fix both were returned"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_in_collection_excludes_outside_collection() {
|
||||
let schema = test_schema();
|
||||
let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));
|
||||
let profile_reg = setup_registry();
|
||||
let registry = two_item_registry();
|
||||
|
||||
// Collection 7 contains item 1 only.
|
||||
let collections = CollectionIndex::new();
|
||||
let cid = CollectionId::new(7);
|
||||
collections.add_item(cid, 1);
|
||||
|
||||
let universe = two_item_universe();
|
||||
let exec = filtered_executor(&ledger, &profile_reg, ®istry, &universe)
|
||||
.with_collection_index(&collections);
|
||||
let query = Search::builder()
|
||||
.vector(vec![1.0f32, 0.0])
|
||||
.using_profile("search")
|
||||
.filter(FilterExpr::in_collection(cid))
|
||||
.limit(10)
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let results = exec.execute(&query).unwrap();
|
||||
assert_eq!(
|
||||
ids_of(&results),
|
||||
vec![1],
|
||||
"in_collection(7) must drop item 2 (not in collection); before the fix both returned"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_social_graph_scopes_to_followed_creator_items() {
|
||||
let schema = test_schema();
|
||||
let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));
|
||||
let profile_reg = setup_registry();
|
||||
let registry = two_item_registry();
|
||||
|
||||
// User 42 follows creator 10; creator 10 owns item 1 only.
|
||||
let user_state = UserStateIndex::new();
|
||||
user_state.add_follow(42, 10);
|
||||
user_state.add_creator_follower(10, 42);
|
||||
let creator_items = CreatorItemsBitmap::new();
|
||||
creator_items.add_item(10, 1);
|
||||
let hard_neg = HardNegIndex::new();
|
||||
let interaction = InteractionLedger::new();
|
||||
|
||||
let universe = two_item_universe();
|
||||
let exec = filtered_executor(&ledger, &profile_reg, ®istry, &universe)
|
||||
.with_user_context(&user_state, &hard_neg, &interaction, &creator_items);
|
||||
let query = Search::builder()
|
||||
.vector(vec![1.0f32, 0.0])
|
||||
.using_profile("search")
|
||||
.for_user(42)
|
||||
.filter(FilterExpr::social_graph(42, 1))
|
||||
.limit(10)
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let results = exec.execute(&query).unwrap();
|
||||
assert_eq!(
|
||||
ids_of(&results),
|
||||
vec![1],
|
||||
"social_graph(42,1) must drop item 2 (not from a followed creator); before the fix \
|
||||
the SocialGraph arm was swallowed and both were returned"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,14 @@
|
||||
//! Helper functions extracted from the SEARCH executor to keep file size
|
||||
//! under the 600-line limit (`CODING_GUIDELINES` §9).
|
||||
//!
|
||||
//! - User-state filter extraction helpers
|
||||
//! - `build_user_context` free function (was a private method on `SearchExecutor`)
|
||||
//! - Creator metadata filter predicates
|
||||
//!
|
||||
//! User-state filter extraction lives in
|
||||
//! [`crate::query::executor::user_filter::extract_user_state_filters`]; both the
|
||||
//! RETRIEVE and SEARCH pipelines call that single collector so they cannot
|
||||
//! drift on which user-state variants (Saved/Liked/InProgress/SocialGraph) are
|
||||
//! recognized.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
@ -14,33 +19,6 @@ use crate::{
|
||||
storage::indexes::filter::FilterExpr,
|
||||
};
|
||||
|
||||
// ── User-state filter extraction ─────────────────────────────────────────────
|
||||
|
||||
fn collect_user_state_filters<'a>(expr: &'a FilterExpr, out: &mut Vec<&'a FilterExpr>) {
|
||||
match expr {
|
||||
FilterExpr::Saved(_) | FilterExpr::Liked(_) | FilterExpr::InProgress { .. } => {
|
||||
out.push(expr);
|
||||
}
|
||||
FilterExpr::And(children) | FilterExpr::Or(children) => {
|
||||
for child in children {
|
||||
collect_user_state_filters(child, out);
|
||||
}
|
||||
}
|
||||
// Do NOT recurse into Not: a deferred variant under Not has no positive
|
||||
// application. Such queries are rejected up front by
|
||||
// `crate::query::executor::user_filter::reject_negated_deferred_filters`,
|
||||
// so extracting the inner variant here would invert the requested set.
|
||||
// `Not` and every other node contribute nothing here.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn extract_user_state_filters(expr: &FilterExpr) -> Vec<&FilterExpr> {
|
||||
let mut result = Vec::new();
|
||||
collect_user_state_filters(expr, &mut result);
|
||||
result
|
||||
}
|
||||
|
||||
// ── UserContext builder ───────────────────────────────────────────────────────
|
||||
|
||||
/// Build a `UserContext` for personalized scoring.
|
||||
|
||||
@ -63,6 +63,7 @@ pub fn l2_distance_sq(a: &[f32], b: &[f32]) -> f32 {
|
||||
/// All reads take a shared lock; all writes take an exclusive lock. This is
|
||||
/// acceptable because brute-force is not a hot-path production index -- it is
|
||||
/// used for correctness baselines, small datasets, and tests.
|
||||
#[derive(Debug)]
|
||||
pub struct BruteForceIndex {
|
||||
vectors: RwLock<HashMap<VectorId, Vec<f32>>>,
|
||||
config: VectorIndexConfig,
|
||||
@ -268,12 +269,57 @@ impl BruteForceIndex {
|
||||
)));
|
||||
}
|
||||
|
||||
// Read count
|
||||
// Read count.
|
||||
//
|
||||
// SECURITY: `count` comes straight off disk and is fully
|
||||
// corruption/attacker-controlled. Every byte of arithmetic below uses
|
||||
// checked operations and is bounded against the *actual* buffer length
|
||||
// BEFORE any allocation or loop. Without this, a hostile `count` would
|
||||
// (a) overflow `count * bytes_per_vector` in release builds, wrapping to
|
||||
// a tiny `expected_size` that slips past the truncation guard, then
|
||||
// (b) drive `HashMap::with_capacity(count)` into a multi-exabyte alloc
|
||||
// (process abort) and an out-of-bounds read loop (panic). A corrupt
|
||||
// *derived* index is recoverable (rebuildable from the entity store), so
|
||||
// it must surface as `CorruptedIndex`, never crash the recovery path.
|
||||
let count = read_u64_le(data, 9) as usize;
|
||||
|
||||
// Validate total size
|
||||
let bytes_per_vector = 8 + dims * 4; // id (8) + floats (dims * 4)
|
||||
let expected_size = HEADER_SIZE + count * bytes_per_vector;
|
||||
// Bytes consumed per stored vector: id (8) + floats (dims * 4).
|
||||
// Guard `dims` (also from disk-ish via header; already matched against
|
||||
// config above, but compute defensively) so the multiply cannot wrap.
|
||||
let float_bytes = dims
|
||||
.checked_mul(4)
|
||||
.ok_or_else(|| VectorError::CorruptedIndex(format!("dimensions too large: {dims}")))?;
|
||||
let bytes_per_vector = 8usize.checked_add(float_bytes).ok_or_else(|| {
|
||||
VectorError::CorruptedIndex(format!("per-vector size overflow at dims {dims}"))
|
||||
})?;
|
||||
debug_assert!(
|
||||
bytes_per_vector >= 8,
|
||||
"bytes_per_vector includes the 8-byte id and cannot be zero"
|
||||
);
|
||||
|
||||
// Cap `count` by how many vectors the body could actually contain. This
|
||||
// makes the subsequent `with_capacity` and read loop safe regardless of
|
||||
// the on-disk value: a damaged header can never request more entries
|
||||
// than the buffer can hold.
|
||||
let body_len = data.len() - HEADER_SIZE; // safe: data.len() >= HEADER_SIZE checked above
|
||||
let max_count = body_len / bytes_per_vector; // bytes_per_vector >= 8, no div-by-zero
|
||||
if count > max_count {
|
||||
return Err(VectorError::CorruptedIndex(format!(
|
||||
"implausible vector count: header claims {count}, but the {body_len}-byte \
|
||||
body holds at most {max_count} vectors of {bytes_per_vector} bytes each"
|
||||
)));
|
||||
}
|
||||
|
||||
// Validate the exact declared size (now provably non-overflowing because
|
||||
// `count <= max_count` and `max_count * bytes_per_vector <= body_len`).
|
||||
let expected_size = count
|
||||
.checked_mul(bytes_per_vector)
|
||||
.and_then(|body| body.checked_add(HEADER_SIZE))
|
||||
.ok_or_else(|| {
|
||||
VectorError::CorruptedIndex(format!(
|
||||
"index size overflow: count {count} * {bytes_per_vector} + {HEADER_SIZE}"
|
||||
))
|
||||
})?;
|
||||
if data.len() < expected_size {
|
||||
return Err(VectorError::CorruptedIndex(format!(
|
||||
"file truncated: expected at least {expected_size} bytes, got {}",
|
||||
@ -281,7 +327,8 @@ impl BruteForceIndex {
|
||||
)));
|
||||
}
|
||||
|
||||
// Parse vectors
|
||||
// Parse vectors. `count` is now bounded by the buffer, so `with_capacity`
|
||||
// cannot over-allocate and the read loop cannot index out of bounds.
|
||||
let mut vectors = HashMap::with_capacity(count);
|
||||
let mut offset = HEADER_SIZE;
|
||||
for _ in 0..count {
|
||||
|
||||
@ -262,6 +262,119 @@ fn vector_index_config_defaults() {
|
||||
assert_eq!(config.ef_search, 200);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Corruption / hostile-input deserialization
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Build a minimal valid 17-byte brute-force index header for `dims`
|
||||
/// dimensions claiming `count` stored vectors. The returned buffer contains
|
||||
/// *only* the header -- callers append (or omit) body bytes to construct
|
||||
/// truncated or torn inputs.
|
||||
fn make_header(dims: u32, count: u64) -> Vec<u8> {
|
||||
let mut buf = Vec::with_capacity(17);
|
||||
buf.extend_from_slice(MAGIC); // [0..4]
|
||||
buf.push(FORMAT_VERSION); // [4]
|
||||
buf.extend_from_slice(&dims.to_le_bytes()); // [5..9]
|
||||
buf.extend_from_slice(&count.to_le_bytes()); // [9..17]
|
||||
buf
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_rejects_enormous_count_without_panicking() {
|
||||
// A header claiming u64::MAX vectors with no body. Pre-fix this wrapped
|
||||
// `count * bytes_per_vector`, slipped past the truncation guard, then
|
||||
// aborted in `HashMap::with_capacity` / panicked in the read loop.
|
||||
let config = test_config(); // dims = 3
|
||||
let data = make_header(config.dimensions as u32, u64::MAX);
|
||||
|
||||
let result = BruteForceIndex::deserialize(&data, &config);
|
||||
assert!(
|
||||
matches!(result, Err(VectorError::CorruptedIndex(_))),
|
||||
"enormous count must be a recoverable CorruptedIndex, got {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_rejects_count_exceeding_body() {
|
||||
// Header claims 1000 vectors but supplies a body big enough for only one.
|
||||
let config = test_config(); // dims = 3 => bytes_per_vector = 8 + 12 = 20
|
||||
let mut data = make_header(config.dimensions as u32, 1000);
|
||||
data.extend_from_slice(&[0u8; 20]); // exactly one vector's worth of body
|
||||
|
||||
let result = BruteForceIndex::deserialize(&data, &config);
|
||||
match result {
|
||||
Err(VectorError::CorruptedIndex(msg)) => {
|
||||
assert!(
|
||||
msg.contains("implausible vector count"),
|
||||
"expected count-cap message, got: {msg}"
|
||||
);
|
||||
}
|
||||
other => panic!("expected CorruptedIndex, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_rejects_torn_body() {
|
||||
// Header honestly claims 2 vectors (40 bytes of body) but the body is torn
|
||||
// mid-vector (only 25 bytes present). This must be rejected with a typed
|
||||
// CorruptedIndex error, never read OOB. The count-cap guard catches it first:
|
||||
// a 25-byte body holds at most 1 whole 20-byte vector, so a claimed count of 2
|
||||
// is already implausible — which is exactly the bound that prevents the OOB
|
||||
// read the torn tail would otherwise cause.
|
||||
let config = test_config(); // dims = 3 => bytes_per_vector = 20
|
||||
let mut data = make_header(config.dimensions as u32, 2);
|
||||
data.extend_from_slice(&[0u8; 25]); // 25 < 40: torn second vector
|
||||
|
||||
let result = BruteForceIndex::deserialize(&data, &config);
|
||||
match result {
|
||||
Err(VectorError::CorruptedIndex(msg)) => {
|
||||
assert!(
|
||||
msg.contains("implausible vector count") || msg.contains("truncated"),
|
||||
"expected a size/count corruption message, got: {msg}"
|
||||
);
|
||||
}
|
||||
other => panic!("expected CorruptedIndex on torn body, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_rejects_overflowing_dimensions() {
|
||||
// A dims value whose `* 4` overflows `usize` must be rejected, not panic.
|
||||
// We point `config.dimensions` at the same huge value so the dimension
|
||||
// check passes and we reach the per-vector size arithmetic.
|
||||
let huge_dims = usize::MAX;
|
||||
let config = VectorIndexConfig {
|
||||
dimensions: huge_dims,
|
||||
..test_config()
|
||||
};
|
||||
// Header stores dims as a u32, so truncation means the on-file dims won't
|
||||
// equal `huge_dims`; that mismatch is itself a CorruptedIndex, which is
|
||||
// still the correct (non-panicking) outcome. Assert no panic + an error.
|
||||
let data = make_header(huge_dims as u32, 1);
|
||||
let result = BruteForceIndex::deserialize(&data, &config);
|
||||
assert!(
|
||||
matches!(result, Err(VectorError::CorruptedIndex(_))),
|
||||
"overflowing dims must yield CorruptedIndex, got {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_accepts_well_formed_buffer() {
|
||||
// Round-trip sanity: a buffer produced by `save` with a real body still
|
||||
// deserializes after the hardening. Guards the fix against over-rejection.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("ok.bfvi");
|
||||
let config = test_config();
|
||||
let index = BruteForceIndex::new(config.clone());
|
||||
index.insert(7, &[1.0, 2.0, 3.0]).unwrap();
|
||||
index.insert(8, &[4.0, 5.0, 6.0]).unwrap();
|
||||
index.save(&path).unwrap();
|
||||
|
||||
let data = std::fs::read(&path).unwrap();
|
||||
let loaded = BruteForceIndex::deserialize(&data, &config).unwrap();
|
||||
assert_eq!(loaded.len(), 2);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Property tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@ -26,6 +26,19 @@ pub struct CompactionResult {
|
||||
/// checkpoint; it is NOT deleted (conservative: we replay a few extra events
|
||||
/// rather than risk losing uncovered events).
|
||||
///
|
||||
/// # Live-writer hazard — use [`compact_wal_online`] instead while the WAL is open
|
||||
///
|
||||
/// This function does NOT account for the segment a running writer thread is
|
||||
/// actively appending to. The writer holds one segment open and only rotates at
|
||||
/// the size threshold, so after any burst the live segment's `first_seq` is well
|
||||
/// below the materialized checkpoint — and this function would `remove_file` it
|
||||
/// out from under the writer's open FD. On Linux the writer keeps appending to
|
||||
/// the now-unlinked inode and every post-checkpoint, already-fsync'd write is
|
||||
/// silently lost on the next open. Therefore this entry point is only safe to
|
||||
/// call once the writer thread has been joined (i.e. after
|
||||
/// `WalHandle::shutdown()`); the periodic-checkpoint path must use
|
||||
/// [`compact_wal_online`].
|
||||
///
|
||||
/// # Safety invariant
|
||||
///
|
||||
/// The caller must ensure the checkpoint at `checkpoint_seq` has been durably
|
||||
@ -48,21 +61,71 @@ pub struct CompactionResult {
|
||||
/// if an error is encountered mid-way -- this is safe (see invariant above).
|
||||
pub fn compact_wal(wal_dir: &Path, checkpoint_seq: u64) -> Result<CompactionResult, WalError> {
|
||||
let segments = segment::list_segments(wal_dir)?;
|
||||
compact_segments(wal_dir, &segments, checkpoint_seq)
|
||||
}
|
||||
|
||||
/// Online-safe compaction: identical to [`compact_wal`] except it NEVER deletes
|
||||
/// the segment the WAL writer thread is (or may be) actively appending to.
|
||||
///
|
||||
/// `list_segments` returns segments sorted by ascending `first_seq`, and the
|
||||
/// writer always appends to — and only ever rotates to a strictly *higher* —
|
||||
/// the segment with the largest `first_seq` (see `WalHandle::open`, which opens
|
||||
/// `segments.last()`). So the live segment is always the maximum-`first_seq`
|
||||
/// segment on disk. We clamp the deletion floor to that segment's `first_seq`,
|
||||
/// guaranteeing it survives even when the checkpoint has advanced past its
|
||||
/// `first_seq` — the common case, since the active segment starts before a
|
||||
/// checkpoint but still holds uncheckpointed tail events. Deleting it would
|
||||
/// unlink the inode out from under the writer's open FD and, on Linux, silently
|
||||
/// lose every post-checkpoint, already-fsync'd write that followed.
|
||||
///
|
||||
/// Safe to run concurrently with the writer: a rotation during compaction only
|
||||
/// creates an even-higher segment (untouched here) and seals the old active one
|
||||
/// (now safely deletable on the *next* compaction) — never a lost write.
|
||||
///
|
||||
/// Use this from the periodic-checkpoint path while the writer is live; use
|
||||
/// [`compact_wal`] only after `WalHandle::shutdown()` has joined the writer.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `WalError::Io` on filesystem failure.
|
||||
pub fn compact_wal_online(
|
||||
wal_dir: &Path,
|
||||
checkpoint_seq: u64,
|
||||
) -> Result<CompactionResult, WalError> {
|
||||
let segments = segment::list_segments(wal_dir)?;
|
||||
// The live segment is the maximum-`first_seq` segment; never delete at or
|
||||
// above it. Clamp the floor so the writer's open segment is always preserved.
|
||||
let floor = match segments.iter().map(|(first_seq, _)| *first_seq).max() {
|
||||
Some(active_first_seq) => checkpoint_seq.min(active_first_seq),
|
||||
None => checkpoint_seq,
|
||||
};
|
||||
compact_segments(wal_dir, &segments, floor)
|
||||
}
|
||||
|
||||
/// Delete every listed segment whose `first_seq` is strictly less than `floor`,
|
||||
/// then fsync the directory so the unlinks are durable.
|
||||
///
|
||||
/// `floor` is the exclusive lower bound: a segment is kept iff
|
||||
/// `first_seq >= floor`. [`compact_wal`] passes `checkpoint_seq` directly;
|
||||
/// [`compact_wal_online`] passes `min(checkpoint_seq, active_first_seq)` to
|
||||
/// protect the live segment.
|
||||
fn compact_segments(
|
||||
wal_dir: &Path,
|
||||
segments: &[(u64, std::path::PathBuf)],
|
||||
floor: u64,
|
||||
) -> Result<CompactionResult, WalError> {
|
||||
let total_before = segments.len();
|
||||
|
||||
let mut deleted = 0usize;
|
||||
let mut bytes_reclaimed = 0u64;
|
||||
|
||||
for (seg_first_seq, seg_path) in &segments {
|
||||
// Only delete segments whose first_seq is strictly less than the
|
||||
// checkpoint sequence. Segments starting at or after checkpoint_seq
|
||||
// may contain events that are not yet covered by the checkpoint.
|
||||
if *seg_first_seq < checkpoint_seq {
|
||||
for (seg_first_seq, seg_path) in segments {
|
||||
if *seg_first_seq < floor {
|
||||
// SAFETY: TOCTOU gap between metadata() and remove_file() is benign.
|
||||
// `list_segments()` only returns files that exist on disk at scan time.
|
||||
// If another process deleted this segment between the scan and now,
|
||||
// remove_file() returns an error which compact_wal propagates -- but
|
||||
// TidalDb enforces single-process-per-directory ownership (see Config::lock_dir),
|
||||
// remove_file() returns an error which propagates -- but TidalDb
|
||||
// enforces single-process-per-directory ownership (see Config::lock_dir),
|
||||
// so this cannot happen in production. The gap is a safe no-op under the
|
||||
// single-owner invariant.
|
||||
let file_size = std::fs::metadata(seg_path).map(|m| m.len()).unwrap_or(0);
|
||||
@ -93,7 +156,7 @@ pub fn compact_wal(wal_dir: &Path, checkpoint_seq: u64) -> Result<CompactionResu
|
||||
deleted,
|
||||
bytes_reclaimed,
|
||||
remaining,
|
||||
checkpoint_seq,
|
||||
floor,
|
||||
"WAL compaction complete"
|
||||
);
|
||||
|
||||
@ -229,4 +292,61 @@ mod tests {
|
||||
assert_eq!(result.segments_deleted, 1); // seq=1 < 100
|
||||
assert_eq!(result.segments_remaining, 1); // seq=100 preserved
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn online_never_deletes_active_segment_even_when_fully_checkpointed() {
|
||||
// BLOCKER regression: the live (max-first_seq) segment must survive even
|
||||
// when the checkpoint has advanced far past its first_seq — otherwise the
|
||||
// writer's open FD points at an unlinked inode and acknowledged, fsync'd
|
||||
// writes are silently lost on Linux.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
for &seq in &[1u64, 100] {
|
||||
let _ = SegmentWriter::open(dir.path(), ShardId::SINGLE, seq, 1024).unwrap();
|
||||
}
|
||||
// Checkpoint way past both segments. Shutdown-compaction would delete both;
|
||||
// online compaction must preserve the active segment (first_seq = 100).
|
||||
let result = compact_wal_online(dir.path(), 1_000).unwrap();
|
||||
assert_eq!(
|
||||
result.segments_deleted, 1,
|
||||
"only the non-active old segment"
|
||||
);
|
||||
assert_eq!(result.segments_remaining, 1);
|
||||
let remaining = list_segments(dir.path()).unwrap();
|
||||
assert_eq!(remaining[0].0, 100, "active segment preserved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn online_preserves_single_active_segment() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let _ = SegmentWriter::open(dir.path(), ShardId::SINGLE, 50, 1024).unwrap();
|
||||
// Even with a checkpoint past it, the sole (active) segment is never deleted.
|
||||
let result = compact_wal_online(dir.path(), 1_000).unwrap();
|
||||
assert_eq!(result.segments_deleted, 0);
|
||||
assert_eq!(result.segments_remaining, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn online_matches_shutdown_compaction_for_older_segments() {
|
||||
// When older segments sit below both the checkpoint and the active
|
||||
// segment, online compaction reclaims exactly the same ones.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
for &seq in &[1u64, 50, 100, 200] {
|
||||
let _ = SegmentWriter::open(dir.path(), ShardId::SINGLE, seq, 1024).unwrap();
|
||||
}
|
||||
// floor = min(100, max_first_seq=200) = 100 -> delete first_seq < 100.
|
||||
let result = compact_wal_online(dir.path(), 100).unwrap();
|
||||
assert_eq!(result.segments_deleted, 2);
|
||||
assert_eq!(result.segments_remaining, 2);
|
||||
let remaining = list_segments(dir.path()).unwrap();
|
||||
assert_eq!(remaining[0].0, 100);
|
||||
assert_eq!(remaining[1].0, 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn online_empty_dir_is_noop() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let result = compact_wal_online(dir.path(), 100).unwrap();
|
||||
assert_eq!(result.segments_deleted, 0);
|
||||
assert_eq!(result.segments_remaining, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@ -81,6 +81,34 @@ impl DedupWindow {
|
||||
false
|
||||
}
|
||||
|
||||
/// Check whether `event` is already in the dedup window WITHOUT recording it.
|
||||
///
|
||||
/// Used by the WAL writer to split duplicate *detection* from dedup-state
|
||||
/// *insertion*. An event must only be marked as seen once its batch is
|
||||
/// durably persisted (via [`record`](Self::record)); otherwise a batch that
|
||||
/// fails to flush and is then retried would be silently suppressed as a
|
||||
/// phantom duplicate — a lost, acknowledged-intent write. Rotates the buffers
|
||||
/// by time/size exactly like [`is_duplicate`](Self::is_duplicate).
|
||||
pub fn contains(&mut self, event: &EventRecord) -> bool {
|
||||
self.maybe_rotate();
|
||||
let hash = event_content_hash(event);
|
||||
self.current.contains(&hash) || self.previous.contains(&hash)
|
||||
}
|
||||
|
||||
/// Record `event` as seen.
|
||||
///
|
||||
/// Call this ONLY after the batch containing `event` has been durably
|
||||
/// 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).
|
||||
pub fn record(&mut self, event: &EventRecord) {
|
||||
if self.current.len() >= self.max_entries {
|
||||
self.force_rotate();
|
||||
}
|
||||
let hash = event_content_hash(event);
|
||||
self.current.insert(hash);
|
||||
}
|
||||
|
||||
/// Populate the dedup window from replayed events during recovery.
|
||||
///
|
||||
/// This inserts all provided events into the current window so that
|
||||
|
||||
@ -266,9 +266,20 @@ fn partition_dedup(
|
||||
) {
|
||||
let mut kept_events: Vec<EventRecord> = Vec::new();
|
||||
let mut kept_replies: Vec<crossbeam::channel::Sender<Result<u64, WalError>>> = Vec::new();
|
||||
// Hashes kept *within this batch*, so two identical events in the same drained
|
||||
// batch still dedup against each other even though the durable dedup window is
|
||||
// only updated AFTER a successful flush (see `run_writer`). This preserves
|
||||
// intra-batch suppression without violating the "mark as seen only once
|
||||
// durable" invariant.
|
||||
let mut batch_seen: std::collections::HashSet<u128> = std::collections::HashSet::new();
|
||||
|
||||
for (event, reply) in batch {
|
||||
if dedup.is_duplicate(&event) {
|
||||
let hash = format::event_content_hash(&event);
|
||||
// `dedup.contains` CHECKS membership without recording; `batch_seen.insert`
|
||||
// returns false when this exact event already appeared earlier in this
|
||||
// batch. Recording into `dedup` happens in `run_writer`, only after the
|
||||
// batch is durably persisted.
|
||||
if dedup.contains(&event) || !batch_seen.insert(hash) {
|
||||
// Duplicate: notify with the dedup sentinel (seq=0) immediately.
|
||||
let _ = reply.send(Ok(0));
|
||||
} else {
|
||||
@ -295,13 +306,22 @@ fn partition_dedup(
|
||||
/// 3. Deduplicate events, encode the batch, write to segment, fsync.
|
||||
/// 4. Send sequence numbers back to all waiting callers.
|
||||
///
|
||||
/// # Resilience
|
||||
///
|
||||
/// A flush failure in the **steady-state loop** does NOT terminate the writer.
|
||||
/// `flush_batch` notifies every waiting caller with the error (so they can
|
||||
/// retry), the batch's events are left un-recorded in the dedup window (so a
|
||||
/// retry is accepted, not suppressed as a phantom duplicate), and the loop keeps
|
||||
/// serving — a transient I/O fault (ENOSPC, EINTR, NFS blip) must never convert
|
||||
/// into a permanent write outage by dropping the command channel.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `WalError::Io` on filesystem failure during batch writes or fsync.
|
||||
/// Returns `WalError::Corruption` if batch encoding fails (should not happen
|
||||
/// under normal operation — `effective_batch_size` clamps the drain limit to
|
||||
/// `MAX_EVENTS_PER_BATCH` so an oversized `batch_size` can never produce an
|
||||
/// unencodable batch).
|
||||
/// Only the shutdown drain / final fsync propagate an error out of this function
|
||||
/// (the WAL is closing anyway, and callers were already notified). The encoding
|
||||
/// path cannot fail under normal operation — `effective_batch_size` clamps the
|
||||
/// drain limit to `MAX_EVENTS_PER_BATCH` so an oversized `batch_size` can never
|
||||
/// produce an unencodable batch.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
@ -402,7 +422,35 @@ pub fn run_writer(
|
||||
let (kept_events, kept_replies) = partition_dedup(&mut dedup, batch.drain(..));
|
||||
|
||||
if !kept_events.is_empty() {
|
||||
next_seq = flush_batch(&mut segment, config, next_seq, &kept_events, kept_replies)?;
|
||||
match flush_batch(&mut segment, config, next_seq, &kept_events, kept_replies) {
|
||||
Ok(seq) => {
|
||||
next_seq = seq;
|
||||
// Record events as seen ONLY now that the batch is durably
|
||||
// persisted. Recording earlier (in `partition_dedup`) would
|
||||
// suppress a legitimate retry of a batch that failed to flush.
|
||||
for event in &kept_events {
|
||||
dedup.record(event);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// A transient I/O fault (a brief ENOSPC that an operator then
|
||||
// clears, an EINTR, an NFS/network-storage blip) must NOT tear
|
||||
// down the writer thread — that would drop the command channel
|
||||
// and wedge every future write forever, since the WAL is the
|
||||
// source of truth for all entity/signal/relationship writes.
|
||||
// `flush_batch` has already notified each waiting caller with
|
||||
// the error, so they can retry; we keep `next_seq` unchanged
|
||||
// (the failed batch's sequence range is free for the retry),
|
||||
// leave the events UNRECORDED in the dedup window so the retry
|
||||
// is accepted, and keep serving.
|
||||
tracing::error!(
|
||||
error = %e,
|
||||
seq = next_seq,
|
||||
events = kept_events.len(),
|
||||
"wal: batch flush failed; callers notified to retry, writer continuing"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if shutdown_requested {
|
||||
|
||||
@ -298,6 +298,81 @@ fn shutdown_drain_notifies_callers_on_write_error() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steady_state_flush_error_does_not_kill_writer_and_allows_retry() {
|
||||
// BLOCKER + wal-3 regression: a transient flush failure in the STEADY-STATE
|
||||
// loop must NOT terminate the writer thread, and the failed event must NOT be
|
||||
// recorded in the dedup window — so an identical retry is accepted and
|
||||
// durably persisted (a real, non-zero seq), never silently swallowed as a
|
||||
// duplicate (the seq=0 sentinel).
|
||||
//
|
||||
// batch_size = 1 makes each event its own batch, so the retry lands in a
|
||||
// SEPARATE flush from the failed first attempt while we drive run_writer
|
||||
// synchronously on this thread (where the thread-local fault hook applies).
|
||||
let dir = tempfile::tempdir().expect("tempdir creation should succeed");
|
||||
let (tx, rx) = bounded(16);
|
||||
let segment = SegmentWriter::open(dir.path(), ShardId::SINGLE, 1, 16 * 1024 * 1024)
|
||||
.expect("open should succeed");
|
||||
let dedup = DedupWindow::new(Duration::from_secs(30));
|
||||
let config = WriterConfig {
|
||||
dir: dir.path().to_path_buf(),
|
||||
segment_size: 16 * 1024 * 1024,
|
||||
batch_size: 1, // one event per batch, so attempt and retry flush separately
|
||||
batch_timeout: Duration::from_millis(10),
|
||||
dedup_window: Duration::from_secs(30),
|
||||
session_journal_path: None,
|
||||
shard_id: ShardId::SINGLE,
|
||||
region_id: RegionId::SINGLE,
|
||||
};
|
||||
|
||||
let event = make_event(99);
|
||||
// First attempt: will hit the injected one-shot flush failure.
|
||||
let (reply_tx1, reply_rx1) = bounded(1);
|
||||
tx.send(WalCommand::Append {
|
||||
event: event.clone(),
|
||||
reply: reply_tx1,
|
||||
})
|
||||
.expect("send should succeed");
|
||||
// Retry of the SAME event: must be accepted (not deduped) and persisted.
|
||||
let (reply_tx2, reply_rx2) = bounded(1);
|
||||
tx.send(WalCommand::Append {
|
||||
event,
|
||||
reply: reply_tx2,
|
||||
})
|
||||
.expect("send should succeed");
|
||||
tx.send(WalCommand::Shutdown).expect("send should succeed");
|
||||
drop(tx);
|
||||
|
||||
// Arm the one-shot fault on THIS thread, then run the writer synchronously
|
||||
// here so the fault fires inside the first steady-state flush_batch.
|
||||
arm_flush_failure();
|
||||
let result = run_writer(&rx, &config, segment, 1, dedup);
|
||||
|
||||
// BLOCKER: the writer survived the transient error and exited cleanly.
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"writer must survive a transient steady-state flush error, got {result:?}"
|
||||
);
|
||||
|
||||
// First attempt observed the error (so the caller knows to retry).
|
||||
let r1 = reply_rx1.recv().expect("first caller must be notified");
|
||||
assert!(
|
||||
matches!(r1, Err(WalError::Io(_))),
|
||||
"first attempt must see the flush error, got {r1:?}"
|
||||
);
|
||||
|
||||
// wal-3: the retry was NOT suppressed as a duplicate — it got a real,
|
||||
// non-zero sequence number, meaning it was durably persisted.
|
||||
let r2 = reply_rx2
|
||||
.recv()
|
||||
.expect("retry caller must be notified")
|
||||
.expect("retry must persist successfully");
|
||||
assert!(
|
||||
r2 > 0,
|
||||
"retry must get a real sequence number, not the dedup sentinel 0; got {r2}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writer_assigns_monotonic_sequences() {
|
||||
let dir = tempfile::tempdir().expect("tempdir creation should succeed");
|
||||
|
||||
Loading…
Reference in New Issue
Block a user