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}