diff --git a/src/BLite.Core/Collections/DocumentCollection.cs b/src/BLite.Core/Collections/DocumentCollection.cs index 948f573..58ddfe4 100644 --- a/src/BLite.Core/Collections/DocumentCollection.cs +++ b/src/BLite.Core/Collections/DocumentCollection.cs @@ -3035,7 +3035,7 @@ private async Task UpdateAtLocationCore(TId id, T entity, byte[] docData, // Unique indexes only where the key changed, so documents that already share a value // can still be updated. _indexManager.EnsureUniqueInAll(entity, oldEntity, transaction); - _indexManager.ValidateKeysInAll(entity); + _indexManager.ValidateKeysInAll(entity, oldEntity); // Read old page var pageBuffer = ArrayPool.Shared.Rent(_storage.PageSize); @@ -3066,7 +3066,9 @@ private async Task UpdateAtLocationCore(TId id, T entity, byte[] docData, { // 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); + // The primary entry is kept and re-pointed below: the id is unchanged, and one + // stored before MaxKeySize was lowered (#217) could not be inserted again. + await DeleteCore(id, transaction, notifyCdc: false, deleteFromIndexes: false, deleteFromPrimary: false); DocumentLocation newLocation; if (bytesWritten + SlotEntry.Size <= _maxDocumentSizeForSinglePage) @@ -3082,7 +3084,8 @@ private async Task UpdateAtLocationCore(TId id, T entity, byte[] docData, newLocation = new DocumentLocation(newPageId, newSlotIndex); } - _primaryIndex.Insert(key, newLocation, transaction.TransactionId); + if (!_primaryIndex.Relocate(key, oldLocation, newLocation, transaction.TransactionId)) + _primaryIndex.Insert(key, newLocation, transaction.TransactionId); _indexManager.UpdateInAll(oldEntity, entity, oldLocation, newLocation, transaction); // Notify CDC @@ -3377,7 +3380,7 @@ private async Task DeleteBulkInternal(IEnumerable ids, ITransaction tr return deleteCount; } - private async Task DeleteCore(TId id, ITransaction transaction, bool notifyCdc = true, bool deleteFromIndexes = true) + private async Task DeleteCore(TId id, ITransaction transaction, bool notifyCdc = true, bool deleteFromIndexes = true, bool deleteFromPrimary = true) { var key = _mapper.ToIndexKey(id); if (!_primaryIndex.TryFind(key, out var location, transaction.TransactionId)) @@ -3434,7 +3437,8 @@ private async Task DeleteCore(TId id, ITransaction transaction, bool notif _fsi.Update(location.PageId, compactedHeader.AvailableFreeSpace); // Remove from primary index - _primaryIndex.Delete(key, location, transaction.TransactionId); + if (deleteFromPrimary) + _primaryIndex.Delete(key, location, transaction.TransactionId); // Notify CDC if (notifyCdc) diff --git a/src/BLite.Core/DynamicCollection.cs b/src/BLite.Core/DynamicCollection.cs index e2b1eb1..746cfea 100644 --- a/src/BLite.Core/DynamicCollection.cs +++ b/src/BLite.Core/DynamicCollection.cs @@ -1267,16 +1267,28 @@ private async Task UpdateAtLocationCore(BsonId id, IndexKey key, BsonDocument ne // Everything that can reject the document is checked before any page is written. ValidateWrite(id, key, newDocument, oldDoc, isInsert: false, transaction); - DeleteSlot(oldLocation, transaction); - + // A document that is not larger than before stays in its slot, so the entries of keys + // that did not change stay valid and are left alone (see IndexUpdate). var docData = newDocument.RawData; - var pageId = FindPageWithSpace(docData.Length + SlotEntry.Size, transaction.TransactionId); - if (pageId == 0) pageId = AllocateNewDataPage(transaction); - var slotIndex = InsertIntoPage(pageId, docData, transaction); - var newLocation = new DocumentLocation(pageId, slotIndex); + DocumentLocation newLocation; + if (TryUpdateInPlace(oldLocation, docData, transaction)) + { + newLocation = oldLocation; + } + else + { + DeleteSlot(oldLocation, transaction); - _primaryIndex.Delete(key, oldLocation, transaction.TransactionId); - _primaryIndex.Insert(key, newLocation, transaction.TransactionId); + var pageId = FindPageWithSpace(docData.Length + SlotEntry.Size, transaction.TransactionId); + if (pageId == 0) pageId = AllocateNewDataPage(transaction); + var slotIndex = InsertIntoPage(pageId, docData, transaction); + newLocation = new DocumentLocation(pageId, slotIndex); + + // The id is unchanged: re-point its entry rather than inserting it again, which + // also lets an id stored before MaxKeySize was lowered (#217) follow its document. + if (!_primaryIndex.Relocate(key, oldLocation, newLocation, transaction.TransactionId)) + _primaryIndex.Insert(key, newLocation, transaction.TransactionId); + } foreach (var (_, idx) in _secondaryIndexes) IndexUpdate(idx, oldDoc, newDocument, oldLocation, newLocation, transaction); @@ -2127,7 +2139,9 @@ private void ValidateWrite(BsonId id, IndexKey key, BsonDocument document, BsonD throw new BLiteValidationException( $"Document size {size} exceeds maximum single page size. Overflow not yet supported in DynamicCollection."); - if (key.Data.Length > _primaryIndex.MaxKeySize) + // An update keeps the id it was found by, so only a new id is checked: one stored before + // MaxKeySize was lowered (#217) must not make its document impossible to update. + if (isInsert && key.Data.Length > _primaryIndex.MaxKeySize) throw new BLiteValidationException( $"Id of {key.Data.Length} bytes is too large for the primary index of collection '{_collectionName}' (maximum {_primaryIndex.MaxKeySize} bytes)."); @@ -2145,20 +2159,21 @@ private void ValidateWrite(BsonId id, IndexKey key, BsonDocument document, BsonD { if (BsonValueToIndexKey(val) is not { } indexKey) break; + // On update an unchanged key keeps its entry (IndexUpdate leaves it in place + // or re-points it), so it is neither size-checked again (a key stored before + // MaxKeySize was lowered, #217, stays updatable) nor checked for uniqueness + // (documents that already share a value can still be updated). + if (oldDocument != null + && TryGetBTreeKey(oldDocument, idx, out var oldKey) + && oldKey.Data.SequenceEqual(indexKey.Data)) + break; + if (indexKey.Data.Length > idx.BTree!.MaxKeySize) throw new BLiteValidationException( $"Key of {indexKey.Data.Length} bytes is too large for index '{name}' (maximum {idx.BTree.MaxKeySize} bytes)."); if (!idx.Options.Unique) break; - // On update an unchanged key keeps its entry (IndexUpdate deletes it first), - // so documents that already share a value can still be updated. - if (oldDocument != null - && TryGetNestedValue(oldDocument, idx.FieldPath, out var oldVal) - && BsonValueToIndexKey(oldVal) is { } oldKey - && oldKey.Data.SequenceEqual(indexKey.Data)) - break; - if (idx.BTree.TryFind(indexKey, out _, transaction.TransactionId)) throw new BLiteValidationException($"Duplicate key violation for unique index '{name}'"); break; @@ -2248,10 +2263,69 @@ private void IndexUpdate(DynamicSecondaryIndex idx, BsonDocument? oldDocument, B && idx.Vector!.Relocate(oldVector, oldLocation, newLocation, transaction)) return; + // An unchanged B-Tree key keeps its entry: left alone when the document stayed in its + // slot, re-pointed when it moved. Deleting and inserting it again would cost a search + // through every copy of a duplicated key (keys here carry no document id), and would + // fail for a key stored before MaxKeySize was lowered (#217). + if (oldDocument != null && idx.Kind == DynamicIndexKind.BTree + && TryGetBTreeKey(oldDocument, idx, out var oldKey) + && TryGetBTreeKey(newDocument, idx, out var newKey) + && oldKey.Data.SequenceEqual(newKey.Data)) + { + bool moved = oldLocation.PageId != newLocation.PageId || oldLocation.SlotIndex != newLocation.SlotIndex; + if (!moved || idx.BTree!.Relocate(oldKey, oldLocation, newLocation, transaction.TransactionId)) + return; + } + if (oldDocument != null) IndexDelete(idx, oldDocument, oldLocation, transaction); IndexInsert(idx, newDocument, newLocation, transaction); } + /// The key has in the B-Tree index , if any. + private bool TryGetBTreeKey(BsonDocument document, DynamicSecondaryIndex idx, out IndexKey key) + { + key = default; + if (!TryGetNestedValue(document, idx.FieldPath, out var val) || BsonValueToIndexKey(val) is not { } k) + return false; + key = k; + return true; + } + + /// + /// Overwrites the document at with when + /// it fits in the slot it already has, so the document keeps its location. Returns + /// false, writing nothing, when it does not fit or the slot is not a live data slot. + /// + private bool TryUpdateInPlace(DocumentLocation location, ReadOnlyMemory data, ITransaction transaction) + { + var buffer = ArrayPool.Shared.Rent(_storage.PageSize); + try + { + _storage.ReadPage(location.PageId, transaction.TransactionId, buffer); + var header = SlottedPageHeader.ReadFrom(buffer); + if (header.PageType != PageType.Data || location.SlotIndex >= header.SlotCount) + return false; + + var slotOffset = SlottedPageHeader.Size + (location.SlotIndex * SlotEntry.Size); + var slot = SlotEntry.ReadFrom(buffer.AsSpan(slotOffset)); + if (slot.Flags != SlotFlags.None || data.Length > slot.Length) + return false; + + data.Span.CopyTo(buffer.AsSpan(slot.Offset, data.Length)); + // Erase the bytes the shorter document no longer uses, as DeleteSlot does. + buffer.AsSpan(slot.Offset + data.Length, slot.Length - data.Length).Clear(); + slot.Length = (ushort)data.Length; + slot.WriteTo(buffer.AsSpan(slotOffset)); + + _storage.WritePage(location.PageId, transaction.TransactionId, buffer.AsSpan(0, _storage.PageSize)); + return true; + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + #endregion #region Internal storage operations diff --git a/src/BLite.Core/Indexing/BTreeCursor.cs b/src/BLite.Core/Indexing/BTreeCursor.cs index 8a0aae3..a87d607 100644 --- a/src/BLite.Core/Indexing/BTreeCursor.cs +++ b/src/BLite.Core/Indexing/BTreeCursor.cs @@ -54,7 +54,12 @@ public bool MoveToFirst() pageId = BitConverter.ToUInt32(_pageBuffer.AsSpan(dataOffset, 4)); } - return PositionAtStart(); + if (PositionAtStart()) return true; + + // The leaf is empty (left unmerged, #217): MoveNext skips to the next one with entries. + _isValid = true; + _currentEntryIndex = -1; + return MoveNext(); } public bool MoveToLast() @@ -89,22 +94,29 @@ public bool MoveToLast() pageId = lastPointer; } - return PositionAtEnd(); + if (PositionAtEnd()) return true; + + // The leaf is empty (left unmerged, #217): MovePrev skips to the previous one with entries. + _isValid = true; + _currentEntryIndex = 0; + return MovePrev(); } public bool Seek(IndexKey key) { - // Use Index to find leaf - var leafPageId = _index.FindLeafNode(key, _transactionId); + // Use Index to find leaf: the leftmost one that can hold the key, so that no copy of a + // duplicated key sits in an earlier leaf (#213). + var leafPageId = _index.FindLeafNode(key, _transactionId, leftmost: true); LoadPage(leafPageId); ParseEntries(); // Binary search in entries var idx = _currentEntries.BinarySearch(new IndexEntry(key, default(DocumentLocation))); - + if (idx >= 0) { - // Found exact match + // Found exact match; with duplicates, position on the first copy. + while (idx > 0 && _currentEntries[idx - 1].Key.Equals(key)) idx--; _currentEntryIndex = idx; _isValid = true; return true; @@ -121,16 +133,18 @@ public bool Seek(IndexKey key) } else { - // Key is larger than max in this page, move to next page - if (_currentHeader.NextLeafPageId != 0) + // Key is larger than max in this page, move to the next page that has entries + // (a leaf can be left empty when it could not be merged, #217). + while (_currentHeader.NextLeafPageId != 0) { LoadPage(_currentHeader.NextLeafPageId); ParseEntries(); _currentEntryIndex = 0; if (_currentEntries.Count > 0) { + // The key can start the next leaf when it equals the separator. _isValid = true; - return false; + return _currentEntries[0].Key.Equals(key); } } @@ -151,11 +165,11 @@ public bool MoveNext() return true; } - // Move to next page - if (_currentHeader.NextLeafPageId != 0) + // Move to the next page that has entries + while (_currentHeader.NextLeafPageId != 0) { LoadPage(_currentHeader.NextLeafPageId); - return PositionAtStart(); + if (PositionAtStart()) return true; } _isValid = false; @@ -172,11 +186,11 @@ public bool MovePrev() return true; } - // Move to prev page - if (_currentHeader.PrevLeafPageId != 0) + // Move to the previous page that has entries + while (_currentHeader.PrevLeafPageId != 0) { LoadPage(_currentHeader.PrevLeafPageId); - return PositionAtEnd(); + if (PositionAtEnd()) return true; } _isValid = false; diff --git a/src/BLite.Core/Indexing/BTreeIndex.cs b/src/BLite.Core/Indexing/BTreeIndex.cs index 0694756..10bcff8 100644 --- a/src/BLite.Core/Indexing/BTreeIndex.cs +++ b/src/BLite.Core/Indexing/BTreeIndex.cs @@ -78,13 +78,34 @@ public BTreeIndex(StorageEngine storage, public uint RootPageId => _rootPageId; /// - /// The largest key that fits in a node page on its own: page header (32) + node header (20) - /// + the leftmost child pointer of an internal node (4) + one entry (4-byte length, key, - /// 4-byte child pointer). A larger key can never be inserted, so the collections reject it - /// before writing anything. Nodes split by entry count, not by size, so many keys below - /// this limit can still overflow a node; that is caught as a failure during the write. + /// The largest key an index on this page size accepts: an entry for it takes at most half + /// of the entry space of a node, leaf (4-byte length, key, 6-byte location) or internal + /// (4-byte length, key, 4-byte child pointer, after the 4-byte leftmost child pointer). + /// That bound is what lets a full node always split in two halves that each fit a page + /// (#217); a key as large as a whole page could fit alone but not next to a sibling entry. + /// The collections reject a larger key before writing anything, and + /// rejects it too. /// - internal int MaxKeySize => _storage.PageSize - 32 - 20 - 4 - 4 - 4; + internal int MaxKeySize => MaxKeySizeOverride ?? InternalEntrySpace / 2 - 4 - 4; + + /// + /// Test hook: raises on this instance so tests can write keys up to + /// the limit of older versions (PageSize - 64), standing in for an index those versions wrote. + /// + internal int? MaxKeySizeOverride { get; set; } + + /// Where the entries of a node start: page header (32) + node header (20). + private const int NodeDataStart = 32 + 20; + + /// Bytes available for the entries of a leaf node. + private int LeafEntrySpace => _storage.PageSize - NodeDataStart; + + /// Bytes available for the entries of an internal node, after its leftmost child pointer. + private int InternalEntrySpace => _storage.PageSize - NodeDataStart - 4; + + private static int LeafEntrySize(int keyLength) => 4 + keyLength + DocumentLocation.SerializedSize; + + private static int InternalEntrySize(int keyLength) => 4 + keyLength + 4; /// /// Reads a page using StorageEngine for transaction isolation. @@ -110,14 +131,20 @@ public void Insert(IndexKey key, DocumentLocation location, ulong? transactionId { var txnId = transactionId ?? 0; + if (key.Data.Length > MaxKeySize) + throw new ArgumentException( + $"Index key of {key.Data.Length} bytes is too large: a B-Tree index on {_storage.PageSize}-byte pages holds keys of at most {MaxKeySize} bytes.", + nameof(key)); + var entry = new IndexEntry(key, location); var path = new List(); var pageBuffer = System.Buffers.ArrayPool.Shared.Rent(_storage.PageSize); try { - // Find the leaf node for insertion; the leaf is left in pageBuffer. - var leafPageId = FindLeafNodeWithPath(key, path, txnId, pageBuffer); + // Find the leaf node for insertion; the leaf is left in pageBuffer. An entry equal to + // a separator goes to the right of it, after any duplicates already there. + var leafPageId = FindLeafNodeWithPath(key, path, txnId, pageBuffer, leftmost: false); var header = BTreeNodeHeader.ReadFrom(pageBuffer.AsSpan(32)); if (_options.Unique) @@ -131,18 +158,8 @@ public void Insert(IndexKey key, DocumentLocation location, ulong? transactionId } } - // Check if we need to split - if (header.EntryCount >= MaxEntriesPerNode) - { - SplitNode(leafPageId, path, txnId); - - // Re-find leaf after split to ensure we have correct node - path.Clear(); - leafPageId = FindLeafNodeWithPath(key, path, txnId, pageBuffer); - } - - // Insert entry into leaf - InsertIntoLeaf(leafPageId, entry, pageBuffer, txnId); + // Insert entry into leaf, splitting it when the entry does not fit + InsertIntoLeaf(leafPageId, entry, pageBuffer, path, txnId); } finally { @@ -158,21 +175,24 @@ public bool TryFind(IndexKey key, out DocumentLocation location, ulong? transact location = default; var txnId = transactionId ?? 0; - var leafPageId = FindLeafNode(key, txnId); - var pageBuffer = System.Buffers.ArrayPool.Shared.Rent(_storage.PageSize); try { - ReadPage(leafPageId, txnId, pageBuffer); - - var header = BTreeNodeHeader.ReadFrom(pageBuffer.AsSpan(32)); - var result = BinarySearchLeaf(pageBuffer, header.EntryCount, key); - if (result.Found) + // Start at the leftmost leaf that can hold the key (#213): in a non-unique index the + // leaf right of an equal separator may have lost its copies of the key while the + // leaf left of it still has some. + var leafPageId = FindLeafNodeWithPath(key, null, txnId, pageBuffer, leftmost: true); + while (true) { - location = result.Location; - return true; + var header = BTreeNodeHeader.ReadFrom(pageBuffer.AsSpan(32)); + if (SearchLeafLowerBound(pageBuffer, header.EntryCount, key, out var found, out location)) + return found; + + // Every key in this leaf is smaller: the key, if present, starts the next leaf. + leafPageId = header.NextLeafPageId; + if (leafPageId == 0) return false; + ReadPage(leafPageId, txnId, pageBuffer); } - return false; } finally { @@ -195,7 +215,9 @@ public IEnumerable Range(IndexKey minKey, IndexKey maxKey, IndexDire { if (direction == IndexDirection.Forward) { - var leafPageId = FindLeafNode(minKey, txnId); + // The leftmost leaf that can hold minKey: copies of it may sit left of an equal + // separator (#213). The scan walks right from there. + var leafPageId = FindLeafNode(minKey, txnId, leftmost: true); while (leafPageId != 0) { @@ -236,8 +258,8 @@ public IEnumerable Range(IndexKey minKey, IndexKey maxKey, IndexDire } else // Backward { - // Start from the end of the range (maxKey) - var leafPageId = FindLeafNode(maxKey, txnId); + // Start from the end of the range: the rightmost leaf that can hold maxKey. + var leafPageId = FindLeafNode(maxKey, txnId, leftmost: false); while (leafPageId != 0) { @@ -324,7 +346,7 @@ internal void ScanRange(IndexKey minKey, IndexKey maxKey, ulong? trans var pageBuffer = System.Buffers.ArrayPool.Shared.Rent(_storage.PageSize); try { - var leafPageId = FindLeafNode(minKey, txnId); + var leafPageId = FindLeafNode(minKey, txnId, leftmost: true); // Once we've passed minKey in the sorted leaf chain all subsequent entries // are also >= minKey, so we only need the minKey check until we enter the range. bool inRange = false; @@ -390,7 +412,7 @@ internal bool TryFindFirst(IndexKey minKey, IndexKey maxKey, ulong txnId, out Do var pageBuffer = System.Buffers.ArrayPool.Shared.Rent(_storage.PageSize); try { - var leafPageId = FindLeafNode(minKey, txnId); + var leafPageId = FindLeafNode(minKey, txnId, leftmost: true); while (leafPageId != 0) { @@ -431,18 +453,22 @@ internal bool TryFindFirst(IndexKey minKey, IndexKey maxKey, ulong txnId, out Do return false; } - internal uint FindLeafNode(IndexKey key, ulong transactionId) - { - var path = new List(); - return FindLeafNodeWithPath(key, path, transactionId); - } - - private uint FindLeafNodeWithPath(IndexKey key, List path, ulong transactionId) + /// + /// Finds the leaf to start from for . A separator has keys lower than + /// or equal to it on its left and keys greater than or equal to it on its right: a leaf split + /// promotes the first key of the right half, and in a non-unique index copies of that key + /// can stay in the left half. So which way to go on a key equal to a separator depends on + /// the direction the caller then walks the leaf chain (#213). + /// goes left and lands on the first leaf that can hold the key, where a forward scan or a + /// lookup starts; otherwise the descent goes right and lands on the last such leaf, where a + /// backward scan starts and an insert adds the key. + /// + internal uint FindLeafNode(IndexKey key, ulong transactionId, bool leftmost) { var pageBuffer = System.Buffers.ArrayPool.Shared.Rent(_storage.PageSize); try { - return FindLeafNodeWithPath(key, path, transactionId, pageBuffer); + return FindLeafNodeWithPath(key, null, transactionId, pageBuffer, leftmost); } finally { @@ -450,8 +476,11 @@ private uint FindLeafNodeWithPath(IndexKey key, List path, ulong transacti } } - /// Like the overload above, but leaves the leaf page in . - private uint FindLeafNodeWithPath(IndexKey key, List path, ulong transactionId, byte[] pageBuffer) + /// + /// Descends like , adds the internal nodes passed on the way to + /// (when given) and leaves the leaf page in . + /// + private uint FindLeafNodeWithPath(IndexKey key, List? path, ulong transactionId, byte[] pageBuffer, bool leftmost) { var currentPageId = _rootPageId; while (true) @@ -462,55 +491,70 @@ private uint FindLeafNodeWithPath(IndexKey key, List path, ulong transacti if (header.IsLeaf) return currentPageId; - path.Add(currentPageId); - currentPageId = FindChildNode(pageBuffer, header, key); + path?.Add(currentPageId); + currentPageId = FindChildNode(pageBuffer, header, key, leftmost); } } - private uint FindChildNode(Span nodeBuffer, BTreeNodeHeader header, IndexKey key) + private static uint FindChildNode(Span nodeBuffer, BTreeNodeHeader header, IndexKey key, bool leftmost) { // Internal Node Format: // [Header] - // [P0 (4 bytes)] - Pointer to child with keys < Key1 - // [Entry 1: Key1, P1] + // [P0 (4 bytes)] - Pointer to child with keys <= Key1 + // [Entry 1: Key1, P1] - P1 has keys >= Key1 (and <= Key2) // [Entry 2: Key2, P2] // ... - var dataOffset = 32 + 20; - var p0 = BitConverter.ToUInt32(nodeBuffer.Slice(dataOffset, 4)); - dataOffset += 4; - - int count = header.EntryCount; - if (count == 0) return p0; - // Build offset table for binary search (O(N), zero-alloc) - Span offsets = stackalloc int[count]; - int off = dataOffset; - for (int i = 0; i < count; i++) + Span offsets = stackalloc int[header.EntryCount]; + BuildInternalOffsets(nodeBuffer, offsets); + return ChildPointer(nodeBuffer, offsets, FindChildIndex(nodeBuffer, offsets, key, leftmost)); + } + + /// Fills with the offset of each entry of an internal node. + private static void BuildInternalOffsets(ReadOnlySpan nodeBuffer, Span offsets) + { + int off = NodeDataStart + 4; // after P0 + for (int i = 0; i < offsets.Length; i++) { offsets[i] = off; int keyLength = BitConverter.ToInt32(nodeBuffer.Slice(off, 4)); - off += 4 + keyLength + 4; // key length prefix + key data + child pointer + off += InternalEntrySize(keyLength); } + } - // Binary search: find first entry where key < entryKey + /// + /// Which child of an internal node to descend to for : 0 for P0, i for + /// the child pointer of entry i. See for . + /// + private static int FindChildIndex(ReadOnlySpan nodeBuffer, ReadOnlySpan offsets, IndexKey key, bool leftmost) + { + // Binary search for the first separator above the key (leftmost: not below it); + // the child on its left is the one to follow. var keyData = key.Data; - int lo = 0, hi = count - 1; - int firstGreater = count; + int lo = 0, hi = offsets.Length - 1; + int firstAbove = offsets.Length; while (lo <= hi) { int mid = (lo + hi) >> 1; int midOff = offsets[mid]; int midKeyLen = BitConverter.ToInt32(nodeBuffer.Slice(midOff, 4)); - if (IndexKey.CompareRaw(keyData, nodeBuffer.Slice(midOff + 4, midKeyLen)) < 0) - { firstGreater = mid; hi = mid - 1; } + int cmp = IndexKey.CompareRaw(keyData, nodeBuffer.Slice(midOff + 4, midKeyLen)); + if (cmp < 0 || (leftmost && cmp == 0)) + { firstAbove = mid; hi = mid - 1; } else { lo = mid + 1; } } + return firstAbove; + } - if (firstGreater == 0) return p0; - int prevOff = offsets[firstGreater - 1]; - int prevKeyLen = BitConverter.ToInt32(nodeBuffer.Slice(prevOff, 4)); - return BitConverter.ToUInt32(nodeBuffer.Slice(prevOff + 4 + prevKeyLen, 4)); + /// The page of child (as returned by ). + private static uint ChildPointer(ReadOnlySpan nodeBuffer, ReadOnlySpan offsets, int childIndex) + { + if (childIndex == 0) + return BitConverter.ToUInt32(nodeBuffer.Slice(NodeDataStart, 4)); + int entryOff = offsets[childIndex - 1]; + int keyLen = BitConverter.ToInt32(nodeBuffer.Slice(entryOff, 4)); + return BitConverter.ToUInt32(nodeBuffer.Slice(entryOff + 4 + keyLen, 4)); } public IBTreeCursor CreateCursor(ulong transactionId) @@ -688,7 +732,7 @@ public IEnumerable Like(string pattern, ulong transactionId) } while (cursor.MoveNext()); } - private void InsertIntoLeaf(uint leafPageId, IndexEntry entry, Span pageBuffer, ulong transactionId) + private void InsertIntoLeaf(uint leafPageId, IndexEntry entry, Span pageBuffer, List path, ulong transactionId) { var header = BTreeNodeHeader.ReadFrom(pageBuffer[32..]); int count = header.EntryCount; @@ -721,7 +765,15 @@ private void InsertIntoLeaf(uint leafPageId, IndexEntry entry, Span pageBu int insertOffset = offsets[insertIdx]; int currentEnd = offsets[count]; - int entrySize = 4 + newKeyData.Length + DocumentLocation.SerializedSize; + int entrySize = LeafEntrySize(newKeyData.Length); + + // A node splits when it has no room left for the entry, by count or by bytes: with + // variable-length keys a page can fill up long before MaxEntriesPerNode (#217). + if (count >= MaxEntriesPerNode || currentEnd + entrySize > _storage.PageSize) + { + SplitLeafAndInsert(leafPageId, header, pageBuffer, offsets, insertIdx, entry, path, transactionId); + return; + } // Shift existing data right (reverse byte copy for overlap safety, zero-alloc) if (insertOffset < currentEnd) @@ -743,104 +795,154 @@ private void InsertIntoLeaf(uint leafPageId, IndexEntry entry, Span pageBu WritePage(leafPageId, transactionId, pageBuffer); } - private void SplitNode(uint nodePageId, List path, ulong transactionId) - { - var pageBuffer = System.Buffers.ArrayPool.Shared.Rent(_storage.PageSize); - try - { - ReadPage(nodePageId, transactionId, pageBuffer); - var header = BTreeNodeHeader.ReadFrom(pageBuffer.AsSpan(32)); - - if (header.IsLeaf) - { - SplitLeafNode(nodePageId, header, pageBuffer, path, transactionId); - } - else - { - SplitInternalNode(nodePageId, header, pageBuffer, path, transactionId); - } - } - finally - { - System.Buffers.ArrayPool.Shared.Return(pageBuffer); - } - } - - private void SplitLeafNode(uint nodePageId, BTreeNodeHeader header, Span pageBuffer, List path, ulong transactionId) + /// + /// Splits a leaf that has no room for and inserts the entry into the + /// half it belongs to. The split point is chosen over the existing entries plus the new one, + /// so both halves are known to fit before anything is written, and by bytes rather than by + /// count: with very uneven key sizes a count midpoint can leave one half larger than a page + /// (#217). The first key of the right half is promoted to the parent. + /// + /// Entry offsets of the leaf in , with the end of the data last. + /// Position of the new entry among the existing ones. + private void SplitLeafAndInsert(uint leafPageId, BTreeNodeHeader header, Span pageBuffer, ReadOnlySpan offsets, + int insertIdx, IndexEntry entry, List path, ulong transactionId) { int count = header.EntryCount; - int splitPoint = count / 2; - - // Build offset table to find split byte boundary - Span offsets = stackalloc int[count + 1]; - int off = 32 + 20; - for (int i = 0; i < count; i++) - { - offsets[i] = off; - int kl = BitConverter.ToInt32(pageBuffer.Slice(off, 4)); - off += 4 + kl + DocumentLocation.SerializedSize; - } - offsets[count] = off; - - int splitOffset = offsets[splitPoint]; - int dataEnd = offsets[count]; - int rightDataSize = dataEnd - splitOffset; - - // Read the promote key (first key of right half) - int promoteKeyLen = BitConverter.ToInt32(pageBuffer.Slice(splitOffset, 4)); - var promoteKey = IndexKey.FromOwnedArray(pageBuffer.Slice(splitOffset + 4, promoteKeyLen).ToArray()); - - // Save original pointers before modifying header - uint origNextLeaf = header.NextLeafPageId; - - // Create new right node - var newNodeId = CreateNode(isLeaf: true, transactionId); - - // Write right node: copy right half data into a new page buffer + int total = count + 1; + var newKeyData = entry.Key.Data; + int newEntrySize = LeafEntrySize(newKeyData.Length); + + // Byte offset (relative to the data start) of each entry of the merged sequence: + // the existing entries with the new one at insertIdx. + Span merged = stackalloc int[total + 1]; + for (int j = 0; j <= total; j++) + merged[j] = j <= insertIdx + ? offsets[j] - NodeDataStart + : offsets[j - 1] - NodeDataStart + newEntrySize; + + int splitPoint = ChooseSplitPoint(merged, LeafEntrySpace, promotesMiddle: false); + if (splitPoint < 0) + throw new InvalidOperationException( + $"B-Tree leaf {leafPageId} cannot be split so that both halves fit a {_storage.PageSize}-byte page; " + + $"it holds keys larger than the {MaxKeySize}-byte limit."); + + var newEntryBytes = System.Buffers.ArrayPool.Shared.Rent(newEntrySize); + var leftBuffer = System.Buffers.ArrayPool.Shared.Rent(_storage.PageSize); var rightBuffer = System.Buffers.ArrayPool.Shared.Rent(_storage.PageSize); try { - Array.Clear(rightBuffer, 0, _storage.PageSize); + var newEntry = newEntryBytes.AsSpan(0, newEntrySize); + BitConverter.TryWriteBytes(newEntry.Slice(0, 4), newKeyData.Length); + newKeyData.CopyTo(newEntry.Slice(4)); + entry.Location.WriteTo(newEntry.Slice(4 + newKeyData.Length)); - var rightPageHeader = new PageHeader + uint origNextLeaf = header.NextLeafPageId; + var newNodeId = CreateNode(isLeaf: true, transactionId); + + // Right half: a new leaf linked between this one and its old successor. + var right = rightBuffer.AsSpan(0, _storage.PageSize); + right.Clear(); + new PageHeader { PageId = newNodeId, PageType = PageType.Index, FreeBytes = 0, NextPageId = 0, TransactionId = 0, Checksum = 0 - }; - rightPageHeader.WriteTo(rightBuffer); - - var rightNodeHeader = new BTreeNodeHeader + }.WriteTo(right); + new BTreeNodeHeader { PageId = newNodeId, IsLeaf = true, - EntryCount = (ushort)(count - splitPoint), + EntryCount = (ushort)(total - splitPoint), ParentPageId = 0, NextLeafPageId = origNextLeaf, - PrevLeafPageId = nodePageId - }; - rightNodeHeader.WriteTo(rightBuffer.AsSpan(32, 20)); - - pageBuffer.Slice(splitOffset, rightDataSize).CopyTo(rightBuffer.AsSpan(32 + 20, rightDataSize)); - WritePage(newNodeId, transactionId, rightBuffer.AsSpan(0, _storage.PageSize)); + PrevLeafPageId = leafPageId + }.WriteTo(right.Slice(32, 20)); + CopyMergedLeafEntries(pageBuffer, offsets, insertIdx, newEntry, splitPoint, total, right.Slice(NodeDataStart)); + WritePage(newNodeId, transactionId, right); + + // Left half: rewritten in place, keeping its page header. + var left = leftBuffer.AsSpan(0, _storage.PageSize); + left.Clear(); + pageBuffer.Slice(0, 32).CopyTo(left); + CopyMergedLeafEntries(pageBuffer, offsets, insertIdx, newEntry, 0, splitPoint, left.Slice(NodeDataStart)); + header.EntryCount = (ushort)splitPoint; + header.NextLeafPageId = newNodeId; + header.WriteTo(left.Slice(32, 20)); + WritePage(leafPageId, transactionId, left); + + // Update the original next node's prev pointer to point to new right node + if (origNextLeaf != 0) + UpdatePrevPointer(origNextLeaf, newNodeId, transactionId); + + // Promote the first key of the right half. + int promoteKeyLen = BitConverter.ToInt32(right.Slice(NodeDataStart, 4)); + var promoteKey = IndexKey.FromOwnedArray(right.Slice(NodeDataStart + 4, promoteKeyLen).ToArray()); + InsertIntoParent(leafPageId, promoteKey, newNodeId, path, transactionId); } finally { + System.Buffers.ArrayPool.Shared.Return(newEntryBytes); + System.Buffers.ArrayPool.Shared.Return(leftBuffer); System.Buffers.ArrayPool.Shared.Return(rightBuffer); } + } - // Update left node in-place: truncate to left half, update header - pageBuffer.Slice(splitOffset, rightDataSize).Clear(); - header.EntryCount = (ushort)splitPoint; - header.NextLeafPageId = newNodeId; - header.WriteTo(pageBuffer.Slice(32, 20)); - WritePage(nodePageId, transactionId, pageBuffer); + /// + /// Copies entries [, ) of the merged sequence + /// (the leaf entries at with at + /// ) to . + /// + private static void CopyMergedLeafEntries(ReadOnlySpan page, ReadOnlySpan offsets, int insertIdx, + ReadOnlySpan newEntry, int from, int to, Span destination) + { + int written = 0; + + // Existing entries before the new one keep their index. + int a = from, b = Math.Min(to, insertIdx); + if (a < b) + { + page.Slice(offsets[a], offsets[b] - offsets[a]).CopyTo(destination); + written = offsets[b] - offsets[a]; + } - // Update the original next node's prev pointer to point to new right node - if (origNextLeaf != 0) + if (from <= insertIdx && insertIdx < to) { - UpdatePrevPointer(origNextLeaf, newNodeId, transactionId); + newEntry.CopyTo(destination.Slice(written)); + written += newEntry.Length; } - InsertIntoParent(nodePageId, promoteKey, newNodeId, path, transactionId); + // Existing entries after the new one: merged index j is existing index j - 1. + a = Math.Max(from, insertIdx + 1) - 1; + b = to - 1; + if (a < b) + page.Slice(offsets[a], offsets[b] - offsets[a]).CopyTo(destination.Slice(written)); + } + + /// + /// Picks where to split a node whose entries, in order, start at the byte offsets in + /// (the last value is the end of the data). Both halves must be + /// non-empty, hold at most entries and fit in + /// bytes; among those split points the one that balances the + /// bytes best wins, which for keys of one size is the count midpoint. + /// With (internal nodes) the entry at the split point moves + /// up to the parent and belongs to neither half. Returns -1 when no split point fits, which + /// cannot happen while every entry takes at most half of + /// (see ). + /// + private static int ChooseSplitPoint(ReadOnlySpan offsets, int capacity, bool promotesMiddle) + { + int count = offsets.Length - 1; + int end = offsets[count] - offsets[0]; + int skip = promotesMiddle ? 1 : 0; + int best = -1, bestImbalance = int.MaxValue; + for (int k = 1; k + skip < count; k++) + { + int leftBytes = offsets[k] - offsets[0]; + int rightBytes = end - (offsets[k + skip] - offsets[0]); + if (k > MaxEntriesPerNode || count - k - skip > MaxEntriesPerNode) continue; + if (leftBytes > capacity || rightBytes > capacity) continue; + int imbalance = Math.Abs(leftBytes - rightBytes); + if (imbalance < bestImbalance) { best = k; bestImbalance = imbalance; } + } + return best; } private void UpdatePrevPointer(uint pageId, uint newPrevId, ulong transactionId) @@ -860,37 +962,12 @@ private void UpdatePrevPointer(uint pageId, uint newPrevId, ulong transactionId) } } - private void SplitInternalNode(uint nodePageId, BTreeNodeHeader header, Span pageBuffer, List path, ulong transactionId) - { - var (p0, entries) = ReadInternalEntries(pageBuffer, header.EntryCount); - var splitPoint = entries.Count / 2; - - // For internal nodes, the median key moves UP to parent and is excluded from children - var promoteKey = entries[splitPoint].Key; - - var leftEntries = entries.Take(splitPoint).ToList(); - var rightEntries = entries.Skip(splitPoint + 1).ToList(); - var rightP0 = entries[splitPoint].PageId; // Attempting to use the pointer associated with promoted key as P0 for right node - - // Create new internal node - var newNodeId = CreateNode(isLeaf: false, transactionId); - - // UpdateAsync left node - WriteInternalNode(nodePageId, p0, leftEntries, transactionId); - - // UpdateAsync right node - WriteInternalNode(newNodeId, rightP0, rightEntries, transactionId); - - // Insert promoted key into parent - InsertIntoParent(nodePageId, promoteKey, newNodeId, path, transactionId); - } - private void InsertIntoParent(uint leftChildPageId, IndexKey key, uint rightChildPageId, List path, ulong transactionId) { if (path.Count == 0 || path.Last() == leftChildPageId) { // Root split (or weird path state where last is current) - // If path.Last == leftChild, we need to pop it to get parent. + // If path.Last == leftChild, we need to pop it to get parent. // But if path is empty or contains ONLY the current node, it's a root split. if (path.Count > 0 && path.Last() == leftChildPageId) path.RemoveAt(path.Count - 1); @@ -911,42 +988,46 @@ private void InsertIntoParent(uint leftChildPageId, IndexKey key, uint rightChil ReadPage(parentPageId, transactionId, pageBuffer); var header = BTreeNodeHeader.ReadFrom(pageBuffer.AsSpan(32)); - if (header.EntryCount >= MaxEntriesPerNode) + // Read all, add the new entry right after the one pointing at the split child, then + // write back as one node or two. The position comes from the child, not from the + // key: in a non-unique index several separators can be equal to the key (#213). + var (p0, entries) = ReadInternalEntries(pageBuffer.AsSpan(0, _storage.PageSize), header.EntryCount); + int insertIndex = p0 == leftChildPageId ? 0 : entries.FindIndex(e => e.PageId == leftChildPageId) + 1; + if (insertIndex == 0 && p0 != leftChildPageId) + throw new InvalidOperationException($"B-Tree node {parentPageId} does not point at its split child {leftChildPageId}."); + entries.Insert(insertIndex, new InternalEntry(key, rightChildPageId)); + + // Byte offset of each entry relative to the first one, then the end of the data. + Span offsets = stackalloc int[entries.Count + 1]; + for (int i = 0; i < entries.Count; i++) + offsets[i + 1] = offsets[i] + InternalEntrySize(entries[i].Key.Data.Length); + + // Like a leaf, an internal node splits by count or by bytes (#217). + if (entries.Count <= MaxEntriesPerNode && offsets[entries.Count] <= InternalEntrySpace) { - // Parent full, split parent - // Ideally should Insert then Split, or Split then Insert. - // Simplified: Split parent first, then insert into appropriate half. - // But wait, to Split we need the median. - // Better approach: Read all, add new entry, then split the collection and write back. - - var (p0, entries) = ReadInternalEntries(pageBuffer.AsSpan(0, _storage.PageSize), header.EntryCount); - - // Insert new key/pointer in sorted order - var newEntry = new InternalEntry(key, rightChildPageId); - int insertIndex = entries.FindIndex(e => e.Key > key); - if (insertIndex == -1) entries.Add(newEntry); - else entries.Insert(insertIndex, newEntry); + WriteInternalNode(parentPageId, p0, entries, transactionId); + return; + } - // Now split these extended entries - var splitPoint = entries.Count / 2; - var promoteKey = entries[splitPoint].Key; - var rightP0 = entries[splitPoint].PageId; + // The entry at the split point moves up: its key becomes the separator in the + // grandparent and its child becomes P0 of the new right node. + int splitPoint = ChooseSplitPoint(offsets, InternalEntrySpace, promotesMiddle: true); + if (splitPoint < 0) + throw new InvalidOperationException( + $"B-Tree node {parentPageId} cannot be split so that both halves fit a {_storage.PageSize}-byte page; " + + $"it holds keys larger than the {MaxKeySize}-byte limit."); - var leftEntries = entries.Take(splitPoint).ToList(); - var rightEntries = entries.Skip(splitPoint + 1).ToList(); + var promoteKey = entries[splitPoint].Key; + var rightP0 = entries[splitPoint].PageId; + var leftEntries = entries.GetRange(0, splitPoint); + var rightEntries = entries.GetRange(splitPoint + 1, entries.Count - splitPoint - 1); - var newParentId = CreateNode(isLeaf: false, transactionId); + var newParentId = CreateNode(isLeaf: false, transactionId); - WriteInternalNode(parentPageId, p0, leftEntries, transactionId); - WriteInternalNode(newParentId, rightP0, rightEntries, transactionId); + WriteInternalNode(parentPageId, p0, leftEntries, transactionId); + WriteInternalNode(newParentId, rightP0, rightEntries, transactionId); - InsertIntoParent(parentPageId, promoteKey, newParentId, path, transactionId); - } - else - { - // Insert directly - InsertIntoInternal(parentPageId, header, pageBuffer.AsSpan(0, _storage.PageSize), key, rightChildPageId, transactionId); - } + InsertIntoParent(parentPageId, promoteKey, newParentId, path, transactionId); } finally { @@ -1204,17 +1285,116 @@ private void WriteInternalNode(uint pageId, uint p0, List entries } } - private void InsertIntoInternal(uint pageId, BTreeNodeHeader header, Span pageBuffer, IndexKey key, uint rightChildId, ulong transactionId) + /// Fills with the offset of each leaf entry, then the end of the data. + private static void BuildLeafOffsets(ReadOnlySpan pageBuffer, Span offsets) + { + int off = NodeDataStart; + for (int i = 0; i < offsets.Length - 1; i++) + { + offsets[i] = off; + off += LeafEntrySize(BitConverter.ToInt32(pageBuffer.Slice(off, 4))); + } + offsets[offsets.Length - 1] = off; + } + + /// Index of the first leaf entry whose key is not below (the entry count if none). + private static int LeafLowerBound(ReadOnlySpan pageBuffer, ReadOnlySpan offsets, ReadOnlySpan keyData) { - // Read, insert, write back. In production do in-place shift. - var (p0, entries) = ReadInternalEntries(pageBuffer, header.EntryCount); + int lo = 0, hi = offsets.Length - 2; + int result = offsets.Length - 1; + while (lo <= hi) + { + int mid = (lo + hi) >> 1; + int midOff = offsets[mid]; + int midKeyLen = BitConverter.ToInt32(pageBuffer.Slice(midOff, 4)); + if (IndexKey.CompareRaw(keyData, pageBuffer.Slice(midOff + 4, midKeyLen)) <= 0) + { result = mid; hi = mid - 1; } + else { lo = mid + 1; } + } + return result; + } - var newEntry = new InternalEntry(key, rightChildId); - int insertIndex = entries.FindIndex(e => e.Key > key); - if (insertIndex == -1) entries.Add(newEntry); - else entries.Insert(insertIndex, newEntry); + /// + /// Looks up in the leaf in , returning + /// false when every key in the leaf is smaller and the lookup must go on in the next + /// leaf; otherwise says whether the first key not below it matches. + /// + private static bool SearchLeafLowerBound(ReadOnlySpan pageBuffer, int entryCount, IndexKey key, + out bool found, out DocumentLocation location) + { + found = false; + location = default; + Span offsets = stackalloc int[entryCount + 1]; + BuildLeafOffsets(pageBuffer, offsets); + int idx = LeafLowerBound(pageBuffer, offsets, key.Data); + if (idx == entryCount) return false; - WriteInternalNode(pageId, p0, entries, transactionId); + int off = offsets[idx]; + int keyLen = BitConverter.ToInt32(pageBuffer.Slice(off, 4)); + if (IndexKey.CompareRaw(key.Data, pageBuffer.Slice(off + 4, keyLen)) == 0) + { + found = true; + location = DocumentLocation.ReadFrom(pageBuffer.Slice(off + 4 + keyLen, DocumentLocation.SerializedSize)); + } + return true; + } + + /// Index of the leaf entry (, ), or -1. + private static int FindEntryInLeaf(ReadOnlySpan pageBuffer, ReadOnlySpan offsets, IndexKey key, DocumentLocation location) + { + var keyData = key.Data; + for (int i = LeafLowerBound(pageBuffer, offsets, keyData); i < offsets.Length - 1; i++) + { + int off = offsets[i]; + int keyLen = BitConverter.ToInt32(pageBuffer.Slice(off, 4)); + if (IndexKey.CompareRaw(keyData, pageBuffer.Slice(off + 4, keyLen)) != 0) break; + var loc = DocumentLocation.ReadFrom(pageBuffer.Slice(off + 4 + keyLen, DocumentLocation.SerializedSize)); + if (loc.PageId == location.PageId && loc.SlotIndex == location.SlotIndex) return i; + } + return -1; + } + + /// + /// Finds the leaf that holds the entry (, ) + /// in the subtree at , adding its internal ancestors to + /// and leaving the leaf page in . + /// In a non-unique index the copies of a key can span several children of a node, from the + /// leftmost child that can hold the key to the rightmost one, so those subtrees are searched + /// in order (#213); usually that is a single child. Returns 0, with + /// unchanged, when the entry is not in the subtree. + /// + private uint FindLeafHoldingEntry(uint pageId, IndexKey key, DocumentLocation location, List path, + ulong transactionId, byte[] pageBuffer) + { + ReadPage(pageId, transactionId, pageBuffer); + var header = BTreeNodeHeader.ReadFrom(pageBuffer.AsSpan(32)); + int count = header.EntryCount; + + if (header.IsLeaf) + { + Span leafOffsets = stackalloc int[count + 1]; + BuildLeafOffsets(pageBuffer, leafOffsets); + return FindEntryInLeaf(pageBuffer, leafOffsets, key, location) >= 0 ? pageId : 0; + } + + Span offsets = stackalloc int[count]; + BuildInternalOffsets(pageBuffer, offsets); + int first = FindChildIndex(pageBuffer, offsets, key, leftmost: true); + int last = FindChildIndex(pageBuffer, offsets, key, leftmost: false); + + // The buffer is reused by the recursive calls: take the child pointers out first. + Span children = stackalloc uint[last - first + 1]; + for (int i = 0; i < children.Length; i++) + children[i] = ChildPointer(pageBuffer, offsets, first + i); + + path.Add(pageId); + foreach (var child in children) + { + var leafPageId = FindLeafHoldingEntry(child, key, location, path, transactionId, pageBuffer); + if (leafPageId != 0) return leafPageId; + } + path.RemoveAt(path.Count - 1); + return 0; } @@ -1258,9 +1438,9 @@ private static (bool Found, DocumentLocation Location) BinarySearchLeaf( /// /// Array overload — callable from async methods without exposing Span locals. /// - private static (bool Found, DocumentLocation Location) BinarySearchLeafArray( - byte[] pageBuffer, int entryCount, IndexKey key) - => BinarySearchLeaf(pageBuffer, entryCount, key); + private static bool SearchLeafLowerBoundArray( + byte[] pageBuffer, int entryCount, IndexKey key, out bool found, out DocumentLocation location) + => SearchLeafLowerBound(pageBuffer, entryCount, key, out found, out location); /// /// Returns the byte offset just past the last leaf entry. @@ -1290,6 +1470,42 @@ private IndexKey ReadIndexKey(byte[] buffer, int offset) return IndexKey.FromOwnedArray(keyData.ToArray()); } + /// + /// Re-points the entry (, ) at + /// , for a document that moved while its key stayed the same. + /// The location is rewritten in place: the leaf keeps its size and the tree its shape, so + /// nothing splits or merges, and a key stored before was lowered + /// (#217) can still follow its document. Returns false, writing nothing, when the + /// entry is not in the index. + /// + internal bool Relocate(IndexKey key, DocumentLocation oldLocation, DocumentLocation newLocation, ulong? transactionId = null) + { + var txnId = transactionId ?? 0; + var pageBuffer = System.Buffers.ArrayPool.Shared.Rent(_storage.PageSize); + try + { + var leafPageId = FindLeafHoldingEntry(_rootPageId, key, oldLocation, new List(), txnId, pageBuffer); + if (leafPageId == 0) + return false; + + var header = BTreeNodeHeader.ReadFrom(pageBuffer.AsSpan(32)); + Span offsets = stackalloc int[header.EntryCount + 1]; + BuildLeafOffsets(pageBuffer, offsets); + int idx = FindEntryInLeaf(pageBuffer, offsets, key, oldLocation); + if (idx == -1) + return false; + + int keyLen = BitConverter.ToInt32(pageBuffer.AsSpan(offsets[idx], 4)); + newLocation.WriteTo(pageBuffer.AsSpan(offsets[idx] + 4 + keyLen, DocumentLocation.SerializedSize)); + WritePage(leafPageId, txnId, pageBuffer.AsSpan(0, _storage.PageSize)); + return true; + } + finally + { + System.Buffers.ArrayPool.Shared.Return(pageBuffer); + } + } + /// /// Deletes a key-location pair from the index /// @@ -1297,68 +1513,24 @@ public bool Delete(IndexKey key, DocumentLocation location, ulong? transactionId { var txnId = transactionId ?? 0; var path = new List(); - var leafPageId = FindLeafNodeWithPath(key, path, txnId); var pageBuffer = System.Buffers.ArrayPool.Shared.Rent(_storage.PageSize); try { - ReadPage(leafPageId, txnId, pageBuffer); + // Copies of the key may sit on either side of an equal separator (#213): find the + // leaf that holds this exact entry, which leaves it in pageBuffer. + var leafPageId = FindLeafHoldingEntry(_rootPageId, key, location, path, txnId, pageBuffer); + if (leafPageId == 0) + return false; + var header = BTreeNodeHeader.ReadFrom(pageBuffer.AsSpan(32)); int count = header.EntryCount; // Build offset table Span offsets = stackalloc int[count + 1]; - int off = 32 + 20; - for (int i = 0; i < count; i++) - { - offsets[i] = off; - int kl = BitConverter.ToInt32(pageBuffer.AsSpan(off, 4)); - off += 4 + kl + DocumentLocation.SerializedSize; - } - offsets[count] = off; - - // Binary search for matching key, then linear scan for exact location match - var keyData = key.Data; - int lo = 0, hi = count - 1; - int foundIdx = -1; - while (lo <= hi) - { - int mid = (lo + hi) >> 1; - int midOff = offsets[mid]; - int midKeyLen = BitConverter.ToInt32(pageBuffer.AsSpan(midOff, 4)); - int cmp = IndexKey.CompareRaw(keyData, pageBuffer.AsSpan(midOff + 4, midKeyLen)); - if (cmp == 0) { foundIdx = mid; break; } - if (cmp < 0) hi = mid - 1; - else lo = mid + 1; - } - - if (foundIdx == -1) - return false; + BuildLeafOffsets(pageBuffer, offsets); - // Scan for exact (key + location) match among duplicates - int deleteIdx = -1; - // Check foundIdx and neighbors with same key - for (int i = foundIdx; i >= 0; i--) - { - int iOff = offsets[i]; - int iKeyLen = BitConverter.ToInt32(pageBuffer.AsSpan(iOff, 4)); - if (IndexKey.CompareRaw(keyData, pageBuffer.AsSpan(iOff + 4, iKeyLen)) != 0) break; - var loc = DocumentLocation.ReadFrom(pageBuffer.AsSpan(iOff + 4 + iKeyLen, DocumentLocation.SerializedSize)); - if (loc.PageId == location.PageId && loc.SlotIndex == location.SlotIndex) - { deleteIdx = i; break; } - } - if (deleteIdx == -1) - { - for (int i = foundIdx + 1; i < count; i++) - { - int iOff = offsets[i]; - int iKeyLen = BitConverter.ToInt32(pageBuffer.AsSpan(iOff, 4)); - if (IndexKey.CompareRaw(keyData, pageBuffer.AsSpan(iOff + 4, iKeyLen)) != 0) break; - var loc = DocumentLocation.ReadFrom(pageBuffer.AsSpan(iOff + 4 + iKeyLen, DocumentLocation.SerializedSize)); - if (loc.PageId == location.PageId && loc.SlotIndex == location.SlotIndex) - { deleteIdx = i; break; } - } - } + int deleteIdx = FindEntryInLeaf(pageBuffer, offsets, key, location); if (deleteIdx == -1) return false; @@ -1448,9 +1620,16 @@ private void HandleUnderflow(uint nodeId, List path, ulong transactionId) } } + /// + /// Moves one entry from a sibling with more than the minimum into the underfull node. Besides + /// the entry count, the moved entry must fit in the node and the new separator in the parent: + /// with variable-length keys either can be larger than what it replaces (#217). A borrow that + /// would not fit is skipped. + /// private bool BorrowFromSibling(uint nodeId, uint parentId, int childIndex, List parentEntries, uint p0, ulong transactionId) { int minEntries = MaxEntriesPerNode / 2; + int parentBytes = InternalEntriesSize(parentEntries); var nodeBuffer = System.Buffers.ArrayPool.Shared.Rent(_storage.PageSize); var siblingBuffer = System.Buffers.ArrayPool.Shared.Rent(_storage.PageSize); try @@ -1479,6 +1658,7 @@ private bool BorrowFromSibling(uint nodeId, uint parentId, int childIndex, List< { ReadPage(rightSiblingId, transactionId, siblingBuffer); var rightHeader = BTreeNodeHeader.ReadFrom(siblingBuffer.AsSpan(32)); + int oldSepLen = parentEntries[rightSepIdx].Key.Data.Length; if (rightHeader.EntryCount > minEntries) { @@ -1493,33 +1673,41 @@ private bool BorrowFromSibling(uint nodeId, uint parentId, int childIndex, List< int rightFirstKeyLen = BitConverter.ToInt32(siblingBuffer.AsSpan(rightFirstOff, 4)); int rightFirstSize = 4 + rightFirstKeyLen + DocumentLocation.SerializedSize; - // 3. Copy that entry to end of current node - new Span(siblingBuffer, rightFirstOff, rightFirstSize) - .CopyTo(new Span(nodeBuffer, nodeDataEnd, rightFirstSize)); - - // 4. Shift right sibling data left to close gap - int rightDataEnd = LeafDataEnd(siblingBuffer, rightHeader.EntryCount); - int rightTailBytes = rightDataEnd - (rightFirstOff + rightFirstSize); - if (rightTailBytes > 0) + // The second entry of the right sibling becomes the separator. + int newSepLen = BitConverter.ToInt32(siblingBuffer.AsSpan(rightFirstOff + rightFirstSize, 4)); + if (nodeDataEnd + rightFirstSize <= _storage.PageSize + && parentBytes - oldSepLen + newSepLen <= InternalEntrySpace) { - siblingBuffer.AsSpan(rightFirstOff + rightFirstSize, rightTailBytes) - .CopyTo(siblingBuffer.AsSpan(rightFirstOff, rightTailBytes)); + // 3. Copy that entry to end of current node + new Span(siblingBuffer, rightFirstOff, rightFirstSize) + .CopyTo(new Span(nodeBuffer, nodeDataEnd, rightFirstSize)); + + // 4. Shift right sibling data left to close gap + int rightDataEnd = LeafDataEnd(siblingBuffer, rightHeader.EntryCount); + int rightTailBytes = rightDataEnd - (rightFirstOff + rightFirstSize); + if (rightTailBytes > 0) + { + siblingBuffer.AsSpan(rightFirstOff + rightFirstSize, rightTailBytes) + .CopyTo(siblingBuffer.AsSpan(rightFirstOff, rightTailBytes)); + } + siblingBuffer.AsSpan(rightDataEnd - rightFirstSize, rightFirstSize).Clear(); + + // 5. Update headers + nodeHeader.EntryCount++; + nodeHeader.WriteTo(nodeBuffer.AsSpan(32, 20)); + rightHeader.EntryCount--; + rightHeader.WriteTo(siblingBuffer.AsSpan(32, 20)); + + // 6. Parent separator = new first key of right sibling + int newRightFirstKeyLen = BitConverter.ToInt32(siblingBuffer.AsSpan(rightFirstOff, 4)); + var newSepKey = IndexKey.FromOwnedArray(siblingBuffer.AsSpan(rightFirstOff + 4, newRightFirstKeyLen).ToArray()); + parentEntries[rightSepIdx] = new InternalEntry(newSepKey, rightSiblingId); + + WritePage(nodeId, transactionId, new ReadOnlySpan(nodeBuffer, 0, _storage.PageSize)); + WritePage(rightSiblingId, transactionId, new ReadOnlySpan(siblingBuffer, 0, _storage.PageSize)); + WriteInternalNode(parentId, p0, parentEntries, transactionId); + return true; } - siblingBuffer.AsSpan(rightDataEnd - rightFirstSize, rightFirstSize).Clear(); - - // 5. Update headers - nodeHeader.EntryCount++; - nodeHeader.WriteTo(nodeBuffer.AsSpan(32, 20)); - rightHeader.EntryCount--; - rightHeader.WriteTo(siblingBuffer.AsSpan(32, 20)); - - // 6. Parent separator = new first key of right sibling - int newRightFirstKeyLen = BitConverter.ToInt32(siblingBuffer.AsSpan(rightFirstOff, 4)); - var newSepKey = IndexKey.FromOwnedArray(siblingBuffer.AsSpan(rightFirstOff + 4, newRightFirstKeyLen).ToArray()); - parentEntries[rightSepIdx] = new InternalEntry(newSepKey, rightSiblingId); - - WritePage(nodeId, transactionId, new ReadOnlySpan(nodeBuffer, 0, _storage.PageSize)); - WritePage(rightSiblingId, transactionId, new ReadOnlySpan(siblingBuffer, 0, _storage.PageSize)); } else { @@ -1529,19 +1717,22 @@ private bool BorrowFromSibling(uint nodeId, uint parentId, int childIndex, List< // Rotate left for internal node: // Pull separator key down from parent; right's P0 becomes new last child of current. var sep = parentEntries[rightSepIdx].Key; - nodeEntries.Add(new InternalEntry(sep, rightP0)); + if (InternalEntriesSize(nodeEntries) + InternalEntrySize(sep.Data.Length) <= InternalEntrySpace + && parentBytes - oldSepLen + rightEntries[0].Key.Data.Length <= InternalEntrySpace) + { + nodeEntries.Add(new InternalEntry(sep, rightP0)); - // Right sibling's new P0 = its former first entry's PageId - var newRightP0 = rightEntries[0].PageId; - parentEntries[rightSepIdx] = new InternalEntry(rightEntries[0].Key, rightSiblingId); - rightEntries.RemoveAt(0); + // Right sibling's new P0 = its former first entry's PageId + var newRightP0 = rightEntries[0].PageId; + parentEntries[rightSepIdx] = new InternalEntry(rightEntries[0].Key, rightSiblingId); + rightEntries.RemoveAt(0); - WriteInternalNode(nodeId, nodeP0, nodeEntries, transactionId); - WriteInternalNode(rightSiblingId, newRightP0, rightEntries, transactionId); + WriteInternalNode(nodeId, nodeP0, nodeEntries, transactionId); + WriteInternalNode(rightSiblingId, newRightP0, rightEntries, transactionId); + WriteInternalNode(parentId, p0, parentEntries, transactionId); + return true; + } } - - WriteInternalNode(parentId, p0, parentEntries, transactionId); - return true; } } @@ -1567,6 +1758,7 @@ private bool BorrowFromSibling(uint nodeId, uint parentId, int childIndex, List< { ReadPage(leftSiblingId, transactionId, siblingBuffer); var leftHeader = BTreeNodeHeader.ReadFrom(siblingBuffer.AsSpan(32)); + int oldSepLen = parentEntries[leftSepIdx].Key.Data.Length; if (leftHeader.EntryCount > minEntries) { @@ -1589,36 +1781,42 @@ private bool BorrowFromSibling(uint nodeId, uint parentId, int childIndex, List< int lastEntryKeyLen = BitConverter.ToInt32(siblingBuffer.AsSpan(leftLastOff, 4)); int lastEntrySize = 4 + lastEntryKeyLen + DocumentLocation.SerializedSize; - // 2. Shift current node data right by lastEntrySize int nodeDataEnd = LeafDataEnd(nodeBuffer, nodeHeader.EntryCount); - int nodeDataStart = 32 + 20; - int nodeDataLen = nodeDataEnd - nodeDataStart; - if (nodeDataLen > 0) + if (nodeDataEnd + lastEntrySize <= _storage.PageSize + && parentBytes - oldSepLen + lastEntryKeyLen <= InternalEntrySpace) { - // Reverse copy for overlapping shift-right - for (int b = nodeDataLen - 1; b >= 0; b--) - nodeBuffer[nodeDataStart + lastEntrySize + b] = nodeBuffer[nodeDataStart + b]; - } + // 2. Shift current node data right by lastEntrySize + int nodeDataStart = 32 + 20; + int nodeDataLen = nodeDataEnd - nodeDataStart; + if (nodeDataLen > 0) + { + // Reverse copy for overlapping shift-right + for (int b = nodeDataLen - 1; b >= 0; b--) + nodeBuffer[nodeDataStart + lastEntrySize + b] = nodeBuffer[nodeDataStart + b]; + } - // 3. Copy left's last entry to start of current node - new Span(siblingBuffer, leftLastOff, lastEntrySize) - .CopyTo(new Span(nodeBuffer, nodeDataStart, lastEntrySize)); + // 3. Copy left's last entry to start of current node + new Span(siblingBuffer, leftLastOff, lastEntrySize) + .CopyTo(new Span(nodeBuffer, nodeDataStart, lastEntrySize)); - // 4. Truncate left sibling (clear removed entry) - siblingBuffer.AsSpan(leftLastOff, lastEntrySize).Clear(); + // 4. Truncate left sibling (clear removed entry) + siblingBuffer.AsSpan(leftLastOff, lastEntrySize).Clear(); - // 5. Update headers - leftHeader.EntryCount--; - leftHeader.WriteTo(siblingBuffer.AsSpan(32, 20)); - nodeHeader.EntryCount++; - nodeHeader.WriteTo(nodeBuffer.AsSpan(32, 20)); + // 5. Update headers + leftHeader.EntryCount--; + leftHeader.WriteTo(siblingBuffer.AsSpan(32, 20)); + nodeHeader.EntryCount++; + nodeHeader.WriteTo(nodeBuffer.AsSpan(32, 20)); - // 6. Parent separator = the borrowed key (now first key of current) - var borrowedKey = IndexKey.FromOwnedArray(nodeBuffer.AsSpan(nodeDataStart + 4, lastEntryKeyLen).ToArray()); - parentEntries[leftSepIdx] = new InternalEntry(borrowedKey, nodeId); + // 6. Parent separator = the borrowed key (now first key of current) + var borrowedKey = IndexKey.FromOwnedArray(nodeBuffer.AsSpan(nodeDataStart + 4, lastEntryKeyLen).ToArray()); + parentEntries[leftSepIdx] = new InternalEntry(borrowedKey, nodeId); - WritePage(leftSiblingId, transactionId, new ReadOnlySpan(siblingBuffer, 0, _storage.PageSize)); - WritePage(nodeId, transactionId, new ReadOnlySpan(nodeBuffer, 0, _storage.PageSize)); + WritePage(leftSiblingId, transactionId, new ReadOnlySpan(siblingBuffer, 0, _storage.PageSize)); + WritePage(nodeId, transactionId, new ReadOnlySpan(nodeBuffer, 0, _storage.PageSize)); + WriteInternalNode(parentId, p0, parentEntries, transactionId); + return true; + } } else { @@ -1628,20 +1826,23 @@ private bool BorrowFromSibling(uint nodeId, uint parentId, int childIndex, List< // Rotate right for internal node: // Pull separator key down from parent; old nodeP0 becomes regular first entry of current. var sep = parentEntries[leftSepIdx].Key; - nodeEntries.Insert(0, new InternalEntry(sep, nodeP0)); - - // Left's last entry key becomes new parent separator; its PageId is new nodeP0 var lastLeft = leftEntries[leftEntries.Count - 1]; - parentEntries[leftSepIdx] = new InternalEntry(lastLeft.Key, nodeId); - var newNodeP0 = lastLeft.PageId; - leftEntries.RemoveAt(leftEntries.Count - 1); + if (InternalEntriesSize(nodeEntries) + InternalEntrySize(sep.Data.Length) <= InternalEntrySpace + && parentBytes - oldSepLen + lastLeft.Key.Data.Length <= InternalEntrySpace) + { + nodeEntries.Insert(0, new InternalEntry(sep, nodeP0)); - WriteInternalNode(leftSiblingId, leftP0, leftEntries, transactionId); - WriteInternalNode(nodeId, newNodeP0, nodeEntries, transactionId); - } + // Left's last entry key becomes new parent separator; its PageId is new nodeP0 + parentEntries[leftSepIdx] = new InternalEntry(lastLeft.Key, nodeId); + var newNodeP0 = lastLeft.PageId; + leftEntries.RemoveAt(leftEntries.Count - 1); - WriteInternalNode(parentId, p0, parentEntries, transactionId); - return true; + WriteInternalNode(leftSiblingId, leftP0, leftEntries, transactionId); + WriteInternalNode(nodeId, newNodeP0, nodeEntries, transactionId); + WriteInternalNode(parentId, p0, parentEntries, transactionId); + return true; + } + } } } @@ -1654,6 +1855,15 @@ private bool BorrowFromSibling(uint nodeId, uint parentId, int childIndex, List< } } + /// Bytes taken by the entries of an internal node, P0 excluded. + private static int InternalEntriesSize(List entries) + { + int size = 0; + foreach (var entry in entries) + size += InternalEntrySize(entry.Key.Data.Length); + return size; + } + private void MergeWithSibling(uint nodeId, uint parentId, int childIndex, List parentEntries, uint p0, List path, ulong transactionId) { // Identify sibling to merge with. @@ -1661,6 +1871,11 @@ private void MergeWithSibling(uint nodeId, uint parentId, int childIndex, List. /// Each level performs a true async page read via . /// Comparisons and pointer lookups are done synchronously on the in-memory buffer. + /// See for . /// private async ValueTask FindLeafNodeWithPathAsync( - IndexKey key, List? path, ulong transactionId, CancellationToken ct) + IndexKey key, List? path, ulong transactionId, bool leftmost, CancellationToken ct) { var currentPageId = _rootPageId; var pageBuffer = System.Buffers.ArrayPool.Shared.Rent(_storage.PageSize); @@ -1749,7 +1968,7 @@ private async ValueTask FindLeafNodeWithPathAsync( return currentPageId; path?.Add(currentPageId); - currentPageId = FindChildNode(pageBuffer.AsSpan(0, _storage.PageSize), header, key); + currentPageId = FindChildNode(pageBuffer.AsSpan(0, _storage.PageSize), header, key, leftmost); } } finally @@ -1770,14 +1989,21 @@ private async ValueTask FindLeafNodeWithPathAsync( IndexKey key, ulong? transactionId = null, CancellationToken ct = default) { var txnId = transactionId ?? 0; - var leafPageId = await FindLeafNodeWithPathAsync(key, null, txnId, ct).ConfigureAwait(false); + // Leftmost leaf that can hold the key, then on along the chain (see TryFind, #213). + var leafPageId = await FindLeafNodeWithPathAsync(key, null, txnId, leftmost: true, ct).ConfigureAwait(false); var pageBuffer = System.Buffers.ArrayPool.Shared.Rent(_storage.PageSize); try { - await _storage.ReadPageAsync(leafPageId, txnId, pageBuffer.AsMemory(0, _storage.PageSize), ct).ConfigureAwait(false); - var header = BTreeNodeHeader.ReadFrom(pageBuffer.AsSpan(32)); - return BinarySearchLeafArray(pageBuffer, header.EntryCount, key); + while (leafPageId != 0) + { + await _storage.ReadPageAsync(leafPageId, txnId, pageBuffer.AsMemory(0, _storage.PageSize), ct).ConfigureAwait(false); + var header = BTreeNodeHeader.ReadFrom(pageBuffer.AsSpan(32)); + if (SearchLeafLowerBoundArray(pageBuffer, header.EntryCount, key, out var found, out var location)) + return (found, location); + leafPageId = header.NextLeafPageId; + } + return (false, default); } finally { @@ -1806,7 +2032,7 @@ public async IAsyncEnumerable RangeAsync( if (direction == IndexDirection.Forward) { - var leafPageId = await FindLeafNodeWithPathAsync(minKey, null, txnId, ct).ConfigureAwait(false); + var leafPageId = await FindLeafNodeWithPathAsync(minKey, null, txnId, leftmost: true, ct).ConfigureAwait(false); while (leafPageId != 0) { @@ -1859,7 +2085,7 @@ public async IAsyncEnumerable RangeAsync( } else // Backward { - var leafPageId = await FindLeafNodeWithPathAsync(maxKey, null, txnId, ct).ConfigureAwait(false); + var leafPageId = await FindLeafNodeWithPathAsync(maxKey, null, txnId, leftmost: false, ct).ConfigureAwait(false); while (leafPageId != 0) { @@ -2051,7 +2277,11 @@ private void CollectPagesRecursive(uint pageId, List result, ulong transac } } - private void MergeNodes(uint leftNodeId, uint rightNodeId, IndexKey separatorKey, ulong transactionId) + /// + /// Moves every entry of the right node into the left one. Returns false, writing + /// nothing, when the result would not fit in one node by count or by bytes (#217). + /// + private bool MergeNodes(uint leftNodeId, uint rightNodeId, IndexKey separatorKey, ulong transactionId) { var buffer = System.Buffers.ArrayPool.Shared.Rent(_storage.PageSize); try @@ -2079,6 +2309,10 @@ private void MergeNodes(uint leftNodeId, uint rightNodeId, IndexKey separatorKey int rightDataEnd = LeafDataEnd(rightBuffer, rightHeader.EntryCount); int rightDataLen = rightDataEnd - rightDataStart; + if (leftHeader.EntryCount + rightHeader.EntryCount > MaxEntriesPerNode + || leftDataEnd + rightDataLen > _storage.PageSize) + return false; + // Bulk copy right's entries to end of left if (rightDataLen > 0) { @@ -2115,6 +2349,11 @@ private void MergeNodes(uint leftNodeId, uint rightNodeId, IndexKey separatorKey var rightHeader = BTreeNodeHeader.ReadFrom(buffer.AsSpan(32)); var (rightP0, rightEntries) = ReadInternalEntries(buffer, rightHeader.EntryCount); + if (leftEntries.Count + 1 + rightEntries.Count > MaxEntriesPerNode + || InternalEntriesSize(leftEntries) + InternalEntrySize(separatorKey.Data.Length) + + InternalEntriesSize(rightEntries) > InternalEntrySpace) + return false; + // Add Separator Key (from parent) pointing to Right's P0 leftEntries.Add(new InternalEntry(separatorKey, rightP0)); @@ -2124,6 +2363,7 @@ private void MergeNodes(uint leftNodeId, uint rightNodeId, IndexKey separatorKey // UpdateAsync Left Node WriteInternalNode(leftNodeId, leftP0, leftEntries, transactionId); } + return true; } finally { diff --git a/src/BLite.Core/Indexing/CollectionIndexManager.cs b/src/BLite.Core/Indexing/CollectionIndexManager.cs index ccb9cbe..f9f326b 100644 --- a/src/BLite.Core/Indexing/CollectionIndexManager.cs +++ b/src/BLite.Core/Indexing/CollectionIndexManager.cs @@ -581,7 +581,8 @@ public void EnsureUniqueInAll(T document, T? oldDocument, ITransaction transacti /// be written to one of the indexes (vector dimension mismatch, key too large). /// Call before any page is written, together with . /// - public void ValidateKeysInAll(T document) + /// The stored version on update: keys equal to its keys are not checked again. + public void ValidateKeysInAll(T document, T? oldDocument = default) { if (document == null) throw new ArgumentNullException(nameof(document)); @@ -590,7 +591,7 @@ public void ValidateKeysInAll(T document) { foreach (var index in _indexes.Values) { - index.ValidateKey(document); + index.ValidateKey(document, oldDocument); } } } diff --git a/src/BLite.Core/Indexing/CollectionSecondaryIndex.cs b/src/BLite.Core/Indexing/CollectionSecondaryIndex.cs index 2e993b2..887135d 100644 --- a/src/BLite.Core/Indexing/CollectionSecondaryIndex.cs +++ b/src/BLite.Core/Indexing/CollectionSecondaryIndex.cs @@ -154,7 +154,13 @@ public void Update(T oldDocument, T newDocument, DocumentLocation oldLocation, D return; // Same key, new location: re-point the entry. An unchanged vector keeps its HNSW - // node instead of leaving a deleted node behind. + // node instead of leaving a deleted node behind; an unchanged B-Tree key is rewritten + // in place, so a key stored before MaxKeySize was lowered (#217) is not inserted again. + if (_btreeIndex != null + && _btreeIndex.Relocate(CreateCompositeKey(ConvertToIndexKey(oldKey), _mapper.ToIndexKey(_mapper.GetId(oldDocument))), + oldLocation, newLocation, transaction?.TransactionId)) + return; + if (_vectorIndex == null || !TryRelocateVectors(oldKey, oldLocation, newLocation, transaction)) { DeleteKey(oldKey, oldDocument, oldLocation, transaction); @@ -642,11 +648,21 @@ internal void EnsureUnique(T document, T? oldDocument, ITransaction? transaction /// B-Tree key larger than a node page. Called before any page is written, so a rejected write /// leaves nothing behind instead of failing halfway through the indexes. /// - internal void ValidateKey(T document) + /// + /// The stored version on update. A key equal to the stored one keeps its entry (see + /// ), so it is not checked again: a key stored before + /// was lowered (#217) must not block every update. + /// + internal void ValidateKey(T document, T? oldDocument = default) { if (!_definition.TryGetKey(document, out var keyValue) || keyValue == null) return; // null keys are not indexed + if (oldDocument != null + && _definition.TryGetKey(oldDocument, out var oldKeyValue) + && KeysEqual(oldKeyValue, keyValue)) + return; + if (_vectorIndex != null) { if (keyValue is float[] singleVector) diff --git a/tests/BLite.Tests/BTreeSplitTests.cs b/tests/BLite.Tests/BTreeSplitTests.cs new file mode 100644 index 0000000..00994ea --- /dev/null +++ b/tests/BLite.Tests/BTreeSplitTests.cs @@ -0,0 +1,425 @@ +using BLite.Bson; +using BLite.Core; +using BLite.Core.Indexing; +using BLite.Core.Query; +using BLite.Core.Storage; +using BLite.Shared; + +namespace BLite.Tests; + +/// +/// Regression tests for B-Tree node splits and descent: +/// +/// #217: nodes split by entry count only, so variable-length keys filled a node page +/// before it reached entries and the next insert +/// overflowed the page. +/// #213: a leaf split promotes the first key of the right half, and descent went right +/// when the search key equalled that separator, so a forward range scan or a delete started +/// past duplicates of the key left behind in the left leaf. +/// +/// +public class BTreeSplitTests : IDisposable +{ + private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"btree_split_{Guid.NewGuid():N}.db"); + + public void Dispose() + { + foreach (var f in new[] { _dbPath, Path.ChangeExtension(_dbPath, ".wal") }) + if (File.Exists(f)) File.Delete(f); + } + + private static string LongName(int i, int padding) => $"{i:D5}{new string('x', padding)}"; + + // ── #217: split by size ───────────────────────────────────────────────── + + [Theory] + [InlineData(100)] + [InlineData(400)] + [InlineData(2000)] + public async Task Dynamic_LongStringKeys_AllInsertsSucceed_AndAreFound(int padding) + { + const int count = 200; + using var engine = new BLiteEngine(_dbPath); + var col = engine.GetOrCreateCollection("people"); + await col.CreateIndexAsync("name", "idx", unique: false); + + for (int i = 0; i < count; i++) + { + var name = LongName(i, padding); + await col.InsertAsync(col.CreateDocument(["name"], b => b.AddString("name", name))); + } + + var all = await col.QueryIndexAsync("idx", null, null).ToListAsync(); + Assert.Equal(count, all.Count); + for (int i = 0; i < count; i += 7) + { + var name = LongName(i, padding); + Assert.Single(await col.QueryIndexAsync("idx", name, name).ToListAsync()); + Assert.Single(await col.QueryIndexAsync("idx", name, name, false).ToListAsync()); + } + } + + [Theory] + [InlineData(400)] + [InlineData(2000)] + public async Task Typed_LongStringKeys_AllInsertsSucceed_AndAreFound(int padding) + { + const int count = 120; + using (var db = new TestDbContext(_dbPath)) + { + await db.PeopleV2.CreateIndexAsync(p => p.Name, "idx_name", unique: false); + for (int i = 0; i < count; i++) + await db.PeopleV2.InsertAsync(new PersonV2 { Name = LongName(i, padding), Age = i }); + + for (int i = 0; i < count; i += 11) + { + var name = LongName(i, padding); + var hits = await db.PeopleV2.AsQueryable().Where(p => p.Name == name).ToListAsync(); + Assert.Single(hits); + Assert.Equal(i, hits[0].Age); + } + } + + using var reopened = new TestDbContext(_dbPath); + Assert.Equal(count, await reopened.PeopleV2.CountAsync()); + var last = LongName(count - 1, padding); + Assert.Single(await reopened.PeopleV2.AsQueryable().Where(p => p.Name == last).ToListAsync()); + } + + [Fact] + public async Task Dynamic_KeyAboveMaxKeySize_IsRejectedUpFront_WithTheLimitInTheMessage() + { + using var engine = new BLiteEngine(_dbPath); + var col = engine.GetOrCreateCollection("people"); + await col.CreateIndexAsync("name", "idx", unique: false); + await col.InsertAsync(col.CreateDocument(["name"], b => b.AddString("name", "small"))); + + // A 9000-character key cannot share a 16 KB node with a sibling entry. + var ex = await Assert.ThrowsAsync(() => + col.InsertAsync(col.CreateDocument(["name"], b => b.AddString("name", new string('y', 9000)))).AsTask()); + Assert.Contains("too large for index 'idx'", ex.Message); + + Assert.Single(await col.QueryIndexAsync("idx", null, null).ToListAsync()); + } + + [Fact] + public void BTree_KeyAboveMaxKeySize_ThrowsArgumentException_BeforeWriting() + { + using var storage = new StorageEngine(_dbPath, PageFileConfig.Default); + var index = new BTreeIndex(storage, IndexOptions.CreateBTree("field")); + var txn = storage.BeginTransaction().TransactionId; + + var tooLong = IndexKey.Create(new string('z', index.MaxKeySize + 1)); + Assert.True(tooLong.Data.Length > index.MaxKeySize); + var ex = Assert.Throws(() => index.Insert(tooLong, new DocumentLocation(1, 0), txn)); + Assert.Contains(index.MaxKeySize.ToString(), ex.Message); + + Assert.Empty(index.Range(IndexKey.MinKey, IndexKey.MaxKey, IndexDirection.Forward, txn)); + } + + [Fact] + public void BTree_KeysAtMaxKeySize_MixedWithSmallKeys_SplitIntoNodesThatFit() + { + // Very uneven entry sizes: a split by entry count would put several maximum-size keys + // in one half; the split must be chosen by bytes. + using var storage = new StorageEngine(_dbPath, PageFileConfig.Small); + var index = new BTreeIndex(storage, IndexOptions.CreateBTree("field")); + var txn = storage.BeginTransaction().TransactionId; + + var expected = new List<(IndexKey Key, DocumentLocation Loc)>(); + ushort slot = 0; + for (int i = 0; i < 300; i++) + { + var key = i % 5 == 0 ? KeyOfSize(index.MaxKeySize, (char)('a' + i % 26), i) : IndexKey.Create($"{i:D4}"); + var loc = new DocumentLocation((uint)(1 + i / 100), slot++); + index.Insert(key, loc, txn); + expected.Add((key, loc)); + } + + var actual = index.Range(IndexKey.MinKey, IndexKey.MaxKey, IndexDirection.Forward, txn).ToList(); + Assert.Equal(expected.Count, actual.Count); + Assert.Equal(expected.Select(e => e.Key).OrderBy(k => k).ToList(), actual.Select(e => e.Key).ToList()); + } + + /// A string key whose encoded size is exactly bytes. + private static IndexKey KeyOfSize(int size, char fill, int discriminator) + { + var prefix = $"{fill}{discriminator:D5}"; + var probe = IndexKey.Create(prefix); + var key = IndexKey.Create(prefix + new string(fill, size - probe.Data.Length)); + Assert.Equal(size, key.Data.Length); + return key; + } + + // ── #213: duplicates across a leaf split ───────────────────────────────── + + /// "p0".."p1999" without p5, with a second document for every i % 7 == 0. + private static async Task>> InsertIssue213DataAsync(DynamicCollection col) + { + var ids = new Dictionary>(); + for (int i = 0; i < 2000; i++) + { + if (i == 5) continue; + var name = $"p{i}"; + var copies = i % 7 == 0 ? 2 : 1; + for (int c = 0; c < copies; c++) + { + var id = await col.InsertAsync(col.CreateDocument(["name"], b => b.AddString("name", name))); + if (!ids.TryGetValue(name, out var list)) ids[name] = list = new List(); + list.Add(id); + } + } + return ids; + } + + [Fact] + public async Task Dynamic_NonUniqueIndex_DuplicatesAcrossSplit_FoundForwardAndBackward() + { + using var engine = new BLiteEngine(_dbPath); + var col = engine.GetOrCreateCollection("people"); + await col.CreateIndexAsync("name", "idx", unique: false); + await InsertIssue213DataAsync(col); + + Assert.Equal(2, (await col.QueryIndexAsync("idx", "p70", "p70").ToListAsync()).Count); + Assert.Equal(2, (await col.QueryIndexAsync("idx", "p70", "p70", false).ToListAsync()).Count); + } + + [Fact] + public async Task Dynamic_NonUniqueIndex_EveryDuplicatedKey_FoundForwardAndBackward() + { + using var engine = new BLiteEngine(_dbPath); + var col = engine.GetOrCreateCollection("people"); + await col.CreateIndexAsync("name", "idx", unique: false); + await InsertIssue213DataAsync(col); + + var missing = new List(); + for (int i = 0; i < 2000; i += 7) + { + var name = $"p{i}"; + if ((await col.QueryIndexAsync("idx", name, name).ToListAsync()).Count != 2 + || (await col.QueryIndexAsync("idx", name, name, false).ToListAsync()).Count != 2) + missing.Add(name); + } + Assert.Empty(missing); + } + + [Fact] + public async Task Dynamic_NonUniqueIndex_DeleteDuplicatesAcrossSplit_RemovesEveryIndexEntry() + { + var straddling = new[] { "p70", "p364", "p490", "p567", "p1148", "p1764", "p1890" }; + using var engine = new BLiteEngine(_dbPath); + var col = engine.GetOrCreateCollection("people"); + await col.CreateIndexAsync("name", "idx", unique: false); + var ids = await InsertIssue213DataAsync(col); + + foreach (var name in straddling) + foreach (var id in ids[name]) + Assert.True(await col.DeleteAsync(id)); + + // A stale index entry would point at a slot the new documents may reuse. + foreach (var name in straddling) + await col.InsertAsync(col.CreateDocument(["name"], b => b.AddString("name", name))); + + foreach (var name in straddling) + { + Assert.Single(await col.QueryIndexAsync("idx", name, name).ToListAsync()); + Assert.Single(await col.QueryIndexAsync("idx", name, name, false).ToListAsync()); + } + } + + [Fact] + public void BTree_NonUnique_DeleteDuplicateLeftOfSeparator_ReturnsTrueAndRemovesIt() + { + using var storage = new StorageEngine(_dbPath, PageFileConfig.Default); + var index = new BTreeIndex(storage, IndexOptions.CreateBTree("field")); + var txn = storage.BeginTransaction().TransactionId; + + // Same layout as #213: duplicates of every 7th key, so some pairs straddle a leaf split. + var locations = new Dictionary>(); + ushort slot = 0; + uint page = 1; + for (int i = 0; i < 2000; i++) + { + if (i == 5) continue; + var name = $"p{i}"; + for (int c = 0; c < (i % 7 == 0 ? 2 : 1); c++) + { + if (slot == ushort.MaxValue) { slot = 0; page++; } + var loc = new DocumentLocation(page, slot++); + index.Insert(IndexKey.Create(name), loc, txn); + if (!locations.TryGetValue(name, out var list)) locations[name] = list = new(); + list.Add(loc); + } + } + + var failures = new List(); + for (int i = 0; i < 2000; i += 7) + { + var name = $"p{i}"; + var key = IndexKey.Create(name); + foreach (var loc in locations[name]) + if (!index.Delete(key, loc, txn)) failures.Add(name); + if (index.Range(key, key, IndexDirection.Forward, txn).Any()) failures.Add(name + " (left behind)"); + } + Assert.Empty(failures); + } + + [Fact] + public void BTree_NonUnique_KeyDuplicatedAcrossManyLeaves_EveryCopyFoundAndDeleted() + { + using var storage = new StorageEngine(_dbPath, PageFileConfig.Small); + var index = new BTreeIndex(storage, IndexOptions.CreateBTree("field")); + var txn = storage.BeginTransaction().TransactionId; + + // 400 copies of one 500-byte key between smaller and larger neighbours: the copies fill + // many leaves, and several internal separators are equal to the key. + var same = IndexKey.Create("m" + new string('k', 500)); + var copies = new List(); + for (int i = 0; i < 400; i++) + { + index.Insert(IndexKey.Create($"a{i:D4}"), new DocumentLocation(1, (ushort)i), txn); + var loc = new DocumentLocation(2, (ushort)i); + index.Insert(same, loc, txn); + copies.Add(loc); + index.Insert(IndexKey.Create($"z{i:D4}"), new DocumentLocation(3, (ushort)i), txn); + } + + Assert.Equal(400, index.Range(same, same, IndexDirection.Forward, txn).Count()); + Assert.Equal(400, index.Range(same, same, IndexDirection.Backward, txn).Count()); + Assert.Equal(400, index.CountRange(same, same, txn)); + + var rng = new Random(7); + int left = copies.Count; + foreach (var loc in copies.OrderBy(_ => rng.Next())) + { + Assert.True(index.Delete(same, loc, txn)); + left--; + if (left % 50 == 0) + Assert.Equal(left, index.Range(same, same, IndexDirection.Forward, txn).Count()); + } + Assert.False(index.TryFind(same, out _, txn)); + Assert.Equal(800, index.Range(IndexKey.MinKey, IndexKey.MaxKey, IndexDirection.Forward, txn).Count()); + } + + // ── Randomised: long keys, duplicates, deletes, reopen ─────────────────── + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(106)] // leaves an empty leaf that cannot be merged away; the cursor must skip it + [InlineData(107)] + public void BTree_RandomKeys_MatchSortedMultiset_BeforeAndAfterReopen(int seed) + { + var rng = new Random(seed); + var config = PageFileConfig.Small; // 8 KB pages: more splits for the same data + var model = new List<(string Key, DocumentLocation Loc)>(); + uint rootPageId; + int maxKeySize; + + using (var storage = new StorageEngine(_dbPath, config)) + { + var index = new BTreeIndex(storage, IndexOptions.CreateBTree("field")); + rootPageId = index.RootPageId; + maxKeySize = index.MaxKeySize; + var txn = storage.BeginTransaction().TransactionId; + + // A pool of keys so that some of them are inserted several times. + var pool = Enumerable.Range(0, 600).Select(_ => RandomKey(rng, maxKeySize)).ToList(); + ushort slot = 0; + uint page = 1; + for (int i = 0; i < 1500; i++) + { + var key = pool[rng.Next(pool.Count)]; + if (slot == ushort.MaxValue) { slot = 0; page++; } + var loc = new DocumentLocation(page, slot++); + index.Insert(IndexKey.Create(key), loc, txn); + model.Add((key, loc)); + } + + // Delete about half of the entries, in random order. + foreach (var victim in model.OrderBy(_ => rng.Next()).Take(model.Count / 2).ToList()) + { + Assert.True(index.Delete(IndexKey.Create(victim.Key), victim.Loc, txn), $"delete of '{Abbrev(victim.Key)}' failed"); + model.Remove(victim); + } + + // And insert some more on top of the shrunken tree. + for (int i = 0; i < 300; i++) + { + var key = rng.Next(3) == 0 ? RandomKey(rng, maxKeySize) : pool[rng.Next(pool.Count)]; + var loc = new DocumentLocation(page, slot++); + index.Insert(IndexKey.Create(key), loc, txn); + model.Add((key, loc)); + } + + VerifyAgainstModel(index, model, pool, txn); + storage.CommitTransactionAsync(txn).GetAwaiter().GetResult(); + } + + using (var storage = new StorageEngine(_dbPath, config)) + { + var index = new BTreeIndex(storage, IndexOptions.CreateBTree("field"), rootPageId); + VerifyAgainstModel(index, model, model.Select(m => m.Key).Distinct().ToList(), 0); + } + } + + private static string RandomKey(Random rng, int maxKeySize) + { + int roll = rng.Next(100); + int length = roll < 70 ? rng.Next(1, 40) + : roll < 92 ? rng.Next(100, 900) + : rng.Next(maxKeySize - 200, maxKeySize - 20); + var chars = new char[length]; + for (int i = 0; i < length; i++) chars[i] = (char)('a' + rng.Next(4)); + var s = new string(chars); + while (IndexKey.Create(s).Data.Length > maxKeySize) s = s[..^1]; + return s; + } + + private static string Abbrev(string key) => key.Length <= 12 ? key : $"{key[..12]}…({key.Length})"; + + private static void VerifyAgainstModel(BTreeIndex index, List<(string Key, DocumentLocation Loc)> model, List probes, ulong txn) + { + static string Loc(DocumentLocation l) => $"{l.PageId}:{l.SlotIndex}"; + + var expected = model + .Select(m => (Key: IndexKey.Create(m.Key), m.Loc)) + .OrderBy(m => m.Key) + .ToList(); + + var forward = index.Range(IndexKey.MinKey, IndexKey.MaxKey, IndexDirection.Forward, txn).ToList(); + Assert.Equal(expected.Select(e => e.Key).ToList(), forward.Select(e => e.Key).ToList()); + Assert.Equal(expected.Select(e => Loc(e.Loc)).OrderBy(s => s), forward.Select(e => Loc(e.Location)).OrderBy(s => s)); + + var backward = index.Range(IndexKey.MinKey, IndexKey.MaxKey, IndexDirection.Backward, txn).ToList(); + Assert.Equal(forward.Select(e => e.Key).Reverse().ToList(), backward.Select(e => e.Key).ToList()); + + // The cursor walks the same leaf chain, skipping leaves left empty by deletes. + int cursorForward = 0, cursorBackward = 0; + using (var cursor = index.CreateCursor(txn)) + { + if (cursor.MoveToFirst()) do cursorForward++; while (cursor.MoveNext()); + if (cursor.MoveToLast()) do cursorBackward++; while (cursor.MovePrev()); + } + Assert.Equal(expected.Count, cursorForward); + Assert.Equal(expected.Count, cursorBackward); + + var byKey = model.GroupBy(m => m.Key).ToDictionary(g => g.Key, g => g.Select(m => Loc(m.Loc)).OrderBy(s => s).ToList()); + var failures = new List(); + foreach (var probe in probes.Distinct()) + { + var key = IndexKey.Create(probe); + var want = byKey.TryGetValue(probe, out var w) ? w : new List(); + var fwd = index.Range(key, key, IndexDirection.Forward, txn).Select(e => Loc(e.Location)).OrderBy(s => s).ToList(); + var bwd = index.Range(key, key, IndexDirection.Backward, txn).Select(e => Loc(e.Location)).OrderBy(s => s).ToList(); + if (!want.SequenceEqual(fwd)) failures.Add($"forward '{Abbrev(probe)}': {fwd.Count} of {want.Count}"); + if (!want.SequenceEqual(bwd)) failures.Add($"backward '{Abbrev(probe)}': {bwd.Count} of {want.Count}"); + if (index.TryFind(key, out _, txn) != want.Count > 0) failures.Add($"TryFind '{Abbrev(probe)}'"); + if (index.CountRange(key, key, txn) != want.Count) failures.Add($"CountRange '{Abbrev(probe)}'"); + if (index.Equal(key, txn).Any() != want.Count > 0) failures.Add($"Equal '{Abbrev(probe)}'"); + } + Assert.Empty(failures); + } +} diff --git a/tests/BLite.Tests/ExplicitTransactionFailureTests.cs b/tests/BLite.Tests/ExplicitTransactionFailureTests.cs index 8899536..d065b6d 100644 --- a/tests/BLite.Tests/ExplicitTransactionFailureTests.cs +++ b/tests/BLite.Tests/ExplicitTransactionFailureTests.cs @@ -195,8 +195,10 @@ public async Task Failure_After_The_First_Page_Write_Rolls_Back_Callers_Transact await db.PeopleV2.InsertAsync(new PersonV2 { Name = "committed", Age = 1 }); using var tx = await db.BeginTransactionAsync(); - var ex = await InsertUntilBTreeNodeOverflows(i => - db.PeopleV2.InsertAsync(new PersonV2 { Name = LongKey(i), Age = i }, tx).AsTask()); + CorruptIndexRootInTransaction(db.Storage, db.PeopleV2.CollectionName, "idx_name", tx); + var ex = await Record.ExceptionAsync(() => + db.PeopleV2.InsertAsync(new PersonV2 { Name = "failing", Age = 2 }, tx).AsTask()); + Assert.NotNull(ex); // The failing insert had already written its data page and the primary index. Assert.IsNotType(ex); @@ -392,8 +394,9 @@ public async Task Dynamic_Failure_After_The_First_Page_Write_Rolls_Back_Callers_ await col.InsertAsync(Doc(col, 0, "committed")); var tx = await engine.BeginTransactionAsync(); - var ex = await InsertUntilBTreeNodeOverflows(i => - col.InsertAsync(Doc(col, i, LongKey(i)), tx).AsTask()); + CorruptIndexRootInTransaction(engine.Storage, col.Name, "idx_name", tx); + var ex = await Record.ExceptionAsync(() => col.InsertAsync(Doc(col, 1, "failing"), tx).AsTask()); + Assert.NotNull(ex); Assert.IsNotType(ex); Assert.Equal(TransactionState.Aborted, tx.State); @@ -409,22 +412,22 @@ await Assert.ThrowsAsync(async () => // ── Helpers ────────────────────────────────────────────────────────────── /// - /// B-Tree nodes split by entry count, not by size, so a run of long keys overflows a leaf - /// page while the key is being inserted, after the document's data page and primary index - /// entry were written: a real failure in the middle of a write. + /// Makes the next insert into the B-Tree index fail while the key + /// is being inserted, after the document's data page and primary index entry were written: + /// a real failure in the middle of a write. (These tests used to rely on long keys + /// overflowing a node page, which #217 fixed.) The index root, a leaf holding one key, is + /// rewritten inside with a key length that runs past the end of the + /// page, so the rollback discards the damage along with the failed write. /// - private static async Task InsertUntilBTreeNodeOverflows(Func insert) + private static void CorruptIndexRootInTransaction(StorageEngine storage, string collection, string indexName, ITransaction tx) { - for (int i = 1; i <= 200; i++) - { - try { await insert(i); } - catch (Exception ex) { return ex; } - } - throw new Xunit.Sdk.XunitException("Expected a B-Tree node to overflow."); + var rootPageId = storage.GetCollectionMetadata(collection)!.Indexes.Single(i => i.Name == indexName).RootPageId; + var page = new byte[storage.PageSize]; + storage.ReadPage(rootPageId, tx.TransactionId, page); + BitConverter.TryWriteBytes(page.AsSpan(32 + 20, 4), int.MaxValue / 2); // first key's length + storage.WritePage(rootPageId, tx.TransactionId, page); } - private static string LongKey(int i) => i.ToString("D5") + new string('k', 1000); - private static async Task AssertTypedVectorItemsConsistent(TestDbContext db, int expectedCount) { var docs = await db.VectorItems.FindAllAsync().ToListAsync(); diff --git a/tests/BLite.Tests/UnchangedIndexKeyUpdateTests.cs b/tests/BLite.Tests/UnchangedIndexKeyUpdateTests.cs new file mode 100644 index 0000000..9d31dd0 --- /dev/null +++ b/tests/BLite.Tests/UnchangedIndexKeyUpdateTests.cs @@ -0,0 +1,275 @@ +using System.Collections; +using System.Reflection; +using BLite.Bson; +using BLite.Core; +using BLite.Core.Indexing; +using BLite.Core.Query; +using BLite.Shared; + +namespace BLite.Tests; + +/// +/// Updates whose index keys do not change (follow-ups to #217/#213): +/// +/// An unchanged key keeps its index entry: left alone when the document stays in its +/// slot, re-pointed when it moves. Deleting and re-inserting it cost a search through every +/// copy of a duplicated key in a DynamicCollection index, whose keys carry no document id. +/// An unchanged key is not size-checked again, so a key stored before +/// was lowered (#217) does not make its document +/// impossible to update. A new or changed oversized key is still rejected up front. +/// +/// +public class UnchangedIndexKeyUpdateTests : IDisposable +{ + private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"unchanged_key_{Guid.NewGuid():N}.db"); + + public void Dispose() + { + foreach (var f in new[] { _dbPath, Path.ChangeExtension(_dbPath, ".wal") }) + if (File.Exists(f)) File.Delete(f); + } + + /// Longer than MaxKeySize on 16 KB pages (8156 bytes), within the old limit (16320). + private static readonly string LegacyLongValue = "legacy-" + new string('L', 8300); + + /// The limit of the versions before #217, for . + private static int LegacyMaxKeySize(BTreeIndex index) => index.MaxKeySize * 2 + 8; + + private static BTreeIndex DynamicBTree(DynamicCollection col, string indexName) + { + var indexes = (IDictionary)typeof(DynamicCollection) + .GetField("_secondaryIndexes", BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(col)!; + var entry = indexes[indexName]!; + return (BTreeIndex)entry.GetType().GetProperty("BTree")!.GetValue(entry)!; + } + + private static BTreeIndex TypedBTree(BLite.Core.Collections.DocumentCollection col, string indexName) where T : class + { + var manager = (CollectionIndexManager)typeof(BLite.Core.Collections.DocumentCollection) + .GetField("_indexManager", BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(col)!; + return manager.GetIndex(indexName)!.BTreeIndex!; + } + + /// The content of every page of , keyed by page id. + private static Dictionary Snapshot(BLiteEngine engine, BTreeIndex index) + { + var pages = new Dictionary(); + foreach (var pageId in index.CollectAllPages()) + { + var page = new byte[engine.Storage.PageSize]; + engine.Storage.ReadPage(pageId, null, page); + pages[pageId] = page; + } + return pages; + } + + private static BsonDocument Doc(DynamicCollection col, BsonId id, string name, string tag, int counter, string pad = "") + => col.CreateDocument(["_id", "name", "tag", "counter", "pad"], b => b + .AddId(id) + .AddString("name", name) + .AddString("tag", tag) + .AddInt32("counter", counter) + .AddString("pad", pad)); + + private static int Counter(BsonDocument doc) + { + Assert.True(doc.TryGetInt32("counter", out var value)); + return value; + } + + // ── Unchanged keys keep their entries ─────────────────────────────────── + + [Fact] + public async Task Dynamic_UpdateOfUnrelatedField_LeavesUniqueAndNonUniqueEntriesIntact() + { + using var engine = new BLiteEngine(_dbPath); + var col = engine.GetOrCreateCollection("docs", BsonIdType.Int32); + await col.CreateIndexAsync("name", "idx_name", unique: true); + await col.CreateIndexAsync("tag", "idx_tag", unique: false); + for (int i = 0; i < 300; i++) + await col.InsertAsync(Doc(col, i, $"n{i:D4}", $"t{i % 3}", 0)); + + for (int i = 0; i < 300; i++) + Assert.True(await col.UpdateAsync(i, Doc(col, i, $"n{i:D4}", $"t{i % 3}", i + 1))); + + Assert.Equal(300, (await col.QueryIndexAsync("idx_name", null, null).ToListAsync()).Count); + Assert.Equal(300, (await col.QueryIndexAsync("idx_tag", null, null).ToListAsync()).Count); + Assert.Equal(100, (await col.QueryIndexAsync("idx_tag", "t1", "t1").ToListAsync()).Count); + var hit = Assert.Single(await col.QueryIndexAsync("idx_name", "n0042", "n0042").ToListAsync()); + Assert.Equal(43, Counter(hit)); + } + + [Fact] + public async Task Dynamic_UpdateThatRelocatesTheDocument_IsStillFoundThroughEveryIndex() + { + using var engine = new BLiteEngine(_dbPath); + var col = engine.GetOrCreateCollection("docs", BsonIdType.Int32); + await col.CreateIndexAsync("name", "idx_name", unique: true); + await col.CreateIndexAsync("tag", "idx_tag", unique: false); + for (int i = 0; i < 40; i++) + await col.InsertAsync(Doc(col, i, $"n{i:D4}", "same", 0)); + + // Growing every document by 3 KB moves it to another page. + for (int i = 0; i < 40; i++) + Assert.True(await col.UpdateAsync(i, Doc(col, i, $"n{i:D4}", "same", 1, new string('p', 3000)))); + + Assert.Equal(40, (await col.QueryIndexAsync("idx_tag", "same", "same").ToListAsync()).Count); + Assert.Equal(40, (await col.QueryIndexAsync("idx_tag", "same", "same", false).ToListAsync()).Count); + for (int i = 0; i < 40; i++) + { + var byName = Assert.Single(await col.QueryIndexAsync("idx_name", $"n{i:D4}", $"n{i:D4}").ToListAsync()); + Assert.Equal(1, Counter(byName)); + Assert.NotNull(await col.FindByIdAsync(i)); + } + } + + [Fact] + public async Task Dynamic_UpdateOfUnrelatedField_DoesNotTouchAHeavilyDuplicatedIndex() + { + const int count = 5000; + using var engine = new BLiteEngine(_dbPath); + var col = engine.GetOrCreateCollection("docs", BsonIdType.Int32); + await col.CreateIndexAsync("tag", "idx_tag", unique: false); + for (int i = 0; i < count; i++) + await col.InsertAsync(Doc(col, i, $"n{i:D5}", "shared", 1000)); + + var tagIndex = DynamicBTree(col, "idx_tag"); + var before = Snapshot(engine, tagIndex); + + // Same-size documents stay in their slots: the index is not rewritten at all, instead of + // searching the leaves of 5000 copies of "shared" for every update. + for (int i = 0; i < count; i++) + Assert.True(await col.UpdateAsync(i, Doc(col, i, $"n{i:D5}", "shared", 2000 + i % 1000))); + + var after = Snapshot(engine, tagIndex); + Assert.Equal(before.Keys.OrderBy(k => k), after.Keys.OrderBy(k => k)); + Assert.All(before, p => Assert.True(p.Value.AsSpan().SequenceEqual(after[p.Key]), $"index page {p.Key} was rewritten")); + Assert.Equal(count, tagIndex.CountRange(IndexKey.Create("shared"), IndexKey.Create("shared"))); + } + + [Fact] + public async Task Typed_UpdateOfUnrelatedField_LeavesUniqueAndNonUniqueEntriesIntact() + { + using var db = new TestDbContext(_dbPath); + await db.PeopleV2.CreateIndexAsync(p => p.Name, "idx_name", unique: true); + var people = Enumerable.Range(0, 200).Select(i => new PersonV2 { Name = $"n{i:D4}", Age = i % 4 }).ToList(); + foreach (var p in people) await db.PeopleV2.InsertAsync(p); + + // Age is indexed (non-unique) by the model; Name has a unique index. + foreach (var p in people) await db.PeopleV2.UpdateAsync(p); + foreach (var p in people.Take(100)) { p.Name = $"m{p.Name[1..]}"; await db.PeopleV2.UpdateAsync(p); } + + Assert.Equal(200, (await db.PeopleV2.QueryIndexAsync("idx_name", null, null).ToListAsync()).Count); + Assert.Equal(50, await db.PeopleV2.AsQueryable().Where(p => p.Age == 2).CountAsync()); + Assert.Single(await db.PeopleV2.AsQueryable().Where(p => p.Name == "m0042").ToListAsync()); + Assert.Single(await db.PeopleV2.AsQueryable().Where(p => p.Name == "n0142").ToListAsync()); + } + + // ── Keys stored before MaxKeySize was lowered (#217) ───────────────────── + + [Fact] + public async Task Dynamic_LegacyOversizedKey_UnrelatedUpdatesSucceed_NewOversizedKeysAreRejected() + { + using var engine = new BLiteEngine(_dbPath); + var col = engine.GetOrCreateCollection("docs", BsonIdType.String); + await col.CreateIndexAsync("name", "idx_name", unique: false); + await col.CreateIndexAsync("tag", "idx_tag", unique: true); + var nameIndex = DynamicBTree(col, "idx_name"); + + // Stand-in for a database written by an older version: an indexed value between the new + // limit and the old one. (A dynamic _id that long cannot be built: the BSON writer rejects + // it, so the primary-key case is covered by the typed test below.) + nameIndex.MaxKeySizeOverride = LegacyMaxKeySize(nameIndex); + await col.InsertAsync(Doc(col, "a", LegacyLongValue, "t1", 0)); + await col.InsertAsync(Doc(col, "b", LegacyLongValue, "t2", 0)); + nameIndex.MaxKeySizeOverride = null; + + // In place, then relocated by growing the document. + Assert.True(await col.UpdateAsync("a", Doc(col, "a", LegacyLongValue, "t1", 1))); + Assert.True(await col.UpdateAsync("b", Doc(col, "b", LegacyLongValue, "t2", 1))); + Assert.True(await col.UpdateAsync("b", Doc(col, "b", LegacyLongValue, "t2", 2, new string('p', 3000)))); + Assert.True(await col.UpdateAsync("a", Doc(col, "a", LegacyLongValue, "t3", 2, new string('p', 3000)))); + + Assert.Equal(2, (await col.QueryIndexAsync("idx_name", LegacyLongValue, LegacyLongValue).ToListAsync()).Count); + Assert.Equal(2, (await col.QueryIndexAsync("idx_name", LegacyLongValue, LegacyLongValue, false).ToListAsync()).Count); + Assert.Equal(2, Counter((await col.FindByIdAsync("a"))!)); + Assert.Single(await col.QueryIndexAsync("idx_tag", "t3", "t3").ToListAsync()); + + // A new or changed oversized key is still rejected up front. + var otherLong = "other-" + new string('O', 8300); + await Assert.ThrowsAsync(() => + col.InsertAsync(Doc(col, "c", otherLong, "t4", 0)).AsTask()); + await Assert.ThrowsAsync(() => + col.UpdateAsync("b", Doc(col, "b", otherLong, "t2", 3)).AsTask()); + Assert.Equal(2, await col.CountAsync()); + Assert.Equal(2, (await col.QueryIndexAsync("idx_name", LegacyLongValue, LegacyLongValue).ToListAsync()).Count); + } + + [Fact] + public async Task Typed_LegacyOversizedPrimaryKey_UpdatesInPlaceAndRelocated() + { + var legacyId = "id-" + new string('I', 8300); + using var db = new TestDbContext(_dbPath); + var primary = db.StringEntities._primaryIndex; + + primary.MaxKeySizeOverride = LegacyMaxKeySize(primary); + await db.StringEntities.InsertAsync(new StringEntity { Id = legacyId, Value = "a" }); + primary.MaxKeySizeOverride = null; + + await db.StringEntities.UpdateAsync(new StringEntity { Id = legacyId, Value = "b" }); + Assert.Equal("b", (await db.StringEntities.FindByIdAsync(legacyId))!.Value); + + // Growing the document moves it: the primary entry follows it. + await db.StringEntities.UpdateAsync(new StringEntity { Id = legacyId, Value = new string('c', 3000) }); + Assert.Equal(3000, (await db.StringEntities.FindByIdAsync(legacyId))!.Value!.Length); + Assert.Equal(1, await db.StringEntities.CountAsync()); + + await Assert.ThrowsAsync(() => + db.StringEntities.InsertAsync(new StringEntity { Id = "id-" + new string('J', 8300), Value = "x" }).AsTask()); + } + + [Fact] + public async Task Typed_LegacyOversizedIndexedValue_UnrelatedUpdateSucceeds_ChangedOversizedValueIsRejected() + { + using var db = new TestDbContext(_dbPath); + await db.PeopleV2.CreateIndexAsync(p => p.Name, "idx_name", unique: false); + var nameIndex = TypedBTree(db.PeopleV2, "idx_name"); + + nameIndex.MaxKeySizeOverride = LegacyMaxKeySize(nameIndex); + var legacy = new PersonV2 { Name = LegacyLongValue, Age = 1 }; + await db.PeopleV2.InsertAsync(legacy); + nameIndex.MaxKeySizeOverride = null; + + legacy.Age = 2; + await db.PeopleV2.UpdateAsync(legacy); + var found = Assert.Single(await db.PeopleV2.AsQueryable().Where(p => p.Name == LegacyLongValue).ToListAsync()); + Assert.Equal(2, found.Age); + + legacy.Name = "other-" + new string('O', 8300); + await Assert.ThrowsAsync(() => db.PeopleV2.UpdateAsync(legacy).AsTask()); + await Assert.ThrowsAsync(() => + db.PeopleV2.InsertAsync(new PersonV2 { Name = "new-" + new string('N', 8300), Age = 3 }).AsTask()); + Assert.Equal(2, (await db.PeopleV2.FindByIdAsync(legacy.Id))!.Age); + } + + [Fact] + public async Task Typed_LegacyOversizedIndexedValue_FollowsItsDocumentWhenItMoves() + { + using var db = new TestDbContext(_dbPath); + var indexName = db.TestDocuments.GetIndexes().Single(i => i.PropertyPaths.SequenceEqual(["Category"])).Name; + var categoryIndex = TypedBTree(db.TestDocuments, indexName); + + categoryIndex.MaxKeySizeOverride = LegacyMaxKeySize(categoryIndex); + var legacy = new TestDocument { Category = LegacyLongValue, Amount = 1, Name = "small" }; + await db.TestDocuments.InsertAsync(legacy); + categoryIndex.MaxKeySizeOverride = null; + + // Growing the document moves it: the entry of its unchanged, oversized key is re-pointed. + legacy.Name = new string('g', 3000); + await db.TestDocuments.UpdateAsync(legacy); + + var found = Assert.Single(await db.TestDocuments.AsQueryable().Where(d => d.Category == LegacyLongValue).ToListAsync()); + Assert.Equal(3000, found.Name.Length); + Assert.Equal(1, await db.TestDocuments.AsQueryable().CountAsync(d => d.Category == LegacyLongValue)); + } +}