Skip to main content

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}