Skip to content

Commit 8588cf8

Browse files
committed
perf(join): eliminate JSON decode from join execution pipeline
Hash, nested-loop, and sort-merge join handlers previously decoded msgpack rows to serde_json::Value for merging, filtering, and projection, then re-encoded back to msgpack before sending the response. This introduced two redundant serialize/deserialize roundtrips per joined row. Replace the JSON-based path with an end-to-end binary msgpack pipeline: - merge_join_docs_binary now writes a prefixed msgpack map directly without decoding values, copying value bytes verbatim from the source buffers. - probe_hash_index returns Vec<Vec<u8>> (binary rows) instead of Vec<serde_json::Value>. - Post-join filters evaluate against binary rows using binary_row_matches_filters, with suffix-matching to handle qualified keys (e.g. "orders.amount") against unqualified filter fields ("amount"). - Post-join projection uses binary_row_project, scanning the map once and writing only selected fields to a new buffer. - All three join types encode results with encode_binary_rows, which concatenates pre-built row buffers under a msgpack array header with zero decoding. - Extract write_str (msgpack string encoding) to a shared msgpack_utils module consumed by both response_codec and the join handlers. - Propagate projection and post_filters through the PhysicalPlan broadcast join variant and the dispatch path so the Data Plane can apply them without a Control Plane roundtrip.
1 parent 0011a6f commit 8588cf8

10 files changed

Lines changed: 258 additions & 118 deletions

File tree

nodedb/src/bridge/physical_plan/query.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ pub enum QueryOp {
5353
post_group_by: Vec<String>,
5454
/// Post-join aggregates: (op, field) pairs (empty = no aggregation).
5555
post_aggregates: Vec<(String, String)>,
56+
/// Post-join projection: column names to keep (empty = all).
57+
projection: Vec<String>,
58+
/// Post-join WHERE filter predicates (MessagePack).
59+
post_filters: Vec<u8>,
5660
},
5761

5862
/// Shuffle join: repartition by join key via SPSC.

