use std::path::PathBuf;
use super::grace_drive::GraceSources;
use super::grace_partitioner::GraceSpec;
use super::params::JoinParams;
use super::row_source::RowSource;
use crate::bridge::envelope::Response;
use crate::data::executor::core_loop::CoreLoop;
pub(in crate::data::executor) struct ShuffleJoinInputs {
pub build_path: PathBuf,
pub probe_path: PathBuf,
pub probe_qualifier: String,
pub index_qualifier: String,
}
impl CoreLoop {
pub(in crate::data::executor) fn execute_shuffle_join(
&self,
join: &JoinParams<'_>,
inputs: ShuffleJoinInputs,
budget: usize,
) -> Response {
let probe_keys: Vec<&str> = join.on.iter().map(|(l, _)| l.as_str()).collect();
let build_keys: Vec<&str> = join.on.iter().map(|(_, r)| r.as_str()).collect();
if join.join_type == "cross" || build_keys.is_empty() || probe_keys.is_empty() {
return self.response_error(
join.task,
crate::bridge::envelope::ErrorCode::Internal {
detail: "shuffle join requires equi-join keys; cross/keyless join must not be \
routed to the shuffle-join consumer"
.into(),
},
);
}
let (probe_limit, enforce_output_budget) = if join.limit != usize::MAX {
(join.limit, false)
} else if budget == 0 {
(usize::MAX, false)
} else {
(
crate::data::executor::handlers::scan_budget::fetch_limit_for(
usize::MAX,
0,
budget,
),
true,
)
};
let spec = GraceSpec {
build_keys: &build_keys,
probe_keys: &probe_keys,
join_type: join.join_type,
limit: probe_limit,
probe_collection: &inputs.probe_qualifier,
index_collection: &inputs.index_qualifier,
emit_unmatched_right: true,
};
let sources = GraceSources {
build: RowSource::ShuffleStream {
path: inputs.build_path,
},
probe: RowSource::ShuffleStream {
path: inputs.probe_path,
},
};
let unique_join_id = join.task.request_id().as_u64();
self.finish_grace_join(
join,
sources,
&spec,
budget,
unique_join_id,
enforce_output_budget,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bridge::dispatch::{BridgeRequest, BridgeResponse};
use crate::bridge::envelope::{Priority, Request, Status};
use crate::data::executor::handlers::join::grace_partitioner::{
GraceSpec, grace_join_in_memory,
};
use crate::data::executor::task::ExecutionTask;
use crate::types::*;
use nodedb_bridge::buffer::RingBuffer;
use nodedb_physical::physical_plan::{DocumentOp, PhysicalPlan};
use std::io::Write as _;
use std::time::{Duration, Instant};
fn make_core() -> (CoreLoop, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("tempdir");
let (_req_tx, req_rx) = RingBuffer::channel::<BridgeRequest>(64);
let (resp_tx, _resp_rx) = RingBuffer::channel::<BridgeResponse>(64);
let core = CoreLoop::open(
0,
req_rx,
resp_tx,
dir.path(),
std::sync::Arc::new(nodedb_types::OrdinalClock::new()),
)
.expect("open core");
(core, dir)
}
fn make_task() -> ExecutionTask {
let request = Request {
request_id: RequestId::new(7),
tenant_id: TenantId::new(0),
database_id: DatabaseId::DEFAULT,
vshard_id: VShardId::new(0),
plan: PhysicalPlan::Document(DocumentOp::PointGet {
collection: "t".into(),
document_id: "d".into(),
surrogate: nodedb_types::Surrogate::ZERO,
pk_bytes: Vec::new(),
rls_filters: Vec::new(),
system_time: nodedb_types::SystemTimeScope::Current,
valid_at_ms: None,
}),
deadline: Instant::now() + Duration::from_secs(30),
priority: Priority::Normal,
trace_id: TraceId::generate(),
consistency: ReadConsistency::Strong,
idempotency_key: None,
event_source: crate::event::EventSource::User,
user_roles: Vec::new(),
user_id: None,
statement_digest: None,
txn_id: None,
wal_lsn: None,
resolved_now_ms: None,
admission: crate::bridge::envelope::Admission::Exempt(
crate::bridge::envelope::ExemptReason::Read,
),
};
ExecutionTask::new(request)
}
fn 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)).expect("encode row")
}
fn write_staged(path: &std::path::Path, rows: &[Vec<u8>]) {
let mut f = std::fs::File::create(path).expect("create staged file");
for r in rows {
let len = u32::try_from(r.len()).expect("row fits u32");
f.write_all(&len.to_le_bytes()).expect("write len");
f.write_all(r).expect("write body");
}
f.flush().expect("flush");
}
fn fixtures() -> (Vec<Vec<u8>>, Vec<Vec<u8>>) {
let build = vec![
row(&[("k", serde_json::json!(1)), ("rv", serde_json::json!("r1"))]),
row(&[
("k", serde_json::json!(1)),
("rv", serde_json::json!("r1b")),
]), row(&[("k", serde_json::json!(2)), ("rv", serde_json::json!("r2"))]),
row(&[("k", serde_json::json!(9)), ("rv", serde_json::json!("r9"))]), ];
let probe = vec![
row(&[("k", serde_json::json!(1)), ("lv", serde_json::json!("l1"))]), row(&[("k", serde_json::json!(2)), ("lv", serde_json::json!("l2"))]), row(&[("k", serde_json::json!(7)), ("lv", serde_json::json!("l7"))]), ];
(build, probe)
}
fn spec<'a>(
build_keys: &'a [&'a str],
probe_keys: &'a [&'a str],
join_type: &'a str,
) -> GraceSpec<'a> {
GraceSpec {
build_keys,
probe_keys,
join_type,
limit: usize::MAX,
probe_collection: "l",
index_collection: "r",
emit_unmatched_right: true,
}
}
fn as_multiset(mut rows: Vec<Vec<u8>>) -> Vec<Vec<u8>> {
rows.sort();
rows
}
fn gen_rows(n: usize, key_mod: i64) -> Vec<Vec<u8>> {
(0..n)
.map(|i| {
row(&[
("k", serde_json::json!((i as i64) % key_mod)),
("v", serde_json::json!(format!("val-{i}"))),
])
})
.collect()
}
fn total_bytes(rows: &[Vec<u8>]) -> usize {
rows.iter().map(|r| r.len()).sum()
}
#[test]
fn shuffle_grace_matches_reference_inmemory_join() {
let (core, _dir) = make_core();
let tmp = tempfile::tempdir().expect("staging dir");
let build_path = tmp.path().join("build.frames");
let probe_path = tmp.path().join("probe.frames");
let build = gen_rows(120, 40);
let probe = gen_rows(120, 40);
write_staged(&build_path, &build);
write_staged(&probe_path, &probe);
let build_keys = ["k"];
let probe_keys = ["k"];
let spill_budget = (total_bytes(&build) / 3).max(1);
for jt in ["inner", "left", "right", "full"] {
let s = spec(&build_keys, &probe_keys, jt);
let want = as_multiset(grace_join_in_memory(
build.iter().map(|r| (String::new(), r.clone())).collect(),
probe.iter().map(|r| (String::new(), r.clone())).collect(),
64,
&s,
));
for budget in [0usize, spill_budget] {
let sources = GraceSources {
build: RowSource::ShuffleStream {
path: build_path.clone(),
},
probe: RowSource::ShuffleStream {
path: probe_path.clone(),
},
};
let got = core
.drive_grace_build(sources, &s, budget, 100 + budget as u64)
.expect("shuffle grace join completes");
assert_eq!(
want,
as_multiset(got),
"shuffle grace must equal reference: join_type={jt} budget={budget}"
);
}
}
}
#[test]
fn shuffle_grace_infeasible_budget_is_deterministic_error() {
let (core, _dir) = make_core();
let tmp = tempfile::tempdir().expect("staging dir");
let build_path = tmp.path().join("b.frames");
let probe_path = tmp.path().join("p.frames");
let (build, probe) = fixtures();
write_staged(&build_path, &build);
write_staged(&probe_path, &probe);
let build_keys = ["k"];
let probe_keys = ["k"];
let s = spec(&build_keys, &probe_keys, "inner");
let sources = GraceSources {
build: RowSource::ShuffleStream { path: build_path },
probe: RowSource::ShuffleStream { path: probe_path },
};
let got = core.drive_grace_build(sources, &s, 4, 999);
assert!(
matches!(got, Err(crate::Error::MemoryExhausted { .. })),
"an infeasible (sub-row) budget must surface a deterministic \
MemoryExhausted, got a different outcome"
);
}
#[test]
fn execute_shuffle_join_returns_ok_response_with_join_rows() {
let (core, _dir) = make_core();
let tmp = tempfile::tempdir().expect("staging dir");
let build_path = tmp.path().join("b.frames");
let probe_path = tmp.path().join("p.frames");
let (build, probe) = fixtures();
write_staged(&build_path, &build);
write_staged(&probe_path, &probe);
let build_keys = ["k"];
let probe_keys = ["k"];
let ref_spec = spec(&build_keys, &probe_keys, "inner");
let reference = grace_join_in_memory(
build.iter().map(|r| (String::new(), r.clone())).collect(),
probe.iter().map(|r| (String::new(), r.clone())).collect(),
64,
&ref_spec,
);
let task = make_task();
let join = JoinParams {
task: &task,
on: &[("k".to_string(), "k".to_string())],
join_type: "inner",
limit: usize::MAX,
projection: &[],
computed_projection_bytes: &[],
join_filter_bytes: &[],
post_filter_bytes: &[],
};
let inputs = ShuffleJoinInputs {
build_path,
probe_path,
probe_qualifier: "l".into(),
index_qualifier: "r".into(),
};
let resp = core.execute_shuffle_join(&join, inputs, 0);
assert_eq!(resp.status, Status::Ok, "shuffle join must succeed");
let payload = resp.payload.as_bytes();
assert!(
payload[0] & 0xf0 == 0x90,
"payload must start with a fixarray header"
);
let row_count = (payload[0] & 0x0f) as usize;
assert_eq!(
row_count,
reference.len(),
"encoded row count must match the reference inner-join count"
);
let l1_marker = b"l1";
assert!(
payload.windows(l1_marker.len()).any(|w| w == l1_marker),
"matched probe row bytes must be present in the join output"
);
}
}