tidaldb/tidal-net/tests/transport_contract.rs
jordan.washburn fe711870be feat: M8 phases 7-10 — gRPC transport, cluster server, scatter-gather, multi-node UAT
Delivers the distributed fabric's network layer and HTTP cluster surface:

**m8p7: tidal-net crate (gRPC transport)**
- GrpcTransport implementing Transport trait via tonic 0.12
- Per-peer circuit breaker (Closed/Open/HalfOpen), mutual TLS via rustls
- Boxed error types (clippy-clean), graceful mutex recovery, debug_assert
  against calling block_on from tokio context
- Proto: WalShipping service (ShipSegment, StreamSegments stub, Heartbeat)
- 19 tests: contract, mTLS, reconnection, multi-node UAT, benchmarks

**m8p8: cluster subcommand + HTTP routes**
- ClusterState wrapping SimulatedCluster with region name mapping
- Routes: /health, /cluster/status, /cluster/promote, /partition, /heal
- Data routes: /items, /embeddings, /signals, /feed, /search (region-aware)
- Ranking profiles wired through ClusterConfig to all cluster nodes
- Topology YAML config, docker/cluster/Dockerfile (ENTRYPOINT+CMD, non-root)

**m8p9: scatter-gather query routing**
- Entity-sharded writes via Knuth multiplicative hash
- Scatter-gather RETRIEVE and SEARCH with deadline propagation (50ms-5ms)
- Partial failure: degraded=true with unavailable_shards metadata
- 6 tests: distribution, determinism, multi-shard retrieve, degraded
  partial results, deadline propagation, scatter-gather search

**m8p10: gRPC transport integration tests**
- 8 tests over real gRPC: replication convergence, idempotent replay,
  mixed signals, 3-node fan-out, partition/heal, degraded follower, perf
- Documented as tier-2 (in-process+gRPC); tier-3 multi-process pending
2026-04-11 13:51:08 -06:00

139 lines
4.0 KiB
Rust

//! Transport trait contract tests for `GrpcTransport`.
//!
//! Mirrors the test patterns from `InProcessTransport` but over gRPC on localhost.
use std::collections::HashMap;
use std::net::SocketAddr;
use std::thread;
use std::time::Duration;
use tidaldb::replication::WalSegmentId;
use tidaldb::replication::shard::{RegionId, ShardId};
use tidaldb::replication::transport::{Transport, TransportError, WalSegmentPayload};
use tidal_net::GrpcTransport;
use tidal_net::config::GrpcTransportConfig;
/// Get a unique listen address using port 0 (OS-assigned).
/// Since tonic doesn't support port 0, we bind a TcpListener to find a free port.
fn free_addr() -> SocketAddr {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.local_addr().unwrap()
}
fn make_config(
shard: ShardId,
listen: SocketAddr,
peers: HashMap<ShardId, SocketAddr>,
) -> GrpcTransportConfig {
GrpcTransportConfig {
local_shard: shard,
listen_addr: listen,
peers,
insecure: true,
..Default::default()
}
}
fn make_payload(shard: ShardId, seqno: u64) -> WalSegmentPayload {
WalSegmentPayload {
id: WalSegmentId::new(RegionId::SINGLE, shard, seqno),
bytes: vec![0xAB; 100],
event_count: 5,
}
}
/// Build two GrpcTransports on localhost that can talk to each other.
fn build_pair() -> (GrpcTransport, GrpcTransport) {
let addr0 = free_addr();
let addr1 = free_addr();
let config0 = make_config(ShardId(0), addr0, HashMap::from([(ShardId(1), addr1)]));
let config1 = make_config(ShardId(1), addr1, HashMap::from([(ShardId(0), addr0)]));
let t0 = GrpcTransport::new(config0).expect("transport 0");
let t1 = GrpcTransport::new(config1).expect("transport 1");
// Give servers a moment to start.
thread::sleep(Duration::from_millis(100));
(t0, t1)
}
#[test]
fn send_and_receive_between_shards() {
let (t0, t1) = build_pair();
// Shard 0 sends to Shard 1.
let payload = make_payload(ShardId(0), 42);
t0.send_segment(ShardId(1), payload).unwrap();
// Shard 1 receives it.
let received = t1.recv_segment().unwrap();
assert_eq!(received.id.seqno, 42);
assert_eq!(received.event_count, 5);
assert_eq!(received.bytes.len(), 100);
}
#[test]
fn send_to_unknown_peer_fails() {
let addr0 = free_addr();
let config0 = make_config(ShardId(0), addr0, HashMap::new());
let t0 = GrpcTransport::new(config0).expect("transport 0");
thread::sleep(Duration::from_millis(50));
let result = t0.send_segment(ShardId(99), make_payload(ShardId(0), 1));
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
TransportError::UnknownPeer(_)
));
}
#[test]
fn payload_too_large_rejected() {
let addr0 = free_addr();
let addr1 = free_addr();
let config0 = make_config(ShardId(0), addr0, HashMap::from([(ShardId(1), addr1)]));
let t0 = GrpcTransport::new(config0).expect("transport 0");
thread::sleep(Duration::from_millis(50));
let payload = WalSegmentPayload {
id: WalSegmentId::new(RegionId::SINGLE, ShardId(0), 1),
bytes: vec![0u8; 64 * 1024 * 1024 + 1],
event_count: 0,
};
let result = t0.send_segment(ShardId(1), payload);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
TransportError::PayloadTooLarge { .. }
));
}
#[test]
fn local_shard_returns_correct_id() {
let addr = free_addr();
let config = make_config(ShardId(7), addr, HashMap::new());
let t = GrpcTransport::new(config).expect("transport");
assert_eq!(t.local_shard(), ShardId(7));
}
#[test]
fn multiple_segments_fifo_order() {
let (t0, t1) = build_pair();
for seq in 1..=5u64 {
t0.send_segment(ShardId(1), make_payload(ShardId(0), seq))
.unwrap();
}
// Brief pause to let segments flow through gRPC.
thread::sleep(Duration::from_millis(50));
for expected_seq in 1..=5u64 {
let received = t1.recv_segment().unwrap();
assert_eq!(received.id.seqno, expected_seq);
}
}