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