Repository navigation
Conversation
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.
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.
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.
Contributor
Author
|
Two commits added: the batch resolve step no longer sizes a buffer from the edge count (CodeQL alerts at |
…ses mid-propose A statement whose deadline ends during a metadata propose leaves two leaks. The proposal is in the log and applies, but the error returns before anything cleans it up. - The preparation lease acquire proposes the token, then waits for the apply. On a timeout no lease object exists, so nothing releases the token. The next DDL waits 60 s for the leader to reclaim it. Send the token to the background releaser. A release for a token that never became owner does nothing. - The drain start proposes the drain, then waits for the apply. On a timeout the drain stays open, and a drain has no expiry. End it through the same helper the drain wait uses. Each has a test that expires the deadline 20 times and fails on the old code.
deadline_never_leaves_a_partial_batch seeded one single-edge insert per budget so the bounded statement never flagged the collection. That statement could leave the metadata propose path blocked for 60 s. The leak is fixed, so the test runs the bounded statement directly.
…ne transaction BatchEdge gains a write_gate slot, the same RlsWriteCheck type that GraphOp::EdgeDelete carries as rls_write_check. The handler fills it per edge from resolve_edge_write_rls. The per-edge fallback for governed deletes is removed: every delete batch rides one Calvin transaction (or one apply burst per source home when standalone). Behaviour change: a delete batch with any denied edge is refused as a whole and deletes nothing. Before, the allowed edges went and only the denied edge stayed, with an error. The statement is now atomic, like the insert batch and like a batch with a refused member. Mechanism: - Governed edges are decided before any write. Each source home runs one read-only ResolveEdgeDelete over an EdgeDeleteBatch. A live predicate cannot be sequenced or replicated, and a destination home holds no forward row to judge. - Admitted edges carry decided_earlier_in_request. The Raft/Calvin wire form drops the slot and decode rebuilds it as already_decided_elsewhere. - execute_edge_delete_batch also checks every gate before its first tombstone, so a predicate that reaches it directly refuses the whole batch with nothing written. A gate still at PendingInjection denies. - rls_write_checks now lists the gate of every batch edge, so the replication guard refuses a batch that still holds a live predicate. Tests: batch_delete_evaluates_the_write_policy_per_edge is replaced by batch_delete_with_a_denied_edge_deletes_nothing (2 allowed + 1 denied: all three edges survive, the error names the policy) and batch_delete_of_only_allowed_edges_deletes_all. Data Plane unit tests cover the denied, all-admitted and uninjected cases.
Two debug asserts check one surrogate pair per edge, so a zip cannot drop an edge. Two parser tests pin what the flat field list accepts. The insert path says why it accepts an empty response. A doc comment typo is fixed.
Clippy 1.99 refuses chunks_exact with a constant size.
The tokenizer drops parentheses and commas, so a missing comma between triples and three bare words parsed as valid batches. The batch parser now checks the statement text for comma-separated parenthesized triples.
…tatement The insert and delete builders share one function with the same count and text bounds. The method is inherent on the native and remote clients, because the NodeDb trait file is at its line limit.
…nknown A batch is one sequenced transaction. A deadline ends the wait, not the transaction, so the error now says every edge landed or none did and a retry is safe.
…utcome is unknown
…shard fallback batch_home reads the home of the first edge and refuses an empty batch with XX000, so a batch task never falls back to shard 0. The batch parser names its three command words.
EnRaiha
force-pushed
the
pr/369-batch-edge-writes
branch
from
October 23, 2026 04:57
cc20bc7 to
ce33f11
Compare
A self-loop is one edge and sits in both the outbound and the inbound list of its node. DIRECTION both read both lists and returned the edge twice. The inbound pass now skips an edge whose neighbor is the node itself when the outbound pass already ran. The durable lookup, the collection-scoped lookup and the transaction overlay merge share the rule.
The batch delete with a denied edge, the batch insert that needs a property and the single-edge delete under the same policy all assert the SQLSTATE, not only the message.
Insert and delete round trip, a batch inside a transaction that reads its own writes and rolls back, and the bounds refused before the round trip. The wire test target needs nodedb-client with the native feature.
The graph tokenizer dropped commas, so LABEL 'a' 'b' parsed as two labels on GRAPH TRAVERSE, NEIGHBORS and PATH, and AS 'x' 'y' on GRAPH LABEL. The tokenizer now emits a comma token. A label list takes exactly one comma between items. A comma outside a list is skipped as before.
reserve_buffered starts a drain for each altered descriptor. When a later step failed, the error arm only released the preparation lease, so the drains stayed open until a restart. The error arm now ends every drain the reserve started, before it releases the lease.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds
GRAPH INSERT EDGESandGRAPH 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"). Closes #436: the PR also fixes theGRAPH NEIGHBORSself-loop double entry that the batch tests exposed.Changes
feat(graph)edge_batch.rs,edge_batch_resolve.rs). The handler builds one task with every edge and submits it withbuild_single_vshard_tx_class. Surrogates for all endpoints come from oneassign_surrogates_routedcall.feat(client)batch_graph_insert_edgesbuilds oneGRAPH INSERT EDGESstatement and checks the bounds before the round trip.test(graph)fix(graph)54000) before it sizes any buffer. CodeQL flagged bothVec::with_capacity(edges.len())calls.refactor(graph)BoundedEdges::newconstructor checks collection, count, endpoints, label and duplicates.fix(metadata)graph:write gatedocs(graph)fix(graph)parserVALUEStext for comma-separated parenthesized triples. A missing comma and three bare words are parse errors. New filebatch_shape.rs, the tokenizer is untouched.feat(client)deletebatch_graph_delete_edgeson the native and remote clients sends oneGRAPH DELETE EDGESstatement. It shares the builder and the bounds with the insert.fix(graph)deadlinefix(graph)neighborsGRAPH NEIGHBORS ... DIRECTION bothlists a self-loop once.Bothread the outbound list and then the inbound list, and a self-loop sits in both. The durable lookup, the collection-scoped lookup and the transaction overlay merge skip the repeat. Two distinct edges between one pair stay two entries.refactor(graph)batch_homereads the home of the first edge and refuses an empty batch withXX000, so a batch task never falls back to shard 0. The batch parser names its three command words.The submit path already slices a batch per home pair and sums the owned-edge counts (
slice_edge_batches,ReplyFold::SumOwnedEdges), andwrite_keys/graph.rsalready locks the keys ofEdgePutBatchandEdgeDeleteBatch. The handler only has to hand it one task.Semantics
BatchEdge::write_gatecarries each edge's verdict. Each source home decides its governed edges in one read-only pass before any write. One denied edge refuses the whole statement and nothing is deleted. The rest of the batch rides the same single transaction.batch outcome unknown, every edge landed or none did, retry is safe. A deadline before the submit writes nothing and keeps the plainexceeded deadlinetext. Withstatement_timeoutof 30 ms and 120 ms the client gotexceeded deadlineand all 240 edges had landed. At 1 us and 5 ms none had. No partial result was seen.MAX_EDGES_PER_BATCH_STATEMENT(1000) andMAX_BATCH_TEXT_BYTES(1 MiB). The parser and the client share them.Deadline cleanup
A statement whose deadline expires inside
mark_collection_edge_bearingexposed two leaks on the metadata path. A proposal reaches the log and applies after the deadline has failed the call, and nothing cleaned up what it started.acquire_ddl_prepare_lease_asyncmetadata propose: request req:0 exceeded deadline).start_draindrain_startends the drain (end_failed_drain).Tests
an_acquire_that_misses_its_deadline_does_not_keep_the_leaseanda_drain_start_that_misses_its_deadline_does_not_stay_openfail on the old code and pass now.deadline_never_leaves_a_partial_batchno longer seeds an edge to dodge the leak.Measurements
Single node, release build, 2 data-plane cores, trust mode, psycopg2 autocommit.
GRAPH INSERT EDGEGRAPH INSERT EDGESOn 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
Head
1f7461cb1, rustc 1.99.1,RUST_MIN_STACK=33554432.cargo fmt --all -- --checkcargo test -p nodedb-sql graphcargo test -p nodedb-client --all-features --lib graph_dslcargo test --profile ci -p nodedb --test wire -- graph_neighbors_entries graph_dsl_batch_edges graph_batch_rls graph_dsl_handlers --test-threads=2cargo test -p nodedb-graphboth_directions_list_a_self_loop_oncecargo test -p nodedb --lib -- edge_batch delete_batch bounded_edges metadata_proposer lease79fd37c95, not rerun after the refactorcargo clippy -p nodedb -p nodedb-graph --all-targets --all-features --profile ci -D warningscargo clippy -p nodedb-sql --all-targets --all-features --profile ci -D warningscc20bc7c7, the last head that touched itcargo clippy -p nodedb-client --all-targets --all-features --profile ci -D warnings47b4c7c93, the last head that touched the clientnodedb-preflight.sh, run by the pre-push hookRed proof: with the self-loop skip removed from the three read paths,
both_directions_list_a_self_loop_once(wire and CSR unit) anda_staged_self_loop_is_listed_once_inside_a_transactionfail. With the parser shape check removed,batch_edge_rejects_a_missing_comma_between_triplesandbatch_edge_rejects_bare_words_without_parenthesesfail. With the deadline text removed,deadline_never_leaves_a_partial_batchfails at the budgets that land every edge.Not run on this head: the full
nodedblib suite, and theTest Suitejob (skipped on a draft).Not in this PR
OpCodehas no graph write op, and theSqlopcode already carries the batch in one round trip. A new op touches the opcode table, the frames, the codec, the server dispatch and the client.batch_graph_delete_edgeson theNodeDbtrait.trait_def.rsis at the 500-line limit (preflight C5), so the method is inherent on the native and remote clients.