Skip to main content

reddb_server/storage/query/executors/
agg_spill.rs

1//! Hash-aggregation spill helper — Fase 4 P4 building block.
2//!
3//! Provides a `SpilledHashAgg` data structure that holds an
4//! in-memory hash table plus zero or more on-disk batch files.
5//! When the in-memory table exceeds `mem_limit_bytes`, callers
6//! invoke `spill_partition` to write a batch to a temporary file
7//! and free the corresponding entries from the hash map. The
8//! `drain` step reads all spilled batches back, merges them with
9//! whatever is still in memory, and produces the final aggregated
10//! output.
11//!
12//! Mirrors PostgreSQL's `nodeAgg.c::hashagg_spill_*` family
13//! modulo features we don't have:
14//!
15//! - No tape-based recursion: PG does N-way repartitioning when a
16//!   spilled batch itself doesn't fit. Week 4 here just rewinds and
17//!   reads each batch back in full. If a single batch exceeds
18//!   memory we return an error so the caller can switch to
19//!   sort-based aggregation.
20//! - No parallel spill: single producer, single consumer.
21//! - No on-disk hash format: each spill batch is a plain
22//!   serialised `Vec<(GroupKey, AggState)>` — small overhead but
23//!   simple to read back.
24//!
25//! The module is **not yet wired** into `executors/aggregation.rs`.
26//! Wiring happens in a follow-up commit when the aggregation
27//! executor learns to track its current memory footprint and
28//! call `spill_partition` from inside its insert loop.
29//!
30//! ## Type parameters
31//!
32//! - `K` — the group key. Must be `Hash + Eq + Clone + Serialize`
33//!   so it can both index the hash map and round-trip through
34//!   the spill file.
35//! - `S` — the aggregation state per group. Must be `Clone +
36//!   Serialize + Mergeable` so spilled batches can be combined
37//!   with the in-memory state during drain.
38
39use std::collections::HashMap;
40use std::fs::{File, OpenOptions};
41use std::hash::Hash;
42use std::io::{BufReader, BufWriter, Read, Write};
43use std::path::{Path, PathBuf};
44
45/// Trait implemented by any aggregation state that can absorb
46/// another value of its own type. Used by the drain step to merge
47/// spilled batches back into the in-memory table.
48///
49/// Implementors:
50/// - SUM:    `lhs += rhs`
51/// - COUNT:  `lhs += rhs`
52/// - MIN:    `lhs = min(lhs, rhs)`
53/// - MAX:    `lhs = max(lhs, rhs)`
54/// - AVG:    pair `(sum, count)` → element-wise add
55/// - STDDEV: triple `(n, mean, M2)` → Welford's parallel formula
56pub trait Mergeable {
57    /// Combine `other` into `self`, leaving `self` as the merged
58    /// result and consuming `other`.
59    fn merge(&mut self, other: Self);
60}
61
62/// Errors raised by the spill helper.
63#[derive(Debug)]
64pub enum SpillError {
65    /// I/O failure writing or reading a spill batch.
66    Io(std::io::Error),
67    /// A single spill batch exceeds the configured memory limit
68    /// even after offloading. Caller should fall back to
69    /// sort-based aggregation.
70    BatchTooLarge { size: usize, limit: usize },
71    /// Encoding / decoding of a key / state failed during
72    /// round-trip.
73    Codec(String),
74}
75
76impl From<std::io::Error> for SpillError {
77    fn from(e: std::io::Error) -> Self {
78        Self::Io(e)
79    }
80}
81
82impl std::fmt::Display for SpillError {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        match self {
85            Self::Io(e) => write!(f, "spill i/o: {e}"),
86            Self::BatchTooLarge { size, limit } => {
87                write!(f, "spill batch {size} bytes exceeds limit {limit}")
88            }
89            Self::Codec(msg) => write!(f, "spill codec: {msg}"),
90        }
91    }
92}
93
94impl std::error::Error for SpillError {}
95
96/// A file becomes owned only after create_new succeeds. Ownership stays with
97/// the aggregator through every read/error path; readers close before cleanup.
98struct SpillBatch {
99    path: PathBuf,
100    bytes: u64,
101    entries: usize,
102}
103
104impl Drop for SpillBatch {
105    fn drop(&mut self) {
106        let _ = std::fs::remove_file(&self.path);
107    }
108}
109
110/// Only zero bytes at the start of a record mean clean EOF. A partially read
111/// length/key must remain an error, including with readers that return short reads.
112pub(crate) fn read_spill_header<R: Read, const N: usize>(
113    reader: &mut R,
114) -> std::io::Result<Option<[u8; N]>> {
115    assert!(N > 0, "invariant: spill headers have a positive size");
116    let mut bytes = [0; N];
117    let read = loop {
118        match reader.read(&mut bytes) {
119            Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
120            result => break result?,
121        }
122    };
123    if read == 0 {
124        return Ok(None);
125    }
126    reader.read_exact(&mut bytes[read..])?;
127    Ok(Some(bytes))
128}
129
130/// Default allowance for standalone codec reads and manually spilled aggregators
131/// with automatic spilling disabled. Zero never means unlimited decode memory.
132pub const DEFAULT_SPILL_DECODE_BYTES: usize = 64 * 1024 * 1024;
133
134/// Local decoded-allocation admission for every key/state pair in one batch,
135/// including nested vectors and codec scratch. Merged tables, transport and global
136/// ownership need their own shared reservations; this is not a process-wide limit.
137pub struct SpillDecodeBudget {
138    limit_bytes: usize,
139    remaining_bytes: usize,
140}
141
142impl SpillDecodeBudget {
143    pub fn new(limit_bytes: usize) -> Self {
144        Self {
145            limit_bytes,
146            remaining_bytes: limit_bytes,
147        }
148    }
149    pub fn remaining_bytes(&self) -> usize {
150        self.remaining_bytes
151    }
152    pub fn charge_bytes(&mut self, bytes: usize) -> std::io::Result<()> {
153        let Some(remaining) = self.remaining_bytes.checked_sub(bytes) else {
154            return Err(std::io::Error::new(
155                std::io::ErrorKind::OutOfMemory,
156                format!(
157                    "spill decode allocation limit {} exceeded: needs {bytes}, remaining {}",
158                    self.limit_bytes, self.remaining_bytes
159                ),
160            ));
161        };
162        self.remaining_bytes = remaining;
163        Ok(())
164    }
165    pub fn charge_elements<T>(&mut self, count: usize) -> std::io::Result<()> {
166        let bytes = count.checked_mul(std::mem::size_of::<T>()).ok_or_else(|| {
167            std::io::Error::new(
168                std::io::ErrorKind::OutOfMemory,
169                "spill decode allocation limit: element count overflow",
170            )
171        })?;
172        self.charge_bytes(bytes)
173    }
174}
175
176/// Trait for serialising a key or state into a flat byte
177/// representation. Implementations should be deterministic so the
178/// spill file is byte-equal across runs (helpful for debugging).
179///
180/// Default impls below use `bincode`-style length-prefixed
181/// encoding; the helper doesn't require a specific serde crate
182/// because reddb deliberately avoids large transitive deps.
183pub trait SpillCodec: Sized {
184    /// Encode `self` into the writer. Returns the number of bytes
185    /// written so the caller can track per-batch size.
186    fn encode<W: Write>(&self, w: &mut W) -> Result<usize, SpillError>;
187    /// Decode a fresh value from the reader. Reads exactly one
188    /// element; returns `Ok(None)` on a clean end-of-file so
189    /// drain loops can terminate naturally.
190    fn decode<R: Read>(r: &mut R) -> Result<Option<Self>, SpillError>;
191
192    /// Implementations must admit heap allocations before reserving them. The
193    /// drain path refuses legacy codecs without a bounded implementation.
194    fn decode_with_budget<R: Read>(
195        _r: &mut R,
196        _budget: &mut SpillDecodeBudget,
197    ) -> Result<Option<Self>, SpillError> {
198        Err(SpillError::Codec(
199            "bounded spill decoding is not implemented for this codec".into(),
200        ))
201    }
202}
203
204/// Implementation strategy for fixed-size primitive types. The
205/// code uses raw little-endian writes so we don't depend on
206/// `bincode` / `serde` from this module — keeps the dep graph
207/// flat.
208impl SpillCodec for u64 {
209    fn encode<W: Write>(&self, w: &mut W) -> Result<usize, SpillError> {
210        w.write_all(&self.to_le_bytes())?;
211        Ok(8)
212    }
213    fn decode<R: Read>(r: &mut R) -> Result<Option<Self>, SpillError> {
214        Ok(read_spill_header(r)?.map(u64::from_le_bytes))
215    }
216    fn decode_with_budget<R: Read>(
217        r: &mut R,
218        _budget: &mut SpillDecodeBudget,
219    ) -> Result<Option<Self>, SpillError> {
220        Self::decode(r)
221    }
222}
223
224impl SpillCodec for i64 {
225    fn encode<W: Write>(&self, w: &mut W) -> Result<usize, SpillError> {
226        w.write_all(&self.to_le_bytes())?;
227        Ok(8)
228    }
229    fn decode<R: Read>(r: &mut R) -> Result<Option<Self>, SpillError> {
230        Ok(read_spill_header(r)?.map(i64::from_le_bytes))
231    }
232    fn decode_with_budget<R: Read>(
233        r: &mut R,
234        _budget: &mut SpillDecodeBudget,
235    ) -> Result<Option<Self>, SpillError> {
236        Self::decode(r)
237    }
238}
239
240impl SpillCodec for f64 {
241    fn encode<W: Write>(&self, w: &mut W) -> Result<usize, SpillError> {
242        w.write_all(&self.to_le_bytes())?;
243        Ok(8)
244    }
245    fn decode<R: Read>(r: &mut R) -> Result<Option<Self>, SpillError> {
246        Ok(read_spill_header(r)?.map(f64::from_le_bytes))
247    }
248    fn decode_with_budget<R: Read>(
249        r: &mut R,
250        _budget: &mut SpillDecodeBudget,
251    ) -> Result<Option<Self>, SpillError> {
252        Self::decode(r)
253    }
254}
255
256impl SpillCodec for String {
257    fn encode<W: Write>(&self, w: &mut W) -> Result<usize, SpillError> {
258        let bytes = self.as_bytes();
259        let len = bytes.len() as u32;
260        w.write_all(&len.to_le_bytes())?;
261        w.write_all(bytes)?;
262        Ok(4 + bytes.len())
263    }
264    fn decode<R: Read>(r: &mut R) -> Result<Option<Self>, SpillError> {
265        Self::decode_with_budget(r, &mut SpillDecodeBudget::new(DEFAULT_SPILL_DECODE_BYTES))
266    }
267    fn decode_with_budget<R: Read>(
268        r: &mut R,
269        budget: &mut SpillDecodeBudget,
270    ) -> Result<Option<Self>, SpillError> {
271        let Some(lenbuf) = read_spill_header(r)? else {
272            return Ok(None);
273        };
274        let len = u32::from_le_bytes(lenbuf) as usize;
275        budget.charge_bytes(len)?;
276        let mut buf = vec![0u8; len];
277        r.read_exact(&mut buf)?;
278        String::from_utf8(buf)
279            .map(Some)
280            .map_err(|e| SpillError::Codec(format!("invalid utf-8: {e}")))
281    }
282}
283
284/// Hash aggregation table with optional spill-to-disk overflow.
285///
286/// Owns the in-memory `HashMap<K, S>` plus a list of `PathBuf`s
287/// pointing at spilled batch files. The caller drives the
288/// lifecycle by calling `accumulate` for each input row and
289/// `drain` once the input is exhausted.
290pub struct SpilledHashAgg<K, S>
291where
292    K: Hash + Eq + Clone + SpillCodec,
293    S: Clone + Mergeable + SpillCodec,
294{
295    /// In-memory hash table. Cleared after each spill.
296    table: HashMap<K, S>,
297    /// Estimated bytes-per-(key, state) pair. Used to compute
298    /// when to spill — a rough proxy for actual heap usage.
299    /// Callers can tune by passing a more accurate value.
300    avg_entry_bytes: usize,
301    /// Soft limit on `table.len() * avg_entry_bytes`. Crossing
302    /// this triggers a spill.
303    mem_limit_bytes: usize,
304    /// Directory where spill batches land. Each batch is a single
305    /// file named `spill_{seq}.bin`.
306    spill_dir: PathBuf,
307    /// Owned batches in order of creation, including their committed framing.
308    spilled_batches: Vec<SpillBatch>,
309    /// Monotonic batch counter for unique filenames.
310    next_seq: u64,
311    /// Total bytes spilled across all batches — diagnostic.
312    pub total_spilled_bytes: u64,
313    /// Number of times `spill_partition` was called — diagnostic.
314    pub spill_count: u64,
315}
316
317impl<K, S> SpilledHashAgg<K, S>
318where
319    K: Hash + Eq + Clone + SpillCodec,
320    S: Clone + Mergeable + SpillCodec,
321{
322    /// Create a new spillable hash aggregator. `spill_dir` must
323    /// exist and be writable; the helper does NOT create it.
324    /// `mem_limit_bytes == 0` disables spilling entirely (useful
325    /// for tests that want to exercise the in-memory path).
326    pub fn new(
327        spill_dir: impl AsRef<Path>,
328        mem_limit_bytes: usize,
329        avg_entry_bytes: usize,
330    ) -> Self {
331        Self {
332            table: HashMap::new(),
333            avg_entry_bytes,
334            mem_limit_bytes,
335            spill_dir: spill_dir.as_ref().to_path_buf(),
336            spilled_batches: Vec::new(),
337            next_seq: 0,
338            total_spilled_bytes: 0,
339            spill_count: 0,
340        }
341    }
342
343    /// Insert or update an aggregation state for the given key.
344    /// `accumulate` triggers a spill when the in-memory table's
345    /// estimated footprint exceeds the configured limit. Returns
346    /// the key/state pair after the merge so callers can chain.
347    pub fn accumulate(&mut self, key: K, increment: S) -> Result<(), SpillError> {
348        match self.table.get_mut(&key) {
349            Some(existing) => existing.merge(increment),
350            None => {
351                self.table.insert(key, increment);
352                if self.should_spill() {
353                    self.spill_partition()?;
354                }
355            }
356        }
357        Ok(())
358    }
359
360    /// Returns true when the current in-memory footprint exceeds
361    /// `mem_limit_bytes`. Cheap O(1) check using the estimated
362    /// per-entry size; callers should keep `avg_entry_bytes`
363    /// in sync with reality if precision matters.
364    fn should_spill(&self) -> bool {
365        if self.mem_limit_bytes == 0 {
366            return false;
367        }
368        let estimated = self.table.len().saturating_mul(self.avg_entry_bytes);
369        estimated > self.mem_limit_bytes
370    }
371
372    /// Write the entire in-memory table to a new spill batch file
373    /// and clear the table. Updates the spill diagnostics. Caller
374    /// is free to keep accumulating after this returns — the
375    /// batch will be merged back during `drain`.
376    pub fn spill_partition(&mut self) -> Result<(), SpillError> {
377        if self.table.is_empty() {
378            return Ok(());
379        }
380        let path = self.spill_dir.join(format!("spill_{}.bin", self.next_seq));
381        self.next_seq += 1;
382        let file = OpenOptions::new()
383            .write(true)
384            .create_new(true)
385            .open(&path)?;
386        let mut batch = SpillBatch {
387            path,
388            bytes: 0,
389            entries: self.table.len(),
390        };
391        let mut writer = BufWriter::new(file);
392        let mut bytes_written = 0usize;
393        // Borrow rather than clone or drain: a failed encoder/flush leaves the
394        // original groups available for retry, and the partial file is removed.
395        for (k, s) in &self.table {
396            bytes_written = bytes_written
397                .checked_add(k.encode(&mut writer)?)
398                .ok_or_else(|| SpillError::Codec("spill byte count overflow".into()))?;
399            bytes_written = bytes_written
400                .checked_add(s.encode(&mut writer)?)
401                .ok_or_else(|| SpillError::Codec("spill byte count overflow".into()))?;
402        }
403        writer.flush()?;
404        batch.bytes = u64::try_from(bytes_written)
405            .map_err(|_| SpillError::Codec("spill byte count overflow".into()))?;
406        let total_bytes = self
407            .total_spilled_bytes
408            .checked_add(batch.bytes)
409            .ok_or_else(|| SpillError::Codec("spill byte count overflow".into()))?;
410        self.table.clear();
411        self.total_spilled_bytes = total_bytes;
412        self.spill_count += 1;
413        self.spilled_batches.push(batch);
414        Ok(())
415    }
416
417    /// Consume the aggregator and return the final merged state
418    /// for every group. Reads every spilled batch back into a
419    /// new in-memory hash table, merges with whatever the
420    /// accumulator left in place, and yields the unified set.
421    ///
422    /// Both encoded size and cumulative decoded allocations of each batch must
423    /// fit `mem_limit_bytes`. Decoding shares one allowance across all entries,
424    /// not one per entry. The running merge table still needs separate shared
425    /// accounting; this local decode allowance does not bound its growth.
426    pub fn drain(mut self) -> Result<HashMap<K, S>, SpillError> {
427        // The current `table` is the most recent in-memory chunk
428        // that hasn't been spilled — start the merge from it.
429        let mut merged = std::mem::take(&mut self.table);
430        for batch in self.spilled_batches {
431            let file = File::open(&batch.path)?;
432            let metadata = file.metadata()?;
433            let file_bytes = usize::try_from(metadata.len()).unwrap_or(usize::MAX);
434            if self.mem_limit_bytes > 0 && file_bytes > self.mem_limit_bytes {
435                return Err(SpillError::BatchTooLarge {
436                    size: file_bytes,
437                    limit: self.mem_limit_bytes,
438                });
439            }
440            if metadata.len() != batch.bytes {
441                return Err(SpillError::Codec(
442                    "spill batch length changed after publication".into(),
443                ));
444            }
445            let mut reader = BufReader::new(file);
446            let mut budget = SpillDecodeBudget::new(if self.mem_limit_bytes == 0 {
447                DEFAULT_SPILL_DECODE_BYTES
448            } else {
449                self.mem_limit_bytes
450            });
451            for _ in 0..batch.entries {
452                let key = K::decode_with_budget(&mut reader, &mut budget)?.ok_or_else(|| {
453                    SpillError::Codec("spill batch ended before its published entry count".into())
454                })?;
455                let state = match S::decode_with_budget(&mut reader, &mut budget)? {
456                    Some(s) => s,
457                    None => {
458                        return Err(SpillError::Codec(
459                            "spill batch ended mid-entry: state missing".to_string(),
460                        ))
461                    }
462                };
463                match merged.get_mut(&key) {
464                    Some(existing) => existing.merge(state),
465                    None => {
466                        merged.insert(key, state);
467                    }
468                }
469            }
470            if read_spill_header::<_, 1>(&mut reader)?.is_some() {
471                return Err(SpillError::Codec("spill batch has trailing entries".into()));
472            }
473        }
474        Ok(merged)
475    }
476
477    /// Number of spill batches currently on disk. Diagnostic
478    /// hook for tests / metrics.
479    pub fn spilled_batch_count(&self) -> usize {
480        self.spilled_batches.len()
481    }
482
483    /// Number of groups currently held in memory.
484    pub fn in_memory_groups(&self) -> usize {
485        self.table.len()
486    }
487}
488
489// ────────────────────────────────────────────────────────────────
490// Convenience Mergeable implementations for the common scalar
491// aggregates. The aggregation executor wires its concrete state
492// types through these so callers don't need to spell out the
493// merge logic at every call site.
494// ────────────────────────────────────────────────────────────────
495
496/// SUM state — running total. Generic over any numeric type that
497/// supports `+=`.
498#[derive(Debug, Clone, Copy)]
499pub struct SumState<T>(pub T);
500
501impl Mergeable for SumState<i64> {
502    fn merge(&mut self, other: Self) {
503        self.0 = self.0.saturating_add(other.0);
504    }
505}
506impl SpillCodec for SumState<i64> {
507    fn encode<W: Write>(&self, w: &mut W) -> Result<usize, SpillError> {
508        self.0.encode(w)
509    }
510    fn decode<R: Read>(r: &mut R) -> Result<Option<Self>, SpillError> {
511        Ok(i64::decode(r)?.map(SumState))
512    }
513    fn decode_with_budget<R: Read>(
514        r: &mut R,
515        _budget: &mut SpillDecodeBudget,
516    ) -> Result<Option<Self>, SpillError> {
517        Self::decode(r)
518    }
519}
520
521impl Mergeable for SumState<f64> {
522    fn merge(&mut self, other: Self) {
523        self.0 += other.0;
524    }
525}
526impl SpillCodec for SumState<f64> {
527    fn encode<W: Write>(&self, w: &mut W) -> Result<usize, SpillError> {
528        self.0.encode(w)
529    }
530    fn decode<R: Read>(r: &mut R) -> Result<Option<Self>, SpillError> {
531        Ok(f64::decode(r)?.map(SumState))
532    }
533    fn decode_with_budget<R: Read>(
534        r: &mut R,
535        _budget: &mut SpillDecodeBudget,
536    ) -> Result<Option<Self>, SpillError> {
537        Self::decode(r)
538    }
539}
540
541/// COUNT state — monotonic non-negative counter.
542#[derive(Debug, Clone, Copy)]
543pub struct CountState(pub u64);
544
545impl Mergeable for CountState {
546    fn merge(&mut self, other: Self) {
547        self.0 = self.0.saturating_add(other.0);
548    }
549}
550impl SpillCodec for CountState {
551    fn encode<W: Write>(&self, w: &mut W) -> Result<usize, SpillError> {
552        self.0.encode(w)
553    }
554    fn decode<R: Read>(r: &mut R) -> Result<Option<Self>, SpillError> {
555        Ok(u64::decode(r)?.map(CountState))
556    }
557    fn decode_with_budget<R: Read>(
558        r: &mut R,
559        _budget: &mut SpillDecodeBudget,
560    ) -> Result<Option<Self>, SpillError> {
561        Self::decode(r)
562    }
563}
564
565/// MIN/MAX state — wraps a numeric and merges via comparison.
566/// Two distinct types so the type system enforces direction.
567#[derive(Debug, Clone, Copy)]
568pub struct MinState<T>(pub T);
569#[derive(Debug, Clone, Copy)]
570pub struct MaxState<T>(pub T);
571
572impl Mergeable for MinState<i64> {
573    fn merge(&mut self, other: Self) {
574        if other.0 < self.0 {
575            self.0 = other.0;
576        }
577    }
578}
579impl SpillCodec for MinState<i64> {
580    fn encode<W: Write>(&self, w: &mut W) -> Result<usize, SpillError> {
581        self.0.encode(w)
582    }
583    fn decode<R: Read>(r: &mut R) -> Result<Option<Self>, SpillError> {
584        Ok(i64::decode(r)?.map(MinState))
585    }
586    fn decode_with_budget<R: Read>(
587        r: &mut R,
588        _budget: &mut SpillDecodeBudget,
589    ) -> Result<Option<Self>, SpillError> {
590        Self::decode(r)
591    }
592}
593
594impl Mergeable for MaxState<i64> {
595    fn merge(&mut self, other: Self) {
596        if other.0 > self.0 {
597            self.0 = other.0;
598        }
599    }
600}
601impl SpillCodec for MaxState<i64> {
602    fn encode<W: Write>(&self, w: &mut W) -> Result<usize, SpillError> {
603        self.0.encode(w)
604    }
605    fn decode<R: Read>(r: &mut R) -> Result<Option<Self>, SpillError> {
606        Ok(i64::decode(r)?.map(MaxState))
607    }
608    fn decode_with_budget<R: Read>(
609        r: &mut R,
610        _budget: &mut SpillDecodeBudget,
611    ) -> Result<Option<Self>, SpillError> {
612        Self::decode(r)
613    }
614}
615
616/// AVG state — pair (sum, count). Final value is sum / count.
617#[derive(Debug, Clone, Copy)]
618pub struct AvgState {
619    pub sum: f64,
620    pub count: u64,
621}
622
623impl Mergeable for AvgState {
624    fn merge(&mut self, other: Self) {
625        self.sum += other.sum;
626        self.count += other.count;
627    }
628}
629impl SpillCodec for AvgState {
630    fn encode<W: Write>(&self, w: &mut W) -> Result<usize, SpillError> {
631        let a = self.sum.encode(w)?;
632        let b = self.count.encode(w)?;
633        Ok(a + b)
634    }
635    fn decode<R: Read>(r: &mut R) -> Result<Option<Self>, SpillError> {
636        let sum = match f64::decode(r)? {
637            Some(v) => v,
638            None => return Ok(None),
639        };
640        let count = match u64::decode(r)? {
641            Some(v) => v,
642            None => {
643                return Err(SpillError::Codec(
644                    "AvgState ended after sum: count missing".to_string(),
645                ))
646            }
647        };
648        Ok(Some(AvgState { sum, count }))
649    }
650    fn decode_with_budget<R: Read>(
651        r: &mut R,
652        _budget: &mut SpillDecodeBudget,
653    ) -> Result<Option<Self>, SpillError> {
654        Self::decode(r)
655    }
656}
657
658impl AvgState {
659    /// Final average value. Returns `None` for an empty state to
660    /// distinguish "no rows" from `0.0`.
661    pub fn finalize(self) -> Option<f64> {
662        if self.count == 0 {
663            None
664        } else {
665            Some(self.sum / self.count as f64)
666        }
667    }
668}
669
670/// Phase 3.3 wiring entry point. Builds a `SpilledHashAgg` with
671/// production-grade defaults (`mem_limit_bytes = 64 MiB`,
672/// `avg_entry_bytes = 128`) targeting reddb's tmpfs at
673/// `/tmp/reddb-spill`. Used by `executors/aggregation.rs::execute_group_by`
674/// when the input row count exceeds the in-memory threshold.
675///
676/// The caller is expected to feed every row via `accumulate` and
677/// then call `drain` to materialise the merged result. The helper
678/// returns the constructed aggregator so the caller can wire it
679/// into its existing per-row loop without re-implementing the
680/// spill bookkeeping.
681///
682/// Spill files land in a process-unique subdirectory so concurrent
683/// queries don't collide; the directory is auto-cleaned on Drop.
684pub fn spilled_hash_agg_default<K, S>() -> std::io::Result<SpilledHashAgg<K, S>>
685where
686    K: std::hash::Hash + Eq + Clone + SpillCodec,
687    S: Clone + Mergeable + SpillCodec,
688{
689    use std::sync::atomic::{AtomicU64, Ordering};
690    static SEQ: AtomicU64 = AtomicU64::new(0);
691    let seq = SEQ.fetch_add(1, Ordering::Relaxed);
692    let pid = std::process::id();
693    let dir = std::env::temp_dir().join(format!("reddb-spill-{pid}-{seq}"));
694    std::fs::create_dir_all(&dir)?;
695    Ok(SpilledHashAgg::new(
696        dir,
697        64 * 1024 * 1024, // 64 MiB soft limit
698        128,              // avg bytes per (key, state) — tuned for SUM/COUNT
699    ))
700}