1use std::{
5 backtrace::Backtrace,
6 ops::Bound,
7 sync::{
8 Arc,
9 atomic::{AtomicBool, Ordering},
10 },
11};
12
13use reifydb_core::{
14 actors::cdc::CdcPollMessage,
15 common::CommitVersion,
16 interface::{
17 catalog::config::{ConfigKey, GetConfig},
18 cdc::{Cdc, CdcConsumerId, CheckpointState},
19 },
20 key::cdc::CdcConsumerKey,
21};
22use reifydb_runtime::{
23 actor::{
24 context::Context,
25 system::ActorConfig,
26 traits::{Actor, Directive},
27 },
28 fatal::{
29 fatal,
30 report::{FatalKind, FatalReport},
31 },
32};
33use reifydb_store_cdc::{storage::CdcStorage, store::CdcStore};
34use reifydb_transaction::{error::TransactionError, transaction::Transaction};
35use reifydb_value::{Result, error::Error, reifydb_assertions, value::duration::Duration};
36use tracing::{debug, error};
37
38use super::{
39 checkpoint::{CdcCheckpoint, CheckpointRow},
40 consumer::CdcConsume,
41 host::CdcHost,
42 is_relevant_cdc,
43 watermark::CdcConsumerWatermark,
44};
45
46#[derive(Debug, Clone)]
47pub struct PollActorConfig {
48 pub consumer_id: CdcConsumerId,
49
50 pub poll_interval: Duration,
51
52 pub max_batch_size: Option<u64>,
53}
54
55pub struct PollActor<H: CdcHost, C: CdcConsume> {
56 config: PollActorConfig,
57 host: H,
58 consumer: Box<C>,
59 store: CdcStore,
60 consumer_key: CdcConsumerKey,
61 consumer_watermark: Option<CdcConsumerWatermark>,
62 wake_armed: Arc<AtomicBool>,
63}
64
65impl<H: CdcHost, C: CdcConsume> PollActor<H, C> {
66 pub fn new(
67 config: PollActorConfig,
68 host: H,
69 consumer: C,
70 store: CdcStore,
71 consumer_watermark: Option<CdcConsumerWatermark>,
72 wake_armed: Arc<AtomicBool>,
73 ) -> Self {
74 let consumer_key = CdcConsumerKey {
75 consumer: config.consumer_id.clone(),
76 };
77
78 Self {
79 config,
80 host,
81 consumer: Box::new(consumer),
82 store,
83 consumer_key,
84 consumer_watermark,
85 wake_armed,
86 }
87 }
88
89 #[inline]
90 fn publish_watermark(&self, version: CommitVersion) {
91 if let Some(wm) = &self.consumer_watermark {
92 wm.store(version);
93 }
94 }
95
96 #[inline]
97 fn consume_wait_timeout(&self) -> Duration {
98 self.host.catalog().get_config_duration(ConfigKey::CdcConsumeWaitTimeout)
99 }
100}
101
102pub enum Phase {
103 Ready,
104
105 WaitingForWatermark,
106
107 WaitingForConsume {
108 latest_version: CommitVersion,
109
110 count: usize,
111
112 generation: u64,
113 },
114
115 WaitingForResync {
116 generation: u64,
117 },
118}
119
120pub struct PollState {
121 phase: Phase,
122
123 cached_checkpoint: Option<CommitVersion>,
124
125 consume_generation: u64,
126
127 consume_stall_ticks: u32,
128}
129
130impl<H: CdcHost, C: CdcConsume + Send + Sync + 'static> Actor for PollActor<H, C> {
131 type State = PollState;
132 type Message = CdcPollMessage;
133
134 fn init(&self, ctx: &Context<Self::Message>) -> Self::State {
135 debug!(
136 "[Consumer {:?}] Started polling with interval {:?}",
137 self.config.consumer_id, self.config.poll_interval
138 );
139
140 let _ = ctx.self_ref().send(CdcPollMessage::Poll);
141 let _ = ctx.schedule_once(self.config.poll_interval, || CdcPollMessage::Tick);
142
143 PollState {
144 phase: Phase::Ready,
145 cached_checkpoint: None,
146 consume_generation: 0,
147 consume_stall_ticks: 0,
148 }
149 }
150
151 fn handle(&self, state: &mut Self::State, msg: Self::Message, ctx: &Context<Self::Message>) -> Directive {
152 match msg {
153 CdcPollMessage::Poll => self.on_poll(state, ctx),
154 CdcPollMessage::CheckWatermark => self.on_check_watermark(state, ctx),
155 CdcPollMessage::ConsumeResponse {
156 generation,
157 result,
158 } => self.on_consume_response(state, ctx, generation, result),
159 CdcPollMessage::ResyncResponse {
160 generation,
161 result,
162 } => self.on_resync_response(state, ctx, generation, result),
163 CdcPollMessage::Tick => self.on_tick(state, ctx),
164 CdcPollMessage::Shutdown => {
165 debug!("[Consumer {:?}] Shutdown", self.config.consumer_id);
166 Directive::Stop
167 }
168 }
169 }
170
171 fn config(&self) -> ActorConfig {
172 ActorConfig::new()
173 }
174}
175
176impl<H: CdcHost, C: CdcConsume> PollActor<H, C> {
177 #[inline]
178 fn on_poll(&self, state: &mut PollState, ctx: &Context<CdcPollMessage>) -> Directive {
179 if !matches!(state.phase, Phase::Ready) {
180 return Directive::Continue;
181 }
182 if ctx.is_cancelled() {
183 debug!("[Consumer {:?}] Stopped", self.config.consumer_id);
184 return Directive::Stop;
185 }
186 let current_version = match self.host.current_version() {
187 Ok(v) => v,
188 Err(e) => {
189 error!("[Consumer {:?}] Error getting current version: {}", self.config.consumer_id, e);
190 return Directive::Continue;
191 }
192 };
193 if self.host.done_until() >= current_version {
194 self.start_consume(state, ctx);
195 } else {
196 state.phase = Phase::WaitingForWatermark;
197 let self_ref = ctx.self_ref();
198 self.host.notify_on_mark(
199 current_version,
200 Box::new(move || {
201 let _ = self_ref.send(CdcPollMessage::CheckWatermark);
202 }),
203 );
204 }
205 Directive::Continue
206 }
207
208 #[inline]
209 fn on_check_watermark(&self, state: &mut PollState, ctx: &Context<CdcPollMessage>) -> Directive {
210 if !matches!(state.phase, Phase::WaitingForWatermark) {
211 return Directive::Continue;
212 }
213 if ctx.is_cancelled() {
214 debug!("[Consumer {:?}] Stopped", self.config.consumer_id);
215 return Directive::Stop;
216 }
217 state.phase = Phase::Ready;
218 self.start_consume(state, ctx);
219 Directive::Continue
220 }
221
222 #[inline]
223 fn on_consume_response(
224 &self,
225 state: &mut PollState,
226 ctx: &Context<CdcPollMessage>,
227 generation: u64,
228 result: Result<()>,
229 ) -> Directive {
230 if let Phase::WaitingForConsume {
231 latest_version,
232 count,
233 generation: pending,
234 } = state.phase
235 {
236 if pending != generation {
237 return Directive::Continue;
238 }
239 state.phase = Phase::Ready;
240 self.finish_consume(state, ctx, latest_version, count, result);
241 }
242 Directive::Continue
243 }
244
245 #[inline]
246 fn on_tick(&self, state: &mut PollState, ctx: &Context<CdcPollMessage>) -> Directive {
247 if ctx.is_cancelled() {
248 debug!("[Consumer {:?}] Stopped", self.config.consumer_id);
249 return Directive::Stop;
250 }
251
252 let flow = if matches!(state.phase, Phase::Ready) {
253 self.on_poll(state, ctx)
254 } else if matches!(state.phase, Phase::WaitingForWatermark) {
255 self.on_check_watermark(state, ctx)
256 } else {
257 self.check_consume_stall(state, ctx)
258 };
259 if matches!(flow, Directive::Stop) {
260 return Directive::Stop;
261 }
262
263 ctx.schedule_once(self.config.poll_interval, || CdcPollMessage::Tick);
264 Directive::Continue
265 }
266
267 #[inline]
268 fn check_consume_stall(&self, state: &mut PollState, ctx: &Context<CdcPollMessage>) -> Directive {
269 if !matches!(state.phase, Phase::WaitingForConsume { .. } | Phase::WaitingForResync { .. }) {
270 return Directive::Continue;
271 }
272 state.consume_stall_ticks = state.consume_stall_ticks.saturating_add(1);
273 if state.consume_stall_ticks < self.stall_tick_threshold() {
274 return Directive::Continue;
275 }
276 error!(
277 "[Consumer {:?}] consume reply not received within {:?}; re-dispatching batch",
278 self.config.consumer_id,
279 self.consume_wait_timeout()
280 );
281 state.phase = Phase::Ready;
282 state.consume_stall_ticks = 0;
283 let _ = ctx.self_ref().send(CdcPollMessage::Poll);
284 Directive::Continue
285 }
286
287 #[inline]
288 fn stall_tick_threshold(&self) -> u32 {
289 let consume_ms = self.consume_wait_timeout().to_std().as_millis().max(1);
290 let poll_ms = self.config.poll_interval.to_std().as_millis().max(1);
291 consume_ms.div_ceil(poll_ms).max(1) as u32
292 }
293
294 fn start_consume(&self, state: &mut PollState, ctx: &Context<CdcPollMessage>) {
295 state.phase = Phase::Ready;
296 self.wake_armed.store(false, Ordering::Release);
297 let safe_version = self.host.cdc_producer_watermark();
298 if safe_version > self.host.done_until() {
299 return;
300 }
301
302 let Some(checkpoint) = self.resolve_checkpoint(state, ctx) else {
303 return;
304 };
305 let truncated_before = match self.store.truncated_before() {
306 Ok(v) => v,
307 Err(e) => {
308 error!(
309 "[Consumer {:?}] Error reading truncation floor: {}",
310 self.config.consumer_id, e
311 );
312 return;
313 }
314 };
315 if checkpoint.0.saturating_add(1) < truncated_before.0 {
316 self.begin_resync(state, ctx, checkpoint, truncated_before);
317 return;
318 }
319 if safe_version <= checkpoint {
320 return;
321 }
322
323 let Some(transactions) = self.fetch_cdcs(checkpoint, safe_version) else {
324 return;
325 };
326 if transactions.is_empty() {
327 self.advance_checkpoint_skip_ahead(state, ctx, safe_version);
328 return;
329 }
330
331 let (count, latest_version) = summarize_batch(checkpoint, &transactions);
332 let relevant_cdcs: Vec<Cdc> = transactions.into_iter().filter(is_relevant_cdc).collect();
333
334 if relevant_cdcs.is_empty() {
335 self.advance_checkpoint_skip_ahead(state, ctx, latest_version);
336 return;
337 }
338
339 state.consume_generation = state.consume_generation.wrapping_add(1);
340 let generation = state.consume_generation;
341 state.phase = Phase::WaitingForConsume {
342 latest_version,
343 count,
344 generation,
345 };
346 state.consume_stall_ticks = 0;
347 self.dispatch_to_consumer(relevant_cdcs, generation, ctx);
348 }
349
350 #[inline]
351 fn advance_checkpoint_skip_ahead(
352 &self,
353 state: &mut PollState,
354 ctx: &Context<CdcPollMessage>,
355 latest_version: CommitVersion,
356 ) {
357 reifydb_assertions! {
358 if let Some(prev) = state.cached_checkpoint {
359 assert!(
360 latest_version >= prev,
361 "the consumer checkpoint moved backwards, so CDC that was already consumed would be \
362 re-delivered (cached checkpoint prev={}, new latest={})",
363 prev.0,
364 latest_version.0
365 );
366 }
367 }
368 state.cached_checkpoint = Some(latest_version);
369 self.publish_watermark(latest_version);
370 let _ = ctx.self_ref().send(CdcPollMessage::Poll);
371 }
372
373 #[inline]
374 fn resolve_checkpoint(&self, state: &mut PollState, ctx: &Context<CdcPollMessage>) -> Option<CommitVersion> {
375 if let Some(v) = state.cached_checkpoint {
376 return Some(v);
377 }
378 let row = self.seed_checkpoint_from_durable()?;
379 if let Some(row) = &row
380 && row.state == CheckpointState::Invalidated
381 {
382 let truncated = self.store.truncated_before().unwrap_or(row.version);
383 self.begin_resync(state, ctx, row.version, truncated);
384 return None;
385 }
386 let v = match row {
387 Some(r) => r.version,
388 None => {
389 let floor = self.store.truncated_before().unwrap_or(CommitVersion(0));
390 CommitVersion(floor.0.saturating_sub(1).max(1))
391 }
392 };
393 state.cached_checkpoint = Some(v);
394 self.publish_watermark(v);
395 Some(v)
396 }
397
398 #[inline]
399 fn seed_checkpoint_from_durable(&self) -> Option<Option<CheckpointRow>> {
400 let mut query = match self.host.begin_query() {
401 Ok(q) => q,
402 Err(e) => {
403 error!("[Consumer {:?}] Error beginning query: {}", self.config.consumer_id, e);
404 return None;
405 }
406 };
407 let row = match CdcCheckpoint::fetch_row(&mut Transaction::Query(&mut query), &self.consumer_key) {
408 Ok(c) => c,
409 Err(e) => {
410 error!("[Consumer {:?}] Error fetching checkpoint: {}", self.config.consumer_id, e);
411 return None;
412 }
413 };
414 drop(query);
415 Some(row)
416 }
417
418 #[inline]
419 fn fetch_cdcs(&self, checkpoint: CommitVersion, safe_version: CommitVersion) -> Option<Vec<Cdc>> {
420 match self.fetch_cdcs_until(checkpoint, safe_version) {
421 Ok(t) => Some(t),
422 Err(e) => {
423 error!("[Consumer {:?}] Error fetching CDCs: {}", self.config.consumer_id, e);
424 None
425 }
426 }
427 }
428
429 #[inline]
430 fn dispatch_to_consumer(&self, cdcs: Vec<Cdc>, generation: u64, ctx: &Context<CdcPollMessage>) {
431 let self_ref = ctx.self_ref().clone();
432 let reply: Box<dyn FnOnce(Result<()>) + Send> = Box::new(move |result| {
433 let _ = self_ref.send(CdcPollMessage::ConsumeResponse {
434 generation,
435 result,
436 });
437 });
438 self.consumer.consume(cdcs, reply);
439 }
440
441 fn finish_consume(
442 &self,
443 state: &mut PollState,
444 ctx: &Context<CdcPollMessage>,
445 latest_version: CommitVersion,
446 count: usize,
447 result: Result<()>,
448 ) {
449 state.phase = Phase::Ready;
450 match result {
451 Ok(()) => self.advance_after_success(state, ctx, latest_version, count),
452 Err(e) if TransactionError::is_consumer_overtaken(&e)
453 || TransactionError::is_snapshot_evicted(&e) =>
454 {
455 let cursor = state.cached_checkpoint.unwrap_or(CommitVersion(0));
456 let truncated = self.store.truncated_before().unwrap_or(cursor);
457 error!(
458 "[Consumer {:?}] batch reads lost their MVCC history ({}); starting resync",
459 self.config.consumer_id, e
460 );
461 self.begin_resync(state, ctx, cursor, truncated);
462 }
463 Err(e) => self.abort_on_error(e),
464 }
465 }
466
467 fn begin_resync(
468 &self,
469 state: &mut PollState,
470 ctx: &Context<CdcPollMessage>,
471 cursor: CommitVersion,
472 truncated_before: CommitVersion,
473 ) {
474 error!(
475 "[Consumer {:?}] overtaken by retention: cursor {} is behind the truncation floor {}",
476 self.config.consumer_id, cursor.0, truncated_before.0
477 );
478 self.invalidate_durable_checkpoint();
479 state.consume_generation = state.consume_generation.wrapping_add(1);
480 let generation = state.consume_generation;
481 state.phase = Phase::WaitingForResync {
482 generation,
483 };
484 state.consume_stall_ticks = 0;
485 let self_ref = ctx.self_ref().clone();
486 let reply: Box<dyn FnOnce(Result<CommitVersion>) + Send> = Box::new(move |result| {
487 let _ = self_ref.send(CdcPollMessage::ResyncResponse {
488 generation,
489 result,
490 });
491 });
492 self.consumer.overtaken(cursor, truncated_before, reply);
493 }
494
495 #[inline]
496 fn invalidate_durable_checkpoint(&self) {
497 let result = self.host.begin_command().and_then(|mut txn| {
498 CdcCheckpoint::invalidate(&mut txn, &self.consumer_key)?;
499 txn.commit()
500 });
501 if let Err(e) = result {
502 error!("[Consumer {:?}] Error invalidating checkpoint: {}", self.config.consumer_id, e);
503 }
504 }
505
506 fn on_resync_response(
507 &self,
508 state: &mut PollState,
509 ctx: &Context<CdcPollMessage>,
510 generation: u64,
511 result: Result<CommitVersion>,
512 ) -> Directive {
513 let Phase::WaitingForResync {
514 generation: pending,
515 } = state.phase
516 else {
517 return Directive::Continue;
518 };
519 if pending != generation {
520 return Directive::Continue;
521 }
522 state.phase = Phase::Ready;
523 match result {
524 Ok(resume) => {
525 reifydb_assertions! {
526 let floor = self.store.truncated_before().map(|v| v.0).unwrap_or(0);
527 assert!(
528 resume.0.saturating_add(1) >= floor,
529 "a resync must resume at or past the truncation floor, or the next poll \
530 detects the same gap and the consumer loops forever (resume={}, floor={})",
531 resume.0,
532 floor
533 );
534 }
535 state.cached_checkpoint = Some(resume);
536 self.publish_watermark(resume);
537 let _ = ctx.self_ref().send(CdcPollMessage::Poll);
538 Directive::Continue
539 }
540 Err(e) => self.abort_on_error(e),
541 }
542 }
543
544 #[inline]
545 fn advance_after_success(
546 &self,
547 state: &mut PollState,
548 ctx: &Context<CdcPollMessage>,
549 latest_version: CommitVersion,
550 count: usize,
551 ) {
552 reifydb_assertions! {
553 if let Some(prev) = state.cached_checkpoint {
554 assert!(
555 latest_version >= prev,
556 "the consumer checkpoint moved backwards, so CDC that was already consumed would be \
557 re-delivered (cached checkpoint prev={}, new latest={})",
558 prev.0,
559 latest_version.0
560 );
561 }
562 }
563 state.cached_checkpoint = Some(latest_version);
564 self.publish_watermark(latest_version);
565 if count > 0 {
566 let _ = ctx.self_ref().send(CdcPollMessage::Poll);
567 }
568 }
569
570 #[inline]
571 fn abort_on_error(&self, err: Error) -> ! {
572 fatal(FatalReport::new(FatalKind::Error, format!("{}", err))
573 .component("cdc consumer")
574 .with("consumer", format!("{:?}", self.config.consumer_id))
575 .backtrace(Backtrace::force_capture().to_string()))
576 }
577
578 fn fetch_cdcs_until(&self, since_version: CommitVersion, until_version: CommitVersion) -> Result<Vec<Cdc>> {
579 let batch_size = self.config.max_batch_size.unwrap_or(1024);
580 let batch = self.store.read_range(
581 Bound::Excluded(since_version),
582 Bound::Included(until_version),
583 batch_size,
584 )?;
585 Ok(batch.items)
586 }
587}
588
589#[inline]
590fn summarize_batch(checkpoint: CommitVersion, transactions: &[Cdc]) -> (usize, CommitVersion) {
591 let count = transactions.len();
592 let latest_version = transactions.iter().map(|tx| tx.version).max().unwrap_or(checkpoint);
593 (count, latest_version)
594}