Skip to main content

reifydb_sub_server/subscription/
connection_batcher.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 ReifyDB
3
4use std::sync::Arc;
5
6use indexmap::IndexMap;
7use reifydb_core::interface::catalog::id::SubscriptionId;
8use reifydb_runtime::sync::mutex::Mutex;
9use reifydb_value::value::duration::Duration;
10use tokio::{
11	pin, select,
12	sync::Notify,
13	time::{Instant, sleep},
14};
15
16pub type MergeFn<W> = Arc<dyn Fn(&mut W, W) + Send + Sync>;
17pub type EmitFn<W> = Arc<dyn Fn(Vec<W>) + Send + Sync>;
18
19struct Pending<W> {
20	item: W,
21	linger: Duration,
22	first_pending_at: Instant,
23}
24
25struct Inner<W> {
26	pending: Mutex<IndexMap<SubscriptionId, Pending<W>>>,
27	merge: MergeFn<W>,
28	emit: EmitFn<W>,
29	wake: Notify,
30}
31
32pub struct ConnectionBatcher<W> {
33	inner: Arc<Inner<W>>,
34}
35
36impl<W> Clone for ConnectionBatcher<W> {
37	fn clone(&self) -> Self {
38		Self {
39			inner: self.inner.clone(),
40		}
41	}
42}
43
44impl<W: Send + 'static> ConnectionBatcher<W> {
45	pub fn new(merge: MergeFn<W>, emit: EmitFn<W>) -> Self {
46		Self {
47			inner: Arc::new(Inner {
48				pending: Mutex::new(IndexMap::new()),
49				merge,
50				emit,
51				wake: Notify::new(),
52			}),
53		}
54	}
55
56	pub fn append(&self, subscription_id: SubscriptionId, item: W, linger: impl Into<Duration>) {
57		let linger = linger.into();
58		{
59			let mut pending = self.inner.pending.lock();
60			match pending.get_mut(&subscription_id) {
61				Some(existing) => (self.inner.merge)(&mut existing.item, item),
62				None => {
63					pending.insert(
64						subscription_id,
65						Pending {
66							item,
67							linger,
68							first_pending_at: Instant::now(),
69						},
70					);
71				}
72			}
73		}
74		self.inner.wake.notify_one();
75	}
76
77	pub fn flush_due(&self) {
78		let now = Instant::now();
79		self.emit_where(|p| Duration::from_std(now.duration_since(p.first_pending_at)) >= p.linger);
80	}
81
82	pub fn flush_all(&self) {
83		self.emit_where(|_| true);
84	}
85
86	fn emit_where(&self, due: impl Fn(&Pending<W>) -> bool) {
87		let items: Vec<W> = {
88			let mut pending = self.inner.pending.lock();
89			if pending.is_empty() {
90				return;
91			}
92			let keys: Vec<SubscriptionId> =
93				pending.iter().filter(|(_, p)| due(p)).map(|(k, _)| *k).collect();
94			if keys.is_empty() {
95				return;
96			}
97			keys.into_iter().filter_map(|k| pending.shift_remove(&k).map(|p| p.item)).collect()
98		};
99		if !items.is_empty() {
100			(self.inner.emit)(items);
101		}
102	}
103
104	fn next_deadline(&self) -> Option<Duration> {
105		let now = Instant::now();
106		let pending = self.inner.pending.lock();
107		pending.values()
108			.map(|p| p.linger.saturating_sub(Duration::from_std(now.duration_since(p.first_pending_at))))
109			.min()
110	}
111
112	pub async fn run(self) {
113		loop {
114			match self.next_deadline() {
115				None => self.inner.wake.notified().await,
116				Some(remaining) => {
117					let notified = self.inner.wake.notified();
118					pin!(notified);
119					select! {
120						_ = sleep(remaining.to_std()) => {}
121						_ = &mut notified => {}
122					}
123				}
124			}
125			self.flush_due();
126		}
127	}
128}
129
130#[cfg(test)]
131mod tests {
132	use std::sync::{
133		Arc,
134		atomic::{AtomicUsize, Ordering},
135	};
136
137	use reifydb_core::interface::catalog::id::SubscriptionId;
138	use reifydb_runtime::sync::mutex::Mutex;
139	use reifydb_value::value::duration::Duration;
140	use tokio::{spawn, time::sleep};
141
142	use super::{ConnectionBatcher, EmitFn, MergeFn};
143
144	fn collecting_emit(out: Arc<Mutex<Vec<Vec<u64>>>>) -> EmitFn<u64> {
145		Arc::new(move |items: Vec<u64>| out.lock().push(items))
146	}
147
148	fn sum_merge() -> MergeFn<u64> {
149		Arc::new(|existing: &mut u64, next: u64| *existing += next)
150	}
151
152	fn ms(n: u64) -> Duration {
153		Duration::from_milliseconds(n as i64).unwrap()
154	}
155
156	#[test]
157	fn flush_all_emits_one_batch_in_insertion_order() {
158		let out = Arc::new(Mutex::new(Vec::new()));
159		let batcher = ConnectionBatcher::new(sum_merge(), collecting_emit(out.clone()));
160
161		batcher.append(SubscriptionId(7), 1, ms(5));
162		batcher.append(SubscriptionId(3), 2, ms(5));
163		batcher.flush_all();
164
165		let batches = out.lock().clone();
166		assert_eq!(batches.len(), 1, "one flush emits exactly one batch");
167		assert_eq!(batches[0], vec![1, 2], "distinct subscriptions stay distinct items, in first-seen order");
168	}
169
170	#[test]
171	fn append_for_same_subscription_merges_into_one_item() {
172		let out = Arc::new(Mutex::new(Vec::new()));
173		let batcher = ConnectionBatcher::new(sum_merge(), collecting_emit(out.clone()));
174
175		batcher.append(SubscriptionId(7), 10, ms(5));
176		batcher.append(SubscriptionId(7), 5, ms(5));
177		batcher.append(SubscriptionId(7), 1, ms(5));
178		batcher.flush_all();
179
180		let batches = out.lock().clone();
181		assert_eq!(
182			batches[0],
183			vec![16],
184			"the same subscription collapses to one merged item, never a second item"
185		);
186	}
187
188	#[test]
189	fn empty_flush_does_not_emit() {
190		let out = Arc::new(Mutex::new(Vec::new()));
191		let batcher = ConnectionBatcher::new(sum_merge(), collecting_emit(out.clone()));
192
193		batcher.flush_all();
194
195		assert!(out.lock().is_empty(), "a flush with nothing pending must not emit an envelope");
196	}
197
198	#[test]
199	fn flush_due_holds_a_subscription_until_its_linger_elapses() {
200		let out = Arc::new(Mutex::new(Vec::new()));
201		let batcher = ConnectionBatcher::new(sum_merge(), collecting_emit(out.clone()));
202
203		batcher.append(SubscriptionId(7), 1, ms(3600_000));
204		batcher.flush_due();
205		assert!(out.lock().is_empty(), "a subscription is not due until its own linger has elapsed");
206
207		batcher.flush_all();
208		assert_eq!(out.lock()[0], vec![1], "flush_all ignores linger (teardown/shape path)");
209	}
210
211	#[tokio::test]
212	async fn run_loop_respects_per_subscription_linger_independently() {
213		let out = Arc::new(Mutex::new(Vec::new()));
214		let batcher = ConnectionBatcher::new(sum_merge(), collecting_emit(out.clone()));
215
216		let task = spawn(batcher.clone().run());
217		batcher.append(SubscriptionId(1), 1, ms(15));
218		batcher.append(SubscriptionId(2), 2, ms(150));
219
220		sleep(ms(60).to_std()).await;
221		assert_eq!(
222			out.lock().clone(),
223			vec![vec![1]],
224			"the short-linger subscription is delivered first, in its own envelope, while the long one waits"
225		);
226
227		sleep(ms(150).to_std()).await;
228		assert_eq!(
229			out.lock().clone(),
230			vec![vec![1], vec![2]],
231			"the long-linger subscription is delivered only after its own linger, in a separate envelope"
232		);
233
234		task.abort();
235	}
236
237	#[tokio::test]
238	async fn run_loop_coalesces_same_linger_subscriptions_into_one_envelope() {
239		let out = Arc::new(Mutex::new(Vec::new()));
240		let batcher = ConnectionBatcher::new(sum_merge(), collecting_emit(out.clone()));
241
242		let task = spawn(batcher.clone().run());
243		batcher.append(SubscriptionId(1), 1, ms(15));
244		batcher.append(SubscriptionId(2), 2, ms(15));
245
246		sleep(ms(60).to_std()).await;
247		assert_eq!(
248			out.lock().clone(),
249			vec![vec![1, 2]],
250			"subscriptions sharing a linger ride one envelope - the N-into-one case is just equal lingers"
251		);
252
253		task.abort();
254	}
255
256	#[tokio::test]
257	async fn run_loop_flushes_zero_linger_promptly() {
258		let out = Arc::new(Mutex::new(Vec::new()));
259		let batcher = ConnectionBatcher::new(sum_merge(), collecting_emit(out.clone()));
260
261		let task = spawn(batcher.clone().run());
262		batcher.append(SubscriptionId(1), 1, ms(0));
263
264		sleep(ms(30).to_std()).await;
265		assert_eq!(
266			out.lock().clone(),
267			vec![vec![1]],
268			"a zero-linger subscription is delivered with no added latency"
269		);
270
271		task.abort();
272	}
273
274	#[tokio::test]
275	async fn run_loop_lets_a_nearer_deadline_preempt_a_pending_sleep() {
276		let out = Arc::new(Mutex::new(Vec::new()));
277		let batcher = ConnectionBatcher::new(sum_merge(), collecting_emit(out.clone()));
278
279		let task = spawn(batcher.clone().run());
280		batcher.append(SubscriptionId(1), 1, ms(150));
281		sleep(ms(10).to_std()).await;
282		batcher.append(SubscriptionId(2), 2, ms(15));
283
284		sleep(ms(60).to_std()).await;
285		assert_eq!(
286			out.lock().clone(),
287			vec![vec![2]],
288			"a later-arriving nearer-deadline subscription preempts the long sleep and flushes first"
289		);
290
291		task.abort();
292	}
293
294	#[tokio::test]
295	async fn run_loop_emits_nothing_while_idle() {
296		let calls = Arc::new(AtomicUsize::new(0));
297		let calls_for_emit = calls.clone();
298		let emit: EmitFn<u64> = Arc::new(move |_| {
299			calls_for_emit.fetch_add(1, Ordering::SeqCst);
300		});
301		let batcher = ConnectionBatcher::new(sum_merge(), emit);
302
303		let task = spawn(batcher.clone().run());
304		sleep(ms(40).to_std()).await;
305		task.abort();
306
307		assert_eq!(calls.load(Ordering::SeqCst), 0, "an idle connection never wakes to emit");
308	}
309}