Skip to content

ADR 0050: Query tags, timeouts and write counters

Date: 2026-10-22. Status: Accepted on 2026-10-23, after the verification below passed (evidence). Adds operational controls that Neo4jClient users rely on (WithIdentifier, MaxExecutionTime, WithQueryStats), in EF form.

Context

Operators need to find a statement in the server's view, bound how long it may run, and know what a write changed. Neo4jClient offers each of these on its fluent builder. GraphBridge, as checked on 22 October 2026:

  • Tags: EF Core's TagWith and TagWithCallSite are accepted on GraphBridge LINQ queries and then silently ignored. EF 10.0.13's QueryableMethodNormalizingExpressionVisitor adds them to QueryCompilationContext.Tags, which the provider never reads.
  • Timeouts: none for queries or saves. Only migrations set a driver transaction timeout (GraphMigrationOptions.TransactionTimeout, 1 ms to 24 h) and transaction metadata.
  • Counters: every command runs through GraphBridgeDiagnostics.RunAsync, which wraps the cursor in TracedResultCursor, and ConsumeAsync returns the driver's IResultSummary. Its Counters are read only by the EXPLAIN check.

Decision

1. Tags become Cypher comments

  • LINQ: each tag in QueryCompilationContext.Tags is written before the generated Cypher, one // comment line per line of the tag, followed by an empty line. This matches relational EF, whose QuerySqlGenerator.GenerateTagsHeaderComment writes -- per line. ToQueryString() shows the comments. Queries, Include queries, ExecuteUpdate and ExecuteDelete are covered, as in relational EF.
  • Line breaks: the tag is split on every line terminator Cypher or .NET recognizes (\r\n, \r, \n, U+0085, U+2028, U+2029), and each part gets its own //. A tag can therefore never end the comment and inject Cypher.
  • Fluent Cypher: CypherResultQuery<T>.TagWith(string tag) with the same rendering. Several calls keep their order.
  • Native Cypher: callers write their own comments; no API.
  • Where it shows: in ToQueryString, GraphBridge's command logging, and the server's SHOW TRANSACTIONS currentQuery while the statement runs. That last point is verified live before acceptance. If Neo4j drops comments there, this ADR adds the tags as transaction metadata for the transactions GraphBridge owns, before acceptance.
  • Tags are constant text. A tag changes the query text, so each distinct tag gets its own server plan-cache entry and its own statement fingerprint. Documented rule: a tag must never contain user data or per-call values. TagWithCallSite is constant per call site.

