Skip to main content

nodedb_physical/physical_plan/
query.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Query operations (joins, aggregates) dispatched to the Data Plane.
4
5pub use nodedb_query::expr::GroupKeySpec;
6
7/// Aggregate specification for Data Plane aggregate execution.
8#[derive(
9    Debug,
10    Clone,
11    PartialEq,
12    serde::Serialize,
13    serde::Deserialize,
14    zerompk::ToMessagePack,
15    zerompk::FromMessagePack,
16)]
17pub struct AggregateSpec {
18    pub function: String,
19    /// Internal aggregate key used by HAVING and downstream references.
20    pub alias: String,
21    /// Optional user-facing SQL alias for final output naming.
22    pub user_alias: Option<String>,
23    /// Field name for simple field-based aggregates. `"*"` is used for COUNT(*).
24    pub field: String,
25    /// Optional expression to evaluate per-document before aggregating.
26    pub expr: Option<nodedb_query::expr::SqlExpr>,
27}
28
29#[derive(
30    Debug,
31    Clone,
32    PartialEq,
33    serde::Serialize,
34    serde::Deserialize,
35    zerompk::ToMessagePack,
36    zerompk::FromMessagePack,
37)]
38pub struct JoinProjection {
39    pub source: String,
40    pub output: String,
41}
42
43/// Query-level physical operations (joins, aggregates).
44#[derive(
45    Debug,
46    Clone,
47    PartialEq,
48    serde::Serialize,
49    serde::Deserialize,
50    zerompk::ToMessagePack,
51    zerompk::FromMessagePack,
52)]
53pub enum QueryOp {
54    /// Coordinator-resolved data-movement wrapper. Defensive error if it reaches a core.
55    Exchange(crate::physical_plan::ExchangeOp),
56
57    /// Pre-materialized rows. `provider: Some(name)` => filled per-request
58    /// (identity-scoped catalog table); `None` => `rows` is final (constant
59    /// SELECT or gathered broadcast child). `rows` is a canonical msgpack array
60    /// of map values (the `encode_binary_rows` shape).
61    ///
62    /// The executor applies predicate, offset, sort, distinct, projection, and
63    /// limit in that order over the rows before emitting the response. Empty
64    /// `filters`/`projection` and `None` limit are no-ops (all rows emitted
65    /// unchanged through those stages).
66    ProviderScan {
67        /// Catalog provider name for deferred materialization by the coordinator.
68        /// `Some(name)` is replaced with `None` after `materialize_providers`
69        /// fills `rows`. Data-Plane cores must never see `Some`.
70        provider: Option<String>,
71        /// Msgpack array of plain map rows. Filled by the coordinator from the
72        /// catalog producer or from a gathered broadcast child.
73        rows: Vec<u8>,
74        /// Serialized `Vec<ScanFilter>` (MessagePack). Empty = no predicate.
75        #[serde(default)]
76        filters: Vec<u8>,
77        /// Output column names to keep. Empty = emit all columns.
78        #[serde(default)]
79        projection: Vec<String>,
80        /// Sort keys: `(column_name, ascending)`. Empty = unordered.
81        #[serde(default)]
82        sort_keys: Vec<(String, bool)>,
83        /// Maximum rows to emit after offset/sort/distinct/projection.
84        /// `None` = unlimited.
85        #[serde(default)]
86        limit: Option<usize>,
87        /// Number of rows to skip before applying limit.
88        #[serde(default)]
89        offset: usize,
90        /// SQL DISTINCT: deduplicate on the projected row.
91        #[serde(default)]
92        distinct: bool,
93    },
94
95    /// Aggregate: GROUP BY + aggregate functions.
96    Aggregate {
97        collection: String,
98        /// Optional sub-plan whose decoded rows are aggregated instead of
99        /// scanning `collection` per-shard. `Some` currently means EXACTLY a
100        /// catalog source (a `ProviderScan` lowered by the converter): the
101        /// aggregate runs over the coordinator-materialized catalog rows and is
102        /// therefore coordinator-local (never broadcast — see
103        /// `is_sharded_source`). `None` = legacy path: scan the named
104        /// `collection` on every shard. `collection` stays populated in both
105        /// cases so downstream RLS / permission / classification continue to
106        /// read it; the executor simply prefers `input` when present.
107        #[serde(default)]
108        input: Option<Box<crate::physical_plan::PhysicalPlan>>,
109        group_by: Vec<GroupKeySpec>,
110        aggregates: Vec<AggregateSpec>,
111        filters: Vec<u8>,
112        /// HAVING predicates applied post-aggregation.
113        having: Vec<u8>,
114        limit: usize,
115        sub_group_by: Vec<String>,
116        sub_aggregates: Vec<AggregateSpec>,
117        /// ROLLUP / CUBE / GROUPING SETS expansion.  Each inner `Vec<u32>` is
118        /// one grouping set — the indices into `group_by` that are *present*
119        /// (non-NULL) for rows in that set.  Empty outer vec = plain single-set
120        /// GROUP BY (no null-filling needed).
121        grouping_sets: Vec<Vec<u32>>,
122        /// Post-aggregation sort keys: `(column_name, ascending)`.
123        /// Empty = preserve executor's natural order (hash-map iteration
124        /// for plain GROUP BY). The executor applies the sort after all
125        /// groups are finalized and HAVING is filtered.
126        #[serde(default)]
127        sort_keys: Vec<(String, bool)>,
128    },
129
130    /// Partial aggregate: each core computes locally, Control Plane merges.
131    PartialAggregate {
132        collection: String,
133        group_by: Vec<String>,
134        aggregates: Vec<AggregateSpec>,
135        filters: Vec<u8>,
136    },
137
138    /// Partial aggregate STATE producer (distributed GROUP BY shuffle, map side).
139    ///
140    /// Accumulates exactly like [`QueryOp::PartialAggregate`] — scan the named
141    /// `collection`, apply `filters`, build per-group `GroupState` accumulators
142    /// keyed on `group_by` — but DOES NOT finalize. Instead it emits one row
143    /// PER GROUP of the flat shape:
144    ///
145    /// ```text
146    /// { <group_by[0]>: value_0, ..., "__agg_state": <bytes> }
147    /// ```
148    ///
149    /// where `__agg_state` is the serialized partial `GroupState` for that
150    /// group. A downstream [`QueryOp::ShuffleAggregateConsume`] merges these
151    /// partial states (from every producer shard/node) and finalizes them.
152    ///
153    /// This op carries NO node-local paths — it reads only the named collection
154    /// (or its `input` sub-plan) — so it is wire-shippable: a coordinator embeds
155    /// it in a producer plan and dispatches it to a remote node's Data Plane.
156    PartialAggregateState {
157        collection: String,
158        /// Optional sub-plan whose decoded rows are aggregated instead of
159        /// scanning `collection` per-shard. Mirrors [`QueryOp::Aggregate`]'s
160        /// `input`: `Some` runs the producer over the sub-plan's rows (e.g. a
161        /// coordinator-materialized `ProviderScan`), `None` scans the named
162        /// `collection`. `collection` stays populated either way.
163        #[serde(default)]
164        input: Option<Box<crate::physical_plan::PhysicalPlan>>,
165        group_by: Vec<GroupKeySpec>,
166        aggregates: Vec<AggregateSpec>,
167        filters: Vec<u8>,
168    },
169
170    /// Hash join: build hash map on right, probe with left.
171    ///
172    /// `left_input`/`right_input` carry a resolved child plan (an Exchange
173    /// child during planning, or an embedded `ProviderScan` after coordinator
174    /// resolution). When `None` the side is scanned locally by collection name.
175    HashJoin {
176        left_collection: String,
177        right_collection: String,
178        left_alias: Option<String>,
179        right_alias: Option<String>,
180        on: Vec<(String, String)>,
181        join_type: String,
182        limit: usize,
183        /// Post-join GROUP BY columns (empty = no aggregation).
184        post_group_by: Vec<String>,
185        /// Post-join aggregates: (op, field) pairs (empty = no aggregation).
186        post_aggregates: Vec<(String, String)>,
187        /// Post-join projection: column names to keep (empty = all).
188        projection: Vec<JoinProjection>,
189        /// MessagePack-encoded computed projection expressions. When present,
190        /// these represent the complete SELECT list in output order.
191        computed_projection: Vec<u8>,
192        /// Residual `ON` predicates evaluated for each equi-key candidate
193        /// before outer-join match accounting (MessagePack).
194        join_filters: Vec<u8>,
195        /// Post-join WHERE filter predicates (MessagePack).
196        post_filters: Vec<u8>,
197        /// Resolved child plan for the left side. An Exchange child during
198        /// planning; a `ProviderScan` after coordinator resolution. `None`
199        /// means the left side is scanned locally by `left_collection`.
200        left_input: Option<Box<crate::physical_plan::PhysicalPlan>>,
201        /// Resolved child plan for the right side. Same semantics as
202        /// `left_input` but applied to `right_collection`.
203        right_input: Option<Box<crate::physical_plan::PhysicalPlan>>,
204        /// Bitmap-producer sub-plan for the left side. When set, the executor
205        /// executes this plan first, collects surrogates from all returned rows,
206        /// and injects the resulting bitmap into the probe-side prefilter before
207        /// scanning. `None` = no bitmap pushdown for the left side.
208        left_bitmap: Option<Box<crate::physical_plan::PhysicalPlan>>,
209        /// Bitmap-producer sub-plan for the right side. Same semantics as
210        /// `left_bitmap` but applied to the right (probe) collection.
211        right_bitmap: Option<Box<crate::physical_plan::PhysicalPlan>>,
212    },
213
214    /// Cross-node shuffle-join CONSUMER (E4b): run the node-local grace-hash
215    /// join over two already-staged shuffle frame files.
216    ///
217    /// This variant is BUILT LOCALLY by the part-owner node's consume hook with
218    /// resolved local absolute paths and dispatched to the SAME node's Data
219    /// Plane. It carries node-local filesystem paths (as plain strings) and must
220    /// NEVER be serialized cross-node — the wire encoder rejects it for exactly
221    /// this reason (see `physical_plan::wire::encode`).
222    ///
223    /// The owned fields mirror the borrowed `JoinParams` the Data-Plane handler
224    /// (`execute_shuffle_join`) reconstructs: `on` (equi-join key pairs),
225    /// `join_type`, `limit`, and the two column qualifiers. `build_path` /
226    /// `probe_path` are the staged build (right) and probe (left) frame files.
227    ShuffleJoinConsume {
228        /// Local absolute path to the staged BUILD (right) side frame file.
229        build_path: String,
230        /// Local absolute path to the staged PROBE (left) side frame file.
231        probe_path: String,
232        /// Equi-join key pairs `(left_key, right_key)`.
233        on: Vec<(String, String)>,
234        /// Join type (`inner` / `left` / `right` / `full`). `cross`/keyless is
235        /// rejected by the handler.
236        join_type: String,
237        /// Output row cap. `usize::MAX` = no explicit LIMIT (budget-bounded).
238        limit: usize,
239        /// Column qualifier (prefix) for probe-side (left) columns.
240        probe_qualifier: String,
241        /// Column qualifier (prefix) for build-side (right/index) columns.
242        index_qualifier: String,
243    },
244
245    /// Distributed GROUP BY shuffle CONSUMER (reduce side): merge the staged
246    /// partial `GroupState`s produced by [`QueryOp::PartialAggregateState`]
247    /// producers, then finalize, HAVING-filter, sort, and LIMIT.
248    ///
249    /// `state_path` is a node-local frame file written by the shuffle receive
250    /// path: a sequence of `[u32 LE len][row-bytes]` frames, one msgpack row per
251    /// frame, each row the flat `{<group_by cols>, "__agg_state": <bytes>}` shape
252    /// a `PartialAggregateState` producer emits. The handler rebuilds each
253    /// group key (byte-identically to the accumulate path), decodes
254    /// `__agg_state`, and `merge_from`s into a consolidated per-group map before
255    /// running the same finalize tail as a normal aggregate.
256    ///
257    /// Like [`QueryOp::ShuffleJoinConsume`], this variant carries a node-local
258    /// path and is BUILT LOCALLY by the consume hook for dispatch to the SAME
259    /// node's Data Plane — it must NEVER be serialized cross-node (the wire
260    /// encoder rejects it; see `physical_plan::wire::encode`).
261    ShuffleAggregateConsume {
262        /// Local absolute path to the staged partial-state frame file.
263        state_path: String,
264        /// GROUP BY columns (the key the partial states were grouped on).
265        group_by: Vec<String>,
266        /// Aggregate specs (must match the producers' specs, in order).
267        aggregates: Vec<AggregateSpec>,
268        /// HAVING predicates applied post-merge (serialized `Vec<ScanFilter>`).
269        having: Vec<u8>,
270        /// Output row cap after sort. `usize::MAX` = no explicit LIMIT.
271        limit: usize,
272        /// Post-aggregation sort keys: `(column_name, ascending)`.
273        sort_keys: Vec<(String, bool)>,
274    },
275
276    /// Nested loop join: fallback for non-equi joins.
277    NestedLoopJoin {
278        left_collection: String,
279        right_collection: String,
280        /// Join condition as serialized `Vec<ScanFilter>`.
281        condition: Vec<u8>,
282        join_type: String,
283        limit: usize,
284    },
285
286    /// Sort-merge join: both sides pre-sorted by join key.
287    /// Optimal when both collections have index-ordered scans or
288    /// when the planner sorts both sides before joining.
289    SortMergeJoin {
290        left_collection: String,
291        right_collection: String,
292        on: Vec<(String, String)>,
293        join_type: String,
294        limit: usize,
295        /// If true, both sides are assumed pre-sorted by join key (skip sort phase).
296        pre_sorted: bool,
297    },
298
299    /// Multi-facet aggregation: compute facet counts for multiple fields
300    /// in a single query, sharing the filter evaluation across all facets.
301    FacetCounts {
302        collection: String,
303        /// Serialized `Vec<ScanFilter>` predicates (MessagePack).
304        filters: Vec<u8>,
305        /// Field names to facet on (each produces a `[{value, count}]` array).
306        fields: Vec<String>,
307        /// Maximum number of values to return per facet field (0 = unlimited).
308        limit_per_facet: usize,
309    },
310
311    /// Recursive CTE: iterative fixed-point execution.
312    ///
313    /// Executes the base query once, then repeatedly executes the recursive
314    /// query using the previous iteration's results as the working table,
315    /// until no new rows are produced (fixed point).
316    RecursiveScan {
317        /// Collection for the recursive scan.
318        collection: String,
319        /// Base query filters (seeded once).
320        base_filters: Vec<u8>,
321        /// Recursive step filters (applied to working table each iteration).
322        recursive_filters: Vec<u8>,
323        /// Equi-join link for tree-traversal recursion:
324        /// `(collection_field, working_table_field)`.
325        /// Each iteration finds rows where `collection_field` value
326        /// matches a `working_table_field` value from the previous iteration.
327        join_link: Option<(String, String)>,
328        /// Maximum iterations to prevent infinite loops. Default: 100.
329        max_iterations: usize,
330        /// Whether to deduplicate results (UNION vs UNION ALL).
331        distinct: bool,
332        limit: usize,
333    },
334
335    /// Value-generating recursive CTE: iterative expression evaluation.
336    ///
337    /// No collection is needed.  The executor evaluates the anchor expressions
338    /// once to produce the first row, then repeatedly applies the step
339    /// expressions to the previous row until the condition becomes false,
340    /// a fixed point is reached, or `max_depth` is exceeded (typed error).
341    RecursiveValue {
342        /// CTE name (used in error messages).
343        cte_name: String,
344        /// Column names (length == `init_exprs.len()` == `step_exprs.len()`).
345        columns: Vec<String>,
346        /// Anchor SELECT expressions as raw SQL text.
347        init_exprs: Vec<String>,
348        /// Recursive step SELECT expressions as raw SQL text.
349        step_exprs: Vec<String>,
350        /// Optional WHERE condition as raw SQL text.
351        condition: Option<String>,
352        /// Maximum iterations before returning a depth-exceeded error.
353        max_depth: usize,
354        /// Whether to deduplicate (UNION vs UNION ALL).
355        distinct: bool,
356    },
357
358    /// LATERAL equi-correlated top-K: scan `inner_collection` once per outer
359    /// row, applying the equi-correlation as an equality filter, then return
360    /// the top `inner_limit` rows ordered by `inner_order_by`.
361    ///
362    /// The executor first runs `outer_plan` to materialise outer rows, then
363    /// for each outer row injects the equi-correlation as an equality filter
364    /// on `inner_collection`, applies `inner_order_by`, and keeps the top
365    /// `inner_limit` rows.  Output rows are `(outer_row merged with inner_row)`.
366    ///
367    /// `correlation_keys` are `(outer_col, inner_col)` equi-join pairs.
368    LateralTopK {
369        /// Sub-plan that produces the outer (driving) rows.
370        outer_plan: Box<crate::physical_plan::PhysicalPlan>,
371        /// Alias qualifying the outer columns in output rows.
372        outer_alias: String,
373        /// Inner collection to scan per outer row.
374        inner_collection: String,
375        /// Non-correlated filters applied to every inner scan (msgpack bytes).
376        inner_filters: Vec<u8>,
377        /// Sort keys for the inner per-outer-row result.
378        /// Each entry is `(field_name, ascending)`.
379        inner_order_by: Vec<(String, bool)>,
380        /// Maximum inner rows per outer row.
381        inner_limit: usize,
382        /// Equi-join pairs `(outer_col, inner_col)`.
383        correlation_keys: Vec<(String, String)>,
384        /// Alias qualifying inner columns in output rows.
385        lateral_alias: String,
386        /// Output projection (empty = all columns).
387        projection: Vec<JoinProjection>,
388        /// LEFT join semantics: preserve outer rows when inner is empty.
389        left_join: bool,
390    },
391
392    /// LATERAL nested-loop: for each outer row, re-execute the inner plan
393    /// with correlated values injected as additional equality filters.
394    ///
395    /// The executor runs `outer_plan`, then for each outer row reads the
396    /// `outer_col` values from `correlation_predicates`, appends equality
397    /// filters on the corresponding `inner_col` fields, and scans
398    /// `inner_collection`.
399    LateralLoop {
400        /// Sub-plan that produces the outer (driving) rows.
401        outer_plan: Box<crate::physical_plan::PhysicalPlan>,
402        /// Alias qualifying the outer columns in output rows.
403        outer_alias: String,
404        /// Inner collection to scan per outer row.
405        inner_collection: String,
406        /// Base inner filters (non-correlated, msgpack bytes).
407        inner_filters: Vec<u8>,
408        /// Correlated predicates: `(inner_field, outer_field)`.
409        correlation_predicates: Vec<(String, String)>,
410        /// Alias qualifying inner columns in output rows.
411        lateral_alias: String,
412        /// Output projection (empty = all columns).
413        projection: Vec<JoinProjection>,
414        /// LEFT join semantics.
415        left_join: bool,
416        /// Hard cap on outer rows. Queries that exceed this cap return a
417        /// typed `LateralCapExceeded` error instead of silently truncating.
418        outer_row_cap: usize,
419    },
420}