Skip to content

feat(graph): batch edge inserts and deletes in one statement - #435

Draft
EnRaiha wants to merge 6 commits into
NodeDB-Lab:mainfrom
EnRaiha:pr/369-batch-edge-writes
Draft

EnRaiha wants to merge 6 commits into
NodeDB-Lab:mainfrom
EnRaiha:pr/369-batch-edge-writes

Conversation

@EnRaiha

@EnRaiha EnRaiha commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds GRAPH INSERT EDGES and GRAPH DELETE EDGES. One statement carries up to 1000 edges and runs as one sequenced Calvin transaction. Refs #369. It does not close #369 (see "Not in this PR").

GRAPH INSERT EDGES IN 'kg_code_edges' VALUES ('a','b','CONTAINS'), ('a','c','CONTAINS');
GRAPH DELETE EDGES IN 'kg_code_edges' VALUES ('a','b','CONTAINS'), ('a','c','CONTAINS');

Changes

Commit Change
feat(graph) Parser variants, protocol bounds, and the handler (edge_batch.rs, edge_batch_resolve.rs). The handler builds one task with every edge and submits it with build_single_vshard_tx_class. Surrogates for all endpoints come from one assign_surrogates_routed call.
feat(client) batch_graph_insert_edges builds one GRAPH INSERT EDGES statement and checks the bounds before the round trip.
test(graph) Wire tests for count, bounds, write policy, random ids, refused member, deadline and retry.
fix(graph) The handler repeats the 1000-edge cap (SQLSTATE 54000) before it sizes any buffer. CodeQL flagged both Vec::with_capacity(edges.len()) calls, because a hand-built AST skips the parser. Three unit tests cover the cap.

The submit path already slices a batch per home pair and sums the owned-edge counts (slice_edge_batches, ReplyFold::SumOwnedEdges), and write_keys/graph.rs already locks the keys of EdgePutBatch and EdgeDeleteBatch. The handler only has to hand it one task.

Semantics

  • One statement is one sequenced transaction. Every edge lands or none does.
  • A refused member (invalid label, RLS refusal) fails before any write.
  • A deadline that ends the wait returns an error, and the outcome is unknown but all-or-nothing. With statement_timeout of 30 ms and 120 ms the client got exceeded deadline and all 240 edges had landed. At 1 us and 5 ms none had. No partial result was seen.
  • A retry is safe. A put rewrites the same edge. A delete of an absent edge counts zero.
  • Bounds are the protocol constants MAX_EDGES_PER_BATCH_STATEMENT (1000) and MAX_BATCH_TEXT_BYTES (1 MiB). The parser and the client share them.
  • Write policy runs per edge through resolve_edge_write_rls. An insert batch has no properties, so a policy that needs a property refuses every edge. A delete under a write policy keeps the per-edge path: the allowed edge goes, the denied edge stays, and the statement returns an error.

Measurements

Single node, release build, 2 data-plane cores, trust mode, psycopg2 autocommit.

Case Single GRAPH INSERT EDGE One GRAPH INSERT EDGES
2000 edges, one endpoint pair 325.3 s (163 ms each) 0.83 s (2 statements of 1000)
100 random-id edges 8.0 s 0.37 s
1000 random-id edges about 80 s 0.85 s
1000 random-id edges, delete not measured 0.86 s, 0 left

On a production-size store (6.5 GB, 82k symbols) under host load, 1000 random-id edges took 15 to 18 s for insert and 14 to 16 s for delete. Inserts landed and deletes removed every sampled source. The same shape as single statements costs about 160 ms per edge.

Checks

  • cargo fmt --all -- --check: pass.
  • cargo check -p nodedb --tests --locked: pass.
  • cargo test --profile ci -p nodedb --test wire -- graph_dsl_batch_edges graph_batch_rls --test-threads=2: 20 pass.
  • cargo test -p nodedb-sql graph: 82 pass, with 7 new batch parser tests.
  • cargo test -p nodedb-client --all-features --lib graph_dsl: 34 pass, with 7 new insert_edges_sql_* tests.
  • cargo test -p nodedb --lib -- edge_batch::tests: 3 pass (the handler cap).
  • Only the final tree was built and tested. The three commits were not built one by one.
  • I did not run the nodedb library unit tests.

Not in this PR

  • The native protocol op. batch_graph_insert_edges on the native client sends the statement text over the existing Sql opcode. No OpCode or GraphOp request field exists for the batch, and the client has no batch_graph_delete_edges. The issue asks for it.
  • The UPSERT-batch RLS probe from test(graph): batch write paths need RLS and GRANT probes #373. This branch has no UPSERT batch path, so there is nothing to probe.
  • Atomic delete under a write policy. BatchEdge has no write-gate slot. Fixing it needs a policy slot there or a pre-check of all members.
  • The standalone route (no Calvin sequencer) applies one task per source home in turn, so it is not atomic across homes.
  • A timed-out batch reports request req:0 exceeded deadline and does not say the outcome is unknown.

