diff --git a/src/BLite.Core/Collections/DocumentCollection.cs b/src/BLite.Core/Collections/DocumentCollection.cs index 7bb3e16..a721d9e 100644 --- a/src/BLite.Core/Collections/DocumentCollection.cs +++ b/src/BLite.Core/Collections/DocumentCollection.cs @@ -351,36 +351,223 @@ private async Task ApplyRetentionPolicyCoreAsync(CancellationToken ct) /// /// Reads the raw BSON bytes for a document at the given location without deserializing. - /// Returns null if the slot is deleted, the page is invalid, or the document spans - /// multiple overflow pages — overflow documents are exempt from age-based retention - /// because reassembling the full payload is expensive in this context. - /// For retention purposes, and - /// still apply to overflow documents (their primary-index entry is included in the count scan). + /// Returns null if the slot is deleted or the page/location is invalid. + /// Overflow documents are reassembled only when is true. /// - private byte[]? ReadRawBytesAt(DocumentLocation location, ulong txnId) + private static bool TryReadInlineRawBytes(byte[] pageBuffer, ushort slotIndex, out ReadOnlySpan rawBytes) { - var buffer = ArrayPool.Shared.Rent(_storage.PageSize); + rawBytes = default; + + if ((PageType)pageBuffer[4] != PageType.Data) + return false; + + var header = SlottedPageHeader.ReadFrom(pageBuffer); + if (slotIndex >= header.SlotCount) return false; + + var slotOffset = SlottedPageHeader.Size + (slotIndex * SlotEntry.Size); + var slot = SlotEntry.ReadFrom(pageBuffer.AsSpan(slotOffset)); + if ((slot.Flags & (SlotFlags.Deleted | SlotFlags.HasOverflow)) != 0) return false; + if (slot.Offset + slot.Length > pageBuffer.Length) return false; + + rawBytes = pageBuffer.AsSpan(slot.Offset, slot.Length); + return true; + } + + private byte[]? ReadRawBytesAt(DocumentLocation location, ulong txnId, bool includeOverflow = false, byte[]? preloadedPage = null) + { + byte[]? ownedBuffer = null; + var buffer = preloadedPage ?? (ownedBuffer = ArrayPool.Shared.Rent(_storage.PageSize)); try { - _storage.ReadPage(location.PageId, txnId, buffer); + if (ownedBuffer != null) + _storage.ReadPage(location.PageId, txnId, buffer); + var pageType = (PageType)buffer[4]; if (pageType == PageType.Free || pageType == PageType.Empty || pageType == PageType.TimeSeries) return null; + if (TryReadInlineRawBytes(buffer, location.SlotIndex, out var rawBytes)) + return rawBytes.ToArray(); + var header = SlottedPageHeader.ReadFrom(buffer); if (location.SlotIndex >= header.SlotCount) return null; var slotOffset = SlottedPageHeader.Size + (location.SlotIndex * SlotEntry.Size); var slot = SlotEntry.ReadFrom(buffer.AsSpan(slotOffset)); if ((slot.Flags & SlotFlags.Deleted) != 0) return null; - if ((slot.Flags & SlotFlags.HasOverflow) != 0) return null; // skip overflow docs for retention + if ((slot.Flags & SlotFlags.HasOverflow) == 0) return null; + if (!includeOverflow) return null; + + if (slot.Offset + slot.Length > buffer.Length || slot.Length < 8) return null; + + var payload = buffer.AsSpan(slot.Offset, slot.Length); + int totalLength = System.Buffers.Binary.BinaryPrimitives.ReadInt32LittleEndian(payload.Slice(0, 4)); + uint nextOverflowPageId = System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4, 4)); + if (totalLength <= 0) return null; + + int primaryChunkSize = slot.Length - 8; + if (primaryChunkSize < 0 || primaryChunkSize > totalLength) return null; + + var fullBuffer = new byte[totalLength]; + payload.Slice(8, primaryChunkSize).CopyTo(fullBuffer); + + int offset = primaryChunkSize; + var overflowBuffer = ArrayPool.Shared.Rent(_storage.PageSize); + try + { + var currentOverflowPageId = nextOverflowPageId; + while (currentOverflowPageId != 0 && offset < totalLength) + { + _storage.ReadPage(currentOverflowPageId, txnId, overflowBuffer); + var overflowHeader = SlottedPageHeader.ReadFrom(overflowBuffer); + + int chunkSize = Math.Min(_storage.PageSize - SlottedPageHeader.Size, totalLength - offset); + overflowBuffer.AsSpan(SlottedPageHeader.Size, chunkSize) + .CopyTo(fullBuffer.AsSpan(offset)); + + offset += chunkSize; + currentOverflowPageId = overflowHeader.NextOverflowPage; + } + } + finally + { + ArrayPool.Shared.Return(overflowBuffer); + } - if (slot.Offset + slot.Length > buffer.Length) return null; - return buffer.AsSpan(slot.Offset, slot.Length).ToArray(); + return offset == totalLength ? fullBuffer : null; } finally { - ArrayPool.Shared.Return(buffer); + if (ownedBuffer != null) + ArrayPool.Shared.Return(ownedBuffer); + } + } + + private bool MatchesIndexEntryKey(ReadOnlySpan bsonBytes, IndexKey expectedKey) + { + if (TryReadIndexKeyFromBson(bsonBytes, out var actualKey)) + return actualKey.Equals(expectedKey); + + try + { + var entity = _mapper.Deserialize(new BsonSpanReader(bsonBytes, _storage.GetKeyReverseMap())); + return _mapper.ToIndexKey(_mapper.GetId(entity)).Equals(expectedKey); + } + catch + { + return false; + } + } + + private bool TryReadIndexKeyFromBson(ReadOnlySpan bsonBytes, out IndexKey key) + { + key = default; + try + { + var reader = new BsonSpanReader(bsonBytes, _storage.GetKeyReverseMap()); + reader.ReadDocumentSize(); + + if (_storage.GetKeyMap().TryGetValue("_id", out var idFieldId) && + reader.TrySeekToField(idFieldId, out var seekType)) + { + return TryReadIndexKeyFromReader(ref reader, seekType, out key); + } + + while (reader.Remaining > 1) + { + var type = reader.ReadBsonType(); + if (type == BsonType.EndOfDocument) break; + + var name = reader.ReadElementHeader(); + if (name == "_id") + return TryReadIndexKeyFromReader(ref reader, type, out key); + + reader.SkipValue(type); + } + } + catch + { + return false; + } + + return false; + } + + private static bool TryReadIndexKeyFromReader(ref BsonSpanReader reader, BsonType type, out IndexKey key) + { + key = default; + + if (typeof(TId) == typeof(ObjectId) && type == BsonType.ObjectId) + { + key = IndexKey.Create(reader.ReadObjectId()); + return true; + } + + if (typeof(TId) == typeof(int) && type == BsonType.Int32) + { + key = IndexKey.Create(reader.ReadInt32()); + return true; + } + + if (typeof(TId) == typeof(long) && type == BsonType.Int64) + { + key = IndexKey.Create(reader.ReadInt64()); + return true; + } + + if (type == BsonType.String) + { + var value = reader.ReadString(); + + if (typeof(TId) == typeof(string)) + { + key = IndexKey.Create(value); + return true; + } + + if (typeof(TId) == typeof(Guid) && Guid.TryParse(value, out var guid)) + { + key = IndexKey.Create(guid); + return true; + } + } + + return false; + } + + private async IAsyncEnumerable<(IndexEntry Entry, byte[] RawBytes)> EnumerateOwnedRawDocumentsAsync( + ulong txnId, + bool includeOverflow, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default) + { + var pageCache = new Dictionary(); + + try + { + await foreach (var entry in _primaryIndex + .RangeAsync(IndexKey.MinKey, IndexKey.MaxKey, IndexDirection.Forward, txnId, ct) + .ConfigureAwait(false)) + { + ct.ThrowIfCancellationRequested(); + + if (!pageCache.TryGetValue(entry.Location.PageId, out var cachedBuffer)) + { + cachedBuffer = ArrayPool.Shared.Rent(_storage.PageSize); + _storage.ReadPage(entry.Location.PageId, txnId, cachedBuffer); + pageCache[entry.Location.PageId] = cachedBuffer; + } + + var rawBytes = ReadRawBytesAt(entry.Location, txnId, includeOverflow, cachedBuffer); + if (rawBytes is null) continue; + if (!MatchesIndexEntryKey(rawBytes, entry.Key)) continue; + + yield return (entry, rawBytes); + } + } + finally + { + foreach (var buf in pageCache.Values) + ArrayPool.Shared.Return(buf); } } @@ -895,61 +1082,18 @@ public async IAsyncEnumerable ScanAsync( var sw = _storage.MetricsDispatcher != null ? ValueStopwatch.StartNew() : default; var txnId = transaction?.TransactionId ?? 0UL; - var buffer = ArrayPool.Shared.Rent(_storage.PageSize); + var keyMap = _storage.GetKeyReverseMap(); try { - foreach (var pageId in _storage.GetCollectionPageIds(_collectionName)) + await foreach (var (_, rawBytes) in EnumerateOwnedRawDocumentsAsync(txnId, includeOverflow: true, ct).ConfigureAwait(false)) { - ct.ThrowIfCancellationRequested(); - await _storage.ReadPageAsync(pageId, txnId, buffer.AsMemory(0, _storage.PageSize), ct); - - var header = SlottedPageHeader.ReadFrom(buffer); - if (header.PageType != PageType.Data) continue; - - // Collect matching locations first (no Span across yield) - var matchingLocations = new List<(uint pageId, ushort slotIndex)>(); - { - var slots = MemoryMarshal.Cast( - buffer.AsSpan(SlottedPageHeader.Size, header.SlotCount * SlotEntry.Size)); - - for (int i = 0; i < header.SlotCount; i++) - { - var slot = slots[i]; - if (slot.Flags.HasFlag(SlotFlags.Deleted)) continue; - - var data = buffer.AsSpan(slot.Offset, slot.Length); - var reader = new BsonSpanReader(data, _storage.GetKeyReverseMap()); - - if (predicate(reader)) - { - matchingLocations.Add((pageId, (ushort)i)); - } - } - } - - // Yield matching documents. The buffer already holds this page — pass it - // to FindByLocationAsync so the page is not read a second time. - // A data page may contain slots from multiple collections; if the mapper - // fails for a foreign-collection document, silently skip it. - foreach (var (pid, idx) in matchingLocations) - { - T? doc; - try - { - doc = await FindByLocationAsync(new DocumentLocation(pid, idx), txnId, buffer, ct); - } - catch - { - continue; // foreign-collection document — skip - } - if (doc != null) yield return doc; - } + if (!predicate(new BsonSpanReader(rawBytes, keyMap))) continue; + yield return _mapper.Deserialize(new BsonSpanReader(rawBytes, keyMap)); } } finally { - ArrayPool.Shared.Return(buffer); if (sw.IsActive) _storage.MetricsDispatcher?.Publish(new MetricEvent { @@ -1105,47 +1249,12 @@ public async IAsyncEnumerable ScanAsync( if (projector == null) throw new ArgumentNullException(nameof(projector)); var txnId = 0UL; - var buffer = ArrayPool.Shared.Rent(_storage.PageSize); + var keyMap = _storage.GetKeyReverseMap(); - try + await foreach (var (_, rawBytes) in EnumerateOwnedRawDocumentsAsync(txnId, includeOverflow: true, ct).ConfigureAwait(false)) { - foreach (var pageId in _storage.GetCollectionPageIds(_collectionName)) - { - ct.ThrowIfCancellationRequested(); - await _storage.ReadPageAsync(pageId, txnId, buffer.AsMemory(0, _storage.PageSize), ct); - - var header = SlottedPageHeader.ReadFrom(buffer); - if (header.PageType != PageType.Data) continue; - - // Process all slots and collect results (no Span across yield) - var pageResults = new List(); - { - var slots = MemoryMarshal.Cast( - buffer.AsSpan(SlottedPageHeader.Size, header.SlotCount * SlotEntry.Size)); - - var keyMap = _storage.GetKeyReverseMap(); - for (int i = 0; i < header.SlotCount; i++) - { - var slot = slots[i]; - if (slot.Flags.HasFlag(SlotFlags.Deleted)) continue; - - var data = buffer.AsSpan(slot.Offset, slot.Length); - var reader = new BsonSpanReader(data, keyMap); - var result = projector(reader); - if (result is not null) pageResults.Add(result); - } - } - - // Yield results after Span is out of scope - foreach (var result in pageResults) - { - yield return result; - } - } - } - finally - { - ArrayPool.Shared.Return(buffer); + var result = projector(new BsonSpanReader(rawBytes, keyMap)); + if (result is not null) yield return result; } } @@ -1154,11 +1263,9 @@ public async IAsyncEnumerable ScanAsync( /// directly on raw BSON bytes — no CLR instances are ever created. /// /// - /// The predicate is executed once per non-deleted, non-overflow slot on every data page. - /// Overflow-flagged primary slots are skipped: the BSON data in such slots starts after - /// an 8-byte overflow header that a normal BSON predicate cannot parse correctly, so - /// those documents are not counted (same behaviour as the existing - /// for overflow documents). + /// The predicate is executed once per document referenced by the collection's primary index. + /// Raw BSON is read directly from the owning slot, reassembling overflow documents when needed, + /// and no CLR instances are created. /// internal async Task CountScanAsync( BsonReaderPredicate predicate, @@ -1167,47 +1274,19 @@ internal async Task CountScanAsync( if (predicate == null) throw new ArgumentNullException(nameof(predicate)); var txnId = 0UL; - var buffer = ArrayPool.Shared.Rent(_storage.PageSize); int count = 0; + var keyMap = _storage.GetKeyReverseMap(); - try + await foreach (var (_, rawBytes) in EnumerateOwnedRawDocumentsAsync(txnId, includeOverflow: true, ct).ConfigureAwait(false)) { - foreach (var pageId in _storage.GetCollectionPageIds(_collectionName)) + try { - ct.ThrowIfCancellationRequested(); - await _storage.ReadPageAsync(pageId, txnId, buffer.AsMemory(0, _storage.PageSize), ct); - - var header = SlottedPageHeader.ReadFrom(buffer); - if (header.PageType != PageType.Data) continue; - - var slots = MemoryMarshal.Cast( - buffer.AsSpan(SlottedPageHeader.Size, header.SlotCount * SlotEntry.Size)); - var keyMap = _storage.GetKeyReverseMap(); - - for (int i = 0; i < header.SlotCount; i++) - { - var slot = slots[i]; - if ((slot.Flags & SlotFlags.Deleted) != 0) continue; - // Skip overflow continuation slots: the primary slot's raw data starts - // with an 8-byte overflow header (totalLength + nextPageId), not BSON. - if ((slot.Flags & SlotFlags.HasOverflow) != 0) continue; - - var data = buffer.AsSpan(slot.Offset, slot.Length); - var reader = new BsonSpanReader(data, keyMap); - try - { - if (predicate(reader)) count++; - } - catch - { - // Malformed BSON or foreign-collection slot — skip silently. - } - } + if (predicate(new BsonSpanReader(rawBytes, keyMap))) count++; + } + catch + { + // Malformed BSON — skip silently. } - } - finally - { - ArrayPool.Shared.Return(buffer); } return count; @@ -1259,65 +1338,54 @@ public async IAsyncEnumerable ParallelScanAsync( if (predicate == null) throw new ArgumentNullException(nameof(predicate)); var txnId = 0UL; - var allPageIds = _storage.GetCollectionPageIds(_collectionName).ToArray(); - var pageCount = allPageIds.Length; - if (degreeOfParallelism <= 0) degreeOfParallelism = Environment.ProcessorCount; var semaphore = new SemaphoreSlim(degreeOfParallelism); var tasks = new List>>(); + var keyMap = _storage.GetKeyReverseMap(); + const int batchSize = 128; + var batch = new List(batchSize); - for (int pageIdx = 0; pageIdx < pageCount; pageIdx++) + async Task QueueBatchAsync(List entries) { - await semaphore.WaitAsync(ct); - var localPageId = allPageIds[pageIdx]; + await semaphore.WaitAsync(ct).ConfigureAwait(false); + var localBatch = entries.ToArray(); var task = Task.Run(async () => { try { - var buffer = ArrayPool.Shared.Rent(_storage.PageSize); var results = new List(); + var pageCache = new Dictionary(); try { - await _storage.ReadPageAsync(localPageId, txnId, buffer.AsMemory(0, _storage.PageSize), ct); - - var header = SlottedPageHeader.ReadFrom(buffer); - if (header.PageType != PageType.Data) return results; - - // First pass: collect matching locations (Span scope) - var matchingIndices = new List(); + foreach (var entry in localBatch) { - var slots = MemoryMarshal.Cast( - buffer.AsSpan(SlottedPageHeader.Size, header.SlotCount * SlotEntry.Size)); + ct.ThrowIfCancellationRequested(); + var location = entry.Location; - for (int i = 0; i < header.SlotCount; i++) + if (!pageCache.TryGetValue(location.PageId, out var buffer)) { - var slot = slots[i]; - if (slot.Flags.HasFlag(SlotFlags.Deleted)) continue; - - var data = buffer.AsSpan(slot.Offset, slot.Length); - var reader = new BsonSpanReader(data, _storage.GetKeyReverseMap()); - - if (predicate(reader)) - { - matchingIndices.Add((ushort)i); - } + buffer = ArrayPool.Shared.Rent(_storage.PageSize); + await _storage.ReadPageAsync(location.PageId, txnId, buffer.AsMemory(0, _storage.PageSize), ct).ConfigureAwait(false); + pageCache[location.PageId] = buffer; } - } - // Second pass: fetch documents (no Span, safe to await) - foreach (var idx in matchingIndices) - { - var doc = await FindByLocationAsync(new DocumentLocation(localPageId, idx), txnId, ct); - if (doc != null) results.Add(doc); + var rawBytes = ReadRawBytesAt(location, txnId, includeOverflow: true, buffer); + if (rawBytes is null) continue; + if (!MatchesIndexEntryKey(rawBytes, entry.Key)) continue; + if (!predicate(new BsonSpanReader(rawBytes, keyMap))) + continue; + + results.Add(_mapper.Deserialize(new BsonSpanReader(rawBytes, keyMap))); } } finally { - ArrayPool.Shared.Return(buffer); + foreach (var buffer in pageCache.Values) + ArrayPool.Shared.Return(buffer); } return results; @@ -1331,13 +1399,40 @@ public async IAsyncEnumerable ParallelScanAsync( tasks.Add(task); } + await foreach (var entry in _primaryIndex + .RangeAsync(IndexKey.MinKey, IndexKey.MaxKey, IndexDirection.Forward, txnId, ct) + .ConfigureAwait(false)) + { + ct.ThrowIfCancellationRequested(); + batch.Add(entry); + if (batch.Count < batchSize) continue; + + await QueueBatchAsync(batch).ConfigureAwait(false); + if (tasks.Count >= degreeOfParallelism) + { + var completedTask = await Task.WhenAny(tasks).ConfigureAwait(false); + tasks.Remove(completedTask); + + var results = await completedTask.ConfigureAwait(false); + foreach (var doc in results) + { + yield return doc; + } + } + + batch = new List(batchSize); + } + + if (batch.Count > 0) + await QueueBatchAsync(batch).ConfigureAwait(false); + // Yield results as tasks complete while (tasks.Count > 0) { - var completedTask = await Task.WhenAny(tasks); + var completedTask = await Task.WhenAny(tasks).ConfigureAwait(false); tasks.Remove(completedTask); - var results = await completedTask; + var results = await completedTask.ConfigureAwait(false); foreach (var doc in results) { yield return doc; @@ -3826,4 +3921,3 @@ public ValueTask DeleteBulkAsync(IEnumerable ids, CancellationToken ct return DeleteBulkAsync(ids, null, ct); } } - diff --git a/tests/BLite.Tests/CrossCollectionQueryIsolationTests.cs b/tests/BLite.Tests/CrossCollectionQueryIsolationTests.cs index fcc5f01..26a0a50 100644 --- a/tests/BLite.Tests/CrossCollectionQueryIsolationTests.cs +++ b/tests/BLite.Tests/CrossCollectionQueryIsolationTests.cs @@ -1,3 +1,4 @@ +using BLite.Core.Query; using BLite.Shared; namespace BLite.Tests; @@ -69,6 +70,78 @@ public async Task Contains_WithDuplicateValues_DoesNotDoubleCount() Assert.Equal(2, count); } + [Fact] + public async Task Where_OnUnindexedSharedField_DoesNotReturnCrossCollectionRows() + { + await _db.IntEntities.InsertAsync(new IntEntity { Id = 1, Name = "Pranzo" }); + await _db.People.InsertAsync(new Person { Id = 101, Name = "Pranzo", Age = 20 }); + await _db.SaveChangesAsync(); + + var results = await _db.IntEntities.AsQueryable() + .Where(x => x.Name == "Pranzo") + .ToListAsync(); + + Assert.Single(results); + Assert.Equal(1, results[0].Id); + Assert.Equal("Pranzo", results[0].Name); + } + + [Fact] + public async Task CountAsync_OnUnindexedNumericField_DoesNotCountCrossCollectionRows() + { + await _db.Users.InsertAsync(new User { Name = "Alpha", Age = 30 }); + await _db.People.InsertAsync(new Person { Id = 101, Name = "Foreign", Age = 30 }); + await _db.SaveChangesAsync(); + + var count = await _db.Users.AsQueryable() + .CountAsync(x => x.Age == 30); + + Assert.Equal(1, count); + } + + [Fact] + public async Task MaxAsync_FallbackScan_OnUnindexedNumericField_DoesNotReadCrossCollectionRows() + { + await _db.Users.InsertAsync(new User { Name = "Alpha", Age = 30 }); + await _db.People.InsertAsync(new Person { Id = 101, Name = "Foreign", Age = 99 }); + await _db.SaveChangesAsync(); + + var plan = IndexMinMax.Scan(BsonAggregator.Max("age")); + var maxAge = await _db.Users.AsQueryable().MaxAsync(plan); + + Assert.Equal(30, maxAge); + } + + [Fact] + public async Task ParallelScan_OnSharedPage_OnlyReturnsCurrentCollectionRows() + { + await _db.Users.InsertAsync(new User { Name = "Pranzo", Age = 30 }); + await _db.People.InsertAsync(new Person { Id = 101, Name = "Pranzo", Age = 20 }); + await _db.SaveChangesAsync(); + + var results = await _db.Users.ParallelScanAsync(reader => + { + reader.ReadDocumentSize(); + while (reader.Remaining > 0) + { + var type = reader.ReadBsonType(); + if (type == 0) break; + + var fieldName = reader.ReadElementHeader(); + if (fieldName == "name") + return reader.ReadString() == "Pranzo"; + + reader.SkipValue(type); + } + + return false; + }, degreeOfParallelism: 2).ToListAsync(); + + Assert.Single(results); + Assert.Equal("Pranzo", results[0].Name); + Assert.Equal(30, results[0].Age); + } + public void Dispose() { _db.Dispose(); diff --git a/tests/BLite.Tests/RetentionPolicyTests.cs b/tests/BLite.Tests/RetentionPolicyTests.cs index 3d107e5..445e1d3 100644 --- a/tests/BLite.Tests/RetentionPolicyTests.cs +++ b/tests/BLite.Tests/RetentionPolicyTests.cs @@ -442,6 +442,34 @@ public async Task TypedCollection_MaxAge_DeletesOlderThanCutoff() Assert.Equal("new1", results[0].SensorId); } + [Fact] + public async Task TypedCollection_MaxAge_ExemptsOverflowDocuments() + { + using var col = GetCollection(); + col.SetRetentionPolicy(new RetentionPolicy + { + MaxAgeMs = (long)TimeSpan.FromHours(1).TotalMilliseconds, + TimestampField = "timestamp", + Triggers = RetentionTrigger.None + }); + + var old = DateTime.UtcNow.AddHours(-2); + var now = DateTime.UtcNow; + var overflowSensorId = new string('O', 20 * 1024); + + await col.InsertAsync(new BLite.Shared.SensorReading { SensorId = overflowSensorId, Value = 1, Timestamp = old }); + await col.InsertAsync(new BLite.Shared.SensorReading { SensorId = "old-inline", Value = 2, Timestamp = old }); + await col.InsertAsync(new BLite.Shared.SensorReading { SensorId = "new-inline", Value = 3, Timestamp = now }); + + await col.ForceApplyRetentionPolicyAsync(); + + var results = await col.FindAllAsync().ToListAsync(); + Assert.Equal(2, results.Count); + Assert.Contains(results, r => r.SensorId == overflowSensorId); + Assert.Contains(results, r => r.SensorId == "new-inline"); + Assert.DoesNotContain(results, r => r.SensorId == "old-inline"); + } + [Fact] public async Task TypedCollection_OnInsert_TriggersRetention() {