Skip to main content

nodedb_cluster/distributed_array/
handler.rs

1// SPDX-License-Identifier: BUSL-1.1
2
3//! Shard-side RPC handler for incoming array operations.
4//!
5//! `handle_array_shard_rpc` is called by the vshard dispatch table
6//! (see `crate::vshard_handler`) when an incoming `VShardEnvelope`
7//! carries an array opcode. It decodes the msgpack payload, delegates
8//! to the local array engine through `ArrayLocalExecutor`, and returns
9//! a serialised response envelope payload.
10//!
11//! The `ArrayLocalExecutor` trait is defined in `local_executor.rs` and
12//! implemented in the main `nodedb` binary, which has access to the SPSC
13//! bridge and the Data Plane array engine. This keeps `nodedb-cluster`
14//! free of a compile-time dependency on the engine crates.
15
16use std::sync::Arc;
17
18use crate::error::{ClusterError, Result};
19
20use super::local_executor::ArrayLocalExecutor;
21use super::opcodes::{
22    ARRAY_SHARD_AGG_REQ, ARRAY_SHARD_DELETE_REQ, ARRAY_SHARD_PUT_REQ, ARRAY_SHARD_SLICE_REQ,
23    ARRAY_SHARD_SURROGATE_BITMAP_REQ,
24};
25use super::routing::{array_vshard_for_tile, array_vshards_for_slice};
26use super::wire::{
27    ArrayShardAggReq, ArrayShardAggResp, ArrayShardDeleteReq, ArrayShardDeleteResp,
28    ArrayShardPutReq, ArrayShardPutResp, ArrayShardSliceReq, ArrayShardSliceResp,
29    ArrayShardSurrogateBitmapReq, ArrayShardSurrogateBitmapResp,
30};
31
32/// Dispatch an incoming array shard RPC to the appropriate local handler.
33///
34/// `opcode` is the `VShardMessageType` discriminant (u16).
35/// `local_vshard_id` is this shard's own vShard ID (for routing validation).
36/// `payload` is the zerompk-encoded request body.
37/// `executor` reaches into the local Data Plane array engine.
38///
39/// Returns the zerompk-encoded response body on success, ready to be placed
40/// into a response `VShardEnvelope`.
41pub async fn handle_array_shard_rpc(
42    opcode: u32,
43    local_vshard_id: u32,
44    payload: &[u8],
45    executor: &Arc<dyn ArrayLocalExecutor>,
46) -> Result<Vec<u8>> {
47    match opcode {
48        ARRAY_SHARD_SLICE_REQ => handle_slice(local_vshard_id, payload, executor).await,
49        ARRAY_SHARD_AGG_REQ => handle_agg(local_vshard_id, payload, executor).await,
50        ARRAY_SHARD_PUT_REQ => handle_put(local_vshard_id, payload, executor).await,
51        ARRAY_SHARD_DELETE_REQ => handle_delete(local_vshard_id, payload, executor).await,
52        ARRAY_SHARD_SURROGATE_BITMAP_REQ => {
53            handle_surrogate_bitmap(local_vshard_id, payload, executor).await
54        }
55        other => Err(ClusterError::Codec {
56            detail: format!("handle_array_shard_rpc: unknown opcode {other}"),
57        }),
58    }
59}
60
61async fn handle_slice(
62    local_vshard_id: u32,
63    payload: &[u8],
64    executor: &Arc<dyn ArrayLocalExecutor>,
65) -> Result<Vec<u8>> {
66    let req: ArrayShardSliceReq =
67        zerompk::from_msgpack(payload).map_err(|e| ClusterError::Codec {
68            detail: format!("ArrayShardSliceReq decode: {e}"),
69        })?;
70
71    validate_slice_routing(&req, local_vshard_id)?;
72
73    let exec = executor.exec_slice(&req).await?;
74
75    let truncated = req.limit > 0 && exec.rows.len() >= req.limit as usize;
76    let resp = ArrayShardSliceResp {
77        shard_id: local_vshard_id,
78        rows_msgpack: exec.rows,
79        truncated,
80        truncated_before_horizon: exec.truncated_before_horizon,
81    };
82    serialise(resp)
83}
84
85async fn handle_agg(
86    local_vshard_id: u32,
87    payload: &[u8],
88    executor: &Arc<dyn ArrayLocalExecutor>,
89) -> Result<Vec<u8>> {
90    let req: ArrayShardAggReq =
91        zerompk::from_msgpack(payload).map_err(|e| ClusterError::Codec {
92            detail: format!("ArrayShardAggReq decode: {e}"),
93        })?;
94
95    let exec = executor.exec_agg(&req).await?;
96
97    let resp = ArrayShardAggResp {
98        shard_id: local_vshard_id,
99        partials: exec.partials,
100        truncated_before_horizon: exec.truncated_before_horizon,
101    };
102    serialise(resp)
103}
104
105async fn handle_put(
106    local_vshard_id: u32,
107    payload: &[u8],
108    executor: &Arc<dyn ArrayLocalExecutor>,
109) -> Result<Vec<u8>> {
110    let req: ArrayShardPutReq =
111        zerompk::from_msgpack(payload).map_err(|e| ClusterError::Codec {
112            detail: format!("ArrayShardPutReq decode: {e}"),
113        })?;
114
115    // Validate routing before dispatching: the PUT must have been sent to
116    // the correct shard. A mismatch means the coordinator used a stale
117    // routing table and the write must be rejected so the caller can retry
118    // with a refreshed routing table rather than silently writing to the
119    // wrong shard.
120    validate_put_routing(&req, local_vshard_id)?;
121
122    let applied_lsn = executor.exec_put(&req).await?;
123    let resp = ArrayShardPutResp {
124        shard_id: local_vshard_id,
125        applied_lsn,
126    };
127    serialise(resp)
128}
129
130async fn handle_delete(
131    local_vshard_id: u32,
132    payload: &[u8],
133    executor: &Arc<dyn ArrayLocalExecutor>,
134) -> Result<Vec<u8>> {
135    let req: ArrayShardDeleteReq =
136        zerompk::from_msgpack(payload).map_err(|e| ClusterError::Codec {
137            detail: format!("ArrayShardDeleteReq decode: {e}"),
138        })?;
139
140    validate_delete_routing(&req, local_vshard_id)?;
141
142    let applied_lsn = executor.exec_delete(&req).await?;
143    let resp = ArrayShardDeleteResp {
144        shard_id: local_vshard_id,
145        applied_lsn,
146    };
147    serialise(resp)
148}
149
150async fn handle_surrogate_bitmap(
151    local_vshard_id: u32,
152    payload: &[u8],
153    executor: &Arc<dyn ArrayLocalExecutor>,
154) -> Result<Vec<u8>> {
155    let req: ArrayShardSurrogateBitmapReq =
156        zerompk::from_msgpack(payload).map_err(|e| ClusterError::Codec {
157            detail: format!("ArrayShardSurrogateBitmapReq decode: {e}"),
158        })?;
159
160    let bitmap_msgpack = executor
161        .exec_surrogate_bitmap_scan(&req.array_id_msgpack, &req.slice_msgpack)
162        .await?;
163
164    let resp = ArrayShardSurrogateBitmapResp {
165        shard_id: local_vshard_id,
166        bitmap_msgpack,
167    };
168    serialise(resp)
169}
170
171/// Validate that a PUT request is routed to the correct shard.
172///
173/// Returns `Ok(())` when routing is correct or when `prefix_bits == 0`
174/// (which means the request predates Hilbert routing and skips validation).
175/// Returns `Err(ClusterError::WrongOwner)` when the coordinator used a stale
176/// routing table; the coordinator should refresh and retry.
177fn validate_put_routing(req: &ArrayShardPutReq, local_vshard_id: u32) -> Result<()> {
178    if req.prefix_bits == 0 {
179        return Ok(());
180    }
181    let expected = array_vshard_for_tile(req.representative_hilbert_prefix, req.prefix_bits)?;
182    if expected != local_vshard_id {
183        return Err(ClusterError::WrongOwner {
184            vshard_id: local_vshard_id,
185            expected_owner_node: None,
186        });
187    }
188    Ok(())
189}
190
191/// Validate that a SLICE request is routed to the correct shard.
192///
193/// Returns `Ok(())` when `prefix_bits == 0` (no validation) or when
194/// `local_vshard_id` falls within the Hilbert ranges carried by the request.
195/// Returns `Err(ClusterError::WrongOwner)` when this shard no longer covers
196/// any of the requested ranges, indicating a stale routing table.
197fn validate_slice_routing(req: &ArrayShardSliceReq, local_vshard_id: u32) -> Result<()> {
198    if req.prefix_bits == 0 || req.slice_hilbert_ranges.is_empty() {
199        return Ok(());
200    }
201    // Compute which vShards overlap the slice ranges. If local_vshard_id is not
202    // among them, this node no longer owns the required Hilbert range.
203    let covered = array_vshards_for_slice(
204        &req.slice_hilbert_ranges,
205        req.prefix_bits,
206        crate::routing::VSHARD_COUNT,
207    )?;
208    if covered.binary_search(&local_vshard_id).is_err() {
209        return Err(ClusterError::WrongOwner {
210            vshard_id: local_vshard_id,
211            expected_owner_node: None,
212        });
213    }
214    Ok(())
215}
216
217/// Validate that a DELETE request is routed to the correct shard.
218///
219/// Returns `Ok(())` when routing is correct or when `prefix_bits == 0`.
220/// Returns `Err(ClusterError::WrongOwner)` on misroute.
221fn validate_delete_routing(req: &ArrayShardDeleteReq, local_vshard_id: u32) -> Result<()> {
222    if req.prefix_bits == 0 {
223        return Ok(());
224    }
225    let expected = array_vshard_for_tile(req.representative_hilbert_prefix, req.prefix_bits)?;
226    if expected != local_vshard_id {
227        return Err(ClusterError::WrongOwner {
228            vshard_id: local_vshard_id,
229            expected_owner_node: None,
230        });
231    }
232    Ok(())
233}
234
235fn serialise<T: zerompk::ToMessagePack>(val: T) -> Result<Vec<u8>> {
236    zerompk::to_msgpack_vec(&val).map_err(|e| ClusterError::Codec {
237        detail: format!("array response serialise: {e}"),
238    })
239}
240
241#[cfg(test)]
242mod tests {
243    use std::sync::Arc;
244
245    use async_trait::async_trait;
246
247    use crate::distributed_array::merge::ArrayAggPartial;
248    use crate::distributed_array::wire::{ArrayShardAggReq, ArrayShardDeleteReq, ArrayShardPutReq};
249    use crate::error::Result;
250
251    use super::super::local_executor::{ArrayAggExec, ArrayLocalExecutor, ArraySliceExec};
252    use super::super::opcodes::{
253        ARRAY_SHARD_AGG_REQ, ARRAY_SHARD_DELETE_REQ, ARRAY_SHARD_PUT_REQ, ARRAY_SHARD_SLICE_REQ,
254        ARRAY_SHARD_SURROGATE_BITMAP_REQ,
255    };
256    use super::super::wire::{
257        ArrayShardAggResp, ArrayShardDeleteResp, ArrayShardPutResp, ArrayShardSliceResp,
258        ArrayShardSurrogateBitmapResp,
259    };
260    use super::handle_array_shard_rpc;
261
262    /// Mock executor that returns a fixed set of row bytes for slice,
263    /// a fixed bitmap for surrogate scan, fixed partials for agg, and
264    /// echoes back the `wal_lsn` for put/delete.
265    struct StubExecutor {
266        rows: Vec<Vec<u8>>,
267        bitmap: Vec<u8>,
268        partials: Vec<ArrayAggPartial>,
269        truncated_before_horizon: bool,
270    }
271
272    #[async_trait]
273    impl ArrayLocalExecutor for StubExecutor {
274        async fn exec_slice(
275            &self,
276            _req: &super::super::wire::ArrayShardSliceReq,
277        ) -> Result<ArraySliceExec> {
278            Ok(ArraySliceExec {
279                rows: self.rows.clone(),
280                truncated_before_horizon: self.truncated_before_horizon,
281            })
282        }
283
284        async fn exec_surrogate_bitmap_scan(
285            &self,
286            _array_id_msgpack: &[u8],
287            _slice_msgpack: &[u8],
288        ) -> Result<Vec<u8>> {
289            Ok(self.bitmap.clone())
290        }
291
292        async fn exec_agg(&self, _req: &ArrayShardAggReq) -> Result<ArrayAggExec> {
293            Ok(ArrayAggExec {
294                partials: self.partials.clone(),
295                truncated_before_horizon: self.truncated_before_horizon,
296            })
297        }
298
299        async fn exec_put(&self, req: &ArrayShardPutReq) -> Result<u64> {
300            Ok(req.wal_lsn)
301        }
302
303        async fn exec_delete(&self, req: &ArrayShardDeleteReq) -> Result<u64> {
304            Ok(req.wal_lsn)
305        }
306    }
307
308    fn make_slice_req_bytes() -> Vec<u8> {
309        let req = super::super::wire::ArrayShardSliceReq {
310            array_id_msgpack: vec![],
311            slice_msgpack: vec![],
312            attr_projection: vec![],
313            limit: 10,
314            cell_filter_msgpack: vec![],
315            // prefix_bits=0 skips routing validation in the handler.
316            prefix_bits: 0,
317            slice_hilbert_ranges: vec![],
318            shard_hilbert_range: None,
319            system_time: nodedb_types::SystemTimeScope::Current,
320            valid_at_ms: None,
321        };
322        zerompk::to_msgpack_vec(&req).unwrap()
323    }
324
325    fn make_agg_req_bytes() -> Vec<u8> {
326        // Encode a minimal Sum reducer (c_enum byte).
327        // We use rmp_serde directly since we don't have ArrayReducer here,
328        // but any single-byte msgpack value works as the reducer payload for
329        // StubExecutor (it never decodes it).
330        let reducer_bytes = vec![0x00u8]; // Sum = 0 as c_enum
331        let req = super::super::wire::ArrayShardAggReq {
332            array_id_msgpack: vec![],
333            attr_idx: 0,
334            reducer_msgpack: reducer_bytes,
335            group_by_dim: -1,
336            cell_filter_msgpack: vec![],
337            shard_hilbert_range: None,
338            system_as_of: None,
339            valid_at_ms: None,
340        };
341        zerompk::to_msgpack_vec(&req).unwrap()
342    }
343
344    fn make_bitmap_req_bytes() -> Vec<u8> {
345        let req = super::super::wire::ArrayShardSurrogateBitmapReq {
346            array_id_msgpack: vec![],
347            slice_msgpack: vec![],
348        };
349        zerompk::to_msgpack_vec(&req).unwrap()
350    }
351
352    #[tokio::test]
353    async fn handle_slice_returns_executor_rows() {
354        let row = b"row-data".to_vec();
355        let executor: Arc<dyn ArrayLocalExecutor> = Arc::new(StubExecutor {
356            rows: vec![row.clone(), row.clone()],
357            bitmap: vec![],
358            partials: vec![],
359            truncated_before_horizon: false,
360        });
361        let payload = make_slice_req_bytes();
362        let resp_bytes = handle_array_shard_rpc(ARRAY_SHARD_SLICE_REQ, 0, &payload, &executor)
363            .await
364            .expect("slice handler should succeed");
365
366        let resp: ArrayShardSliceResp =
367            zerompk::from_msgpack(&resp_bytes).expect("response should deserialise");
368        assert_eq!(resp.rows_msgpack.len(), 2);
369        assert_eq!(resp.rows_msgpack[0], row);
370    }
371
372    #[tokio::test]
373    async fn handle_slice_propagates_truncated_before_horizon() {
374        // The Data Plane computes `truncated_before_horizon`; the handler must
375        // forward it from the executor, not hardcode `false` — otherwise the
376        // coordinator's OR-reduce always sees `false` and below-horizon
377        // bitemporal reads silently report complete results.
378        let executor: Arc<dyn ArrayLocalExecutor> = Arc::new(StubExecutor {
379            rows: vec![],
380            bitmap: vec![],
381            partials: vec![],
382            truncated_before_horizon: true,
383        });
384        let payload = make_slice_req_bytes();
385        let resp_bytes = handle_array_shard_rpc(ARRAY_SHARD_SLICE_REQ, 0, &payload, &executor)
386            .await
387            .expect("slice handler should succeed");
388        let resp: ArrayShardSliceResp =
389            zerompk::from_msgpack(&resp_bytes).expect("response should deserialise");
390        assert!(
391            resp.truncated_before_horizon,
392            "handler must forward the executor's below-horizon flag"
393        );
394    }
395
396    #[tokio::test]
397    async fn handle_agg_propagates_truncated_before_horizon() {
398        let executor: Arc<dyn ArrayLocalExecutor> = Arc::new(StubExecutor {
399            rows: vec![],
400            bitmap: vec![],
401            partials: vec![],
402            truncated_before_horizon: true,
403        });
404        let payload = make_agg_req_bytes();
405        let resp_bytes = handle_array_shard_rpc(ARRAY_SHARD_AGG_REQ, 0, &payload, &executor)
406            .await
407            .expect("agg handler should succeed");
408        let resp: ArrayShardAggResp =
409            zerompk::from_msgpack(&resp_bytes).expect("response should deserialise");
410        assert!(
411            resp.truncated_before_horizon,
412            "handler must forward the executor's below-horizon flag"
413        );
414    }
415
416    #[tokio::test]
417    async fn handle_surrogate_bitmap_returns_executor_bitmap() {
418        let bitmap = vec![0xDE, 0xAD, 0xBE, 0xEF];
419        let executor: Arc<dyn ArrayLocalExecutor> = Arc::new(StubExecutor {
420            rows: vec![],
421            bitmap: bitmap.clone(),
422            partials: vec![],
423            truncated_before_horizon: false,
424        });
425        let payload = make_bitmap_req_bytes();
426        let resp_bytes =
427            handle_array_shard_rpc(ARRAY_SHARD_SURROGATE_BITMAP_REQ, 1, &payload, &executor)
428                .await
429                .expect("bitmap handler should succeed");
430
431        let resp: ArrayShardSurrogateBitmapResp =
432            zerompk::from_msgpack(&resp_bytes).expect("response should deserialise");
433        assert_eq!(resp.shard_id, 1);
434        assert_eq!(resp.bitmap_msgpack, bitmap);
435    }
436
437    #[tokio::test]
438    async fn handle_agg_returns_executor_partials() {
439        let partial = ArrayAggPartial::from_single(0, 42.0);
440        let executor: Arc<dyn ArrayLocalExecutor> = Arc::new(StubExecutor {
441            rows: vec![],
442            bitmap: vec![],
443            partials: vec![partial.clone()],
444            truncated_before_horizon: false,
445        });
446        let payload = make_agg_req_bytes();
447        let resp_bytes = handle_array_shard_rpc(ARRAY_SHARD_AGG_REQ, 3, &payload, &executor)
448            .await
449            .expect("agg handler should succeed");
450
451        let resp: ArrayShardAggResp =
452            zerompk::from_msgpack(&resp_bytes).expect("response should deserialise");
453        assert_eq!(resp.shard_id, 3);
454        assert_eq!(resp.partials.len(), 1);
455        assert_eq!(resp.partials[0].count, 1);
456        assert!((resp.partials[0].sum - 42.0).abs() < f64::EPSILON);
457    }
458
459    fn make_put_req_bytes(vshard_id: u32, prefix_bits: u8) -> Vec<u8> {
460        // Build a minimal ArrayShardPutReq with routing metadata set so
461        // validate_put_routing can confirm the request is destined for
462        // the right shard. Use prefix_bits=0 to skip routing validation
463        // in tests that only care about the executor round-trip.
464        let req = super::super::wire::ArrayShardPutReq {
465            array_id_msgpack: vec![],
466            cells_msgpack: vec![],
467            wal_lsn: 77,
468            representative_hilbert_prefix: if prefix_bits == 0 {
469                0
470            } else {
471                // Route to bucket 0 (top bits all zero) → vshard 0 with any prefix_bits.
472                0u64
473            },
474            prefix_bits,
475        };
476        // Adjust routing so the request would target vshard_id: set bits so
477        // bucket = vshard_id when stride = 1 (prefix_bits=10, VSHARD_COUNT=1024).
478        let _ = vshard_id; // routing byte chosen above; test uses prefix_bits=0.
479        zerompk::to_msgpack_vec(&req).unwrap()
480    }
481
482    fn make_delete_req_bytes() -> Vec<u8> {
483        let req = super::super::wire::ArrayShardDeleteReq {
484            array_id_msgpack: vec![],
485            coords_msgpack: vec![],
486            wal_lsn: 88,
487            // prefix_bits=0 skips routing validation in the handler.
488            representative_hilbert_prefix: 0,
489            prefix_bits: 0,
490        };
491        zerompk::to_msgpack_vec(&req).unwrap()
492    }
493
494    #[tokio::test]
495    async fn handle_put_delegates_to_executor_and_echoes_lsn() {
496        let executor: Arc<dyn ArrayLocalExecutor> = Arc::new(StubExecutor {
497            rows: vec![],
498            bitmap: vec![],
499            partials: vec![],
500            truncated_before_horizon: false,
501        });
502        // prefix_bits=0 disables routing validation.
503        let payload = make_put_req_bytes(0, 0);
504        let resp_bytes = handle_array_shard_rpc(ARRAY_SHARD_PUT_REQ, 0, &payload, &executor)
505            .await
506            .expect("put handler should succeed");
507
508        let resp: ArrayShardPutResp =
509            zerompk::from_msgpack(&resp_bytes).expect("response should deserialise");
510        assert_eq!(resp.shard_id, 0);
511        assert_eq!(resp.applied_lsn, 77);
512    }
513
514    #[tokio::test]
515    async fn handle_delete_delegates_to_executor_and_echoes_lsn() {
516        let executor: Arc<dyn ArrayLocalExecutor> = Arc::new(StubExecutor {
517            rows: vec![],
518            bitmap: vec![],
519            partials: vec![],
520            truncated_before_horizon: false,
521        });
522        let payload = make_delete_req_bytes();
523        let resp_bytes = handle_array_shard_rpc(ARRAY_SHARD_DELETE_REQ, 2, &payload, &executor)
524            .await
525            .expect("delete handler should succeed");
526
527        let resp: ArrayShardDeleteResp =
528            zerompk::from_msgpack(&resp_bytes).expect("response should deserialise");
529        assert_eq!(resp.shard_id, 2);
530        assert_eq!(resp.applied_lsn, 88);
531    }
532
533    #[tokio::test]
534    async fn handle_put_rejects_misrouted_request() {
535        let executor: Arc<dyn ArrayLocalExecutor> = Arc::new(StubExecutor {
536            rows: vec![],
537            bitmap: vec![],
538            partials: vec![],
539            truncated_before_horizon: false,
540        });
541        // prefix_bits=10, stride=1 → bucket = top 10 bits of hilbert_prefix.
542        // hilbert_prefix = 0 → bucket 0 → expected vshard 0.
543        // We tell the handler we're vshard 7 → mismatch → error.
544        let req = super::super::wire::ArrayShardPutReq {
545            array_id_msgpack: vec![],
546            cells_msgpack: vec![],
547            wal_lsn: 1,
548            representative_hilbert_prefix: 0,
549            prefix_bits: 10,
550        };
551        let payload = zerompk::to_msgpack_vec(&req).unwrap();
552        let err = handle_array_shard_rpc(ARRAY_SHARD_PUT_REQ, 7, &payload, &executor)
553            .await
554            .expect_err("misrouted put should fail");
555        assert!(
556            matches!(
557                err,
558                crate::error::ClusterError::WrongOwner { vshard_id: 7, .. }
559            ),
560            "expected WrongOwner for vshard 7, got {err:?}"
561        );
562    }
563
564    #[test]
565    fn validate_slice_routing_rejects_disjoint_range() {
566        // prefix_bits=10, stride=1 → vshard == bucket == top 10 bits.
567        // Hilbert range [0x0040_0000_0000_0000, 0x0040_0000_0000_0000]
568        // covers bucket 1 → vshard 1.
569        // Local shard is vshard 5 → disjoint → WrongOwner.
570        let req = super::super::wire::ArrayShardSliceReq {
571            array_id_msgpack: vec![],
572            slice_msgpack: vec![],
573            attr_projection: vec![],
574            limit: 10,
575            cell_filter_msgpack: vec![],
576            prefix_bits: 10,
577            slice_hilbert_ranges: vec![(0x0040_0000_0000_0000, 0x0040_0000_0000_0000)],
578            shard_hilbert_range: None,
579            system_time: nodedb_types::SystemTimeScope::Current,
580            valid_at_ms: None,
581        };
582        let err = super::validate_slice_routing(&req, 5)
583            .expect_err("disjoint Hilbert range should reject");
584        assert!(
585            matches!(
586                err,
587                crate::error::ClusterError::WrongOwner { vshard_id: 5, .. }
588            ),
589            "expected WrongOwner for vshard 5, got {err:?}"
590        );
591    }
592
593    #[test]
594    fn validate_slice_routing_accepts_overlapping_range() {
595        // Same range as above → vshard 1. Local shard IS vshard 1 → accept.
596        let req = super::super::wire::ArrayShardSliceReq {
597            array_id_msgpack: vec![],
598            slice_msgpack: vec![],
599            attr_projection: vec![],
600            limit: 10,
601            cell_filter_msgpack: vec![],
602            prefix_bits: 10,
603            slice_hilbert_ranges: vec![(0x0040_0000_0000_0000, 0x0040_0000_0000_0000)],
604            shard_hilbert_range: None,
605            system_time: nodedb_types::SystemTimeScope::Current,
606            valid_at_ms: None,
607        };
608        super::validate_slice_routing(&req, 1).expect("overlapping range should accept");
609    }
610
611    #[tokio::test]
612    async fn handle_unknown_opcode_returns_codec_error() {
613        let executor: Arc<dyn ArrayLocalExecutor> = Arc::new(StubExecutor {
614            rows: vec![],
615            bitmap: vec![],
616            partials: vec![],
617            truncated_before_horizon: false,
618        });
619        let err = handle_array_shard_rpc(0xFF, 0, &[], &executor)
620            .await
621            .expect_err("unknown opcode should fail");
622        assert!(
623            matches!(err, crate::error::ClusterError::Codec { .. }),
624            "{err:?}"
625        );
626    }
627}