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
176 lines
5.5 KiB
Rust
176 lines
5.5 KiB
Rust
//! Integration tests for M7P4 Operational Visibility — `QueryStats` + Prometheus format.
|
|
//!
|
|
//! - Task 01: `QueryStats` — per-query execution statistics attached to every result
|
|
//! - Task 05: Prometheus exposition format correctness
|
|
#![allow(clippy::too_many_lines, clippy::unwrap_used)]
|
|
|
|
use std::{collections::HashMap, time::Duration};
|
|
|
|
use tidaldb::{
|
|
TidalDb,
|
|
schema::{DecaySpec, EntityId, EntityKind, Timestamp, Window},
|
|
};
|
|
|
|
// ── Shared test schema ───────────────────────────────────────────────────────
|
|
|
|
fn build_test_schema() -> tidaldb::schema::Schema {
|
|
use tidaldb::{AgentPolicy, schema::SchemaBuilder};
|
|
|
|
let mut builder = SchemaBuilder::new();
|
|
let _ = builder
|
|
.signal(
|
|
"view",
|
|
EntityKind::Item,
|
|
DecaySpec::Exponential {
|
|
half_life: Duration::from_secs(7 * 24 * 3600),
|
|
},
|
|
)
|
|
.windows(&[Window::OneHour])
|
|
.add();
|
|
let _ = builder
|
|
.signal(
|
|
"like",
|
|
EntityKind::Item,
|
|
DecaySpec::Exponential {
|
|
half_life: Duration::from_secs(30 * 24 * 3600),
|
|
},
|
|
)
|
|
.windows(&[Window::OneHour])
|
|
.add();
|
|
builder.session_policy(
|
|
"default",
|
|
AgentPolicy {
|
|
allowed_signals: vec!["view".to_string(), "like".to_string()],
|
|
denied_signals: vec![],
|
|
max_session_duration: Duration::from_secs(3600),
|
|
max_signals_per_session: 1000,
|
|
},
|
|
);
|
|
builder.build().unwrap()
|
|
}
|
|
|
|
// ── Task 01: QueryStats ──────────────────────────────────────────────────────
|
|
|
|
/// RETRIEVE results carry `QueryStats` with the profile name and pipeline counts.
|
|
#[test]
|
|
fn retrieve_results_include_query_stats() {
|
|
use tidaldb::query::retrieve::{ProfileRef, RetrieveBuilder};
|
|
|
|
let schema = build_test_schema();
|
|
let db = TidalDb::builder()
|
|
.ephemeral()
|
|
.with_schema(schema)
|
|
.open()
|
|
.unwrap();
|
|
|
|
// Write items so the candidate generation stage has something to score.
|
|
for i in 1u64..=5 {
|
|
let mut meta = HashMap::new();
|
|
meta.insert("category".to_string(), "news".to_string());
|
|
db.write_item_with_metadata(EntityId::new(i), &meta)
|
|
.unwrap();
|
|
db.signal("view", EntityId::new(i), 1.0, Timestamp::now())
|
|
.unwrap();
|
|
}
|
|
|
|
let query = RetrieveBuilder::new(EntityKind::Item, ProfileRef::new("new"))
|
|
.limit(5)
|
|
.build()
|
|
.unwrap();
|
|
let results = db.retrieve(&query).unwrap();
|
|
|
|
// QueryStats is unconditionally populated (not feature-gated).
|
|
assert_eq!(
|
|
results.stats.profile_name, "new",
|
|
"stats must capture the profile name used for scoring"
|
|
);
|
|
// Pipeline stage fields are accessible; exact values depend on item count.
|
|
let _ = results.stats.candidates_considered;
|
|
let _ = results.stats.candidates_after_filter;
|
|
let _ = results.stats.total_time_us;
|
|
let _ = results.stats.scoring_time_us;
|
|
let _ = results.stats.diversity_time_us;
|
|
}
|
|
|
|
/// SEARCH results carry `QueryStats` with the "search" profile name.
|
|
#[test]
|
|
fn search_results_include_query_stats() {
|
|
use tidaldb::query::search::Search;
|
|
|
|
let schema = build_test_schema();
|
|
let db = TidalDb::builder()
|
|
.ephemeral()
|
|
.with_schema(schema)
|
|
.open()
|
|
.unwrap();
|
|
|
|
// No text schema, so BM25 returns empty — but stats are still populated.
|
|
let query = Search::builder()
|
|
.query("test article")
|
|
.limit(5)
|
|
.build()
|
|
.unwrap();
|
|
let results = db.search(&query).unwrap();
|
|
|
|
assert_eq!(
|
|
results.stats.profile_name, "search",
|
|
"search pipeline must record the 'search' builtin profile in stats"
|
|
);
|
|
let _ = results.stats.candidates_considered;
|
|
let _ = results.stats.total_time_us;
|
|
}
|
|
|
|
// ── Task 05: Prometheus Exposition Format ────────────────────────────────────
|
|
|
|
/// Every `# HELP` line must be immediately followed by a `# TYPE` line.
|
|
#[test]
|
|
fn prometheus_format_help_always_followed_by_type() {
|
|
let schema = build_test_schema();
|
|
let db = TidalDb::builder()
|
|
.ephemeral()
|
|
.with_schema(schema)
|
|
.open()
|
|
.unwrap();
|
|
|
|
let prom = db.metrics().render_prometheus();
|
|
let lines: Vec<&str> = prom.lines().collect();
|
|
|
|
for (i, line) in lines.iter().enumerate() {
|
|
if line.starts_with("# HELP ") {
|
|
assert!(
|
|
i + 1 < lines.len() && lines[i + 1].starts_with("# TYPE "),
|
|
"# HELP line must be immediately followed by # TYPE at index {i}: {line:?}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// /healthz JSON contains the required fields.
|
|
#[test]
|
|
fn healthz_json_contains_required_fields() {
|
|
let schema = build_test_schema();
|
|
let db = TidalDb::builder()
|
|
.ephemeral()
|
|
.with_schema(schema)
|
|
.open()
|
|
.unwrap();
|
|
|
|
let healthz = db.metrics().render_healthz();
|
|
assert!(
|
|
healthz.contains("\"status\":"),
|
|
"healthz must contain status"
|
|
);
|
|
assert!(
|
|
healthz.contains("\"uptime_seconds\":"),
|
|
"healthz must contain uptime_seconds"
|
|
);
|
|
assert!(
|
|
healthz.contains("\"version\":"),
|
|
"healthz must contain version"
|
|
);
|
|
assert!(
|
|
healthz.contains("\"build_hash\":"),
|
|
"healthz must contain build_hash"
|
|
);
|
|
}
|