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::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 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 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 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 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 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 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 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 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 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 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}