Skip to content

Commit a2c4e1b

Browse files
committed
feat: implement UNION DISTINCT deduplication and BEFORE trigger row patching in the query router
The pgwire query router now collects response payloads from tasks flagged post_dedup and runs dedup_union_payloads before returning results. Deduplication operates at the msgpack binary level — each row's raw bytes serve as the hash key, avoiding a decode/re-encode round-trip. BEFORE trigger handling is updated to use the PreDispatchResult enum: when a trigger returns mutated fields the router patches the task in place before dispatching, so the Data Plane receives the trigger-modified row rather than the original.
1 parent df83b99 commit a2c4e1b

1 file changed

Lines changed: 131 additions & 7 deletions

File tree

  • nodedb/src/control/server/pgwire/handler

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

Lines changed: 131 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,11 @@ impl NodeDbPgHandler {
9696
return self.forward_sql(sql, tenant_id, leader).await;
9797
}
9898

99+
let needs_dedup = tasks.iter().any(|t| t.post_dedup);
100+
let mut dedup_payloads: Vec<Vec<u8>> = Vec::new();
101+
let mut dedup_plan_kind = None;
99102
let mut responses = Vec::with_capacity(tasks.len());
100-
for task in tasks {
103+
for mut task in tasks {
101104
if task.tenant_id != tenant_id {
102105
tracing::error!(
103106
expected = %tenant_id,
@@ -131,6 +134,7 @@ impl NodeDbPgHandler {
131134

132135
let plan_kind = describe_plan(&task.plan);
133136
let collection_for_si = extract_collection(&task.plan).map(String::from);
137+
let resp_post_dedup = task.post_dedup;
134138

135139
// --- Trigger interception for DML writes ---
136140
let dml_info = crate::control::trigger::dml_hook::classify_dml_write(&task.plan);
@@ -157,7 +161,8 @@ impl NodeDbPgHandler {
157161
};
158162

159163
if let Some(ref info) = dml_info {
160-
let proceed = crate::control::trigger::dml_hook::fire_pre_dispatch_triggers(
164+
use crate::control::trigger::dml_hook::PreDispatchResult;
165+
let result = crate::control::trigger::dml_hook::fire_pre_dispatch_triggers(
161166
&self.state,
162167
identity,
163168
tenant_id,
@@ -175,10 +180,23 @@ impl NodeDbPgHandler {
175180
)))
176181
})?;
177182

178-
if !proceed {
179-
// INSTEAD OF trigger handled the write — skip dispatch.
180-
responses.push(Response::Execution(Tag::new("OK")));
181-
continue;
183+
match result {
184+
PreDispatchResult::Handled => {
185+
// INSTEAD OF trigger handled the write — skip dispatch.
186+
responses.push(Response::Execution(Tag::new("OK")));
187+
continue;
188+
}
189+
PreDispatchResult::Proceed {
190+
mutated_fields: Some(ref fields),
191+
} => {
192+
// BEFORE trigger mutated the row — patch the task.
193+
crate::control::trigger::dml_hook::patch_task_with_mutated_fields(
194+
&mut task, fields,
195+
);
196+
}
197+
PreDispatchResult::Proceed {
198+
mutated_fields: None,
199+
} => {}
182200
}
183201
}
184202

@@ -237,7 +255,19 @@ impl NodeDbPgHandler {
237255
.record_read(addr, collection, String::new(), resp.watermark_lsn);
238256
}
239257

240-
responses.push(payload_to_response(&resp.payload, plan_kind));
258+
if needs_dedup && resp_post_dedup {
259+
dedup_payloads.push(resp.payload.to_vec());
260+
dedup_plan_kind = Some(plan_kind);
261+
} else {
262+
responses.push(payload_to_response(&resp.payload, plan_kind));
263+
}
264+
}
265+
266+
// UNION DISTINCT: merge all sub-query payloads and deduplicate rows.
267+
if needs_dedup && !dedup_payloads.is_empty() {
268+
let kind = dedup_plan_kind.unwrap_or(PlanKind::MultiRow);
269+
let merged = dedup_union_payloads(&dedup_payloads);
270+
responses.push(payload_to_response(&merged, kind));
241271
}
242272

243273
Ok(responses)
@@ -370,3 +400,97 @@ impl NodeDbPgHandler {
370400
}
371401
}
372402
}
403+
404+
/// Merge multiple Data Plane response payloads and deduplicate rows (UNION DISTINCT).
405+
///
406+
/// Each payload is a msgpack-encoded array of rows. Deduplication is performed
407+
/// at the binary level: each row's raw msgpack bytes serve as the canonical key,
408+
/// eliminating the decode → JSON string → re-encode round-trip.
409+
///
410+
/// Output: a single msgpack array containing all unique rows in encounter order.
411+
fn dedup_union_payloads(payloads: &[Vec<u8>]) -> Vec<u8> {
412+
use nodedb_query::msgpack_scan;
413+
414+
let mut seen: std::collections::HashSet<Vec<u8>> = std::collections::HashSet::new();
415+
// Collect raw byte slices of unique rows before writing output.
416+
let mut unique_row_bytes: Vec<Vec<u8>> = Vec::new();
417+
418+
for payload in payloads {
419+
if payload.is_empty() {
420+
continue;
421+
}
422+
423+
let bytes = payload.as_slice();
424+
let first = bytes[0];
425+
426+
// Decode msgpack array header to get element count and header length.
427+
let (count, hdr_len) = if (0x90..=0x9f).contains(&first) {
428+
((first & 0x0f) as usize, 1)
429+
} else if first == 0xdc && bytes.len() >= 3 {
430+
(u16::from_be_bytes([bytes[1], bytes[2]]) as usize, 3)
431+
} else if first == 0xdd && bytes.len() >= 5 {
432+
(
433+
u32::from_be_bytes([bytes[1], bytes[2], bytes[3], bytes[4]]) as usize,
434+
5,
435+
)
436+
} else {
437+
// Not a msgpack array — treat the whole payload as one row.
438+
tracing::warn!(
439+
payload_len = bytes.len(),
440+
"dedup_union_payloads: payload is not a msgpack array; treating as single row"
441+
);
442+
let key = bytes.to_vec();
443+
if seen.insert(key.clone()) {
444+
unique_row_bytes.push(key);
445+
}
446+
continue;
447+
};
448+
449+
let mut pos = hdr_len;
450+
for _ in 0..count {
451+
if pos >= bytes.len() {
452+
break;
453+
}
454+
let elem_start = pos;
455+
match msgpack_scan::skip_value(bytes, pos) {
456+
Some(next_pos) => {
457+
let row_bytes = bytes[elem_start..next_pos].to_vec();
458+
if seen.insert(row_bytes.clone()) {
459+
unique_row_bytes.push(row_bytes);
460+
}
461+
pos = next_pos;
462+
}
463+
None => {
464+
tracing::warn!(
465+
pos,
466+
payload_len = bytes.len(),
467+
"dedup_union_payloads: could not skip msgpack element; stopping early"
468+
);
469+
break;
470+
}
471+
}
472+
}
473+
}
474+
475+
// Write output: msgpack array header + concatenated unique row bytes.
476+
let row_count = unique_row_bytes.len();
477+
let total_data: usize = unique_row_bytes.iter().map(|r| r.len()).sum();
478+
let mut out = Vec::with_capacity(total_data + 5);
479+
480+
// Write array header.
481+
if row_count < 16 {
482+
out.push(0x90 | row_count as u8);
483+
} else if row_count <= u16::MAX as usize {
484+
out.push(0xdc);
485+
out.extend_from_slice(&(row_count as u16).to_be_bytes());
486+
} else {
487+
out.push(0xdd);
488+
out.extend_from_slice(&(row_count as u32).to_be_bytes());
489+
}
490+
491+
for row in unique_row_bytes {
492+
out.extend_from_slice(&row);
493+
}
494+
495+
out
496+
}

0 commit comments

Comments
 (0)