Skip to main content

reifydb_core/flow/
operator.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 ReifyDB
3
4use serde::{Deserialize, Serialize};
5
6use crate::{
7	common::{JoinType, TimeDomain},
8	expression::Expression,
9	interface::catalog::{
10		flow::OperatorId,
11		id::{RingBufferId, SeriesId, SubscriptionId, TableId, ViewId},
12		object::ObjectId,
13		series::SeriesKey,
14	},
15	operator_with::{AggregateWith, ApplyWith, DistinctWith, JoinWith, LookupWith, WindowWith},
16	sort::SortKey,
17};
18
19#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
20pub enum OperatorDef {
21	SourceInlineData {},
22	SourceTable {
23		table: TableId,
24		time_domain: TimeDomain,
25	},
26	SourceView {
27		view: ViewId,
28	},
29	SourceRingBuffer {
30		ringbuffer: RingBufferId,
31		time_domain: TimeDomain,
32	},
33	SourceSeries {
34		series: SeriesId,
35		time_domain: TimeDomain,
36	},
37	Filter {
38		conditions: Vec<Expression>,
39	},
40	Gate {
41		conditions: Vec<Expression>,
42	},
43	Map {
44		expressions: Vec<Expression>,
45	},
46	Extend {
47		expressions: Vec<Expression>,
48	},
49	Join {
50		join_type: JoinType,
51		left: Vec<Expression>,
52		right: Vec<Expression>,
53		alias: Option<String>,
54		#[serde(default)]
55		natural: bool,
56		with: JoinWith,
57	},
58	Aggregate {
59		by: Vec<Expression>,
60		map: Vec<Expression>,
61		with: AggregateWith,
62	},
63	Append {},
64	Sort {
65		by: Vec<SortKey>,
66	},
67	Take {
68		limit: usize,
69	},
70	Distinct {
71		expressions: Vec<Expression>,
72		with: DistinctWith,
73	},
74	Apply {
75		operator: String,
76		params: Vec<Expression>,
77		with: ApplyWith,
78	},
79	SinkTableView {
80		view: ViewId,
81	},
82	SinkRingBufferView {
83		view: ViewId,
84		capacity: u64,
85	},
86	SinkSeriesView {
87		view: ViewId,
88		key: SeriesKey,
89	},
90	SinkSubscription {
91		subscription: SubscriptionId,
92	},
93	Window {
94		group_by: Vec<Expression>,
95		aggregations: Vec<Expression>,
96		with: WindowWith,
97	},
98	Lookup {
99		join_type: JoinType,
100		right: LookupObject,
101		left: Vec<Expression>,
102		alias: Option<String>,
103		with: LookupWith,
104	},
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
108pub enum LookupObject {
109	Table(TableId),
110	View(ViewId),
111	RingBuffer(RingBufferId),
112}
113
114impl OperatorDef {
115	pub fn is_source(&self) -> bool {
116		matches!(
117			self,
118			OperatorDef::SourceInlineData {}
119				| OperatorDef::SourceTable { .. }
120				| OperatorDef::SourceView { .. }
121				| OperatorDef::SourceRingBuffer { .. }
122				| OperatorDef::SourceSeries { .. }
123		)
124	}
125
126	pub fn declares_time(&self) -> bool {
127		match self {
128			OperatorDef::SourceTable {
129				time_domain,
130				..
131			}
132			| OperatorDef::SourceRingBuffer {
133				time_domain,
134				..
135			}
136			| OperatorDef::SourceSeries {
137				time_domain,
138				..
139			} => *time_domain != TimeDomain::None,
140			OperatorDef::SourceView {
141				..
142			} => true,
143			_ => false,
144		}
145	}
146
147	pub fn ticks(&self) -> bool {
148		matches!(
149			self,
150			OperatorDef::Append { .. }
151				| OperatorDef::Distinct { .. }
152				| OperatorDef::Window { .. } | OperatorDef::Apply { .. }
153				| OperatorDef::Join { .. } | OperatorDef::Lookup { .. }
154				| OperatorDef::Aggregate { .. }
155				| OperatorDef::SinkRingBufferView { .. }
156		)
157	}
158
159	pub fn label(&self) -> String {
160		match self {
161			OperatorDef::SourceInlineData {
162				..
163			} => "SourceInlineData".into(),
164			OperatorDef::SourceTable {
165				..
166			} => "SourceTable".into(),
167			OperatorDef::SourceView {
168				..
169			} => "SourceView".into(),
170			OperatorDef::SourceRingBuffer {
171				..
172			} => "SourceRingBuffer".into(),
173			OperatorDef::SourceSeries {
174				..
175			} => "SourceSeries".into(),
176			OperatorDef::Filter {
177				..
178			} => "Filter".into(),
179			OperatorDef::Gate {
180				..
181			} => "Gate".into(),
182			OperatorDef::Map {
183				..
184			} => "Map".into(),
185			OperatorDef::Extend {
186				..
187			} => "Extend".into(),
188			OperatorDef::Join {
189				..
190			} => "Join".into(),
191			OperatorDef::Aggregate {
192				..
193			} => "Aggregate".into(),
194			OperatorDef::Append {
195				..
196			} => "Append".into(),
197			OperatorDef::Sort {
198				..
199			} => "Sort".into(),
200			OperatorDef::Take {
201				..
202			} => "Take".into(),
203			OperatorDef::Distinct {
204				..
205			} => "Distinct".into(),
206			OperatorDef::Apply {
207				operator,
208				..
209			} => format!("Apply({})", operator),
210			OperatorDef::SinkTableView {
211				..
212			} => "SinkTableView".into(),
213			OperatorDef::SinkRingBufferView {
214				..
215			} => "SinkRingBufferView".into(),
216			OperatorDef::SinkSeriesView {
217				..
218			} => "SinkSeriesView".into(),
219			OperatorDef::SinkSubscription {
220				..
221			} => "SinkSubscription".into(),
222			OperatorDef::Window {
223				..
224			} => "Window".into(),
225			OperatorDef::Lookup {
226				..
227			} => "Lookup".into(),
228		}
229	}
230
231	pub fn discriminator(&self) -> u8 {
232		match self {
233			OperatorDef::SourceInlineData {
234				..
235			} => 0,
236			OperatorDef::SourceTable {
237				..
238			} => 1,
239			OperatorDef::SourceView {
240				..
241			} => 2,
242			OperatorDef::Filter {
243				..
244			} => 4,
245			OperatorDef::Map {
246				..
247			} => 5,
248			OperatorDef::Extend {
249				..
250			} => 6,
251			OperatorDef::Join {
252				..
253			} => 7,
254			OperatorDef::Aggregate {
255				..
256			} => 8,
257			OperatorDef::Append {
258				..
259			} => 9,
260			OperatorDef::Sort {
261				..
262			} => 10,
263			OperatorDef::Take {
264				..
265			} => 11,
266			OperatorDef::Distinct {
267				..
268			} => 12,
269			OperatorDef::Apply {
270				..
271			} => 13,
272			OperatorDef::SinkSubscription {
273				..
274			} => 14,
275			OperatorDef::Window {
276				..
277			} => 15,
278			OperatorDef::SourceRingBuffer {
279				..
280			} => 16,
281			OperatorDef::SourceSeries {
282				..
283			} => 17,
284			OperatorDef::Gate {
285				..
286			} => 18,
287			OperatorDef::SinkTableView {
288				..
289			} => 19,
290			OperatorDef::SinkRingBufferView {
291				..
292			} => 20,
293			OperatorDef::SinkSeriesView {
294				..
295			} => 21,
296			OperatorDef::Lookup {
297				..
298			} => 22,
299		}
300	}
301
302	pub fn source_object_id(&self) -> Option<ObjectId> {
303		match self {
304			OperatorDef::SourceTable {
305				table,
306				..
307			} => Some(ObjectId::table(*table)),
308			OperatorDef::SourceRingBuffer {
309				ringbuffer,
310				..
311			} => Some(ObjectId::ringbuffer(*ringbuffer)),
312			OperatorDef::SourceSeries {
313				series,
314				..
315			} => Some(ObjectId::series(*series)),
316			OperatorDef::SourceInlineData {
317				..
318			}
319			| OperatorDef::SourceView {
320				..
321			}
322			| OperatorDef::Filter {
323				..
324			}
325			| OperatorDef::Gate {
326				..
327			}
328			| OperatorDef::Map {
329				..
330			}
331			| OperatorDef::Extend {
332				..
333			}
334			| OperatorDef::Join {
335				..
336			}
337			| OperatorDef::Aggregate {
338				..
339			}
340			| OperatorDef::Append {
341				..
342			}
343			| OperatorDef::Sort {
344				..
345			}
346			| OperatorDef::Take {
347				..
348			}
349			| OperatorDef::Distinct {
350				..
351			}
352			| OperatorDef::Apply {
353				..
354			}
355			| OperatorDef::SinkTableView {
356				..
357			}
358			| OperatorDef::SinkRingBufferView {
359				..
360			}
361			| OperatorDef::SinkSeriesView {
362				..
363			}
364			| OperatorDef::SinkSubscription {
365				..
366			}
367			| OperatorDef::Window {
368				..
369			}
370			| OperatorDef::Lookup {
371				..
372			} => None,
373		}
374	}
375}
376
377#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
378pub struct FlowNode {
379	pub id: OperatorId,
380	pub ty: OperatorDef,
381	pub inputs: Vec<OperatorId>,
382	pub outputs: Vec<OperatorId>,
383}
384
385impl FlowNode {
386	pub fn new(id: impl Into<OperatorId>, ty: OperatorDef) -> Self {
387		Self {
388			id: id.into(),
389			ty,
390			inputs: Vec::new(),
391			outputs: Vec::new(),
392		}
393	}
394}
395
396#[cfg(test)]
397mod tests {
398	use postcard::{from_bytes, to_stdvec};
399	use reifydb_value::value::duration::Duration;
400
401	use super::{LookupObject, OperatorDef};
402	use crate::{
403		common::JoinType,
404		interface::catalog::id::{TableId, ViewId},
405		operator_with::{ApplyWith, DistinctWith, JoinWith, LookupWith},
406		row::OperatorRetention,
407	};
408
409	fn join() -> OperatorDef {
410		OperatorDef::Join {
411			join_type: JoinType::Inner,
412			left: vec![],
413			right: vec![],
414			alias: None,
415			natural: false,
416			with: JoinWith::default(),
417		}
418	}
419
420	#[test]
421	fn join_always_requests_ticks() {
422		// The graph-level gate never reads the join's per-side TTL, so only the runtime operator can decide.
423		assert!(join().ticks());
424	}
425
426	#[test]
427	fn apply_always_requests_ticks() {
428		// The graph-level gate cannot see the runtime operator, so it must register unconditionally and let
429		// the operator decide; without that a tick-capable custom operator could never be ticked at all.
430		let apply = OperatorDef::Apply {
431			operator: "compute_swap_volumes".to_string(),
432			params: vec![],
433			with: ApplyWith::default(),
434		};
435		assert!(apply.ticks());
436	}
437
438	#[test]
439	fn append_and_distinct_always_request_ticks() {
440		// The graph-level gate cannot see their TTL, so only the runtime operator can decide.
441		assert!(OperatorDef::Append {}.ticks());
442		assert!(OperatorDef::Distinct {
443			expressions: vec![],
444			with: DistinctWith {},
445		}
446		.ticks());
447	}
448
449	#[test]
450	fn sink_ringbuffer_view_always_requests_ticks() {
451		// The row TTL lives in row settings, not the node, so the graph-level gate cannot see it. Without an
452		// unconditional request the flow is never scheduled to tick and quiet partitions leak forever.
453		assert!(OperatorDef::SinkRingBufferView {
454			view: ViewId(1),
455			capacity: 1,
456		}
457		.ticks());
458	}
459
460	#[test]
461	fn stateless_nodes_do_not_request_ticks() {
462		assert!(!OperatorDef::Map {
463			expressions: vec![]
464		}
465		.ticks());
466		assert!(!OperatorDef::Filter {
467			conditions: vec![]
468		}
469		.ticks());
470	}
471
472	fn lookup(right: LookupObject) -> OperatorDef {
473		OperatorDef::Lookup {
474			join_type: JoinType::Left,
475			right,
476			left: vec![],
477			alias: Some("price".to_string()),
478			with: LookupWith {
479				retention: Some(OperatorRetention {
480					duration: Duration::from_seconds(10).unwrap(),
481				}),
482			},
483		}
484	}
485
486	#[test]
487	fn lookup_always_requests_ticks() {
488		// Left retention frees rows on ticks; without them the read-version lease never moves.
489		assert!(lookup(LookupObject::Table(TableId(1))).ticks());
490	}
491
492	#[test]
493	fn lookup_is_not_a_source_and_has_no_source_object() {
494		// A source flag would route the right side's CDC into the flow, which the lookup must never read.
495		let node = lookup(LookupObject::View(ViewId(7)));
496		assert!(!node.is_source());
497		assert!(node.source_object_id().is_none());
498	}
499
500	#[test]
501	fn lookup_discriminator_is_appended_after_every_existing_operator() {
502		// Stored operator rows keep their type byte, so a lookup must not reuse or shift an existing number.
503		let node = lookup(LookupObject::Table(TableId(1)));
504		assert_eq!(node.discriminator(), 22);
505		assert_eq!(node.label(), "Lookup");
506	}
507
508	#[test]
509	fn lookup_survives_the_persisted_encoding_with_its_right_object_and_retention() {
510		// The DAG is stored with postcard, so a lost field registers a different lookup after restart.
511		for right in [LookupObject::Table(TableId(3)), LookupObject::View(ViewId(4))] {
512			let node = lookup(right);
513			let bytes = to_stdvec(&node).expect("a lookup node must encode");
514			let decoded: OperatorDef = from_bytes(&bytes).expect("a lookup node must decode");
515			let OperatorDef::Lookup {
516				join_type,
517				right: decoded_right,
518				alias,
519				with,
520				..
521			} = decoded
522			else {
523				panic!("a lookup must decode as a lookup, got {decoded:?}");
524			};
525			assert_eq!(join_type, JoinType::Left);
526			assert_eq!(decoded_right, right);
527			assert_eq!(alias.as_deref(), Some("price"));
528			assert_eq!(with.retention.map(|r| r.duration), Some(Duration::from_seconds(10).unwrap()));
529		}
530	}
531}