Skip to main content

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}