Skip to main content

nodedb_cluster/distributed_array/
merge.rs

1// SPDX-License-Identifier: BUSL-1.1
2
3//! Merge functions for distributed array query results.
4//!
5//! Plain slice merges (`merge_slice_rows`) concatenate row sets from shards
6//! in arrival order and apply the coordinator-side limit, preserving each
7//! shard's intra-shard order. Cross-shard ordering reflects arrival, since
8//! a plain slice row carries no per-row sort key. A globally Hilbert-ordered
9//! merge would require carrying a parallel prefix column on the wire and
10//! a k-way merge here — that is a wire-format change, not a merger change,
11//! and lives outside this module.
12//!
13//! Audit-log slice merges (`merge_slice_rows_sorted`) do carry a sort key:
14//! each native-msgpack row holds a `_ts_system` column, so these are merged
15//! globally by system-time ascending before the limit is applied.
16//!
17//! Aggregate merges combine per-shard partial aggregates using
18//! reducer-specific arithmetic (SUM/COUNT/MIN/MAX — same Welford
19//! technique as the timeseries merger).
20
21use serde::{Deserialize, Serialize};
22
23use super::wire::{ArrayShardAggResp, ArrayShardSliceResp};
24use crate::error::{ClusterError, Result};
25
26/// Partial aggregate contributed by a single shard for one group-by bucket.
27///
28/// Carries enough state for all supported reducers (SUM, COUNT, MIN, MAX,
29/// MEAN). Welford fields enable variance/stddev if a future reducer needs it.
30#[derive(
31    Debug, Clone, Serialize, Deserialize, zerompk::ToMessagePack, zerompk::FromMessagePack,
32)]
33pub struct ArrayAggPartial {
34    /// Group-by dimension value, or 0 when group_by_dim < 0 (scalar aggregate).
35    pub group_key: i64,
36    pub count: u64,
37    pub sum: f64,
38    pub min: f64,
39    pub max: f64,
40    /// Welford mean — enables MEAN without a second pass.
41    pub welford_mean: f64,
42    pub welford_m2: f64,
43}
44
45impl ArrayAggPartial {
46    /// Create from a single cell value.
47    pub fn from_single(group_key: i64, val: f64) -> Self {
48        Self {
49            group_key,
50            count: 1,
51            sum: val,
52            min: val,
53            max: val,
54            welford_mean: val,
55            welford_m2: 0.0,
56        }
57    }
58
59    /// Merge another partial into this one using parallel Welford.
60    pub fn merge(&mut self, other: &ArrayAggPartial) {
61        if other.count == 0 {
62            return;
63        }
64        if self.count == 0 {
65            *self = other.clone();
66            return;
67        }
68        self.sum += other.sum;
69        if other.min < self.min {
70            self.min = other.min;
71        }
72        if other.max > self.max {
73            self.max = other.max;
74        }
75        let new_count = self.count + other.count;
76        let delta = other.welford_mean - self.welford_mean;
77        let combined_mean = (self.welford_mean * self.count as f64
78            + other.welford_mean * other.count as f64)
79            / new_count as f64;
80        let combined_m2 = self.welford_m2
81            + other.welford_m2
82            + delta * delta * (self.count as f64 * other.count as f64) / new_count as f64;
83        self.welford_mean = combined_mean;
84        self.welford_m2 = combined_m2;
85        self.count = new_count;
86    }
87}
88
89/// Returns `true` if any shard reported that `system_as_of` fell below its
90/// oldest tile version and it produced zero rows as a result.
91///
92/// Callers combine this flag via logical OR across shards to propagate the
93/// below-horizon signal to the upstream coordinator response.
94pub fn any_truncated_before_horizon_slice(shard_resps: &[ArrayShardSliceResp]) -> bool {
95    shard_resps.iter().any(|r| r.truncated_before_horizon)
96}
97
98/// Returns `true` if any shard reported that `system_as_of` fell below its
99/// oldest tile version, causing the shard to contribute zero partials.
100pub fn any_truncated_before_horizon_agg(shard_resps: &[ArrayShardAggResp]) -> bool {
101    shard_resps.iter().any(|r| r.truncated_before_horizon)
102}
103
104/// Merge row batches from multiple shards into one result set.
105///
106/// Rows are concatenated in shard-arrival order (order-independent for
107/// an unsorted slice). If `coordinator_limit > 0` the merged list is
108/// truncated to at most `coordinator_limit` rows after concatenation —
109/// this is the final cut-off after shards have already applied their own
110/// per-shard limit via `ArrayShardSliceReq::limit`.
111///
112/// Pass `coordinator_limit = 0` to return all rows without truncation.
113pub fn merge_slice_rows(
114    shard_resps: &[ArrayShardSliceResp],
115    coordinator_limit: u32,
116) -> Vec<Vec<u8>> {
117    let total: usize = shard_resps.iter().map(|r| r.rows_msgpack.len()).sum();
118    let cap = if coordinator_limit > 0 {
119        total.min(coordinator_limit as usize)
120    } else {
121        total
122    };
123    let mut merged = Vec::with_capacity(cap);
124    'outer: for resp in shard_resps {
125        for row in &resp.rows_msgpack {
126            if coordinator_limit > 0 && merged.len() >= coordinator_limit as usize {
127                break 'outer;
128            }
129            merged.push(row.clone());
130        }
131    }
132    merged
133}
134
135/// Merge row batches from multiple shards for an audit-log (`AllVersions`) fan-out.
136///
137/// Unlike `merge_slice_rows`, which concatenates rows in shard-arrival order,
138/// this function decodes each row's system-time and sorts globally by that
139/// time ascending (ties broken by the raw msgpack byte sequence as a stable
140/// coord proxy) before applying the limit.
141///
142/// Each row is the native (untagged) msgpack map emitted by the array slice
143/// handler — `{"coords": [...], "attrs": [...], "_ts_system": <int>}`. In
144/// audit-log mode every row carries `_ts_system`; a row that fails to decode
145/// or is missing that column is a wire-contract violation (corruption or a
146/// handler bug), so this function returns an error rather than silently
147/// mis-ordering the result — see the "no silent partial success" rule.
148///
149/// Pass `coordinator_limit = 0` to return all rows without truncation.
150pub fn merge_slice_rows_sorted(
151    shard_resps: &[ArrayShardSliceResp],
152    coordinator_limit: u32,
153) -> Result<Vec<Vec<u8>>> {
154    // Collect all raw rows from all shards. Each entry is (system_time_ms, raw_bytes).
155    let mut tagged: Vec<(i64, Vec<u8>)> = Vec::new();
156    for resp in shard_resps {
157        for row in &resp.rows_msgpack {
158            let ts = decode_system_time(row)?;
159            tagged.push((ts, row.clone()));
160        }
161    }
162    // Stable sort: ascending by system_time, then by raw bytes for tie-breaking.
163    tagged.sort_by(|(ts_a, bytes_a), (ts_b, bytes_b)| {
164        ts_a.cmp(ts_b).then_with(|| bytes_a.cmp(bytes_b))
165    });
166    let total = tagged.len();
167    let cap = if coordinator_limit > 0 {
168        total.min(coordinator_limit as usize)
169    } else {
170        total
171    };
172    Ok(tagged.into_iter().take(cap).map(|(_, b)| b).collect())
173}
174
175/// Decode the `_ts_system` system-time column from a native-msgpack audit-log row.
176///
177/// Rows are encoded by the array slice handler via the native (untagged)
178/// `value_to_msgpack` writer, so they decode back to a `Value::Object` map.
179/// In audit-log mode the handler always stamps `_ts_system`; its absence — or
180/// a row that doesn't decode as an object at all — means the wire payload is
181/// corrupt or the handler contract was broken, both of which are hard errors.
182fn decode_system_time(bytes: &[u8]) -> Result<i64> {
183    let value = nodedb_types::value_from_msgpack(bytes).map_err(|e| ClusterError::Codec {
184        detail: format!("audit-log row decode in sorted merge: {e}"),
185    })?;
186    match value {
187        nodedb_types::Value::Object(map) => match map.get("_ts_system") {
188            Some(nodedb_types::Value::Integer(ts)) => Ok(*ts),
189            Some(other) => Err(ClusterError::Codec {
190                detail: format!(
191                    "audit-log row _ts_system is {}, expected integer",
192                    other.type_name()
193                ),
194            }),
195            None => Err(ClusterError::Codec {
196                detail: "audit-log row missing _ts_system column in sorted merge".into(),
197            }),
198        },
199        other => Err(ClusterError::Codec {
200            detail: format!(
201                "audit-log row decoded as {}, expected object map",
202                other.type_name()
203            ),
204        }),
205    }
206}
207
208/// Merge per-shard partial aggregates into one result per group-by key.
209///
210/// Groups by `group_key`; uses `ArrayAggPartial::merge` for each group.
211pub fn reduce_agg_partials(shard_resps: &[ArrayShardAggResp]) -> Vec<ArrayAggPartial> {
212    use std::collections::BTreeMap;
213    let mut buckets: BTreeMap<i64, ArrayAggPartial> = BTreeMap::new();
214    for resp in shard_resps {
215        for partial in &resp.partials {
216            buckets
217                .entry(partial.group_key)
218                .and_modify(|existing| existing.merge(partial))
219                .or_insert_with(|| partial.clone());
220        }
221    }
222    buckets.into_values().collect()
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn reduce_sum_across_shards() {
231        let resp_a = ArrayShardAggResp {
232            shard_id: 0,
233            partials: vec![ArrayAggPartial::from_single(0, 10.0)],
234            truncated_before_horizon: false,
235        };
236        let resp_b = ArrayShardAggResp {
237            shard_id: 1,
238            partials: vec![ArrayAggPartial::from_single(0, 20.0)],
239            truncated_before_horizon: false,
240        };
241        let merged = reduce_agg_partials(&[resp_a, resp_b]);
242        assert_eq!(merged.len(), 1);
243        assert_eq!(merged[0].count, 2);
244        assert!((merged[0].sum - 30.0).abs() < f64::EPSILON);
245    }
246
247    #[test]
248    fn reduce_separate_group_keys() {
249        let resp = ArrayShardAggResp {
250            shard_id: 0,
251            partials: vec![
252                ArrayAggPartial::from_single(0, 5.0),
253                ArrayAggPartial::from_single(1, 15.0),
254            ],
255            truncated_before_horizon: false,
256        };
257        let merged = reduce_agg_partials(&[resp]);
258        assert_eq!(merged.len(), 2);
259    }
260
261    #[test]
262    fn merge_empty_partial_is_noop() {
263        let mut a = ArrayAggPartial::from_single(0, 42.0);
264        let empty = ArrayAggPartial {
265            count: 0,
266            ..ArrayAggPartial::from_single(0, 0.0)
267        };
268        a.merge(&empty);
269        assert_eq!(a.count, 1);
270        assert!((a.sum - 42.0).abs() < f64::EPSILON);
271    }
272
273    #[test]
274    fn merge_slice_rows_concatenates() {
275        let r0 = ArrayShardSliceResp {
276            shard_id: 0,
277            rows_msgpack: vec![vec![1u8], vec![2u8]],
278            truncated: false,
279            truncated_before_horizon: false,
280        };
281        let r1 = ArrayShardSliceResp {
282            shard_id: 1,
283            rows_msgpack: vec![vec![3u8]],
284            truncated: false,
285            truncated_before_horizon: false,
286        };
287        let rows = merge_slice_rows(&[r0, r1], 0);
288        assert_eq!(rows.len(), 3);
289    }
290
291    #[test]
292    fn merge_slice_rows_applies_coordinator_limit() {
293        let resp = ArrayShardSliceResp {
294            shard_id: 0,
295            rows_msgpack: vec![vec![1u8], vec![2u8], vec![3u8], vec![4u8], vec![5u8]],
296            truncated: false,
297            truncated_before_horizon: false,
298        };
299        let rows = merge_slice_rows(&[resp], 3);
300        assert_eq!(rows.len(), 3);
301        assert_eq!(rows[0], vec![1u8]);
302        assert_eq!(rows[2], vec![3u8]);
303    }
304
305    #[test]
306    fn reduce_min_across_shards() {
307        let resp_a = ArrayShardAggResp {
308            shard_id: 0,
309            partials: vec![ArrayAggPartial::from_single(0, 5.0)],
310            truncated_before_horizon: false,
311        };
312        let resp_b = ArrayShardAggResp {
313            shard_id: 1,
314            partials: vec![ArrayAggPartial::from_single(0, 3.0)],
315            truncated_before_horizon: false,
316        };
317        let merged = reduce_agg_partials(&[resp_a, resp_b]);
318        assert_eq!(merged.len(), 1);
319        assert!((merged[0].min - 3.0).abs() < f64::EPSILON);
320    }
321
322    #[test]
323    fn reduce_max_across_shards() {
324        let resp_a = ArrayShardAggResp {
325            shard_id: 0,
326            partials: vec![ArrayAggPartial::from_single(0, 5.0)],
327            truncated_before_horizon: false,
328        };
329        let resp_b = ArrayShardAggResp {
330            shard_id: 1,
331            partials: vec![ArrayAggPartial::from_single(0, 99.0)],
332            truncated_before_horizon: false,
333        };
334        let merged = reduce_agg_partials(&[resp_a, resp_b]);
335        assert_eq!(merged.len(), 1);
336        assert!((merged[0].max - 99.0).abs() < f64::EPSILON);
337    }
338
339    #[test]
340    fn reduce_avg_welford_merge_exact() {
341        // Two shards: shard A has one value of 10, shard B has one value of 20.
342        // Combined mean should be exactly 15.
343        let mut a = ArrayAggPartial::from_single(0, 10.0);
344        let b = ArrayAggPartial::from_single(0, 20.0);
345        a.merge(&b);
346        // welford_mean after merge = 15.0
347        assert!((a.welford_mean - 15.0).abs() < 1e-9);
348        assert_eq!(a.count, 2);
349        assert!((a.sum - 30.0).abs() < f64::EPSILON);
350    }
351
352    #[test]
353    fn reduce_grouped_overlapping_keys() {
354        // Shard A: groups 0→5, 1→10. Shard B: groups 1→20, 2→30.
355        let resp_a = ArrayShardAggResp {
356            shard_id: 0,
357            partials: vec![
358                ArrayAggPartial::from_single(0, 5.0),
359                ArrayAggPartial::from_single(1, 10.0),
360            ],
361            truncated_before_horizon: false,
362        };
363        let resp_b = ArrayShardAggResp {
364            shard_id: 1,
365            partials: vec![
366                ArrayAggPartial::from_single(1, 20.0),
367                ArrayAggPartial::from_single(2, 30.0),
368            ],
369            truncated_before_horizon: false,
370        };
371        let merged = reduce_agg_partials(&[resp_a, resp_b]);
372        assert_eq!(merged.len(), 3);
373        let g0 = merged.iter().find(|p| p.group_key == 0).unwrap();
374        let g1 = merged.iter().find(|p| p.group_key == 1).unwrap();
375        let g2 = merged.iter().find(|p| p.group_key == 2).unwrap();
376        assert!((g0.sum - 5.0).abs() < f64::EPSILON);
377        assert!((g1.sum - 30.0).abs() < f64::EPSILON);
378        assert!((g2.sum - 30.0).abs() < f64::EPSILON);
379    }
380
381    #[test]
382    fn truncated_before_horizon_or_combines_across_shards() {
383        let r0 = ArrayShardSliceResp {
384            shard_id: 0,
385            rows_msgpack: vec![],
386            truncated: false,
387            truncated_before_horizon: true,
388        };
389        let r1 = ArrayShardSliceResp {
390            shard_id: 1,
391            rows_msgpack: vec![vec![1u8]],
392            truncated: false,
393            truncated_before_horizon: false,
394        };
395        assert!(any_truncated_before_horizon_slice(&[r0, r1]));
396
397        let a0 = ArrayShardAggResp {
398            shard_id: 0,
399            partials: vec![],
400            truncated_before_horizon: false,
401        };
402        let a1 = ArrayShardAggResp {
403            shard_id: 1,
404            partials: vec![],
405            truncated_before_horizon: true,
406        };
407        assert!(any_truncated_before_horizon_agg(&[a0, a1]));
408
409        let a_none = ArrayShardAggResp {
410            shard_id: 2,
411            partials: vec![],
412            truncated_before_horizon: false,
413        };
414        assert!(!any_truncated_before_horizon_agg(&[a_none]));
415    }
416
417    #[test]
418    fn reduce_grouped_disjoint_keys() {
419        // Shard A has only group 0; shard B has only group 1 — no overlap.
420        let resp_a = ArrayShardAggResp {
421            shard_id: 0,
422            partials: vec![ArrayAggPartial::from_single(0, 7.0)],
423            truncated_before_horizon: false,
424        };
425        let resp_b = ArrayShardAggResp {
426            shard_id: 1,
427            partials: vec![ArrayAggPartial::from_single(1, 13.0)],
428            truncated_before_horizon: false,
429        };
430        let merged = reduce_agg_partials(&[resp_a, resp_b]);
431        assert_eq!(merged.len(), 2);
432        let g0 = merged.iter().find(|p| p.group_key == 0).unwrap();
433        let g1 = merged.iter().find(|p| p.group_key == 1).unwrap();
434        assert_eq!(g0.count, 1);
435        assert_eq!(g1.count, 1);
436    }
437
438    #[test]
439    fn merge_slice_rows_limit_across_shards() {
440        let r0 = ArrayShardSliceResp {
441            shard_id: 0,
442            rows_msgpack: vec![vec![1u8], vec![2u8]],
443            truncated: false,
444            truncated_before_horizon: false,
445        };
446        let r1 = ArrayShardSliceResp {
447            shard_id: 1,
448            rows_msgpack: vec![vec![3u8], vec![4u8]],
449            truncated: false,
450            truncated_before_horizon: false,
451        };
452        // Total 4 rows, limit 3 → first 3.
453        let rows = merge_slice_rows(&[r0, r1], 3);
454        assert_eq!(rows.len(), 3);
455    }
456
457    /// Build a wire row exactly as the array slice handler does: a native
458    /// (untagged) msgpack encoding of `Value::ArrayCell` carrying `_ts_system`.
459    fn audit_row(system_time: i64, coord: i64) -> Vec<u8> {
460        use nodedb_types::Value;
461        use nodedb_types::array_cell::ArrayCell;
462        let cell = Value::ArrayCell(ArrayCell {
463            coords: vec![Value::Integer(coord)],
464            attrs: vec![Value::Float(1.0)],
465            system_time: Some(system_time),
466        });
467        nodedb_types::value_to_msgpack(&cell).expect("encode audit row")
468    }
469
470    #[test]
471    fn merge_slice_rows_sorted_orders_by_system_time_across_shards() {
472        // Shard 0 emits ts=30 then ts=10; shard 1 emits ts=20. The merged
473        // output must be globally ascending by system-time: 10, 20, 30.
474        let r0 = ArrayShardSliceResp {
475            shard_id: 0,
476            rows_msgpack: vec![audit_row(30, 3), audit_row(10, 1)],
477            truncated: false,
478            truncated_before_horizon: false,
479        };
480        let r1 = ArrayShardSliceResp {
481            shard_id: 1,
482            rows_msgpack: vec![audit_row(20, 2)],
483            truncated: false,
484            truncated_before_horizon: false,
485        };
486        let rows = merge_slice_rows_sorted(&[r0, r1], 0).expect("sorted merge");
487        let times: Vec<i64> = rows
488            .iter()
489            .map(|b| decode_system_time(b).unwrap())
490            .collect();
491        assert_eq!(times, vec![10, 20, 30]);
492    }
493
494    #[test]
495    fn merge_slice_rows_sorted_applies_limit_after_global_sort() {
496        // Limit must cut the globally-sorted sequence, not per-shard arrival.
497        let r0 = ArrayShardSliceResp {
498            shard_id: 0,
499            rows_msgpack: vec![audit_row(50, 5), audit_row(10, 1)],
500            truncated: false,
501            truncated_before_horizon: false,
502        };
503        let r1 = ArrayShardSliceResp {
504            shard_id: 1,
505            rows_msgpack: vec![audit_row(30, 3), audit_row(20, 2)],
506            truncated: false,
507            truncated_before_horizon: false,
508        };
509        let rows = merge_slice_rows_sorted(&[r0, r1], 2).expect("sorted merge");
510        let times: Vec<i64> = rows
511            .iter()
512            .map(|b| decode_system_time(b).unwrap())
513            .collect();
514        assert_eq!(times, vec![10, 20]);
515    }
516
517    #[test]
518    fn merge_slice_rows_sorted_rejects_corrupt_row() {
519        // A row that doesn't decode as a native object map is a wire-contract
520        // violation and must surface as an error, not sort silently to the end.
521        let resp = ArrayShardSliceResp {
522            shard_id: 0,
523            rows_msgpack: vec![audit_row(10, 1), vec![0xFF, 0xFF, 0xFF]],
524            truncated: false,
525            truncated_before_horizon: false,
526        };
527        assert!(merge_slice_rows_sorted(&[resp], 0).is_err());
528    }
529
530    #[test]
531    fn agg_partial_wire_shape_is_tuple_with_horizon_flag() {
532        // The Data Plane `return_partial` path encodes `(partials, flag)` as a
533        // 2-element msgpack array (see `encode_bitemporal_agg_partial`). The
534        // cluster executor MUST decode that tuple — decoding it as a bare
535        // `Vec<ArrayAggPartial>` is a shape mismatch that fails outright and
536        // also drops the below-horizon signal. This pins the contract so the
537        // producer and consumer can't drift apart again.
538        let partials = vec![
539            ArrayAggPartial::from_single(0, 10.0),
540            ArrayAggPartial::from_single(1, 20.0),
541        ];
542        let bytes = zerompk::to_msgpack_vec(&(&partials, true)).expect("encode tuple");
543
544        // Correct shape round-trips, carrying the flag.
545        let (decoded, flag): (Vec<ArrayAggPartial>, bool) =
546            zerompk::from_msgpack(&bytes).expect("decode tuple");
547        assert_eq!(decoded.len(), 2);
548        assert!(flag);
549
550        // The old (buggy) bare-Vec decode must fail — guards the regression.
551        assert!(zerompk::from_msgpack::<Vec<ArrayAggPartial>>(&bytes).is_err());
552    }
553
554    #[test]
555    fn decode_system_time_reads_ts_system_column() {
556        let bytes = audit_row(1_700_000_000_123, 7);
557        assert_eq!(decode_system_time(&bytes).unwrap(), 1_700_000_000_123);
558    }
559
560    #[test]
561    fn merge_slice_rows_zero_limit_is_unlimited() {
562        let resp = ArrayShardSliceResp {
563            shard_id: 0,
564            rows_msgpack: (0u8..20).map(|i| vec![i]).collect(),
565            truncated: false,
566            truncated_before_horizon: false,
567        };
568        let rows = merge_slice_rows(&[resp], 0);
569        assert_eq!(rows.len(), 20);
570    }
571}