Skip to main content

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}