1use 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, ®istry_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 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 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 partition_of(&pool(Some(DictionaryId(1))), &["pool".to_string()], &[Value::Utf8("aa".to_string())]);
342 }
343}