Resolves the 142 findings from tidal/docs/reviews/CODE_REVIEW_m0-m10.md across the engine, server, net, and CLI surfaces: - WAL/session-journal durability, checkpoint format, and crash-recovery hardening - Replication shipper/receiver, tenant isolation, and migration paths - Cluster scatter-gather, router, standalone server + health/offload endpoints - tidalctl refactored into command modules with JSON output and WAL-state tooling - Cohort, governance, signal-ledger, and vector-registry correctness fixes - Expanded UAT/integration/durability test coverage across all milestones
917 lines
34 KiB
Rust
917 lines
34 KiB
Rust
//! Per-user state bitmap indexes.
|
|
//!
|
|
//! Tracks seen items, blocked creators/items, saved items, liked items,
|
|
//! and completion progress.
|
|
//!
|
|
//! # Durability
|
|
//!
|
|
//! `seen`, `blocked`, `follows`, and `creator_followers` are rebuilt on open
|
|
//! from durable relationship edges (`rebuild_entity_state`). The
|
|
//! `completion` / `saved` / `liked` / `save_timestamps` maps have no
|
|
//! relationship edge — they are driven by `completion` / `save` / `like` signals
|
|
//! — so they are persisted write-through as durable `Tag::UserState` rows by the
|
|
//! signal path and restored on open via [`UserStateIndex::restore`]. Without
|
|
//! that, the `InProgress` filter and `DateSaved` sort silently reset to empty on
|
|
//! every restart.
|
|
|
|
use std::collections::HashSet;
|
|
|
|
use dashmap::DashMap;
|
|
use roaring::{RoaringBitmap, RoaringTreemap};
|
|
|
|
use super::CreatorItemsBitmap;
|
|
use crate::{
|
|
schema::EntityId,
|
|
storage::{StorageEngine, Tag, encode_key, entity_tag_prefix, parse_key},
|
|
};
|
|
|
|
/// Narrow a `u64` item-side entity ID into the `u32` slot used by every
|
|
/// per-user item bitmap (seen / hidden / saved / liked), emitting a
|
|
/// `tracing::warn!` if the ID exceeds `u32::MAX`.
|
|
///
|
|
/// The item universe is bounded to `u32::MAX` (~4 billion) because the
|
|
/// in-memory candidate-generation indexes key on a `u32` slot. The primary
|
|
/// write path (`db/items.rs::write_item_with_metadata`) *rejects* an
|
|
/// over-range item ID before it ever lands in storage, so in a well-formed
|
|
/// database this narrowing never overflows. The relationship-driven write
|
|
/// paths (the `Hide` edge in `db/relationships.rs` and its replay in
|
|
/// `db/state_rebuild.rs`) reach this index with a `to` id that was *not*
|
|
/// funneled through that guard, so they must route their `u64 -> u32`
|
|
/// narrowing through this helper to keep every truncation observable and
|
|
/// consistent with the warning `db/items.rs` already emits. Truncation is
|
|
/// preserved (the documented universe limit is unchanged); only the silent
|
|
/// part is fixed.
|
|
///
|
|
/// Out-of-scope note: `db/relationships.rs` (the `Hide` relationship write /
|
|
/// delete) and `db/state_rebuild.rs` (the `Hide` replay) currently perform a
|
|
/// bare `to.as_u64() as u32`; they should call this helper instead. Those
|
|
/// files are not edited here.
|
|
#[must_use]
|
|
pub(crate) fn narrow_item_slot(item_id: u64) -> u32 {
|
|
if item_id > u64::from(u32::MAX) {
|
|
// SLOT-NARROW: mirrors the warn db/items.rs emits when rejecting an
|
|
// over-range item ID, so a Hide on such an ID is equally observable.
|
|
tracing::warn!(
|
|
item_id,
|
|
limit = u32::MAX,
|
|
"item id exceeds u32 item-slot universe; narrowing aliases a lower id (silent cross-id collision in per-user bitmaps)"
|
|
);
|
|
}
|
|
// Documented, intentional truncation to the u32 item slot.
|
|
#[allow(clippy::cast_possible_truncation)]
|
|
{
|
|
item_id as u32
|
|
}
|
|
}
|
|
|
|
/// Blocked state: both individual items and all items from blocked creators.
|
|
#[derive(Debug, Default)]
|
|
pub struct BlockedState {
|
|
pub creators: HashSet<u64>,
|
|
pub items: RoaringBitmap,
|
|
}
|
|
|
|
/// Per-user state bitmaps for filtering during ranking.
|
|
///
|
|
/// Thread-safe via `DashMap` -- no mutex on the hot path. Each user's
|
|
/// state is independent, so concurrent updates to different users never
|
|
/// contend.
|
|
pub struct UserStateIndex {
|
|
seen: DashMap<u64, RoaringBitmap>,
|
|
blocked: DashMap<u64, BlockedState>,
|
|
saved: DashMap<u64, RoaringBitmap>,
|
|
liked: DashMap<u64, RoaringBitmap>,
|
|
/// `user_id` -> (`item_id` -> completion ratio).
|
|
///
|
|
/// Keyed per-user rather than by a flat `(user_id, item_id)` so that
|
|
/// [`in_progress_items`](Self::in_progress_items) scans only one user's
|
|
/// completion entries instead of the global map. The threshold is applied
|
|
/// at query time, so the inner map keeps the raw ratio (it cannot be
|
|
/// pre-bucketed by an "in-progress" flag that depends on a per-call
|
|
/// threshold).
|
|
completion: DashMap<u64, std::collections::HashMap<u64, f64>>,
|
|
/// `user_id` -> set of followed creator IDs.
|
|
/// Populated from `RelationshipType::Follows` edges on startup and
|
|
/// maintained in real-time by `write_relationship` / `delete_relationship`.
|
|
follows: DashMap<u64, HashSet<u64>>,
|
|
/// Reverse index: `creator_id` -> set of follower user IDs.
|
|
///
|
|
/// Populated from `RelationshipType::Follows` edges. Updated on every
|
|
/// `write_relationship` / `delete_relationship`. Rebuilt from durable
|
|
/// relationship storage on open -- no separate persistence needed.
|
|
///
|
|
/// Backed by a `RoaringTreemap` (64-bit) so the **full** `u64` user ID is
|
|
/// preserved. The item-side bitmaps elsewhere in this index legitimately use
|
|
/// `u32` because item IDs are dense and bounded, but user IDs are not -- a
|
|
/// `u32` here silently aliased high user IDs onto each other, corrupting the
|
|
/// follower graph for any deployment with > 4B users or sparse ID schemes.
|
|
creator_followers: DashMap<u64, RoaringTreemap>,
|
|
/// M6p3: save timestamps for `DateSaved` sort.
|
|
/// Maps `(user_id, item_id_u64)` -> `saved_at_ns`.
|
|
save_timestamps: DashMap<(u64, u64), u64>,
|
|
}
|
|
|
|
impl UserStateIndex {
|
|
#[must_use]
|
|
pub fn new() -> Self {
|
|
Self {
|
|
seen: DashMap::new(),
|
|
blocked: DashMap::new(),
|
|
saved: DashMap::new(),
|
|
liked: DashMap::new(),
|
|
completion: DashMap::new(),
|
|
follows: DashMap::new(),
|
|
creator_followers: DashMap::new(),
|
|
save_timestamps: DashMap::new(),
|
|
}
|
|
}
|
|
|
|
// ── Seen ────────────────────────────────────────────────────────────
|
|
|
|
pub fn mark_seen(&self, user_id: u64, item_id: u32) {
|
|
self.seen.entry(user_id).or_default().insert(item_id);
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn is_seen(&self, user_id: u64, item_id: u32) -> bool {
|
|
self.seen
|
|
.get(&user_id)
|
|
.is_some_and(|bm| bm.contains(item_id))
|
|
}
|
|
|
|
/// Get the seen bitmap for a user (cloned).
|
|
#[must_use]
|
|
pub fn seen_bitmap(&self, user_id: u64) -> RoaringBitmap {
|
|
self.seen
|
|
.get(&user_id)
|
|
.map(|r| r.clone())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
// ── Blocked ─────────────────────────────────────────────────────────
|
|
|
|
/// Hide a specific item for a user (item already narrowed to a `u32` slot).
|
|
pub fn add_hide(&self, user_id: u64, item_id: u32) {
|
|
self.blocked
|
|
.entry(user_id)
|
|
.or_default()
|
|
.items
|
|
.insert(item_id);
|
|
}
|
|
|
|
/// Hide a specific item for a user, narrowing the `u64` item ID to its
|
|
/// `u32` slot through [`narrow_item_slot`] so an over-range ID logs a
|
|
/// warning instead of silently aliasing a lower ID.
|
|
///
|
|
/// This is the observable entry point the `Hide` relationship write path
|
|
/// (`db/relationships.rs`) and its rebuild replay (`db/state_rebuild.rs`)
|
|
/// should call instead of `add_hide(user_id, to.as_u64() as u32)`.
|
|
pub fn add_hide_item(&self, user_id: u64, item_id: u64) {
|
|
self.add_hide(user_id, narrow_item_slot(item_id));
|
|
}
|
|
|
|
/// Un-hide a specific item for a user, narrowing the `u64` item ID to its
|
|
/// `u32` slot through [`narrow_item_slot`]. Companion to
|
|
/// [`add_hide_item`](Self::add_hide_item) for the `Hide` delete path.
|
|
pub fn remove_hide_item(&self, user_id: u64, item_id: u64) {
|
|
self.remove_hide(user_id, narrow_item_slot(item_id));
|
|
}
|
|
|
|
/// Block an entire creator for a user.
|
|
pub fn add_block_creator(&self, user_id: u64, creator_id: u64) {
|
|
self.blocked
|
|
.entry(user_id)
|
|
.or_default()
|
|
.creators
|
|
.insert(creator_id);
|
|
}
|
|
|
|
/// Get hidden items bitmap for a user.
|
|
#[must_use]
|
|
pub fn hidden_items(&self, user_id: u64) -> RoaringBitmap {
|
|
self.blocked
|
|
.get(&user_id)
|
|
.map(|state| state.items.clone())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// Get blocked creator IDs for a user.
|
|
#[must_use]
|
|
pub fn blocked_creators(&self, user_id: u64) -> HashSet<u64> {
|
|
self.blocked
|
|
.get(&user_id)
|
|
.map(|state| state.creators.clone())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// Returns a predicate that checks whether an item ID is unseen for a user.
|
|
#[must_use]
|
|
pub fn unseen_predicate(&self, user_id: u64) -> Box<dyn Fn(u32) -> bool + Send + Sync> {
|
|
let seen = self.seen_bitmap(user_id);
|
|
let hidden = self.hidden_items(user_id);
|
|
Box::new(move |item_id: u32| !seen.contains(item_id) && !hidden.contains(item_id))
|
|
}
|
|
|
|
/// Returns a predicate that checks whether an item is not from a blocked creator.
|
|
///
|
|
/// Requires the `CreatorItemsBitmap` to expand creator blocks into item blocks.
|
|
#[must_use]
|
|
pub fn unblocked_predicate(
|
|
&self,
|
|
user_id: u64,
|
|
creator_items: &CreatorItemsBitmap,
|
|
) -> Box<dyn Fn(u32) -> bool + Send + Sync> {
|
|
let blocked_creators = self.blocked_creators(user_id);
|
|
let hidden = self.hidden_items(user_id);
|
|
|
|
// Build a bitmap of all items from blocked creators.
|
|
let mut blocked_items = hidden;
|
|
for &cid in &blocked_creators {
|
|
if let Some(bm) = creator_items.get(cid) {
|
|
blocked_items |= &bm;
|
|
}
|
|
}
|
|
|
|
Box::new(move |item_id: u32| !blocked_items.contains(item_id))
|
|
}
|
|
|
|
/// Build a bitmap of all items from the given followed creators.
|
|
///
|
|
/// The result is purely a function of `followed_creator_ids` and the
|
|
/// creator->items map; it does not depend on any per-user state held by this
|
|
/// index, so it takes no `user_id`. Callers resolve a user's followed
|
|
/// creators via [`followed_creators_vec`](Self::followed_creators_vec) and
|
|
/// pass them in.
|
|
#[must_use]
|
|
pub fn follows_bitmap(
|
|
&self,
|
|
followed_creator_ids: &[u64],
|
|
creator_items: &CreatorItemsBitmap,
|
|
) -> RoaringBitmap {
|
|
creator_items.union_for(followed_creator_ids)
|
|
}
|
|
|
|
/// Remove a creator block for a user.
|
|
pub fn remove_block_creator(&self, user_id: u64, creator_id: u64) {
|
|
if let Some(mut state) = self.blocked.get_mut(&user_id) {
|
|
state.creators.remove(&creator_id);
|
|
}
|
|
}
|
|
|
|
/// Remove a hidden item for a user (un-hide).
|
|
///
|
|
/// Clears *only* the hidden flag. The seen bitmap is intentionally left
|
|
/// untouched: hiding an item the user has already seen, then un-hiding it,
|
|
/// must not resurface that item as UNSEEN. Seen-state and hidden-state are
|
|
/// orthogonal -- `seen` records "the user encountered this", `blocked.items`
|
|
/// records "the user asked to suppress this". Un-suppressing does not erase
|
|
/// the fact that they saw it, so `unseen_predicate` keeps excluding it.
|
|
pub fn remove_hide(&self, user_id: u64, item_id: u32) {
|
|
if let Some(mut state) = self.blocked.get_mut(&user_id) {
|
|
state.items.remove(item_id);
|
|
}
|
|
}
|
|
|
|
// ── Follows ─────────────────────────────────────────────────────────
|
|
|
|
/// Record that a user follows a creator.
|
|
pub fn add_follow(&self, user_id: u64, creator_id: u64) {
|
|
self.follows.entry(user_id).or_default().insert(creator_id);
|
|
}
|
|
|
|
/// Remove a follow relationship.
|
|
pub fn remove_follow(&self, user_id: u64, creator_id: u64) {
|
|
if let Some(mut set) = self.follows.get_mut(&user_id) {
|
|
set.remove(&creator_id);
|
|
}
|
|
}
|
|
|
|
/// Get the set of creator IDs this user follows.
|
|
#[must_use]
|
|
pub fn followed_creators(&self, user_id: u64) -> HashSet<u64> {
|
|
self.follows
|
|
.get(&user_id)
|
|
.map(|r| r.clone())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// Get the followed creator IDs as a Vec (for convenience).
|
|
#[must_use]
|
|
pub fn followed_creators_vec(&self, user_id: u64) -> Vec<u64> {
|
|
self.follows
|
|
.get(&user_id)
|
|
.map(|r| r.iter().copied().collect())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
// ── Creator Followers (reverse index) ────────────────────────────────
|
|
|
|
/// Add a follower to a creator's follower set.
|
|
///
|
|
/// `creator_id` is the entity being followed; `follower_user_id` is the
|
|
/// user doing the following. Stored at full `u64` precision in a
|
|
/// `RoaringTreemap`.
|
|
pub fn add_creator_follower(&self, creator_id: u64, follower_user_id: u64) {
|
|
self.creator_followers
|
|
.entry(creator_id)
|
|
.or_default()
|
|
.insert(follower_user_id);
|
|
}
|
|
|
|
/// Remove a follower from a creator's follower set.
|
|
pub fn remove_creator_follower(&self, creator_id: u64, follower_user_id: u64) {
|
|
if let Some(mut bm) = self.creator_followers.get_mut(&creator_id) {
|
|
bm.remove(follower_user_id);
|
|
}
|
|
}
|
|
|
|
/// Get the set of follower user IDs for a creator (as a 64-bit treemap, cloned).
|
|
#[must_use]
|
|
pub fn followers_bitmap(&self, creator_id: u64) -> RoaringTreemap {
|
|
self.creator_followers
|
|
.get(&creator_id)
|
|
.map(|r| r.clone())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// Get follower user IDs for a creator as a `Vec<u64>`.
|
|
#[must_use]
|
|
pub fn follower_ids(&self, creator_id: u64) -> Vec<u64> {
|
|
self.creator_followers
|
|
.get(&creator_id)
|
|
.map(|bm| bm.iter().collect())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
// ── Saved / Liked ───────────────────────────────────────────────────
|
|
|
|
pub fn add_save(&self, user_id: u64, item_id: u32) {
|
|
self.saved.entry(user_id).or_default().insert(item_id);
|
|
}
|
|
|
|
/// Save an item with a timestamp (for `DateSaved` sort).
|
|
///
|
|
/// Also adds the item to the saved bitmap for inclusion filters.
|
|
pub fn add_save_timestamped(&self, user_id: u64, item_id: u32, timestamp_ns: u64) {
|
|
self.saved.entry(user_id).or_default().insert(item_id);
|
|
self.save_timestamps
|
|
.insert((user_id, u64::from(item_id)), timestamp_ns);
|
|
}
|
|
|
|
/// Get the save timestamp for a (user, item) pair.
|
|
///
|
|
/// Returns `None` if the item was not saved via `add_save_timestamped` or
|
|
/// was saved via the older `add_save` (which does not record timestamps).
|
|
#[must_use]
|
|
pub fn get_save_timestamp(&self, user_id: u64, item_id: u64) -> Option<u64> {
|
|
self.save_timestamps.get(&(user_id, item_id)).map(|r| *r)
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn is_saved(&self, user_id: u64, item_id: u32) -> bool {
|
|
self.saved
|
|
.get(&user_id)
|
|
.is_some_and(|bm| bm.contains(item_id))
|
|
}
|
|
|
|
/// Get the saved-items bitmap for a user (cloned).
|
|
#[must_use]
|
|
pub fn saved_bitmap(&self, user_id: u64) -> RoaringBitmap {
|
|
self.saved
|
|
.get(&user_id)
|
|
.map(|r| r.clone())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
pub fn add_like(&self, user_id: u64, item_id: u32) {
|
|
self.liked.entry(user_id).or_default().insert(item_id);
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn is_liked(&self, user_id: u64, item_id: u32) -> bool {
|
|
self.liked
|
|
.get(&user_id)
|
|
.is_some_and(|bm| bm.contains(item_id))
|
|
}
|
|
|
|
/// Get the liked-items bitmap for a user (cloned).
|
|
#[must_use]
|
|
pub fn liked_bitmap(&self, user_id: u64) -> RoaringBitmap {
|
|
self.liked
|
|
.get(&user_id)
|
|
.map(|r| r.clone())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
// ── Completion ──────────────────────────────────────────────────────
|
|
|
|
pub fn record_completion(&self, user_id: u64, item_id: u64, ratio: f64) {
|
|
self.completion
|
|
.entry(user_id)
|
|
.or_default()
|
|
.insert(item_id, ratio);
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn get_completion(&self, user_id: u64, item_id: u64) -> Option<f64> {
|
|
self.completion
|
|
.get(&user_id)
|
|
.and_then(|items| items.get(&item_id).copied())
|
|
}
|
|
|
|
/// Check if an item is "in progress" (0.0 < completion < threshold).
|
|
#[must_use]
|
|
pub fn is_in_progress(&self, user_id: u64, item_id: u64, threshold: f64) -> bool {
|
|
self.completion
|
|
.get(&user_id)
|
|
.and_then(|items| items.get(&item_id).copied())
|
|
.is_some_and(|r| r > 0.0 && r < threshold)
|
|
}
|
|
|
|
/// List all item IDs with partial completion for a user (0 < v < threshold).
|
|
///
|
|
/// Returns a `RoaringBitmap` of item IDs (narrowed to a u32 slot via
|
|
/// [`narrow_item_slot`]) that satisfy the in-progress predicate. Used by
|
|
/// Stage 2.5 of the executor to build the inclusion set for
|
|
/// `FilterExpr::InProgress`.
|
|
///
|
|
/// Scans only this user's completion entries — the map is keyed per-user,
|
|
/// so the cost is `O(items completed by this user)`, not `O(global
|
|
/// completion rows)` as it was when the map was keyed by a flat
|
|
/// `(user_id, item_id)` tuple.
|
|
#[must_use]
|
|
pub fn in_progress_items(&self, user_id: u64, threshold: f64) -> RoaringBitmap {
|
|
let mut bm = RoaringBitmap::new();
|
|
if let Some(items) = self.completion.get(&user_id) {
|
|
for (&iid, &ratio) in items.iter() {
|
|
if ratio > 0.0 && ratio < threshold {
|
|
bm.insert(narrow_item_slot(iid));
|
|
}
|
|
}
|
|
}
|
|
bm
|
|
}
|
|
}
|
|
|
|
// ── Durable rows (completion / saved-timestamp / liked) ──────────────────────
|
|
|
|
/// Discriminator byte for a `Tag::UserState` durable row, distinguishing the
|
|
/// three signal-driven maps that share the sentinel `(0, Tag::UserState)` prefix.
|
|
#[repr(u8)]
|
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
enum UserStateRowKind {
|
|
/// `(user_be8, item_be8)` -> completion ratio (8B LE f64 value).
|
|
Completion = 0x01,
|
|
/// `(user_be8, item_be8)` -> save timestamp ns (8B LE u64 value).
|
|
Save = 0x02,
|
|
/// `(user_be8, item_be8)` -> liked (empty value).
|
|
Like = 0x03,
|
|
}
|
|
|
|
/// Encode a `Tag::UserState` key: `[kind: 1B][user: 8B BE][item: 8B BE]`.
|
|
fn user_state_key(kind: UserStateRowKind, user_id: u64, item_id: u64) -> Vec<u8> {
|
|
let mut suffix = [0u8; 17];
|
|
suffix[0] = kind as u8;
|
|
suffix[1..9].copy_from_slice(&user_id.to_be_bytes());
|
|
suffix[9..].copy_from_slice(&item_id.to_be_bytes());
|
|
encode_key(EntityId::new(0), Tag::UserState, &suffix)
|
|
}
|
|
|
|
/// Decode a `Tag::UserState` suffix into `(kind_byte, user_id, item_id)`.
|
|
fn decode_user_state_suffix(suffix: &[u8]) -> Option<(u8, u64, u64)> {
|
|
if suffix.len() < 17 {
|
|
return None;
|
|
}
|
|
let user_id = u64::from_be_bytes(suffix[1..9].try_into().ok()?);
|
|
let item_id = u64::from_be_bytes(suffix[9..17].try_into().ok()?);
|
|
Some((suffix[0], user_id, item_id))
|
|
}
|
|
|
|
impl UserStateIndex {
|
|
/// Persist a completion ratio write-through as a durable `Tag::UserState` row,
|
|
/// then update the in-memory map. Mirrors [`record_completion`](Self::record_completion).
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns storage errors from the underlying engine.
|
|
pub fn record_completion_durable(
|
|
&self,
|
|
storage: &dyn StorageEngine,
|
|
user_id: u64,
|
|
item_id: u64,
|
|
ratio: f64,
|
|
) -> crate::Result<()> {
|
|
let key = user_state_key(UserStateRowKind::Completion, user_id, item_id);
|
|
storage
|
|
.put(&key, &ratio.to_le_bytes())
|
|
.map_err(crate::schema::TidalError::from)?;
|
|
self.record_completion(user_id, item_id, ratio);
|
|
Ok(())
|
|
}
|
|
|
|
/// Persist a timestamped save write-through, then update the in-memory map.
|
|
/// Mirrors [`add_save_timestamped`](Self::add_save_timestamped).
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns storage errors from the underlying engine.
|
|
pub fn add_save_durable(
|
|
&self,
|
|
storage: &dyn StorageEngine,
|
|
user_id: u64,
|
|
item_id: u32,
|
|
timestamp_ns: u64,
|
|
) -> crate::Result<()> {
|
|
let key = user_state_key(UserStateRowKind::Save, user_id, u64::from(item_id));
|
|
storage
|
|
.put(&key, ×tamp_ns.to_le_bytes())
|
|
.map_err(crate::schema::TidalError::from)?;
|
|
self.add_save_timestamped(user_id, item_id, timestamp_ns);
|
|
Ok(())
|
|
}
|
|
|
|
/// Persist a like write-through, then update the in-memory map.
|
|
/// Mirrors [`add_like`](Self::add_like).
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns storage errors from the underlying engine.
|
|
pub fn add_like_durable(
|
|
&self,
|
|
storage: &dyn StorageEngine,
|
|
user_id: u64,
|
|
item_id: u32,
|
|
) -> crate::Result<()> {
|
|
let key = user_state_key(UserStateRowKind::Like, user_id, u64::from(item_id));
|
|
storage
|
|
.put(&key, &[])
|
|
.map_err(crate::schema::TidalError::from)?;
|
|
self.add_like(user_id, item_id);
|
|
Ok(())
|
|
}
|
|
|
|
/// Restore the completion / saved-timestamp / liked maps from durable
|
|
/// `Tag::UserState` rows on open. Targeted prefix scan over the sentinel
|
|
/// entity (`0`), so restore is `O(user_state_rows)`.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns storage errors from the underlying engine.
|
|
#[allow(clippy::cast_possible_truncation)]
|
|
pub fn restore(&self, storage: &dyn StorageEngine) -> crate::Result<()> {
|
|
let prefix = entity_tag_prefix(EntityId::new(0), Tag::UserState);
|
|
let mut count = 0u64;
|
|
for entry in storage.scan_prefix(&prefix) {
|
|
let (key, value) = entry.map_err(crate::schema::TidalError::from)?;
|
|
let Some((_, Tag::UserState, suffix)) = parse_key(&key) else {
|
|
continue;
|
|
};
|
|
let Some((kind, user_id, item_id)) = decode_user_state_suffix(suffix) else {
|
|
continue;
|
|
};
|
|
match kind {
|
|
k if k == UserStateRowKind::Completion as u8 => {
|
|
if value.len() >= 8 {
|
|
let ratio = f64::from_le_bytes(value[..8].try_into().unwrap_or([0u8; 8]));
|
|
self.record_completion(user_id, item_id, ratio);
|
|
count += 1;
|
|
}
|
|
}
|
|
k if k == UserStateRowKind::Save as u8 => {
|
|
if value.len() >= 8 {
|
|
let ts = u64::from_le_bytes(value[..8].try_into().unwrap_or([0u8; 8]));
|
|
self.add_save_timestamped(user_id, item_id as u32, ts);
|
|
count += 1;
|
|
}
|
|
}
|
|
k if k == UserStateRowKind::Like as u8 => {
|
|
self.add_like(user_id, item_id as u32);
|
|
count += 1;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
if count > 0 {
|
|
tracing::info!(rows = count, "user-state index restored from durable rows");
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl Default for UserStateIndex {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[allow(clippy::unwrap_used, clippy::float_cmp)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn seen_mark_and_check() {
|
|
let idx = UserStateIndex::new();
|
|
assert!(!idx.is_seen(1, 10));
|
|
idx.mark_seen(1, 10);
|
|
assert!(idx.is_seen(1, 10));
|
|
assert!(!idx.is_seen(1, 20));
|
|
assert!(!idx.is_seen(2, 10)); // different user
|
|
}
|
|
|
|
#[test]
|
|
fn hide_item() {
|
|
let idx = UserStateIndex::new();
|
|
idx.add_hide(1, 100);
|
|
let hidden = idx.hidden_items(1);
|
|
assert!(hidden.contains(100));
|
|
assert!(!hidden.contains(200));
|
|
}
|
|
|
|
#[test]
|
|
fn remove_hide_clears_only_hidden_not_seen() {
|
|
// Regression: un-hiding an item the user has already seen must NOT
|
|
// mark it unseen. Seen-state and hidden-state are orthogonal.
|
|
let idx = UserStateIndex::new();
|
|
idx.mark_seen(1, 100);
|
|
idx.add_hide(1, 100);
|
|
|
|
// While hidden + seen, the item is excluded for both reasons.
|
|
assert!(idx.is_seen(1, 100));
|
|
assert!(idx.hidden_items(1).contains(100));
|
|
assert!(!idx.unseen_predicate(1)(100));
|
|
|
|
// Un-hide: the hidden flag clears, but seen-state must survive.
|
|
idx.remove_hide(1, 100);
|
|
assert!(!idx.hidden_items(1).contains(100), "hidden flag must clear");
|
|
assert!(idx.is_seen(1, 100), "un-hide must not reset seen-state");
|
|
assert!(
|
|
!idx.unseen_predicate(1)(100),
|
|
"still-seen item must not resurface as unseen after un-hide"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn narrow_item_slot_in_range_is_identity() {
|
|
// Any id within the u32 universe narrows to itself with no warning.
|
|
assert_eq!(narrow_item_slot(0), 0);
|
|
assert_eq!(narrow_item_slot(123), 123);
|
|
assert_eq!(narrow_item_slot(u64::from(u32::MAX)), u32::MAX);
|
|
}
|
|
|
|
#[test]
|
|
fn narrow_item_slot_over_range_truncates() {
|
|
// Over-range ids are truncated to the low 32 bits (the documented,
|
|
// intentional universe limit); the warning makes the truncation
|
|
// observable but the value contract is unchanged.
|
|
assert_eq!(narrow_item_slot(u64::from(u32::MAX) + 1), 0);
|
|
assert_eq!(narrow_item_slot((1u64 << 32) | 7), 7);
|
|
}
|
|
|
|
#[test]
|
|
fn add_hide_item_routes_through_narrowing() {
|
|
let idx = UserStateIndex::new();
|
|
// In-range id hides exactly that slot.
|
|
idx.add_hide_item(1, 100);
|
|
assert!(idx.hidden_items(1).contains(100));
|
|
|
|
// Over-range id narrows to its low-32 slot, matching narrow_item_slot.
|
|
// (Low 32 bits = 42; the high bit is dropped by the narrowing.)
|
|
idx.add_hide_item(1, (1u64 << 32) + 42);
|
|
assert!(idx.hidden_items(1).contains(42));
|
|
|
|
// remove_hide_item un-hides through the same narrowing.
|
|
idx.remove_hide_item(1, (1u64 << 32) + 42);
|
|
assert!(!idx.hidden_items(1).contains(42));
|
|
// The unrelated in-range hide is untouched.
|
|
assert!(idx.hidden_items(1).contains(100));
|
|
}
|
|
|
|
#[test]
|
|
fn block_creator() {
|
|
let idx = UserStateIndex::new();
|
|
idx.add_block_creator(1, 77);
|
|
let blocked = idx.blocked_creators(1);
|
|
assert!(blocked.contains(&77));
|
|
}
|
|
|
|
#[test]
|
|
fn unseen_predicate_excludes_seen_and_hidden() {
|
|
let idx = UserStateIndex::new();
|
|
idx.mark_seen(1, 10);
|
|
idx.add_hide(1, 20);
|
|
|
|
let pred = idx.unseen_predicate(1);
|
|
assert!(!pred(10)); // seen
|
|
assert!(!pred(20)); // hidden
|
|
assert!(pred(30)); // neither
|
|
}
|
|
|
|
#[test]
|
|
fn unblocked_predicate_excludes_blocked_creator_items() {
|
|
let idx = UserStateIndex::new();
|
|
let creator_items = CreatorItemsBitmap::new();
|
|
creator_items.add_item(77, 100);
|
|
creator_items.add_item(77, 101);
|
|
|
|
idx.add_block_creator(1, 77);
|
|
idx.add_hide(1, 200);
|
|
|
|
let pred = idx.unblocked_predicate(1, &creator_items);
|
|
assert!(!pred(100)); // from blocked creator
|
|
assert!(!pred(101)); // from blocked creator
|
|
assert!(!pred(200)); // hidden
|
|
assert!(pred(300)); // ok
|
|
}
|
|
|
|
#[test]
|
|
fn save_and_like() {
|
|
let idx = UserStateIndex::new();
|
|
idx.add_save(1, 10);
|
|
idx.add_like(1, 20);
|
|
|
|
assert!(idx.is_saved(1, 10));
|
|
assert!(!idx.is_saved(1, 20));
|
|
assert!(idx.is_liked(1, 20));
|
|
assert!(!idx.is_liked(1, 10));
|
|
}
|
|
|
|
#[test]
|
|
fn saved_bitmap_returns_all_saved_items() {
|
|
let idx = UserStateIndex::new();
|
|
idx.add_save(1, 10);
|
|
idx.add_save(1, 20);
|
|
idx.add_save(1, 30);
|
|
idx.add_save(2, 99); // different user
|
|
|
|
let bm = idx.saved_bitmap(1);
|
|
assert_eq!(bm.len(), 3);
|
|
assert!(bm.contains(10));
|
|
assert!(bm.contains(20));
|
|
assert!(bm.contains(30));
|
|
assert!(!bm.contains(99)); // belongs to user 2
|
|
|
|
// Unknown user returns empty bitmap.
|
|
assert!(idx.saved_bitmap(999).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn liked_bitmap_returns_all_liked_items() {
|
|
let idx = UserStateIndex::new();
|
|
idx.add_like(1, 5);
|
|
idx.add_like(1, 15);
|
|
idx.add_like(2, 25); // different user
|
|
|
|
let bm = idx.liked_bitmap(1);
|
|
assert_eq!(bm.len(), 2);
|
|
assert!(bm.contains(5));
|
|
assert!(bm.contains(15));
|
|
assert!(!bm.contains(25));
|
|
|
|
// Unknown user returns empty bitmap.
|
|
assert!(idx.liked_bitmap(999).is_empty());
|
|
}
|
|
|
|
// ── Creator Followers Tests ──────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn add_creator_follower_and_query() {
|
|
let idx = UserStateIndex::new();
|
|
idx.add_creator_follower(100, 1); // creator 100, follower user 1
|
|
idx.add_creator_follower(100, 2);
|
|
idx.add_creator_follower(200, 3);
|
|
|
|
let bm = idx.followers_bitmap(100);
|
|
assert_eq!(bm.len(), 2);
|
|
assert!(bm.contains(1));
|
|
assert!(bm.contains(2));
|
|
|
|
let ids = idx.follower_ids(100);
|
|
assert_eq!(ids.len(), 2);
|
|
assert!(ids.contains(&1));
|
|
assert!(ids.contains(&2));
|
|
|
|
// Creator 200 has only user 3.
|
|
assert_eq!(idx.follower_ids(200), vec![3]);
|
|
|
|
// Unknown creator returns empty.
|
|
assert!(idx.followers_bitmap(999).is_empty());
|
|
assert!(idx.follower_ids(999).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn remove_creator_follower() {
|
|
let idx = UserStateIndex::new();
|
|
idx.add_creator_follower(100, 1);
|
|
idx.add_creator_follower(100, 2);
|
|
|
|
idx.remove_creator_follower(100, 1);
|
|
let bm = idx.followers_bitmap(100);
|
|
assert_eq!(bm.len(), 1);
|
|
assert!(!bm.contains(1));
|
|
assert!(bm.contains(2));
|
|
|
|
// Removing from unknown creator is a no-op.
|
|
idx.remove_creator_follower(999, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn creator_followers_preserve_full_u64() {
|
|
// Regression: the reverse index previously stored follower IDs as u32,
|
|
// which aliased high user IDs onto each other (e.g. `1` and
|
|
// `1 + 2^32` collide once truncated). Full-u64 storage keeps them
|
|
// distinct.
|
|
let idx = UserStateIndex::new();
|
|
let low = 1u64;
|
|
let high = 1u64 + (1u64 << 32); // shares low 32 bits with `low`
|
|
let huge = u64::MAX - 7; // far outside u32 range
|
|
|
|
idx.add_creator_follower(100, low);
|
|
idx.add_creator_follower(100, high);
|
|
idx.add_creator_follower(100, huge);
|
|
|
|
let bm = idx.followers_bitmap(100);
|
|
assert_eq!(bm.len(), 3, "u32 truncation would have collapsed low/high");
|
|
assert!(bm.contains(low));
|
|
assert!(bm.contains(high));
|
|
assert!(bm.contains(huge));
|
|
|
|
let ids = idx.follower_ids(100);
|
|
assert_eq!(ids.len(), 3);
|
|
assert!(ids.contains(&low));
|
|
assert!(ids.contains(&high));
|
|
assert!(ids.contains(&huge));
|
|
|
|
// Removing the truncation-colliding `high` must not affect `low`.
|
|
idx.remove_creator_follower(100, high);
|
|
let bm = idx.followers_bitmap(100);
|
|
assert!(bm.contains(low));
|
|
assert!(!bm.contains(high));
|
|
assert!(bm.contains(huge));
|
|
}
|
|
|
|
#[test]
|
|
fn completion_tracking() {
|
|
let idx = UserStateIndex::new();
|
|
idx.record_completion(1, 10, 0.5);
|
|
assert_eq!(idx.get_completion(1, 10), Some(0.5));
|
|
assert!(idx.is_in_progress(1, 10, 0.8));
|
|
assert!(!idx.is_in_progress(1, 10, 0.4)); // 0.5 >= 0.4
|
|
|
|
idx.record_completion(1, 10, 0.9);
|
|
assert!(!idx.is_in_progress(1, 10, 0.8)); // 0.9 >= 0.8
|
|
}
|
|
|
|
#[test]
|
|
fn in_progress_items_bitmap() {
|
|
let idx = UserStateIndex::new();
|
|
idx.record_completion(1, 10, 0.3); // in progress
|
|
idx.record_completion(1, 20, 0.9); // complete
|
|
idx.record_completion(1, 30, 0.0); // not started
|
|
idx.record_completion(1, 40, 0.5); // in progress
|
|
idx.record_completion(2, 50, 0.4); // different user
|
|
|
|
let bm = idx.in_progress_items(1, 0.8);
|
|
assert_eq!(bm.len(), 2);
|
|
assert!(bm.contains(10));
|
|
assert!(bm.contains(40));
|
|
assert!(!bm.contains(20)); // >= threshold
|
|
assert!(!bm.contains(30)); // == 0.0
|
|
assert!(!bm.contains(50)); // different user
|
|
}
|
|
|
|
#[test]
|
|
fn in_progress_items_isolates_users_under_shared_item_ids() {
|
|
// Regression for the per-user completion keying: many users completing
|
|
// the *same* item IDs must not bleed into each other's in-progress set.
|
|
// Previously the global (user_id, item_id) map was scanned in full per
|
|
// call; the per-user map makes each query touch only its own user.
|
|
let idx = UserStateIndex::new();
|
|
for uid in 0..100u64 {
|
|
// Every user has item 7 in progress and item 8 complete.
|
|
idx.record_completion(uid, 7, 0.5);
|
|
idx.record_completion(uid, 8, 0.95);
|
|
}
|
|
// User 42 additionally has item 9 in progress.
|
|
idx.record_completion(42, 9, 0.25);
|
|
|
|
let bm = idx.in_progress_items(42, 0.8);
|
|
assert_eq!(bm.len(), 2, "only user 42's in-progress items");
|
|
assert!(bm.contains(7));
|
|
assert!(bm.contains(9));
|
|
assert!(!bm.contains(8)); // >= threshold
|
|
|
|
// A user without the extra item sees only the shared in-progress item.
|
|
let other = idx.in_progress_items(7, 0.8);
|
|
assert_eq!(other.len(), 1);
|
|
assert!(other.contains(7));
|
|
assert!(!other.contains(9)); // belongs to user 42 only
|
|
|
|
// get_completion / is_in_progress agree with the per-user map.
|
|
assert_eq!(idx.get_completion(42, 9), Some(0.25));
|
|
assert_eq!(idx.get_completion(7, 9), None);
|
|
assert!(idx.is_in_progress(42, 9, 0.8));
|
|
assert!(!idx.is_in_progress(7, 9, 0.8));
|
|
}
|
|
}
|