tidaldb/tidal-server/src/dto.rs
jx12n 31ee612f27 feat(m12p4): sharded ingestion — scatter-gather pool + cross-shard unified reads (L4)
Scale write throughput across data-shard groups while keeping a single unified
read surface:

- scatter_gather.rs: pooled fan-out across shard groups (replaces per-request
  client construction); cross-shard query results merged on one node
- cluster/node.rs: cross-shard read routing — a read on any node gathers from
  every shard group's leader and unions results
- cluster/forward.rs: fix h2 204 forward-relay bug (relay_forwarded skips body
  for 1xx/204/304 — synthesized JSON body on a 204 triggered HTTP/2 RST_STREAM
  on the real mTLS plane)
- dto.rs: cross-shard query/result DTOs
- k8s/cluster/: enable 3-group `shards:` topology (statefulset, service-peers,
  topology-configmap)
- k8s/cluster-local-kind/: local-kind overlay to run the T5 gate without Ref-A
- tidal-stress/k8s/stress-job-t5.yaml: 2-generator sharded throughput job
- tests: cluster_cross_shard_reads.rs + multiproc support; ran real on kind
- docs/profiling/m12p4-t5-sharded-throughput.md: T5 throughput findings
2026-06-14 15:17:35 -06:00

467 lines
17 KiB
Rust

