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	ops::Bound,
6	process,
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, SystemChange},
20	},
21	key::{EncodableKey, Key, cdc_consumer::CdcConsumerKey, kind::KeyKind},
22};
23use reifydb_runtime::actor::{
24	context::Context,
25	system::ActorConfig,
26	traits::{Actor, Directive},
27};
28use reifydb_transaction::transaction::Transaction;
29use reifydb_value::{Result, error::Error, reifydb_assertions, value::duration::Duration};
30use tracing::{debug, error};
31
32use super::{checkpoint::CdcCheckpoint, consumer::CdcConsume, host::CdcHost, watermark::CdcConsumerWatermark};
33use crate::storage::CdcStore;
34
35#[derive(Debug, Clone)]
36pub struct PollActorConfig {
37	pub consumer_id: CdcConsumerId,
38
39	pub poll_interval: Duration,
40
41	pub max_batch_size: Option<u64>,
42}
43
44pub struct PollActor<H: CdcHost, C: CdcConsume> {
45	config: PollActorConfig,
46	host: H,
47	consumer: Box<C>,
48	store: CdcStore,
49	consumer_key: EncodedKey,
50	consumer_watermark: Option<CdcConsumerWatermark>,
51	wake_armed: Arc<AtomicBool>,
52}
53
54impl<H: CdcHost, C: CdcConsume> PollActor<H, C> {
55	pub fn new(
56		config: PollActorConfig,
57		host: H,
58		consumer: C,
59		store: CdcStore,
60		consumer_watermark: Option<CdcConsumerWatermark>,
61		wake_armed: Arc<AtomicBool>,
62	) -> Self {
63		let consumer_key = CdcConsumerKey {
64			consumer: config.consumer_id.clone(),
65		}
66		.encode();
67
68		Self {
69			config,
70			host,
71			consumer: Box::new(consumer),
72			store,
73			consumer_key,
74			consumer_watermark,
75			wake_armed,
76		}
77	}
78
79	#[inline]
80	fn publish_watermark(&self, version: CommitVersion) {
81		if let Some(wm) = &self.consumer_watermark {
82			wm.store(version);
83		}
84	}
85
86	#[inline]
87	fn consume_wait_timeout(&self) -> Duration {
88		self.host.catalog().get_config_duration(ConfigKey::CdcConsumeWaitTimeout)
89	}
90}
91
92pub enum Phase {
93	Ready,
94
95	WaitingForWatermark,
96
97	WaitingForConsume {
98		latest_version: CommitVersion,
99
100		count: usize,
101
102		generation: u64,
103	},
104}
105
106pub struct PollState {
107	phase: Phase,
108
109	cached_checkpoint: Option<CommitVersion>,
110
111	consume_generation: u64,
112
113	consume_stall_ticks: u32,
114}
115
116impl<H: CdcHost, C: CdcConsume + Send + Sync + 'static> Actor for PollActor<H, C> {
117	type State = PollState;
118	type Message = CdcPollMessage;
119
120	fn init(&self, ctx: &Context<Self::Message>) -> Self::State {
121		debug!(
122			"[Consumer {:?}] Started polling with interval {:?}",
123			self.config.consumer_id, self.config.poll_interval
124		);
125
126		let _ = ctx.self_ref().send(CdcPollMessage::Poll);
127		let _ = ctx.schedule_once(self.config.poll_interval, || CdcPollMessage::Tick);
128
129		PollState {
130			phase: Phase::Ready,
131			cached_checkpoint: None,
132			consume_generation: 0,
133			consume_stall_ticks: 0,
134		}
135	}
136
137	fn handle(&self, state: &mut Self::State, msg: Self::Message, ctx: &Context<Self::Message>) -> Directive {
138		match msg {
139			CdcPollMessage::Poll => self.on_poll(state, ctx),
140			CdcPollMessage::CheckWatermark => self.on_check_watermark(state, ctx),
141			CdcPollMessage::ConsumeResponse {
142				generation,
143				result,
144			} => self.on_consume_response(state, ctx, generation, result),
145			CdcPollMessage::Tick => self.on_tick(state, ctx),
146			CdcPollMessage::Shutdown => {
147				debug!("[Consumer {:?}] Shutdown", self.config.consumer_id);
148				Directive::Stop
149			}
150		}
151	}
152
153	fn config(&self) -> ActorConfig {
154		ActorConfig::new()
155	}
156}
157
158impl<H: CdcHost, C: CdcConsume> PollActor<H, C> {
159	#[inline]
160	fn on_poll(&self, state: &mut PollState, ctx: &Context<CdcPollMessage>) -> Directive {
161		if !matches!(state.phase, Phase::Ready) {
162			return Directive::Continue;
163		}
164		if ctx.is_cancelled() {
165			debug!("[Consumer {:?}] Stopped", self.config.consumer_id);
166			return Directive::Stop;
167		}
168		let current_version = match self.host.current_version() {
169			Ok(v) => v,
170			Err(e) => {
171				error!("[Consumer {:?}] Error getting current version: {}", self.config.consumer_id, e);
172				return Directive::Continue;
173			}
174		};
175		if self.host.done_until() >= current_version {
176			self.start_consume(state, ctx);
177		} else {
178			state.phase = Phase::WaitingForWatermark;
179			let self_ref = ctx.self_ref();
180			self.host.notify_on_mark(
181				current_version,
182				Box::new(move || {
183					let _ = self_ref.send(CdcPollMessage::CheckWatermark);
184				}),
185			);
186		}
187		Directive::Continue
188	}
189
190	#[inline]
191	fn on_check_watermark(&self, state: &mut PollState, ctx: &Context<CdcPollMessage>) -> Directive {
192		if !matches!(state.phase, Phase::WaitingForWatermark) {
193			return Directive::Continue;
194		}
195		if ctx.is_cancelled() {
196			debug!("[Consumer {:?}] Stopped", self.config.consumer_id);
197			return Directive::Stop;
198		}
199		state.phase = Phase::Ready;
200		self.start_consume(state, ctx);
201		Directive::Continue
202	}
203
204	#[inline]
205	fn on_consume_response(
206		&self,
207		state: &mut PollState,
208		ctx: &Context<CdcPollMessage>,
209		generation: u64,
210		result: Result<()>,
211	) -> Directive {
212		if let Phase::WaitingForConsume {
213			latest_version,
214			count,
215			generation: pending,
216		} = state.phase
217		{
218			if pending != generation {
219				return Directive::Continue;
220			}
221			state.phase = Phase::Ready;
222			self.finish_consume(state, ctx, latest_version, count, result);
223		}
224		Directive::Continue
225	}
226
227	#[inline]
228	fn on_tick(&self, state: &mut PollState, ctx: &Context<CdcPollMessage>) -> Directive {
229		if ctx.is_cancelled() {
230			debug!("[Consumer {:?}] Stopped", self.config.consumer_id);
231			return Directive::Stop;
232		}
233
234		let flow = if matches!(state.phase, Phase::Ready) {
235			self.on_poll(state, ctx)
236		} else if matches!(state.phase, Phase::WaitingForWatermark) {
237			self.on_check_watermark(state, ctx)
238		} else {
239			self.check_consume_stall(state, ctx)
240		};
241		if matches!(flow, Directive::Stop) {
242			return Directive::Stop;
243		}
244
245		ctx.schedule_once(self.config.poll_interval, || CdcPollMessage::Tick);
246		Directive::Continue
247	}
248
249	#[inline]
250	fn check_consume_stall(&self, state: &mut PollState, ctx: &Context<CdcPollMessage>) -> Directive {
251		if !matches!(state.phase, Phase::WaitingForConsume { .. }) {
252			return Directive::Continue;
253		}
254		state.consume_stall_ticks = state.consume_stall_ticks.saturating_add(1);
255		if state.consume_stall_ticks < self.stall_tick_threshold() {
256			return Directive::Continue;
257		}
258		error!(
259			"[Consumer {:?}] consume reply not received within {:?}; re-dispatching batch",
260			self.config.consumer_id,
261			self.consume_wait_timeout()
262		);
263		state.phase = Phase::Ready;
264		state.consume_stall_ticks = 0;
265		let _ = ctx.self_ref().send(CdcPollMessage::Poll);
266		Directive::Continue
267	}
268
269	#[inline]
270	fn stall_tick_threshold(&self) -> u32 {
271		let consume_ms = self.consume_wait_timeout().to_std().as_millis().max(1);
272		let poll_ms = self.config.poll_interval.to_std().as_millis().max(1);
273		consume_ms.div_ceil(poll_ms).max(1) as u32
274	}
275
276	fn start_consume(&self, state: &mut PollState, ctx: &Context<CdcPollMessage>) {
277		state.phase = Phase::Ready;
278		self.wake_armed.store(false, Ordering::Release);
279		let safe_version = self.host.cdc_producer_watermark();
280		if safe_version > self.host.done_until() {
281			return;
282		}
283
284		let Some(checkpoint) = self.resolve_checkpoint(state) else {
285			return;
286		};
287		if safe_version <= checkpoint {
288			return;
289		}
290
291		let Some(transactions) = self.fetch_cdcs(checkpoint, safe_version) else {
292			return;
293		};
294		if transactions.is_empty() {
295			self.advance_checkpoint_skip_ahead(state, ctx, safe_version);
296			return;
297		}
298
299		let (count, latest_version) = summarize_batch(checkpoint, &transactions);
300		let relevant_cdcs: Vec<Cdc> = transactions.into_iter().filter(is_relevant_cdc).collect();
301
302		if relevant_cdcs.is_empty() {
303			self.advance_checkpoint_skip_ahead(state, ctx, latest_version);
304			return;
305		}
306
307		state.consume_generation = state.consume_generation.wrapping_add(1);
308		let generation = state.consume_generation;
309		state.phase = Phase::WaitingForConsume {
310			latest_version,
311			count,
312			generation,
313		};
314		state.consume_stall_ticks = 0;
315		self.dispatch_to_consumer(relevant_cdcs, generation, ctx);
316	}
317
318	#[inline]
319	fn advance_checkpoint_skip_ahead(
320		&self,
321		state: &mut PollState,
322		ctx: &Context<CdcPollMessage>,
323		latest_version: CommitVersion,
324	) {
325		reifydb_assertions! {
326			if let Some(prev) = state.cached_checkpoint {
327				assert!(
328					latest_version >= prev,
329					"the consumer checkpoint moved backwards, so CDC that was already consumed would be \
330					 re-delivered (cached checkpoint prev={}, new latest={})",
331					prev.0,
332					latest_version.0
333				);
334			}
335		}
336		state.cached_checkpoint = Some(latest_version);
337		self.publish_watermark(latest_version);
338		let _ = ctx.self_ref().send(CdcPollMessage::Poll);
339	}
340
341	#[inline]
342	fn resolve_checkpoint(&self, state: &mut PollState) -> Option<CommitVersion> {
343		if let Some(v) = state.cached_checkpoint {
344			return Some(v);
345		}
346		let v = self.seed_checkpoint_from_durable()?;
347		state.cached_checkpoint = Some(v);
348		self.publish_watermark(v);
349		Some(v)
350	}
351
352	#[inline]
353	fn seed_checkpoint_from_durable(&self) -> Option<CommitVersion> {
354		let mut query = match self.host.begin_query() {
355			Ok(q) => q,
356			Err(e) => {
357				error!("[Consumer {:?}] Error beginning query: {}", self.config.consumer_id, e);
358				return None;
359			}
360		};
361		let v = match CdcCheckpoint::fetch(&mut Transaction::Query(&mut query), &self.consumer_key) {
362			Ok(c) => c,
363			Err(e) => {
364				error!("[Consumer {:?}] Error fetching checkpoint: {}", self.config.consumer_id, e);
365				return None;
366			}
367		};
368		drop(query);
369		Some(v)
370	}
371
372	#[inline]
373	fn fetch_cdcs(&self, checkpoint: CommitVersion, safe_version: CommitVersion) -> Option<Vec<Cdc>> {
374		match self.fetch_cdcs_until(checkpoint, safe_version) {
375			Ok(t) => Some(t),
376			Err(e) => {
377				error!("[Consumer {:?}] Error fetching CDCs: {}", self.config.consumer_id, e);
378				None
379			}
380		}
381	}
382
383	#[inline]
384	fn dispatch_to_consumer(&self, cdcs: Vec<Cdc>, generation: u64, ctx: &Context<CdcPollMessage>) {
385		let self_ref = ctx.self_ref().clone();
386		let reply: Box<dyn FnOnce(Result<()>) + Send> = Box::new(move |result| {
387			let _ = self_ref.send(CdcPollMessage::ConsumeResponse {
388				generation,
389				result,
390			});
391		});
392		self.consumer.consume(cdcs, reply);
393	}
394
395	fn finish_consume(
396		&self,
397		state: &mut PollState,
398		ctx: &Context<CdcPollMessage>,
399		latest_version: CommitVersion,
400		count: usize,
401		result: Result<()>,
402	) {
403		state.phase = Phase::Ready;
404		match result {
405			Ok(()) => self.advance_after_success(state, ctx, latest_version, count),
406			Err(e) => self.abort_on_error(e),
407		}
408	}
409
410	#[inline]
411	fn advance_after_success(
412		&self,
413		state: &mut PollState,
414		ctx: &Context<CdcPollMessage>,
415		latest_version: CommitVersion,
416		count: usize,
417	) {
418		reifydb_assertions! {
419			if let Some(prev) = state.cached_checkpoint {
420				assert!(
421					latest_version >= prev,
422					"the consumer checkpoint moved backwards, so CDC that was already consumed would be \
423					 re-delivered (cached checkpoint prev={}, new latest={})",
424					prev.0,
425					latest_version.0
426				);
427			}
428		}
429		state.cached_checkpoint = Some(latest_version);
430		self.publish_watermark(latest_version);
431		if count > 0 {
432			let _ = ctx.self_ref().send(CdcPollMessage::Poll);
433		}
434	}
435
436	#[inline]
437	fn abort_on_error(&self, err: Error) -> ! {
438		error!(
439			"[Consumer {:?}] fatal error consuming events, aborting application: {}",
440			self.config.consumer_id, err
441		);
442		process::abort();
443	}
444
445	fn fetch_cdcs_until(&self, since_version: CommitVersion, until_version: CommitVersion) -> Result<Vec<Cdc>> {
446		let batch_size = self.config.max_batch_size.unwrap_or(1024);
447		let batch = self.store.read_range(
448			Bound::Excluded(since_version),
449			Bound::Included(until_version),
450			batch_size,
451		)?;
452		Ok(batch.items)
453	}
454}
455
456#[inline]
457fn summarize_batch(checkpoint: CommitVersion, transactions: &[Cdc]) -> (usize, CommitVersion) {
458	let count = transactions.len();
459	let latest_version = transactions.iter().map(|tx| tx.version).max().unwrap_or(checkpoint);
460	(count, latest_version)
461}
462
463fn is_relevant_cdc(cdc: &Cdc) -> bool {
464	!cdc.changes.is_empty() || cdc.system_changes.iter().any(is_relevant_system_change)
465}
466
467fn is_relevant_system_change(change: &SystemChange) -> bool {
468	let key = match change {
469		SystemChange::Insert {
470			key,
471			..
472		}
473		| SystemChange::Update {
474			key,
475			..
476		}
477		| SystemChange::Delete {
478			key,
479			..
480		} => key,
481	};
482	Key::kind(key)
483		.map(|kind| {
484			matches!(
485				kind,
486				KeyKind::Row
487					| KeyKind::PartitionedRow | KeyKind::Flow
488					| KeyKind::FlowNode | KeyKind::FlowNodeByFlow
489					| KeyKind::FlowEdge | KeyKind::FlowEdgeByFlow
490					| KeyKind::NamespaceFlow
491			)
492		})
493		.unwrap_or(false)
494}