2. Command timeouts

  • Context default: a new overload, UseNeo4j(driver, databaseName, neo4j => neo4j.CommandTimeout(TimeSpan.FromSeconds(30))), with Neo4jDbContextOptionsBuilder, following EF's UseSqlServer(…, sql => sql.CommandTimeout(…)). The existing UseNeo4j(driver, databaseName) is unchanged, so this is binary compatible.
  • Per context instance: db.Database.SetGraphCommandTimeout(TimeSpan?) and GetGraphCommandTimeout(). Relational EF's SetCommandTimeout name is not reused, so an application that also references a relational provider sees no ambiguity.
  • Per Fluent query: CypherResultQuery<T>.WithTimeout(TimeSpan).
  • Per LINQ query: not in this slice. A queryable operator would need its own EF query-cache design; set the context timeout around the query instead.
  • Meaning: Neo4j applies timeouts to transactions.
  • For work GraphBridge owns (a LINQ query's auto-commit session, the owned transaction of SaveChanges, bulk mutations and native Cypher), the timeout in effect is that transaction's timeout, passed as the driver's TransactionConfig timeout.
  • Inside a transaction the application began with Database.BeginTransactionAsync, the timeout in effect when it began covers the whole transaction. A Fluent WithTimeout inside it throws InvalidOperationException, rather than being silently ignored.
  • Values: positive, at most 24 hours, validated like migrations. null means the server's default. The server's own maximum still applies.
  • Errors: no new exception type. The driver's Neo4jException, with code Neo.ClientError.Transaction.TransactionTimedOutClientConfiguration, propagates as other driver errors do; inside SaveChanges, EF wraps it in DbUpdateException. The failing activity gets error.type = "timeout". The docs name the code to catch.

3. Write counters

  • Type: a public record GraphWriteCounters with NodesCreated, NodesDeleted, RelationshipsCreated, RelationshipsDeleted, PropertiesSet, LabelsAdded and LabelsRemoved, read from IResultSummary.Counters.
  • Tracing: when a statement's cursor is consumed and the summary reports updates, the GraphBridge.Statement activity gets graphbridge.counters.nodes_created and the other six as tags. The enclosing operation activity (GraphBridge.SaveChanges, GraphBridge.Bulk, GraphBridge.NativeQuery) gets the sums. The graphbridge. prefix follows OpenTelemetry's rule for attributes outside the semantic conventions, which define no graph counters.
  • Native writes: a new Database.ExecuteCypherAsync(text, parameters, cancellationToken) returns GraphWriteCounters for statements run for their effect, like Neo4jClient's ExecuteWithoutResults with WithQueryStats. It uses the same transaction ownership and diagnostics as QueryCypherAsync.
  • No extra round trips: counters come only from statement summaries, which the server sends with the last record. SaveChanges and bulk mutations read every record and then consume the cursor, which returns that summary without another request; the first live run showed they had never consumed it, so their spans carried no counters. A bulk operation consumes only its mutation statement, not its locking statements, so a lock's no-op SET n.key = n.key is never counted as a property set. EF's ExecuteUpdateAsync and ExecuteDeleteAsync keep returning the affected row count, and their counters are on the activity. PL-37's typed upserts report created and updated counts from these counters.

Not adopted

  • Neo4jClient's WithIdentifier as transaction metadata only. Comments are visible in every place a statement's text appears; metadata is the fallback named above.
  • A per-LINQ-query timeout operator in this slice.
  • A GraphBridge timeout exception type. It would hide the server's code, and EF already wraps save failures.
  • Counters on read queries. Reads report no updates; the existing statement fingerprint and parameter counts are enough.

Verification

  • Unit: tag rendering for one and several tags, multi-line tags with every line terminator, a tag that tries to inject Cypher, TagWithCallSite, Fluent TagWith, and tags on bulk mutations. Timeout validation and propagation into the captured driver TransactionConfig for each owned path, refusal of Fluent WithTimeout inside an application transaction, and the error.type classification. Counter mapping from a fake summary, statement and operation activity tags, and ExecuteCypherAsync results.
  • Live, Neo4j 2026.09:
  • while a slow tagged statement runs, SHOW TRANSACTIONS from a second session shows the comment in currentQuery;
  • a slow statement with a timeout fails with the documented code (verified with a 3-second context timeout through QueryCypherAsync; LINQ, Fluent and native paths run with a configured timeout, and a Fluent WithTimeout inside an application transaction is refused);
  • counters from ExecuteCypherAsync, SaveChanges and a bulk update equal an independent readback of what changed.

Result, 2026-10-23: 844 provider units and the three live ProviderOperationalControlsTests pass, with the whole Integration (305), Specification (81), Tooling (23) and Workshop (9) suites. SHOW TRANSACTIONS showed the tag comment in currentQuery, so transaction metadata was not needed. The first live run found that SaveChanges and bulk mutations never consumed their summaries, so their spans had no counters; they now consume after the last record, and bulk operations count only their mutation statement.

Consequences

  • Find: applications can find GraphBridge statements on the server by tag, as relational EF users already do with SQL tags.
  • Bound: long-running work can be bounded without native Cypher.
  • Measure: writes become measurable, and PL-37 gets its counts.
  • Cost: a tagged query is a different statement text, so it takes its own plan-cache entry.