From 8cff3de985729edb050edffdaf7ebefe0305f090 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Mon, 12 Oct 2026 14:21:00 +0200 Subject: [PATCH 1/5] fix(index): fill model-declared indexes from existing documents (#171) An index declared in OnModelCreating was created empty when the collection already held documents, so queries routed to it missed every older document. This hit the usual way of adding an index: a new app version that adds HasIndex, HasVectorIndex or HasSpatialIndex to an existing collection. ApplyIndexBuilder now builds a missing index unregistered, fills it from the primary index, and only then registers and persists it. A crash before that leaves no empty index in the metadata, and the next open builds it again. If a document cannot be loaded or indexed, the open fails with an InvalidOperationException naming the index, and nothing is persisted. Existing indexes are still a cheap name lookup on every open. Co-Authored-By: Claude Opus 5.5 --- .../Collections/DocumentCollection.cs | 35 +++---- .../Indexing/CollectionIndexManager.cs | 51 ++++++++++ tests/BLite.Shared/IndexEvolutionContexts.cs | 58 +++++++++++ tests/BLite.Tests/IndexBackfillTests.cs | 98 +++++++++++++++++++ 4 files changed, 225 insertions(+), 17 deletions(-) create mode 100644 tests/BLite.Shared/IndexEvolutionContexts.cs create mode 100644 tests/BLite.Tests/IndexBackfillTests.cs diff --git a/src/BLite.Core/Collections/DocumentCollection.cs b/src/BLite.Core/Collections/DocumentCollection.cs index 19d77c2..2f47927 100644 --- a/src/BLite.Core/Collections/DocumentCollection.cs +++ b/src/BLite.Core/Collections/DocumentCollection.cs @@ -1070,28 +1070,29 @@ public IEnumerable GetIndexes() [RequiresDynamicCode("Index creation compiles key selector expressions using Expression.Compile() which requires dynamic code generation.")] internal void ApplyIndexBuilder(Metadata.IndexBuilder builder) { - // Use the IndexManager directly to ensure the index exists - // We need to convert the LambdaExpression to a typed expression if possible, - // or add an untyped CreateIndexAsync to IndexManager. + var name = CollectionIndexManager.ResolveIndexName(builder.KeySelector, builder.Name); + if (_indexManager.GetIndex(name) != null) + return; - // For now, let's use a dynamic approach or cast if we know it's Func - if (builder.Type == IndexType.Vector) - { - _indexManager.CreateVectorIndexUntyped(builder.KeySelector, builder.Dimensions, builder.Metric, builder.Name); - } - else if (builder.Type == IndexType.Spatial) - { - _indexManager.CreateSpatialIndexUntyped(builder.KeySelector, builder.Name); - } - else if (builder.KeySelector is System.Linq.Expressions.Expression> selector) + // 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 { - _indexManager.EnsureIndex(selector, builder.Name, builder.IsUnique); + // Run on the thread pool so a captured SynchronizationContext cannot deadlock the wait. + Task.Run(() => RebuildIndexAsync(index, skipUnreadableDocuments: false)).GetAwaiter().GetResult(); } - else + catch (Exception ex) { - // Try to rebuild the expression or use untyped version - _indexManager.EnsureIndexUntyped(builder.KeySelector, builder.Name, builder.IsUnique); + index.FreeAllPages(_storage); + index.Dispose(); + throw new InvalidOperationException( + $"Failed to build index '{name}' of collection '{_collectionName}' from its existing documents; it is built again on the next open.", ex); } + _indexManager.Register(index); } /// diff --git a/src/BLite.Core/Indexing/CollectionIndexManager.cs b/src/BLite.Core/Indexing/CollectionIndexManager.cs index d465365..674646a 100644 --- a/src/BLite.Core/Indexing/CollectionIndexManager.cs +++ b/src/BLite.Core/Indexing/CollectionIndexManager.cs @@ -110,6 +110,57 @@ public CollectionSecondaryIndex CreateIndex(CollectionIndexDefinition } } + /// 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 release it with + /// . + /// + internal CollectionSecondaryIndex CreateUnregistered(CollectionIndexDefinition definition) + => new(definition, _storage, _mapper); + + /// Registers an index built by and persists it. + 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"); + + _indexes[index.Name] = index; + _cachedIndexInfo = null; // invalidate cache + SaveMetadata(); + } + } + // ... methods ... /// diff --git a/tests/BLite.Shared/IndexEvolutionContexts.cs b/tests/BLite.Shared/IndexEvolutionContexts.cs new file mode 100644 index 0000000..bbcb44a --- /dev/null +++ b/tests/BLite.Shared/IndexEvolutionContexts.cs @@ -0,0 +1,58 @@ +using BLite.Bson; +using BLite.Core; +using BLite.Core.Collections; +using BLite.Core.Indexing; +using BLite.Core.Metadata; + +namespace BLite.Shared; + +// Contexts for adding model-declared indexes to collections that already hold documents. + +/// "App v1": the collections without any secondary index. +public partial class IndexEvoV1DbContext : DocumentDbContext +{ + public DocumentCollection People { get; set; } = null!; + public DocumentCollection Vectors { get; set; } = null!; + public DocumentCollection Places { get; set; } = null!; + + public IndexEvoV1DbContext(string path) : base(path) => InitializeCollections(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().ToCollection("evo_people"); + modelBuilder.Entity().ToCollection("evo_vectors"); + modelBuilder.Entity().ToCollection("evo_places"); + } +} + +/// "App v2": the same collections with a B-Tree, a vector and a spatial index. +public partial class IndexEvoV2DbContext : DocumentDbContext +{ + public DocumentCollection People { get; set; } = null!; + public DocumentCollection Vectors { get; set; } = null!; + public DocumentCollection Places { get; set; } = null!; + + public IndexEvoV2DbContext(string path) : base(path) => InitializeCollections(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().ToCollection("evo_people").HasIndex(p => p.Age); + modelBuilder.Entity().ToCollection("evo_vectors") + .HasVectorIndex(x => x.Embedding, dimensions: 3, metric: VectorMetric.L2, name: "idx_vector"); + modelBuilder.Entity().ToCollection("evo_places").HasSpatialIndex(x => x.Location, name: "idx_spatial"); + } +} + +/// Declares a vector index whose dimension does not match the stored embeddings. +public partial class IndexEvoBadVectorDbContext : DocumentDbContext +{ + public DocumentCollection Vectors { get; set; } = null!; + + public IndexEvoBadVectorDbContext(string path) : base(path) => InitializeCollections(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().ToCollection("evo_vectors") + .HasVectorIndex(x => x.Embedding, dimensions: 4, metric: VectorMetric.L2, name: "idx_vector"); + } +} diff --git a/tests/BLite.Tests/IndexBackfillTests.cs b/tests/BLite.Tests/IndexBackfillTests.cs new file mode 100644 index 0000000..714ffb4 --- /dev/null +++ b/tests/BLite.Tests/IndexBackfillTests.cs @@ -0,0 +1,98 @@ +using BLite.Core; +using BLite.Shared; + +namespace BLite.Tests; + +/// +/// An index declared in the model of a collection that already holds documents must be filled +/// from them when it is first created (#171). +/// +public class IndexBackfillTests : IDisposable +{ + private readonly string _dbPath; + + public IndexBackfillTests() + { + _dbPath = Path.Combine(Path.GetTempPath(), $"idx_backfill_{Guid.NewGuid():N}.db"); + } + + public void Dispose() + { + foreach (var f in new[] { _dbPath, Path.ChangeExtension(_dbPath, ".wal") }) + try { if (File.Exists(f)) File.Delete(f); } catch { /* best-effort */ } + } + + private async Task SeedAsync() + { + using var v1 = new IndexEvoV1DbContext(_dbPath); + for (int i = 0; i < 100; i++) + await v1.People.InsertAsync(new PersonV2 { Name = $"p{i}", Age = i % 10 }); + await v1.Vectors.InsertAsync(new VectorEntity { Title = "A", Embedding = [0f, 0f, 0f] }); + await v1.Vectors.InsertAsync(new VectorEntity { Title = "B", Embedding = [5f, 5f, 5f] }); + await v1.Places.InsertAsync(new GeoEntity { Name = "Milan", Location = (45.46, 9.19) }); + await v1.Places.InsertAsync(new GeoEntity { Name = "New York", Location = (40.71, -74.01) }); + } + + [Fact] + public async Task AddedBTreeIndex_CoversExistingDocuments() + { + await SeedAsync(); + + using var v2 = new IndexEvoV2DbContext(_dbPath); + + Assert.Equal(10, v2.People.AsQueryable().Where(p => p.Age == 3).ToList().Count); + Assert.Equal(10, (await v2.People.QueryIndexAsync("idx_Age", 3, 3).ToListAsync()).Count); + } + + [Fact] + public async Task AddedVectorIndex_CoversExistingDocuments() + { + await SeedAsync(); + + using var v2 = new IndexEvoV2DbContext(_dbPath); + + var nearest = await v2.Vectors.VectorSearchAsync("idx_vector", [4f, 4f, 4f], 1).ToListAsync(); + Assert.Equal("B", Assert.Single(nearest).Title); + } + + [Fact] + public async Task AddedSpatialIndex_CoversExistingDocuments() + { + await SeedAsync(); + + using var v2 = new IndexEvoV2DbContext(_dbPath); + + var inBox = await v2.Places.WithinAsync("idx_spatial", (44.0, 8.0), (46.0, 10.0)).ToListAsync(); + Assert.Equal("Milan", Assert.Single(inBox).Name); + } + + [Fact] + public async Task AddedIndex_IsFilledOnce_AndMaintainedAfterwards() + { + await SeedAsync(); + using (var v2 = new IndexEvoV2DbContext(_dbPath)) + await v2.People.InsertAsync(new PersonV2 { Name = "late", Age = 3 }); + + using var reopened = new IndexEvoV2DbContext(_dbPath); + + Assert.Equal(11, (await reopened.People.QueryIndexAsync("idx_Age", 3, 3).ToListAsync()).Count); + } + + [Fact] + public async Task IndexThatCannotBeFilled_FailsTheOpen_AndIsNotPersisted() + { + await SeedAsync(); + + // The stored embeddings have 3 dimensions; the declared index expects 4. + var ex = Assert.Throws(() => new IndexEvoBadVectorDbContext(_dbPath)); + Assert.Contains("idx_vector", ex.Message); + + // Nothing was registered, so the data stays readable and the next open tries again. + using (var v1 = new IndexEvoV1DbContext(_dbPath)) + { + Assert.DoesNotContain(v1.Vectors.GetIndexes(), i => i.Name == "idx_vector"); + Assert.Equal(2, v1.Vectors.AsQueryable().ToList().Count); + } + Assert.Throws(() => new IndexEvoBadVectorDbContext(_dbPath)); + } +} From 1c04a620b9d6f37a924534fcf91d39366e277c89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Mon, 12 Oct 2026 15:35:55 +0200 Subject: [PATCH 2/5] fix(index): address review of model-declared index backfill (#171) - The error when an existing document cannot be indexed now says that nothing was saved and that every open fails the same way until the documents can be indexed or the index is removed from OnModelCreating, and includes the underlying message. - Removes CollectionIndexManager.EnsureIndexUntyped and the public CreateVectorIndexUntyped / CreateSpatialIndexUntyped, which BuildDefinition made dead code. - The CreateUnregistered doc comment notes that a spatial index cannot release its pages yet. Opening a context whose model declares a new index over documents it cannot index now throws; before, the index was created empty. Co-Authored-By: Claude Opus 5.5 --- .../Collections/DocumentCollection.cs | 4 +- .../Indexing/CollectionIndexManager.cs | 75 +------------------ tests/BLite.Tests/IndexBackfillTests.cs | 2 + 3 files changed, 7 insertions(+), 74 deletions(-) diff --git a/src/BLite.Core/Collections/DocumentCollection.cs b/src/BLite.Core/Collections/DocumentCollection.cs index 2f47927..796fbed 100644 --- a/src/BLite.Core/Collections/DocumentCollection.cs +++ b/src/BLite.Core/Collections/DocumentCollection.cs @@ -1090,7 +1090,9 @@ internal void ApplyIndexBuilder(Metadata.IndexBuilder builder) index.FreeAllPages(_storage); index.Dispose(); throw new InvalidOperationException( - $"Failed to build index '{name}' of collection '{_collectionName}' from its existing documents; it is built again on the next open.", ex); + $"Cannot build index '{name}' of collection '{_collectionName}' from the documents already in it: {ex.Message} " + + "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); } _indexManager.Register(index); } diff --git a/src/BLite.Core/Indexing/CollectionIndexManager.cs b/src/BLite.Core/Indexing/CollectionIndexManager.cs index 674646a..280f44f 100644 --- a/src/BLite.Core/Indexing/CollectionIndexManager.cs +++ b/src/BLite.Core/Indexing/CollectionIndexManager.cs @@ -140,7 +140,8 @@ internal static CollectionIndexDefinition BuildDefinition( /// 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 release it with - /// . + /// (a spatial index cannot release + /// its pages yet, so an abandoned one leaks them). /// internal CollectionSecondaryIndex CreateUnregistered(CollectionIndexDefinition definition) => new(definition, _storage, _mapper); @@ -239,78 +240,6 @@ public CollectionSecondaryIndex EnsureIndex( } } - [RequiresDynamicCode("Index creation compiles key selector expressions using Expression.Compile() which requires dynamic code generation.")] - internal CollectionSecondaryIndex EnsureIndexUntyped( - LambdaExpression keySelector, - string? name = null, - bool unique = false) - { - // Convert LambdaExpression to Expression> properly by sharing parameters - var body = keySelector.Body; - if (body.Type != typeof(object)) - { - body = Expression.Convert(body, typeof(object)); - } - - var lambda = Expression.Lambda>(body, keySelector.Parameters); - - return EnsureIndex(lambda, name, unique); - } - - [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); - } - } - - [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 /// diff --git a/tests/BLite.Tests/IndexBackfillTests.cs b/tests/BLite.Tests/IndexBackfillTests.cs index 714ffb4..c1ef444 100644 --- a/tests/BLite.Tests/IndexBackfillTests.cs +++ b/tests/BLite.Tests/IndexBackfillTests.cs @@ -86,6 +86,8 @@ public async Task IndexThatCannotBeFilled_FailsTheOpen_AndIsNotPersisted() // The stored embeddings have 3 dimensions; the declared index expects 4. var ex = Assert.Throws(() => new IndexEvoBadVectorDbContext(_dbPath)); Assert.Contains("idx_vector", ex.Message); + Assert.Contains("removed from the model", ex.Message); + Assert.IsType(ex.InnerException); // the dimension mismatch // Nothing was registered, so the data stays readable and the next open tries again. using (var v1 = new IndexEvoV1DbContext(_dbPath)) From 1cb3a8e4e22fcbccdbc02e268d08a88450fddf5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Mon, 12 Oct 2026 19:00:15 +0200 Subject: [PATCH 3/5] fix(index): address follow-ups on model-declared index backfill (#171) - Restore the public CreateVectorIndexUntyped and CreateSpatialIndexUntyped on CollectionIndexManager, unchanged but marked [Obsolete]; the internal EnsureIndexUntyped stays removed. - The backfill failure message ends the inner message with exactly one full stop before "Nothing was saved". Co-Authored-By: Claude Opus 5.5 --- .../Collections/DocumentCollection.cs | 2 +- .../Indexing/CollectionIndexManager.cs | 56 +++++++++++++++++++ tests/BLite.Tests/IndexBackfillTests.cs | 1 + 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/BLite.Core/Collections/DocumentCollection.cs b/src/BLite.Core/Collections/DocumentCollection.cs index 796fbed..ce52d91 100644 --- a/src/BLite.Core/Collections/DocumentCollection.cs +++ b/src/BLite.Core/Collections/DocumentCollection.cs @@ -1090,7 +1090,7 @@ internal void ApplyIndexBuilder(Metadata.IndexBuilder builder) index.FreeAllPages(_storage); index.Dispose(); throw new InvalidOperationException( - $"Cannot build index '{name}' of collection '{_collectionName}' from the documents already in it: {ex.Message} " + + $"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); } diff --git a/src/BLite.Core/Indexing/CollectionIndexManager.cs b/src/BLite.Core/Indexing/CollectionIndexManager.cs index 280f44f..672d776 100644 --- a/src/BLite.Core/Indexing/CollectionIndexManager.cs +++ b/src/BLite.Core/Indexing/CollectionIndexManager.cs @@ -240,6 +240,62 @@ public CollectionSecondaryIndex EnsureIndex( } } + [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 /// diff --git a/tests/BLite.Tests/IndexBackfillTests.cs b/tests/BLite.Tests/IndexBackfillTests.cs index c1ef444..9d59801 100644 --- a/tests/BLite.Tests/IndexBackfillTests.cs +++ b/tests/BLite.Tests/IndexBackfillTests.cs @@ -88,6 +88,7 @@ public async Task IndexThatCannotBeFilled_FailsTheOpen_AndIsNotPersisted() Assert.Contains("idx_vector", ex.Message); Assert.Contains("removed from the model", ex.Message); Assert.IsType(ex.InnerException); // the dimension mismatch + Assert.Contains($"{ex.InnerException!.Message.TrimEnd('.')}. Nothing was saved", ex.Message); // Nothing was registered, so the data stays readable and the next open tries again. using (var v1 = new IndexEvoV1DbContext(_dbPath)) From 4a15f87b4e88239994c5e881eca483dee9e90663 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 15:20:42 +0200 Subject: [PATCH 4/5] fix(index): free a partially built model index and roll back a failed registration (#171) Address review of #175 on top of #170: - ApplyIndexBuilder fills the new index through the same helper as the vacuum rebuild (FillUnregisteredIndexAsync): its own transaction, pages collected through it before the rollback, then freed. Before, a fill that failed after splits freed only the root, so every failing open leaked the rest of the partial tree and grew the file. - PublishFilledIndex hands a filled index to Register or ReplaceIndex and frees it if it did not get registered; used by both callers. - CollectionIndexManager: Register and ReplaceIndex share PublishLocked, which restores the previous registration (or none) when the metadata cannot be saved, and refreshes the unique-index flag. Register skipped that refresh, so a unique index added by the model was not enforced until the next open. - CreateUnregisteredCopy is gone; callers use CreateUnregistered with the existing definition, which now checks for disposal. Tests: a fill that fails on the last of 3000 documents does not grow the file over repeated opens; a failed metadata save leaves the index unregistered and frees its pages; a unique model index is enforced in the session that adds it. All three fail without these changes. Co-Authored-By: Claude Opus 5.5 --- .../Collections/DocumentCollection.cs | 56 ++++++--- .../Indexing/CollectionIndexManager.cs | 91 +++++++------- tests/BLite.Tests/IndexBackfillTests.cs | 117 ++++++++++++++++++ 3 files changed, 201 insertions(+), 63 deletions(-) diff --git a/src/BLite.Core/Collections/DocumentCollection.cs b/src/BLite.Core/Collections/DocumentCollection.cs index fe1c910..3d39d94 100644 --- a/src/BLite.Core/Collections/DocumentCollection.cs +++ b/src/BLite.Core/Collections/DocumentCollection.cs @@ -724,7 +724,7 @@ public async Task VacuumAsync(VacuumOptions? options = null, CancellationToken c // RTreeIndex cannot enumerate its pages yet, so a rebuild would leak the // whole old tree on every vacuum. Spatial indexes are kept as they are. if (index.Definition.Type == IndexType.Spatial) continue; - await RebuildIndexFromScratchAsync(index.Name, ct).ConfigureAwait(false); + await RebuildIndexFromScratchAsync(index, ct).ConfigureAwait(false); } } @@ -1088,18 +1088,16 @@ internal void ApplyIndexBuilder(Metadata.IndexBuilder builder) try { // Run on the thread pool so a captured SynchronizationContext cannot deadlock the wait. - Task.Run(() => RebuildIndexAsync(index, skipUnreadableDocuments: false)).GetAwaiter().GetResult(); + Task.Run(() => FillUnregisteredIndexAsync(index, CancellationToken.None)).GetAwaiter().GetResult(); } catch (Exception ex) { - index.FreeAllPages(_storage); - index.Dispose(); 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); } - _indexManager.Register(index); + PublishFilledIndex(index, _indexManager.Register); } /// @@ -1603,55 +1601,75 @@ public async IAsyncEnumerable QueryIndexAsync( } /// - /// Populates an empty copy of index from the documents and swaps it in + /// 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(string name, CancellationToken ct) + 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 fresh = _indexManager.CreateUnregisteredCopy(name); var transaction = _storage.BeginTransaction(IsolationLevel.ReadCommitted); try { - await FillIndexAsync(fresh, transaction, skipUnreadableDocuments: false, ct).ConfigureAwait(false); + await FillIndexAsync(index, transaction, skipUnreadableDocuments: false, ct).ConfigureAwait(false); await transaction.CommitAsync(ct); } catch { - // Collect the copy's pages through the transaction before rolling it back: pages added + // 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 = fresh.CollectAllPages(transaction.TransactionId); } + 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 */ } - FreeUnregisteredCopy(fresh, pages); + 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 { - _indexManager.ReplaceIndex(name, fresh); + register(index); } catch { - // The copy is committed but, unless the swap itself went through, nothing refers to it. - if (!ReferenceEquals(_indexManager.GetIndex(name), fresh)) + if (!ReferenceEquals(_indexManager.GetIndex(index.Name), index)) { IReadOnlyList pages = Array.Empty(); - try { pages = fresh.CollectAllPages(0); } + try { pages = index.CollectAllPages(0); } catch { /* best-effort: never mask the original failure */ } - FreeUnregisteredCopy(fresh, pages); + FreeUnregisteredIndex(index, pages); } throw; } } - private void FreeUnregisteredCopy(CollectionSecondaryIndex copy, IReadOnlyList pages) + private void FreeUnregisteredIndex(CollectionSecondaryIndex index, IReadOnlyList pages) { try { @@ -1659,7 +1677,7 @@ private void FreeUnregisteredCopy(CollectionSecondaryIndex copy, IReadOn _storage.FreePage(pageId); } catch { /* best-effort: never mask the original failure */ } - copy.Dispose(); + index.Dispose(); } private async Task RebuildIndexAsync(CollectionSecondaryIndex index, CancellationToken ct = default, bool skipUnreadableDocuments = true) diff --git a/src/BLite.Core/Indexing/CollectionIndexManager.cs b/src/BLite.Core/Indexing/CollectionIndexManager.cs index dd035f9..1b75c05 100644 --- a/src/BLite.Core/Indexing/CollectionIndexManager.cs +++ b/src/BLite.Core/Indexing/CollectionIndexManager.cs @@ -153,14 +153,24 @@ internal static CollectionIndexDefinition BuildDefinition( /// /// 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 release it with - /// (a spatial index cannot release - /// its pages yet, so an abandoned one leaks them). + /// root page: hand it to or , or release its + /// pages (a spatial index cannot enumerate its pages yet, so an abandoned one leaks them). /// internal CollectionSecondaryIndex CreateUnregistered(CollectionIndexDefinition definition) - => new(definition, _storage, _mapper); + { + lock (_lock) + { + if (_disposed) + throw new ObjectDisposedException(nameof(CollectionIndexManager)); + + return new CollectionSecondaryIndex(definition, _storage, _mapper); + } + } - /// Registers an index built by and persists it. + /// + /// 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) @@ -170,9 +180,7 @@ internal void Register(CollectionSecondaryIndex index) if (_indexes.ContainsKey(index.Name)) throw new InvalidOperationException($"Index '{index.Name}' already exists"); - _indexes[index.Name] = index; - _cachedIndexInfo = null; // invalidate cache - SaveMetadata(); + PublishLocked(index.Name, index, previous: null); } } @@ -352,32 +360,13 @@ internal CollectionSecondaryIndex RecreateIndex(string name) if (!_indexes.TryGetValue(name, out var old)) throw new InvalidOperationException($"Index '{name}' does not exist"); - var fresh = CreateUnregisteredCopy(name); + var fresh = CreateUnregistered(old.Definition); fresh.KeyFormat = old.KeyFormat; ReplaceIndex(name, fresh); return fresh; } } - /// - /// Creates an empty index with the same definition as without - /// registering it, so queries keep using the existing index while the copy is populated. - /// The copy owns its root page: hand it to or release it with - /// . - /// - internal CollectionSecondaryIndex CreateUnregisteredCopy(string name) - { - lock (_lock) - { - if (_disposed) - throw new ObjectDisposedException(nameof(CollectionIndexManager)); - if (!_indexes.TryGetValue(name, out var old)) - throw new InvalidOperationException($"Index '{name}' does not exist"); - - return new CollectionSecondaryIndex(old.Definition, _storage, _mapper); - } - } - /// /// Registers under , persists its root page /// and frees the pages of the index it replaces. If the metadata cannot be saved, the old @@ -392,29 +381,43 @@ internal void ReplaceIndex(string name, CollectionSecondaryIndex fresh) if (!_indexes.TryGetValue(name, out var old)) throw new InvalidOperationException($"Index '{name}' does not exist"); - _indexes[name] = fresh; - _cachedIndexInfo = null; // invalidate cache - RefreshHasUniqueBTreeIndex(); - // 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. - try - { - SaveMetadata(); - } - catch - { - // Keep the old index registered, matching the metadata on disk; the caller still - // owns the copy. - _indexes[name] = old; - UpdateMetadata(); - throw; - } + 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) { diff --git a/tests/BLite.Tests/IndexBackfillTests.cs b/tests/BLite.Tests/IndexBackfillTests.cs index 9d59801..7fec0a4 100644 --- a/tests/BLite.Tests/IndexBackfillTests.cs +++ b/tests/BLite.Tests/IndexBackfillTests.cs @@ -1,4 +1,11 @@ +using System.Linq.Expressions; +using System.Reflection; +using BLite.Bson; using BLite.Core; +using BLite.Core.Collections; +using BLite.Core.Indexing; +using BLite.Core.Metadata; +using BLite.Core.Storage; using BLite.Shared; namespace BLite.Tests; @@ -98,4 +105,114 @@ public async Task IndexThatCannotBeFilled_FailsTheOpen_AndIsNotPersisted() } Assert.Throws(() => new IndexEvoBadVectorDbContext(_dbPath)); } + + /// + /// A fill that fails after the index has split must free every page it added, not only the + /// root (#171 review): the error repeats on every open until the data is fixed, so a leak would + /// grow the file each time. + /// + [Fact] + public async Task IndexThatFailsLate_FreesEveryPageItAdded_SoRepeatedOpensDoNotGrowTheFile() + { + using (var v1 = new IndexEvoV1DbContext(_dbPath)) + { + await v1.Vectors.InsertBulkAsync(Enumerable.Range(0, 3000) + .Select(i => new VectorEntity { Title = $"v{i}", Embedding = [i, i % 7, i % 13] }).ToList()); + + // Give the last document in primary-key order a wrong-dimension embedding, so the fill + // fails only after thousands of inserts. + var primary = (BTreeIndex)typeof(DocumentCollection) + .GetField("_primaryIndex", BindingFlags.NonPublic | BindingFlags.Instance)! + .GetValue(v1.Vectors)!; + var lastId = primary.Range(IndexKey.MinKey, IndexKey.MaxKey, IndexDirection.Backward).First().Key.As(); + var last = (await v1.Vectors.FindByIdAsync(lastId))!; + last.Embedding = [1f, 2f]; + await v1.Vectors.UpdateAsync(last); + } + + var sizes = new List(); + for (int open = 0; open < 4; open++) + { + var ex = Assert.Throws(() => new IndexEvoV2DbContext(_dbPath)); + Assert.Contains("idx_vector", ex.Message); + sizes.Add(new FileInfo(_dbPath).Length); + } + + // 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 reopened = new IndexEvoV1DbContext(_dbPath); + Assert.DoesNotContain(reopened.Vectors.GetIndexes(), i => i.Name == "idx_vector"); + } + + /// + /// If the filled index cannot be persisted, it is not left registered in memory either, and + /// its pages are freed; the next open builds it again. + /// + [Fact] + public async Task AddedIndex_WhoseMetadataCannotBeSaved_IsNotRegistered_AndItsPagesAreFreed() + { + using (var v1 = new IndexEvoV1DbContext(_dbPath)) + { + await v1.People.InsertBulkAsync(Enumerable.Range(0, 3000) + .Select(i => new PersonV2 { Name = $"p{i}", Age = i % 90 }).ToList()); + await v1.CheckpointAsync(); + + // Hold the metadata lock so saving the new index times out. + var metadataLock = (SemaphoreSlim)typeof(StorageEngine) + .GetField("_metadataLock", BindingFlags.NonPublic | BindingFlags.Instance)! + .GetValue(v1.Storage)!; + Expression> age = p => p.Age; + metadataLock.Wait(); + try + { + Assert.Throws(() => v1.People.ApplyIndexBuilder(new IndexBuilder(age, "idx_Age", unique: false))); + } + finally + { + metadataLock.Release(); + } + + Assert.Null(v1.People.IndexManager.GetIndex("idx_Age")); + Assert.DoesNotContain(v1.People.GetIndexes(), i => i.Name == "idx_Age"); + Assert.DoesNotContain(v1.Storage.GetCollectionMetadata("evo_people")!.Indexes, i => i.Name == "idx_Age"); + await AssertIndexPagesWereFreedAsync(v1); + } + + using var v2 = new IndexEvoV2DbContext(_dbPath); + Assert.Equal(34, (await v2.People.QueryIndexAsync("idx_Age", 3, 3).ToListAsync()).Count); + } + + /// + /// A unique index added by the model is enforced in the session that creates it, not only after + /// the next open. + /// + [Fact] + public async Task AddedUniqueIndex_IsEnforcedRightAway() + { + await SeedAsync(); + using var v1 = new IndexEvoV1DbContext(_dbPath); + Expression> name = p => p.Name; + + v1.People.ApplyIndexBuilder(new IndexBuilder(name, "idx_Name", unique: true)); + + await Assert.ThrowsAnyAsync(async () => await v1.People.InsertAsync(new PersonV2 { Name = "p99", Age = 1 })); + await v1.People.InsertAsync(new PersonV2 { Name = "new", Age = 1 }); + Assert.Single(v1.People.AsQueryable().ToList(), p => p.Name == "p99"); + } + + /// + /// A 3000-entry B-Tree spans dozens of pages. Each must be back on the free list (handed out + /// next, below the high-water mark) or queued for the next WAL truncation. + /// + private static async Task AssertIndexPagesWereFreedAsync(IndexEvoV1DbContext db) + { + var storage = db.Storage; + await db.CheckpointAsync(); + var flags = BindingFlags.NonPublic | BindingFlags.Instance; + int queued = ((HashSet)typeof(StorageEngine).GetField("_pendingFreePages", flags)!.GetValue(storage)!).Count; + var highWater = ((IPageStorage)typeof(StorageEngine).GetField("_pageFile", flags)!.GetValue(storage)!).NextPageId; + int reused = 0; + while (storage.AllocatePage() < highWater) reused++; + Assert.True(reused + queued > 20, $"only {reused} pages were returned to the free list and {queued} queued"); + } } From 758717bf735839141ae1133a093a8ba568c79c17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Tue, 13 Oct 2026 15:28:02 +0200 Subject: [PATCH 5/5] fix(index): migrate legacy decimal indexes into a copy and free it on failure (#171) MigrateLegacyDecimalIndexes replaced the index with an empty tree (RecreateIndex) and then filled it in place. A rebuild that failed after splits rolled back and left those pages linked from nowhere, and the next open freed only the root of the emptied tree, so every failing open leaked the partial tree and grew the file. The legacy tree was also gone after the first failure. The migration now uses the same helpers as model indexes and the vacuum rebuild: it fills an unregistered copy (FillUnregisteredIndexAsync) set to the current key format and swaps it in with ReplaceIndex through PublishFilledIndex, so the new format and root are one metadata save. On failure the copy is freed and the old index stays registered in its old format with its data, and the next open retries. RecreateIndex has no callers left and is removed. Test: a legacy-format decimal index over 3000 documents whose last one cannot be read; four failing opens do not grow the file, the stored index keeps its root and legacy format, and the migration succeeds once the document is repaired. Fails without this change. Co-Authored-By: Claude Opus 5.5 --- .../Collections/DocumentCollection.cs | 17 +++-- .../Indexing/CollectionIndexManager.cs | 20 ------ tests/BLite.Tests/DecimalIndexTests.cs | 67 +++++++++++++++++++ 3 files changed, 77 insertions(+), 27 deletions(-) diff --git a/src/BLite.Core/Collections/DocumentCollection.cs b/src/BLite.Core/Collections/DocumentCollection.cs index 3d39d94..c25c5c4 100644 --- a/src/BLite.Core/Collections/DocumentCollection.cs +++ b/src/BLite.Core/Collections/DocumentCollection.cs @@ -870,10 +870,12 @@ internal DocumentCollection(StorageEngine storage, ITransactionHolder transactio /// /// Decimal properties used to be indexed as (double)value. An index whose persisted - /// predates the exact decimal encoding is replaced by an - /// empty tree, repopulated from the documents and then marked with the current format, the first - /// time the collection is opened. The format is recorded only after the rebuild committed, so a - /// crash in between just repeats the migration on the next open. No-op for every other index. + /// 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() { @@ -884,12 +886,13 @@ private void MigrateLegacyDecimalIndexes() var name = index.Name; try { - var fresh = _indexManager.RecreateIndex(name); + 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(() => RebuildIndexAsync(fresh, skipUnreadableDocuments: false)).GetAwaiter().GetResult(); - _indexManager.SetKeyFormat(name, IndexMetadata.CurrentKeyFormat); + Task.Run(() => FillUnregisteredIndexAsync(fresh, CancellationToken.None)).GetAwaiter().GetResult(); + PublishFilledIndex(fresh, copy => _indexManager.ReplaceIndex(name, copy)); } catch (Exception ex) { diff --git a/src/BLite.Core/Indexing/CollectionIndexManager.cs b/src/BLite.Core/Indexing/CollectionIndexManager.cs index 1b75c05..3f9d2e0 100644 --- a/src/BLite.Core/Indexing/CollectionIndexManager.cs +++ b/src/BLite.Core/Indexing/CollectionIndexManager.cs @@ -347,26 +347,6 @@ public bool DropIndex(string name) } } - /// - /// Replaces an index with an empty one of the same definition, freeing the old tree's pages. - /// The new index keeps the old : the - /// caller repopulates it and then records the new format with , so an - /// interrupted rebuild is simply redone on the next open. - /// - internal CollectionSecondaryIndex RecreateIndex(string name) - { - lock (_lock) - { - if (!_indexes.TryGetValue(name, out var old)) - throw new InvalidOperationException($"Index '{name}' does not exist"); - - var fresh = CreateUnregistered(old.Definition); - fresh.KeyFormat = old.KeyFormat; - ReplaceIndex(name, fresh); - return fresh; - } - } - /// /// Registers under , persists its root page /// and frees the pages of the index it replaces. If the metadata cannot be saved, the old diff --git a/tests/BLite.Tests/DecimalIndexTests.cs b/tests/BLite.Tests/DecimalIndexTests.cs index d025193..fd69a20 100644 --- a/tests/BLite.Tests/DecimalIndexTests.cs +++ b/tests/BLite.Tests/DecimalIndexTests.cs @@ -1,5 +1,7 @@ +using System.Reflection; using BLite.Bson; using BLite.Core; +using BLite.Core.Collections; using BLite.Core.Indexing; using BLite.Core.Query; using BLite.Core.Query.Blql; @@ -220,6 +222,71 @@ public async Task KeyFormat_RoundTripsThroughMetadata() Assert.Equal(IndexMetadata.CurrentKeyFormat, priceIndex.KeyFormat); } + /// + /// A migration that fails late, after the rebuilt tree has split, keeps the legacy index as it + /// was and frees every page of the partial rebuild: it is retried on each open without growing + /// the file, and succeeds once the document can be read again. + /// + [Fact] + public async Task LegacyIndexMigration_ThatFailsLate_KeepsTheOldIndex_AndDoesNotGrowTheFile() + { + const int count = 3000; + uint legacyRoot; + uint corruptedPage; + byte[] intact; + using (var db = new TestDbContext(_dbPath)) + { + await db.Products.InsertBulkAsync(Enumerable.Range(1, count) + .Select(i => new Product { Id = i, Title = $"P{i}", Price = i + 0.01m }).ToList()); + db.Products.IndexManager.SetKeyFormat(PriceIndex(db).Name, IndexMetadata.LegacyKeyFormat); + legacyRoot = PriceIndex(db).RootPageId; + await db.CheckpointAsync(); + + // Make the last document in primary-key order unreadable, so the rebuild on open fails + // only after thousands of inserts. + var primary = (BTreeIndex)typeof(DocumentCollection) + .GetField("_primaryIndex", BindingFlags.NonPublic | BindingFlags.Instance)! + .GetValue(db.Products)!; + var location = primary.Range(IndexKey.MinKey, IndexKey.MaxKey, IndexDirection.Backward).First().Location; + corruptedPage = location.PageId; + intact = new byte[db.Storage.PageSize]; + db.Storage.ReadPage(corruptedPage, null, intact); + var corrupt = (byte[])intact.Clone(); + var slot = SlotEntry.ReadFrom(corrupt.AsSpan(SlottedPageHeader.Size + location.SlotIndex * SlotEntry.Size)); + corrupt.AsSpan(slot.Offset + 4, 12).Fill(0xFF); // invalid element types + db.Storage.WritePageImmediate(corruptedPage, corrupt); + } + + var sizes = new List(); + for (int open = 0; open < 4; open++) + { + var ex = Assert.Throws(() => new TestDbContext(_dbPath)); + Assert.Contains("retried on the next open", ex.Message); + sizes.Add(new FileInfo(_dbPath).Length); + } + // 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 storage = new StorageEngine(_dbPath, PageFileConfig.Default)) + { + // The legacy index is still the registered one, so the next open retries the migration. + var stored = storage.GetCollectionMetadata("products_collection")!.Indexes + .Single(i => i.PropertyPaths.Single() == nameof(Product.Price)); + Assert.Equal(IndexMetadata.LegacyKeyFormat, stored.KeyFormat); + Assert.Equal(legacyRoot, stored.RootPageId); + storage.WritePageImmediate(corruptedPage, intact); + } + + using (var db = new TestDbContext(_dbPath)) + { + var index = PriceIndex(db); + Assert.Equal(IndexMetadata.CurrentKeyFormat, index.KeyFormat); + Assert.Equal(count, AllKeys(index).Count); + Assert.Equal(count, (await db.Products.AsQueryable().Where(p => p.Price >= 0m).ToListAsync()).Count); + Assert.Equal(1500, Assert.Single(await db.Products.AsQueryable().Where(p => p.Price == 1500.01m).ToListAsync()).Id); + } + } + private static List AllKeys(CollectionSecondaryIndex index) => index.BTreeIndex!.Range(IndexKey.NullSentinelNext, IndexKey.MaxKey).Select(e => e.Key).ToList();