Skip to main content

reifydb_cdc/consume/
actor.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 ReifyDB
3
4use 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}