Found while testing, not changed here

A graph-edge statement whose deadline expires inside mark_collection_edge_bearing blocks the metadata propose path. The next CREATE COLLECTION then fails after 60 s with metadata propose: request req:0 exceeded deadline. It reproduces on main with one GRAPH INSERT EDGE after SET statement_timeout='1us'.

A loader with N edges sent one GRAPH INSERT EDGE or GRAPH DELETE EDGE per
edge. Each cost a round trip and its own Calvin transaction.

GRAPH INSERT EDGES and GRAPH DELETE EDGES now carry the edges of one
statement in a VALUES list. The handler builds one task with every edge
and submits it as one sequenced Calvin transaction. The submit path
already slices a batch per home pair and sums the owned-edge counts
(edge_slices, ReplyFold::SumOwnedEdges). Every edge lands or none does.
A deadline that ends the wait leaves an unknown but all-or-nothing
outcome, and a retry is idempotent.

Surrogates for all endpoints of a statement are assigned with one
assign_surrogates_routed call.

Write policy is evaluated per edge with resolve_edge_write_rls. A delete
under a write policy keeps the per-edge path: the allowed edges apply,
the denied edge stays, and the statement returns an error. A batch inside
a transaction block validates the whole list before it stages any tuple.

The bounds are protocol constants, MAX_EDGES_PER_BATCH_STATEMENT and
MAX_BATCH_TEXT_BYTES, so the parser and the client agree: 1000 edges or
1 MiB of SQL text, whichever the statement crosses first. The error names
the limit.
`batch_graph_insert_edges` builds one `GRAPH INSERT EDGES` statement from
the edge list. It sends the statement over the existing `Sql` opcode and
checks the protocol bounds first. An oversized batch fails with the limit
named, before any round trip.

The trait default keeps the per-edge path for a client that cannot send
the batch statement.
Wire tests pin the affected count, the per-statement bounds, and the
delete count against the removed edges. They pin the per-edge write
policy: a delete batch with an allowed and a denied edge removes the
allowed edge and keeps the denied one, and an insert batch under a policy
that needs a property refuses every edge.

The random-id tests pin that 240 edges over many home pairs land
completely, that a refused member lands nothing, that a deadline leaves
either no edge or all of them, and that a retry after a delete batch is
idempotent.
@EnRaiha EnRaiha closed this Oct 21, 2026
The parser caps a batch at MAX_EDGES_PER_BATCH_STATEMENT, but the handler
sized its buffers from the edge count without repeating the cap. A
hand-built AST skips the parser, so the allocation size came from the
caller. CodeQL flagged both Vec::with_capacity calls in the resolve step.

validate_edge_batch now refuses a list over the cap with SQLSTATE 54000
and names the limit, before any buffer is sized. Unit tests cover the
cap, one edge over it, and the empty and collection-less shapes.
@EnRaiha EnRaiha reopened this Oct 21, 2026
CodeQL flags Vec::with_capacity(edges.len()) in the insert and delete
resolve functions as an allocation sized from user input. The parser and
the handler cap the list at MAX_EDGES_PER_BATCH_STATEMENT, but the
analysis does not follow a guard that lives in another function.

Build the three buffers without a size hint. The list holds at most 1000
entries, so growth costs nothing measurable and no allocation size comes
from the caller.

Also correct the delete_edges doc: on the Calvin route each home counts
the edges it owns and the answers add up.
BoundedEdges::new is the only way to make a checked edge batch.
It checks the collection, the count (1000, SQLSTATE 54000), the
endpoints and label, and removes duplicates. The resolve functions
take BoundedEdges, so no caller can size a buffer from an unchecked
count.
@EnRaiha

EnRaiha commented Oct 21, 2026

Copy link
Copy Markdown
Contributor Author

Two commits added: the batch resolve step no longer sizes a buffer from the edge count (CodeQL alerts at edge_batch_resolve.rs), and a single BoundedEdges constructor now checks every batch before any buffer exists. Wire tests: 20 pass. The pre-push clippy -D warnings step reports nonminimal_bool at vector_primary.rs:171 and calvin_reply/stage.rs:94. Both lines are on main and outside this diff, so I pushed with PREFLIGHT_SKIP=1 once.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

graph: bulk edge ingest has no batch form — every loader pays one statement per edge; add GRAPH INSERT/DELETE EDGES

2 participants