Skip to main content

reifydb_core/
partition.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 ReifyDB
3
4use std::{collections::HashMap, hash::Hash, sync::LazyLock};
5
6use arrow_array::{ArrayRef, make_array};
7use arrow_row::{RowConverter, Rows, SortField};
8use postcard::{from_bytes, to_stdvec};
9use reifydb_codec::row::{
10	bytes::{EncodedBytes, RowBuilder},
11	shape::{RowFamily, RowShape, RowShapeField},
12};
13use reifydb_value::{
14	Result,
15	error::{Diagnostic, Error, IntoDiagnostic},
16	fragment::Fragment,
17	reifydb_assertions,
18	value::{
19		Value, blob::Blob, column_view::ColumnView, constraint::Constraint, partition::Partition,
20		value_type::ValueType,
21	},
22};
23
24use crate::{
25	interface::catalog::{column::Column, object::ObjectId},
26	value::{batch::decode_cells, column::builder::ColumnBuilder},
27};
28
29static REGISTRY_SHAPE: LazyLock<RowShape> =
30	LazyLock::new(|| RowShape::new(RowFamily::Pod, vec![RowShapeField::unconstrained("values", ValueType::Blob)]));
31
32#[derive(Debug, Default)]
33pub struct BatchPartitions {
34	pub rows: Vec<Partition>,
35	pub distinct: Vec<(Partition, Vec<Value>)>,
36}
37
38pub fn partitions_of_batch(keys: &[ColumnView<'_>]) -> BatchPartitions {
39	let rows = keys.first().map_or(0, ColumnView::len);
40	match converted_rows(keys) {
41		Some(converted) => assign(keys, rows, |row| converted.row(row)),
42		None => assign(keys, rows, |row| to_stdvec(&cell_values(keys, row)).expect("value postcard is total")),
43	}
44}
45
46fn converted_rows(keys: &[ColumnView<'_>]) -> Option<Rows> {
47	let fields = keys.iter().map(|key| SortField::new(key.array().data_type().clone())).collect();
48	let arrays: Vec<ArrayRef> = keys.iter().map(|key| make_array(key.array().to_data())).collect();
49	RowConverter::new(fields).ok()?.convert_columns(&arrays).ok()
50}
51
52fn assign<K: Hash + Eq>(keys: &[ColumnView<'_>], rows: usize, key_of: impl Fn(usize) -> K) -> BatchPartitions {
53	let mut groups: HashMap<K, Partition> = HashMap::new();
54	let mut distinct = Vec::new();
55	let mut partitions = Vec::with_capacity(rows);
56	for row in 0..rows {
57		let partition = *groups.entry(key_of(row)).or_insert_with(|| {
58			let values = cell_values(keys, row);
59			let partition = Partition::of(&values);
60			distinct.push((partition, values));
61			partition
62		});
63		partitions.push(partition);
64	}
65	BatchPartitions {
66		rows: partitions,
67		distinct,
68	}
69}
70
71fn cell_values(keys: &[ColumnView<'_>], row: usize) -> Vec<Value> {
72	keys.iter()
73		.map(|key| match key.get_value(row) {
74			Value::None {
75				..
76			} => Value::none(),
77			value => value,
78		})
79		.collect()
80}
81
82fn registry_blob(values: &[Value]) -> Value {
83	Value::Blob(Blob::from(to_stdvec(values).expect("value postcard is total")))
84}
85
86pub fn registry_row(values: &[Value]) -> EncodedBytes {
87	let mut row = REGISTRY_SHAPE.allocate_pod();
88	REGISTRY_SHAPE.set_value(&mut row, 0, &registry_blob(values));
89	row.freeze_bytes()
90}
91
92pub fn registry_matches(row: &[u8], values: &[Value]) -> bool {
93	REGISTRY_SHAPE.get_value(row, 0) == registry_blob(values)
94}
95
96pub fn registry_values(row: &[u8]) -> Vec<Value> {
97	match REGISTRY_SHAPE.get_value(row, 0) {
98		Value::Blob(blob) => from_bytes(blob.as_bytes()).expect("partition registry values must decode"),
99		other => panic!("partition registry row holds {other:?} where a blob is required"),
100	}
101}
102
103pub fn partitions_of_rows(shape: &RowShape, indices: &[usize], rows: &[EncodedBytes]) -> Result<Vec<Partition>> {
104	let columns = indices
105		.iter()
106		.map(|&index| {
107			let field = &shape.fields()[index];
108			let mut builder = ColumnBuilder::with_capacity(field.constraint.get_type(), rows.len());
109			if let Some(Constraint::Dictionary(dictionary, _)) = field.constraint.constraint() {
110				builder.set_dictionary_id(*dictionary);
111			}
112			decode_cells(&mut builder, &field.name, shape, index, rows)?;
113			Ok(builder.finish(&field.name))
114		})
115		.collect::<Result<Vec<_>>>()?;
116	let views = columns.iter().map(ColumnView::try_from).collect::<Result<Vec<_>>>()?;
117	let partitions = partitions_of_batch(&views).rows;
118	reifydb_assertions! {
119		for (row, partition) in rows.iter().zip(&partitions) {
120			let values: Vec<Value> = indices.iter().map(|&index| shape.get_value(row, index)).collect();
121			assert_eq!(
122				Partition::of(&values),
123				*partition,
124				"a partition decoded from stored rows must hash the cells the row holds"
125			);
126		}
127	}
128	Ok(partitions)
129}
130
131#[cfg_attr(not(reifydb_assertions), allow(unused_variables))]
132#[allow(clippy::disallowed_methods)]
133pub fn partition_of(columns: &[Column], partition_by: &[String], values: &[Value]) -> Partition {
134	reifydb_assertions! {
135		assert_eq!(
136			partition_by.len(),
137			values.len(),
138			"one value per partition column, otherwise the dictionary check skips columns"
139		);
140		for (name, value) in partition_by.iter().zip(values) {
141			let column = columns
142				.iter()
143				.find(|c| c.name == *name)
144				.expect("partition column must exist (validated during planning)");
145			assert!(
146				column.dictionary_id.is_none() || matches!(value, Value::DictionaryId(_) | Value::None { .. }),
147				"partition column '{}' is dictionary-encoded but was hashed from {:?}: stored rows hash the \
148				 entry id, so a plain value lands in a different partition",
149				name,
150				value
151			);
152		}
153	}
154	Partition::of(values)
155}
156
157pub fn partition_col_indices(columns: &[Column], partition_by: &[String]) -> Vec<usize> {
158	partition_by
159		.iter()
160		.map(|pb| {
161			columns.iter()
162				.position(|c| c.name == *pb)
163				.expect("partition column must exist (validated during planning)")
164		})
165		.collect()
166}
167
168#[derive(Debug, thiserror::Error)]
169pub enum PartitionError {
170	#[error("cannot change partition column via UPDATE on object {object}: partition columns are immutable")]
171	ImmutablePartitionColumn {
172		object: ObjectId,
173	},
174
175	#[error(
176		"partition hash collision on object {object}: hash {hash:032x} maps to two distinct partition value tuples"
177	)]
178	PartitionHashCollision {
179		object: ObjectId,
180		hash: u128,
181	},
182
183	#[error(
184		"partition column changed by a pre-insert hook on object {object}: the row no longer hashes to its partition"
185	)]
186	PartitionColumnChangedByHook {
187		object: ObjectId,
188	},
189
190	#[error("cannot partition by column `{column}`: a {ty} value cannot be a partition key")]
191	DigestPartitionColumn {
192		column: String,
193		ty: ValueType,
194		fragment: Fragment,
195	},
196}
197
198impl IntoDiagnostic for PartitionError {
199	fn into_diagnostic(self) -> Diagnostic {
200		match self {
201			PartitionError::ImmutablePartitionColumn {
202				object,
203			} => Diagnostic {
204				code: "PART_002".to_string(),
205				rql: None,
206				message: format!(
207					"cannot change partition column via UPDATE on object {}: partition columns are immutable",
208					object
209				),
210				column: None,
211				fragment: Fragment::None,
212				label: Some("partition column change rejected".to_string()),
213				help: Some(
214					"partition columns determine a row's physical location and cannot be updated; delete and re-insert the row instead"
215						.to_string(),
216				),
217				notes: vec![],
218				cause: None,
219				operator_chain: None,
220			},
221
222			PartitionError::PartitionHashCollision {
223				object,
224				hash,
225			} => Diagnostic {
226				code: "PART_003".to_string(),
227				rql: None,
228				message: format!(
229					"partition hash collision on object {}: hash {:032x} maps to two distinct partition value tuples",
230					object, hash
231				),
232				column: None,
233				fragment: Fragment::None,
234				label: Some("128-bit hash collision".to_string()),
235				help: Some(
236					"two distinct partition value tuples produced the same 128-bit hash; this is astronomically unlikely and points to a hashing bug or data corruption, report it as a bug"
237						.to_string(),
238				),
239				notes: vec![],
240				cause: None,
241				operator_chain: None,
242			},
243
244			PartitionError::PartitionColumnChangedByHook {
245				object,
246			} => Diagnostic {
247				code: "PART_006".to_string(),
248				rql: None,
249				message: format!(
250					"partition column changed by a pre-insert hook on object {}: the row no longer hashes to its partition",
251					object
252				),
253				column: None,
254				fragment: Fragment::None,
255				label: Some("partition column change rejected".to_string()),
256				help: Some(
257					"a pre-insert hook must not change a partition column, since the row's partition is fixed before hooks run"
258						.to_string(),
259				),
260				notes: vec![],
261				cause: None,
262				operator_chain: None,
263			},
264
265			PartitionError::DigestPartitionColumn {
266				column,
267				ty,
268				fragment,
269			} => Diagnostic {
270				code: "PART_005".to_string(),
271				rql: None,
272				message: format!("cannot partition by column `{}`: a {} value cannot be a partition key", column, ty),
273				column: None,
274				fragment,
275				label: Some("digest partition column".to_string()),
276				help: Some(
277					"a digest has no key encoding and no equality, so it cannot address a partition; partition by a column of another type"
278						.to_string(),
279				),
280				notes: vec![],
281				cause: None,
282				operator_chain: None,
283			},
284		}
285	}
286}
287
288impl From<PartitionError> for Error {
289	fn from(err: PartitionError) -> Self {
290		Error(Box::new(err.into_diagnostic()))
291	}
292}
293
294#[cfg(test)]
295#[allow(clippy::disallowed_methods)]
296mod tests {
297	use std::slice::from_ref;
298
299	use reifydb_value::value::{
300		constraint::TypeConstraint,
301		dictionary::{DictionaryEntryId, DictionaryId},
302	};
303
304	use super::*;
305	use crate::interface::catalog::{column::ColumnIndex, id::ColumnId};
306
307	fn pool(dictionary_id: Option<DictionaryId>) -> Vec<Column> {
308		vec![Column {
309			id: ColumnId(1),
310			name: "pool".to_string(),
311			constraint: TypeConstraint::unconstrained(ValueType::Utf8),
312			properties: vec![],
313			index: ColumnIndex(0),
314			auto_increment: false,
315			dictionary_id,
316		}]
317	}
318
319	#[test]
320	fn a_dictionary_partition_column_hashes_its_entry_id() {
321		// The helper must hash the stored id unchanged, otherwise it disagrees with the rows it addresses.
322		let id = DictionaryEntryId::U4(7).to_value();
323		let partition = partition_of(&pool(Some(DictionaryId(1))), &["pool".to_string()], from_ref(&id));
324		assert_eq!(partition, Partition::of(&[id]));
325	}
326
327	#[test]
328	fn a_plain_partition_column_hashes_its_plain_value() {
329		// The check must only guard dictionary columns, otherwise every plain partitioned write fails.
330		let value = Value::Utf8("aa".to_string());
331		let partition = partition_of(&pool(None), &["pool".to_string()], from_ref(&value));
332		assert_eq!(partition, Partition::of(&[value]));
333	}
334
335	#[test]
336	#[cfg(reifydb_assertions)]
337	#[should_panic(expected = "is dictionary-encoded but was hashed from")]
338	fn a_plain_value_on_a_dictionary_partition_column_is_refused() {
339		// A site hashing the plain value puts the row in another partition than its stored twins, so it must
340		// fail loudly.
341		partition_of(&pool(Some(DictionaryId(1))), &["pool".to_string()], &[Value::Utf8("aa".to_string())]);
342	}
343}