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