etl_core/sink/bundle.rs
1//! A ready-to-run sink, decomposed: what a connector's config factory
2//! hands the pipeline builder.
3//!
4//! Connector crates implement [`SinkBundle`] on their config-built sink
5//! type (e.g. a ClickHouse sink, a capturing test sink); hand-rolled sinks
6//! construct [`SinkParts`] directly โ it implements the trait itself, so
7//! `Pipeline::sink` accepts either. The only bound is [`ShardWriter`]:
8//! connector-specific types appear solely as the implementor's associated
9//! types, keeping 0.x dependencies out of this crate's public bounds
10//! (`docs/DESIGN.md` ยง Dependency policy).
11
12use super::{ShardWriter, SinkPoolConfig, SinkProbeFn};
13
14/// A sink ready for assembly: the writer, the shard/replica topology, pool
15/// tuning, and optional metadata (metric labels, readiness probe).
16///
17/// Consumed once by the pipeline builder, which turns it into shard
18/// queues, per-shard metrics, and a spawned
19/// [`SinkPool`](crate::sink::SinkPool).
20pub trait SinkBundle {
21 /// The connector's [`ShardWriter`] implementation.
22 type Writer: ShardWriter;
23
24 /// Decompose into the parts the builder wires up. Consuming: the
25 /// endpoints move into the sink pool.
26 fn into_parts(self) -> SinkParts<Self::Writer>;
27}
28
29/// The decomposed sink. Construct with [`SinkParts::new`] and refine with
30/// the `with_*` methods (the struct is `#[non_exhaustive]`; fields may be
31/// added without breaking implementors).
32///
33/// `shard_endpoints` is indexed `[shard][replica]` and must be non-empty
34/// with every shard holding at least one replica โ the builder rejects
35/// ragged or empty topologies before anything spawns.
36#[non_exhaustive]
37pub struct SinkParts<W: ShardWriter> {
38 /// The connector's writer, shared by every shard worker.
39 pub writer: W,
40 /// Per-shard replica endpoints, `[shard][replica]`.
41 pub shard_endpoints: Vec<Vec<W::Endpoint>>,
42 /// Pool tuning (batching, inflight, retry, breaker).
43 pub pool: SinkPoolConfig,
44 /// The `component_type` metric label (e.g. `"clickhouse"`).
45 pub component_type: String,
46 /// Per-replica display labels for shard metrics, same shape as
47 /// `shard_endpoints`. Defaults to `"{component_type}-{shard}-{replica}"`.
48 pub replica_labels: Vec<Vec<String>>,
49 /// Optional readiness probe. Probes should use their own client set โ
50 /// sharing the writer's connections would report the insert path
51 /// healthy simply because probing keeps it warm.
52 pub probe: Option<SinkProbeFn>,
53}
54
55impl<W: ShardWriter> SinkParts<W> {
56 /// Minimal parts: `component_type` defaults to `"custom"`, replica
57 /// labels to `"{component_type}-{shard}-{replica}"`, no probe.
58 pub fn new(writer: W, shard_endpoints: Vec<Vec<W::Endpoint>>, pool: SinkPoolConfig) -> Self {
59 SinkParts {
60 writer,
61 shard_endpoints,
62 pool,
63 component_type: "custom".to_string(),
64 replica_labels: Vec::new(),
65 probe: None,
66 }
67 }
68
69 /// Set the `component_type` metric label.
70 #[must_use]
71 pub fn with_component_type(mut self, component_type: impl Into<String>) -> Self {
72 self.component_type = component_type.into();
73 self
74 }
75
76 /// Set per-replica display labels (same `[shard][replica]` shape as
77 /// the endpoints).
78 #[must_use]
79 pub fn with_replica_labels(mut self, labels: Vec<Vec<String>>) -> Self {
80 self.replica_labels = labels;
81 self
82 }
83
84 /// Attach a readiness probe.
85 #[must_use]
86 pub fn with_probe(mut self, probe: SinkProbeFn) -> Self {
87 self.probe = Some(probe);
88 self
89 }
90
91 /// The replica labels to use: the configured ones, or the
92 /// `"{component_type}-{shard}-{replica}"` defaults. Manual assemblies
93 /// can feed these to
94 /// [`SinkShardMetrics::new`](crate::metrics::SinkShardMetrics::new).
95 pub fn effective_replica_labels(&self) -> Vec<Vec<String>> {
96 if self.replica_labels.is_empty() {
97 self.shard_endpoints
98 .iter()
99 .enumerate()
100 .map(|(shard, replicas)| {
101 (0..replicas.len())
102 .map(|replica| format!("{}-{shard}-{replica}", self.component_type))
103 .collect()
104 })
105 .collect()
106 } else {
107 self.replica_labels.clone()
108 }
109 }
110}
111
112impl<W: ShardWriter> std::fmt::Debug for SinkParts<W> {
113 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114 f.debug_struct("SinkParts")
115 .field("component_type", &self.component_type)
116 .field(
117 "shards",
118 &self
119 .shard_endpoints
120 .iter()
121 .map(Vec::len)
122 .collect::<Vec<_>>(),
123 )
124 .field("pool", &self.pool)
125 .field("probe", &self.probe.is_some())
126 .finish_non_exhaustive()
127 }
128}
129
130impl<W: ShardWriter> SinkBundle for SinkParts<W> {
131 type Writer = W;
132
133 fn into_parts(self) -> SinkParts<W> {
134 self
135 }
136}
137
138#[cfg(all(test, not(loom)))]
139mod tests {
140 use super::*;
141 use crate::error::SinkError;
142 use crate::sink::SealedBatch;
143
144 struct NullWriter;
145 impl ShardWriter for NullWriter {
146 type Endpoint = ();
147 async fn write_batch(&self, (): &(), _batch: &SealedBatch) -> Result<(), SinkError> {
148 Ok(())
149 }
150 }
151
152 #[test]
153 fn default_replica_labels_follow_topology_shape() {
154 let parts = SinkParts::new(
155 NullWriter,
156 vec![vec![(), ()], vec![()]],
157 SinkPoolConfig::default(),
158 )
159 .with_component_type("stdout");
160 assert_eq!(
161 parts.effective_replica_labels(),
162 vec![
163 vec!["stdout-0-0".to_string(), "stdout-0-1".to_string()],
164 vec!["stdout-1-0".to_string()]
165 ]
166 );
167 }
168
169 #[test]
170 fn explicit_replica_labels_win() {
171 let parts = SinkParts::new(NullWriter, vec![vec![()]], SinkPoolConfig::default())
172 .with_replica_labels(vec![vec!["primary".to_string()]]);
173 assert_eq!(
174 parts.effective_replica_labels(),
175 vec![vec!["primary".to_string()]]
176 );
177 }
178
179 #[test]
180 fn sink_parts_round_trips_through_the_trait() {
181 let parts = SinkParts::new(NullWriter, vec![vec![()]], SinkPoolConfig::default())
182 .with_component_type("capture");
183 let parts = SinkBundle::into_parts(parts);
184 assert_eq!(parts.component_type, "capture");
185 assert_eq!(parts.shard_endpoints.len(), 1);
186 }
187}