use std::path::PathBuf;
use super::grace_partitioner::{GraceSpec, partition_hash};
use super::grace_repartition::{PartitionSource, repartition_side};
use super::hash::{HashIndex, ProbeParams, probe_hash_index};
use super::spill::SpillPartitionWriter;
const MAX_DEPTH: u32 = 4;
const SUB_P: usize = 16;
struct SideState {
buffers: Vec<Vec<(String, Vec<u8>)>>,
bytes: Vec<usize>,
spillers: Vec<Option<SpillPartitionWriter>>,
}
impl SideState {
fn new(partitions: usize) -> Self {
Self {
buffers: vec![vec![]; partitions],
bytes: vec![0; partitions],
spillers: (0..partitions).map(|_| None).collect(),
}
}
}
pub(super) struct PartitionedSpiller {
partitions: usize,
per_partition_budget: usize,
materialize_cap: usize,
spill_dir: PathBuf,
build_keys: Vec<String>,
probe_keys: Vec<String>,
join_type: String,
limit: usize,
probe_collection: String,
index_collection: String,
emit_unmatched_right: bool,
build: SideState,
probe: SideState,
}
impl PartitionedSpiller {
pub(super) fn new(
spec: &GraceSpec,
partitions: usize,
per_partition_budget: usize,
materialize_cap: usize,
spill_dir: PathBuf,
) -> Self {
let build_keys: Vec<String> = spec.build_keys.iter().map(|s| s.to_string()).collect();
let probe_keys: Vec<String> = spec.probe_keys.iter().map(|s| s.to_string()).collect();
let partitions = if spec.join_type == "cross"
|| build_keys.is_empty()
|| probe_keys.is_empty()
|| partitions == 0
{
1
} else {
partitions
};
Self {
partitions,
per_partition_budget,
materialize_cap,
spill_dir,
build_keys,
probe_keys,
join_type: spec.join_type.to_string(),
limit: spec.limit,
probe_collection: spec.probe_collection.to_string(),
index_collection: spec.index_collection.to_string(),
emit_unmatched_right: spec.emit_unmatched_right,
build: SideState::new(partitions),
probe: SideState::new(partitions),
}
}
pub(super) fn push_build(&mut self, value: &[u8]) -> crate::Result<()> {
let p = (partition_hash(value, &self.build_keys) % self.partitions as u64) as usize;
push_row(
&mut self.build,
p,
value,
self.per_partition_budget,
&self.spill_dir,
"build",
)
}
pub(super) fn push_probe(&mut self, value: &[u8]) -> crate::Result<()> {
let p = (partition_hash(value, &self.probe_keys) % self.partitions as u64) as usize;
push_row(
&mut self.probe,
p,
value,
self.per_partition_budget,
&self.spill_dir,
"probe",
)
}
pub(super) fn finish_and_probe(self) -> crate::Result<Vec<Vec<u8>>> {
let PartitionedSpiller {
partitions,
per_partition_budget: _,
materialize_cap,
spill_dir,
join_type,
limit,
probe_collection,
index_collection,
emit_unmatched_right,
build_keys,
probe_keys,
build,
probe,
} = self;
let build_key_refs: Vec<&str> = build_keys.iter().map(String::as_str).collect();
let probe_key_refs: Vec<&str> = probe_keys.iter().map(String::as_str).collect();
let mut build_buffers = build.buffers;
let mut build_spillers = build.spillers;
let mut probe_buffers = probe.buffers;
let mut probe_spillers = probe.spillers;
let mut queue: Vec<WorkItem> = Vec::with_capacity(partitions);
for i in 0..partitions {
let build_src = side_source(
build_spillers[i].take(),
std::mem::take(&mut build_buffers[i]),
)?;
let probe_src = side_source(
probe_spillers[i].take(),
std::mem::take(&mut probe_buffers[i]),
)?;
queue.push(WorkItem {
build: build_src,
probe: probe_src,
depth: 0,
});
}
let mut results: Vec<Vec<u8>> = Vec::new();
let mut next_repartition_id: u64 = 0;
while let Some(item) = queue.pop() {
let is_spilled = matches!(item.build, PartitionSource::Spilled(_));
let build_size = item.build.size_bytes()?;
let fits = !is_spilled || materialize_cap == 0 || build_size <= materialize_cap;
if fits {
let build_docs = item.build.materialize()?;
let probe_docs = item.probe.materialize()?;
let index = HashIndex::build(&build_docs, &build_key_refs);
let mut part = probe_hash_index(&ProbeParams {
probe_docs: &probe_docs,
index: &index,
index_docs: &build_docs,
probe_keys: &probe_key_refs,
join_type: &join_type,
limit: usize::MAX,
probe_collection: &probe_collection,
index_collection: &index_collection,
join_filters: &[],
emit_unmatched_right,
});
results.append(&mut part);
continue;
}
if item.depth >= MAX_DEPTH {
return Err(crate::Error::MemoryExhausted {
engine: "grace-join".into(),
});
}
let new_seed = new_seed_for(item.depth);
let sub_dir = spill_dir.join(format!("rp-{next_repartition_id}-d{}", item.depth + 1));
next_repartition_id += 1;
let build_sub_paths = repartition_side(
item.build,
&build_key_refs,
new_seed,
SUB_P,
&sub_dir,
"build",
)?;
let probe_sub_paths = repartition_side(
item.probe,
&probe_key_refs,
new_seed,
SUB_P,
&sub_dir,
"probe",
)?;
for (bp, pp) in build_sub_paths.into_iter().zip(probe_sub_paths) {
queue.push(WorkItem {
build: PartitionSource::Spilled(bp),
probe: PartitionSource::Spilled(pp),
depth: item.depth + 1,
});
}
}
results.truncate(limit);
Ok(results)
}
}
struct WorkItem {
build: PartitionSource,
probe: PartitionSource,
depth: u32,
}
fn new_seed_for(depth: u32) -> u64 {
(depth as u64 + 1).wrapping_mul(0x9E37_79B9_7F4A_7C15)
}
fn side_source(
spiller: Option<SpillPartitionWriter>,
in_mem: Vec<(String, Vec<u8>)>,
) -> crate::Result<PartitionSource> {
match spiller {
Some(writer) => Ok(PartitionSource::Spilled(writer.finish()?)),
None => Ok(PartitionSource::InMemory(in_mem)),
}
}
fn push_row(
side: &mut SideState,
p: usize,
value: &[u8],
budget: usize,
spill_dir: &std::path::Path,
side_tag: &str,
) -> crate::Result<()> {
if let Some(w) = side.spillers[p].as_mut() {
w.append_row(value)?;
return Ok(());
}
side.buffers[p].push((String::new(), value.to_vec()));
side.bytes[p] += value.len();
if budget == 0 || side.bytes[p] <= budget {
return Ok(());
}
let path = spill_dir.join(format!("p{p}.{side_tag}.spill"));
match SpillPartitionWriter::create(&path) {
Some(mut w) => {
for (_, row) in side.buffers[p].drain(..) {
w.append_row(&row)?;
}
side.bytes[p] = 0;
side.spillers[p] = Some(w);
}
None => {
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
type DocSet = Vec<(String, Vec<u8>)>;
fn msgpack_row(fields: &[(&str, serde_json::Value)]) -> Vec<u8> {
let mut map = serde_json::Map::new();
for (k, v) in fields {
map.insert((*k).to_string(), v.clone());
}
nodedb_types::json_to_msgpack(&serde_json::Value::Object(map)).unwrap()
}
fn as_multiset(mut rows: Vec<Vec<u8>>) -> Vec<Vec<u8>> {
rows.sort();
rows
}
fn single_key_fixtures() -> (DocSet, DocSet) {
let build = vec![
(
"b1".into(),
msgpack_row(&[("k", serde_json::json!(1)), ("rv", serde_json::json!("r1"))]),
),
(
"b2".into(),
msgpack_row(&[
("k", serde_json::json!(1)),
("rv", serde_json::json!("r1dup")),
]),
), (
"b3".into(),
msgpack_row(&[("k", serde_json::json!(2)), ("rv", serde_json::json!("r2"))]),
),
(
"b4".into(),
msgpack_row(&[("k", serde_json::json!(9)), ("rv", serde_json::json!("r9"))]),
), (
"b5".into(),
msgpack_row(&[("rv", serde_json::json!("r-nokey"))]),
), ];
let probe = vec![
(
"p1".into(),
msgpack_row(&[("k", serde_json::json!(1)), ("lv", serde_json::json!("l1"))]),
),
(
"p2".into(),
msgpack_row(&[
("k", serde_json::json!(1)),
("lv", serde_json::json!("l1dup")),
]),
), (
"p3".into(),
msgpack_row(&[("k", serde_json::json!(2)), ("lv", serde_json::json!("l2"))]),
),
(
"p4".into(),
msgpack_row(&[("k", serde_json::json!(7)), ("lv", serde_json::json!("l7"))]),
), (
"p5".into(),
msgpack_row(&[("lv", serde_json::json!("l-nokey"))]),
), ];
(build, probe)
}
const ALL_JOIN_TYPES: [&str; 7] = ["inner", "left", "right", "full", "semi", "anti", "cross"];
fn run_spiller(
build: &[(String, Vec<u8>)],
probe: &[(String, Vec<u8>)],
partitions: usize,
per_partition_budget: usize,
materialize_cap: usize,
spill_dir: PathBuf,
spec: &GraceSpec,
) -> Vec<Vec<u8>> {
let mut spiller = PartitionedSpiller::new(
spec,
partitions,
per_partition_budget,
materialize_cap,
spill_dir,
);
for (_, v) in build {
spiller.push_build(v).unwrap();
}
for (_, v) in probe {
spiller.push_probe(v).unwrap();
}
spiller.finish_and_probe().unwrap()
}
fn run_spiller_result(
build: &[(String, Vec<u8>)],
probe: &[(String, Vec<u8>)],
partitions: usize,
per_partition_budget: usize,
materialize_cap: usize,
spill_dir: PathBuf,
spec: &GraceSpec,
) -> crate::Result<Vec<Vec<u8>>> {
let mut spiller = PartitionedSpiller::new(
spec,
partitions,
per_partition_budget,
materialize_cap,
spill_dir,
);
for (_, v) in build {
spiller.push_build(v)?;
}
for (_, v) in probe {
spiller.push_probe(v)?;
}
spiller.finish_and_probe()
}
#[cfg(target_os = "linux")]
mod io_tests {
use super::super::super::grace_partitioner::grace_join_in_memory;
use super::*;
#[test]
fn spilling_matches_in_memory_reference_all_join_types() {
let (build, probe) = single_key_fixtures();
let build_keys = ["k"];
let probe_keys = ["k"];
for jt in ALL_JOIN_TYPES {
let spec = GraceSpec {
build_keys: &build_keys,
probe_keys: &probe_keys,
join_type: jt,
limit: usize::MAX,
probe_collection: "l",
index_collection: "r",
emit_unmatched_right: true,
};
let want =
as_multiset(grace_join_in_memory(build.clone(), probe.clone(), 4, &spec));
for p in [1usize, 4] {
let dir = tempfile::tempdir().unwrap();
let got = run_spiller(
&build,
&probe,
p,
1,
64 * 1024 * 1024,
dir.path().to_path_buf(),
&spec,
);
assert_eq!(
want,
as_multiset(got),
"SPILLING join_type={jt} partitions={p} multiset mismatch"
);
}
}
}
#[test]
fn spilling_matches_reference_composite_key() {
let build = vec![
(
"b1".into(),
msgpack_row(&[
("a", serde_json::json!(1)),
("b", serde_json::json!("x")),
("rv", serde_json::json!("r1")),
]),
),
(
"b2".into(),
msgpack_row(&[
("a", serde_json::json!(1)),
("b", serde_json::json!("y")),
("rv", serde_json::json!("r2")),
]),
),
(
"b3".into(),
msgpack_row(&[
("a", serde_json::json!(1)),
("b", serde_json::json!("x")),
("rv", serde_json::json!("r1dup")),
]),
), ];
let probe = vec![
(
"p1".into(),
msgpack_row(&[
("a", serde_json::json!(1)),
("b", serde_json::json!("x")),
("lv", serde_json::json!("l1")),
]),
),
(
"p2".into(),
msgpack_row(&[
("a", serde_json::json!(5)),
("b", serde_json::json!("z")),
("lv", serde_json::json!("nomatch")),
]),
),
];
let build_keys = ["a", "b"];
let probe_keys = ["a", "b"];
for jt in ["inner", "left"] {
let spec = GraceSpec {
build_keys: &build_keys,
probe_keys: &probe_keys,
join_type: jt,
limit: usize::MAX,
probe_collection: "l",
index_collection: "r",
emit_unmatched_right: true,
};
let want =
as_multiset(grace_join_in_memory(build.clone(), probe.clone(), 4, &spec));
for p in [1usize, 4] {
let dir = tempfile::tempdir().unwrap();
let got = run_spiller(
&build,
&probe,
p,
1,
64 * 1024 * 1024,
dir.path().to_path_buf(),
&spec,
);
assert_eq!(
want,
as_multiset(got),
"SPILLING composite join_type={jt} partitions={p}"
);
}
}
}
#[test]
fn non_spilling_matches_in_memory_reference() {
let (build, probe) = single_key_fixtures();
let build_keys = ["k"];
let probe_keys = ["k"];
for jt in ALL_JOIN_TYPES {
let spec = GraceSpec {
build_keys: &build_keys,
probe_keys: &probe_keys,
join_type: jt,
limit: usize::MAX,
probe_collection: "l",
index_collection: "r",
emit_unmatched_right: true,
};
let want =
as_multiset(grace_join_in_memory(build.clone(), probe.clone(), 4, &spec));
for p in [1usize, 4] {
let dir = tempfile::tempdir().unwrap();
let got = run_spiller(
&build,
&probe,
p,
64 * 1024 * 1024, 64 * 1024 * 1024, dir.path().to_path_buf(),
&spec,
);
assert_eq!(
want,
as_multiset(got),
"NON-SPILLING join_type={jt} partitions={p} multiset mismatch"
);
}
}
}
#[test]
fn skewed_distinct_keys_completes_via_repartition() {
const N: i64 = 200;
let build: Vec<(String, Vec<u8>)> = (0..N)
.map(|k| {
(
format!("b{k}"),
msgpack_row(&[
("k", serde_json::json!(k)),
("rv", serde_json::json!(format!("r{k}"))),
]),
)
})
.collect();
let probe: Vec<(String, Vec<u8>)> = (0..N)
.map(|k| {
(
format!("p{k}"),
msgpack_row(&[
("k", serde_json::json!(k)),
("lv", serde_json::json!(format!("l{k}"))),
]),
)
})
.collect();
let build_keys = ["k"];
let probe_keys = ["k"];
for jt in ["inner", "left", "right", "full"] {
let spec = GraceSpec {
build_keys: &build_keys,
probe_keys: &probe_keys,
join_type: jt,
limit: usize::MAX,
probe_collection: "l",
index_collection: "r",
emit_unmatched_right: true,
};
let want =
as_multiset(grace_join_in_memory(build.clone(), probe.clone(), 1, &spec));
let dir = tempfile::tempdir().unwrap();
let got = run_spiller(
&build,
&probe,
1,
1, 1024,
dir.path().to_path_buf(),
&spec,
);
assert_eq!(
want.len(),
N as usize,
"fixture sanity: expected {N} matches for inner-style join_type={jt}"
);
assert_eq!(
want,
as_multiset(got),
"SKEWED-DISTINCT join_type={jt}: re-partition must produce the full result"
);
}
}
#[test]
fn identical_key_skew_hits_depth_cap_error() {
const N: i64 = 500;
let build: Vec<(String, Vec<u8>)> = (0..N)
.map(|i| {
(
format!("b{i}"),
msgpack_row(&[
("k", serde_json::json!(1)),
("rv", serde_json::json!(format!("r{i}"))),
]),
)
})
.collect();
let probe: Vec<(String, Vec<u8>)> = (0..N)
.map(|i| {
(
format!("p{i}"),
msgpack_row(&[
("k", serde_json::json!(1)),
("lv", serde_json::json!(format!("l{i}"))),
]),
)
})
.collect();
let build_keys = ["k"];
let probe_keys = ["k"];
let spec = GraceSpec {
build_keys: &build_keys,
probe_keys: &probe_keys,
join_type: "inner",
limit: usize::MAX,
probe_collection: "l",
index_collection: "r",
emit_unmatched_right: true,
};
let dir = tempfile::tempdir().unwrap();
let result = run_spiller_result(
&build,
&probe,
1,
1,
1024,
dir.path().to_path_buf(),
&spec,
);
match result {
Err(crate::Error::MemoryExhausted { engine }) => {
assert_eq!(engine, "grace-join", "depth-cap error engine tag");
}
other => panic!(
"identical-key skew must hit the depth cap with MemoryExhausted, got {other:?}"
),
}
}
}
}