Handle out-of-order split include rows during concurrent inserts#38676
Handle out-of-order split include rows during concurrent inserts#38676AndriySvyryd wants to merge 2 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR fixes a split-query (AsSplitQuery) materialization bug where concurrent, unrelated modifications can cause EF Core to silently drop included child collections by making split-include child rows appear out-of-order. The change adds a per-parent child-count projection to the outer query and updates split-collection stitching to tolerate a single out-of-order “block” (or throw a dedicated exception when correlation becomes ambiguous).
Changes:
- Project a correlated
COUNT(*)per split collection onto the parent query (plumbed viaRelationalSplitCollectionShaperExpression.ChildCount). - Rewrite split-include/split-collection materialization to use a one-block buffer and throw
DbQueryConcurrencyExceptionwhen correlation cannot be safely determined. - Add
DbQueryConcurrencyExceptionpublic API + message resource, and add regression coverage for #33826 (SQL Server; SQLite no-op).
Reviewed changes
Copilot reviewed 38 out of 40 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| test/EFCore.SqlServer.FunctionalTests/Query/TemporalOwnedQuerySqlServerTest.cs | Updates SQL baselines to include correlated COUNT(*) columns. |
| test/EFCore.SqlServer.FunctionalTests/Query/OwnedQuerySqlServerTest.cs | Updates SQL baselines to include correlated COUNT(*) columns. |
| test/EFCore.SqlServer.FunctionalTests/Query/OwnedEntityQuerySqlServerTest.cs | Updates SQL baselines to include correlated COUNT(*) columns. |
| test/EFCore.SqlServer.FunctionalTests/Query/NorthwindQueryTaggingQuerySqlServerTest.cs | Updates SQL baselines to include correlated COUNT(*) columns. |
| test/EFCore.SqlServer.FunctionalTests/Query/NorthwindMiscellaneousQuerySqlServerTest.cs | Updates SQL baselines to include correlated COUNT(*) columns. |
| test/EFCore.SqlServer.FunctionalTests/Query/InheritanceRelationshipsQuerySqlServerTest.cs | Updates SQL baselines to include correlated COUNT(*) columns. |
| test/EFCore.SqlServer.FunctionalTests/Query/Inheritance/TPTRelationshipsQuerySqlServerTest.cs | Updates SQL baselines to include correlated COUNT(*) columns. |
| test/EFCore.SqlServer.FunctionalTests/Query/Inheritance/TPCRelationshipsQuerySqlServerTest.cs | Updates SQL baselines to include correlated COUNT(*) columns. |
| test/EFCore.SqlServer.FunctionalTests/Query/Inheritance/TPCManyToManyNoTrackingQuerySqlServerTest.cs | Updates SQL baselines to include correlated COUNT(*) columns. |
| test/EFCore.SqlServer.FunctionalTests/Query/FromSqlQuerySqlServerTest.cs | Updates SQL baselines to include correlated COUNT(*) columns. |
| test/EFCore.SqlServer.FunctionalTests/Query/CompositeKeysSplitQuerySqlServerTest.cs | Updates SQL baselines to include correlated COUNT(*) columns. |
| test/EFCore.SqlServer.FunctionalTests/Query/AdHocQuerySplittingQuerySqlServerTest.cs | Overrides new regression tests to skip nondeterministic SQL baselines. |
| test/EFCore.SqlServer.FunctionalTests/Query/AdHocNavigationsQuerySqlServerTest.cs | Updates SQL baselines to include correlated COUNT(*) columns. |
| test/EFCore.SqlServer.FunctionalTests/Query/AdHocAdvancedMappingsQuerySqlServerTest.cs | Updates SQL baselines to include correlated COUNT(*) columns. |
| test/EFCore.Sqlite.FunctionalTests/Query/AdHocQuerySplittingQuerySqliteTest.cs | No-ops the new concurrency regression tests (SQLite snapshot behavior). |
| test/EFCore.Relational.Specification.Tests/Query/AdHocQuerySplittingQueryTestBase.cs | Adds #33826 regression tests for concurrent insert/child-deletion scenarios. |
| src/EFCore.Relational/Query/SqlTreePruner.cs | Ensures ChildCount is preserved during pruning. |
| src/EFCore.Relational/Query/SqlExpressions/SelectExpression.Helper.cs | Plumbs ChildCount through split-collection shaper construction. |
| src/EFCore.Relational/Query/SqlExpressions/SelectExpression.cs | Projects correlated COUNT(*) for split collections; adds precompilation fallback. |
| src/EFCore.Relational/Query/RelationalSplitCollectionShaperExpression.cs | Adds ChildCount to the split-collection shaper expression. |
| src/EFCore.Relational/Query/RelationalShapedQueryCompilingExpressionVisitor.ShaperProcessingExpressionVisitor.cs | Compiles and passes childCount lambda into split include/collection init. |
| src/EFCore.Relational/Query/RelationalShapedQueryCompilingExpressionVisitor.ShaperProcessingExpressionVisitor.ClientMethods.cs | Implements block/buffer correlation logic and throws DbQueryConcurrencyException on ambiguity. |
| src/EFCore.Relational/Query/RelationalQueryTranslationPostprocessor.cs | Passes IsPrecompiling into projection application. |
| src/EFCore.Relational/Query/Internal/SplitQueryResultCoordinator.cs | Adds end-of-stream verification for leftover buffered blocks. |
| src/EFCore.Relational/Query/Internal/SplitQueryingEnumerable.cs | Calls VerifyNoLeftoverBufferedBlocks() when outer enumeration completes. |
| src/EFCore.Relational/Query/Internal/SplitQueryDataReaderContext.cs | Stores one buffered block + “anomaly used” marker. |
| src/EFCore.Relational/Query/Internal/SplitQueryCollectionContext.cs | Stores ChildCount per parent/collection context. |
| src/EFCore.Relational/Query/Internal/SelectExpressionProjectionApplyingExpressionVisitor.cs | Threads isPrecompiling into SelectExpression.ApplyProjection. |
| src/EFCore.Relational/Properties/RelationalStrings.resx | Adds SplitQueryConcurrentModification resource text. |
| src/EFCore.Relational/Properties/RelationalStrings.Designer.cs | Adds strongly-typed accessor for the new resource string. |
| src/EFCore.Relational/EFCore.Relational.baseline.json | Updates public API baseline (new exception, new shaper signature, new ApplyProjection overload). |
| src/EFCore.Relational/DbQueryConcurrencyException.cs | Introduces new public exception type for split-query correlation failures. |
Files not reviewed (1)
- src/EFCore.Relational/Properties/RelationalStrings.Designer.cs: Generated file
Co-authored-by: AndriySvyryd <6539701+AndriySvyryd@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 42 out of 44 changed files in this pull request and generated no new comments.
Files not reviewed (1)
- src/EFCore.Relational/Properties/RelationalStrings.Designer.cs: Generated file
Comments suppressed due to low confidence (1)
src/EFCore.Relational/Query/SqlExpressions/SelectExpression.cs:1269
- The correlated COUNT() subquery is created as a SqlFunctionExpression typed as int and then only type-mapped. For providers where COUNT() returns a larger integer type at the ADO.NET level (e.g. bigint/Int64), this can cause an invalid cast when EF reads the projection as int. Consider emitting an explicit SQL CAST by typing COUNT() as long and then converting to int via SqlExpressionFactory.Convert(), so the SQL generator produces CAST(COUNT() AS int) and the reader shape matches the declared type.
childCountSelectExpression.ClearOrdering();
var countExpression = new SqlFunctionExpression(
"COUNT",
new SqlExpression[] { new SqlFragmentExpression("*") },
nullable: false,
argumentsPropagateNullability: new[] { false },
typeof(int),
typeMapping: null);
| ISqlExpressionFactory? sqlExpressionFactory, | ||
| IAggregateMethodCallTranslatorProvider? aggregateMethodCallTranslatorProvider, | ||
| QueryCompilationContext? queryCompilationContext) |
There was a problem hiding this comment.
I don't love this, but the alternative is to assume "COUNT" works for all relational providers
There was a problem hiding this comment.
Even if we would assume COUNT works for all relational providers, the type mapping is definitely going to differ.
We would have to relocate the COUNT construction earlier in translation phase. But that's bigger change than this.
|
|
||
| // The child count could not be projected for this collection's shape (grouping-element projection, etc.). | ||
| // Fall back to streaming correlation: read this parent's contiguous block and stop at the first non-matching row. | ||
| if (splitQueryCollectionContext.ChildCount < 0) |
There was a problem hiding this comment.
Given we hardcode -1 it would make sense to me to compare specifically with -1 here.
|
|
||
| // The child count could not be projected for this collection's shape (grouping-element projection, etc.). | ||
| // Fall back to streaming correlation: read this parent's contiguous block and stop at the first non-matching row. | ||
| if (splitQueryCollectionContext.ChildCount < 0) |
There was a problem hiding this comment.
Same -1 comment as above.
|
|
||
| // The child count could not be projected for this collection's shape (grouping-element projection, etc.). | ||
| // Fall back to streaming correlation: read this parent's contiguous block and stop at the first non-matching row. | ||
| if (splitQueryCollectionContext.ChildCount < 0) |
There was a problem hiding this comment.
Same -1 comment as above.
| if (relatedDataLoaders != null) | ||
| // The child count could not be projected for this collection's shape (grouping-element projection, etc.). | ||
| // Fall back to streaming correlation: read this parent's contiguous block and stop at the first non-matching row. | ||
| if (splitQueryCollectionContext.ChildCount < 0) |
There was a problem hiding this comment.
Same -1 comment as above.
| ISqlExpressionFactory? sqlExpressionFactory, | ||
| IAggregateMethodCallTranslatorProvider? aggregateMethodCallTranslatorProvider, | ||
| QueryCompilationContext? queryCompilationContext) |
There was a problem hiding this comment.
Even if we would assume COUNT works for all relational providers, the type mapping is definitely going to differ.
We would have to relocate the COUNT construction earlier in translation phase. But that's bigger change than this.
|
Every split-collection now carries a correlated What makes more sense to me is doing "detect and throw" only. The cascading silent loss can be detected purely client-side (buffered non-matching row still unclaimed at end of the parent stream) and turned into |
Fixes #33826
Problem
When a query uses
AsSplitQuery()with collectionIncludes, EF executes one SQL statement per collection level. Between those statements the database can change. Issue #33826 showed that a completely unrelated concurrent insert could make EF silently drop the child collections of untouched parents:Root cause
The parent query and each child query both carry the same
ORDER BY <parent key>, so a parent's child rows arrive as a contiguous, correctly-ordered block. The old stitching loop streamed child rows and treated the first row whose parent key didn't match the current parent as the boundary of the next parent — then stopped. When a concurrent insert put an unrelated child row in the stream (e.g. an out-of-order/orphan parent key), that row was misread as "the current parent has no more children," so the remaining real children were abandoned and the whole collection was lost. The bug is purely in client-side correlation — the correct rows were fetched from the database and then thrown away.Why this approach
I considered several designs; the key constraints (from reviewing the earlier attempt in this PR and the issue discussion) were:
No client-side ordering comparison. The obvious fix — "if the non-matching row sorts after the current parent it's the next parent, otherwise skip it as an orphan" — requires comparing key magnitudes on the client. That's wrong because .NET ordering doesn't always match the database's ordering (GUIDs on SQL Server, culture/collation-sensitive strings, etc.). Any magnitude comparison risks new, subtler correctness bugs. We must rely only on key equality, which EF already trusts.
Avoid buffering the whole result set. The previous iteration of this PR (Handle out-of-order split-include rows to prevent child collection loss under concurrent inserts #38590) fell back to buffering every child row into a dictionary whenever ordering wasn't "trusted," which defeats the streaming purpose of split queries and regressed memory for ordinary queries.
Fail loudly rather than silently corrupt. Where a scenario genuinely can't be disambiguated, throwing is far better than returning wrong data.
The chosen design satisfies all three by giving the materializer the one piece of information that removes the ambiguity: how many children each parent is supposed to have.
The fix: project a per-parent child count, correlate by count + equality
Producer. For every split collection, the outer (parent) query now projects a correlated
COUNT(*)of that collection as an extra scalar column:The count is derived from a clone of the collection subquery taken before the split decorrelates it, so it automatically respects the collection's own predicate/filters, and
Take/Skip/Distinct/GroupBy(via pushdown) so it equals the number of rows the child query actually yields.Consumer. The materializer reads child rows as contiguous blocks (a maximal run sharing one parent key) and correlates each parent using key equality only, driven by the expected count:
At most one block is ever held in memory, and no ordering magnitudes are compared. When a second, colliding concurrent change would be needed to explain the stream, EF throws the new
DbQueryConcurrencyExceptioninstead of guessing.Best-effort detection, by design
The guarantee is deliberately scoped: correct for any single concurrent change (which may touch many rows of one parent), and never silently wrong. Multiple concurrent changes either still produce correct results (e.g. rows added/removed within several parents' own blocks — pure count changes that keep each block in place) or raise
DbQueryConcurrencyException. Callers handle it the same way as optimistic-concurrency conflicts on save:A dedicated exception type (rather than a bare
InvalidOperationException) is introduced precisely so this is catchable and retryable.Changes
DbQueryConcurrencyException(relational).SelectExpressionprojects a correlatedCOUNT(*)per split collection; carried onRelationalSplitCollectionShaperExpression.ChildCountandSplitCollectionInfo.PopulateSplitIncludeCollection/PopulateSplitCollection(sync + async) to the block/count algorithm;SplitQueryCollectionContext.ChildCountand a bounded one-block buffer (BufferedBlock*,AnomalyUsed) onSplitQueryDataReaderContext.SplitQueryResultCoordinator.VerifyNoLeftoverBufferedBlocks()(called when the outer enumerator completes) throws if a buffered "childless" block was never claimed.#33826regression tests inAdHocQuerySplittingQueryTestBasecovering a concurrent unrelated insert and a concurrent parent-made-childless, sync + async (SQL Server; no-ops on SQLite, whose per-connection snapshot can't reproduce cross-statement visibility).Trade-offs
COUNT(*)column on the parent query (extra aggregate cost on wide parent sets), in exchange for correctness under concurrency without buffering. Split-query SQL baselines are updated accordingly.DbQueryConcurrencyExceptionrather than a (possibly wrong) result — the same failure model asDbUpdateConcurrencyException, and strictly better than the previous silent data loss.