nodedb_physical/physical_plan/array.rs
1// SPDX-License-Identifier: Apache-2.0
2
3//! Array engine operations dispatched to the Data Plane.
4//!
5//! `ArrayOp` is the wire type for array query operators (slice,
6//! project, aggregate, elementwise) plus the write-side ops (put,
7//! delete) and engine maintenance (flush, compact). Complex nested
8//! payloads — schemas, slice predicates, cell batches — ride as
9//! opaque msgpack bytes (`*_msgpack`) decoded on the Data Plane via
10//! zerompk against the canonical type from `nodedb-array`. This keeps
11//! the bridge enum flat and zerompk-derivable while preserving full
12//! type fidelity at the engine boundary.
13
14use nodedb_array::types::ArrayId;
15use nodedb_types::SurrogateBitmap;
16
17/// Reducer for [`ArrayOp::Aggregate`]. Numeric `c_enum` keeps the
18/// wire encoding to a single byte.
19#[derive(
20 Debug,
21 Clone,
22 Copy,
23 PartialEq,
24 Eq,
25 serde::Serialize,
26 serde::Deserialize,
27 zerompk::ToMessagePack,
28 zerompk::FromMessagePack,
29)]
30#[msgpack(c_enum)]
31pub enum ArrayReducer {
32 Sum,
33 Count,
34 Min,
35 Max,
36 Mean,
37}
38
39/// Pairwise op for [`ArrayOp::Elementwise`].
40#[derive(
41 Debug,
42 Clone,
43 Copy,
44 PartialEq,
45 Eq,
46 serde::Serialize,
47 serde::Deserialize,
48 zerompk::ToMessagePack,
49 zerompk::FromMessagePack,
50)]
51#[msgpack(c_enum)]
52pub enum ArrayBinaryOp {
53 Add,
54 Sub,
55 Mul,
56 Div,
57}
58
59/// Array engine physical operations.
60///
61/// All ops scope by [`ArrayId`] (tenant + array name). LSN-bearing
62/// write ops (`Put`, `Delete`) carry a Control-Plane-allocated
63/// `wal_lsn` so the Data Plane handler can stamp memtable entries
64/// without re-appending to the WAL — matching the existing
65/// timeseries / columnar dispatch contract.
66#[derive(
67 Debug,
68 Clone,
69 PartialEq,
70 serde::Serialize,
71 serde::Deserialize,
72 zerompk::ToMessagePack,
73 zerompk::FromMessagePack,
74)]
75pub enum ArrayOp {
76 /// Open or attach to an existing array. Schema bytes are an
77 /// zerompk encoding of `nodedb_array::ArraySchema`. The DDL
78 /// pathway will replace this with a catalog lookup.
79 OpenArray {
80 array_id: ArrayId,
81 schema_msgpack: Vec<u8>,
82 schema_hash: u64,
83 /// Hilbert-prefix bits for vShard routing. Immutable post-create.
84 prefix_bits: u8,
85 },
86
87 /// Insert one or more cells. `cells_msgpack` is an zerompk
88 /// encoding of `Vec<nodedb::engine::array::wal::ArrayPutCell>`.
89 /// Each cell carries a Control-Plane-allocated `Surrogate` parallel
90 /// to its `coord` tuple — cross-engine bitmap joins read the
91 /// surrogate column on read paths instead of translating coords
92 /// back to user-visible PKs.
93 Put {
94 array_id: ArrayId,
95 cells_msgpack: Vec<u8>,
96 wal_lsn: u64,
97 },
98
99 /// Delete by exact coordinates. `coords_msgpack` is an zerompk
100 /// encoding of `Vec<Vec<CoordValue>>`.
101 Delete {
102 array_id: ArrayId,
103 coords_msgpack: Vec<u8>,
104 wal_lsn: u64,
105 },
106
107 /// Coord-range slice with optional attribute projection.
108 /// `slice_msgpack` is an zerompk encoding of
109 /// `nodedb_array::query::Slice`. Empty `attr_projection` means
110 /// "all attributes".
111 Slice {
112 array_id: ArrayId,
113 slice_msgpack: Vec<u8>,
114 attr_projection: Vec<u32>,
115 limit: u32,
116 /// Optional surrogate prefilter restricting which cells are returned.
117 /// Only cells whose surrogate is present in this bitmap are included.
118 /// `None` = no restriction.
119 cell_filter: Option<SurrogateBitmap>,
120 /// Optional Hilbert-prefix range `[lo, hi]`. When set, only cells
121 /// whose Hilbert prefix falls within this range are included.
122 /// Used by distributed shard slice to prevent duplicate rows in
123 /// single-node harnesses where all vShards share one Data Plane.
124 /// `None` = no Hilbert filter (all cells included).
125 hilbert_range: Option<(u64, u64)>,
126 /// Bitemporal system-time cutoff. When `Some(t)`, only tile versions
127 /// with `system_from_ms <= t` are considered. `None` = live read
128 /// (`i64::MAX` effective cutoff — returns current state).
129 system_as_of: Option<i64>,
130 /// Bitemporal valid-time point. When `Some(vt)`, a cell is only
131 /// returned if `valid_from_ms <= vt < valid_until_ms`. `None` =
132 /// no valid-time filter.
133 valid_at_ms: Option<i64>,
134 },
135
136 /// Attribute projection (no coord filter).
137 Project {
138 array_id: ArrayId,
139 attr_indices: Vec<u32>,
140 },
141
142 /// Reduce one attribute column. `group_by_dim < 0` means no
143 /// group-by (one scalar partial per tile, merged across tiles by
144 /// the executor).
145 Aggregate {
146 array_id: ArrayId,
147 attr_idx: u32,
148 reducer: ArrayReducer,
149 group_by_dim: i32,
150 /// Optional surrogate prefilter: only cells whose surrogate is in
151 /// this bitmap contribute to the aggregate. `None` = all cells.
152 cell_filter: Option<SurrogateBitmap>,
153 /// When `true` the Data Plane returns a zerompk-encoded
154 /// `Vec<ArrayAggPartial>` instead of the finalized scalar rows.
155 /// Used by the distributed shard handler so the coordinator can
156 /// merge partials across shards before finalizing.
157 /// Single-node SQL callers always pass `false`.
158 return_partial: bool,
159 /// Optional Hilbert-prefix range `[lo, hi]`. When set, only cells
160 /// whose Hilbert prefix falls within this range are included.
161 /// Used by distributed shard agg to prevent double-counting in
162 /// single-node harnesses where all vShards share one Data Plane.
163 /// `None` = no Hilbert filter (all cells included).
164 hilbert_range: Option<(u64, u64)>,
165 /// Bitemporal system-time cutoff. When `Some(t)`, only tile versions
166 /// with `system_from_ms <= t` contribute to the aggregate. `None` =
167 /// live read.
168 system_as_of: Option<i64>,
169 /// Bitemporal valid-time point. When `Some(vt)`, only cells whose
170 /// `valid_from_ms <= vt < valid_until_ms` contribute. `None` = no
171 /// valid-time filter.
172 valid_at_ms: Option<i64>,
173 },
174
175 /// Pairwise op between two coord-aligned arrays. Both must share
176 /// schema (validated on the Data Plane).
177 Elementwise {
178 left: ArrayId,
179 right: ArrayId,
180 op: ArrayBinaryOp,
181 attr_idx: u32,
182 /// Optional surrogate prefilter restricting which cells participate
183 /// in the pairwise op. Applied to both operands so outer-join
184 /// fallthroughs from either side are also excluded.
185 /// `None` = all cells.
186 cell_filter: Option<SurrogateBitmap>,
187 },
188
189 /// Force a memtable flush. Returns the new segment ref's id +
190 /// flush_lsn in the response payload. The Control Plane allocates
191 /// `wal_lsn` from the central WAL writer and the engine stamps it
192 /// as the segment's flush watermark.
193 Flush { array_id: ArrayId, wal_lsn: u64 },
194
195 /// Trigger compaction if the picker selects one. Response
196 /// indicates whether a merge happened.
197 ///
198 /// `audit_retain_ms` is the array's retention window in milliseconds
199 /// (from `ArrayCatalogEntry`). `None` means retain all versions.
200 Compact {
201 array_id: ArrayId,
202 audit_retain_ms: Option<i64>,
203 },
204
205 /// Coord-range slice that emits one document-shaped row per matching
206 /// cell, where the row's `id` is the cell's bound `Surrogate` formatted
207 /// as 8-char zero-padded lowercase hex (substrate row-key format).
208 /// Used by the cross-engine fusion path: the vector engine runs this
209 /// as an `inline_prefilter_plan` and reads `id` via
210 /// `collect_surrogates` to build a `SurrogateBitmap`. Since the
211 /// surrogate identity initiative binds every cell to a global
212 /// `Surrogate` at write time, the cell's own surrogate is the
213 /// cross-engine join key — no extra attr resolution needed.
214 SurrogateBitmapScan {
215 array_id: ArrayId,
216 slice_msgpack: Vec<u8>,
217 },
218
219 /// Drop the per-core array store. Broadcast on `DROP ARRAY` after
220 /// the Control-Plane catalog has been mutated so each Data-Plane
221 /// core releases its local store; otherwise a follow-up
222 /// `CREATE ARRAY` of the same name could carry stale memtable /
223 /// segment state. Idempotent: silently succeeds when no store is
224 /// open on the receiving core.
225 DropArray { array_id: ArrayId },
226}
227
228impl ArrayOp {
229 /// The array this op targets. For `Elementwise`, returns the
230 /// left operand — vshard routing pins to the left array's
231 /// shard and the right is fetched cross-shard.
232 pub fn primary_array(&self) -> &ArrayId {
233 match self {
234 ArrayOp::OpenArray { array_id, .. }
235 | ArrayOp::Put { array_id, .. }
236 | ArrayOp::Delete { array_id, .. }
237 | ArrayOp::Slice { array_id, .. }
238 | ArrayOp::SurrogateBitmapScan { array_id, .. }
239 | ArrayOp::Project { array_id, .. }
240 | ArrayOp::Aggregate { array_id, .. }
241 | ArrayOp::Flush { array_id, .. }
242 | ArrayOp::Compact { array_id, .. }
243 | ArrayOp::DropArray { array_id } => array_id,
244 ArrayOp::Elementwise { left, .. } => left,
245 }
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252 use nodedb_types::TenantId;
253
254 fn aid() -> ArrayId {
255 ArrayId::new(TenantId::new(1), "g")
256 }
257
258 #[test]
259 fn array_op_roundtrips_through_msgpack() {
260 let op = ArrayOp::Aggregate {
261 array_id: aid(),
262 attr_idx: 0,
263 reducer: ArrayReducer::Sum,
264 group_by_dim: -1,
265 cell_filter: None,
266 return_partial: false,
267 hilbert_range: None,
268 system_as_of: None,
269 valid_at_ms: None,
270 };
271 let bytes = zerompk::to_msgpack_vec(&op).unwrap();
272 let back: ArrayOp = zerompk::from_msgpack(&bytes).unwrap();
273 assert_eq!(op, back);
274 }
275
276 #[test]
277 fn primary_array_picks_left_for_elementwise() {
278 let op = ArrayOp::Elementwise {
279 left: ArrayId::new(TenantId::new(1), "L"),
280 right: ArrayId::new(TenantId::new(1), "R"),
281 op: ArrayBinaryOp::Add,
282 attr_idx: 0,
283 cell_filter: None,
284 };
285 assert_eq!(op.primary_array().name, "L");
286 }
287
288 #[test]
289 fn binary_op_and_reducer_are_c_enum_one_byte() {
290 // c_enum encodes as a single u8 — confirm round-trip preserves identity.
291 for r in [
292 ArrayReducer::Sum,
293 ArrayReducer::Count,
294 ArrayReducer::Min,
295 ArrayReducer::Max,
296 ArrayReducer::Mean,
297 ] {
298 let bytes = zerompk::to_msgpack_vec(&r).unwrap();
299 let back: ArrayReducer = zerompk::from_msgpack(&bytes).unwrap();
300 assert_eq!(r, back);
301 }
302 for o in [
303 ArrayBinaryOp::Add,
304 ArrayBinaryOp::Sub,
305 ArrayBinaryOp::Mul,
306 ArrayBinaryOp::Div,
307 ] {
308 let bytes = zerompk::to_msgpack_vec(&o).unwrap();
309 let back: ArrayBinaryOp = zerompk::from_msgpack(&bytes).unwrap();
310 assert_eq!(o, back);
311 }
312 }
313}