- Add BUILD.bazel across tidal, tidal-net, tidal-server, tidalctl for bzlmod build - Add tidal/ crate docs (README, CHANGELOG, CONTRIBUTING, AGENTS, CLAUDE, API, ARCHITECTURE) and ai-lookup reference - Add docker standalone/cluster/deploy images, compose, and prometheus config - Harden WAL (batch format, writer, dedup, diagnostics), text syncer/collectors, and vector registry - Expand tidalctl CLI and tests; restructure WAL/visibility integration test suites - Refine tidal-net transport/client/server and tidal-server cluster/scatter-gather
232 lines
7.1 KiB
Rust
232 lines
7.1 KiB
Rust
//! M9p3 integration test — Retroactive Purge & Deterministic Rematerialization.
|
|
//!
|
|
//! Validates the full purge flow: multiple users contribute to a community,
|
|
//! one user purges their prior contributions, and the community aggregate
|
|
//! rematerializes to exactly the remaining contributors — deterministically,
|
|
//! idempotently, with the local profile untouched and the purge surviving a
|
|
//! close/reopen.
|
|
//!
|
|
//! ROADMAP §Milestone 9 Phase 3.
|
|
|
|
#![allow(clippy::unwrap_used)]
|
|
|
|
use std::time::Duration;
|
|
|
|
use tidaldb::{
|
|
CommunityId, ShareIntent, SharePolicy, TidalDb, UserId,
|
|
schema::{DecaySpec, EntityId, EntityKind, SchemaBuilder, Timestamp, Window},
|
|
};
|
|
|
|
fn schema() -> tidaldb::schema::Schema {
|
|
let mut b = SchemaBuilder::new();
|
|
let _ = b
|
|
.signal(
|
|
"like",
|
|
EntityKind::Item,
|
|
DecaySpec::Exponential {
|
|
half_life: Duration::from_secs(7 * 24 * 3600),
|
|
},
|
|
)
|
|
.windows(&[Window::AllTime])
|
|
.velocity(false)
|
|
.add();
|
|
b.build().unwrap()
|
|
}
|
|
|
|
fn policy() -> SharePolicy {
|
|
SharePolicy::community_share(1, [ShareIntent::Like])
|
|
}
|
|
|
|
/// Join `users` and have each contribute one `like` on `item` to `community`.
|
|
fn seed(db: &TidalDb, community: CommunityId, item: EntityId, users: &[u64]) {
|
|
let now = Timestamp::now();
|
|
for &u in users {
|
|
db.join_community(UserId(u), community, policy()).unwrap();
|
|
assert!(
|
|
db.signal_for_community("like", item, 1.0, now, UserId(u), community)
|
|
.unwrap()
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn purge_removes_only_target_user_and_rematerializes() {
|
|
let db = TidalDb::builder()
|
|
.ephemeral()
|
|
.with_schema(schema())
|
|
.open()
|
|
.unwrap();
|
|
let (community, item) = (CommunityId(1), EntityId::new(100));
|
|
seed(&db, community, item, &[1, 2, 3]);
|
|
|
|
// Aggregate reflects all 3 contributors.
|
|
assert_eq!(
|
|
db.read_community_windowed_count(community, item, "like", Window::AllTime)
|
|
.unwrap(),
|
|
3
|
|
);
|
|
|
|
// User 2 purges their prior contributions (all epochs).
|
|
let receipt = db
|
|
.purge_prior_contributions(UserId(2), community, 0..=u32::MAX)
|
|
.unwrap();
|
|
assert_eq!(receipt.contributions_removed, 1);
|
|
assert!(receipt.watermark_ns > 0);
|
|
|
|
// Rematerialized aggregate = remaining 2 contributors.
|
|
assert_eq!(
|
|
db.read_community_windowed_count(community, item, "like", Window::AllTime)
|
|
.unwrap(),
|
|
2
|
|
);
|
|
// Purge watermark is exposed.
|
|
assert_eq!(
|
|
db.community_purge_watermark(community),
|
|
Some(receipt.watermark_ns)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn purge_is_idempotent() {
|
|
let db = TidalDb::builder()
|
|
.ephemeral()
|
|
.with_schema(schema())
|
|
.open()
|
|
.unwrap();
|
|
let (community, item) = (CommunityId(1), EntityId::new(1));
|
|
seed(&db, community, item, &[1, 2, 3]);
|
|
|
|
db.purge_prior_contributions(UserId(2), community, 0..=u32::MAX)
|
|
.unwrap();
|
|
let after_first = db
|
|
.read_community_windowed_count(community, item, "like", Window::AllTime)
|
|
.unwrap();
|
|
|
|
// Re-purge the same range: removes nothing further, aggregate unchanged.
|
|
let receipt2 = db
|
|
.purge_prior_contributions(UserId(2), community, 0..=u32::MAX)
|
|
.unwrap();
|
|
assert_eq!(receipt2.contributions_removed, 0);
|
|
assert_eq!(
|
|
db.read_community_windowed_count(community, item, "like", Window::AllTime)
|
|
.unwrap(),
|
|
after_first,
|
|
"repeated purge must yield an identical aggregate"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn purge_does_not_touch_local_profile() {
|
|
let db = TidalDb::builder()
|
|
.ephemeral()
|
|
.with_schema(schema())
|
|
.open()
|
|
.unwrap();
|
|
let (community, item) = (CommunityId(1), EntityId::new(7));
|
|
db.join_community(UserId(1), community, policy()).unwrap();
|
|
let now = Timestamp::now();
|
|
|
|
// User 1 contributes to the community AND has local likes on the same item.
|
|
db.signal_for_community("like", item, 1.0, now, UserId(1), community)
|
|
.unwrap();
|
|
db.signal("like", item, 1.0, now).unwrap();
|
|
db.signal("like", item, 1.0, now).unwrap();
|
|
let local_before = db
|
|
.read_windowed_count(item, "like", Window::AllTime)
|
|
.unwrap();
|
|
assert_eq!(local_before, 2);
|
|
|
|
// Purge the user's community contributions.
|
|
db.purge_prior_contributions(UserId(1), community, 0..=u32::MAX)
|
|
.unwrap();
|
|
|
|
// Community aggregate emptied, but the LOCAL profile is untouched.
|
|
assert_eq!(
|
|
db.read_community_windowed_count(community, item, "like", Window::AllTime)
|
|
.unwrap(),
|
|
0
|
|
);
|
|
assert_eq!(
|
|
db.read_windowed_count(item, "like", Window::AllTime)
|
|
.unwrap(),
|
|
2,
|
|
"purge must never touch the local profile"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn purge_respects_epoch_range_via_rejoin() {
|
|
let db = TidalDb::builder()
|
|
.ephemeral()
|
|
.with_schema(schema())
|
|
.open()
|
|
.unwrap();
|
|
let (community, item) = (CommunityId(2), EntityId::new(3));
|
|
let user = UserId(5);
|
|
let now = Timestamp::now();
|
|
|
|
// Epoch 0 contribution.
|
|
db.join_community(user, community, policy()).unwrap();
|
|
db.signal_for_community("like", item, 1.0, now, user, community)
|
|
.unwrap();
|
|
// Leave + rejoin -> epoch 1 contribution.
|
|
db.leave_community(user, community, tidaldb::LeaveMode::StopForward)
|
|
.unwrap();
|
|
db.rejoin_community(user, community, policy()).unwrap();
|
|
db.signal_for_community("like", item, 1.0, now, user, community)
|
|
.unwrap();
|
|
assert_eq!(
|
|
db.read_community_windowed_count(community, item, "like", Window::AllTime)
|
|
.unwrap(),
|
|
2
|
|
);
|
|
|
|
// Purge only epoch 0; the epoch-1 (post-rejoin) contribution survives.
|
|
let receipt = db
|
|
.purge_prior_contributions(user, community, 0..=0)
|
|
.unwrap();
|
|
assert_eq!(receipt.contributions_removed, 1);
|
|
assert_eq!(
|
|
db.read_community_windowed_count(community, item, "like", Window::AllTime)
|
|
.unwrap(),
|
|
1
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn purge_survives_reopen() {
|
|
let home = tidaldb::TempTidalHome::new().unwrap();
|
|
let (community, item) = (CommunityId(1), EntityId::new(100));
|
|
|
|
{
|
|
let db = TidalDb::builder()
|
|
.with_data_dir(home.path())
|
|
.with_schema(schema())
|
|
.open()
|
|
.unwrap();
|
|
seed(&db, community, item, &[1, 2, 3]);
|
|
db.purge_prior_contributions(UserId(2), community, 0..=u32::MAX)
|
|
.unwrap();
|
|
assert_eq!(
|
|
db.read_community_windowed_count(community, item, "like", Window::AllTime)
|
|
.unwrap(),
|
|
2
|
|
);
|
|
db.shutdown().unwrap();
|
|
}
|
|
|
|
// Reopen: the community aggregate is restored from the Tag::Community
|
|
// checkpoint with the purge already applied.
|
|
let db = TidalDb::builder()
|
|
.with_data_dir(home.path())
|
|
.with_schema(schema())
|
|
.open()
|
|
.unwrap();
|
|
assert_eq!(
|
|
db.read_community_windowed_count(community, item, "like", Window::AllTime)
|
|
.unwrap(),
|
|
2,
|
|
"purged community aggregate must survive reopen"
|
|
);
|
|
}
|