Skip to main content

datum_sql/
time.rs

1use std::collections::BTreeMap;
2use std::fmt;
3use std::sync::Arc;
4use std::time::Duration;
5
6use arrow::array::{
7    Array, RecordBatch, TimestampMicrosecondArray, TimestampMillisecondArray,
8    TimestampNanosecondArray, TimestampSecondArray,
9};
10use arrow::datatypes::{DataType, Schema, TimeUnit};
11use datafusion::common::{DataFusionError, Result};
12use datum::{NotUsed, Source, StreamCompletion, StreamError, StreamResult, UniqueKillSwitch};
13
14use crate::{ChangelogBatch, stream_error};
15
16/// Event-time progress in nanoseconds since the Unix epoch.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
18pub struct Watermark {
19    timestamp_ns: i64,
20}
21
22impl Watermark {
23    /// Creates a watermark from nanoseconds since the Unix epoch.
24    #[must_use]
25    pub const fn new(timestamp_ns: i64) -> Self {
26        Self { timestamp_ns }
27    }
28
29    /// Returns the watermark timestamp in nanoseconds since the Unix epoch.
30    #[must_use]
31    pub const fn timestamp_ns(self) -> i64 {
32        self.timestamp_ns
33    }
34}
35
36/// Future checkpoint/alignment marker carried outside SQL row data.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub struct SqlBarrier {
39    epoch: u64,
40}
41
42impl SqlBarrier {
43    /// Creates a barrier marker for a checkpoint epoch.
44    #[must_use]
45    pub const fn new(epoch: u64) -> Self {
46        Self { epoch }
47    }
48
49    /// Returns the checkpoint epoch.
50    #[must_use]
51    pub const fn epoch(self) -> u64 {
52        self.epoch
53    }
54}
55
56/// In-band SQL stream envelope for data and control records.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub enum SqlEvent<T> {
59    /// User data payload.
60    Data(T),
61    /// Event-time progress marker.
62    Watermark(Watermark),
63    /// Checkpoint or alignment marker.
64    Barrier(SqlBarrier),
65}
66
67impl<T> SqlEvent<T> {
68    /// Maps only the data branch and passes watermarks and barriers through.
69    pub fn map_data_result<U, F>(self, f: F) -> StreamResult<SqlEvent<U>>
70    where
71        F: FnOnce(T) -> StreamResult<U>,
72    {
73        match self {
74            Self::Data(data) => f(data).map(SqlEvent::Data),
75            Self::Watermark(watermark) => Ok(SqlEvent::Watermark(watermark)),
76            Self::Barrier(barrier) => Ok(SqlEvent::Barrier(barrier)),
77        }
78    }
79
80    /// Returns the contained watermark when this event is a watermark.
81    #[must_use]
82    pub const fn as_watermark(&self) -> Option<Watermark> {
83        match self {
84            Self::Watermark(watermark) => Some(*watermark),
85            Self::Data(_) | Self::Barrier(_) => None,
86        }
87    }
88}
89
90/// Strategy for producing watermarks from an event-time column.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub enum WatermarkStrategy {
93    /// Emit `max_observed_event_time - max_out_of_orderness`, monotonically.
94    BoundedOutOfOrderness {
95        /// Maximum event-time delay tolerated before a row is considered late.
96        max_out_of_orderness: Duration,
97    },
98}
99
100/// Event-time declaration for a registered SQL table.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct EventTimeConfig {
103    column: Arc<str>,
104    strategy: WatermarkStrategy,
105}
106
107impl EventTimeConfig {
108    /// Declares event time with a bounded-out-of-orderness watermark strategy.
109    #[must_use]
110    pub fn bounded_out_of_orderness(
111        column: impl Into<Arc<str>>,
112        max_out_of_orderness: Duration,
113    ) -> Self {
114        Self {
115            column: column.into(),
116            strategy: WatermarkStrategy::BoundedOutOfOrderness {
117                max_out_of_orderness,
118            },
119        }
120    }
121
122    /// Returns the Arrow timestamp column used for event time.
123    #[must_use]
124    pub fn column(&self) -> &str {
125        &self.column
126    }
127
128    /// Returns the watermark strategy.
129    #[must_use]
130    pub const fn strategy(&self) -> &WatermarkStrategy {
131        &self.strategy
132    }
133
134    pub(crate) fn resolve(&self, schema: &Schema) -> Result<ResolvedEventTimeConfig> {
135        let column_index = schema.index_of(&self.column).map_err(|_| {
136            DataFusionError::Plan(format!(
137                "event-time column '{}' does not exist in table schema",
138                self.column
139            ))
140        })?;
141        let field = schema.field(column_index);
142        if !matches!(field.data_type(), DataType::Timestamp(_, _)) {
143            return Err(DataFusionError::Plan(format!(
144                "event-time column '{}' must be an Arrow Timestamp, found {:?}",
145                self.column,
146                field.data_type()
147            )));
148        }
149        if field.is_nullable() {
150            return Err(DataFusionError::Plan(format!(
151                "event-time column '{}' must be non-nullable",
152                self.column
153            )));
154        }
155
156        let max_out_of_orderness_ns = match &self.strategy {
157            WatermarkStrategy::BoundedOutOfOrderness {
158                max_out_of_orderness,
159            } => duration_to_ns(*max_out_of_orderness)?,
160        };
161
162        Ok(ResolvedEventTimeConfig {
163            column_index,
164            max_out_of_orderness_ns,
165        })
166    }
167}
168
169#[derive(Debug, Clone, PartialEq, Eq)]
170pub(crate) struct ResolvedEventTimeConfig {
171    column_index: usize,
172    max_out_of_orderness_ns: i64,
173}
174
175/// Payloads that expose the Arrow batch used to read event-time columns.
176pub trait SqlEventPayload {
177    /// Returns the Arrow batch containing the configured event-time column.
178    fn event_time_batch(&self) -> &RecordBatch;
179
180    /// Partition identity for one row within this source.
181    ///
182    /// The identity is local to a single source. Sources that do not expose
183    /// partition metadata return `None` and are treated as one logical
184    /// partition.
185    fn event_time_partition(&self, _row: usize) -> Option<i64> {
186        None
187    }
188
189    /// Partitions that can still produce rows for this source.
190    ///
191    /// Returning assigned-but-idle partitions lets the watermark generator hold
192    /// progress until every active partition has produced event-time progress.
193    fn event_time_active_partitions(&self) -> Option<Vec<i64>> {
194        None
195    }
196}
197
198impl SqlEventPayload for RecordBatch {
199    fn event_time_batch(&self) -> &RecordBatch {
200        self
201    }
202}
203
204impl SqlEventPayload for ChangelogBatch {
205    fn event_time_batch(&self) -> &RecordBatch {
206        self.batch()
207    }
208}
209
210#[must_use]
211/// Wraps every payload from a source as `SqlEvent::Data`.
212pub fn data_events<T, Mat>(source: Source<T, Mat>) -> Source<SqlEvent<T>, Mat>
213where
214    T: Send + 'static,
215    Mat: Send + 'static,
216{
217    source.map(SqlEvent::Data)
218}
219
220/// Assigns event-time watermarks to a source.
221///
222/// The returned source emits each data payload followed by a watermark whenever
223/// the configured bounded-out-of-orderness strategy advances.
224pub fn assign_event_time_watermarks<T, Mat>(
225    source: Source<T, Mat>,
226    schema: &Schema,
227    config: EventTimeConfig,
228) -> Result<Source<SqlEvent<T>, Mat>>
229where
230    T: SqlEventPayload + Send + 'static,
231    Mat: Send + 'static,
232{
233    let resolved = config.resolve(schema)?;
234    Ok(assign_resolved_event_time_watermarks(source, resolved))
235}
236
237pub(crate) fn assign_resolved_event_time_watermarks<T, Mat>(
238    source: Source<T, Mat>,
239    config: ResolvedEventTimeConfig,
240) -> Source<SqlEvent<T>, Mat>
241where
242    T: SqlEventPayload + Send + 'static,
243    Mat: Send + 'static,
244{
245    source.try_stateful_map_concat(WatermarkGenerator::new(config), |generator, payload| {
246        generator.apply(payload)
247    })
248}
249
250/// Applies a function to the data branch of a SQL event stream.
251pub fn map_sql_event_data<T, U, Mat, F>(
252    source: Source<SqlEvent<T>, Mat>,
253    f: F,
254) -> Source<SqlEvent<U>, Mat>
255where
256    T: Send + 'static,
257    U: Send + 'static,
258    Mat: Send + 'static,
259    F: Fn(T) -> StreamResult<U> + Send + Sync + 'static,
260{
261    source.try_map(move |event| event.map_data_result(&f))
262}
263
264#[derive(Clone)]
265struct WatermarkGenerator {
266    config: ResolvedEventTimeConfig,
267    max_seen_event_time_ns_by_partition: BTreeMap<i64, Option<i64>>,
268    last_emitted_watermark_ns: Option<i64>,
269}
270
271impl WatermarkGenerator {
272    fn new(config: ResolvedEventTimeConfig) -> Self {
273        Self {
274            config,
275            max_seen_event_time_ns_by_partition: BTreeMap::new(),
276            last_emitted_watermark_ns: None,
277        }
278    }
279
280    fn apply<T>(&mut self, payload: T) -> StreamResult<Vec<SqlEvent<T>>>
281    where
282        T: SqlEventPayload,
283    {
284        if let Some(active_partitions) = payload.event_time_active_partitions() {
285            for partition in active_partitions {
286                self.max_seen_event_time_ns_by_partition
287                    .entry(partition)
288                    .or_insert(None);
289            }
290        }
291        let batch_max_by_partition =
292            max_event_time_ns_by_partition(&payload, self.config.column_index)?;
293        let mut out = vec![SqlEvent::Data(payload)];
294        for (partition, batch_max) in batch_max_by_partition {
295            let entry = self
296                .max_seen_event_time_ns_by_partition
297                .entry(partition)
298                .or_insert(None);
299            *entry = Some(entry.map_or(batch_max, |previous| previous.max(batch_max)));
300        }
301
302        if let Some(min_seen) = self.min_seen_event_time_ns() {
303            let watermark_ns = min_seen
304                .checked_sub(self.config.max_out_of_orderness_ns)
305                .ok_or_else(|| stream_error("event-time watermark underflowed i64 nanoseconds"))?;
306            if self
307                .last_emitted_watermark_ns
308                .is_none_or(|previous| watermark_ns > previous)
309            {
310                self.last_emitted_watermark_ns = Some(watermark_ns);
311                out.push(SqlEvent::Watermark(Watermark::new(watermark_ns)));
312            }
313        }
314
315        Ok(out)
316    }
317
318    fn min_seen_event_time_ns(&self) -> Option<i64> {
319        let mut min_seen = None;
320        for value in self.max_seen_event_time_ns_by_partition.values() {
321            let value = (*value)?;
322            min_seen = Some(min_seen.map_or(value, |current: i64| current.min(value)));
323        }
324        min_seen
325    }
326}
327
328const DEFAULT_EVENT_TIME_PARTITION: i64 = 0;
329
330fn max_event_time_ns_by_partition<T>(
331    payload: &T,
332    column_index: usize,
333) -> StreamResult<BTreeMap<i64, i64>>
334where
335    T: SqlEventPayload,
336{
337    let batch = payload.event_time_batch();
338    if batch.num_rows() == 0 {
339        return Ok(BTreeMap::new());
340    }
341    if column_index >= batch.num_columns() {
342        return Err(stream_error(format!(
343            "event-time column index {column_index} is out of range for {} columns",
344            batch.num_columns()
345        )));
346    }
347
348    let column = batch.column(column_index);
349    match batch.schema().field(column_index).data_type() {
350        DataType::Timestamp(TimeUnit::Second, _) => {
351            timestamp_max_ns_by_partition::<TimestampSecondArray, T>(
352                column.as_any().downcast_ref::<TimestampSecondArray>(),
353                TimeUnit::Second,
354                payload,
355            )
356        }
357        DataType::Timestamp(TimeUnit::Millisecond, _) => {
358            timestamp_max_ns_by_partition::<TimestampMillisecondArray, T>(
359                column.as_any().downcast_ref::<TimestampMillisecondArray>(),
360                TimeUnit::Millisecond,
361                payload,
362            )
363        }
364        DataType::Timestamp(TimeUnit::Microsecond, _) => {
365            timestamp_max_ns_by_partition::<TimestampMicrosecondArray, T>(
366                column.as_any().downcast_ref::<TimestampMicrosecondArray>(),
367                TimeUnit::Microsecond,
368                payload,
369            )
370        }
371        DataType::Timestamp(TimeUnit::Nanosecond, _) => {
372            timestamp_max_ns_by_partition::<TimestampNanosecondArray, T>(
373                column.as_any().downcast_ref::<TimestampNanosecondArray>(),
374                TimeUnit::Nanosecond,
375                payload,
376            )
377        }
378        other => Err(stream_error(format!(
379            "event-time column must be an Arrow Timestamp, found {other:?}"
380        ))),
381    }
382}
383
384fn timestamp_max_ns_by_partition<A, T>(
385    array: Option<&A>,
386    unit: TimeUnit,
387    payload: &T,
388) -> StreamResult<BTreeMap<i64, i64>>
389where
390    A: Array + TimestampValues,
391    T: SqlEventPayload,
392{
393    let array = array.ok_or_else(|| stream_error("event-time column array type mismatch"))?;
394    let multiplier = timestamp_unit_multiplier(unit);
395    let mut max_by_partition = BTreeMap::new();
396    for row in 0..array.len() {
397        if array.is_null(row) {
398            return Err(stream_error(format!(
399                "event-time column contains null at row {row}"
400            )));
401        }
402        let value = array.value_at(row);
403        let ns = value
404            .checked_mul(multiplier)
405            .ok_or_else(|| stream_error("event-time value overflowed i64 nanoseconds"))?;
406        let partition = payload
407            .event_time_partition(row)
408            .unwrap_or(DEFAULT_EVENT_TIME_PARTITION);
409        max_by_partition
410            .entry(partition)
411            .and_modify(|previous: &mut i64| *previous = (*previous).max(ns))
412            .or_insert(ns);
413    }
414    Ok(max_by_partition)
415}
416
417trait TimestampValues {
418    fn value_at(&self, row: usize) -> i64;
419}
420
421impl TimestampValues for TimestampSecondArray {
422    fn value_at(&self, row: usize) -> i64 {
423        self.value(row)
424    }
425}
426
427impl TimestampValues for TimestampMillisecondArray {
428    fn value_at(&self, row: usize) -> i64 {
429        self.value(row)
430    }
431}
432
433impl TimestampValues for TimestampMicrosecondArray {
434    fn value_at(&self, row: usize) -> i64 {
435        self.value(row)
436    }
437}
438
439impl TimestampValues for TimestampNanosecondArray {
440    fn value_at(&self, row: usize) -> i64 {
441        self.value(row)
442    }
443}
444
445const fn timestamp_unit_multiplier(unit: TimeUnit) -> i64 {
446    match unit {
447        TimeUnit::Second => 1_000_000_000,
448        TimeUnit::Millisecond => 1_000_000,
449        TimeUnit::Microsecond => 1_000,
450        TimeUnit::Nanosecond => 1,
451    }
452}
453
454fn duration_to_ns(duration: Duration) -> Result<i64> {
455    i64::try_from(duration.as_nanos()).map_err(|_| {
456        DataFusionError::Plan(format!(
457            "event-time watermark delay {duration:?} exceeds i64 nanoseconds"
458        ))
459    })
460}
461
462/// Materialized handle for a streaming SQL query.
463pub struct ContinuousQueryHandle {
464    kill_switch: UniqueKillSwitch,
465    completion: StreamCompletion<NotUsed>,
466}
467
468impl ContinuousQueryHandle {
469    pub(crate) fn new(
470        kill_switch: UniqueKillSwitch,
471        completion: StreamCompletion<NotUsed>,
472    ) -> Self {
473        Self {
474            kill_switch,
475            completion,
476        }
477    }
478
479    /// Requests graceful shutdown of the continuous query.
480    pub fn cancel(&self) {
481        self.kill_switch.shutdown();
482    }
483
484    /// Aborts the continuous query with a stream error.
485    pub fn abort(&self, error: StreamError) {
486        self.kill_switch.abort(error);
487    }
488
489    /// Waits for query completion.
490    pub fn wait(self) -> StreamResult<NotUsed> {
491        self.completion.wait()
492    }
493
494    /// Polls completion without blocking.
495    #[must_use]
496    pub fn try_wait(&mut self) -> Option<StreamResult<NotUsed>> {
497        self.completion.try_wait()
498    }
499}
500
501impl fmt::Debug for ContinuousQueryHandle {
502    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
503        f.debug_struct("ContinuousQueryHandle")
504            .finish_non_exhaustive()
505    }
506}