Skip to main content

crabka_connect/
sink.rs

1//! The [`Sink`] SPI: push records into an external system, with an optional
2//! transactional write gate for exactly-once delivery.
3
4use async_trait::async_trait;
5
6use crate::error::ConnectError;
7use crate::record::ConnectRecord;
8
9/// A connector that pushes records consumed from Kafka into an external system
10/// (a data warehouse, a search index, a telemetry backend).
11///
12/// This is the write side of the connector SPI and the template every telemetry
13/// sink builds on. It mirrors the streams runtime's `RecordProducer`: records
14/// are buffered by [`put`](Sink::put) and made durable by [`flush`](Sink::flush).
15///
16/// ## Delivery
17///
18/// The runtime hands batches to [`put`](Sink::put) and periodically calls
19/// [`flush`](Sink::flush) to establish a durability barrier before committing
20/// the corresponding consumer offsets. A sink that buffers internally must make
21/// every buffered record durable by the time `flush` returns.
22///
23/// ## Transactional write gate
24///
25/// A sink that can write atomically opts into exactly-once delivery by setting
26/// [`supports_transactions`](Sink::supports_transactions) and implementing
27/// [`begin`](Sink::begin) / [`commit`](Sink::commit) / [`abort`](Sink::abort).
28/// The runtime then brackets each commit interval in a transaction:
29///
30/// ```text
31/// begin → put* → commit   (or abort on failure)
32/// ```
33///
34/// Like the streams `BeginTxnGate`, the gate is lazy: the runtime calls `begin`
35/// only when a non-empty batch is about to be written, so an idle interval opens
36/// no transaction. The default implementations are at-least-once: `begin` and
37/// `abort` are no-ops and `commit` delegates to `flush`.
38#[async_trait]
39pub trait Sink<K, V>: Send + Sync + 'static {
40    /// Buffer a batch of records for writing. May write through immediately or
41    /// accumulate until [`flush`](Sink::flush); either way the records are not
42    /// guaranteed durable until `flush` (or [`commit`](Sink::commit)) returns.
43    ///
44    /// # Errors
45    ///
46    /// Returns [`ConnectError`] if the records cannot be accepted.
47    async fn put(&mut self, records: Vec<ConnectRecord<K, V>>) -> Result<(), ConnectError>;
48
49    /// Make every record accepted since the last flush/commit durable. The
50    /// runtime calls this before committing consumer offsets, so a successful
51    /// `flush` is the guarantee those records will not be re-delivered.
52    ///
53    /// # Errors
54    ///
55    /// Returns [`ConnectError`] if the buffered records cannot be made durable.
56    async fn flush(&mut self) -> Result<(), ConnectError>;
57
58    /// Whether this sink writes atomically and so can take part in exactly-once
59    /// delivery. When `false` (the default) the runtime drives it at-least-once
60    /// and never calls [`begin`](Sink::begin) / [`abort`](Sink::abort).
61    fn supports_transactions(&self) -> bool {
62        false
63    }
64
65    /// Open a transaction enclosing the [`put`](Sink::put)s that follow, up to
66    /// the next [`commit`](Sink::commit) or [`abort`](Sink::abort). Called by
67    /// the runtime only when [`supports_transactions`](Sink::supports_transactions)
68    /// is `true` and only before a non-empty batch. Default: no-op.
69    ///
70    /// # Errors
71    ///
72    /// Returns [`ConnectError::Transaction`] if a transaction cannot be opened.
73    async fn begin(&mut self) -> Result<(), ConnectError> {
74        Ok(())
75    }
76
77    /// Atomically commit the records written since [`begin`](Sink::begin),
78    /// making them durable. The default (no transaction support) delegates to
79    /// [`flush`](Sink::flush).
80    ///
81    /// # Errors
82    ///
83    /// Returns [`ConnectError`] if the transaction cannot be committed.
84    async fn commit(&mut self) -> Result<(), ConnectError> {
85        self.flush().await
86    }
87
88    /// Discard the records written since [`begin`](Sink::begin) without making
89    /// them durable. Called by the runtime to roll back a failed interval.
90    /// Default: no-op.
91    ///
92    /// # Errors
93    ///
94    /// Returns [`ConnectError::Transaction`] if the transaction cannot be
95    /// aborted.
96    async fn abort(&mut self) -> Result<(), ConnectError> {
97        Ok(())
98    }
99
100    /// Release any resources held by the sink (connections, buffers). The
101    /// default is a no-op. After `close`, no further records are written.
102    ///
103    /// # Errors
104    ///
105    /// Returns [`ConnectError`] if cleanup fails.
106    async fn close(&mut self) -> Result<(), ConnectError> {
107        Ok(())
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use assert2::check;
114    use async_trait::async_trait;
115    use bytes::Bytes;
116
117    use super::*;
118
119    /// A sink that takes every default — at-least-once, no transactions.
120    #[derive(Default)]
121    struct DefaultSink {
122        durable: usize,
123        buffered: usize,
124    }
125
126    #[async_trait]
127    impl Sink<Bytes, Bytes> for DefaultSink {
128        async fn put(
129            &mut self,
130            records: Vec<ConnectRecord<Bytes, Bytes>>,
131        ) -> Result<(), ConnectError> {
132            self.buffered += records.len();
133            Ok(())
134        }
135
136        async fn flush(&mut self) -> Result<(), ConnectError> {
137            self.durable += self.buffered;
138            self.buffered = 0;
139            Ok(())
140        }
141    }
142
143    #[test]
144    fn default_sink_is_not_transactional() {
145        // A sink that doesn't opt in must report `false`, so the runtime drives
146        // it at-least-once and never calls begin/abort.
147        check!(!DefaultSink::default().supports_transactions());
148    }
149
150    #[tokio::test]
151    async fn default_commit_makes_buffered_records_durable() {
152        // The default `commit` delegates to `flush`.
153        let mut sink = DefaultSink::default();
154        sink.put(vec![ConnectRecord::new(
155            None,
156            Some(Bytes::from_static(b"x")),
157        )])
158        .await
159        .unwrap();
160        sink.commit().await.unwrap();
161        check!(sink.durable == 1);
162        check!(sink.buffered == 0);
163    }
164
165    #[tokio::test]
166    async fn default_begin_abort_and_close_are_noops() {
167        let mut sink = DefaultSink::default();
168        check!(sink.begin().await.is_ok());
169        check!(sink.abort().await.is_ok());
170        check!(sink.close().await.is_ok());
171    }
172}