Skip to content

Commit d4d970c

Browse files
committed
fix(trigger): fire AFTER UPDATE on UPSERT and INSERT ON CONFLICT overwrites
Previously UPSERT always fired AFTER INSERT regardless of whether the target row already existed, causing AFTER UPDATE subscribers (CDC, materialized views, application triggers) to silently miss overwrite events. Add needs_existence_probe to DmlWriteInfo. When set, the pgwire routing layer fetches the old row before dispatch and reclassifies the DmlEvent to Insert or Update based on probe result before firing any BEFORE or AFTER triggers. The DSL UPSERT handler receives the same treatment: it probes the primary key and calls fire_sync_after_update_triggers (new helper in insert_parse.rs) on an overwrite, falling back to fire_sync_after_triggers on a fresh key.
1 parent dfe9a39 commit d4d970c

4 files changed

Lines changed: 130 additions & 7 deletions

File tree

nodedb/src/control/server/pgwire/ddl/collection/insert_parse.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,37 @@ pub(super) async fn fire_sync_after_triggers(
398398
None
399399
}
400400

401+
/// Fire SYNC AFTER UPDATE triggers, returning an error response on failure.
402+
///
403+
/// Used by the UPSERT DSL when the probe finds a pre-existing row —
404+
/// without this, AFTER UPDATE subscribers would silently miss overwrite
405+
/// events because the UPSERT handler historically fired only AFTER INSERT.
406+
pub(super) async fn fire_sync_after_update_triggers(
407+
state: &SharedState,
408+
identity: &AuthenticatedIdentity,
409+
tenant_id: nodedb_types::TenantId,
410+
coll_name: &str,
411+
old_fields: &std::collections::HashMap<String, nodedb_types::Value>,
412+
new_fields: &std::collections::HashMap<String, nodedb_types::Value>,
413+
) -> Option<PgWireResult<Vec<Response>>> {
414+
use crate::control::security::catalog::trigger_types::TriggerExecutionMode;
415+
if let Err(e) = crate::control::trigger::fire_after::fire_after_update(
416+
state,
417+
identity,
418+
tenant_id,
419+
coll_name,
420+
old_fields,
421+
new_fields,
422+
0,
423+
Some(TriggerExecutionMode::Sync),
424+
)
425+
.await
426+
{
427+
return Some(Err(sqlstate_error("XX000", &format!("trigger error: {e}"))));
428+
}
429+
None
430+
}
431+
401432
/// Fire INSTEAD OF INSERT triggers, returning the result.
402433
pub(super) async fn fire_instead_triggers(
403434
state: &SharedState,

nodedb/src/control/server/pgwire/ddl/collection/upsert.rs

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ use crate::control::security::identity::AuthenticatedIdentity;
77
use crate::control::state::SharedState;
88

99
use super::insert_parse::{
10-
fire_before_triggers, fire_instead_triggers, fire_sync_after_triggers, parse_write_statement,
10+
fire_before_triggers, fire_instead_triggers, fire_sync_after_triggers,
11+
fire_sync_after_update_triggers, parse_write_statement,
1112
};
1213

1314
/// UPSERT INTO <collection> (col1, col2, ...) VALUES (val1, val2, ...)
@@ -88,6 +89,35 @@ pub async fn upsert_document(
8889
}
8990
}
9091

92+
// Probe for an existing row BEFORE dispatch so the correct AFTER
93+
// trigger class fires: UPSERT onto an existing primary key is an
94+
// UPDATE from every downstream consumer's perspective (AFTER UPDATE
95+
// triggers, CDC, materialized views). Probing ahead of dispatch is
96+
// safe because the document primary key acts as the upsert key and
97+
// the probe + dispatch + AFTER-fire all run serially on this
98+
// connection.
99+
let pk_for_probe = fields
100+
.get("id")
101+
.or_else(|| fields.get("document_id"))
102+
.or_else(|| fields.get("key"))
103+
.map(|v| match v {
104+
nodedb_types::Value::String(s) => s.clone(),
105+
nodedb_types::Value::Integer(i) => i.to_string(),
106+
other => format!("{other:?}"),
107+
});
108+
let old_fields = if let Some(ref pk) = pk_for_probe {
109+
let row = crate::control::trigger::dml_hook::fetch_old_row(
110+
state,
111+
tenant_id,
112+
&parsed.coll_name,
113+
pk,
114+
)
115+
.await;
116+
if row.is_empty() { None } else { Some(row) }
117+
} else {
118+
None
119+
};
120+
91121
// Build SQL and route through nodedb-sql → EngineRules → sql_plan_convert.
92122
let upsert_sql = super::insert_parse::fields_to_upsert_sql(&parsed.coll_name, &fields);
93123
if let Err(e) =
@@ -96,8 +126,25 @@ pub async fn upsert_document(
96126
return Some(Err(e));
97127
}
98128

