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::{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}