There was no metric anywhere that could answer "how much traffic are we serving"
or "what is our error rate". The engine published a rich DOMAIN surface (search
latency, WAL fsync, quorum timeouts, replication lag) and nothing about HTTP, so
a cluster could serve 401s or 503s indefinitely with every existing gauge looking
healthy. Logs were collected but unusable. There was no way to ask a RUNNING node
anything.
1. HTTP metrics. tidaldb_http_requests_total{route,method,status} plus a
per-route duration histogram, recorded by one layer placed OUTSIDE the auth,
timeout and rate-limit layers so it sees the status actually returned to the
client. Cardinality is the whole design: the route label is axum's MatchedPath
TEMPLATE, not the path, and unmatched requests collapse into one <unmatched>
bucket so a 404 flood cannot mint series. A hard cap folds anything past it
into an overflow bucket while established series keep counting.
The engine owns the /metrics listener but must not learn what a route or a
status code is, so it gained one registration hook
(MetricsState::set_extra_renderer) and tidal-server publishes through it. One
scrape target per node, not two.
2. Structured logs. The previous init was a bare tracing_subscriber::fmt(), which
produced two real defects: ANSI escapes leaked into collected logs, and every
line failed the collector's JSON parse and was stamped level=info — so
`level:error` matched NOTHING and errors were invisible to the log platform
while being collected. JSON_LOGS=1 emits the collector's exact wire format
(ts/level/service/env/msg), span fields are lifted so request_id lands on every
line of a request, and ANSI is off unconditionally in both formats.
Verified against the running binary, which caught a defect no unit test would
have: dependencies logging through the `log` crate arrived with target="log"
and four log.* metadata fields (absolute cargo registry paths, indexed
forever). The real module is now lifted into target and the bridge metadata
pruned.
3. Dashboard. docs/ops/grafana-tidaldb.json, 13 panels, mirrored into the fleet
as a grafana-database-dashboards key. Every metric name was checked against a
live endpoint and all 26 PromQL expressions were executed against the live
TSDB before commit, because a dashboard full of "No data" is worse than none.
Confirmed loaded in Grafana (uid tidaldb-overview, Databases folder).
4. tidalctl live mode. Every other subcommand reads a data dir AT REST, some
requiring a stopped node. `search`, `feed`, `cluster-status` and `watch` take
--url and talk to a running server, with --ca/--insecure because a cluster's
client port is served with the INTERNAL cluster CA. Exit codes follow the crate
contract, so `tidalctl cluster-status && deploy` gates on convergence.
Its first real run immediately found a reporting defect: the aggregated
/cluster/status reported two HEALTHY peers as UNREACHABLE PARTITIONED at 13.3M
lag, having derived lag against an uninitialised applied=0, while every node's
own status reported lag=0, reseed=false and identical frontiers, with
pod-to-pod connectivity open and nothing logged. cluster-status now names that
signature "NO REPORT (aggregated view; query the node directly)" instead of
repeating it as replication lag; a genuine non-zero-applied lag still reports
BEHIND. The underlying gap is documented as open work in
docs/ops/observability.md.
Verified: 2101 + 175 engine/server unit tests, 8 standalone integration (3 new,
including the cardinality proof and the cross-crate metrics seam), 23 tidalctl
(10 new), reseed + catchup + admin-gate e2e green, clippy clean, and both the
metrics and the log format exercised against a real running binary.
490 lines
18 KiB
Rust
490 lines
18 KiB
Rust
// Integration-test exemption (same posture as the other tidal-server tests):
|
|
// unwrap on known-good fixtures + rank index casts are idiomatic here.
|
|
#![allow(clippy::unwrap_used, clippy::cast_possible_truncation)]
|
|
//! End-to-end integration coverage for the STANDALONE data surface.
|
|
//!
|
|
//! `middleware.rs` covers auth / body-limit / request-ID and
|
|
//! `standalone_offload.rs` covers the `/feed` offload round trip, but neither
|
|
//! drives the full data path through the HTTP surface nor pins the
|
|
//! engine-result → DTO mapping. This test:
|
|
//!
|
|
//! 1. Drives the real handlers in-process via `tower::ServiceExt::oneshot`
|
|
//! (no TCP bind): POST /items → POST /embeddings → POST /signals → GET /feed
|
|
//! → GET /search.
|
|
//! 2. Asserts the [`crate::dto`] shape: `signals` is OMITTED when empty and
|
|
//! PRESENT when a profile populates the snapshot; search items carry
|
|
//! `bm25_score`.
|
|
//! 3. Asserts the standalone region-rejection path: any `?region=` param is a
|
|
//! 400 in both `/feed` and `/search` (region routing is a cluster concept).
|
|
|
|
use std::sync::Arc;
|
|
|
|
use axum::{
|
|
body::Body,
|
|
http::{Method, Request, StatusCode},
|
|
};
|
|
use tidal_server::{router::build_router, state::ServerState};
|
|
use tidaldb::TidalDb;
|
|
use tower::ServiceExt;
|
|
|
|
fn make_app() -> axum::Router {
|
|
// Default schema + built-in profiles (incl. `for_you`/`trending`), same
|
|
// setup as `run_standalone` in main.rs. No API key so requests need no auth.
|
|
let (schema, profiles) = tidal_server::config::load_schema(None).unwrap();
|
|
let db = TidalDb::builder()
|
|
.ephemeral()
|
|
.with_schema(schema)
|
|
.with_profiles(profiles)
|
|
.open()
|
|
.unwrap();
|
|
let state = Arc::new(ServerState::new(db));
|
|
build_router(
|
|
state,
|
|
std::sync::Arc::new(tidal_server::cluster::security::ClusterCreds::unauthenticated()),
|
|
)
|
|
}
|
|
|
|
async fn post_json(app: &axum::Router, uri: &str, body: serde_json::Value) -> StatusCode {
|
|
app.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method(Method::POST)
|
|
.uri(uri)
|
|
.header("Content-Type", "application/json")
|
|
.body(Body::from(serde_json::to_vec(&body).unwrap()))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.status()
|
|
}
|
|
|
|
async fn get_json(app: &axum::Router, uri: &str) -> (StatusCode, serde_json::Value) {
|
|
let response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method(Method::GET)
|
|
.uri(uri)
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let status = response.status();
|
|
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.unwrap();
|
|
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null);
|
|
(status, json)
|
|
}
|
|
|
|
/// Full standalone round trip: items + embeddings + signals, then a ranked
|
|
/// /feed and a /search over the indexed titles. Pins the DTO shape in both
|
|
/// directions.
|
|
#[tokio::test]
|
|
async fn items_signals_feed_search_round_trip() {
|
|
let app = make_app();
|
|
|
|
// Two items with searchable titles + a created_at for the Hot sort age.
|
|
for (id, title) in [(1u64, "jazz piano nocturne"), (2u64, "ambient jazz drift")] {
|
|
let status = post_json(
|
|
&app,
|
|
"/items",
|
|
serde_json::json!({
|
|
"entity_id": id,
|
|
"metadata": { "created_at": "1700000000", "title": title, "category": "music" }
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::CREATED, "item {id} create");
|
|
|
|
let status = post_json(
|
|
&app,
|
|
"/embeddings",
|
|
serde_json::json!({ "entity_id": id, "values": vec![0.1f32; 128] }),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::NO_CONTENT, "item {id} embedding");
|
|
}
|
|
|
|
// Drive view signals so the ranking profile has a signal snapshot to expose.
|
|
// Item 2 gets more weight so it should out-rank item 1.
|
|
for (id, weight) in [(1u64, 1.0), (2u64, 5.0)] {
|
|
let status = post_json(
|
|
&app,
|
|
"/signals",
|
|
serde_json::json!({ "entity_id": id, "signal": "view", "weight": weight }),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::NO_CONTENT, "signal for item {id}");
|
|
}
|
|
|
|
// ── GET /feed ────────────────────────────────────────────────────────────
|
|
// `for_you` boosts the `view` signal, so the feed items must carry a
|
|
// populated `signals` array (the DTO present-when-populated branch).
|
|
let (status, body) = get_json(&app, "/feed?profile=for_you&limit=10").await;
|
|
assert_eq!(status, StatusCode::OK, "feed body: {body}");
|
|
let items = body
|
|
.get("items")
|
|
.and_then(serde_json::Value::as_array)
|
|
.expect("feed has items array");
|
|
assert!(
|
|
!items.is_empty(),
|
|
"feed should rank the written items: {body}"
|
|
);
|
|
|
|
// Ranks contiguous from 1; the heavier-weighted item 2 ranks first.
|
|
for (idx, item) in items.iter().enumerate() {
|
|
let rank = item
|
|
.get("rank")
|
|
.and_then(serde_json::Value::as_u64)
|
|
.unwrap();
|
|
assert_eq!(rank as usize, idx + 1, "ranks contiguous from 1");
|
|
assert!(
|
|
item.get("score")
|
|
.and_then(serde_json::Value::as_f64)
|
|
.is_some(),
|
|
"each item carries a score"
|
|
);
|
|
}
|
|
assert_eq!(
|
|
items[0]
|
|
.get("entity_id")
|
|
.and_then(serde_json::Value::as_u64),
|
|
Some(2),
|
|
"heavier-weighted item ranks first: {body}"
|
|
);
|
|
|
|
// DTO: at least one ranked item must expose a populated `signals` array
|
|
// (the `for_you` view boost), proving the present-when-populated branch.
|
|
let any_with_signals = items.iter().any(|item| {
|
|
item.get("signals")
|
|
.and_then(serde_json::Value::as_array)
|
|
.is_some_and(|s| !s.is_empty())
|
|
});
|
|
assert!(
|
|
any_with_signals,
|
|
"a boosted profile must populate the signals snapshot: {body}"
|
|
);
|
|
|
|
// ── GET /search ──────────────────────────────────────────────────────────
|
|
// The background text syncer commits on a ~2s cadence (ephemeral index,
|
|
// manual reload), and the `/search` handler reloads the reader on every
|
|
// call. Poll the real endpoint until the committed docs are visible — a
|
|
// deterministic end-to-end wait on the real syncer, not a fixed sleep.
|
|
let body = poll_search_until_nonempty(&app, "/search?query=jazz&limit=10").await;
|
|
let results = body
|
|
.get("items")
|
|
.and_then(serde_json::Value::as_array)
|
|
.expect("search has items array");
|
|
assert!(
|
|
!results.is_empty(),
|
|
"search should match the jazz titles within the syncer window: {body}"
|
|
);
|
|
for (idx, item) in results.iter().enumerate() {
|
|
let rank = item
|
|
.get("rank")
|
|
.and_then(serde_json::Value::as_u64)
|
|
.unwrap();
|
|
assert_eq!(rank as usize, idx + 1, "search ranks contiguous from 1");
|
|
}
|
|
// A pure-text query must surface a BM25 component in the DTO for the
|
|
// matched items (the `skip_serializing_if = Option::is_none` field is
|
|
// present when scored).
|
|
let any_bm25 = results.iter().any(|item| {
|
|
item.get("bm25_score")
|
|
.and_then(serde_json::Value::as_f64)
|
|
.is_some()
|
|
});
|
|
assert!(any_bm25, "text search must expose bm25_score: {body}");
|
|
}
|
|
|
|
/// Re-issue a `/search` against the real handler until it returns a non-empty
|
|
/// `items` array or a bounded deadline elapses. Each call asserts 200 and
|
|
/// reloads the text reader (the handler does), so this waits on the real
|
|
/// background syncer commit (≈2s cadence) without a blind fixed sleep.
|
|
async fn poll_search_until_nonempty(app: &axum::Router, uri: &str) -> serde_json::Value {
|
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(8);
|
|
loop {
|
|
let (status, body) = get_json(app, uri).await;
|
|
assert_eq!(status, StatusCode::OK, "search body: {body}");
|
|
let nonempty = body
|
|
.get("items")
|
|
.and_then(serde_json::Value::as_array)
|
|
.is_some_and(|a| !a.is_empty());
|
|
if nonempty || std::time::Instant::now() >= deadline {
|
|
return body;
|
|
}
|
|
// The text syncer runs on its own OS thread, so a brief blocking sleep
|
|
// here (between awaits, not during one) does not stall the commit — it
|
|
// just paces the retry. `tokio::time` is not enabled for this crate, so
|
|
// a std sleep is the right tool.
|
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
|
}
|
|
}
|
|
|
|
/// DTO: an item with NO signals must serialize with `signals` ABSENT (the
|
|
/// skip-when-empty branch). Uses the `new` profile (sort-by-recency, no boosts),
|
|
/// so no signal snapshot is attached even though the item exists.
|
|
#[tokio::test]
|
|
async fn feed_omits_signals_when_empty() {
|
|
let app = make_app();
|
|
|
|
let status = post_json(
|
|
&app,
|
|
"/items",
|
|
serde_json::json!({
|
|
"entity_id": 42,
|
|
"metadata": { "created_at": "1700000000", "title": "no signals here" }
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::CREATED);
|
|
|
|
// `new` is a built-in sort-by-recency profile with no signal boosts, so the
|
|
// ranked item carries an empty snapshot → `signals` omitted from the JSON.
|
|
let (status, body) = get_json(&app, "/feed?profile=new&limit=10").await;
|
|
assert_eq!(status, StatusCode::OK, "feed body: {body}");
|
|
let items = body
|
|
.get("items")
|
|
.and_then(serde_json::Value::as_array)
|
|
.expect("feed has items array");
|
|
assert!(!items.is_empty(), "the item should still rank: {body}");
|
|
for item in items {
|
|
assert!(
|
|
item.get("signals").is_none(),
|
|
"empty signal snapshot must be OMITTED, not [] : {item}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Standalone region rejection: any `?region=` param is a 400 on `/feed`.
|
|
/// Region routing is a cluster-only concept; a standalone server must reject it
|
|
/// loudly rather than silently ignore it.
|
|
#[tokio::test]
|
|
async fn feed_rejects_region_param() {
|
|
let app = make_app();
|
|
let (status, body) = get_json(&app, "/feed?profile=for_you®ion=us-east").await;
|
|
assert_eq!(status, StatusCode::BAD_REQUEST, "body: {body}");
|
|
let err = body
|
|
.get("error")
|
|
.and_then(serde_json::Value::as_str)
|
|
.unwrap_or("");
|
|
assert!(
|
|
err.contains("region routing requires cluster mode"),
|
|
"error must explain region routing is cluster-only: {body}"
|
|
);
|
|
}
|
|
|
|
/// Standalone region rejection also applies to `/search`.
|
|
#[tokio::test]
|
|
async fn search_rejects_region_param() {
|
|
let app = make_app();
|
|
let (status, body) = get_json(&app, "/search?query=jazz®ion=us-east").await;
|
|
assert_eq!(status, StatusCode::BAD_REQUEST, "body: {body}");
|
|
let err = body
|
|
.get("error")
|
|
.and_then(serde_json::Value::as_str)
|
|
.unwrap_or("");
|
|
assert!(
|
|
err.contains("region routing requires cluster mode"),
|
|
"error must explain region routing is cluster-only: {body}"
|
|
);
|
|
}
|
|
|
|
/// The `OpenAPI` document is served, unauthenticated, at `GET /openapi.json`
|
|
/// and describes the data surface (this is the canonical HTTP API reference).
|
|
/// The served document — not just the in-crate `StandaloneApiDoc` unit test — is
|
|
/// the contract clients fetch, so pin it end-to-end through the router.
|
|
#[tokio::test]
|
|
async fn openapi_json_is_served_and_describes_the_data_routes() {
|
|
let app = make_app();
|
|
let (status, body) = get_json(&app, "/openapi.json").await;
|
|
assert_eq!(status, StatusCode::OK, "body: {body}");
|
|
|
|
// OpenAPI 3.x envelope: a top-level `openapi` version string.
|
|
let version = body
|
|
.get("openapi")
|
|
.and_then(serde_json::Value::as_str)
|
|
.unwrap_or("");
|
|
assert!(
|
|
version.starts_with("3."),
|
|
"expected an OpenAPI 3.x document, got openapi={version:?}"
|
|
);
|
|
|
|
// The /feed path must be advertised so generated clients can call the feed.
|
|
assert!(
|
|
body.pointer("/paths/~1feed").is_some(),
|
|
"served spec must document the /feed path: {body}"
|
|
);
|
|
|
|
// The bearerAuth scheme must be registered so clients know the data routes
|
|
// are token-gated when TIDAL_API_KEY is set.
|
|
assert!(
|
|
body.pointer("/components/securitySchemes/bearerAuth")
|
|
.is_some(),
|
|
"served spec must declare the bearerAuth security scheme: {body}"
|
|
);
|
|
}
|
|
|
|
// ── HTTP surface metrics ──────────────────────────────────────────────────────
|
|
//
|
|
// These drive the REAL router stack, so they exercise the layer's placement as
|
|
// well as its arithmetic: the unit tests in `http_metrics` prove the counters
|
|
// work in isolation and would still pass if the layer were never wired in.
|
|
|
|
/// The layer must observe the status ACTUALLY returned to the client, including
|
|
/// the 401 produced by the auth gate ahead of the handler. Before this existed
|
|
/// there was no metric anywhere that could show an authentication failure.
|
|
#[tokio::test]
|
|
async fn http_metrics_count_requests_by_route_and_status() {
|
|
const KEY: &str = "metrics-surface-test-key";
|
|
let (schema, profiles) = tidal_server::config::load_schema(None).unwrap();
|
|
let db = TidalDb::builder()
|
|
.ephemeral()
|
|
.with_schema(schema)
|
|
.with_profiles(profiles)
|
|
.open()
|
|
.unwrap();
|
|
let state = Arc::new(ServerState::new(db));
|
|
let app = build_router(
|
|
state,
|
|
Arc::new(tidal_server::cluster::security::ClusterCreds::with_keys(
|
|
Some(KEY.to_string()),
|
|
None,
|
|
)),
|
|
);
|
|
|
|
let unauthorized = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri("/search?query=x&limit=1")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
|
|
|
|
let ok = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri("/search?query=x&limit=1")
|
|
.header("Authorization", format!("Bearer {KEY}"))
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(ok.status(), StatusCode::OK);
|
|
|
|
let mut out = String::new();
|
|
tidal_server::http_metrics::global().render_into(&mut out);
|
|
assert!(
|
|
out.contains(
|
|
"tidaldb_http_requests_total{route=\"/search\",method=\"GET\",status=\"401\"}"
|
|
),
|
|
"the auth rejection must be counted: {out}"
|
|
);
|
|
assert!(
|
|
out.contains(
|
|
"tidaldb_http_requests_total{route=\"/search\",method=\"GET\",status=\"200\"}"
|
|
),
|
|
"the success must be counted separately: {out}"
|
|
);
|
|
assert!(
|
|
out.contains("tidaldb_http_request_duration_us") && out.contains("route=\"/search\""),
|
|
"per-route latency must be published: {out}"
|
|
);
|
|
}
|
|
|
|
/// Cardinality is the failure mode this design exists to prevent. An unmatched
|
|
/// path is attacker-controlled, so it must fold into ONE constant bucket rather
|
|
/// than minting a series per URL — otherwise a trivial 404 flood blows up the
|
|
/// scrape target.
|
|
#[tokio::test]
|
|
async fn unmatched_paths_never_become_metric_labels() {
|
|
let app = make_app();
|
|
for path in [
|
|
"/definitely-not-a-route/8675309",
|
|
"/definitely-not-a-route/1a2b3c4d",
|
|
"/definitely-not-a-route/%2e%2e%2fetc",
|
|
] {
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
resp.status(),
|
|
StatusCode::NOT_FOUND,
|
|
"expected {path} to be unrouted"
|
|
);
|
|
}
|
|
|
|
let mut out = String::new();
|
|
tidal_server::http_metrics::global().render_into(&mut out);
|
|
assert!(
|
|
out.contains("route=\"<unmatched>\""),
|
|
"unrouted requests must land in the constant bucket: {out}"
|
|
);
|
|
for leaked in ["8675309", "1a2b3c4d", "definitely-not-a-route"] {
|
|
assert!(
|
|
!out.contains(leaked),
|
|
"caller-supplied path fragment {leaked:?} leaked into a label: {out}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The cross-crate seam: registering the renderer must actually surface these
|
|
/// series on the engine's own `/metrics` listener. The engine owns that listener
|
|
/// and knows nothing about HTTP, so this proves the hook rather than the
|
|
/// counters.
|
|
#[tokio::test]
|
|
async fn http_series_are_served_by_the_engine_metrics_listener() {
|
|
let (schema, profiles) = tidal_server::config::load_schema(None).unwrap();
|
|
let db = TidalDb::builder()
|
|
.ephemeral()
|
|
.with_schema(schema)
|
|
.with_profiles(profiles)
|
|
.enable_metrics("127.0.0.1:0")
|
|
.open()
|
|
.unwrap();
|
|
assert!(
|
|
tidal_server::http_metrics::publish_to(db.metrics()),
|
|
"first registration on a fresh MetricsState must succeed"
|
|
);
|
|
assert!(
|
|
!tidal_server::http_metrics::publish_to(db.metrics()),
|
|
"a second registration must be refused, never double-rendered"
|
|
);
|
|
|
|
tidal_server::http_metrics::global().record("/seam-probe", "GET", 200, 42);
|
|
|
|
let addr = db.metrics_addr().expect("metrics listener bound");
|
|
let body = reqwest::get(format!("http://{addr}/metrics"))
|
|
.await
|
|
.unwrap()
|
|
.text()
|
|
.await
|
|
.unwrap();
|
|
|
|
// The engine's own series must still be there ...
|
|
assert!(
|
|
body.contains("tidaldb_uptime_seconds"),
|
|
"engine series missing: {body}"
|
|
);
|
|
// ... alongside the embedder's.
|
|
assert!(
|
|
body.contains("tidaldb_http_requests_total"),
|
|
"HTTP series absent from the engine listener: {body}"
|
|
);
|
|
assert!(
|
|
body.contains("route=\"/seam-probe\""),
|
|
"recorded route missing from the served output: {body}"
|
|
);
|
|
}
|