nodedb/src/control/server/pgwire/handler/dispatch.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,8 @@ impl NodeDbPgHandler {
6767
limit,
6868
ref post_group_by,
6969
ref post_aggregates,
70-
..
70+
ref projection,
71+
ref post_filters,
7172
},
7273
) = task.plan
7374
{
@@ -120,6 +121,8 @@ impl NodeDbPgHandler {
120121
limit,
121122
post_group_by: Vec::new(),
122123
post_aggregates: Vec::new(),
124+
projection: projection.clone(),
125+
post_filters: post_filters.clone(),
123126
},
124127
);
125128
let mut resp = crate::control::server::dispatch_utils::broadcast_to_all_cores(

nodedb/src/data/executor/dispatch/other.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,8 @@ impl CoreLoop {
155155
on,
156156
join_type,
157157
limit,
158+
projection,
159+
post_filters,
158160
..
159161
}) => self.execute_broadcast_join(
160162
task,
@@ -165,6 +167,8 @@ impl CoreLoop {
165167
on,
166168
join_type,
167169
*limit,
170+
projection,
171+
post_filters,
168172
),
169173

170174
PhysicalPlan::Query(QueryOp::ShuffleJoin {

nodedb/src/data/executor/handlers/join/hash.rs

Lines changed: 30 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,9 @@ pub(super) struct ProbeParams<'a> {
104104

105105
/// Probe a hash index with probe-side documents and produce join results.
106106
///
107+
/// Returns binary msgpack rows — no JSON decode.
107108
/// Uses u64 hash keys — zero String allocation for key matching.
108-
pub(super) fn probe_hash_index(p: &ProbeParams<'_>) -> Vec<serde_json::Value> {
109+
pub(super) fn probe_hash_index(p: &ProbeParams<'_>) -> Vec<Vec<u8>> {
109110
let is_left = p.join_type == "left" || p.join_type == "full";
110111
let is_right = p.join_type == "right" || p.join_type == "full";
111112
let is_semi = p.join_type == "semi";
@@ -259,56 +260,24 @@ impl CoreLoop {
259260
index_collection: right_collection,
260261
});
261262

262-
// Apply post-join WHERE filters.
263+
// Apply post-join WHERE filters (binary — no JSON roundtrip).
263264
if !post_filter_bytes.is_empty() {
264265
let filters: Vec<crate::bridge::scan_filter::ScanFilter> =
265-
match zerompk::from_msgpack(post_filter_bytes) {
266-
Ok(f) => f,
267-
Err(e) => {
268-
return self.response_error(
269-
task,
270-
ErrorCode::Internal {
271-
detail: format!("post-join filter deserialization failed: {e}"),
272-
},
273-
);
274-
}
275-
};
266+
zerompk::from_msgpack(post_filter_bytes).unwrap_or_default();
276267
if !filters.is_empty() {
277-
results.retain(|row| {
278-
// Convert JSON row to msgpack for ScanFilter::matches_binary.
279-
let ndb_val = nodedb_types::Value::from(row.clone());
280-
match nodedb_types::value_to_msgpack(&ndb_val) {
281-
Ok(bytes) => filters.iter().all(|f| f.matches_binary(&bytes)),
282-
Err(_) => true, // pass through on serialization failure
283-
}
284-
});
268+
results.retain(|row| super::binary_row_matches_filters(row, &filters));
285269
}
286270
}
287271

288-
// Apply post-join projection: keep only requested columns, preserving SELECT order.
289-
if !projection.is_empty() {
272+
// Apply post-join projection (binary — no JSON roundtrip).
273+
if !projection.is_empty() && !projection.iter().any(|p| p == "*") {
290274
for row in &mut results {
291-
if let serde_json::Value::Object(map) = row {
292-
let mut reordered = serde_json::Map::with_capacity(projection.len());
293-
for col in projection {
294-
if let Some(val) = map.remove(col.as_str()) {
295-
reordered.insert(col.clone(), val);
296-
}
297-
}
298-
*row = serde_json::Value::Object(reordered);
299-
}
275+
*row = super::binary_row_project(row, projection);
300276
}
301277
}
302278

303-
match super::super::super::response_codec::encode_json_vec(&results) {
304-
Ok(payload) => self.response_with_payload(task, payload),
305-
Err(e) => self.response_error(
306-
task,
307-
ErrorCode::Internal {
308-
detail: e.to_string(),
309-
},
310-
),
311-
}
279+
let payload = super::super::super::response_codec::encode_binary_rows(&results);
280+
self.response_with_payload(task, payload)
312281
}
313282

314283
/// Broadcast join: the small side is pre-serialized by the Control Plane
@@ -326,6 +295,8 @@ impl CoreLoop {
326295
on: &[(String, String)],
327296
join_type: &str,
328297
limit: usize,
298+
projection: &[String],
299+
post_filter_bytes: &[u8],
329300
) -> Response {
330301
debug!(
331302
core = self.core_id,
@@ -372,7 +343,7 @@ impl CoreLoop {
372343
let small_index = HashIndex::build(&small_docs_raw, &small_keys);
373344

374345
// Probe the hash index with large (scanned) side.
375-
let results = probe_hash_index(&ProbeParams {
346+
let mut results = probe_hash_index(&ProbeParams {
376347
probe_docs: &large_docs,
377348
index: &small_index,
378349
index_docs: &small_docs_raw,
@@ -383,14 +354,23 @@ impl CoreLoop {
383354
index_collection: small_collection,
384355
});
385356

386-
match super::super::super::response_codec::encode_json_vec(&results) {
387-
Ok(payload) => self.response_with_payload(task, payload),
388-
Err(e) => self.response_error(
389-
task,
390-
ErrorCode::Internal {
391-
detail: e.to_string(),
392-
},
393-
),
357+
// Apply post-join WHERE filters (binary).
358+
if !post_filter_bytes.is_empty() {
359+
let filters: Vec<crate::bridge::scan_filter::ScanFilter> =
360+
zerompk::from_msgpack(post_filter_bytes).unwrap_or_default();
361+
if !filters.is_empty() {
362+
results.retain(|row| super::binary_row_matches_filters(row, &filters));
363+
}
394364
}
365+
366+
// Apply post-join projection (binary).
367+
if !projection.is_empty() && !projection.iter().any(|p| p == "*") {
368+
for row in &mut results {
369+
*row = super::binary_row_project(row, projection);
370+
}
371+
}
372+
373+
let payload = super::super::super::response_codec::encode_binary_rows(&results);
374+
self.response_with_payload(task, payload)
395375
}
396376
}

0 commit comments

Comments
 (0)