Skip to content

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

Description

@mrdevrobot

Summary

DocumentCollection<TId, T>.ScanAsync(BsonReaderPredicate, ...) (src/BLite.Core/Collections/DocumentCollection.cs, ~L889-947) is the fallback for every predicate the index optimizer cannot serve. It iterates _storage.GetCollectionPageIds(_collectionName) and applies the BSON predicate to every live slot of each data page, then materialises the matches with this collection's mapper. Data pages are shared between collections, so slots of other collections are evaluated too.

The comment there assumes the mapper throws on a foreign-collection document and the catch skips it ("silently skip it"). It never throws when the entity types share field names: the foreign document deserialises cleanly with the missing fields at their defaults, and the foreign document is yielded as a T.

Result: a query on collection A returns documents of collection B stored in the same file, whenever B has a field with the same name whose value passes the predicate.

Reproduction (BLite 5.1.0)

Same file, four collections whose entities share a base class (_id, LastModifiedAt, IsDeleted, RowVersion) and a Name field. Insert one Category, one Product, one MealService and one Vat all named "Pranzo". Then:

Query Result
MealServices.Where(e => e.Name == "Pranzo") (Name not indexed) 3 rows, foreign documents returned
MealServices.Where(e => e.Name.Contains("Pran")) 3 rows
Products.Where(e => e.Type == ProductType.Good) (Type not indexed) 2 rows
MealServices.CountAsync(e => e.Name == "Pranzo") 3 (same defect in CountScanAsync)
Vats.Where(e => e.Rate == 10) (indexed) ok
Vats.Where(e => e.Rate == 10 && e.Description == "Pranzo") (one indexed conjunct) ok
Products.Where(e => e.Name.ToLower().Contains("pran")) (not BSON-compilable, falls back to FindAllAsync) ok
no Where ok

So any predicate that is BSON-compilable, has no indexed conjunct, and names a field that also exists in another collection of the same file leaks.

Other paths with the same page-level iteration

  • ScanAsync<TResult>(projector) (~L1101)
  • CountScanAsync (~L1163)
  • ParallelScanAsync (~L1254)
  • TryBsonAggregate / BsonProjectionCompiler (e.g. MaxAsync(p => p.Plu) picks up a Plu field from another collection)
  • Not application reads, but they walk the same page ids slot by slot and are worth a look: EstimateCollectionSizeBytes (~L451), VacuumAsync (~L490), RebuildFreeSpaceIndex (~L692).

Expected

A scan must only consider the slots that belong to the collection. FindAllAsync already has the right source of truth: it walks the collection's primary index and reads the locations it yields. The scan fallbacks should restrict the evaluated slots to those locations (or the page format should carry the collection id per slot and the scan should filter on it).

Impact

Any consumer whose entities share field names across collections in one file (a common pattern with a base entity) can get foreign rows from unindexed queries, and can then write them back into the wrong collection.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

bugSomething isn't workingtriage

Type

No type

Projects

  • Status
    Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions