Analyzer rule
Spec: LC046 - Concurrent DbContext Operations
EF Core LINQ performance analyzer and Roslyn analyzer for catching query issues at compile time.
Spec: LC046 - Concurrent DbContext Operations
Goal
Detect overlapping Entity Framework Core operations that are proven to use the same DbContext instance.
The Problem
Entity Framework Core does not support multiple parallel operations on one context. Starting another query or save
before the previous task completes can throw InvalidOperationException; when the overlap escapes EF Core’s guard,
the context’s state is undefined.
Example Violation
var users = db.Users.ToListAsync(cancellationToken);
var roles = db.Roles.ToListAsync(cancellationToken); // LC046
await Task.WhenAll(users, roles);
The same risk appears when one operation is a query and the other is a save, bulk command, FindAsync, LoadAsync,
or relational raw command.
Safer Shapes
Await operations sequentially when they belong to one unit of work:
var users = await db.Users.ToListAsync(cancellationToken);
var roles = await db.Roles.ToListAsync(cancellationToken);
When the work is genuinely independent and parallelism is intentional, create a separate context for each operation:
var usersTask = LoadUsersAsync(factory, cancellationToken);
var rolesTask = LoadRolesAsync(factory, cancellationToken);
await Task.WhenAll(usersTask, rolesTask);
Each helper must create and dispose its own context.
Analyzer Logic
ID: LC046
Category: Safety
Severity: Warning
For direct overlap, LC046 reports the second proven overlapping EF Core invocation and points back to the first
operation as an additional location. It recognises async query terminals, including ContainsAsync, ElementAtAsync, and
ElementAtOrDefaultAsync, plus FindAsync, SaveChangesAsync, LoadAsync, ExecuteUpdateAsync,
ExecuteDeleteAsync, and relational ExecuteSql*Async commands.
For direct overlap, the analyzer also follows a source-visible local function when its body consists of one direct
return of a recognised EF Core async invocation. A parameterless helper may use a stable context captured from
outside the helper. A helper with one, two, or three parameters may use exactly one DbContext parameter as the returned
operation’s context when that context argument is evaluated first and resolves to a proven context origin; repeated
calls with the same origin report, while distinct or reassigned arguments stay quiet. Implicit optional defaults do not
participate in source evaluation order, and an explicit conversion in the context receiver stays outside the direct
proof because a downcast may throw. Any remaining parameter may be unused, but any use
must appear only in non-throwing direct arguments to the EF terminal. A helper with one, two, or three parameters may instead
return an EF task over a stable captured context under that same argument-use proof. Every parameterized helper form
also requires each explicit call-site and nested helper-body argument to be proven non-throwing, and a
nullable instance method-group receiver remains outside that proof. A
CancellationToken forwarded directly to the EF terminal must not be definitely cancelled; wrapped forwarding remains
outside the proof. Required arguments, including directly bound or captured stable locals, are revalidated at their source argument’s evaluation point; transformed required values remain outside the
proof, so argument evaluation, an invalid value, or cancellation
cannot prevent the later EF task from starting. The diagnostic is reported on the repeated helper call, with the earlier
call as an additional location. A returned operation that uses one of two DbContext parameters remains ambiguous and
quiet; a stable captured context still uses the captured-context proof even when helper parameters are context-typed.
Helper-local or reassigned captured contexts and potentially throwing argument evaluation,
helpers with four or more parameters, helper chains, and branch or multi-operation bodies
remain outside this deliberately narrow interprocedural proof.
The analyzer follows stable locals, parameters, readonly fields, source-visible auto-properties, DbSet members,
DbContext.Set<TEntity>(), and transparent LINQ or EF query chains. It also reports
Task.WhenAll(items.Select(...)) when the selector captures one outer context and the source can contain multiple
items. Instance context members are matched by both the member and its proven receiver, so the same member on two
different holder objects is not conflated.
A separate loop pass reports the loop-body invocation itself when a foreach iterates an inline array initializer with
at least two elements and the loop body’s only statement either discards the EF Core async invocation or passes it
directly to Add or Enqueue on a single-assignment local whose proven runtime construction is a framework List<T>,
HashSet<T>, or Queue<T>, including when the local is declared through ICollection<T> or IList<T> and the
constructed type is one of those three. The accumulator must be constructed with new or, when the
local’s target type is itself List<T>, HashSet<T>, or Queue<T>, a collection expression
before the loop, either at declaration or by one later simple assignment. The accepted accumulator construction is
an empty parameterless new or empty collection expression; an interface parameter, ConcurrentBag<T>, a
HashSet/Queue constructed from an existing collection, or other unproven collection
implementation remains outside the proof. The task-list branch additionally requires
a synchronous, non-deconstructing loop over a direct inline array with at least two compile-time-constant elements and
an identity iteration-variable conversion. It does not report that branch for an unknown, empty, or singleton source,
an asynchronous or deconstructing loop, a source whose setup can throw before repetition, a user-defined source or
iteration-variable conversion, a multi-statement or conditionally exited loop body, a context that can change between
iterations, an awaited result, a throwing or unstable list receiver, a potentially throwing explicit cast or
user-defined conversion around the task, an evaluated or non-empty accumulator construction, a task-producing call that may consume the accumulator directly, through one
or more alias assignments, or through an invoked captured local before starting the next operation, a potentially
throwing query-receiver evaluation, explicit argument conversion, expanded params element, or other invocation
argument that can prevent a later EF call from starting, an invalid query-construction argument including a null
required sequence, callable, or nullable instance method-group receiver, string, or formattable-string parameter, a required terminal callable
argument, a null or blank required raw-SQL argument, a null required FindAsync key array, an unguarded,
empty FindAsync key array or an array containing an unproven/null key, a null raw-SQL parameter collection, a possibly-empty raw-SQL interpolation,
a definitely-cancelled token, an unproven-non-blank DbContext.Set<TEntity>(name) argument, an unguarded,
null-suppressed, or nullable-oblivious context parameter, a nullable local query alias, a nullable or
constructor-invalidated stored query member, or a static member whose type initialization is not proven safe, loop source setup that
references the accumulator between body executions, any executable use or retained closure of the accumulator between
its construction and the loop’s Add receiver, or a custom
Add-shaped API. Safe explicit identity and reference upcasts around the task retain the diagnostic.
Null-conditional Add remains diagnostic when the same construction proof establishes that the local receiver cannot
be null. A local or anonymous function that captures the accumulator affects the proof only when its reachable direct
or delegate invocation can run before the loop or its binding escapes local control; a locally bound invocation that
occurs only after the loop does not suppress the diagnostic. A nullable context parameter retains the diagnostic only after
nullable flow analysis proves a preceding guard; null forgiveness alone is not treated as runtime proof, while redundant
suppression after a proven is null or built-in equality null-exit guard retains that proof only when no intervening
parameter write invalidates it. Overloaded equality is not accepted as null proof. The known
metadata-backed EF Core DbContext.Database property retains relational-command diagnostics without requiring source
declarations, while required terminal non-blank SQL, raw-SQL parameter collections, non-empty key arrays containing
only proven non-null keys, and cancellation tokens must
permit a task to start before the overlap is reported. CancellationToken.None is a readonly static of a
core-library struct, so reading it cannot throw and it never suppresses the diagnostic. A definitely-cancelled token
suppresses, as does any token expression whose evaluation cannot be proven non-throwing. Outside loops, an operation is quiet when a required argument is provably invalid — a literal null or blank
SQL string or set name, a null key array or parameter collection, or a definitely-cancelled token — because the
call then faults before starting any work and cannot overlap anything. The same applies to query construction
that provably faults on its own arguments. An argument the analyzer cannot evaluate, such as one supplied by a
parameter or field, does not suppress the diagnostic. Inside a loop the burden is the opposite and stricter:
validity must be positively proven, because the loop gate has to establish that the operation starts on every
iteration.
A named DbContext.Set<TEntity>(name) root must have a provably non-blank name whether or not it appears in a loop,
because EF Core rejects a null or whitespace name before any query is constructed. Proof covers constant strings, interpolated
strings with non-whitespace literal text, and single-assignment locals resolving to either; a name that cannot be
proven non-blank stays quiet, which is a deliberate conservative false negative.
To preserve precision, LC046 stays quiet for sequential awaits, separate contexts, branch-exclusive operations,
unproven reassigned or escaped task/context state, repository-produced IQueryable values, computed context or set properties,
custom lookalike APIs, query construction, AsAsyncEnumerable() alone, per-item context factories, and selector
fan-out over statically empty or singleton sources, including fixed-size arrays. LC036 continues to own Task.Run,
Parallel, Thread, thread-pool, and timer capture diagnostics.
An await or task escape suppresses the diagnostic only when it is guaranteed to execute before the later EF Core
operation. A conditional await or an exception path that can bypass an await still reports because another reaching
path can leave the first operation active, including when argument evaluation throws after the EF task starts but
before an immediate, task-local, or Task.WhenAll wrapper reaches the await. Explicit throws reach a handler only
when its type and filter permit it, and a compatible nested catch can intercept that transfer. An earlier
nonconstant filtered catch that can propagate the exception prevents a later catch from being treated as a definite
interceptor, and a nested catch or finally can replace the original transfer before an outer continuing catch.
Exact single-assignment replacement locals retain their constructed exception type, and an
always-throwing finally prevents the original exception from reaching a handler that cannot accept any of its
replacement exceptions. Each
independently drained and restarted overlap group receives its own diagnostic. Awaiting a stored Task.WhenAll, a
stored single-input Task.WhenAny, a direct one-element array-backed or span-backed collection expression whose
direct element is the tracked task, or a single-assignment Task[] whose only direct element is the tracked task in
an awaited Task.WhenAny ends a
proven task lifetime. The singleton-array combinator may be awaited directly, through an
exact framework task wrapper, or through its single-assignment stored result, and the array may be initialized at its
declaration with array-initializer or collection-expression syntax or assigned once later; other read-only WhenAll
or WhenAny uses of the same stable array do not invalidate a later proven completion. Array initializer and
collection-expression elements both participate in non-null WhenAll proof, and a known null collection-expression
element retains its exact ArgumentException route. Task.WhenAny can fail while allocating its result before its
await completes, including a direct span-backed collection expression or when that result is first stored in a local,
but that allocation path is considered only when the combinator invocation itself occurs inside the continuing
handler’s try. An array-backed
IEnumerable<Task> collection expression is likewise an OutOfMemoryException allocation path. An allocation
failure (OutOfMemoryException, or OverflowException from a runtime-sized or oversized constant dimension or a
potentially overflowing checked built-in conversion), an InvalidOperationException from a nullable length conversion, or a user-defined
conversion or operator that can reach a continuing handler without being intercepted by an
earlier compatible non-propagating or terminal nested catch, a user-defined task wrapper, or an exact
Task.FromResult-wrapped task used directly or through a local, multi-element, mutated including through non-lexical control flow,
aliased, captured, unawaited, or reassigned shape remains conservative and does not prove completion. Unknown task
consumers, including consumers reached through exact Task.FromResult, direct task-returning helpers in a task-array
element, and consumers inside unrelated array bounds, non-task array initializers, or nested task-array element
expressions, retain the normal escape boundary. That boundary occurs only after argument evaluation, so a
metadata Task.FromResult allocation failure before the consumer receives the task can still leave it active, but only
OutOfMemoryException is routed from that framework wrapper. Branch-local escape points
combine with ordinary awaits when every path ends or transfers ownership of the task; a wrapped-task assignment must
definitely reach its consumer to transfer ownership. A branch that exits through a nested try return is not treated
as terminal when evaluating the opposite branch if a prefix statement, the return expression, or its finally can
throw to an outer continuing handler, or if one of its catch paths can fall through; event assignments are potential
throwing prefixes because a custom accessor can fail. Prefixes whose faults cannot reach the later operation, an
absent or provably empty finally, and catch paths that all transfer out can preserve that terminal proof. A continuing catch remains a bypass even when the
completed await appears inside a nested block, and an explicit opposite-branch throw remains reaching when either its
operand can throw into, or its declared exception can reach, a compatible outer continuing catch. User-defined
task-sequence conversions are not treated as the original stable array, and custom replacement
exception constructors remain potential exception paths before a later direct or exact throw; metadata framework
exception constructors remain precise non-throwing replacement construction.
Before a singleton Task.WhenAny await, a custom event accessor assignment, possibly-null field-like event receiver,
user-defined unary operator, or possibly-null instance-field receiver can likewise throw into a compatible continuing
handler, so it does not prove completion. Field-like event accessors with known receivers, null-conditional field
access, and known non-null field receivers do not create that path, and a possible field/event-receiver fault reaches
only a compatible NullReferenceException handler. Explicit reference or unboxing casts, checked arithmetic and
increments, and integral division/remainder are likewise evaluated through their exact
InvalidCastException, NullReferenceException, OverflowException, or DivideByZeroException routes; harmless
implicit reference and widening numeric conversions do not block terminal-branch proof. Parentheses, null-forgiveness,
and non-user-defined base casts around a direct singleton span collection element still match the tracked task. Runtime
byte, ushort, and char array dimensions do not have an OverflowException route.
Calling parameterless
Wait() or GetAwaiter().GetResult() directly or after the framework ValueTask.AsTask() wrapper also ends a proven
task lifetime; a timed wait does not. Selector analysis inspects only code executed by the selector itself, not
uninvoked nested lambdas or local functions. Explicitly discarding an EF task or a local that stores it does not end
its active lifetime.
Why There Is No Code Fix
Sequential execution and separate contexts change performance, lifetime, transaction, tracking, and consistency semantics. Choosing between them requires application intent, so LC046 is diagnostic-only.