All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Three parallel reviews of 6385425 found two BLOCKERs I introduced, one CRITICAL,
and a CHANGELOG that named profiles that do not exist. All verified before fixing.
BLOCKER 1 -- an undated row ranked #1 instead of last. `Sort::New` mapped a
missing age to 0.0, but every DATED candidate scores `-age_hours`, i.e. <= 0.0.
So 0.0 was the MAXIMUM of the scale, not a neutral value: a row with no readable
`created_at` outranked the genuinely newest item and `normalize` reported its
score as 1.0.
The codebase had already ruled on this and gone the other way. `score_shortest`,
`score_longest` and `score_date_saved` all use the NEG_INFINITY sentinel, and
`helpers.rs` carries a regression test named for the exact anomaly --
`normalize_neg_inf_sentinel_on_negated_scale_folds_to_bottom` -- because
`Shortest` hit it first: "a pre-clamped 0.0 floated above them and the
last-ranked (missing) item reported the highest score 1.0". I reintroduced it on
a new negated scale.
Three reachable data classes, none hypothetical: a legacy row written before
`created_at` was materialized (`state_rebuild.rs` documents the class), an empty
map from `deserialize_metadata` on a short or corrupt row, and an entity dropped
by the `.ok().flatten()` in the metadata map builder -- which means a TRANSIENT
STORAGE READ ERROR could promote an item to the top of the feed.
Now the two `None` causes are distinguished. Map absent entirely -> 0.0, the whole
set ties, unchanged. Map present but this entity has no usable `created_at` ->
NEG_INFINITY, sorts last. A future-dated timestamp still clamps to age 0, because
that is a real value that legitimately means "newest"; an absent one carries no
recency claim at all.
Same conflation in `score_hot`, and worse there because `Sort::Hot` now reports
`needs_item_metadata`, so the map is loaded on essentially every Hot query and the
per-entity branch is the LIVE one. `unwrap_or(DEFAULT_HOT_AGE_HOURS)` handed a
single undated row the freshest divisor in the set: against a year-old cohort at
the builtin gravity 1.8 the ratio `(8762/26)^1.8` is ~3e4, so a corrupt row with
two views outranked correctly-dated items with tens of thousands. Across all four
Hot builtins. Now scored at Hot's floor.
BLOCKER 2 -- my own load-shedding "fix" INVERTED the survivor set. `truncate_to_
newest` ranked candidates against the newest ids of the WHOLE universe, but
`scan_candidates` iterates the universe bitmap ASCENDING and breaks at
`(limit * multiplier).max(200)`, so `candidates` is the LOW-id prefix. On a
catalog whose ids are assigned in creation order -- which is exactly what
`metadata_with_created_at`'s `Timestamp::now()` default produces -- the global
newest are the HIGH ids. Above roughly `max_candidates + 4*cap` items the two sets
stop intersecting, every candidate tied at `usize::MAX`, the stable sort became a
NO-OP, and `truncate(cap)` kept the OLDEST candidates. The `select_nth_unstable_by`
I replaced kept the newest available. Strictly worse than the bug I set out to fix,
and only visible when degraded.
The key is now composite -- recency rank, then DESCENDING id for anything the
oversample did not cover -- so where index and candidate window overlap the
survivors are genuinely newest, and where they do not it degrades to the documented
pre-existing approximation instead of inverting. Back to `select_nth_unstable_by_
key`: this path runs ONLY when the load shedder has already decided the node cannot
afford the work, so it must stay a linear partition, and only membership of
`[0, cap)` matters because Stage 3 re-orders the survivors anyway.
Mutation-proven, and the numbers show why the old test was blind: rank-only key
returns `EntityId(100)` where the composite key returns `EntityId(200)`, and the
pre-existing 150-item test PASSES under that same mutation -- its fixture had the
oversample covering the whole universe AND the newest items at the low ids, the one
configuration where the defect cannot appear. New test uses a 3000-item
time-ordered catalog; limit=50 does NOT exercise it (max_candidates == cap == 200
so the guard skips), limit=25 does.
Three of my ordering fixtures pinned nothing. `finalize`'s tie-break 2 is ASCENDING
entity id, and I had made newest-first coincide with it, so any mutation that
merely TIED the set still produced the asserted vector. The gate mutation proves
it: reverting `needs_metadata_for_sort` to its old four-variant form yielded
`left: [1, 2, 3, 4, 5]` -- literally the old fixture's expectation. No test in the
suite failed if the retrieve executor stopped consulting `Sort::needs_item_metadata`,
which is the exact drift this work exists to prevent. Every ordering fixture is now
non-monotonic in id, so it disagrees with BOTH descending id (the old proxy) and
ascending id (the tie collapse).
CHANGELOG named profiles that do not exist. `recent_uploads` -- zero hits
repo-wide; I invented it. `following` is Sort::New, not Hot. `brief` is Hot, not
New. `related` (Hot{1.2}) and `chronological` (New) were omitted entirely. The real
blast radius is SEVEN profiles, not the four-plus-three I claimed, and five of them
carry a diversity block so they already loaded item metadata and change behaviour
the instant this image rolls. Replaced with a verified table. Also cited the spec
conformance this brings: docs/specs/09-ranking-scoring.md:1214 already specified
`Sort::New` as "created_at DESC".
created_at units are now trusted with a warning instead of silently. A
seconds-unit value parses as u64 and was stored verbatim; `read_age_hours` divides
by nanos-per-hour, so `1700000000` reads as 56 years old. `Sort::New` then scores
-496731 against boost sums in single digits -- re-entering through DATA the exact
"recency annihilates every boost" defect this work removed -- and Hot buries the
item by a factor of 5.1e7. Pre-change both sorts ignored the value, so it was
inert; this work made it live. The repo's OWN fixtures made that mistake in three
places, which is the proof it is the natural one.
`metadata_with_created_at` now warns when the value is too small to be nanoseconds,
logging entity id, value, age_hours and age_years so an operator can act. It does
NOT rewrite the value -- guessing the unit would corrupt what the `created_at`
range index already reads as nanoseconds -- and does NOT reject the write, which
would break an API that currently accepts it. Threshold 6e17 ns (1989): nanosecond
timestamps after 1990 exceed 6.31e17 while seconds/millis/micros for any plausible
date stay under 1e16, so the ranges cannot overlap. VERIFIED on a live server:
seconds, millis and micros each warn with age_years=56; a real nanosecond value is
silent. The nanosecond contract is now documented on the public DTO and propagates
to the OpenAPI schema, where it was invisible before.
Stale docs corrected: two published blog posts and two claim-verification ledgers
were certifying "entity recency (higher ID = newer)"; the e2e fixture contract
justified an interleaving decision with reasoning that is now false (its conclusion
still holds, for a different reason); and k8s/discover/schema.yaml's "NO sort:"
rationale cited behaviour this work removed, so it is now marked PENDING
RE-MEASUREMENT with the three specific measurements named rather than left reading
as justified.
Full lib suite 2133 passed. Clippy 66 vs 66 at baseline, zero added, zero errors.
Fast integration suites all green. Real-server e2e re-verified: `new` and `hot`
both return newest-first, `new`'s scores now evenly spaced across evenly spaced
ages.
496 lines
18 KiB
Rust
496 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.
|
|
// `created_at` is NANOSECONDS since the Unix epoch; 1_700_000_000_000_000_000
|
|
// is 2023-11-14T22:13:20Z. A seconds-unit value here would parse fine, read as
|
|
// ~56 years old (measured 496_731 h on 2026-09-01), and quietly floor the
|
|
// Hot score.
|
|
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": "1700000000000000000", "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,
|
|
// Nanoseconds since the Unix epoch (2023-11-14T22:13:20Z), the unit
|
|
// `Sort::New` and the `created_at` range index both read.
|
|
"metadata": { "created_at": "1700000000000000000", "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}"
|
|
);
|
|
}
|