Skip to main content

reifydb_sub_flow/operator/sink/
view.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 ReifyDB
3
4use postcard::to_stdvec;
5use reifydb_abi::operator::capabilities::OperatorCapability;
6use reifydb_codec::{
7	encoded::{
8		row::{EncodedRow, SHAPE_HEADER_SIZE},
9		shape::RowShape,
10	},
11	key::{encode_u8, encode_u64_varint, encoded::EncodedKey, serializer::KeySerializer},
12};
13use reifydb_core::{
14	interface::{
15		catalog::{
16			dictionary::Dictionary,
17			flow::FlowNodeId,
18			id::TableId,
19			shape::ShapeId,
20			view::{View, ViewSortKey},
21		},
22		change::{Change, ChangeOrigin, Diff},
23		resolved::ResolvedView,
24	},
25	key::{catalog::serialize_shape_id, kind::KeyKind},
26	row::row_shape_from_columns,
27	value::column::{buffer::ColumnBuffer, columns::Columns},
28};
29use reifydb_transaction::interceptor::dictionary_row::DictionaryRowInterceptor;
30use reifydb_value::{
31	Result,
32	error::Error,
33	value::{
34		Value, datetime::DateTime, dictionary::DictionaryEntryId, row_number::RowNumber, value_type::ValueType,
35	},
36};
37use smallvec::smallvec;
38
39use super::{coerce_columns, encode_row_at_index, shape_field_columns};
40use crate::{
41	Operator,
42	error::{FlowSinkError, FlowStateError},
43	operator::OperatorCell,
44	transaction::FlowTransaction,
45};
46
47pub struct SinkTableViewOperator {
48	#[allow(dead_code)]
49	parent: OperatorCell,
50	node: FlowNodeId,
51	view: ResolvedView,
52
53	key_prefix: Vec<u8>,
54	shape: RowShape,
55	sort: Vec<ViewSortKey>,
56}
57
58impl SinkTableViewOperator {
59	pub fn new(parent: OperatorCell, node: FlowNodeId, view: ResolvedView, underlying: TableId) -> Self {
60		let mut key_prefix: Vec<u8> = Vec::with_capacity(10);
61		key_prefix.push(encode_u8(KeyKind::Row as u8));
62		serialize_shape_id(&ShapeId::table(underlying), &mut key_prefix);
63		let shape = row_shape_from_columns(view.def().columns());
64		let sort = view.def().sort().to_vec();
65		Self {
66			parent,
67			node,
68			view,
69			key_prefix,
70			shape,
71			sort,
72		}
73	}
74
75	#[inline]
76	fn row_key(&self, row: RowNumber) -> EncodedKey {
77		let mut buf = Vec::with_capacity(self.key_prefix.len() + 9);
78		buf.extend_from_slice(&self.key_prefix);
79		encode_u64_varint(row.0, &mut buf);
80		EncodedKey::new(buf)
81	}
82
83	#[inline]
84	fn clustered_key(&self, cols: &Columns, row_idx: usize, row: RowNumber) -> EncodedKey {
85		if self.sort.is_empty() {
86			return self.row_key(row);
87		}
88		let mut serializer = KeySerializer::new();
89		serializer.extend_raw(&self.key_prefix);
90		for key in &self.sort {
91			let value = cols.data_at(key.column.0 as usize).get_value(row_idx);
92			serializer.extend_value_with_direction(&value, key.direction.clone().into());
93		}
94		serializer.extend_raw(&row.0.to_be_bytes());
95		serializer.to_encoded_key()
96	}
97}
98
99impl Operator for SinkTableViewOperator {
100	fn id(&self) -> FlowNodeId {
101		self.node
102	}
103
104	fn capabilities(&self) -> &[OperatorCapability] {
105		OperatorCapability::STANDARD
106	}
107
108	fn apply(&self, txn: &mut FlowTransaction, change: Change) -> Result<Change> {
109		let view = self.view.def();
110		let shape = &self.shape;
111
112		for diff in change.diffs.iter() {
113			match diff {
114				Diff::Insert {
115					post,
116					..
117				} => self.apply_table_view_insert(txn, view, shape, post)?,
118				Diff::Update {
119					pre,
120					post,
121					..
122				} => self.apply_table_view_update(txn, view, shape, pre, post)?,
123				Diff::Remove {
124					pre,
125					..
126				} => self.apply_table_view_remove(txn, view, pre)?,
127			}
128		}
129
130		Ok(Change::from_flow(self.node, change.version, Vec::new(), change.changed_at))
131	}
132}
133
134impl SinkTableViewOperator {
135	#[inline]
136	fn apply_table_view_insert(
137		&self,
138		txn: &mut FlowTransaction,
139		view: &View,
140		shape: &RowShape,
141		post: &Columns,
142	) -> Result<()> {
143		let coerced = coerce_columns(post, view.columns())?;
144		let dict_encoded = dictionary_encode_view_columns(txn, view, &coerced)?;
145		let source = dict_encoded.as_ref().unwrap_or(&coerced);
146		let row_count = source.row_count();
147		let field_columns = shape_field_columns(source, shape);
148		let mut keys: Vec<EncodedKey> = Vec::with_capacity(row_count);
149		let mut encoded_rows: Vec<EncodedRow> = Vec::with_capacity(row_count);
150
151		for row_idx in 0..row_count {
152			let row_number = source.row_numbers[row_idx];
153			let (_, encoded) = encode_row_at_index(source, row_idx, shape, row_number, &field_columns)?;
154			keys.push(self.clustered_key(source, row_idx, row_number));
155			encoded_rows.push(encoded);
156		}
157
158		txn.set_batch(&keys, &encoded_rows)?;
159
160		emit_view_change(txn, view, Diff::insert(coerced));
161		Ok(())
162	}
163
164	#[inline]
165	fn apply_table_view_update(
166		&self,
167		txn: &mut FlowTransaction,
168		view: &View,
169		shape: &RowShape,
170		pre: &Columns,
171		post: &Columns,
172	) -> Result<()> {
173		let coerced_pre = coerce_columns(pre, view.columns())?;
174		let coerced_post = coerce_columns(post, view.columns())?;
175		let dict_pre = dictionary_encode_view_columns(txn, view, &coerced_pre)?;
176		let dict_post = dictionary_encode_view_columns(txn, view, &coerced_post)?;
177		let source_pre = dict_pre.as_ref().unwrap_or(&coerced_pre);
178		let source_post = dict_post.as_ref().unwrap_or(&coerced_post);
179		let row_count = source_post.row_count();
180		let field_columns = shape_field_columns(source_post, shape);
181		let mut pre_keys: Vec<EncodedKey> = Vec::with_capacity(row_count);
182		let mut post_keys: Vec<EncodedKey> = Vec::with_capacity(row_count);
183		let mut post_encoded_rows: Vec<EncodedRow> = Vec::with_capacity(row_count);
184		for row_idx in 0..row_count {
185			let pre_row_number = source_pre.row_numbers[row_idx];
186			let post_row_number = source_post.row_numbers[row_idx];
187			let (_, mut post_encoded) =
188				encode_row_at_index(source_post, row_idx, shape, post_row_number, &field_columns)?;
189
190			let pre_key = self.clustered_key(source_pre, row_idx, pre_row_number);
191			let post_key = self.clustered_key(source_post, row_idx, post_row_number);
192
193			let prior_created = match txn.get(&post_key)? {
194				Some(prior) if prior.len() >= SHAPE_HEADER_SIZE => {
195					let c = prior.created_at_nanos();
196					if c != 0 {
197						Some(c)
198					} else {
199						None
200					}
201				}
202				_ => None,
203			};
204			if prior_created.is_none() && pre_key.as_slice() != post_key.as_slice() {
205				match txn.get(&pre_key)? {
206					Some(prior) if prior.len() >= SHAPE_HEADER_SIZE => {
207						let c = prior.created_at_nanos();
208						if c != 0 && post_encoded.len() >= SHAPE_HEADER_SIZE {
209							let updated = post_encoded.updated_at_nanos();
210							post_encoded.set_timestamps(c, updated);
211						}
212					}
213					_ => {}
214				}
215			} else if let Some(c) = prior_created
216				&& post_encoded.len() >= SHAPE_HEADER_SIZE
217			{
218				let updated = post_encoded.updated_at_nanos();
219				post_encoded.set_timestamps(c, updated);
220			}
221
222			pre_keys.push(pre_key);
223			post_keys.push(post_key);
224			post_encoded_rows.push(post_encoded);
225		}
226
227		txn.remove_batch(&pre_keys)?;
228		txn.set_batch(&post_keys, &post_encoded_rows)?;
229
230		emit_view_change(txn, view, Diff::update(coerced_pre, coerced_post));
231		Ok(())
232	}
233
234	#[inline]
235	fn apply_table_view_remove(&self, txn: &mut FlowTransaction, view: &View, pre: &Columns) -> Result<()> {
236		let coerced = coerce_columns(pre, view.columns())?;
237		let dict_encoded = dictionary_encode_view_columns(txn, view, &coerced)?;
238		let source = dict_encoded.as_ref().unwrap_or(&coerced);
239		let row_count = source.row_count();
240		let mut keys: Vec<EncodedKey> = Vec::with_capacity(row_count);
241		for row_idx in 0..row_count {
242			let row_number = source.row_numbers[row_idx];
243			keys.push(self.clustered_key(source, row_idx, row_number));
244		}
245
246		txn.remove_batch(&keys)?;
247
248		emit_view_change(txn, view, Diff::remove(coerced));
249		Ok(())
250	}
251}
252
253#[inline]
254fn emit_view_change(txn: &mut FlowTransaction, view: &View, diff: Diff) {
255	let version = txn.version();
256	let changed_at = DateTime::from_nanos(txn.clock().now_nanos());
257	txn.track_flow_change(Change {
258		origin: ChangeOrigin::Shape(ShapeId::view(view.id())),
259		version,
260		diffs: smallvec![diff],
261		changed_at,
262	});
263}
264
265pub(crate) fn dictionary_encode_view_columns(
266	txn: &mut FlowTransaction,
267	view: &View,
268	columns: &Columns,
269) -> Result<Option<Columns>> {
270	let mut dict_columns: Vec<(usize, Dictionary)> = Vec::new();
271	{
272		let catalog = txn.catalog();
273		for (pos, col) in view.columns().iter().enumerate() {
274			if let Some(dict_id) = col.dictionary_id {
275				let dictionary = catalog.cache().find_dictionary(dict_id).ok_or_else(|| {
276					Error::from(FlowSinkError::DictionaryNotFound {
277						dictionary_id: format!("{:?}", dict_id),
278						column: col.name.to_string(),
279					})
280				})?;
281				dict_columns.push((pos, dictionary));
282			}
283		}
284	}
285
286	if dict_columns.is_empty() {
287		return Ok(None);
288	}
289
290	let mut encoded = columns.clone();
291	for (col_pos, dictionary) in &dict_columns {
292		let row_count = encoded[*col_pos].len();
293		let mut new_data = ColumnBuffer::with_capacity(ValueType::DictionaryId, row_count);
294		for row_idx in 0..row_count {
295			let value = encoded[*col_pos].get_value(row_idx);
296			let entry_id = dictionary_intern(txn, dictionary, &value)?;
297			new_data.push_value(entry_id.to_value());
298		}
299		encoded.columns.make_mut()[*col_pos] = new_data;
300	}
301
302	Ok(Some(encoded))
303}
304
305fn dictionary_intern(txn: &mut FlowTransaction, dictionary: &Dictionary, value: &Value) -> Result<DictionaryEntryId> {
306	let mut values_buf = [value.clone()];
307	DictionaryRowInterceptor::pre_insert(txn, dictionary, &mut values_buf)?;
308	let [value] = values_buf;
309
310	let value_bytes = to_stdvec(&value).map_err(|e| {
311		Error::from(FlowStateError::Encode {
312			state: "value",
313			cause: e.to_string(),
314		})
315	})?;
316
317	let registry = txn.dictionary_allocators();
318	let outcome = registry.intern(dictionary, &value_bytes, txn)?;
319
320	if let Some(writes) = outcome.writes {
321		txn.set(&writes.entry_key, writes.entry_value)?;
322		txn.set(&writes.index_key, writes.index_value)?;
323	}
324
325	Ok(outcome.id)
326}
327
328#[cfg(test)]
329mod tests {
330	use postcard::from_bytes;
331	use reifydb_core::{
332		actors::pending::PendingWrite, common::CommitVersion, key::dictionary::DictionaryEntryIndexKey,
333	};
334	use reifydb_engine::test_harness::TestEngine;
335	use reifydb_value::value::identity::IdentityId;
336
337	use super::*;
338
339	// A deferred flow batch reads source data at its own commit version. Dictionary interning
340	// state (the sequence counter and entry/index rows), however, is persisted by the coordinator
341	// at a *higher* flow commit version. If a later batch's interning read resolves through that
342	// pinned source-version snapshot, it misses an earlier batch's committed sequence increment,
343	// computes a colliding id, and overwrites the existing index entry - so several distinct
344	// strings decode to one (the production symptom: 3 view rows all reading "wsol").
345	//
346	// This test reproduces the split-batch ordering deterministically: phase 1 interns and
347	// COMMITS two values, phase 2 interns a third through a transaction pinned to a version that
348	// predates phase 1's commit. Interning must still allocate a fresh id and never clobber an
349	// existing entry. The fix routes dictionary reads to the latest committed version
350	// (ReadFrom::DictionaryQuery), so phase 2 observes the committed sequence.
351	#[test]
352	fn dictionary_intern_does_not_collide_across_a_stale_version_snapshot() {
353		let t = TestEngine::new();
354		t.admin("CREATE NAMESPACE test");
355		t.admin("CREATE DICTIONARY test::syms FOR utf8 AS uint2");
356
357		let engine = t.inner();
358		let catalog = engine.catalog();
359		let namespace = catalog.cache().find_namespace_by_name("test").expect("namespace test");
360		let dictionary =
361			catalog.cache().find_dictionary_by_name(namespace.id(), "syms").expect("dictionary syms");
362
363		// Persist a deferred transaction's pending dictionary writes the way the coordinator does.
364		let commit_pending = |txn: &mut FlowTransaction| {
365			let pending = txn.take_pending();
366			let mut cmd = engine.begin_command(IdentityId::system()).unwrap();
367			cmd.disable_conflict_tracking().unwrap();
368			for (key, pw) in pending.iter_sorted() {
369				match pw {
370					PendingWrite::Set(v) => cmd.set(key, v.clone()).unwrap(),
371					PendingWrite::Remove => cmd.remove(key).unwrap(),
372					PendingWrite::Drop => cmd.drop_key(key).unwrap(),
373				};
374			}
375			cmd.commit_unchecked().unwrap()
376		};
377
378		// Phase 1 (the INSERT batch): intern sol + usdc, then commit the pending writes.
379		let parent = engine.begin_admin(IdentityId::system()).unwrap();
380		let version = parent.version();
381		let mut insert_txn = FlowTransaction::deferred(
382			&parent,
383			version,
384			catalog.clone(),
385			engine.create_interceptors(),
386			engine.clock().clone(),
387		);
388		let sol_id = dictionary_intern(&mut insert_txn, &dictionary, &Value::Utf8("sol".to_string()))
389			.unwrap()
390			.to_u128();
391		let usdc_id = dictionary_intern(&mut insert_txn, &dictionary, &Value::Utf8("usdc".to_string()))
392			.unwrap()
393			.to_u128();
394		assert_ne!(sol_id, usdc_id, "distinct strings must intern to distinct ids");
395		let phase1_commit = commit_pending(&mut insert_txn);
396
397		// Phase 2 (the UPDATE batch): a fresh deferred transaction whose source-version snapshot
398		// predates phase 1's commit. (A deferred read at version V sees commits with version <= V+1,
399		// so pinning two below the phase-1 commit excludes phase 1's persisted dictionary writes -
400		// exactly the production split-batch situation where the UPDATE batch's source version is
401		// below the flow commit that persisted the INSERT batch.) With the bug, the sequence read is
402		// stale and "wsol" reuses an id already in use, overwriting that entry.
403		let stale_version = CommitVersion(phase1_commit.0 - 2);
404		let parent = engine.begin_admin(IdentityId::system()).unwrap();
405		let mut update_txn = FlowTransaction::deferred(
406			&parent,
407			stale_version,
408			catalog.clone(),
409			engine.create_interceptors(),
410			engine.clock().clone(),
411		);
412		let wsol_id = dictionary_intern(&mut update_txn, &dictionary, &Value::Utf8("wsol".to_string()))
413			.unwrap()
414			.to_u128();
415
416		assert_ne!(wsol_id, sol_id, "wsol must not reuse sol's id (would overwrite sol's entry)");
417		assert_ne!(wsol_id, usdc_id, "wsol must not reuse usdc's id (would overwrite usdc's entry)");
418		commit_pending(&mut update_txn);
419
420		// Every interned string must still decode to itself - no entry was clobbered.
421		let decode = |id: u128| -> String {
422			let key = DictionaryEntryIndexKey::encoded(dictionary.id, id);
423			let query = engine.multi().begin_query().unwrap();
424			let bytes = query.get(&key).unwrap().expect("index entry present").row().to_vec();
425			match from_bytes::<Value>(&bytes).unwrap() {
426				Value::Utf8(s) => s,
427				other => panic!("expected Utf8, got {:?}", other),
428			}
429		};
430		assert_eq!(decode(sol_id), "sol", "sol's dictionary entry was overwritten");
431		assert_eq!(decode(usdc_id), "usdc", "usdc's dictionary entry was overwritten");
432		assert_eq!(decode(wsol_id), "wsol");
433	}
434}