Two correctness bugs in multi-process cluster mode (m8p10), both found live on a
real 3-pod k3s cluster while validating the deployment, both invisible to the
existing in-process / unauthenticated test suites:
1. Forwarded item/embedding writes dropped on the floor. create_item /
write_embedding gated the leader's peer broadcast on `if internal { return }`.
A write to a NON-leader gateway is forwarded to the leader with the internal
marker set (loop-prevention), so it hit that branch and terminated WITHOUT
broadcasting — the item landed only on the leader. Signals were unaffected
(the WAL relay ships regardless of the marker), which masked it. Fix: gate on
leadership, not the marker — a follower applying a marked broadcast/heal
terminates; the leader (external OR forwarded) always fans out. Forwarded
writes now return the {replicated_to,failed} report instead of a bodyless null.
2. Heal item/embedding backfill 401'd whenever TIDAL_API_KEY is set.
post_marked_blocking sent the internal marker but no Authorization header. The
marker is a trust signal, not an auth bypass (the bearer middleware runs
first), so every backfill POST was rejected 401 — a region that missed an item
while down stayed permanently inconsistent at lag 0. Unauthenticated tests
never caught it. Fix: thread TIDAL_API_KEY into RegionClusterState and attach
it (same key on every region) to the backfill POSTs.
Verified live: forwarded writes via follower gateways converge to all 3 regions;
a region scaled to 0 during an item write backfills on heal (item_failures=0).
Follow-up: add multiproc regression tests with auth for both paths.
573 lines
21 KiB
Rust
573 lines
21 KiB
Rust
//! m8p10 task 03: cross-process route tests.
|
|
//!
|
|
//! Builds 3-node in-process clusters — distinct `RegionClusterState`s pointing at
|
|
//! each other's REAL loopback gRPC + HTTP addresses — and drives them over real
|
|
//! HTTP (axum on loopback) + real `GrpcTransport` replication + real reqwest
|
|
//! forwarding. No OS processes (tier-3 OS-process coverage is tasks 04/05); these
|
|
//! run in the default test build like `cluster_region.rs` / `cluster_grpc.rs`.
|
|
//!
|
|
//! They prove the node behaves as one coherent cluster: writes to ANY node land
|
|
//! on the leader and replicate; promote propagates; `/cluster/status` aggregates
|
|
//! every region with `reachable` honesty; `/cluster/reconcile` converges both
|
|
//! sides and reports timing (idempotent on repeat); `/sharded/*` fans out across
|
|
//! processes with honest degraded semantics and routes writes to the owner.
|
|
#![allow(
|
|
clippy::unwrap_used,
|
|
clippy::missing_panics_doc,
|
|
clippy::too_many_lines,
|
|
clippy::doc_markdown,
|
|
clippy::cast_precision_loss
|
|
)]
|
|
|
|
use std::{
|
|
net::{SocketAddr, TcpListener},
|
|
sync::Arc,
|
|
time::{Duration, Instant},
|
|
};
|
|
|
|
use tidal_server::cluster::{
|
|
RegionClusterState, RegionSpec, TimeoutsSpec, TopologySpec, build_region_router,
|
|
};
|
|
use tidaldb::schema::{DecaySpec, EntityKind, Schema, SchemaBuilder, Window};
|
|
|
|
/// A `view` signal (decayed) + a `hide` hard-negative signal (so `/hardnegs`
|
|
/// resolves) — the same schema shape `cluster_region.rs` uses.
|
|
fn region_schema() -> Schema {
|
|
let mut builder = SchemaBuilder::new();
|
|
let _ = builder
|
|
.signal(
|
|
"view",
|
|
EntityKind::Item,
|
|
DecaySpec::Exponential {
|
|
half_life: Duration::from_secs(7 * 24 * 3600),
|
|
},
|
|
)
|
|
.windows(&[Window::OneHour])
|
|
.velocity(false)
|
|
.add();
|
|
let _ = builder
|
|
.signal("hide", EntityKind::Item, DecaySpec::Permanent)
|
|
.velocity(false)
|
|
.add();
|
|
builder.build().unwrap()
|
|
}
|
|
|
|
fn free_addr() -> SocketAddr {
|
|
TcpListener::bind("127.0.0.1:0")
|
|
.unwrap()
|
|
.local_addr()
|
|
.unwrap()
|
|
}
|
|
|
|
/// A 3-region topology where every region declares real loopback grpc + http
|
|
/// addresses pointing at the in-test binds. Region 0 (`us-east`) is the leader.
|
|
struct Cluster3 {
|
|
names: [String; 3],
|
|
grpc: [SocketAddr; 3],
|
|
http: [SocketAddr; 3],
|
|
}
|
|
|
|
impl Cluster3 {
|
|
fn new() -> Self {
|
|
Self {
|
|
names: ["us-east".into(), "eu-west".into(), "ap-south".into()],
|
|
grpc: [free_addr(), free_addr(), free_addr()],
|
|
http: [free_addr(), free_addr(), free_addr()],
|
|
}
|
|
}
|
|
|
|
fn topology(&self) -> TopologySpec {
|
|
TopologySpec {
|
|
regions: (0..3)
|
|
.map(|i| RegionSpec {
|
|
name: self.names[i].clone(),
|
|
grpc_addr: Some(self.grpc[i].to_string()),
|
|
http_addr: Some(self.http[i].to_string()),
|
|
grpc_tls: None,
|
|
})
|
|
.collect(),
|
|
leader: self.names[0].clone(),
|
|
write_workers: None,
|
|
timeouts: TimeoutsSpec::default(),
|
|
}
|
|
}
|
|
|
|
fn base(&self, i: usize) -> String {
|
|
format!("http://{}", self.http[i])
|
|
}
|
|
}
|
|
|
|
/// Build one region node off the reactor (GrpcTransport::new blocks on its own
|
|
/// runtime, so it must run on a plain thread).
|
|
fn build_region(topology: TopologySpec, region: &str) -> RegionClusterState {
|
|
let region = region.to_string();
|
|
std::thread::spawn(move || {
|
|
RegionClusterState::new(&topology, ®ion, region_schema(), Vec::new(), None, 0, None)
|
|
})
|
|
.join()
|
|
.unwrap()
|
|
.expect("region node builds with real gRPC transport")
|
|
}
|
|
|
|
/// Serve `router` on `addr` using `rt`; returns once the listener is bound.
|
|
fn serve(rt: &tokio::runtime::Runtime, router: axum::Router, addr: SocketAddr) {
|
|
let listener = rt
|
|
.block_on(tokio::net::TcpListener::bind(addr))
|
|
.unwrap_or_else(|e| panic!("bind {addr}: {e}"));
|
|
rt.spawn(async move {
|
|
let _ = axum::serve(listener, router).await;
|
|
});
|
|
}
|
|
|
|
/// A 3-node serving handle: the runtime, the cluster spec, and an HTTP client.
|
|
struct Serving {
|
|
/// Held for the test's lifetime so the axum servers keep running; dropped at
|
|
/// test end, which stops the runtime and tears the nodes down.
|
|
_rt: tokio::runtime::Runtime,
|
|
cluster: Cluster3,
|
|
client: reqwest::blocking::Client,
|
|
}
|
|
|
|
impl Serving {
|
|
/// Build + serve all three region nodes on loopback.
|
|
fn start_all() -> Self {
|
|
Self::start(&[0, 1, 2])
|
|
}
|
|
|
|
/// Build + serve only the named regions (by index), so a test can leave one
|
|
/// node DOWN to exercise unreachable/degraded paths.
|
|
fn start(indices: &[usize]) -> Self {
|
|
let rt = tokio::runtime::Builder::new_multi_thread()
|
|
.worker_threads(3)
|
|
.enable_all()
|
|
.build()
|
|
.unwrap();
|
|
let cluster = Cluster3::new();
|
|
for &i in indices {
|
|
let node = build_region(cluster.topology(), &cluster.names[i]);
|
|
serve(
|
|
&rt,
|
|
build_region_router(Arc::new(node), None),
|
|
cluster.http[i],
|
|
);
|
|
}
|
|
Self {
|
|
_rt: rt,
|
|
cluster,
|
|
client: reqwest::blocking::Client::new(),
|
|
}
|
|
}
|
|
|
|
fn base(&self, i: usize) -> String {
|
|
self.cluster.base(i)
|
|
}
|
|
}
|
|
|
|
/// Poll `pred(json)` against `GET {base}{path}` until it holds or the deadline.
|
|
fn poll_json(
|
|
client: &reqwest::blocking::Client,
|
|
url: &str,
|
|
pred: impl Fn(&serde_json::Value) -> bool,
|
|
) -> serde_json::Value {
|
|
let deadline = Instant::now() + Duration::from_secs(8);
|
|
loop {
|
|
let body: serde_json::Value = client.get(url).send().unwrap().json().unwrap();
|
|
if pred(&body) {
|
|
return body;
|
|
}
|
|
assert!(
|
|
Instant::now() <= deadline,
|
|
"predicate not met within 8s for {url}: {body}"
|
|
);
|
|
std::thread::sleep(Duration::from_millis(25));
|
|
}
|
|
}
|
|
|
|
fn post_json(
|
|
client: &reqwest::blocking::Client,
|
|
url: &str,
|
|
body: &serde_json::Value,
|
|
) -> reqwest::blocking::Response {
|
|
client.post(url).json(body).send().unwrap()
|
|
}
|
|
|
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
|
|
/// A signal POSTed to a FOLLOWER is forwarded to the leader (204), durably
|
|
/// applied there, and replicates to every node over gRPC.
|
|
#[test]
|
|
fn forwarded_write_lands_on_leader_and_replicates() {
|
|
let s = Serving::start_all();
|
|
let client = &s.client;
|
|
|
|
// Broadcast items to all three (items are config data; the leader broadcasts
|
|
// /items, but we also seed each so every node can rank). Then POST signals to
|
|
// a FOLLOWER (eu-west, index 1) — these must forward to the leader.
|
|
for i in 1..=6u64 {
|
|
// POST /items to the LEADER → it broadcasts to peers.
|
|
let resp = post_json(
|
|
client,
|
|
&format!("{}/items", s.base(0)),
|
|
&serde_json::json!({ "entity_id": i, "metadata": { "title": format!("item {i}") } }),
|
|
);
|
|
assert_eq!(resp.status().as_u16(), 201, "leader /items");
|
|
let body: serde_json::Value = resp.json().unwrap();
|
|
// The leader reports the broadcast fan-out.
|
|
assert!(
|
|
body.get("replicated_to").is_some(),
|
|
"leader /items must report broadcast: {body}"
|
|
);
|
|
|
|
// POST /signals to a FOLLOWER → forwarded to leader → 204.
|
|
let resp = post_json(
|
|
client,
|
|
&format!("{}/signals", s.base(1)),
|
|
&serde_json::json!({ "entity_id": i, "signal": "view", "weight": 1.0 }),
|
|
);
|
|
assert_eq!(
|
|
resp.status().as_u16(),
|
|
204,
|
|
"follower /signals must forward to leader and 204: {}",
|
|
resp.status()
|
|
);
|
|
}
|
|
|
|
// Every follower converges (applied reaches 6, lag 0) via the WAL relay.
|
|
for i in [1usize, 2] {
|
|
poll_json(
|
|
client,
|
|
&format!("{}/cluster/status/local", s.base(i)),
|
|
|st| {
|
|
st["applied_events"].as_u64().unwrap_or(0) >= 6
|
|
&& st["lag_events"].as_u64().unwrap_or(u64::MAX) == 0
|
|
},
|
|
);
|
|
}
|
|
|
|
// The followers' broadcast items are present and the feed ranks them.
|
|
for i in 0..3 {
|
|
let feed: serde_json::Value = client
|
|
.get(format!("{}/feed?profile=trending&limit=6", s.base(i)))
|
|
.send()
|
|
.unwrap()
|
|
.json()
|
|
.unwrap();
|
|
assert!(
|
|
!feed["items"].as_array().unwrap().is_empty(),
|
|
"node {i} feed must rank replicated items"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Promote a NON-leader (eu-west) via that follower; the promote fans out so all
|
|
/// three nodes agree on the new leader, and a write to the OLD leader now forwards
|
|
/// to the new one (transparently 204).
|
|
#[test]
|
|
fn promote_fans_out() {
|
|
let s = Serving::start_all();
|
|
let client = &s.client;
|
|
|
|
// Promote eu-west (index 1) by calling promote ON eu-west; it fans out.
|
|
let resp = post_json(
|
|
client,
|
|
&format!("{}/cluster/promote", s.base(1)),
|
|
&serde_json::json!({ "region": s.cluster.names[1] }),
|
|
);
|
|
assert_eq!(resp.status().as_u16(), 200);
|
|
let body: serde_json::Value = resp.json().unwrap();
|
|
assert_eq!(body["leader"].as_str(), Some(s.cluster.names[1].as_str()));
|
|
assert!(body.get("acked").is_some(), "promote must report fan-out");
|
|
|
|
// All three nodes agree the leader is eu-west (status/local view).
|
|
for i in 0..3 {
|
|
let st = poll_json(
|
|
client,
|
|
&format!("{}/cluster/status/local", s.base(i)),
|
|
|st| st["leader"].as_str() == Some(s.cluster.names[1].as_str()),
|
|
);
|
|
assert_eq!(st["leader"].as_str(), Some(s.cluster.names[1].as_str()));
|
|
}
|
|
|
|
// A write to the OLD leader (us-east, index 0) now forwards to the new leader
|
|
// (eu-west) and 204s — transparent leader forwarding after a promote.
|
|
let resp = post_json(
|
|
client,
|
|
&format!("{}/signals", s.base(0)),
|
|
&serde_json::json!({ "entity_id": 1, "signal": "view", "weight": 1.0 }),
|
|
);
|
|
assert_eq!(
|
|
resp.status().as_u16(),
|
|
204,
|
|
"write to demoted node must forward to the new leader: {}",
|
|
resp.status()
|
|
);
|
|
}
|
|
|
|
/// `GET /cluster/status` on any node aggregates EVERY region; with one node DOWN
|
|
/// that region reports `reachable: false`, `partitioned: true`, worst-case lag.
|
|
#[test]
|
|
fn status_aggregates_all_regions() {
|
|
// Start only nodes 0 and 1; ap-south (index 2) is DOWN.
|
|
let s = Serving::start(&[0, 1]);
|
|
let client = &s.client;
|
|
|
|
// Seed a couple of signals so the leader's relay seqno is non-zero.
|
|
for i in 1..=3u64 {
|
|
let _ = post_json(
|
|
client,
|
|
&format!("{}/signals", s.base(0)),
|
|
&serde_json::json!({ "entity_id": i, "signal": "view", "weight": 1.0 }),
|
|
);
|
|
}
|
|
|
|
// Aggregate from the leader: 3 regions, ap-south unreachable.
|
|
let status = poll_json(client, &format!("{}/cluster/status", s.base(0)), |st| {
|
|
let regions = st["regions"].as_array();
|
|
regions.is_some_and(|r| r.len() == 3) && st["relay_log_len"].as_u64().unwrap_or(0) >= 3
|
|
});
|
|
assert_eq!(status["leader"].as_str(), Some(s.cluster.names[0].as_str()));
|
|
let relay_len = status["relay_log_len"].as_u64().unwrap();
|
|
assert!(relay_len >= 3, "leader relay len: {relay_len}");
|
|
|
|
let regions = status["regions"].as_array().unwrap();
|
|
let row = |name: &str| {
|
|
regions
|
|
.iter()
|
|
.find(|r| r["name"].as_str() == Some(name))
|
|
.unwrap()
|
|
};
|
|
|
|
// The leader row: reachable, zero lag.
|
|
let leader_row = row(&s.cluster.names[0]);
|
|
assert_eq!(leader_row["reachable"].as_bool(), Some(true));
|
|
assert_eq!(leader_row["lag_events"].as_u64(), Some(0));
|
|
|
|
// The live follower (eu-west): reachable.
|
|
let follower_row = row(&s.cluster.names[1]);
|
|
assert_eq!(follower_row["reachable"].as_bool(), Some(true));
|
|
|
|
// The DOWN region (ap-south): unreachable, partitioned, applied 0, worst-case
|
|
// lag = leader_last_seq.
|
|
let down_row = row(&s.cluster.names[2]);
|
|
assert_eq!(
|
|
down_row["reachable"].as_bool(),
|
|
Some(false),
|
|
"down region unreachable: {down_row}"
|
|
);
|
|
assert_eq!(down_row["partitioned"].as_bool(), Some(true));
|
|
assert_eq!(down_row["applied_events"].as_u64(), Some(0));
|
|
assert_eq!(
|
|
down_row["lag_events"].as_u64(),
|
|
Some(relay_len),
|
|
"worst-case lag = leader_last_seq"
|
|
);
|
|
}
|
|
|
|
/// `/cluster/reconcile` exchanges CRDT snapshots and converges both sides: a hide
|
|
/// recorded ONLY on us-east becomes visible on eu-west after reconcile, the timing
|
|
/// fields are reported, and a second reconcile is a no-op (idempotent).
|
|
#[test]
|
|
fn reconcile_converges_and_times() {
|
|
let s = Serving::start(&[0, 1]);
|
|
let client = &s.client;
|
|
|
|
// Record a hide on us-east (the leader) only. (Hardnegs converge via reconcile,
|
|
// not the WAL relay, so eu-west does not see it yet.)
|
|
let resp = post_json(
|
|
client,
|
|
&format!("{}/hardnegs", s.base(0)),
|
|
&serde_json::json!({ "user_id": 42, "item_id": 7 }),
|
|
);
|
|
assert_eq!(
|
|
resp.status().as_u16(),
|
|
204,
|
|
"hardneg on leader: {}",
|
|
resp.status()
|
|
);
|
|
|
|
// Reconcile eu-west WITH us-east: eu-west drives the exchange, applying
|
|
// us-east's pre-merge snapshot (which carries the hide).
|
|
let resp = post_json(
|
|
client,
|
|
&format!("{}/cluster/reconcile", s.base(1)),
|
|
&serde_json::json!({ "region": s.cluster.names[0] }),
|
|
);
|
|
assert_eq!(resp.status().as_u16(), 200, "reconcile: {}", resp.status());
|
|
let body: serde_json::Value = resp.json().unwrap();
|
|
assert_eq!(body["ok"].as_bool(), Some(true));
|
|
assert_eq!(body["region"].as_str(), Some(s.cluster.names[0].as_str()));
|
|
// Both timing fields are present (u64, possibly 0 on a fast localhost merge).
|
|
assert!(
|
|
body["local_elapsed_ms"].is_u64(),
|
|
"local_elapsed_ms reported: {body}"
|
|
);
|
|
assert!(
|
|
body["remote_elapsed_ms"].is_u64(),
|
|
"remote_elapsed_ms reported: {body}"
|
|
);
|
|
let first_ops = body["ops_applied"].as_u64().unwrap();
|
|
assert!(
|
|
first_ops >= 1,
|
|
"first reconcile must apply the hide: {body}"
|
|
);
|
|
|
|
// Capture eu-west's hardneg state after the first reconcile via take_snapshot
|
|
// (exposed over the internal snapshot route): the hide for (42, 7) is present.
|
|
let hardneg_present = |base: &str| -> bool {
|
|
// Drive the internal snapshot route with an EMPTY remote snapshot (a no-op
|
|
// merge) and read back the node's pre-merge snapshot, which lists every
|
|
// hard-negative register it holds.
|
|
let resp = client
|
|
.post(format!("{base}/cluster/reconcile/snapshot"))
|
|
.header("x-tidal-internal", "1")
|
|
.json(&serde_json::json!({ "signal_states": [], "hardneg_registers": [] }))
|
|
.send()
|
|
.unwrap();
|
|
assert_eq!(resp.status().as_u16(), 200, "snapshot route");
|
|
let body: serde_json::Value = resp.json().unwrap();
|
|
body["pre_merge_snapshot"]["hardneg_registers"]
|
|
.as_array()
|
|
.is_some_and(|regs| {
|
|
regs.iter().any(|r| {
|
|
// wire form: [user_id, item_id, register]
|
|
r.get(0).and_then(serde_json::Value::as_u64) == Some(42)
|
|
&& r.get(1).and_then(serde_json::Value::as_u64) == Some(7)
|
|
})
|
|
})
|
|
};
|
|
assert!(
|
|
hardneg_present(&s.base(1)),
|
|
"after reconcile, eu-west must hold the hide for (42, 7)"
|
|
);
|
|
|
|
// A second reconcile is idempotent IN EFFECT: the LWW plan re-resolves the
|
|
// same hide (the engine re-stamps Hide registers at now() per snapshot, so the
|
|
// op count is not 0 — that is the engine's honest semantics), but the
|
|
// converged STATE is unchanged: the hide is still present on both sides, never
|
|
// dropped or duplicated.
|
|
let resp = post_json(
|
|
client,
|
|
&format!("{}/cluster/reconcile", s.base(1)),
|
|
&serde_json::json!({ "region": s.cluster.names[0] }),
|
|
);
|
|
assert_eq!(resp.status().as_u16(), 200);
|
|
assert!(
|
|
hardneg_present(&s.base(1)) && hardneg_present(&s.base(0)),
|
|
"second reconcile must leave both sides converged (hide present on both)"
|
|
);
|
|
}
|
|
|
|
/// `/sharded/feed` fans out across processes; with one node DOWN the response is
|
|
/// `degraded: true`, names the unavailable shard, and still merges results from
|
|
/// the live shards.
|
|
#[test]
|
|
fn sharded_feed_degrades_honestly() {
|
|
// Start nodes 0 and 1; ap-south (index 2) is DOWN.
|
|
let s = Serving::start(&[0, 1]);
|
|
let client = &s.client;
|
|
|
|
// Seed items + signals on BOTH live nodes' local stores via /sharded writes so
|
|
// each shard owns its slice. (Sharded writes route to the owner; a write whose
|
|
// owner is the DOWN node will 503, which we tolerate — we only need the live
|
|
// shards populated for the read fan-out.)
|
|
for i in 1..=12u64 {
|
|
let _ = post_json(
|
|
client,
|
|
&format!("{}/sharded/items", s.base(0)),
|
|
&serde_json::json!({ "entity_id": i, "metadata": { "title": format!("item {i}") } }),
|
|
);
|
|
let _ = post_json(
|
|
client,
|
|
&format!("{}/sharded/signals", s.base(0)),
|
|
&serde_json::json!({ "entity_id": i, "signal": "view", "weight": i as f64 }),
|
|
);
|
|
}
|
|
|
|
// Sharded feed from the leader, generous per-shard deadline so the LIVE shards
|
|
// always answer and only the DOWN shard degrades.
|
|
let feed: serde_json::Value = poll_json(
|
|
client,
|
|
&format!(
|
|
"{}/sharded/feed?profile=trending&limit=12&deadline_ms=2000",
|
|
s.base(0)
|
|
),
|
|
|f| f["scatter_gather"]["degraded"].as_bool() == Some(true),
|
|
);
|
|
let sg = &feed["scatter_gather"];
|
|
assert_eq!(
|
|
sg["degraded"].as_bool(),
|
|
Some(true),
|
|
"must be degraded: {feed}"
|
|
);
|
|
let unavailable = sg["unavailable_shards"].as_array().unwrap();
|
|
assert!(
|
|
unavailable
|
|
.iter()
|
|
.any(|u| u.as_str() == Some(s.cluster.names[2].as_str())),
|
|
"unavailable_shards must name the down region: {sg}"
|
|
);
|
|
// Still returns merged results from the live shards — never an error.
|
|
assert!(
|
|
!feed["items"].as_array().unwrap().is_empty(),
|
|
"degraded sharded feed must still return live-shard results: {feed}"
|
|
);
|
|
}
|
|
|
|
/// A `/sharded/items` write whose `ShardRouter` owner is a REMOTE region is
|
|
/// forwarded to that region and becomes readable via the owner's local feed.
|
|
#[test]
|
|
fn sharded_write_routes_to_owner() {
|
|
let s = Serving::start_all();
|
|
let client = &s.client;
|
|
|
|
// Find an entity id whose engine ShardRouter owner is NOT the leader (so the
|
|
// write must forward). Probe the entity_shard mapping the server uses by
|
|
// checking which region's local feed the item lands on after a /sharded write.
|
|
// The engine hash is deterministic; iterate ids until one owns to region 1 or 2.
|
|
let mut routed = false;
|
|
for entity_id in 1..=200u64 {
|
|
let resp = post_json(
|
|
client,
|
|
&format!("{}/sharded/items", s.base(0)),
|
|
&serde_json::json!({ "entity_id": entity_id, "metadata": { "title": format!("e{entity_id}") } }),
|
|
);
|
|
// 201 = created (locally or forwarded to a reachable owner).
|
|
if resp.status().as_u16() != 201 {
|
|
continue;
|
|
}
|
|
// Add a signal too so the item is rankable on its owner.
|
|
let _ = post_json(
|
|
client,
|
|
&format!("{}/sharded/signals", s.base(0)),
|
|
&serde_json::json!({ "entity_id": entity_id, "signal": "view", "weight": 5.0 }),
|
|
);
|
|
|
|
// Check each non-leader region's LOCAL feed: if the item appears there, it
|
|
// was routed to that owner (a remote region), proving sharded forwarding.
|
|
for owner in [1usize, 2] {
|
|
let feed: serde_json::Value = client
|
|
.get(format!("{}/feed?profile=trending&limit=50", s.base(owner)))
|
|
.send()
|
|
.unwrap()
|
|
.json()
|
|
.unwrap();
|
|
let on_owner = feed["items"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|it| it["entity_id"].as_u64() == Some(entity_id));
|
|
if on_owner {
|
|
routed = true;
|
|
break;
|
|
}
|
|
}
|
|
if routed {
|
|
break;
|
|
}
|
|
}
|
|
assert!(
|
|
routed,
|
|
"no sharded item routed to a remote region within 200 ids — sharded forwarding broken"
|
|
);
|
|
}
|