Skip to main content

datum_sql/
sink.rs

1use std::fmt;
2use std::sync::Arc;
3
4use arrow::record_batch::RecordBatch;
5use datafusion::common::{DataFusionError, Result};
6use datum::{Source, StreamError, StreamResult};
7
8use crate::connect::BatchEnvelope;
9use crate::{ChangelogBatch, SqlEventPayload, stream_error};
10
11/// Type-erased source position carried by SQL sink pipelines.
12///
13/// Connector adapters keep their precise position types at the edge and convert
14/// them into this enum when a query needs commit-after-sink-confirmation.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum SqlSourcePosition {
17    #[cfg(feature = "mq")]
18    /// Kafka topic/partition offset ranges.
19    Kafka(crate::connect::mq::KafkaSourcePosition),
20    #[cfg(feature = "cdc")]
21    /// PostgreSQL CDC LSN position.
22    Cdc(crate::connect::cdc::CdcSourcePosition),
23    /// Opaque source position for user-defined connector adapters.
24    Custom {
25        /// Connector family or instance name.
26        connector: Arc<str>,
27        /// Connector-specific position text.
28        position: Arc<str>,
29    },
30}
31
32impl SqlSourcePosition {
33    /// Creates an opaque connector-specific source position.
34    #[must_use]
35    pub fn custom(connector: impl Into<Arc<str>>, position: impl Into<Arc<str>>) -> Self {
36        Self::Custom {
37            connector: connector.into(),
38            position: position.into(),
39        }
40    }
41}
42
43type CommitCallback = dyn Fn() -> StreamResult<()> + Send + Sync;
44
45/// A committable source position.
46///
47/// SQL sink lowering calls this only after the sink has confirmed the batch.
48#[derive(Clone)]
49pub struct SourceCommit {
50    description: Arc<str>,
51    commit: Arc<CommitCallback>,
52}
53
54impl SourceCommit {
55    /// Creates a commit handle from a callback.
56    #[must_use]
57    pub fn from_fn<F>(description: impl Into<Arc<str>>, commit: F) -> Self
58    where
59        F: Fn() -> StreamResult<()> + Send + Sync + 'static,
60    {
61        Self {
62            description: description.into(),
63            commit: Arc::new(commit),
64        }
65    }
66
67    /// Creates a commit handle that does nothing.
68    #[must_use]
69    pub fn noop() -> Self {
70        Self::from_fn("no source position", || Ok(()))
71    }
72
73    /// Confirms the source position represented by this handle.
74    pub fn commit(&self) -> StreamResult<()> {
75        (self.commit)()
76    }
77
78    /// Human-readable description used in debug output.
79    #[must_use]
80    pub fn description(&self) -> &str {
81        &self.description
82    }
83}
84
85impl fmt::Debug for SourceCommit {
86    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87        f.debug_struct("SourceCommit")
88            .field("description", &self.description)
89            .finish_non_exhaustive()
90    }
91}
92
93/// Append-only SQL batch plus source metadata and a deferred commit handle.
94#[derive(Clone)]
95pub struct CommittableRecordBatch {
96    batch: RecordBatch,
97    envelope: BatchEnvelope<Option<SqlSourcePosition>>,
98    commit: SourceCommit,
99}
100
101impl CommittableRecordBatch {
102    /// Creates an append-only SQL batch with source metadata and a commit hook.
103    #[must_use]
104    pub fn new(
105        batch: RecordBatch,
106        source_position: Option<SqlSourcePosition>,
107        schema_revision: u64,
108        commit: SourceCommit,
109    ) -> Self {
110        Self {
111            batch,
112            envelope: BatchEnvelope::new(source_position, schema_revision),
113            commit,
114        }
115    }
116
117    /// Wraps an ordinary Arrow batch with no source position.
118    #[must_use]
119    pub fn plain(batch: RecordBatch) -> Self {
120        Self::new(batch, None, 0, SourceCommit::noop())
121    }
122
123    /// Returns the Arrow payload batch.
124    #[must_use]
125    pub fn batch(&self) -> &RecordBatch {
126        &self.batch
127    }
128
129    /// Returns the connector-side envelope.
130    #[must_use]
131    pub fn envelope(&self) -> &BatchEnvelope<Option<SqlSourcePosition>> {
132        &self.envelope
133    }
134
135    /// Returns the optional source position.
136    #[must_use]
137    pub fn source_position(&self) -> Option<&SqlSourcePosition> {
138        self.envelope.source_position().as_ref()
139    }
140
141    /// Returns the schema revision attached by the source adapter.
142    #[must_use]
143    pub fn schema_revision(&self) -> u64 {
144        self.envelope.schema_revision()
145    }
146
147    /// Returns the deferred source commit handle.
148    #[must_use]
149    pub fn commit_handle(&self) -> &SourceCommit {
150        &self.commit
151    }
152
153    /// Commits the source position after a sink write has succeeded.
154    pub fn commit(&self) -> StreamResult<()> {
155        self.commit.commit()
156    }
157
158    pub(crate) fn try_map_batch<F>(self, f: F) -> Result<Self>
159    where
160        F: FnOnce(&RecordBatch) -> Result<RecordBatch>,
161    {
162        let batch = f(&self.batch)?;
163        Ok(Self {
164            batch,
165            envelope: self.envelope,
166            commit: self.commit,
167        })
168    }
169}
170
171impl fmt::Debug for CommittableRecordBatch {
172    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
173        f.debug_struct("CommittableRecordBatch")
174            .field("batch", &self.batch)
175            .field("envelope", &self.envelope)
176            .field("commit", &self.commit)
177            .finish()
178    }
179}
180
181impl SqlEventPayload for CommittableRecordBatch {
182    fn event_time_batch(&self) -> &RecordBatch {
183        &self.batch
184    }
185
186    fn event_time_partition(&self, row: usize) -> Option<i64> {
187        match self.source_position()? {
188            #[cfg(feature = "mq")]
189            SqlSourcePosition::Kafka(position) => position.partition_for_row(row).map(i64::from),
190            #[cfg(feature = "cdc")]
191            SqlSourcePosition::Cdc(_) | SqlSourcePosition::Custom { .. } => None,
192            #[cfg(not(feature = "cdc"))]
193            SqlSourcePosition::Custom { .. } => None,
194        }
195    }
196
197    fn event_time_active_partitions(&self) -> Option<Vec<i64>> {
198        match self.source_position()? {
199            #[cfg(feature = "mq")]
200            SqlSourcePosition::Kafka(position) => {
201                if !position.has_event_time_partition_mapping() {
202                    return None;
203                }
204                let partitions = position
205                    .active_partitions()
206                    .iter()
207                    .map(|partition| i64::from(partition.partition))
208                    .collect::<Vec<_>>();
209                (!partitions.is_empty()).then_some(partitions)
210            }
211            #[cfg(feature = "cdc")]
212            SqlSourcePosition::Cdc(_) | SqlSourcePosition::Custom { .. } => None,
213            #[cfg(not(feature = "cdc"))]
214            SqlSourcePosition::Custom { .. } => None,
215        }
216    }
217}
218
219type AppendWriter = dyn Fn(RecordBatch) -> StreamResult<()> + Send + Sync;
220type ChangelogWriter = dyn Fn(ChangelogBatch) -> StreamResult<()> + Send + Sync;
221
222#[derive(Clone)]
223pub(crate) struct RegisteredSqlSink {
224    append_writer: Arc<AppendWriter>,
225    changelog_writer: Option<Arc<ChangelogWriter>>,
226}
227
228impl RegisteredSqlSink {
229    pub(crate) fn append_only<F>(writer: F) -> Self
230    where
231        F: Fn(RecordBatch) -> StreamResult<()> + Send + Sync + 'static,
232    {
233        Self {
234            append_writer: Arc::new(writer),
235            changelog_writer: None,
236        }
237    }
238
239    pub(crate) fn changelog<F, G>(append_writer: F, changelog_writer: G) -> Self
240    where
241        F: Fn(RecordBatch) -> StreamResult<()> + Send + Sync + 'static,
242        G: Fn(ChangelogBatch) -> StreamResult<()> + Send + Sync + 'static,
243    {
244        Self {
245            append_writer: Arc::new(append_writer),
246            changelog_writer: Some(Arc::new(changelog_writer)),
247        }
248    }
249
250    #[cfg(feature = "mq")]
251    pub(crate) fn kafka_json(
252        settings: datum_mq::KafkaProducerSettings,
253        topic: String,
254        format: crate::connect::mq::JsonRowFormat,
255    ) -> Self {
256        Self::append_only(move |batch| {
257            let payloads = format.encode_record_batch(&batch).map_err(stream_error)?;
258            if payloads.is_empty() {
259                return Ok(());
260            }
261
262            let records = payloads.into_iter().map(|payload| {
263                datum_mq::ProducerRecord::new(topic.clone(), bytes::Bytes::from(payload))
264            });
265            let control = Source::from_iter(records)
266                .run_with(datum_mq::KafkaSink::plain(settings.clone()))
267                .map_err(stream_error)?;
268            control.drain_and_shutdown().map_err(stream_error)
269        })
270    }
271
272    #[must_use]
273    pub(crate) fn accepts_changelog(&self) -> bool {
274        self.changelog_writer.is_some()
275    }
276
277    pub(crate) fn write_append(&self, batch: RecordBatch) -> StreamResult<()> {
278        (self.append_writer)(batch)
279    }
280
281    pub(crate) fn write_changelog(&self, changes: ChangelogBatch) -> StreamResult<()> {
282        let Some(writer) = &self.changelog_writer else {
283            return Err(StreamError::Failed(
284                "datum-sql sink is not changelog-aware".to_owned(),
285            ));
286        };
287        writer(changes)
288    }
289}
290
291impl fmt::Debug for RegisteredSqlSink {
292    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
293        f.debug_struct("RegisteredSqlSink")
294            .field("accepts_changelog", &self.accepts_changelog())
295            .finish_non_exhaustive()
296    }
297}
298
299pub(crate) enum AppendSinkSource {
300    Plain(Source<RecordBatch>),
301    Committable(Source<CommittableRecordBatch>),
302}
303
304impl AppendSinkSource {
305    pub(crate) fn into_committable(self) -> Source<CommittableRecordBatch> {
306        match self {
307            Self::Plain(source) => source.map(CommittableRecordBatch::plain),
308            Self::Committable(source) => source,
309        }
310    }
311}
312
313pub(crate) fn sink_registry_error(error: impl fmt::Display) -> DataFusionError {
314    DataFusionError::External(Box::new(SinkRegistryError(error.to_string())))
315}
316
317#[derive(Debug)]
318struct SinkRegistryError(String);
319
320impl fmt::Display for SinkRegistryError {
321    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
322        f.write_str(&self.0)
323    }
324}
325
326impl std::error::Error for SinkRegistryError {}