99-
// Fire SYNC AFTER INSERT triggers.
100-
if let Some(err) =
129+
// Fire the AFTER trigger family that matches the actual mutation:
130+
// AFTER UPDATE when a prior row existed, AFTER INSERT otherwise.
131+
// Firing AFTER INSERT unconditionally would silently skip AFTER
132+
// UPDATE subscribers on overwrites — the exact bug this routing
133+
// fixes.
134+
if let Some(ref old) = old_fields {
135+
if let Some(err) = fire_sync_after_update_triggers(
136+
state,
137+
identity,
138+
tenant_id,
139+
&parsed.coll_name,
140+
old,
141+
&fields,
142+
)
143+
.await
144+
{
145+
return Some(err);
146+
}
147+
} else if let Some(err) =
101148
fire_sync_after_triggers(state, identity, tenant_id, &parsed.coll_name, &fields).await
102149
{
103150
return Some(err);

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

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -340,16 +340,21 @@ impl NodeDbPgHandler {
340340
let plan_for_response = task.plan.clone();
341341

342342
// --- Trigger interception for DML writes ---
343-
let dml_info = crate::control::trigger::dml_hook::classify_dml_write(&task.plan);
343+
let mut dml_info = crate::control::trigger::dml_hook::classify_dml_write(&task.plan);
344344

345345
// Fetch OLD row and fire BEFORE/INSTEAD OF triggers if applicable.
346+
// UPSERT sets `needs_existence_probe` so the probe decides whether
347+
// AFTER INSERT or AFTER UPDATE triggers fire — post-dispatch routing
348+
// branches on `info.event`, which we override here based on the
349+
// probe result before the BEFORE / AFTER hooks run.
346350
let old_row = if let Some(ref info) = dml_info
347351
&& info.document_id.is_some()
348-
&& matches!(
352+
&& (matches!(
349353
info.event,
350354
crate::control::trigger::DmlEvent::Update
351355
| crate::control::trigger::DmlEvent::Delete
352-
) {
356+
) || info.needs_existence_probe)
357+
{
353358
let doc_id = info.document_id.as_deref().unwrap_or("");
354359
let row = crate::control::trigger::dml_hook::fetch_old_row(
355360
&self.state,
@@ -363,6 +368,20 @@ impl NodeDbPgHandler {
363368
None
364369
};
365370

371+
// Probe-driven reclassification: UPSERT onto an existing row is an
372+
// UPDATE for trigger purposes; onto a fresh key it's an INSERT.
373+
// `needs_existence_probe` is the signal that `event` was a
374+
// placeholder and must be refined from the probe result.
375+
if let Some(ref mut info) = dml_info
376+
&& info.needs_existence_probe
377+
{
378+
info.event = if old_row.is_some() {
379+
crate::control::trigger::DmlEvent::Update
380+
} else {
381+
crate::control::trigger::DmlEvent::Insert
382+
};
383+
}
384+
366385
if let Some(ref info) = dml_info {
367386
use crate::control::trigger::dml_hook::PreDispatchResult;
368387
match crate::control::trigger::dml_hook::fire_pre_dispatch_triggers(

nodedb/src/control/trigger/dml_hook.rs

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,22 @@ pub struct DmlWriteInfo {
3535
/// Document ID (for point operations). None for bulk operations.
3636
pub document_id: Option<String>,
3737
/// DML event type.
38+
///
39+
/// For UPSERT the initial value is a best guess — the true event is
40+
/// not known until the routing layer probes the pre-write row via
41+
/// `fetch_old_row`. When `needs_existence_probe` is set, routing
42+
/// overrides this field based on probe results before firing
43+
/// post-dispatch triggers.
3844
pub event: DmlEvent,
3945
/// NEW row fields extracted from the write plan. None for DELETE.
4046
pub new_fields: Option<HashMap<String, nodedb_types::Value>>,
47+
/// True when the operation's real event type depends on whether the
48+
/// target row already exists (currently: UPSERT / INSERT ... ON
49+
/// CONFLICT). Routing uses this flag to force a pre-dispatch
50+
/// existence probe so the correct AFTER INSERT vs AFTER UPDATE
51+
/// triggers fire — otherwise an UPSERT onto an existing row would
52+
/// silently fire AFTER INSERT, which is the wrong trigger class.
53+
pub needs_existence_probe: bool,
4154
}
4255

4356
/// Attempt to classify a PhysicalPlan as a document DML write.
@@ -74,6 +87,7 @@ fn classify_document_op(op: &DocumentOp) -> Option<DmlWriteInfo> {
7487
document_id: Some(document_id.clone()),
7588
event: DmlEvent::Insert,
7689
new_fields: Some(new_fields),
90+
needs_existence_probe: false,
7791
})
7892
}
7993
DocumentOp::Upsert {
@@ -82,12 +96,17 @@ fn classify_document_op(op: &DocumentOp) -> Option<DmlWriteInfo> {
8296
value,
8397
..
8498
} => {
99+
// UPSERT's event type depends on whether the primary key
100+
// already exists — routing must probe before firing
101+
// post-dispatch SYNC triggers. `event` starts at Insert as a
102+
// harmless default; the probe result overrides it.
85103
let new_fields = deserialize_value_to_fields(value);
86104
Some(DmlWriteInfo {
87105
collection: collection.clone(),
88106
document_id: Some(document_id.clone()),
89-
event: DmlEvent::Insert, // Upsert treated as INSERT for trigger purposes
107+
event: DmlEvent::Insert,
90108
new_fields: Some(new_fields),
109+
needs_existence_probe: true,
91110
})
92111
}
93112
DocumentOp::PointDelete {
@@ -98,6 +117,7 @@ fn classify_document_op(op: &DocumentOp) -> Option<DmlWriteInfo> {
98117
document_id: Some(document_id.clone()),
99118
event: DmlEvent::Delete,
100119
new_fields: None,
120+
needs_existence_probe: false,
101121
}),
102122
DocumentOp::PointUpdate {
103123
collection,
@@ -108,30 +128,35 @@ fn classify_document_op(op: &DocumentOp) -> Option<DmlWriteInfo> {
108128
document_id: Some(document_id.clone()),
109129
event: DmlEvent::Update,
110130
new_fields: None, // NEW fields computed after applying updates to OLD
131+
needs_existence_probe: false,
111132
}),
112133
DocumentOp::BatchInsert { collection, .. } => Some(DmlWriteInfo {
113134
collection: collection.clone(),
114135
document_id: None,
115136
event: DmlEvent::Insert,
116137
new_fields: None, // Batch — individual rows not available here
138+
needs_existence_probe: false,
117139
}),
118140
DocumentOp::BulkUpdate { collection, .. } => Some(DmlWriteInfo {
119141
collection: collection.clone(),
120142
document_id: None,
121143
event: DmlEvent::Update,
122144
new_fields: None,
145+
needs_existence_probe: false,
123146
}),
124147
DocumentOp::BulkDelete { collection, .. } => Some(DmlWriteInfo {
125148
collection: collection.clone(),
126149
document_id: None,
127150
event: DmlEvent::Delete,
128151
new_fields: None,
152+
needs_existence_probe: false,
129153
}),
130154
DocumentOp::Truncate { collection, .. } => Some(DmlWriteInfo {
131155
collection: collection.clone(),
132156
document_id: None,
133157
event: DmlEvent::Delete,
134158
new_fields: None,
159+
needs_existence_probe: false,
135160
}),
136161
DocumentOp::InsertSelect {
137162
target_collection, ..
@@ -140,6 +165,7 @@ fn classify_document_op(op: &DocumentOp) -> Option<DmlWriteInfo> {
140165
document_id: None,
141166
event: DmlEvent::Insert,
142167
new_fields: None,
168+
needs_existence_probe: false,
143169
}),
144170
// Not a write operation.
145171
DocumentOp::PointGet { .. }

0 commit comments

Comments
 (0)