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::{ShardBucket, 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 /// Routing bucket bits for vShard routing. Immutable post-create.
85 prefix_bits: u8,
86 /// Array audit retention selected by CREATE ARRAY. This stays on the
87 /// authorized task so planning remains read-only.
88 audit_retain_ms: Option<i64>,
89 /// Immutable lower bound for `audit_retain_ms`.
90 minimum_audit_retain_ms: Option<u64>,
91 },
92
93 /// Insert one or more cells. `cells_msgpack` is an zerompk
94 /// encoding of `Vec<nodedb::engine::array::wal::ArrayPutCell>`.
95 /// Each cell carries a Control-Plane-allocated `Surrogate` parallel
96 /// to its `coord` tuple — cross-engine bitmap joins read the
97 /// surrogate column on read paths instead of translating coords
98 /// back to user-visible PKs.
99 ///
100 /// `provenance` is `Some` only on the inbound sync path (Lite → Origin).
101 /// The Data Plane uses it for epoch fencing and HWM tracking.
102 /// Non-sync callers (SQL DML, Raft apply) set this to `None`.
103 Put {
104 array_id: ArrayId,
105 cells_msgpack: Vec<u8>,
106 wal_lsn: u64,
107 /// Sync provenance for epoch fencing and HWM tracking.
108 /// `None` for locally-originated or Raft-replicated writes.
109 #[serde(default)]
110 provenance: Option<SyncProvenance>,
111 /// The vShard every cell of the op homes to: the vShard its tile
112 /// hashes to. A Calvin transaction routes and locks the write by it.
113 vshard_id: u32,
114 },
115
116 /// Delete by exact coordinates. `coords_msgpack` is an zerompk
117 /// encoding of `Vec<Vec<CoordValue>>`.
118 ///
119 /// `provenance` is `Some` only on the inbound sync path (Lite → Origin).
120 Delete {
121 array_id: ArrayId,
122 coords_msgpack: Vec<u8>,
123 wal_lsn: u64,
124 /// Sync provenance for epoch fencing and HWM tracking.
125 /// `None` for locally-originated or Raft-replicated writes.
126 #[serde(default)]
127 provenance: Option<SyncProvenance>,
128 /// The vShard every coordinate of the op homes to: the vShard its
129 /// tile hashes to. A Calvin transaction routes and locks the write
130 /// by it.
131 vshard_id: u32,
132 },
133
134 /// Coord-range slice with optional attribute projection.
135 /// `slice_msgpack` is an zerompk encoding of
136 /// `nodedb_array::query::Slice`. Empty `attr_projection` means
137 /// "all attributes".
138 ///
139 /// Under `SystemTimeScope::AllVersions` (`AS OF SYSTEM TIME NULL`),
140 /// the Data Plane emits one row per live cell-version, each carrying
141 /// `ArrayCell::system_time = Some(system_from_ms)`. Rows are sorted
142 /// ascending by system time, ties broken by coord lexicographic order.
143 /// `limit` bounds the total number of versions returned (not cells).
144 Slice {
145 array_id: ArrayId,
146 slice_msgpack: Vec<u8>,
147 attr_projection: Vec<u32>,
148 limit: u32,
149 /// Optional surrogate prefilter restricting which cells are returned.
150 /// Only cells whose surrogate is present in this bitmap are included.
151 /// `None` = no restriction.
152 cell_filter: Option<SurrogateBitmap>,
153 /// Optional shard bucket. When set, only cells whose tile key routes
154 /// to this bucket are included, so vShards that share one Data
155 /// Plane return no duplicate rows. `None` includes every cell.
156 shard_bucket: Option<ShardBucket>,
157 /// Bitemporal system-time scope.
158 ///
159 /// - `Current`: live read (returns the latest committed state).
160 /// - `AsOf(t)`: point-in-time snapshot at system-time `t`.
161 /// - `AllVersions`: audit-log read — every live cell-version,
162 /// ordered ascending by `system_from_ms`. Each emitted row has
163 /// `ArrayCell::system_time = Some(system_from_ms)`.
164 system_time: SystemTimeScope,
165 /// Bitemporal valid-time point. When `Some(vt)`, a cell is only
166 /// returned if `valid_from_ms <= vt < valid_until_ms`. `None` =
167 /// no valid-time filter.
168 valid_at_ms: Option<i64>,
169 },
170
171 /// Attribute projection (no coord filter).
172 Project {
173 array_id: ArrayId,
174 attr_indices: Vec<u32>,
175 },
176
177 /// Reduce one attribute column. `group_by_dim < 0` means no
178 /// group-by (one scalar partial per tile, merged across tiles by
179 /// the executor).
180 Aggregate {
181 array_id: ArrayId,
182 attr_idx: u32,
183 reducer: ArrayReducer,
184 group_by_dim: i32,
185 /// Optional surrogate prefilter: only cells whose surrogate is in
186 /// this bitmap contribute to the aggregate. `None` = all cells.
187 cell_filter: Option<SurrogateBitmap>,
188 /// When `true` the Data Plane returns a zerompk-encoded
189 /// `Vec<ArrayAggPartial>` instead of the finalized scalar rows.
190 /// Used by the distributed shard handler so the coordinator can
191 /// merge partials across shards before finalizing.
192 /// Single-node SQL callers always pass `false`.
193 return_partial: bool,
194 /// Optional shard bucket. When set, only cells whose tile key routes
195 /// to this bucket contribute, so vShards that share one Data Plane
196 /// count no cell twice. `None` includes every cell.
197 shard_bucket: Option<ShardBucket>,
198 /// Bitemporal system-time cutoff. When `Some(t)`, only tile versions
199 /// with `system_from_ms <= t` contribute to the aggregate. `None` =
200 /// live read.
201 system_as_of: Option<i64>,
202 /// Bitemporal valid-time point. When `Some(vt)`, only cells whose
203 /// `valid_from_ms <= vt < valid_until_ms` contribute. `None` = no
204 /// valid-time filter.
205 valid_at_ms: Option<i64>,
206 },
207
208 /// Pairwise op between two coord-aligned arrays. Both must share
209 /// schema (validated on the Data Plane).
210 Elementwise {
211 left: ArrayId,
212 right: ArrayId,
213 op: ArrayBinaryOp,
214 attr_idx: u32,
215 /// Optional surrogate prefilter restricting which cells participate
216 /// in the pairwise op. Applied to both operands so outer-join
217 /// fallthroughs from either side are also excluded.
218 /// `None` = all cells.
219 cell_filter: Option<SurrogateBitmap>,
220 },
221
222 /// Force a memtable flush. Returns the new segment ref's id +
223 /// flush_lsn in the response payload. The Control Plane allocates
224 /// `wal_lsn` from the central WAL writer and the engine stamps it
225 /// as the segment's flush watermark.
226 Flush { array_id: ArrayId, wal_lsn: u64 },
227
228 /// Trigger compaction if the picker selects one. Response
229 /// indicates whether a merge happened.
230 ///
231 /// `audit_retain_ms` is the array's retention window in milliseconds
232 /// (from `ArrayCatalogEntry`). `None` means retain all versions.
233 Compact {
234 array_id: ArrayId,
235 audit_retain_ms: Option<i64>,
236 },
237
238 /// Coord-range slice that emits one document-shaped row per matching
239 /// cell, where the row's `id` is the cell's bound `Surrogate` formatted
240 /// as 8-char zero-padded lowercase hex (substrate row-key format).
241 /// Used by the cross-engine fusion path: the vector engine runs this
242 /// as an `inline_prefilter_plan` and reads `id` via
243 /// `collect_surrogates` to build a `SurrogateBitmap`. Since the
244 /// surrogate identity initiative binds every cell to a global
245 /// `Surrogate` at write time, the cell's own surrogate is the
246 /// cross-engine join key — no extra attr resolution needed.
247 SurrogateBitmapScan {
248 array_id: ArrayId,
249 slice_msgpack: Vec<u8>,
250 },
251
252 /// Reversibly stage a per-core array drop. The store is closed and its
253 /// directory atomically renamed to a deterministic tombstone. This is the
254 /// externally planned `DROP ARRAY` operation and is idempotent.
255 DropArray { array_id: ArrayId },
256
257 /// Move a store from `array_id` to `target` on one core: flush, close,
258 /// and rename its directory. The MOVE TENANT rekey sends it to every
259 /// core. Idempotent once the store sits under `target`.
260 RekeyArray { array_id: ArrayId, target: ArrayId },
261
262 /// Permanently purge a successfully dropped array's tombstone. This is an
263 /// internal all-core operation; failures must be retried before recreation.
264 PurgeArrayDrop { array_id: ArrayId },
265}
266
267impl ArrayOp {
268 /// The array this op targets. For `Elementwise`, returns the
269 /// left operand — vshard routing pins to the left array's
270 /// shard and the right is fetched cross-shard.
271 pub fn primary_array(&self) -> &ArrayId {
272 match self {
273 ArrayOp::OpenArray { array_id, .. }
274 | ArrayOp::Put { array_id, .. }
275 | ArrayOp::Delete { array_id, .. }
276 | ArrayOp::Slice { array_id, .. }
277 | ArrayOp::SurrogateBitmapScan { array_id, .. }
278 | ArrayOp::Project { array_id, .. }
279 | ArrayOp::Aggregate { array_id, .. }
280 | ArrayOp::Flush { array_id, .. }
281 | ArrayOp::Compact { array_id, .. }
282 | ArrayOp::DropArray { array_id }
283 | ArrayOp::RekeyArray { array_id, .. }
284 | ArrayOp::PurgeArrayDrop { array_id } => array_id,
285 ArrayOp::Elementwise { left, .. } => left,
286 }
287 }
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293 use nodedb_types::TenantId;
294
295 fn aid() -> ArrayId {
296 ArrayId::new(TenantId::new(1), "g")
297 }
298
299 #[test]
300 fn array_op_roundtrips_through_msgpack() {
301 let op = ArrayOp::Aggregate {
302 array_id: aid(),
303 attr_idx: 0,
304 reducer: ArrayReducer::Sum,
305 group_by_dim: -1,
306 cell_filter: None,
307 return_partial: false,
308 shard_bucket: None,
309 system_as_of: None,
310 valid_at_ms: None,
311 };
312 let bytes = zerompk::to_msgpack_vec(&op).unwrap();
313 let back: ArrayOp = zerompk::from_msgpack(&bytes).unwrap();
314 assert_eq!(op, back);
315 }
316
317 #[test]
318 fn slice_all_versions_roundtrips_through_msgpack() {
319 use nodedb_types::SystemTimeScope;
320 let op = ArrayOp::Slice {
321 array_id: aid(),
322 slice_msgpack: vec![],
323 attr_projection: vec![],
324 limit: 0,
325 cell_filter: None,
326 shard_bucket: None,
327 system_time: SystemTimeScope::AllVersions,
328 valid_at_ms: None,
329 };
330 let bytes = zerompk::to_msgpack_vec(&op).unwrap();
331 let back: ArrayOp = zerompk::from_msgpack(&bytes).unwrap();
332 assert_eq!(op, back);
333 assert!(
334 matches!(
335 back,
336 ArrayOp::Slice {
337 system_time: SystemTimeScope::AllVersions,
338 ..
339 }
340 ),
341 "round-trip must preserve AllVersions"
342 );
343 }
344
345 #[test]
346 fn slice_as_of_roundtrips_through_msgpack() {
347 use nodedb_types::SystemTimeScope;
348 let op = ArrayOp::Slice {
349 array_id: aid(),
350 slice_msgpack: vec![],
351 attr_projection: vec![],
352 limit: 10,
353 cell_filter: None,
354 shard_bucket: None,
355 system_time: SystemTimeScope::AsOf(1_700_000_000_000),
356 valid_at_ms: Some(1_600_000_000_000),
357 };
358 let bytes = zerompk::to_msgpack_vec(&op).unwrap();
359 let back: ArrayOp = zerompk::from_msgpack(&bytes).unwrap();
360 assert_eq!(op, back);
361 }
362
363 #[test]
364 fn primary_array_picks_left_for_elementwise() {
365 let op = ArrayOp::Elementwise {
366 left: ArrayId::new(TenantId::new(1), "L"),
367 right: ArrayId::new(TenantId::new(1), "R"),
368 op: ArrayBinaryOp::Add,
369 attr_idx: 0,
370 cell_filter: None,
371 };
372 assert_eq!(op.primary_array().name, "L");
373 }
374
375 #[test]
376 fn binary_op_and_reducer_are_c_enum_one_byte() {
377 // c_enum encodes as a single u8 — confirm round-trip preserves identity.
378 for r in [
379 ArrayReducer::Sum,
380 ArrayReducer::Count,
381 ArrayReducer::Min,
382 ArrayReducer::Max,
383 ArrayReducer::Mean,
384 ] {
385 let bytes = zerompk::to_msgpack_vec(&r).unwrap();
386 let back: ArrayReducer = zerompk::from_msgpack(&bytes).unwrap();
387 assert_eq!(r, back);
388 }
389 for o in [
390 ArrayBinaryOp::Add,
391 ArrayBinaryOp::Sub,
392 ArrayBinaryOp::Mul,
393 ArrayBinaryOp::Div,
394 ] {
395 let bytes = zerompk::to_msgpack_vec(&o).unwrap();
396 let back: ArrayBinaryOp = zerompk::from_msgpack(&bytes).unwrap();
397 assert_eq!(o, back);
398 }
399 }
400}