1use std::collections::VecDeque;
19use std::time::{Duration, SystemTime};
20
21use crate::error::StreamingError;
22
23#[derive(Debug, Clone)]
27pub struct JoinEvent<T> {
28 pub timestamp: SystemTime,
30 pub key: String,
32 pub payload: T,
34}
35
36impl<T> JoinEvent<T> {
37 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#[derive(Debug, Clone)]
51pub struct JoinedPair<L, R> {
52 pub left: JoinEvent<L>,
54 pub right: JoinEvent<R>,
56 pub time_delta: Duration,
58}
59
60#[derive(Debug, Clone, PartialEq)]
64pub enum JoinMode {
65 Inner,
67 LeftOuter,
71 Interval {
73 lower: Duration,
75 upper: Duration,
77 },
78}
79
80#[derive(Debug, Clone)]
84pub struct TemporalJoinConfig {
85 pub time_tolerance: Duration,
88 pub max_buffer_size: usize,
90 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
104pub 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 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
124struct LeftEntry<L> {
126 event: JoinEvent<L>,
127 matched: bool,
128}
129
130impl<L: Clone, R: Clone> TemporalJoiner<L, R> {
131 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 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 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 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 pub fn drain_output(&mut self) -> Vec<JoinedPair<L, R>> {
191 self.output.drain(..).collect()
192 }
193
194 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 pub fn drain_unmatched_left(&mut self) -> Vec<JoinEvent<L>> {
213 self.unmatched_left.drain(..).collect()
214 }
215
216 pub fn total_unmatched_left(&self) -> u64 {
218 self.total_unmatched_left
219 }
220
221 pub fn total_joined(&self) -> u64 {
223 self.total_joined
224 }
225
226 pub fn left_buffer_size(&self) -> usize {
228 self.left_buffer.len()
229 }
230
231 pub fn right_buffer_size(&self) -> usize {
233 self.right_buffer.len()
234 }
235
236 pub fn total_expired_left(&self) -> u64 {
238 self.total_expired_left
239 }
240
241 pub fn total_expired_right(&self) -> u64 {
243 self.total_expired_right
244 }
245
246 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 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 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 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 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"); 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()); joiner.add_left(left_event(100, "k1")).expect("add ok");
357 joiner.add_right(right_event(110, "k1")).expect("add ok"); 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"); let pairs = joiner.drain_output();
381 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 joiner.add_left(left_event(100, "k1")).expect("add ok");
394 joiner.add_right(right_event(101, "k2")).expect("add ok");
395 assert!(joiner.drain_output().is_empty());
397 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"); assert_eq!(joiner.drain_output().len(), 1);
415 joiner.flush();
416 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 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 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"); 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 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), };
474 let mut joiner = TemporalJoiner::new(cfg);
475 joiner.add_right(right_event(100, "k1")).expect("add ok");
477 joiner.add_left(left_event(105, "k1")).expect("add ok"); 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"); 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 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 joiner.add_right(right_event(100, "k")).expect("ok");
556 joiner.add_right(right_event(101, "k")).expect("ok");
557 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"); 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}