diff --git a/src/BLite.Core/BLiteMigration.cs b/src/BLite.Core/BLiteMigration.cs index 3388358..cbec33d 100644 --- a/src/BLite.Core/BLiteMigration.cs +++ b/src/BLite.Core/BLiteMigration.cs @@ -290,6 +290,9 @@ private static async Task RecreateIndexAsync(DynamicCollection col, IndexMetadat case IndexType.Spatial: await col.CreateSpatialIndexAsync(field, idx.Name); break; + case IndexType.FullText: + await col.CreateFullTextIndexAsync(field, idx.Name); + break; } } diff --git a/src/BLite.Core/Collections/DocumentCollection.cs b/src/BLite.Core/Collections/DocumentCollection.cs index 638e84c..9bf1996 100644 --- a/src/BLite.Core/Collections/DocumentCollection.cs +++ b/src/BLite.Core/Collections/DocumentCollection.cs @@ -1,4532 +1,4629 @@ -using BLite.Core.CDC; -using System; -using System.Buffers; -using System.Buffers.Binary; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using BLite.Bson; -using BLite.Core.Indexing; -using BLite.Core.Metadata; -using BLite.Core.Metrics; -using BLite.Core.Retention; -using BLite.Core.Storage; -using BLite.Core.Transactions; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; -using System.Linq.Expressions; -using BLite.Core.Query; - -[assembly: InternalsVisibleTo("BLite.Tests")] - -namespace BLite.Core.Collections; - -public class DocumentCollection : DocumentCollection, IDocumentCollection where T : class -{ - [RequiresDynamicCode("DocumentCollection uses CollectionIndexManager which compiles index key selectors via Expression.Compile().")] - [RequiresUnreferencedCode("Index creation uses reflection (Expression.PropertyOrField) to access type members. Ensure all entity types and their members are preserved.")] - public DocumentCollection(StorageEngine storage, ITransactionHolder transactionHolder, IDocumentMapper mapper, string? collectionName = null) - : base(storage, transactionHolder, mapper, collectionName) - { - } -} - -/// -/// Production-ready document collection with slotted page architecture. -/// Supports multiple documents per page, overflow chains, and efficient space utilization. -/// -/// Represents a collection of documents of type T with an ID of type TId. -/// -/// Type of the primary key -/// Type of the entity -public class DocumentCollection : IDocumentCollection, IDisposable where T : class -{ - private readonly ITransactionHolder _transactionHolder; - private readonly StorageEngine _storage; - private readonly IDocumentMapper _mapper; - internal readonly BTreeIndex _primaryIndex; - private readonly CollectionIndexManager _indexManager; - private readonly string _collectionName; - - // Free space tracking: 16-bucket index for O(1) FindPage - private readonly FreeSpaceIndex _fsi; - // Cached delegate — avoids per-call closure allocation when passed to _fsi.FindPage. - private readonly Func _isPageLocked; - private bool _isTimeSeries; - private string? _ttlFieldName; - - // ── Generalized Retention Policy ───────────────────────────────────────── - private RetentionPolicy? _retentionPolicy; - private Timer? _retentionTimer; - private int _retentionRunning; // 0=idle, 1=running (Interlocked flag) - - // Current page for inserts (optimization) - private uint _currentDataPage; - - public SchemaVersion? CurrentSchemaVersion { get; private set; } - - // Concurrency control for write operations (B-Tree and Page modifications). - // Obtained from the storage engine: single-file databases share one instance across all - // collections, because page placement depends on file-wide state. Never disposed here — the - // instance may be shared with other collections. - private readonly SemaphoreSlim _collectionLock; - private int WriteLockTimeoutMs => _storage.LockTimeout.WriteTimeoutMs; - - private readonly int _maxDocumentSizeForSinglePage; - - // Tracks the last successful serialized size (+25% margin) to skip retry steps that are - // known to be too small. Volatile: benign race in Parallel.For — worst case a step is - // not skipped once, never a correctness issue. - private volatile int _lastSerializedSize = 65536; - - // Value converters registered via OnModelCreating (e.g. ValueObject Id converters). - // Exposed to BTreeQueryProvider so the query engine can convert ValueObjects to BSON - // primitives at query-plan time, enabling index lookups on ValueObject-keyed collections. - internal ValueConverterRegistry ConverterRegistry { get; private set; } = ValueConverterRegistry.Empty; - - internal void SetConverterRegistry(ValueConverterRegistry registry) => - ConverterRegistry = registry ?? ValueConverterRegistry.Empty; - - /// The name of this collection (lowercase). Exposed for audit and query components. - internal string CollectionName => _collectionName; - - /// The storage engine backing this collection. Exposed for audit hooks. - internal StorageEngine Storage => _storage; - - /// - /// Exposes the storage key map (field name → field ID) so that query components - /// in the same assembly (e.g. ) can pass - /// it to projection/predicate compilers for offset-table fast paths. - /// - internal IReadOnlyDictionary GetKeyMap() => _storage.GetKeyMap(); - - /// - /// Configures this collection as a TimeSeries with automatic TTL-based pruning. - /// Must be called before the first insert. Persists configuration to storage metadata. - /// - internal void SetTimeSeries(string ttlFieldName, TimeSpan retention) - { - var meta = _indexManager.GetMetadata(); - meta.IsTimeSeries = true; - meta.TtlFieldName = ttlFieldName; - meta.RetentionPolicyMs = (long)retention.TotalMilliseconds; - meta.LastPruningTimestamp = DateTime.UtcNow.Ticks; - _storage.SaveCollectionMetadata(meta); - _isTimeSeries = true; - _ttlFieldName = ttlFieldName; - } - - /// - /// Forces immediate pruning of expired TimeSeries documents, regardless of automatic thresholds. - /// Primarily intended for testing; in production, pruning is triggered automatically on insert. - /// - public async Task ForcePruneAsync() - { - if (!_isTimeSeries) - throw new InvalidOperationException("ForcePrune is only valid on TimeSeries collections."); - - // Read fresh metadata from storage: InsertTimeSeries updates TimeSeriesHeadPageId - // in StorageEngine's own metadata store, which is separate from _indexManager._metadata. - var meta = _storage.GetCollectionMetadata(_collectionName); - if (meta == null || meta.RetentionPolicyMs <= 0) return; - - var transaction = _storage.BeginTransaction(IsolationLevel.ReadCommitted); - _storage.PruneTimeSeries(meta, transaction); - meta.InsertedSinceLastPruning = 0; - meta.LastPruningTimestamp = DateTime.UtcNow.Ticks; - _storage.SaveCollectionMetadata(meta); - await transaction.CommitAsync(); - } - - #region Retention Policy - - private static readonly TimeSpan DefaultScheduledInterval = TimeSpan.FromMinutes(5); - - /// - /// Configures a generalized retention policy for this collection. - /// The policy is persisted in collection metadata and survives engine restarts. - /// Can be called directly on the collection or via the model builder in - /// configuration. - /// - /// The retention policy to apply. Must not be null. - public void SetRetentionPolicy(RetentionPolicy policy) - { - if (policy == null) throw new ArgumentNullException(nameof(policy)); - var meta = _indexManager.GetMetadata(); - meta.GeneralRetentionPolicy = policy; - _storage.SaveCollectionMetadata(meta); - ApplyRetentionPolicyConfig(policy); - } - - /// - /// Immediately runs the retention policy regardless of any triggers. - /// Primarily intended for testing; in production, retention is triggered automatically. - /// - public async Task ForceApplyRetentionPolicyAsync(CancellationToken ct = default) - { - // Load from persisted metadata if not yet set in memory. - if (_retentionPolicy == null) - { - var metaPolicy = _storage.GetCollectionMetadata(_collectionName)?.GeneralRetentionPolicy; - if (metaPolicy == null) return; - ApplyRetentionPolicyConfig(metaPolicy); - } - - if (!await _collectionLock.WaitAsync(WriteLockTimeoutMs, ct)) - throw new TimeoutException("Timed out acquiring collection lock (ForceApplyRetentionPolicy)."); - try - { - await ApplyRetentionPolicyCoreAsync(ct); - } - finally - { - _collectionLock.Release(); - } - } - - /// - /// Sets up the in-memory retention policy reference and starts/stops the scheduled timer. - /// - private void ApplyRetentionPolicyConfig(RetentionPolicy policy) - { - _retentionPolicy = policy; - - _retentionTimer?.Dispose(); - _retentionTimer = null; - - if ((policy.Triggers & RetentionTrigger.Scheduled) != 0) - { - var interval = policy.ScheduledIntervalMs > 0 - ? TimeSpan.FromMilliseconds(policy.ScheduledIntervalMs) - : DefaultScheduledInterval; - - _retentionTimer = new Timer( - callback: _ => _ = RunScheduledRetentionAsync(), - state: null, - dueTime: interval, - period: interval); - } - } - - private async Task RunScheduledRetentionAsync() - { - if (Interlocked.CompareExchange(ref _retentionRunning, 1, 0) != 0) - return; - try - { - if (_retentionPolicy == null) return; - - if (!await _collectionLock.WaitAsync(WriteLockTimeoutMs)) - return; - try - { - await ApplyRetentionPolicyCoreAsync(CancellationToken.None); - } - finally - { - _collectionLock.Release(); - } - } - catch (OperationCanceledException) - { - // Ignore cancellation from background retention execution. - } - catch (Exception ex) when (!IsFatalException(ex)) - { - // Swallow non-fatal exceptions from the background timer to avoid crashing the process. - } - finally - { - Interlocked.Exchange(ref _retentionRunning, 0); - } - } - - private static bool IsFatalException(Exception ex) => - ex is OutOfMemoryException - or StackOverflowException - or AccessViolationException - or AppDomainUnloadedException - or BadImageFormatException - or CannotUnloadAppDomainException - or InvalidProgramException - or ThreadAbortException; - - /// - /// Core retention enforcement. Must be called with the collection lock held. - /// - private async Task ApplyRetentionPolicyCoreAsync(CancellationToken ct) - { - var policy = _retentionPolicy!; - - // Collect all document IDs and (optionally) timestamps using a raw BSON scan. - var entries = new List<(TId Id, long TimestampTicks)>(); - long nowTicks = DateTime.UtcNow.Ticks; - - foreach (var entry in _primaryIndex.Range(IndexKey.MinKey, IndexKey.MaxKey, IndexDirection.Forward, 0UL)) - { - ct.ThrowIfCancellationRequested(); - var id = _mapper.FromIndexKey(entry.Key); - long ticks = 0; - - if (policy.TimestampField != null) - { - var rawBytes = ReadRawBytesAt(entry.Location, 0UL); - if (rawBytes != null) - ticks = ExtractTimestampTicks(rawBytes, policy.TimestampField); - } - - entries.Add((id, ticks)); - } - - var toDelete = new HashSet(); - - // MaxAge — delete documents older than the cutoff - if (policy.MaxAgeMs > 0 && policy.TimestampField != null) - { - long cutoff = nowTicks - (policy.MaxAgeMs * TimeSpan.TicksPerMillisecond); - foreach (var (id, ticks) in entries) - { - ct.ThrowIfCancellationRequested(); - if (ticks > 0 && ticks < cutoff) - toDelete.Add(id); - } - } - - // MaxDocumentCount — delete oldest documents over the limit - if (policy.MaxDocumentCount > 0 && entries.Count > policy.MaxDocumentCount) - { - int excess = (int)(entries.Count - policy.MaxDocumentCount); - var ordered = policy.TimestampField != null - ? entries.OrderBy(e => e.TimestampTicks == 0 ? long.MaxValue : e.TimestampTicks) - : entries.AsEnumerable(); - - foreach (var (id, _) in ordered.Take(excess)) - { - ct.ThrowIfCancellationRequested(); - toDelete.Add(id); - } - } - - // MaxSizeBytes — delete oldest documents until size is within limit - if (policy.MaxSizeBytes > 0) - { - long estimatedBytes = EstimateCollectionSizeBytes(); - if (estimatedBytes > policy.MaxSizeBytes) - { - var ordered = policy.TimestampField != null - ? entries.OrderBy(e => e.TimestampTicks == 0 ? long.MaxValue : e.TimestampTicks) - : entries.AsEnumerable(); - - foreach (var (id, _) in ordered) - { - ct.ThrowIfCancellationRequested(); - if (estimatedBytes <= policy.MaxSizeBytes) break; - toDelete.Add(id); - estimatedBytes -= _storage.PageSize / Math.Max(entries.Count, 1); - } - } - } - - if (toDelete.Count == 0) return; - - // Delete the collected IDs in a single transaction via the existing DeleteCore path. - var transaction = _storage.BeginTransaction(IsolationLevel.ReadCommitted); - try - { - foreach (var id in toDelete) - { - ct.ThrowIfCancellationRequested(); - await DeleteCore(id, transaction); - } - await transaction.CommitAsync(ct); - } - catch - { - await transaction.RollbackAsync(); - throw; - } - } - - /// - /// Reads the raw BSON bytes for a document at the given location without deserializing. - /// Returns null if the slot is deleted or the page/location is invalid. - /// Overflow documents are reassembled only when is true. - /// - private static bool TryReadInlineRawBytes(byte[] pageBuffer, ushort slotIndex, out ReadOnlySpan rawBytes) - { - 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 - { - 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; - 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); - } - - return offset == totalLength ? fullBuffer : null; - } - finally - { - 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; - } - - /// Processes the raw BSON of one document in place; returns whether to yield . - private delegate bool RawDocumentVisitor(ReadOnlySpan bson, out TResult result); - - /// - /// Visits the raw BSON of every document in primary-key order. Single-page documents are - /// handed to straight from the page buffer, without a copy; only - /// overflow documents are reassembled. Each page is read once and kept until the scan ends: - /// primary-key order is not page order (ObjectIds created in the same second are not - /// monotonic), so a bounded window of pages would read the same pages over and over. - /// - private async IAsyncEnumerable VisitRawDocumentsAsync( - ulong txnId, - RawDocumentVisitor visit, - [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 pageBuffer)) - { - pageBuffer = ArrayPool.Shared.Rent(_storage.PageSize); - _storage.ReadPage(entry.Location.PageId, txnId, pageBuffer); - pageCache[entry.Location.PageId] = pageBuffer; - } - - if (TryVisitRawDocument(entry, txnId, pageBuffer, visit, out var result)) - yield return result; - } - } - finally - { - foreach (var buf in pageCache.Values) ArrayPool.Shared.Return(buf); - } - } - - /// - /// Applies to the document points at, if that - /// slot still holds the document with this primary key. - /// - private bool TryVisitRawDocument(IndexEntry entry, ulong txnId, byte[] pageBuffer, RawDocumentVisitor visit, out TResult result) - { - result = default!; - - var pageType = (PageType)pageBuffer[4]; - if (pageType == PageType.Free || pageType == PageType.Empty || pageType == PageType.TimeSeries) - return false; - - if (TryReadInlineRawBytes(pageBuffer, entry.Location.SlotIndex, out var inline)) - return MatchesIndexEntryKey(inline, entry.Key) && visit(inline, out result); - - var reassembled = ReadRawBytesAt(entry.Location, txnId, includeOverflow: true, pageBuffer); - return reassembled is not null && MatchesIndexEntryKey(reassembled, entry.Key) && visit(reassembled, out result); - } - - /// - /// Extracts a UTC-ticks timestamp from raw BSON bytes using the given field name. - /// Resolves the field name against the Key Dictionary for correct C-BSON lookup, - /// then uses the O(1) offset-table fast path when available. - /// Returns 0 if the field is absent or is not a DateTime/Int64. - /// - private long ExtractTimestampTicks(byte[] bsonBytes, string fieldName) - { - // fieldName is already lower-cased by RetentionPolicyBuilder.OnField(). - // Resolve to key ID via the forward dictionary for fast C-BSON seek. - var keyMap = _storage.GetKeyMap(); - bool hasKeyId = keyMap.TryGetValue(fieldName, out var keyId); - - try - { - var reader = new BsonSpanReader(bsonBytes, _storage.GetKeyReverseMap()); - reader.ReadDocumentSize(); - - // Fast path: use offset table via key ID. - if (hasKeyId && reader.TrySeekToField(keyId, out var seekType)) - { - var value = BsonValue.ReadFrom(ref reader, seekType); - return value.Type switch - { - BsonType.DateTime or BsonType.DateTimeOffset => value.AsDateTime.Ticks, - BsonType.Int64 => value.AsInt64, - _ => 0 - }; - } - - // Slow path: sequential scan (offset table absent or field ID out of range). - while (reader.Remaining > 1) - { - var type = reader.ReadBsonType(); - if (type == BsonType.EndOfDocument) break; - var name = reader.ReadElementHeader(); - // Both sides are lowercase: fieldName normalised by builder, name from key reverse map. - if (name != fieldName) - { - reader.SkipValue(type); - continue; - } - var val = BsonValue.ReadFrom(ref reader, type); - return val.Type switch - { - BsonType.DateTime or BsonType.DateTimeOffset => val.AsDateTime.Ticks, - BsonType.Int64 => val.AsInt64, - _ => 0 - }; - } - } - catch (InvalidOperationException) { return 0; } - catch (ArgumentOutOfRangeException) { return 0; } - catch (IndexOutOfRangeException) { return 0; } - return 0; - } - - /// - /// Estimates the total size of this collection's data pages in bytes. - /// - private long EstimateCollectionSizeBytes() - { - long count = 0; - var hdrBuf = new byte[SlottedPageHeader.Size]; - foreach (var pageId in _storage.GetCollectionPageIds(_collectionName)) - { - _storage.ReadPageHeader(pageId, null, hdrBuf); - var h = SlottedPageHeader.ReadFrom(hdrBuf); - if (h.PageType == PageType.Data) - count++; - } - return count * _storage.PageSize; - } - - #endregion - - /// - /// Compacts all data pages in this collection, securely erasing freed byte ranges - /// by zero-filling the free space area of every page after compaction. - /// - /// Acquires the collection write lock for the duration of the operation — - /// no concurrent writes to this collection are allowed while VACUUM runs. - /// - /// - public async Task VacuumAsync(VacuumOptions? options = null, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - - if (!await _collectionLock.WaitAsync(WriteLockTimeoutMs, ct).ConfigureAwait(false)) - throw new TimeoutException("Timed out acquiring collection write lock (VacuumAsync)."); - - var sw = _storage.MetricsDispatcher != null ? ValueStopwatch.StartNew() : default; - long bytesFreed = 0; - bool success = false; - try - { - var buffer = ArrayPool.Shared.Rent(_storage.PageSize); - try - { - var transaction = _storage.BeginTransaction(IsolationLevel.ReadCommitted); - try - { - bool secureErase = options?.SecureErase ?? true; - foreach (var pageId in _storage.GetCollectionPageIds(_collectionName)) - { - ct.ThrowIfCancellationRequested(); - - _storage.ReadPage(pageId, transaction.TransactionId, buffer); - var header = SlottedPageHeader.ReadFrom(buffer.AsSpan(0, SlottedPageHeader.Size)); - - if (header.PageType != PageType.Data) - continue; - - // Compact the page, packing live documents to the top. - CompactPage(buffer.AsSpan(0, _storage.PageSize)); - - // Optionally zero-fill the free space area to erase residual deleted bytes. - var compactedHdr = SlottedPageHeader.ReadFrom(buffer.AsSpan(0, SlottedPageHeader.Size)); - int freeBytes = compactedHdr.FreeSpaceEnd - compactedHdr.FreeSpaceStart; - if (freeBytes > 0) - { - bytesFreed += freeBytes; - if (secureErase) - buffer.AsSpan(compactedHdr.FreeSpaceStart, freeBytes).Clear(); - - _storage.WritePage(pageId, transaction.TransactionId, - buffer.AsSpan(0, _storage.PageSize)); - SnapshotFsiForTransaction(transaction, pageId); - _fsi.Update(pageId, compactedHdr.AvailableFreeSpace); - } - } - - await transaction.CommitAsync(ct).ConfigureAwait(false); - } - catch - { - await _storage.RollbackTransactionAsync(transaction).ConfigureAwait(false); - throw; - } - } - finally - { - ArrayPool.Shared.Return(buffer); - } - - // Optionally rebuild all secondary indexes from scratch. - if (options?.RebuildIndexes == true) - { - foreach (var index in _indexManager.GetAllIndexes()) - { - await RebuildIndexFromScratchAsync(index, ct).ConfigureAwait(false); - } - } - - success = true; - } - finally - { - _collectionLock.Release(); - if (sw.IsActive) - { - _storage.MetricsDispatcher?.Publish(new MetricEvent - { - Timestamp = sw.StartTimestamp, - Type = MetricEventType.Vacuum, - ElapsedMicros = sw.GetElapsedMicros(), - BytesFreed = bytesFreed, - Success = success, - }); - } - } - } - - /// - /// Deletes all documents and clears all secondary indexes. - /// The collection structure (schema, indexes, metadata) is preserved. - /// - public Task TruncateAsync(CancellationToken ct = default) - => TruncateAsync(null, ct); - - /// - /// - /// With a caller-owned , a failure while writing rolls the - /// transaction back ( becomes ), - /// so that a half-applied delete can never be committed. - /// - public async Task TruncateAsync(ITransaction? transaction, CancellationToken ct = default) - { - int deleted = 0; - bool autoCommit = transaction == null; - - WriteFailure.EnsureWritable(transaction); - - if (!await _collectionLock.WaitAsync(WriteLockTimeoutMs, ct).ConfigureAwait(false)) - throw new TimeoutException("Timed out acquiring collection lock (TruncateAsync)."); - - transaction ??= _storage.BeginTransaction(IsolationLevel.ReadCommitted); - try - { - try - { - // Collect all keys first to avoid modifying the index while iterating. - // Use the caller's transaction ID so that uncommitted inserts/updates - // visible to this transaction are included in the truncation. - var txnId = transaction.TransactionId; - var keys = new List(); - foreach (var entry in _primaryIndex.Range(IndexKey.MinKey, IndexKey.MaxKey, IndexDirection.Forward, txnId)) - { - ct.ThrowIfCancellationRequested(); - keys.Add(entry.Key); - } - - foreach (var key in keys) - { - ct.ThrowIfCancellationRequested(); - var id = _mapper.FromIndexKey(key); - if (await DeleteCore(id, transaction, notifyCdc: false).ConfigureAwait(false)) - deleted++; - } - - if (autoCommit) - await transaction.CommitAsync(ct).ConfigureAwait(false); - } - catch (Exception ex) - { - await WriteFailure.HandleAsync(transaction, autoCommit, ex); - throw; - } - } - finally - { - _collectionLock.Release(); - } - - return deleted; - } - - [RequiresDynamicCode("DocumentCollection uses CollectionIndexManager which compiles index key selectors via Expression.Compile().")] - [RequiresUnreferencedCode("Index creation uses reflection (Expression.PropertyOrField) to access type members. Ensure all entity types and their members are preserved.")] - public DocumentCollection(StorageEngine storage, ITransactionHolder transactionHolder, IDocumentMapper mapper, string? collectionName = null) - : this(storage, transactionHolder, mapper, collectionName, null) - { - } - - [RequiresDynamicCode("DocumentCollection uses CollectionIndexManager which compiles index key selectors via Expression.Compile().")] - [RequiresUnreferencedCode("Index creation uses reflection (Expression.PropertyOrField) to access type members. Ensure all entity types and their members are preserved.")] - internal DocumentCollection(StorageEngine storage, ITransactionHolder transactionHolder, IDocumentMapper mapper, string? collectionName, FreeSpaceIndex? freeSpaceIndex) - { - _storage = storage ?? throw new ArgumentNullException(nameof(storage)); - _transactionHolder = transactionHolder ?? throw new ArgumentNullException(nameof(transactionHolder)); - _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper)); - _collectionName = collectionName ?? _mapper.CollectionName; - _collectionLock = _storage.CreateCollectionWriteLock(); - - // Initialize secondary index manager first (loads metadata including Primary Root Page ID) - _indexManager = new CollectionIndexManager(_storage, _mapper, _collectionName); - _fsi = freeSpaceIndex ?? new FreeSpaceIndex(_storage.PageSize); - _isPageLocked = _storage.IsPageLocked; - - // Calculate max document size dynamically based on page size - // Reserve space for PageHeader (24) and some safety margin - _maxDocumentSizeForSinglePage = _storage.PageSize - 128; - - // Ensure schema is persisted and versioned - EnsureSchema(); - - // Restore TimeSeries flag from persisted metadata - var tsMeta = _indexManager.GetMetadata(); - if (tsMeta.IsTimeSeries) - { - _isTimeSeries = true; - _ttlFieldName = tsMeta.TtlFieldName; - } - - // Create primary index on _id (stores ObjectId → DocumentLocation mapping) - // Use persisted root page ID if available - var indexOptions = IndexOptions.CreateUnique("_id"); - _primaryIndex = new BTreeIndex(_storage, indexOptions, _indexManager.PrimaryRootPageId); - - // If a new root page was allocated, persist it - if (_indexManager.PrimaryRootPageId != _primaryIndex.RootPageId) - { - _indexManager.SetPrimaryRootPageId(_primaryIndex.RootPageId); - } - - // Register keys used by the mapper to ensure they are available for compression - _storage.RegisterKeys(_mapper.UsedKeys); - - // Rebuild the free-space index from existing page headers so that a cold-start - // DocumentCollection can reuse partially-filled pages instead of always allocating new ones. - RebuildFreeSpaceIndex(); - - MigrateLegacyDecimalIndexes(); - - // Restore generalized retention policy and set up the scheduled timer if needed. Done last so - // no scheduled pruning can run while an index is being rebuilt above. - if (tsMeta.GeneralRetentionPolicy != null) - ApplyRetentionPolicyConfig(tsMeta.GeneralRetentionPolicy); - } - - /// - /// Decimal properties used to be indexed as (double)value. An index whose persisted - /// predates the exact decimal encoding is rebuilt from the - /// documents into a copy that queries don't see, the first time the collection is opened. Only - /// after the copy committed is it swapped in, with the current format, in one metadata save, and - /// the old tree freed. If the rebuild fails or is interrupted, the copy's pages are freed and the - /// old index stays registered in its old format, so the next open repeats the migration. - /// No-op for every other index. - /// - private void MigrateLegacyDecimalIndexes() - { - foreach (var index in _indexManager.GetAllIndexes()) - { - if (!index.NeedsKeyFormatMigration) continue; - - var name = index.Name; - try - { - var fresh = _indexManager.CreateUnregistered(index.Definition); - fresh.KeyFormat = IndexMetadata.CurrentKeyFormat; - // Run on the thread pool so a captured SynchronizationContext cannot deadlock the wait. - // A document that cannot be loaded fails the migration rather than silently leaving - // the rebuilt index incomplete. - Task.Run(() => FillUnregisteredIndexAsync(fresh, CancellationToken.None)).GetAwaiter().GetResult(); - PublishFilledIndex(fresh, copy => _indexManager.ReplaceIndex(name, copy)); - } - catch (Exception ex) - { - throw new InvalidOperationException( - $"Failed to rebuild index '{name}' of collection '{_collectionName}' with the exact decimal key format; the migration is retried on the next open.", ex); - } - } - } - - - /// - /// Scans the header of every data page belonging to this collection and - /// populates the free-space index. This eliminates the cold-start allocation - /// defect where a newly-constructed DocumentCollection always allocated a fresh - /// page on the first insert even when existing pages had ample free space. - /// - private void RebuildFreeSpaceIndex() - { - // Read only the fixed-size page header (24 bytes) rather than renting a full-page - // buffer. Using a stack-allocated span avoids any heap allocation and reduces - // memory traffic: we copy 24 bytes per page instead of the full page size. - Span hdrBuf = stackalloc byte[SlottedPageHeader.Size]; - uint lastDataPage = 0; - - foreach (var pageId in _storage.GetCollectionPageIds(_collectionName)) - { - _storage.ReadPageHeader(pageId, null, hdrBuf); - var hdr = SlottedPageHeader.ReadFrom(hdrBuf); - - if (hdr.PageType == PageType.Data) - { - _fsi.Update(pageId, hdr.AvailableFreeSpace); - lastDataPage = pageId; - } - } - - // Seed _currentDataPage with the last data page found so that the first - // FindPageWithSpace fast-path hits an already-known page instead of allocating. - if (lastDataPage != 0) - _currentDataPage = lastDataPage; - } - - [RequiresUnreferencedCode("Schema management uses reflection to discover entity properties. Ensure all entity types and their members are preserved.")] - private void EnsureSchema() - { - var currentSchema = _mapper.GetSchema(); - var metadata = _indexManager.GetMetadata(); - - var persistedSchemas = _storage.GetSchemas(metadata.SchemaRootPageId); - var latestPersisted = persistedSchemas.Count > 0 ? persistedSchemas[persistedSchemas.Count - 1] : null; - - if (latestPersisted == null || !currentSchema.Equals(latestPersisted)) - { - // Assign next version number - int nextVersion = persistedSchemas.Count + 1; - currentSchema.Version = nextVersion; - - var newRootId = _storage.AppendSchema(metadata.SchemaRootPageId, currentSchema); - if (newRootId != metadata.SchemaRootPageId) - { - metadata.SchemaRootPageId = newRootId; - _storage.SaveCollectionMetadata(metadata); - } - - CurrentSchemaVersion = new SchemaVersion(nextVersion, currentSchema.GetHash()); - } - else - { - // Latest persisted is same as current structure - int resolvedVersion = latestPersisted.Version ?? persistedSchemas.Count; - CurrentSchemaVersion = new SchemaVersion(resolvedVersion, latestPersisted.GetHash()); - } - } - - #region Index Management API - - /// - /// Asynchronously creates a secondary index on the specified property. - /// - [RequiresDynamicCode("Index creation compiles key selector expressions using Expression.Compile() which requires dynamic code generation.")] - public async Task> CreateIndexAsync( - System.Linq.Expressions.Expression> keySelector, - string? name = null, - bool unique = false, - CancellationToken ct = default) - { - if (keySelector == null) - throw new ArgumentNullException(nameof(keySelector)); - - using (var txn = _storage.BeginTransaction(IsolationLevel.ReadCommitted)) - { - var index = _indexManager.CreateIndex(keySelector, name, unique); - - // Rebuild index for existing documents - await RebuildIndexAsync(index, ct); - - await txn.CommitAsync(); - - return await BindReaderAndReturn(index); - } - } - - /// - /// Asynchronously creates a vector (HNSW) index for similarity search. - /// - [RequiresDynamicCode("Index creation compiles key selector expressions using Expression.Compile() which requires dynamic code generation.")] - public async Task> CreateVectorIndexAsync( - System.Linq.Expressions.Expression> keySelector, - int dimensions, - VectorMetric metric = VectorMetric.Cosine, - string? name = null, - CancellationToken ct = default) - { - if (keySelector == null) throw new ArgumentNullException(nameof(keySelector)); - - using (var txn = _storage.BeginTransaction(IsolationLevel.ReadCommitted)) - { - var index = _indexManager.CreateVectorIndex(keySelector, dimensions, metric, name); - await RebuildIndexAsync(index, ct); - await txn.CommitAsync(); - return await BindReaderAndReturn(index); - } - } - - /// - /// Asynchronously ensures that an index exists on the specified property. - /// If the index already exists, it is returned without modification (idempotent). - /// If it doesn't exist, it is created and populated. - /// - [RequiresDynamicCode("Index creation compiles key selector expressions using Expression.Compile() which requires dynamic code generation.")] - public async Task> EnsureIndexAsync( - System.Linq.Expressions.Expression> keySelector, - string? name = null, - bool unique = false, - CancellationToken ct = default) - { - if (keySelector == null) throw new ArgumentNullException(nameof(keySelector)); - - // 1. Check if index already exists (fast path) - var propertyPaths = ExpressionAnalyzer.ExtractPropertyPaths(keySelector); - var indexName = name ?? $"idx_{string.Join("_", propertyPaths)}"; - - var existingIndex = await GetIndexAsync(indexName); - if (existingIndex != null) - { - return existingIndex; - } - - // 2. Create if missing (slow path: rebuilds index) - return await CreateIndexAsync(keySelector, name, unique, ct); - } - - /// - /// Asynchronously drops (removes) an existing secondary index by name. - /// The primary index (_id) cannot be dropped. - /// - /// Name of the index to drop - /// Cancellation token - /// True if the index was found and dropped, false otherwise - public Task DropIndexAsync(string name, CancellationToken ct = default) - { - if (string.IsNullOrWhiteSpace(name)) - throw new ArgumentException("Index name cannot be empty", nameof(name)); - - // Prevent dropping primary index - if (name.Equals("_id", StringComparison.OrdinalIgnoreCase)) - throw new InvalidOperationException("Cannot drop primary index"); - - // DropIndex is synchronous (no heavy I/O), wrap in Task - return Task.FromResult(_indexManager.DropIndex(name)); - } - - /// - /// Gets metadata about all secondary indexes in this collection. - /// Does not include the primary index (_id). - /// - /// Collection of index metadata - public IEnumerable GetIndexes() - { - return _indexManager.GetIndexInfo(); - } - - [RequiresDynamicCode("Index creation compiles key selector expressions using Expression.Compile() which requires dynamic code generation.")] - internal void ApplyIndexBuilder(Metadata.IndexBuilder builder) - { - var name = CollectionIndexManager.ResolveIndexName(builder.KeySelector, builder.Name); - if (_indexManager.GetIndex(name) != null) - return; - - // The collection may already hold documents, e.g. when a new app version adds the index. - // Fill the index before registering it, so neither a crash nor a document that cannot be - // indexed leaves an empty index persisted that later opens would trust. - var definition = CollectionIndexManager.BuildDefinition( - builder.KeySelector, name, builder.IsUnique, builder.Type, builder.Dimensions, builder.Metric); - var index = _indexManager.CreateUnregistered(definition); - try - { - // Run on the thread pool so a captured SynchronizationContext cannot deadlock the wait. - Task.Run(() => FillUnregisteredIndexAsync(index, CancellationToken.None)).GetAwaiter().GetResult(); - } - catch (Exception ex) - { - throw new InvalidOperationException( - $"Cannot build index '{name}' of collection '{_collectionName}' from the documents already in it: {ex.Message.TrimEnd('.')}. " + - "Nothing was saved, but every open fails the same way until those documents can be indexed or the index " + - "is removed from the model (OnModelCreating).", ex); - } - PublishFilledIndex(index, _indexManager.Register); - } - - /// - /// Asynchronously scans the entire collection using a raw BSON predicate. - /// This avoids deserializing documents that don't match the criteria. - /// - /// Function to evaluate raw BSON data - /// Cancellation token - /// Async enumerable of matching documents - public IAsyncEnumerable ScanAsync( - BsonReaderPredicate predicate, - CancellationToken ct = default) - => ScanAsync(predicate, null, ct); - - public async IAsyncEnumerable ScanAsync( - BsonReaderPredicate predicate, - ITransaction? transaction, - [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default) - { - if (predicate == null) throw new ArgumentNullException(nameof(predicate)); - - var sw = _storage.MetricsDispatcher != null ? ValueStopwatch.StartNew() : default; - var txnId = transaction?.TransactionId ?? 0UL; - var keyMap = _storage.GetKeyReverseMap(); - - try - { - await foreach (var document in VisitRawDocumentsAsync(txnId, (ReadOnlySpan bson, out T doc) => - { - doc = default!; - if (!predicate(new BsonSpanReader(bson, keyMap))) return false; - doc = _mapper.Deserialize(new BsonSpanReader(bson, keyMap)); - return true; - }, ct).ConfigureAwait(false)) - { - yield return document; - } - } - finally - { - if (sw.IsActive) - _storage.MetricsDispatcher?.Publish(new MetricEvent - { - Timestamp = sw.StartTimestamp, - Type = MetricEventType.CollectionQuery, - ElapsedMicros = sw.GetElapsedMicros(), - CollectionName = _collectionName, - Success = true, - }); - } - } - - /// - /// Executes a query described by an . - /// - /// - /// Index range – the collection's B-Tree index named - /// is seeked between - /// and (both inclusive). - /// - /// - /// Index IN – each key in InKeys is looked up as an exact match. - /// - /// - /// Full scan – every document is evaluated against - /// . - /// - /// - /// An optional is applied as a BSON-level - /// post-filter on every candidate before it is deserialised and yielded. - /// - public async IAsyncEnumerable ScanAsync( - IndexQueryPlan plan, - [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default) - { - if (plan == null) throw new ArgumentNullException(nameof(plan)); - - switch (plan.Kind) - { - case IndexQueryPlan.PlanKind.IndexRange: - { - await foreach (var doc in QueryIndexAsync(plan.IndexName!, plan.MinKey, plan.MaxKey, ct: ct)) - { - ct.ThrowIfCancellationRequested(); - if (MatchesResidue(doc, plan.ResiduePredicate)) - yield return doc; - } - break; - } - - case IndexQueryPlan.PlanKind.IndexIn: - { - if (plan.InKeys != null) - { - foreach (var key in plan.InKeys) - { - await foreach (var doc in QueryIndexAsync(plan.IndexName!, (object?)key, (object?)key, ct: ct)) - { - ct.ThrowIfCancellationRequested(); - if (MatchesResidue(doc, plan.ResiduePredicate)) - yield return doc; - } - } - } - break; - } - - case IndexQueryPlan.PlanKind.Scan: - default: - { - var predicate = plan.ScanPredicate ?? (_ => true); - await foreach (var doc in ScanAsync(predicate, ct)) - { - yield return doc; - } - break; - } - } - } - - /// - /// Evaluates against a serialised form of . - /// Returns true when is null (no post-filter). - /// - /// Serialisation is done into a pooled buffer to avoid heap pressure; the buffer is - /// returned to the pool in the finally block before the caller receives the result. - /// - private bool MatchesResidue(T doc, BsonReaderPredicate? residue) - { - if (residue == null) return true; - - var buf = ArrayPool.Shared.Rent(_maxDocumentSizeForSinglePage + 128); - try - { - int written = _mapper.Serialize(doc, new BsonSpanWriter(buf, _storage.GetFrozenKeyMap())); - var reader = new BsonSpanReader(buf.AsSpan(0, written), _storage.GetKeyReverseMap()); - return residue(reader); - } - finally - { - ArrayPool.Shared.Return(buf); - } - } - - - /// - /// - /// When both selectors access a single flat, scalar property - /// (supported by ) a single-pass BSON - /// projector is compiled and the two fields are read in one sweep. - /// Falls back to full deserialization + LINQ projection otherwise. - /// - public async IAsyncEnumerable<(TKey Key, TValue Value)> ScanPairsAsync( - Expression> keySelector, - Expression> valueSelector) - { - if (keySelector is null) throw new ArgumentNullException(nameof(keySelector)); - if (valueSelector is null) throw new ArgumentNullException(nameof(valueSelector)); - - var projector = BsonProjectionCompiler.TryCompilePair( - keySelector, valueSelector, _storage.GetKeyMap()); - - if (projector is not null) - { - await foreach (var t in ScanAsync(projector)) { yield return t; } - yield break; - } - - // Fallback: full deserialization. - var keyFunc = keySelector.Compile(); - var valFunc = valueSelector.Compile(); - await foreach (var item in FindAllAsync()) - { - yield return (keyFunc(item), valFunc(item)); - } - } - - /// - /// Asynchronously scans the entire collection applying a directly - /// to raw BSON bytes. Only the fields accessed by the projector are read. - /// - /// The projected result type. - /// - /// A delegate that receives a positioned at the start of each - /// document and returns the projected value, or null to skip the document. - /// - /// Cancellation token - /// Async enumerable of projected results - public async IAsyncEnumerable ScanAsync( - BsonReaderProjector projector, - [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default) - { - if (projector == null) throw new ArgumentNullException(nameof(projector)); - - var txnId = 0UL; - var keyMap = _storage.GetKeyReverseMap(); - - await foreach (var result in VisitRawDocumentsAsync(txnId, (ReadOnlySpan bson, out TResult projected) => - { - projected = projector(new BsonSpanReader(bson, keyMap))!; - return projected is not null; - }, ct).ConfigureAwait(false)) - { - yield return result; - } - } - - /// - /// Counts documents matching by evaluating the predicate - /// directly on raw BSON bytes — no CLR instances are ever created. - /// - /// - /// 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, - CancellationToken ct = default) - { - if (predicate == null) throw new ArgumentNullException(nameof(predicate)); - - var txnId = 0UL; - int count = 0; - var keyMap = _storage.GetKeyReverseMap(); - - await foreach (var _ in VisitRawDocumentsAsync(txnId, (ReadOnlySpan bson, out bool matched) => - { - try - { - matched = predicate(new BsonSpanReader(bson, keyMap)); - } - catch - { - matched = false; // Malformed BSON — skip silently. - } - return matched; - }, ct).ConfigureAwait(false)) - { - count++; - } - - return count; - } - - private void ScanPageProjected( - uint pageId, - ulong txnId, - byte[] buffer, - BsonReaderProjector projector, - List results) - { - _storage.ReadPage(pageId, txnId, buffer); - var header = SlottedPageHeader.ReadFrom(buffer); - - if (header.PageType != PageType.Data) - return; - - 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) results.Add(result); - } - } - - /// - /// Asynchronously scans the collection in parallel using multiple tasks. - /// Useful for large collections on multi-core machines. - /// - /// Function to evaluate raw BSON data - /// Number of concurrent tasks (default: -1 = ProcessorCount) - /// Cancellation token - /// Async enumerable of matching documents - public async IAsyncEnumerable ParallelScanAsync( - BsonReaderPredicate predicate, - int degreeOfParallelism = -1, - [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default) - { - if (predicate == null) throw new ArgumentNullException(nameof(predicate)); - - var txnId = 0UL; - 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); - - async Task QueueBatchAsync(List entries) - { - await semaphore.WaitAsync(ct).ConfigureAwait(false); - var localBatch = entries.ToArray(); - - var task = Task.Run(async () => - { - try - { - var results = new List(); - var pageCache = new Dictionary(); - - try - { - foreach (var entry in localBatch) - { - ct.ThrowIfCancellationRequested(); - var location = entry.Location; - - if (!pageCache.TryGetValue(location.PageId, out var buffer)) - { - buffer = ArrayPool.Shared.Rent(_storage.PageSize); - await _storage.ReadPageAsync(location.PageId, txnId, buffer.AsMemory(0, _storage.PageSize), ct).ConfigureAwait(false); - pageCache[location.PageId] = buffer; - } - - 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 - { - foreach (var buffer in pageCache.Values) - ArrayPool.Shared.Return(buffer); - } - - return results; - } - finally - { - semaphore.Release(); - } - }, ct); - - 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).ConfigureAwait(false); - tasks.Remove(completedTask); - - var results = await completedTask.ConfigureAwait(false); - foreach (var doc in results) - { - yield return doc; - } - } - } - - /// - /// Gets a queryable interface for this collection. - /// Supports LINQ queries that are translated to optimized BTree scans or index lookups. - /// - [RequiresDynamicCode("LINQ queries over BLite collections use Expression.Compile() and MakeGenericMethod which require dynamic code generation.")] - [RequiresUnreferencedCode("LINQ queries over BLite collections use reflection to resolve methods at runtime. Ensure all entity types and their members are preserved.")] - public IBLiteQueryable AsQueryable() - { - return new BTreeQueryable(new BTreeQueryProvider(this, ConverterRegistry)); - } - - /// Secondary index manager of this collection (test and migration hooks). - internal CollectionIndexManager IndexManager => _indexManager; - - /// - /// Gets a specific secondary index by name for advanced querying. - /// Returns null if the index doesn't exist. - /// - public async Task?> GetIndexAsync(string name) - { - var idx = _indexManager.GetIndex(name); - return idx is null ? null : await BindReaderAndReturn(idx); - } - - private async Task> BindReaderAndReturn(CollectionSecondaryIndex index) - { - index.SetDocumentReader( - async: (loc, ct) => new ValueTask(FindByLocation(loc))); - return index; - } - - /// - /// Asynchronously queries a specific index for a range of values. - /// Returns matching documents using the index for efficient retrieval. - /// - /// Name of the index to query - /// Minimum key value (inclusive) - /// Maximum key value (inclusive) - /// True for ascending order, false for descending - /// Number of index entries to skip before yielding documents. - /// Skipping is performed at the index-entry level so documents are never read for - /// skipped positions, enabling efficient pagination without full materialization. - /// Maximum number of documents to yield. Defaults to all. - /// Cancellation token - /// Async enumerable of matching documents - public IAsyncEnumerable QueryIndexAsync( - string indexName, - object? minKey, - object? maxKey, - bool ascending = true, - int skip = 0, - int take = int.MaxValue, - CancellationToken ct = default) - => QueryIndexAsync(indexName, minKey, maxKey, ascending, skip, take, null, ct); - - public async IAsyncEnumerable QueryIndexAsync( - string indexName, - object? minKey, - object? maxKey, - bool ascending, - int skip, - int take, - ITransaction? transaction, - [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default) - { - var index = _indexManager.GetIndex(indexName); - if (index == null) throw new ArgumentException($"Index {indexName} not found"); - - var sw = _storage.MetricsDispatcher != null ? ValueStopwatch.StartNew() : default; - var txnId = transaction?.TransactionId ?? 0UL; - var direction = ascending ? IndexDirection.Forward : IndexDirection.Backward; - - // Per-query page cache: when multiple index entries point to the same data page - // (common for small documents), each page is loaded only once. - // For a collection of 200-byte docs on 16 KB pages (~80 docs/page), a query - // returning 250 docs touches ~3 unique pages — without this cache we'd do 250 - // ReadPage calls instead of 3. - var pageCache = new Dictionary(); - try - { - int skipped = 0; - int taken = 0; - foreach (var location in index.Range(minKey, maxKey, direction, transaction)) - { - // Skip index entries without reading documents — O(skip) index key reads, - // zero document deserializations for skipped positions. - if (skip > 0 && skipped < skip) { skipped++; continue; } - - ct.ThrowIfCancellationRequested(); - - if (!pageCache.TryGetValue(location.PageId, out var cachedBuffer)) - { - cachedBuffer = ArrayPool.Shared.Rent(_storage.PageSize); - _storage.ReadPage(location.PageId, txnId, cachedBuffer); - pageCache[location.PageId] = cachedBuffer; - } - - var doc = await FindByLocationAsync(location, txnId, cachedBuffer, ct); - if (doc != null) - { - yield return doc; - if (++taken >= take) break; - } - } - } - finally - { - foreach (var buf in pageCache.Values) - ArrayPool.Shared.Return(buf); - if (sw.IsActive) - _storage.MetricsDispatcher?.Publish(new MetricEvent - { - Timestamp = sw.StartTimestamp, - Type = MetricEventType.CollectionQuery, - ElapsedMicros = sw.GetElapsedMicros(), - CollectionName = _collectionName, - Success = true, - }); - } - } - - /// - /// Populates an empty copy of from the documents and swaps it in - /// only after the rebuild committed; the old tree is then freed. If any document cannot be - /// loaded or indexed, the rebuild fails and the existing index stays in place untouched, - /// instead of being replaced by an incomplete one. - /// - private async Task RebuildIndexFromScratchAsync(CollectionSecondaryIndex current, CancellationToken ct) - { - var fresh = _indexManager.CreateUnregistered(current.Definition); - await FillUnregisteredIndexAsync(fresh, ct).ConfigureAwait(false); - PublishFilledIndex(fresh, copy => _indexManager.ReplaceIndex(current.Name, copy)); - } - - /// - /// Fills an index that is not registered yet (see ) - /// from every document, in its own transaction, and commits it. If a document cannot be loaded - /// or indexed, or the commit fails, every page of the index is freed, including the pages its - /// splits added, the index is disposed and the exception propagates. - /// - private async Task FillUnregisteredIndexAsync(CollectionSecondaryIndex index, CancellationToken ct) - { - var transaction = _storage.BeginTransaction(IsolationLevel.ReadCommitted); - try - { - await FillIndexAsync(index, transaction, skipUnreadableDocuments: false, ct).ConfigureAwait(false); - await transaction.CommitAsync(ct); - } - catch - { - // Collect the index's pages through the transaction before rolling it back: pages added - // by splits are allocated outside the transaction but linked only from nodes written - // in it, so after the rollback they could not be found any more. - IReadOnlyList pages = Array.Empty(); - try { pages = index.CollectAllPages(transaction.TransactionId); } - catch { /* best-effort: never mask the original failure */ } - - try { await transaction.RollbackAsync(); } - catch { /* best-effort: never mask the original failure */ } - - FreeUnregisteredIndex(index, pages); - throw; - } - } - - /// - /// Hands an index filled by to - /// ( or ). - /// If that fails and the index did not get registered, nothing refers to it any more, so its - /// pages are freed and it is disposed. - /// - private void PublishFilledIndex(CollectionSecondaryIndex index, Action> register) - { - try - { - register(index); - } - catch - { - if (!ReferenceEquals(_indexManager.GetIndex(index.Name), index)) - { - IReadOnlyList pages = Array.Empty(); - try { pages = index.CollectAllPages(0); } - catch { /* best-effort: never mask the original failure */ } - FreeUnregisteredIndex(index, pages); - } - throw; - } - } - - private void FreeUnregisteredIndex(CollectionSecondaryIndex index, IReadOnlyList pages) - { - try - { - foreach (var pageId in pages) - _storage.FreePage(pageId); - } - catch { /* best-effort: never mask the original failure */ } - index.Dispose(); - } - - private async Task RebuildIndexAsync(CollectionSecondaryIndex index, CancellationToken ct = default, bool skipUnreadableDocuments = true) - { - var transaction = _storage.BeginTransaction(IsolationLevel.ReadCommitted); - try - { - await FillIndexAsync(index, transaction, skipUnreadableDocuments, ct).ConfigureAwait(false); - await transaction.CommitAsync(ct); - } - catch - { - await transaction.RollbackAsync(); - throw; - } - } - - private async Task FillIndexAsync(CollectionSecondaryIndex index, ITransaction transaction, bool skipUnreadableDocuments, CancellationToken ct) - { - // Iterate all documents in the collection via primary index - foreach (var entry in _primaryIndex.Range(IndexKey.MinKey, IndexKey.MaxKey, IndexDirection.Forward, transaction.TransactionId)) - { - ct.ThrowIfCancellationRequested(); - try - { - var document = await FindByLocationAsync(entry.Location, transaction.TransactionId, ct); - if (document != null) - { - index.Insert(document, entry.Location, transaction); - } - } - catch when (skipUnreadableDocuments) - { - // Skip documents that fail to load or index - // Production: should log errors - } - } - } - - #endregion - - #region Data Page Management - - private Task FindPageWithSpace(int requiredBytes, ITransaction transaction) - { - var txnId = transaction.TransactionId; - - // Fast path: try current page first (avoids a full FSI scan on the common case). - if (_currentDataPage != 0) - { - if (_fsi.TryGetFreeBytes(_currentDataPage, out var freeBytes)) - { - if (freeBytes >= requiredBytes && !_storage.IsPageLocked(_currentDataPage, txnId)) - return Task.FromResult(_currentDataPage); - } - else - { - // Page not yet in FSI (e.g., after a rollback recovery): read header from disk. - Span page = stackalloc byte[SlottedPageHeader.Size]; - _storage.ReadPageHeader(_currentDataPage, null, page); - var header = SlottedPageHeader.ReadFrom(page); - - if (header.AvailableFreeSpace >= requiredBytes) - { - _fsi.Update(_currentDataPage, header.AvailableFreeSpace); - if (!_storage.IsPageLocked(_currentDataPage, txnId)) - return Task.FromResult(_currentDataPage); - } - } - } - - // Search FSI: O(BucketCount) = O(1) regardless of the number of tracked pages. - // Use the cached _isPageLocked delegate to avoid per-call closure allocation. - return Task.FromResult(_fsi.FindPage(requiredBytes, txnId, _isPageLocked)); - } - - private Task AllocateNewDataPage(ITransaction transaction) - { - var pageId = _storage.AllocateCollectionPage(_collectionName); - - // Initialize slotted page header - var buffer = ArrayPool.Shared.Rent(_storage.PageSize); - try - { - buffer.AsSpan().Clear(); - - var header = new SlottedPageHeader - { - PageId = pageId, - PageType = PageType.Data, - SlotCount = 0, - FreeSpaceStart = SlottedPageHeader.Size, - FreeSpaceEnd = (ushort)_storage.PageSize, - NextOverflowPage = 0, - TransactionId = 0 - }; - - header.WriteTo(buffer); - - // Transaction write or direct write - if (transaction is Transaction t) - { - // OPTIMIZATION: Pass ReadOnlyMemory to avoid ToArray() allocation - var writeOp = new WriteOperation(ObjectId.Empty, buffer.AsMemory(0, _storage.PageSize), pageId, OperationType.AllocatePage); - t.AddWrite(writeOp); - } - else - { - _storage.WritePage(pageId, transaction.TransactionId, buffer); - } - - SnapshotFsiForTransaction(transaction, pageId); - _fsi.Update(pageId, header.AvailableFreeSpace); - _currentDataPage = pageId; - } - finally - { - ArrayPool.Shared.Return(buffer); - } - - return Task.FromResult(pageId); - } - - private async Task InsertIntoPage(uint pageId, byte[] data, ITransaction transaction) - { - var buffer = ArrayPool.Shared.Rent(_storage.PageSize); - - try - { - _storage.ReadPage(pageId, transaction.TransactionId, buffer); - - var header = SlottedPageHeader.ReadFrom(buffer); - - // ROLLBACK RECOVERY: If the page is completely zeroed (e.g., from a rolled-back allocation) - // we re-initialize the header for the current transaction. - if (header.PageType == PageType.Empty && header.FreeSpaceEnd == 0) - { - header = new SlottedPageHeader - { - PageId = pageId, - PageType = PageType.Data, - SlotCount = 0, - FreeSpaceStart = SlottedPageHeader.Size, - FreeSpaceEnd = (ushort)_storage.PageSize, - TransactionId = (uint)transaction.TransactionId - }; - header.WriteTo(buffer); - } - - // Check free space - var freeSpace = header.AvailableFreeSpace; - var requiredSpace = data.Length + SlotEntry.Size; - - if (freeSpace < requiredSpace) - { - // Capture the stale FSI value for the diagnostic message before correcting it. - // Correcting the FSI here breaks the "poisoned-cache loop" that arises when a - // transaction that updated the FSI (e.g. a delete) was subsequently rolled back: - // the physical page reverts via WAL but the in-memory FSI is not reverted, - // leaving it with an overly optimistic free-space value. Without this correction - // every subsequent insert would be routed to the same full page and fail. - _fsi.TryGetFreeBytes(pageId, out var staleFsi); - _fsi.Update(pageId, freeSpace); - throw new InvalidOperationException($"Not enough space: need {requiredSpace}, have {freeSpace} | PageId={pageId} | SlotCount={header.SlotCount} | Start={header.FreeSpaceStart} | End={header.FreeSpaceEnd} | FSI={staleFsi}"); - } - - // Find free slot (reuse deleted or create new) - ushort slotIndex = FindFreeSlot(buffer, ref header); - - // Write document at end of used space (grows up) - var docOffset = header.FreeSpaceEnd - data.Length; - data.CopyTo(buffer.AsSpan(docOffset, data.Length)); - - // Write slot entry - var slotOffset = SlottedPageHeader.Size + (slotIndex * SlotEntry.Size); - var slot = new SlotEntry - { - Offset = (ushort)docOffset, - Length = (ushort)data.Length, - Flags = SlotFlags.None - }; - slot.WriteTo(buffer.AsSpan(slotOffset)); - - // UpdateAsync header - if (slotIndex >= header.SlotCount) - header.SlotCount = (ushort)(slotIndex + 1); - - header.FreeSpaceStart = (ushort)(SlottedPageHeader.Size + (header.SlotCount * SlotEntry.Size)); - header.FreeSpaceEnd = (ushort)docOffset; - header.WriteTo(buffer); - - // NEW: Buffer write in transaction or write immediately - if (transaction is Transaction t) - { - // OPTIMIZATION: Pass ReadOnlyMemory to avoid ToArray() allocation - var writeOp = new WriteOperation( - documentId: ObjectId.Empty, - newValue: buffer.AsMemory(0, _storage.PageSize), - pageId: pageId, - type: OperationType.Insert - ); - t.AddWrite(writeOp); - } - else - { - _storage.WritePage(pageId, transaction.TransactionId, buffer); - } - - SnapshotFsiForTransaction(transaction, pageId); - _fsi.Update(pageId, header.AvailableFreeSpace); - - return slotIndex; - } - finally - { - ArrayPool.Shared.Return(buffer); - } - } - - private ushort FindFreeSlot(Span page, ref SlottedPageHeader header) - { - // Scan existing slots for deleted ones - for (ushort i = 0; i < header.SlotCount; i++) - { - var slotOffset = SlottedPageHeader.Size + (i * SlotEntry.Size); - var slot = SlotEntry.ReadFrom(page.Slice(slotOffset, SlotEntry.Size)); - - if ((slot.Flags & SlotFlags.Deleted) != 0) - return i; // Reuse deleted slot - } - - // No free slot, use next index - return header.SlotCount; - } - - private uint AllocateOverflowPage(ReadOnlySpan data, uint nextOverflowPageId, ITransaction transaction) - { - var pageId = _storage.AllocateCollectionPage(_collectionName); - var buffer = ArrayPool.Shared.Rent(_storage.PageSize); - - try - { - buffer.AsSpan().Clear(); - - var header = new SlottedPageHeader - { - PageId = pageId, - PageType = PageType.Overflow, - SlotCount = 0, - FreeSpaceStart = SlottedPageHeader.Size, - FreeSpaceEnd = (ushort)_storage.PageSize, - NextOverflowPage = nextOverflowPageId, - TransactionId = 0 - }; - - header.WriteTo(buffer); - - // Write data immediately after header - data.CopyTo(buffer.AsSpan(SlottedPageHeader.Size)); - - // NEW: Buffer write in transaction or write immediately - var writeOp = new WriteOperation( - documentId: ObjectId.Empty, - newValue: buffer.AsMemory(0, _storage.PageSize), - pageId: pageId, - type: OperationType.Insert - ); - ((Transaction)transaction).AddWrite(writeOp); - - return pageId; - } - finally - { - ArrayPool.Shared.Return(buffer); - } - } - - private async Task<(uint pageId, ushort slotIndex)> InsertWithOverflow(byte[] data, ITransaction transaction) - { - // 1. Calculate Primary Chunk Size - // We need 8 bytes for metadata (TotalLength: 4, NextOverflowPage: 4) - const int MetadataSize = 8; - int maxPrimaryPayload = _maxDocumentSizeForSinglePage - MetadataSize; - - // 2. Build Overflow Chain (Reverse Order) - // We must ensure that pages closer to Primary are FULL (PageSize-Header), - // and only the last page (tail) is partial. This matches FindByLocation greedy reading. - - uint nextOverflowPageId = 0; - int overflowChunkSize = _storage.PageSize - SlottedPageHeader.Size; - int totalOverflowBytes = data.Length - maxPrimaryPayload; - - if (totalOverflowBytes > 0) - { - int tailSize = totalOverflowBytes % overflowChunkSize; - int fullPages = totalOverflowBytes / overflowChunkSize; - - // 2a. Handle Tail (if any) - This is the highest offset - if (tailSize > 0) - { - int tailOffset = maxPrimaryPayload + (fullPages * overflowChunkSize); - var overflowPageId = AllocateOverflowPage( - data.AsSpan(tailOffset, tailSize), - nextOverflowPageId, // Points to 0 (or previous tail if we had one? No, 0) - transaction - ); - nextOverflowPageId = overflowPageId; - } - else if (fullPages > 0) - { - // If no tail, nextId starts at 0. - } - - // 2b. Handle Full Pages (Reverse order) - // Iterate from last full page down to first full page - for (int i = fullPages - 1; i >= 0; i--) - { - int chunkOffset = maxPrimaryPayload + (i * overflowChunkSize); - var overflowPageId = AllocateOverflowPage( - data.AsSpan(chunkOffset, overflowChunkSize), - nextOverflowPageId, - transaction - ); - nextOverflowPageId = overflowPageId; - } - } - - // 3. PrepareAsync Primary Page Payload - // Layout: [TotalLength (4)] [NextOverflowPage (4)] [DataChunk (...)] - // Since we are in InsertWithOverflow, we know data.Length > maxPrimaryPayload - int primaryPayloadSize = maxPrimaryPayload; - int totalSlotSize = MetadataSize + primaryPayloadSize; - - // Allocate primary page - var primaryPageId = await FindPageWithSpace(totalSlotSize + SlotEntry.Size, transaction); - if (primaryPageId == 0) - primaryPageId = await AllocateNewDataPage(transaction); - - // 4. Write to Primary Page - var buffer = ArrayPool.Shared.Rent(_storage.PageSize); - try - { - _storage.ReadPage(primaryPageId, transaction.TransactionId, buffer); - - var header = SlottedPageHeader.ReadFrom(buffer); - - // Find free slot - ushort slotIndex = FindFreeSlot(buffer, ref header); - - // Write payload at end of used space - var docOffset = header.FreeSpaceEnd - totalSlotSize; - var payloadSpan = buffer.AsSpan(docOffset, totalSlotSize); - - // Write Metadata - System.Buffers.Binary.BinaryPrimitives.WriteInt32LittleEndian(payloadSpan.Slice(0, 4), data.Length); // Total Length - System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(payloadSpan.Slice(4, 4), nextOverflowPageId); // First Overflow Page - - // Write Data Chunk - data.AsSpan(0, primaryPayloadSize).CopyTo(payloadSpan.Slice(8)); - - // Write Slot Entry - // FLAGS: HasOverflow - // LENGTH: Length of data *in this slot* (Metadata + Chunk) - // This avoids the 65KB limit issue for the SlotEntry.Length field itself, - // as specific slots are bounded by Page Size (16KB). - var slotOffset = SlottedPageHeader.Size + (slotIndex * SlotEntry.Size); - var slot = new SlotEntry - { - Offset = (ushort)docOffset, - Length = (ushort)totalSlotSize, - Flags = SlotFlags.HasOverflow - }; - slot.WriteTo(buffer.AsSpan(slotOffset)); - - // UpdateAsync header - if (slotIndex >= header.SlotCount) - header.SlotCount = (ushort)(slotIndex + 1); - - header.FreeSpaceStart = (ushort)(SlottedPageHeader.Size + (header.SlotCount * SlotEntry.Size)); - header.FreeSpaceEnd = (ushort)docOffset; - header.WriteTo(buffer); - - // NEW: Buffer write in transaction or write immediately - var writeOp = new WriteOperation( - documentId: ObjectId.Empty, - newValue: buffer.AsMemory(0, _storage.PageSize), - pageId: primaryPageId, - type: OperationType.Insert - ); - ((Transaction)transaction).AddWrite(writeOp); - - SnapshotFsiForTransaction(transaction, primaryPageId); - _fsi.Update(primaryPageId, header.AvailableFreeSpace); - - return (primaryPageId, slotIndex); - } - finally - { - ArrayPool.Shared.Return(buffer); - } - } - - /// - /// Asynchronously inserts a new document into the collection - /// - /// - /// With a caller-owned : if the call throws - /// (duplicate key, vector dimension mismatch, document or - /// key too large), it wrote nothing and the transaction stays active, so the caller can catch it, - /// carry on and commit. Any other failure while writing rolls the transaction back - /// ( becomes ), so that a - /// half-applied write, with the data and its indexes out of step, can never be committed. - /// - public async ValueTask InsertAsync(T entity, ITransaction? transaction, CancellationToken ct = default) - { - if (entity == null) throw new ArgumentNullException(nameof(entity)); - - var sw = _storage.MetricsDispatcher != null ? ValueStopwatch.StartNew() : default; - bool success = false; - bool autoCommit = transaction == null; - - WriteFailure.EnsureWritable(transaction); - - if (!await _collectionLock.WaitAsync(WriteLockTimeoutMs, ct)) - throw new TimeoutException("Timed out acquiring collection lock (Insert)."); - - transaction ??= _storage.BeginTransaction(IsolationLevel.ReadCommitted); - try - { - try - { - var id = await InsertCore(entity, transaction); - if (autoCommit) - { - await transaction.CommitAsync(ct); - // ── OnInsert retention trigger — runs AFTER commit within the lock ── - if (_retentionPolicy != null && (_retentionPolicy.Triggers & RetentionTrigger.OnInsert) != 0) - await ApplyRetentionPolicyCoreAsync(ct); - } - else if (_retentionPolicy != null && (_retentionPolicy.Triggers & RetentionTrigger.OnInsert) != 0 - && transaction is Transaction concreteTx) - { - // For caller-managed transactions, fire retention in the background after commit. - concreteTx.OnCommit += () => _ = RunScheduledRetentionAsync(); - } - success = true; - return id; - } - catch (Exception ex) - { - await WriteFailure.HandleAsync(transaction, autoCommit, ex); - throw; - } - } - finally - { - _collectionLock.Release(); - if (sw.IsActive) - _storage.MetricsDispatcher?.Publish(new MetricEvent - { - Timestamp = sw.StartTimestamp, - Type = MetricEventType.CollectionInsert, - ElapsedMicros = sw.GetElapsedMicros(), - CollectionName = _collectionName, - Success = success, - }); - } - } - - /// - /// Asynchronously inserts multiple documents in a single transaction. - /// - /// - /// With a caller-owned the call is all or nothing: if the first - /// document is rejected with (duplicate key, vector - /// dimension mismatch, document or key too large), nothing was written and the transaction stays - /// active. Any other failure, including a rejected document after earlier documents of the batch - /// were written, rolls the transaction back ( becomes - /// ). - /// - public async ValueTask> InsertBulkAsync(IEnumerable entities, ITransaction? transaction, CancellationToken ct = default) - { - if (entities == null) throw new ArgumentNullException(nameof(entities)); - - var entityList = entities.ToList(); - var ids = new List(entityList.Count); - - WriteFailure.EnsureWritable(transaction); - - if (!await _collectionLock.WaitAsync(WriteLockTimeoutMs, ct)) - throw new TimeoutException("Timed out acquiring collection lock (InsertBulk)."); - - bool autoCommit = transaction == null; - transaction ??= _storage.BeginTransaction(IsolationLevel.ReadCommitted); - try - { - try - { - await InsertBulkInternal(entityList, ids, transaction); - if (autoCommit) - { - await transaction.CommitAsync(ct); - // ── OnInsert retention trigger — runs AFTER commit within the lock ── - if (_retentionPolicy != null && (_retentionPolicy.Triggers & RetentionTrigger.OnInsert) != 0) - await ApplyRetentionPolicyCoreAsync(ct); - } - else if (_retentionPolicy != null && (_retentionPolicy.Triggers & RetentionTrigger.OnInsert) != 0 - && transaction is Transaction concreteTx) - { - // For caller-managed transactions, fire retention in the background after commit. - concreteTx.OnCommit += () => _ = RunScheduledRetentionAsync(); - } - return ids; - } - catch (Exception ex) - { - await WriteFailure.HandleAsync(transaction, autoCommit, ex, callHasWritten: ids.Count > 0); - throw; - } - } - finally - { - _collectionLock.Release(); - } - } - - private async Task InsertBulkInternal(List entityList, List ids, ITransaction transaction) - { - // Scale batch size with available cores: more parallelism on multi-core machines. - // Clamped to [32, 200] to avoid pathological cases on very high-core-count machines. - int BATCH_SIZE = Math.Max(32, Math.Min(Environment.ProcessorCount * 4, 200)); - - for (int batchStart = 0; batchStart < entityList.Count; batchStart += BATCH_SIZE) - { - int batchEnd = Math.Min(batchStart + BATCH_SIZE, entityList.Count); - int batchCount = batchEnd - batchStart; - - // PHASE 1: Parallel serialize this batch - var serializedBatch = new (TId id, byte[] data, int length)[batchCount]; - - System.Threading.Tasks.Parallel.For(0, batchCount, i => - { - var entity = entityList[batchStart + i]; - var id = EnsureId(entity); - var length = SerializeWithRetry(entity, out var buffer); - serializedBatch[i] = (id, buffer, length); - }); - - // PHASE 2: Sequential insert this batch - for (int i = 0; i < batchCount; i++) - { - var (id, buffer, length) = serializedBatch[i]; - var entity = entityList[batchStart + i]; - - try - { - await InsertDataCore(id, entity, buffer, transaction, length); - ids.Add(id); - } - finally - { - ArrayPool.Shared.Return(buffer); - } - } - } - } - - private TId EnsureId(T entity) - { - var id = _mapper.GetId(entity); - if (EqualityComparer.Default.Equals(id, default!)) - { - if (typeof(TId) == typeof(ObjectId)) - { - id = (TId)(object)ObjectId.NewObjectId(); - _mapper.SetId(entity, id); - } - else if (typeof(TId) == typeof(Guid)) - { - id = (TId)(object)Guid.NewGuid(); - _mapper.SetId(entity, id); - } - else if (typeof(TId) == typeof(int)) - { - id = (TId)(object)(int)_storage.GetNextSequence(_collectionName); - _mapper.SetId(entity, id); - } - else if (typeof(TId) == typeof(long)) - { - id = (TId)(object)_storage.GetNextSequence(_collectionName); - _mapper.SetId(entity, id); - } - else if (typeof(TId) == typeof(string)) - { - id = (TId)(object)Ulid.NewUlid().ToString(); - _mapper.SetId(entity, id); - } - } - return id; - } - - private async Task InsertCore(T entity, ITransaction transaction) - { - var id = EnsureId(entity); - var length = SerializeWithRetry(entity, out var buffer); - try - { - await InsertDataCore(id, entity, buffer, transaction, length); - return id; - } - finally - { - ArrayPool.Shared.Return(buffer); - } - } - - private async Task InsertDataCore(TId id, T entity, byte[] docData, ITransaction transaction, int docLength = -1) - { - if (docLength >= 0 && docLength < docData.Length) - docData = docData[..docLength]; // trim to actual serialized size - - // ── AUDIT: start ───────────────────────────────────────────────────── - var auditVsw = (_storage.AuditSink is not null || _storage.AuditMetrics is not null) - ? ValueStopwatch.StartNew() - : default; - // ──────────────────────────────────────────────────────────────────── - - // Everything that can reject the document is checked before any page is written, so a - // rejected insert leaves nothing behind (BLiteValidationException keeps the transaction usable). - var key = _mapper.ToIndexKey(id); - ValidatePrimaryKeyForInsert(id, key, transaction); - _indexManager.EnsureUniqueInAll(entity, null, transaction); - _indexManager.ValidateKeysInAll(entity); - - DocumentLocation location; - if (_isTimeSeries) - { - // Wrap the already-serialized BSON bytes in a BsonDocument so InsertTimeSeries - // can read the timestamp field and manage TimeSeriesPage allocation/pruning. - var bsonDoc = new BsonDocument(docData.ToArray(), _storage.GetKeyReverseMap(), _storage.GetKeyMap()); - var loc = _storage.InsertTimeSeries(_collectionName, bsonDoc, transaction); - location = new DocumentLocation(loc.PageId, (ushort)loc.SlotIndex); - } - else if (docData.Length + SlotEntry.Size <= _maxDocumentSizeForSinglePage) - { - var pageId = await FindPageWithSpace(docData.Length + SlotEntry.Size, transaction); - if (pageId == 0) pageId = await AllocateNewDataPage(transaction); - var slotIndex = await InsertIntoPage(pageId, docData, transaction); - location = new DocumentLocation(pageId, slotIndex); - } - else - { - var (pageId, slotIndex) = await InsertWithOverflow(docData, transaction); - location = new DocumentLocation(pageId, slotIndex); - } - - _primaryIndex.Insert(key, location, transaction.TransactionId); - _indexManager.InsertIntoAll(entity, location, transaction); - - // ── AUDIT: emit ────────────────────────────────────────────────────── - if (auditVsw.IsActive) - { - var elapsed = auditVsw.GetElapsed(); - var opts = _storage.AuditOptions!; - var userId = (opts.ContextProvider ?? Audit.AmbientAuditContext.Instance).GetCurrentUserId(); - var evt = new Audit.InsertAuditEvent( - TransactionId: transaction.TransactionId, - CollectionName: _collectionName, - DocumentSizeBytes: docData.Length, - Elapsed: elapsed, - UserId: userId); - - _storage.AuditSink?.OnInsert(evt); - _storage.AuditMetrics?.RecordInsert(elapsed); - - // Slow-insert detection - if (opts.SlowOperationThreshold is { } threshold && elapsed > threshold) - { - _storage.AuditSink?.OnSlowOperation(new Audit.SlowOperationEvent( - Audit.SlowOperationType.Insert, - CollectionName: _collectionName, - Elapsed: elapsed, - Detail: null)); - } - } - // ──────────────────────────────────────────────────────────────────── - - // Notify CDC - await NotifyCdc(OperationType.Insert, id, transaction, docData); - } - - /// - /// Throws if cannot be inserted - /// into the primary index: it is already taken, or it is larger than a node page. - /// - private void ValidatePrimaryKeyForInsert(TId id, IndexKey key, ITransaction transaction) - { - if (key.Data.Length > _primaryIndex.MaxKeySize) - throw new BLiteValidationException( - $"Id of {key.Data.Length} bytes is too large for the primary index of collection '{_collectionName}' (maximum {_primaryIndex.MaxKeySize} bytes)."); - - if (_primaryIndex.TryFind(key, out _, transaction.TransactionId)) - throw new BLiteValidationException( - $"Duplicate key violation: a document with id '{id}' already exists in collection '{_collectionName}'."); - } - - /// - /// Compacts the data area of a slotted page by reclaiming bytes occupied by deleted slots. - /// Live documents are packed contiguously toward the top of the page and each slot's Offset - /// field is updated accordingly. Slot indices are never renumbered, so the primary index - /// remains valid. is advanced to reflect the - /// reclaimed space. - /// - /// Full page buffer (exactly PageSize bytes) to compact in-place. - /// true if at least one deleted slot was found and space was reclaimed; false if the page was already compact. - private bool CompactPage(Span buffer) - { - var header = SlottedPageHeader.ReadFrom(buffer); - if (header.SlotCount == 0) return false; - - // Gather live (non-deleted) slots - var liveSlots = new List<(ushort index, SlotEntry slot)>(header.SlotCount); - for (ushort i = 0; i < header.SlotCount; i++) - { - var entryOffset = SlottedPageHeader.Size + (i * SlotEntry.Size); - var slot = SlotEntry.ReadFrom(buffer.Slice(entryOffset, SlotEntry.Size)); - if ((slot.Flags & SlotFlags.Deleted) == 0) - liveSlots.Add((i, slot)); - } - - if (liveSlots.Count == header.SlotCount) - return false; // No deleted slots — nothing to compact - - // Pack live document data from top of page downward using a temp copy - // of the data area to avoid read-after-write corruption. - var temp = ArrayPool.Shared.Rent(buffer.Length); - try - { - buffer.CopyTo(temp); - ushort newEnd = (ushort)buffer.Length; - foreach (var (idx, slot) in liveSlots) - { - newEnd -= slot.Length; - // Copy original bytes from temp into the new (higher) position in buffer - temp.AsSpan(slot.Offset, slot.Length).CopyTo(buffer.Slice(newEnd, slot.Length)); - // UpdateAsync slot entry to point at the new offset - var updatedSlot = slot; - updatedSlot.Offset = newEnd; - var entryOffset = SlottedPageHeader.Size + (idx * SlotEntry.Size); - updatedSlot.WriteTo(buffer.Slice(entryOffset, SlotEntry.Size)); - } - - header.FreeSpaceEnd = newEnd; - header.WriteTo(buffer); - return true; - } - finally - { - ArrayPool.Shared.Return(temp); - } - } - - #endregion - - #region Find - - /// - /// Async exact-match lookup. Uses for the index - /// traversal and for the page read. - /// - public ValueTask FindByIdAsync(TId id, CancellationToken ct = default) - => FindByIdAsync(id, null, ct); - - /// - public async ValueTask FindByIdAsync(TId id, ITransaction? transaction, CancellationToken ct = default) - { - var sw = _storage.MetricsDispatcher != null ? ValueStopwatch.StartNew() : default; - bool success = false; - var txnId = transaction?.TransactionId ?? 0UL; - var key = _mapper.ToIndexKey(id); - try - { - var (found, location) = await _primaryIndex.TryFindAsync(key, txnId, ct).ConfigureAwait(false); - if (!found) return default; - var result = await FindByLocationAsync(location, txnId, ct).ConfigureAwait(false); - success = result != null; - return result; - } - finally - { - if (sw.IsActive) - _storage.MetricsDispatcher?.Publish(new MetricEvent - { - Timestamp = sw.StartTimestamp, - Type = MetricEventType.CollectionFind, - ElapsedMicros = sw.GetElapsedMicros(), - CollectionName = _collectionName, - Success = success, - }); - } - } - - /// - /// Async full-collection scan. Uses for leaf chaining - /// and for each page read. - /// - public IAsyncEnumerable FindAllAsync(CancellationToken ct = default) - => FindAllAsync(null, ct); - - /// - public async IAsyncEnumerable FindAllAsync(ITransaction? transaction, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default) - { - var sw = _storage.MetricsDispatcher != null ? ValueStopwatch.StartNew() : default; - var txnId = transaction?.TransactionId ?? 0UL; - - 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 entity = await FindByLocationAsync(entry.Location, txnId, cachedBuffer, ct).ConfigureAwait(false); - if (entity != null) - yield return entity; - } - } - finally - { - foreach (var buf in pageCache.Values) - ArrayPool.Shared.Return(buf); - if (sw.IsActive) - _storage.MetricsDispatcher?.Publish(new MetricEvent - { - Timestamp = sw.StartTimestamp, - Type = MetricEventType.CollectionQuery, - ElapsedMicros = sw.GetElapsedMicros(), - CollectionName = _collectionName, - Success = true, - }); - } - } - - internal async Task FindByLocation(DocumentLocation location, ITransaction? transaction = null) - { - var txnId = transaction?.TransactionId ?? 0UL; - var buffer = ArrayPool.Shared.Rent(_storage.PageSize); - try - { - // Read from StorageEngine with transaction isolation - _storage.ReadPage(location.PageId, txnId, buffer); - - var pageType = (PageType)buffer[4]; - if (pageType == PageType.Free || pageType == PageType.Empty) - return null; - - if (pageType == PageType.TimeSeries) - { - int tsOffset = location.SlotIndex; - if (tsOffset < TimeSeriesPage.DataOffset || tsOffset + 4 > buffer.Length) - return null; - int tsSize = BitConverter.ToInt32(buffer.AsSpan(tsOffset, 4)); - if (tsSize <= 0 || tsOffset + tsSize > buffer.Length) - return null; - return _mapper.Deserialize(new BsonSpanReader(buffer.AsSpan(tsOffset, tsSize), _storage.GetKeyReverseMap())); - } - - 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) - { - // Layout: [TotalLength (4)] [NextOverflowPage (4)] [DataChunk (...)] - var payload = buffer.AsSpan(slot.Offset, slot.Length); - - // Read Metadata - int totalLength = System.Buffers.Binary.BinaryPrimitives.ReadInt32LittleEndian(payload.Slice(0, 4)); - uint nextOverflowPageId = System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4, 4)); - - var fullBuffer = ArrayPool.Shared.Rent(totalLength); - try - { - // Copy primary chunk (skipping 8 bytes of metadata) - int primaryChunkSize = slot.Length - 8; - payload.Slice(8, primaryChunkSize).CopyTo(fullBuffer.AsSpan(0, primaryChunkSize)); - - int offset = primaryChunkSize; - var currentOverflowPageId = nextOverflowPageId; - - // Follow overflow chain - while (currentOverflowPageId != 0 && offset < totalLength) - { - // Read from StorageEngine with transaction isolation - _storage.ReadPage(currentOverflowPageId, txnId, buffer); - var overflowHeader = SlottedPageHeader.ReadFrom(buffer); - - // Calculate data in this overflow page - // Overflow pages are full data pages (PageSize - HeaderSize) - int maxChunkSize = _storage.PageSize - SlottedPageHeader.Size; - int remaining = totalLength - offset; - int chunkSize = Math.Min(maxChunkSize, remaining); - - buffer.AsSpan(SlottedPageHeader.Size, chunkSize) - .CopyTo(fullBuffer.AsSpan(offset)); - - offset += chunkSize; - currentOverflowPageId = overflowHeader.NextOverflowPage; - } - - return _mapper.Deserialize(new BsonSpanReader(fullBuffer.AsSpan(0, totalLength), _storage.GetKeyReverseMap())); - } - finally - { - ArrayPool.Shared.Return(fullBuffer); - } - } - - // Validation check - if (slot.Offset + slot.Length > buffer.Length) - { - throw new InvalidOperationException($"Corrupted slot: Offset={slot.Offset}, Length={slot.Length}, Buffer={buffer.Length}, SlotIndex={location.SlotIndex}, PageId={location.PageId}, Flags={slot.Flags}"); - } - - var docData = buffer.AsSpan(slot.Offset, slot.Length); - return _mapper.Deserialize(new BsonSpanReader(docData, _storage.GetKeyReverseMap())); - } - finally - { - ArrayPool.Shared.Return(buffer); - } - } - - /// - /// Async version of . - /// All operations are performed synchronously on the in-memory buffer. - /// No Span variable survives across an await boundary. - /// - internal ValueTask FindByLocationAsync(DocumentLocation location, ulong txnId, CancellationToken ct = default) - => FindByLocationCoreAsync(location, txnId, null, ct); - - // Called when the primary page for this location is already in memory (page-cache fast path). - // The preloadedPage buffer skips the initial ReadPageAsync; overflow chain pages are still read - // from storage as needed. - internal ValueTask FindByLocationAsync(DocumentLocation location, ulong txnId, byte[] preloadedPage, CancellationToken ct = default) - => FindByLocationCoreAsync(location, txnId, preloadedPage, ct); - - private async ValueTask FindByLocationCoreAsync(DocumentLocation location, ulong txnId, byte[]? preloadedPage, CancellationToken ct) - { - byte[]? ownedBuffer = null; - byte[] buffer; - - if (preloadedPage is not null) - { - buffer = preloadedPage; - } - else - { - ownedBuffer = ArrayPool.Shared.Rent(_storage.PageSize); - await _storage.ReadPageAsync(location.PageId, txnId, ownedBuffer.AsMemory(0, _storage.PageSize), ct).ConfigureAwait(false); - buffer = ownedBuffer; - } - - try - { - // --- all Span work is sync after the first read --- - var pageType = (PageType)buffer[4]; - if (pageType == PageType.Free || pageType == PageType.Empty) - return default; - - if (pageType == PageType.TimeSeries) - { - int tsOffset = location.SlotIndex; - if (tsOffset < TimeSeriesPage.DataOffset || tsOffset + 4 > buffer.Length) - return default; - int tsSize = BitConverter.ToInt32(buffer.AsSpan(tsOffset, 4)); - if (tsSize <= 0 || tsOffset + tsSize > buffer.Length) - return default; - return _mapper.Deserialize(new BsonSpanReader(buffer.AsSpan(tsOffset, tsSize), _storage.GetKeyReverseMap())); - } - - var header = SlottedPageHeader.ReadFrom(buffer); - if (location.SlotIndex >= header.SlotCount) return default; - - var slotOffset = SlottedPageHeader.Size + (location.SlotIndex * SlotEntry.Size); - var slot = SlotEntry.ReadFrom(buffer.AsSpan(slotOffset)); - if ((slot.Flags & SlotFlags.Deleted) != 0) return default; - - if ((slot.Flags & SlotFlags.HasOverflow) != 0) - { - // Extract scalar values from the buffer before the first await in the loop - int totalLength = System.Buffers.Binary.BinaryPrimitives.ReadInt32LittleEndian(buffer.AsSpan(slot.Offset, 4)); - uint nextOverflowPage = System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(slot.Offset + 4, 4)); - int primaryChunkSize = slot.Length - 8; - - var fullBuffer = ArrayPool.Shared.Rent(totalLength); - // Use a dedicated overflow-chain buffer so the primary page buffer (which may be a - // shared cached entry from FindAllAsync) is never overwritten during the chain walk. - var overflowBuffer = ArrayPool.Shared.Rent(_storage.PageSize); - try - { - // Copy primary chunk synchronously (no await here) - buffer.AsSpan(slot.Offset + 8, primaryChunkSize).CopyTo(fullBuffer.AsSpan(0, primaryChunkSize)); - - int bytesCopied = primaryChunkSize; - uint currentPage = nextOverflowPage; - - while (currentPage != 0 && bytesCopied < totalLength) - { - ct.ThrowIfCancellationRequested(); - await _storage.ReadPageAsync(currentPage, txnId, overflowBuffer.AsMemory(0, _storage.PageSize), ct).ConfigureAwait(false); - - // Recreate SlottedPageHeader span after each await — safe because overflowBuffer is byte[] - uint nextPage = SlottedPageHeader.ReadFrom(overflowBuffer).NextOverflowPage; - int chunkSize = Math.Min(_storage.PageSize - SlottedPageHeader.Size, totalLength - bytesCopied); - overflowBuffer.AsSpan(SlottedPageHeader.Size, chunkSize).CopyTo(fullBuffer.AsSpan(bytesCopied)); - bytesCopied += chunkSize; - currentPage = nextPage; - } - - return _mapper.Deserialize(new BsonSpanReader(fullBuffer.AsSpan(0, totalLength), _storage.GetKeyReverseMap())); - } - finally - { - ArrayPool.Shared.Return(overflowBuffer); - ArrayPool.Shared.Return(fullBuffer); - } - } - - if (slot.Offset + slot.Length > buffer.Length) - throw new InvalidOperationException($"Corrupted slot: Offset={slot.Offset}, Length={slot.Length}, PageId={location.PageId}"); - - // Inline deserialization — Span created after the only await, safe - return _mapper.Deserialize(new BsonSpanReader(buffer.AsSpan(slot.Offset, slot.Length), _storage.GetKeyReverseMap())); - } - finally - { - // Only return the buffer to the pool when we own it; caller-provided pages - // are managed by the caller (e.g. the per-query page cache in QueryIndexAsync). - if (ownedBuffer is not null) - ArrayPool.Shared.Return(ownedBuffer); - } - } - - #endregion - - #region Update & Delete - - /// - /// Asynchronously updates an existing document in the collection - /// - /// - /// With a caller-owned : if the call throws - /// (duplicate key, vector dimension mismatch, document or - /// key too large), it wrote nothing and the transaction stays active, so the caller can catch it, - /// carry on and commit. Any other failure while writing rolls the transaction back - /// ( becomes ), so that a - /// half-applied write, with the data and its indexes out of step, can never be committed. - /// - public async ValueTask UpdateAsync(T entity, ITransaction? transaction, CancellationToken ct = default) - { - if (entity == null) throw new ArgumentNullException(nameof(entity)); - - var sw = _storage.MetricsDispatcher != null ? ValueStopwatch.StartNew() : default; - bool success = false; - bool autoCommit = transaction == null; - - WriteFailure.EnsureWritable(transaction); - - if (!await _collectionLock.WaitAsync(WriteLockTimeoutMs, ct)) - throw new TimeoutException("Timed out acquiring collection lock (Update)."); - - transaction ??= _storage.BeginTransaction(IsolationLevel.ReadCommitted); - try - { - try - { - var result = await UpdateCore(entity, transaction); - if (autoCommit && result) await transaction.CommitAsync(ct); - success = result; - return result; - } - catch (Exception ex) - { - await WriteFailure.HandleAsync(transaction, autoCommit, ex); - throw; - } - } - finally - { - _collectionLock.Release(); - if (sw.IsActive) - _storage.MetricsDispatcher?.Publish(new MetricEvent - { - Timestamp = sw.StartTimestamp, - Type = MetricEventType.CollectionUpdate, - ElapsedMicros = sw.GetElapsedMicros(), - CollectionName = _collectionName, - Success = success, - }); - } - } - - /// - /// Asynchronously updates multiple documents in a single transaction. - /// - /// - /// With a caller-owned the call is all or nothing: if the first - /// document is rejected with (duplicate key, vector - /// dimension mismatch, document or key too large), nothing was written and the transaction stays - /// active. Any other failure, including a rejected document after earlier documents of the batch - /// were written, rolls the transaction back ( becomes - /// ). - /// - public async ValueTask UpdateBulkAsync(IEnumerable entities, ITransaction? transaction, CancellationToken ct = default) - { - if (entities == null) throw new ArgumentNullException(nameof(entities)); - - var entityList = entities.ToList(); - int updateCount = 0; - bool autoCommit = transaction == null; - // Documents of this call written so far: a failure after the first one must roll back. - var progress = new StrongBox(); - - WriteFailure.EnsureWritable(transaction); - - if (!await _collectionLock.WaitAsync(WriteLockTimeoutMs, ct)) - throw new TimeoutException("Timed out acquiring collection lock (UpdateBulk)."); - - transaction ??= _storage.BeginTransaction(IsolationLevel.ReadCommitted); - try - { - try - { - updateCount = await UpdateBulkInternal(entityList, transaction, progress); - if (autoCommit) await transaction.CommitAsync(ct); - return updateCount; - } - catch (Exception ex) - { - await WriteFailure.HandleAsync(transaction, autoCommit, ex, callHasWritten: progress.Value > 0); - throw; - } - } - finally - { - _collectionLock.Release(); - } - } - - private async Task UpdateBulkInternal(List entityList, ITransaction transaction, StrongBox progress) - { - int updateCount = 0; - const int BATCH_SIZE = 50; - - for (int batchStart = 0; batchStart < entityList.Count; batchStart += BATCH_SIZE) - { - int batchEnd = Math.Min(batchStart + BATCH_SIZE, entityList.Count); - int batchCount = batchEnd - batchStart; - - // PHASE 1: Parallel Serialization - var serializedBatch = new (TId id, byte[] data, int length, bool found)[batchCount]; - - for (int i = 0; i < batchCount; i++) - { - var entity = entityList[batchStart + i]; - var id = _mapper.GetId(entity); - var key = _mapper.ToIndexKey(id); - - // Check if entity exists - // We do this sequentially to avoid ThreadPool exhaustion or IO-related deadlocks - if (_primaryIndex.TryFind(key, out var _, transaction.TransactionId)) - { - var length = SerializeWithRetry(entity, out var buffer); - serializedBatch[i] = (id, buffer, length, true); - } - else - { - serializedBatch[i] = (default!, null!, 0, false); - } - } - - // PHASE 2: Sequential UpdateAsync - for (int i = 0; i < batchCount; i++) - { - var (id, docData, length, found) = serializedBatch[i]; - if (!found) continue; - - var entity = entityList[batchStart + i]; - try - { - if (await UpdateDataCore(id, entity, docData, transaction, length)) - progress.Value = ++updateCount; - } - finally - { - ArrayPool.Shared.Return(docData); - } - } - } - return updateCount; - } - - private async Task UpdateCore(T entity, ITransaction transaction) - { - var id = _mapper.GetId(entity); - var length = SerializeWithRetry(entity, out var buffer); - try - { - return await UpdateDataCore(id, entity, buffer, transaction, length); - } - finally - { - ArrayPool.Shared.Return(buffer); - } - } - - private async Task UpdateDataCore(TId id, T entity, byte[] docData, ITransaction transaction, int docLength = -1) - { - var key = _mapper.ToIndexKey(id); - if (!_primaryIndex.TryFind(key, out var oldLocation, transaction.TransactionId)) - return false; - - return await UpdateAtLocationCore(id, entity, docData, key, oldLocation, transaction, docLength); - } - - /// - /// Replaces the document at an already-resolved primary-index location. - /// Callers that already hold from their own - /// _primaryIndex.TryFind (e.g. ) use this to avoid a - /// second lookup for the same key. - /// - private async Task UpdateAtLocationCore(TId id, T entity, byte[] docData, IndexKey key, DocumentLocation oldLocation, ITransaction transaction, int docLength = -1) - { - if (docLength >= 0 && docLength < docData.Length) - docData = docData[..docLength]; // trim to actual serialized size - var bytesWritten = docData.Length; - - // Retrieve old version for index updates - var oldEntity = await FindByLocation(oldLocation, transaction); - if (oldEntity == null) return false; - - // Everything that can reject the document is checked before any page is written. - // Unique indexes only where the key changed, so documents that already share a value - // can still be updated. - _indexManager.EnsureUniqueInAll(entity, oldEntity, transaction); - _indexManager.ValidateKeysInAll(entity); - - // Read old page - var pageBuffer = ArrayPool.Shared.Rent(_storage.PageSize); - try - { - _storage.ReadPage(oldLocation.PageId, transaction.TransactionId, pageBuffer); - - var slotOffset = SlottedPageHeader.Size + (oldLocation.SlotIndex * SlotEntry.Size); - var oldSlot = SlotEntry.ReadFrom(pageBuffer.AsSpan(slotOffset)); - - if (bytesWritten <= oldSlot.Length && (oldSlot.Flags & SlotFlags.HasOverflow) == 0) - { - // In-place update - docData.CopyTo(pageBuffer.AsSpan(oldSlot.Offset, bytesWritten)); - var newSlot = oldSlot; - newSlot.Length = (ushort)bytesWritten; - newSlot.WriteTo(pageBuffer.AsSpan(slotOffset)); - _storage.WritePage(oldLocation.PageId, transaction.TransactionId, pageBuffer); - - // Notify secondary indexes (primary index unchanged) - _indexManager.UpdateInAll(oldEntity, entity, oldLocation, oldLocation, transaction); - - // Notify CDC - await NotifyCdc(OperationType.Update, id, transaction, docData); - return true; - } - else - { - // Delete old + insert new. Secondary indexes are moved below, so an entry whose - // key did not change is re-pointed rather than deleted and inserted again. - await DeleteCore(id, transaction, notifyCdc: false, deleteFromIndexes: false); - - DocumentLocation newLocation; - if (bytesWritten + SlotEntry.Size <= _maxDocumentSizeForSinglePage) - { - var newPageId = await FindPageWithSpace(bytesWritten + SlotEntry.Size, transaction); - if (newPageId == 0) newPageId = await AllocateNewDataPage(transaction); - var newSlotIndex = await InsertIntoPage(newPageId, docData, transaction); - newLocation = new DocumentLocation(newPageId, newSlotIndex); - } - else - { - var (newPageId, newSlotIndex) = await InsertWithOverflow(docData, transaction); - newLocation = new DocumentLocation(newPageId, newSlotIndex); - } - - _primaryIndex.Insert(key, newLocation, transaction.TransactionId); - _indexManager.UpdateInAll(oldEntity, entity, oldLocation, newLocation, transaction); - - // Notify CDC - await NotifyCdc(OperationType.Update, id, transaction, docData); - return true; - } - } - finally - { - ArrayPool.Shared.Return(pageBuffer); - } - } - - /// - /// Inserts if its id is unset or not present in the collection, - /// otherwise replaces the existing document with the same id. Resolved with a single - /// primary-index lookup per document — not a separate Find followed by Insert/Update. - /// - public ValueTask> UpsertAsync(T entity, CancellationToken ct = default) - => UpsertAsync(entity, null, ct); - - /// - /// - /// With a caller-owned : if the call throws - /// (duplicate key, vector dimension mismatch, document or - /// key too large), it wrote nothing and the transaction stays active, so the caller can catch it, - /// carry on and commit. Any other failure while writing rolls the transaction back - /// ( becomes ), so that a - /// half-applied write, with the data and its indexes out of step, can never be committed. - /// - public async ValueTask> UpsertAsync(T entity, ITransaction? transaction, CancellationToken ct = default) - { - if (entity == null) throw new ArgumentNullException(nameof(entity)); - - var sw = _storage.MetricsDispatcher != null ? ValueStopwatch.StartNew() : default; - bool success = false; - bool inserted = false; - bool autoCommit = transaction == null; - - WriteFailure.EnsureWritable(transaction); - - if (!await _collectionLock.WaitAsync(WriteLockTimeoutMs, ct)) - throw new TimeoutException("Timed out acquiring collection lock (Upsert)."); - - transaction ??= _storage.BeginTransaction(IsolationLevel.ReadCommitted); - try - { - try - { - var result = await UpsertCore(entity, transaction); - inserted = result.Inserted; - if (autoCommit) - { - await transaction.CommitAsync(ct); - // ── OnInsert retention trigger — only fires when this call actually inserted ── - if (inserted && _retentionPolicy != null && (_retentionPolicy.Triggers & RetentionTrigger.OnInsert) != 0) - await ApplyRetentionPolicyCoreAsync(ct); - } - else if (inserted && _retentionPolicy != null && (_retentionPolicy.Triggers & RetentionTrigger.OnInsert) != 0 - && transaction is Transaction concreteTx) - { - concreteTx.OnCommit += () => _ = RunScheduledRetentionAsync(); - } - success = true; - return result; - } - catch (Exception ex) - { - await WriteFailure.HandleAsync(transaction, autoCommit, ex); - throw; - } - } - finally - { - _collectionLock.Release(); - if (sw.IsActive) - _storage.MetricsDispatcher?.Publish(new MetricEvent - { - Timestamp = sw.StartTimestamp, - Type = inserted ? MetricEventType.CollectionInsert : MetricEventType.CollectionUpdate, - ElapsedMicros = sw.GetElapsedMicros(), - CollectionName = _collectionName, - Success = success, - }); - } - } - - /// - /// Upserts multiple documents in a single transaction. Each entity is resolved with its - /// own single primary-index lookup, in list order. - /// - public ValueTask>> UpsertBulkAsync(IEnumerable entities, CancellationToken ct = default) - => UpsertBulkAsync(entities, null, ct); - - /// - /// - /// With a caller-owned the call is all or nothing: if the first - /// document is rejected with (duplicate key, vector - /// dimension mismatch, document or key too large), nothing was written and the transaction stays - /// active. Any other failure, including a rejected document after earlier documents of the batch - /// were written, rolls the transaction back ( becomes - /// ). - /// - public async ValueTask>> UpsertBulkAsync(IEnumerable entities, ITransaction? transaction, CancellationToken ct = default) - { - if (entities == null) throw new ArgumentNullException(nameof(entities)); - - var entityList = entities.ToList(); - var results = new List>(entityList.Count); - bool autoCommit = transaction == null; - - WriteFailure.EnsureWritable(transaction); - - if (!await _collectionLock.WaitAsync(WriteLockTimeoutMs, ct)) - throw new TimeoutException("Timed out acquiring collection lock (UpsertBulk)."); - - transaction ??= _storage.BeginTransaction(IsolationLevel.ReadCommitted); - try - { - try - { - bool anyInserted = false; - foreach (var entity in entityList) - { - var result = await UpsertCore(entity, transaction); - anyInserted |= result.Inserted; - results.Add(result); - } - - if (autoCommit) - { - await transaction.CommitAsync(ct); - if (anyInserted && _retentionPolicy != null && (_retentionPolicy.Triggers & RetentionTrigger.OnInsert) != 0) - await ApplyRetentionPolicyCoreAsync(ct); - } - else if (anyInserted && _retentionPolicy != null && (_retentionPolicy.Triggers & RetentionTrigger.OnInsert) != 0 - && transaction is Transaction concreteTx) - { - concreteTx.OnCommit += () => _ = RunScheduledRetentionAsync(); - } - return results; - } - catch (Exception ex) - { - await WriteFailure.HandleAsync(transaction, autoCommit, ex, callHasWritten: results.Count > 0); - throw; - } - } - finally - { - _collectionLock.Release(); - } - } - - /// - /// Resolves the entity's id, then commits it with a single primary-index lookup: - /// found → in-place/relocated replace via ; - /// not found → insert via . When the id is unset - /// (default), the lookup is skipped entirely since no existing document could match it. - /// - private async Task> UpsertCore(T entity, ITransaction transaction) - { - var id = _mapper.GetId(entity); - if (EqualityComparer.Default.Equals(id, default!)) - { - var newId = await InsertCore(entity, transaction); - return new UpsertResult(newId, Inserted: true); - } - - var key = _mapper.ToIndexKey(id); - var length = SerializeWithRetry(entity, out var buffer); - try - { - if (_primaryIndex.TryFind(key, out var oldLocation, transaction.TransactionId)) - { - if (!await UpdateAtLocationCore(id, entity, buffer, key, oldLocation, transaction, length)) - throw new InvalidOperationException( - $"Upsert failed for id '{id}': the primary index resolved a location whose document could not be read (stale or corrupted index entry)."); - return new UpsertResult(id, Inserted: false); - } - else - { - await InsertDataCore(id, entity, buffer, transaction, length); - return new UpsertResult(id, Inserted: true); - } - } - finally - { - ArrayPool.Shared.Return(buffer); - } - } - - /// - /// Asynchronously deletes a document by its primary key. - /// - /// - /// With a caller-owned , a failure while writing rolls the - /// transaction back ( becomes ), - /// so that a half-applied delete can never be committed. - /// - public async ValueTask DeleteAsync(TId id, ITransaction? transaction, CancellationToken ct = default) - { - var sw = _storage.MetricsDispatcher != null ? ValueStopwatch.StartNew() : default; - bool success = false; - bool autoCommit = transaction == null; - - WriteFailure.EnsureWritable(transaction); - - if (!await _collectionLock.WaitAsync(WriteLockTimeoutMs, ct)) - throw new TimeoutException("Timed out acquiring collection lock (Delete)."); - - transaction ??= _storage.BeginTransaction(IsolationLevel.ReadCommitted); - try - { - try - { - var result = await DeleteCore(id, transaction); - if (autoCommit && result) await transaction.CommitAsync(ct); - success = result; - return result; - } - catch (Exception ex) - { - await WriteFailure.HandleAsync(transaction, autoCommit, ex); - throw; - } - } - finally - { - _collectionLock.Release(); - if (sw.IsActive) - _storage.MetricsDispatcher?.Publish(new MetricEvent - { - Timestamp = sw.StartTimestamp, - Type = MetricEventType.CollectionDelete, - ElapsedMicros = sw.GetElapsedMicros(), - CollectionName = _collectionName, - Success = success, - }); - } - } - - /// - /// Asynchronously deletes multiple documents in a single transaction. - /// - /// - /// With a caller-owned , a failure while writing rolls the - /// transaction back ( becomes ), - /// so that a half-applied delete can never be committed. - /// - public async ValueTask DeleteBulkAsync(IEnumerable ids, ITransaction? transaction, CancellationToken ct = default) - { - if (ids == null) throw new ArgumentNullException(nameof(ids)); - - int deleteCount = 0; - bool autoCommit = transaction == null; - - WriteFailure.EnsureWritable(transaction); - - if (!await _collectionLock.WaitAsync(WriteLockTimeoutMs, ct)) - throw new TimeoutException("Timed out acquiring collection lock (DeleteBulk)."); - - transaction ??= _storage.BeginTransaction(IsolationLevel.ReadCommitted); - try - { - try - { - deleteCount = await DeleteBulkInternal(ids, transaction); - if (autoCommit) await transaction.CommitAsync(ct); - return deleteCount; - } - catch (Exception ex) - { - await WriteFailure.HandleAsync(transaction, autoCommit, ex); - throw; - } - } - finally - { - _collectionLock.Release(); - } - } - - private async Task DeleteBulkInternal(IEnumerable ids, ITransaction transaction) - { - int deleteCount = 0; - foreach (var id in ids) - { - if (await DeleteCore(id, transaction)) - deleteCount++; - } - return deleteCount; - } - - private async Task DeleteCore(TId id, ITransaction transaction, bool notifyCdc = true, bool deleteFromIndexes = true) - { - var key = _mapper.ToIndexKey(id); - if (!_primaryIndex.TryFind(key, out var location, transaction.TransactionId)) - return false; - - // Notify secondary indexes BEFORE deleting document from storage - if (deleteFromIndexes) - { - var entity = await FindByLocation(location, transaction); - if (entity != null) - { - _indexManager.DeleteFromAll(entity, location, transaction); - } - } - - // Read page - var buffer = ArrayPool.Shared.Rent(_storage.PageSize); - try - { - _storage.ReadPage(location.PageId, transaction.TransactionId, buffer); - - var slotOffset = SlottedPageHeader.Size + (location.SlotIndex * SlotEntry.Size); - var slot = SlotEntry.ReadFrom(buffer.AsSpan(slotOffset)); - - // Check if slot has overflow and free it - if ((slot.Flags & SlotFlags.HasOverflow) != 0) - { - var nextOverflowPage = System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian( - buffer.AsSpan(slot.Offset + 4, 4)); - await FreeOverflowChain(nextOverflowPage, transaction); - } - - // Mark slot as deleted - var newSlot = slot; - newSlot.Flags |= SlotFlags.Deleted; - newSlot.WriteTo(buffer.AsSpan(slotOffset)); - - // Compact the page: reclaim the bytes that belonged to the deleted document - // and advance FreeSpaceEnd so FindPageWithSpace can see the freed space. - CompactPage(buffer.AsSpan(0, _storage.PageSize)); - - // Secure erase: zero-fill the free space area so the deleted document bytes - // are unrecoverable at the page level, supporting GDPR Art. 17 compliance. - var postCompactHeader = SlottedPageHeader.ReadFrom(buffer.AsSpan(0, SlottedPageHeader.Size)); - buffer.AsSpan(postCompactHeader.FreeSpaceStart, - postCompactHeader.FreeSpaceEnd - postCompactHeader.FreeSpaceStart).Clear(); - - _storage.WritePage(location.PageId, transaction.TransactionId, buffer); - - // Update the free space index with post-compaction free bytes. - // Snapshot the pre-modification value first so that a rollback can restore it. - var compactedHeader = SlottedPageHeader.ReadFrom(buffer.AsSpan(0, SlottedPageHeader.Size)); - SnapshotFsiForTransaction(transaction, location.PageId); - _fsi.Update(location.PageId, compactedHeader.AvailableFreeSpace); - - // Remove from primary index - _primaryIndex.Delete(key, location, transaction.TransactionId); - - // Notify CDC - if (notifyCdc) - await NotifyCdc(OperationType.Delete, id, transaction); - - return true; - } - finally - { - ArrayPool.Shared.Return(buffer); - } - } - - private async Task FreeOverflowChain(uint overflowPageId, ITransaction transaction) - { - var tempBuffer = ArrayPool.Shared.Rent(_storage.PageSize); - try - { - while (overflowPageId != 0) - { - _storage.ReadPage(overflowPageId, transaction.TransactionId, tempBuffer); - var header = SlottedPageHeader.ReadFrom(tempBuffer); - var nextPage = header.NextOverflowPage; - - // Recycle this page - _storage.FreePage(overflowPageId); - - overflowPageId = nextPage; - } - } - finally - { - ArrayPool.Shared.Return(tempBuffer); - } - } - - #endregion - - #region Query Helpers - - /// - /// Asynchronously counts all documents in the collection. - /// If called within a transaction, will count uncommitted changes. - /// - /// Cancellation token - /// Number of documents - public Task CountAsync(CancellationToken ct = default) - { - var sw = _storage.MetricsDispatcher != null ? ValueStopwatch.StartNew() : default; - try - { - // Count all entries in primary index - // Use generic min/max keys for the index - var minKey = IndexKey.MinKey; - var maxKey = IndexKey.MaxKey; - return Task.FromResult(_primaryIndex.Range(minKey, maxKey, IndexDirection.Forward, (ulong?)null).Count()); - } - finally - { - if (sw.IsActive) - _storage.MetricsDispatcher?.Publish(new MetricEvent - { - Timestamp = sw.StartTimestamp, - Type = MetricEventType.CollectionQuery, - ElapsedMicros = sw.GetElapsedMicros(), - CollectionName = _collectionName, - Success = true, - }); - } - } - - /// - /// Returns the minimum field value from this collection using an plan. - /// When the plan targets a B-Tree index, the O(log n) index boundary read is used. - /// Falls back to a BSON-level full collection scan when no index is available. - /// - public async ValueTask MinBoundaryAsync(IndexMinMax plan, CancellationToken ct = default) - { - if (plan.Kind == IndexMinMax.PlanKind.IndexBoundary) - { - var index = _indexManager.GetIndex(plan.IndexName!); - if (index != null) - { - T? doc = null; - await foreach (var d in QueryIndexAsync(plan.IndexName!, null, null, ascending: true, skip: 0, take: 1, ct: ct).ConfigureAwait(false)) - { - doc = d; - break; - } - if (doc != null) - { - var keyValue = index.Definition.KeySelector(doc); - if (keyValue is TResult r) return r; - } - return default!; - } - // Index was not found; cannot fall back (no BsonAggregator stored for IndexBoundary plans). - return default!; - } - - // BsonAggregate fallback: scan the collection using BSON field reads. - var agg = plan.Fallback!; - return await BsonAggregateFieldAsync(agg, isMin: true, ct).ConfigureAwait(false); - } - - /// - /// Returns the maximum field value from this collection using an plan. - /// When the plan targets a B-Tree index, the O(log n) index boundary read is used. - /// Falls back to a BSON-level full collection scan when no index is available. - /// - public async ValueTask MaxBoundaryAsync(IndexMinMax plan, CancellationToken ct = default) - { - if (plan.Kind == IndexMinMax.PlanKind.IndexBoundary) - { - var index = _indexManager.GetIndex(plan.IndexName!); - if (index != null) - { - T? doc = null; - await foreach (var d in QueryIndexAsync(plan.IndexName!, null, null, ascending: false, skip: 0, take: 1, ct: ct).ConfigureAwait(false)) - { - doc = d; - break; - } - if (doc != null) - { - var keyValue = index.Definition.KeySelector(doc); - if (keyValue is TResult r) return r; - } - return default!; - } - return default!; - } - - var agg = plan.Fallback!; - return await BsonAggregateFieldAsync(agg, isMin: false, ct).ConfigureAwait(false); - } - - private async Task BsonAggregateFieldAsync(BsonAggregator agg, bool isMin, CancellationToken ct) - { - var projector = BsonExpressionEvaluator.CreateFieldProjector(agg.FieldName, _storage.GetKeyMap()); - TResult result = default!; - bool found = false; - var comparer = Comparer.Default; - - await foreach (var value in ScanAsync(projector, ct).ConfigureAwait(false)) - { - ct.ThrowIfCancellationRequested(); - if (!found) - { - result = value; - found = true; - } - else if (isMin) - { - if (comparer.Compare(value, result) < 0) result = value; - } - else - { - if (comparer.Compare(value, result) > 0) result = value; - } - } - - return result; - } - - /// - /// Counts documents matching without fully materializing - /// the result set in memory. - /// - /// If the predicate targets an indexed field with an exact-covering filter, - /// the count is derived from an index key-only leaf scan — zero data-page reads. - /// Otherwise the documents are streamed through (index / - /// BSON / full-scan strategies) and counted in a tight loop without accumulating - /// a List<T>. - /// - /// - [RequiresDynamicCode("Count-by-predicate uses index optimization and Expression.Compile() which require dynamic code generation.")] - [RequiresUnreferencedCode("Count-by-predicate uses reflection to resolve members at runtime. Ensure all entity types are preserved.")] - internal async Task CountByPredicateAsync( - System.Linq.Expressions.LambdaExpression whereClause, - CancellationToken ct = default) - { - // Strategy 1: Index key-only scan — no data-page reads at all. - // Applicable whenever the predicate targets an indexed field AND the index fully - // covers the predicate (HasResiduePredicate=false). Strict operators (> / <) set - // IsExactFilter=false but HasResiduePredicate=false, so we still handle them here - // by passing the correct start/end inclusivity to CountRange. - // When HasResiduePredicate=true (compound AND with a non-indexed clause), we must - // fall through to FetchAsync so the residue predicate is applied per document. - var indexOpt = Query.IndexOptimizer.TryOptimize(whereClause, GetIndexes(), ConverterRegistry); - if (indexOpt != null - && !indexOpt.IsVectorSearch - && !indexOpt.IsSpatialSearch - && !indexOpt.HasResiduePredicate) - { - var index = _indexManager.GetIndex(indexOpt.IndexName); - if (index != null) - { - if (indexOpt.InValues != null) - { - int inCount = 0; - var seen = new HashSet(s_inProbeKeyComparer); - foreach (var key in indexOpt.InValues) - { - if (!seen.Add(key)) continue; - inCount += index.CountRange(key, key, true, true, null); - } - return inCount; - } - - // Use the per-bound inclusivity flags from OptimizationResult. - // These are set correctly for every operator (==, >=, >, <=, <) and - // propagated through AND-merges, so compound predicates like - // x.Price > 50 && x.Price < 90 get both boundaries exclusive. - return index.CountRange(indexOpt.MinValue, indexOpt.MaxValue, - indexOpt.StartInclusive, indexOpt.EndInclusive, null); - } - } - - // Strategy 2: BSON-level count — evaluates the predicate on raw BSON bytes without - // deserialising T. Combined with the Phase 1 widening of BsonExpressionEvaluator - // this covers the vast majority of real-world WHERE predicates. - if (BsonExpressionEvaluator.TryCompile(whereClause, ConverterRegistry, _storage.GetKeyMap()) is { } bsonPred) - return await CountScanAsync(bsonPred, ct).ConfigureAwait(false); - - // Strategy 3: Stream matching documents via FetchAsync and count without - // keeping them in a List, relying on FetchAsync's own BSON-scan / full-scan - // strategies to minimise allocations. - int count = 0; - await foreach (var _ in FetchAsync(whereClause, int.MaxValue, ct).ConfigureAwait(false)) - { - ct.ThrowIfCancellationRequested(); - count++; - } - return count; - } - - /// - /// Computes Sum, Average, Min or Max of over - /// the documents matching without materialising them, with the - /// result LINQ would compute over the entities. Returns Handled = false when the query - /// must run the usual way. - /// - /// Only for a property the mapper restores verbatim from a BSON field - /// (): the stored value, in the - /// document or in an index key, is then the value of the materialised property. Ignored, - /// computed and converted properties, and ones whose accessors have logic, are materialised. - /// - /// - /// An int/long property with a B-Tree index on exactly that property, and - /// no filter or one the same index covers exactly, is aggregated from the index keys - /// alone (Min/Max read one entry). Keys are in value order, so a - /// Sum/Average for which LINQ could overflow in one order but not in - /// another is not taken from them. - /// Otherwise, when no index serves the filter, the property is read from the raw BSON - /// of each document in primary-key order, the order a scan yields the entities in. A - /// document without the field, or holding a type the entity would not be read from - /// unchanged, ends the attempt so the query runs the usual way. - /// - /// - /// A plain property access, x => x.Property. - /// The result type of the LINQ operator. - [RequiresDynamicCode("Aggregate push-down uses index optimization and Expression.Compile() which require dynamic code generation.")] - [RequiresUnreferencedCode("Aggregate push-down uses reflection to resolve members at runtime. Ensure all entity types are preserved.")] - internal Task<(bool Handled, object? Value)> TryAggregateAsync( - AggregateOperator op, - LambdaExpression selector, - LambdaExpression? whereClause, - Type resultType, - CancellationToken ct = default) - { - if (selector.Body is not MemberExpression { Member: System.Reflection.PropertyInfo property } member - || member.Expression != selector.Parameters[0] - || selector.ReturnType != property.PropertyType - || ConverterRegistry.HasConverter(property.Name)) - return Task.FromResult<(bool, object?)>(default); - - var valueType = Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType; - bool nullable = valueType != property.PropertyType; - - if (valueType == typeof(int)) return TryAggregateAsync(op, property, nullable, whereClause, resultType, ct); - if (valueType == typeof(long)) return TryAggregateAsync(op, property, nullable, whereClause, resultType, ct); - if (valueType == typeof(double)) return TryAggregateAsync(op, property, nullable, whereClause, resultType, ct); - if (valueType == typeof(float)) return TryAggregateAsync(op, property, nullable, whereClause, resultType, ct); - if (valueType == typeof(decimal)) return TryAggregateAsync(op, property, nullable, whereClause, resultType, ct); - return Task.FromResult<(bool, object?)>(default); - } - - [RequiresDynamicCode("Aggregate push-down uses index optimization and Expression.Compile() which require dynamic code generation.")] - [RequiresUnreferencedCode("Aggregate push-down uses reflection to resolve members at runtime. Ensure all entity types are preserved.")] - private async Task<(bool Handled, object? Value)> TryAggregateAsync( - AggregateOperator op, - System.Reflection.PropertyInfo property, - bool nullable, - LambdaExpression? whereClause, - Type resultType, - CancellationToken ct) - where TValue : struct, IComparable - { - if (LinqAggregate.ResultType(op, nullable) != resultType) - return default; - - // The field the mapper restores the property from unchanged. Without it, neither the raw - // field nor an index key (computed from the property when the document was written) is - // known to be the value the materialised entity returns. - var fieldName = (_mapper as DocumentMapperBase)?.GetVerbatimFieldName(property.Name); - if (fieldName == null) - return default; - - var plan = whereClause == null ? null : Query.IndexOptimizer.TryOptimize(whereClause, GetIndexes(), ConverterRegistry); - - // Index keys hold the exact int/long value. Not for the other types: floating-point sums - // depend on the order of the values, decimal keys drop trailing zeros (1.50 → 1.5), and the - // first of equal Min/Max values wins (-0.0 vs 0.0, 1.0 vs 1.00). - if (typeof(TValue) == typeof(int) || typeof(TValue) == typeof(long)) - { - var index = _indexManager.GetAllIndexes().FirstOrDefault(i => - i.Definition.Type == IndexType.BTree - && !i.Definition.IsPrimary - && i.Definition.PropertyPaths.Length == 1 - && i.Definition.PropertyPaths[0] == property.Name - && KeysAreValuesOf(i.Definition, property, typeof(TValue))); - - if (index != null) - { - (bool Handled, object? Value) fromKeys = default; - if (whereClause == null) - { - fromKeys = AggregateIndexKeys(op, nullable, index, null, null, true, true); - } - else if (plan != null - && plan.IndexName == index.Name - && !plan.HasResiduePredicate - && !plan.IsVectorSearch - && !plan.IsSpatialSearch - && plan.InValues == null - && plan.MinValue is null or TValue - && plan.MaxValue is null or TValue) - { - // The optimizer keeps the constant's own type (r.Count < 9.5 gives a double - // bound), and keys carry no type tag, so a bound of another type would be - // compared bytewise against the int/long keys. Such filters take the path below, - // which post-filters the documents the index returns. - fromKeys = AggregateIndexKeys(op, nullable, index, plan.MinValue, plan.MaxValue, plan.StartInclusive, plan.EndInclusive); - } - if (fromKeys.Handled) - return fromKeys; - // Otherwise aggregate the values in the order LINQ sees them, below. - } - } - - // An index serves the filter: fetching the matching documents through it is cheaper than - // scanning them all, and keeps the order the values are aggregated in unchanged. - if (plan != null) - return default; - - BsonReaderPredicate? predicate = null; - if (whereClause != null) - { - predicate = BsonExpressionEvaluator.TryCompile(whereClause, ConverterRegistry, _storage.GetKeyMap()); - if (predicate == null) return default; - } - - return await AggregateRawFieldAsync(op, nullable, fieldName, predicate, ct).ConfigureAwait(false); - } - - /// - /// Whether the keys of the index are the values of - /// itself (x => x.Property), encoded as - /// : the user key is encoded from the boxed value the selector - /// returns, so this fixes the key layout the index path decodes. - /// - private static bool KeysAreValuesOf(CollectionIndexDefinition definition, System.Reflection.PropertyInfo property, Type valueType) - { - var body = definition.KeySelectorExpression.Body; - while (body is UnaryExpression { NodeType: ExpressionType.Convert } convert && convert.Type == typeof(object)) - body = convert.Operand; - - return body is MemberExpression { Member: System.Reflection.PropertyInfo keyProperty } access - && access.Expression == definition.KeySelectorExpression.Parameters[0] - && keyProperty.Name == property.Name - && (Nullable.GetUnderlyingType(access.Type) ?? access.Type) == valueType; - } - - /// Aggregates the values of the index entries in the given range from their keys. - private static (bool Handled, object? Value) AggregateIndexKeys( - AggregateOperator op, - bool nullable, - CollectionSecondaryIndex index, - object? min, - object? max, - bool startInclusive, - bool endInclusive) - where TValue : struct, IComparable - { - var aggregate = new LinqAggregate(op, nullable, anyOrder: true); - - if (op == AggregateOperator.Max) - { - // Keys are [value][document id]: the last entry holds the largest value. - var last = index.LastKey(min, max, startInclusive, endInclusive); - if (last is { } key) - { - if (!IndexKeyValueVisitor.TryDecode(key.Data, out var value)) return default; - aggregate.Add(value); - } - return (true, aggregate.Result()); - } - - var visitor = new IndexKeyValueVisitor(aggregate, stopAfterFirst: op == AggregateOperator.Min); - index.ScanKeys(min, max, startInclusive, endInclusive, ref visitor); - return visitor.Malformed || aggregate.DependsOnOrder ? default : (true, aggregate.Result()); - } - - /// - /// Feeds the value prefix of each [value][document id] key of an int or - /// long index to a . - /// - private struct IndexKeyValueVisitor : BTreeIndex.IKeyVisitor - where TValue : struct, IComparable - { - private readonly LinqAggregate _aggregate; - private readonly bool _stopAfterFirst; - - public IndexKeyValueVisitor(LinqAggregate aggregate, bool stopAfterFirst) - { - _aggregate = aggregate; - _stopAfterFirst = stopAfterFirst; - Malformed = false; - } - - /// A key did not start with an encoded value of the expected type. - public bool Malformed { get; private set; } - - public bool Visit(ReadOnlySpan key) - { - if (!TryDecode(key, out var value)) - { - Malformed = true; - return false; - } - _aggregate.Add(value); - // Past an order-dependent sum the remaining keys cannot change the outcome. - return !_stopAfterFirst && !_aggregate.DependsOnOrder; - } - - /// - /// Decodes the / prefix of a - /// [value][document id] key. The index was checked to be keyed by a - /// (); a key not laid out that way - /// (no prefix byte, or no document id after the value) is reported as malformed rather than - /// decoded. - /// - public static bool TryDecode(ReadOnlySpan key, out TValue value) - { - value = default; - if (key.Length == 0 || key[0] != IndexKey.KeyPrefix) return false; - - if (typeof(TValue) == typeof(int)) - { - if (key.Length <= 5) return false; - value = (TValue)(object)(int)(BinaryPrimitives.ReadUInt32BigEndian(key.Slice(1)) ^ 0x8000_0000u); - return true; - } - if (typeof(TValue) == typeof(long)) - { - if (key.Length <= 9) return false; - value = (TValue)(object)(long)(BinaryPrimitives.ReadUInt64BigEndian(key.Slice(1)) ^ 0x8000_0000_0000_0000UL); - return true; - } - return false; - } - } - - /// - /// Aggregates field of every document matching - /// from raw BSON, in primary-key order. Gives up (Handled = - /// false) on a document without the field, with a BSON type other than the one the mapper - /// writes for (or null for a nullable property), or that - /// fails to evaluate, since materialising the entity could then yield a different value. - /// - private async Task<(bool Handled, object? Value)> AggregateRawFieldAsync( - AggregateOperator op, - bool nullable, - string fieldName, - BsonReaderPredicate? predicate, - CancellationToken ct) - where TValue : struct, IComparable - { - if (!_storage.GetKeyMap().TryGetValue(fieldName, out var fieldId)) - return default; // never written: every document lacks it - - var expectedType = - typeof(TValue) == typeof(int) ? BsonType.Int32 : - typeof(TValue) == typeof(long) ? BsonType.Int64 : - typeof(TValue) == typeof(decimal) ? BsonType.Decimal128 : - BsonType.Double; // double and float - var keyMap = _storage.GetKeyReverseMap(); - var aggregate = new LinqAggregate(op, nullable); - - // Adds the document's value if it matches; false to give up. - bool Fold(ReadOnlySpan bson) - { - try - { - if (predicate != null && !predicate(new BsonSpanReader(bson, keyMap))) - return true; - - var reader = new BsonSpanReader(bson, keyMap); - reader.ReadDocumentSize(); - var firstElement = reader.Position; - if (!reader.TrySeekToField(fieldId, out var type)) - { - // No usable offset table entry: look for the field from the first element on. - reader.Seek(firstElement); - if (!SeekField(ref reader, fieldName, out type)) - return false; - } - - if (type == BsonType.Null) - return nullable; - if (type != expectedType) - return false; - - aggregate.Add(ReadValue(ref reader)); - return true; - } - catch - { - return false; - } - } - - // The scan yields only for a document that ends the attempt. - await foreach (var _ in VisitRawDocumentsAsync(0UL, (ReadOnlySpan bson, out bool giveUp) => giveUp = !Fold(bson), ct).ConfigureAwait(false)) - return default; - - return (true, aggregate.Result()); - - static bool SeekField(ref BsonSpanReader reader, string name, out BsonType type) - { - while (reader.Remaining > 1) - { - type = reader.ReadBsonType(); - if (type == BsonType.EndOfDocument) break; - if (reader.ReadElementHeader() == name) return true; - reader.SkipValue(type); - } - type = BsonType.EndOfDocument; - return false; - } - - static TV ReadValue(ref BsonSpanReader reader) - { - if (typeof(TV) == typeof(int)) return (TV)(object)reader.ReadInt32(); - if (typeof(TV) == typeof(long)) return (TV)(object)reader.ReadInt64(); - if (typeof(TV) == typeof(double)) return (TV)(object)reader.ReadDouble(); - if (typeof(TV) == typeof(float)) return (TV)(object)(float)reader.ReadDouble(); - return (TV)(object)reader.ReadDecimal128(); - } - } - - /// - /// Queries the collection using an expression tree predicate, enabling index optimisation. - /// When the predicate references an indexed field the query engine performs a BTree lookup - /// instead of a full collection scan. - /// The returned preserves the expression tree so that - /// terminal operators (FirstOrDefaultAsync, ToListAsync, etc.) can inject - /// limits before materialisation. - /// - [RequiresDynamicCode("LINQ-style find operations use Expression.Compile() and index optimization which require dynamic code generation.")] - [RequiresUnreferencedCode("LINQ-style find operations use reflection to resolve members at runtime. Ensure all entity types are preserved.")] - public IAsyncEnumerable FindAsync( - System.Linq.Expressions.Expression> predicate, - CancellationToken ct = default) - => FetchAsync(predicate, int.MaxValue, ct); - - /// - [RequiresDynamicCode("LINQ-style find operations use Expression.Compile() and index optimization which require dynamic code generation.")] - [RequiresUnreferencedCode("LINQ-style find operations use reflection to resolve members at runtime. Ensure all entity types are preserved.")] - public IAsyncEnumerable FindAsync( - System.Linq.Expressions.Expression> predicate, - ITransaction? transaction, - CancellationToken ct = default) - => FetchAsync(predicate, int.MaxValue, transaction, ct); - - /// - /// Returns the first document matching , or null if none. - /// Stops reading from the storage engine as soon as one document is found (fetchLimit = 1). - /// - [RequiresDynamicCode("LINQ-style find operations use Expression.Compile() and index optimization which require dynamic code generation.")] - [RequiresUnreferencedCode("LINQ-style find operations use reflection to resolve members at runtime. Ensure all entity types are preserved.")] - public Task FindOneAsync( - System.Linq.Expressions.Expression> predicate, - CancellationToken ct = default) - => FindOneAsync(predicate, null, ct); - - /// - [RequiresDynamicCode("LINQ-style find operations use Expression.Compile() and index optimization which require dynamic code generation.")] - [RequiresUnreferencedCode("LINQ-style find operations use reflection to resolve members at runtime. Ensure all entity types are preserved.")] - public async Task FindOneAsync( - System.Linq.Expressions.Expression> predicate, - ITransaction? transaction, - CancellationToken ct = default) - { - var sw = _storage.MetricsDispatcher != null ? ValueStopwatch.StartNew() : default; - bool success = false; - try - { - // Fast path: equality query on an indexed field. - // Bypasses FetchAsync / QueryIndexAsync / page-cache Dictionary entirely. - // Path: IndexOptimizer → CollectionSecondaryIndex.Seek (TryFindFirst) → FindByLocationAsync. - var indexOpt = Query.IndexOptimizer.TryOptimize(predicate, GetIndexes(), ConverterRegistry); - if (indexOpt != null && !indexOpt.IsRange && !indexOpt.IsVectorSearch && !indexOpt.IsSpatialSearch - && indexOpt.MinValue != null) - { - var index = _indexManager.GetIndex(indexOpt.IndexName); - if (index != null) - { - var location = index.Seek(indexOpt.MinValue, transaction); - if (location == null) return default; - var result = await FindByLocationAsync(location.Value, transaction?.TransactionId ?? 0UL, ct).ConfigureAwait(false); - success = result != null; - return result; - } - } - - // General path: range / vector / spatial / no index. - await foreach (var item in FetchAsync(predicate, 1, transaction, ct).ConfigureAwait(false)) - { - success = true; - return item; - } - return default; - } - finally - { - if (sw.IsActive) - _storage.MetricsDispatcher?.Publish(new MetricEvent - { - Timestamp = sw.StartTimestamp, - Type = MetricEventType.CollectionQuery, - ElapsedMicros = sw.GetElapsedMicros(), - CollectionName = _collectionName, - Success = success, - }); - } - } - - /// - /// Core data-fetching primitive used by both and - /// . - /// Selects the cheapest access strategy in order: - /// - /// BTree index scan (narrows candidates to the index range) + in-memory WHERE filter - /// BSON-level predicate scan (compiled once, no full deserialization per-document) - /// Full collection scan + in-memory predicate filter - /// - /// All three strategies guarantee the returned stream is fully filtered: callers - /// never need to re-apply after consuming this enumerable. - /// - /// - /// Optional filter. Must be an Expression<Func<T, bool>> at runtime. - /// Pass null to enumerate all documents. - /// - /// - /// Maximum number of documents to yield. Pass for no limit. - /// - [RequiresDynamicCode("LINQ-style fetch operations use Expression.Compile() and index optimization which require dynamic code generation.")] - [RequiresUnreferencedCode("LINQ-style fetch operations use reflection to resolve members at runtime. Ensure all entity types are preserved.")] - internal IAsyncEnumerable FetchAsync( - System.Linq.Expressions.LambdaExpression? whereClause, - int fetchLimit, - CancellationToken ct = default) - => FetchAsync(whereClause, fetchLimit, null, ct); - - [RequiresDynamicCode("LINQ-style fetch operations use Expression.Compile() and index optimization which require dynamic code generation.")] - [RequiresUnreferencedCode("LINQ-style fetch operations use reflection to resolve members at runtime. Ensure all entity types are preserved.")] - internal async IAsyncEnumerable FetchAsync( - System.Linq.Expressions.LambdaExpression? whereClause, - int fetchLimit, - ITransaction? transaction, - [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default) - { - int yielded = 0; - - if (whereClause != null) - { - // Lazy compile: Expression.Compile() is expensive (~1 ms) — pay it at most once - // per FetchAsync call, only if we actually need the compiled delegate. - Func? compiledWhere = null; - Func GetCompiled() => - compiledWhere ??= ((System.Linq.Expressions.Expression>)whereClause).Compile(); - - // ── Strategy 1: BTree index ─────────────────────────────────────── - // FilterCompleteness.Exact → index range == full WHERE result; no post-filter. - // FilterCompleteness.StrictBoundary / .PartialAnd - // → index only narrows candidates; reuse the lazily-compiled Func as post-filter. - var indexOpt = Query.IndexOptimizer.TryOptimize(whereClause, GetIndexes(), ConverterRegistry); - if (indexOpt != null) - { - if (indexOpt.IsVectorSearch) - { - // The index satisfied the VectorSearch term; the post-filter checks the rest. - // Compiled lazily, like GetCompiled(), only when a candidate needs it. - bool exact = indexOpt.FilterCompleteness == Query.IndexOptimizer.FilterCompleteness.Exact; - Func? residual = null; - await foreach (var item in VectorSearchAsync(indexOpt.IndexName, indexOpt.VectorQuery!, indexOpt.K, ct: ct)) - { - if (!exact && !(residual ??= ((System.Linq.Expressions.Expression>)Query.IndexOptimizer.WithoutCall(whereClause, indexOpt.VectorSearchCall!)).Compile())(item)) - continue; - yield return item; - if (++yielded >= fetchLimit) yield break; - } - } - else if (indexOpt.IsSpatialSearch) - { - var spatialSeq = indexOpt.SpatialType == Query.IndexOptimizer.SpatialQueryType.Near - ? NearAsync(indexOpt.IndexName, indexOpt.SpatialPoint, indexOpt.RadiusKm, ct) - : WithinAsync(indexOpt.IndexName, indexOpt.SpatialMin, indexOpt.SpatialMax, ct); - await foreach (var item in spatialSeq) - if (indexOpt.FilterCompleteness == Query.IndexOptimizer.FilterCompleteness.Exact || GetCompiled()(item)) { yield return item; if (++yielded >= fetchLimit) yield break; } - } - else - { - if (indexOpt.InValues != null) - { - var seen = new HashSet(s_inProbeKeyComparer); - foreach (var key in indexOpt.InValues) - { - if (!seen.Add(key)) continue; - await foreach (var item in QueryIndexAsync(indexOpt.IndexName, key, key, true, 0, int.MaxValue, transaction, ct)) - if (indexOpt.FilterCompleteness == Query.IndexOptimizer.FilterCompleteness.Exact || GetCompiled()(item)) { yield return item; if (++yielded >= fetchLimit) yield break; } - } - } - else - { - await foreach (var item in QueryIndexAsync(indexOpt.IndexName, indexOpt.MinValue, indexOpt.MaxValue, true, 0, int.MaxValue, transaction, ct)) - if (indexOpt.FilterCompleteness == Query.IndexOptimizer.FilterCompleteness.Exact || GetCompiled()(item)) { yield return item; if (++yielded >= fetchLimit) yield break; } - } - } - yield break; - } - - // ── Strategy 2: BSON-level predicate scan ───────────────────────── - // Filters at raw-BSON level before deserializing — no compiled Func needed. - if (BsonExpressionEvaluator.TryCompile(whereClause, ConverterRegistry, _storage.GetKeyMap()) is { } bsonPred) - { - await foreach (var item in ScanAsync(bsonPred, transaction, ct)) - { - yield return item; - if (++yielded >= fetchLimit) yield break; - } - yield break; - } - - // ── Strategy 3: full scan + in-memory filter ────────────────────── - // Reuses compiledWhere if Strategy 1 already triggered it; compiles otherwise. - await foreach (var item in FindAllAsync(transaction, ct)) - if (GetCompiled()(item)) { yield return item; if (++yielded >= fetchLimit) yield break; } - } - else - { - // ── No WHERE: plain full scan with optional limit ───────────────── - await foreach (var item in FindAllAsync(transaction, ct)) - { - yield return item; - if (++yielded >= fetchLimit) yield break; - } - } - } - - private void SnapshotFsiForTransaction(ITransaction transaction, uint pageId) - { - if (_fsi.SnapshotForTransaction(transaction.TransactionId, pageId)) - { - var txnId = transaction.TransactionId; - transaction.OnRollback += () => _fsi.RollbackTransaction(txnId); - - if (transaction is Transaction concreteTxn) - concreteTxn.OnCommit += () => _fsi.CommitTransaction(txnId); - } - } - - private static readonly IEqualityComparer s_inProbeKeyComparer = new InProbeKeyComparer(); - - private sealed class InProbeKeyComparer : IEqualityComparer - { - bool IEqualityComparer.Equals(object? x, object? y) - { - if (ReferenceEquals(x, y)) return true; - if (x is null || y is null) return false; - if (x is byte[] xb && y is byte[] yb) return xb.AsSpan().SequenceEqual(yb); - return x.Equals(y); - } - - int IEqualityComparer.GetHashCode(object? obj) - { - if (obj is null) return 0; - if (obj is byte[] bytes) - { - var hc = new HashCode(); - foreach (var b in bytes) hc.Add(b); - return hc.ToHashCode(); - } - return obj.GetHashCode(); - } - } - - #endregion - - /// - /// Serializes an entity with adaptive buffer sizing (Stepped Retry). - /// Strategies: - /// 1. 64KB (Covers 99% of docs, small overhead) - /// 2. 2MB (Covers large docs) - /// 3. 16MB (Max limit) - /// - private int SerializeWithRetry(T entity, out byte[] rentedBuffer) - { - // Cache once — avoids repeated volatile reads of the FrozenDictionary snapshot inside the loop. - var keyMap = _storage.GetFrozenKeyMap(); - - // Stored as compiler-constant data segment — no heap allocation. - ReadOnlySpan steps = [65536, 524288, 2097152, 16777216]; // 64KB, 512KB, 2MB, 16MB - - int minSize = _lastSerializedSize; - - foreach (var size in steps) - { - int bufSize = size < _storage.PageSize ? _storage.PageSize : size; - - // Skip steps that are smaller than the last known serialized size. - // This avoids predictable failures and the associated exception overhead. - if (bufSize < minSize) continue; - - var buffer = ArrayPool.Shared.Rent(bufSize); - try - { - int bytesWritten = _mapper.Serialize(entity, new BsonSpanWriter(buffer, keyMap)); - - // Inject schema version if available - if (CurrentSchemaVersion != null) - { - if (bytesWritten + 8 > buffer.Length) - { - throw new IndexOutOfRangeException("Not enough space for version field"); - } - AppendVersionField(buffer, ref bytesWritten); - } - - // Update warm-start hint: last size + 25% margin so the next document of - // similar size starts at the right step without needing a retry. - _lastSerializedSize = Math.Max(_lastSerializedSize, bytesWritten + (bytesWritten >> 2)); - - rentedBuffer = buffer; - return bytesWritten; - } - catch (Exception ex) when (ex is ArgumentException || ex is IndexOutOfRangeException || ex is ArgumentOutOfRangeException) - { - ArrayPool.Shared.Return(buffer); - // Continue to next step - } - catch - { - ArrayPool.Shared.Return(buffer); - throw; - } - } - - rentedBuffer = null!; // specific compiler satisfaction, though we throw - throw new BLiteValidationException($"Document too large. Maximum size allowed is 16MB."); - } - - /// - /// Appends a version field to the specified BSON buffer if a current schema version is set. - /// - /// The version field is only appended if a current schema version is available. The method - /// updates the BSON document's size and ensures the buffer remains in a valid BSON format. - /// The byte array buffer to which the version field is appended. Must be large enough to accommodate the additional - /// bytes. - /// A reference to the number of bytes written to the buffer. Updated to reflect the new total after the version - /// field is appended. - private void AppendVersionField(byte[] buffer, ref int bytesWritten) - { - if (CurrentSchemaVersion == null) return; - - int version = CurrentSchemaVersion.Value.Version; - - // BSON element for _v (Int32) with Compressed Key: - // Type (1 byte: 0x10) - // Key ID (2 bytes, little-endian) - // Value (4 bytes: int32) - // Total = 7 bytes - - int pos = bytesWritten - 1; // Position of old 0x00 terminator - buffer[pos++] = 0x10; // Int32 - - ushort versionKeyId = _storage.GetOrAddDictionaryEntry("_v"); - BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(pos, 2), versionKeyId); - pos += 2; - - BinaryPrimitives.WriteInt32LittleEndian(buffer.AsSpan(pos, 4), version); - pos += 4; - - buffer[pos++] = 0x00; // new document terminator - - bytesWritten = pos; - - // UpdateAsync total size (first 4 bytes) - BinaryPrimitives.WriteInt32LittleEndian(buffer.AsSpan(0, 4), bytesWritten); - } - - /// - /// Asynchronously performs a vector similarity search on the specified index and returns up to - /// the top-k matching documents. - /// - /// The name of the index to search - /// The query vector - /// Maximum number of nearest neighbors to return - /// Size of the dynamic candidate list during search - /// Cancellation token - /// Async enumerable of matching documents - public async IAsyncEnumerable VectorSearchAsync( - string indexName, - float[] query, - int k, - int efSearch = 100, - [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default) - { - var index = _indexManager.GetIndex(indexName); - if (index == null) - throw new ArgumentException($"Index '{indexName}' not found.", nameof(indexName)); - - foreach (var result in index.VectorSearch(query, k, efSearch, null)) - { - ct.ThrowIfCancellationRequested(); - var doc = await FindByLocationAsync(result.Location, 0UL, ct); - if (doc != null) yield return doc; - } - } - - /// - /// Asynchronously finds all documents located within a specified radius of a geographic center - /// point using a spatial index. - /// - /// The name of the spatial index to use for the search - /// Latitude and longitude of the center point - /// The search radius in kilometers - /// Cancellation token - /// Async enumerable of matching documents - public async IAsyncEnumerable NearAsync( - string indexName, - (double Latitude, double Longitude) center, - double radiusKm, - [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default) - { - var index = _indexManager.GetIndex(indexName); - if (index == null) - throw new ArgumentException($"Index '{indexName}' not found.", nameof(indexName)); - - foreach (var loc in index.Near(center, radiusKm, null)) - { - ct.ThrowIfCancellationRequested(); - var doc = await FindByLocationAsync(loc, 0UL, ct); - if (doc != null) yield return doc; - } - } - - /// - /// Asynchronously returns all documents within the specified rectangular geographic area from - /// the given spatial index. - /// - /// The name of the spatial index to search within - /// Minimum latitude and longitude coordinates - /// Maximum latitude and longitude coordinates - /// Cancellation token - /// Async enumerable of matching documents - public async IAsyncEnumerable WithinAsync( - string indexName, - (double Latitude, double Longitude) min, - (double Latitude, double Longitude) max, - [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default) - { - var index = _indexManager.GetIndex(indexName); - if (index == null) - throw new ArgumentException($"Index '{indexName}' not found.", nameof(indexName)); - - foreach (var loc in index.Within(min, max, null)) - { - ct.ThrowIfCancellationRequested(); - var doc = await FindByLocationAsync(loc, 0UL, ct); - if (doc != null) yield return doc; - } - } - - /// - /// Subscribes to a change stream that notifies observers of changes to the collection. - /// - /// - /// The returned observable emits events as changes are detected in the collection. - /// When is , personal-data - /// fields (annotated with [PersonalData] or registered via HasPersonalData) - /// are masked by default (RevealPersonalData defaults to ). - /// - /// - /// Controls payload capture and GDPR masking behaviour. Pass (or - /// omit the argument) to use the default — no payload, GDPR-safe masking if payload is - /// later enabled. - /// - /// An observable sequence of change stream events for the collection. - /// Thrown if CDC is not initialized for the storage. - [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode( - "Watch resolves personal-data fields via PersonalDataResolver which uses reflection.")] - public IObservable> Watch(WatchOptions? options = null) - { - if (_storage.Cdc == null) throw new InvalidOperationException("CDC is not initialized."); - - var opts = options ?? new WatchOptions(); - - // Defensively copy the field lists so that mutations to the caller's collection - // after Watch() returns do not affect the registered masking policy. - // Only copy when the lists are non-empty (ExcludeFields) or non-null and non-empty - // (IncludeOnlyFields) to avoid unnecessary allocation for the common case. - var needsCopy = opts.ExcludeFields.Count > 0 || (opts.IncludeOnlyFields?.Count ?? 0) > 0; - var safeOpts = needsCopy - ? new WatchOptions - { - CapturePayload = opts.CapturePayload, - RevealPersonalData = opts.RevealPersonalData, - PersonalDataMaskValue = opts.PersonalDataMaskValue, - ExcludeFields = opts.ExcludeFields.Count > 0 - ? Array.AsReadOnly(opts.ExcludeFields.ToArray()) - : Array.Empty(), - IncludeOnlyFields = opts.IncludeOnlyFields?.Count > 0 - ? Array.AsReadOnly(opts.IncludeOnlyFields.ToArray()) - : null - } - : opts; - - // Resolve personal-data fields once at Watch() time and cache on the observable - // so the dispatch path is O(personalFields) per event, not O(types) per event. - // Skip the reflection work entirely when CapturePayload is false — the field list - // is irrelevant for metadata-only events. - var personalDataFields = safeOpts.CapturePayload - ? BLite.Core.GDPR.PersonalDataResolver.Resolve(typeof(T)) - : Array.Empty(); - - return new ChangeStreamObservable( - _storage.Cdc, - _collectionName, - safeOpts, - _mapper, - _storage.GetKeyReverseMap(), - _storage.GetFrozenKeyMap(), - personalDataFields); - } - - /// - /// Subscribes to a change stream that notifies observers of changes to the collection. - /// - /// - /// When , each event includes the full BSON payload of the changed - /// document (after applying GDPR masking rules). When (default), - /// only metadata is included. - /// - /// An observable sequence of change stream events for the collection. - /// Thrown if CDC is not initialized for the storage. - [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode( - "Watch resolves personal-data fields via PersonalDataResolver which uses reflection.")] - public IObservable> Watch(bool capturePayload) - => Watch(new WatchOptions { CapturePayload = capturePayload }); - - private Task NotifyCdc(OperationType type, TId id, ITransaction transaction, ReadOnlyMemory docData = default) - { - if (_storage.Cdc == null) return Task.CompletedTask; - - // Early exit if no watchers for this collection - avoid allocations - if (!_storage.Cdc.HasAnyWatchers(_collectionName)) return Task.CompletedTask; - - ReadOnlyMemory? payload = null; - if (!docData.IsEmpty && _storage.Cdc.HasPayloadWatchers(_collectionName)) - { - payload = docData.ToArray(); - } - - var idBytes = _mapper.ToIndexKey(id).Data.ToArray(); - - if (transaction is Transaction t) - { - t.AddChange(new InternalChangeEvent - { - Timestamp = DateTime.UtcNow.Ticks, - TransactionId = transaction.TransactionId, - CollectionName = _collectionName, - Type = type, - IdBytes = idBytes, - PayloadBytes = payload - }); - } - - return Task.CompletedTask; - } - - /// - /// Releases all resources used by the current instance of the class. - /// - /// Call this method when you are finished using the object to free unmanaged resources - /// immediately. After calling Dispose, the object should not be used. - public void Dispose() - { - _retentionTimer?.Dispose(); - _retentionTimer = null; - _indexManager.Dispose(); - GC.SuppressFinalize(this); - } - - public ValueTask InsertAsync(T entity, CancellationToken ct = default) - { - return InsertAsync(entity, null, ct); - } - - public ValueTask> InsertBulkAsync(IEnumerable entities, CancellationToken ct = default) - { - return InsertBulkAsync(entities, null, ct); - } - - public ValueTask UpdateAsync(T entity, CancellationToken ct = default) - { - return UpdateAsync(entity, null, ct); - } - - public ValueTask UpdateBulkAsync(IEnumerable entities, CancellationToken ct = default) - { - return UpdateBulkAsync(entities, null, ct); - } - - public ValueTask DeleteAsync(TId id, CancellationToken ct = default) - { - return DeleteAsync(id, null, ct); - } - - public ValueTask DeleteBulkAsync(IEnumerable ids, CancellationToken ct = default) - { - return DeleteBulkAsync(ids, null, ct); - } -} +using BLite.Core.CDC; +using System; +using System.Buffers; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using BLite.Bson; +using BLite.Core.Indexing; +using BLite.Core.Metadata; +using BLite.Core.Metrics; +using BLite.Core.Retention; +using BLite.Core.Storage; +using BLite.Core.Transactions; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Linq.Expressions; +using BLite.Core.Query; + +[assembly: InternalsVisibleTo("BLite.Tests")] + +namespace BLite.Core.Collections; + +public class DocumentCollection : DocumentCollection, IDocumentCollection where T : class +{ + [RequiresDynamicCode("DocumentCollection uses CollectionIndexManager which compiles index key selectors via Expression.Compile().")] + [RequiresUnreferencedCode("Index creation uses reflection (Expression.PropertyOrField) to access type members. Ensure all entity types and their members are preserved.")] + public DocumentCollection(StorageEngine storage, ITransactionHolder transactionHolder, IDocumentMapper mapper, string? collectionName = null) + : base(storage, transactionHolder, mapper, collectionName) + { + } +} + +/// +/// Production-ready document collection with slotted page architecture. +/// Supports multiple documents per page, overflow chains, and efficient space utilization. +/// +/// Represents a collection of documents of type T with an ID of type TId. +/// +/// Type of the primary key +/// Type of the entity +public class DocumentCollection : IDocumentCollection, IDisposable where T : class +{ + private readonly ITransactionHolder _transactionHolder; + private readonly StorageEngine _storage; + private readonly IDocumentMapper _mapper; + internal readonly BTreeIndex _primaryIndex; + private readonly CollectionIndexManager _indexManager; + private readonly string _collectionName; + + // Free space tracking: 16-bucket index for O(1) FindPage + private readonly FreeSpaceIndex _fsi; + // Cached delegate — avoids per-call closure allocation when passed to _fsi.FindPage. + private readonly Func _isPageLocked; + private bool _isTimeSeries; + private string? _ttlFieldName; + + // ── Generalized Retention Policy ───────────────────────────────────────── + private RetentionPolicy? _retentionPolicy; + private Timer? _retentionTimer; + private int _retentionRunning; // 0=idle, 1=running (Interlocked flag) + + // Current page for inserts (optimization) + private uint _currentDataPage; + + public SchemaVersion? CurrentSchemaVersion { get; private set; } + + // Concurrency control for write operations (B-Tree and Page modifications). + // Obtained from the storage engine: single-file databases share one instance across all + // collections, because page placement depends on file-wide state. Never disposed here — the + // instance may be shared with other collections. + private readonly SemaphoreSlim _collectionLock; + private int WriteLockTimeoutMs => _storage.LockTimeout.WriteTimeoutMs; + + private readonly int _maxDocumentSizeForSinglePage; + + // Tracks the last successful serialized size (+25% margin) to skip retry steps that are + // known to be too small. Volatile: benign race in Parallel.For — worst case a step is + // not skipped once, never a correctness issue. + private volatile int _lastSerializedSize = 65536; + + // Value converters registered via OnModelCreating (e.g. ValueObject Id converters). + // Exposed to BTreeQueryProvider so the query engine can convert ValueObjects to BSON + // primitives at query-plan time, enabling index lookups on ValueObject-keyed collections. + internal ValueConverterRegistry ConverterRegistry { get; private set; } = ValueConverterRegistry.Empty; + + internal void SetConverterRegistry(ValueConverterRegistry registry) => + ConverterRegistry = registry ?? ValueConverterRegistry.Empty; + + /// The name of this collection (lowercase). Exposed for audit and query components. + internal string CollectionName => _collectionName; + + /// The storage engine backing this collection. Exposed for audit hooks. + internal StorageEngine Storage => _storage; + + /// + /// Exposes the storage key map (field name → field ID) so that query components + /// in the same assembly (e.g. ) can pass + /// it to projection/predicate compilers for offset-table fast paths. + /// + internal IReadOnlyDictionary GetKeyMap() => _storage.GetKeyMap(); + + /// + /// Configures this collection as a TimeSeries with automatic TTL-based pruning. + /// Must be called before the first insert. Persists configuration to storage metadata. + /// + internal void SetTimeSeries(string ttlFieldName, TimeSpan retention) + { + var meta = _indexManager.GetMetadata(); + meta.IsTimeSeries = true; + meta.TtlFieldName = ttlFieldName; + meta.RetentionPolicyMs = (long)retention.TotalMilliseconds; + meta.LastPruningTimestamp = DateTime.UtcNow.Ticks; + _storage.SaveCollectionMetadata(meta); + _isTimeSeries = true; + _ttlFieldName = ttlFieldName; + } + + /// + /// Forces immediate pruning of expired TimeSeries documents, regardless of automatic thresholds. + /// Primarily intended for testing; in production, pruning is triggered automatically on insert. + /// + public async Task ForcePruneAsync() + { + if (!_isTimeSeries) + throw new InvalidOperationException("ForcePrune is only valid on TimeSeries collections."); + + // Read fresh metadata from storage: InsertTimeSeries updates TimeSeriesHeadPageId + // in StorageEngine's own metadata store, which is separate from _indexManager._metadata. + var meta = _storage.GetCollectionMetadata(_collectionName); + if (meta == null || meta.RetentionPolicyMs <= 0) return; + + var transaction = _storage.BeginTransaction(IsolationLevel.ReadCommitted); + _storage.PruneTimeSeries(meta, transaction); + meta.InsertedSinceLastPruning = 0; + meta.LastPruningTimestamp = DateTime.UtcNow.Ticks; + _storage.SaveCollectionMetadata(meta); + await transaction.CommitAsync(); + } + + #region Retention Policy + + private static readonly TimeSpan DefaultScheduledInterval = TimeSpan.FromMinutes(5); + + /// + /// Configures a generalized retention policy for this collection. + /// The policy is persisted in collection metadata and survives engine restarts. + /// Can be called directly on the collection or via the model builder in + /// configuration. + /// + /// The retention policy to apply. Must not be null. + public void SetRetentionPolicy(RetentionPolicy policy) + { + if (policy == null) throw new ArgumentNullException(nameof(policy)); + var meta = _indexManager.GetMetadata(); + meta.GeneralRetentionPolicy = policy; + _storage.SaveCollectionMetadata(meta); + ApplyRetentionPolicyConfig(policy); + } + + /// + /// Immediately runs the retention policy regardless of any triggers. + /// Primarily intended for testing; in production, retention is triggered automatically. + /// + public async Task ForceApplyRetentionPolicyAsync(CancellationToken ct = default) + { + // Load from persisted metadata if not yet set in memory. + if (_retentionPolicy == null) + { + var metaPolicy = _storage.GetCollectionMetadata(_collectionName)?.GeneralRetentionPolicy; + if (metaPolicy == null) return; + ApplyRetentionPolicyConfig(metaPolicy); + } + + if (!await _collectionLock.WaitAsync(WriteLockTimeoutMs, ct)) + throw new TimeoutException("Timed out acquiring collection lock (ForceApplyRetentionPolicy)."); + try + { + await ApplyRetentionPolicyCoreAsync(ct); + } + finally + { + _collectionLock.Release(); + } + } + + /// + /// Sets up the in-memory retention policy reference and starts/stops the scheduled timer. + /// + private void ApplyRetentionPolicyConfig(RetentionPolicy policy) + { + _retentionPolicy = policy; + + _retentionTimer?.Dispose(); + _retentionTimer = null; + + if ((policy.Triggers & RetentionTrigger.Scheduled) != 0) + { + var interval = policy.ScheduledIntervalMs > 0 + ? TimeSpan.FromMilliseconds(policy.ScheduledIntervalMs) + : DefaultScheduledInterval; + + _retentionTimer = new Timer( + callback: _ => _ = RunScheduledRetentionAsync(), + state: null, + dueTime: interval, + period: interval); + } + } + + private async Task RunScheduledRetentionAsync() + { + if (Interlocked.CompareExchange(ref _retentionRunning, 1, 0) != 0) + return; + try + { + if (_retentionPolicy == null) return; + + if (!await _collectionLock.WaitAsync(WriteLockTimeoutMs)) + return; + try + { + await ApplyRetentionPolicyCoreAsync(CancellationToken.None); + } + finally + { + _collectionLock.Release(); + } + } + catch (OperationCanceledException) + { + // Ignore cancellation from background retention execution. + } + catch (Exception ex) when (!IsFatalException(ex)) + { + // Swallow non-fatal exceptions from the background timer to avoid crashing the process. + } + finally + { + Interlocked.Exchange(ref _retentionRunning, 0); + } + } + + private static bool IsFatalException(Exception ex) => + ex is OutOfMemoryException + or StackOverflowException + or AccessViolationException + or AppDomainUnloadedException + or BadImageFormatException + or CannotUnloadAppDomainException + or InvalidProgramException + or ThreadAbortException; + + /// + /// Core retention enforcement. Must be called with the collection lock held. + /// + private async Task ApplyRetentionPolicyCoreAsync(CancellationToken ct) + { + var policy = _retentionPolicy!; + + // Collect all document IDs and (optionally) timestamps using a raw BSON scan. + var entries = new List<(TId Id, long TimestampTicks)>(); + long nowTicks = DateTime.UtcNow.Ticks; + + foreach (var entry in _primaryIndex.Range(IndexKey.MinKey, IndexKey.MaxKey, IndexDirection.Forward, 0UL)) + { + ct.ThrowIfCancellationRequested(); + var id = _mapper.FromIndexKey(entry.Key); + long ticks = 0; + + if (policy.TimestampField != null) + { + var rawBytes = ReadRawBytesAt(entry.Location, 0UL); + if (rawBytes != null) + ticks = ExtractTimestampTicks(rawBytes, policy.TimestampField); + } + + entries.Add((id, ticks)); + } + + var toDelete = new HashSet(); + + // MaxAge — delete documents older than the cutoff + if (policy.MaxAgeMs > 0 && policy.TimestampField != null) + { + long cutoff = nowTicks - (policy.MaxAgeMs * TimeSpan.TicksPerMillisecond); + foreach (var (id, ticks) in entries) + { + ct.ThrowIfCancellationRequested(); + if (ticks > 0 && ticks < cutoff) + toDelete.Add(id); + } + } + + // MaxDocumentCount — delete oldest documents over the limit + if (policy.MaxDocumentCount > 0 && entries.Count > policy.MaxDocumentCount) + { + int excess = (int)(entries.Count - policy.MaxDocumentCount); + var ordered = policy.TimestampField != null + ? entries.OrderBy(e => e.TimestampTicks == 0 ? long.MaxValue : e.TimestampTicks) + : entries.AsEnumerable(); + + foreach (var (id, _) in ordered.Take(excess)) + { + ct.ThrowIfCancellationRequested(); + toDelete.Add(id); + } + } + + // MaxSizeBytes — delete oldest documents until size is within limit + if (policy.MaxSizeBytes > 0) + { + long estimatedBytes = EstimateCollectionSizeBytes(); + if (estimatedBytes > policy.MaxSizeBytes) + { + var ordered = policy.TimestampField != null + ? entries.OrderBy(e => e.TimestampTicks == 0 ? long.MaxValue : e.TimestampTicks) + : entries.AsEnumerable(); + + foreach (var (id, _) in ordered) + { + ct.ThrowIfCancellationRequested(); + if (estimatedBytes <= policy.MaxSizeBytes) break; + toDelete.Add(id); + estimatedBytes -= _storage.PageSize / Math.Max(entries.Count, 1); + } + } + } + + if (toDelete.Count == 0) return; + + // Delete the collected IDs in a single transaction via the existing DeleteCore path. + var transaction = _storage.BeginTransaction(IsolationLevel.ReadCommitted); + try + { + foreach (var id in toDelete) + { + ct.ThrowIfCancellationRequested(); + await DeleteCore(id, transaction); + } + await transaction.CommitAsync(ct); + } + catch + { + await transaction.RollbackAsync(); + throw; + } + } + + /// + /// Reads the raw BSON bytes for a document at the given location without deserializing. + /// Returns null if the slot is deleted or the page/location is invalid. + /// Overflow documents are reassembled only when is true. + /// + private static bool TryReadInlineRawBytes(byte[] pageBuffer, ushort slotIndex, out ReadOnlySpan rawBytes) + { + 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 + { + 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; + 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); + } + + return offset == totalLength ? fullBuffer : null; + } + finally + { + 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; + } + + /// Processes the raw BSON of one document in place; returns whether to yield . + private delegate bool RawDocumentVisitor(ReadOnlySpan bson, out TResult result); + + /// + /// Visits the raw BSON of every document in primary-key order. Single-page documents are + /// handed to straight from the page buffer, without a copy; only + /// overflow documents are reassembled. Each page is read once and kept until the scan ends: + /// primary-key order is not page order (ObjectIds created in the same second are not + /// monotonic), so a bounded window of pages would read the same pages over and over. + /// + private async IAsyncEnumerable VisitRawDocumentsAsync( + ulong txnId, + RawDocumentVisitor visit, + [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 pageBuffer)) + { + pageBuffer = ArrayPool.Shared.Rent(_storage.PageSize); + _storage.ReadPage(entry.Location.PageId, txnId, pageBuffer); + pageCache[entry.Location.PageId] = pageBuffer; + } + + if (TryVisitRawDocument(entry, txnId, pageBuffer, visit, out var result)) + yield return result; + } + } + finally + { + foreach (var buf in pageCache.Values) ArrayPool.Shared.Return(buf); + } + } + + /// + /// Applies to the document points at, if that + /// slot still holds the document with this primary key. + /// + private bool TryVisitRawDocument(IndexEntry entry, ulong txnId, byte[] pageBuffer, RawDocumentVisitor visit, out TResult result) + { + result = default!; + + var pageType = (PageType)pageBuffer[4]; + if (pageType == PageType.Free || pageType == PageType.Empty || pageType == PageType.TimeSeries) + return false; + + if (TryReadInlineRawBytes(pageBuffer, entry.Location.SlotIndex, out var inline)) + return MatchesIndexEntryKey(inline, entry.Key) && visit(inline, out result); + + var reassembled = ReadRawBytesAt(entry.Location, txnId, includeOverflow: true, pageBuffer); + return reassembled is not null && MatchesIndexEntryKey(reassembled, entry.Key) && visit(reassembled, out result); + } + + /// + /// Extracts a UTC-ticks timestamp from raw BSON bytes using the given field name. + /// Resolves the field name against the Key Dictionary for correct C-BSON lookup, + /// then uses the O(1) offset-table fast path when available. + /// Returns 0 if the field is absent or is not a DateTime/Int64. + /// + private long ExtractTimestampTicks(byte[] bsonBytes, string fieldName) + { + // fieldName is already lower-cased by RetentionPolicyBuilder.OnField(). + // Resolve to key ID via the forward dictionary for fast C-BSON seek. + var keyMap = _storage.GetKeyMap(); + bool hasKeyId = keyMap.TryGetValue(fieldName, out var keyId); + + try + { + var reader = new BsonSpanReader(bsonBytes, _storage.GetKeyReverseMap()); + reader.ReadDocumentSize(); + + // Fast path: use offset table via key ID. + if (hasKeyId && reader.TrySeekToField(keyId, out var seekType)) + { + var value = BsonValue.ReadFrom(ref reader, seekType); + return value.Type switch + { + BsonType.DateTime or BsonType.DateTimeOffset => value.AsDateTime.Ticks, + BsonType.Int64 => value.AsInt64, + _ => 0 + }; + } + + // Slow path: sequential scan (offset table absent or field ID out of range). + while (reader.Remaining > 1) + { + var type = reader.ReadBsonType(); + if (type == BsonType.EndOfDocument) break; + var name = reader.ReadElementHeader(); + // Both sides are lowercase: fieldName normalised by builder, name from key reverse map. + if (name != fieldName) + { + reader.SkipValue(type); + continue; + } + var val = BsonValue.ReadFrom(ref reader, type); + return val.Type switch + { + BsonType.DateTime or BsonType.DateTimeOffset => val.AsDateTime.Ticks, + BsonType.Int64 => val.AsInt64, + _ => 0 + }; + } + } + catch (InvalidOperationException) { return 0; } + catch (ArgumentOutOfRangeException) { return 0; } + catch (IndexOutOfRangeException) { return 0; } + return 0; + } + + /// + /// Estimates the total size of this collection's data pages in bytes. + /// + private long EstimateCollectionSizeBytes() + { + long count = 0; + var hdrBuf = new byte[SlottedPageHeader.Size]; + foreach (var pageId in _storage.GetCollectionPageIds(_collectionName)) + { + _storage.ReadPageHeader(pageId, null, hdrBuf); + var h = SlottedPageHeader.ReadFrom(hdrBuf); + if (h.PageType == PageType.Data) + count++; + } + return count * _storage.PageSize; + } + + #endregion + + /// + /// Compacts all data pages in this collection, securely erasing freed byte ranges + /// by zero-filling the free space area of every page after compaction. + /// + /// Acquires the collection write lock for the duration of the operation — + /// no concurrent writes to this collection are allowed while VACUUM runs. + /// + /// + public async Task VacuumAsync(VacuumOptions? options = null, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (!await _collectionLock.WaitAsync(WriteLockTimeoutMs, ct).ConfigureAwait(false)) + throw new TimeoutException("Timed out acquiring collection write lock (VacuumAsync)."); + + var sw = _storage.MetricsDispatcher != null ? ValueStopwatch.StartNew() : default; + long bytesFreed = 0; + bool success = false; + try + { + var buffer = ArrayPool.Shared.Rent(_storage.PageSize); + try + { + var transaction = _storage.BeginTransaction(IsolationLevel.ReadCommitted); + try + { + bool secureErase = options?.SecureErase ?? true; + foreach (var pageId in _storage.GetCollectionPageIds(_collectionName)) + { + ct.ThrowIfCancellationRequested(); + + _storage.ReadPage(pageId, transaction.TransactionId, buffer); + var header = SlottedPageHeader.ReadFrom(buffer.AsSpan(0, SlottedPageHeader.Size)); + + if (header.PageType != PageType.Data) + continue; + + // Compact the page, packing live documents to the top. + CompactPage(buffer.AsSpan(0, _storage.PageSize)); + + // Optionally zero-fill the free space area to erase residual deleted bytes. + var compactedHdr = SlottedPageHeader.ReadFrom(buffer.AsSpan(0, SlottedPageHeader.Size)); + int freeBytes = compactedHdr.FreeSpaceEnd - compactedHdr.FreeSpaceStart; + if (freeBytes > 0) + { + bytesFreed += freeBytes; + if (secureErase) + buffer.AsSpan(compactedHdr.FreeSpaceStart, freeBytes).Clear(); + + _storage.WritePage(pageId, transaction.TransactionId, + buffer.AsSpan(0, _storage.PageSize)); + SnapshotFsiForTransaction(transaction, pageId); + _fsi.Update(pageId, compactedHdr.AvailableFreeSpace); + } + } + + await transaction.CommitAsync(ct).ConfigureAwait(false); + } + catch + { + await _storage.RollbackTransactionAsync(transaction).ConfigureAwait(false); + throw; + } + } + finally + { + ArrayPool.Shared.Return(buffer); + } + + // Optionally rebuild all secondary indexes from scratch. + if (options?.RebuildIndexes == true) + { + foreach (var index in _indexManager.GetAllIndexes()) + { + await RebuildIndexFromScratchAsync(index, ct).ConfigureAwait(false); + } + } + + success = true; + } + finally + { + _collectionLock.Release(); + if (sw.IsActive) + { + _storage.MetricsDispatcher?.Publish(new MetricEvent + { + Timestamp = sw.StartTimestamp, + Type = MetricEventType.Vacuum, + ElapsedMicros = sw.GetElapsedMicros(), + BytesFreed = bytesFreed, + Success = success, + }); + } + } + } + + /// + /// Deletes all documents and clears all secondary indexes. + /// The collection structure (schema, indexes, metadata) is preserved. + /// + public Task TruncateAsync(CancellationToken ct = default) + => TruncateAsync(null, ct); + + /// + /// + /// With a caller-owned , a failure while writing rolls the + /// transaction back ( becomes ), + /// so that a half-applied delete can never be committed. + /// + public async Task TruncateAsync(ITransaction? transaction, CancellationToken ct = default) + { + int deleted = 0; + bool autoCommit = transaction == null; + + WriteFailure.EnsureWritable(transaction); + + if (!await _collectionLock.WaitAsync(WriteLockTimeoutMs, ct).ConfigureAwait(false)) + throw new TimeoutException("Timed out acquiring collection lock (TruncateAsync)."); + + transaction ??= _storage.BeginTransaction(IsolationLevel.ReadCommitted); + try + { + try + { + // Collect all keys first to avoid modifying the index while iterating. + // Use the caller's transaction ID so that uncommitted inserts/updates + // visible to this transaction are included in the truncation. + var txnId = transaction.TransactionId; + var keys = new List(); + foreach (var entry in _primaryIndex.Range(IndexKey.MinKey, IndexKey.MaxKey, IndexDirection.Forward, txnId)) + { + ct.ThrowIfCancellationRequested(); + keys.Add(entry.Key); + } + + foreach (var key in keys) + { + ct.ThrowIfCancellationRequested(); + var id = _mapper.FromIndexKey(key); + if (await DeleteCore(id, transaction, notifyCdc: false).ConfigureAwait(false)) + deleted++; + } + + if (autoCommit) + await transaction.CommitAsync(ct).ConfigureAwait(false); + } + catch (Exception ex) + { + await WriteFailure.HandleAsync(transaction, autoCommit, ex); + throw; + } + } + finally + { + _collectionLock.Release(); + } + + return deleted; + } + + [RequiresDynamicCode("DocumentCollection uses CollectionIndexManager which compiles index key selectors via Expression.Compile().")] + [RequiresUnreferencedCode("Index creation uses reflection (Expression.PropertyOrField) to access type members. Ensure all entity types and their members are preserved.")] + public DocumentCollection(StorageEngine storage, ITransactionHolder transactionHolder, IDocumentMapper mapper, string? collectionName = null) + : this(storage, transactionHolder, mapper, collectionName, null) + { + } + + [RequiresDynamicCode("DocumentCollection uses CollectionIndexManager which compiles index key selectors via Expression.Compile().")] + [RequiresUnreferencedCode("Index creation uses reflection (Expression.PropertyOrField) to access type members. Ensure all entity types and their members are preserved.")] + internal DocumentCollection(StorageEngine storage, ITransactionHolder transactionHolder, IDocumentMapper mapper, string? collectionName, FreeSpaceIndex? freeSpaceIndex) + { + _storage = storage ?? throw new ArgumentNullException(nameof(storage)); + _transactionHolder = transactionHolder ?? throw new ArgumentNullException(nameof(transactionHolder)); + _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper)); + _collectionName = collectionName ?? _mapper.CollectionName; + _collectionLock = _storage.CreateCollectionWriteLock(); + + // Initialize secondary index manager first (loads metadata including Primary Root Page ID) + _indexManager = new CollectionIndexManager(_storage, _mapper, _collectionName); + _fsi = freeSpaceIndex ?? new FreeSpaceIndex(_storage.PageSize); + _isPageLocked = _storage.IsPageLocked; + + // Calculate max document size dynamically based on page size + // Reserve space for PageHeader (24) and some safety margin + _maxDocumentSizeForSinglePage = _storage.PageSize - 128; + + // Ensure schema is persisted and versioned + EnsureSchema(); + + // Restore TimeSeries flag from persisted metadata + var tsMeta = _indexManager.GetMetadata(); + if (tsMeta.IsTimeSeries) + { + _isTimeSeries = true; + _ttlFieldName = tsMeta.TtlFieldName; + } + + // Create primary index on _id (stores ObjectId → DocumentLocation mapping) + // Use persisted root page ID if available + var indexOptions = IndexOptions.CreateUnique("_id"); + _primaryIndex = new BTreeIndex(_storage, indexOptions, _indexManager.PrimaryRootPageId); + + // If a new root page was allocated, persist it + if (_indexManager.PrimaryRootPageId != _primaryIndex.RootPageId) + { + _indexManager.SetPrimaryRootPageId(_primaryIndex.RootPageId); + } + + // Register keys used by the mapper to ensure they are available for compression + _storage.RegisterKeys(_mapper.UsedKeys); + + // Rebuild the free-space index from existing page headers so that a cold-start + // DocumentCollection can reuse partially-filled pages instead of always allocating new ones. + RebuildFreeSpaceIndex(); + + MigrateLegacyDecimalIndexes(); + + // Restore generalized retention policy and set up the scheduled timer if needed. Done last so + // no scheduled pruning can run while an index is being rebuilt above. + if (tsMeta.GeneralRetentionPolicy != null) + ApplyRetentionPolicyConfig(tsMeta.GeneralRetentionPolicy); + } + + /// + /// Decimal properties used to be indexed as (double)value. An index whose persisted + /// predates the exact decimal encoding, or a full-text + /// index written in another format or holding keys it did not write, is rebuilt from the + /// documents into a copy that queries don't see, the first time the collection is opened. Only + /// after the copy committed is it swapped in, with its target format, in one metadata save, and + /// the old tree freed. If the rebuild fails or is interrupted, the copy's pages are freed and the + /// old index stays registered as it was, so it still needs the migration (whatever triggered it) + /// and the next open repeats it. No-op for every other index. + /// + private void MigrateLegacyDecimalIndexes() + { + foreach (var index in _indexManager.GetAllIndexes()) + { + if (!index.NeedsKeyFormatMigration) continue; + + var name = index.Name; + try + { + var fresh = _indexManager.CreateUnregistered(index.Definition); + fresh.KeyFormat = fresh.TargetKeyFormat; + // Run on the thread pool so a captured SynchronizationContext cannot deadlock the wait. + // A document that cannot be loaded fails the migration rather than silently leaving + // the rebuilt index incomplete. + Task.Run(() => FillUnregisteredIndexAsync(fresh, CancellationToken.None)).GetAwaiter().GetResult(); + PublishFilledIndex(fresh, copy => _indexManager.ReplaceIndex(name, copy)); + } + catch (Exception ex) + { + throw new InvalidOperationException( + $"Failed to rebuild index '{name}' of collection '{_collectionName}' in its current key format; the migration is retried on the next open.", ex); + } + } + } + + + /// + /// Scans the header of every data page belonging to this collection and + /// populates the free-space index. This eliminates the cold-start allocation + /// defect where a newly-constructed DocumentCollection always allocated a fresh + /// page on the first insert even when existing pages had ample free space. + /// + private void RebuildFreeSpaceIndex() + { + // Read only the fixed-size page header (24 bytes) rather than renting a full-page + // buffer. Using a stack-allocated span avoids any heap allocation and reduces + // memory traffic: we copy 24 bytes per page instead of the full page size. + Span hdrBuf = stackalloc byte[SlottedPageHeader.Size]; + uint lastDataPage = 0; + + foreach (var pageId in _storage.GetCollectionPageIds(_collectionName)) + { + _storage.ReadPageHeader(pageId, null, hdrBuf); + var hdr = SlottedPageHeader.ReadFrom(hdrBuf); + + if (hdr.PageType == PageType.Data) + { + _fsi.Update(pageId, hdr.AvailableFreeSpace); + lastDataPage = pageId; + } + } + + // Seed _currentDataPage with the last data page found so that the first + // FindPageWithSpace fast-path hits an already-known page instead of allocating. + if (lastDataPage != 0) + _currentDataPage = lastDataPage; + } + + [RequiresUnreferencedCode("Schema management uses reflection to discover entity properties. Ensure all entity types and their members are preserved.")] + private void EnsureSchema() + { + var currentSchema = _mapper.GetSchema(); + var metadata = _indexManager.GetMetadata(); + + var persistedSchemas = _storage.GetSchemas(metadata.SchemaRootPageId); + var latestPersisted = persistedSchemas.Count > 0 ? persistedSchemas[persistedSchemas.Count - 1] : null; + + if (latestPersisted == null || !currentSchema.Equals(latestPersisted)) + { + // Assign next version number + int nextVersion = persistedSchemas.Count + 1; + currentSchema.Version = nextVersion; + + var newRootId = _storage.AppendSchema(metadata.SchemaRootPageId, currentSchema); + if (newRootId != metadata.SchemaRootPageId) + { + metadata.SchemaRootPageId = newRootId; + _storage.SaveCollectionMetadata(metadata); + } + + CurrentSchemaVersion = new SchemaVersion(nextVersion, currentSchema.GetHash()); + } + else + { + // Latest persisted is same as current structure + int resolvedVersion = latestPersisted.Version ?? persistedSchemas.Count; + CurrentSchemaVersion = new SchemaVersion(resolvedVersion, latestPersisted.GetHash()); + } + } + + #region Index Management API + + /// + /// Asynchronously creates a secondary index on the specified property. + /// + [RequiresDynamicCode("Index creation compiles key selector expressions using Expression.Compile() which requires dynamic code generation.")] + public async Task> CreateIndexAsync( + System.Linq.Expressions.Expression> keySelector, + string? name = null, + bool unique = false, + CancellationToken ct = default) + { + if (keySelector == null) + throw new ArgumentNullException(nameof(keySelector)); + + using (var txn = _storage.BeginTransaction(IsolationLevel.ReadCommitted)) + { + var index = _indexManager.CreateIndex(keySelector, name, unique); + + // Rebuild index for existing documents + await RebuildIndexAsync(index, ct); + + await txn.CommitAsync(); + + return await BindReaderAndReturn(index); + } + } + + /// + /// Creates a full-text index on a string property, filled from the documents already in the + /// collection, or returns the existing index of that name. Query it with + /// in LINQ or rank with + /// . The default name is idx_fts_{property}. + /// + /// Each document touches one index page per distinct term, so single-document writes cost + /// noticeably more than without the index; insert and update in batches + /// (InsertBulkAsync or one explicit transaction) where possible. + /// + /// + /// Compatibility: the collection records that it requires full-text indexes, so BLite 5.3 and + /// later versions without them refuse to open it. Older releases cannot refuse: do not open a + /// database with full-text indexes for writing with BLite < 5.3. + /// + /// + /// An index of that name exists and is not a full-text index. + [RequiresDynamicCode("Index creation compiles key selector expressions using Expression.Compile() which requires dynamic code generation.")] + public async Task> CreateFullTextIndexAsync( + System.Linq.Expressions.Expression> keySelector, + string? name = null, + CancellationToken ct = default) + { + if (keySelector == null) throw new ArgumentNullException(nameof(keySelector)); + + // Block writers while the index is filled: a document inserted meanwhile would miss the + // index, which only starts receiving writes once it is registered. + if (!await _collectionLock.WaitAsync(WriteLockTimeoutMs, ct).ConfigureAwait(false)) + throw new TimeoutException("Timed out acquiring collection write lock (CreateFullTextIndexAsync)."); + try + { + var existing = _indexManager.GetIndex(CollectionIndexManager.ResolveIndexName(keySelector, name, IndexType.FullText)); + if (existing != null && existing.Definition.Type != IndexType.FullText) + throw new InvalidOperationException($"Index '{existing.Name}' already exists and is not a full-text index."); + + var builder = new Metadata.IndexBuilder(keySelector, name, false, IndexType.FullText); + await Task.Run(() => ApplyIndexBuilder(builder, ct), ct).ConfigureAwait(false); + } + finally + { + _collectionLock.Release(); + } + + var indexName = CollectionIndexManager.ResolveIndexName(keySelector, name, IndexType.FullText); + return await BindReaderAndReturn(_indexManager.GetIndex(indexName)!); + } + + /// + /// Asynchronously creates a vector (HNSW) index for similarity search. + /// + [RequiresDynamicCode("Index creation compiles key selector expressions using Expression.Compile() which requires dynamic code generation.")] + public async Task> CreateVectorIndexAsync( + System.Linq.Expressions.Expression> keySelector, + int dimensions, + VectorMetric metric = VectorMetric.Cosine, + string? name = null, + CancellationToken ct = default) + { + if (keySelector == null) throw new ArgumentNullException(nameof(keySelector)); + + using (var txn = _storage.BeginTransaction(IsolationLevel.ReadCommitted)) + { + var index = _indexManager.CreateVectorIndex(keySelector, dimensions, metric, name); + await RebuildIndexAsync(index, ct); + await txn.CommitAsync(); + return await BindReaderAndReturn(index); + } + } + + /// + /// Asynchronously ensures that an index exists on the specified property. + /// If the index already exists, it is returned without modification (idempotent). + /// If it doesn't exist, it is created and populated. + /// + [RequiresDynamicCode("Index creation compiles key selector expressions using Expression.Compile() which requires dynamic code generation.")] + public async Task> EnsureIndexAsync( + System.Linq.Expressions.Expression> keySelector, + string? name = null, + bool unique = false, + CancellationToken ct = default) + { + if (keySelector == null) throw new ArgumentNullException(nameof(keySelector)); + + // 1. Check if index already exists (fast path) + var propertyPaths = ExpressionAnalyzer.ExtractPropertyPaths(keySelector); + var indexName = name ?? $"idx_{string.Join("_", propertyPaths)}"; + + var existingIndex = await GetIndexAsync(indexName); + if (existingIndex != null) + { + return existingIndex; + } + + // 2. Create if missing (slow path: rebuilds index) + return await CreateIndexAsync(keySelector, name, unique, ct); + } + + /// + /// Asynchronously drops (removes) an existing secondary index by name. + /// The primary index (_id) cannot be dropped. + /// + /// Name of the index to drop + /// Cancellation token + /// True if the index was found and dropped, false otherwise + public Task DropIndexAsync(string name, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Index name cannot be empty", nameof(name)); + + // Prevent dropping primary index + if (name.Equals("_id", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("Cannot drop primary index"); + + // DropIndex is synchronous (no heavy I/O), wrap in Task + return Task.FromResult(_indexManager.DropIndex(name)); + } + + /// + /// Gets metadata about all secondary indexes in this collection. + /// Does not include the primary index (_id). + /// + /// Collection of index metadata + public IEnumerable GetIndexes() + { + return _indexManager.GetIndexInfo(); + } + + [RequiresDynamicCode("Index creation compiles key selector expressions using Expression.Compile() which requires dynamic code generation.")] + internal void ApplyIndexBuilder(Metadata.IndexBuilder builder, CancellationToken ct = default) + { + var name = CollectionIndexManager.ResolveIndexName(builder.KeySelector, builder.Name, builder.Type); + if (_indexManager.GetIndex(name) != null) + return; + + // The collection may already hold documents, e.g. when a new app version adds the index. + // Fill the index before registering it, so neither a crash nor a document that cannot be + // indexed leaves an empty index persisted that later opens would trust. + var definition = CollectionIndexManager.BuildDefinition( + builder.KeySelector, name, builder.IsUnique, builder.Type, builder.Dimensions, builder.Metric); + var index = _indexManager.CreateUnregistered(definition); + try + { + // Run on the thread pool so a captured SynchronizationContext cannot deadlock the wait. + Task.Run(() => FillUnregisteredIndexAsync(index, ct)).GetAwaiter().GetResult(); + } + catch (Exception ex) + { + throw new InvalidOperationException( + $"Cannot build index '{name}' of collection '{_collectionName}' from the documents already in it: {ex.Message.TrimEnd('.')}. " + + "Nothing was saved, but every open fails the same way until those documents can be indexed or the index " + + "is removed from the model (OnModelCreating).", ex); + } + PublishFilledIndex(index, _indexManager.Register); + } + + /// + /// Asynchronously scans the entire collection using a raw BSON predicate. + /// This avoids deserializing documents that don't match the criteria. + /// + /// Function to evaluate raw BSON data + /// Cancellation token + /// Async enumerable of matching documents + public IAsyncEnumerable ScanAsync( + BsonReaderPredicate predicate, + CancellationToken ct = default) + => ScanAsync(predicate, null, ct); + + public async IAsyncEnumerable ScanAsync( + BsonReaderPredicate predicate, + ITransaction? transaction, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default) + { + if (predicate == null) throw new ArgumentNullException(nameof(predicate)); + + var sw = _storage.MetricsDispatcher != null ? ValueStopwatch.StartNew() : default; + var txnId = transaction?.TransactionId ?? 0UL; + var keyMap = _storage.GetKeyReverseMap(); + + try + { + await foreach (var document in VisitRawDocumentsAsync(txnId, (ReadOnlySpan bson, out T doc) => + { + doc = default!; + if (!predicate(new BsonSpanReader(bson, keyMap))) return false; + doc = _mapper.Deserialize(new BsonSpanReader(bson, keyMap)); + return true; + }, ct).ConfigureAwait(false)) + { + yield return document; + } + } + finally + { + if (sw.IsActive) + _storage.MetricsDispatcher?.Publish(new MetricEvent + { + Timestamp = sw.StartTimestamp, + Type = MetricEventType.CollectionQuery, + ElapsedMicros = sw.GetElapsedMicros(), + CollectionName = _collectionName, + Success = true, + }); + } + } + + /// + /// Executes a query described by an . + /// + /// + /// Index range – the collection's B-Tree index named + /// is seeked between + /// and (both inclusive). + /// + /// + /// Index IN – each key in InKeys is looked up as an exact match. + /// + /// + /// Full scan – every document is evaluated against + /// . + /// + /// + /// An optional is applied as a BSON-level + /// post-filter on every candidate before it is deserialised and yielded. + /// + public async IAsyncEnumerable ScanAsync( + IndexQueryPlan plan, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default) + { + if (plan == null) throw new ArgumentNullException(nameof(plan)); + + switch (plan.Kind) + { + case IndexQueryPlan.PlanKind.IndexRange: + { + await foreach (var doc in QueryIndexAsync(plan.IndexName!, plan.MinKey, plan.MaxKey, ct: ct)) + { + ct.ThrowIfCancellationRequested(); + if (MatchesResidue(doc, plan.ResiduePredicate)) + yield return doc; + } + break; + } + + case IndexQueryPlan.PlanKind.IndexIn: + { + if (plan.InKeys != null) + { + foreach (var key in plan.InKeys) + { + await foreach (var doc in QueryIndexAsync(plan.IndexName!, (object?)key, (object?)key, ct: ct)) + { + ct.ThrowIfCancellationRequested(); + if (MatchesResidue(doc, plan.ResiduePredicate)) + yield return doc; + } + } + } + break; + } + + case IndexQueryPlan.PlanKind.Scan: + default: + { + var predicate = plan.ScanPredicate ?? (_ => true); + await foreach (var doc in ScanAsync(predicate, ct)) + { + yield return doc; + } + break; + } + } + } + + /// + /// Evaluates against a serialised form of . + /// Returns true when is null (no post-filter). + /// + /// Serialisation is done into a pooled buffer to avoid heap pressure; the buffer is + /// returned to the pool in the finally block before the caller receives the result. + /// + private bool MatchesResidue(T doc, BsonReaderPredicate? residue) + { + if (residue == null) return true; + + var buf = ArrayPool.Shared.Rent(_maxDocumentSizeForSinglePage + 128); + try + { + int written = _mapper.Serialize(doc, new BsonSpanWriter(buf, _storage.GetFrozenKeyMap())); + var reader = new BsonSpanReader(buf.AsSpan(0, written), _storage.GetKeyReverseMap()); + return residue(reader); + } + finally + { + ArrayPool.Shared.Return(buf); + } + } + + + /// + /// + /// When both selectors access a single flat, scalar property + /// (supported by ) a single-pass BSON + /// projector is compiled and the two fields are read in one sweep. + /// Falls back to full deserialization + LINQ projection otherwise. + /// + public async IAsyncEnumerable<(TKey Key, TValue Value)> ScanPairsAsync( + Expression> keySelector, + Expression> valueSelector) + { + if (keySelector is null) throw new ArgumentNullException(nameof(keySelector)); + if (valueSelector is null) throw new ArgumentNullException(nameof(valueSelector)); + + var projector = BsonProjectionCompiler.TryCompilePair( + keySelector, valueSelector, _storage.GetKeyMap()); + + if (projector is not null) + { + await foreach (var t in ScanAsync(projector)) { yield return t; } + yield break; + } + + // Fallback: full deserialization. + var keyFunc = keySelector.Compile(); + var valFunc = valueSelector.Compile(); + await foreach (var item in FindAllAsync()) + { + yield return (keyFunc(item), valFunc(item)); + } + } + + /// + /// Asynchronously scans the entire collection applying a directly + /// to raw BSON bytes. Only the fields accessed by the projector are read. + /// + /// The projected result type. + /// + /// A delegate that receives a positioned at the start of each + /// document and returns the projected value, or null to skip the document. + /// + /// Cancellation token + /// Async enumerable of projected results + public async IAsyncEnumerable ScanAsync( + BsonReaderProjector projector, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default) + { + if (projector == null) throw new ArgumentNullException(nameof(projector)); + + var txnId = 0UL; + var keyMap = _storage.GetKeyReverseMap(); + + await foreach (var result in VisitRawDocumentsAsync(txnId, (ReadOnlySpan bson, out TResult projected) => + { + projected = projector(new BsonSpanReader(bson, keyMap))!; + return projected is not null; + }, ct).ConfigureAwait(false)) + { + yield return result; + } + } + + /// + /// Counts documents matching by evaluating the predicate + /// directly on raw BSON bytes — no CLR instances are ever created. + /// + /// + /// 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, + CancellationToken ct = default) + { + if (predicate == null) throw new ArgumentNullException(nameof(predicate)); + + var txnId = 0UL; + int count = 0; + var keyMap = _storage.GetKeyReverseMap(); + + await foreach (var _ in VisitRawDocumentsAsync(txnId, (ReadOnlySpan bson, out bool matched) => + { + try + { + matched = predicate(new BsonSpanReader(bson, keyMap)); + } + catch + { + matched = false; // Malformed BSON — skip silently. + } + return matched; + }, ct).ConfigureAwait(false)) + { + count++; + } + + return count; + } + + private void ScanPageProjected( + uint pageId, + ulong txnId, + byte[] buffer, + BsonReaderProjector projector, + List results) + { + _storage.ReadPage(pageId, txnId, buffer); + var header = SlottedPageHeader.ReadFrom(buffer); + + if (header.PageType != PageType.Data) + return; + + 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) results.Add(result); + } + } + + /// + /// Asynchronously scans the collection in parallel using multiple tasks. + /// Useful for large collections on multi-core machines. + /// + /// Function to evaluate raw BSON data + /// Number of concurrent tasks (default: -1 = ProcessorCount) + /// Cancellation token + /// Async enumerable of matching documents + public async IAsyncEnumerable ParallelScanAsync( + BsonReaderPredicate predicate, + int degreeOfParallelism = -1, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default) + { + if (predicate == null) throw new ArgumentNullException(nameof(predicate)); + + var txnId = 0UL; + 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); + + async Task QueueBatchAsync(List entries) + { + await semaphore.WaitAsync(ct).ConfigureAwait(false); + var localBatch = entries.ToArray(); + + var task = Task.Run(async () => + { + try + { + var results = new List(); + var pageCache = new Dictionary(); + + try + { + foreach (var entry in localBatch) + { + ct.ThrowIfCancellationRequested(); + var location = entry.Location; + + if (!pageCache.TryGetValue(location.PageId, out var buffer)) + { + buffer = ArrayPool.Shared.Rent(_storage.PageSize); + await _storage.ReadPageAsync(location.PageId, txnId, buffer.AsMemory(0, _storage.PageSize), ct).ConfigureAwait(false); + pageCache[location.PageId] = buffer; + } + + 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 + { + foreach (var buffer in pageCache.Values) + ArrayPool.Shared.Return(buffer); + } + + return results; + } + finally + { + semaphore.Release(); + } + }, ct); + + 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).ConfigureAwait(false); + tasks.Remove(completedTask); + + var results = await completedTask.ConfigureAwait(false); + foreach (var doc in results) + { + yield return doc; + } + } + } + + /// + /// Gets a queryable interface for this collection. + /// Supports LINQ queries that are translated to optimized BTree scans or index lookups. + /// + [RequiresDynamicCode("LINQ queries over BLite collections use Expression.Compile() and MakeGenericMethod which require dynamic code generation.")] + [RequiresUnreferencedCode("LINQ queries over BLite collections use reflection to resolve methods at runtime. Ensure all entity types and their members are preserved.")] + public IBLiteQueryable AsQueryable() + { + return new BTreeQueryable(new BTreeQueryProvider(this, ConverterRegistry)); + } + + /// Secondary index manager of this collection (test and migration hooks). + internal CollectionIndexManager IndexManager => _indexManager; + + /// + /// Gets a specific secondary index by name for advanced querying. + /// Returns null if the index doesn't exist. + /// + public async Task?> GetIndexAsync(string name) + { + var idx = _indexManager.GetIndex(name); + return idx is null ? null : await BindReaderAndReturn(idx); + } + + private async Task> BindReaderAndReturn(CollectionSecondaryIndex index) + { + index.SetDocumentReader( + async: (loc, ct) => new ValueTask(FindByLocation(loc))); + return index; + } + + /// + /// Asynchronously queries a specific index for a range of values. + /// Returns matching documents using the index for efficient retrieval. + /// + /// Name of the index to query + /// Minimum key value (inclusive) + /// Maximum key value (inclusive) + /// True for ascending order, false for descending + /// Number of index entries to skip before yielding documents. + /// Skipping is performed at the index-entry level so documents are never read for + /// skipped positions, enabling efficient pagination without full materialization. + /// Maximum number of documents to yield. Defaults to all. + /// Cancellation token + /// Async enumerable of matching documents + public IAsyncEnumerable QueryIndexAsync( + string indexName, + object? minKey, + object? maxKey, + bool ascending = true, + int skip = 0, + int take = int.MaxValue, + CancellationToken ct = default) + => QueryIndexAsync(indexName, minKey, maxKey, ascending, skip, take, null, ct); + + public async IAsyncEnumerable QueryIndexAsync( + string indexName, + object? minKey, + object? maxKey, + bool ascending, + int skip, + int take, + ITransaction? transaction, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default) + { + var index = _indexManager.GetIndex(indexName); + if (index == null) throw new ArgumentException($"Index {indexName} not found"); + + var sw = _storage.MetricsDispatcher != null ? ValueStopwatch.StartNew() : default; + var txnId = transaction?.TransactionId ?? 0UL; + var direction = ascending ? IndexDirection.Forward : IndexDirection.Backward; + + // Per-query page cache: when multiple index entries point to the same data page + // (common for small documents), each page is loaded only once. + // For a collection of 200-byte docs on 16 KB pages (~80 docs/page), a query + // returning 250 docs touches ~3 unique pages — without this cache we'd do 250 + // ReadPage calls instead of 3. + var pageCache = new Dictionary(); + try + { + int skipped = 0; + int taken = 0; + foreach (var location in index.Range(minKey, maxKey, direction, transaction)) + { + // Skip index entries without reading documents — O(skip) index key reads, + // zero document deserializations for skipped positions. + if (skip > 0 && skipped < skip) { skipped++; continue; } + + ct.ThrowIfCancellationRequested(); + + if (!pageCache.TryGetValue(location.PageId, out var cachedBuffer)) + { + cachedBuffer = ArrayPool.Shared.Rent(_storage.PageSize); + _storage.ReadPage(location.PageId, txnId, cachedBuffer); + pageCache[location.PageId] = cachedBuffer; + } + + var doc = await FindByLocationAsync(location, txnId, cachedBuffer, ct); + if (doc != null) + { + yield return doc; + if (++taken >= take) break; + } + } + } + finally + { + foreach (var buf in pageCache.Values) + ArrayPool.Shared.Return(buf); + if (sw.IsActive) + _storage.MetricsDispatcher?.Publish(new MetricEvent + { + Timestamp = sw.StartTimestamp, + Type = MetricEventType.CollectionQuery, + ElapsedMicros = sw.GetElapsedMicros(), + CollectionName = _collectionName, + Success = true, + }); + } + } + + /// + /// Populates an empty copy of from the documents and swaps it in + /// only after the rebuild committed; the old tree is then freed. If any document cannot be + /// loaded or indexed, the rebuild fails and the existing index stays in place untouched, + /// instead of being replaced by an incomplete one. + /// + private async Task RebuildIndexFromScratchAsync(CollectionSecondaryIndex current, CancellationToken ct) + { + var fresh = _indexManager.CreateUnregistered(current.Definition); + await FillUnregisteredIndexAsync(fresh, ct).ConfigureAwait(false); + PublishFilledIndex(fresh, copy => _indexManager.ReplaceIndex(current.Name, copy)); + } + + /// + /// Fills an index that is not registered yet (see ) + /// from every document, in its own transaction, and commits it. If a document cannot be loaded + /// or indexed, or the commit fails, every page of the index is freed, including the pages its + /// splits added, the index is disposed and the exception propagates. + /// + private async Task FillUnregisteredIndexAsync(CollectionSecondaryIndex index, CancellationToken ct) + { + var transaction = _storage.BeginTransaction(IsolationLevel.ReadCommitted); + try + { + await FillIndexAsync(index, transaction, skipUnreadableDocuments: false, ct).ConfigureAwait(false); + await transaction.CommitAsync(ct); + } + catch + { + // Collect the index's pages through the transaction before rolling it back: pages added + // by splits are allocated outside the transaction but linked only from nodes written + // in it, so after the rollback they could not be found any more. + IReadOnlyList pages = Array.Empty(); + try { pages = index.CollectAllPages(transaction.TransactionId); } + catch { /* best-effort: never mask the original failure */ } + + try { await transaction.RollbackAsync(); } + catch { /* best-effort: never mask the original failure */ } + + FreeUnregisteredIndex(index, pages); + throw; + } + } + + /// + /// Hands an index filled by to + /// ( or ). + /// If that fails and the index did not get registered, nothing refers to it any more, so its + /// pages are freed and it is disposed. + /// + private void PublishFilledIndex(CollectionSecondaryIndex index, Action> register) + { + try + { + register(index); + } + catch + { + if (!ReferenceEquals(_indexManager.GetIndex(index.Name), index)) + { + IReadOnlyList pages = Array.Empty(); + try { pages = index.CollectAllPages(0); } + catch { /* best-effort: never mask the original failure */ } + FreeUnregisteredIndex(index, pages); + } + throw; + } + } + + private void FreeUnregisteredIndex(CollectionSecondaryIndex index, IReadOnlyList pages) + { + try + { + foreach (var pageId in pages) + _storage.FreePage(pageId); + } + catch { /* best-effort: never mask the original failure */ } + index.Dispose(); + } + + private async Task RebuildIndexAsync(CollectionSecondaryIndex index, CancellationToken ct = default, bool skipUnreadableDocuments = true) + { + var transaction = _storage.BeginTransaction(IsolationLevel.ReadCommitted); + try + { + await FillIndexAsync(index, transaction, skipUnreadableDocuments, ct).ConfigureAwait(false); + await transaction.CommitAsync(ct); + } + catch + { + await transaction.RollbackAsync(); + throw; + } + } + + private async Task FillIndexAsync(CollectionSecondaryIndex index, ITransaction transaction, bool skipUnreadableDocuments, CancellationToken ct) + { + // Iterate all documents in the collection via primary index + foreach (var entry in _primaryIndex.Range(IndexKey.MinKey, IndexKey.MaxKey, IndexDirection.Forward, transaction.TransactionId)) + { + ct.ThrowIfCancellationRequested(); + try + { + var document = await FindByLocationAsync(entry.Location, transaction.TransactionId, ct); + if (document != null) + { + index.Insert(document, entry.Location, transaction); + } + } + catch when (skipUnreadableDocuments) + { + // Skip documents that fail to load or index + // Production: should log errors + } + } + } + + #endregion + + #region Data Page Management + + private Task FindPageWithSpace(int requiredBytes, ITransaction transaction) + { + var txnId = transaction.TransactionId; + + // Fast path: try current page first (avoids a full FSI scan on the common case). + if (_currentDataPage != 0) + { + if (_fsi.TryGetFreeBytes(_currentDataPage, out var freeBytes)) + { + if (freeBytes >= requiredBytes && !_storage.IsPageLocked(_currentDataPage, txnId)) + return Task.FromResult(_currentDataPage); + } + else + { + // Page not yet in FSI (e.g., after a rollback recovery): read header from disk. + Span page = stackalloc byte[SlottedPageHeader.Size]; + _storage.ReadPageHeader(_currentDataPage, null, page); + var header = SlottedPageHeader.ReadFrom(page); + + if (header.AvailableFreeSpace >= requiredBytes) + { + _fsi.Update(_currentDataPage, header.AvailableFreeSpace); + if (!_storage.IsPageLocked(_currentDataPage, txnId)) + return Task.FromResult(_currentDataPage); + } + } + } + + // Search FSI: O(BucketCount) = O(1) regardless of the number of tracked pages. + // Use the cached _isPageLocked delegate to avoid per-call closure allocation. + return Task.FromResult(_fsi.FindPage(requiredBytes, txnId, _isPageLocked)); + } + + private Task AllocateNewDataPage(ITransaction transaction) + { + var pageId = _storage.AllocateCollectionPage(_collectionName); + + // Initialize slotted page header + var buffer = ArrayPool.Shared.Rent(_storage.PageSize); + try + { + buffer.AsSpan().Clear(); + + var header = new SlottedPageHeader + { + PageId = pageId, + PageType = PageType.Data, + SlotCount = 0, + FreeSpaceStart = SlottedPageHeader.Size, + FreeSpaceEnd = (ushort)_storage.PageSize, + NextOverflowPage = 0, + TransactionId = 0 + }; + + header.WriteTo(buffer); + + // Transaction write or direct write + if (transaction is Transaction t) + { + // OPTIMIZATION: Pass ReadOnlyMemory to avoid ToArray() allocation + var writeOp = new WriteOperation(ObjectId.Empty, buffer.AsMemory(0, _storage.PageSize), pageId, OperationType.AllocatePage); + t.AddWrite(writeOp); + } + else + { + _storage.WritePage(pageId, transaction.TransactionId, buffer); + } + + SnapshotFsiForTransaction(transaction, pageId); + _fsi.Update(pageId, header.AvailableFreeSpace); + _currentDataPage = pageId; + } + finally + { + ArrayPool.Shared.Return(buffer); + } + + return Task.FromResult(pageId); + } + + private async Task InsertIntoPage(uint pageId, byte[] data, ITransaction transaction) + { + var buffer = ArrayPool.Shared.Rent(_storage.PageSize); + + try + { + _storage.ReadPage(pageId, transaction.TransactionId, buffer); + + var header = SlottedPageHeader.ReadFrom(buffer); + + // ROLLBACK RECOVERY: If the page is completely zeroed (e.g., from a rolled-back allocation) + // we re-initialize the header for the current transaction. + if (header.PageType == PageType.Empty && header.FreeSpaceEnd == 0) + { + header = new SlottedPageHeader + { + PageId = pageId, + PageType = PageType.Data, + SlotCount = 0, + FreeSpaceStart = SlottedPageHeader.Size, + FreeSpaceEnd = (ushort)_storage.PageSize, + TransactionId = (uint)transaction.TransactionId + }; + header.WriteTo(buffer); + } + + // Check free space + var freeSpace = header.AvailableFreeSpace; + var requiredSpace = data.Length + SlotEntry.Size; + + if (freeSpace < requiredSpace) + { + // Capture the stale FSI value for the diagnostic message before correcting it. + // Correcting the FSI here breaks the "poisoned-cache loop" that arises when a + // transaction that updated the FSI (e.g. a delete) was subsequently rolled back: + // the physical page reverts via WAL but the in-memory FSI is not reverted, + // leaving it with an overly optimistic free-space value. Without this correction + // every subsequent insert would be routed to the same full page and fail. + _fsi.TryGetFreeBytes(pageId, out var staleFsi); + _fsi.Update(pageId, freeSpace); + throw new InvalidOperationException($"Not enough space: need {requiredSpace}, have {freeSpace} | PageId={pageId} | SlotCount={header.SlotCount} | Start={header.FreeSpaceStart} | End={header.FreeSpaceEnd} | FSI={staleFsi}"); + } + + // Find free slot (reuse deleted or create new) + ushort slotIndex = FindFreeSlot(buffer, ref header); + + // Write document at end of used space (grows up) + var docOffset = header.FreeSpaceEnd - data.Length; + data.CopyTo(buffer.AsSpan(docOffset, data.Length)); + + // Write slot entry + var slotOffset = SlottedPageHeader.Size + (slotIndex * SlotEntry.Size); + var slot = new SlotEntry + { + Offset = (ushort)docOffset, + Length = (ushort)data.Length, + Flags = SlotFlags.None + }; + slot.WriteTo(buffer.AsSpan(slotOffset)); + + // UpdateAsync header + if (slotIndex >= header.SlotCount) + header.SlotCount = (ushort)(slotIndex + 1); + + header.FreeSpaceStart = (ushort)(SlottedPageHeader.Size + (header.SlotCount * SlotEntry.Size)); + header.FreeSpaceEnd = (ushort)docOffset; + header.WriteTo(buffer); + + // NEW: Buffer write in transaction or write immediately + if (transaction is Transaction t) + { + // OPTIMIZATION: Pass ReadOnlyMemory to avoid ToArray() allocation + var writeOp = new WriteOperation( + documentId: ObjectId.Empty, + newValue: buffer.AsMemory(0, _storage.PageSize), + pageId: pageId, + type: OperationType.Insert + ); + t.AddWrite(writeOp); + } + else + { + _storage.WritePage(pageId, transaction.TransactionId, buffer); + } + + SnapshotFsiForTransaction(transaction, pageId); + _fsi.Update(pageId, header.AvailableFreeSpace); + + return slotIndex; + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + private ushort FindFreeSlot(Span page, ref SlottedPageHeader header) + { + // Scan existing slots for deleted ones + for (ushort i = 0; i < header.SlotCount; i++) + { + var slotOffset = SlottedPageHeader.Size + (i * SlotEntry.Size); + var slot = SlotEntry.ReadFrom(page.Slice(slotOffset, SlotEntry.Size)); + + if ((slot.Flags & SlotFlags.Deleted) != 0) + return i; // Reuse deleted slot + } + + // No free slot, use next index + return header.SlotCount; + } + + private uint AllocateOverflowPage(ReadOnlySpan data, uint nextOverflowPageId, ITransaction transaction) + { + var pageId = _storage.AllocateCollectionPage(_collectionName); + var buffer = ArrayPool.Shared.Rent(_storage.PageSize); + + try + { + buffer.AsSpan().Clear(); + + var header = new SlottedPageHeader + { + PageId = pageId, + PageType = PageType.Overflow, + SlotCount = 0, + FreeSpaceStart = SlottedPageHeader.Size, + FreeSpaceEnd = (ushort)_storage.PageSize, + NextOverflowPage = nextOverflowPageId, + TransactionId = 0 + }; + + header.WriteTo(buffer); + + // Write data immediately after header + data.CopyTo(buffer.AsSpan(SlottedPageHeader.Size)); + + // NEW: Buffer write in transaction or write immediately + var writeOp = new WriteOperation( + documentId: ObjectId.Empty, + newValue: buffer.AsMemory(0, _storage.PageSize), + pageId: pageId, + type: OperationType.Insert + ); + ((Transaction)transaction).AddWrite(writeOp); + + return pageId; + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + private async Task<(uint pageId, ushort slotIndex)> InsertWithOverflow(byte[] data, ITransaction transaction) + { + // 1. Calculate Primary Chunk Size + // We need 8 bytes for metadata (TotalLength: 4, NextOverflowPage: 4) + const int MetadataSize = 8; + int maxPrimaryPayload = _maxDocumentSizeForSinglePage - MetadataSize; + + // 2. Build Overflow Chain (Reverse Order) + // We must ensure that pages closer to Primary are FULL (PageSize-Header), + // and only the last page (tail) is partial. This matches FindByLocation greedy reading. + + uint nextOverflowPageId = 0; + int overflowChunkSize = _storage.PageSize - SlottedPageHeader.Size; + int totalOverflowBytes = data.Length - maxPrimaryPayload; + + if (totalOverflowBytes > 0) + { + int tailSize = totalOverflowBytes % overflowChunkSize; + int fullPages = totalOverflowBytes / overflowChunkSize; + + // 2a. Handle Tail (if any) - This is the highest offset + if (tailSize > 0) + { + int tailOffset = maxPrimaryPayload + (fullPages * overflowChunkSize); + var overflowPageId = AllocateOverflowPage( + data.AsSpan(tailOffset, tailSize), + nextOverflowPageId, // Points to 0 (or previous tail if we had one? No, 0) + transaction + ); + nextOverflowPageId = overflowPageId; + } + else if (fullPages > 0) + { + // If no tail, nextId starts at 0. + } + + // 2b. Handle Full Pages (Reverse order) + // Iterate from last full page down to first full page + for (int i = fullPages - 1; i >= 0; i--) + { + int chunkOffset = maxPrimaryPayload + (i * overflowChunkSize); + var overflowPageId = AllocateOverflowPage( + data.AsSpan(chunkOffset, overflowChunkSize), + nextOverflowPageId, + transaction + ); + nextOverflowPageId = overflowPageId; + } + } + + // 3. PrepareAsync Primary Page Payload + // Layout: [TotalLength (4)] [NextOverflowPage (4)] [DataChunk (...)] + // Since we are in InsertWithOverflow, we know data.Length > maxPrimaryPayload + int primaryPayloadSize = maxPrimaryPayload; + int totalSlotSize = MetadataSize + primaryPayloadSize; + + // Allocate primary page + var primaryPageId = await FindPageWithSpace(totalSlotSize + SlotEntry.Size, transaction); + if (primaryPageId == 0) + primaryPageId = await AllocateNewDataPage(transaction); + + // 4. Write to Primary Page + var buffer = ArrayPool.Shared.Rent(_storage.PageSize); + try + { + _storage.ReadPage(primaryPageId, transaction.TransactionId, buffer); + + var header = SlottedPageHeader.ReadFrom(buffer); + + // Find free slot + ushort slotIndex = FindFreeSlot(buffer, ref header); + + // Write payload at end of used space + var docOffset = header.FreeSpaceEnd - totalSlotSize; + var payloadSpan = buffer.AsSpan(docOffset, totalSlotSize); + + // Write Metadata + System.Buffers.Binary.BinaryPrimitives.WriteInt32LittleEndian(payloadSpan.Slice(0, 4), data.Length); // Total Length + System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(payloadSpan.Slice(4, 4), nextOverflowPageId); // First Overflow Page + + // Write Data Chunk + data.AsSpan(0, primaryPayloadSize).CopyTo(payloadSpan.Slice(8)); + + // Write Slot Entry + // FLAGS: HasOverflow + // LENGTH: Length of data *in this slot* (Metadata + Chunk) + // This avoids the 65KB limit issue for the SlotEntry.Length field itself, + // as specific slots are bounded by Page Size (16KB). + var slotOffset = SlottedPageHeader.Size + (slotIndex * SlotEntry.Size); + var slot = new SlotEntry + { + Offset = (ushort)docOffset, + Length = (ushort)totalSlotSize, + Flags = SlotFlags.HasOverflow + }; + slot.WriteTo(buffer.AsSpan(slotOffset)); + + // UpdateAsync header + if (slotIndex >= header.SlotCount) + header.SlotCount = (ushort)(slotIndex + 1); + + header.FreeSpaceStart = (ushort)(SlottedPageHeader.Size + (header.SlotCount * SlotEntry.Size)); + header.FreeSpaceEnd = (ushort)docOffset; + header.WriteTo(buffer); + + // NEW: Buffer write in transaction or write immediately + var writeOp = new WriteOperation( + documentId: ObjectId.Empty, + newValue: buffer.AsMemory(0, _storage.PageSize), + pageId: primaryPageId, + type: OperationType.Insert + ); + ((Transaction)transaction).AddWrite(writeOp); + + SnapshotFsiForTransaction(transaction, primaryPageId); + _fsi.Update(primaryPageId, header.AvailableFreeSpace); + + return (primaryPageId, slotIndex); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + /// + /// Asynchronously inserts a new document into the collection + /// + /// + /// With a caller-owned : if the call throws + /// (duplicate key, vector dimension mismatch, document or + /// key too large), it wrote nothing and the transaction stays active, so the caller can catch it, + /// carry on and commit. Any other failure while writing rolls the transaction back + /// ( becomes ), so that a + /// half-applied write, with the data and its indexes out of step, can never be committed. + /// + public async ValueTask InsertAsync(T entity, ITransaction? transaction, CancellationToken ct = default) + { + if (entity == null) throw new ArgumentNullException(nameof(entity)); + + var sw = _storage.MetricsDispatcher != null ? ValueStopwatch.StartNew() : default; + bool success = false; + bool autoCommit = transaction == null; + + WriteFailure.EnsureWritable(transaction); + + if (!await _collectionLock.WaitAsync(WriteLockTimeoutMs, ct)) + throw new TimeoutException("Timed out acquiring collection lock (Insert)."); + + transaction ??= _storage.BeginTransaction(IsolationLevel.ReadCommitted); + try + { + try + { + var id = await InsertCore(entity, transaction); + if (autoCommit) + { + await transaction.CommitAsync(ct); + // ── OnInsert retention trigger — runs AFTER commit within the lock ── + if (_retentionPolicy != null && (_retentionPolicy.Triggers & RetentionTrigger.OnInsert) != 0) + await ApplyRetentionPolicyCoreAsync(ct); + } + else if (_retentionPolicy != null && (_retentionPolicy.Triggers & RetentionTrigger.OnInsert) != 0 + && transaction is Transaction concreteTx) + { + // For caller-managed transactions, fire retention in the background after commit. + concreteTx.OnCommit += () => _ = RunScheduledRetentionAsync(); + } + success = true; + return id; + } + catch (Exception ex) + { + await WriteFailure.HandleAsync(transaction, autoCommit, ex); + throw; + } + } + finally + { + _collectionLock.Release(); + if (sw.IsActive) + _storage.MetricsDispatcher?.Publish(new MetricEvent + { + Timestamp = sw.StartTimestamp, + Type = MetricEventType.CollectionInsert, + ElapsedMicros = sw.GetElapsedMicros(), + CollectionName = _collectionName, + Success = success, + }); + } + } + + /// + /// Asynchronously inserts multiple documents in a single transaction. + /// + /// + /// With a caller-owned the call is all or nothing: if the first + /// document is rejected with (duplicate key, vector + /// dimension mismatch, document or key too large), nothing was written and the transaction stays + /// active. Any other failure, including a rejected document after earlier documents of the batch + /// were written, rolls the transaction back ( becomes + /// ). + /// + public async ValueTask> InsertBulkAsync(IEnumerable entities, ITransaction? transaction, CancellationToken ct = default) + { + if (entities == null) throw new ArgumentNullException(nameof(entities)); + + var entityList = entities.ToList(); + var ids = new List(entityList.Count); + + WriteFailure.EnsureWritable(transaction); + + if (!await _collectionLock.WaitAsync(WriteLockTimeoutMs, ct)) + throw new TimeoutException("Timed out acquiring collection lock (InsertBulk)."); + + bool autoCommit = transaction == null; + transaction ??= _storage.BeginTransaction(IsolationLevel.ReadCommitted); + try + { + try + { + await InsertBulkInternal(entityList, ids, transaction); + if (autoCommit) + { + await transaction.CommitAsync(ct); + // ── OnInsert retention trigger — runs AFTER commit within the lock ── + if (_retentionPolicy != null && (_retentionPolicy.Triggers & RetentionTrigger.OnInsert) != 0) + await ApplyRetentionPolicyCoreAsync(ct); + } + else if (_retentionPolicy != null && (_retentionPolicy.Triggers & RetentionTrigger.OnInsert) != 0 + && transaction is Transaction concreteTx) + { + // For caller-managed transactions, fire retention in the background after commit. + concreteTx.OnCommit += () => _ = RunScheduledRetentionAsync(); + } + return ids; + } + catch (Exception ex) + { + await WriteFailure.HandleAsync(transaction, autoCommit, ex, callHasWritten: ids.Count > 0); + throw; + } + } + finally + { + _collectionLock.Release(); + } + } + + private async Task InsertBulkInternal(List entityList, List ids, ITransaction transaction) + { + // Scale batch size with available cores: more parallelism on multi-core machines. + // Clamped to [32, 200] to avoid pathological cases on very high-core-count machines. + int BATCH_SIZE = Math.Max(32, Math.Min(Environment.ProcessorCount * 4, 200)); + + for (int batchStart = 0; batchStart < entityList.Count; batchStart += BATCH_SIZE) + { + int batchEnd = Math.Min(batchStart + BATCH_SIZE, entityList.Count); + int batchCount = batchEnd - batchStart; + + // PHASE 1: Parallel serialize this batch + var serializedBatch = new (TId id, byte[] data, int length)[batchCount]; + + System.Threading.Tasks.Parallel.For(0, batchCount, i => + { + var entity = entityList[batchStart + i]; + var id = EnsureId(entity); + var length = SerializeWithRetry(entity, out var buffer); + serializedBatch[i] = (id, buffer, length); + }); + + // PHASE 2: Sequential insert this batch + for (int i = 0; i < batchCount; i++) + { + var (id, buffer, length) = serializedBatch[i]; + var entity = entityList[batchStart + i]; + + try + { + await InsertDataCore(id, entity, buffer, transaction, length); + ids.Add(id); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + } + } + + private TId EnsureId(T entity) + { + var id = _mapper.GetId(entity); + if (EqualityComparer.Default.Equals(id, default!)) + { + if (typeof(TId) == typeof(ObjectId)) + { + id = (TId)(object)ObjectId.NewObjectId(); + _mapper.SetId(entity, id); + } + else if (typeof(TId) == typeof(Guid)) + { + id = (TId)(object)Guid.NewGuid(); + _mapper.SetId(entity, id); + } + else if (typeof(TId) == typeof(int)) + { + id = (TId)(object)(int)_storage.GetNextSequence(_collectionName); + _mapper.SetId(entity, id); + } + else if (typeof(TId) == typeof(long)) + { + id = (TId)(object)_storage.GetNextSequence(_collectionName); + _mapper.SetId(entity, id); + } + else if (typeof(TId) == typeof(string)) + { + id = (TId)(object)Ulid.NewUlid().ToString(); + _mapper.SetId(entity, id); + } + } + return id; + } + + private async Task InsertCore(T entity, ITransaction transaction) + { + var id = EnsureId(entity); + var length = SerializeWithRetry(entity, out var buffer); + try + { + await InsertDataCore(id, entity, buffer, transaction, length); + return id; + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + private async Task InsertDataCore(TId id, T entity, byte[] docData, ITransaction transaction, int docLength = -1) + { + if (docLength >= 0 && docLength < docData.Length) + docData = docData[..docLength]; // trim to actual serialized size + + // ── AUDIT: start ───────────────────────────────────────────────────── + var auditVsw = (_storage.AuditSink is not null || _storage.AuditMetrics is not null) + ? ValueStopwatch.StartNew() + : default; + // ──────────────────────────────────────────────────────────────────── + + // Everything that can reject the document is checked before any page is written, so a + // rejected insert leaves nothing behind (BLiteValidationException keeps the transaction usable). + var key = _mapper.ToIndexKey(id); + ValidatePrimaryKeyForInsert(id, key, transaction); + _indexManager.EnsureUniqueInAll(entity, null, transaction); + _indexManager.ValidateKeysInAll(entity); + + DocumentLocation location; + if (_isTimeSeries) + { + // Wrap the already-serialized BSON bytes in a BsonDocument so InsertTimeSeries + // can read the timestamp field and manage TimeSeriesPage allocation/pruning. + var bsonDoc = new BsonDocument(docData.ToArray(), _storage.GetKeyReverseMap(), _storage.GetKeyMap()); + var loc = _storage.InsertTimeSeries(_collectionName, bsonDoc, transaction); + location = new DocumentLocation(loc.PageId, (ushort)loc.SlotIndex); + } + else if (docData.Length + SlotEntry.Size <= _maxDocumentSizeForSinglePage) + { + var pageId = await FindPageWithSpace(docData.Length + SlotEntry.Size, transaction); + if (pageId == 0) pageId = await AllocateNewDataPage(transaction); + var slotIndex = await InsertIntoPage(pageId, docData, transaction); + location = new DocumentLocation(pageId, slotIndex); + } + else + { + var (pageId, slotIndex) = await InsertWithOverflow(docData, transaction); + location = new DocumentLocation(pageId, slotIndex); + } + + _primaryIndex.Insert(key, location, transaction.TransactionId); + _indexManager.InsertIntoAll(entity, location, transaction); + + // ── AUDIT: emit ────────────────────────────────────────────────────── + if (auditVsw.IsActive) + { + var elapsed = auditVsw.GetElapsed(); + var opts = _storage.AuditOptions!; + var userId = (opts.ContextProvider ?? Audit.AmbientAuditContext.Instance).GetCurrentUserId(); + var evt = new Audit.InsertAuditEvent( + TransactionId: transaction.TransactionId, + CollectionName: _collectionName, + DocumentSizeBytes: docData.Length, + Elapsed: elapsed, + UserId: userId); + + _storage.AuditSink?.OnInsert(evt); + _storage.AuditMetrics?.RecordInsert(elapsed); + + // Slow-insert detection + if (opts.SlowOperationThreshold is { } threshold && elapsed > threshold) + { + _storage.AuditSink?.OnSlowOperation(new Audit.SlowOperationEvent( + Audit.SlowOperationType.Insert, + CollectionName: _collectionName, + Elapsed: elapsed, + Detail: null)); + } + } + // ──────────────────────────────────────────────────────────────────── + + // Notify CDC + await NotifyCdc(OperationType.Insert, id, transaction, docData); + } + + /// + /// Throws if cannot be inserted + /// into the primary index: it is already taken, or it is larger than a node page. + /// + private void ValidatePrimaryKeyForInsert(TId id, IndexKey key, ITransaction transaction) + { + if (key.Data.Length > _primaryIndex.MaxKeySize) + throw new BLiteValidationException( + $"Id of {key.Data.Length} bytes is too large for the primary index of collection '{_collectionName}' (maximum {_primaryIndex.MaxKeySize} bytes)."); + + if (_primaryIndex.TryFind(key, out _, transaction.TransactionId)) + throw new BLiteValidationException( + $"Duplicate key violation: a document with id '{id}' already exists in collection '{_collectionName}'."); + } + + /// + /// Compacts the data area of a slotted page by reclaiming bytes occupied by deleted slots. + /// Live documents are packed contiguously toward the top of the page and each slot's Offset + /// field is updated accordingly. Slot indices are never renumbered, so the primary index + /// remains valid. is advanced to reflect the + /// reclaimed space. + /// + /// Full page buffer (exactly PageSize bytes) to compact in-place. + /// true if at least one deleted slot was found and space was reclaimed; false if the page was already compact. + private bool CompactPage(Span buffer) + { + var header = SlottedPageHeader.ReadFrom(buffer); + if (header.SlotCount == 0) return false; + + // Gather live (non-deleted) slots + var liveSlots = new List<(ushort index, SlotEntry slot)>(header.SlotCount); + for (ushort i = 0; i < header.SlotCount; i++) + { + var entryOffset = SlottedPageHeader.Size + (i * SlotEntry.Size); + var slot = SlotEntry.ReadFrom(buffer.Slice(entryOffset, SlotEntry.Size)); + if ((slot.Flags & SlotFlags.Deleted) == 0) + liveSlots.Add((i, slot)); + } + + if (liveSlots.Count == header.SlotCount) + return false; // No deleted slots — nothing to compact + + // Pack live document data from top of page downward using a temp copy + // of the data area to avoid read-after-write corruption. + var temp = ArrayPool.Shared.Rent(buffer.Length); + try + { + buffer.CopyTo(temp); + ushort newEnd = (ushort)buffer.Length; + foreach (var (idx, slot) in liveSlots) + { + newEnd -= slot.Length; + // Copy original bytes from temp into the new (higher) position in buffer + temp.AsSpan(slot.Offset, slot.Length).CopyTo(buffer.Slice(newEnd, slot.Length)); + // UpdateAsync slot entry to point at the new offset + var updatedSlot = slot; + updatedSlot.Offset = newEnd; + var entryOffset = SlottedPageHeader.Size + (idx * SlotEntry.Size); + updatedSlot.WriteTo(buffer.Slice(entryOffset, SlotEntry.Size)); + } + + header.FreeSpaceEnd = newEnd; + header.WriteTo(buffer); + return true; + } + finally + { + ArrayPool.Shared.Return(temp); + } + } + + #endregion + + #region Find + + /// + /// Async exact-match lookup. Uses for the index + /// traversal and for the page read. + /// + public ValueTask FindByIdAsync(TId id, CancellationToken ct = default) + => FindByIdAsync(id, null, ct); + + /// + public async ValueTask FindByIdAsync(TId id, ITransaction? transaction, CancellationToken ct = default) + { + var sw = _storage.MetricsDispatcher != null ? ValueStopwatch.StartNew() : default; + bool success = false; + var txnId = transaction?.TransactionId ?? 0UL; + var key = _mapper.ToIndexKey(id); + try + { + var (found, location) = await _primaryIndex.TryFindAsync(key, txnId, ct).ConfigureAwait(false); + if (!found) return default; + var result = await FindByLocationAsync(location, txnId, ct).ConfigureAwait(false); + success = result != null; + return result; + } + finally + { + if (sw.IsActive) + _storage.MetricsDispatcher?.Publish(new MetricEvent + { + Timestamp = sw.StartTimestamp, + Type = MetricEventType.CollectionFind, + ElapsedMicros = sw.GetElapsedMicros(), + CollectionName = _collectionName, + Success = success, + }); + } + } + + /// + /// Async full-collection scan. Uses for leaf chaining + /// and for each page read. + /// + public IAsyncEnumerable FindAllAsync(CancellationToken ct = default) + => FindAllAsync(null, ct); + + /// + public async IAsyncEnumerable FindAllAsync(ITransaction? transaction, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default) + { + var sw = _storage.MetricsDispatcher != null ? ValueStopwatch.StartNew() : default; + var txnId = transaction?.TransactionId ?? 0UL; + + 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 entity = await FindByLocationAsync(entry.Location, txnId, cachedBuffer, ct).ConfigureAwait(false); + if (entity != null) + yield return entity; + } + } + finally + { + foreach (var buf in pageCache.Values) + ArrayPool.Shared.Return(buf); + if (sw.IsActive) + _storage.MetricsDispatcher?.Publish(new MetricEvent + { + Timestamp = sw.StartTimestamp, + Type = MetricEventType.CollectionQuery, + ElapsedMicros = sw.GetElapsedMicros(), + CollectionName = _collectionName, + Success = true, + }); + } + } + + internal async Task FindByLocation(DocumentLocation location, ITransaction? transaction = null) + { + var txnId = transaction?.TransactionId ?? 0UL; + var buffer = ArrayPool.Shared.Rent(_storage.PageSize); + try + { + // Read from StorageEngine with transaction isolation + _storage.ReadPage(location.PageId, txnId, buffer); + + var pageType = (PageType)buffer[4]; + if (pageType == PageType.Free || pageType == PageType.Empty) + return null; + + if (pageType == PageType.TimeSeries) + { + int tsOffset = location.SlotIndex; + if (tsOffset < TimeSeriesPage.DataOffset || tsOffset + 4 > buffer.Length) + return null; + int tsSize = BitConverter.ToInt32(buffer.AsSpan(tsOffset, 4)); + if (tsSize <= 0 || tsOffset + tsSize > buffer.Length) + return null; + return _mapper.Deserialize(new BsonSpanReader(buffer.AsSpan(tsOffset, tsSize), _storage.GetKeyReverseMap())); + } + + 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) + { + // Layout: [TotalLength (4)] [NextOverflowPage (4)] [DataChunk (...)] + var payload = buffer.AsSpan(slot.Offset, slot.Length); + + // Read Metadata + int totalLength = System.Buffers.Binary.BinaryPrimitives.ReadInt32LittleEndian(payload.Slice(0, 4)); + uint nextOverflowPageId = System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4, 4)); + + var fullBuffer = ArrayPool.Shared.Rent(totalLength); + try + { + // Copy primary chunk (skipping 8 bytes of metadata) + int primaryChunkSize = slot.Length - 8; + payload.Slice(8, primaryChunkSize).CopyTo(fullBuffer.AsSpan(0, primaryChunkSize)); + + int offset = primaryChunkSize; + var currentOverflowPageId = nextOverflowPageId; + + // Follow overflow chain + while (currentOverflowPageId != 0 && offset < totalLength) + { + // Read from StorageEngine with transaction isolation + _storage.ReadPage(currentOverflowPageId, txnId, buffer); + var overflowHeader = SlottedPageHeader.ReadFrom(buffer); + + // Calculate data in this overflow page + // Overflow pages are full data pages (PageSize - HeaderSize) + int maxChunkSize = _storage.PageSize - SlottedPageHeader.Size; + int remaining = totalLength - offset; + int chunkSize = Math.Min(maxChunkSize, remaining); + + buffer.AsSpan(SlottedPageHeader.Size, chunkSize) + .CopyTo(fullBuffer.AsSpan(offset)); + + offset += chunkSize; + currentOverflowPageId = overflowHeader.NextOverflowPage; + } + + return _mapper.Deserialize(new BsonSpanReader(fullBuffer.AsSpan(0, totalLength), _storage.GetKeyReverseMap())); + } + finally + { + ArrayPool.Shared.Return(fullBuffer); + } + } + + // Validation check + if (slot.Offset + slot.Length > buffer.Length) + { + throw new InvalidOperationException($"Corrupted slot: Offset={slot.Offset}, Length={slot.Length}, Buffer={buffer.Length}, SlotIndex={location.SlotIndex}, PageId={location.PageId}, Flags={slot.Flags}"); + } + + var docData = buffer.AsSpan(slot.Offset, slot.Length); + return _mapper.Deserialize(new BsonSpanReader(docData, _storage.GetKeyReverseMap())); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + /// + /// Async version of . + /// All operations are performed synchronously on the in-memory buffer. + /// No Span variable survives across an await boundary. + /// + internal ValueTask FindByLocationAsync(DocumentLocation location, ulong txnId, CancellationToken ct = default) + => FindByLocationCoreAsync(location, txnId, null, ct); + + // Called when the primary page for this location is already in memory (page-cache fast path). + // The preloadedPage buffer skips the initial ReadPageAsync; overflow chain pages are still read + // from storage as needed. + internal ValueTask FindByLocationAsync(DocumentLocation location, ulong txnId, byte[] preloadedPage, CancellationToken ct = default) + => FindByLocationCoreAsync(location, txnId, preloadedPage, ct); + + private async ValueTask FindByLocationCoreAsync(DocumentLocation location, ulong txnId, byte[]? preloadedPage, CancellationToken ct) + { + byte[]? ownedBuffer = null; + byte[] buffer; + + if (preloadedPage is not null) + { + buffer = preloadedPage; + } + else + { + ownedBuffer = ArrayPool.Shared.Rent(_storage.PageSize); + await _storage.ReadPageAsync(location.PageId, txnId, ownedBuffer.AsMemory(0, _storage.PageSize), ct).ConfigureAwait(false); + buffer = ownedBuffer; + } + + try + { + // --- all Span work is sync after the first read --- + var pageType = (PageType)buffer[4]; + if (pageType == PageType.Free || pageType == PageType.Empty) + return default; + + if (pageType == PageType.TimeSeries) + { + int tsOffset = location.SlotIndex; + if (tsOffset < TimeSeriesPage.DataOffset || tsOffset + 4 > buffer.Length) + return default; + int tsSize = BitConverter.ToInt32(buffer.AsSpan(tsOffset, 4)); + if (tsSize <= 0 || tsOffset + tsSize > buffer.Length) + return default; + return _mapper.Deserialize(new BsonSpanReader(buffer.AsSpan(tsOffset, tsSize), _storage.GetKeyReverseMap())); + } + + var header = SlottedPageHeader.ReadFrom(buffer); + if (location.SlotIndex >= header.SlotCount) return default; + + var slotOffset = SlottedPageHeader.Size + (location.SlotIndex * SlotEntry.Size); + var slot = SlotEntry.ReadFrom(buffer.AsSpan(slotOffset)); + if ((slot.Flags & SlotFlags.Deleted) != 0) return default; + + if ((slot.Flags & SlotFlags.HasOverflow) != 0) + { + // Extract scalar values from the buffer before the first await in the loop + int totalLength = System.Buffers.Binary.BinaryPrimitives.ReadInt32LittleEndian(buffer.AsSpan(slot.Offset, 4)); + uint nextOverflowPage = System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(buffer.AsSpan(slot.Offset + 4, 4)); + int primaryChunkSize = slot.Length - 8; + + var fullBuffer = ArrayPool.Shared.Rent(totalLength); + // Use a dedicated overflow-chain buffer so the primary page buffer (which may be a + // shared cached entry from FindAllAsync) is never overwritten during the chain walk. + var overflowBuffer = ArrayPool.Shared.Rent(_storage.PageSize); + try + { + // Copy primary chunk synchronously (no await here) + buffer.AsSpan(slot.Offset + 8, primaryChunkSize).CopyTo(fullBuffer.AsSpan(0, primaryChunkSize)); + + int bytesCopied = primaryChunkSize; + uint currentPage = nextOverflowPage; + + while (currentPage != 0 && bytesCopied < totalLength) + { + ct.ThrowIfCancellationRequested(); + await _storage.ReadPageAsync(currentPage, txnId, overflowBuffer.AsMemory(0, _storage.PageSize), ct).ConfigureAwait(false); + + // Recreate SlottedPageHeader span after each await — safe because overflowBuffer is byte[] + uint nextPage = SlottedPageHeader.ReadFrom(overflowBuffer).NextOverflowPage; + int chunkSize = Math.Min(_storage.PageSize - SlottedPageHeader.Size, totalLength - bytesCopied); + overflowBuffer.AsSpan(SlottedPageHeader.Size, chunkSize).CopyTo(fullBuffer.AsSpan(bytesCopied)); + bytesCopied += chunkSize; + currentPage = nextPage; + } + + return _mapper.Deserialize(new BsonSpanReader(fullBuffer.AsSpan(0, totalLength), _storage.GetKeyReverseMap())); + } + finally + { + ArrayPool.Shared.Return(overflowBuffer); + ArrayPool.Shared.Return(fullBuffer); + } + } + + if (slot.Offset + slot.Length > buffer.Length) + throw new InvalidOperationException($"Corrupted slot: Offset={slot.Offset}, Length={slot.Length}, PageId={location.PageId}"); + + // Inline deserialization — Span created after the only await, safe + return _mapper.Deserialize(new BsonSpanReader(buffer.AsSpan(slot.Offset, slot.Length), _storage.GetKeyReverseMap())); + } + finally + { + // Only return the buffer to the pool when we own it; caller-provided pages + // are managed by the caller (e.g. the per-query page cache in QueryIndexAsync). + if (ownedBuffer is not null) + ArrayPool.Shared.Return(ownedBuffer); + } + } + + #endregion + + #region Update & Delete + + /// + /// Asynchronously updates an existing document in the collection + /// + /// + /// With a caller-owned : if the call throws + /// (duplicate key, vector dimension mismatch, document or + /// key too large), it wrote nothing and the transaction stays active, so the caller can catch it, + /// carry on and commit. Any other failure while writing rolls the transaction back + /// ( becomes ), so that a + /// half-applied write, with the data and its indexes out of step, can never be committed. + /// + public async ValueTask UpdateAsync(T entity, ITransaction? transaction, CancellationToken ct = default) + { + if (entity == null) throw new ArgumentNullException(nameof(entity)); + + var sw = _storage.MetricsDispatcher != null ? ValueStopwatch.StartNew() : default; + bool success = false; + bool autoCommit = transaction == null; + + WriteFailure.EnsureWritable(transaction); + + if (!await _collectionLock.WaitAsync(WriteLockTimeoutMs, ct)) + throw new TimeoutException("Timed out acquiring collection lock (Update)."); + + transaction ??= _storage.BeginTransaction(IsolationLevel.ReadCommitted); + try + { + try + { + var result = await UpdateCore(entity, transaction); + if (autoCommit && result) await transaction.CommitAsync(ct); + success = result; + return result; + } + catch (Exception ex) + { + await WriteFailure.HandleAsync(transaction, autoCommit, ex); + throw; + } + } + finally + { + _collectionLock.Release(); + if (sw.IsActive) + _storage.MetricsDispatcher?.Publish(new MetricEvent + { + Timestamp = sw.StartTimestamp, + Type = MetricEventType.CollectionUpdate, + ElapsedMicros = sw.GetElapsedMicros(), + CollectionName = _collectionName, + Success = success, + }); + } + } + + /// + /// Asynchronously updates multiple documents in a single transaction. + /// + /// + /// With a caller-owned the call is all or nothing: if the first + /// document is rejected with (duplicate key, vector + /// dimension mismatch, document or key too large), nothing was written and the transaction stays + /// active. Any other failure, including a rejected document after earlier documents of the batch + /// were written, rolls the transaction back ( becomes + /// ). + /// + public async ValueTask UpdateBulkAsync(IEnumerable entities, ITransaction? transaction, CancellationToken ct = default) + { + if (entities == null) throw new ArgumentNullException(nameof(entities)); + + var entityList = entities.ToList(); + int updateCount = 0; + bool autoCommit = transaction == null; + // Documents of this call written so far: a failure after the first one must roll back. + var progress = new StrongBox(); + + WriteFailure.EnsureWritable(transaction); + + if (!await _collectionLock.WaitAsync(WriteLockTimeoutMs, ct)) + throw new TimeoutException("Timed out acquiring collection lock (UpdateBulk)."); + + transaction ??= _storage.BeginTransaction(IsolationLevel.ReadCommitted); + try + { + try + { + updateCount = await UpdateBulkInternal(entityList, transaction, progress); + if (autoCommit) await transaction.CommitAsync(ct); + return updateCount; + } + catch (Exception ex) + { + await WriteFailure.HandleAsync(transaction, autoCommit, ex, callHasWritten: progress.Value > 0); + throw; + } + } + finally + { + _collectionLock.Release(); + } + } + + private async Task UpdateBulkInternal(List entityList, ITransaction transaction, StrongBox progress) + { + int updateCount = 0; + const int BATCH_SIZE = 50; + + for (int batchStart = 0; batchStart < entityList.Count; batchStart += BATCH_SIZE) + { + int batchEnd = Math.Min(batchStart + BATCH_SIZE, entityList.Count); + int batchCount = batchEnd - batchStart; + + // PHASE 1: Parallel Serialization + var serializedBatch = new (TId id, byte[] data, int length, bool found)[batchCount]; + + for (int i = 0; i < batchCount; i++) + { + var entity = entityList[batchStart + i]; + var id = _mapper.GetId(entity); + var key = _mapper.ToIndexKey(id); + + // Check if entity exists + // We do this sequentially to avoid ThreadPool exhaustion or IO-related deadlocks + if (_primaryIndex.TryFind(key, out var _, transaction.TransactionId)) + { + var length = SerializeWithRetry(entity, out var buffer); + serializedBatch[i] = (id, buffer, length, true); + } + else + { + serializedBatch[i] = (default!, null!, 0, false); + } + } + + // PHASE 2: Sequential UpdateAsync + for (int i = 0; i < batchCount; i++) + { + var (id, docData, length, found) = serializedBatch[i]; + if (!found) continue; + + var entity = entityList[batchStart + i]; + try + { + if (await UpdateDataCore(id, entity, docData, transaction, length)) + progress.Value = ++updateCount; + } + finally + { + ArrayPool.Shared.Return(docData); + } + } + } + return updateCount; + } + + private async Task UpdateCore(T entity, ITransaction transaction) + { + var id = _mapper.GetId(entity); + var length = SerializeWithRetry(entity, out var buffer); + try + { + return await UpdateDataCore(id, entity, buffer, transaction, length); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + private async Task UpdateDataCore(TId id, T entity, byte[] docData, ITransaction transaction, int docLength = -1) + { + var key = _mapper.ToIndexKey(id); + if (!_primaryIndex.TryFind(key, out var oldLocation, transaction.TransactionId)) + return false; + + return await UpdateAtLocationCore(id, entity, docData, key, oldLocation, transaction, docLength); + } + + /// + /// Replaces the document at an already-resolved primary-index location. + /// Callers that already hold from their own + /// _primaryIndex.TryFind (e.g. ) use this to avoid a + /// second lookup for the same key. + /// + private async Task UpdateAtLocationCore(TId id, T entity, byte[] docData, IndexKey key, DocumentLocation oldLocation, ITransaction transaction, int docLength = -1) + { + if (docLength >= 0 && docLength < docData.Length) + docData = docData[..docLength]; // trim to actual serialized size + var bytesWritten = docData.Length; + + // Retrieve old version for index updates + var oldEntity = await FindByLocation(oldLocation, transaction); + if (oldEntity == null) return false; + + // Everything that can reject the document is checked before any page is written. + // Unique indexes only where the key changed, so documents that already share a value + // can still be updated. + _indexManager.EnsureUniqueInAll(entity, oldEntity, transaction); + _indexManager.ValidateKeysInAll(entity); + + // Read old page + var pageBuffer = ArrayPool.Shared.Rent(_storage.PageSize); + try + { + _storage.ReadPage(oldLocation.PageId, transaction.TransactionId, pageBuffer); + + var slotOffset = SlottedPageHeader.Size + (oldLocation.SlotIndex * SlotEntry.Size); + var oldSlot = SlotEntry.ReadFrom(pageBuffer.AsSpan(slotOffset)); + + if (bytesWritten <= oldSlot.Length && (oldSlot.Flags & SlotFlags.HasOverflow) == 0) + { + // In-place update + docData.CopyTo(pageBuffer.AsSpan(oldSlot.Offset, bytesWritten)); + var newSlot = oldSlot; + newSlot.Length = (ushort)bytesWritten; + newSlot.WriteTo(pageBuffer.AsSpan(slotOffset)); + _storage.WritePage(oldLocation.PageId, transaction.TransactionId, pageBuffer); + + // Notify secondary indexes (primary index unchanged) + _indexManager.UpdateInAll(oldEntity, entity, oldLocation, oldLocation, transaction); + + // Notify CDC + await NotifyCdc(OperationType.Update, id, transaction, docData); + return true; + } + else + { + // Delete old + insert new. Secondary indexes are moved below, so an entry whose + // key did not change is re-pointed rather than deleted and inserted again. + await DeleteCore(id, transaction, notifyCdc: false, deleteFromIndexes: false); + + DocumentLocation newLocation; + if (bytesWritten + SlotEntry.Size <= _maxDocumentSizeForSinglePage) + { + var newPageId = await FindPageWithSpace(bytesWritten + SlotEntry.Size, transaction); + if (newPageId == 0) newPageId = await AllocateNewDataPage(transaction); + var newSlotIndex = await InsertIntoPage(newPageId, docData, transaction); + newLocation = new DocumentLocation(newPageId, newSlotIndex); + } + else + { + var (newPageId, newSlotIndex) = await InsertWithOverflow(docData, transaction); + newLocation = new DocumentLocation(newPageId, newSlotIndex); + } + + _primaryIndex.Insert(key, newLocation, transaction.TransactionId); + _indexManager.UpdateInAll(oldEntity, entity, oldLocation, newLocation, transaction); + + // Notify CDC + await NotifyCdc(OperationType.Update, id, transaction, docData); + return true; + } + } + finally + { + ArrayPool.Shared.Return(pageBuffer); + } + } + + /// + /// Inserts if its id is unset or not present in the collection, + /// otherwise replaces the existing document with the same id. Resolved with a single + /// primary-index lookup per document — not a separate Find followed by Insert/Update. + /// + public ValueTask> UpsertAsync(T entity, CancellationToken ct = default) + => UpsertAsync(entity, null, ct); + + /// + /// + /// With a caller-owned : if the call throws + /// (duplicate key, vector dimension mismatch, document or + /// key too large), it wrote nothing and the transaction stays active, so the caller can catch it, + /// carry on and commit. Any other failure while writing rolls the transaction back + /// ( becomes ), so that a + /// half-applied write, with the data and its indexes out of step, can never be committed. + /// + public async ValueTask> UpsertAsync(T entity, ITransaction? transaction, CancellationToken ct = default) + { + if (entity == null) throw new ArgumentNullException(nameof(entity)); + + var sw = _storage.MetricsDispatcher != null ? ValueStopwatch.StartNew() : default; + bool success = false; + bool inserted = false; + bool autoCommit = transaction == null; + + WriteFailure.EnsureWritable(transaction); + + if (!await _collectionLock.WaitAsync(WriteLockTimeoutMs, ct)) + throw new TimeoutException("Timed out acquiring collection lock (Upsert)."); + + transaction ??= _storage.BeginTransaction(IsolationLevel.ReadCommitted); + try + { + try + { + var result = await UpsertCore(entity, transaction); + inserted = result.Inserted; + if (autoCommit) + { + await transaction.CommitAsync(ct); + // ── OnInsert retention trigger — only fires when this call actually inserted ── + if (inserted && _retentionPolicy != null && (_retentionPolicy.Triggers & RetentionTrigger.OnInsert) != 0) + await ApplyRetentionPolicyCoreAsync(ct); + } + else if (inserted && _retentionPolicy != null && (_retentionPolicy.Triggers & RetentionTrigger.OnInsert) != 0 + && transaction is Transaction concreteTx) + { + concreteTx.OnCommit += () => _ = RunScheduledRetentionAsync(); + } + success = true; + return result; + } + catch (Exception ex) + { + await WriteFailure.HandleAsync(transaction, autoCommit, ex); + throw; + } + } + finally + { + _collectionLock.Release(); + if (sw.IsActive) + _storage.MetricsDispatcher?.Publish(new MetricEvent + { + Timestamp = sw.StartTimestamp, + Type = inserted ? MetricEventType.CollectionInsert : MetricEventType.CollectionUpdate, + ElapsedMicros = sw.GetElapsedMicros(), + CollectionName = _collectionName, + Success = success, + }); + } + } + + /// + /// Upserts multiple documents in a single transaction. Each entity is resolved with its + /// own single primary-index lookup, in list order. + /// + public ValueTask>> UpsertBulkAsync(IEnumerable entities, CancellationToken ct = default) + => UpsertBulkAsync(entities, null, ct); + + /// + /// + /// With a caller-owned the call is all or nothing: if the first + /// document is rejected with (duplicate key, vector + /// dimension mismatch, document or key too large), nothing was written and the transaction stays + /// active. Any other failure, including a rejected document after earlier documents of the batch + /// were written, rolls the transaction back ( becomes + /// ). + /// + public async ValueTask>> UpsertBulkAsync(IEnumerable entities, ITransaction? transaction, CancellationToken ct = default) + { + if (entities == null) throw new ArgumentNullException(nameof(entities)); + + var entityList = entities.ToList(); + var results = new List>(entityList.Count); + bool autoCommit = transaction == null; + + WriteFailure.EnsureWritable(transaction); + + if (!await _collectionLock.WaitAsync(WriteLockTimeoutMs, ct)) + throw new TimeoutException("Timed out acquiring collection lock (UpsertBulk)."); + + transaction ??= _storage.BeginTransaction(IsolationLevel.ReadCommitted); + try + { + try + { + bool anyInserted = false; + foreach (var entity in entityList) + { + var result = await UpsertCore(entity, transaction); + anyInserted |= result.Inserted; + results.Add(result); + } + + if (autoCommit) + { + await transaction.CommitAsync(ct); + if (anyInserted && _retentionPolicy != null && (_retentionPolicy.Triggers & RetentionTrigger.OnInsert) != 0) + await ApplyRetentionPolicyCoreAsync(ct); + } + else if (anyInserted && _retentionPolicy != null && (_retentionPolicy.Triggers & RetentionTrigger.OnInsert) != 0 + && transaction is Transaction concreteTx) + { + concreteTx.OnCommit += () => _ = RunScheduledRetentionAsync(); + } + return results; + } + catch (Exception ex) + { + await WriteFailure.HandleAsync(transaction, autoCommit, ex, callHasWritten: results.Count > 0); + throw; + } + } + finally + { + _collectionLock.Release(); + } + } + + /// + /// Resolves the entity's id, then commits it with a single primary-index lookup: + /// found → in-place/relocated replace via ; + /// not found → insert via . When the id is unset + /// (default), the lookup is skipped entirely since no existing document could match it. + /// + private async Task> UpsertCore(T entity, ITransaction transaction) + { + var id = _mapper.GetId(entity); + if (EqualityComparer.Default.Equals(id, default!)) + { + var newId = await InsertCore(entity, transaction); + return new UpsertResult(newId, Inserted: true); + } + + var key = _mapper.ToIndexKey(id); + var length = SerializeWithRetry(entity, out var buffer); + try + { + if (_primaryIndex.TryFind(key, out var oldLocation, transaction.TransactionId)) + { + if (!await UpdateAtLocationCore(id, entity, buffer, key, oldLocation, transaction, length)) + throw new InvalidOperationException( + $"Upsert failed for id '{id}': the primary index resolved a location whose document could not be read (stale or corrupted index entry)."); + return new UpsertResult(id, Inserted: false); + } + else + { + await InsertDataCore(id, entity, buffer, transaction, length); + return new UpsertResult(id, Inserted: true); + } + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + /// + /// Asynchronously deletes a document by its primary key. + /// + /// + /// With a caller-owned , a failure while writing rolls the + /// transaction back ( becomes ), + /// so that a half-applied delete can never be committed. + /// + public async ValueTask DeleteAsync(TId id, ITransaction? transaction, CancellationToken ct = default) + { + var sw = _storage.MetricsDispatcher != null ? ValueStopwatch.StartNew() : default; + bool success = false; + bool autoCommit = transaction == null; + + WriteFailure.EnsureWritable(transaction); + + if (!await _collectionLock.WaitAsync(WriteLockTimeoutMs, ct)) + throw new TimeoutException("Timed out acquiring collection lock (Delete)."); + + transaction ??= _storage.BeginTransaction(IsolationLevel.ReadCommitted); + try + { + try + { + var result = await DeleteCore(id, transaction); + if (autoCommit && result) await transaction.CommitAsync(ct); + success = result; + return result; + } + catch (Exception ex) + { + await WriteFailure.HandleAsync(transaction, autoCommit, ex); + throw; + } + } + finally + { + _collectionLock.Release(); + if (sw.IsActive) + _storage.MetricsDispatcher?.Publish(new MetricEvent + { + Timestamp = sw.StartTimestamp, + Type = MetricEventType.CollectionDelete, + ElapsedMicros = sw.GetElapsedMicros(), + CollectionName = _collectionName, + Success = success, + }); + } + } + + /// + /// Asynchronously deletes multiple documents in a single transaction. + /// + /// + /// With a caller-owned , a failure while writing rolls the + /// transaction back ( becomes ), + /// so that a half-applied delete can never be committed. + /// + public async ValueTask DeleteBulkAsync(IEnumerable ids, ITransaction? transaction, CancellationToken ct = default) + { + if (ids == null) throw new ArgumentNullException(nameof(ids)); + + int deleteCount = 0; + bool autoCommit = transaction == null; + + WriteFailure.EnsureWritable(transaction); + + if (!await _collectionLock.WaitAsync(WriteLockTimeoutMs, ct)) + throw new TimeoutException("Timed out acquiring collection lock (DeleteBulk)."); + + transaction ??= _storage.BeginTransaction(IsolationLevel.ReadCommitted); + try + { + try + { + deleteCount = await DeleteBulkInternal(ids, transaction); + if (autoCommit) await transaction.CommitAsync(ct); + return deleteCount; + } + catch (Exception ex) + { + await WriteFailure.HandleAsync(transaction, autoCommit, ex); + throw; + } + } + finally + { + _collectionLock.Release(); + } + } + + private async Task DeleteBulkInternal(IEnumerable ids, ITransaction transaction) + { + int deleteCount = 0; + foreach (var id in ids) + { + if (await DeleteCore(id, transaction)) + deleteCount++; + } + return deleteCount; + } + + private async Task DeleteCore(TId id, ITransaction transaction, bool notifyCdc = true, bool deleteFromIndexes = true) + { + var key = _mapper.ToIndexKey(id); + if (!_primaryIndex.TryFind(key, out var location, transaction.TransactionId)) + return false; + + // Notify secondary indexes BEFORE deleting document from storage + if (deleteFromIndexes) + { + var entity = await FindByLocation(location, transaction); + if (entity != null) + { + _indexManager.DeleteFromAll(entity, location, transaction); + } + } + + // Read page + var buffer = ArrayPool.Shared.Rent(_storage.PageSize); + try + { + _storage.ReadPage(location.PageId, transaction.TransactionId, buffer); + + var slotOffset = SlottedPageHeader.Size + (location.SlotIndex * SlotEntry.Size); + var slot = SlotEntry.ReadFrom(buffer.AsSpan(slotOffset)); + + // Check if slot has overflow and free it + if ((slot.Flags & SlotFlags.HasOverflow) != 0) + { + var nextOverflowPage = System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian( + buffer.AsSpan(slot.Offset + 4, 4)); + await FreeOverflowChain(nextOverflowPage, transaction); + } + + // Mark slot as deleted + var newSlot = slot; + newSlot.Flags |= SlotFlags.Deleted; + newSlot.WriteTo(buffer.AsSpan(slotOffset)); + + // Compact the page: reclaim the bytes that belonged to the deleted document + // and advance FreeSpaceEnd so FindPageWithSpace can see the freed space. + CompactPage(buffer.AsSpan(0, _storage.PageSize)); + + // Secure erase: zero-fill the free space area so the deleted document bytes + // are unrecoverable at the page level, supporting GDPR Art. 17 compliance. + var postCompactHeader = SlottedPageHeader.ReadFrom(buffer.AsSpan(0, SlottedPageHeader.Size)); + buffer.AsSpan(postCompactHeader.FreeSpaceStart, + postCompactHeader.FreeSpaceEnd - postCompactHeader.FreeSpaceStart).Clear(); + + _storage.WritePage(location.PageId, transaction.TransactionId, buffer); + + // Update the free space index with post-compaction free bytes. + // Snapshot the pre-modification value first so that a rollback can restore it. + var compactedHeader = SlottedPageHeader.ReadFrom(buffer.AsSpan(0, SlottedPageHeader.Size)); + SnapshotFsiForTransaction(transaction, location.PageId); + _fsi.Update(location.PageId, compactedHeader.AvailableFreeSpace); + + // Remove from primary index + _primaryIndex.Delete(key, location, transaction.TransactionId); + + // Notify CDC + if (notifyCdc) + await NotifyCdc(OperationType.Delete, id, transaction); + + return true; + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + private async Task FreeOverflowChain(uint overflowPageId, ITransaction transaction) + { + var tempBuffer = ArrayPool.Shared.Rent(_storage.PageSize); + try + { + while (overflowPageId != 0) + { + _storage.ReadPage(overflowPageId, transaction.TransactionId, tempBuffer); + var header = SlottedPageHeader.ReadFrom(tempBuffer); + var nextPage = header.NextOverflowPage; + + // Recycle this page + _storage.FreePage(overflowPageId); + + overflowPageId = nextPage; + } + } + finally + { + ArrayPool.Shared.Return(tempBuffer); + } + } + + #endregion + + #region Query Helpers + + /// + /// Asynchronously counts all documents in the collection. + /// If called within a transaction, will count uncommitted changes. + /// + /// Cancellation token + /// Number of documents + public Task CountAsync(CancellationToken ct = default) + { + var sw = _storage.MetricsDispatcher != null ? ValueStopwatch.StartNew() : default; + try + { + // Count all entries in primary index + // Use generic min/max keys for the index + var minKey = IndexKey.MinKey; + var maxKey = IndexKey.MaxKey; + return Task.FromResult(_primaryIndex.Range(minKey, maxKey, IndexDirection.Forward, (ulong?)null).Count()); + } + finally + { + if (sw.IsActive) + _storage.MetricsDispatcher?.Publish(new MetricEvent + { + Timestamp = sw.StartTimestamp, + Type = MetricEventType.CollectionQuery, + ElapsedMicros = sw.GetElapsedMicros(), + CollectionName = _collectionName, + Success = true, + }); + } + } + + /// + /// Returns the minimum field value from this collection using an plan. + /// When the plan targets a B-Tree index, the O(log n) index boundary read is used. + /// Falls back to a BSON-level full collection scan when no index is available. + /// + public async ValueTask MinBoundaryAsync(IndexMinMax plan, CancellationToken ct = default) + { + if (plan.Kind == IndexMinMax.PlanKind.IndexBoundary) + { + var index = _indexManager.GetIndex(plan.IndexName!); + if (index != null) + { + T? doc = null; + await foreach (var d in QueryIndexAsync(plan.IndexName!, null, null, ascending: true, skip: 0, take: 1, ct: ct).ConfigureAwait(false)) + { + doc = d; + break; + } + if (doc != null) + { + var keyValue = index.Definition.KeySelector(doc); + if (keyValue is TResult r) return r; + } + return default!; + } + // Index was not found; cannot fall back (no BsonAggregator stored for IndexBoundary plans). + return default!; + } + + // BsonAggregate fallback: scan the collection using BSON field reads. + var agg = plan.Fallback!; + return await BsonAggregateFieldAsync(agg, isMin: true, ct).ConfigureAwait(false); + } + + /// + /// Returns the maximum field value from this collection using an plan. + /// When the plan targets a B-Tree index, the O(log n) index boundary read is used. + /// Falls back to a BSON-level full collection scan when no index is available. + /// + public async ValueTask MaxBoundaryAsync(IndexMinMax plan, CancellationToken ct = default) + { + if (plan.Kind == IndexMinMax.PlanKind.IndexBoundary) + { + var index = _indexManager.GetIndex(plan.IndexName!); + if (index != null) + { + T? doc = null; + await foreach (var d in QueryIndexAsync(plan.IndexName!, null, null, ascending: false, skip: 0, take: 1, ct: ct).ConfigureAwait(false)) + { + doc = d; + break; + } + if (doc != null) + { + var keyValue = index.Definition.KeySelector(doc); + if (keyValue is TResult r) return r; + } + return default!; + } + return default!; + } + + var agg = plan.Fallback!; + return await BsonAggregateFieldAsync(agg, isMin: false, ct).ConfigureAwait(false); + } + + private async Task BsonAggregateFieldAsync(BsonAggregator agg, bool isMin, CancellationToken ct) + { + var projector = BsonExpressionEvaluator.CreateFieldProjector(agg.FieldName, _storage.GetKeyMap()); + TResult result = default!; + bool found = false; + var comparer = Comparer.Default; + + await foreach (var value in ScanAsync(projector, ct).ConfigureAwait(false)) + { + ct.ThrowIfCancellationRequested(); + if (!found) + { + result = value; + found = true; + } + else if (isMin) + { + if (comparer.Compare(value, result) < 0) result = value; + } + else + { + if (comparer.Compare(value, result) > 0) result = value; + } + } + + return result; + } + + /// + /// Counts documents matching without fully materializing + /// the result set in memory. + /// + /// If the predicate targets an indexed field with an exact-covering filter, + /// the count is derived from an index key-only leaf scan — zero data-page reads. + /// Otherwise the documents are streamed through (index / + /// BSON / full-scan strategies) and counted in a tight loop without accumulating + /// a List<T>. + /// + /// + [RequiresDynamicCode("Count-by-predicate uses index optimization and Expression.Compile() which require dynamic code generation.")] + [RequiresUnreferencedCode("Count-by-predicate uses reflection to resolve members at runtime. Ensure all entity types are preserved.")] + internal async Task CountByPredicateAsync( + System.Linq.Expressions.LambdaExpression whereClause, + CancellationToken ct = default) + { + // Strategy 1: Index key-only scan — no data-page reads at all. + // Applicable whenever the predicate targets an indexed field AND the index fully + // covers the predicate (HasResiduePredicate=false). Strict operators (> / <) set + // IsExactFilter=false but HasResiduePredicate=false, so we still handle them here + // by passing the correct start/end inclusivity to CountRange. + // When HasResiduePredicate=true (compound AND with a non-indexed clause), we must + // fall through to FetchAsync so the residue predicate is applied per document. + var indexOpt = Query.IndexOptimizer.TryOptimize(whereClause, GetIndexes(), ConverterRegistry); + if (indexOpt != null + && !indexOpt.IsVectorSearch + && !indexOpt.IsSpatialSearch + && !indexOpt.IsFullTextSearch + && !indexOpt.HasResiduePredicate) + { + var index = _indexManager.GetIndex(indexOpt.IndexName); + if (index != null) + { + if (indexOpt.InValues != null) + { + int inCount = 0; + var seen = new HashSet(s_inProbeKeyComparer); + foreach (var key in indexOpt.InValues) + { + if (!seen.Add(key)) continue; + inCount += index.CountRange(key, key, true, true, null); + } + return inCount; + } + + // Use the per-bound inclusivity flags from OptimizationResult. + // These are set correctly for every operator (==, >=, >, <=, <) and + // propagated through AND-merges, so compound predicates like + // x.Price > 50 && x.Price < 90 get both boundaries exclusive. + return index.CountRange(indexOpt.MinValue, indexOpt.MaxValue, + indexOpt.StartInclusive, indexOpt.EndInclusive, null); + } + } + + // Strategy 2: BSON-level count — evaluates the predicate on raw BSON bytes without + // deserialising T. Combined with the Phase 1 widening of BsonExpressionEvaluator + // this covers the vast majority of real-world WHERE predicates. + if (BsonExpressionEvaluator.TryCompile(whereClause, ConverterRegistry, _storage.GetKeyMap()) is { } bsonPred) + return await CountScanAsync(bsonPred, ct).ConfigureAwait(false); + + // Strategy 3: Stream matching documents via FetchAsync and count without + // keeping them in a List, relying on FetchAsync's own BSON-scan / full-scan + // strategies to minimise allocations. + int count = 0; + await foreach (var _ in FetchAsync(whereClause, int.MaxValue, ct).ConfigureAwait(false)) + { + ct.ThrowIfCancellationRequested(); + count++; + } + return count; + } + + /// + /// Computes Sum, Average, Min or Max of over + /// the documents matching without materialising them, with the + /// result LINQ would compute over the entities. Returns Handled = false when the query + /// must run the usual way. + /// + /// Only for a property the mapper restores verbatim from a BSON field + /// (): the stored value, in the + /// document or in an index key, is then the value of the materialised property. Ignored, + /// computed and converted properties, and ones whose accessors have logic, are materialised. + /// + /// + /// An int/long property with a B-Tree index on exactly that property, and + /// no filter or one the same index covers exactly, is aggregated from the index keys + /// alone (Min/Max read one entry). Keys are in value order, so a + /// Sum/Average for which LINQ could overflow in one order but not in + /// another is not taken from them. + /// Otherwise, when no index serves the filter, the property is read from the raw BSON + /// of each document in primary-key order, the order a scan yields the entities in. A + /// document without the field, or holding a type the entity would not be read from + /// unchanged, ends the attempt so the query runs the usual way. + /// + /// + /// A plain property access, x => x.Property. + /// The result type of the LINQ operator. + [RequiresDynamicCode("Aggregate push-down uses index optimization and Expression.Compile() which require dynamic code generation.")] + [RequiresUnreferencedCode("Aggregate push-down uses reflection to resolve members at runtime. Ensure all entity types are preserved.")] + internal Task<(bool Handled, object? Value)> TryAggregateAsync( + AggregateOperator op, + LambdaExpression selector, + LambdaExpression? whereClause, + Type resultType, + CancellationToken ct = default) + { + if (selector.Body is not MemberExpression { Member: System.Reflection.PropertyInfo property } member + || member.Expression != selector.Parameters[0] + || selector.ReturnType != property.PropertyType + || ConverterRegistry.HasConverter(property.Name)) + return Task.FromResult<(bool, object?)>(default); + + var valueType = Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType; + bool nullable = valueType != property.PropertyType; + + if (valueType == typeof(int)) return TryAggregateAsync(op, property, nullable, whereClause, resultType, ct); + if (valueType == typeof(long)) return TryAggregateAsync(op, property, nullable, whereClause, resultType, ct); + if (valueType == typeof(double)) return TryAggregateAsync(op, property, nullable, whereClause, resultType, ct); + if (valueType == typeof(float)) return TryAggregateAsync(op, property, nullable, whereClause, resultType, ct); + if (valueType == typeof(decimal)) return TryAggregateAsync(op, property, nullable, whereClause, resultType, ct); + return Task.FromResult<(bool, object?)>(default); + } + + [RequiresDynamicCode("Aggregate push-down uses index optimization and Expression.Compile() which require dynamic code generation.")] + [RequiresUnreferencedCode("Aggregate push-down uses reflection to resolve members at runtime. Ensure all entity types are preserved.")] + private async Task<(bool Handled, object? Value)> TryAggregateAsync( + AggregateOperator op, + System.Reflection.PropertyInfo property, + bool nullable, + LambdaExpression? whereClause, + Type resultType, + CancellationToken ct) + where TValue : struct, IComparable + { + if (LinqAggregate.ResultType(op, nullable) != resultType) + return default; + + // The field the mapper restores the property from unchanged. Without it, neither the raw + // field nor an index key (computed from the property when the document was written) is + // known to be the value the materialised entity returns. + var fieldName = (_mapper as DocumentMapperBase)?.GetVerbatimFieldName(property.Name); + if (fieldName == null) + return default; + + var plan = whereClause == null ? null : Query.IndexOptimizer.TryOptimize(whereClause, GetIndexes(), ConverterRegistry); + + // Index keys hold the exact int/long value. Not for the other types: floating-point sums + // depend on the order of the values, decimal keys drop trailing zeros (1.50 → 1.5), and the + // first of equal Min/Max values wins (-0.0 vs 0.0, 1.0 vs 1.00). + if (typeof(TValue) == typeof(int) || typeof(TValue) == typeof(long)) + { + var index = _indexManager.GetAllIndexes().FirstOrDefault(i => + i.Definition.Type == IndexType.BTree + && !i.Definition.IsPrimary + && i.Definition.PropertyPaths.Length == 1 + && i.Definition.PropertyPaths[0] == property.Name + && KeysAreValuesOf(i.Definition, property, typeof(TValue))); + + if (index != null) + { + (bool Handled, object? Value) fromKeys = default; + if (whereClause == null) + { + fromKeys = AggregateIndexKeys(op, nullable, index, null, null, true, true); + } + else if (plan != null + && plan.IndexName == index.Name + && !plan.HasResiduePredicate + && !plan.IsVectorSearch + && !plan.IsSpatialSearch + && plan.InValues == null + && plan.MinValue is null or TValue + && plan.MaxValue is null or TValue) + { + // The optimizer keeps the constant's own type (r.Count < 9.5 gives a double + // bound), and keys carry no type tag, so a bound of another type would be + // compared bytewise against the int/long keys. Such filters take the path below, + // which post-filters the documents the index returns. + fromKeys = AggregateIndexKeys(op, nullable, index, plan.MinValue, plan.MaxValue, plan.StartInclusive, plan.EndInclusive); + } + if (fromKeys.Handled) + return fromKeys; + // Otherwise aggregate the values in the order LINQ sees them, below. + } + } + + // An index serves the filter: fetching the matching documents through it is cheaper than + // scanning them all, and keeps the order the values are aggregated in unchanged. + if (plan != null) + return default; + + BsonReaderPredicate? predicate = null; + if (whereClause != null) + { + predicate = BsonExpressionEvaluator.TryCompile(whereClause, ConverterRegistry, _storage.GetKeyMap()); + if (predicate == null) return default; + } + + return await AggregateRawFieldAsync(op, nullable, fieldName, predicate, ct).ConfigureAwait(false); + } + + /// + /// Whether the keys of the index are the values of + /// itself (x => x.Property), encoded as + /// : the user key is encoded from the boxed value the selector + /// returns, so this fixes the key layout the index path decodes. + /// + private static bool KeysAreValuesOf(CollectionIndexDefinition definition, System.Reflection.PropertyInfo property, Type valueType) + { + var body = definition.KeySelectorExpression.Body; + while (body is UnaryExpression { NodeType: ExpressionType.Convert } convert && convert.Type == typeof(object)) + body = convert.Operand; + + return body is MemberExpression { Member: System.Reflection.PropertyInfo keyProperty } access + && access.Expression == definition.KeySelectorExpression.Parameters[0] + && keyProperty.Name == property.Name + && (Nullable.GetUnderlyingType(access.Type) ?? access.Type) == valueType; + } + + /// Aggregates the values of the index entries in the given range from their keys. + private static (bool Handled, object? Value) AggregateIndexKeys( + AggregateOperator op, + bool nullable, + CollectionSecondaryIndex index, + object? min, + object? max, + bool startInclusive, + bool endInclusive) + where TValue : struct, IComparable + { + var aggregate = new LinqAggregate(op, nullable, anyOrder: true); + + if (op == AggregateOperator.Max) + { + // Keys are [value][document id]: the last entry holds the largest value. + var last = index.LastKey(min, max, startInclusive, endInclusive); + if (last is { } key) + { + if (!IndexKeyValueVisitor.TryDecode(key.Data, out var value)) return default; + aggregate.Add(value); + } + return (true, aggregate.Result()); + } + + var visitor = new IndexKeyValueVisitor(aggregate, stopAfterFirst: op == AggregateOperator.Min); + index.ScanKeys(min, max, startInclusive, endInclusive, ref visitor); + return visitor.Malformed || aggregate.DependsOnOrder ? default : (true, aggregate.Result()); + } + + /// + /// Feeds the value prefix of each [value][document id] key of an int or + /// long index to a . + /// + private struct IndexKeyValueVisitor : BTreeIndex.IKeyVisitor + where TValue : struct, IComparable + { + private readonly LinqAggregate _aggregate; + private readonly bool _stopAfterFirst; + + public IndexKeyValueVisitor(LinqAggregate aggregate, bool stopAfterFirst) + { + _aggregate = aggregate; + _stopAfterFirst = stopAfterFirst; + Malformed = false; + } + + /// A key did not start with an encoded value of the expected type. + public bool Malformed { get; private set; } + + public bool Visit(ReadOnlySpan key) + { + if (!TryDecode(key, out var value)) + { + Malformed = true; + return false; + } + _aggregate.Add(value); + // Past an order-dependent sum the remaining keys cannot change the outcome. + return !_stopAfterFirst && !_aggregate.DependsOnOrder; + } + + /// + /// Decodes the / prefix of a + /// [value][document id] key. The index was checked to be keyed by a + /// (); a key not laid out that way + /// (no prefix byte, or no document id after the value) is reported as malformed rather than + /// decoded. + /// + public static bool TryDecode(ReadOnlySpan key, out TValue value) + { + value = default; + if (key.Length == 0 || key[0] != IndexKey.KeyPrefix) return false; + + if (typeof(TValue) == typeof(int)) + { + if (key.Length <= 5) return false; + value = (TValue)(object)(int)(BinaryPrimitives.ReadUInt32BigEndian(key.Slice(1)) ^ 0x8000_0000u); + return true; + } + if (typeof(TValue) == typeof(long)) + { + if (key.Length <= 9) return false; + value = (TValue)(object)(long)(BinaryPrimitives.ReadUInt64BigEndian(key.Slice(1)) ^ 0x8000_0000_0000_0000UL); + return true; + } + return false; + } + } + + /// + /// Aggregates field of every document matching + /// from raw BSON, in primary-key order. Gives up (Handled = + /// false) on a document without the field, with a BSON type other than the one the mapper + /// writes for (or null for a nullable property), or that + /// fails to evaluate, since materialising the entity could then yield a different value. + /// + private async Task<(bool Handled, object? Value)> AggregateRawFieldAsync( + AggregateOperator op, + bool nullable, + string fieldName, + BsonReaderPredicate? predicate, + CancellationToken ct) + where TValue : struct, IComparable + { + if (!_storage.GetKeyMap().TryGetValue(fieldName, out var fieldId)) + return default; // never written: every document lacks it + + var expectedType = + typeof(TValue) == typeof(int) ? BsonType.Int32 : + typeof(TValue) == typeof(long) ? BsonType.Int64 : + typeof(TValue) == typeof(decimal) ? BsonType.Decimal128 : + BsonType.Double; // double and float + var keyMap = _storage.GetKeyReverseMap(); + var aggregate = new LinqAggregate(op, nullable); + + // Adds the document's value if it matches; false to give up. + bool Fold(ReadOnlySpan bson) + { + try + { + if (predicate != null && !predicate(new BsonSpanReader(bson, keyMap))) + return true; + + var reader = new BsonSpanReader(bson, keyMap); + reader.ReadDocumentSize(); + var firstElement = reader.Position; + if (!reader.TrySeekToField(fieldId, out var type)) + { + // No usable offset table entry: look for the field from the first element on. + reader.Seek(firstElement); + if (!SeekField(ref reader, fieldName, out type)) + return false; + } + + if (type == BsonType.Null) + return nullable; + if (type != expectedType) + return false; + + aggregate.Add(ReadValue(ref reader)); + return true; + } + catch + { + return false; + } + } + + // The scan yields only for a document that ends the attempt. + await foreach (var _ in VisitRawDocumentsAsync(0UL, (ReadOnlySpan bson, out bool giveUp) => giveUp = !Fold(bson), ct).ConfigureAwait(false)) + return default; + + return (true, aggregate.Result()); + + static bool SeekField(ref BsonSpanReader reader, string name, out BsonType type) + { + while (reader.Remaining > 1) + { + type = reader.ReadBsonType(); + if (type == BsonType.EndOfDocument) break; + if (reader.ReadElementHeader() == name) return true; + reader.SkipValue(type); + } + type = BsonType.EndOfDocument; + return false; + } + + static TV ReadValue(ref BsonSpanReader reader) + { + if (typeof(TV) == typeof(int)) return (TV)(object)reader.ReadInt32(); + if (typeof(TV) == typeof(long)) return (TV)(object)reader.ReadInt64(); + if (typeof(TV) == typeof(double)) return (TV)(object)reader.ReadDouble(); + if (typeof(TV) == typeof(float)) return (TV)(object)(float)reader.ReadDouble(); + return (TV)(object)reader.ReadDecimal128(); + } + } + + /// + /// Queries the collection using an expression tree predicate, enabling index optimisation. + /// When the predicate references an indexed field the query engine performs a BTree lookup + /// instead of a full collection scan. + /// The returned preserves the expression tree so that + /// terminal operators (FirstOrDefaultAsync, ToListAsync, etc.) can inject + /// limits before materialisation. + /// + [RequiresDynamicCode("LINQ-style find operations use Expression.Compile() and index optimization which require dynamic code generation.")] + [RequiresUnreferencedCode("LINQ-style find operations use reflection to resolve members at runtime. Ensure all entity types are preserved.")] + public IAsyncEnumerable FindAsync( + System.Linq.Expressions.Expression> predicate, + CancellationToken ct = default) + => FetchAsync(predicate, int.MaxValue, ct); + + /// + [RequiresDynamicCode("LINQ-style find operations use Expression.Compile() and index optimization which require dynamic code generation.")] + [RequiresUnreferencedCode("LINQ-style find operations use reflection to resolve members at runtime. Ensure all entity types are preserved.")] + public IAsyncEnumerable FindAsync( + System.Linq.Expressions.Expression> predicate, + ITransaction? transaction, + CancellationToken ct = default) + => FetchAsync(predicate, int.MaxValue, transaction, ct); + + /// + /// Returns the first document matching , or null if none. + /// Stops reading from the storage engine as soon as one document is found (fetchLimit = 1). + /// + [RequiresDynamicCode("LINQ-style find operations use Expression.Compile() and index optimization which require dynamic code generation.")] + [RequiresUnreferencedCode("LINQ-style find operations use reflection to resolve members at runtime. Ensure all entity types are preserved.")] + public Task FindOneAsync( + System.Linq.Expressions.Expression> predicate, + CancellationToken ct = default) + => FindOneAsync(predicate, null, ct); + + /// + [RequiresDynamicCode("LINQ-style find operations use Expression.Compile() and index optimization which require dynamic code generation.")] + [RequiresUnreferencedCode("LINQ-style find operations use reflection to resolve members at runtime. Ensure all entity types are preserved.")] + public async Task FindOneAsync( + System.Linq.Expressions.Expression> predicate, + ITransaction? transaction, + CancellationToken ct = default) + { + var sw = _storage.MetricsDispatcher != null ? ValueStopwatch.StartNew() : default; + bool success = false; + try + { + // Fast path: equality query on an indexed field. + // Bypasses FetchAsync / QueryIndexAsync / page-cache Dictionary entirely. + // Path: IndexOptimizer → CollectionSecondaryIndex.Seek (TryFindFirst) → FindByLocationAsync. + var indexOpt = Query.IndexOptimizer.TryOptimize(predicate, GetIndexes(), ConverterRegistry); + if (indexOpt != null && !indexOpt.IsRange && !indexOpt.IsVectorSearch && !indexOpt.IsSpatialSearch + && !indexOpt.IsFullTextSearch && indexOpt.MinValue != null) + { + var index = _indexManager.GetIndex(indexOpt.IndexName); + if (index != null) + { + var location = index.Seek(indexOpt.MinValue, transaction); + if (location == null) return default; + var result = await FindByLocationAsync(location.Value, transaction?.TransactionId ?? 0UL, ct).ConfigureAwait(false); + success = result != null; + return result; + } + } + + // General path: range / vector / spatial / no index. + await foreach (var item in FetchAsync(predicate, 1, transaction, ct).ConfigureAwait(false)) + { + success = true; + return item; + } + return default; + } + finally + { + if (sw.IsActive) + _storage.MetricsDispatcher?.Publish(new MetricEvent + { + Timestamp = sw.StartTimestamp, + Type = MetricEventType.CollectionQuery, + ElapsedMicros = sw.GetElapsedMicros(), + CollectionName = _collectionName, + Success = success, + }); + } + } + + /// + /// Core data-fetching primitive used by both and + /// . + /// Selects the cheapest access strategy in order: + /// + /// BTree index scan (narrows candidates to the index range) + in-memory WHERE filter + /// BSON-level predicate scan (compiled once, no full deserialization per-document) + /// Full collection scan + in-memory predicate filter + /// + /// All three strategies guarantee the returned stream is fully filtered: callers + /// never need to re-apply after consuming this enumerable. + /// + /// + /// Optional filter. Must be an Expression<Func<T, bool>> at runtime. + /// Pass null to enumerate all documents. + /// + /// + /// Maximum number of documents to yield. Pass for no limit. + /// + [RequiresDynamicCode("LINQ-style fetch operations use Expression.Compile() and index optimization which require dynamic code generation.")] + [RequiresUnreferencedCode("LINQ-style fetch operations use reflection to resolve members at runtime. Ensure all entity types are preserved.")] + internal IAsyncEnumerable FetchAsync( + System.Linq.Expressions.LambdaExpression? whereClause, + int fetchLimit, + CancellationToken ct = default) + => FetchAsync(whereClause, fetchLimit, null, ct); + + [RequiresDynamicCode("LINQ-style fetch operations use Expression.Compile() and index optimization which require dynamic code generation.")] + [RequiresUnreferencedCode("LINQ-style fetch operations use reflection to resolve members at runtime. Ensure all entity types are preserved.")] + internal async IAsyncEnumerable FetchAsync( + System.Linq.Expressions.LambdaExpression? whereClause, + int fetchLimit, + ITransaction? transaction, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default) + { + int yielded = 0; + + if (whereClause != null) + { + // Lazy compile: Expression.Compile() is expensive (~1 ms) — pay it at most once + // per FetchAsync call, only if we actually need the compiled delegate. + Func? compiledWhere = null; + Func GetCompiled() => + compiledWhere ??= ((System.Linq.Expressions.Expression>)whereClause).Compile(); + + // ── Strategy 1: BTree index ─────────────────────────────────────── + // FilterCompleteness.Exact → index range == full WHERE result; no post-filter. + // FilterCompleteness.StrictBoundary / .PartialAnd + // → index only narrows candidates; reuse the lazily-compiled Func as post-filter. + var indexOpt = Query.IndexOptimizer.TryOptimize(whereClause, GetIndexes(), ConverterRegistry); + if (indexOpt != null) + { + if (indexOpt.IsVectorSearch) + { + // The index satisfied the VectorSearch term; the post-filter checks the rest. + // Compiled lazily, like GetCompiled(), only when a candidate needs it. + bool exact = indexOpt.FilterCompleteness == Query.IndexOptimizer.FilterCompleteness.Exact; + Func? residual = null; + await foreach (var item in VectorSearchAsync(indexOpt.IndexName, indexOpt.VectorQuery!, indexOpt.K, ct: ct)) + { + if (!exact && !(residual ??= ((System.Linq.Expressions.Expression>)Query.IndexOptimizer.WithoutCall(whereClause, indexOpt.VectorSearchCall!)).Compile())(item)) + continue; + yield return item; + if (++yielded >= fetchLimit) yield break; + } + } + else if (indexOpt.IsFullTextSearch) + { + // Every hit is re-checked with the full predicate: a posting left behind by an + // analyzer change must not surface the document that now holds its location. + var ftsIndex = _indexManager.GetIndex(indexOpt.IndexName)!; + foreach (var location in ftsIndex.FullTextMatch(indexOpt.FullTextQuery!, transaction)) + { + var item = await FindByLocationAsync(location, transaction?.TransactionId ?? 0UL, ct); + if (item != null && GetCompiled()(item)) { yield return item; if (++yielded >= fetchLimit) yield break; } + } + } + else if (indexOpt.IsSpatialSearch) + { + var spatialSeq = indexOpt.SpatialType == Query.IndexOptimizer.SpatialQueryType.Near + ? NearAsync(indexOpt.IndexName, indexOpt.SpatialPoint, indexOpt.RadiusKm, ct) + : WithinAsync(indexOpt.IndexName, indexOpt.SpatialMin, indexOpt.SpatialMax, ct); + await foreach (var item in spatialSeq) + if (indexOpt.FilterCompleteness == Query.IndexOptimizer.FilterCompleteness.Exact || GetCompiled()(item)) { yield return item; if (++yielded >= fetchLimit) yield break; } + } + else + { + if (indexOpt.InValues != null) + { + var seen = new HashSet(s_inProbeKeyComparer); + foreach (var key in indexOpt.InValues) + { + if (!seen.Add(key)) continue; + await foreach (var item in QueryIndexAsync(indexOpt.IndexName, key, key, true, 0, int.MaxValue, transaction, ct)) + if (indexOpt.FilterCompleteness == Query.IndexOptimizer.FilterCompleteness.Exact || GetCompiled()(item)) { yield return item; if (++yielded >= fetchLimit) yield break; } + } + } + else + { + await foreach (var item in QueryIndexAsync(indexOpt.IndexName, indexOpt.MinValue, indexOpt.MaxValue, true, 0, int.MaxValue, transaction, ct)) + if (indexOpt.FilterCompleteness == Query.IndexOptimizer.FilterCompleteness.Exact || GetCompiled()(item)) { yield return item; if (++yielded >= fetchLimit) yield break; } + } + } + yield break; + } + + // ── Strategy 2: BSON-level predicate scan ───────────────────────── + // Filters at raw-BSON level before deserializing — no compiled Func needed. + if (BsonExpressionEvaluator.TryCompile(whereClause, ConverterRegistry, _storage.GetKeyMap()) is { } bsonPred) + { + await foreach (var item in ScanAsync(bsonPred, transaction, ct)) + { + yield return item; + if (++yielded >= fetchLimit) yield break; + } + yield break; + } + + // ── Strategy 3: full scan + in-memory filter ────────────────────── + // Reuses compiledWhere if Strategy 1 already triggered it; compiles otherwise. + await foreach (var item in FindAllAsync(transaction, ct)) + if (GetCompiled()(item)) { yield return item; if (++yielded >= fetchLimit) yield break; } + } + else + { + // ── No WHERE: plain full scan with optional limit ───────────────── + await foreach (var item in FindAllAsync(transaction, ct)) + { + yield return item; + if (++yielded >= fetchLimit) yield break; + } + } + } + + private void SnapshotFsiForTransaction(ITransaction transaction, uint pageId) + { + if (_fsi.SnapshotForTransaction(transaction.TransactionId, pageId)) + { + var txnId = transaction.TransactionId; + transaction.OnRollback += () => _fsi.RollbackTransaction(txnId); + + if (transaction is Transaction concreteTxn) + concreteTxn.OnCommit += () => _fsi.CommitTransaction(txnId); + } + } + + private static readonly IEqualityComparer s_inProbeKeyComparer = new InProbeKeyComparer(); + + private sealed class InProbeKeyComparer : IEqualityComparer + { + bool IEqualityComparer.Equals(object? x, object? y) + { + if (ReferenceEquals(x, y)) return true; + if (x is null || y is null) return false; + if (x is byte[] xb && y is byte[] yb) return xb.AsSpan().SequenceEqual(yb); + return x.Equals(y); + } + + int IEqualityComparer.GetHashCode(object? obj) + { + if (obj is null) return 0; + if (obj is byte[] bytes) + { + var hc = new HashCode(); + foreach (var b in bytes) hc.Add(b); + return hc.ToHashCode(); + } + return obj.GetHashCode(); + } + } + + #endregion + + /// + /// Serializes an entity with adaptive buffer sizing (Stepped Retry). + /// Strategies: + /// 1. 64KB (Covers 99% of docs, small overhead) + /// 2. 2MB (Covers large docs) + /// 3. 16MB (Max limit) + /// + private int SerializeWithRetry(T entity, out byte[] rentedBuffer) + { + // Cache once — avoids repeated volatile reads of the FrozenDictionary snapshot inside the loop. + var keyMap = _storage.GetFrozenKeyMap(); + + // Stored as compiler-constant data segment — no heap allocation. + ReadOnlySpan steps = [65536, 524288, 2097152, 16777216]; // 64KB, 512KB, 2MB, 16MB + + int minSize = _lastSerializedSize; + + foreach (var size in steps) + { + int bufSize = size < _storage.PageSize ? _storage.PageSize : size; + + // Skip steps that are smaller than the last known serialized size. + // This avoids predictable failures and the associated exception overhead. + if (bufSize < minSize) continue; + + var buffer = ArrayPool.Shared.Rent(bufSize); + try + { + int bytesWritten = _mapper.Serialize(entity, new BsonSpanWriter(buffer, keyMap)); + + // Inject schema version if available + if (CurrentSchemaVersion != null) + { + if (bytesWritten + 8 > buffer.Length) + { + throw new IndexOutOfRangeException("Not enough space for version field"); + } + AppendVersionField(buffer, ref bytesWritten); + } + + // Update warm-start hint: last size + 25% margin so the next document of + // similar size starts at the right step without needing a retry. + _lastSerializedSize = Math.Max(_lastSerializedSize, bytesWritten + (bytesWritten >> 2)); + + rentedBuffer = buffer; + return bytesWritten; + } + catch (Exception ex) when (ex is ArgumentException || ex is IndexOutOfRangeException || ex is ArgumentOutOfRangeException) + { + ArrayPool.Shared.Return(buffer); + // Continue to next step + } + catch + { + ArrayPool.Shared.Return(buffer); + throw; + } + } + + rentedBuffer = null!; // specific compiler satisfaction, though we throw + throw new BLiteValidationException($"Document too large. Maximum size allowed is 16MB."); + } + + /// + /// Appends a version field to the specified BSON buffer if a current schema version is set. + /// + /// The version field is only appended if a current schema version is available. The method + /// updates the BSON document's size and ensures the buffer remains in a valid BSON format. + /// The byte array buffer to which the version field is appended. Must be large enough to accommodate the additional + /// bytes. + /// A reference to the number of bytes written to the buffer. Updated to reflect the new total after the version + /// field is appended. + private void AppendVersionField(byte[] buffer, ref int bytesWritten) + { + if (CurrentSchemaVersion == null) return; + + int version = CurrentSchemaVersion.Value.Version; + + // BSON element for _v (Int32) with Compressed Key: + // Type (1 byte: 0x10) + // Key ID (2 bytes, little-endian) + // Value (4 bytes: int32) + // Total = 7 bytes + + int pos = bytesWritten - 1; // Position of old 0x00 terminator + buffer[pos++] = 0x10; // Int32 + + ushort versionKeyId = _storage.GetOrAddDictionaryEntry("_v"); + BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(pos, 2), versionKeyId); + pos += 2; + + BinaryPrimitives.WriteInt32LittleEndian(buffer.AsSpan(pos, 4), version); + pos += 4; + + buffer[pos++] = 0x00; // new document terminator + + bytesWritten = pos; + + // UpdateAsync total size (first 4 bytes) + BinaryPrimitives.WriteInt32LittleEndian(buffer.AsSpan(0, 4), bytesWritten); + } + + /// + /// Asynchronously performs a vector similarity search on the specified index and returns up to + /// the top-k matching documents. + /// + /// The name of the index to search + /// The query vector + /// Maximum number of nearest neighbors to return + /// Size of the dynamic candidate list during search + /// Cancellation token + /// Async enumerable of matching documents + public async IAsyncEnumerable VectorSearchAsync( + string indexName, + float[] query, + int k, + int efSearch = 100, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default) + { + var index = _indexManager.GetIndex(indexName); + if (index == null) + throw new ArgumentException($"Index '{indexName}' not found.", nameof(indexName)); + + foreach (var result in index.VectorSearch(query, k, efSearch, null)) + { + ct.ThrowIfCancellationRequested(); + var doc = await FindByLocationAsync(result.Location, 0UL, ct); + if (doc != null) yield return doc; + } + } + + /// + /// Returns the documents that best match in a + /// full-text index, best first, each with its BM25 relevance score. + /// + /// The name of the full-text index to search. + /// The terms to look for, analyzed like the indexed text. + /// Maximum number of documents to return. + /// + /// true (the default) to return only documents containing every query term; + /// false to return documents containing any of them, ranked by relevance. + /// + /// Cancellation token. + public async IAsyncEnumerable> FullTextSearchAsync( + string indexName, + string query, + int k, + bool matchAllTerms = true, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default) + { + var index = _indexManager.GetIndex(indexName); + if (index == null) + throw new ArgumentException($"Index '{indexName}' not found.", nameof(indexName)); + + // Hits are re-checked against the document, so a stale posting can never surface another + // document; candidates past the first k are taken until k hits survive the re-check. + int returned = 0; + foreach (var (location, score) in index.FullTextSearchExpanding(query, k, matchAllTerms)) + { + ct.ThrowIfCancellationRequested(); + var doc = await FindByLocationAsync(location, 0UL, ct); + if (doc == null || !index.FullTextContains(doc, query, matchAllTerms)) continue; + + yield return new FullTextSearchResult(doc, score); + if (++returned == k) yield break; + } + } + + /// + /// Asynchronously finds all documents located within a specified radius of a geographic center + /// point using a spatial index. + /// + /// The name of the spatial index to use for the search + /// Latitude and longitude of the center point + /// The search radius in kilometers + /// Cancellation token + /// Async enumerable of matching documents + public async IAsyncEnumerable NearAsync( + string indexName, + (double Latitude, double Longitude) center, + double radiusKm, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default) + { + var index = _indexManager.GetIndex(indexName); + if (index == null) + throw new ArgumentException($"Index '{indexName}' not found.", nameof(indexName)); + + foreach (var loc in index.Near(center, radiusKm, null)) + { + ct.ThrowIfCancellationRequested(); + var doc = await FindByLocationAsync(loc, 0UL, ct); + if (doc != null) yield return doc; + } + } + + /// + /// Asynchronously returns all documents within the specified rectangular geographic area from + /// the given spatial index. + /// + /// The name of the spatial index to search within + /// Minimum latitude and longitude coordinates + /// Maximum latitude and longitude coordinates + /// Cancellation token + /// Async enumerable of matching documents + public async IAsyncEnumerable WithinAsync( + string indexName, + (double Latitude, double Longitude) min, + (double Latitude, double Longitude) max, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default) + { + var index = _indexManager.GetIndex(indexName); + if (index == null) + throw new ArgumentException($"Index '{indexName}' not found.", nameof(indexName)); + + foreach (var loc in index.Within(min, max, null)) + { + ct.ThrowIfCancellationRequested(); + var doc = await FindByLocationAsync(loc, 0UL, ct); + if (doc != null) yield return doc; + } + } + + /// + /// Subscribes to a change stream that notifies observers of changes to the collection. + /// + /// + /// The returned observable emits events as changes are detected in the collection. + /// When is , personal-data + /// fields (annotated with [PersonalData] or registered via HasPersonalData) + /// are masked by default (RevealPersonalData defaults to ). + /// + /// + /// Controls payload capture and GDPR masking behaviour. Pass (or + /// omit the argument) to use the default — no payload, GDPR-safe masking if payload is + /// later enabled. + /// + /// An observable sequence of change stream events for the collection. + /// Thrown if CDC is not initialized for the storage. + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode( + "Watch resolves personal-data fields via PersonalDataResolver which uses reflection.")] + public IObservable> Watch(WatchOptions? options = null) + { + if (_storage.Cdc == null) throw new InvalidOperationException("CDC is not initialized."); + + var opts = options ?? new WatchOptions(); + + // Defensively copy the field lists so that mutations to the caller's collection + // after Watch() returns do not affect the registered masking policy. + // Only copy when the lists are non-empty (ExcludeFields) or non-null and non-empty + // (IncludeOnlyFields) to avoid unnecessary allocation for the common case. + var needsCopy = opts.ExcludeFields.Count > 0 || (opts.IncludeOnlyFields?.Count ?? 0) > 0; + var safeOpts = needsCopy + ? new WatchOptions + { + CapturePayload = opts.CapturePayload, + RevealPersonalData = opts.RevealPersonalData, + PersonalDataMaskValue = opts.PersonalDataMaskValue, + ExcludeFields = opts.ExcludeFields.Count > 0 + ? Array.AsReadOnly(opts.ExcludeFields.ToArray()) + : Array.Empty(), + IncludeOnlyFields = opts.IncludeOnlyFields?.Count > 0 + ? Array.AsReadOnly(opts.IncludeOnlyFields.ToArray()) + : null + } + : opts; + + // Resolve personal-data fields once at Watch() time and cache on the observable + // so the dispatch path is O(personalFields) per event, not O(types) per event. + // Skip the reflection work entirely when CapturePayload is false — the field list + // is irrelevant for metadata-only events. + var personalDataFields = safeOpts.CapturePayload + ? BLite.Core.GDPR.PersonalDataResolver.Resolve(typeof(T)) + : Array.Empty(); + + return new ChangeStreamObservable( + _storage.Cdc, + _collectionName, + safeOpts, + _mapper, + _storage.GetKeyReverseMap(), + _storage.GetFrozenKeyMap(), + personalDataFields); + } + + /// + /// Subscribes to a change stream that notifies observers of changes to the collection. + /// + /// + /// When , each event includes the full BSON payload of the changed + /// document (after applying GDPR masking rules). When (default), + /// only metadata is included. + /// + /// An observable sequence of change stream events for the collection. + /// Thrown if CDC is not initialized for the storage. + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode( + "Watch resolves personal-data fields via PersonalDataResolver which uses reflection.")] + public IObservable> Watch(bool capturePayload) + => Watch(new WatchOptions { CapturePayload = capturePayload }); + + private Task NotifyCdc(OperationType type, TId id, ITransaction transaction, ReadOnlyMemory docData = default) + { + if (_storage.Cdc == null) return Task.CompletedTask; + + // Early exit if no watchers for this collection - avoid allocations + if (!_storage.Cdc.HasAnyWatchers(_collectionName)) return Task.CompletedTask; + + ReadOnlyMemory? payload = null; + if (!docData.IsEmpty && _storage.Cdc.HasPayloadWatchers(_collectionName)) + { + payload = docData.ToArray(); + } + + var idBytes = _mapper.ToIndexKey(id).Data.ToArray(); + + if (transaction is Transaction t) + { + t.AddChange(new InternalChangeEvent + { + Timestamp = DateTime.UtcNow.Ticks, + TransactionId = transaction.TransactionId, + CollectionName = _collectionName, + Type = type, + IdBytes = idBytes, + PayloadBytes = payload + }); + } + + return Task.CompletedTask; + } + + /// + /// Releases all resources used by the current instance of the class. + /// + /// Call this method when you are finished using the object to free unmanaged resources + /// immediately. After calling Dispose, the object should not be used. + public void Dispose() + { + _retentionTimer?.Dispose(); + _retentionTimer = null; + _indexManager.Dispose(); + GC.SuppressFinalize(this); + } + + public ValueTask InsertAsync(T entity, CancellationToken ct = default) + { + return InsertAsync(entity, null, ct); + } + + public ValueTask> InsertBulkAsync(IEnumerable entities, CancellationToken ct = default) + { + return InsertBulkAsync(entities, null, ct); + } + + public ValueTask UpdateAsync(T entity, CancellationToken ct = default) + { + return UpdateAsync(entity, null, ct); + } + + public ValueTask UpdateBulkAsync(IEnumerable entities, CancellationToken ct = default) + { + return UpdateBulkAsync(entities, null, ct); + } + + public ValueTask DeleteAsync(TId id, CancellationToken ct = default) + { + return DeleteAsync(id, null, ct); + } + + public ValueTask DeleteBulkAsync(IEnumerable ids, CancellationToken ct = default) + { + return DeleteBulkAsync(ids, null, ct); + } +} diff --git a/src/BLite.Core/Collections/IDocumentCollection.cs b/src/BLite.Core/Collections/IDocumentCollection.cs index 77719c7..daa4267 100644 --- a/src/BLite.Core/Collections/IDocumentCollection.cs +++ b/src/BLite.Core/Collections/IDocumentCollection.cs @@ -165,6 +165,21 @@ Task> CreateIndexAsync( bool unique = false, CancellationToken ct = default); + /// + [RequiresDynamicCode("Index creation compiles key selector expressions using Expression.Compile() which requires dynamic code generation.")] + Task> CreateFullTextIndexAsync( + Expression> keySelector, + string? name = null, + CancellationToken ct = default); + + /// + IAsyncEnumerable> FullTextSearchAsync( + string indexName, + string query, + int k, + bool matchAllTerms = true, + CancellationToken ct = default); + [RequiresDynamicCode("Index creation compiles key selector expressions using Expression.Compile() which requires dynamic code generation.")] Task> CreateVectorIndexAsync( Expression> keySelector, diff --git a/src/BLite.Core/DynamicCollection.cs b/src/BLite.Core/DynamicCollection.cs index a25711b..8889f4c 100644 --- a/src/BLite.Core/DynamicCollection.cs +++ b/src/BLite.Core/DynamicCollection.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using BLite.Bson; +using BLite.Core.Text; using BLite.Core.Audit; using BLite.Core.CDC; using BLite.Core.Collections; @@ -28,6 +29,7 @@ public sealed record DynamicIndexDescriptor( { public bool IsVector => Type == IndexType.Vector; public bool IsSpatial => Type == IndexType.Spatial; + public bool IsFullText => Type == IndexType.FullText; public bool IsStandard => Type == IndexType.BTree; } @@ -59,7 +61,7 @@ public sealed class DynamicCollection : IDisposable private int _retentionRunning; // 0=idle, 1=running (Interlocked flag) // ── Discriminated union for secondary indexes ───────────────────────────── - private enum DynamicIndexKind { BTree, Vector, Spatial } + private enum DynamicIndexKind { BTree, Vector, Spatial, FullText } private sealed class DynamicSecondaryIndex { @@ -69,7 +71,14 @@ private sealed class DynamicSecondaryIndex public BTreeIndex? BTree { get; } public VectorSearchIndex? Vector { get; } public RTreeIndex? Spatial { get; } - public uint RootPageId => BTree?.RootPageId ?? Vector?.RootPageId ?? Spatial?.RootPageId ?? 0; + public FullTextIndex? FullText { get; } + /// + /// Key format of a full-text index's pages, persisted as its . + /// Kept per index so persisting the metadata never marks an index that still awaits its + /// rebuild as current. + /// + public byte KeyFormat { get; init; } = FullTextIndex.FormatVersion; + public uint RootPageId => BTree?.RootPageId ?? Vector?.RootPageId ?? Spatial?.RootPageId ?? FullText?.RootPageId ?? 0; public DynamicSecondaryIndex(BTreeIndex btree, string fieldPath, IndexOptions options) { Kind = DynamicIndexKind.BTree; BTree = btree; FieldPath = fieldPath; Options = options; } @@ -79,6 +88,9 @@ public DynamicSecondaryIndex(VectorSearchIndex vector, string fieldPath, IndexOp public DynamicSecondaryIndex(RTreeIndex spatial, string fieldPath, IndexOptions options) { Kind = DynamicIndexKind.Spatial; Spatial = spatial; FieldPath = fieldPath; Options = options; } + + public DynamicSecondaryIndex(FullTextIndex fullText, string fieldPath, IndexOptions options) + { Kind = DynamicIndexKind.FullText; FullText = fullText; FieldPath = fieldPath; Options = options; } } // Secondary indexes: name → DynamicSecondaryIndex @@ -110,6 +122,7 @@ internal DynamicCollection(StorageEngine storage, ITransactionHolder transaction _isPageLocked = _storage.IsPageLocked; // Load or create collection metadata + var staleFullTextIndexes = new List(); var metadata = _storage.GetCollectionMetadata(_collectionName); uint primaryRootPageId = 0; @@ -158,6 +171,15 @@ internal DynamicCollection(StorageEngine storage, ITransactionHolder transaction _secondaryIndexes[idxMeta.Name] = new DynamicSecondaryIndex(spatial, fieldPath, opts); break; } + case IndexType.FullText: + { + var opts = IndexOptions.CreateBTree(idxMeta.PropertyPaths); + var fullText = new FullTextIndex(_storage, opts, idxMeta.RootPageId); + _secondaryIndexes[idxMeta.Name] = new DynamicSecondaryIndex(fullText, fieldPath, opts) { KeyFormat = idxMeta.KeyFormat }; + if (idxMeta.KeyFormat != FullTextIndex.FormatVersion || fullText.HasForeignKeys()) + staleFullTextIndexes.Add(idxMeta.Name); + break; + } } } } @@ -188,15 +210,114 @@ internal DynamicCollection(StorageEngine storage, ITransactionHolder transaction rootsChanged = true; } } + // Re-record the features the indexes require if a release that predates them rewrote the + // record and dropped them (a read-only open cannot, and the indexes still protect it). + if (!_storage.IsReadOnly && CollectionFeatures.SyncIndexFeatures(metadata)) + rootsChanged = true; // Only the root ids are patched in place so index entries this constructor does not // restore (unknown types, no property paths) are preserved as stored. if (rootsChanged) _storage.SaveCollectionMetadata(metadata); + // A full-text index written with another analyzer or key layout is rebuilt: deleting a + // document relies on reproducing the keys it was indexed under. + foreach (var name in staleFullTextIndexes) + RebuildFullTextIndex(name); + // Rebuild the free-space index from existing page headers on cold start. RebuildFreeSpaceIndex(); } + /// + /// Rebuilds full-text index into a fresh tree, swaps it in once the + /// rebuild committed, persists it with the current format version and frees the old tree. + /// If the rebuild or the swap fails, the fresh tree's pages are freed and the old index stays + /// persisted as it was, so the next open retries. + /// + private void RebuildFullTextIndex(string name) + { + var old = _secondaryIndexes[name]; + var fresh = new DynamicSecondaryIndex(new FullTextIndex(_storage, old.Options), old.FieldPath, old.Options); + // Run on the thread pool so a captured SynchronizationContext cannot deadlock the wait. + Task.Run(() => FillUnregisteredFullTextIndexAsync(fresh)).GetAwaiter().GetResult(); + + var oldPages = old.FullText!.CollectAllPages(); + PublishFullTextIndex(name, fresh, old); + foreach (var pageId in oldPages) + _storage.FreePage(pageId); + } + + /// + /// Fills a full-text index that is not registered yet from every document, in its own + /// transaction, and commits it. If a document cannot be read or indexed, or the commit fails, + /// every page of the index is freed, including the pages its splits added, and the exception + /// propagates. + /// + private async Task FillUnregisteredFullTextIndexAsync(DynamicSecondaryIndex index) + { + var transaction = _storage.BeginTransaction(IsolationLevel.ReadCommitted); + try + { + foreach (var e in _primaryIndex.Range(IndexKey.MinKey, IndexKey.MaxKey, IndexDirection.Forward, transaction.TransactionId)) + { + var doc = ReadDocumentAt(e.Location, transaction.TransactionId); + if (doc != null) IndexInsert(index, doc, e.Location, transaction); + } + await transaction.CommitAsync().ConfigureAwait(false); + } + catch + { + // Collect the pages through the transaction before rolling it back: pages added by + // splits are allocated outside the transaction but linked only from nodes written in + // it, so after the rollback they could not be found any more. + IReadOnlyList pages = Array.Empty(); + try { pages = index.FullText!.CollectAllPages(transaction.TransactionId); } + catch { /* best-effort: never mask the original failure */ } + + try { await transaction.RollbackAsync().ConfigureAwait(false); } + catch { /* best-effort: never mask the original failure */ } + + FreePagesBestEffort(pages); + throw; + } + } + + /// + /// Registers the filled full-text index under + /// in place of (null: none) and persists the metadata. If that + /// fails, the previous registration is restored, the index's pages are freed and the exception + /// propagates. + /// + private void PublishFullTextIndex(string name, DynamicSecondaryIndex index, DynamicSecondaryIndex? previous) + { + _secondaryIndexes[name] = index; + try + { + PersistIndexMetadata(); + } + catch + { + if (previous != null) _secondaryIndexes[name] = previous; + else _secondaryIndexes.Remove(name); + + IReadOnlyList pages = Array.Empty(); + try { pages = index.FullText!.CollectAllPages(); } + catch { /* best-effort: never mask the original failure */ } + FreePagesBestEffort(pages); + throw; + } + } + + private void FreePagesBestEffort(IReadOnlyList pages) + { + try + { + foreach (var pageId in pages) + _storage.FreePage(pageId); + } + catch { /* best-effort: never mask the original failure */ } + } + private void RebuildFreeSpaceIndex() { // Read only the fixed-size page header (24 bytes) rather than renting a full-page @@ -1010,6 +1131,45 @@ public async IAsyncEnumerable WithinAsync(string indexName, (doubl } } + /// + /// Returns the documents that best match in a + /// full-text index, best first, each with its BM25 relevance score. + /// The field must have been indexed via . + /// + /// The name of the full-text index to search. + /// The terms to look for, analyzed like the indexed text. + /// Maximum number of documents to return. + /// + /// true (the default) to return only documents containing every query term; + /// false to return documents containing any of them, ranked by relevance. + /// + public async IAsyncEnumerable> FullTextSearchAsync( + string indexName, + string query, + int k, + bool matchAllTerms = true, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default) + { + if (!_secondaryIndexes.TryGetValue(indexName, out var entry) || entry.Kind != DynamicIndexKind.FullText || entry.FullText == null) + throw new ArgumentException($"Full-text index '{indexName}' not found on collection '{_collectionName}'"); + + int returned = 0; + foreach (var (location, score) in entry.FullText.SearchExpanding(query, k, matchAllTerms, null)) + { + ct.ThrowIfCancellationRequested(); + var doc = ReadDocumentAt(location, 0UL); + if (doc == null) continue; + + // Re-check the hit, so a stale posting can never surface another document; candidates + // past the first k are taken until k hits survive the re-check. + var text = TryGetNestedValue(doc, entry.FieldPath, out var val) && val.IsString ? val.AsString : null; + if (!(matchAllTerms ? FullTextAnalyzer.Matches(text, query) : FullTextAnalyzer.MatchesAny(text, query))) continue; + + yield return new FullTextSearchResult(doc, score); + if (++returned == k) yield break; + } + } + /// /// Asynchronously yields documents matching the specified predicate. /// @@ -1640,6 +1800,7 @@ public async Task VacuumAsync(VacuumOptions? options = null, CancellationToken c DynamicIndexKind.BTree => new DynamicSecondaryIndex(new BTreeIndex(_storage, entry.Options), entry.FieldPath, entry.Options), DynamicIndexKind.Vector => new DynamicSecondaryIndex(new VectorSearchIndex(_storage, entry.Options), entry.FieldPath, entry.Options), DynamicIndexKind.Spatial => new DynamicSecondaryIndex(new RTreeIndex(_storage, entry.Options, 0), entry.FieldPath, entry.Options), + DynamicIndexKind.FullText => new DynamicSecondaryIndex(new FullTextIndex(_storage, entry.Options), entry.FieldPath, entry.Options), _ => entry }; rebuildEntries[name] = freshEntry; @@ -1790,6 +1951,46 @@ public async Task CreateSpatialIndexAsync(string fieldPath, string? name = null) PersistIndexMetadata(); } + /// + /// Creates a full-text index on a string field (dot-notation for nested fields), filled from + /// the documents already in the collection. Search it with . + /// The default name is idx_fts_{field}. + /// + /// Compatibility: the collection records that it requires full-text indexes, so BLite 5.3 and + /// later versions without them refuse to open it. Older releases cannot refuse: do not open a + /// database with full-text indexes for writing with BLite < 5.3. + /// + /// + public async Task CreateFullTextIndexAsync(string fieldPath, string? name = null) + { + name ??= $"idx_fts_{fieldPath.ToLowerInvariant()}"; + fieldPath = fieldPath.ToLowerInvariant(); + + // Block writers while the index is filled: a document inserted meanwhile would miss the + // index, which only starts receiving writes once it is registered. + if (!await _collectionLock.WaitAsync(WriteLockTimeoutMs).ConfigureAwait(false)) + throw new TimeoutException("Timed out acquiring collection write lock (CreateFullTextIndexAsync)."); + try + { + if (_secondaryIndexes.ContainsKey(name)) + throw new InvalidOperationException($"Index '{name}' already exists"); + + RegisterNestedPathKeys(fieldPath); + + var opts = IndexOptions.CreateBTree(fieldPath); + var entry = new DynamicSecondaryIndex(new FullTextIndex(_storage, opts), fieldPath, opts); + + // Fill before registering, so a crash or a failure leaves no partial index persisted; + // a failed fill or registration frees the index's pages. + await FillUnregisteredFullTextIndexAsync(entry).ConfigureAwait(false); + PublishFullTextIndex(name, entry, previous: null); + } + finally + { + _collectionLock.Release(); + } + } + /// Drops a secondary index by name. public bool DropIndex(string name) { @@ -1848,8 +2049,9 @@ public IReadOnlyList GetIndexDescriptors() { var type = kvp.Value.Kind switch { - DynamicIndexKind.Vector => IndexType.Vector, - DynamicIndexKind.Spatial => IndexType.Spatial, + DynamicIndexKind.Vector => IndexType.Vector, + DynamicIndexKind.Spatial => IndexType.Spatial, + DynamicIndexKind.FullText => IndexType.FullText, _ => IndexType.BTree }; return new DynamicIndexDescriptor( @@ -1891,10 +2093,15 @@ internal void PersistIndexMetadata() case DynamicIndexKind.Spatial: idxMeta.Type = IndexType.Spatial; break; + case DynamicIndexKind.FullText: + idxMeta.Type = IndexType.FullText; + idxMeta.KeyFormat = idx.KeyFormat; + break; } metadata.Indexes.Add(idxMeta); } + CollectionFeatures.SyncIndexFeatures(metadata); _storage.SaveCollectionMetadata(metadata); } @@ -1990,6 +2197,9 @@ private void IndexInsert(DynamicSecondaryIndex idx, BsonDocument document, Docum var coords = ExtractCoordinates(val); if (coords.HasValue) idx.Spatial!.Insert(GeoBox.FromPoint(new GeoPoint(coords.Value.Lat, coords.Value.Lon)), location, transaction); break; + case DynamicIndexKind.FullText: + if (val.IsString) idx.FullText!.Insert(val.AsString, location, transaction); + break; } } @@ -2012,6 +2222,9 @@ private void IndexDelete(DynamicSecondaryIndex idx, BsonDocument document, Docum var coords = ExtractCoordinates(val); if (coords.HasValue) idx.Spatial!.Delete(GeoBox.FromPoint(new GeoPoint(coords.Value.Lat, coords.Value.Lon)), location, transaction); break; + case DynamicIndexKind.FullText: + if (val.IsString) idx.FullText!.Delete(val.AsString, location, transaction); + break; } } diff --git a/src/BLite.Core/Indexing/CollectionIndexManager.cs b/src/BLite.Core/Indexing/CollectionIndexManager.cs index 064e298..ccb9cbe 100644 --- a/src/BLite.Core/Indexing/CollectionIndexManager.cs +++ b/src/BLite.Core/Indexing/CollectionIndexManager.cs @@ -1,815 +1,827 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Linq.Expressions; -using BLite.Bson; -using BLite.Core.Collections; -using BLite.Core.Storage; -using BLite.Core.Transactions; - -namespace BLite.Core.Indexing; - -/// -/// Manages a collection of secondary indexes on a document collection. -/// Handles index creation, deletion, automatic selection, and maintenance. -/// -/// Primary key type -/// Document type -public sealed class CollectionIndexManager : IDisposable where T : class -{ - private readonly Dictionary> _indexes; - private readonly StorageEngine _storage; - private readonly IDocumentMapper _mapper; - private readonly object _lock = new(); - private bool _disposed; - private readonly string _collectionName; - private CollectionMetadata _metadata; - private volatile IReadOnlyList? _cachedIndexInfo; - // Whether any index is a unique BTree index, so EnsureUniqueInAll can skip the loop. - // Refreshed under _lock whenever _indexes changes. - private volatile bool _hasUniqueBTreeIndex; - - [RequiresDynamicCode("Index creation compiles key selector expressions using Expression.Compile() which requires dynamic code generation.")] - [RequiresUnreferencedCode("Index creation uses reflection (Expression.PropertyOrField) to access type members. Ensure all entity types and their members are preserved.")] - public CollectionIndexManager(StorageEngine storage, IDocumentMapper mapper, string? collectionName = null) - { - _storage = storage ?? throw new ArgumentNullException(nameof(storage)); - _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper)); - _collectionName = collectionName ?? _mapper.CollectionName; - _indexes = new Dictionary>(StringComparer.OrdinalIgnoreCase); - - // Load existing metadata via storage - _metadata = _storage.GetCollectionMetadata(_collectionName) ?? new CollectionMetadata { Name = _collectionName }; - - // Refuse a collection that requires features this version cannot maintain, before anything - // below writes to it. - CollectionFeatures.EnsureCanOpen(_metadata, _storage.IsReadOnly); - - // Initialize indexes from metadata. Root page ids are fixed for the lifetime of an index - // (a root split rewrites the root page in place), so they only need persisting when the - // constructor allocated one (an index persisted before its root was allocated eagerly). - bool rootAllocated = false; - foreach (var idxMeta in _metadata.Indexes) - { - var indexName = idxMeta.Name; - var definition = RebuildDefinition(idxMeta.Name, idxMeta.PropertyPaths, idxMeta.IsUnique, idxMeta.Type, idxMeta.Dimensions, idxMeta.Metric); - var index = new CollectionSecondaryIndex(definition, _storage, _mapper, idxMeta.RootPageId) - { - KeyFormat = idxMeta.KeyFormat - }; - _indexes[indexName] = index; - rootAllocated |= index.RootPageId != idxMeta.RootPageId; - } - - RefreshHasUniqueBTreeIndex(); - - if (rootAllocated) - SaveMetadata(); - } - - private void RefreshHasUniqueBTreeIndex() - { - var any = false; - foreach (var index in _indexes.Values) - any |= index.IsUniqueBTree; - _hasUniqueBTreeIndex = any; - } - - private void UpdateMetadata() - { - _metadata.Indexes.Clear(); - foreach (var index in _indexes.Values) - { - var info = index.GetInfo(); - _metadata.Indexes.Add(new IndexMetadata - { - Name = info.Name, - IsUnique = info.IsUnique, - Type = info.Type, - PropertyPaths = info.PropertyPaths, - Dimensions = index.Definition.Dimensions, - Metric = index.Definition.Metric, - RootPageId = index.RootPageId, - KeyFormat = index.KeyFormat - }); - } - } - - /// - /// Creates a new secondary index - /// - /// Index definition - /// The created secondary index - public CollectionSecondaryIndex CreateIndex(CollectionIndexDefinition definition) - { - if (definition == null) - throw new ArgumentNullException(nameof(definition)); - - lock (_lock) - { - if (_disposed) - throw new ObjectDisposedException(nameof(CollectionIndexManager)); - - // Check if index with this name already exists - if (_indexes.ContainsKey(definition.Name)) - throw new InvalidOperationException($"Index '{definition.Name}' already exists"); - - // Create secondary index - var secondaryIndex = new CollectionSecondaryIndex(definition, _storage, _mapper); - _indexes[definition.Name] = secondaryIndex; - _cachedIndexInfo = null; // invalidate cache - RefreshHasUniqueBTreeIndex(); - - // Persist metadata - UpdateMetadata(); - _storage.SaveCollectionMetadata(_metadata); - - return secondaryIndex; - } - } - - /// The name an index on gets when none is given. - internal static string ResolveIndexName(LambdaExpression keySelector, string? name) - => name ?? GenerateIndexName(ExpressionAnalyzer.ExtractPropertyPaths(keySelector)); - - /// - /// Builds an index definition from an untyped key selector, as declared in the model. - /// - [RequiresDynamicCode("Index creation compiles key selector expressions using Expression.Compile() which requires dynamic code generation.")] - internal static CollectionIndexDefinition BuildDefinition( - LambdaExpression keySelector, - string? name, - bool unique, - IndexType type, - int dimensions, - VectorMetric metric) - { - var propertyPaths = ExpressionAnalyzer.ExtractPropertyPaths(keySelector); - var body = keySelector.Body.Type == typeof(object) - ? keySelector.Body - : Expression.Convert(keySelector.Body, typeof(object)); - var lambda = Expression.Lambda>(body, keySelector.Parameters); - - return new CollectionIndexDefinition( - name ?? GenerateIndexName(propertyPaths), propertyPaths, lambda, unique, type, false, dimensions, metric); - } - - /// - /// Creates an index for without registering or persisting it, - /// so it can be filled before queries or the collection metadata see it. The index owns its - /// root page: hand it to or , or release its - /// pages. - /// - internal CollectionSecondaryIndex CreateUnregistered(CollectionIndexDefinition definition) - { - lock (_lock) - { - if (_disposed) - throw new ObjectDisposedException(nameof(CollectionIndexManager)); - - return new CollectionSecondaryIndex(definition, _storage, _mapper); - } - } - - /// - /// Registers an index built by and persists it. If the - /// metadata cannot be saved, the index is not registered and remains the caller's to release. - /// - internal void Register(CollectionSecondaryIndex index) - { - lock (_lock) - { - if (_disposed) - throw new ObjectDisposedException(nameof(CollectionIndexManager)); - if (_indexes.ContainsKey(index.Name)) - throw new InvalidOperationException($"Index '{index.Name}' already exists"); - - PublishLocked(index.Name, index, previous: null); - } - } - - // ... methods ... - - /// - /// Creates a simple index on a single property - /// - /// Key type - /// Expression to extract key from document - /// Optional index name (auto-generated if null) - /// Enforce uniqueness constraint - /// The created secondary index - [RequiresDynamicCode("Index creation compiles key selector expressions using Expression.Compile() which requires dynamic code generation.")] - public CollectionSecondaryIndex CreateIndex( - Expression> keySelector, - string? name = null, - bool unique = false) - { - if (keySelector == null) - throw new ArgumentNullException(nameof(keySelector)); - - // Extract property paths from expression - var propertyPaths = ExpressionAnalyzer.ExtractPropertyPaths(keySelector); - - // Generate name if not provided - name ??= GenerateIndexName(propertyPaths); - - // Convert expression to object-returning expression (required for definition) - var objectSelector = Expression.Lambda>( - Expression.Convert(keySelector.Body, typeof(object)), - keySelector.Parameters); - - // Create definition - var definition = new CollectionIndexDefinition( - name, - propertyPaths, - objectSelector, - unique); - - return CreateIndex(definition); - } - - [RequiresDynamicCode("Index creation compiles key selector expressions using Expression.Compile() which requires dynamic code generation.")] - public CollectionSecondaryIndex CreateVectorIndex(Expression> keySelector, int dimensions, VectorMetric metric = VectorMetric.Cosine, string? name = null) - { - var propertyPaths = ExpressionAnalyzer.ExtractPropertyPaths(keySelector); - var indexName = name ?? GenerateIndexName(propertyPaths); - - lock (_lock) - { - if (_indexes.TryGetValue(indexName, out var existing)) - return existing; - - var param = Expression.Parameter(typeof(T), "u"); - var body = Expression.Convert(keySelector.Body, typeof(object)); - var lambda = Expression.Lambda>(body, param); - - var definition = new CollectionIndexDefinition(indexName, propertyPaths, lambda, false, IndexType.Vector, false, dimensions, metric); - return CreateIndex(definition); - } - } - - [RequiresDynamicCode("Index creation compiles key selector expressions using Expression.Compile() which requires dynamic code generation.")] - public CollectionSecondaryIndex EnsureIndex( - Expression> keySelector, - string? name = null, - bool unique = false) - { - var propertyPaths = ExpressionAnalyzer.ExtractPropertyPaths(keySelector); - name ??= GenerateIndexName(propertyPaths); - - lock (_lock) - { - if (_indexes.TryGetValue(name, out var existing)) - return existing; - - return CreateIndex(keySelector, name, unique); - } - } - - [Obsolete("Use model HasVectorIndex/HasSpatialIndex or CreateIndex(definition); will be removed in 6.0")] - [RequiresDynamicCode("Index creation compiles key selector expressions using Expression.Compile() which requires dynamic code generation.")] - public CollectionSecondaryIndex CreateVectorIndexUntyped( - LambdaExpression keySelector, - int dimensions, - VectorMetric metric = VectorMetric.Cosine, - string? name = null) - { - var propertyPaths = ExpressionAnalyzer.ExtractPropertyPaths(keySelector); - var indexName = name ?? GenerateIndexName(propertyPaths); - - lock (_lock) - { - if (_indexes.TryGetValue(indexName, out var existing)) - return existing; - - var body = keySelector.Body; - if (body.Type != typeof(object)) - { - body = Expression.Convert(body, typeof(object)); - } - - var lambda = Expression.Lambda>(body, keySelector.Parameters); - - var definition = new CollectionIndexDefinition(indexName, propertyPaths, lambda, false, IndexType.Vector, false, dimensions, metric); - return CreateIndex(definition); - } - } - - [Obsolete("Use model HasVectorIndex/HasSpatialIndex or CreateIndex(definition); will be removed in 6.0")] - [RequiresDynamicCode("Index creation compiles key selector expressions using Expression.Compile() which requires dynamic code generation.")] - public CollectionSecondaryIndex CreateSpatialIndexUntyped( - LambdaExpression keySelector, - string? name = null) - { - var propertyPaths = ExpressionAnalyzer.ExtractPropertyPaths(keySelector); - var indexName = name ?? GenerateIndexName(propertyPaths); - - lock (_lock) - { - if (_indexes.TryGetValue(indexName, out var existing)) - return existing; - - var body = keySelector.Body; - if (body.Type != typeof(object)) - { - body = Expression.Convert(body, typeof(object)); - } - - var lambda = Expression.Lambda>(body, keySelector.Parameters); - - var definition = new CollectionIndexDefinition(indexName, propertyPaths, lambda, false, IndexType.Spatial); - return CreateIndex(definition); - } - } - - /// - /// Drops an existing index by name - /// - /// Index name - /// True if index was found and dropped, false otherwise - public bool DropIndex(string name) - { - if (string.IsNullOrWhiteSpace(name)) - throw new ArgumentException("Index name cannot be empty", nameof(name)); - - lock (_lock) - { - if (_indexes.TryGetValue(name, out var index)) - { - // Free all pages owned by this index before removing it - index.FreeAllPages(_storage); - index.Dispose(); - _indexes.Remove(name); - _cachedIndexInfo = null; // invalidate cache - RefreshHasUniqueBTreeIndex(); - - SaveMetadata(); // Save metadata after dropping index - return true; - } - - return false; - } - } - - /// - /// Registers under , persists its root page - /// and frees the pages of the index it replaces. If the metadata cannot be saved, the old - /// index stays registered and remains the caller's to release. - /// - internal void ReplaceIndex(string name, CollectionSecondaryIndex fresh) - { - lock (_lock) - { - if (_disposed) - throw new ObjectDisposedException(nameof(CollectionIndexManager)); - if (!_indexes.TryGetValue(name, out var old)) - throw new InvalidOperationException($"Index '{name}' does not exist"); - - // Persist the new root before freeing the old tree: a crash in between leaks the old - // pages, whereas the reverse order would let the next open free them a second time. - PublishLocked(name, fresh, old); - old.FreeAllPages(_storage); - old.Dispose(); - } - } - - /// - /// Registers under in place of - /// (null: none) and saves the metadata. If the save fails, the - /// previous registration is restored, so memory keeps matching the metadata on disk, and the - /// exception propagates. Must be called under . - /// - private void PublishLocked(string name, CollectionSecondaryIndex index, CollectionSecondaryIndex? previous) - { - _indexes[name] = index; - _cachedIndexInfo = null; // invalidate cache - RefreshHasUniqueBTreeIndex(); - - try - { - SaveMetadata(); - } - catch - { - if (previous != null) - _indexes[name] = previous; - else - _indexes.Remove(name); - _cachedIndexInfo = null; - RefreshHasUniqueBTreeIndex(); - UpdateMetadata(); - throw; - } - } - - /// Records the key format an index's pages are written with and persists it. - internal void SetKeyFormat(string name, byte keyFormat) - { - lock (_lock) - { - if (!_indexes.TryGetValue(name, out var index)) - throw new InvalidOperationException($"Index '{name}' does not exist"); - - index.KeyFormat = keyFormat; - SaveMetadata(); - } - } - - /// - /// Gets an index by name - /// - public CollectionSecondaryIndex? GetIndex(string name) - { - lock (_lock) - { - return _indexes.TryGetValue(name, out var index) ? index : null; - } - } - - /// - /// Gets all indexes - /// - public IEnumerable> GetAllIndexes() - { - lock (_lock) - { - return _indexes.Values.ToList(); // Return copy to avoid lock issues - } - } - - /// - /// Gets information about all indexes. Result is cached and rebuilt only when indexes change. - /// - public IReadOnlyList GetIndexInfo() - { - var cached = _cachedIndexInfo; - if (cached != null) return cached; - return BuildIndexInfoCache(); - } - - private IReadOnlyList BuildIndexInfoCache() - { - lock (_lock) - { - // Re-check under lock in case another thread already built the cache. - var cached = _cachedIndexInfo; - if (cached != null) return cached; - - var result = new CollectionIndexInfo[_indexes.Count]; - int i = 0; - foreach (var idx in _indexes.Values) - result[i++] = idx.GetInfo(); - _cachedIndexInfo = result; - return result; - } - } - - /// - /// Finds the best index to use for a query on the specified property. - /// Returns null if no suitable index found (requires full scan). - /// - /// Property path being queried - /// Best index for the query, or null if none suitable - public CollectionSecondaryIndex? FindBestIndex(string propertyPath) - { - if (string.IsNullOrWhiteSpace(propertyPath)) - return null; - - lock (_lock) - { - // Find all indexes that can support this query - var candidates = _indexes.Values - .Where(idx => idx.Definition.CanSupportQuery(propertyPath)) - .ToList(); - - if (candidates.Count == 0) - return null; - - // Simple strategy: prefer unique indexes, then shortest property path - return candidates - .OrderByDescending(idx => idx.Definition.IsUnique) - .ThenBy(idx => idx.Definition.PropertyPaths.Length) - .First(); - } - } - - /// - /// Finds the best index for a compound query on multiple properties - /// - public CollectionSecondaryIndex? FindBestCompoundIndex(string[] propertyPaths) - { - if (propertyPaths == null || propertyPaths.Length == 0) - return null; - - lock (_lock) - { - var candidates = _indexes.Values - .Where(idx => idx.Definition.CanSupportCompoundQuery(propertyPaths)) - .ToList(); - - if (candidates.Count == 0) - return null; - - // Prefer longest matching index (more selective) - return candidates - .OrderByDescending(idx => idx.Definition.PropertyPaths.Length) - .ThenByDescending(idx => idx.Definition.IsUnique) - .First(); - } - } - - /// - /// Inserts a document into all indexes - /// - /// Document to insert - /// Physical location of the document - /// Transaction context - public void InsertIntoAll(T document, DocumentLocation location, ITransaction transaction) - { - if (document == null) - throw new ArgumentNullException(nameof(document)); - - lock (_lock) - { - foreach (var index in _indexes.Values) - { - index.Insert(document, location, transaction); - } - } - } - - /// - /// Throws if would violate any unique index. - /// Call before any page is written, so a rejected write leaves nothing behind. - /// On update, pass the stored version as : only indexes whose - /// key changed are checked, so documents that already share a value can still be updated. - /// - /// Document about to be inserted or written by an update - /// Stored version of the document on update; null on insert - /// Transaction context - public void EnsureUniqueInAll(T document, T? oldDocument, ITransaction transaction) - { - if (document == null) - throw new ArgumentNullException(nameof(document)); - - if (!_hasUniqueBTreeIndex) - return; - - lock (_lock) - { - foreach (var index in _indexes.Values) - { - index.EnsureUnique(document, oldDocument, transaction); - } - } - } - - /// - /// Throws if 's key could not - /// be written to one of the indexes (vector dimension mismatch, key too large). - /// Call before any page is written, together with . - /// - public void ValidateKeysInAll(T document) - { - if (document == null) - throw new ArgumentNullException(nameof(document)); - - lock (_lock) - { - foreach (var index in _indexes.Values) - { - index.ValidateKey(document); - } - } - } - - /// - /// Updates a document in all indexes - /// - /// Old version of document - /// New version of document - /// Physical location of old document - /// Physical location of new document - /// Transaction context - public void UpdateInAll(T oldDocument, T newDocument, DocumentLocation oldLocation, DocumentLocation newLocation, ITransaction transaction) - { - if (oldDocument == null) - throw new ArgumentNullException(nameof(oldDocument)); - if (newDocument == null) - throw new ArgumentNullException(nameof(newDocument)); - - lock (_lock) - { - foreach (var index in _indexes.Values) - { - index.Update(oldDocument, newDocument, oldLocation, newLocation, transaction); - } - } - } - - /// - /// Deletes a document from all indexes - /// - /// Document to delete - /// Physical location of the document - /// Transaction context - public void DeleteFromAll(T document, DocumentLocation location, ITransaction transaction) - { - if (document == null) - throw new ArgumentNullException(nameof(document)); - - lock (_lock) - { - foreach (var index in _indexes.Values) - { - index.Delete(document, location, transaction); - } - } - } - - /// - /// Generates an index name from property paths - /// - private static string GenerateIndexName(string[] propertyPaths) - { - return $"idx_{string.Join("_", propertyPaths)}"; - } - - [RequiresDynamicCode("Index creation compiles key selector expressions using Expression.Compile() which requires dynamic code generation.")] - [RequiresUnreferencedCode("Index creation uses Expression.PropertyOrField which requires type metadata to be preserved.")] - private CollectionIndexDefinition RebuildDefinition(string name, string[] paths, bool isUnique, IndexType type, int dimensions = 0, VectorMetric metric = VectorMetric.Cosine) - { - var param = Expression.Parameter(typeof(T), "u"); - Expression body; - - if (paths.Length == 1) - { - // Handle nested paths like "MainAddress.City.Name" - body = BuildNestedPropertyAccess(param, paths[0]); - } - else - { - // Compound key with multiple paths - body = Expression.NewArrayInit(typeof(object), - paths.Select(p => Expression.Convert(BuildNestedPropertyAccess(param, p), typeof(object)))); - } - - var objectBody = Expression.Convert(body, typeof(object)); - var lambda = Expression.Lambda>(objectBody, param); - - return new CollectionIndexDefinition(name, paths, lambda, isUnique, type, false, dimensions, metric); - } - - /// - /// Builds nested property access expression from dot-notation path. - /// Example: "MainAddress.City.Name" -> param.MainAddress.City.Name - /// - [RequiresUnreferencedCode("Builds nested property access using Expression.PropertyOrField which requires type member metadata to be preserved.")] - private static Expression BuildNestedPropertyAccess(Expression param, string path) - { - var parts = path.Split('.'); - Expression current = param; - - foreach (var part in parts) - { - current = Expression.PropertyOrField(current, part); - } - - return current; - } - - public uint PrimaryRootPageId => _metadata.PrimaryRootPageId; - - public void SetPrimaryRootPageId(uint pageId) - { - lock (_lock) - { - if (_metadata.PrimaryRootPageId != pageId) - { - _metadata.PrimaryRootPageId = pageId; - RefreshSequenceValue(); - _storage.SaveCollectionMetadata(_metadata); - } - } - } - - public CollectionMetadata GetMetadata() => _metadata; - - private void SaveMetadata() - { - UpdateMetadata(); - RefreshSequenceValue(); - _storage.SaveCollectionMetadata(_metadata); - } - - /// - /// Reads the current SequenceValue from the page file so that this in-memory - /// _metadata snapshot does not overwrite the auto-increment counter - /// that maintains independently. - /// - private void RefreshSequenceValue() - { - var current = _storage.GetCollectionMetadata(_collectionName); - if (current != null) - _metadata.SequenceValue = current.SequenceValue; - } - - public void Dispose() - { - if (_disposed) - return; - - // No auto-save on dispose to avoid unnecessary I/O if no changes - - lock (_lock) - { - foreach (var index in _indexes.Values) - { - try { index.Dispose(); } catch { /* Best effort */ } - } - - _indexes.Clear(); - _hasUniqueBTreeIndex = false; - _disposed = true; - } - - GC.SuppressFinalize(this); - } -} - -/// -/// Helper class to analyze LINQ expressions and extract property paths -/// -public static class ExpressionAnalyzer -{ - /// - /// Extracts property paths from a lambda expression. - /// Supports simple property access (p => p.Age), nested properties (p => p.Address.City.Name), - /// and anonymous types (p => new { p.City, p.Age }). - /// Returns full dot-notation paths for nested properties (e.g., "Address.City.Name"). - /// - public static string[] ExtractPropertyPaths(LambdaExpression expression) - { - if (expression.Body is MemberExpression memberExpr) - { - // Simple or nested property: p => p.Age or p => p.Address.City.Name - return new[] { ExtractFullPath(memberExpr) }; - } - else if (expression.Body is NewExpression newExpr) - { - // Compound key via anonymous type: p => new { p.City, p.Address.Country } - return newExpr.Arguments - .OfType() - .Select(ExtractFullPath) - .ToArray(); - } - else if (expression.Body is UnaryExpression { NodeType: ExpressionType.Convert } unaryExpr) - { - // Handle Convert(Member) or Convert(New) - if (unaryExpr.Operand is MemberExpression innerMember) - { - // Wrapped property: p => (object)p.Age - return new[] { ExtractFullPath(innerMember) }; - } - else if (unaryExpr.Operand is NewExpression innerNew) - { - // Wrapped anonymous type: p => (object)new { p.City, p.Age } - return innerNew.Arguments - .OfType() - .Select(ExtractFullPath) - .ToArray(); - } - } - - throw new ArgumentException( - "Expression must be a property accessor (p => p.Property) or anonymous type (p => new { p.Prop1, p.Prop2 })", - nameof(expression)); - } - - /// - /// Extracts full dot-notation path from a MemberExpression chain. - /// Example: p.Address.City.Name -> "Address.City.Name" - /// - private static string ExtractFullPath(MemberExpression memberExpr) - { - var parts = new List(); - Expression? current = memberExpr; - - while (current is MemberExpression member) - { - parts.Insert(0, member.Member.Name); - current = member.Expression; - } - - // Current should now be the parameter (e.g., 'p' in p => p.Name) - // If we hit something else (e.g., a method call), that's an error - if (current is not ParameterExpression) - { - throw new ArgumentException( - $"Property path must start from the parameter, got {current?.GetType().Name}", - nameof(memberExpr)); - } - - return string.Join(".", parts); - } -} +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using BLite.Bson; +using BLite.Core.Collections; +using BLite.Core.Storage; +using BLite.Core.Transactions; + +namespace BLite.Core.Indexing; + +/// +/// Manages a collection of secondary indexes on a document collection. +/// Handles index creation, deletion, automatic selection, and maintenance. +/// +/// Primary key type +/// Document type +public sealed class CollectionIndexManager : IDisposable where T : class +{ + private readonly Dictionary> _indexes; + private readonly StorageEngine _storage; + private readonly IDocumentMapper _mapper; + private readonly object _lock = new(); + private bool _disposed; + private readonly string _collectionName; + private CollectionMetadata _metadata; + private volatile IReadOnlyList? _cachedIndexInfo; + // Whether any index is a unique BTree index, so EnsureUniqueInAll can skip the loop. + // Refreshed under _lock whenever _indexes changes. + private volatile bool _hasUniqueBTreeIndex; + + [RequiresDynamicCode("Index creation compiles key selector expressions using Expression.Compile() which requires dynamic code generation.")] + [RequiresUnreferencedCode("Index creation uses reflection (Expression.PropertyOrField) to access type members. Ensure all entity types and their members are preserved.")] + public CollectionIndexManager(StorageEngine storage, IDocumentMapper mapper, string? collectionName = null) + { + _storage = storage ?? throw new ArgumentNullException(nameof(storage)); + _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper)); + _collectionName = collectionName ?? _mapper.CollectionName; + _indexes = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + // Load existing metadata via storage + _metadata = _storage.GetCollectionMetadata(_collectionName) ?? new CollectionMetadata { Name = _collectionName }; + + // Refuse a collection that requires features this version cannot maintain, before anything + // below writes to it. + CollectionFeatures.EnsureCanOpen(_metadata, _storage.IsReadOnly); + + // Initialize indexes from metadata. Root page ids are fixed for the lifetime of an index + // (a root split rewrites the root page in place), so they only need persisting when the + // constructor allocated one (an index persisted before its root was allocated eagerly). + bool rootAllocated = false; + foreach (var idxMeta in _metadata.Indexes) + { + var indexName = idxMeta.Name; + var definition = RebuildDefinition(idxMeta.Name, idxMeta.PropertyPaths, idxMeta.IsUnique, idxMeta.Type, idxMeta.Dimensions, idxMeta.Metric); + var index = new CollectionSecondaryIndex(definition, _storage, _mapper, idxMeta.RootPageId) + { + KeyFormat = idxMeta.KeyFormat + }; + _indexes[indexName] = index; + rootAllocated |= index.RootPageId != idxMeta.RootPageId; + } + + RefreshHasUniqueBTreeIndex(); + + // Re-record the features the indexes require if a release that predates them rewrote the + // record and dropped them (a read-only open cannot, and the indexes still protect it). + if (rootAllocated || (!_storage.IsReadOnly && CollectionFeatures.SyncIndexFeatures(_metadata))) + SaveMetadata(); + } + + private void RefreshHasUniqueBTreeIndex() + { + var any = false; + foreach (var index in _indexes.Values) + any |= index.IsUniqueBTree; + _hasUniqueBTreeIndex = any; + } + + private void UpdateMetadata() + { + _metadata.Indexes.Clear(); + foreach (var index in _indexes.Values) + { + var info = index.GetInfo(); + _metadata.Indexes.Add(new IndexMetadata + { + Name = info.Name, + IsUnique = info.IsUnique, + Type = info.Type, + PropertyPaths = info.PropertyPaths, + Dimensions = index.Definition.Dimensions, + Metric = index.Definition.Metric, + RootPageId = index.RootPageId, + KeyFormat = index.KeyFormat + }); + } + CollectionFeatures.SyncIndexFeatures(_metadata); + } + + /// + /// Creates a new secondary index + /// + /// Index definition + /// The created secondary index + public CollectionSecondaryIndex CreateIndex(CollectionIndexDefinition definition) + { + if (definition == null) + throw new ArgumentNullException(nameof(definition)); + + lock (_lock) + { + if (_disposed) + throw new ObjectDisposedException(nameof(CollectionIndexManager)); + + // Check if index with this name already exists + if (_indexes.ContainsKey(definition.Name)) + throw new InvalidOperationException($"Index '{definition.Name}' already exists"); + + // Create secondary index + var secondaryIndex = new CollectionSecondaryIndex(definition, _storage, _mapper); + _indexes[definition.Name] = secondaryIndex; + _cachedIndexInfo = null; // invalidate cache + RefreshHasUniqueBTreeIndex(); + + // Persist metadata + UpdateMetadata(); + _storage.SaveCollectionMetadata(_metadata); + + return secondaryIndex; + } + } + + /// + /// The name an index on gets when none is given. Full-text + /// indexes get their own prefix, so they can sit next to a B-Tree index on the same property. + /// + internal static string ResolveIndexName(LambdaExpression keySelector, string? name, IndexType type = IndexType.BTree) + { + if (name != null) return name; + var propertyPaths = ExpressionAnalyzer.ExtractPropertyPaths(keySelector); + return type == IndexType.FullText + ? $"idx_fts_{string.Join("_", propertyPaths)}" + : GenerateIndexName(propertyPaths); + } + + /// + /// Builds an index definition from an untyped key selector, as declared in the model. + /// + [RequiresDynamicCode("Index creation compiles key selector expressions using Expression.Compile() which requires dynamic code generation.")] + internal static CollectionIndexDefinition BuildDefinition( + LambdaExpression keySelector, + string? name, + bool unique, + IndexType type, + int dimensions, + VectorMetric metric) + { + var propertyPaths = ExpressionAnalyzer.ExtractPropertyPaths(keySelector); + var body = keySelector.Body.Type == typeof(object) + ? keySelector.Body + : Expression.Convert(keySelector.Body, typeof(object)); + var lambda = Expression.Lambda>(body, keySelector.Parameters); + + return new CollectionIndexDefinition( + name ?? GenerateIndexName(propertyPaths), propertyPaths, lambda, unique, type, false, dimensions, metric); + } + + /// + /// Creates an index for without registering or persisting it, + /// so it can be filled before queries or the collection metadata see it. The index owns its + /// root page: hand it to or , or release its + /// pages. + /// + internal CollectionSecondaryIndex CreateUnregistered(CollectionIndexDefinition definition) + { + lock (_lock) + { + if (_disposed) + throw new ObjectDisposedException(nameof(CollectionIndexManager)); + + return new CollectionSecondaryIndex(definition, _storage, _mapper); + } + } + + /// + /// Registers an index built by and persists it. If the + /// metadata cannot be saved, the index is not registered and remains the caller's to release. + /// + internal void Register(CollectionSecondaryIndex index) + { + lock (_lock) + { + if (_disposed) + throw new ObjectDisposedException(nameof(CollectionIndexManager)); + if (_indexes.ContainsKey(index.Name)) + throw new InvalidOperationException($"Index '{index.Name}' already exists"); + + PublishLocked(index.Name, index, previous: null); + } + } + + // ... methods ... + + /// + /// Creates a simple index on a single property + /// + /// Key type + /// Expression to extract key from document + /// Optional index name (auto-generated if null) + /// Enforce uniqueness constraint + /// The created secondary index + [RequiresDynamicCode("Index creation compiles key selector expressions using Expression.Compile() which requires dynamic code generation.")] + public CollectionSecondaryIndex CreateIndex( + Expression> keySelector, + string? name = null, + bool unique = false) + { + if (keySelector == null) + throw new ArgumentNullException(nameof(keySelector)); + + // Extract property paths from expression + var propertyPaths = ExpressionAnalyzer.ExtractPropertyPaths(keySelector); + + // Generate name if not provided + name ??= GenerateIndexName(propertyPaths); + + // Convert expression to object-returning expression (required for definition) + var objectSelector = Expression.Lambda>( + Expression.Convert(keySelector.Body, typeof(object)), + keySelector.Parameters); + + // Create definition + var definition = new CollectionIndexDefinition( + name, + propertyPaths, + objectSelector, + unique); + + return CreateIndex(definition); + } + + [RequiresDynamicCode("Index creation compiles key selector expressions using Expression.Compile() which requires dynamic code generation.")] + public CollectionSecondaryIndex CreateVectorIndex(Expression> keySelector, int dimensions, VectorMetric metric = VectorMetric.Cosine, string? name = null) + { + var propertyPaths = ExpressionAnalyzer.ExtractPropertyPaths(keySelector); + var indexName = name ?? GenerateIndexName(propertyPaths); + + lock (_lock) + { + if (_indexes.TryGetValue(indexName, out var existing)) + return existing; + + var param = Expression.Parameter(typeof(T), "u"); + var body = Expression.Convert(keySelector.Body, typeof(object)); + var lambda = Expression.Lambda>(body, param); + + var definition = new CollectionIndexDefinition(indexName, propertyPaths, lambda, false, IndexType.Vector, false, dimensions, metric); + return CreateIndex(definition); + } + } + + [RequiresDynamicCode("Index creation compiles key selector expressions using Expression.Compile() which requires dynamic code generation.")] + public CollectionSecondaryIndex EnsureIndex( + Expression> keySelector, + string? name = null, + bool unique = false) + { + var propertyPaths = ExpressionAnalyzer.ExtractPropertyPaths(keySelector); + name ??= GenerateIndexName(propertyPaths); + + lock (_lock) + { + if (_indexes.TryGetValue(name, out var existing)) + return existing; + + return CreateIndex(keySelector, name, unique); + } + } + + [Obsolete("Use model HasVectorIndex/HasSpatialIndex or CreateIndex(definition); will be removed in 6.0")] + [RequiresDynamicCode("Index creation compiles key selector expressions using Expression.Compile() which requires dynamic code generation.")] + public CollectionSecondaryIndex CreateVectorIndexUntyped( + LambdaExpression keySelector, + int dimensions, + VectorMetric metric = VectorMetric.Cosine, + string? name = null) + { + var propertyPaths = ExpressionAnalyzer.ExtractPropertyPaths(keySelector); + var indexName = name ?? GenerateIndexName(propertyPaths); + + lock (_lock) + { + if (_indexes.TryGetValue(indexName, out var existing)) + return existing; + + var body = keySelector.Body; + if (body.Type != typeof(object)) + { + body = Expression.Convert(body, typeof(object)); + } + + var lambda = Expression.Lambda>(body, keySelector.Parameters); + + var definition = new CollectionIndexDefinition(indexName, propertyPaths, lambda, false, IndexType.Vector, false, dimensions, metric); + return CreateIndex(definition); + } + } + + [Obsolete("Use model HasVectorIndex/HasSpatialIndex or CreateIndex(definition); will be removed in 6.0")] + [RequiresDynamicCode("Index creation compiles key selector expressions using Expression.Compile() which requires dynamic code generation.")] + public CollectionSecondaryIndex CreateSpatialIndexUntyped( + LambdaExpression keySelector, + string? name = null) + { + var propertyPaths = ExpressionAnalyzer.ExtractPropertyPaths(keySelector); + var indexName = name ?? GenerateIndexName(propertyPaths); + + lock (_lock) + { + if (_indexes.TryGetValue(indexName, out var existing)) + return existing; + + var body = keySelector.Body; + if (body.Type != typeof(object)) + { + body = Expression.Convert(body, typeof(object)); + } + + var lambda = Expression.Lambda>(body, keySelector.Parameters); + + var definition = new CollectionIndexDefinition(indexName, propertyPaths, lambda, false, IndexType.Spatial); + return CreateIndex(definition); + } + } + + /// + /// Drops an existing index by name + /// + /// Index name + /// True if index was found and dropped, false otherwise + public bool DropIndex(string name) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Index name cannot be empty", nameof(name)); + + lock (_lock) + { + if (_indexes.TryGetValue(name, out var index)) + { + // Free all pages owned by this index before removing it + index.FreeAllPages(_storage); + index.Dispose(); + _indexes.Remove(name); + _cachedIndexInfo = null; // invalidate cache + RefreshHasUniqueBTreeIndex(); + + SaveMetadata(); // Save metadata after dropping index + return true; + } + + return false; + } + } + + /// + /// Registers under , persists its root page + /// and frees the pages of the index it replaces. If the metadata cannot be saved, the old + /// index stays registered and remains the caller's to release. + /// + internal void ReplaceIndex(string name, CollectionSecondaryIndex fresh) + { + lock (_lock) + { + if (_disposed) + throw new ObjectDisposedException(nameof(CollectionIndexManager)); + if (!_indexes.TryGetValue(name, out var old)) + throw new InvalidOperationException($"Index '{name}' does not exist"); + + // Persist the new root before freeing the old tree: a crash in between leaks the old + // pages, whereas the reverse order would let the next open free them a second time. + PublishLocked(name, fresh, old); + old.FreeAllPages(_storage); + old.Dispose(); + } + } + + /// + /// Registers under in place of + /// (null: none) and saves the metadata. If the save fails, the + /// previous registration is restored, so memory keeps matching the metadata on disk, and the + /// exception propagates. Must be called under . + /// + private void PublishLocked(string name, CollectionSecondaryIndex index, CollectionSecondaryIndex? previous) + { + _indexes[name] = index; + _cachedIndexInfo = null; // invalidate cache + RefreshHasUniqueBTreeIndex(); + + try + { + SaveMetadata(); + } + catch + { + if (previous != null) + _indexes[name] = previous; + else + _indexes.Remove(name); + _cachedIndexInfo = null; + RefreshHasUniqueBTreeIndex(); + UpdateMetadata(); + throw; + } + } + + /// Records the key format an index's pages are written with and persists it. + internal void SetKeyFormat(string name, byte keyFormat) + { + lock (_lock) + { + if (!_indexes.TryGetValue(name, out var index)) + throw new InvalidOperationException($"Index '{name}' does not exist"); + + index.KeyFormat = keyFormat; + SaveMetadata(); + } + } + + /// + /// Gets an index by name + /// + public CollectionSecondaryIndex? GetIndex(string name) + { + lock (_lock) + { + return _indexes.TryGetValue(name, out var index) ? index : null; + } + } + + /// + /// Gets all indexes + /// + public IEnumerable> GetAllIndexes() + { + lock (_lock) + { + return _indexes.Values.ToList(); // Return copy to avoid lock issues + } + } + + /// + /// Gets information about all indexes. Result is cached and rebuilt only when indexes change. + /// + public IReadOnlyList GetIndexInfo() + { + var cached = _cachedIndexInfo; + if (cached != null) return cached; + return BuildIndexInfoCache(); + } + + private IReadOnlyList BuildIndexInfoCache() + { + lock (_lock) + { + // Re-check under lock in case another thread already built the cache. + var cached = _cachedIndexInfo; + if (cached != null) return cached; + + var result = new CollectionIndexInfo[_indexes.Count]; + int i = 0; + foreach (var idx in _indexes.Values) + result[i++] = idx.GetInfo(); + _cachedIndexInfo = result; + return result; + } + } + + /// + /// Finds the best index to use for a query on the specified property. + /// Returns null if no suitable index found (requires full scan). + /// + /// Property path being queried + /// Best index for the query, or null if none suitable + public CollectionSecondaryIndex? FindBestIndex(string propertyPath) + { + if (string.IsNullOrWhiteSpace(propertyPath)) + return null; + + lock (_lock) + { + // Find all indexes that can support this query + var candidates = _indexes.Values + .Where(idx => idx.Definition.CanSupportQuery(propertyPath)) + .ToList(); + + if (candidates.Count == 0) + return null; + + // Simple strategy: prefer unique indexes, then shortest property path + return candidates + .OrderByDescending(idx => idx.Definition.IsUnique) + .ThenBy(idx => idx.Definition.PropertyPaths.Length) + .First(); + } + } + + /// + /// Finds the best index for a compound query on multiple properties + /// + public CollectionSecondaryIndex? FindBestCompoundIndex(string[] propertyPaths) + { + if (propertyPaths == null || propertyPaths.Length == 0) + return null; + + lock (_lock) + { + var candidates = _indexes.Values + .Where(idx => idx.Definition.CanSupportCompoundQuery(propertyPaths)) + .ToList(); + + if (candidates.Count == 0) + return null; + + // Prefer longest matching index (more selective) + return candidates + .OrderByDescending(idx => idx.Definition.PropertyPaths.Length) + .ThenByDescending(idx => idx.Definition.IsUnique) + .First(); + } + } + + /// + /// Inserts a document into all indexes + /// + /// Document to insert + /// Physical location of the document + /// Transaction context + public void InsertIntoAll(T document, DocumentLocation location, ITransaction transaction) + { + if (document == null) + throw new ArgumentNullException(nameof(document)); + + lock (_lock) + { + foreach (var index in _indexes.Values) + { + index.Insert(document, location, transaction); + } + } + } + + /// + /// Throws if would violate any unique index. + /// Call before any page is written, so a rejected write leaves nothing behind. + /// On update, pass the stored version as : only indexes whose + /// key changed are checked, so documents that already share a value can still be updated. + /// + /// Document about to be inserted or written by an update + /// Stored version of the document on update; null on insert + /// Transaction context + public void EnsureUniqueInAll(T document, T? oldDocument, ITransaction transaction) + { + if (document == null) + throw new ArgumentNullException(nameof(document)); + + if (!_hasUniqueBTreeIndex) + return; + + lock (_lock) + { + foreach (var index in _indexes.Values) + { + index.EnsureUnique(document, oldDocument, transaction); + } + } + } + + /// + /// Throws if 's key could not + /// be written to one of the indexes (vector dimension mismatch, key too large). + /// Call before any page is written, together with . + /// + public void ValidateKeysInAll(T document) + { + if (document == null) + throw new ArgumentNullException(nameof(document)); + + lock (_lock) + { + foreach (var index in _indexes.Values) + { + index.ValidateKey(document); + } + } + } + + /// + /// Updates a document in all indexes + /// + /// Old version of document + /// New version of document + /// Physical location of old document + /// Physical location of new document + /// Transaction context + public void UpdateInAll(T oldDocument, T newDocument, DocumentLocation oldLocation, DocumentLocation newLocation, ITransaction transaction) + { + if (oldDocument == null) + throw new ArgumentNullException(nameof(oldDocument)); + if (newDocument == null) + throw new ArgumentNullException(nameof(newDocument)); + + lock (_lock) + { + foreach (var index in _indexes.Values) + { + index.Update(oldDocument, newDocument, oldLocation, newLocation, transaction); + } + } + } + + /// + /// Deletes a document from all indexes + /// + /// Document to delete + /// Physical location of the document + /// Transaction context + public void DeleteFromAll(T document, DocumentLocation location, ITransaction transaction) + { + if (document == null) + throw new ArgumentNullException(nameof(document)); + + lock (_lock) + { + foreach (var index in _indexes.Values) + { + index.Delete(document, location, transaction); + } + } + } + + /// + /// Generates an index name from property paths + /// + private static string GenerateIndexName(string[] propertyPaths) + { + return $"idx_{string.Join("_", propertyPaths)}"; + } + + [RequiresDynamicCode("Index creation compiles key selector expressions using Expression.Compile() which requires dynamic code generation.")] + [RequiresUnreferencedCode("Index creation uses Expression.PropertyOrField which requires type metadata to be preserved.")] + private CollectionIndexDefinition RebuildDefinition(string name, string[] paths, bool isUnique, IndexType type, int dimensions = 0, VectorMetric metric = VectorMetric.Cosine) + { + var param = Expression.Parameter(typeof(T), "u"); + Expression body; + + if (paths.Length == 1) + { + // Handle nested paths like "MainAddress.City.Name" + body = BuildNestedPropertyAccess(param, paths[0]); + } + else + { + // Compound key with multiple paths + body = Expression.NewArrayInit(typeof(object), + paths.Select(p => Expression.Convert(BuildNestedPropertyAccess(param, p), typeof(object)))); + } + + var objectBody = Expression.Convert(body, typeof(object)); + var lambda = Expression.Lambda>(objectBody, param); + + return new CollectionIndexDefinition(name, paths, lambda, isUnique, type, false, dimensions, metric); + } + + /// + /// Builds nested property access expression from dot-notation path. + /// Example: "MainAddress.City.Name" -> param.MainAddress.City.Name + /// + [RequiresUnreferencedCode("Builds nested property access using Expression.PropertyOrField which requires type member metadata to be preserved.")] + private static Expression BuildNestedPropertyAccess(Expression param, string path) + { + var parts = path.Split('.'); + Expression current = param; + + foreach (var part in parts) + { + current = Expression.PropertyOrField(current, part); + } + + return current; + } + + public uint PrimaryRootPageId => _metadata.PrimaryRootPageId; + + public void SetPrimaryRootPageId(uint pageId) + { + lock (_lock) + { + if (_metadata.PrimaryRootPageId != pageId) + { + _metadata.PrimaryRootPageId = pageId; + RefreshSequenceValue(); + _storage.SaveCollectionMetadata(_metadata); + } + } + } + + public CollectionMetadata GetMetadata() => _metadata; + + private void SaveMetadata() + { + UpdateMetadata(); + RefreshSequenceValue(); + _storage.SaveCollectionMetadata(_metadata); + } + + /// + /// Reads the current SequenceValue from the page file so that this in-memory + /// _metadata snapshot does not overwrite the auto-increment counter + /// that maintains independently. + /// + private void RefreshSequenceValue() + { + var current = _storage.GetCollectionMetadata(_collectionName); + if (current != null) + _metadata.SequenceValue = current.SequenceValue; + } + + public void Dispose() + { + if (_disposed) + return; + + // No auto-save on dispose to avoid unnecessary I/O if no changes + + lock (_lock) + { + foreach (var index in _indexes.Values) + { + try { index.Dispose(); } catch { /* Best effort */ } + } + + _indexes.Clear(); + _hasUniqueBTreeIndex = false; + _disposed = true; + } + + GC.SuppressFinalize(this); + } +} + +/// +/// Helper class to analyze LINQ expressions and extract property paths +/// +public static class ExpressionAnalyzer +{ + /// + /// Extracts property paths from a lambda expression. + /// Supports simple property access (p => p.Age), nested properties (p => p.Address.City.Name), + /// and anonymous types (p => new { p.City, p.Age }). + /// Returns full dot-notation paths for nested properties (e.g., "Address.City.Name"). + /// + public static string[] ExtractPropertyPaths(LambdaExpression expression) + { + if (expression.Body is MemberExpression memberExpr) + { + // Simple or nested property: p => p.Age or p => p.Address.City.Name + return new[] { ExtractFullPath(memberExpr) }; + } + else if (expression.Body is NewExpression newExpr) + { + // Compound key via anonymous type: p => new { p.City, p.Address.Country } + return newExpr.Arguments + .OfType() + .Select(ExtractFullPath) + .ToArray(); + } + else if (expression.Body is UnaryExpression { NodeType: ExpressionType.Convert } unaryExpr) + { + // Handle Convert(Member) or Convert(New) + if (unaryExpr.Operand is MemberExpression innerMember) + { + // Wrapped property: p => (object)p.Age + return new[] { ExtractFullPath(innerMember) }; + } + else if (unaryExpr.Operand is NewExpression innerNew) + { + // Wrapped anonymous type: p => (object)new { p.City, p.Age } + return innerNew.Arguments + .OfType() + .Select(ExtractFullPath) + .ToArray(); + } + } + + throw new ArgumentException( + "Expression must be a property accessor (p => p.Property) or anonymous type (p => new { p.Prop1, p.Prop2 })", + nameof(expression)); + } + + /// + /// Extracts full dot-notation path from a MemberExpression chain. + /// Example: p.Address.City.Name -> "Address.City.Name" + /// + private static string ExtractFullPath(MemberExpression memberExpr) + { + var parts = new List(); + Expression? current = memberExpr; + + while (current is MemberExpression member) + { + parts.Insert(0, member.Member.Name); + current = member.Expression; + } + + // Current should now be the parameter (e.g., 'p' in p => p.Name) + // If we hit something else (e.g., a method call), that's an error + if (current is not ParameterExpression) + { + throw new ArgumentException( + $"Property path must start from the parameter, got {current?.GetType().Name}", + nameof(memberExpr)); + } + + return string.Join(".", parts); + } +} diff --git a/src/BLite.Core/Indexing/CollectionSecondaryIndex.cs b/src/BLite.Core/Indexing/CollectionSecondaryIndex.cs index 09bf738..2e993b2 100644 --- a/src/BLite.Core/Indexing/CollectionSecondaryIndex.cs +++ b/src/BLite.Core/Indexing/CollectionSecondaryIndex.cs @@ -47,6 +47,7 @@ static CollectionSecondaryIndex() private readonly BTreeIndex? _btreeIndex; private readonly VectorSearchIndex? _vectorIndex; private readonly RTreeIndex? _spatialIndex; + private readonly FullTextIndex? _fullTextIndex; private readonly IDocumentMapper _mapper; private bool _disposed; private long _documentCount; @@ -61,7 +62,7 @@ static CollectionSecondaryIndex() /// public BTreeIndex? BTreeIndex => _btreeIndex; - public uint RootPageId => _btreeIndex?.RootPageId ?? _vectorIndex?.RootPageId ?? _spatialIndex?.RootPageId ?? 0; + public uint RootPageId => _btreeIndex?.RootPageId ?? _vectorIndex?.RootPageId ?? _spatialIndex?.RootPageId ?? _fullTextIndex?.RootPageId ?? 0; public CollectionSecondaryIndex( CollectionIndexDefinition definition, @@ -86,6 +87,14 @@ public CollectionSecondaryIndex( _btreeIndex = null; _vectorIndex = null; } + else if (indexOptions.Type == IndexType.FullText) + { + _fullTextIndex = new FullTextIndex(storage, indexOptions, rootPageId); + _btreeIndex = null; + _vectorIndex = null; + _spatialIndex = null; + KeyFormat = FullTextIndex.FormatVersion; + } else { _btreeIndex = new BTreeIndex(storage, indexOptions, rootPageId); @@ -214,6 +223,13 @@ private void InsertKey(object keyValue, T document, DocumentLocation location, I _spatialIndex.Insert(GeoBox.FromPoint(new GeoPoint(t.Item1, t.Item2)), location, transaction); } } + else if (_fullTextIndex != null) + { + if (keyValue is string text) + { + _fullTextIndex.Insert(text, location, transaction); + } + } else if (_btreeIndex != null) { // BTree Index logic @@ -264,6 +280,13 @@ private void DeleteKey(object keyValue, T document, DocumentLocation location, I _spatialIndex.Delete(GeoBox.FromPoint(new GeoPoint(t.Item1, t.Item2)), location, transaction); } } + else if (_fullTextIndex != null) + { + if (keyValue is string text) + { + _fullTextIndex.Delete(text, location, transaction); + } + } else if (_btreeIndex != null) { var userKey = ConvertToIndexKey(keyValue); @@ -359,6 +382,40 @@ public IEnumerable Within((double Latitude, double Longitude) return _spatialIndex.Search(area, transaction); } + /// + /// Locations of the documents whose indexed text contains every term of . + /// + public IReadOnlyList FullTextMatch(string query, ITransaction? transaction = null) + { + if (_fullTextIndex == null) + throw new InvalidOperationException("This index is not a full-text index."); + + return _fullTextIndex.Match(query, transaction); + } + + /// + /// The best BM25 matches for , best first. + /// + public IReadOnlyList<(DocumentLocation Location, double Score)> FullTextSearch(string query, int k, bool matchAllTerms = true, ITransaction? transaction = null) + { + if (_fullTextIndex == null) + throw new InvalidOperationException("This index is not a full-text index."); + + return _fullTextIndex.Search(query, k, matchAllTerms, transaction); + } + + /// + /// The BM25 matches for , best first, starting with the + /// best and continuing past them for as long as the caller enumerates. + /// + internal IEnumerable<(DocumentLocation Location, double Score)> FullTextSearchExpanding(string query, int k, bool matchAllTerms, ITransaction? transaction = null) + { + if (_fullTextIndex == null) + throw new InvalidOperationException("This index is not a full-text index."); + + return _fullTextIndex.SearchExpanding(query, k, matchAllTerms, transaction); + } + /// /// Scans a range of keys (O(log n + k) where k is result count) /// @@ -773,10 +830,27 @@ private static decimal ToDecimalClamped(float f) /// /// True when this B-tree index keys a decimal property and its pages still use the legacy - /// format, i.e. hold (double)value keys that the exact decimal encoding cannot match. + /// format, i.e. hold (double)value keys that the exact decimal encoding cannot match; or + /// when this full-text index was written with another analyzer version or holds keys it did not + /// write (e.g. from an older BLite that read it as a plain B-tree). /// internal bool NeedsKeyFormatMigration => - _btreeIndex != null && KeyFormat < IndexMetadata.CurrentKeyFormat && IsDecimalKeyed; + (_btreeIndex != null && KeyFormat < IndexMetadata.CurrentKeyFormat && IsDecimalKeyed) + || (_fullTextIndex != null && (KeyFormat != FullTextIndex.FormatVersion || _fullTextIndex.HasForeignKeys())); + + /// The key format a rebuilt copy of this index is written in. + internal byte TargetKeyFormat => _fullTextIndex != null ? FullTextIndex.FormatVersion : IndexMetadata.CurrentKeyFormat; + + /// + /// Whether the indexed text of contains every term of + /// (or, with false, at least one). + /// Used to re-check full-text hits, so a stale posting can never surface another document. + /// + internal bool FullTextContains(T document, string query, bool matchAllTerms) + { + var text = _definition.TryGetKey(document, out var key) ? key as string : null; + return matchAllTerms ? Text.FullTextAnalyzer.Matches(text, query) : Text.FullTextAnalyzer.MatchesAny(text, query); + } // ── ICollectionIndex metadata pass-throughs ───────────────────────────── @@ -875,6 +949,7 @@ internal IReadOnlyList CollectAllPages(ulong transactionId) if (_btreeIndex != null) return _btreeIndex.CollectAllPages(transactionId); if (_vectorIndex != null) return _vectorIndex.CollectAllPages(transactionId); if (_spatialIndex != null) return _spatialIndex.CollectAllPages(transactionId); + if (_fullTextIndex != null) return _fullTextIndex.CollectAllPages(transactionId); return Array.Empty(); } } diff --git a/src/BLite.Core/Indexing/FullTextExtensions.cs b/src/BLite.Core/Indexing/FullTextExtensions.cs new file mode 100644 index 0000000..28c942a --- /dev/null +++ b/src/BLite.Core/Indexing/FullTextExtensions.cs @@ -0,0 +1,23 @@ +using BLite.Core.Text; + +namespace BLite.Core; + +public static class FullTextExtensions +{ + /// + /// Whether contains every term of . Terms are + /// compared after removing accents, lowercasing and treating anything that is not a letter or + /// digit as a separator, so "Café, au lait!" matches the query "cafe LAIT". + /// A query without any term matches nothing. + /// + /// The LINQ query provider serves this from the property's full-text index when there is one; + /// otherwise it is evaluated per document with the same rules. + /// + /// + /// The string property of the entity. + /// The terms to look for. + public static bool FullTextMatch(this string? text, string query) + { + return FullTextAnalyzer.Matches(text, query); + } +} diff --git a/src/BLite.Core/Indexing/FullTextIndex.cs b/src/BLite.Core/Indexing/FullTextIndex.cs new file mode 100644 index 0000000..2c3be76 --- /dev/null +++ b/src/BLite.Core/Indexing/FullTextIndex.cs @@ -0,0 +1,368 @@ +using System.Buffers.Binary; +using System.Text; +using BLite.Core.Storage; +using BLite.Core.Text; +using BLite.Core.Transactions; + +namespace BLite.Core.Indexing; + +/// +/// Inverted index for full-text search, stored in a single B-Tree. +/// +/// Posting, one per distinct term of a document: +/// [0x10][term UTF-8][0x00][location (6)][term frequency u16][document length u32], +/// pointing at the document. The term frequency and length make BM25 scoring possible from the +/// postings alone; deleting a document recomputes the same keys from its old text. +/// Statistics, a single entry: [0x20][documents u64][terms u64], the number of +/// indexed documents and their total length, maintained in the same transaction as the +/// postings. +/// +/// The tags cannot start a key that an older BLite (which reads this tree as a plain B-Tree index) +/// writes: those begin with 0x00-0x03, so such keys stay outside every range read here, +/// and detects them so the index is rebuilt on open. +/// Terms come from ; a document without any term is not indexed. +/// +/// Every write updates the single statistics entry, so two explicit transactions writing to the +/// same collection concurrently lose one of the updates (BLite has no write-write conflict +/// detection between transactions). Searches stay correct, but scores drift until the index is +/// rebuilt. +/// +/// +internal sealed class FullTextIndex +{ + /// + /// Version of the analyzer and key layout, recorded as the index's KeyFormat. An index + /// written with another version is rebuilt on open, since deletes rely on reproducing the keys. + /// + internal const byte FormatVersion = 1; + + private const byte PostingTag = 0x10; + private const byte StatsTag = 0x20; + private const byte TermEnd = 0x00; + private const int StatsKeyLength = 17; + + // BM25 parameters (Robertson et al.; the usual defaults, also used by SQLite FTS5 and Lucene). + private const double K1 = 1.2; + private const double B = 0.75; + + private static readonly IndexKey StatsMin = IndexKey.FromOwnedArray([StatsTag]); + private static readonly IndexKey StatsMax = IndexKey.FromOwnedArray([StatsTag + 1]); + private static readonly IndexKey AfterPostings = IndexKey.FromOwnedArray([PostingTag + 1]); + + private readonly BTreeIndex _tree; + + public FullTextIndex(StorageEngine storage, IndexOptions options, uint rootPageId = 0) + { + _tree = new BTreeIndex(storage, options, rootPageId); + } + + public uint RootPageId => _tree.RootPageId; + + public IReadOnlyList CollectAllPages() => _tree.CollectAllPages(); + + /// The index pages as sees them. + public IReadOnlyList CollectAllPages(ulong transactionId) => _tree.CollectAllPages(transactionId); + + /// + /// Whether the tree holds a key this index did not write, such as one inserted by an older BLite + /// that read the tree as a plain B-Tree index. Such an index is also missing the postings of those + /// writes, so it must be rebuilt. Reads at most the first key and the keys after the postings. + /// + public bool HasForeignKeys() + { + foreach (var entry in _tree.Range(IndexKey.MinKey, IndexKey.MaxKey, IndexDirection.Forward, null)) + { + var first = entry.Key.Data; + if (first.Length == 0 || (first[0] != PostingTag && first[0] != StatsTag)) return true; + break; + } + + // After the postings only the single statistics entry may follow. + int count = 0; + foreach (var entry in _tree.Range(AfterPostings, IndexKey.MaxKey, IndexDirection.Forward, null)) + { + var key = entry.Key.Data; + if (++count > 1 || key.Length != StatsKeyLength || key[0] != StatsTag) return true; + } + return false; + } + + public void Insert(string? text, DocumentLocation location, ITransaction? transaction) + { + var terms = FullTextAnalyzer.Analyze(text, out var length); + if (length == 0) return; + + var txnId = transaction?.TransactionId; + foreach (var pair in terms) + _tree.Insert(PostingKey(pair.Key, location, pair.Value, length), location, txnId); + AdjustStats(1, length, txnId); + } + + public void Delete(string? text, DocumentLocation location, ITransaction? transaction) + { + var terms = FullTextAnalyzer.Analyze(text, out var length); + if (length == 0) return; + + var txnId = transaction?.TransactionId; + bool found = false; + foreach (var pair in terms) + found |= _tree.Delete(PostingKey(pair.Key, location, pair.Value, length), location, txnId); + + // A document that was never indexed must not change the statistics. + if (found) + AdjustStats(-1, -length, txnId); + } + + /// Locations of the documents that contain every term of . + public IReadOnlyList Match(string? query, ITransaction? transaction) + { + var terms = FullTextAnalyzer.QueryTerms(query); + if (terms.Count == 0) return Array.Empty(); + + var txnId = transaction?.TransactionId; + // Start from the rarest term so the candidate list is as short as possible. Counting a term + // (CountRange) still walks all of its leaf entries, without decoding or allocating them. + var ordered = terms + .Select(t => (Term: t, Count: _tree.CountRange(TermMin(t), TermMax(t), txnId))) + .OrderBy(t => t.Count) + .Select(t => t.Term) + .ToList(); + + var result = Postings(ordered[0], txnId).Select(p => p.Location).ToList(); + for (int i = 1; i < ordered.Count && result.Count > 0; i++) + { + var present = new HashSet(Postings(ordered[i], txnId).Select(p => Pack(p.Location))); + result.RemoveAll(l => !present.Contains(Pack(l))); + } + return result; + } + + /// + /// The documents with the highest BM25 score for , + /// best first. With only documents containing every query term + /// qualify; otherwise any term is enough and documents with more of them score higher. + /// + public IReadOnlyList<(DocumentLocation Location, double Score)> Search(string? query, int k, bool matchAllTerms, ITransaction? transaction) + { + var terms = FullTextAnalyzer.QueryTerms(query); + if (terms.Count == 0 || k <= 0) return Array.Empty<(DocumentLocation, double)>(); + + var txnId = transaction?.TransactionId; + + // Rarest term first: with matchAllTerms its postings bound the candidate set. Counting a term + // (CountRange) still walks all of its leaf entries, without decoding or allocating them. + var ordered = terms + .Select(t => (Term: t, Count: _tree.CountRange(TermMin(t), TermMax(t), txnId))) + .OrderBy(t => t.Count) + .ToList(); + if (matchAllTerms && ordered[0].Count == 0) return Array.Empty<(DocumentLocation, double)>(); + + var perTerm = new List<(int DocumentFrequency, List<(DocumentLocation Location, int Frequency, int Length)> Postings)>(); + Dictionary? candidates = null; + foreach (var (term, count) in ordered) + { + if (count == 0) continue; + + // Once the candidates are few, look each one up instead of decoding every posting of a + // much more common term. This saves decoding and allocation, not I/O: CountRange above + // has already read that term's leaf pages. + var postings = candidates != null && candidates.Count * 8 < count + ? candidates.Values.Select(l => TryPosting(term, l, txnId)).Where(p => p.HasValue).Select(p => p!.Value).ToList() + : Postings(term, txnId).ToList(); + + if (matchAllTerms) + { + var next = new Dictionary(); + foreach (var p in postings) + { + var key = Pack(p.Location); + if (candidates == null || candidates.ContainsKey(key)) next[key] = p.Location; + } + if (next.Count == 0) return Array.Empty<(DocumentLocation, double)>(); + candidates = next; + } + perTerm.Add((count, postings)); + } + if (perTerm.Count == 0) return Array.Empty<(DocumentLocation, double)>(); + + var (documents, totalLength) = ReadStats(txnId); + if (documents <= 0 || totalLength <= 0) + { + // No statistics (e.g. a concurrent transaction's update won): estimate them from the + // postings at hand rather than return nothing while Match still finds the documents. + var lengths = perTerm.SelectMany(t => t.Postings).GroupBy(p => Pack(p.Location)).Select(g => g.First().Length).ToList(); + documents = Math.Max(1, perTerm.Max(t => t.DocumentFrequency)); + totalLength = Math.Max(1, (long)(lengths.Average() * documents)); + } + double averageLength = (double)totalLength / documents; + + var scores = new Dictionary(); + foreach (var (df, postings) in perTerm) + { + double idf = Math.Log(1 + (documents - df + 0.5) / (df + 0.5)); + foreach (var p in postings) + { + var key = Pack(p.Location); + if (candidates != null && !candidates.ContainsKey(key)) continue; + double weight = idf * p.Frequency * (K1 + 1) + / (p.Frequency + K1 * (1 - B + B * p.Length / averageLength)); + scores[key] = scores.TryGetValue(key, out var s) ? (s.Location, s.Score + weight) : (p.Location, weight); + } + } + + return scores.Values + .OrderByDescending(s => s.Score) + .ThenBy(s => Pack(s.Location)) + .Take(k) + .ToList(); + } + + /// + /// Like , but keeps going past the first hits, best first, + /// for as long as the caller enumerates: when a window is used up it searches again with twice as + /// many and skips the hits already returned. Callers that drop hits (re-checking them against the + /// document) can so still return results. The first window costs exactly one + /// . + /// + public IEnumerable<(DocumentLocation Location, double Score)> SearchExpanding(string? query, int k, bool matchAllTerms, ITransaction? transaction) + { + HashSet? returned = null; + for (int window = k; window > 0;) + { + var hits = Search(query, window, matchAllTerms, transaction); + foreach (var hit in hits) + { + if (returned == null || !returned.Contains(Pack(hit.Location))) + yield return hit; + } + if (hits.Count < window || window == int.MaxValue) yield break; + + returned ??= new HashSet(); + foreach (var hit in hits) returned.Add(Pack(hit.Location)); + window = window > int.MaxValue / 2 ? int.MaxValue : window * 2; + } + } + + /// The posting of for the document at , if any. + private (DocumentLocation Location, int Frequency, int Length)? TryPosting(string term, DocumentLocation location, ulong? txnId) + { + int termBytes = Encoding.UTF8.GetByteCount(term); + int prefixLength = 2 + termBytes + DocumentLocation.SerializedSize; + var min = new byte[prefixLength]; + min[0] = PostingTag; + Encoding.UTF8.GetBytes(term, 0, term.Length, min, 1); + min[1 + termBytes] = TermEnd; + location.WriteTo(min.AsSpan(2 + termBytes)); + var max = new byte[prefixLength + 6]; + min.CopyTo(max, 0); + max.AsSpan(prefixLength).Fill(0xFF); + + foreach (var entry in _tree.Range(IndexKey.FromOwnedArray(min), IndexKey.FromOwnedArray(max), IndexDirection.Forward, txnId)) + { + var key = entry.Key.Data; + return (entry.Location, + BinaryPrimitives.ReadUInt16BigEndian(key.Slice(prefixLength)), + (int)BinaryPrimitives.ReadUInt32BigEndian(key.Slice(prefixLength + 2))); + } + return null; + } + + /// + /// The postings of , one per location. A stale posting left at a location + /// that now holds another document containing the term differs only in its frequency and length, + /// so it sorts next to the current one and only the first posting of the location is returned. + /// + /// + /// "First" is key order, i.e. lowest frequency, then shortest length, not the current posting, so + /// the stale one can be the posting used for scoring (as in ), and + /// CountRange counts both towards the term's document frequency. That only skews BM25 + /// scores while the stale posting exists: which documents match, and that each comes back once, + /// do not depend on it, and hits are re-checked against the document. + /// + private IEnumerable<(DocumentLocation Location, int Frequency, int Length)> Postings(string term, ulong? txnId) + { + int offset = 2 + Encoding.UTF8.GetByteCount(term) + DocumentLocation.SerializedSize; + ulong previous = 0; + bool first = true; + foreach (var entry in _tree.Range(TermMin(term), TermMax(term), IndexDirection.Forward, txnId)) + { + var packed = Pack(entry.Location); + if (!first && packed == previous) continue; + first = false; + previous = packed; + + var key = entry.Key.Data; + yield return (entry.Location, + BinaryPrimitives.ReadUInt16BigEndian(key.Slice(offset)), + (int)BinaryPrimitives.ReadUInt32BigEndian(key.Slice(offset + 2))); + } + } + + private (long Documents, long Length) ReadStats(ulong? txnId) + { + foreach (var entry in _tree.Range(StatsMin, StatsMax, IndexDirection.Forward, txnId)) + { + var key = entry.Key.Data; + if (key.Length != StatsKeyLength) continue; // not written by this index: ignore + return ((long)BinaryPrimitives.ReadUInt64BigEndian(key.Slice(1)), + (long)BinaryPrimitives.ReadUInt64BigEndian(key.Slice(9))); + } + return (0, 0); + } + + private void AdjustStats(long documents, long length, ulong? txnId) + { + long currentDocuments = 0, currentLength = 0; + foreach (var entry in _tree.Range(StatsMin, StatsMax, IndexDirection.Forward, txnId)) + { + var key = entry.Key.Data; + if (key.Length != StatsKeyLength) continue; // not written by this index: ignore + currentDocuments = (long)BinaryPrimitives.ReadUInt64BigEndian(key.Slice(1)); + currentLength = (long)BinaryPrimitives.ReadUInt64BigEndian(key.Slice(9)); + _tree.Delete(entry.Key, entry.Location, txnId); + break; + } + + long newDocuments = currentDocuments + documents; + long newLength = currentLength + length; + if (newDocuments <= 0) return; + + var stats = new byte[StatsKeyLength]; + stats[0] = StatsTag; + BinaryPrimitives.WriteUInt64BigEndian(stats.AsSpan(1), (ulong)newDocuments); + BinaryPrimitives.WriteUInt64BigEndian(stats.AsSpan(9), (ulong)Math.Max(0, newLength)); + _tree.Insert(IndexKey.FromOwnedArray(stats), default, txnId); + } + + private static IndexKey PostingKey(string term, DocumentLocation location, int frequency, int length) + { + int termBytes = Encoding.UTF8.GetByteCount(term); + var key = new byte[2 + termBytes + DocumentLocation.SerializedSize + 2 + 4]; + key[0] = PostingTag; + Encoding.UTF8.GetBytes(term, 0, term.Length, key, 1); + key[1 + termBytes] = TermEnd; + int offset = 2 + termBytes; + location.WriteTo(key.AsSpan(offset, DocumentLocation.SerializedSize)); + offset += DocumentLocation.SerializedSize; + BinaryPrimitives.WriteUInt16BigEndian(key.AsSpan(offset), (ushort)Math.Min(frequency, ushort.MaxValue)); + BinaryPrimitives.WriteUInt32BigEndian(key.AsSpan(offset + 2), (uint)length); + return IndexKey.FromOwnedArray(key); + } + + // [0x10][term][0x00] .. [0x10][term][0x01]: every posting of exactly this term. A longer term + // sharing the prefix has a letter or digit (> 0x01) where these bounds have 0x00 / 0x01. + private static IndexKey TermMin(string term) => TermBound(term, TermEnd); + private static IndexKey TermMax(string term) => TermBound(term, TermEnd + 1); + + private static IndexKey TermBound(string term, int last) + { + int termBytes = Encoding.UTF8.GetByteCount(term); + var key = new byte[2 + termBytes]; + key[0] = PostingTag; + Encoding.UTF8.GetBytes(term, 0, term.Length, key, 1); + key[1 + termBytes] = (byte)last; + return IndexKey.FromOwnedArray(key); + } + + private static ulong Pack(DocumentLocation location) => ((ulong)location.PageId << 16) | location.SlotIndex; +} diff --git a/src/BLite.Core/Indexing/FullTextSearchResult.cs b/src/BLite.Core/Indexing/FullTextSearchResult.cs new file mode 100644 index 0000000..18c0675 --- /dev/null +++ b/src/BLite.Core/Indexing/FullTextSearchResult.cs @@ -0,0 +1,6 @@ +namespace BLite.Core; + +/// A document returned by a ranked full-text search, with its BM25 relevance score. +/// The matching document. +/// The BM25 score; higher is more relevant. Scores compare only within one search. +public readonly record struct FullTextSearchResult(T Document, double Score); diff --git a/src/BLite.Core/Indexing/IndexOptions.cs b/src/BLite.Core/Indexing/IndexOptions.cs index 387c028..f9bfdad 100644 --- a/src/BLite.Core/Indexing/IndexOptions.cs +++ b/src/BLite.Core/Indexing/IndexOptions.cs @@ -18,7 +18,10 @@ public enum IndexType : byte Vector = 4, /// Geospatial index (R-Tree) for spatial queries - Spatial = 5 + Spatial = 5, + + /// Inverted index for full-text search over a string property + FullText = 6 } /// diff --git a/src/BLite.Core/Metadata/EntityTypeBuilder.cs b/src/BLite.Core/Metadata/EntityTypeBuilder.cs index 3805f1f..a5a35ac 100644 --- a/src/BLite.Core/Metadata/EntityTypeBuilder.cs +++ b/src/BLite.Core/Metadata/EntityTypeBuilder.cs @@ -81,6 +81,22 @@ public EntityTypeBuilder HasSpatialIndex(Expression> keyS return this; } + /// + /// Declares a full-text index on a string property, queried with + /// in LINQ or ranked with + /// DocumentCollection.FullTextSearchAsync. The default name is idx_fts_{property}. + /// + /// Each document touches one index page per distinct term, so single-document writes cost + /// noticeably more than without the index; insert and update in batches + /// (InsertBulkAsync or one explicit transaction) where possible. + /// + /// + public EntityTypeBuilder HasFullTextIndex(Expression> keySelector, string? name = null) + { + Indexes.Add(new IndexBuilder(keySelector, name, false, IndexType.FullText)); + return this; + } + public EntityTypeBuilder HasKey(Expression> keySelector) { PrimaryKeySelector = keySelector; diff --git a/src/BLite.Core/Query/IndexOptimizer.cs b/src/BLite.Core/Query/IndexOptimizer.cs index f467d7b..d6b6738 100644 --- a/src/BLite.Core/Query/IndexOptimizer.cs +++ b/src/BLite.Core/Query/IndexOptimizer.cs @@ -58,6 +58,9 @@ public class OptimizationResult /// public MethodCallExpression? VectorSearchCall { get; set; } + public bool IsFullTextSearch { get; set; } + public string? FullTextQuery { get; set; } + public bool IsSpatialSearch { get; set; } public (double Latitude, double Longitude) SpatialPoint { get; set; } public double RadiusKm { get; set; } @@ -178,7 +181,8 @@ public enum FilterCompleteness } if (left != null && right != null && left.IndexName == right.IndexName - && !left.IsSpatialSearch && !right.IsSpatialSearch) + && !left.IsSpatialSearch && !right.IsSpatialSearch + && !left.IsFullTextSearch && !right.IsFullTextSearch) { // Both sides are covered by the same index — combine into a single range. // Propagate the stricter completeness: if either branch required a post-filter @@ -255,7 +259,7 @@ public enum FilterCompleteness bareMember.Type == typeof(bool)) { CollectionIndexInfo? ix = null; - foreach (var idx in indexes) { if (Matches(idx, bareMember.Member.Name)) { ix = idx; break; } } + foreach (var idx in indexes) { if (SupportsKeyLookup(idx) && Matches(idx, bareMember.Member.Name)) { ix = idx; break; } } if (ix != null) return new OptimizationResult { IndexName = ix.Name, MinValue = true, MaxValue = true, IsRange = false, FilterCompleteness = FilterCompleteness.Exact }; } @@ -266,7 +270,7 @@ notExpr.Operand is MemberExpression notMember && notMember.Type == typeof(bool)) { CollectionIndexInfo? ix = null; - foreach (var idx in indexes) { if (Matches(idx, notMember.Member.Name)) { ix = idx; break; } } + foreach (var idx in indexes) { if (SupportsKeyLookup(idx) && Matches(idx, notMember.Member.Name)) { ix = idx; break; } } if (ix != null) return new OptimizationResult { IndexName = ix.Name, MinValue = false, MaxValue = false, IsRange = false, FilterCompleteness = FilterCompleteness.Exact }; } @@ -276,7 +280,7 @@ notExpr.Operand is MemberExpression notMember && if (propertyName != null) { CollectionIndexInfo? index = null; - foreach (var idx in indexes) { if (Matches(idx, propertyName)) { index = idx; break; } } + foreach (var idx in indexes) { if (SupportsKeyLookup(idx) && Matches(idx, propertyName)) { index = idx; break; } } if (index != null) { var result = new OptimizationResult { IndexName = index.Name }; @@ -346,7 +350,7 @@ notExpr.Operand is MemberExpression notMember && if (TryParseContainsInPredicate(expression, parameter, registry, out var inPropertyPath, out var inValues)) { CollectionIndexInfo? index = null; - foreach (var idx in indexes) { if (Matches(idx, inPropertyPath)) { index = idx; break; } } + foreach (var idx in indexes) { if (SupportsKeyLookup(idx) && Matches(idx, inPropertyPath)) { index = idx; break; } } if (index != null) { return new OptimizationResult @@ -389,6 +393,23 @@ notExpr.Operand is MemberExpression notMember && // Handle Method Calls (VectorSearch, Near, Within) if (expression is MethodCallExpression mcall) { + // FullTextMatch(this string text, string query) + if (mcall.Method.Name == "FullTextMatch" && mcall.Method.DeclaringType == typeof(FullTextExtensions) + && mcall.Arguments[0] is MemberExpression tMember && tMember.Expression == parameter) + { + CollectionIndexInfo? index = null; + foreach (var idx in indexes) { if (idx.Type == IndexType.FullText && Matches(idx, tMember.Member.Name)) { index = idx; break; } } + if (index != null) + { + return new OptimizationResult + { + IndexName = index.Name, + IsFullTextSearch = true, + FullTextQuery = EvaluateExpression(mcall.Arguments[1]) + }; + } + } + // VectorSearch(this float[] vector, float[] query, int k) if (mcall.Method.Name == "VectorSearch" && mcall.Arguments[0] is MemberExpression vMember && vMember.Expression == parameter) { @@ -573,6 +594,13 @@ private static bool TryWalkMemberChain(Expression expr, out object? value) return true; } + /// + /// Whether equality, range and IN lookups can use the index. Vector, spatial and full-text + /// indexes do not store property values in key order. + /// + private static bool SupportsKeyLookup(CollectionIndexInfo index) + => index.Type is not (IndexType.Vector or IndexType.Spatial or IndexType.FullText); + private static bool Matches(CollectionIndexInfo index, string propertyName) { if (index.PropertyPaths == null || index.PropertyPaths.Length == 0) return false; diff --git a/src/BLite.Core/Storage/CollectionFeatures.cs b/src/BLite.Core/Storage/CollectionFeatures.cs index 38160e3..ad1955f 100644 --- a/src/BLite.Core/Storage/CollectionFeatures.cs +++ b/src/BLite.Core/Storage/CollectionFeatures.cs @@ -70,8 +70,17 @@ internal sealed record RequiredFeature(string Id, FeatureRequirement Requirement /// internal static class CollectionFeatures { - // Features this version implements. Empty until a feature that needs protecting ships. - private static readonly HashSet s_known = new(StringComparer.Ordinal); + /// + /// Full-text indexes (#70), recorded while the collection has at least one. Needed to open: a + /// version without them reads a full-text index as a B-Tree index, so its query planner would + /// answer equality, range and IN lookups on the property from the postings, and its writes + /// would leave keys and stale postings behind. + /// + internal static readonly RequiredFeature FullTextIndexes = + new("fulltext-index", FeatureRequirement.Open, "full-text indexes (BLite 5.3 or later)"); + + // Features this version implements. + private static readonly HashSet s_known = new(StringComparer.Ordinal) { FullTextIndexes.Id }; // Highest IndexType this version knows; larger values come from newer versions. 0 is accepted: // older releases could persist an index whose type was never set. @@ -88,6 +97,24 @@ internal static class CollectionFeatures /// Whether this version implements the feature with the given id. internal static bool IsKnown(string id) => s_known.Contains(id); + /// + /// Records the features the indexes in require and removes those + /// none of them requires any more (today: while there is a + /// full-text index). Call after changing the index list, before saving the record. Other + /// features, including ones this version does not know, are left as they are. + /// + /// Whether changed. + internal static bool SyncIndexFeatures(CollectionMetadata metadata) + { + bool needed = metadata.Indexes.Any(i => i.Type == IndexType.FullText); + bool recorded = metadata.RequiredFeatures.Any(f => f.Id == FullTextIndexes.Id); + if (needed == recorded) return false; + + if (needed) metadata.RequiredFeatures.Add(FullTextIndexes); + else metadata.RequiredFeatures.RemoveAll(f => f.Id == FullTextIndexes.Id); + return true; + } + /// /// Whether this version may open the collection: every feature it requires is known, except /// features when the database is open read-only. @@ -141,11 +168,20 @@ internal static NotSupportedException UnreadableRecord(string name, Exception ca "that wrote it.", cause); private static List Unsupported(CollectionMetadata metadata, bool readOnly) + => Unsupported(metadata, readOnly, s_known, s_maxIndexType); + + /// + /// The features that keep a version knowing only features and index + /// types up to from opening (or, with + /// false, modifying) the collection. Takes the version's knowledge as parameters so tests can + /// ask what an older version would refuse. + /// + internal static List Unsupported(CollectionMetadata metadata, bool readOnly, ICollection known, byte maxIndexType) { var result = new List(); foreach (var feature in metadata.RequiredFeatures) { - if (IsKnown(feature.Id)) continue; + if (known.Contains(feature.Id)) continue; if (readOnly && feature.Requirement == FeatureRequirement.Write) continue; result.Add(feature); } @@ -155,7 +191,7 @@ private static List Unsupported(CollectionMetadata metadata, bo foreach (var index in metadata.Indexes) { var type = (byte)index.Type; - if (type > s_maxIndexType) + if (type > maxIndexType) result.Add(new RequiredFeature($"index-type-{type}", FeatureRequirement.Open, $"index '{index.Name}' of type {type}, which this version does not know")); } diff --git a/src/BLite.Core/Storage/StorageEngine.Memory.cs b/src/BLite.Core/Storage/StorageEngine.Memory.cs index b90b521..14a17ff 100644 --- a/src/BLite.Core/Storage/StorageEngine.Memory.cs +++ b/src/BLite.Core/Storage/StorageEngine.Memory.cs @@ -441,6 +441,13 @@ internal IReadOnlyCollection FreeCollectionPagesCore(string collectionName toFree.Add(pageId); break; } + case IndexType.FullText: + { + var ftsIndex = new FullTextIndex(this, IndexOptions.CreateBTree(idx.PropertyPaths), idx.RootPageId); + foreach (var pageId in ftsIndex.CollectAllPages()) + toFree.Add(pageId); + break; + } case IndexType.Spatial: { var opts = IndexOptions.CreateSpatial(idx.PropertyPaths); diff --git a/src/BLite.Core/Text/FullTextAnalyzer.cs b/src/BLite.Core/Text/FullTextAnalyzer.cs new file mode 100644 index 0000000..14cec41 --- /dev/null +++ b/src/BLite.Core/Text/FullTextAnalyzer.cs @@ -0,0 +1,113 @@ +using System.Text; + +namespace BLite.Core.Text; + +/// +/// Splits text into the terms a full-text index stores and a full-text query looks up. +/// +/// Text is normalized with (accents removed, lowercased, +/// every character that is not a letter or digit treated as a separator) and split into terms. +/// A term longer than bytes of UTF-8 is cut to that length. +/// Documents and queries go through the same steps, so a document matches a query exactly when +/// it contains every query term, whether or not an index evaluates the query. +/// +/// +/// Scripts written without spaces between words, such as Chinese or Japanese, are not segmented: +/// each run of letters is one term (and is then cut to , about ten +/// ideographs). Characters outside the Basic Multilingual Plane, such as emoji or supplementary +/// letters, are treated as separators. +/// +/// +internal static class FullTextAnalyzer +{ + /// Longest term stored, in bytes of UTF-8; keeps index keys small. + internal const int MaxTermBytes = 32; + + /// + /// Returns each distinct term of with its number of occurrences, and + /// the total number of terms in . + /// + public static Dictionary Analyze(string? text, out int length) + { + var frequencies = new Dictionary(StringComparer.Ordinal); + length = 0; + foreach (var term in Terms(text)) + { + frequencies.TryGetValue(term, out var count); + frequencies[term] = count + 1; + length++; + } + return frequencies; + } + + /// The distinct terms of a query, in the order they first appear. + public static IReadOnlyList QueryTerms(string? query) + { + var seen = new HashSet(StringComparer.Ordinal); + var terms = new List(); + foreach (var term in Terms(query)) + { + if (seen.Add(term)) + terms.Add(term); + } + return terms; + } + + /// Whether contains every term of . + public static bool Matches(string? text, string? query) + { + var queryTerms = QueryTerms(query); + if (queryTerms.Count == 0) + return false; + + var textTerms = new HashSet(Terms(text), StringComparer.Ordinal); + foreach (var term in queryTerms) + { + if (!textTerms.Contains(term)) + return false; + } + return true; + } + + /// Whether contains at least one term of . + public static bool MatchesAny(string? text, string? query) + { + var queryTerms = QueryTerms(query); + if (queryTerms.Count == 0) + return false; + + var textTerms = new HashSet(Terms(text), StringComparer.Ordinal); + foreach (var term in queryTerms) + { + if (textTerms.Contains(term)) + return true; + } + return false; + } + + private static IEnumerable Terms(string? text) + { + var normalized = TextNormalizer.Normalize(text); + if (normalized.Length == 0) + yield break; + + foreach (var token in normalized.Split(' ')) + { + if (token.Length > 0) + yield return Truncate(token); + } + } + + private static string Truncate(string term) + { + if (Encoding.UTF8.GetByteCount(term) <= MaxTermBytes) + return term; + + var bytes = Encoding.UTF8.GetBytes(term); + int cut = MaxTermBytes; + // Back off to the start of a character: UTF-8 continuation bytes are 10xxxxxx. + while (cut > 0 && (bytes[cut] & 0xC0) == 0x80) + cut--; + return Encoding.UTF8.GetString(bytes, 0, cut); + } +} diff --git a/tests/BLite.Shared/FullTextContexts.cs b/tests/BLite.Shared/FullTextContexts.cs new file mode 100644 index 0000000..f502bb6 --- /dev/null +++ b/tests/BLite.Shared/FullTextContexts.cs @@ -0,0 +1,44 @@ +using BLite.Bson; +using BLite.Core; +using BLite.Core.Collections; +using BLite.Core.Metadata; + +namespace BLite.Shared; + +public class Article +{ + public ObjectId Id { get; set; } + public string Title { get; set; } = string.Empty; + public string? Body { get; set; } + public int Year { get; set; } +} + +/// +/// Articles with full-text indexes on Body and Title, next to a B-Tree index on +/// Title for equality and range lookups. +/// +public partial class FullTextDbContext : DocumentDbContext +{ + public DocumentCollection Articles { get; set; } = null!; + + public FullTextDbContext(string path) : base(path) => InitializeCollections(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity
().ToCollection("articles") + .HasIndex(a => a.Title) + .HasFullTextIndex(a => a.Title) + .HasFullTextIndex(a => a.Body); + } +} + +/// The same collection without any secondary index. +public partial class FullTextNoIndexDbContext : DocumentDbContext +{ + public DocumentCollection Articles { get; set; } = null!; + + public FullTextNoIndexDbContext(string path) : base(path) => InitializeCollections(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.Entity
().ToCollection("articles"); +} diff --git a/tests/BLite.Tests/FullTextSearchTests.cs b/tests/BLite.Tests/FullTextSearchTests.cs new file mode 100644 index 0000000..1b92c5d --- /dev/null +++ b/tests/BLite.Tests/FullTextSearchTests.cs @@ -0,0 +1,998 @@ +using BLite.Bson; +using BLite.Core; +using BLite.Core.Collections; +using System.Linq.Expressions; +using BLite.Core.Indexing; +using BLite.Core.Query; +using BLite.Core.Storage; +using BLite.Shared; + +namespace BLite.Tests; + +/// +/// Full-text indexes: analysis, LINQ FullTextMatch, BM25-ranked search, index maintenance +/// and the dynamic API (#70). +/// +public class FullTextSearchTests : IDisposable +{ + private readonly string _dbPath; + + public FullTextSearchTests() + { + _dbPath = Path.Combine(Path.GetTempPath(), $"fts_{Guid.NewGuid():N}.db"); + } + + public void Dispose() + { + foreach (var path in new[] { _dbPath, Path.ChangeExtension(_dbPath, ".fresh.db") }) + foreach (var f in new[] { path, Path.ChangeExtension(path, ".wal") }) + try { if (File.Exists(f)) File.Delete(f); } catch { /* best-effort */ } + } + + private static Article A(string title, string? body, int year = 2020) => new() { Title = title, Body = body, Year = year }; + + private static async Task SeedAsync(FullTextDbContext db) + { + await db.Articles.InsertAsync(A("fox", "The quick brown fox jumps over the lazy dog", 2001)); + await db.Articles.InsertAsync(A("fox again", "A quick fox. Another quick fox!", 1999)); + await db.Articles.InsertAsync(A("dog", "The dog sleeps all day long in the sun", 2010)); + await db.Articles.InsertAsync(A("cafe", "Café au lait, s'il vous plaît", 2015)); + await db.Articles.InsertAsync(A("empty", null, 2020)); + } + + private static List Titles(IEnumerable
articles) => articles.Select(a => a.Title).OrderBy(t => t).ToList(); + + // ── In-memory matching ────────────────────────────────────────────────── + + [Fact] + public void FullTextMatch_InMemory_IgnoresCaseAccentsAndPunctuation() + { + Assert.True("Café, au LAIT!".FullTextMatch("cafe lait")); + Assert.True("The quick brown fox".FullTextMatch("FOX quick")); + Assert.False("The quick brown fox".FullTextMatch("quick dog")); + Assert.False("The quick brown fox".FullTextMatch(" ,; ")); + Assert.False(((string?)null).FullTextMatch("fox")); + } + + // ── LINQ ──────────────────────────────────────────────────────────────── + + [Fact] + public async Task Linq_FullTextMatch_ReturnsDocumentsContainingEveryTerm() + { + using var db = new FullTextDbContext(_dbPath); + await SeedAsync(db); + + Assert.Equal(new[] { "fox", "fox again" }, Titles(db.Articles.AsQueryable().Where(a => a.Body.FullTextMatch("quick fox")).ToList())); + Assert.Equal(new[] { "fox" }, Titles(db.Articles.AsQueryable().Where(a => a.Body.FullTextMatch("fox dog")).ToList())); + Assert.Equal(new[] { "cafe" }, Titles(db.Articles.AsQueryable().Where(a => a.Body.FullTextMatch("CAFE")).ToList())); + Assert.Empty(db.Articles.AsQueryable().Where(a => a.Body.FullTextMatch("cat")).ToList()); + } + + [Fact] + public async Task Linq_FullTextMatch_CombinesWithOtherConditions() + { + using var db = new FullTextDbContext(_dbPath); + await SeedAsync(db); + + var and = db.Articles.AsQueryable().Where(a => a.Body.FullTextMatch("fox") && a.Year > 2000).ToList(); + var or = db.Articles.AsQueryable().Where(a => a.Body.FullTextMatch("sun") || a.Year == 1999).ToList(); + var not = db.Articles.AsQueryable().Where(a => a.Body != null && !a.Body.FullTextMatch("fox")).ToList(); + + Assert.Equal(new[] { "fox" }, Titles(and)); + Assert.Equal(new[] { "dog", "fox again" }, Titles(or)); + Assert.Equal(new[] { "cafe", "dog" }, Titles(not)); + } + + [Fact] + public async Task Linq_CountAndFirst_UseTheFullTextIndex() + { + using var db = new FullTextDbContext(_dbPath); + await SeedAsync(db); + + Assert.Equal(2, db.Articles.AsQueryable().Count(a => a.Body.FullTextMatch("quick"))); + Assert.Equal("dog", db.Articles.AsQueryable().First(a => a.Body.FullTextMatch("sleeps")).Title); + Assert.Null(db.Articles.AsQueryable().FirstOrDefault(a => a.Body.FullTextMatch("cat"))); + } + + [Fact] + public async Task Linq_EqualityOnAPropertyWithBothIndexes_UsesTheBTree() + { + using var db = new FullTextDbContext(_dbPath); + await SeedAsync(db); + + Assert.Equal("fox again", db.Articles.AsQueryable().Single(a => a.Title == "fox again").Title); + Assert.Equal(2, db.Articles.AsQueryable().Count(a => a.Title == "fox" || a.Title == "dog")); + Assert.Equal(new[] { "fox", "fox again" }, Titles(db.Articles.AsQueryable().Where(a => a.Title.FullTextMatch("fox")).ToList())); + } + + // ── Query planning ────────────────────────────────────────────────────── + + private static readonly CollectionIndexInfo[] PlannerIndexes = + [ + new() { Name = "idx_fts_Title", PropertyPaths = ["Title"], Type = IndexType.FullText }, + new() { Name = "idx_Title", PropertyPaths = ["Title"], Type = IndexType.BTree }, + new() { Name = "idx_fts_Body", PropertyPaths = ["Body"], Type = IndexType.FullText }, + ]; + + private static IndexOptimizer.OptimizationResult? Plan(Expression> predicate) + => IndexOptimizer.TryOptimize
(new QueryModel { WhereClause = predicate }, PlannerIndexes); + + [Fact] + public void Planner_ServesFullTextMatch_FromTheFullTextIndex() + { + var plan = Plan(a => a.Body.FullTextMatch("quick fox")); + + Assert.NotNull(plan); + Assert.True(plan.IsFullTextSearch); + Assert.Equal("idx_fts_Body", plan.IndexName); + Assert.Equal("quick fox", plan.FullTextQuery); + Assert.Equal(IndexOptimizer.FilterCompleteness.Exact, plan.FilterCompleteness); + } + + [Fact] + public void Planner_KeyLookups_SkipFullTextIndexes() + { + var plan = Plan(a => a.Title == "fox"); + + Assert.NotNull(plan); + Assert.False(plan.IsFullTextSearch); + Assert.Equal("idx_Title", plan.IndexName); + } + + [Fact] + public void Planner_FullTextMatchWithAnotherCondition_PostFilters() + { + var plan = Plan(a => a.Body.FullTextMatch("fox") && a.Year > 2000); + + Assert.NotNull(plan); + Assert.True(plan.IsFullTextSearch); + Assert.Equal(IndexOptimizer.FilterCompleteness.PartialAnd, plan.FilterCompleteness); + } + + [Fact] + public void Planner_FullTextMatchUnderOrOrNot_IsNotServedByTheIndex() + { + // The index only knows which documents contain the terms: these need a scan. + foreach (var plan in new[] + { + Plan(a => a.Body.FullTextMatch("sun") || a.Year == 1999), + Plan(a => a.Body.FullTextMatch("sun") || a.Body.FullTextMatch("fox")), + Plan(a => !a.Body.FullTextMatch("fox")), + }) + { + Assert.False(plan?.IsFullTextSearch ?? false); + } + } + + [Fact] + public void Planner_FullTextMatchWithoutAnIndex_HasNoPlan() + { + var indexes = new[] { new CollectionIndexInfo { Name = "idx_Title", PropertyPaths = ["Title"], Type = IndexType.BTree } }; + + Assert.Null(IndexOptimizer.TryOptimize
( + new QueryModel { WhereClause = (Expression>)(a => a.Body.FullTextMatch("fox")) }, indexes)); + } + + // ── Ranked search ─────────────────────────────────────────────────────── + + [Fact] + public async Task FullTextSearch_RanksByBm25() + { + using var db = new FullTextDbContext(_dbPath); + await db.Articles.InsertAsync(A("once, long", "fox " + string.Join(" ", Enumerable.Repeat("filler", 30)))); + await db.Articles.InsertAsync(A("twice, short", "fox fox")); + await db.Articles.InsertAsync(A("none", "nothing to see here")); + + var results = await db.Articles.FullTextSearchAsync("idx_fts_Body", "fox", 10).ToListAsync(); + + Assert.Equal(new[] { "twice, short", "once, long" }, results.Select(r => r.Document.Title)); + Assert.True(results[0].Score > results[1].Score); + Assert.All(results, r => Assert.True(r.Score > 0)); + } + + [Fact] + public async Task FullTextSearch_AnyTerm_RanksDocumentsWithMoreAndRarerTermsFirst() + { + // Every body has two terms, so only term rarity and the number of matching terms differ. + using var db = new FullTextDbContext(_dbPath); + await db.Articles.InsertAsync(A("common", "apple filler")); + await db.Articles.InsertAsync(A("common 2", "apple filler")); + await db.Articles.InsertAsync(A("common 3", "apple filler")); + await db.Articles.InsertAsync(A("rare", "durian filler")); + await db.Articles.InsertAsync(A("both", "apple durian")); + + var all = await db.Articles.FullTextSearchAsync("idx_fts_Body", "apple durian", 10).ToListAsync(); + var any = await db.Articles.FullTextSearchAsync("idx_fts_Body", "apple durian", 10, matchAllTerms: false).ToListAsync(); + var top2 = await db.Articles.FullTextSearchAsync("idx_fts_Body", "apple durian", 2, matchAllTerms: false).ToListAsync(); + + Assert.Equal(new[] { "both" }, all.Select(r => r.Document.Title)); + Assert.Equal(5, any.Count); + Assert.Equal(new[] { "both", "rare" }, any.Take(2).Select(r => r.Document.Title)); + Assert.Equal(new[] { "both", "rare" }, top2.Select(r => r.Document.Title)); + } + + [Fact] + public async Task FullTextSearch_ScoresAfterUpdatesAndDeletes_MatchAFreshIndex() + { + using (var db = new FullTextDbContext(_dbPath)) + { + var keep = A("keep", "red green blue"); + var change = A("change", "red red"); + var gone = A("gone", "green green green blue"); + await db.Articles.InsertAsync(keep); + await db.Articles.InsertAsync(change); + await db.Articles.InsertAsync(gone); + await db.Articles.InsertAsync(A("gone too", "blue")); + change.Body = "blue green with many more words here"; + await db.Articles.UpdateAsync(change); + await db.Articles.DeleteAsync(gone.Id); + await db.Articles.DeleteAsync(db.Articles.AsQueryable().Single(a => a.Title == "gone too").Id); + } + + var freshPath = Path.ChangeExtension(_dbPath, ".fresh.db"); + using (var fresh = new FullTextDbContext(freshPath)) + { + await fresh.Articles.InsertAsync(A("keep", "red green blue")); + await fresh.Articles.InsertAsync(A("change", "blue green with many more words here")); + } + + foreach (var query in new[] { "blue", "green", "red", "blue green" }) + { + using var db = new FullTextDbContext(_dbPath); + using var fresh = new FullTextDbContext(freshPath); + var actual = await db.Articles.FullTextSearchAsync("idx_fts_Body", query, 10, matchAllTerms: false).ToListAsync(); + var expected = await fresh.Articles.FullTextSearchAsync("idx_fts_Body", query, 10, matchAllTerms: false).ToListAsync(); + + Assert.Equal(expected.Select(r => r.Document.Title), actual.Select(r => r.Document.Title)); + Assert.Equal(expected.Select(r => Math.Round(r.Score, 9)), actual.Select(r => Math.Round(r.Score, 9))); + } + } + + // ── Maintenance ───────────────────────────────────────────────────────── + + [Fact] + public async Task UpdatesAndDeletes_AreReflected() + { + using var db = new FullTextDbContext(_dbPath); + var a = A("a", "alpha beta"); + var b = A("b", "gamma"); + await db.Articles.InsertAsync(a); + await db.Articles.InsertAsync(b); + + a.Body = "delta"; + await db.Articles.UpdateAsync(a); + b.Body = "epsilon " + new string('x', 3000); // no longer fits its slot + await db.Articles.UpdateAsync(b); + await db.Articles.InsertAsync(A("c", "zeta")); + await db.Articles.DeleteAsync(db.Articles.AsQueryable().Single(x => x.Title == "c").Id); + await db.Articles.InsertAsync(A("d", "unrelated words")); // may reuse the deleted slot + + List Match(string q) => Titles(db.Articles.AsQueryable().Where(x => x.Body.FullTextMatch(q)).ToList()); + Assert.Empty(Match("alpha")); + Assert.Equal(new[] { "a" }, Match("delta")); + Assert.Empty(Match("gamma")); + Assert.Equal(new[] { "b" }, Match("epsilon")); + Assert.Empty(Match("zeta")); + Assert.Empty(await db.Articles.FullTextSearchAsync("idx_fts_Body", "zeta", 10).ToListAsync()); + } + + [Fact] + public async Task RolledBackWrites_LeaveTheIndexUnchanged() + { + using var db = new FullTextDbContext(_dbPath); + var kept = A("kept", "persistent text"); + await db.Articles.InsertAsync(kept); + + using (var txn = await db.BeginTransactionAsync()) + { + await db.Articles.InsertAsync(A("rolled back", "ephemeral text"), txn); + await db.Articles.DeleteAsync(kept.Id, txn); + await txn.RollbackAsync(); + } + + Assert.Equal(new[] { "kept" }, Titles(db.Articles.AsQueryable().Where(x => x.Body.FullTextMatch("text")).ToList())); + var results = await db.Articles.FullTextSearchAsync("idx_fts_Body", "text", 10).ToListAsync(); + Assert.Equal("kept", Assert.Single(results).Document.Title); + } + + [Fact] + public async Task Index_SurvivesReopen() + { + double score; + using (var db = new FullTextDbContext(_dbPath)) + { + await SeedAsync(db); + score = (await db.Articles.FullTextSearchAsync("idx_fts_Body", "dog", 10).ToListAsync())[0].Score; + } + + using var reopened = new FullTextDbContext(_dbPath); + var results = await reopened.Articles.FullTextSearchAsync("idx_fts_Body", "dog", 10).ToListAsync(); + + Assert.Equal(2, results.Count); + Assert.Equal(score, results[0].Score, 9); + Assert.Contains(reopened.Articles.GetIndexes(), i => i.Name == "idx_fts_Body" && i.Type == IndexType.FullText); + } + + [Fact] + public async Task IndexAddedToExistingData_CoversIt() + { + using (var v1 = new FullTextNoIndexDbContext(_dbPath)) + { + await v1.Articles.InsertAsync(A("old", "written before the index existed")); + } + + using (var v1 = new FullTextNoIndexDbContext(_dbPath)) + { + await v1.Articles.CreateFullTextIndexAsync(a => a.Title, "idx_title_runtime"); + await v1.Articles.CreateFullTextIndexAsync(a => a.Title, "idx_title_runtime"); // existing: no-op + Assert.Single(await v1.Articles.FullTextSearchAsync("idx_title_runtime", "old", 10).ToListAsync()); + } + + using var v2 = new FullTextDbContext(_dbPath); + Assert.Equal(new[] { "old" }, Titles(v2.Articles.AsQueryable().Where(a => a.Body.FullTextMatch("index existed")).ToList())); + } + + [Fact] + public async Task VacuumRebuild_KeepsResultsAndScores() + { + using var db = new FullTextDbContext(_dbPath); + await SeedAsync(db); + var before = await db.Articles.FullTextSearchAsync("idx_fts_Body", "quick fox", 10).ToListAsync(); + + await db.Articles.VacuumAsync(new VacuumOptions { RebuildIndexes = true }); + await db.Articles.VacuumAsync(new VacuumOptions { RebuildIndexes = true }); + var after = await db.Articles.FullTextSearchAsync("idx_fts_Body", "quick fox", 10).ToListAsync(); + + Assert.Equal(before.Select(r => r.Document.Title), after.Select(r => r.Document.Title)); + Assert.Equal(before.Select(r => Math.Round(r.Score, 9)), after.Select(r => Math.Round(r.Score, 9))); + } + + [Fact] + public async Task DroppedIndex_FallsBackToScanning() + { + using var db = new FullTextDbContext(_dbPath); + await SeedAsync(db); + + Assert.True(await db.Articles.DropIndexAsync("idx_fts_Body")); + + Assert.Equal(new[] { "fox", "fox again" }, Titles(db.Articles.AsQueryable().Where(a => a.Body.FullTextMatch("quick fox")).ToList())); + await Assert.ThrowsAsync(async () => await db.Articles.FullTextSearchAsync("idx_fts_Body", "fox", 1).ToListAsync()); + } + + // ── On-disk robustness ────────────────────────────────────────────────── + + private static CollectionSecondaryIndex BodyIndex(FullTextDbContext db) + => db.Articles.IndexManager.GetIndex("idx_fts_Body")!; + + /// A second handle on the tree of idx_fts_Body, to write keys the index never would. + private static BTreeIndex RawTree(FullTextDbContext db) + => new(db.Storage, IndexOptions.CreateBTree("Body"), BodyIndex(db).RootPageId); + + private static async Task WriteAsync(FullTextDbContext db, Action write) + { + using var txn = db.BeginTransaction(); + write(txn.TransactionId); + await txn.CommitAsync(); + } + + /// Indexes at , whatever document is stored there. + private static async Task AddPostingsAsync(FullTextDbContext db, string text, DocumentLocation location) + { + using var txn = db.BeginTransaction(); + new FullTextIndex(db.Storage, IndexOptions.CreateBTree("Body"), BodyIndex(db).RootPageId).Insert(text, location, txn); + await txn.CommitAsync(); + } + + private static List<(string Title, double Score)> Ranked(IEnumerable> results) + => results.Select(r => (r.Document.Title, Math.Round(r.Score, 9))).ToList(); + + [Fact] + public async Task IndexesWrittenByThisVersion_AreNotRebuiltOnOpen() + { + uint body, title, empty; + using (var db = new FullTextDbContext(_dbPath)) + { + await SeedAsync(db); + body = BodyIndex(db).RootPageId; + title = db.Articles.IndexManager.GetIndex("idx_fts_Title")!.RootPageId; + } + var emptyPath = Path.ChangeExtension(_dbPath, ".fresh.db"); + using (var db = new FullTextNoIndexDbContext(emptyPath)) + { + await db.Articles.CreateFullTextIndexAsync(a => a.Title, "idx_empty"); + empty = db.Articles.IndexManager.GetIndex("idx_empty")!.RootPageId; + } + + using (var reopened = new FullTextDbContext(_dbPath)) + { + Assert.Equal(body, BodyIndex(reopened).RootPageId); + Assert.Equal(title, reopened.Articles.IndexManager.GetIndex("idx_fts_Title")!.RootPageId); + Assert.Equal(FullTextIndex.FormatVersion, BodyIndex(reopened).KeyFormat); + } + using (var reopened = new FullTextNoIndexDbContext(emptyPath)) + Assert.Equal(empty, reopened.Articles.IndexManager.GetIndex("idx_empty")!.RootPageId); + } + + [Fact] + public async Task KeysWrittenByAnOlderVersion_AreIgnored_AndTheIndexIsRebuiltOnOpen() + { + List<(string, double)> before; + using (var db = new FullTextDbContext(_dbPath)) + { + await SeedAsync(db); + var expected = Ranked(await db.Articles.FullTextSearchAsync("idx_fts_Body", "quick fox", 10).ToListAsync()); + + // An older BLite reads a full-text index as a plain B-Tree and inserts its generic string + // keys ([0x02][string]), most of them shorter than the statistics entry. + await WriteAsync(db, txn => + { + RawTree(db).Insert(new IndexKey("a"), new DocumentLocation(1, 1), txn); + RawTree(db).Insert(new IndexKey("The quick brown fox jumps over the lazy dog"), new DocumentLocation(1, 2), txn); + }); + + Assert.Equal(expected, Ranked(await db.Articles.FullTextSearchAsync("idx_fts_Body", "quick fox", 10).ToListAsync())); + await db.Articles.InsertAsync(A("later", "a quick reply")); + Assert.Equal(new[] { "fox", "fox again", "later" }, Titles(db.Articles.AsQueryable().Where(a => a.Body.FullTextMatch("quick")).ToList())); + before = Ranked(await db.Articles.FullTextSearchAsync("idx_fts_Body", "quick", 10).ToListAsync()); + Assert.True(BodyIndex(db).NeedsKeyFormatMigration); + } + + using var reopened = new FullTextDbContext(_dbPath); + Assert.False(BodyIndex(reopened).NeedsKeyFormatMigration); + Assert.All(RawTree(reopened).Range(IndexKey.MinKey, IndexKey.MaxKey).ToList(), e => Assert.True(e.Key.Data[0] >= 0x10)); + Assert.Equal(before, Ranked(await reopened.Articles.FullTextSearchAsync("idx_fts_Body", "quick", 10).ToListAsync())); + } + + [Fact] + public async Task IndexFromAnotherFormatVersion_IsRebuiltOnOpen() + { + using (var db = new FullTextDbContext(_dbPath)) + { + await SeedAsync(db); + // Postings the current analyzer would not produce for that document, as if written by + // another version of it: deleting the document would leave them behind. + var dog = Assert.Single(BodyIndex(db).FullTextMatch("sleeps")); + await AddPostingsAsync(db, "ghost", dog); + db.Articles.IndexManager.SetKeyFormat("idx_fts_Body", 0); + } + + var freshPath = Path.ChangeExtension(_dbPath, ".fresh.db"); + using (var fresh = new FullTextDbContext(freshPath)) + await SeedAsync(fresh); + + using var db2 = new FullTextDbContext(_dbPath); + using var fresh2 = new FullTextDbContext(freshPath); + Assert.Equal(FullTextIndex.FormatVersion, BodyIndex(db2).KeyFormat); + Assert.Empty(BodyIndex(db2).FullTextMatch("ghost")); + foreach (var query in new[] { "quick fox", "dog" }) + { + Assert.Equal( + Ranked(await fresh2.Articles.FullTextSearchAsync("idx_fts_Body", query, 10).ToListAsync()), + Ranked(await db2.Articles.FullTextSearchAsync("idx_fts_Body", query, 10).ToListAsync())); + } + } + + [Fact] + public async Task FailedRebuildTriggeredByForeignKeys_IsRetriedOnTheNextOpen() + { + DocumentLocation dog; + byte[] intact; + uint root; + using (var db = new FullTextDbContext(_dbPath)) + { + await SeedAsync(db); + // Only the foreign key triggers the rebuild: the persisted format is already current. + await WriteAsync(db, txn => RawTree(db).Insert(new IndexKey("a"), new DocumentLocation(1, 1), txn)); + Assert.Equal(FullTextIndex.FormatVersion, BodyIndex(db).KeyFormat); + Assert.True(BodyIndex(db).NeedsKeyFormatMigration); + root = BodyIndex(db).RootPageId; + await db.CheckpointAsync(); + + // Make one document unreadable, so the rebuild on the next open fails. + dog = Assert.Single(BodyIndex(db).FullTextMatch("sleeps")); + intact = new byte[db.Storage.PageSize]; + db.Storage.ReadPage(dog.PageId, null, intact); + var corrupt = (byte[])intact.Clone(); + var slot = SlotEntry.ReadFrom(corrupt.AsSpan(SlottedPageHeader.Size + dog.SlotIndex * SlotEntry.Size)); + corrupt.AsSpan(slot.Offset + 4, 12).Fill(0xFF); // invalid element types + db.Storage.WritePageImmediate(dog.PageId, corrupt); + } + + var sizes = new List(); + for (int open = 0; open < 3; open++) + { + var ex = Assert.Throws(() => new FullTextDbContext(_dbPath)); + Assert.Contains("idx_fts_Body", ex.Message); + sizes.Add(new FileInfo(_dbPath).Length); + + using var engine = new BLiteEngine(_dbPath); // the collection itself is not opened + // The old index is still the registered one, foreign key included, so it still needs + // the migration (format current, foreign keys present) and the next open retries it. + var stored = engine.Storage.GetCollectionMetadata("articles")!.Indexes.Single(i => i.Name == "idx_fts_Body"); + Assert.Equal(root, stored.RootPageId); + Assert.Equal(FullTextIndex.FormatVersion, stored.KeyFormat); + Assert.True(new FullTextIndex(engine.Storage, IndexOptions.CreateBTree("Body"), stored.RootPageId).HasForeignKeys()); + if (open == 2) engine.Storage.WritePageImmediate(dog.PageId, intact); + } + // The copy each failed open built is freed, so later opens reuse its pages. + Assert.All(sizes.Skip(1), size => Assert.Equal(sizes[0], size)); + + uint migrated; + using (var repaired = new FullTextDbContext(_dbPath)) + { + migrated = BodyIndex(repaired).RootPageId; + Assert.NotEqual(root, migrated); // the rebuilt copy was swapped in + Assert.Equal(FullTextIndex.FormatVersion, BodyIndex(repaired).KeyFormat); + Assert.False(BodyIndex(repaired).NeedsKeyFormatMigration); + } + + using var reopened = new FullTextDbContext(_dbPath); + Assert.Equal(migrated, BodyIndex(reopened).RootPageId); // not rebuilt again + Assert.Equal(dog, Assert.Single(BodyIndex(reopened).FullTextMatch("sleeps"))); + Assert.Equal(new[] { "fox", "fox again" }, + Titles((await reopened.Articles.FullTextSearchAsync("idx_fts_Body", "quick fox", 10).ToListAsync()).Select(r => r.Document))); + } + + [Fact] + public async Task StalePostings_NeverSurfaceTheDocumentAtTheirLocation() + { + using var db = new FullTextDbContext(_dbPath); + await SeedAsync(db); + var dog = Assert.Single(BodyIndex(db).FullTextMatch("sleeps")); + await AddPostingsAsync(db, "ghost", dog); + + Assert.Single(BodyIndex(db).FullTextMatch("ghost")); // the index alone would return it + Assert.Empty(db.Articles.AsQueryable().Where(a => a.Body.FullTextMatch("ghost")).ToList()); + Assert.Equal(0, db.Articles.AsQueryable().Count(a => a.Body.FullTextMatch("ghost"))); + Assert.Null(db.Articles.AsQueryable().FirstOrDefault(a => a.Body.FullTextMatch("ghost sleeps"))); + Assert.Empty(await db.Articles.FullTextSearchAsync("idx_fts_Body", "ghost", 10).ToListAsync()); + Assert.Empty(await db.Articles.FullTextSearchAsync("idx_fts_Body", "ghost sleeps", 10).ToListAsync()); + var any = await db.Articles.FullTextSearchAsync("idx_fts_Body", "ghost sleeps", 10, matchAllTerms: false).ToListAsync(); + Assert.Equal("dog", Assert.Single(any).Document.Title); // through "sleeps", which it does contain + } + + [Fact] + public async Task StaleHitsInTheTopK_AreReplacedByTheNextCandidates() + { + using var db = new FullTextDbContext(_dbPath); + await SeedAsync(db); + // A stale posting that outranks every document really containing "quick". + var dog = Assert.Single(BodyIndex(db).FullTextMatch("sleeps")); + await AddPostingsAsync(db, "quick quick quick quick quick", dog); + Assert.Equal(dog, BodyIndex(db).FullTextSearch("quick", 1)[0].Location); + + var top1 = await db.Articles.FullTextSearchAsync("idx_fts_Body", "quick", 1).ToListAsync(); + var top2 = await db.Articles.FullTextSearchAsync("idx_fts_Body", "quick", 2).ToListAsync(); + var top5 = await db.Articles.FullTextSearchAsync("idx_fts_Body", "quick", 5).ToListAsync(); + + Assert.Equal("fox again", Assert.Single(top1).Document.Title); + Assert.Equal(new[] { "fox again", "fox" }, top2.Select(r => r.Document.Title)); + Assert.Equal(new[] { "fox again", "fox" }, top5.Select(r => r.Document.Title)); + } + + // Checks membership and order only: the stale posting may be the one scored (see FullTextIndex.Postings). + [Fact] + public async Task StalePostingAtTheLocationOfADocumentWithTheSameTerm_IsCountedOnce() + { + using var db = new FullTextDbContext(_dbPath); + await SeedAsync(db); + var dog = Assert.Single(BodyIndex(db).FullTextMatch("sleeps")); + var before = Ranked(await db.Articles.FullTextSearchAsync("idx_fts_Body", "sleeps", 10).ToListAsync()); + // Same term and location as the real posting, another frequency and length. + await AddPostingsAsync(db, "sleeps", dog); + + Assert.Equal(dog, Assert.Single(BodyIndex(db).FullTextMatch("sleeps"))); + Assert.Equal("dog", Assert.Single(db.Articles.AsQueryable().Where(a => a.Body.FullTextMatch("sleeps")).ToList()).Title); + Assert.Equal("dog", Assert.Single(await db.Articles.FullTextSearchAsync("idx_fts_Body", "sleeps", 10).ToListAsync()).Document.Title); + Assert.Equal(before.Select(r => r.Title), Ranked(await db.Articles.FullTextSearchAsync("idx_fts_Body", "sleeps", 10).ToListAsync()).Select(r => r.Title)); + } + + [Fact] + public async Task MissingStatistics_SearchStillFindsAndRanksTheDocuments() + { + using var db = new FullTextDbContext(_dbPath); + await SeedAsync(db); + await WriteAsync(db, txn => + { + var tree = RawTree(db); + var stats = tree.Range(IndexKey.FromOwnedArray([0x20]), IndexKey.FromOwnedArray([0x21]), IndexDirection.Forward, txn).ToList(); + Assert.True(tree.Delete(Assert.Single(stats).Key, Assert.Single(stats).Location, txn)); + }); + + var all = await db.Articles.FullTextSearchAsync("idx_fts_Body", "quick fox", 10).ToListAsync(); + var any = await db.Articles.FullTextSearchAsync("idx_fts_Body", "sun fox", 10, matchAllTerms: false).ToListAsync(); + + Assert.Equal(new[] { "fox", "fox again" }, Titles(all.Select(r => r.Document))); + Assert.Equal(new[] { "dog", "fox", "fox again" }, Titles(any.Select(r => r.Document))); + Assert.All(all.Concat(any), r => Assert.True(r.Score > 0 && !double.IsInfinity(r.Score))); + } + + [Fact] + public async Task CreateFullTextIndex_UnderTheNameOfAnotherIndex_Throws() + { + using var db = new FullTextDbContext(_dbPath); + await SeedAsync(db); + IDocumentCollection articles = db.Articles; + + var ex = await Assert.ThrowsAsync(() => articles.CreateFullTextIndexAsync(a => a.Body, "idx_Title")); + + Assert.Contains("idx_Title", ex.Message); + Assert.Equal(IndexType.BTree, db.Articles.GetIndexes().Single(i => i.Name == "idx_Title").Type); + Assert.Equal("fox again", db.Articles.AsQueryable().Single(a => a.Title == "fox again").Title); + } + + [Fact] + public async Task TermsLongerThan32Bytes_AreCutAtACharacterBoundary() + { + // 'ж' takes two bytes of UTF-8 and '漢' three, so neither 32-byte cut falls on a character start. + var cyrillic = "a" + new string('ж', 20); // 41 bytes; the term kept is "a" + 15 × 'ж' (31 bytes) + var cjk = new string('漢', 15); // 45 bytes; the term kept is 10 × '漢' (30 bytes) + var text = cyrillic + " " + cjk; + var stored = "a" + new string('ж', 15); + + using var db = new FullTextDbContext(_dbPath); + var article = A("long", text); + await db.Articles.InsertAsync(article); + await db.Articles.InsertAsync(A("other", "unrelated")); + + List Match(string q) => Titles(db.Articles.AsQueryable().Where(a => a.Body.FullTextMatch(q)).ToList()); + Assert.Equal(new[] { "long" }, Match(cyrillic)); + Assert.Equal(new[] { "long" }, Match(cjk)); + Assert.Equal(new[] { "long" }, Match(stored)); // the same term once cut + Assert.Equal(new[] { "long" }, Match(new string('漢', 11) + "字")); // differs only past the cut + Assert.Empty(Match("a" + new string('ж', 14))); // a different, shorter term + Assert.True(text.FullTextMatch(stored)); + Assert.Equal("long", Assert.Single(await db.Articles.FullTextSearchAsync("idx_fts_Body", cjk + " " + cyrillic, 10).ToListAsync()).Document.Title); + + // Deleting recomputes the same cut keys: nothing is left in the index itself. + article.Body = "replaced"; + await db.Articles.UpdateAsync(article); + Assert.Empty(BodyIndex(db).FullTextMatch(cyrillic)); + Assert.Empty(BodyIndex(db).FullTextMatch(cjk)); + Assert.Equal(new[] { "long" }, Match("replaced")); + } + + // ── DynamicCollection ─────────────────────────────────────────────────── + + [Fact] + public async Task Dynamic_CreateSearchUpdateDeleteAndReopen() + { + BsonId movedId; + using (var engine = new BLiteEngine(_dbPath)) + { + var col = engine.GetOrCreateCollection("notes"); + BsonDocument Doc(string title, string text) => col.CreateDocument(["title", "text"], b => b + .AddString("title", title).AddString("text", text)); + + await col.InsertAsync(Doc("before index", "indexed later on")); + await col.CreateFullTextIndexAsync("text"); + movedId = await col.InsertAsync(Doc("moved", "original words")); + var goneId = await col.InsertAsync(Doc("gone", "words to forget")); + + await col.UpdateAsync(movedId, Doc("moved", "replacement words")); + await col.DeleteAsync(goneId); + + Assert.Contains(col.GetIndexDescriptors(), d => d.Name == "idx_fts_text" && d.IsFullText); + } + + using (var engine = new BLiteEngine(_dbPath)) + { + var col = engine.GetOrCreateCollection("notes"); + async Task> Search(string q) => + (await col.FullTextSearchAsync("idx_fts_text", q, 10).ToListAsync()) + .Select(r => r.Document.TryGetString("title", out var t) ? t : null).ToList(); + + Assert.Equal(new[] { "before index" }, await Search("later")); + Assert.Equal(new[] { "moved" }, await Search("words")); + Assert.Empty(await Search("original")); + Assert.Empty(await Search("forget")); + } + } + + /// The full-text index of , as persisted. + private static FullTextIndex StoredFullTextIndex(StorageEngine storage, string collection, string index) + => new(storage, IndexOptions.CreateBTree("text"), storage.GetCollectionMetadata(collection)!.Indexes.Single(i => i.Name == index).RootPageId); + + private static async Task SeedNotesAsync(string path) + { + using var engine = new BLiteEngine(path); + var col = engine.GetOrCreateCollection("notes"); + await col.InsertAsync(col.CreateDocument(["title", "text"], b => b.AddString("title", "kept").AddString("text", "plain words"))); + await col.InsertAsync(col.CreateDocument(["title", "text"], b => b.AddString("title", "other").AddString("text", "more words"))); + await col.CreateFullTextIndexAsync("text"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Dynamic_IndexFromAnotherFormatVersionOrWithForeignKeys_IsRebuiltOnOpen(bool foreignKey) + { + await SeedNotesAsync(_dbPath); + using (var engine = new BLiteEngine(_dbPath)) // the collection itself is not opened + { + var storage = engine.Storage; + var index = StoredFullTextIndex(storage, "notes", "idx_fts_text"); + var kept = Assert.Single(index.Match("plain", null)); + var txn = storage.BeginTransaction(); + index.Insert("ghost", kept, txn); + if (foreignKey) + new BTreeIndex(storage, IndexOptions.CreateBTree("text"), index.RootPageId).Insert(new IndexKey("plain words"), kept, txn.TransactionId); + await txn.CommitAsync(); + + if (!foreignKey) + { + var metadata = storage.GetCollectionMetadata("notes")!; + metadata.Indexes.Single(i => i.Name == "idx_fts_text").KeyFormat = 0; + storage.SaveCollectionMetadata(metadata); + } + Assert.Single(StoredFullTextIndex(storage, "notes", "idx_fts_text").Match("ghost", null)); + } + + using (var engine = new BLiteEngine(_dbPath)) + { + var col = engine.GetOrCreateCollection("notes"); + var rebuilt = StoredFullTextIndex(engine.Storage, "notes", "idx_fts_text"); + + Assert.Empty(rebuilt.Match("ghost", null)); + Assert.False(rebuilt.HasForeignKeys()); + Assert.Equal(FullTextIndex.FormatVersion, engine.Storage.GetCollectionMetadata("notes")!.Indexes.Single(i => i.Name == "idx_fts_text").KeyFormat); + Assert.Equal(2, (await col.FullTextSearchAsync("idx_fts_text", "words", 10).ToListAsync()).Count); + } + + using (var engine = new BLiteEngine(_dbPath)) + { + // Rebuilt once: the next open keeps the tree. + var root = engine.Storage.GetCollectionMetadata("notes")!.Indexes.Single(i => i.Name == "idx_fts_text").RootPageId; + engine.GetOrCreateCollection("notes"); + Assert.Equal(root, engine.Storage.GetCollectionMetadata("notes")!.Indexes.Single(i => i.Name == "idx_fts_text").RootPageId); + } + } + + /// + /// A rebuild on open that fails late, after the new tree has split, frees every page of the + /// partial rebuild and keeps the old index: it is retried on each open without growing the + /// file, and succeeds once the document can be read again. + /// + [Fact] + public async Task Dynamic_RebuildOnOpen_ThatFailsLate_KeepsTheOldIndex_AndDoesNotGrowTheFile() + { + const int count = 3000; + uint root; + DocumentLocation last; + byte[] intact; + using (var engine = new BLiteEngine(_dbPath)) + { + var col = engine.GetOrCreateCollection("notes"); + await col.InsertBulkAsync(Enumerable.Range(0, count).Select(i => col.CreateDocument(["title", "text"], b => b + .AddString("title", $"n{i}").AddString("text", $"note number{i} about topic{i % 50} and more words"))).ToList()); + await col.CreateFullTextIndexAsync("text"); + + var storage = engine.Storage; + var index = StoredFullTextIndex(storage, "notes", "idx_fts_text"); + root = index.RootPageId; + // A foreign key makes the next open rebuild the index. + var txn = storage.BeginTransaction(); + new BTreeIndex(storage, IndexOptions.CreateBTree("text"), root).Insert(new IndexKey("plain"), new DocumentLocation(1, 1), txn.TransactionId); + await txn.CommitAsync(); + await engine.CheckpointAsync(); + + // Make the last document in primary-key order unreadable, so the rebuild fails only + // after thousands of inserts. + var primary = new BTreeIndex(storage, IndexOptions.CreateUnique("_id"), storage.GetCollectionMetadata("notes")!.PrimaryRootPageId); + last = primary.Range(IndexKey.MinKey, IndexKey.MaxKey, IndexDirection.Backward).First().Location; + intact = new byte[storage.PageSize]; + storage.ReadPage(last.PageId, null, intact); + var corrupt = (byte[])intact.Clone(); + var slot = SlotEntry.ReadFrom(corrupt.AsSpan(SlottedPageHeader.Size + last.SlotIndex * SlotEntry.Size)); + corrupt.AsSpan(slot.Offset + 4, 12).Fill(0xFF); // invalid element types + storage.WritePageImmediate(last.PageId, corrupt); + } + + var sizes = new List(); + for (int open = 0; open < 4; open++) + { + using (var engine = new BLiteEngine(_dbPath)) + Assert.ThrowsAny(() => engine.GetOrCreateCollection("notes")); + sizes.Add(new FileInfo(_dbPath).Length); + + using (var engine = new BLiteEngine(_dbPath)) // the collection itself is not opened + { + // The old index is still the persisted one, foreign key and all, so the next open retries. + var stored = StoredFullTextIndex(engine.Storage, "notes", "idx_fts_text"); + Assert.Equal(root, stored.RootPageId); + Assert.True(stored.HasForeignKeys()); + Assert.Single(stored.Match("number7", null)); + } + } + // The first failed open may extend the file; the following ones reuse the freed pages. + Assert.All(sizes.Skip(1), size => Assert.Equal(sizes[0], size)); + + using (var engine = new BLiteEngine(_dbPath)) + engine.Storage.WritePageImmediate(last.PageId, intact); + + uint rebuilt; + using (var engine = new BLiteEngine(_dbPath)) + { + var col = engine.GetOrCreateCollection("notes"); + var stored = StoredFullTextIndex(engine.Storage, "notes", "idx_fts_text"); + rebuilt = stored.RootPageId; + Assert.NotEqual(root, rebuilt); + Assert.False(stored.HasForeignKeys()); + Assert.Equal(count / 50, (await col.FullTextSearchAsync("idx_fts_text", "topic7", count).ToListAsync()).Count); + } + + using (var engine = new BLiteEngine(_dbPath)) + { + // Rebuilt once: the next open keeps the tree. + engine.GetOrCreateCollection("notes"); + Assert.Equal(rebuilt, StoredFullTextIndex(engine.Storage, "notes", "idx_fts_text").RootPageId); + } + } + + [Fact] + public async Task Dynamic_StalePostings_NeverSurfaceTheDocumentAtTheirLocation() + { + await SeedNotesAsync(_dbPath); + using var engine = new BLiteEngine(_dbPath); + var index = StoredFullTextIndex(engine.Storage, "notes", "idx_fts_text"); + var txn = engine.Storage.BeginTransaction(); + index.Insert("ghost", Assert.Single(index.Match("plain", null)), txn); + await txn.CommitAsync(); + + var col = engine.GetOrCreateCollection("notes"); + Assert.Empty(await col.FullTextSearchAsync("idx_fts_text", "ghost", 10).ToListAsync()); + var any = await col.FullTextSearchAsync("idx_fts_text", "ghost plain", 10, matchAllTerms: false).ToListAsync(); + Assert.Equal("kept", Assert.Single(any).Document.TryGetString("title", out var t) ? t : null); + + // A stale hit ranked first is replaced by the next candidate, so k hits still come back. + txn = engine.Storage.BeginTransaction(); + index.Insert("more more more more", Assert.Single(index.Match("plain", null)), txn); + await txn.CommitAsync(); + var top1 = await col.FullTextSearchAsync("idx_fts_text", "more", 1).ToListAsync(); + Assert.Equal("other", Assert.Single(top1).Document.TryGetString("title", out var o) ? o : null); + } + + // ── Required feature ──────────────────────────────────────────────────── + + private static bool RequiresFullText(StorageEngine storage, string collection) + => storage.GetCollectionMetadata(collection)!.RequiredFeatures.Contains(CollectionFeatures.FullTextIndexes); + + [Fact] + public async Task RequiredFeature_IsRecordedForModelDeclaredIndexes_AndClearedWithTheLastOne() + { + using (var db = new FullTextDbContext(_dbPath)) + { + await SeedAsync(db); + Assert.True(RequiresFullText(db.Storage, "articles")); + } + + using (var db = new FullTextNoIndexDbContext(_dbPath)) // the indexes stay, so does the feature + { + Assert.True(RequiresFullText(db.Storage, "articles")); + Assert.True(await db.Articles.DropIndexAsync("idx_fts_Title")); + Assert.True(RequiresFullText(db.Storage, "articles")); // idx_fts_Body is left + Assert.True(await db.Articles.DropIndexAsync("idx_fts_Body")); + Assert.False(RequiresFullText(db.Storage, "articles")); + } + + using (var db = new FullTextNoIndexDbContext(_dbPath)) + Assert.False(RequiresFullText(db.Storage, "articles")); + } + + [Fact] + public async Task RequiredFeature_IsRecordedByCreateFullTextIndexAsync_AndSurvivesReopen() + { + using (var db = new FullTextNoIndexDbContext(_dbPath)) + { + await db.Articles.InsertAsync(A("fox", "The quick brown fox")); + Assert.False(RequiresFullText(db.Storage, "articles")); + await db.Articles.CreateFullTextIndexAsync(a => a.Body); + Assert.True(RequiresFullText(db.Storage, "articles")); + } + + using (var db = new FullTextNoIndexDbContext(_dbPath)) + { + Assert.True(RequiresFullText(db.Storage, "articles")); + Assert.True(await db.Articles.DropIndexAsync("idx_fts_Body")); + Assert.False(RequiresFullText(db.Storage, "articles")); + } + } + + [Fact] + public async Task Dynamic_RequiredFeature_IsRecordedOnCreate_SurvivesReopen_AndIsClearedWithTheLastIndex() + { + using (var engine = new BLiteEngine(_dbPath)) + { + var col = engine.GetOrCreateCollection("notes"); + await col.InsertAsync(col.CreateDocument(["title", "text"], b => b.AddString("title", "a").AddString("text", "plain words"))); + await col.CreateIndexAsync("title"); + Assert.False(RequiresFullText(engine.Storage, "notes")); + await col.CreateFullTextIndexAsync("text"); + await col.CreateFullTextIndexAsync("title", "idx_fts_title"); + Assert.True(RequiresFullText(engine.Storage, "notes")); + } + + using (var engine = new BLiteEngine(_dbPath)) + { + var col = engine.GetOrCreateCollection("notes"); + Assert.True(RequiresFullText(engine.Storage, "notes")); + Assert.True(col.DropIndex("idx_fts_text")); + Assert.True(RequiresFullText(engine.Storage, "notes")); + Assert.True(col.DropIndex("idx_fts_title")); + Assert.False(RequiresFullText(engine.Storage, "notes")); + } + + using (var engine = new BLiteEngine(_dbPath)) + { + engine.GetOrCreateCollection("notes"); + Assert.False(RequiresFullText(engine.Storage, "notes")); + } + } + + /// + /// A release that predates the feature rewrites the record without it (5.2.x drops the trailing + /// section); the next read-write open records it again, a read-only open leaves the file alone. + /// + [Fact] + public async Task RequiredFeature_DroppedByAnOlderRelease_IsRecordedAgainOnTheNextReadWriteOpen() + { + static void DropFeature(string path, string collection) + { + using var storage = new StorageEngine(path, PageFileConfig.Default); + var meta = storage.GetCollectionMetadata(collection)!; + Assert.Equal(1, meta.RequiredFeatures.RemoveAll(f => f.Id == CollectionFeatures.FullTextIndexes.Id)); + storage.SaveCollectionMetadata(meta); + } + + using (var db = new FullTextDbContext(_dbPath)) + await SeedAsync(db); + await SeedNotesAsync(_dbPath); + DropFeature(_dbPath, "articles"); + DropFeature(_dbPath, "notes"); + + var readOnly = PageFileConfig.Default with { Access = System.IO.MemoryMappedFiles.MemoryMappedFileAccess.Read, AllowMultiProcessAccess = true }; + using (var engine = new BLiteEngine(_dbPath, readOnly)) + { + engine.GetOrCreateCollection("notes"); + Assert.False(RequiresFullText(engine.Storage, "notes")); + } + + using (var engine = new BLiteEngine(_dbPath)) + { + engine.GetOrCreateCollection("notes"); + Assert.True(RequiresFullText(engine.Storage, "notes")); + } + // Last: the typed context drops "notes", which it does not map. + using (var db = new FullTextNoIndexDbContext(_dbPath)) + Assert.True(RequiresFullText(db.Storage, "articles")); + } + + /// + /// What a version without full-text indexes makes of the record: it refuses to open the + /// collection, read-only included, from the recorded feature alone (a version that knew type 6 + /// but not the feature) and from the index type alone (one whose metadata lost the feature). + /// + [Fact] + public async Task RequiredFeature_IsNeededToOpen_SoAVersionWithoutFullTextRefusesEvenReadOnly() + { + using (var db = new FullTextDbContext(_dbPath)) + await SeedAsync(db); + await SeedNotesAsync(_dbPath); + + using var storage = new StorageEngine(_dbPath, PageFileConfig.Default); + foreach (var name in new[] { "articles", "notes" }) + { + var meta = storage.GetCollectionMetadata(name)!; + Assert.Equal(FeatureRequirement.Open, Assert.Single(meta.RequiredFeatures, f => f.Id == "fulltext-index").Requirement); + Assert.True(CollectionFeatures.CanOpen(meta, readOnly: false)); // this version knows it + + var featureOnly = CollectionFeatures.Unsupported(meta, readOnly: true, new HashSet(), (byte)IndexType.FullText); + Assert.Equal(CollectionFeatures.FullTextIndexes, Assert.Single(featureOnly)); + + meta.RequiredFeatures.Clear(); + var typeOnly = CollectionFeatures.Unsupported(meta, readOnly: true, new HashSet(), (byte)IndexType.Spatial); + Assert.NotEmpty(typeOnly); + Assert.All(typeOnly, f => Assert.Equal(FeatureRequirement.Open, f.Requirement)); + } + } +}