From 8ee272e2d6d31843f9f78c0ecc8fc465e5916e83 Mon Sep 17 00:00:00 2001 From: EnRaiha <15997552+EnRaiha@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:57:06 +0800 Subject: [PATCH 1/5] feat(graph): batch edge writes through one statement Bulk loaders pay one GRAPH INSERT EDGE per edge, so a 66k edge import spends hours in round trips. The plan, staging, WAL, and Data Planes already carried EdgePutBatch / EdgeDeleteBatch; the statement layer above them was the missing piece. Add GRAPH INSERT EDGES / GRAPH DELETE EDGES taking a property-less VALUES list of (src, dst, label) triples, capped at 1000 edges per statement with the cap named in the error. Per-edge PROPERTIES stays on the single-edge form until BatchEdge can carry a property object. The handler resolves each edge (surrogates, write policy) and buckets by home vShard, so a single-home batch is one apply burst per home and the Calvin path sequences the whole batch as one tx class. build_static_tx_class now derives participant homes and lock identity from batch edge plans. --- nodedb-sql/src/ddl_ast/graph_parse/entry.rs | 79 ++++- .../src/ddl_ast/graph_parse/variants.rs | 86 ++++- nodedb-sql/src/ddl_ast/graph_types.rs | 9 + nodedb-sql/src/ddl_ast/mod.rs | 2 +- nodedb-sql/src/ddl_ast/statement/mod.rs | 2 +- .../src/ddl_ast/statement/types/graph.rs | 18 +- .../planner/calvin/tx_class/static_builder.rs | 17 + .../shared/ddl/neutral/graph_ops/dispatch.rs | 10 +- .../shared/ddl/neutral/graph_ops/edge.rs | 4 +- .../ddl/neutral/graph_ops/edge_batch.rs | 315 ++++++++++++++++++ .../shared/ddl/neutral/graph_ops/mod.rs | 1 + .../tests/wire/cases/graph_dsl_batch_edges.rs | 100 ++++++ nodedb/tests/wire/cases/mod.rs | 1 + 13 files changed, 635 insertions(+), 9 deletions(-) create mode 100644 nodedb/src/control/server/shared/ddl/neutral/graph_ops/edge_batch.rs create mode 100644 nodedb/tests/wire/cases/graph_dsl_batch_edges.rs diff --git a/nodedb-sql/src/ddl_ast/graph_parse/entry.rs b/nodedb-sql/src/ddl_ast/graph_parse/entry.rs index 1976639ac..06dd5a4ec 100644 --- a/nodedb-sql/src/ddl_ast/graph_parse/entry.rs +++ b/nodedb-sql/src/ddl_ast/graph_parse/entry.rs @@ -30,7 +30,11 @@ pub fn try_parse(sql: &str) -> Option> { let toks = tokenizer::tokenize(trimmed); - let parsed = if upper.starts_with("GRAPH INSERT EDGE ") { + let parsed = if upper.starts_with("GRAPH INSERT EDGES ") { + variants::parse_insert_edges(&toks) + } else if upper.starts_with("GRAPH DELETE EDGES ") { + variants::parse_delete_edges(&toks) + } else if upper.starts_with("GRAPH INSERT EDGE ") { variants::parse_insert_edge(&toks) } else if upper.starts_with("GRAPH DELETE EDGE ") { variants::parse_delete_edge(&toks) @@ -114,6 +118,79 @@ mod tests { /// A missing required clause is a malformed graph statement, not a /// non-graph one. `None` here would send it to the SQL parser, which /// reports only that `GRAPH` is not SQL. + #[test] + fn parse_graph_insert_edges_batch() { + let stmt = + parsed("GRAPH INSERT EDGES IN 'edges' VALUES ('a','b','CALLS'), ('c','d','IMPORTS')"); + match stmt { + NodedbStatement::Graph(GraphStmt::GraphInsertEdges { collection, edges }) => { + assert_eq!(collection, "edges"); + assert_eq!(edges.len(), 2); + assert_eq!(edges[0].src, "a"); + assert_eq!(edges[0].dst, "b"); + assert_eq!(edges[0].label, "CALLS"); + assert_eq!(edges[1].src, "c"); + assert_eq!(edges[1].label, "IMPORTS"); + } + other => panic!("expected GraphInsertEdges, got {other:?}"), + } + } + + #[test] + fn parse_graph_delete_edges_batch_accepts_bare_words() { + let stmt = parsed("GRAPH DELETE EDGES IN 'edges' VALUES (a, b, CALLS)"); + match stmt { + NodedbStatement::Graph(GraphStmt::GraphDeleteEdges { collection, edges }) => { + assert_eq!(collection, "edges"); + assert_eq!(edges.len(), 1); + assert_eq!(edges[0].src, "a"); + assert_eq!(edges[0].dst, "b"); + assert_eq!(edges[0].label, "CALLS"); + } + other => panic!("expected GraphDeleteEdges, got {other:?}"), + } + } + + #[test] + fn batch_edge_cap_is_enforced() { + let mut sql = String::from("GRAPH INSERT EDGES IN 'edges' VALUES "); + for i in 0..=variants::MAX_EDGES_PER_BATCH { + if i > 0 { + sql.push(','); + } + sql.push_str(&format!("('s{i}','d{i}','L')")); + } + let error = try_parse(&sql) + .expect("input is graph DSL") + .expect_err("a batch over the cap must not produce a statement"); + assert!( + error.to_string().contains("at most 1000 edges"), + "the error must name the cap: {error}" + ); + } + + #[test] + fn batch_edge_rejects_malformed_triples() { + let error = try_parse("GRAPH INSERT EDGES IN 'edges' VALUES ('a','b')") + .expect("input is graph DSL") + .expect_err("a partial triple must not produce a statement"); + assert!( + error.to_string().contains("triples"), + "the error must name the tuple shape: {error}" + ); + } + + #[test] + fn batch_edge_rejects_per_edge_properties() { + let error = try_parse("GRAPH INSERT EDGES IN 'edges' VALUES ('a','b','L') PROPERTIES '{}'") + .expect("input is graph DSL") + .expect_err("per-edge PROPERTIES is not supported in the batch form"); + assert!( + error.to_string().contains("PROPERTIES"), + "the error must name the rejected clause: {error}" + ); + } + #[test] fn parse_graph_insert_edge_missing_collection_names_the_clause() { let error = try_parse("GRAPH INSERT EDGE FROM 'a' TO 'b' TYPE 'l'") diff --git a/nodedb-sql/src/ddl_ast/graph_parse/variants.rs b/nodedb-sql/src/ddl_ast/graph_parse/variants.rs index 6939e724b..fa1910ac5 100644 --- a/nodedb-sql/src/ddl_ast/graph_parse/variants.rs +++ b/nodedb-sql/src/ddl_ast/graph_parse/variants.rs @@ -12,11 +12,12 @@ use super::{ super::statement::{GraphStmt, NodedbStatement}, fusion_params::{FusionParams, RAG_FUSION_KEYWORDS}, helpers::{ - direction_after, extract_properties, missing_clause, quoted_after, quoted_list_after, - usize_after, usize_after_checked, word_after, + direction_after, extract_properties, find_keyword, missing_clause, quoted_after, + quoted_list_after, usize_after, usize_after_checked, word_after, }, tokenizer::Tok, }; +use crate::ddl_ast::graph_types::GraphEdgeTuple; use crate::error::SqlError; pub(super) fn parse_insert_edge(toks: &[Tok<'_>]) -> Result { @@ -36,6 +37,87 @@ pub(super) fn parse_insert_edge(toks: &[Tok<'_>]) -> Result]) -> Result { + let (collection, edges) = parse_edge_batch(toks, "GRAPH INSERT EDGES")?; + Ok(NodedbStatement::Graph(GraphStmt::GraphInsertEdges { + collection, + edges, + })) +} + +pub(super) fn parse_delete_edges(toks: &[Tok<'_>]) -> Result { + let (collection, edges) = parse_edge_batch(toks, "GRAPH DELETE EDGES")?; + Ok(NodedbStatement::Graph(GraphStmt::GraphDeleteEdges { + collection, + edges, + })) +} + +/// Shared body of the batch parsers. +/// +/// Grammar: `IN '' VALUES ('','','