Skip to main content

reifydb_core/key/operator/keyspace/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 ReifyDB
3
4pub mod distinct;
5pub mod expiry;
6pub mod join;
7pub mod ringbuffer;
8pub mod root;
9pub mod timer;
10pub mod window;
11
12use reifydb_codec::row::{operator::state::OperatorState, pod::EncodedPodRow};
13#[cfg(test)]
14use reifydb_value::util::hash::Hash128;
15
16#[cfg(test)]
17use crate::key::typed::{BoundedKey, DenseKey};
18use crate::{
19	key::{
20		operator::{
21			keyspace::{
22				distinct::{DistinctEntry, DistinctLayout},
23				expiry::{Expiry, ReapQueue, TumblingExpiry},
24				join::{
25					JoinExpiryDue, JoinLeft, JoinPin, JoinPublished, JoinRight, JoinRowExpiry,
26					JoinRowExpiryState, JoinRowExpirySuffix, JoinRowMapping, JoinSchema,
27					join_expiry_due_key,
28				},
29				ringbuffer::{
30					PartitionedRingbufferEntry, PartitionedRingbufferExpiry,
31					PartitionedRingbufferMeta, PartitionedRingbufferTtlArm, RingbufferEntry,
32					RingbufferExpiry, RingbufferForward, RingbufferMeta, RingbufferTtlArm,
33				},
34				root::{
35					CustomNotCached, GateVisibility, GroupRowMapping, GuestRowMapping, NodeCounter,
36					SealLedger, SourceWatermark,
37				},
38				timer::{TimerIndex, TimerWheel},
39				window::{
40					Accumulator, Buffer, Count, Emit, EngineMeta, GuestAccumulator, GuestBuffer,
41					GuestRunning, RollingMeta, RowIndex, Running, Session, WindowMeta,
42				},
43			},
44			state::{GroupId, GroupStateKey, KeyspaceId, OperatorStateKey},
45			traits::{Keyspace, group_scoped},
46		},
47		typed::layout::{KeyColumn, KeyLayout},
48	},
49	state::typed::SuffixBytes,
50};
51
52#[derive(Clone, Copy, Debug, PartialEq, Eq)]
53pub struct KeyspaceSpec {
54	pub name: &'static str,
55	pub id: KeyspaceId,
56	pub range_cached: bool,
57	pub columns: &'static [KeyColumn],
58	pub suffix: &'static [KeyColumn],
59}
60
61pub const fn columns_width(columns: &[KeyColumn]) -> usize {
62	let mut width = 0;
63	let mut index = 0;
64	while index < columns.len() {
65		width += columns[index].ty.width();
66		index += 1;
67	}
68	width
69}
70
71impl KeyspaceSpec {
72	pub const fn suffix_width(&self) -> usize {
73		columns_width(self.suffix)
74	}
75}
76
77struct GroupScoped;
78
79impl KeyspaceVisitor for GroupScoped {
80	type Output = bool;
81
82	fn visit<K: Keyspace>(self) -> Self::Output {
83		const { group_scoped::<K>() }
84	}
85}
86
87/// The runtime form of `group_scoped`, for callers that hold an id rather than the type. `None` names
88/// a keyspace the catalogue does not carry.
89pub fn group_scoped_id(id: KeyspaceId) -> Option<bool> {
90	dispatch(id, GroupScoped)
91}
92
93pub trait KeyspaceVisitor {
94	type Output;
95
96	fn visit<K: Keyspace>(self) -> Self::Output;
97}
98
99macro_rules! catalogue {
100	($($keyspace:ty),* $(,)?) => {
101		pub const KEYSPACES: &[KeyspaceSpec] = &[
102			$(KeyspaceSpec {
103				name: <$keyspace as Keyspace>::NAME,
104				id: <$keyspace as Keyspace>::ID,
105				range_cached: <$keyspace as Keyspace>::RANGE_CACHED,
106				columns: <<$keyspace as Keyspace>::GroupedKey as KeyLayout>::COLUMNS,
107				suffix: <<$keyspace as Keyspace>::Suffix as KeyLayout>::COLUMNS,
108			}),*
109		];
110
111		pub const REGISTERED: [u64; 4] = {
112			let mut bits = [0u64; 4];
113			$({
114				let id = <$keyspace as Keyspace>::ID.0;
115				bits[(id >> 6) as usize] |= 1u64 << (id & 63);
116			})*
117			bits
118		};
119
120		pub fn suffix_width_of(id: KeyspaceId) -> Option<usize> {
121			$(if id == <$keyspace as Keyspace>::ID {
122				return Some(columns_width(<<$keyspace as Keyspace>::Suffix as KeyLayout>::COLUMNS));
123			})*
124			None
125		}
126
127		pub fn dispatch<V: KeyspaceVisitor>(id: KeyspaceId, visitor: V) -> Option<V::Output> {
128			$(if id == <$keyspace as Keyspace>::ID {
129				return Some(visitor.visit::<$keyspace>());
130			})*
131			None
132		}
133
134		#[cfg(test)]
135		fn every_keyspace_round_trips() {
136			$(round_trips::<$keyspace>();)*
137		}
138
139		#[cfg(test)]
140		fn every_keyspace_carries_its_group() {
141			$(carries_its_group::<$keyspace>();)*
142		}
143
144		#[cfg(test)]
145		fn group_scoped_keyspaces() -> usize {
146			let mut count = 0;
147			$(if group_scoped::<$keyspace>() {
148				count += 1;
149			})*
150			count
151		}
152	};
153}
154
155catalogue!(
156	Accumulator,
157	Buffer,
158	Running,
159	Count,
160	Session,
161	RollingMeta,
162	EngineMeta,
163	Emit,
164	RowIndex,
165	WindowMeta,
166	GuestAccumulator,
167	GuestBuffer,
168	GuestRunning,
169	JoinLeft,
170	JoinRight,
171	JoinPublished,
172	JoinPin,
173	JoinSchema,
174	JoinRowExpiry,
175	JoinExpiryDue,
176	JoinRowMapping,
177	RingbufferForward,
178	RingbufferEntry,
179	RingbufferExpiry,
180	RingbufferTtlArm,
181	RingbufferMeta,
182	PartitionedRingbufferEntry,
183	PartitionedRingbufferExpiry,
184	PartitionedRingbufferTtlArm,
185	PartitionedRingbufferMeta,
186	TimerWheel,
187	TimerIndex,
188	Expiry,
189	TumblingExpiry,
190	ReapQueue,
191	DistinctEntry,
192	DistinctLayout,
193	SourceWatermark,
194	SealLedger,
195	NodeCounter,
196	GateVisibility,
197	GroupRowMapping,
198	GuestRowMapping,
199	CustomNotCached,
200);
201
202#[derive(Clone, Debug)]
203pub enum RootSibling {
204	Derived(GroupStateKey),
205	OwnerCleared,
206	None,
207}
208
209pub fn root_sibling(group: GroupId, keyspace: KeyspaceId, suffix: &[u8], row: &EncodedPodRow) -> RootSibling {
210	match keyspace {
211		KeyspaceId::JOIN_ROW_EXPIRY => join_row_expiry_sibling(group, suffix, row),
212
213		KeyspaceId::ACCUMULATOR
214		| KeyspaceId::BUFFER
215		| KeyspaceId::RUNNING
216		| KeyspaceId::COUNT
217		| KeyspaceId::SESSION
218		| KeyspaceId::ROLLING_META
219		| KeyspaceId::ENGINE_META
220		| KeyspaceId::EMIT
221		| KeyspaceId::ROW_INDEX
222		| KeyspaceId::WINDOW_META
223		| KeyspaceId::GUEST_ACCUMULATOR
224		| KeyspaceId::GUEST_BUFFER
225		| KeyspaceId::GUEST_RUNNING
226		| KeyspaceId::JOIN_LEFT
227		| KeyspaceId::JOIN_RIGHT => RootSibling::OwnerCleared,
228
229		KeyspaceId::JOIN_PUBLISHED
230		| KeyspaceId::JOIN_PIN
231		| KeyspaceId::JOIN_SCHEMA
232		| KeyspaceId::JOIN_EXPIRY_DUE
233		| KeyspaceId::JOIN_ROW_MAPPING
234		| KeyspaceId::DISTINCT_ENTRY
235		| KeyspaceId::DISTINCT_LAYOUT
236		| KeyspaceId::ROLLING_EXPIRY
237		| KeyspaceId::TUMBLING_EXPIRY
238		| KeyspaceId::REAP_QUEUE
239		| KeyspaceId::SOURCE_WATERMARK
240		| KeyspaceId::SEAL_LEDGER
241		| KeyspaceId::NODE_COUNTER
242		| KeyspaceId::GATE_VISIBILITY
243		| KeyspaceId::GROUP_ROW_MAPPING
244		| KeyspaceId::GUEST_ROW_MAPPING
245		| KeyspaceId::CUSTOM_NOT_CACHED
246		| KeyspaceId::RINGBUFFER_FORWARD
247		| KeyspaceId::RINGBUFFER_ENTRY
248		| KeyspaceId::RINGBUFFER_EXPIRY
249		| KeyspaceId::RINGBUFFER_TTL_ARM
250		| KeyspaceId::RINGBUFFER_META
251		| KeyspaceId::PARTITIONED_RINGBUFFER_ENTRY
252		| KeyspaceId::PARTITIONED_RINGBUFFER_EXPIRY
253		| KeyspaceId::PARTITIONED_RINGBUFFER_TTL_ARM
254		| KeyspaceId::PARTITIONED_RINGBUFFER_META
255		| KeyspaceId::TIMER_WHEEL
256		| KeyspaceId::TIMER_INDEX => RootSibling::None,
257
258		other => panic!(
259			"keyspace {} answers nothing about root siblings; a keyspace that reaches a group sweep \
260			 unclassified would leave whatever it points at outside the group orphaned behind a group \
261			 id nothing can resolve again",
262			other.name()
263		),
264	}
265}
266
267fn join_row_expiry_sibling(group: GroupId, suffix: &[u8], row: &EncodedPodRow) -> RootSibling {
268	let Some(suffix) = JoinRowExpirySuffix::from_suffix_bytes(suffix) else {
269		panic!("a join row expiry key carries a suffix that keyspace cannot decode");
270	};
271	let at = match JoinRowExpiryState::decode_state(row) {
272		Ok(state) => state.at,
273		Err(err) => {
274			panic!("a join row expiry row will not decode, so its due index key cannot be derived: {err}")
275		}
276	};
277	RootSibling::Derived(join_expiry_due_key(at, group, suffix.side.0, suffix.row.0))
278}
279
280pub fn root_sibling_of(key: &GroupStateKey, row: &EncodedPodRow) -> Option<RootSibling> {
281	let (group, keyspace, suffix) = OperatorStateKey::decode_inner(key.as_encoded().as_bytes())?;
282	Some(root_sibling(group, keyspace, suffix, row))
283}
284
285#[cfg(test)]
286fn round_trips<K: Keyspace>() {
287	// low() is every column at the start of its own order, so a join that hardcoded a column to its
288	// minimum would round trip against low() alone; stepping the suffix first is what makes the
289	// probe able to fail
290	let mut suffix = <K::Suffix as BoundedKey>::low();
291	for step in 0..4 {
292		let key = K::join(GroupId::hashed(Hash128(9)), suffix.clone());
293		let (group, split) = K::split(&key);
294		assert_eq!(split, suffix, "{}: step {step}: a suffix must survive join then split", K::NAME);
295		assert_eq!(
296			K::join(group, split.clone()),
297			key,
298			"{}: step {step}: split then join must return the same key",
299			K::NAME
300		);
301		let (again_group, again_split) = K::split(&K::join(group, split.clone()));
302		assert_eq!(
303			(again_group, again_split),
304			(group, split),
305			"{}: step {step}: a second round trip must not drift, or the container's identity is \
306			 lost on every rewrite",
307			K::NAME
308		);
309		match suffix.successor() {
310			Some(next) => suffix = next,
311			None => break,
312		}
313	}
314}
315
316#[cfg(test)]
317fn carries_its_group<K: Keyspace>() {
318	// a keyspace whose typed layout drops the group answers every group's read with one shared row: the
319	// sqlite primary key loses the column, so writes from different groups overwrite each other and reads
320	// come back stamped GroupId::ROOT. The suffix round trip alone cannot see it, because a join that
321	// hardcodes ROOT still returns the suffix it was handed. A keyspace is group-scoped exactly when its
322	// key is its suffix behind a leading group column; one whose key adds nothing to its suffix carries
323	// the group as payload at most, is ROOT-only by construction, and must collapse every group to ROOT.
324	let inside_one_group = group_scoped::<K>();
325	let mut suffix = <K::Suffix as BoundedKey>::low();
326	for step in 0..4 {
327		for group in [
328			GroupId::ROOT,
329			GroupId::hashed(Hash128(1)),
330			GroupId::hashed(Hash128(9)),
331			GroupId::hashed(Hash128(u128::MAX)),
332		] {
333			let (back, _) = K::split(&K::join(group, suffix.clone()));
334			if inside_one_group {
335				assert_eq!(
336					back,
337					group,
338					"{}: step {step}: a group must survive join then split",
339					K::NAME
340				);
341			} else {
342				assert_eq!(
343					back,
344					GroupId::ROOT,
345					"{}: step {step}: a keyspace with no group column must collapse every group to \
346					 ROOT, or its writers believe a group is kept that the key cannot hold",
347					K::NAME
348				);
349			}
350		}
351		if inside_one_group {
352			let distinct = K::join(GroupId::hashed(Hash128(1)), suffix.clone());
353			let other = K::join(GroupId::hashed(Hash128(9)), suffix.clone());
354			assert_ne!(
355				distinct.to_suffix_bytes(),
356				other.to_suffix_bytes(),
357				"{}: step {step}: two groups holding the same suffix must not encode to one primary key",
358				K::NAME
359			);
360		}
361		match suffix.successor() {
362			Some(next) => suffix = next,
363			None => break,
364		}
365	}
366}
367
368#[cfg(test)]
369mod tests {
370	use std::collections::HashSet;
371
372	use reifydb_codec::row::operator::state::OperatorState;
373	use reifydb_value::{
374		util::hash::Hash128,
375		value::{datetime::DateTime, row_number::RowNumber},
376	};
377
378	use super::{
379		KEYSPACES, RootSibling, every_keyspace_carries_its_group, every_keyspace_round_trips,
380		group_scoped_keyspaces, root_sibling,
381	};
382	use crate::{
383		key::{
384			operator::{
385				keyspace::join::{JoinRowExpiryState, JoinRowExpirySuffix},
386				state::{GroupId, KeyspaceId},
387			},
388			typed::direction::Asc,
389		},
390		state::typed::SuffixBytes,
391	};
392
393	fn catalogue() -> Vec<(&'static str, KeyspaceId, bool)> {
394		KEYSPACES.iter().map(|spec| (spec.name, spec.id, spec.range_cached)).collect()
395	}
396
397	#[test]
398	fn every_registered_keyspace_answers_what_it_implies_outside_its_group() {
399		// A keyspace that never answers reaches a group sweep unclassified and orphans whatever it points at
400		// outside the group.
401		let suffix = JoinRowExpirySuffix {
402			side: Asc(0),
403			row: Asc(RowNumber(1)),
404		}
405		.to_suffix_bytes();
406		let row = JoinRowExpiryState {
407			at: DateTime::default(),
408		}
409		.encode_state()
410		.unwrap();
411		let mut derived = Vec::new();
412		for spec in KEYSPACES {
413			if let RootSibling::Derived(_) =
414				root_sibling(GroupId::hashed(Hash128(7)), spec.id, &suffix, &row)
415			{
416				derived.push(spec.name);
417			}
418		}
419		assert_eq!(
420			derived,
421			vec!["JOIN_ROW_EXPIRY"],
422			"only a keyspace whose ROOT key is a function of its own key and row may be reaped by \
423			 construction; any other name here claims a derivation it does not have"
424		);
425	}
426
427	#[test]
428	fn every_keyspace_names_and_tiers_itself_the_way_its_id_does() {
429		// the impl writes NAME and RANGE_CACHED down by hand and KeyspaceId answers them separately, so this is
430		// the only place the two lists are forced to agree; a keyspace that quietly changed tiers on one
431		// side would otherwise be cached by the store and uncached by the catalogue
432		for (name, id, range_cached) in catalogue() {
433			assert_eq!(name, id.name(), "{name} and its id disagree on the name");
434			assert_eq!(range_cached, id.caches_ranges(), "{name} and its id disagree on the range tier");
435		}
436	}
437
438	#[test]
439	fn no_two_keyspaces_claim_the_same_id() {
440		// two keyspaces on one id is exactly the bug R20, R22 and R25 exist to undo, and it is invisible
441		// at runtime: the second one's rows simply decode as the first one's shape
442		let mut seen = HashSet::new();
443		for (name, id, _) in catalogue() {
444			assert!(seen.insert(id), "{name} reuses an id another keyspace already claims");
445		}
446		assert_eq!(seen.len(), 44, "the catalogue is forty four keyspaces");
447	}
448
449	#[test]
450	fn join_and_split_are_inverse_for_every_keyspace() {
451		// R15: the range tier stores only the suffix and rebuilds the key from its partition identity, so
452		// a lossy split silently drops a key column on every read back
453		every_keyspace_round_trips();
454	}
455
456	#[test]
457	fn a_group_survives_join_and_split_for_every_keyspace() {
458		// the group is the only thing separating one operator group's rows from another's; a keyspace that
459		// declares the column but hardcodes GroupId::ROOT in split collapses them all onto one primary key,
460		// so a sweep of group A reaps rows belonging to B and the rows A really holds are never named. The
461		// converse half holds ROOT-only keyspaces to ROOT, so a writer cannot pass a group the key drops
462		every_keyspace_carries_its_group();
463	}
464
465	#[test]
466	fn exactly_twenty_four_of_the_forty_four_keyspaces_are_group_scoped() {
467		// a dropped group column silently reclassifies a keyspace and the sweep follows it
468		assert_eq!(
469			KEYSPACES.len(),
470			44,
471			"a keyspace was added or removed without revisiting the group scope split"
472		);
473		assert_eq!(
474			group_scoped_keyspaces(),
475			24,
476			"a keyspace changed group scope; confirm its key layout meant to"
477		);
478	}
479
480	#[test]
481	fn the_catalogue_covers_every_id_the_substrate_declares() {
482		// A declared id with no keyspace has no typed key, so its writers fall back to raw suffix bytes
483		// nothing round-trips and the range tier rebuilds a key it cannot decode. There is no reflection
484		// over associated constants, so counting the declarations in the source is the only way to notice
485		// an id nobody gave a keyspace; 0xFE was the last one and S10 retired it.
486		let source = include_str!("../state.rs");
487		let body = source
488			.split("impl KeyspaceId {")
489			.nth(1)
490			.expect("the KeyspaceId impl block is where the constants are declared");
491		let declared = body
492			.split("\n}\n")
493			.next()
494			.expect("the impl block is closed")
495			.lines()
496			.filter(|line| {
497				let line = line.trim_start();
498				line.starts_with("pub const") && line.contains("Self(")
499			})
500			.count();
501		assert_eq!(
502			catalogue().len(),
503			declared,
504			"the substrate declares {declared} keyspace ids and the catalogue types {}",
505			catalogue().len()
506		);
507	}
508}