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