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