Skip to main content

linkrs_query/executor/streaming/operators/
base.rs

1use std::sync::Arc;
2
3use parking_lot::Mutex;
4
5use super::super::runtime::{ExecutionRuntime, OperatorProfileKey};
6use super::super::spill::SpillManager;
7use super::super::state::{
8    GlobalState, GlobalStateKey, LocalState, LocalStateKey, StateArenaSet, TaskId,
9};
10use crate::executor::streaming::plan::types::PhysicalOperatorId;
11use crate::executor::streaming::slot::SlotLayout;
12use linkrs_core::Arena;
13/// Explicit operator lifecycle state machine.
14///
15/// # Transitions
16/// - `New` → `Opened`: on successful `open()`
17/// - `Opened` → `Exhausted`: when `advance()` returns `None`
18/// - `Opened` / `Exhausted` → `Stopped`: when consumer calls `stop()` early
19/// - Any → `Failed`: unrecoverable error (set during error cleanup)
20/// - Any non-terminal → `Closed`: after `close()` completes (idempotent)
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum OperatorLifecycle {
23    /// Before `open()` has been called.
24    New,
25    /// After successful `open()`; can produce chunks via `advance()`.
26    Opened,
27    /// All data has been consumed (`advance()` returned `None`).
28    Exhausted,
29    /// Consumer signalled early termination via `stop()`.
30    Stopped,
31    /// Unrecoverable error during execution.
32    Failed,
33    /// Resources released. Terminal state.
34    Closed,
35}
36
37impl OperatorLifecycle {
38    /// Whether the operator was opened and not yet stopped/failed/closed.
39    /// `Exhausted` is included so the close guard works after natural exhaustion.
40    pub fn is_opened(self) -> bool {
41        matches!(self, Self::Opened | Self::Exhausted)
42    }
43
44    pub fn is_exhausted(self) -> bool {
45        matches!(self, Self::Exhausted)
46    }
47
48    pub fn mark_opened(&mut self) {
49        debug_assert!(
50            matches!(*self, Self::New),
51            "mark_opened from {:?} (expected New)",
52            self
53        );
54        *self = Self::Opened;
55    }
56
57    pub fn mark_exhausted(&mut self) {
58        if matches!(*self, Self::Opened) {
59            *self = Self::Exhausted;
60        }
61    }
62
63    pub fn mark_stopped(&mut self) {
64        if matches!(*self, Self::Opened | Self::Exhausted) {
65            *self = Self::Stopped;
66        }
67    }
68
69    pub fn mark_failed(&mut self) {
70        *self = Self::Failed;
71    }
72
73    pub fn mark_closed(&mut self) {
74        *self = Self::Closed;
75    }
76}
77
78#[derive(Debug)]
79pub struct OperatorBase {
80    pub plan_node_id: i64,
81    pub runtime: Option<Arc<ExecutionRuntime>>,
82    pub lifecycle: OperatorLifecycle,
83    /// Whether this operator produces global (merged) output.
84    /// Local operators process one partition at a time.
85    pub is_global: bool,
86    /// Local partition that owns this operator. `None` denotes a global or
87    /// non-partitioned operator.
88    pub partition_id: Option<usize>,
89    /// Rows per chunk when this operator produces output.
90    /// Source operators use this value directly; unary/blocking operators
91    /// pass through whatever they receive from their child.
92    pub chunk_size: usize,
93    /// Unique physical identity used for this operator's mutable runtime
94    /// state. Production materialization must provide the arena-assigned ID.
95    pub physical_operator_id: PhysicalOperatorId,
96    /// Immutable output layout supplied by the physical plan.  Operators use
97    /// this for chunks they construct themselves, including empty-result
98    /// paths, rather than inferring schema from data rows.
99    pub output_layout: Arc<SlotLayout>,
100    /// Set when a `reset()` call degraded to the transitional `close + open`
101    /// fallback for this operator (see `StreamingExecutor::reset`). Surfaced
102    /// in EXPLAIN as `reset:fallback` so un-resettable paths stay auditable.
103    pub reset_used_fallback: bool,
104}
105
106impl OperatorBase {
107    pub fn new(plan_node_id: i64) -> Self {
108        Self {
109            plan_node_id,
110            runtime: None,
111            lifecycle: OperatorLifecycle::New,
112            is_global: false,
113            partition_id: None,
114            chunk_size: 2048,
115            physical_operator_id: PhysicalOperatorId(plan_node_id.unsigned_abs() as usize),
116            output_layout: Arc::new(SlotLayout::new(Vec::new())),
117            reset_used_fallback: false,
118        }
119    }
120
121    /// Lock and return the partition-local operator state arena from the runtime.
122    ///
123    /// Each partition gets its own arena so parallel workers don't contend.
124    /// Panics if no runtime is attached.
125    pub fn state_arena(&self) -> parking_lot::MutexGuard<'_, StateArenaSet> {
126        self.runtime
127            .as_ref()
128            .expect("runtime required")
129            .state_arena_for(self.partition_id)
130            .lock()
131    }
132
133    /// Override the tree-node identity with the arena-assigned physical
134    /// operator identity. This is required by production materialization.
135    pub fn with_physical_operator_id(mut self, operator_id: PhysicalOperatorId) -> Self {
136        self.physical_operator_id = operator_id;
137        self
138    }
139
140    /// Attach the output contract layout during physical-plan materialization.
141    pub fn with_output_layout(mut self, output_layout: Arc<SlotLayout>) -> Self {
142        self.output_layout = output_layout;
143        self
144    }
145
146    /// Return the [`GlobalStateKey`] for this operator's slot.
147    pub fn state_key(&self) -> GlobalStateKey {
148        GlobalStateKey(self.physical_operator_id, self.partition_id)
149    }
150
151    /// Take the [`GlobalState`] out of the arena (for cleanup).
152    pub fn take_state(&mut self) -> Option<GlobalState> {
153        let rt = self.runtime.as_ref()?;
154        let key = self.state_key();
155        rt.state_arena_for(self.partition_id)
156            .lock()
157            .global
158            .remove(&key)
159    }
160
161    /// Insert a [`GlobalState`] into this operator's slot in the runtime arena.
162    ///
163    /// No-op when no runtime is attached (e.g. in unit tests that construct
164    /// executor trees directly without an [`ExecutionRuntime`]).
165    pub fn insert_state(&mut self, state: GlobalState) {
166        let Some(rt) = self.runtime.as_ref() else {
167            return;
168        };
169        let key = self.state_key();
170        rt.state_arena_for(self.partition_id)
171            .lock()
172            .global
173            .insert(key, state);
174    }
175
176    // ── Local state access (per-task) ──
177
178    /// Return the [`LocalStateKey`] for this operator + task.
179    pub fn local_state_key(&self, task_id: TaskId) -> LocalStateKey {
180        LocalStateKey(self.physical_operator_id, self.partition_id, task_id)
181    }
182
183    /// Insert a [`LocalState`] into the arena for a given task.
184    pub fn insert_local_state(&mut self, task_id: TaskId, state: LocalState) {
185        let Some(rt) = self.runtime.as_ref() else {
186            return;
187        };
188        let key = self.local_state_key(task_id);
189        rt.state_arena_for(self.partition_id)
190            .lock()
191            .local
192            .insert(key, state);
193    }
194
195    /// Take a [`LocalState`] out of the arena (for cleanup).
196    pub fn take_local_state(&mut self, task_id: TaskId) -> Option<LocalState> {
197        let rt = self.runtime.as_ref()?;
198        let key = self.local_state_key(task_id);
199        rt.state_arena_for(self.partition_id)
200            .lock()
201            .local
202            .remove(&key)
203    }
204
205    /// Access local state for a given task via a closure.
206    ///
207    /// The closure receives `Option<&mut LocalState>` scoped within the
208    /// state arena lock.  Returns the closure's result.
209    pub fn with_local_state<T, F>(&self, task_id: TaskId, f: F) -> Option<T>
210    where
211        F: FnOnce(&mut LocalState) -> T,
212    {
213        let rt = self.runtime.as_ref()?;
214        let key = self.local_state_key(task_id);
215        let mut arena = rt.state_arena_for(self.partition_id).lock();
216        arena.local.get_mut(&key).map(f)
217    }
218
219    pub fn with_chunk_size(mut self, chunk_size: usize) -> Self {
220        self.chunk_size = chunk_size;
221        self
222    }
223
224    pub fn with_runtime(mut self, rt: Option<Arc<ExecutionRuntime>>) -> Self {
225        self.runtime = rt;
226        self
227    }
228
229    pub fn with_global(mut self, is_global: bool) -> Self {
230        self.is_global = is_global;
231        self
232    }
233
234    pub fn with_partition(mut self, partition_id: usize) -> Self {
235        self.partition_id = Some(partition_id);
236        self
237    }
238
239    pub fn profile_key(&self) -> OperatorProfileKey {
240        OperatorProfileKey::new(self.physical_operator_id, self.partition_id)
241    }
242
243    pub fn ensure_not_cancelled(&self) -> Result<(), linkrs_core::error::QueryError> {
244        if let Some(rt) = &self.runtime {
245            rt.ensure_not_cancelled()
246        } else {
247            Ok(())
248        }
249    }
250
251    pub fn record_profile_rows(&self, count: u64) {
252        if let Some(rt) = &self.runtime {
253            if let Some(entry) = rt.profile().get_entry(&self.profile_key()) {
254                entry
255                    .output_rows
256                    .fetch_add(count, std::sync::atomic::Ordering::Relaxed);
257                entry
258                    .advance_count
259                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
260            }
261        }
262    }
263
264    /// Mark this operator instance as ran-to-exhaustion in the profile.
265    ///
266    /// Called when `advance` returns `None`: operators that never exhaust
267    /// were starved (Limit / early termination) and their partial counts
268    /// must not train the feedback loop.
269    pub fn mark_profile_exhausted(&self) {
270        if let Some(rt) = &self.runtime {
271            if let Some(entry) = rt.profile().get_entry(&self.profile_key()) {
272                entry
273                    .exhausted
274                    .store(true, std::sync::atomic::Ordering::Relaxed);
275            }
276        }
277    }
278
279    pub fn register_resource<F>(&self, f: F)
280    where
281        F: FnOnce() + Send + 'static,
282    {
283        if let Some(rt) = &self.runtime {
284            rt.on_cleanup(f);
285        }
286    }
287
288    /// Convenience accessor for the spill manager from the runtime.
289    pub fn spill_manager(&self) -> Option<Arc<SpillManager>> {
290        self.runtime.as_ref().and_then(|rt| rt.get_spill_manager())
291    }
292
293    /// Return a reference to the bumpalo arena, if configured.
294    pub fn arena(&self) -> Option<&Arc<Mutex<Arena>>> {
295        self.runtime.as_ref().and_then(|rt| rt.arena())
296    }
297
298    /// Reset the lifecycle to `New` so a `close + open` sequence can run
299    /// again on the same operator instance (reset fallback path).
300    pub fn mark_new(&mut self) {
301        self.lifecycle = OperatorLifecycle::New;
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::OperatorBase;
308    use crate::executor::streaming::plan::types::PhysicalOperatorId;
309
310    #[test]
311    fn state_keys_include_physical_operator_and_partition() {
312        let global = OperatorBase::new(10).with_physical_operator_id(PhysicalOperatorId(7));
313        let partition_zero = OperatorBase::new(10)
314            .with_physical_operator_id(PhysicalOperatorId(7))
315            .with_partition(0);
316        let partition_one = OperatorBase::new(10)
317            .with_physical_operator_id(PhysicalOperatorId(7))
318            .with_partition(1);
319
320        assert_ne!(global.state_key(), partition_zero.state_key());
321        assert_ne!(partition_zero.state_key(), partition_one.state_key());
322        assert_ne!(
323            partition_zero.local_state_key(0),
324            partition_zero.local_state_key(1)
325        );
326    }
327}