Skip to content

Commit d600433

Browse files
committed
feat(control): route DML on CRDT document collections to CrdtOp
INSERT, UPSERT, UPDATE, and DELETE on a `crdt = true` document collection now lower to CrdtOp::DocUpsert / DocDelete instead of the plain DocumentOp path, so writes converge via CRDT last-writer-wins rather than bypassing sync. Only representable shapes are routed: full-replace INSERT/UPSERT and PK-targeted UPDATE with literal SET values map to DocUpsert (partial for UPDATE), PK-targeted DELETE maps to DocDelete. Predicate UPDATE/DELETE, IF NOT EXISTS INSERT, ON CONFLICT DO UPDATE, non-literal UPDATE RHS, and UPDATE ... RETURNING are rejected outright rather than silently falling through to the non-CRDT path. A shared crdt_gate module detects CRDT collections once per statement and builds the fields_json payload consumed by DocUpsert.
1 parent 222ad32 commit d600433

6 files changed

Lines changed: 442 additions & 34 deletions

File tree

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// SPDX-License-Identifier: BUSL-1.1
2+
3+
//! CRDT-routing gate for document-collection DML.
4+
//!
5+
//! Detects `crdt = true` document collections and builds the `fields_json`
6+
//! payload that `CrdtOp::DocUpsert` consumes. Lives at the `dml` module level
7+
//! so INSERT / UPSERT (in `insert.rs`) and UPDATE / DELETE (in
8+
//! `update_delete/`) all reach the same detection + payload builders.
9+
//!
10+
//! Routing contract: on a CRDT document collection, PK-targeted
11+
//! INSERT / UPSERT (full replace) / UPDATE-SET (literal RHS) / DELETE lower to
12+
//! `CrdtOp::DocUpsert` / `DocDelete`; every unsupported shape (predicate
13+
//! UPDATE/DELETE, non-literal UPDATE RHS, UPDATE ... RETURNING, `IF NOT EXISTS`
14+
//! INSERT, explicit `ON CONFLICT DO UPDATE`) is rejected with a typed error.
15+
//! There is NO silent fallthrough to a non-CRDT `DocumentOp` — that would
16+
//! bypass CRDT convergence and is a correctness bug.
17+
18+
use nodedb_sql::types::{SqlExpr, SqlValue};
19+
20+
use crate::control::planner::sql_plan_convert::convert::ConvertContext;
21+
use crate::control::planner::sql_plan_convert::value::row_to_msgpack;
22+
23+
/// `true` when `collection` (already db-qualified by the caller) is a CRDT
24+
/// document collection.
25+
///
26+
/// A genuine catalog READ error propagates: misrouting a write to the non-CRDT
27+
/// path would silently bypass CRDT convergence. An ABSENT credential store or
28+
/// catalog, or an absent collection row (`Ok(None)`), is treated as non-CRDT
29+
/// (`Ok(false)`). Mirrors `document_collection_is_edge_bearing` in
30+
/// `update_delete/shared.rs` exactly.
31+
pub(in crate::control::planner::sql_plan_convert::dml) fn document_collection_is_crdt(
32+
ctx: &ConvertContext,
33+
collection: &str,
34+
) -> crate::Result<bool> {
35+
let Some(credentials) = ctx.credentials.as_ref() else {
36+
return Ok(false);
37+
};
38+
let catalog = credentials.catalog();
39+
Ok(catalog
40+
.get_collection(ctx.database_id, ctx.tenant_id.as_u64(), collection)?
41+
.map(|c| c.crdt)
42+
.unwrap_or(false))
43+
}
44+
45+
/// Full-row fields → JSON object string for `DocUpsert.fields_json`.
46+
///
47+
/// Reuses the DML msgpack writer + `json_from_msgpack` so the stored payload is
48+
/// byte-identical to what the non-CRDT `PointInsert` path would produce for the
49+
/// same row, then serializes to JSON text (the shape `DocUpsert` consumes).
50+
pub(in crate::control::planner::sql_plan_convert::dml) fn row_to_fields_json(
51+
row: &[(String, SqlValue)],
52+
) -> crate::Result<String> {
53+
let mpk = row_to_msgpack(row)?;
54+
let json = nodedb_types::json_from_msgpack(&mpk).map_err(|e| crate::Error::Serialization {
55+
format: "json".into(),
56+
detail: format!("crdt fields_json encode: {e}"),
57+
})?;
58+
sonic_rs::to_string(&json).map_err(|e| crate::Error::Serialization {
59+
format: "json".into(),
60+
detail: format!("crdt fields_json encode: {e}"),
61+
})
62+
}
63+
64+
/// UPDATE SET (partial) `fields_json` from LITERAL assignments only.
65+
///
66+
/// An expression RHS is rejected: the Control Plane planner cannot evaluate a
67+
/// per-row expression here (same restriction the KV and columnar/spatial UPDATE
68+
/// paths enforce). Only the provided fields go into the object — `DocUpsert`
69+
/// with `partial = true` writes exactly these (LWW-per-field), leaving untouched
70+
/// keys intact.
71+
pub(in crate::control::planner::sql_plan_convert::dml) fn literal_assignments_to_fields_json(
72+
assignments: &[(String, SqlExpr)],
73+
) -> crate::Result<String> {
74+
let mut row: Vec<(String, SqlValue)> = Vec::with_capacity(assignments.len());
75+
for (field, expr) in assignments {
76+
match expr {
77+
SqlExpr::Literal(v) => row.push((field.clone(), v.clone())),
78+
_ => {
79+
return Err(crate::Error::BadRequest {
80+
detail: format!(
81+
"UPDATE with non-literal RHS on CRDT collection (field '{field}') \
82+
is not supported; use a literal value"
83+
),
84+
});
85+
}
86+
}
87+
}
88+
row_to_fields_json(&row)
89+
}

