Restrict BSON scan fallbacks to collection-owned document locations - #148
Conversation
Co-authored-by: mrdevrobot <12503462+mrdevrobot@users.noreply.github.com>
Co-authored-by: mrdevrobot <12503462+mrdevrobot@users.noreply.github.com>
mrdevrobot
left a comment
There was a problem hiding this comment.
Reviewed against the issue's reproduction and by running the suite locally (macOS): the four new tests pass on the branch, and the full BLite.Tests run shows no regression (2346 passed; the 3 MultiProcessWalSharedMemoryTests failures are #134 and fail identically on main). The approach is the right one: the primary index is the only source of truth for what belongs to a collection, and in single-file mode GetCollectionPageIds returns every page of the file, so nothing page-based could ever have worked.
Points to address before merging:
1. Two of the four new tests are not regression tests. With src/BLite.Core/Collections/DocumentCollection.cs reverted to main and the test file kept, only Where_OnUnindexedSharedField_DoesNotReturnCrossCollectionRows and ParallelScan_OnSharedPage_OnlyReturnsCurrentCollectionRows fail. Count_OnUnindexedSharedField_DoesNotCountCrossCollectionRows and Max_OnUnindexedSharedField_DoesNotReadCrossCollectionRows pass on the unfixed code, so they do not exercise CountScanAsync / the BSON aggregate path on a page that actually carries the foreign document. Please make them red first (check which path Count(predicate) and Max(selector) take with those entity types, and that the foreign row shares the page), otherwise the count and aggregate fixes are unpinned.
2. GetCollectionLocationsByPageAsync materialises the whole primary index before the scan starts (ScanAsync, ScanAsync<TResult>, CountScanAsync). That is O(documents) memory per unindexed query, on collections that can be large, and it widens the window between reading an index entry and reading its slot to the whole duration of the scan: a document relocated by a concurrent update in that window leaves a stale (page, slot) that now points at whatever reused the slot, which can again be another collection's document. FindAllAsync streams the index with a small page cache; the scan fallbacks should do the same. Since the index entry carries the key, a cheap guard is to compare entry.Key with the _id read at the location and skip on mismatch.
3. Behaviour change in retention, undocumented. ReadRawBytesAt used to return null for overflow documents on purpose (the removed doc comment: exempt from age-based retention because reassembly is expensive; MaxDocumentCount / MaxSizeBytes still applied). It now reassembles the chain, so MaxAge retention starts deleting overflow documents that were previously kept, and ApplyRetentionPolicyCoreAsync now pays the chain reads on every run. If intended, say so in the PR and add a retention test with an overflow document; if not, keep the exemption for the retention caller (a flag on ReadRawBytesAt) and reassemble only for the scan paths.
4. ParallelScanAsync drives the sync _primaryIndex.Range(...) enumerator from an async iterator, holding it open across semaphore.WaitAsync and Task.WhenAny. Range rents one page buffer and reads leaf pages synchronously as it advances, so this puts sync I/O on the async path and keeps a leaf walk suspended while worker tasks run. RangeAsync with manual batching keeps the same streaming shape without that.
Follow-ups, not for this PR (I can open issues): the other callers of GetCollectionPageIds still walk the entire file in single-file mode (EstimateCollectionSizeBytes, RebuildFreeSpaceIndex, DynamicCollection at lines ~197/547/1531, and VacuumAsync, which compacts and rewrites pages of every collection inside this collection's transaction). Nit: the trailing newline at the end of DocumentCollection.cs was dropped.
|
@copilot The branch head is still c65f7f1: your last run pushed no commits. Please address the four points of the review (changes requested): (1) make |
Co-authored-by: mrdevrobot <12503462+mrdevrobot@users.noreply.github.com>
mrdevrobot
left a comment
There was a problem hiding this comment.
1faf0f4 addresses all four points. Verified locally: with DocumentCollection.cs reverted to main all four isolation tests now fail (Where, CountAsync, MaxAsync scan plan, ParallelScan); with the branch they pass, TypedCollection_MaxAge_ExemptsOverflowDocuments pins the retention exemption, and the full BLite.Tests run is at the main baseline (the 3 MultiProcessWalSharedMemoryTests of #134; CrossCollectionWriteRaceTests.Concurrent_Inserts_Into_Different_Collections_LoseNothing failed once in the full run but passes 5/5 in isolation on both the branch and main, so it is pre-existing flakiness, not this change).
One non-blocking note on TryReadIndexKeyFromReader: it gates the fast path on typeof(TId), but the index key is built from the provider value (ToIndexKey in the generated mappers is IndexKey.Create(_idConverter.ConvertToProvider(id))). For any TId with a value converter (a custom id type stored as string or long) the fast path never matches, so every document goes through the fallback: a full _mapper.Deserialize + GetId just to compare the key, then a second deserialize to yield it. Building the key from the BSON type alone (String → IndexKey.Create(string), Int32/Int64/ObjectId likewise) and dropping the TId check would make the guard cheap for every collection. Fine as a follow-up.
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical and moderate findings affect correctness, memory usage, cancellation, and scan performance.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
This PR restricts BSON fallback scans to primary-index locations owned by the target collection, preventing cross-collection leakage while supporting overflow documents and parallel scans.
Changes:
- Reworks predicate, projection, count, and parallel scans around primary-index locations.
- Adds overflow BSON reassembly and incremental parallel batching.
- Adds isolation and retention regression tests.
Final review findings:
- Critical (3 votes): Custom ID transformations can be rejected instead of falling back to mapper deserialization (
DocumentCollection.cs:448,555). - Moderate (2 votes): Per-page buffer caching can cause excessive memory usage (
:543). - Moderate (1 vote): Inline documents are unnecessarily copied with
ToArray()(:389). - Moderate (2 votes): Parallel-scan cancellation can leak rented buffers (
:1371). - Nit (3 votes): Overflow-backed scan paths lack regression coverage (
:1089). - Moderate (1 vote): Overflow reads ignore cancellation and use synchronous reads (
:419). - Moderate (1 vote): Unsupported IDs trigger per-candidate CLR deserialization (
:451).
File summaries
| File | Summary |
|---|---|
tests/BLite.Tests/RetentionPolicyTests.cs |
Verifies overflow retention behavior. |
tests/BLite.Tests/CrossCollectionQueryIsolationTests.cs |
Tests isolation across unindexed, projection, count, and parallel scans. |
src/BLite.Core/Collections/DocumentCollection.cs |
Implements primary-index-owned scanning, overflow reads, and incremental parallel batching. |
Review details
Suppressed comments (4)
src/BLite.Core/Collections/DocumentCollection.cs:390
ToArray()allocates a separate BSON array for every inline document before the predicate or projector runs, including documents that will be rejected. The old scan evaluated directly over the page span, so this changes a raw scan into an allocation-heavy pass; keep inline payloads backed by the page buffer and only materialize overflow payloads.
if (TryReadInlineRawBytes(buffer, location.SlotIndex, out var rawBytes))
return rawBytes.ToArray();
src/BLite.Core/Collections/DocumentCollection.cs:557
- The same ownership gap exists here: if the synchronous page read throws before the assignment,
cachedBufferis never placed inpageCacheand is not returned by the iterator'sfinally, leaking a pooled page buffer. Register the buffer before reading or return it when the read fails.
cachedBuffer = ArrayPool<byte>.Shared.Rent(_storage.PageSize);
_storage.ReadPage(entry.Location.PageId, txnId, cachedBuffer);
pageCache[entry.Location.PageId] = cachedBuffer;
src/BLite.Core/Collections/DocumentCollection.cs:421
- This overflow loop is now on the cancellation-aware scan paths, but it never checks
ctand uses the synchronous page read. CancelingScanAsync,CountScanAsync, orParallelScanAsynccan therefore remain stuck reading every page of a large overflow chain before honoring cancellation. Pass the token through the raw-read helper and check it before each continuation read (using the async read where available).
while (currentOverflowPageId != 0 && offset < totalLength)
{
_storage.ReadPage(currentOverflowPageId, txnId, overflowBuffer);
src/BLite.Core/Collections/DocumentCollection.cs:454
- For any ID representation not covered by the hard-coded cases (including converter-backed IDs such as the repository's
OrderId), this fallback deserializes aTfor every candidate before the predicate/projector runs. That defeats the raw-scan/count contract of avoiding CLR materialization and adds a full extra mapping pass; use a mapper-provided raw-key conversion or avoid this per-document validation when the primary-index location is already the source of truth.
try
{
var entity = _mapper.Deserialize(new BsonSpanReader(bsonBytes, _storage.GetKeyReverseMap()));
return _mapper.ToIndexKey(_mapper.GetId(entity)).Equals(expectedKey);
- Files reviewed: 3/3 changed files
- Comments generated: 4
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| if (TryReadIndexKeyFromBson(bsonBytes, out var actualKey)) | ||
| return actualKey.Equals(expectedKey); |
| var pageCache = new Dictionary<uint, byte[]>(); | ||
|
|
| buffer = ArrayPool<byte>.Shared.Rent(_storage.PageSize); | ||
| await _storage.ReadPageAsync(location.PageId, txnId, buffer.AsMemory(0, _storage.PageSize), ct).ConfigureAwait(false); | ||
| pageCache[location.PageId] = buffer; |
| try | ||
| { | ||
| foreach (var pageId in _storage.GetCollectionPageIds(_collectionName)) | ||
| await foreach (var (_, rawBytes) in EnumerateOwnedRawDocumentsAsync(txnId, includeOverflow: true, ct).ConfigureAwait(false)) |
Unindexed BSON-compiled queries were scanning every live slot on shared data pages, so a collection could evaluate and materialize documents belonging to other collections when their fields overlapped. The same page-level leak affected count, projection/aggregate, and parallel scan fallbacks.
Use the primary index as the scan source of truth
_storage.GetCollectionPageIds(...)+ every live slot on each page.Apply the restriction across all scan fallbacks
ScanAsync(BsonReaderPredicate, ...)ScanAsync<TResult>(projector)CountScanAsync(...)ParallelScanAsync(...)Keep raw BSON reads compatible with overflow documents
Preserve parallel scan behavior without full upfront materialization
ParallelScanAsyncnow batches primary-index locations incrementally, so work can start without building a complete page map first.Regression coverage
WhereCountMaxParallelScanAsync