1use serde::{Deserialize, Serialize};
22
23use super::wire::{ArrayShardAggResp, ArrayShardSliceResp};
24use crate::error::{ClusterError, Result};
25
26#[derive(
31 Debug, Clone, Serialize, Deserialize, zerompk::ToMessagePack, zerompk::FromMessagePack,
32)]
33pub struct ArrayAggPartial {
34 pub group_key: i64,
36 pub count: u64,
37 pub sum: f64,
38 pub min: f64,
39 pub max: f64,
40 pub welford_mean: f64,
42 pub welford_m2: f64,
43}
44
45impl ArrayAggPartial {
46 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 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
89pub fn any_truncated_before_horizon_slice(shard_resps: &[ArrayShardSliceResp]) -> bool {
95 shard_resps.iter().any(|r| r.truncated_before_horizon)
96}
97
98pub fn any_truncated_before_horizon_agg(shard_resps: &[ArrayShardAggResp]) -> bool {
101 shard_resps.iter().any(|r| r.truncated_before_horizon)
102}
103
104pub 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
135pub fn merge_slice_rows_sorted(
151 shard_resps: &[ArrayShardSliceResp],
152 coordinator_limit: u32,
153) -> Result<Vec<Vec<u8>>> {
154 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 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
175fn 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
208pub 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 let mut a = ArrayAggPartial::from_single(0, 10.0);
344 let b = ArrayAggPartial::from_single(0, 20.0);
345 a.merge(&b);
346 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 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 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 let rows = merge_slice_rows(&[r0, r1], 3);
454 assert_eq!(rows.len(), 3);
455 }
456
457 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 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 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 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 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 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 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}