nodedb/src/control/planner/sql_plan_convert/dml/insert.rs

Lines changed: 61 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,19 @@ pub(in super::super) fn convert_insert(
195195
let mut tasks = Vec::new();
196196
let mut columnar_rows: Vec<&Vec<(String, SqlValue)>> = Vec::new();
197197

198+
// Detect CRDT document collections once (never re-hit the catalog per row).
199+
// `IF NOT EXISTS` (ON CONFLICT DO NOTHING → `if_absent`) cannot be honored by
200+
// `CrdtOp::DocUpsert`, which is an unconditional LWW full-replace: reject.
201+
let is_crdt = super::crdt_gate::document_collection_is_crdt(ctx, collection)?;
202+
if is_crdt && if_absent {
203+
return Err(crate::Error::BadRequest {
204+
detail: format!(
205+
"INSERT ... IF NOT EXISTS on CRDT collection '{collection}' is not supported; \
206+
CRDT documents converge via last-writer-wins full replace"
207+
),
208+
});
209+
}
210+
198211
let mut expanded_rows: Vec<Vec<(String, SqlValue)>> = Vec::with_capacity(rows.len());
199212
for row in rows {
200213
if column_defaults.is_empty() {
@@ -252,17 +265,28 @@ pub(in super::super) fn convert_insert(
252265
let s = assign_for_pk(ctx, collection, doc_id.as_bytes())?;
253266
(doc_id, s)
254267
};
255-
tasks.push(PhysicalTask {
256-
tenant_id,
257-
vshard_id: vshard,
258-
database_id: ctx.database_id,
259-
plan: PhysicalPlan::Document(DocumentOp::PointInsert {
268+
let plan = if is_crdt {
269+
PhysicalPlan::Crdt(CrdtOp::DocUpsert {
270+
collection: collection.into(),
271+
document_id: doc_id,
272+
fields_json: super::crdt_gate::row_to_fields_json(row)?,
273+
surrogate,
274+
partial: false,
275+
})
276+
} else {
277+
PhysicalPlan::Document(DocumentOp::PointInsert {
260278
collection: collection.into(),
261279
document_id: doc_id,
262280
value: value_bytes,
263281
if_absent,
264282
surrogate,
265-
}),
283+
})
284+
};
285+
tasks.push(PhysicalTask {
286+
tenant_id,
287+
vshard_id: vshard,
288+
database_id: ctx.database_id,
289+
plan,
266290
post_set_op: PostSetOp::None,
267291
txn_id: None,
268292
});
@@ -341,6 +365,20 @@ pub(in super::super) fn convert_upsert(
341365
let vshard = VShardId::from_collection_in_database(ctx.database_id, collection);
342366
let mut tasks = Vec::new();
343367

368+
// Detect CRDT document collections once. An explicit `ON CONFLICT DO UPDATE
369+
// SET ...` cannot be honored: CRDT conflict resolution IS the LWW
370+
// full-replace `DocUpsert` performs, so a caller-supplied merge clause has
371+
// no place to run. Reject rather than silently ignore it.
372+
let is_crdt = super::crdt_gate::document_collection_is_crdt(ctx, collection)?;
373+
if is_crdt && !on_conflict_updates.is_empty() {
374+
return Err(crate::Error::BadRequest {
375+
detail: format!(
376+
"UPSERT with ON CONFLICT DO UPDATE on CRDT collection '{collection}' is not \
377+
supported; CRDT documents converge via last-writer-wins full replace"
378+
),
379+
});
380+
}
381+
344382
let on_conflict_values = if on_conflict_updates.is_empty() {
345383
Vec::new()
346384
} else {
@@ -368,17 +406,28 @@ pub(in super::super) fn convert_upsert(
368406
let s = assign_for_pk(ctx, collection, doc_id.as_bytes())?;
369407
(doc_id, s)
370408
};
371-
tasks.push(PhysicalTask {
372-
tenant_id,
373-
vshard_id: vshard,
374-
database_id: ctx.database_id,
375-
plan: PhysicalPlan::Document(DocumentOp::Upsert {
409+
let plan = if is_crdt {
410+
PhysicalPlan::Crdt(CrdtOp::DocUpsert {
411+
collection: collection.into(),
412+
document_id: doc_id,
413+
fields_json: super::crdt_gate::row_to_fields_json(row)?,
414+
surrogate,
415+
partial: false,
416+
})
417+
} else {
418+
PhysicalPlan::Document(DocumentOp::Upsert {
376419
collection: collection.into(),
377420
document_id: doc_id,
378421
value: value_bytes,
379422
on_conflict_updates: on_conflict_values.clone(),
380423
surrogate,
381-
}),
424+
})
425+
};
426+
tasks.push(PhysicalTask {
427+
tenant_id,
428+
vshard_id: vshard,
429+
database_id: ctx.database_id,
430+
plan,
382431
post_set_op: PostSetOp::None,
383432
txn_id: None,
384433
});

nodedb/src/control/planner/sql_plan_convert/dml/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// SPDX-License-Identifier: BUSL-1.1
22

3+
mod crdt_gate;
34
mod insert;
45
mod kv_and_vector;
56
mod merge;

nodedb/src/control/planner/sql_plan_convert/dml/update_delete/delete.rs

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,21 @@ pub(in crate::control::planner::sql_plan_convert) fn convert_delete(
6969
}]);
7070
}
7171

72+
// CRDT GATE: a DELETE on a `crdt = true` document collection routes to
73+
// `CrdtOp::DocDelete` (tombstone + sparse-store removal). Only a PK-targeted
74+
// DELETE is representable; a predicate (non-PK) DELETE is rejected — there is
75+
// NO silent fallthrough to a `DocumentOp`, which would bypass CRDT
76+
// convergence. Read the flag ONCE, before the PK loop.
77+
let is_crdt = super::super::crdt_gate::document_collection_is_crdt(ctx, collection)?;
78+
if is_crdt && target_keys.is_empty() {
79+
return Err(crate::Error::BadRequest {
80+
detail: format!(
81+
"predicate (non-primary-key) DELETE on CRDT collection '{collection}' is not \
82+
supported; target rows by primary key"
83+
),
84+
});
85+
}
86+
7287
// EDGE-BEARING GATE: a PK-equality delete on a schemaless-document
7388
// collection that carries implicit edges must NOT lower to a static
7489
// `PointDelete` — that op bypasses the dependent-predicate (OLLP) path
@@ -79,7 +94,8 @@ pub(in crate::control::planner::sql_plan_convert) fn convert_delete(
7994
// collections keep the fast `PointDelete` path below. Reached only for
8095
// document engines (the KV case returned above); strict/columnar/etc.
8196
// never set `has_implicit_edges`, so the flag naturally scopes this.
82-
if !target_keys.is_empty() && document_collection_is_edge_bearing(ctx, collection)? {
97+
if !is_crdt && !target_keys.is_empty() && document_collection_is_edge_bearing(ctx, collection)?
98+
{
8399
let effective_filter = delete_effective_filter(filters, target_keys)?;
84100
return Ok(vec![PhysicalTask {
85101
tenant_id,
@@ -109,17 +125,26 @@ pub(in crate::control::planner::sql_plan_convert) fn convert_delete(
109125
},
110126
None => Surrogate::ZERO,
111127
};
112-
tasks.push(PhysicalTask {
113-
tenant_id,
114-
vshard_id: vshard,
115-
database_id: ctx.database_id,
116-
plan: PhysicalPlan::Document(DocumentOp::PointDelete {
128+
let plan = if is_crdt {
129+
PhysicalPlan::Crdt(CrdtOp::DocDelete {
130+
collection: collection.into(),
131+
document_id: pk_string,
132+
surrogate,
133+
})
134+
} else {
135+
PhysicalPlan::Document(DocumentOp::PointDelete {
117136
collection: collection.into(),
118137
document_id: pk_string,
119138
surrogate,
120139
pk_bytes,
121140
returning: None,
122-
}),
141+
})
142+
};
143+
tasks.push(PhysicalTask {
144+
tenant_id,
145+
vshard_id: vshard,
146+
database_id: ctx.database_id,
147+
plan,
123148
post_set_op: PostSetOp::None,
124149
txn_id: None,
125150
});

0 commit comments

Comments
 (0)