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
TagWithandTagWithCallSiteare accepted on GraphBridge LINQ queries and then silently ignored. EF 10.0.13'sQueryableMethodNormalizingExpressionVisitoradds them toQueryCompilationContext.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 inTracedResultCursor, andConsumeAsyncreturns the driver'sIResultSummary. ItsCountersare read only by the EXPLAIN check.
Decision¶
1. Tags become Cypher comments¶
- LINQ: each tag in
QueryCompilationContext.Tagsis written before the generated Cypher, one//comment line per line of the tag, followed by an empty line. This matches relational EF, whoseQuerySqlGenerator.GenerateTagsHeaderCommentwrites--per line.ToQueryString()shows the comments. Queries, Include queries,ExecuteUpdateandExecuteDeleteare 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'sSHOW TRANSACTIONScurrentQuerywhile 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.
TagWithCallSiteis constant per call site.
2. Command timeouts¶
- Context default: a new overload,
UseNeo4j(driver, databaseName, neo4j => neo4j.CommandTimeout(TimeSpan.FromSeconds(30))), withNeo4jDbContextOptionsBuilder, following EF'sUseSqlServer(…, sql => sql.CommandTimeout(…)). The existingUseNeo4j(driver, databaseName)is unchanged, so this is binary compatible. - Per context instance:
db.Database.SetGraphCommandTimeout(TimeSpan?)andGetGraphCommandTimeout(). Relational EF'sSetCommandTimeoutname 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
TransactionConfigtimeout. - Inside a transaction the application began with
Database.BeginTransactionAsync, the timeout in effect when it began covers the whole transaction. A FluentWithTimeoutinside it throwsInvalidOperationException, rather than being silently ignored. - Values: positive, at most 24 hours, validated like migrations.
nullmeans the server's default. The server's own maximum still applies. - Errors: no new exception type. The driver's
Neo4jException, with codeNeo.ClientError.Transaction.TransactionTimedOutClientConfiguration, propagates as other driver errors do; inside SaveChanges, EF wraps it inDbUpdateException. The failing activity getserror.type = "timeout". The docs name the code to catch.
3. Write counters¶
- Type: a public record
GraphWriteCounterswithNodesCreated,NodesDeleted,RelationshipsCreated,RelationshipsDeleted,PropertiesSet,LabelsAddedandLabelsRemoved, read fromIResultSummary.Counters. - Tracing: when a statement's cursor is consumed and the summary reports updates, the
GraphBridge.Statementactivity getsgraphbridge.counters.nodes_createdand the other six as tags. The enclosing operation activity (GraphBridge.SaveChanges,GraphBridge.Bulk,GraphBridge.NativeQuery) gets the sums. Thegraphbridge.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)returnsGraphWriteCountersfor statements run for their effect, like Neo4jClient'sExecuteWithoutResultswithWithQueryStats. It uses the same transaction ownership and diagnostics asQueryCypherAsync. - 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.keyis never counted as a property set. EF'sExecuteUpdateAsyncandExecuteDeleteAsynckeep 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
WithIdentifieras 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, FluentTagWith, and tags on bulk mutations. Timeout validation and propagation into the captured driverTransactionConfigfor each owned path, refusal of FluentWithTimeoutinside an application transaction, and theerror.typeclassification. Counter mapping from a fake summary, statement and operation activity tags, andExecuteCypherAsyncresults. - Live, Neo4j 2026.09:
- while a slow tagged statement runs,
SHOW TRANSACTIONSfrom a second session shows the comment incurrentQuery; - 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 FluentWithTimeoutinside 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.