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