Skip to content

ADR-0013: Async node persistence, concurrency and transaction ownership

Date: 2026-10-11. Status: implemented for the node subset; local live evidence recorded below. Relationship persistence remains a separate gate.

Decision

Use EF Core 10.0.12 Database.SaveChangesAsync(IList<IUpdateEntry>, CancellationToken) as the write seam. EF selects entries, detects changes, supplies original/current values and accepts or discards generated values. GraphBridge neither duplicates tracking nor snapshots entities itself.

GraphNodeMutationPlanner derives commands from finalized model bindings. Added nodes use CREATE, modified nodes update only modified properties (plus the provider version), and deleted nodes use restrictive DELETE. There is no runtime DDL, implicit upsert or DETACH DELETE. Every label written must already have a uniqueness constraint covering its complete application key. Writes check actual schema inside their transaction before mutations.

Updates/deletes acquire the node write lock by assigning a stable key property to itself, then execute the original-token predicate in a subsequent statement in the same transaction. This ordering addresses read-before-lock races. The lock and mutation must each match exactly one node. Missing or conflicting matches become DbUpdateConcurrencyException; server mutation failures become DbUpdateException with relevant EF entries. User-supplied concurrency tokens compare their original values, including null. An unconverted non-nullable long configured with IsRowVersion() starts at one and increments with checked arithmetic.

Neo4jTransactionManager is scoped and owns at most one explicit driver transaction and session. Queries and saves borrow that same transaction. Without an explicit transaction, each nonempty save begins and commits one transaction. A failed explicit save aborts the entire transaction because savepoints are unsupported. No automatic retries are introduced: a failed commit acknowledgement may leave an unknown database outcome.

The application owns the configured IDriver; GraphBridge owns only sessions/transactions it opens. Cleanup attempts rollback when appropriate and always attempts both transaction and session disposal, preserving the primary failure. Acknowledged commits remain marked committed even if later cleanup fails. Seven client fault-injection tests verify those branches, including a reproduced and fixed bug where failed cleanup replaced cancellation after transaction begin. These tests do not prove actual server outcomes after a lost commit acknowledgement.

EF state and cancellation

Owned saves publish generated values only after successful commit/finalization. Saves within an explicit transaction publish values after a successful batch; EF's ordinary acceptAllChangesOnSuccess behavior still applies. Rolling back the surrounding transaction does not rewind an already accepted EF graph. Use a fresh context or deliberately manage SaveChangesAsync(false) and acceptance; GraphBridge adds no second rollback tracker. Do not use EF ReloadAsync or GetDatabaseValuesAsync as a recovery path yet: the current translator rejects their required projection before transport, as two public-API boundary tests confirm.

Driver 6.3.0 transaction begin/commit/rollback methods used here expose no cancellation-token overload. Check cancellation before and after awaited work, await cleanup, and do not abandon an in-flight commit using a raced cancellation task. This does not promise immediate cancellation of server work.

Only async query, save and active transaction operations are supported. Synchronous save/transaction calls fail clearly. Use await using for contexts and transactions with active resources. Ambient/distributed transactions, nested transactions and savepoints are explicitly rejected.

Model restrictions

  • Stable application keys are mandatory. EF client-generated Guid keys work; application integer keys require ValueGeneratedNever() where EF conventions would otherwise generate temporary values.
  • Arbitrary store-generated values and converted/non-long provider versions are rejected. A provider-generated version cannot be part of the application key.
  • Non-generated properties configured with PropertySaveBehavior.Ignore are rejected during model validation. Ignoring such metadata while writing values would violate EF's configured contract. Provider-generated versions retain their special save behavior.
  • Direct node foreign-key storage, owned/complex values and inheritance remain unsupported. GB-009 observed join entries using test-only service replacements. The subsequent GB-010 metadata increment accepts explicit association mappings and their navigation metadata, while edge execution still rejects; see ADR-0015 for that separate boundary.

Evidence and limits

The central live node persistence suite passed 13 cases: inserts and generated values, selective updates/null removal, restrictive deletes, missing/duplicate keys, failed-batch atomicity, two-context version conflict, one simultaneous two-writer race, caller tokens, explicit transaction reuse/commit/rollback, failed explicit save, cancellation before execution, and unsupported transaction/schema paths. Ten mutation unit cases and five independent review regressions are included in the latest 50 passing unit tests. The Blogging executable also completed its node CRUD and rollback workflow.

The subsequently authored sample acceptance test and combined M2 regression remain pending. One successful race test does not establish broad contention behavior. Server/network failures during commit and in-flight cancellation remain unproven by live testing. Hosted CI and broad EF specification conformance remain unexecuted. See test evidence (maintainer-local record) for actual commands and historical failures.

Follow-up verification on 2026-10-11 completed the sample acceptance and combined local regression: all 56 live tests pass on the bounded fixture, including all 13 node persistence/transaction cases and the Blogging workflow. The initial M2 node subset is locally verified. Broader upstream conformance remains the M6 deliverable; the live network/commit uncertainty and cancellation limitations above remain unchanged.

Source trail

A later seven-case client protocol suite now verifies the state/exception rules through public SaveChangesAsync and the concrete production database service, rather than only the transaction manager. Failed commands, schema checks, count checks, commit responses and cleanup leave EF entries pending and generated versions unpublished; a successful control publishes versions and accepts state only after commit returns. The initial failures came from an incomplete scripted cursor and were corrected in the test helper. No new production defect was found. These checks remain distinct from live graph/server evidence.

  • Pinned EF source: v10.0.12, commit 001bfac21c1910cdf1110ca256c9ea0a06f374ca; Storage/Database.cs, Update/IUpdateEntry.cs, Storage/IDbContextTransactionManager.cs, Storage/IDbContextTransaction.cs.
  • ChangeTracking/Internal/StateManager.cs, GetEntriesToSave, SaveChangesAsync, AcceptAllChanges and failure DiscardStoreGeneratedValues: inspected as EF behavior; production GraphBridge does not invoke these internal methods directly.
  • Neo4j.Driver 6.3.0 package source commit f8a4020c589489a92c969856d6a624619080d276; restored binary/XML signatures used for compilation.
  • Neo4j concurrent data access informed lock ordering; chosen server behavior was exercised by the live two-writer case.