Skip to main content

reifydb_flow/window/kind/
session.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 ReifyDB
3
4use reifydb_value::value::{datetime::DateTime, duration::Duration};
5
6use crate::{operator::state::seal::rule::SealRule, window::coord::EventCoord};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
9pub struct SessionTracker {
10	pub session_id: u64,
11	pub last: DateTime,
12	pub start: DateTime,
13	opened: bool,
14}
15
16impl SessionTracker {
17	pub fn resumed(session_id: u64, last: DateTime, start: DateTime) -> Self {
18		Self {
19			session_id,
20			last,
21			start,
22			opened: true,
23		}
24	}
25
26	fn is_unopened(&self) -> bool {
27		!self.opened
28	}
29
30	fn adopt(&mut self, coord: DateTime) {
31		self.last = coord;
32		self.start = coord;
33		self.opened = true;
34	}
35
36	fn extend(&mut self, coord: DateTime) {
37		self.last = self.last.max(coord);
38		self.start = self.start.min(coord);
39	}
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum SessionAssignment {
44	Opened(u64),
45	Extended(u64),
46	Rotated {
47		closed: u64,
48		opened: u64,
49	},
50	Refused,
51}
52
53impl SessionAssignment {
54	pub fn session_id(self) -> Option<u64> {
55		match self {
56			SessionAssignment::Opened(session_id) | SessionAssignment::Extended(session_id) => {
57				Some(session_id)
58			}
59			SessionAssignment::Rotated {
60				opened,
61				..
62			} => Some(opened),
63			SessionAssignment::Refused => None,
64		}
65	}
66
67	pub fn closed(self) -> Option<u64> {
68		match self {
69			SessionAssignment::Rotated {
70				closed,
71				..
72			} => Some(closed),
73			_ => None,
74		}
75	}
76}
77
78pub struct SessionKind {
79	gap: Duration,
80}
81
82impl SessionKind {
83	pub fn with_gap(gap: Duration) -> Self {
84		Self {
85			gap,
86		}
87	}
88
89	pub fn seal_rule(&self, lateness: Duration) -> SealRule {
90		SealRule::session(self.gap, lateness)
91	}
92
93	pub fn assign(&self, tracker: &mut SessionTracker, coord: EventCoord) -> SessionAssignment {
94		let coord = coord.at();
95
96		if tracker.is_unopened() {
97			tracker.adopt(coord);
98			return SessionAssignment::Opened(tracker.session_id);
99		}
100		if coord > tracker.last && coord - tracker.last > self.gap {
101			let closed = tracker.session_id;
102			tracker.session_id += 1;
103			tracker.adopt(coord);
104			return SessionAssignment::Rotated {
105				closed,
106				opened: tracker.session_id,
107			};
108		}
109		if coord < tracker.start && tracker.start - coord > self.gap {
110			return SessionAssignment::Refused;
111		}
112		tracker.extend(coord);
113		SessionAssignment::Extended(tracker.session_id)
114	}
115}
116
117#[cfg(test)]
118mod tests {
119	use reifydb_value::factory::time::at_millis;
120
121	use super::*;
122	use crate::factory::coord::event_coord_at_millis;
123
124	fn ms(millis: u64) -> Duration {
125		Duration::from_milliseconds_const(millis as i64)
126	}
127
128	fn kind() -> SessionKind {
129		SessionKind::with_gap(ms(1_000))
130	}
131
132	#[test]
133	fn a_quiet_period_longer_than_the_gap_rotates_to_a_new_session() {
134		// Activity separated by more than the gap is two sessions. The rotation must report the id
135		// it closed as well as the one it opened, or the closed session's accumulator stays live
136		// forever with nothing left to seal it.
137		let mut tracker = SessionTracker::default();
138
139		assert_eq!(kind().assign(&mut tracker, event_coord_at_millis(5_000)), SessionAssignment::Opened(0));
140		assert_eq!(
141			kind().assign(&mut tracker, event_coord_at_millis(6_001)),
142			SessionAssignment::Rotated {
143				closed: 0,
144				opened: 1
145			}
146		);
147		assert_eq!(tracker.session_id, 1);
148	}
149
150	#[test]
151	fn a_quiet_period_of_exactly_the_gap_stays_in_the_same_session() {
152		// The gap boundary is inclusive: sessions are defined by the absence of activity for longer
153		// than the gap, so splitting at exactly the gap ends a session that never went quiet.
154		let mut tracker = SessionTracker::default();
155
156		kind().assign(&mut tracker, event_coord_at_millis(5_000));
157
158		assert_eq!(kind().assign(&mut tracker, event_coord_at_millis(6_000)), SessionAssignment::Extended(0));
159	}
160
161	#[test]
162	fn a_late_row_inside_the_gap_extends_the_session_backwards() {
163		// Sessions grow at both ends. If the start does not move back to cover a late row, the
164		// session's span no longer contains all its rows and the seal timer is armed too late.
165		let mut tracker = SessionTracker::default();
166
167		kind().assign(&mut tracker, event_coord_at_millis(5_000));
168
169		assert_eq!(kind().assign(&mut tracker, event_coord_at_millis(4_500)), SessionAssignment::Extended(0));
170		assert_eq!(tracker.start, at_millis(4_500));
171		assert_eq!(tracker.last, at_millis(5_000), "reaching backwards must not drag the high end down");
172	}
173
174	#[test]
175	fn a_row_far_before_the_session_start_is_refused_rather_than_misfiled() {
176		// A row more than a gap before the session start belongs to an earlier, already-sealed
177		// session. Admitting it amends a published aggregate; opening a new session for it
178		// interleaves two sessions on one tracker.
179		let mut tracker = SessionTracker::default();
180
181		kind().assign(&mut tracker, event_coord_at_millis(5_000));
182
183		assert_eq!(kind().assign(&mut tracker, event_coord_at_millis(3_999)), SessionAssignment::Refused);
184		assert_eq!(tracker.start, at_millis(5_000), "a refused row must leave the tracker untouched");
185		assert_eq!(tracker.last, at_millis(5_000));
186	}
187
188	#[test]
189	fn a_fresh_tracker_adopts_its_first_coordinate_without_closing_anything() {
190		// The first row of a new group has no session to rotate out of, so it must open rather than
191		// rotate - a Rotated would tell the caller to seal session id 0, whose accumulator does not
192		// exist, and the seal would run against empty state.
193		let mut tracker = SessionTracker::default();
194
195		let assignment = kind().assign(&mut tracker, event_coord_at_millis(9_000));
196
197		assert_eq!(assignment, SessionAssignment::Opened(0));
198		assert_eq!(assignment.closed(), None);
199		assert_eq!(tracker, SessionTracker::resumed(0, at_millis(9_000), at_millis(9_000)));
200	}
201
202	#[test]
203	fn a_session_opened_at_the_epoch_still_rotates_across_its_gap() {
204		// Openness used to be encoded as `last == 0`, which is also a real coordinate, so a session
205		// opened at the epoch read as unopened forever and every row folded into one aggregate. It
206		// is now carried explicitly, which makes the epoch an ordinary coordinate.
207		let mut tracker = SessionTracker::default();
208
209		assert_eq!(kind().assign(&mut tracker, event_coord_at_millis(0)), SessionAssignment::Opened(0));
210		assert_eq!(tracker.last, at_millis(0), "the tracker must keep the epoch coordinate it adopted");
211		assert_eq!(
212			kind().assign(&mut tracker, event_coord_at_millis(1_001)),
213			SessionAssignment::Rotated {
214				closed: 0,
215				opened: 1
216			}
217		);
218	}
219
220	#[test]
221	fn a_tracker_that_has_adopted_the_epoch_is_distinguishable_from_a_fresh_one() {
222		// The store tells openness apart by whether a SessionState row exists, and load_session maps
223		// that onto these two values. Comparing equal would erase the distinction and bring the
224		// epoch collision back through the persistence path.
225		let mut tracker = SessionTracker::default();
226
227		kind().assign(&mut tracker, event_coord_at_millis(0));
228
229		assert_ne!(tracker, SessionTracker::default());
230		assert_eq!(tracker, SessionTracker::resumed(0, at_millis(0), at_millis(0)));
231	}
232
233	#[test]
234	fn a_resumed_tracker_continues_the_session_it_was_persisted_with() {
235		// A session outlives the batch that opened it, so the tracker is reloaded per batch.
236		// Resuming into a fresh tracker restarts the ids at 0 and aliases every group's second
237		// session onto its first.
238		let mut tracker = SessionTracker::resumed(7, at_millis(5_000), at_millis(4_000));
239
240		assert_eq!(kind().assign(&mut tracker, event_coord_at_millis(5_500)), SessionAssignment::Extended(7));
241		assert_eq!(tracker, SessionTracker::resumed(7, at_millis(5_500), at_millis(4_000)));
242	}
243
244	#[test]
245	fn a_refused_row_reports_no_session_and_a_rotation_reports_the_new_one() {
246		// The caller routes on session_id() alone, so a Rotated that answered its closed id would
247		// file the row that caused the rotation into the session it just ended.
248		assert_eq!(SessionAssignment::Refused.session_id(), None);
249		assert_eq!(
250			SessionAssignment::Rotated {
251				closed: 3,
252				opened: 4
253			}
254			.session_id(),
255			Some(4)
256		);
257		assert_eq!(SessionAssignment::Opened(2).session_id(), Some(2));
258		assert_eq!(SessionAssignment::Extended(2).session_id(), Some(2));
259	}
260
261	#[test]
262	fn a_zero_gap_puts_every_distinct_instant_in_its_own_session() {
263		// A zero gap means any quiet period ends the session, so consecutive instants rotate but a
264		// repeated instant does not. Zero is coherent here, unlike a sliding slide: it is the
265		// degenerate "group by instant" case and there is no division to blow up.
266		let kind = SessionKind::with_gap(ms(0));
267		let mut tracker = SessionTracker::default();
268
269		assert_eq!(kind.assign(&mut tracker, event_coord_at_millis(5_000)), SessionAssignment::Opened(0));
270		assert_eq!(kind.assign(&mut tracker, event_coord_at_millis(5_000)), SessionAssignment::Extended(0));
271		assert_eq!(
272			kind.assign(&mut tracker, event_coord_at_millis(5_001)),
273			SessionAssignment::Rotated {
274				closed: 0,
275				opened: 1
276			}
277		);
278	}
279}