use std::cmp::Reverse;
use std::collections::BinaryHeap;
use std::io::{BufReader, Read as _};
use std::path::{Path, PathBuf};
use tracing::debug;
use crate::data::executor::core_loop::CoreLoop;
use crate::data::io::uring_seq_reader::UringSeqReader;
use crate::data::io::uring_writer::UringWriter;
use nodedb_query::msgpack_scan;
impl CoreLoop {
pub(super) fn external_sort(
&self,
rows: Vec<(String, Vec<u8>)>,
sort_keys: &[(String, bool)],
output_limit: usize,
) -> crate::Result<Vec<(String, Vec<u8>)>> {
let spill_dir = self
.data_dir
.join(format!("sort-spill/core-{}", self.core_id));
std::fs::create_dir_all(&spill_dir).map_err(|e| crate::Error::Storage {
engine: "sort".into(),
detail: format!("failed to create sort spill dir: {e}"),
})?;
let total_rows = rows.len();
let mut cleanup = SortSpillCleanup {
dir: spill_dir.clone(),
paths: Vec::new(),
};
for (run_idx, chunk) in rows.chunks(self.query_tuning.sort_run_size).enumerate() {
let mut run: Vec<(String, Vec<u8>)> = chunk.to_vec();
sort_rows(&mut run, sort_keys)?;
let mut framed = Vec::new();
framed.extend_from_slice(&(run.len() as u32).to_le_bytes());
for (id, val) in &run {
let id_bytes = id.as_bytes();
framed.extend_from_slice(&(id_bytes.len() as u32).to_le_bytes());
framed.extend_from_slice(id_bytes);
framed.extend_from_slice(&(val.len() as u32).to_le_bytes());
framed.extend_from_slice(val);
}
let run_path = spill_dir.join(format!("run-{run_idx}.spill"));
write_sort_run(&run_path, &framed)?;
cleanup.paths.push(run_path);
}
debug!(
core = self.core_id,
runs = cleanup.paths.len(),
total_rows,
"external sort: spilled runs"
);
let mut readers: Vec<RunReader> = Vec::with_capacity(cleanup.paths.len());
for (idx, path) in cleanup.paths.iter().enumerate() {
readers.push(RunReader::open(path, idx)?);
}
let mut heap: BinaryHeap<Reverse<MergeEntry>> = BinaryHeap::new();
for reader in &mut readers {
if let Some(row) = reader.next_row()? {
heap.push(Reverse(MergeEntry {
row,
run_idx: reader.run_idx,
sort_keys: sort_keys.to_vec(),
}));
}
}
let mut result = Vec::with_capacity(output_limit.min(total_rows));
while let Some(Reverse(entry)) = heap.pop() {
result.push(entry.row);
if result.len() >= output_limit {
break;
}
if let Some(next_row) = readers[entry.run_idx].next_row()? {
heap.push(Reverse(MergeEntry {
row: next_row,
run_idx: entry.run_idx,
sort_keys: sort_keys.to_vec(),
}));
}
}
Ok(result)
}
}
struct SortSpillCleanup {
dir: PathBuf,
paths: Vec<PathBuf>,
}
impl Drop for SortSpillCleanup {
fn drop(&mut self) {
for p in &self.paths {
let _ = std::fs::remove_file(p);
}
let _ = std::fs::remove_dir(&self.dir);
}
}
fn write_sort_run(path: &Path, bytes: &[u8]) -> crate::Result<()> {
match UringWriter::new(path) {
Some(mut w) => {
w.append(bytes)?;
w.finish()?;
Ok(())
}
None => std::fs::write(path, bytes).map_err(|e| crate::Error::Storage {
engine: "sort".into(),
detail: format!("sort spill write error: {e}"),
}),
}
}
pub(super) fn compare_docs_by_keys_binary(
a_bytes: &[u8],
b_bytes: &[u8],
sort_keys: &[(String, bool)],
) -> std::cmp::Ordering {
for (field, asc) in sort_keys {
let a_range = msgpack_scan::extract_field(a_bytes, 0, field);
let b_range = msgpack_scan::extract_field(b_bytes, 0, field);
let cmp = match (a_range, b_range) {
(Some(ar), Some(br)) => msgpack_scan::compare_field_bytes(a_bytes, ar, b_bytes, br),
(Some(_), None) => std::cmp::Ordering::Greater,
(None, Some(_)) => std::cmp::Ordering::Less,
(None, None) => std::cmp::Ordering::Equal,
};
let ordered = if *asc { cmp } else { cmp.reverse() };
if ordered != std::cmp::Ordering::Equal {
return ordered;
}
}
std::cmp::Ordering::Equal
}
type SortKeyOffsets = Vec<Option<(usize, usize)>>;
pub(in crate::data::executor) fn sort_rows(
rows: &mut [(String, Vec<u8>)],
sort_keys: &[(String, bool)],
) -> crate::Result<()> {
if sort_keys.is_empty() {
return Ok(());
}
let key_offsets: Vec<SortKeyOffsets> = rows
.iter()
.map(|(_, bytes)| {
sort_keys
.iter()
.map(|(field, _)| msgpack_scan::extract_field(bytes, 0, field))
.collect()
})
.collect();
let mut indices: Vec<usize> = (0..rows.len()).collect();
indices.sort_by(|&ai, &bi| {
compare_with_preextracted(
&rows[ai].1,
&key_offsets[ai],
&rows[bi].1,
&key_offsets[bi],
sort_keys,
)
});
drop(key_offsets);
apply_permutation(rows, indices)
}
fn compare_with_preextracted(
a_bytes: &[u8],
a_offsets: &[Option<(usize, usize)>],
b_bytes: &[u8],
b_offsets: &[Option<(usize, usize)>],
sort_keys: &[(String, bool)],
) -> std::cmp::Ordering {
for (i, (_, asc)) in sort_keys.iter().enumerate() {
let cmp = match (a_offsets[i], b_offsets[i]) {
(Some(ar), Some(br)) => msgpack_scan::compare_field_bytes(a_bytes, ar, b_bytes, br),
(Some(_), None) => std::cmp::Ordering::Greater,
(None, Some(_)) => std::cmp::Ordering::Less,
(None, None) => std::cmp::Ordering::Equal,
};
let ordered = if *asc { cmp } else { cmp.reverse() };
if ordered != std::cmp::Ordering::Equal {
return ordered;
}
}
std::cmp::Ordering::Equal
}
fn apply_permutation(rows: &mut [(String, Vec<u8>)], indices: Vec<usize>) -> crate::Result<()> {
let mut src: Vec<Option<(String, Vec<u8>)>> =
rows.iter_mut().map(|r| Some(std::mem::take(r))).collect();
let n = src.len();
for (target_pos, &src_idx) in indices.iter().enumerate() {
let slot = src.get_mut(src_idx).ok_or_else(|| crate::Error::Internal {
detail: format!(
"apply_permutation: index {src_idx} out of range (len={n}, target_pos={target_pos})"
),
})?;
let row = slot.take().ok_or_else(|| crate::Error::Internal {
detail: format!(
"apply_permutation: duplicate index {src_idx} at target_pos={target_pos} (len={n})"
),
})?;
rows[target_pos] = row;
}
Ok(())
}
enum RunBackend {
Uring(Box<UringSeqReader>),
Std(BufReader<std::fs::File>),
}
pub(super) struct RunReader {
backend: RunBackend,
remaining: u32,
pub(super) run_idx: usize,
}
impl RunReader {
pub(super) fn open(path: &Path, run_idx: usize) -> crate::Result<Self> {
let mut backend = match UringSeqReader::open_default(path) {
Some(r) => RunBackend::Uring(Box::new(r)),
None => RunBackend::Std(BufReader::new(std::fs::File::open(path).map_err(|e| {
crate::Error::Storage {
engine: "sort".into(),
detail: format!("run reader open: {e}"),
}
})?)),
};
let mut buf4 = [0u8; 4];
if !Self::read_full(&mut backend, &mut buf4)? {
return Err(crate::Error::Storage {
engine: "sort".into(),
detail: "sort run truncated: missing count header".into(),
});
}
let count = u32::from_le_bytes(buf4);
Ok(Self {
backend,
remaining: count,
run_idx,
})
}
fn read_full(backend: &mut RunBackend, dst: &mut [u8]) -> crate::Result<bool> {
match backend {
RunBackend::Uring(r) => r.read_exact(dst),
RunBackend::Std(r) => match r.read_exact(dst) {
Ok(()) => Ok(true),
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => Ok(false),
Err(e) => Err(crate::Error::Io(e)),
},
}
}
pub(super) fn next_row(&mut self) -> crate::Result<Option<(String, Vec<u8>)>> {
if self.remaining == 0 {
return Ok(None);
}
self.remaining -= 1;
let mut buf4 = [0u8; 4];
if !Self::read_full(&mut self.backend, &mut buf4)? {
return Err(crate::Error::Storage {
engine: "sort".into(),
detail: "sort run truncated: expected row frame".into(),
});
}
let id_len = u32::from_le_bytes(buf4) as usize;
let mut id_buf = vec![0u8; id_len];
if !Self::read_full(&mut self.backend, &mut id_buf)? {
return Err(crate::Error::Storage {
engine: "sort".into(),
detail: "sort run truncated: expected row frame".into(),
});
}
let id = String::from_utf8(id_buf).map_err(|_| crate::Error::Storage {
engine: "sort".into(),
detail: "sort run corrupt: id not valid utf-8".into(),
})?;
if !Self::read_full(&mut self.backend, &mut buf4)? {
return Err(crate::Error::Storage {
engine: "sort".into(),
detail: "sort run truncated: expected row frame".into(),
});
}
let val_len = u32::from_le_bytes(buf4) as usize;
let mut val_buf = vec![0u8; val_len];
if !Self::read_full(&mut self.backend, &mut val_buf)? {
return Err(crate::Error::Storage {
engine: "sort".into(),
detail: "sort run truncated: expected row frame".into(),
});
}
Ok(Some((id, val_buf)))
}
}
pub(super) struct MergeEntry {
pub(super) row: (String, Vec<u8>),
pub(super) run_idx: usize,
pub(super) sort_keys: Vec<(String, bool)>,
}
impl PartialEq for MergeEntry {
fn eq(&self, other: &Self) -> bool {
self.cmp(other) == std::cmp::Ordering::Equal
}
}
impl Eq for MergeEntry {}
impl PartialOrd for MergeEntry {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for MergeEntry {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
compare_docs_by_keys_binary(&self.row.1, &other.row.1, &self.sort_keys)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn encode(v: &serde_json::Value) -> Vec<u8> {
nodedb_types::json_msgpack::json_to_msgpack(v).expect("encode")
}
#[test]
fn sort_by_int_field_asc() {
let mut rows = vec![
(
"a".into(),
encode(&serde_json::json!({"id": "a", "val": 30})),
),
(
"b".into(),
encode(&serde_json::json!({"id": "b", "val": 10})),
),
(
"c".into(),
encode(&serde_json::json!({"id": "c", "val": 20})),
),
];
sort_rows(&mut rows, &[("val".into(), true)]).expect("sort_rows failed");
let order: Vec<&str> = rows.iter().map(|(id, _)| id.as_str()).collect();
assert_eq!(order, vec!["b", "c", "a"], "ASC by val: 10, 20, 30");
}
#[test]
fn sort_by_int_field_desc() {
let mut rows = vec![
(
"a".into(),
encode(&serde_json::json!({"id": "a", "val": 30})),
),
(
"b".into(),
encode(&serde_json::json!({"id": "b", "val": 10})),
),
(
"c".into(),
encode(&serde_json::json!({"id": "c", "val": 20})),
),
];
sort_rows(&mut rows, &[("val".into(), false)]).expect("sort_rows failed");
assert_eq!(rows[0].0, "a", "DESC first should be a (val=30)");
assert_eq!(rows[1].0, "c", "DESC second should be c (val=20)");
assert_eq!(rows[2].0, "b", "DESC third should be b (val=10)");
}
#[test]
fn sort_by_string_field_asc() {
let mut rows = vec![
(
"1".into(),
encode(&serde_json::json!({"id": "1", "name": "Charlie"})),
),
(
"2".into(),
encode(&serde_json::json!({"id": "2", "name": "Alice"})),
),
(
"3".into(),
encode(&serde_json::json!({"id": "3", "name": "Bob"})),
),
];
sort_rows(&mut rows, &[("name".into(), true)]).expect("sort_rows failed");
assert_eq!(rows[0].0, "2", "first should be Alice");
assert_eq!(rows[2].0, "1", "last should be Charlie");
}
#[test]
fn apply_permutation_valid_reorders_correctly() {
let mut rows: Vec<(String, Vec<u8>)> = vec![
("a".into(), vec![1]),
("b".into(), vec![2]),
("c".into(), vec![3]),
];
apply_permutation(&mut rows, vec![2, 0, 1]).expect("valid permutation must succeed");
assert_eq!(rows[0].0, "c");
assert_eq!(rows[1].0, "a");
assert_eq!(rows[2].0, "b");
}
#[test]
fn apply_permutation_duplicate_index_errors_not_sentinel() {
let mut rows: Vec<(String, Vec<u8>)> = vec![("x".into(), vec![10]), ("y".into(), vec![20])];
let result = apply_permutation(&mut rows, vec![0, 0]);
assert!(
result.is_err(),
"duplicate index must return Err, not a silent sentinel row"
);
}
#[test]
fn apply_permutation_out_of_range_index_errors() {
let mut rows: Vec<(String, Vec<u8>)> = vec![("x".into(), vec![10]), ("y".into(), vec![20])];
let result = apply_permutation(&mut rows, vec![0, 5]);
assert!(
result.is_err(),
"out-of-range index must return Err, not panic"
);
}
}
#[cfg(all(test, target_os = "linux"))]
mod spill_merge_tests {
use super::*;
fn encode(v: &serde_json::Value) -> Vec<u8> {
nodedb_types::json_msgpack::json_to_msgpack(v).expect("encode")
}
fn frame(rows: &[(String, Vec<u8>)]) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(&(rows.len() as u32).to_le_bytes());
for (id, val) in rows {
let idb = id.as_bytes();
out.extend_from_slice(&(idb.len() as u32).to_le_bytes());
out.extend_from_slice(idb);
out.extend_from_slice(&(val.len() as u32).to_le_bytes());
out.extend_from_slice(val);
}
out
}
fn row(id: &str, val: i64) -> (String, Vec<u8>) {
(
id.to_string(),
encode(&serde_json::json!({"id": id, "val": val})),
)
}
#[test]
fn spill_then_kway_merge_round_trip() {
let dir = tempfile::tempdir().unwrap();
let sort_keys = vec![("val".to_string(), true)];
let runs = [
vec![row("a", 1), row("d", 4), row("g", 7)],
vec![row("b", 2), row("e", 5), row("h", 8)],
vec![row("c", 3), row("f", 6), row("i", 9)],
];
let mut readers: Vec<RunReader> = Vec::new();
for (idx, run) in runs.iter().enumerate() {
let path = dir.path().join(format!("run-{idx}.spill"));
write_sort_run(&path, &frame(run)).unwrap();
readers.push(RunReader::open(&path, idx).unwrap());
}
let mut heap: BinaryHeap<Reverse<MergeEntry>> = BinaryHeap::new();
for reader in &mut readers {
if let Some(r) = reader.next_row().unwrap() {
heap.push(Reverse(MergeEntry {
row: r,
run_idx: reader.run_idx,
sort_keys: sort_keys.clone(),
}));
}
}
let mut out: Vec<String> = Vec::new();
while let Some(Reverse(entry)) = heap.pop() {
out.push(entry.row.0.clone());
if let Some(next) = readers[entry.run_idx].next_row().unwrap() {
heap.push(Reverse(MergeEntry {
row: next,
run_idx: entry.run_idx,
sort_keys: sort_keys.clone(),
}));
}
}
assert_eq!(out, vec!["a", "b", "c", "d", "e", "f", "g", "h", "i"]);
}
#[test]
fn truncated_run_errors_not_silent_drop() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("trunc.spill");
let one = vec![row("x", 1)];
let mut bytes = frame(&one);
bytes[0..4].copy_from_slice(&3u32.to_le_bytes());
write_sort_run(&path, &bytes).unwrap();
let mut reader = RunReader::open(&path, 0).unwrap();
assert!(reader.next_row().unwrap().is_some());
assert!(
reader.next_row().is_err(),
"truncated run must error, not silently drop rows"
);
}
}