spate_core/sink/mod.rs
1//! Sink abstraction: pipeline threads encode, shard workers batch and
2//! write.
3//!
4//! The division of labour (ADR-0006):
5//!
6//! - **Pipeline threads** route each record to a shard, then run the
7//! sink's [`RowEncoder`] inside the chain's terminal stage, accumulating
8//! encoded rows into small [`EncodedChunk`] frames per shard and
9//! `try_send`ing them into bounded per-shard queues. The send never
10//! blocks; a full queue surfaces as backpressure. Routing has two tiers
11//! over one seam: meta-only [`ShardRouter`] (the default
12//! [`KeyHashRouter`] uses the key hash, else a stable partition hash) or
13//! record-aware [`RecordRouter`] for payload-derived shard affinity.
14//! - **Shard workers** (tokio tasks) merge chunks from all pipeline
15//! threads into full-size batches, seal on `max_rows` / `max_bytes` /
16//! `linger`, and dispatch up to `inflight.max_per_shard` concurrent
17//! [`ShardWriter::write_batch`] calls rotating across healthy replicas.
18//! Merging at the worker keeps batches large regardless of the pipeline
19//! thread count.
20//!
21//! A connector implements [`RowEncoder`] (CPU half) and [`ShardWriter`]
22//! (I/O half), and may ship a [`RecordRouter`] when the target's sharding
23//! is payload-derived and a [`FrameCodec`] when its wire form is a coded
24//! version of the encoded rows; the framework owns everything between them.
25
26mod breaker;
27mod bundle;
28mod config;
29mod failures;
30mod pool;
31#[cfg(test)]
32mod pool_tests;
33mod queue;
34mod retry;
35mod worker;
36
37pub use bundle::{SinkBundle, SinkParts};
38pub use config::{
39 BatchConfig, BreakerConfig, BreakerConfigError, InflightConfig, RetryConfig, RetryConfigError,
40 SinkPoolConfig,
41};
42pub use failures::SinkFailures;
43pub use pool::{DrainReport, SinkPool};
44pub use queue::{ChunkSendError, ShardQueues, shard_queues};
45
46/// Boxed sink drain hook: budget in, report out. Produced by sink
47/// assemblies (wrapping [`SinkPool::drain`]), consumed once at shutdown by
48/// the pipeline runtime.
49pub type SinkDrainFn = Box<
50 dyn FnOnce(std::time::Duration) -> std::pin::Pin<Box<dyn Future<Output = DrainReport> + Send>>
51 + Send,
52>;
53
54/// Boxed, repeatable sink connectivity probe (readiness). The runtime
55/// probes at startup and then periodically, driving the sinks-connected
56/// half of `/readyz`.
57pub type SinkProbeFn = Box<
58 dyn Fn() -> std::pin::Pin<Box<dyn Future<Output = Result<(), SinkError>> + Send>> + Send + Sync,
59>;
60
61/// Build a [`SinkProbeFn`] that probes every replica of every shard in
62/// `shard_endpoints` (indexed `[shard][replica]`) via
63/// [`ShardWriter::probe`]. This is the readiness loop
64/// [`SinkParts::with_probe`](crate::sink::SinkParts::with_probe) expects.
65/// Back `writer` with an independent probe client set, never the insert
66/// clients (see [`SinkParts::probe`](crate::sink::SinkParts)).
67pub fn endpoint_probe<W>(
68 writer: W,
69 shard_endpoints: std::sync::Arc<Vec<Vec<W::Endpoint>>>,
70) -> SinkProbeFn
71where
72 W: ShardWriter + Clone,
73{
74 Box::new(move || {
75 let writer = writer.clone();
76 let shard_endpoints = std::sync::Arc::clone(&shard_endpoints);
77 Box::pin(async move {
78 for shard in shard_endpoints.iter() {
79 for endpoint in shard {
80 writer.probe(endpoint).await?;
81 }
82 }
83 Ok(())
84 })
85 })
86}
87
88use crate::checkpoint::AckSet;
89use crate::deser::RecFamily;
90use crate::error::SinkError;
91use crate::metrics::Meter;
92use crate::record::{Record, RecordMeta};
93use bytes::{Bytes, BytesMut};
94use std::time::Instant;
95
96/// A small frame of encoded rows produced on a pipeline thread, the unit
97/// shipped over the per-shard queues. Wire frames are concatenable, so
98/// workers accumulate chunks without re-encoding. A frame is one of three
99/// things:
100///
101/// - headerless rows appended back-to-back (RowBinary);
102/// - one complete, self-describing block (ClickHouse Native);
103/// - a unit the sink's [`FrameCodec`] made from one of those, which decodes
104/// on its own.
105///
106/// In every case, frames concatenated in seal order form a legal body.
107///
108/// Teardown safety: `acks` is an [`AckSet`]. Dropping a chunk anywhere
109/// (a closed queue, an aborted worker, a parked chunk at teardown) fails
110/// its batches so their offsets never commit; only a completed durable
111/// write delivers them.
112#[derive(Debug)]
113pub struct EncodedChunk {
114 /// Encoded rows in the sink's wire format, after the sink's
115 /// [`FrameCodec`] when it has one.
116 pub frame: Bytes,
117 /// The frame's length as the encoder wrote it, before any codec. Equal
118 /// to `frame.len()` when the sink has no codec. The in-flight budget and
119 /// [`BatchConfig::max_bytes`] count this length.
120 pub raw_len: usize,
121 /// Number of rows in `frame`.
122 pub rows: u32,
123 /// Acknowledgment handles of the source batches represented in
124 /// `frame`. Consecutive records usually share a batch, so this stays
125 /// short (the encoder dedupes consecutive identical handles).
126 pub acks: AckSet,
127 /// When the oldest record in `frame` entered the terminal stage
128 /// (ingest-basis end-to-end latency).
129 pub oldest_ingest: Instant,
130 /// Smallest record event time in `frame`, milliseconds since the epoch
131 /// (event-basis end-to-end latency).
132 pub oldest_event_ms: i64,
133}
134
135/// The CPU half of a sink connector: encodes one record into the sink's
136/// wire format. Runs on pinned pipeline threads inside the chain's
137/// terminal stage; must not perform I/O. Family-generic and dyn-compatible,
138/// like [`Deserializer`](crate::deser::Deserializer).
139pub trait RowEncoder<F: RecFamily>: Send {
140 /// Append `rec`'s encoding to `buf`. Errors are record-level and
141 /// subject to the sink stage's `ErrorPolicy`. Errors of
142 /// [`ErrorClass::Fatal`](crate::error::ErrorClass::Fatal) stop the
143 /// pipeline regardless of policy; fatal means the encoder itself is
144 /// broken (e.g. the row type cannot match the target schema), so every
145 /// subsequent record would fail identically.
146 fn encode<'buf>(
147 &mut self,
148 rec: &Record<F::Rec<'buf>>,
149 buf: &mut BytesMut,
150 ) -> Result<(), SinkError>;
151
152 /// Bytes the encoder is holding internally that have **not** yet been
153 /// flushed to a frame. Row formats append directly in
154 /// [`encode`](Self::encode) and buffer nothing, so the default is `0`.
155 /// Columnar formats (which must transpose a whole block before any bytes
156 /// exist) return the approximate size of the block under assembly; the
157 /// terminal stage adds this to the shard buffer length when deciding
158 /// whether to seal a chunk, so a columnar block still respects
159 /// [`ChunkConfig::target_bytes`](crate::ops::ChunkConfig).
160 fn buffered_bytes(&self) -> usize {
161 0
162 }
163
164 /// Finalize the pending chunk: flush any internally-buffered rows into
165 /// `buf` as exactly **one** complete, self-describing wire frame, leaving
166 /// the encoder empty and ready for the next chunk. Row formats already
167 /// wrote every row in [`encode`](Self::encode), so the default is a
168 /// no-op. The terminal stage calls this immediately before it seals each
169 /// [`EncodedChunk`] (in steady state, on data lulls, and at drain), so a
170 /// columnar encoder's buffered rows are not silently dropped.
171 ///
172 /// An `Err` is fatal (a broken encoder, not a bad record). The stage
173 /// discards whatever the failed call wrote and ships no partial frame.
174 /// The stop it triggers is asynchronous, so the chunk stays open and a
175 /// later seal may call `finish_chunk` again for it, by which point the
176 /// chunk may hold rows the failed call never saw. Return `Ok` only from a
177 /// call whose frame carries every row the chunk holds. The stage discards
178 /// what the call wrote and not the row count, so an encoder that had
179 /// already moved its rows into `buf` when it failed re-encodes them, and
180 /// keeps failing until it can. Rows no call finalizes have their acknowledgments fail on
181 /// teardown, so the data replays.
182 ///
183 /// Because a Native block concatenates with the blocks around it, each
184 /// `finish_chunk` frame is independently valid, so workers still
185 /// accumulate frames without re-encoding.
186 fn finish_chunk(&mut self, buf: &mut BytesMut) -> Result<(), SinkError> {
187 let _ = buf;
188 Ok(())
189 }
190}
191
192/// Turns each sealed chunk into its wire form, on the pipeline thread that
193/// sealed it. Compression is the usual case.
194///
195/// The terminal stage calls [`encode_frame`](Self::encode_frame) once per
196/// sealed chunk, after the [`RowEncoder`] has finished it and before the
197/// chunk is queued. The output becomes the chunk's frame, is stored, and is
198/// never recomputed, so every write attempt of a batch, including retries,
199/// sends the same bytes. The in-flight budget and
200/// [`BatchConfig::max_bytes`] count the chunk's length before the codec, so
201/// neither depends on what a codec does.
202///
203/// The contract:
204///
205/// - The output is one unit that decodes on its own, and units concatenated
206/// in seal order form a legal stream for the sink. Compressed database
207/// blocks and the members of a multi-frame zstd or gzip object body both
208/// fit.
209/// - The output is a deterministic function of the input.
210/// - A codec must not perform I/O.
211/// - An instance may keep state between calls, such as a compression
212/// context or a scratch buffer. The framework makes one instance per sink
213/// per chain, which is one per pipeline thread, and calls it from that
214/// thread only.
215/// - The terminal stage copies the returned slice before the next call.
216/// - An `Err` stops the pipeline whatever its [`ErrorClass`]. Nothing ships
217/// for the chunk; its rows and acknowledgments stay in the shard buffer,
218/// and they fail on teardown if no later call succeeds.
219///
220/// The `Sync` bound keeps [`SinkHandoff`], the terminal stage that holds the
221/// codec, `Sync`.
222///
223/// [`ErrorClass`]: crate::error::ErrorClass
224/// [`SinkHandoff`]: crate::ops::SinkHandoff
225///
226/// # Examples
227///
228/// A codec that writes each chunk's length before it:
229///
230/// ```
231/// use spate_core::error::SinkError;
232/// use spate_core::sink::{FrameCodec, FrameCodecFactory};
233///
234/// #[derive(Default)]
235/// struct LengthPrefix {
236/// out: Vec<u8>,
237/// }
238///
239/// impl FrameCodec for LengthPrefix {
240/// fn encode_frame(&mut self, chunk: &[u8]) -> Result<&[u8], SinkError> {
241/// self.out.clear();
242/// let len = u32::try_from(chunk.len()).map_err(|_| SinkError::Client {
243/// class: spate_core::ErrorClass::Fatal,
244/// reason: "chunk longer than u32::MAX".into(),
245/// })?;
246/// self.out.extend_from_slice(&len.to_le_bytes());
247/// self.out.extend_from_slice(chunk);
248/// Ok(&self.out)
249/// }
250/// }
251///
252/// let factory = FrameCodecFactory::new(|| Box::new(LengthPrefix::default()));
253/// let mut codec = factory.make();
254/// assert_eq!(codec.encode_frame(b"ab").unwrap(), b"\x02\x00\x00\x00ab");
255/// ```
256pub trait FrameCodec: Send + Sync {
257 /// Code one finished chunk, returning the frame to ship.
258 ///
259 /// # Errors
260 ///
261 /// Any error stops the pipeline; see the trait's contract.
262 fn encode_frame(&mut self, chunk: &[u8]) -> Result<&[u8], SinkError>;
263}
264
265/// Makes the [`FrameCodec`] instances for one sink: one per chain. Cloning
266/// shares the factory.
267///
268/// The closure is `RefUnwindSafe`, so the factory, and a sink type that holds
269/// one, stays `UnwindSafe` and `RefUnwindSafe`.
270///
271/// A closure that captures state which is not `RefUnwindSafe` wraps it in
272/// [`AssertUnwindSafe`](std::panic::AssertUnwindSafe) and reaches it through
273/// deref, as in `let state: &T = &wrapper;`. From edition 2021 a closure
274/// that names `wrapper.0` captures only the inner field, which loses the
275/// wrapper and fails the bound.
276#[derive(Clone)]
277pub struct FrameCodecFactory(std::sync::Arc<dyn MakeFrameCodec>);
278
279/// The closure a [`FrameCodecFactory`] wraps.
280trait MakeFrameCodec: Fn() -> Box<dyn FrameCodec> + Send + Sync + std::panic::RefUnwindSafe {}
281
282impl<T> MakeFrameCodec for T where
283 T: Fn() -> Box<dyn FrameCodec> + Send + Sync + std::panic::RefUnwindSafe
284{
285}
286
287impl FrameCodecFactory {
288 /// Wrap `make`, which builds one codec instance per call.
289 pub fn new(
290 make: impl Fn() -> Box<dyn FrameCodec> + Send + Sync + std::panic::RefUnwindSafe + 'static,
291 ) -> Self {
292 FrameCodecFactory(std::sync::Arc::new(make))
293 }
294
295 /// Build a fresh codec instance.
296 #[must_use]
297 pub fn make(&self) -> Box<dyn FrameCodec> {
298 (self.0)()
299 }
300}
301
302impl std::fmt::Debug for FrameCodecFactory {
303 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
304 f.write_str("FrameCodecFactory")
305 }
306}
307
308/// A batch sealed by a shard worker, ready to write. Frames concatenate to
309/// the full wire payload (a stream of one or more self-describing blocks for
310/// block formats like ClickHouse Native).
311#[derive(Debug)]
312pub struct SealedBatch {
313 /// Encoded frames, in order.
314 pub frames: Vec<Bytes>,
315 /// Total rows across `frames`.
316 pub rows: u64,
317 /// Total bytes the encoder wrote for `frames`: the sum of each chunk's
318 /// [`EncodedChunk::raw_len`], which exceeds the frames' length when the
319 /// sink's [`FrameCodec`] compresses.
320 pub bytes: u64,
321 /// Deterministic-within-a-session batch identity. Retries of the same
322 /// sealed batch (including on other replicas) reuse the same token,
323 /// so sinks with server-side deduplication windows treat them as
324 /// idempotent. Crash replay produces different tokens (documented
325 /// at-least-once semantics).
326 pub dedup_token: String,
327}
328
329/// What the builder hands a sink writer for its own metric families, through
330/// [`ShardWriter::attach_metrics`].
331#[derive(Debug)]
332#[non_exhaustive]
333pub struct SinkMetricsCtx {
334 /// Scoped `spate_<component_type>_sink_*` and pre-labeled with the
335 /// standard `pipeline`/`component`/`component_type`. `None` when the
336 /// sink's `component_type` cannot scope a family: the default `"custom"`
337 /// (reserved for pipeline-author metrics) or a reserved root opts out
338 /// silently, and a malformed value is logged and also yields `None`.
339 /// Declare a distinct `component_type` via
340 /// [`SinkParts::with_component_type`](crate::sink::SinkParts::with_component_type)
341 /// to receive a scope.
342 pub meter: Option<Meter>,
343}
344
345impl SinkMetricsCtx {
346 /// Context carrying `meter`. Connectors outside this crate construct it
347 /// here, as the struct is `#[non_exhaustive]`.
348 ///
349 /// # Examples
350 ///
351 /// ```
352 /// use spate_core::metrics::{Meter, render_local};
353 /// use spate_core::sink::SinkMetricsCtx;
354 ///
355 /// let meter = Meter::with_namespace("probe_sink", "orders", "sink", "probe");
356 /// let cx = SinkMetricsCtx::new(Some(meter));
357 /// let meter = cx.meter.expect("a scoped Meter");
358 /// let text = render_local(|| meter.counter("writes_total", &[]).increment(1));
359 /// assert!(text.contains("spate_probe_sink_writes_total{"));
360 /// ```
361 #[must_use]
362 pub fn new(meter: Option<Meter>) -> Self {
363 SinkMetricsCtx { meter }
364 }
365}
366
367/// The I/O half of a sink connector: writes one sealed batch to one
368/// replica endpoint. Returning `Ok` is the durable-ack point; only then
369/// may the framework resolve the batch's acknowledgments.
370pub trait ShardWriter: Send + Sync + 'static {
371 /// A connected replica endpoint (e.g. one HTTP client per replica).
372 type Endpoint: Send + Sync + 'static;
373
374 /// Receive the [`SinkMetricsCtx`] for the writer's own metric families.
375 /// Called once by the builder before the writer is shared across shard
376 /// workers; resolve handles here and store them (they are `Arc`-backed, so
377 /// `write_batch`'s `&self` can touch them). See [`SinkMetricsCtx::meter`]
378 /// for when it carries no `Meter`. The default ignores it.
379 fn attach_metrics(&mut self, _cx: SinkMetricsCtx) {}
380
381 /// Write `batch` to `endpoint` durably.
382 ///
383 /// **Bound it.** The framework guarantees that shutdown terminates, not
384 /// that this call does. At the drain deadline the write task is aborted,
385 /// which only lands if this future is at an await point. A client with no
386 /// request timeout turns every shutdown into a wait for the deadline, and
387 /// one that blocks its thread between awaits cannot be aborted at all.
388 /// Give the underlying client a request timeout and let the framework's
389 /// retry policy handle the failure.
390 fn write_batch(
391 &self,
392 endpoint: &Self::Endpoint,
393 batch: &SealedBatch,
394 ) -> impl Future<Output = Result<(), SinkError>> + Send;
395
396 /// Connectivity probe for readiness. Defaults to healthy.
397 fn probe(
398 &self,
399 endpoint: &Self::Endpoint,
400 ) -> impl Future<Output = Result<(), SinkError>> + Send {
401 let _ = endpoint;
402 async { Ok(()) }
403 }
404}
405
406/// Routes records to shards on metadata alone, the **meta-only tier** of
407/// sink routing. Pure and cheap; called per record on pipeline threads.
408///
409/// Every `ShardRouter` is also a [`RecordRouter`] for every record family
410/// through a blanket bridge, so meta-only routers plug into the same
411/// builder seam unchanged. Implement [`RecordRouter`] directly instead
412/// when routing needs the payload.
413pub trait ShardRouter: Send + Sync {
414 /// The shard index in `0..num_shards` for a record.
415 fn route(&self, meta: &RecordMeta, num_shards: usize) -> usize;
416}
417
418/// Default router: key hash modulo shards, falling back to the source
419/// partition for keyless records (keeps a partition's keyless records
420/// together and the distribution stable).
421#[derive(Clone, Copy, Debug, Default)]
422pub struct KeyHashRouter;
423
424impl ShardRouter for KeyHashRouter {
425 #[inline]
426 fn route(&self, meta: &RecordMeta, num_shards: usize) -> usize {
427 debug_assert!(num_shards > 0);
428 let h = meta
429 .key_hash
430 .unwrap_or_else(|| u64::from(meta.partition.0).wrapping_mul(0x9E37_79B9_7F4A_7C15));
431 (h % num_shards as u64) as usize
432 }
433}
434
435/// Routes records to shards with access to the full record, the
436/// **record-aware tier** of sink routing. Pure and cheap; called once per
437/// record on pinned pipeline threads, strictly before encoding. It must
438/// not perform I/O, block, or allocate per call. Family-generic and
439/// dyn-compatible, like [`RowEncoder`].
440///
441/// The two tiers share one seam:
442///
443/// - **Meta-only** ([`ShardRouter`]): routes on [`RecordMeta`] alone (key
444/// hash, source partition). The default [`KeyHashRouter`] lives here.
445/// Every `ShardRouter` is automatically a `RecordRouter` for every
446/// family through a blanket bridge, so meta-only routers plug into the
447/// same builder seam unchanged.
448/// - **Record-aware** (this trait): routes on the payload itself.
449/// Required when shard affinity derives from a field of the terminal
450/// record type (e.g. matching a sink cluster's own sharding expression),
451/// and the only way to route `flat_map` children independently, since
452/// children inherit their parent's [`RecordMeta`] and a meta-only router
453/// colocates them.
454///
455/// The router sees the record exactly as the [`RowEncoder`] will (after
456/// every transform), so a routing key must survive to the terminal record
457/// type. A router may hold state (a weights table, an atomic counter);
458/// `&self` plus interior mutability covers stateful strategies.
459///
460/// A router must also be **total**: return a shard index for every record
461/// and never panic. Routing has no per-record error policy; a record
462/// either has a well-defined shard or the router picks a deterministic
463/// fallback. Unlike an encoder error, which honors the sink
464/// stage's Skip/Fail policy, a router panic fails the in-flight batch and
465/// stops the pipeline; restart then replays the same record, so a
466/// payload-dependent panic is a deterministic crash loop until a code fix
467/// ships.
468///
469/// # Examples
470///
471/// A record-aware router over an owned family:
472///
473/// ```
474/// use spate_core::deser::Owned;
475/// use spate_core::record::Record;
476/// use spate_core::sink::RecordRouter;
477///
478/// struct ByLen;
479/// impl RecordRouter<Owned<Vec<u8>>> for ByLen {
480/// fn route_record<'buf>(&self, rec: &Record<Vec<u8>>, num_shards: usize) -> usize {
481/// rec.payload.len() % num_shards
482/// }
483/// }
484/// ```
485///
486/// Implement **either** this trait **or** [`ShardRouter`], never both;
487/// the bridge makes implementing both a coherence overlap:
488///
489/// ```compile_fail,E0119
490/// use spate_core::deser::Owned;
491/// use spate_core::record::{Record, RecordMeta};
492/// use spate_core::sink::{RecordRouter, ShardRouter};
493///
494/// struct Both;
495/// impl ShardRouter for Both {
496/// fn route(&self, _: &RecordMeta, _: usize) -> usize { 0 }
497/// }
498/// impl RecordRouter<Owned<Vec<u8>>> for Both {
499/// fn route_record<'buf>(&self, _: &Record<Vec<u8>>, _: usize) -> usize { 0 }
500/// }
501/// ```
502pub trait RecordRouter<F: RecFamily>: Send + Sync {
503 /// The shard index in `0..num_shards` for `rec`. Must be
504 /// `< num_shards`; the terminal stage indexes its shard buffers
505 /// directly with the result.
506 fn route_record<'buf>(&self, rec: &Record<F::Rec<'buf>>, num_shards: usize) -> usize;
507}
508
509/// Bridge: every meta-only [`ShardRouter`] routes any record family by
510/// ignoring the payload and delegating to [`ShardRouter::route`] on the
511/// record's metadata.
512impl<F: RecFamily, R: ShardRouter> RecordRouter<F> for R {
513 #[inline]
514 fn route_record<'buf>(&self, rec: &Record<F::Rec<'buf>>, num_shards: usize) -> usize {
515 self.route(&rec.meta, num_shards)
516 }
517}
518
519#[cfg(all(test, not(loom)))]
520mod tests {
521 use super::*;
522 use crate::checkpoint::AckRef;
523 use crate::deser::Owned;
524 use crate::record::PartitionId;
525
526 fn meta(key_hash: Option<u64>, partition: u32) -> RecordMeta {
527 RecordMeta {
528 partition: PartitionId(partition),
529 offset: 0,
530 event_time_ms: 0,
531 key_hash,
532 }
533 }
534
535 #[test]
536 fn key_hash_router_uses_key_then_partition() {
537 let r = KeyHashRouter;
538 assert_eq!(r.route(&meta(Some(10), 0), 4), (10 % 4) as usize);
539 // Keyless: stable per partition, and different partitions spread.
540 let a = r.route(&meta(None, 0), 4);
541 let b = r.route(&meta(None, 0), 4);
542 assert_eq!(a, b);
543 let spread: crate::hash::FastSet<_> = (0..16).map(|p| r.route(&meta(None, p), 4)).collect();
544 assert!(spread.len() > 1, "keyless records must not all colocate");
545 }
546
547 #[test]
548 fn shard_router_bridges_to_record_router_ignoring_payload() {
549 let (ack, _rx) = AckRef::test_pair();
550 for key_hash in [Some(10), Some(u64::MAX), None] {
551 let rec = Record {
552 payload: vec![1u8, 2, 3],
553 meta: meta(key_hash, 3),
554 ack: ack.clone(),
555 };
556 for n in [1usize, 2, 4, 7] {
557 assert_eq!(
558 RecordRouter::<Owned<Vec<u8>>>::route_record(&KeyHashRouter, &rec, n),
559 ShardRouter::route(&KeyHashRouter, &rec.meta, n),
560 "the bridge must delegate to the meta-only route"
561 );
562 }
563 }
564 }
565
566 #[test]
567 fn record_router_is_dyn_compatible_for_a_concrete_family() {
568 let _router: &dyn RecordRouter<Owned<Vec<u8>>> = &KeyHashRouter;
569 }
570}