Skip to content

Restrict BSON scan fallbacks to collection-owned document locations - #148

Merged
mrdevrobot merged 4 commits into
mainfrom
copilot/fix-predicate-scan-fallback
Sep 13, 2026
Merged

mrdevrobot merged 4 commits into
mainfrom
copilot/fix-predicate-scan-fallback

Conversation

Copilot AI commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

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

    • Build scan candidates from the collection's primary-index locations instead of _storage.GetCollectionPageIds(...) + every live slot on each page.
    • This limits raw BSON predicate/projector evaluation to documents that actually belong to the target collection.
  • Apply the restriction across all scan fallbacks

    • ScanAsync(BsonReaderPredicate, ...)
    • ScanAsync<TResult>(projector)
    • CountScanAsync(...)
    • ParallelScanAsync(...)
  • Keep raw BSON reads compatible with overflow documents

    • Added an inline-slot fast path.
    • Reassemble overflow-backed BSON payloads when raw scan paths need the full document bytes.
  • Preserve parallel scan behavior without full upfront materialization

    • ParallelScanAsync now batches primary-index locations incrementally, so work can start without building a complete page map first.
  • Regression coverage

    • Added cross-collection isolation tests for:
      • unindexed Where
      • unindexed Count
      • BSON projection / Max
      • direct ParallelScanAsync
foreach (var entry in _primaryIndex.Range(IndexKey.MinKey, IndexKey.MaxKey, IndexDirection.Forward, txnId))
{
    var location = entry.Location;
    // evaluate predicate/projector only for this collection's indexed locations
}

Copilot AI and others added 2 commits September 13, 2026 12:50
Co-authored-by: mrdevrobot <12503462+mrdevrobot@users.noreply.github.com>
Co-authored-by: mrdevrobot <12503462+mrdevrobot@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix predicate scan fallback to avoid evaluating foreign collections Restrict BSON scan fallbacks to collection-owned document locations Sep 13, 2026
Copilot AI requested a review from mrdevrobot September 13, 2026 12:54

@mrdevrobot mrdevrobot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@mrdevrobot

Copy link
Copy Markdown
Contributor

@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 Count_OnUnindexedSharedField_* and Max_OnUnindexedSharedField_* fail on the unfixed code, they pass today with DocumentCollection.cs reverted to main; (2) stream the primary index in ScanAsync, ScanAsync<TResult> and CountScanAsync like FindAllAsync does instead of materialising every location upfront, and skip a location whose document _id no longer matches the index key; (3) keep overflow documents exempt from age-based retention in ApplyRetentionPolicyCoreAsync (reassemble only on the scan paths) or state the behaviour change and test it; (4) use RangeAsync in ParallelScanAsync instead of holding the sync Range enumerator across awaits.

Co-authored-by: mrdevrobot <12503462+mrdevrobot@users.noreply.github.com>

Copilot AI commented Sep 13, 2026

Copy link
Copy Markdown
Contributor Author

@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 Count_OnUnindexedSharedField_* and `Max_OnUnind...

Addressed in 1faf0f4.

@mrdevrobot mrdevrobot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot AI deployed to Production September 13, 2026 15:02 Active
@mrdevrobot
mrdevrobot marked this pull request as ready for review September 13, 2026 15:02
Copilot AI lite review requested due to automatic review settings September 13, 2026 15:02

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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, cachedBuffer is never placed in pageCache and is not returned by the iterator's finally, 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 ct and uses the synchronous page read. Canceling ScanAsync, CountScanAsync, or ParallelScanAsync can 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 a T for 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.

Comment on lines +448 to +449
if (TryReadIndexKeyFromBson(bsonBytes, out var actualKey))
return actualKey.Equals(expectedKey);
Comment on lines +543 to +544
var pageCache = new Dictionary<uint, byte[]>();

Comment on lines +1371 to +1373
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))
@mrdevrobot
mrdevrobot merged commit e6897ba into main Sep 13, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Predicate scan fallback evaluates slots of other collections and returns their documents

3 participants