1use 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 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 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 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 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 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 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 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 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 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 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}