Skip to main content

reifydb_cdc/consume/
backlog.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 ReifyDB
3
4use std::{
5	collections::BTreeMap,
6	mem::{replace, size_of},
7	sync::{
8		Arc,
9		atomic::{AtomicBool, AtomicU64, Ordering},
10	},
11};
12
13use reifydb_core::{
14	common::CommitVersion,
15	interface::{
16		cdc::{Cdc, SystemChange},
17		change::{Change, Diff},
18	},
19	metrics::{collect::MetricsCollector, sample::MetricsSample},
20};
21use reifydb_runtime::sync::rwlock::RwLock;
22use reifydb_value::{byte_size::ByteSize, reifydb_assertions};
23
24pub enum BacklogPull {
25	Hit {
26		items: Vec<Arc<Cdc>>,
27		advance_to: CommitVersion,
28		more: bool,
29	},
30
31	Behind,
32}
33
34struct BacklogInner {
35	entries: BTreeMap<CommitVersion, (u64, Arc<Cdc>)>,
36	bytes: u64,
37	cover_from: Option<CommitVersion>,
38}
39
40type Waker = Box<dyn Fn() + Send + Sync>;
41
42struct BacklogShared {
43	inner: RwLock<BacklogInner>,
44	limit: u64,
45	anchor: AtomicU64,
46	waker: RwLock<Option<Waker>>,
47	armed: AtomicBool,
48	published_entries: AtomicU64,
49	pull_hits: AtomicU64,
50	pull_behinds: AtomicU64,
51	evicted_floor: AtomicU64,
52	evicted_ceiling: AtomicU64,
53}
54
55#[derive(Clone)]
56pub struct FlowBacklog {
57	shared: Arc<BacklogShared>,
58}
59
60impl FlowBacklog {
61	pub fn new(limit: ByteSize) -> Self {
62		Self {
63			shared: Arc::new(BacklogShared {
64				inner: RwLock::new(BacklogInner {
65					entries: BTreeMap::new(),
66					bytes: 0,
67					cover_from: None,
68				}),
69				limit: limit.as_bytes().max(1),
70				anchor: AtomicU64::new(0),
71				waker: RwLock::new(None),
72				armed: AtomicBool::new(false),
73				published_entries: AtomicU64::new(0),
74				pull_hits: AtomicU64::new(0),
75				pull_behinds: AtomicU64::new(0),
76				evicted_floor: AtomicU64::new(0),
77				evicted_ceiling: AtomicU64::new(0),
78			}),
79		}
80	}
81
82	pub fn limit(&self) -> ByteSize {
83		ByteSize::from_bytes(self.shared.limit)
84	}
85
86	pub fn set_waker(&self, waker: impl Fn() + Send + Sync + 'static) {
87		*self.shared.waker.write() = Some(Box::new(waker));
88	}
89
90	pub fn notify(&self) {
91		if self.shared.armed.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire).is_ok()
92			&& let Some(waker) = self.shared.waker.read().as_ref()
93		{
94			waker();
95		}
96	}
97
98	pub fn disarm(&self) {
99		self.shared.armed.store(false, Ordering::Release);
100	}
101
102	pub fn publish(&self, version: CommitVersion, cdc: Option<Arc<Cdc>>) {
103		let mut inner = self.shared.inner.write();
104		if inner.cover_from.is_none() {
105			inner.cover_from = Some(CommitVersion(version.0.saturating_sub(1)));
106		}
107		let Some(cdc) = cdc else {
108			return;
109		};
110		if version <= inner.cover_from.expect("cover_from set above") {
111			return;
112		}
113		let bytes = cdc_bytes(&cdc);
114		if let Some((replaced, _)) = inner.entries.insert(version, (bytes, cdc)) {
115			inner.bytes -= replaced;
116		}
117		inner.bytes += bytes;
118		self.shared.published_entries.fetch_add(1, Ordering::Relaxed);
119		self.evict_over_ceiling(&mut inner);
120	}
121
122	fn evict_over_ceiling(&self, inner: &mut BacklogInner) {
123		let anchor = CommitVersion(self.shared.anchor.load(Ordering::Acquire));
124		while inner.bytes > self.shared.limit {
125			let Some(lowest) = inner.entries.keys().next().copied() else {
126				break;
127			};
128			if lowest > anchor {
129				break;
130			}
131			if let Some((evicted, _)) = inner.entries.remove(&lowest) {
132				inner.bytes -= evicted;
133			}
134			inner.cover_from = Some(inner.cover_from.map_or(lowest, |c| c.max(lowest)));
135			self.shared.evicted_ceiling.fetch_add(1, Ordering::Relaxed);
136		}
137	}
138
139	pub fn pull(&self, cursor: CommitVersion, up_to: CommitVersion, budget: ByteSize) -> BacklogPull {
140		if up_to <= cursor {
141			return BacklogPull::Hit {
142				items: Vec::new(),
143				advance_to: cursor,
144				more: false,
145			};
146		}
147		let inner = self.shared.inner.read();
148		let Some(cover_from) = inner.cover_from else {
149			self.shared.pull_behinds.fetch_add(1, Ordering::Relaxed);
150			return BacklogPull::Behind;
151		};
152		if cursor < cover_from {
153			self.shared.pull_behinds.fetch_add(1, Ordering::Relaxed);
154			return BacklogPull::Behind;
155		}
156
157		let budget = budget.as_bytes().max(1);
158		let mut items: Vec<Arc<Cdc>> = Vec::new();
159		let mut taken = 0u64;
160		let mut truncated_at: Option<CommitVersion> = None;
161		for (version, (bytes, cdc)) in inner.entries.range(next_version(cursor)..=up_to) {
162			if !items.is_empty() && taken + bytes > budget {
163				truncated_at = Some(*version);
164				break;
165			}
166			taken += bytes;
167			items.push(cdc.clone());
168		}
169		self.shared.pull_hits.fetch_add(1, Ordering::Relaxed);
170		match truncated_at {
171			Some(_) => BacklogPull::Hit {
172				advance_to: items.last().expect("truncation implies at least one item").version,
173				items,
174				more: true,
175			},
176			None => BacklogPull::Hit {
177				items,
178				advance_to: up_to,
179				more: false,
180			},
181		}
182	}
183
184	pub fn evict_below(&self, version: CommitVersion) {
185		let mut inner = self.shared.inner.write();
186		if inner.cover_from.is_none() {
187			return;
188		}
189		let retained = inner.entries.split_off(&next_version(version));
190		let evicted = replace(&mut inner.entries, retained);
191		let count = evicted.len() as u64;
192		for (bytes, _) in evicted.into_values() {
193			inner.bytes -= bytes;
194		}
195		inner.cover_from = Some(inner.cover_from.map_or(version, |c| c.max(version)));
196		self.shared.evicted_floor.fetch_add(count, Ordering::Relaxed);
197	}
198
199	pub fn set_anchor(&self, version: CommitVersion) {
200		reifydb_assertions! {
201			let prev = self.shared.anchor.load(Ordering::Acquire);
202			assert!(
203				version.0 >= prev,
204				"the backlog scan anchor moved backwards ({} -> {}), so ceiling eviction could remove \
205				 entries the supervisor has not scanned for DDL yet",
206				prev,
207				version.0
208			);
209		}
210		self.shared.anchor.store(version.0, Ordering::Release);
211	}
212}
213
214#[inline]
215fn next_version(v: CommitVersion) -> CommitVersion {
216	CommitVersion(v.0.saturating_add(1))
217}
218
219impl MetricsCollector for FlowBacklog {
220	fn collect(&self, out: &mut Vec<MetricsSample>) {
221		let (bytes, count, cover_from) = {
222			let inner = self.shared.inner.read();
223			(inner.bytes, inner.entries.len() as u64, inner.cover_from.map(|c| c.0).unwrap_or(0))
224		};
225		out.push(MetricsSample::heap("flow_backlog", "bytes", ByteSize::from_bytes(bytes)));
226		out.push(MetricsSample::count("flow_backlog", "entries", count));
227		out.push(MetricsSample::count("flow_backlog", "cover_from", cover_from));
228		out.push(MetricsSample::counter(
229			"flow_backlog",
230			"published_entries",
231			self.shared.published_entries.load(Ordering::Relaxed),
232		));
233		out.push(MetricsSample::counter(
234			"flow_backlog",
235			"pull_hits",
236			self.shared.pull_hits.load(Ordering::Relaxed),
237		));
238		out.push(MetricsSample::counter(
239			"flow_backlog",
240			"pull_behinds",
241			self.shared.pull_behinds.load(Ordering::Relaxed),
242		));
243		out.push(MetricsSample::counter(
244			"flow_backlog",
245			"evicted_floor",
246			self.shared.evicted_floor.load(Ordering::Relaxed),
247		));
248		out.push(MetricsSample::counter(
249			"flow_backlog",
250			"evicted_ceiling",
251			self.shared.evicted_ceiling.load(Ordering::Relaxed),
252		));
253	}
254}
255
256pub fn cdc_bytes(cdc: &Cdc) -> u64 {
257	let changes: usize = cdc.changes.iter().map(change_bytes).sum();
258	let system: usize = cdc
259		.system_changes
260		.iter()
261		.map(|change| size_of::<SystemChange>() + change.key().len() + change.value_bytes())
262		.sum();
263	(size_of::<Cdc>() + changes + system) as u64
264}
265
266fn change_bytes(change: &Change) -> usize {
267	size_of::<Change>() + change.diffs.iter().map(diff_bytes).sum::<usize>()
268}
269
270fn diff_bytes(diff: &Diff) -> usize {
271	size_of::<Diff>()
272		+ match diff {
273			Diff::Insert {
274				post,
275				..
276			} => post.heap_size(),
277			Diff::Update {
278				pre,
279				post,
280				..
281			} => pre.heap_size() + post.heap_size(),
282			Diff::Remove {
283				pre,
284				..
285			} => pre.heap_size(),
286		}
287}
288
289#[cfg(test)]
290mod tests {
291	use std::sync::atomic::AtomicUsize;
292
293	use reifydb_codec::{key::encoded::EncodedKey, row::bytes::EncodedBytes};
294	use reifydb_value::{util::cowvec::CowVec, value::datetime::DateTime};
295
296	use super::*;
297
298	fn cv(n: u64) -> CommitVersion {
299		CommitVersion(n)
300	}
301
302	fn cdc_with_payload(version: u64, payload: usize) -> Arc<Cdc> {
303		Arc::new(Cdc::new(
304			cv(version),
305			DateTime::default(),
306			Vec::new(),
307			vec![SystemChange::Insert {
308				key: EncodedKey::new(vec![0xAB; 4]),
309				post: EncodedBytes(CowVec::new(vec![0u8; payload])),
310			}],
311		))
312	}
313
314	fn backlog(limit_bytes: u64) -> FlowBacklog {
315		let b = FlowBacklog::new(ByteSize::from_bytes(limit_bytes));
316		b.set_anchor(cv(u64::MAX));
317		b
318	}
319
320	fn entry_bytes() -> u64 {
321		cdc_bytes(&cdc_with_payload(1, 100))
322	}
323
324	#[test]
325	fn pull_before_any_publish_is_behind() {
326		// An empty backlog covers nothing: claiming coverage would let a flow with an old
327		// checkpoint skip its whole catch-up range as if it carried no CDC.
328		let b = backlog(u64::MAX);
329		assert!(matches!(b.pull(cv(0), cv(10), ByteSize::from_mib(1)), BacklogPull::Behind));
330	}
331
332	#[test]
333	fn coverage_starts_just_below_the_first_published_version() {
334		// The first publish establishes the floor: from that version on the backlog is authoritative,
335		// anything earlier lives only on disk and must be sent to the loader.
336		let b = backlog(u64::MAX);
337		b.publish(cv(100), Some(cdc_with_payload(100, 10)));
338		match b.pull(cv(99), cv(100), ByteSize::from_mib(1)) {
339			BacklogPull::Hit {
340				items,
341				advance_to,
342				more,
343			} => {
344				assert_eq!(items.len(), 1);
345				assert_eq!(advance_to, cv(100));
346				assert!(!more);
347			}
348			BacklogPull::Behind => panic!("cursor at cover_from must be served"),
349		}
350		assert!(matches!(b.pull(cv(98), cv(100), ByteSize::from_mib(1)), BacklogPull::Behind));
351	}
352
353	#[test]
354	fn irrelevant_versions_extend_coverage_without_entries() {
355		// Versions carrying nothing a flow cares about must still extend coverage, or crossing
356		// them would cost a disk trip for no data.
357		let b = backlog(u64::MAX);
358		b.publish(cv(5), None);
359		match b.pull(cv(4), cv(9), ByteSize::from_mib(1)) {
360			BacklogPull::Hit {
361				items,
362				advance_to,
363				more,
364			} => {
365				assert!(items.is_empty());
366				assert_eq!(advance_to, cv(9), "an empty pull must advance to the caller's bound");
367				assert!(!more);
368			}
369			BacklogPull::Behind => panic!("published coverage must serve the empty range"),
370		}
371	}
372
373	#[test]
374	fn pull_up_to_at_or_below_cursor_is_an_empty_hit() {
375		let b = backlog(u64::MAX);
376		b.publish(cv(5), Some(cdc_with_payload(5, 10)));
377		match b.pull(cv(5), cv(5), ByteSize::from_mib(1)) {
378			BacklogPull::Hit {
379				items,
380				advance_to,
381				..
382			} => {
383				assert!(items.is_empty());
384				assert_eq!(advance_to, cv(5));
385			}
386			BacklogPull::Behind => panic!("nothing to pull is not Behind"),
387		}
388	}
389
390	#[test]
391	fn budget_truncation_reports_more_and_advances_only_to_the_last_taken() {
392		// advance_to on a truncated pull must be the last item actually handed out; advancing
393		// to the bound would checkpoint past entries the flow never applied, losing them.
394		let b = backlog(u64::MAX);
395		for v in 1..=4 {
396			b.publish(cv(v), Some(cdc_with_payload(v, 100)));
397		}
398		let two = entry_bytes() * 2;
399		match b.pull(cv(0), cv(4), ByteSize::from_bytes(two)) {
400			BacklogPull::Hit {
401				items,
402				advance_to,
403				more,
404			} => {
405				assert_eq!(items.len(), 2);
406				assert_eq!(advance_to, cv(2));
407				assert!(more, "a truncated pull must tell the caller to come back");
408			}
409			BacklogPull::Behind => panic!("expected Hit"),
410		}
411	}
412
413	#[test]
414	fn a_single_oversized_entry_is_still_served() {
415		// The budget bounds batching, not progress: an entry larger than the whole budget must
416		// still be handed out alone, or the flow would spin forever on an empty pull.
417		let b = backlog(u64::MAX);
418		b.publish(cv(1), Some(cdc_with_payload(1, 4096)));
419		match b.pull(cv(0), cv(1), ByteSize::from_bytes(1)) {
420			BacklogPull::Hit {
421				items,
422				advance_to,
423				more,
424			} => {
425				assert_eq!(items.len(), 1);
426				assert_eq!(advance_to, cv(1));
427				assert!(!more);
428			}
429			BacklogPull::Behind => panic!("expected Hit"),
430		}
431	}
432
433	#[test]
434	fn evict_below_raises_the_floor_and_later_pulls_go_behind() {
435		let b = backlog(u64::MAX);
436		for v in 1..=4 {
437			b.publish(cv(v), Some(cdc_with_payload(v, 100)));
438		}
439		b.evict_below(cv(2));
440		assert!(
441			matches!(b.pull(cv(1), cv(4), ByteSize::from_mib(1)), BacklogPull::Behind),
442			"a cursor below the raised floor must be sent to the loader"
443		);
444		match b.pull(cv(2), cv(4), ByteSize::from_mib(1)) {
445			BacklogPull::Hit {
446				items,
447				..
448			} => assert_eq!(items.len(), 2),
449			BacklogPull::Behind => panic!("entries above the floor must survive evict_below"),
450		}
451	}
452
453	#[test]
454	fn ceiling_eviction_drops_lowest_versions_first_and_raises_the_floor() {
455		// The deepest laggard is the one who pays disk again: the ceiling evicts from the
456		// bottom so the near-frontier window every healthy flow reads stays resident.
457		let one = entry_bytes();
458		let b = backlog(one * 2);
459		for v in 1..=3 {
460			b.publish(cv(v), Some(cdc_with_payload(v, 100)));
461		}
462		assert!(matches!(b.pull(cv(0), cv(3), ByteSize::from_mib(1)), BacklogPull::Behind));
463		match b.pull(cv(1), cv(3), ByteSize::from_mib(1)) {
464			BacklogPull::Hit {
465				items,
466				..
467			} => assert_eq!(items.len(), 2, "the two newest entries must survive"),
468			BacklogPull::Behind => panic!("expected Hit above the evicted floor"),
469		}
470	}
471
472	#[test]
473	fn ceiling_eviction_never_crosses_the_scan_anchor() {
474		// Entries above the anchor have not been scanned by the supervisor for flow DDL yet;
475		// evicting them would let flow creations or deletions vanish without being processed.
476		// The ceiling is soft against the anchor: bytes exceed the limit until the anchor moves.
477		let one = entry_bytes();
478		let b = FlowBacklog::new(ByteSize::from_bytes(one));
479		b.set_anchor(cv(1));
480		for v in 1..=3 {
481			b.publish(cv(v), Some(cdc_with_payload(v, 100)));
482		}
483		match b.pull(cv(1), cv(3), ByteSize::from_mib(1)) {
484			BacklogPull::Hit {
485				items,
486				..
487			} => assert_eq!(items.len(), 2, "unscanned entries must survive the ceiling"),
488			BacklogPull::Behind => panic!("entries above the anchor must not be evicted"),
489		}
490		b.set_anchor(cv(3));
491		b.publish(cv(4), Some(cdc_with_payload(4, 100)));
492		assert!(
493			matches!(b.pull(cv(1), cv(4), ByteSize::from_mib(1)), BacklogPull::Behind),
494			"once the anchor passes them, over-ceiling entries must be evicted lowest-first"
495		);
496	}
497
498	#[test]
499	fn out_of_order_publish_below_the_floor_is_ignored() {
500		// The producer can process commits out of order; a version arriving below the established
501		// floor cannot extend coverage downward, and a stray entry there would contradict Behind.
502		let b = backlog(u64::MAX);
503		b.publish(cv(101), Some(cdc_with_payload(101, 10)));
504		b.publish(cv(99), Some(cdc_with_payload(99, 10)));
505		assert!(matches!(b.pull(cv(98), cv(101), ByteSize::from_mib(1)), BacklogPull::Behind));
506		match b.pull(cv(100), cv(101), ByteSize::from_mib(1)) {
507			BacklogPull::Hit {
508				items,
509				..
510			} => assert_eq!(items.len(), 1),
511			BacklogPull::Behind => panic!("expected Hit"),
512		}
513	}
514
515	#[test]
516	fn notify_fires_once_until_disarmed() {
517		// A burst of publishes must coalesce into one supervisor wake; without the re-arm on
518		// disarm, a supervisor that scanned everything would sleep through all later CDC.
519		let fired = Arc::new(AtomicUsize::new(0));
520		let b = backlog(u64::MAX);
521		let counter = fired.clone();
522		b.set_waker(move || {
523			counter.fetch_add(1, Ordering::SeqCst);
524		});
525		b.notify();
526		b.notify();
527		b.notify();
528		assert_eq!(fired.load(Ordering::SeqCst), 1, "repeat notifies while armed must coalesce");
529		b.disarm();
530		b.notify();
531		assert_eq!(fired.load(Ordering::SeqCst), 2, "a disarmed backlog must wake again");
532	}
533
534	#[test]
535	fn byte_accounting_balances_across_publish_replace_and_eviction() {
536		// The ceiling compares against this tally, so drift here breaks eviction itself, not just
537		// the reported metric.
538		let one = entry_bytes();
539		let b = backlog(u64::MAX);
540		b.publish(cv(1), Some(cdc_with_payload(1, 100)));
541		b.publish(cv(2), Some(cdc_with_payload(2, 100)));
542		b.publish(cv(2), Some(cdc_with_payload(2, 300)));
543		let mut out = Vec::new();
544		b.collect(&mut out);
545		let bytes = out
546			.iter()
547			.find(|s| s.scope == "flow_backlog" && s.metric == "bytes")
548			.map(|s| s.reading.as_f64())
549			.expect("bytes sample");
550		assert_eq!(bytes, (one + one + 200) as f64, "replacing an entry must swap its tally, not add");
551
552		b.evict_below(cv(2));
553		let mut out = Vec::new();
554		b.collect(&mut out);
555		let bytes = out
556			.iter()
557			.find(|s| s.scope == "flow_backlog" && s.metric == "bytes")
558			.map(|s| s.reading.as_f64())
559			.expect("bytes sample");
560		assert_eq!(bytes, 0.0, "evicting every entry must zero the tally");
561	}
562}