# Batched Graph Mutations
This page is a proposed 1.1 design, not part of the frozen 1.0 SQL or GQL
contract. Function names and signatures remain subject to contract review until
implementation evidence is generated.
## Problem
The 1.0 GQL write profile accepts one parameter object and mutates one mapped
node or relationship per call. Batch-oriented ingestion systems already collect
many nodes and relationships before writing them. Calling `graph.gql()` once per
item creates application-to-PostgreSQL round trips and repeated parse, bind, ACL,
and SPI work.
The immutable base CSR is not the problem. In persisted `mutable_overlay` mode,
committed source-table changes already become durable projection segments after
sync without a full graph rebuild. The missing capability is a bounded public
write surface that turns one static graph mutation shape plus many parameter
objects into set-based PostgreSQL DML.
## Decision Summary
The proposed 1.1 direction is:
- add a bounded `gql_batch` write function in the `graph` schema;
- accept one static mapped GQL write and a JSONB array of parameter objects;
- parse and bind the graph statement once;
- lower the parameter array to a typed input relation that preserves input
order;
- issue set-based PostgreSQL DML per mapped table and operation phase, rather
than one SPI statement per input row;
- preserve PostgreSQL constraints, triggers, ACLs, RLS, MVCC, partition routing,
and source-row identity;
- preflight graph resource and transaction-delta capacity before authoritative
DML;
- apply the resulting projection changes incrementally, without rebuilding the
base graph after each batch.
This belongs in the existing query, SQL facade, projection-delta, and sync
modules. It does not justify a new crate, background worker, or second durable
write store.
## Current 1.0 Path
Applications that need bulk ingestion today should write the registered
PostgreSQL source tables with ordinary multi-row `INSERT`, `INSERT ... SELECT`,
`UPDATE ... FROM`, or `DELETE ... USING` statements. Trigger sync records those
source changes, and `graph.apply_sync()` publishes them to the active
projection. This path is efficient and remains supported after a batch graph
API is added.
The proposed API adds graph mapping ergonomics for adapters that should not need
to know each registered table and column layout. It does not replace direct
PostgreSQL DML.
## Proposed SQL Contract
The contract-review target is:
```sql
"graph"."gql_batch"(
query text,
param_sets jsonb,
hydrate boolean DEFAULT true
)
RETURNS TABLE (
input_ordinal bigint,
row jsonb
)
```
Example:
```sql
SELECT input_ordinal, row
FROM "graph"."gql_batch"(
query := '
MERGE (n:entities {id: $id, name: $name})
ON CREATE SET n.status = $status
ON MATCH SET n.name = $name
RETURN n.id AS id
',
param_sets := '[
{"id":"n-1","name":"Ada","status":"new"},
{"id":"n-2","name":"Grace","status":"new"}
]'::jsonb,
hydrate := false
);
```
The initial contract is deliberately narrower than general `UNWIND`:
- `query` must bind to one supported mapped write shape;
- every batch uses one static node label or relationship type;
- adapters group heterogeneous inputs by static query and mapping;
- `param_sets` must be a JSON array of objects;
- every referenced parameter must be present and type-compatible in every
object;
- output order follows the input ordinality;
- an empty array returns no rows;
- duplicate effective source identities in one batch are rejected before DML;
- any row failure aborts the whole SQL statement and produces no partial graph
delta.
`cypher_batch` in the `graph` schema and general-purpose `UNWIND` are not
required for the first slice. They should be added only if compatibility demand
is measured and they can lower to the same batch plan without creating a second
executor.
## Execution Shape
- require a JSONB array of parameter objects
- enforce input row and byte limits
- reject missing parameters and duplicate effective identities
- parse one GQL statement
- bind one static mapping
- check operation-specific ACL requirements
- preserve JSON array ordinality
- cast values using registered PostgreSQL column types
- resolve tenant and source identities before mutation
- lock affected source rows in stable table-and-primary-key order
- use INSERT SELECT, UPDATE FROM, DELETE USING, and ON CONFLICT
- let PostgreSQL enforce RLS, constraints, triggers, and partition routing
- re-read authoritative trigger-adjusted result rows
- record transaction-local projection changes in input order
- let durable trigger sync publish committed changes to other backends
The number of authoritative DML statements must grow with mapping groups and
required operation phases, not with input row count. Moving the tight loop from
an application client into a Rust loop that still runs one SPI statement per row
does not satisfy this design.
## Write Semantics By Slice
| Slice | Set-based PostgreSQL shape | Required graph behavior |
|---|---|---|
| Node `CREATE` | `INSERT ... SELECT` from the typed input relation | Preflight the maximum added-node delta, preserve ordinality, and return authoritative inserted rows |
| Node `MERGE` | `INSERT ... ON CONFLICT ... DO UPDATE` using registered identity | Reject duplicate batch identities, distinguish inserted and matched rows internally, and preserve `ON CREATE`/`ON MATCH` semantics |
| Relationship `CREATE` and `MERGE` | Resolve and lock endpoints set-wise, then use `INSERT ... SELECT` with registered relationship identity conflict handling | Preserve relationship source identity, dynamic-label checks, bidirectional delta accounting, same-transaction visibility, and retry-safe upsert semantics |
| Node `SET` and `REMOVE` | `UPDATE ... FROM` the typed input relation | Recheck predicates at the write boundary and refresh registered filter values from authoritative rows |
| Relationship `DELETE` | `DELETE ... USING` resolved relationship identities | Delete only registered source rows, preserving parallel relationships with equal endpoints and type |
| Node `DETACH DELETE` | Set-based incident-edge deletes followed by node delete | Use stable lock/delete ordering and abort the complete statement if any mapped incident relationship is unsafe |
Node `CREATE`, node `MERGE`, relationship `CREATE`, and relationship `MERGE` are
the first delivery slice because they remove the ingestion bottleneck.
Relationship `MERGE` is a new mapped-write vertical slice: it must use a
registered relationship primary or unique identity and must not infer identity
from endpoints alone. Update and delete shapes must reuse the same typed batch
relation and resource contract rather than introducing operation-specific batch
APIs.
## Transaction And Concurrency Contract
- A batch call is one atomic PostgreSQL statement. Callers may combine node and
relationship batches in one explicit transaction.
- Node batches run before dependent relationship batches when both are present
in an ingestion transaction.
- Savepoint rollback removes every transaction-local delta created after that
savepoint.
- Capacity for the worst-case node and edge delta is checked before source DML.
- Existing registered unique constraints arbitrate concurrent `MERGE` and
`CREATE` races.
- A stable `(table_oid, primary_key)` lock order prevents input ordering from
creating avoidable deadlocks.
- Trigger-modified rows are re-read before output and delta publication.
- A constraint, RLS, trigger, timeout, cancellation, or resource failure aborts
the complete batch.
There is no `continue_on_error` mode in the initial contract. Per-row partial
success would complicate constraint, trigger, savepoint, and projection-delta
semantics and would make retries harder to reason about.
`MERGE` is the retry-safe ingestion operation. Replaying the same node or
relationship `MERGE` batch must converge through its registered source identity.
`CREATE` retains ordinary PostgreSQL insert semantics and may raise a unique
violation when replayed. pgGraph does not silently retry a batch after an
ambiguous client disconnect; adapters that need safe replay use `MERGE` and keep
their stable source identities.
## Resource Contract
The implementation must add a dedicated input-row limit for batch writes. The
name, default, and range are frozen with the SQL contract, after benchmark
evidence establishes a safe default. The effective limit is also bounded by:
- `graph.max_tx_delta_nodes`;
- `graph.max_tx_delta_edges`;
- query memory and elapsed-time budgets;
- PostgreSQL `statement_timeout`;
- JSONB input and returned-row memory accounting.
Large batches fail before authoritative DML when their worst-case graph delta
cannot fit. The executor must check cancellation and elapsed-time budgets while
validating input, resolving identities, producing output, and recording deltas.
## Security Contract
Batch execution is security-invoker behavior. It must:
- perform the same table privilege checks as the corresponding single-row GQL
write;
- execute generated DML as the calling role;
- allow PostgreSQL RLS, column types, constraints, triggers, and partition
routing to decide each source-row write;
- use only catalog-resolved table and column identifiers in generated SQL;
- bind values as data rather than interpolating JSON text into SQL;
- retain tenant enforcement and write-boundary predicate rechecks.
Batching must never become a path around the 1.0 source-of-truth or security
contracts.
## Test-Driven Delivery
Implementation starts with failing tests in these vertical slices:
1. Contract and validation tests for arrays, empty input, ordinality, parameter
shape, type conversion, duplicate identities, and input limits.
2. Parser/binder/lowering tests proving one static plan is reused for all
parameter objects.
3. PostgreSQL integration tests for node `CREATE` and `MERGE`, including
defaults, generated columns, partitions, constraints, user triggers, RLS,
tenant scope, rollback, and idempotent replay.
4. Relationship `CREATE` and `MERGE` integration tests for endpoint resolution,
registered relationship identity, replay, parallel edges, dynamic labels,
composite keys, bidirectional mappings, and same-transaction node-to-edge
ingestion.
5. Update and delete integration tests for write-boundary rechecks, trigger
changes, detach ordering, savepoints, and all-or-nothing failure.
6. Cross-backend sync, durable segment, reload, compaction, and crash-recovery
tests proving no full rebuild is required after each batch.
7. Concurrent overlapping `MERGE`, create/delete races, cancellation, and
resource-exhaustion tests.
8. PostgreSQL 14 through 18 release-matrix and packaging evidence.
Parser and JSON parameter handling remain fuzz targets. Batch identity grouping
and ordinal/result reconstruction should receive property tests for
order-independence of source effects and order-preservation of returned rows.
## Performance Acceptance
The 1.1 release evidence must include:
- an ingestion fixture with thousands of nodes and relationships;
- the existing repeated `graph.gql()` path as the comparison baseline;
- batch size, mapping-group count, source DML statement count, throughput,
latency, peak RSS/PSS, WAL volume, sync lag, and compaction state;
- proof that source DML statement count is proportional to mapping groups and
operation phases rather than rows;
- a reviewed regression threshold recorded before release-candidate testing.
The feature is not complete merely because it uses one client call. It is
complete when the authoritative PostgreSQL work is set-based and the benchmark
shows the expected reduction in repeated planning, SPI, and round-trip cost.
## Scope Boundary
This design batches mapped graph mutations. It does not implement or maintain an
embedding/vector index. A system that writes both graph data and embeddings must
continue to use its configured vector backend for vector updates. pgGraph owns
only the PostgreSQL-derived graph projection and its incremental sync lifecycle.
For vector and embedding indexing, search, and hybrid retrieval inside
PostgreSQL, see Evokoa's
[pgContext](https://github.com/evokoa/pgcontext) project.