Skip to main content

reifydb_cdc/storage/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 ReifyDB
3
4//! Pluggable backing store for the CDC log. The in-memory implementation is the testing default; SQLite is the
5//! durable default for production deployments. Both implement the same trait surface so the producer and consumer
6//! sides are agnostic to which is configured.
7
8pub mod memory;
9#[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))]
10pub mod sqlite;
11
12use std::{
13	collections::{Bound, HashMap},
14	sync,
15};
16
17use memory::MemoryCdcStorage;
18use reifydb_catalog::metrics::storage::parser::parse_id;
19use reifydb_core::{
20	common::CommitVersion,
21	event::metric::CdcEviction,
22	interface::{
23		catalog::metrics::MetricsId,
24		cdc::{Cdc, CdcBatch, SystemChange},
25	},
26};
27use reifydb_runtime::shutdown::Shutdown;
28#[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))]
29use reifydb_sqlite::SqliteConfig;
30use reifydb_value::{byte_size::ByteSize, count::Count, value::datetime::DateTime};
31
32use crate::error::CdcError;
33
34pub type CdcStorageResult<T> = Result<T, CdcError>;
35
36enum ScanContinuation {
37	Done(CommitVersion),
38	Continue(Bound<CommitVersion>),
39}
40
41#[inline]
42fn scan_batch_for_cutoff(items: &[Cdc], cutoff: DateTime) -> Option<CommitVersion> {
43	for cdc in items {
44		if cdc.timestamp >= cutoff {
45			return Some(cdc.version);
46		}
47	}
48	None
49}
50
51#[inline]
52fn next_start_after_batch(batch: &CdcBatch, max: CommitVersion) -> ScanContinuation {
53	if !batch.has_more {
54		return ScanContinuation::Done(CommitVersion(max.0.saturating_add(1)));
55	}
56	let last = batch.items.last().unwrap().version;
57	ScanContinuation::Continue(Bound::Excluded(last))
58}
59
60#[inline]
61pub(crate) fn normalize_range_inclusive(
62	start: Bound<CommitVersion>,
63	end: Bound<CommitVersion>,
64) -> Option<(CommitVersion, CommitVersion)> {
65	let lo_inc = match start {
66		Bound::Included(v) => v,
67		Bound::Excluded(v) => CommitVersion(v.0.saturating_add(1)),
68		Bound::Unbounded => CommitVersion(0),
69	};
70	let hi_inc = match end {
71		Bound::Included(v) => v,
72		Bound::Excluded(v) => CommitVersion(v.0.saturating_sub(1)),
73		Bound::Unbounded => CommitVersion(u64::MAX),
74	};
75	if lo_inc > hi_inc {
76		None
77	} else {
78		Some((lo_inc, hi_inc))
79	}
80}
81
82#[derive(Debug, Clone, Default)]
83pub struct DropBeforeResult {
84	pub count: Count,
85	pub entries: Vec<CdcEviction>,
86	pub more_remaining: bool,
87}
88
89/// One entry per source, matching exactly what the metrics gauge subtracts on eviction; storing the
90/// aggregate lets eviction skip decoding payloads.
91pub(crate) fn aggregate_evictions<'a, I>(system_changes: I) -> Vec<CdcEviction>
92where
93	I: IntoIterator<Item = &'a SystemChange>,
94{
95	let mut by_source: HashMap<MetricsId, CdcEviction> = HashMap::new();
96	for change in system_changes {
97		let key = change.key();
98		let id = parse_id(key.as_ref());
99		let entry = by_source.entry(id).or_insert_with(|| CdcEviction {
100			id,
101			key_bytes: ByteSize::ZERO,
102			value_bytes: ByteSize::ZERO,
103			count: Count::ZERO,
104		});
105		entry.key_bytes = entry.key_bytes.saturating_add(ByteSize::from_bytes(key.as_ref().len() as u64));
106		entry.value_bytes = entry.value_bytes.saturating_add(ByteSize::from_bytes(change.value_bytes() as u64));
107		entry.count = entry.count.saturating_add(Count::new(1));
108	}
109	by_source.into_values().collect()
110}
111
112pub(crate) fn total_evicted_count(evictions: &[CdcEviction]) -> Count {
113	evictions.iter().fold(Count::ZERO, |acc, e| acc.saturating_add(e.count))
114}
115
116/// Merges per-record/per-block rollups into one entry per source, so a single eviction reports at
117/// most one aggregate per source regardless of how many records/blocks contributed.
118#[cfg(not(target_arch = "wasm32"))]
119pub(crate) fn merge_evictions(evictions: Vec<CdcEviction>) -> Vec<CdcEviction> {
120	let mut by_source: HashMap<MetricsId, CdcEviction> = HashMap::new();
121	for e in evictions {
122		let acc = by_source.entry(e.id).or_insert_with(|| CdcEviction {
123			id: e.id,
124			key_bytes: ByteSize::ZERO,
125			value_bytes: ByteSize::ZERO,
126			count: Count::ZERO,
127		});
128		acc.key_bytes = acc.key_bytes.saturating_add(e.key_bytes);
129		acc.value_bytes = acc.value_bytes.saturating_add(e.value_bytes);
130		acc.count = acc.count.saturating_add(e.count);
131	}
132	by_source.into_values().collect()
133}
134
135pub trait CdcStorage: Send + Sync + Clone + 'static {
136	fn write(&self, cdc: &Cdc) -> CdcStorageResult<()>;
137
138	fn read(&self, version: CommitVersion) -> CdcStorageResult<Option<Cdc>>;
139
140	fn read_range(
141		&self,
142		start: Bound<CommitVersion>,
143		end: Bound<CommitVersion>,
144		batch_size: u64,
145	) -> CdcStorageResult<CdcBatch>;
146
147	fn count(&self, version: CommitVersion) -> CdcStorageResult<usize>;
148
149	fn min_version(&self) -> CdcStorageResult<Option<CommitVersion>>;
150
151	fn max_version(&self) -> CdcStorageResult<Option<CommitVersion>>;
152
153	fn exists(&self, version: CommitVersion) -> CdcStorageResult<bool> {
154		Ok(self.read(version)?.is_some())
155	}
156
157	fn drop_before(&self, version: CommitVersion, limit: usize) -> CdcStorageResult<DropBeforeResult>;
158
159	fn truncated_before(&self) -> CdcStorageResult<CommitVersion>;
160
161	fn find_ttl_cutoff(&self, cutoff: DateTime) -> CdcStorageResult<Option<CommitVersion>> {
162		let Some(min) = self.min_version()? else {
163			return Ok(None);
164		};
165		let Some(max) = self.max_version()? else {
166			return Ok(None);
167		};
168
169		let mut next_start = Bound::Included(min);
170		loop {
171			let batch = self.read_range(next_start, Bound::Unbounded, 256)?;
172			if batch.items.is_empty() {
173				return Ok(Some(CommitVersion(max.0.saturating_add(1))));
174			}
175			if let Some(version) = scan_batch_for_cutoff(&batch.items, cutoff) {
176				return Ok(Some(version));
177			}
178			match next_start_after_batch(&batch, max) {
179				ScanContinuation::Done(v) => return Ok(Some(v)),
180				ScanContinuation::Continue(start) => next_start = start,
181			}
182		}
183	}
184
185	fn range(&self, start: Bound<CommitVersion>, end: Bound<CommitVersion>) -> CdcStorageResult<CdcBatch> {
186		self.read_range(start, end, 1024)
187	}
188
189	fn scan(&self, batch_size: u64) -> CdcStorageResult<CdcBatch> {
190		self.read_range(Bound::Unbounded, Bound::Unbounded, batch_size)
191	}
192}
193
194impl<T: CdcStorage> CdcStorage for sync::Arc<T> {
195	fn write(&self, cdc: &Cdc) -> CdcStorageResult<()> {
196		(**self).write(cdc)
197	}
198
199	fn read(&self, version: CommitVersion) -> CdcStorageResult<Option<Cdc>> {
200		(**self).read(version)
201	}
202
203	fn read_range(
204		&self,
205		start: Bound<CommitVersion>,
206		end: Bound<CommitVersion>,
207		batch_size: u64,
208	) -> CdcStorageResult<CdcBatch> {
209		(**self).read_range(start, end, batch_size)
210	}
211
212	fn count(&self, version: CommitVersion) -> CdcStorageResult<usize> {
213		(**self).count(version)
214	}
215
216	fn min_version(&self) -> CdcStorageResult<Option<CommitVersion>> {
217		(**self).min_version()
218	}
219
220	fn max_version(&self) -> CdcStorageResult<Option<CommitVersion>> {
221		(**self).max_version()
222	}
223
224	fn drop_before(&self, version: CommitVersion, limit: usize) -> CdcStorageResult<DropBeforeResult> {
225		(**self).drop_before(version, limit)
226	}
227
228	fn truncated_before(&self) -> CdcStorageResult<CommitVersion> {
229		(**self).truncated_before()
230	}
231
232	fn find_ttl_cutoff(&self, cutoff: DateTime) -> CdcStorageResult<Option<CommitVersion>> {
233		(**self).find_ttl_cutoff(cutoff)
234	}
235}
236
237#[derive(Clone)]
238pub enum CdcStore {
239	Memory(MemoryCdcStorage),
240
241	#[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))]
242	Sqlite(sqlite::storage::SqliteCdcStorage),
243}
244
245#[derive(Clone)]
246pub struct CdcHotReader {
247	store: CdcStore,
248}
249
250impl CdcHotReader {
251	pub fn read_range(
252		&self,
253		start: Bound<CommitVersion>,
254		end: Bound<CommitVersion>,
255		batch_size: u64,
256	) -> CdcStorageResult<CdcBatch> {
257		match &self.store {
258			CdcStore::Memory(s) => s.read_range(start, end, batch_size),
259			#[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))]
260			CdcStore::Sqlite(s) => s.read_range_hot(start, end, batch_size),
261		}
262	}
263}
264
265impl Shutdown for CdcStore {
266	fn shutdown(&self) {
267		match self {
268			Self::Memory(_) => {}
269			#[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))]
270			Self::Sqlite(s) => s.shutdown(),
271		}
272	}
273}
274
275impl CdcStore {
276	pub fn memory() -> Self {
277		Self::Memory(MemoryCdcStorage::new())
278	}
279
280	#[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))]
281	pub fn sqlite(config: SqliteConfig) -> Self {
282		Self::Sqlite(sqlite::storage::SqliteCdcStorage::new(config))
283	}
284
285	#[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))]
286	pub fn sqlite_with_block_cache_capacity(config: SqliteConfig, block_cache_capacity: usize) -> Self {
287		Self::Sqlite(sqlite::storage::SqliteCdcStorage::new_with_cache_capacity(config, block_cache_capacity))
288	}
289
290	pub fn hot_reader(&self) -> CdcHotReader {
291		CdcHotReader {
292			store: self.clone(),
293		}
294	}
295
296	#[cfg_attr(any(not(feature = "sqlite"), target_arch = "wasm32"), allow(unused_variables))]
297	pub fn write(&self, cdc: &Cdc) -> CdcStorageResult<()> {
298		match self {
299			Self::Memory(s) => s.write(cdc),
300			#[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))]
301			Self::Sqlite(s) => s.write(cdc),
302		}
303	}
304
305	pub fn read(&self, version: CommitVersion) -> CdcStorageResult<Option<Cdc>> {
306		match self {
307			Self::Memory(s) => s.read(version),
308			#[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))]
309			Self::Sqlite(s) => s.read(version),
310		}
311	}
312
313	pub fn read_range(
314		&self,
315		start: Bound<CommitVersion>,
316		end: Bound<CommitVersion>,
317		batch_size: u64,
318	) -> CdcStorageResult<CdcBatch> {
319		match self {
320			Self::Memory(s) => s.read_range(start, end, batch_size),
321			#[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))]
322			Self::Sqlite(s) => s.read_range(start, end, batch_size),
323		}
324	}
325
326	pub fn count(&self, version: CommitVersion) -> CdcStorageResult<usize> {
327		match self {
328			Self::Memory(s) => s.count(version),
329			#[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))]
330			Self::Sqlite(s) => s.count(version),
331		}
332	}
333
334	pub fn min_version(&self) -> CdcStorageResult<Option<CommitVersion>> {
335		match self {
336			Self::Memory(s) => s.min_version(),
337			#[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))]
338			Self::Sqlite(s) => s.min_version(),
339		}
340	}
341
342	pub fn max_version(&self) -> CdcStorageResult<Option<CommitVersion>> {
343		match self {
344			Self::Memory(s) => s.max_version(),
345			#[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))]
346			Self::Sqlite(s) => s.max_version(),
347		}
348	}
349
350	pub fn delete_before(&self, version: CommitVersion, limit: usize) -> CdcStorageResult<DropBeforeResult> {
351		match self {
352			Self::Memory(s) => s.drop_before(version, limit),
353			#[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))]
354			Self::Sqlite(s) => s.drop_before(version, limit),
355		}
356	}
357
358	pub fn find_ttl_cutoff(&self, cutoff: DateTime) -> CdcStorageResult<Option<CommitVersion>> {
359		match self {
360			Self::Memory(s) => s.find_ttl_cutoff(cutoff),
361			#[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))]
362			Self::Sqlite(s) => s.find_ttl_cutoff(cutoff),
363		}
364	}
365
366	pub fn truncated_before(&self) -> CdcStorageResult<CommitVersion> {
367		match self {
368			Self::Memory(s) => s.truncated_before(),
369			#[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))]
370			Self::Sqlite(s) => s.truncated_before(),
371		}
372	}
373}
374
375impl CdcStorage for CdcStore {
376	fn write(&self, cdc: &Cdc) -> CdcStorageResult<()> {
377		CdcStore::write(self, cdc)
378	}
379
380	fn read(&self, version: CommitVersion) -> CdcStorageResult<Option<Cdc>> {
381		CdcStore::read(self, version)
382	}
383
384	fn read_range(
385		&self,
386		start: Bound<CommitVersion>,
387		end: Bound<CommitVersion>,
388		batch_size: u64,
389	) -> CdcStorageResult<CdcBatch> {
390		CdcStore::read_range(self, start, end, batch_size)
391	}
392
393	fn count(&self, version: CommitVersion) -> CdcStorageResult<usize> {
394		CdcStore::count(self, version)
395	}
396
397	fn min_version(&self) -> CdcStorageResult<Option<CommitVersion>> {
398		CdcStore::min_version(self)
399	}
400
401	fn max_version(&self) -> CdcStorageResult<Option<CommitVersion>> {
402		CdcStore::max_version(self)
403	}
404
405	fn drop_before(&self, version: CommitVersion, limit: usize) -> CdcStorageResult<DropBeforeResult> {
406		CdcStore::delete_before(self, version, limit)
407	}
408
409	fn truncated_before(&self) -> CdcStorageResult<CommitVersion> {
410		CdcStore::truncated_before(self)
411	}
412
413	fn find_ttl_cutoff(&self, cutoff: DateTime) -> CdcStorageResult<Option<CommitVersion>> {
414		CdcStore::find_ttl_cutoff(self, cutoff)
415	}
416}