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}