Skip to main content

oxigeo_streaming/v2/
stream_join.rs

1//! Stream-to-stream temporal joins for geospatial data.
2//!
3//! Supports:
4//! - **Inner join**: emit a pair when both streams have a matching key **and**
5//!   their timestamps are within `time_tolerance` of each other.
6//! - **Left outer join** (mode variant): same matching rule as inner, but the
7//!   joiner additionally tracks left-stream events that were never matched by
8//!   any right-stream event. A left event is surfaced as *unmatched* once it
9//!   leaves the bounded left buffer without ever having produced a join pair —
10//!   either because it was evicted when the buffer overflowed, or because
11//!   [`TemporalJoiner::flush`] was called at end-of-stream. Unmatched left
12//!   events are retrieved with [`TemporalJoiner::drain_unmatched_left`].
13//! - **Interval join**: join when `right.time + lower ≤ left.time ≤ right.time + upper`.
14//!
15//! Buffers are bounded: when `max_buffer_size` is reached, the oldest entry is
16//! evicted before inserting the new one.
17
18use std::collections::VecDeque;
19use std::time::{Duration, SystemTime};
20
21use crate::error::StreamingError;
22
23// ─── JoinEvent ────────────────────────────────────────────────────────────────
24
25/// A timestamped event suitable for joining.
26#[derive(Debug, Clone)]
27pub struct JoinEvent<T> {
28    /// Wall-clock timestamp of the event.
29    pub timestamp: SystemTime,
30    /// Join key (matched between left and right streams).
31    pub key: String,
32    /// Application payload.
33    pub payload: T,
34}
35
36impl<T> JoinEvent<T> {
37    /// Construct a new `JoinEvent`.
38    pub fn new(timestamp: SystemTime, key: impl Into<String>, payload: T) -> Self {
39        Self {
40            timestamp,
41            key: key.into(),
42            payload,
43        }
44    }
45}
46
47// ─── JoinedPair ───────────────────────────────────────────────────────────────
48
49/// A successfully joined pair from the left and right streams.
50#[derive(Debug, Clone)]
51pub struct JoinedPair<L, R> {
52    /// Left-stream event.
53    pub left: JoinEvent<L>,
54    /// Right-stream event.
55    pub right: JoinEvent<R>,
56    /// Absolute time difference between the two events.
57    pub time_delta: Duration,
58}
59
60// ─── JoinMode ────────────────────────────────────────────────────────────────
61
62/// Which join semantics to apply.
63#[derive(Debug, Clone, PartialEq)]
64pub enum JoinMode {
65    /// Emit only when both sides have a matching key + timestamp within `time_tolerance`.
66    Inner,
67    /// Like inner, but also tracks left events that left the bounded buffer
68    /// without ever matching a right event (see
69    /// [`TemporalJoiner::drain_unmatched_left`]).
70    LeftOuter,
71    /// Interval join: `left.time ∈ [right.time + lower, right.time + upper]`
72    Interval {
73        /// Lower bound offset added to the right event time.
74        lower: Duration,
75        /// Upper bound offset added to the right event time.
76        upper: Duration,
77    },
78}
79
80// ─── TemporalJoinConfig ───────────────────────────────────────────────────────
81
82/// Configuration for the temporal stream joiner.
83#[derive(Debug, Clone)]
84pub struct TemporalJoinConfig {
85    /// Maximum time difference between left and right events for a match
86    /// (used by `Inner` and `LeftOuter` modes).
87    pub time_tolerance: Duration,
88    /// Maximum number of events to buffer per stream before eviction.
89    pub max_buffer_size: usize,
90    /// Join semantics.
91    pub mode: JoinMode,
92}
93
94impl Default for TemporalJoinConfig {
95    fn default() -> Self {
96        Self {
97            time_tolerance: Duration::from_secs(5),
98            max_buffer_size: 10_000,
99            mode: JoinMode::Inner,
100        }
101    }
102}
103
104// ─── TemporalJoiner ───────────────────────────────────────────────────────────
105
106/// Bidirectional temporal stream joiner.
107///
108/// Feed events from the left stream via [`Self::add_left`] and from the right stream
109/// via [`Self::add_right`]. After each call, retrieve any newly produced join pairs
110/// with [`Self::drain_output`].
111pub struct TemporalJoiner<L: Clone, R: Clone> {
112    config: TemporalJoinConfig,
113    left_buffer: VecDeque<LeftEntry<L>>,
114    right_buffer: VecDeque<JoinEvent<R>>,
115    output: VecDeque<JoinedPair<L, R>>,
116    /// Left events (LeftOuter mode only) that left the buffer without a match.
117    unmatched_left: VecDeque<JoinEvent<L>>,
118    total_joined: u64,
119    total_expired_left: u64,
120    total_expired_right: u64,
121    total_unmatched_left: u64,
122}
123
124/// A buffered left-stream event together with whether it has ever been joined.
125struct LeftEntry<L> {
126    event: JoinEvent<L>,
127    matched: bool,
128}
129
130impl<L: Clone, R: Clone> TemporalJoiner<L, R> {
131    /// Create a new joiner with the given configuration.
132    pub fn new(config: TemporalJoinConfig) -> Self {
133        Self {
134            config,
135            left_buffer: VecDeque::new(),
136            right_buffer: VecDeque::new(),
137            output: VecDeque::new(),
138            unmatched_left: VecDeque::new(),
139            total_joined: 0,
140            total_expired_left: 0,
141            total_expired_right: 0,
142            total_unmatched_left: 0,
143        }
144    }
145
146    /// Add an event from the left stream.
147    ///
148    /// If the buffer is full, the oldest entry is evicted first. In
149    /// [`JoinMode::LeftOuter`] mode, an evicted entry that never matched is
150    /// recorded as an unmatched-left event (retrievable via
151    /// [`Self::drain_unmatched_left`]). The new event is then matched against all
152    /// buffered right-stream events before being added to the left buffer.
153    pub fn add_left(&mut self, event: JoinEvent<L>) -> Result<(), StreamingError> {
154        if self.left_buffer.len() >= self.config.max_buffer_size
155            && let Some(evicted) = self.left_buffer.pop_front()
156        {
157            self.total_expired_left += 1;
158            self.record_if_unmatched(evicted);
159        }
160        let matched = self.try_join_with_left(&event);
161        self.left_buffer.push_back(LeftEntry { event, matched });
162        Ok(())
163    }
164
165    /// Record an evicted/flushed left entry as unmatched when in LeftOuter mode
166    /// and it never produced a join pair.
167    fn record_if_unmatched(&mut self, entry: LeftEntry<L>) {
168        if matches!(self.config.mode, JoinMode::LeftOuter) && !entry.matched {
169            self.total_unmatched_left += 1;
170            self.unmatched_left.push_back(entry.event);
171        }
172    }
173
174    /// Add an event from the right stream.
175    ///
176    /// If the buffer is full, the oldest entry is evicted first.
177    /// Then the event is matched against all buffered left-stream events
178    /// before being added to the right buffer.
179    pub fn add_right(&mut self, event: JoinEvent<R>) -> Result<(), StreamingError> {
180        if self.right_buffer.len() >= self.config.max_buffer_size {
181            self.right_buffer.pop_front();
182            self.total_expired_right += 1;
183        }
184        self.try_join_with_right(&event);
185        self.right_buffer.push_back(event);
186        Ok(())
187    }
188
189    /// Drain all join pairs produced since the last call to `drain_output`.
190    pub fn drain_output(&mut self) -> Vec<JoinedPair<L, R>> {
191        self.output.drain(..).collect()
192    }
193
194    /// Close the joiner at end-of-stream.
195    ///
196    /// In [`JoinMode::LeftOuter`] mode, every left event still buffered that has
197    /// not produced a join pair is recorded as an unmatched-left event. Both
198    /// buffers are then cleared. Call [`Self::drain_unmatched_left`] afterwards
199    /// to collect the surfaced events.
200    pub fn flush(&mut self) {
201        while let Some(entry) = self.left_buffer.pop_front() {
202            self.record_if_unmatched(entry);
203        }
204        self.right_buffer.clear();
205    }
206
207    /// Drain the left-stream events that were surfaced as unmatched.
208    ///
209    /// Only ever non-empty in [`JoinMode::LeftOuter`] mode. Events are surfaced
210    /// when they leave the bounded left buffer (via overflow eviction or
211    /// [`Self::flush`]) without ever having matched a right-stream event.
212    pub fn drain_unmatched_left(&mut self) -> Vec<JoinEvent<L>> {
213        self.unmatched_left.drain(..).collect()
214    }
215
216    /// Total number of left events surfaced as unmatched since creation.
217    pub fn total_unmatched_left(&self) -> u64 {
218        self.total_unmatched_left
219    }
220
221    /// Total number of join pairs produced since creation.
222    pub fn total_joined(&self) -> u64 {
223        self.total_joined
224    }
225
226    /// Number of events currently in the left buffer.
227    pub fn left_buffer_size(&self) -> usize {
228        self.left_buffer.len()
229    }
230
231    /// Number of events currently in the right buffer.
232    pub fn right_buffer_size(&self) -> usize {
233        self.right_buffer.len()
234    }
235
236    /// Total left events evicted due to buffer overflow.
237    pub fn total_expired_left(&self) -> u64 {
238        self.total_expired_left
239    }
240
241    /// Total right events evicted due to buffer overflow.
242    pub fn total_expired_right(&self) -> u64 {
243        self.total_expired_right
244    }
245
246    // ── internals ────────────────────────────────────────────────────────────
247
248    /// Absolute difference between two `SystemTime` values.
249    fn time_delta(a: SystemTime, b: SystemTime) -> Duration {
250        a.duration_since(b)
251            .unwrap_or_else(|_| b.duration_since(a).unwrap_or(Duration::ZERO))
252    }
253
254    /// Return `Some(delta)` if `left_time` and `right_time` satisfy the join
255    /// condition, `None` otherwise.
256    fn matches(&self, left_time: SystemTime, right_time: SystemTime) -> Option<Duration> {
257        let delta = Self::time_delta(left_time, right_time);
258        match &self.config.mode {
259            JoinMode::Inner | JoinMode::LeftOuter => {
260                if delta <= self.config.time_tolerance {
261                    Some(delta)
262                } else {
263                    None
264                }
265            }
266            JoinMode::Interval { lower, upper } => {
267                // Condition: right.time + lower ≤ left.time ≤ right.time + upper
268                let lower_bound = right_time + *lower;
269                let upper_bound = right_time + *upper;
270                if left_time >= lower_bound && left_time <= upper_bound {
271                    Some(delta)
272                } else {
273                    None
274                }
275            }
276        }
277    }
278
279    /// Try to join the newly arrived `left` event against all buffered right
280    /// events. Returns `true` if at least one join pair was produced.
281    fn try_join_with_left(&mut self, left: &JoinEvent<L>) -> bool {
282        let mut matched = false;
283        for right in &self.right_buffer {
284            if right.key != left.key {
285                continue;
286            }
287            if let Some(delta) = self.matches(left.timestamp, right.timestamp) {
288                self.output.push_back(JoinedPair {
289                    left: left.clone(),
290                    right: right.clone(),
291                    time_delta: delta,
292                });
293                self.total_joined += 1;
294                matched = true;
295            }
296        }
297        matched
298    }
299
300    /// Try to join the newly arrived `right` event against all buffered left
301    /// events, marking any matched left entry so it is not later surfaced as
302    /// an unmatched-left event.
303    fn try_join_with_right(&mut self, right: &JoinEvent<R>) {
304        for idx in 0..self.left_buffer.len() {
305            let (left_key, left_ts) = {
306                let entry = &self.left_buffer[idx];
307                (entry.event.key.clone(), entry.event.timestamp)
308            };
309            if left_key != right.key {
310                continue;
311            }
312            if let Some(delta) = self.matches(left_ts, right.timestamp) {
313                let left_event = self.left_buffer[idx].event.clone();
314                self.output.push_back(JoinedPair {
315                    left: left_event,
316                    right: right.clone(),
317                    time_delta: delta,
318                });
319                self.total_joined += 1;
320                self.left_buffer[idx].matched = true;
321            }
322        }
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329    use std::time::UNIX_EPOCH;
330
331    fn ts(secs: u64) -> SystemTime {
332        UNIX_EPOCH + Duration::from_secs(secs)
333    }
334
335    fn left_event(secs: u64, key: &str) -> JoinEvent<&'static str> {
336        JoinEvent::new(ts(secs), key.to_string(), "left_payload")
337    }
338
339    fn right_event(secs: u64, key: &str) -> JoinEvent<&'static str> {
340        JoinEvent::new(ts(secs), key.to_string(), "right_payload")
341    }
342
343    #[test]
344    fn test_inner_join_matching_key_and_time() {
345        let mut joiner = TemporalJoiner::new(TemporalJoinConfig::default());
346        joiner.add_left(left_event(100, "k1")).expect("add ok");
347        joiner.add_right(right_event(102, "k1")).expect("add ok"); // 2s < 5s tolerance
348        let pairs = joiner.drain_output();
349        assert_eq!(pairs.len(), 1);
350        assert_eq!(pairs[0].left.key, "k1");
351    }
352
353    #[test]
354    fn test_inner_join_miss_outside_tolerance() {
355        let mut joiner = TemporalJoiner::new(TemporalJoinConfig::default()); // 5s tolerance
356        joiner.add_left(left_event(100, "k1")).expect("add ok");
357        joiner.add_right(right_event(110, "k1")).expect("add ok"); // 10s > 5s
358        let pairs = joiner.drain_output();
359        assert!(pairs.is_empty());
360    }
361
362    #[test]
363    fn test_no_join_on_key_mismatch() {
364        let mut joiner = TemporalJoiner::new(TemporalJoinConfig::default());
365        joiner.add_left(left_event(100, "k1")).expect("add ok");
366        joiner.add_right(right_event(100, "k2")).expect("add ok");
367        let pairs = joiner.drain_output();
368        assert!(pairs.is_empty());
369    }
370
371    #[test]
372    fn test_left_outer_mode_config() {
373        let cfg = TemporalJoinConfig {
374            mode: JoinMode::LeftOuter,
375            ..Default::default()
376        };
377        let mut joiner = TemporalJoiner::<&str, &str>::new(cfg);
378        joiner.add_left(left_event(100, "k1")).expect("add ok");
379        joiner.add_right(right_event(103, "k1")).expect("add ok"); // within 5 s
380        let pairs = joiner.drain_output();
381        // Left outer: matching pair is still produced
382        assert_eq!(pairs.len(), 1);
383    }
384
385    #[test]
386    fn test_left_outer_surfaces_unmatched_left_on_flush() {
387        let cfg = TemporalJoinConfig {
388            mode: JoinMode::LeftOuter,
389            ..Default::default()
390        };
391        let mut joiner = TemporalJoiner::<&str, &str>::new(cfg);
392        // Left event that never gets a right-side match (right has a different key).
393        joiner.add_left(left_event(100, "k1")).expect("add ok");
394        joiner.add_right(right_event(101, "k2")).expect("add ok");
395        // No join pair produced.
396        assert!(joiner.drain_output().is_empty());
397        // At end-of-stream, the unmatched left event is surfaced.
398        joiner.flush();
399        let unmatched = joiner.drain_unmatched_left();
400        assert_eq!(unmatched.len(), 1);
401        assert_eq!(unmatched[0].key, "k1");
402        assert_eq!(joiner.total_unmatched_left(), 1);
403    }
404
405    #[test]
406    fn test_left_outer_matched_left_not_surfaced() {
407        let cfg = TemporalJoinConfig {
408            mode: JoinMode::LeftOuter,
409            ..Default::default()
410        };
411        let mut joiner = TemporalJoiner::<&str, &str>::new(cfg);
412        joiner.add_left(left_event(100, "k1")).expect("add ok");
413        joiner.add_right(right_event(102, "k1")).expect("add ok"); // matches
414        assert_eq!(joiner.drain_output().len(), 1);
415        joiner.flush();
416        // A matched left event must NOT appear as unmatched.
417        assert!(joiner.drain_unmatched_left().is_empty());
418        assert_eq!(joiner.total_unmatched_left(), 0);
419    }
420
421    #[test]
422    fn test_left_outer_match_arrives_after_left_buffered() {
423        let cfg = TemporalJoinConfig {
424            mode: JoinMode::LeftOuter,
425            ..Default::default()
426        };
427        let mut joiner = TemporalJoiner::<&str, &str>::new(cfg);
428        // Left first, right arrives later and matches the buffered left entry.
429        joiner.add_left(left_event(100, "k1")).expect("add ok");
430        joiner.add_right(right_event(103, "k1")).expect("add ok");
431        assert_eq!(joiner.drain_output().len(), 1);
432        joiner.flush();
433        assert!(joiner.drain_unmatched_left().is_empty());
434    }
435
436    #[test]
437    fn test_left_outer_eviction_surfaces_unmatched() {
438        let cfg = TemporalJoinConfig {
439            mode: JoinMode::LeftOuter,
440            max_buffer_size: 2,
441            ..Default::default()
442        };
443        let mut joiner = TemporalJoiner::<&str, &str>::new(cfg);
444        // Three unmatched left events with a buffer of 2 → first is evicted.
445        joiner.add_left(left_event(0, "a")).expect("ok");
446        joiner.add_left(left_event(1, "b")).expect("ok");
447        joiner.add_left(left_event(2, "c")).expect("ok"); // evicts "a"
448        let unmatched = joiner.drain_unmatched_left();
449        assert_eq!(unmatched.len(), 1);
450        assert_eq!(unmatched[0].key, "a");
451    }
452
453    #[test]
454    fn test_inner_mode_does_not_track_unmatched() {
455        // Inner mode never surfaces unmatched-left events, even after flush.
456        let mut joiner = TemporalJoiner::<&str, &str>::new(TemporalJoinConfig::default());
457        joiner.add_left(left_event(100, "k1")).expect("ok");
458        joiner.add_right(right_event(101, "k2")).expect("ok");
459        joiner.flush();
460        assert!(joiner.drain_unmatched_left().is_empty());
461        assert_eq!(joiner.total_unmatched_left(), 0);
462    }
463
464    #[test]
465    fn test_interval_join_matches_within_interval() {
466        let cfg = TemporalJoinConfig {
467            mode: JoinMode::Interval {
468                lower: Duration::from_secs(2),
469                upper: Duration::from_secs(8),
470            },
471            max_buffer_size: 100,
472            time_tolerance: Duration::from_secs(1), // unused for interval mode
473        };
474        let mut joiner = TemporalJoiner::new(cfg);
475        // right.time=100, left must be in [102, 108]
476        joiner.add_right(right_event(100, "k1")).expect("add ok");
477        joiner.add_left(left_event(105, "k1")).expect("add ok"); // 105 ∈ [102,108] ✓
478        let pairs = joiner.drain_output();
479        assert_eq!(pairs.len(), 1);
480    }
481
482    #[test]
483    fn test_interval_join_no_match_outside_interval() {
484        let cfg = TemporalJoinConfig {
485            mode: JoinMode::Interval {
486                lower: Duration::from_secs(2),
487                upper: Duration::from_secs(8),
488            },
489            max_buffer_size: 100,
490            time_tolerance: Duration::from_secs(1),
491        };
492        let mut joiner = TemporalJoiner::new(cfg);
493        joiner.add_right(right_event(100, "k1")).expect("add ok");
494        joiner.add_left(left_event(110, "k1")).expect("add ok"); // 110 > 108 ✗
495        let pairs = joiner.drain_output();
496        assert!(pairs.is_empty());
497    }
498
499    #[test]
500    fn test_buffer_eviction_when_max_exceeded() {
501        let cfg = TemporalJoinConfig {
502            max_buffer_size: 3,
503            ..Default::default()
504        };
505        let mut joiner = TemporalJoiner::<&str, &str>::new(cfg);
506        for i in 0u64..5 {
507            joiner.add_left(left_event(i * 1000, "kx")).expect("add ok");
508        }
509        assert_eq!(joiner.total_expired_left(), 2);
510        assert_eq!(joiner.left_buffer_size(), 3);
511    }
512
513    #[test]
514    fn test_time_delta_computation_is_correct() {
515        let mut joiner = TemporalJoiner::new(TemporalJoinConfig::default());
516        joiner.add_left(left_event(1000, "k1")).expect("add ok");
517        joiner.add_right(right_event(1003, "k1")).expect("add ok");
518        let pairs = joiner.drain_output();
519        assert_eq!(pairs[0].time_delta, Duration::from_secs(3));
520    }
521
522    #[test]
523    fn test_total_joined_counter() {
524        let mut joiner = TemporalJoiner::new(TemporalJoinConfig::default());
525        joiner.add_left(left_event(100, "k1")).expect("add ok");
526        joiner.add_right(right_event(101, "k1")).expect("add ok");
527        joiner.add_left(left_event(200, "k2")).expect("add ok");
528        joiner.add_right(right_event(201, "k2")).expect("add ok");
529        joiner.drain_output();
530        assert_eq!(joiner.total_joined(), 2);
531    }
532
533    #[test]
534    fn test_add_left_then_right_same_as_right_then_left() {
535        let mut j1 = TemporalJoiner::new(TemporalJoinConfig::default());
536        j1.add_left(left_event(100, "k")).expect("ok");
537        j1.add_right(right_event(102, "k")).expect("ok");
538        let p1 = j1.drain_output();
539
540        let mut j2 = TemporalJoiner::new(TemporalJoinConfig::default());
541        j2.add_right(right_event(102, "k")).expect("ok");
542        j2.add_left(left_event(100, "k")).expect("ok");
543        let p2 = j2.drain_output();
544
545        // Both orderings should produce exactly one join pair
546        assert_eq!(p1.len(), 1);
547        assert_eq!(p2.len(), 1);
548        assert_eq!(p1[0].time_delta, p2[0].time_delta);
549    }
550
551    #[test]
552    fn test_multiple_right_events_match_single_left() {
553        let mut joiner = TemporalJoiner::new(TemporalJoinConfig::default());
554        // Buffer two right events first
555        joiner.add_right(right_event(100, "k")).expect("ok");
556        joiner.add_right(right_event(101, "k")).expect("ok");
557        // Then add a left event that matches both
558        joiner.add_left(left_event(102, "k")).expect("ok");
559        let pairs = joiner.drain_output();
560        assert_eq!(pairs.len(), 2);
561    }
562
563    #[test]
564    fn test_expired_right_counter() {
565        let cfg = TemporalJoinConfig {
566            max_buffer_size: 2,
567            ..Default::default()
568        };
569        let mut joiner = TemporalJoiner::<&str, &str>::new(cfg);
570        joiner.add_right(right_event(0, "a")).expect("ok");
571        joiner.add_right(right_event(1, "a")).expect("ok");
572        joiner.add_right(right_event(2, "a")).expect("ok"); // evicts oldest
573        assert_eq!(joiner.total_expired_right(), 1);
574    }
575
576    #[test]
577    fn test_no_cross_key_contamination() {
578        let mut joiner = TemporalJoiner::new(TemporalJoinConfig::default());
579        joiner.add_left(left_event(100, "alpha")).expect("ok");
580        joiner.add_left(left_event(100, "beta")).expect("ok");
581        joiner.add_right(right_event(101, "alpha")).expect("ok");
582        let pairs = joiner.drain_output();
583        assert_eq!(pairs.len(), 1);
584        assert_eq!(pairs[0].left.key, "alpha");
585    }
586}