//! Shared request/response DTOs and result-mapping for the data routes.
//!
//! The standalone router ([`crate::router`]) and the cluster router
//! ([`crate::cluster`]) expose the SAME data surface (`/items`,
//! `/embeddings`, `/signals`, `/feed`, `/search`). Their request/response
//! shapes and the engine-result → JSON mapping were previously duplicated
//! verbatim across both modules; any drift between the two (e.g. a new field
//! added on one side only) would silently change one mode's API. This module
//! is the single source of truth so the two routers cannot diverge.
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use tidaldb::{
query::{retrieve::RetrieveResult, search::SearchResultItem},
storage::vector::VectorSearchResult,
};
use utoipa::ToSchema;
// ── Request DTOs ─────────────────────────────────────────────────────────────
/// `POST /items` body.
///
/// `Serialize` so the multi-process cluster can forward / broadcast the verbatim
/// body to the leader or peers (m8p10 task 03).
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct ItemRequest {
/// Entity ID of the item to create.
#[schema(example = 1)]
pub entity_id: u64,
/// Arbitrary string→string metadata (e.g. `title`, `category`, `created_at`).
pub metadata: HashMap<String, String>,
}
/// `POST /embeddings` body.
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct EmbeddingRequest {
/// Entity ID the embedding belongs to.
#[schema(example = 1)]
pub entity_id: u64,
/// Dense embedding vector. The database retrieves and ranks over these
/// vectors; it does not generate them.
pub values: Vec<f32>,
}
/// `POST /signals` body.
///
/// `user_id` / `creator_id` are optional signal context. They are read on the
/// standalone path; the cluster path ignores them (its replication layer does
/// not yet carry per-signal context), so defaulting them keeps both modes'
/// wire contract identical.
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct SignalRequest {
/// Entity the signal is recorded against.
#[schema(example = 1)]
pub entity_id: u64,
/// Signal type name (e.g. `view`, `like`, `share`).
#[schema(example = "view")]
pub signal: String,
/// Signal weight applied to the running decay score.
#[schema(example = 1.0)]
pub weight: f64,
/// Optional originating user context (standalone path only).
#[serde(default)]
pub user_id: Option<u64>,
/// Optional originating creator context (standalone path only).
#[serde(default)]
pub creator_id: Option<u64>,
}
/// `POST /vector_search` body — the m12p1 recall-measurement probe.
///
/// A raw query vector goes in the body (1536 floats do not belong in a GET query
/// string), and the engine returns the `k` items whose stored content embedding
/// is nearest by the slot's distance metric — **no** profile scoring, fusion, or
/// diversity. Comparing this against a brute-force cosine ground truth yields the
/// ANN recall@k the harness reports.
///
/// `Serialize` so the cluster region node can forward the verbatim body to a peer
/// region on a `?region=` read, exactly like the other write/read DTOs.
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct VectorSearchRequest {
/// Dense query vector. Must match the item content slot's dimensionality.
pub vector: Vec<f32>,
/// Number of nearest neighbors to return; clamped to `MAX_LIMIT` (1000) at
/// the trust boundary. Defaults to 10 (the recall@10 target's `k`).
#[serde(default = "default_k")]
#[schema(example = 10)]
pub k: u32,
/// Optional per-request HNSW beam width override (m12p3 makes this the
/// recall/latency knob). Omitted = the slot's configured default.
#[serde(default)]
pub ef_search: Option<u32>,
/// Internal cross-shard read selector (m12p4). When the cluster gateway fans
/// a corpus-wide probe out to a node that hosts only some shard groups, it
/// sets this on the per-group internal hop so the remote serves ONLY group
/// `shard` and never re-fans-out. Absent on every external request — clients
/// never set it; the gateway merges the per-group slices itself.
#[serde(default)]
pub shard: Option<u16>,
}
impl VectorSearchRequest {
/// The requested `k` clamped to [`MAX_LIMIT`] — the single enforcement point
/// for the network trust boundary, mirroring the `/feed` and `/search`
/// `clamped_limit`. An oversized `k` can never size the engine's result
/// buffer.
#[must_use]
pub const fn clamped_k(&self) -> usize {
(if self.k > MAX_LIMIT {
MAX_LIMIT
} else {
self.k
}) as usize
}
/// The `ef_search` override as a `usize`, if any.
#[must_use]
pub fn ef_search(&self) -> Option<usize> {
self.ef_search.map(|v| v as usize)
}
}
/// Default `k` (nearest-neighbor count) when `k` is omitted: the recall@10 `k`.
#[must_use]
pub const fn default_k() -> u32 {
10
}
/// Maximum page size a client may request via `?limit=`.
///
/// `limit` arrives across the network trust boundary and feeds the engine's
/// candidate cap directly. Left unbounded, a single `?limit=4000000000` request
/// would drive the retrieval/scoring pipeline to size buffers for a four-billion
/// row result, a trivial memory-amplification `DoS`. The page-ranking surfaces
/// (`/feed`, `/search`) are interactive UIs — a few hundred ranked items is the
/// realistic ceiling — so 1000 is a generous-but-finite cap. Bulk export uses a
/// separate, much larger ceiling (`ExportRequest::MAX_EXPORT_LIMIT`); this is
/// deliberately the smaller interactive-read limit. Callers clamp with
/// [`FeedQuery::clamped_limit`] / [`SearchQueryParams::clamped_limit`] at the
/// boundary rather than trusting the raw value.
pub const MAX_LIMIT: u32 = 1000;
/// `GET /feed` query parameters.
#[derive(Debug, Deserialize, ToSchema, utoipa::IntoParams)]
#[into_params(parameter_in = Query)]
pub struct FeedQuery {
/// Optional user to personalize ranking for.
#[serde(default)]
pub user_id: Option<u64>,
/// Named ranking profile; defaults to `for_you`.
#[serde(default = "default_profile")]
#[param(example = "for_you")]
pub profile: String,
/// Page size; clamped to `MAX_LIMIT` (1000) at the trust boundary.
#[serde(default = "default_limit")]
#[param(example = 20)]
pub limit: u32,
/// Target region (cluster mode only; rejected with 400 standalone).
#[serde(default)]
pub region: Option<String>,
/// Seed item for "more like this" (m12p2): with `profile=related`, the
/// engine resolves this item's embedding and sources candidates by ANN
/// nearest-neighbour over it. Ignored by profiles that do not use it.
#[serde(default)]
#[param(example = 42)]
pub similar_to: Option<u64>,
/// Internal cross-shard read selector (m12p4). The cluster gateway sets this
/// on the per-group internal hop when it fans a corpus-wide `/feed` out to a
/// node hosting a strict subset of shard groups, so the remote reads ONLY
/// group `shard` and never re-fans-out. Absent on every external request —
/// clients never set it; the gateway merges the per-group slices itself.
#[serde(default)]
#[param(example = 0)]
pub shard: Option<u16>,
}
impl FeedQuery {
/// The requested page size clamped to [`MAX_LIMIT`].
///
/// Always call this instead of reading `limit` directly: it is the single
/// enforcement point for the network trust boundary, so an oversized client
/// value can never reach the engine's candidate cap.
#[must_use]
pub const fn clamped_limit(&self) -> u32 {
if self.limit > MAX_LIMIT {
MAX_LIMIT
} else {
self.limit
}
}
}
/// `GET /search` query parameters.
#[derive(Debug, Deserialize, ToSchema, utoipa::IntoParams)]
#[into_params(parameter_in = Query)]
pub struct SearchQueryParams {
/// Free-text query string (BM25 + ANN hybrid).
#[param(example = "jazz piano")]
pub query: String,
/// Optional user to personalize ranking for.
#[serde(default)]
pub user_id: Option<u64>,
/// Page size; clamped to `MAX_LIMIT` (1000) at the trust boundary.
#[serde(default = "default_limit")]
#[param(example = 20)]
pub limit: u32,
/// Target region (cluster mode only; rejected with 400 standalone).
#[serde(default)]
pub region: Option<String>,
/// Internal cross-shard read selector (m12p4). The cluster gateway sets this
/// on the per-group internal hop when it fans a corpus-wide `/search` out to
/// a node hosting a strict subset of shard groups, so the remote searches
/// ONLY group `shard` and never re-fans-out. Absent on every external
/// request — clients never set it; the gateway merges the per-group slices.
#[serde(default)]
#[param(example = 0)]
pub shard: Option<u16>,
}
impl SearchQueryParams {
/// The requested page size clamped to [`MAX_LIMIT`].
///
/// Always call this instead of reading `limit` directly: it is the single
/// enforcement point for the network trust boundary, so an oversized client
/// value can never reach the engine's candidate cap.
#[must_use]
pub const fn clamped_limit(&self) -> u32 {
if self.limit > MAX_LIMIT {
MAX_LIMIT
} else {
self.limit
}
}
}
/// Default ranking profile when `?profile=` is omitted.
#[must_use]
pub fn default_profile() -> String {
"for_you".into()
}
/// Default page size when `?limit=` is omitted.
#[must_use]
pub const fn default_limit() -> u32 {
20
}
// ── Response DTOs ────────────────────────────────────────────────────────────
/// `GET /feed` response body.
#[derive(Debug, Serialize, ToSchema)]
pub struct FeedResponse {
/// Ranked items, best-first.
pub items: Vec<FeedItem>,
/// Total candidates considered before ranking/diversity.
pub total_candidates: usize,
/// Region the feed was served from (cluster mode); `null` standalone.
pub region: Option<String>,
/// Names of shard groups that could not be reached for this cross-shard read
/// (m12p4). Present ONLY when the result is degraded — a complete read omits
/// it. Its presence tells the client the page is PARTIAL (fewer items and a
/// lower `total_candidates` than a complete read), so a degraded read is never
/// silently indistinguishable from a complete one.
#[serde(skip_serializing_if = "Option::is_none")]
pub unavailable_shards: Option<Vec<String>>,
}
/// One ranked item in a feed response.
#[derive(Debug, Serialize, ToSchema)]
pub struct FeedItem {
/// Entity ID of the ranked item.
pub entity_id: u64,
/// Final ranking score.
pub score: f64,
/// 1-based rank within the page.
pub rank: usize,
/// Signal snapshot for explainability; omitted when empty.
#[serde(skip_serializing_if = "Option::is_none")]
pub signals: Option<Vec<SignalValue>>,
}
/// A signal value snapshot attached to a ranked item, for explainability.
#[derive(Debug, Serialize, ToSchema)]
pub struct SignalValue {
/// Signal type name.
pub name: String,
/// Decayed signal value at query time.
pub value: f64,
}
/// `GET /search` response body.
#[derive(Debug, Serialize, ToSchema)]
pub struct SearchResponse {
/// Ranked search results, best-first.
pub items: Vec<SearchItem>,
/// Total candidates considered before ranking/diversity.
pub total_candidates: usize,
/// Region the search was served from (cluster mode); `null` standalone.
pub region: Option<String>,
/// Shard groups unreachable for this cross-shard read (m12p4); present ONLY
/// when the result is degraded (partial page), omitted on a complete read.
/// See [`FeedResponse::unavailable_shards`].
#[serde(skip_serializing_if = "Option::is_none")]
pub unavailable_shards: Option<Vec<String>>,
}
/// One ranked item in a search response.
#[derive(Debug, Serialize, ToSchema)]
pub struct SearchItem {
/// Entity ID of the ranked item.
pub entity_id: u64,
/// Final fused ranking score.
pub score: f64,
/// 1-based rank within the page.
pub rank: usize,
/// BM25 lexical component; omitted when the query had no text match path.
#[serde(skip_serializing_if = "Option::is_none")]
pub bm25_score: Option<f64>,
/// Semantic (ANN) component; omitted when no vector match path ran.
#[serde(skip_serializing_if = "Option::is_none")]
pub semantic_score: Option<f64>,
}
/// `POST /vector_search` response body — the raw ANN result, closest-first.
#[derive(Debug, Serialize, ToSchema)]
pub struct VectorSearchResponse {
/// Nearest items, ordered by ascending distance (closest first).
pub items: Vec<VectorMatch>,
/// Always `null`: the recall probe serves locally (standalone, or — in
/// cluster mode — merged across the node's hosted shard groups), so there is
/// no single serving region to report.
pub region: Option<String>,
/// Shard groups unreachable for this cross-shard probe (m12p4); present ONLY
/// when the result is degraded (partial nearest-set), omitted when complete.
/// See [`FeedResponse::unavailable_shards`].
#[serde(skip_serializing_if = "Option::is_none")]
pub unavailable_shards: Option<Vec<String>>,
}
/// One nearest-neighbor match: the entity and its distance from the query.
#[derive(Debug, Serialize, ToSchema)]
pub struct VectorMatch {
/// Entity ID of the matched item.
pub entity_id: u64,
/// L2-squared distance from the query vector (lower = more similar). For the
/// L2-normalized vectors tidalDB stores, this lies in `[0.0, 4.0]` and is
/// monotonic with cosine distance.
pub distance: f32,
}
/// Map one engine [`VectorSearchResult`] into a wire [`VectorMatch`].
#[must_use]
pub const fn vector_match(r: &VectorSearchResult) -> VectorMatch {
VectorMatch {
entity_id: r.id,
distance: r.distance,
}
}
/// Map a slice of engine [`VectorSearchResult`]s into wire [`VectorMatch`]es.
#[must_use]
pub fn vector_matches(items: &[VectorSearchResult]) -> Vec<VectorMatch> {
items.iter().map(vector_match).collect()
}
// ── Engine-result → DTO mapping ──────────────────────────────────────────────
/// Map one engine [`RetrieveResult`] into a wire [`FeedItem`].
///
/// An empty signal list serializes as absent (`None`) rather than `[]` so the
/// response stays compact.
#[must_use]
pub fn feed_item(item: &RetrieveResult) -> FeedItem {
let signals = if item.signals.is_empty() {
None
} else {
Some(
item.signals
.iter()
.map(|s| SignalValue {
name: s.name.clone(),
value: s.value,
})
.collect(),
)
};
FeedItem {
entity_id: item.entity_id.as_u64(),
score: item.score,
rank: item.rank,
signals,
}
}
/// Map a slice of engine [`RetrieveResult`]s into wire [`FeedItem`]s.
#[must_use]
pub fn feed_items(items: &[RetrieveResult]) -> Vec<FeedItem> {
items.iter().map(feed_item).collect()
}
/// Map one engine [`SearchResultItem`] into a wire [`SearchItem`].
#[must_use]
pub fn search_item(item: &SearchResultItem) -> SearchItem {
SearchItem {
entity_id: item.entity_id.as_u64(),
score: item.score,
rank: item.rank,
bm25_score: item.bm25_score.map(f64::from),
semantic_score: item.semantic_score.map(f64::from),
}
}
/// Map a slice of engine [`SearchResultItem`]s into wire [`SearchItem`]s.
#[must_use]
pub fn search_items(items: &[SearchResultItem]) -> Vec<SearchItem> {
items.iter().map(search_item).collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn feed_query(limit: u32) -> FeedQuery {
FeedQuery {
user_id: None,
profile: default_profile(),
limit,
region: None,
similar_to: None,
shard: None,
}
}
fn search_query(limit: u32) -> SearchQueryParams {
SearchQueryParams {
query: "q".into(),
user_id: None,
limit,
region: None,
shard: None,
}
}
#[test]
fn feed_limit_clamped_at_boundary() {
// An oversized client-supplied limit is capped, never passed through.
assert_eq!(feed_query(u32::MAX).clamped_limit(), MAX_LIMIT);
assert_eq!(feed_query(MAX_LIMIT + 1).clamped_limit(), MAX_LIMIT);
// Values at or below the cap pass through unchanged.
assert_eq!(feed_query(MAX_LIMIT).clamped_limit(), MAX_LIMIT);
assert_eq!(feed_query(default_limit()).clamped_limit(), default_limit());
assert_eq!(feed_query(0).clamped_limit(), 0);
}
#[test]
fn search_limit_clamped_at_boundary() {
assert_eq!(search_query(u32::MAX).clamped_limit(), MAX_LIMIT);
assert_eq!(search_query(MAX_LIMIT + 1).clamped_limit(), MAX_LIMIT);
assert_eq!(search_query(MAX_LIMIT).clamped_limit(), MAX_LIMIT);
assert_eq!(
search_query(default_limit()).clamped_limit(),
default_limit()
);
}
}