Repository navigation
perf: index-aware LINQ terminals with predicates, single-copy page writes, single-pass B-Tree insert (#179) - #180
Conversation
mrdevrobot
left a comment
There was a problem hiding this comment.
Thanks. The single-copy page write is safe: StorageEngine.WritePage always copies, and every AddWrite caller passes a pooled buffer only for the duration of the call. The single-pass B-Tree insert behaves exactly like before, since TryFind and the insert path use the same leaf routing. The terminal semantics also check out against LINQ-to-Objects. The problem is when the predicate is folded.
1. The folded predicate crashes after GroupBy (BTreeExpressionVisitor.cs:171-176, :197-202). The guard checks SelectClause, Take and Skip, but not HasComplexOperators or the element type. Q.GroupBy(p => p.Age).Count(g => g.Count() > 5) and .Any(g => g.Key == 3) put a lambda over IGrouping<,> into WhereClause, and FetchAsync throws InvalidCastException when it casts it to Expression<Func<T,bool>>. On main these return 50 and true. OfType<>, Cast<>, SelectMany, Join and Zip are exposed the same way.
2. Wrong results after SkipWhile / TakeWhile. Q.SkipWhile(p => p.Age < 10).FirstOrDefault(p => p.Age == 5) returns null; main and LINQ-to-Objects return the matching document. The predicate is pushed below an order-dependent operator.
Fix for both: fold only when !_model.HasComplexOperators and predicate.Parameters[0].Type == typeof(T), or allowlist the operators that may come before the terminal (Where, OrderBy*, ThenBy*). Please add regression tests for GroupBy, SkipWhile and OfType.
3. Behaviour changes to mention in the description. Unordered First(pred) now returns the first match in index order, not scan order. That is legal and matches Where(p).First(), but users can see it. Count(p => p.V.VectorSearch(q, k)) now returns k instead of the whole collection; that's better, but untested.
This PR doesn't touch #181. If you're in this code anyway, skipping TryPushDownSelect for element terminals when the push-down doesn't apply would fix part of it cheaply; a separate PR is fine too.
Nits: the XML doc at Transaction.cs:51-55 still says "Makes a defensive copy"; DocumentCollection.cs:1854 still does a redundant ToArray() before AddWrite. Merges cleanly with main, #169 and #173.
…ntglDb#180) Address review of EntglDb#180: - Count/LongCount/First*/Single*/Any fold their predicate into the WHERE clause only when every operator between the collection root and the terminal is a single-parameter Where or OrderBy*/ThenBy*, and the predicate takes the collection's entity type. Fixes the InvalidCastException after GroupBy/OfType/Cast/SelectMany/Join/Zip and the wrong results after SkipWhile/TakeWhile (and the indexed Where overload). - Regression tests for GroupBy (Count/Any/First), SkipWhile, TakeWhile, OfType/Cast, the fold allowlist, and Count(p => p.V.VectorSearch(q, k)). - Transaction.AddWrite XML doc no longer claims a defensive copy. - AllocateOverflowPage passes the pooled buffer to AddWrite without a redundant ToArray(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks. Addressed in 1f4dd2f. 1 + 2 (predicate folded after GroupBy / SkipWhile / …): I went with the allowlist. Regression tests, each failing on 73e9c99:
3: the description now has a "Behaviour changes" section with both points. #181: left for a separate PR. Nits: the Related, pre-existing on Full suite: 2392 passed, 3 failed (the known |
mrdevrobot
left a comment
There was a problem hiding this comment.
Verified 1f4dd2f. I compared 28 query shapes against LINQ-to-Objects over the same 300 rows: GroupBy, SkipWhile, TakeWhile, OfType, Cast, Select, Where + OrderBy/ThenBy before First, Take/Skip/Distinct/Reverse before a predicate terminal, Single/SingleOrDefault/Last. All match, and ordering is respected when an OrderBy precedes the terminal. The only mismatch is the indexed Where((p, i) => ...), which throws on main too (#186). Five of the new tests fail with the fix reverted.
One thing before merging. #173 is now on main, and combined with this PR one of its tests fails:
NearWithinCorrectnessTests.VectorSearch_InCountPredicate_AfterOtherWhere_Throws
Assert.Throws() Failure: No exception was thrown
Where(x => x.Title != null).Count(x => x.Embedding.VectorSearch(Q, 1)) now has its predicate folded into the WHERE, so it runs as Where(Title != null && VectorSearch(Q, 1)).Count(): the vector term drives and Title != null is the post-filter. That's consistent with how #173 treats VectorSearch && x, and with your Count_WithVectorSearchPredicate_CountsTheNearestK, so I think the new behaviour is the right one and the test should change, not the code. Please rebase onto main and update that test to assert the folded result (the same count as Where(x => x.Title != null && x.Embedding.VectorSearch(Q, 1)).Count()). The Sum(x => VectorSearch(...) ? 1 : 0) variant isn't folded and still throws. With that, the combination of main and this branch is otherwise green.
…ites, single-pass B-Tree insert (EntglDb#179) - Count/LongCount and First/FirstOrDefault/Single/SingleOrDefault/Any with a predicate fold it into the WHERE clause when no Select/Take/Skip comes before, so the index planner sees it. With only a WHERE in front, element terminals fetch one document (two for Single*) instead of loading every match and running EnumerableRewriter. - Transaction.AddWrite passes the page straight to StorageEngine.WritePage, which already copies it, instead of allocating a 16 KB defensive copy per write. - Unique B-Tree inserts check for duplicates on the leaf they already found instead of a separate TryFind traversal, and reuse that leaf buffer. TryFind uses a pooled buffer instead of a zeroed 16 KB stackalloc, and leaf inserts shift entries with Span.CopyTo. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ntglDb#180) Address review of EntglDb#180: - Count/LongCount/First*/Single*/Any fold their predicate into the WHERE clause only when every operator between the collection root and the terminal is a single-parameter Where or OrderBy*/ThenBy*, and the predicate takes the collection's entity type. Fixes the InvalidCastException after GroupBy/OfType/Cast/SelectMany/Join/Zip and the wrong results after SkipWhile/TakeWhile (and the indexed Where overload). - Regression tests for GroupBy (Count/Any/First), SkipWhile, TakeWhile, OfType/Cast, the fold allowlist, and Count(p => p.V.VectorSearch(q, k)). - Transaction.AddWrite XML doc no longer claims a defensive copy. - AllocateOverflowPage passes the pooled buffer to AddWrite without a redundant ToArray(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…glDb#180) With EntglDb#173 on main, Where(x => x.Title != null).Count(x => x.Embedding.VectorSearch(Q, 1)) has its predicate folded into the WHERE, so the index serves the VectorSearch instead of the replay throwing. - Rename VectorSearch_InCountPredicate_AfterOtherWhere_Throws to VectorSearch_InCountPredicate_AfterOtherWhere_IsFoldedIntoTheWhere and assert the same count as Where(x => x.Title != null && x.Embedding.VectorSearch(Q, 1)).Count(). - VectorSearch_InSumSelector_AfterOtherWhere_Throws (not folded) is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1f4dd2f to
a167c72
Compare
|
Rebased onto
One related shape: |
…ntglDb#180) OrderBy(x => x.Title).First(x => x.Embedding.VectorSearch(Q, 1)) folded the predicate into the WHERE, but the OrderBy sent the element terminal through the fallback, whose replay evaluated First(p) again and threw for VectorSearch. The same happened for Any, and for Count/LongCount after ThenBy (which the direct pipeline does not model). - QueryModel.FoldedTerminal records the terminal whose predicate was folded; the replay swaps it for its parameterless overload (First(p) -> First()) next to DropAppliedWheres. FetchAsync already applied p, and folding requires only Where/OrderBy/ThenBy in front. - Last/LastOrDefault fold their predicate too (Last(p) is Where(p).Last()); they always use the fallback since they need every match. - All(p) is still never folded and still evaluated in the replay, as are predicates after operators that block folding (SkipWhile, Take, GroupBy, ...). - Tests: OrderBy/ThenBy + First/FirstOrDefault/Last/LastOrDefault/Single/SingleOrDefault/ Any/Count/LongCount with VectorSearch against the equivalent Where(...).OrderBy(...) query, the same terminals with a plain predicate against LINQ-to-Objects, and unfolded shapes (All, SkipWhile, Take, GroupBy) that still throw. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Follow-up in 3cba537, which also covers the
|
mrdevrobot
left a comment
There was a problem hiding this comment.
Verified the rebase and the two new commits. ae6308e and ef05b29 are identical to the commits I reviewed. a167c72 asserts the folded count against Where(Title != null && VectorSearch(Q, 1)).Count(), and the other #173 throw tests still pass. 3cba537 only swaps a terminal for its parameterless overload when CanFoldPredicate allowed the fold, and the fetch then applies the whole predicate, partial-AND residuals included. Probes against LINQ-to-Objects match, including OrderBy(Title).First(VectorSearch(Q, 1)), and Select(x => x).First(VectorSearch) still throws. Merged with today's main, the full suite passes (2507 / 0 / 4 skipped). Merging.
Fixes #179.
Four small changes from profiling BLite against the SQLite benchmarks: two query-side, two on the write path.
Changes
1 + 2. LINQ terminals with a predicate use the index
BTreeExpressionVisitorfolds the predicate ofCount/LongCountand ofFirst,FirstOrDefault,Single,SingleOrDefaultandAnyinto the WHERE clause, using the sameAndAlsomerge as chainedWherecalls.Where,OrderBy*orThenBy*, and the predicate takes the entity type. After anything else (Select,Take,Skip,SkipWhile,TakeWhile,GroupBy,OfType,Cast,SelectMany,Join,Zip, the indexedWhere) the query stays on the fallback pipeline. Filtering commutes with ordering, so anOrderBybefore it is fine.Allis never folded. The .NET 6+ overloads that take a default value are not folded either.Single*) through the index-awareFetchAsyncand applies the terminal to that list. Otherwise the query is marked complex exactly as before;EnumerableRewriterre-applying an already applied predicate is harmless.3. One copy per page write.
Transaction.AddWritepassesNewValue.Spanstraight toStorageEngine.WritePageinstead ofToArray()-ing it first.WritePagealready copies into the transaction's own page buffer (data.ToArray()for a new page,CopyTofor an existing one), so callers can still return pooled buffers right away. A new test covers this.4. Single-pass unique B-Tree insert
BTreeIndex.Insertfinds the leaf once (FindLeafNodeWithPathnow has an overload that leaves the leaf in the caller's buffer) and does the duplicate check withBinarySearchLeafon that leaf. Before, a separateTryFindwalked from the root first, and the leaf was read a third time.TryFindrents a pooled buffer instead of zeroing a 16 KBstackallocon every lookup.Span.CopyTo(memmove) instead of a byte loop.Behaviour changes
First(pred),FirstOrDefault(pred)andAny(pred)follow index order. When an index serves the predicate, the result is the first match in index order, not in collection scan order. This is whatWhere(pred).First()already did, and LINQ promises no order without anOrderBy. Code that relied on scan order should add anOrderBy.Count(p => p.V.VectorSearch(q, k))returns k (at most the collection size), the same asWhere(p => p.V.VectorSearch(q, k)).Count(). Before, the predicate was evaluated per document in memory, and the count covered the whole collection.Measurements
macOS arm64, Release, averages of a warm loop over 6,000
PersonV2documents withidx_Age, after a forced GC:Count(p => p.Age == x)Count(p => p.Age >= a && p.Age <= b)FirstOrDefault(p => p.Age == x)Any(p => p.Age == x)FirstOrDefault(p => p.Name == x)(no index, BSON scan stops early)SingleOrDefault(p => p.Id == id)Count(p => p.Name == x)(no index)InsertBulkAsync1,000 docs (pk +idx_Age)FindByIdAsyncis within noise in this harness, about 3.4–3.9 µs either way. A tighter loop earlier measured 4.0 → 3.15 µs from theTryFindchange.Tests
QueryAndWritePathTestsadds 13 tests:Take,Skip,Select,SkipWhileor the indexedWhere.GroupBy,SkipWhile,TakeWhile,OfTypeandCastmatch LINQ-to-Objects (these crashed or returned wrong results with the first version of this PR).Count(x => x.Embedding.VectorSearch(q, 3))returns 3.Count/LongCountwith indexed, range and non-indexed predicates, andOrderBy().Take().Count(p), all compared against LINQ-to-Objects.Firstwith no match andSingle/SingleOrDefaultwith several.OrderBy,Where,SkipandSelect, plusAll, and the default-value overload ofFirstOrDefault.AddWrite.Full suite on macOS: 2392 passed, 3 failed, 4 skipped. The 3 failures are the known
MultiProcessWalSharedMemoryTests.wal-shmlock timeouts. The branch merges cleanly with #169, #170, #172, #173, #174, #175 and #177.Found along the way, not changed here
After a
Select, scalar terminals return the default value. Onmain,Select(p => p.Age).Any(),.Any(pred),.Count(pred),.Max()and.Sum()returnfalse/0on a non-empty collection.ProjectEnumerablereturnsdefaultwhen the result type isn't a sequence. That is a correctness bug, so I'll report it separately.🤖 Generated with Claude Code