Skip to main content

linkrs_query/executor/streaming/spill/
hash_partition.rs

1use linkrs_core::columnar::MaterializedBatch;
2use linkrs_core::error::QueryError;
3use linkrs_core::Value;
4
5use super::manager::SpillManager;
6use super::run_format::fnv1a_64_update;
7use super::run_format::SpilledRun;
8use super::run_io::{RunReader, RunWriter};
9
10// ── Hash partition spill ─────────────────────────────────────────────────────
11
12/// Fixed hash algorithm for partition spill operations.
13///
14/// Uses a simple FNV-1a hash of the serialized row/key data.
15/// The seed and algorithm version are part of the contract—changing
16/// them will change partition assignments across all operators.
17pub const HASH_PARTITION_VERSION: u32 = 1;
18pub const HASH_PARTITION_SEED: u64 = 0xdeadbeefcafe;
19
20/// Compute the partition index for a row of values.
21///
22/// Used by hash join, hash aggregate, and hash distinct to
23/// distribute rows across partitions.
24pub fn hash_row_partition(row: &[Value], num_partitions: u64) -> u64 {
25    // Serialize the row to bytes and hash
26    let encoded = postcard::to_allocvec(row).unwrap_or_default();
27    let hash = fnv1a_64_update(HASH_PARTITION_SEED, &encoded);
28    hash % num_partitions
29}
30
31/// Compute the partition index for pre-serialized key bytes.
32///
33/// Shared primitive behind [`hash_row_partition`] and join-key partitioning
34/// so both sides of a Grace Hash Join agree on partition assignment.
35pub fn hash_bytes_partition(bytes: &[u8], num_partitions: u64) -> u64 {
36    if num_partitions == 0 {
37        return 0;
38    }
39    fnv1a_64_update(HASH_PARTITION_SEED, bytes) % num_partitions
40}
41
42/// Compute one partition index per batch row over a column subset.
43///
44/// Key-column projection of [`hash_row_partition`]: rows are hashed by the
45/// selected columns only (empty `cols` means the full row), so Distinct and
46/// Aggregate spill by key instead of by full-row bytes.
47pub fn hash_column_partition(
48    batch: &MaterializedBatch,
49    cols: &[usize],
50    num_partitions: u64,
51) -> Vec<u64> {
52    batch
53        .hash_rows(cols)
54        .into_iter()
55        .map(|h| {
56            if num_partitions == 0 {
57                0
58            } else {
59                h % num_partitions
60            }
61        })
62        .collect()
63}
64
65/// Configuration for hash-based partition spill.
66#[derive(Debug, Clone)]
67pub struct HashPartitionConfig {
68    /// Number of partitions to create.
69    pub num_partitions: u64,
70    /// Maximum rows per partition before triggering recursive repartition.
71    pub max_rows_per_partition: u64,
72    /// Maximum recursion depth for skew handling.
73    pub max_recursion_depth: u32,
74}
75
76impl Default for HashPartitionConfig {
77    fn default() -> Self {
78        Self {
79            num_partitions: 16,
80            max_rows_per_partition: 1_000_000,
81            max_recursion_depth: 3,
82        }
83    }
84}
85
86/// A partition spill writer that routes rows by hash into separate files.
87///
88/// Each partition gets its own `RunWriter` for sorted/typed row data.
89/// The spiller handles skew detection and recursive repartitioning.
90#[derive(Debug)]
91pub struct HashPartitionSpiller {
92    config: HashPartitionConfig,
93    pub(super) writers: Vec<Option<RunWriter>>,
94    counts: Vec<u64>,
95    recursion_depth: u32,
96    schema_fingerprint: u64,
97}
98
99impl HashPartitionSpiller {
100    /// Create a new hash partition spiller.
101    pub fn new(
102        config: HashPartitionConfig,
103        manager: &SpillManager,
104        schema_fingerprint: u64,
105    ) -> Result<Self, QueryError> {
106        let n = config.num_partitions as usize;
107        let mut writers = Vec::with_capacity(n);
108        for _ in 0..n {
109            writers.push(Some(manager.create_run_writer(schema_fingerprint)?));
110        }
111        Ok(Self {
112            config,
113            writers,
114            counts: vec![0; n],
115            recursion_depth: 0,
116            schema_fingerprint,
117        })
118    }
119
120    /// Insert a row into the appropriate partition.
121    pub fn insert_row(&mut self, row: &[Value], manager: &SpillManager) -> Result<(), QueryError> {
122        let partition = hash_row_partition(row, self.config.num_partitions) as usize;
123        self.insert_row_to_partition(row, partition, manager)
124    }
125
126    /// Insert a row into a specific partition (caller computes the hash).
127    ///
128    /// Useful when the caller needs to partition by a derived key
129    /// (e.g., group key for aggregate) rather than the row itself.
130    pub fn insert_row_to_partition(
131        &mut self,
132        row: &[Value],
133        partition: usize,
134        manager: &SpillManager,
135    ) -> Result<(), QueryError> {
136        if let Some(Some(writer)) = self.writers.get_mut(partition) {
137            writer.write_row(row)?;
138            self.counts[partition] += 1;
139
140            if self.counts[partition] > self.config.max_rows_per_partition
141                && self.recursion_depth < self.config.max_recursion_depth
142            {
143                self.repartition(manager)?;
144            }
145        }
146        Ok(())
147    }
148
149    /// Finalize all partitions through the manager so disk quota is enforced.
150    pub fn finalize_with_manager(
151        mut self,
152        manager: &SpillManager,
153    ) -> Result<Vec<Option<SpilledRun>>, QueryError> {
154        let mut runs = Vec::with_capacity(self.writers.len());
155        for writer in self.writers.drain(..) {
156            match writer {
157                Some(w) => runs.push(Some(manager.finalize_run(w)?)),
158                None => runs.push(None),
159            }
160        }
161        Ok(runs)
162    }
163
164    /// Recursively repartition when skew is detected.
165    ///
166    /// This splits the overflowing partition into sub-partitions by
167    /// re-hashing with an increased partition count. Intermediate runs go
168    /// through [`SpillManager::finalize_run`] so disk quota applies; replaced
169    /// files are unlinked and their reservations released.
170    fn repartition(&mut self, manager: &SpillManager) -> Result<(), QueryError> {
171        self.recursion_depth += 1;
172        let _old_count = self.config.num_partitions;
173        self.config.num_partitions = self.config.num_partitions.saturating_mul(2);
174
175        // Finalize current writers to get run files (quota-checked).
176        let old_runs = self.finalize_current(manager)?;
177
178        // Create new writers for doubled partitions
179        let mut new_writers = Vec::with_capacity(self.config.num_partitions as usize);
180        for _ in 0..self.config.num_partitions {
181            new_writers.push(Some(manager.create_run_writer(self.schema_fingerprint)?));
182        }
183        let mut new_counts = vec![0u64; self.config.num_partitions as usize];
184
185        // Read back and rehash all old partitions into new partitions
186        for old_run in old_runs.into_iter().flatten() {
187            let byte_size = old_run.byte_size;
188            let mut reader = RunReader::open(&old_run)?;
189            while let Some(row) = reader.read_row()? {
190                let partition = hash_row_partition(&row, self.config.num_partitions) as usize;
191                if let Some(Some(writer)) = new_writers.get_mut(partition) {
192                    writer.write_row(&row)?;
193                    new_counts[partition] += 1;
194                }
195            }
196            // Delete old partition file and release its quota reservation:
197            // the data now lives in the new partitions.
198            let _ = std::fs::remove_file(&old_run.path);
199            manager.disk_quota().release(byte_size);
200        }
201
202        self.writers = new_writers;
203        self.counts = new_counts;
204        Ok(())
205    }
206
207    /// Finalize all current partition writers and return the run metadata.
208    ///
209    /// Quota-checked via [`SpillManager::finalize_run`]; intermediate
210    /// repartition files are accounted exactly like final runs.
211    fn finalize_current(
212        &mut self,
213        manager: &SpillManager,
214    ) -> Result<Vec<Option<SpilledRun>>, QueryError> {
215        let mut runs = Vec::with_capacity(self.writers.len());
216        for writer in self.writers.drain(..) {
217            match writer {
218                Some(w) => runs.push(Some(manager.finalize_run(w)?)),
219                None => runs.push(None),
220            }
221        }
222        Ok(runs)
223    }
224
225    /// Current partition row counts.
226    pub fn partition_counts(&self) -> &[u64] {
227        &self.counts
228    }
229
230    /// Number of partitions.
231    pub fn num_partitions(&self) -> u64 {
232        self.config.num_partitions
233    }
234}