From df762346ddba6fd7258a84c4ad396106ce8c4345 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Mon, 12 Oct 2026 14:04:17 +0200 Subject: [PATCH 1/3] fix(index): update and delete spatial and vector index entries (#164) CollectionSecondaryIndex.Update and Delete only maintained B-Tree indexes, and DynamicCollection skipped vector and spatial indexes on delete. Neither RTreeIndex nor VectorSearchIndex could remove an entry. A moved or deleted document stayed at its old position or embedding, and once its slot was reused, searches returned the unrelated document now stored there. - RTreeIndex.Delete removes the leaf entry and shrinks the bounding boxes above it. Emptied nodes stay in place, since page frees are not transactional. - VectorSearchIndex.Delete marks the node deleted by clearing its document location (no document lives on page 0). The node keeps routing, and search skips it. Relocate re-points the node of a document that moved with an unchanged vector, which DynamicCollection uses since its updates always move the document. - CollectionSecondaryIndex routes insert, update and delete through per-type helpers, and compares vectors by value so an update that leaves the vector alone does not touch the index. Co-Authored-By: Claude Opus 5.5 --- src/BLite.Core/DynamicCollection.cs | 40 ++- .../Indexing/CollectionSecondaryIndex.cs | 148 +++++++---- src/BLite.Core/Indexing/RTreeIndex.cs | 77 ++++++ src/BLite.Core/Indexing/VectorSearchIndex.cs | 80 +++++- src/BLite.Core/Storage/VectorPage.cs | 11 + .../SpatialVectorIndexMaintenanceTests.cs | 251 ++++++++++++++++++ 6 files changed, 545 insertions(+), 62 deletions(-) create mode 100644 tests/BLite.Tests/SpatialVectorIndexMaintenanceTests.cs diff --git a/src/BLite.Core/DynamicCollection.cs b/src/BLite.Core/DynamicCollection.cs index 3a20770d..614870cb 100644 --- a/src/BLite.Core/DynamicCollection.cs +++ b/src/BLite.Core/DynamicCollection.cs @@ -1081,10 +1081,7 @@ private async Task UpdateAtLocationCore(BsonId id, IndexKey key, BsonDocument ne _primaryIndex.Insert(key, newLocation, transaction.TransactionId); foreach (var (_, idx) in _secondaryIndexes) - { - if (oldDoc != null) IndexDelete(idx, oldDoc, oldLocation, transaction); - IndexInsert(idx, newDocument, newLocation, transaction); - } + IndexUpdate(idx, oldDoc, newDocument, oldLocation, newLocation, transaction); await NotifyCdcAsync(OperationType.Update, id, transaction, newDocument.RawData); } @@ -1308,10 +1305,7 @@ public async ValueTask UpdateBulkAsync(IEnumerable<(BsonId Id, BsonDocument _primaryIndex.Insert(key, newLocation, transaction.TransactionId); foreach (var (_, idx) in _secondaryIndexes) - { - if (oldDoc != null) IndexDelete(idx, oldDoc, oldLocation, transaction); - IndexInsert(idx, newDoc, newLocation, transaction); - } + IndexUpdate(idx, oldDoc, newDoc, oldLocation, newLocation, transaction); await NotifyCdcAsync(OperationType.Update, id, transaction, newDoc.RawData); count++; @@ -1884,11 +1878,37 @@ private void IndexDelete(DynamicSecondaryIndex idx, BsonDocument document, Docum var key = BsonValueToIndexKey(val); if (key.HasValue) idx.BTree!.Delete(key.Value, location, transaction.TransactionId); break; - // Vector and Spatial indexes do not support individual entry deletion; - // their rebuild is handled at the collection level (compaction/reindex). + case DynamicIndexKind.Vector: + var floats = ExtractFloatVector(val); + if (floats != null) idx.Vector!.Delete(floats, location, transaction); + break; + case DynamicIndexKind.Spatial: + var coords = ExtractCoordinates(val); + if (coords.HasValue) idx.Spatial!.Delete(GeoBox.FromPoint(new GeoPoint(coords.Value.Lat, coords.Value.Lon)), location, transaction); + break; } } + /// + /// Moves a document's entry from at + /// to at . An unchanged vector keeps + /// its HNSW node, which is re-pointed instead of being replaced by a new one. + /// + private void IndexUpdate(DynamicSecondaryIndex idx, BsonDocument? oldDocument, BsonDocument newDocument, DocumentLocation oldLocation, DocumentLocation newLocation, ITransaction transaction) + { + if (oldDocument != null && idx.Kind == DynamicIndexKind.Vector + && TryGetNestedValue(oldDocument, idx.FieldPath, out var oldVal) + && TryGetNestedValue(newDocument, idx.FieldPath, out var newVal) + && ExtractFloatVector(oldVal) is { } oldVector + && ExtractFloatVector(newVal) is { } newVector + && oldVector.AsSpan().SequenceEqual(newVector) + && idx.Vector!.Relocate(oldVector, oldLocation, newLocation, transaction)) + return; + + if (oldDocument != null) IndexDelete(idx, oldDocument, oldLocation, transaction); + IndexInsert(idx, newDocument, newLocation, transaction); + } + #endregion #region Internal storage operations diff --git a/src/BLite.Core/Indexing/CollectionSecondaryIndex.cs b/src/BLite.Core/Indexing/CollectionSecondaryIndex.cs index 0afe4b91..d7b04716 100644 --- a/src/BLite.Core/Indexing/CollectionSecondaryIndex.cs +++ b/src/BLite.Core/Indexing/CollectionSecondaryIndex.cs @@ -103,37 +103,7 @@ public void Insert(T document, DocumentLocation location, ITransaction transacti if (!_definition.TryGetKey(document, out var keyValue) || keyValue == null) return; // Skip null keys or failed traversal - if (_vectorIndex != null) - { - // Vector Index Support - if (keyValue is float[] singleVector) - { - _vectorIndex.Insert(singleVector, location, transaction); - } - else if (keyValue is IEnumerable vectors) - { - foreach (var v in vectors) - { - _vectorIndex.Insert(v, location, transaction); - } - } - } - else if (_spatialIndex != null) - { - // Geospatial Index Support - if (keyValue is ValueTuple t) - { - _spatialIndex.Insert(GeoBox.FromPoint(new GeoPoint(t.Item1, t.Item2)), location, transaction); - } - } - else if (_btreeIndex != null) - { - // BTree Index logic - var userKey = ConvertToIndexKey(keyValue); - var documentId = _mapper.GetId(document); - var compositeKey = CreateCompositeKey(userKey, _mapper.ToIndexKey(documentId)); - _btreeIndex.Insert(compositeKey, location, transaction?.TransactionId); - } + InsertKey(keyValue, document, location, transaction); System.Threading.Interlocked.Increment(ref _documentCount); } @@ -163,26 +133,16 @@ public void Update(T oldDocument, T newDocument, DocumentLocation oldLocation, D // If keys are the same, no index update needed (optimization for in-place updates // where the existing entry at the same location remains valid). - if (Equals(oldKey, newKey)) + if (KeysEqual(oldKey, newKey)) return; - var documentId = _mapper.GetId(oldDocument); - // Delete old entry if it had a key if (oldKey != null) - { - var oldUserKey = ConvertToIndexKey(oldKey); - var oldCompositeKey = CreateCompositeKey(oldUserKey, _mapper.ToIndexKey(documentId)); - _btreeIndex?.Delete(oldCompositeKey, oldLocation, transaction?.TransactionId); - } + DeleteKey(oldKey, oldDocument, oldLocation, transaction); // Insert new entry if it has a key if (newKey != null) - { - var newUserKey = ConvertToIndexKey(newKey); - var newCompositeKey = CreateCompositeKey(newUserKey, _mapper.ToIndexKey(documentId)); - _btreeIndex?.Insert(newCompositeKey, newLocation, transaction?.TransactionId); - } + InsertKey(newKey, newDocument, newLocation, transaction); } /// @@ -200,16 +160,104 @@ public void Delete(T document, DocumentLocation location, ITransaction transacti if (!_definition.TryGetKey(document, out var keyValue) || keyValue == null) return; // Nothing to delete - var userKey = ConvertToIndexKey(keyValue); - var documentId = _mapper.GetId(document); - - // Create composite key and delete - var compositeKey = CreateCompositeKey(userKey, _mapper.ToIndexKey(documentId)); - _btreeIndex?.Delete(compositeKey, location, transaction?.TransactionId); + DeleteKey(keyValue, document, location, transaction); System.Threading.Interlocked.Decrement(ref _documentCount); } + private void InsertKey(object keyValue, T document, DocumentLocation location, ITransaction? transaction) + { + if (_vectorIndex != null) + { + // Vector Index Support + if (keyValue is float[] singleVector) + { + _vectorIndex.Insert(singleVector, location, transaction); + } + else if (keyValue is IEnumerable vectors) + { + foreach (var v in vectors) + { + _vectorIndex.Insert(v, location, transaction); + } + } + } + else if (_spatialIndex != null) + { + // Geospatial Index Support + if (keyValue is ValueTuple t) + { + _spatialIndex.Insert(GeoBox.FromPoint(new GeoPoint(t.Item1, t.Item2)), location, transaction); + } + } + else if (_btreeIndex != null) + { + // BTree Index logic + var userKey = ConvertToIndexKey(keyValue); + var documentId = _mapper.GetId(document); + var compositeKey = CreateCompositeKey(userKey, _mapper.ToIndexKey(documentId)); + _btreeIndex.Insert(compositeKey, location, transaction?.TransactionId); + } + } + + private void DeleteKey(object keyValue, T document, DocumentLocation location, ITransaction? transaction) + { + if (_vectorIndex != null) + { + if (keyValue is float[] singleVector) + { + _vectorIndex.Delete(singleVector, location, transaction); + } + else if (keyValue is IEnumerable vectors) + { + foreach (var v in vectors) + { + _vectorIndex.Delete(v, location, transaction); + } + } + } + else if (_spatialIndex != null) + { + if (keyValue is ValueTuple t) + { + _spatialIndex.Delete(GeoBox.FromPoint(new GeoPoint(t.Item1, t.Item2)), location, transaction); + } + } + else if (_btreeIndex != null) + { + var userKey = ConvertToIndexKey(keyValue); + var documentId = _mapper.GetId(document); + var compositeKey = CreateCompositeKey(userKey, _mapper.ToIndexKey(documentId)); + _btreeIndex.Delete(compositeKey, location, transaction?.TransactionId); + } + } + + /// + /// Key equality for skipping index maintenance. Vectors compare by value: every load + /// materializes a new array, so reference equality would rewrite the vector index on + /// every update. + /// + private static bool KeysEqual(object? oldKey, object? newKey) + { + if (oldKey is float[] oldVector && newKey is float[] newVector) + return oldVector.AsSpan().SequenceEqual(newVector); + + if (oldKey is IEnumerable oldVectors && newKey is IEnumerable newVectors) + { + using var oldEnum = oldVectors.GetEnumerator(); + using var newEnum = newVectors.GetEnumerator(); + while (true) + { + bool hasOld = oldEnum.MoveNext(); + if (hasOld != newEnum.MoveNext()) return false; + if (!hasOld) return true; + if (!oldEnum.Current.AsSpan().SequenceEqual(newEnum.Current)) return false; + } + } + + return Equals(oldKey, newKey); + } + /// /// Seeks a single document by exact key match (O(log n)) /// diff --git a/src/BLite.Core/Indexing/RTreeIndex.cs b/src/BLite.Core/Indexing/RTreeIndex.cs index 2ffb2258..67d27dd5 100644 --- a/src/BLite.Core/Indexing/RTreeIndex.cs +++ b/src/BLite.Core/Indexing/RTreeIndex.cs @@ -95,6 +95,83 @@ public void Insert(GeoBox mbr, DocumentLocation loc, ITransaction? transaction = } } + /// + /// Removes the entry for stored under and shrinks + /// the bounding boxes above it. A node left empty stays in the tree, where its empty box + /// matches no search, instead of being freed: page frees are not transactional. + /// + /// false if no such entry exists. + public bool Delete(GeoBox mbr, DocumentLocation loc, ITransaction? transaction = null) + { + lock (_lock) + { + var buffer = RentPageBuffer(); + try + { + if (!TryFindLeafEntry(mbr, loc, transaction, buffer, out var leafPageId, out var index)) + return false; + + // Move the last entry into the freed slot. + ushort count = SpatialPage.GetEntryCount(buffer); + if (index != count - 1) + { + SpatialPage.ReadEntry(buffer, count - 1, out var lastMbr, out var lastPointer); + SpatialPage.WriteEntry(buffer, index, lastMbr, lastPointer); + } + SpatialPage.SetEntryCount(buffer, (ushort)(count - 1)); + + if (transaction != null) + _storage.WritePage(leafPageId, transaction.TransactionId, buffer); + else + _storage.WritePageImmediate(leafPageId, buffer); + + UpdateMBRUpwards(leafPageId, transaction); + return true; + } + finally { ReturnPageBuffer(buffer); } + } + } + + /// + /// Searches every subtree whose box covers for a leaf entry with exactly + /// that box and location. On success holds the leaf page. + /// + private bool TryFindLeafEntry(GeoBox mbr, DocumentLocation loc, ITransaction? transaction, byte[] buffer, out uint leafPageId, out int index) + { + var stack = new Stack(); + stack.Push(_rootPageId); + + while (stack.Count > 0) + { + uint pageId = stack.Pop(); + _storage.ReadPage(pageId, transaction?.TransactionId, buffer); + + bool isLeaf = SpatialPage.GetIsLeaf(buffer); + ushort count = SpatialPage.GetEntryCount(buffer); + for (int i = 0; i < count; i++) + { + SpatialPage.ReadEntry(buffer, i, out var entryMbr, out var pointer); + if (isLeaf) + { + if (entryMbr == mbr && pointer.PageId == loc.PageId && pointer.SlotIndex == loc.SlotIndex) + { + leafPageId = pageId; + index = i; + return true; + } + } + else if (entryMbr.Intersects(mbr)) + { + stack.Push(pointer.PageId); + } + } + } + + leafPageId = 0; + index = -1; + return false; + } + private uint ChooseLeaf(uint rootId, GeoBox mbr, ITransaction? transaction) { uint currentId = rootId; diff --git a/src/BLite.Core/Indexing/VectorSearchIndex.cs b/src/BLite.Core/Indexing/VectorSearchIndex.cs index 87673b0d..780b7ce1 100644 --- a/src/BLite.Core/Indexing/VectorSearchIndex.cs +++ b/src/BLite.Core/Indexing/VectorSearchIndex.cs @@ -481,13 +481,13 @@ public IEnumerable Search(float[] query, int k, int efSearch // 2. Comprehensive search on level 0 var nearest = SearchLayer(currentPoint, query, Math.Max(efSearch, k), 0, transaction); - // 3. Return top-k results. + // 3. Return top-k results, skipping deleted nodes. // SearchLayer already computed distances; we retrieve location + vector together // in a single page read to avoid the double-LoadVector that the old code did. int count = 0; foreach (var node in nearest) { - if (count++ >= k) break; + if (count >= k) break; var buffer = RentPageBuffer(); try @@ -495,6 +495,9 @@ public IEnumerable Search(float[] query, int k, int efSearch _storage.ReadPage(node.PageId, transaction?.TransactionId, buffer); float[] vec = new float[_options.Dimensions]; VectorPage.ReadNodeData(buffer, node.NodeIndex, out var loc, out _, vec); + if (loc.PageId == 0) continue; + + count++; float dist = VectorMath.Distance(query, vec, _options.Metric); yield return new VectorSearchResult(loc, dist); } @@ -502,6 +505,79 @@ public IEnumerable Search(float[] query, int k, int efSearch } } + /// + /// Marks the node that stores for as + /// deleted. The node keeps its vector and links so the graph stays connected, but search no + /// longer returns it. Deleted nodes are dropped when the index is rebuilt. + /// + /// false if no such node exists. + public bool Delete(float[] vector, DocumentLocation docLocation, ITransaction? transaction = null) + => RewriteLocation(vector, docLocation, default, transaction); + + /// + /// Points the node that stores for at + /// , for a document that moved without changing its vector. + /// + /// false if no such node exists. + public bool Relocate(float[] vector, DocumentLocation oldLocation, DocumentLocation newLocation, ITransaction? transaction = null) + => RewriteLocation(vector, oldLocation, newLocation, transaction); + + /// + /// Finds one node holding exactly for and + /// rewrites its location. A search for the vector itself finds the node in practice; the + /// page scan behind it only runs if the approximate search misses. + /// + private bool RewriteLocation(float[] vector, DocumentLocation from, DocumentLocation to, ITransaction? transaction) + { + if (vector.Length != _options.Dimensions || from.PageId == 0) return false; + if (!TryGetEntryPoint(transaction?.TransactionId ?? 0, out var entryPoint)) return false; + + var buffer = RentPageBuffer(); + var stored = new float[_options.Dimensions]; + try + { + bool IsTarget(int nodeIndex) + { + var loc = VectorPage.ReadLocation(buffer, nodeIndex); + if (loc.PageId != from.PageId || loc.SlotIndex != from.SlotIndex) return false; + VectorPage.ReadNodeData(buffer, nodeIndex, out _, out _, stored); + return stored.AsSpan().SequenceEqual(vector); + } + + void Rewrite(uint pageId, int nodeIndex) + { + VectorPage.WriteLocation(buffer, nodeIndex, to); + WritePage(pageId, transaction, buffer); + } + + var currentPoint = entryPoint; + for (int l = entryPoint.MaxLevel; l > 0; l--) + currentPoint = GreedySearch(currentPoint, vector, l, transaction); + + foreach (var node in SearchLayer(currentPoint, vector, _options.EfConstruction, 0, transaction)) + { + _storage.ReadPage(node.PageId, transaction?.TransactionId, buffer); + if (!IsTarget(node.NodeIndex)) continue; + Rewrite(node.PageId, node.NodeIndex); + return true; + } + + for (uint pageId = _rootPageId; pageId != 0; pageId = PageHeader.ReadFrom(buffer).NextPageId) + { + _storage.ReadPage(pageId, transaction?.TransactionId, buffer); + int nodeCount = VectorPage.GetNodeCount(buffer); + for (int i = 0; i < nodeCount; i++) + { + if (!IsTarget(i)) continue; + Rewrite(pageId, i); + return true; + } + } + return false; + } + finally { ReturnPageBuffer(buffer); } + } + private DocumentLocation LoadDocumentLocation(NodeReference node, ITransaction? transaction) { var buffer = RentPageBuffer(); diff --git a/src/BLite.Core/Storage/VectorPage.cs b/src/BLite.Core/Storage/VectorPage.cs index 45d3209c..cd35f296 100644 --- a/src/BLite.Core/Storage/VectorPage.cs +++ b/src/BLite.Core/Storage/VectorPage.cs @@ -133,6 +133,17 @@ public static DocumentLocation ReadLocation(ReadOnlySpan page, int nodeInd return DocumentLocation.ReadFrom(page.Slice(offset, 6)); } + /// + /// Overwrites the document location of a node. Writing default marks the node deleted: + /// no document lives on page 0, so search skips it, while its vector and links keep routing. + /// + public static void WriteLocation(Span page, int nodeIndex, DocumentLocation loc) + { + int nodeSize = GetNodeSize(page); + int offset = DataOffset + (nodeIndex * nodeSize); + loc.WriteTo(page.Slice(offset, 6)); + } + public static Span GetLinksSpan(Span page, int nodeIndex, int level, int dimensions, int maxM) { int nodeSize = GetNodeSize(page); diff --git a/tests/BLite.Tests/SpatialVectorIndexMaintenanceTests.cs b/tests/BLite.Tests/SpatialVectorIndexMaintenanceTests.cs new file mode 100644 index 00000000..9c5b9aa6 --- /dev/null +++ b/tests/BLite.Tests/SpatialVectorIndexMaintenanceTests.cs @@ -0,0 +1,251 @@ +using BLite.Bson; +using BLite.Core; +using BLite.Core.Indexing; +using BLite.Shared; + +namespace BLite.Tests; + +/// +/// Spatial (R-Tree) and vector (HNSW) indexes must follow updates and deletes, so a search never +/// returns a document at a position or embedding it no longer has (#164). +/// +public class SpatialVectorIndexMaintenanceTests : IDisposable +{ + private readonly string _dbPath; + private readonly TestDbContext _db; + + public SpatialVectorIndexMaintenanceTests() + { + _dbPath = Path.Combine(Path.GetTempPath(), $"idx_maint_{Guid.NewGuid():N}.db"); + _db = new TestDbContext(_dbPath); + } + + public void Dispose() + { + _db.Dispose(); + foreach (var f in new[] { _dbPath, Path.ChangeExtension(_dbPath, ".wal") }) + try { if (File.Exists(f)) File.Delete(f); } catch { /* best-effort */ } + } + + private List Within((double, double) min, (double, double) max) + => _db.GeoItems.AsQueryable().Where(p => p.Location.Within(min, max)).ToList(); + + private Task> NearestAsync(float[] query, int k) + => _db.VectorItems.VectorSearchAsync("idx_vector", query, k).ToListAsync(); + + // ── Spatial ───────────────────────────────────────────────────────────── + + [Fact] + public async Task Spatial_Update_MovesDocument() + { + var e = new GeoEntity { Name = "mover", Location = (45.0, 9.0) }; + await _db.GeoItems.InsertAsync(e); + e.Location = (10.0, 10.0); + await _db.GeoItems.UpdateAsync(e); + + Assert.Empty(Within((44.0, 8.0), (46.0, 10.0))); + Assert.Equal("mover", Assert.Single(Within((9.0, 9.0), (11.0, 11.0))).Name); + } + + [Fact] + public async Task Spatial_DeleteThenInsertElsewhere_DoesNotReturnTheNewDocument() + { + var e = new GeoEntity { Name = "deleted", Location = (45.0, 9.0) }; + await _db.GeoItems.InsertAsync(e); + await _db.GeoItems.DeleteAsync(e.Id); + await _db.GeoItems.InsertAsync(new GeoEntity { Name = "far", Location = (-30.0, 120.0) }); + + Assert.Empty(Within((44.0, 8.0), (46.0, 10.0))); + } + + [Fact] + public async Task Spatial_DeleteHalf_OfASplitTree_ReturnsExactlyTheSurvivors() + { + var all = new List(); + for (int i = 0; i < 1000; i++) + { + var e = new GeoEntity { Name = $"p{i}", Location = (i % 40 * 0.5, i / 40 * 0.5) }; + await _db.GeoItems.InsertAsync(e); + all.Add(e); + } + foreach (var e in all.Where((_, i) => i % 2 == 0)) + await _db.GeoItems.DeleteAsync(e.Id); + // Reuse the freed slots far outside the queried area. + for (int i = 0; i < 500; i++) + await _db.GeoItems.InsertAsync(new GeoEntity { Name = $"far{i}", Location = (-60.0, -120.0) }); + + var found = Within((-1.0, -1.0), (100.0, 100.0)).Select(e => e.Name).OrderBy(n => n).ToList(); + var expected = all.Where((_, i) => i % 2 == 1).Select(e => e.Name).OrderBy(n => n).ToList(); + Assert.Equal(expected, found); + } + + [Fact] + public async Task Spatial_RelocatingUpdate_MovesDocument() + { + var e = new GeoEntity { Name = "small", Location = (45.0, 9.0) }; + await _db.GeoItems.InsertAsync(e); + e.Name = new string('x', 2000); // no longer fits its slot + e.Location = (10.0, 10.0); + await _db.GeoItems.UpdateAsync(e); + + Assert.Empty(Within((44.0, 8.0), (46.0, 10.0))); + Assert.Equal(e.Name, Assert.Single(Within((9.0, 9.0), (11.0, 11.0))).Name); + } + + [Fact] + public async Task Spatial_DeleteRolledBack_KeepsDocument() + { + var e = new GeoEntity { Name = "kept", Location = (45.0, 9.0) }; + await _db.GeoItems.InsertAsync(e); + + using (var txn = await _db.BeginTransactionAsync()) + { + await _db.GeoItems.DeleteAsync(e.Id, txn); + await txn.RollbackAsync(); + } + + Assert.Equal("kept", Assert.Single(Within((44.0, 8.0), (46.0, 10.0))).Name); + } + + // ── Vector ────────────────────────────────────────────────────────────── + + [Fact] + public async Task Vector_Update_UsesNewEmbedding() + { + var a = new VectorEntity { Title = "A", Embedding = [0f, 0f, 0f] }; + await _db.VectorItems.InsertAsync(a); + await _db.VectorItems.InsertAsync(new VectorEntity { Title = "B", Embedding = [5f, 5f, 5f] }); + a.Embedding = [100f, 100f, 100f]; + await _db.VectorItems.UpdateAsync(a); + + Assert.Equal("B", Assert.Single(await NearestAsync([0f, 0f, 0f], 1)).Title); + Assert.Equal("A", Assert.Single(await NearestAsync([100f, 100f, 100f], 1)).Title); + } + + [Fact] + public async Task Vector_DeleteThenInsertElsewhere_DoesNotReturnTheNewDocument() + { + var a = new VectorEntity { Title = "A", Embedding = [0f, 0f, 0f] }; + await _db.VectorItems.InsertAsync(a); + await _db.VectorItems.DeleteAsync(a.Id); + await _db.VectorItems.InsertAsync(new VectorEntity { Title = "C", Embedding = [50f, 50f, 50f] }); + await _db.VectorItems.InsertAsync(new VectorEntity { Title = "B", Embedding = [5f, 5f, 5f] }); + + Assert.Equal("B", Assert.Single(await NearestAsync([0f, 0f, 0f], 1)).Title); + } + + [Fact] + public async Task Vector_DeleteMost_SearchReturnsOnlySurvivors() + { + var all = new List(); + for (int i = 0; i < 40; i++) + { + var e = new VectorEntity { Title = $"v{i}", Embedding = [i, i, i] }; + await _db.VectorItems.InsertAsync(e); + all.Add(e); + } + foreach (var e in all.Take(30)) + await _db.VectorItems.DeleteAsync(e.Id); + + var titles = (await NearestAsync([0f, 0f, 0f], 10)).Select(e => e.Title).ToList(); + Assert.Equal(Enumerable.Range(30, 10).Select(i => $"v{i}"), titles); + } + + [Fact] + public async Task Vector_UpdateOfOtherField_KeepsOneResultPerDocument() + { + var a = new VectorEntity { Title = "A0", Embedding = [1f, 1f, 1f] }; + await _db.VectorItems.InsertAsync(a); + await _db.VectorItems.InsertAsync(new VectorEntity { Title = "B", Embedding = [9f, 9f, 9f] }); + for (int i = 1; i <= 3; i++) + { + a.Title = $"A{i}"; + await _db.VectorItems.UpdateAsync(a); + } + a.Title = new string('a', 2000); // relocating update with an unchanged vector + await _db.VectorItems.UpdateAsync(a); + + var results = await NearestAsync([1f, 1f, 1f], 5); + Assert.Equal(2, results.Count); + Assert.Equal(a.Title, results[0].Title); + } + + [Fact] + public async Task Vector_DeleteRolledBack_KeepsDocument() + { + var a = new VectorEntity { Title = "A", Embedding = [0f, 0f, 0f] }; + await _db.VectorItems.InsertAsync(a); + await _db.VectorItems.InsertAsync(new VectorEntity { Title = "B", Embedding = [5f, 5f, 5f] }); + + using (var txn = await _db.BeginTransactionAsync()) + { + await _db.VectorItems.DeleteAsync(a.Id, txn); + await txn.RollbackAsync(); + } + + Assert.Equal("A", Assert.Single(await NearestAsync([0f, 0f, 0f], 1)).Title); + } + + // ── DynamicCollection ─────────────────────────────────────────────────── + + [Fact] + public async Task Dynamic_Spatial_UpdateAndDelete_AreReflected() + { + using var engine = new BLiteEngine(Path.ChangeExtension(_dbPath, ".dyn.db")); + try + { + var col = engine.GetOrCreateCollection("places"); + await col.CreateSpatialIndexAsync("pos"); + + var moverId = await col.InsertAsync(col.CreateDocument(["name", "pos"], b => b + .AddString("name", "mover").AddCoordinates("pos", (45.0, 9.0)))); + var goneId = await col.InsertAsync(col.CreateDocument(["name", "pos"], b => b + .AddString("name", "gone").AddCoordinates("pos", (45.1, 9.1)))); + + await col.UpdateAsync(moverId, col.CreateDocument(["name", "pos"], b => b + .AddString("name", "mover").AddCoordinates("pos", (10.0, 10.0)))); + await col.DeleteAsync(goneId); + + Assert.Empty(await col.WithinAsync("idx_spatial_pos", (44.0, 8.0), (46.0, 10.0)).ToListAsync()); + Assert.Single(await col.WithinAsync("idx_spatial_pos", (9.0, 9.0), (11.0, 11.0)).ToListAsync()); + } + finally + { + engine.Dispose(); + foreach (var f in new[] { Path.ChangeExtension(_dbPath, ".dyn.db"), Path.ChangeExtension(_dbPath, ".dyn.wal") }) + try { if (File.Exists(f)) File.Delete(f); } catch { /* best-effort */ } + } + } + + [Fact] + public async Task Dynamic_Vector_UpdateAndDelete_AreReflected() + { + using var engine = new BLiteEngine(Path.ChangeExtension(_dbPath, ".dyn.db")); + try + { + var col = engine.GetOrCreateCollection("vecs"); + await col.CreateVectorIndexAsync("embedding", dimensions: 3, metric: VectorMetric.L2); + + BsonDocument Doc(string title, float[] v) => col.CreateDocument(["title", "embedding"], b => b + .AddString("title", title).AddFloatArray("embedding", v)); + + var movedId = await col.InsertAsync(Doc("moved", [0f, 0f, 0f])); + var goneId = await col.InsertAsync(Doc("gone", [1f, 1f, 1f])); + var renamedId = await col.InsertAsync(Doc("renamed", [5f, 5f, 5f])); + + await col.UpdateAsync(movedId, Doc("moved", [100f, 100f, 100f])); + await col.DeleteAsync(goneId); + await col.UpdateAsync(renamedId, Doc("renamed twice", [5f, 5f, 5f])); + + var top = await col.VectorSearchAsync("idx_vector_embedding", [0f, 0f, 0f], k: 3).ToListAsync(); + var titles = top.Select(d => d.TryGetString("title", out var t) ? t : null).ToList(); + Assert.Equal(new[] { "renamed twice", "moved" }, titles); + } + finally + { + engine.Dispose(); + foreach (var f in new[] { Path.ChangeExtension(_dbPath, ".dyn.db"), Path.ChangeExtension(_dbPath, ".dyn.wal") }) + try { if (File.Exists(f)) File.Delete(f); } catch { /* best-effort */ } + } + } +} From 623014dd962fea38d01a7e8e5a0b48e71b3f8dd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Mon, 12 Oct 2026 15:39:40 +0200 Subject: [PATCH 2/3] fix(index): address review of spatial/vector index maintenance (#164) - HNSW search counts only live nodes: SearchLayer (in query mode) still expands deleted nodes for routing but keeps them out of the result and keeps searching until it holds ef live nodes, as hnswlib does. Before, a query near many deleted nodes could return fewer than k results, down to none. Graph construction still treats deleted nodes as neighbours. - Typed relocating updates move secondary-index entries instead of deleting and reinserting them: DeleteCore can leave the indexes alone, and CollectionSecondaryIndex.Update re-points an unchanged key at its new location; for an unchanged vector that is Relocate, so no deleted node is left behind (with delete + insert as the fallback). - Comments on the RewriteLocation page-scan fallback (hit when more than EfConstruction documents share one vector) and on the missing index-level lock. Tests: 300 vectors with 200 deleted near the query (more than efSearch) return exactly the next 10; three relocating updates with an unchanged vector leave the node count unchanged. Both fail without these changes. Co-Authored-By: Claude Opus 5.5 --- .../Collections/DocumentCollection.cs | 20 +++---- .../Indexing/CollectionSecondaryIndex.cs | 34 +++++++++++- src/BLite.Core/Indexing/VectorSearchIndex.cs | 52 ++++++++++++------- .../SpatialVectorIndexMaintenanceTests.cs | 48 +++++++++++++++-- 4 files changed, 120 insertions(+), 34 deletions(-) diff --git a/src/BLite.Core/Collections/DocumentCollection.cs b/src/BLite.Core/Collections/DocumentCollection.cs index 19d77c22..675591ac 100644 --- a/src/BLite.Core/Collections/DocumentCollection.cs +++ b/src/BLite.Core/Collections/DocumentCollection.cs @@ -2809,8 +2809,9 @@ private async Task UpdateAtLocationCore(TId id, T entity, byte[] docData, } else { - // Delete old + insert new - await DeleteCore(id, transaction, notifyCdc: false); + // 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) @@ -2827,9 +2828,7 @@ private async Task UpdateAtLocationCore(TId id, T entity, byte[] docData, } _primaryIndex.Insert(key, newLocation, transaction.TransactionId); - // DeleteCore already removed all secondary index entries for this document. - // We only need to insert the new entries at the new physical location. - _indexManager.InsertIntoAll(entity, newLocation, transaction); + _indexManager.UpdateInAll(oldEntity, entity, oldLocation, newLocation, transaction); // Notify CDC await NotifyCdc(OperationType.Update, id, transaction, docData); @@ -3089,17 +3088,20 @@ private async Task DeleteBulkInternal(IEnumerable ids, ITransaction tr return deleteCount; } - private async Task DeleteCore(TId id, ITransaction transaction, bool notifyCdc = true) + 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 - var entity = await FindByLocation(location, transaction); - if (entity != null) + if (deleteFromIndexes) { - _indexManager.DeleteFromAll(entity, location, transaction); + var entity = await FindByLocation(location, transaction); + if (entity != null) + { + _indexManager.DeleteFromAll(entity, location, transaction); + } } // Read page diff --git a/src/BLite.Core/Indexing/CollectionSecondaryIndex.cs b/src/BLite.Core/Indexing/CollectionSecondaryIndex.cs index d7b04716..1cda2ac0 100644 --- a/src/BLite.Core/Indexing/CollectionSecondaryIndex.cs +++ b/src/BLite.Core/Indexing/CollectionSecondaryIndex.cs @@ -131,10 +131,23 @@ public void Update(T oldDocument, T newDocument, DocumentLocation oldLocation, D _definition.TryGetKey(oldDocument, out var oldKey); _definition.TryGetKey(newDocument, out var newKey); - // If keys are the same, no index update needed (optimization for in-place updates - // where the existing entry at the same location remains valid). + bool moved = oldLocation.PageId != newLocation.PageId || oldLocation.SlotIndex != newLocation.SlotIndex; + if (KeysEqual(oldKey, newKey)) + { + // Same key at the same location: the existing entry stays valid. + if (!moved || oldKey == null) + return; + + // Same key, new location: re-point the entry. An unchanged vector keeps its HNSW + // node instead of leaving a deleted node behind. + if (_vectorIndex == null || !TryRelocateVectors(oldKey, oldLocation, newLocation, transaction)) + { + DeleteKey(oldKey, oldDocument, oldLocation, transaction); + InsertKey(newKey!, newDocument, newLocation, transaction); + } return; + } // Delete old entry if it had a key if (oldKey != null) @@ -200,6 +213,23 @@ private void InsertKey(object keyValue, T document, DocumentLocation location, I } } + /// Re-points every vector of ; false if any node was missing. + private bool TryRelocateVectors(object keyValue, DocumentLocation oldLocation, DocumentLocation newLocation, ITransaction? transaction) + { + if (keyValue is float[] singleVector) + return _vectorIndex!.Relocate(singleVector, oldLocation, newLocation, transaction); + + if (keyValue is IEnumerable vectors) + { + bool all = true; + foreach (var v in vectors) + all &= _vectorIndex!.Relocate(v, oldLocation, newLocation, transaction); + return all; + } + + return false; + } + private void DeleteKey(object keyValue, T document, DocumentLocation location, ITransaction? transaction) { if (_vectorIndex != null) diff --git a/src/BLite.Core/Indexing/VectorSearchIndex.cs b/src/BLite.Core/Indexing/VectorSearchIndex.cs index 780b7ce1..c03fd018 100644 --- a/src/BLite.Core/Indexing/VectorSearchIndex.cs +++ b/src/BLite.Core/Indexing/VectorSearchIndex.cs @@ -382,38 +382,43 @@ private NodeReference GreedySearch(NodeReference entryPoint, float[] query, int return current; } - private IEnumerable SearchLayer(NodeReference entryPoint, float[] query, int ef, int level, ITransaction? transaction) + /// + /// When true (queries), deleted nodes are still expanded so the graph stays navigable, but + /// only live nodes enter the result, and the search keeps going until it holds + /// live nodes or runs out of candidates (as hnswlib does). Graph + /// construction passes false: deleted nodes remain valid neighbours. + /// + private IEnumerable SearchLayer(NodeReference entryPoint, float[] query, int ef, int level, ITransaction? transaction, bool liveOnly = false) { var visited = new HashSet(); var candidates = new PriorityQueue(); - var result = new PriorityQueue(); + var result = new PriorityQueue(); // max-heap via negated distance - float dist = VectorMath.Distance(query, LoadVector(entryPoint, transaction), _options.Metric); + float dist = VectorMath.Distance(query, LoadVector(entryPoint, transaction, out var entryLive), _options.Metric); candidates.Enqueue(entryPoint, dist); - result.Enqueue(entryPoint, -dist); // Max-heap for results + if (entryLive || !liveOnly) result.Enqueue(entryPoint, -dist); visited.Add(entryPoint); + // Distance of the furthest node in the result; unbounded until the result has entries. + float Bound() => result.TryPeek(out _, out var negated) ? -negated : float.PositiveInfinity; + while (candidates.Count > 0) { - float d_c = 0; - candidates.TryPeek(out var c, out d_c); - result.TryPeek(out var f, out var d_f); - - if (d_c > -d_f) break; + candidates.TryPeek(out var c, out var d_c); + if (d_c > Bound() && result.Count >= ef) break; candidates.Dequeue(); foreach (var e in GetNeighbors(c, level, transaction)) { - if (!visited.Contains(e)) - { - visited.Add(e); - result.TryPeek(out _, out d_f); - float d_e = VectorMath.Distance(query, LoadVector(e, transaction), _options.Metric); + if (!visited.Add(e)) continue; - if (d_e < -d_f || result.Count < ef) + float d_e = VectorMath.Distance(query, LoadVector(e, transaction, out var live), _options.Metric); + if (result.Count < ef || d_e < Bound()) + { + candidates.Enqueue(e, d_e); + if (live || !liveOnly) { - candidates.Enqueue(e, d_e); result.Enqueue(e, -d_e); if (result.Count > ef) result.Dequeue(); } @@ -455,13 +460,18 @@ private bool TryGetEntryPoint(ulong transactionId, out NodeReference entryPoint) } private float[] LoadVector(NodeReference node, ITransaction? transaction) + => LoadVector(node, transaction, out _); + + /// Loads a node's vector; is false for a deleted node. + private float[] LoadVector(NodeReference node, ITransaction? transaction, out bool live) { var buffer = RentPageBuffer(); try { _storage.ReadPage(node.PageId, transaction?.TransactionId, buffer); float[] vector = new float[_options.Dimensions]; - VectorPage.ReadNodeData(buffer, node.NodeIndex, out _, out _, vector); + VectorPage.ReadNodeData(buffer, node.NodeIndex, out var location, out _, vector); + live = location.PageId != 0; return vector; } finally { ReturnPageBuffer(buffer); } @@ -479,7 +489,7 @@ public IEnumerable Search(float[] query, int k, int efSearch } // 2. Comprehensive search on level 0 - var nearest = SearchLayer(currentPoint, query, Math.Max(efSearch, k), 0, transaction); + var nearest = SearchLayer(currentPoint, query, Math.Max(efSearch, k), 0, transaction, liveOnly: true); // 3. Return top-k results, skipping deleted nodes. // SearchLayer already computed distances; we retrieve location + vector together @@ -525,7 +535,11 @@ public bool Relocate(float[] vector, DocumentLocation oldLocation, DocumentLocat /// /// Finds one node holding exactly for and /// rewrites its location. A search for the vector itself finds the node in practice; the - /// page scan behind it only runs if the approximate search misses. + /// page scan behind it only runs if the approximate search misses, which happens reliably + /// when more than EfConstruction documents share an identical vector (e.g. all-zero + /// embeddings): every such update then reads all node pages. + /// Like , this runs without an index-level lock; callers serialize + /// writes through the collection lock. /// private bool RewriteLocation(float[] vector, DocumentLocation from, DocumentLocation to, ITransaction? transaction) { diff --git a/tests/BLite.Tests/SpatialVectorIndexMaintenanceTests.cs b/tests/BLite.Tests/SpatialVectorIndexMaintenanceTests.cs index 9c5b9aa6..22452763 100644 --- a/tests/BLite.Tests/SpatialVectorIndexMaintenanceTests.cs +++ b/tests/BLite.Tests/SpatialVectorIndexMaintenanceTests.cs @@ -135,20 +135,60 @@ public async Task Vector_DeleteThenInsertElsewhere_DoesNotReturnTheNewDocument() } [Fact] - public async Task Vector_DeleteMost_SearchReturnsOnlySurvivors() + public async Task Vector_DeleteMoreThanEfSearch_SearchStillReturnsKLiveResults() { + // 200 deleted nodes sit closer to the query than any survivor, more than efSearch (100). var all = new List(); - for (int i = 0; i < 40; i++) + for (int i = 0; i < 300; i++) { var e = new VectorEntity { Title = $"v{i}", Embedding = [i, i, i] }; await _db.VectorItems.InsertAsync(e); all.Add(e); } - foreach (var e in all.Take(30)) + foreach (var e in all.Take(200)) await _db.VectorItems.DeleteAsync(e.Id); var titles = (await NearestAsync([0f, 0f, 0f], 10)).Select(e => e.Title).ToList(); - Assert.Equal(Enumerable.Range(30, 10).Select(i => $"v{i}"), titles); + Assert.Equal(Enumerable.Range(200, 10).Select(i => $"v{i}"), titles); + } + + [Fact] + public async Task Vector_RelocatingUpdatesWithAnUnchangedVector_AddNoNodes() + { + var a = new VectorEntity { Title = "a", Embedding = [1f, 2f, 3f] }; + await _db.VectorItems.InsertAsync(a); + await _db.VectorItems.InsertAsync(new VectorEntity { Title = "b", Embedding = [4f, 5f, 6f] }); + int before = VectorNodeCount(); + + for (int i = 1; i <= 3; i++) + { + a.Title = new string('x', 1000 * i); // outgrows its slot every time + await _db.VectorItems.UpdateAsync(a); + } + + Assert.Equal(before, VectorNodeCount()); + Assert.Equal(a.Title, (await NearestAsync([1f, 2f, 3f], 1)).Single().Title); + } + + /// All HNSW nodes of idx_vector, deleted ones included. + private int VectorNodeCount() + { + var manager = typeof(BLite.Core.Collections.DocumentCollection) + .GetField("_indexManager", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! + .GetValue(_db.VectorItems)!; + var index = (BLite.Core.Indexing.CollectionSecondaryIndex)manager.GetType() + .GetMethod("GetIndex")!.Invoke(manager, ["idx_vector"])!; + var vectorIndex = (VectorSearchIndex)index.GetType() + .GetField("_vectorIndex", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! + .GetValue(index)!; + var buffer = new byte[_db.Storage.PageSize]; + int nodes = 0; + foreach (var page in vectorIndex.CollectAllPages()) + { + _db.Storage.ReadPage(page, null, buffer); + nodes += BLite.Core.Storage.VectorPage.GetNodeCount(buffer); + } + return nodes; } [Fact] From 58d9c71eada4e302eed040f04a112ac504e6a972 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Mon, 12 Oct 2026 18:57:02 +0200 Subject: [PATCH 3/3] fix(index): address follow-ups on spatial/vector index maintenance (#164) - CollectionSecondaryIndex.Update: the XML doc now describes the in-place and relocating cases it handles. - Update adjusts the index's document count when the key goes from null to non-null or back, on both paths. - VectorSearchIndex.Delete: note that query cost grows with deleted nodes when fewer than ef live nodes are reachable, and point at the index rebuild. Co-Authored-By: Claude Opus 5.5 --- .../Indexing/CollectionSecondaryIndex.cs | 17 ++++++---- src/BLite.Core/Indexing/VectorSearchIndex.cs | 3 ++ .../SpatialVectorIndexMaintenanceTests.cs | 34 +++++++++++++++++++ 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/src/BLite.Core/Indexing/CollectionSecondaryIndex.cs b/src/BLite.Core/Indexing/CollectionSecondaryIndex.cs index 1cda2ac0..10bb0d7a 100644 --- a/src/BLite.Core/Indexing/CollectionSecondaryIndex.cs +++ b/src/BLite.Core/Indexing/CollectionSecondaryIndex.cs @@ -109,16 +109,15 @@ public void Insert(T document, DocumentLocation location, ITransaction transacti } /// - /// Updates a document in this index (delete old, insert new). - /// This optimization applies only for in-place updates where the physical location - /// is unchanged and the old index entry remains valid. For non-in-place (relocating) - /// updates, callers must use InsertInAll directly after DeleteFromAll - /// has already been called (e.g., via DeleteCore). + /// Updates a document in this index, for both in-place and relocating updates. + /// If the key is unchanged and the document did not move, the existing entry is kept; + /// if only the location changed, the entry is re-pointed to ; + /// otherwise the old entry is removed and the new one inserted. /// /// Old version of document /// New version of document /// Physical location of old document - /// Physical location of new document (must equal oldLocation for this method) + /// Physical location of new document (may differ from ) /// Optional transaction public void Update(T oldDocument, T newDocument, DocumentLocation oldLocation, DocumentLocation newLocation, ITransaction transaction) { @@ -156,6 +155,12 @@ public void Update(T oldDocument, T newDocument, DocumentLocation oldLocation, D // Insert new entry if it has a key if (newKey != null) InsertKey(newKey, newDocument, newLocation, transaction); + + // Keep the count in line with Insert/Delete, which skip documents without a key. + if (oldKey == null && newKey != null) + System.Threading.Interlocked.Increment(ref _documentCount); + else if (oldKey != null && newKey == null) + System.Threading.Interlocked.Decrement(ref _documentCount); } /// diff --git a/src/BLite.Core/Indexing/VectorSearchIndex.cs b/src/BLite.Core/Indexing/VectorSearchIndex.cs index c03fd018..3d3adf03 100644 --- a/src/BLite.Core/Indexing/VectorSearchIndex.cs +++ b/src/BLite.Core/Indexing/VectorSearchIndex.cs @@ -519,6 +519,9 @@ public IEnumerable Search(float[] query, int k, int efSearch /// Marks the node that stores for as /// deleted. The node keeps its vector and links so the graph stays connected, but search no /// longer returns it. Deleted nodes are dropped when the index is rebuilt. + /// Search keeps expanding deleted nodes until it holds ef live ones, so when fewer + /// than ef live nodes are reachable, query cost grows with the number of deleted + /// nodes; rebuild the index () to remove them. /// /// false if no such node exists. public bool Delete(float[] vector, DocumentLocation docLocation, ITransaction? transaction = null) diff --git a/tests/BLite.Tests/SpatialVectorIndexMaintenanceTests.cs b/tests/BLite.Tests/SpatialVectorIndexMaintenanceTests.cs index 22452763..81a9e7f3 100644 --- a/tests/BLite.Tests/SpatialVectorIndexMaintenanceTests.cs +++ b/tests/BLite.Tests/SpatialVectorIndexMaintenanceTests.cs @@ -288,4 +288,38 @@ BsonDocument Doc(string title, float[] v) => col.CreateDocument(["title", "embed try { if (File.Exists(f)) File.Delete(f); } catch { /* best-effort */ } } } + + // ── Statistics ────────────────────────────────────────────────────────── + + /// + /// An update that gives a document a key, or takes it away, changes the index's document + /// count like an insert or delete would, whether the document stays in its slot or moves. + /// + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Update_BetweenNullAndNonNullKey_AdjustsEstimatedDocumentCount(bool relocating) + { + var people = _db.PeopleWithEmbeddedAddress; + // Read the index directly: GetIndexes() caches its result until the set of indexes changes. + long Count() => people.IndexManager.GetAllIndexes().Select(i => i.GetInfo()) + .Single(i => i.PropertyPaths[0].StartsWith("MainAddress", StringComparison.OrdinalIgnoreCase)) + .EstimatedDocumentCount; + // In place: each update is smaller than the stored document. Relocating: each one is larger. + string Name(int step) => new string('x', relocating ? 1000 * step : 1000 / step); + + var p = new PersonWithEmbeddedAddress { Id = 1, Name = Name(1) }; + await people.InsertAsync(p); + Assert.Equal(0, Count()); + + p.MainAddress = new Address { City = new City { Name = "Milan" } }; + p.Name = Name(2); + await people.UpdateAsync(p); + Assert.Equal(1, Count()); + + p.MainAddress = null; + p.Name = Name(3); + await people.UpdateAsync(p); + Assert.Equal(0, Count()); + } }