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::sync::wire::SyncProvenance;
16use nodedb_types::{SurrogateBitmap, SystemTimeScope};
17
18/// Reducer for [`ArrayOp::Aggregate`]. Numeric `c_enum` keeps the
19/// wire encoding to a single byte.
20#[derive(
21 Debug,
22 Clone,
23 Copy,
24 PartialEq,
25 Eq,
26 serde::Serialize,
27 serde::Deserialize,
28 zerompk::ToMessagePack,
29 zerompk::FromMessagePack,
30)]
31#[msgpack(c_enum)]
32pub enum ArrayReducer {
33 Sum,
34 Count,
35 Min,
36 Max,
37 Mean,
38}
39
40/// Pairwise op for [`ArrayOp::Elementwise`].
41#[derive(
42 Debug,
43 Clone,
44 Copy,
45 PartialEq,
46 Eq,
47 serde::Serialize,
48 serde::Deserialize,
49 zerompk::ToMessagePack,
50 zerompk::FromMessagePack,
51)]
52#[msgpack(c_enum)]
53pub enum ArrayBinaryOp {
54 Add,
55 Sub,
56 Mul,
57 Div,
58}
59
60/// Array engine physical operations.
61///
62/// All ops scope by [`ArrayId`] (tenant + array name). LSN-bearing
63/// write ops (`Put`, `Delete`) carry a Control-Plane-allocated
64/// `wal_lsn` so the Data Plane handler can stamp memtable entries
65/// without re-appending to the WAL — matching the existing
66/// timeseries / columnar dispatch contract.
67#[derive(
68 Debug,
69 Clone,
70 PartialEq,
71 serde::Serialize,
72 serde::Deserialize,
73 zerompk::ToMessagePack,
74 zerompk::FromMessagePack,
75)]
76pub enum ArrayOp {
77 /// Open or attach to an existing array. Schema bytes are an
78 /// zerompk encoding of `nodedb_array::ArraySchema`. The DDL
79 /// pathway will replace this with a catalog lookup.
80 OpenArray {
81 array_id: ArrayId,
82 schema_msgpack: Vec<u8>,
83 schema_hash: u64,
84 /// Hilbert-prefix bits for vShard routing. Immutable post-create.
85 prefix_bits: u8,
86 },
87
88 /// Insert one or more cells. `cells_msgpack` is an zerompk
89 /// encoding of `Vec<nodedb::engine::array::wal::ArrayPutCell>`.
90 /// Each cell carries a Control-Plane-allocated `Surrogate` parallel
91 /// to its `coord` tuple — cross-engine bitmap joins read the
92 /// surrogate column on read paths instead of translating coords
93 /// back to user-visible PKs.
94 ///
95 /// `provenance` is `Some` only on the inbound sync path (Lite → Origin).
96 /// The Data Plane uses it for epoch fencing and HWM tracking.
97 /// Non-sync callers (SQL DML, Raft apply) set this to `None`.
98 Put {
99 array_id: ArrayId,
100 cells_msgpack: Vec<u8>,
101 wal_lsn: u64,
102 /// Sync provenance for epoch fencing and HWM tracking.
103 /// `None` for locally-originated or Raft-replicated writes.
104 #[serde(default)]
105 provenance: Option<SyncProvenance>,
106 },
107
108 /// Delete by exact coordinates. `coords_msgpack` is an zerompk
109 /// encoding of `Vec<Vec<CoordValue>>`.
110 ///
111 /// `provenance` is `Some` only on the inbound sync path (Lite → Origin).
112 Delete {
113 array_id: ArrayId,
114 coords_msgpack: Vec<u8>,
115 wal_lsn: u64,
116 /// Sync provenance for epoch fencing and HWM tracking.
117 /// `None` for locally-originated or Raft-replicated writes.
118 #[serde(default)]
119 provenance: Option<SyncProvenance>,
120 },
121
122 /// Coord-range slice with optional attribute projection.
123 /// `slice_msgpack` is an zerompk encoding of
124 /// `nodedb_array::query::Slice`. Empty `attr_projection` means
125 /// "all attributes".
126 ///
127 /// Under `SystemTimeScope::AllVersions` (`AS OF SYSTEM TIME NULL`),
128 /// the Data Plane emits one row per live cell-version, each carrying
129 /// `ArrayCell::system_time = Some(system_from_ms)`. Rows are sorted
130 /// ascending by system time, ties broken by coord lexicographic order.
131 /// `limit` bounds the total number of versions returned (not cells).
132 Slice {
133 array_id: ArrayId,
134 slice_msgpack: Vec<u8>,
135 attr_projection: Vec<u32>,
136 limit: u32,
137 /// Optional surrogate prefilter restricting which cells are returned.
138 /// Only cells whose surrogate is present in this bitmap are included.
139 /// `None` = no restriction.
140 cell_filter: Option<SurrogateBitmap>,
141 /// Optional Hilbert-prefix range `[lo, hi]`. When set, only cells
142 /// whose Hilbert prefix falls within this range are included.
143 /// Used by distributed shard slice to prevent duplicate rows in
144 /// single-node harnesses where all vShards share one Data Plane.
145 /// `None` = no Hilbert filter (all cells included).
146 hilbert_range: Option<(u64, u64)>,
147 /// Bitemporal system-time scope.
148 ///
149 /// - `Current`: live read (returns the latest committed state).
150 /// - `AsOf(t)`: point-in-time snapshot at system-time `t`.
151 /// - `AllVersions`: audit-log read — every live cell-version,
152 /// ordered ascending by `system_from_ms`. Each emitted row has
153 /// `ArrayCell::system_time = Some(system_from_ms)`.
154 system_time: SystemTimeScope,
155 /// Bitemporal valid-time point. When `Some(vt)`, a cell is only
156 /// returned if `valid_from_ms <= vt < valid_until_ms`. `None` =
157 /// no valid-time filter.
158 valid_at_ms: Option<i64>,
159 },
160
161 /// Attribute projection (no coord filter).
162 Project {
163 array_id: ArrayId,
164 attr_indices: Vec<u32>,
165 },
166
167 /// Reduce one attribute column. `group_by_dim < 0` means no
168 /// group-by (one scalar partial per tile, merged across tiles by
169 /// the executor).
170 Aggregate {
171 array_id: ArrayId,
172 attr_idx: u32,
173 reducer: ArrayReducer,
174 group_by_dim: i32,
175 /// Optional surrogate prefilter: only cells whose surrogate is in
176 /// this bitmap contribute to the aggregate. `None` = all cells.
177 cell_filter: Option<SurrogateBitmap>,
178 /// When `true` the Data Plane returns a zerompk-encoded
179 /// `Vec<ArrayAggPartial>` instead of the finalized scalar rows.
180 /// Used by the distributed shard handler so the coordinator can
181 /// merge partials across shards before finalizing.
182 /// Single-node SQL callers always pass `false`.
183 return_partial: bool,
184 /// Optional Hilbert-prefix range `[lo, hi]`. When set, only cells
185 /// whose Hilbert prefix falls within this range are included.
186 /// Used by distributed shard agg to prevent double-counting in
187 /// single-node harnesses where all vShards share one Data Plane.
188 /// `None` = no Hilbert filter (all cells included).
189 hilbert_range: Option<(u64, u64)>,
190 /// Bitemporal system-time cutoff. When `Some(t)`, only tile versions
191 /// with `system_from_ms <= t` contribute to the aggregate. `None` =
192 /// live read.
193 system_as_of: Option<i64>,
194 /// Bitemporal valid-time point. When `Some(vt)`, only cells whose
195 /// `valid_from_ms <= vt < valid_until_ms` contribute. `None` = no
196 /// valid-time filter.
197 valid_at_ms: Option<i64>,
198 },
199
200 /// Pairwise op between two coord-aligned arrays. Both must share
201 /// schema (validated on the Data Plane).
202 Elementwise {
203 left: ArrayId,
204 right: ArrayId,
205 op: ArrayBinaryOp,
206 attr_idx: u32,
207 /// Optional surrogate prefilter restricting which cells participate
208 /// in the pairwise op. Applied to both operands so outer-join
209 /// fallthroughs from either side are also excluded.
210 /// `None` = all cells.
211 cell_filter: Option<SurrogateBitmap>,
212 },
213
214 /// Force a memtable flush. Returns the new segment ref's id +
215 /// flush_lsn in the response payload. The Control Plane allocates
216 /// `wal_lsn` from the central WAL writer and the engine stamps it
217 /// as the segment's flush watermark.
218 Flush { array_id: ArrayId, wal_lsn: u64 },
219
220 /// Trigger compaction if the picker selects one. Response
221 /// indicates whether a merge happened.
222 ///
223 /// `audit_retain_ms` is the array's retention window in milliseconds
224 /// (from `ArrayCatalogEntry`). `None` means retain all versions.
225 Compact {
226 array_id: ArrayId,
227 audit_retain_ms: Option<i64>,
228 },
229
230 /// Coord-range slice that emits one document-shaped row per matching
231 /// cell, where the row's `id` is the cell's bound `Surrogate` formatted
232 /// as 8-char zero-padded lowercase hex (substrate row-key format).
233 /// Used by the cross-engine fusion path: the vector engine runs this
234 /// as an `inline_prefilter_plan` and reads `id` via
235 /// `collect_surrogates` to build a `SurrogateBitmap`. Since the
236 /// surrogate identity initiative binds every cell to a global
237 /// `Surrogate` at write time, the cell's own surrogate is the
238 /// cross-engine join key — no extra attr resolution needed.
239 SurrogateBitmapScan {
240 array_id: ArrayId,
241 slice_msgpack: Vec<u8>,
242 },
243
244 /// Drop the per-core array store. Broadcast on `DROP ARRAY` after
245 /// the Control-Plane catalog has been mutated so each Data-Plane
246 /// core releases its local store; otherwise a follow-up
247 /// `CREATE ARRAY` of the same name could carry stale memtable /
248 /// segment state. Idempotent: silently succeeds when no store is
249 /// open on the receiving core.
250 DropArray { array_id: ArrayId },
251}
252
253impl ArrayOp {
254 /// The array this op targets. For `Elementwise`, returns the
255 /// left operand — vshard routing pins to the left array's
256 /// shard and the right is fetched cross-shard.
257 pub fn primary_array(&self) -> &ArrayId {
258 match self {
259 ArrayOp::OpenArray { array_id, .. }
260 | ArrayOp::Put { array_id, .. }
261 | ArrayOp::Delete { array_id, .. }
262 | ArrayOp::Slice { array_id, .. }
263 | ArrayOp::SurrogateBitmapScan { array_id, .. }
264 | ArrayOp::Project { array_id, .. }
265 | ArrayOp::Aggregate { array_id, .. }
266 | ArrayOp::Flush { array_id, .. }
267 | ArrayOp::Compact { array_id, .. }
268 | ArrayOp::DropArray { array_id } => array_id,
269 ArrayOp::Elementwise { left, .. } => left,
270 }
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277 use nodedb_types::TenantId;
278
279 fn aid() -> ArrayId {
280 ArrayId::new(TenantId::new(1), "g")
281 }
282
283 #[test]
284 fn array_op_roundtrips_through_msgpack() {
285 let op = ArrayOp::Aggregate {
286 array_id: aid(),
287 attr_idx: 0,
288 reducer: ArrayReducer::Sum,
289 group_by_dim: -1,
290 cell_filter: None,
291 return_partial: false,
292 hilbert_range: None,
293 system_as_of: None,
294 valid_at_ms: None,
295 };
296 let bytes = zerompk::to_msgpack_vec(&op).unwrap();
297 let back: ArrayOp = zerompk::from_msgpack(&bytes).unwrap();
298 assert_eq!(op, back);
299 }
300
301 #[test]
302 fn slice_all_versions_roundtrips_through_msgpack() {
303 use nodedb_types::SystemTimeScope;
304 let op = ArrayOp::Slice {
305 array_id: aid(),
306 slice_msgpack: vec![],
307 attr_projection: vec![],
308 limit: 0,
309 cell_filter: None,
310 hilbert_range: None,
311 system_time: SystemTimeScope::AllVersions,
312 valid_at_ms: None,
313 };
314 let bytes = zerompk::to_msgpack_vec(&op).unwrap();
315 let back: ArrayOp = zerompk::from_msgpack(&bytes).unwrap();
316 assert_eq!(op, back);
317 assert!(
318 matches!(
319 back,
320 ArrayOp::Slice {
321 system_time: SystemTimeScope::AllVersions,
322 ..
323 }
324 ),
325 "round-trip must preserve AllVersions"
326 );
327 }
328
329 #[test]
330 fn slice_as_of_roundtrips_through_msgpack() {
331 use nodedb_types::SystemTimeScope;
332 let op = ArrayOp::Slice {
333 array_id: aid(),
334 slice_msgpack: vec![],
335 attr_projection: vec![],
336 limit: 10,
337 cell_filter: None,
338 hilbert_range: None,
339 system_time: SystemTimeScope::AsOf(1_700_000_000_000),
340 valid_at_ms: Some(1_600_000_000_000),
341 };
342 let bytes = zerompk::to_msgpack_vec(&op).unwrap();
343 let back: ArrayOp = zerompk::from_msgpack(&bytes).unwrap();
344 assert_eq!(op, back);
345 }
346
347 #[test]
348 fn primary_array_picks_left_for_elementwise() {
349 let op = ArrayOp::Elementwise {
350 left: ArrayId::new(TenantId::new(1), "L"),
351 right: ArrayId::new(TenantId::new(1), "R"),
352 op: ArrayBinaryOp::Add,
353 attr_idx: 0,
354 cell_filter: None,
355 };
356 assert_eq!(op.primary_array().name, "L");
357 }
358
359 #[test]
360 fn binary_op_and_reducer_are_c_enum_one_byte() {
361 // c_enum encodes as a single u8 — confirm round-trip preserves identity.
362 for r in [
363 ArrayReducer::Sum,
364 ArrayReducer::Count,
365 ArrayReducer::Min,
366 ArrayReducer::Max,
367 ArrayReducer::Mean,
368 ] {
369 let bytes = zerompk::to_msgpack_vec(&r).unwrap();
370 let back: ArrayReducer = zerompk::from_msgpack(&bytes).unwrap();
371 assert_eq!(r, back);
372 }
373 for o in [
374 ArrayBinaryOp::Add,
375 ArrayBinaryOp::Sub,
376 ArrayBinaryOp::Mul,
377 ArrayBinaryOp::Div,
378 ] {
379 let bytes = zerompk::to_msgpack_vec(&o).unwrap();
380 let back: ArrayBinaryOp = zerompk::from_msgpack(&bytes).unwrap();
381 assert_eq!(o, back);
382 }
383 }
384}