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