diff --git a/src/BLite.Core/Storage/StorageEngine.Collections.cs b/src/BLite.Core/Storage/StorageEngine.Collections.cs index a5b503c..e7eabb4 100644 --- a/src/BLite.Core/Storage/StorageEngine.Collections.cs +++ b/src/BLite.Core/Storage/StorageEngine.Collections.cs @@ -364,147 +364,252 @@ public void SaveCollectionMetadata(CollectionMetadata metadata) writer.Write(idx.KeyFormat); var newData = stream.ToArray(); + if (newData.Length + SlotEntry.Size > PageSize - SlottedPageHeader.Size) + throw new InvalidOperationException( + $"Collection metadata for '{metadata.Name}' ({newData.Length} bytes) does not fit in a page."); - // ── Pass 1: walk the chain to find the existing entry and the first page - // with enough free space to store the new serialised record. ── - - var loadedPages = new List<(uint PageId, byte[] Buffer)>(); - uint chainPageId = 1; - int foundOnPageIdx = -1; // index into loadedPages - int foundSlotIndex = -1; - int bestWriteIdx = -1; // first page with enough room + var chain = LoadMetadataChain(); + if (chain.Count == 0) + throw new InvalidOperationException("Page 1 is not a collection metadata page."); + var (foundPage, foundSlot) = FindMetadataRecord(chain, metadata.Name); - while (chainPageId != 0) + // ── 1. No larger than the stored record: overwrite it where it is ───── + // A single page write. Covers the frequent saves (sequence values, root page ids) whose + // record keeps its size, so they no longer leave a deleted record behind (#195). + if (foundPage >= 0) { - var buf = new byte[PageSize]; - ReadPage(chainPageId, null, buf); - int thisIdx = loadedPages.Count; - loadedPages.Add((chainPageId, buf)); - - var hdr = SlottedPageHeader.ReadFrom(buf); - if (hdr.PageType != PageType.Collection) - break; - - // Locate existing slot for this collection name - if (foundSlotIndex == -1) + var (pageId, buffer) = chain[foundPage]; + var slot = ReadSlot(buffer, foundSlot); + if (newData.Length <= slot.Length) { - for (ushort i = 0; i < hdr.SlotCount; i++) - { - var so = SlottedPageHeader.Size + (i * SlotEntry.Size); - var slot = SlotEntry.ReadFrom(buf.AsSpan(so)); - if ((slot.Flags & SlotFlags.Deleted) != 0) continue; - - try - { - using var ms = new MemoryStream(buf, slot.Offset, slot.Length, false); - using var rdr = new BinaryReader(ms); - var existingName = rdr.ReadString(); - if (string.Equals(existingName, metadata.Name, StringComparison.OrdinalIgnoreCase)) - { - foundOnPageIdx = thisIdx; - foundSlotIndex = i; - break; - } - } - catch { } - } + newData.CopyTo(buffer.AsSpan(slot.Offset)); + slot.Length = (ushort)newData.Length; + WriteSlot(buffer, foundSlot, slot); + WritePageImmediate(pageId, buffer); + + // Shrink a chain grown by earlier releases (or by records that grew) once a page can go. + CompactMetadataChain(chain, onlyIfShorter: true); + return; } + } - // First page with enough room for the new entry - if (bestWriteIdx == -1 && hdr.AvailableFreeSpace >= newData.Length + SlotEntry.Size) - bestWriteIdx = thisIdx; + // ── 2. Larger or new: reclaim dead space first, so the record goes into free space in the + // chain before a page is appended ──────────────────────────────────────────────── + chain = CompactMetadataChain(chain, onlyIfShorter: false); + (foundPage, foundSlot) = FindMetadataRecord(chain, metadata.Name); - chainPageId = hdr.NextOverflowPage; + // ── 3. Write the new record, then delete the old one: a crash in between leaves the old + // record in place rather than neither ───────────────────────────────────────────── + int target = chain.FindIndex(p => + SlottedPageHeader.ReadFrom(p.Buffer).AvailableFreeSpace >= newData.Length + SlotEntry.Size); + + if (target < 0) + { + // No room anywhere: append a page holding the record, then link it from the last page. + var newPageId = AllocatePage(); + var newPage = new byte[PageSize]; + NewMetadataPage(newPageId, 0).WriteTo(newPage); + AppendRecord(newPage, newData); + WritePageImmediate(newPageId, newPage); + + var (lastId, lastBuf) = chain[chain.Count - 1]; + var lastHdr = SlottedPageHeader.ReadFrom(lastBuf); + lastHdr.NextOverflowPage = newPageId; + lastHdr.WriteTo(lastBuf); + WritePageImmediate(lastId, lastBuf); + } + else + { + AppendRecord(chain[target].Buffer, newData); + if (target == foundPage) + { + // Same page: one write covers both the new record and the deletion. + MarkSlotDeleted(chain[foundPage].Buffer, foundSlot); + foundPage = -1; + } + WritePageImmediate(chain[target].PageId, chain[target].Buffer); } - // ── Pass 2: mark old entry as Deleted (in its in-memory buffer) ── - if (foundSlotIndex >= 0) + if (foundPage >= 0) { - var (_, fbuf) = loadedPages[foundOnPageIdx]; - var so = SlottedPageHeader.Size + (foundSlotIndex * SlotEntry.Size); - var slot = SlotEntry.ReadFrom(fbuf.AsSpan(so)); - slot.Flags |= SlotFlags.Deleted; - slot.WriteTo(fbuf.AsSpan(so)); + MarkSlotDeleted(chain[foundPage].Buffer, foundSlot); + WritePageImmediate(chain[foundPage].PageId, chain[foundPage].Buffer); } + } // end try + finally { _metadataLock.Release(); } + } - // ── Pass 3: resolve write-target page ── - byte[] targetBuf; - uint targetPageId; + // ── Collection metadata chain (page 1 and its overflow pages) ──────────── - if (bestWriteIdx >= 0) + /// Reads the Collection pages of the metadata chain, starting at page 1. + private List<(uint PageId, byte[] Buffer)> LoadMetadataChain() + { + var chain = new List<(uint PageId, byte[] Buffer)>(); + uint pageId = 1; + while (pageId != 0) { - (targetPageId, targetBuf) = loadedPages[bestWriteIdx]; + var buffer = new byte[PageSize]; + ReadPage(pageId, null, buffer); + var header = SlottedPageHeader.ReadFrom(buffer); + if (header.PageType != PageType.Collection) + break; + chain.Add((pageId, buffer)); + pageId = header.NextOverflowPage; } - else + return chain; + } + + /// Finds the first live record of the named collection, or (-1, -1). + private static (int Page, int Slot) FindMetadataRecord(List<(uint PageId, byte[] Buffer)> chain, string name) + { + for (int p = 0; p < chain.Count; p++) { - // No existing page has room → allocate a new Collection overflow page - targetPageId = AllocatePage(); - targetBuf = new byte[PageSize]; - var newHdr = new SlottedPageHeader + var buffer = chain[p].Buffer; + var header = SlottedPageHeader.ReadFrom(buffer); + for (int i = 0; i < header.SlotCount; i++) { - PageId = targetPageId, - PageType = PageType.Collection, - SlotCount = 0, - FreeSpaceStart = SlottedPageHeader.Size, - FreeSpaceEnd = (ushort)PageSize, - NextOverflowPage = 0, - TransactionId = 0 - }; - newHdr.WriteTo(targetBuf); + var slot = ReadSlot(buffer, i); + if ((slot.Flags & SlotFlags.Deleted) != 0) continue; + if (string.Equals(TryReadRecordName(buffer, slot), name, StringComparison.OrdinalIgnoreCase)) + return (p, i); + } + } + return (-1, -1); + } - // Append new page to the end of the chain - var (lastPid, lastBuf) = loadedPages[loadedPages.Count - 1]; - var lastHdr = SlottedPageHeader.ReadFrom(lastBuf); - lastHdr.NextOverflowPage = targetPageId; - lastHdr.WriteTo(lastBuf); - WritePageImmediate(lastPid, lastBuf); + /// + /// Packs the live records of the chain into as few pages as possible, starting at page 1 and in + /// their current order, and frees the pages no longer needed. Deleted records, bytes left behind + /// by records overwritten with shorter ones, and duplicates of a record (kept: the first, as + /// lookups do) are dropped. + /// + /// + /// Only rewrite when that frees at least one page; otherwise whenever there is anything to drop. + /// + /// The chain as it is afterwards. + /// + /// Pages are rewritten front to back and every record lands on its own page or an earlier one, + /// so all records are on some page after each write. A crash part-way can leave a record on + /// two pages; the copies are identical, readers take the first, and the next compaction drops + /// the other. + /// + private List<(uint PageId, byte[] Buffer)> CompactMetadataChain(List<(uint PageId, byte[] Buffer)> chain, bool onlyIfShorter) + { + var records = new List(); + var names = new HashSet(StringComparer.OrdinalIgnoreCase); + bool anythingToDrop = false; - // Flush the deletion page if it differs from lastPage - if (foundSlotIndex >= 0 && foundOnPageIdx != loadedPages.Count - 1) + foreach (var (_, buffer) in chain) + { + var header = SlottedPageHeader.ReadFrom(buffer); + int liveBytes = 0; + for (int i = 0; i < header.SlotCount; i++) { - var (fpid, fbuf) = loadedPages[foundOnPageIdx]; - WritePageImmediate(fpid, fbuf); - } + var slot = ReadSlot(buffer, i); + if ((slot.Flags & SlotFlags.Deleted) != 0) { anythingToDrop = true; continue; } + + var name = TryReadRecordName(buffer, slot); + if (name != null && !names.Add(name)) { anythingToDrop = true; continue; } - loadedPages.Add((targetPageId, targetBuf)); + records.Add(buffer.AsSpan(slot.Offset, slot.Length).ToArray()); + liveBytes += slot.Length; + } + if (PageSize - header.FreeSpaceEnd > liveBytes) anythingToDrop = true; } - // If deletion page != target page, flush it now - if (foundSlotIndex >= 0) + // Greedy packing in order needs no more pages than any in-order layout, the current one included. + var pages = new List> { new() }; + int room = PageSize - SlottedPageHeader.Size; + foreach (var record in records) { - var (fpid, fbuf) = loadedPages[foundOnPageIdx]; - if (fpid != targetPageId) - WritePageImmediate(fpid, fbuf); + int need = record.Length + SlotEntry.Size; + if (need > room) + { + pages.Add(new List()); + room = PageSize - SlottedPageHeader.Size; + } + pages[pages.Count - 1].Add(record); + room -= need; } - // ── Pass 4: insert new slot into targetBuf ── - var th = SlottedPageHeader.ReadFrom(targetBuf); + if (pages.Count >= chain.Count && (onlyIfShorter || !anythingToDrop)) + return chain; - // Defensive check (should not trigger given the logic above) - if (th.AvailableFreeSpace < newData.Length + SlotEntry.Size) - throw new InvalidOperationException( - $"Not enough space in Collection page {targetPageId} to save collection metadata."); + var result = new List<(uint PageId, byte[] Buffer)>(pages.Count); + for (int p = 0; p < pages.Count; p++) + { + var pageId = chain[p].PageId; + var next = p + 1 < pages.Count ? chain[p + 1].PageId : 0u; + var buffer = new byte[PageSize]; + NewMetadataPage(pageId, next).WriteTo(buffer); + foreach (var record in pages[p]) + AppendRecord(buffer, record); + WritePageImmediate(pageId, buffer); + result.Add((pageId, buffer)); + } - int docOff = th.FreeSpaceEnd - newData.Length; - newData.CopyTo(targetBuf.AsSpan(docOff)); + // Page 1 is always kept; the pages after the new end of the chain are no longer reachable. + for (int p = pages.Count; p < chain.Count; p++) + FreePage(chain[p].PageId); - int newSlotOff = SlottedPageHeader.Size + (th.SlotCount * SlotEntry.Size); - var newSlot = new SlotEntry + return result; + } + + private SlottedPageHeader NewMetadataPage(uint pageId, uint nextPageId) => new() + { + PageId = pageId, + PageType = PageType.Collection, + SlotCount = 0, + FreeSpaceStart = SlottedPageHeader.Size, + FreeSpaceEnd = (ushort)PageSize, + NextOverflowPage = nextPageId, + TransactionId = 0 + }; + + /// Adds as a new slot; the caller checked that it fits. + private static void AppendRecord(byte[] page, byte[] record) + { + var header = SlottedPageHeader.ReadFrom(page); + int offset = header.FreeSpaceEnd - record.Length; + record.CopyTo(page.AsSpan(offset)); + WriteSlot(page, header.SlotCount, new SlotEntry { - Offset = (ushort)docOff, - Length = (ushort)newData.Length, + Offset = (ushort)offset, + Length = (ushort)record.Length, Flags = SlotFlags.None - }; - newSlot.WriteTo(targetBuf.AsSpan(newSlotOff)); + }); + header.SlotCount++; + header.FreeSpaceEnd = (ushort)offset; + header.FreeSpaceStart = (ushort)(SlottedPageHeader.Size + (header.SlotCount * SlotEntry.Size)); + header.WriteTo(page); + } - th.SlotCount++; - th.FreeSpaceEnd = (ushort)docOff; - th.FreeSpaceStart = (ushort)(SlottedPageHeader.Size + (th.SlotCount * SlotEntry.Size)); - th.WriteTo(targetBuf); + private static void MarkSlotDeleted(byte[] page, int slotIndex) + { + var slot = ReadSlot(page, slotIndex); + slot.Flags |= SlotFlags.Deleted; + WriteSlot(page, slotIndex, slot); + } - WritePageImmediate(targetPageId, targetBuf); - } // end try - finally { _metadataLock.Release(); } + private static SlotEntry ReadSlot(byte[] page, int slotIndex) + => SlotEntry.ReadFrom(page.AsSpan(SlottedPageHeader.Size + (slotIndex * SlotEntry.Size))); + + private static void WriteSlot(byte[] page, int slotIndex, SlotEntry slot) + => slot.WriteTo(page.AsSpan(SlottedPageHeader.Size + (slotIndex * SlotEntry.Size))); + + /// The collection name a record starts with, or null if the record is malformed. + private static string? TryReadRecordName(byte[] page, SlotEntry slot) + { + try + { + using var ms = new MemoryStream(page, slot.Offset, slot.Length, false); + using var reader = new BinaryReader(ms); + return reader.ReadString(); + } + catch + { + return null; + } } /// @@ -544,10 +649,11 @@ public void DeleteCollectionMetadata(string name) if (!string.Equals(collName, name, StringComparison.OrdinalIgnoreCase)) continue; + // Keep going: a crash while the chain was compacted can leave a second, + // identical copy further on (see CompactMetadataChain). slot.Flags |= SlotFlags.Deleted; slot.WriteTo(buffer.AsSpan(slotOffset)); WritePageImmediate(pageId, buffer); - return; } catch { } } @@ -561,6 +667,9 @@ public void DeleteCollectionMetadata(string name) public IReadOnlyList GetAllCollectionsMetadata() { var result = new List(); + // One entry per collection: the first copy, as GetCollectionMetadata returns it (a crash + // while the chain was compacted can leave a second one; see CompactMetadataChain). + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); var buffer = new byte[PageSize]; uint pageId = 1; @@ -584,7 +693,9 @@ public IReadOnlyList GetAllCollectionsMetadata() using var reader = new BinaryReader(ms); var collName = reader.ReadString(); + if (seen.Contains(collName)) continue; result.Add(ReadCollectionMetadata(collName, reader)); + seen.Add(collName); } catch { diff --git a/tests/BLite.Tests/MetadataChainTests.cs b/tests/BLite.Tests/MetadataChainTests.cs new file mode 100644 index 0000000..d4f56da --- /dev/null +++ b/tests/BLite.Tests/MetadataChainTests.cs @@ -0,0 +1,363 @@ +using BLite.Core; +using BLite.Core.Retention; +using BLite.Core.Storage; +using BLite.Shared; + +namespace BLite.Tests; + +/// +/// The collection metadata chain (page 1 and its overflow pages) must not grow with every save, +/// and a chain grown by older releases shrinks again (#195). +/// +public class MetadataChainTests : IDisposable +{ + private readonly string _dbPath; + + public MetadataChainTests() + { + _dbPath = Path.Combine(Path.GetTempPath(), $"blite_metadata_chain_{Guid.NewGuid():N}.db"); + } + + public void Dispose() + { + foreach (var path in new[] { _dbPath, Path.ChangeExtension(_dbPath, ".wal") }) + { + try { if (File.Exists(path)) File.Delete(path); } catch { } + } + } + + private sealed record ChainInfo(List Pages, int Live, int Deleted, List LiveLengths) + { + /// Pages the live records need when packed in order. + public int MinimalPages(int pageSize) + { + int pages = 1, room = pageSize - SlottedPageHeader.Size; + foreach (var length in LiveLengths) + { + if (length + SlotEntry.Size > room) { pages++; room = pageSize - SlottedPageHeader.Size; } + room -= length + SlotEntry.Size; + } + return pages; + } + } + + private static ChainInfo Inspect(StorageEngine storage) + { + var pages = new List(); + var lengths = new List(); + int live = 0, deleted = 0; + var buffer = new byte[storage.PageSize]; + uint pageId = 1; + while (pageId != 0) + { + storage.ReadPage(pageId, null, buffer); + var header = SlottedPageHeader.ReadFrom(buffer); + if (header.PageType != PageType.Collection) break; + pages.Add(pageId); + for (int i = 0; i < header.SlotCount; i++) + { + var slot = SlotEntry.ReadFrom(buffer.AsSpan(SlottedPageHeader.Size + i * SlotEntry.Size)); + if ((slot.Flags & SlotFlags.Deleted) != 0) deleted++; + else { live++; lengths.Add(slot.Length); } + } + pageId = header.NextOverflowPage; + } + return new ChainInfo(pages, live, deleted, lengths); + } + + private async Task SeedAsync(params string[] collections) + { + using var engine = new BLiteEngine(_dbPath); + foreach (var name in collections) + await engine.InsertAsync(name, engine.CreateDocument(["name"], b => b.AddString("name", name + "-1"))); + } + + private static string[] Names(int count) => Enumerable.Range(0, count).Select(i => $"c{i}").ToArray(); + + // ── No growth ──────────────────────────────────────────────────────────── + + [Fact] + public async Task SequenceSaves_RewriteTheRecordInPlace() + { + await SeedAsync(Names(8)); + + using (var storage = new StorageEngine(_dbPath, PageFileConfig.Default)) + { + var before = Inspect(storage); + for (int i = 1; i <= 5000; i++) + Assert.Equal(i, storage.GetNextSequence("c3")); + + var after = Inspect(storage); + Assert.Equal(before.Pages, after.Pages); + Assert.Equal(8, after.Live); + Assert.Equal(0, after.Deleted); + } + + using (var storage = new StorageEngine(_dbPath, PageFileConfig.Default)) + Assert.Equal(5001, storage.GetNextSequence("c3")); + } + + [Fact] + public async Task TypedInsertsWithGeneratedIntIds_KeepTheChainAtOnePage() + { + using (var db = new TestDbContext(_dbPath)) + { + for (int i = 0; i < 2000; i++) + await db.Products.InsertAsync(new Product { Title = $"p{i}", Price = i }); + } + + using (var db = new TestDbContext(_dbPath)) + { + var ids = db.Products.AsQueryable().Select(p => p.Id).ToList().OrderBy(id => id).ToList(); + Assert.Equal(Enumerable.Range(1, 2000), ids); + } + + using var storage = new StorageEngine(_dbPath, PageFileConfig.Default); + var chain = Inspect(storage); + Assert.Single(chain.Pages); + Assert.True(chain.Deleted <= 1); // at most the record the last growing save replaced + } + + [Fact] + public async Task RecordsThatShrinkAndGrow_ReuseTheSpaceTheyLeave() + { + await SeedAsync(Names(8)); + + using var storage = new StorageEngine(_dbPath, PageFileConfig.Default); + var pagesBefore = Inspect(storage).Pages; + for (int i = 0; i < 500; i++) + { + var meta = storage.GetCollectionMetadata("c5")!; + meta.GeneralRetentionPolicy = new RetentionPolicy + { + MaxAgeMs = i, + TimestampField = new string('t', i % 2 == 0 ? 400 : 10), // alternately grows and shrinks + }; + storage.SaveCollectionMetadata(meta); + } + + var chain = Inspect(storage); + Assert.Equal(pagesBefore, chain.Pages); + Assert.Equal(8, chain.Live); + var last = storage.GetCollectionMetadata("c5")!; + Assert.Equal(499, last.GeneralRetentionPolicy!.MaxAgeMs); + Assert.Equal(new string('t', 10), last.GeneralRetentionPolicy.TimestampField); + } + + // ── Chains that span pages ─────────────────────────────────────────────── + + [Fact] + public async Task ChainOfSeveralPages_GrowsOnlyWithItsLiveRecords_AndShrinksWhenTheyGo() + { + // Records of ~1.2 KB: 40 collections need several 16 KB pages. + var names = Names(40); + await SeedAsync(names); + using (var storage = new StorageEngine(_dbPath, PageFileConfig.Default)) + { + foreach (var name in names) + { + var meta = storage.GetCollectionMetadata(name)!; + meta.GeneralRetentionPolicy = new RetentionPolicy { TimestampField = new string('x', 1100) }; + storage.SaveCollectionMetadata(meta); + } + var grown = Inspect(storage); + Assert.True(grown.Pages.Count >= 3); + Assert.Equal(grown.MinimalPages(storage.PageSize), grown.Pages.Count); + + // Rewrite every record many times: the chain keeps its length. + for (int round = 0; round < 20; round++) + foreach (var name in names) + storage.GetNextSequence(name); + var full = Inspect(storage); + Assert.Equal(grown.Pages, full.Pages); + Assert.Equal(40, full.Live); + + // Drop most collections: the next save packs the rest into page 1 and frees the others. + foreach (var name in names.Skip(10)) + storage.DeleteCollectionMetadata(name); + storage.GetNextSequence("c0"); + + var shrunk = Inspect(storage); + Assert.Equal(new uint[] { 1 }, shrunk.Pages); + Assert.Equal(10, shrunk.Live); + Assert.Equal(0, shrunk.Deleted); + + // The freed pages are reused before the file grows. + var freed = full.Pages.Skip(1).ToHashSet(); + Assert.Contains(storage.AllocatePage(), freed); + } + + using (var storage = new StorageEngine(_dbPath, PageFileConfig.Default)) + { + var all = storage.GetAllCollectionsMetadata(); + Assert.Equal(names.Take(10), all.Select(m => m.Name).OrderBy(n => int.Parse(n[1..]))); + Assert.All(all, m => Assert.Equal(new string('x', 1100), m.GeneralRetentionPolicy!.TimestampField)); + Assert.Equal(20, all.Single(m => m.Name == "c1").SequenceValue); + } + + using (var engine = new BLiteEngine(_dbPath)) + Assert.Single(await engine.GetOrCreateCollection("c3").FindAllAsync().ToListAsync()); + } + + // ── Chains grown by older releases ─────────────────────────────────────── + + /// + /// Saves the record of times the way + /// releases before #195 did: the old slot is marked deleted and a copy is appended to the first + /// page with room, or to a new page linked at the end. + /// + private static void GrowLikeOlderReleases(StorageEngine storage, string collection, int saves) + { + for (int n = 0; n < saves; n++) + { + var chain = new List<(uint Id, byte[] Buffer)>(); + byte[]? record = null; + uint pageId = 1; + while (pageId != 0) + { + var buffer = new byte[storage.PageSize]; + storage.ReadPage(pageId, null, buffer); + var header = SlottedPageHeader.ReadFrom(buffer); + chain.Add((pageId, buffer)); + for (int i = 0; i < header.SlotCount && record == null; i++) + { + var offset = SlottedPageHeader.Size + i * SlotEntry.Size; + var slot = SlotEntry.ReadFrom(buffer.AsSpan(offset)); + if ((slot.Flags & SlotFlags.Deleted) != 0) continue; + using var reader = new BinaryReader(new MemoryStream(buffer, slot.Offset, slot.Length, false)); + if (reader.ReadString() != collection) continue; + record = buffer.AsSpan(slot.Offset, slot.Length).ToArray(); + slot.Flags |= SlotFlags.Deleted; + slot.WriteTo(buffer.AsSpan(offset)); + storage.WritePageImmediate(pageId, buffer); + } + pageId = header.NextOverflowPage; + } + + var target = chain.FirstOrDefault(p => + SlottedPageHeader.ReadFrom(p.Buffer).AvailableFreeSpace >= record!.Length + SlotEntry.Size); + if (target.Buffer == null) + { + var id = storage.AllocatePage(); + var page = new byte[storage.PageSize]; + new SlottedPageHeader + { + PageId = id, PageType = PageType.Collection, FreeSpaceStart = SlottedPageHeader.Size, + FreeSpaceEnd = (ushort)storage.PageSize + }.WriteTo(page); + var last = chain[^1]; + var lastHeader = SlottedPageHeader.ReadFrom(last.Buffer); + lastHeader.NextOverflowPage = id; + lastHeader.WriteTo(last.Buffer); + storage.WritePageImmediate(last.Id, last.Buffer); + target = (id, page); + } + + var h = SlottedPageHeader.ReadFrom(target.Buffer); + var dataOffset = h.FreeSpaceEnd - record!.Length; + record.CopyTo(target.Buffer.AsSpan(dataOffset)); + new SlotEntry { Offset = (ushort)dataOffset, Length = (ushort)record.Length } + .WriteTo(target.Buffer.AsSpan(SlottedPageHeader.Size + h.SlotCount * SlotEntry.Size)); + h.SlotCount++; + h.FreeSpaceEnd = (ushort)dataOffset; + h.FreeSpaceStart = (ushort)(SlottedPageHeader.Size + h.SlotCount * SlotEntry.Size); + h.WriteTo(target.Buffer); + storage.WritePageImmediate(target.Id, target.Buffer); + } + } + + [Fact] + public async Task ChainGrownByOlderReleases_ShrinksBackOnTheNextSave() + { + await SeedAsync(Names(8)); + uint[] grownPages; + using (var storage = new StorageEngine(_dbPath, PageFileConfig.Default)) + { + GrowLikeOlderReleases(storage, "c3", 3000); + var grown = Inspect(storage); + Assert.True(grown.Pages.Count >= 7, $"expected a grown chain, got {grown.Pages.Count} pages"); + Assert.Equal(3000, grown.Deleted); + grownPages = grown.Pages.ToArray(); + } + + using (var engine = new BLiteEngine(_dbPath)) + { + // The collection reads as before; the next metadata save (an index here) repairs the chain. + Assert.Single(await engine.GetOrCreateCollection("c3").FindAllAsync().ToListAsync()); + await engine.GetOrCreateCollection("c4").CreateIndexAsync("name"); + } + + using (var storage = new StorageEngine(_dbPath, PageFileConfig.Default)) + { + var healed = Inspect(storage); + Assert.Equal(new uint[] { 1 }, healed.Pages); + Assert.Equal(8, healed.Live); + Assert.True(healed.Deleted <= 1); // the record c4 replaced, until the next compaction + Assert.Equal(Names(8), storage.GetAllCollectionsMetadata().Select(m => m.Name).OrderBy(n => n)); + Assert.Single(storage.GetCollectionMetadata("c4")!.Indexes); + Assert.Contains(storage.AllocatePage(), grownPages.Skip(1)); + } + } + + // ── Duplicates a crash during compaction can leave ─────────────────────── + + [Fact] + public async Task DuplicateRecord_IsListedOnce_DeletedWithTheOriginal_AndDroppedByTheNextCompaction() + { + await SeedAsync(Names(3)); + using var storage = new StorageEngine(_dbPath, PageFileConfig.Default); + + // A second, identical copy of c1's record on a page further on, as a crash part-way + // through a compaction leaves it. + var page1 = new byte[storage.PageSize]; + storage.ReadPage(1, null, page1); + byte[]? record = null; + var header1 = SlottedPageHeader.ReadFrom(page1); + for (int i = 0; i < header1.SlotCount; i++) + { + var slot = SlotEntry.ReadFrom(page1.AsSpan(SlottedPageHeader.Size + i * SlotEntry.Size)); + using var reader = new BinaryReader(new MemoryStream(page1, slot.Offset, slot.Length, false)); + if (reader.ReadString() == "c1") record = page1.AsSpan(slot.Offset, slot.Length).ToArray(); + } + var extraId = storage.AllocatePage(); + var extra = new byte[storage.PageSize]; + var extraHeader = new SlottedPageHeader + { + PageId = extraId, PageType = PageType.Collection, SlotCount = 1, + FreeSpaceStart = SlottedPageHeader.Size + SlotEntry.Size, + FreeSpaceEnd = (ushort)(storage.PageSize - record!.Length) + }; + extraHeader.WriteTo(extra); + record.CopyTo(extra.AsSpan(extraHeader.FreeSpaceEnd)); + new SlotEntry { Offset = extraHeader.FreeSpaceEnd, Length = (ushort)record.Length } + .WriteTo(extra.AsSpan(SlottedPageHeader.Size)); + storage.WritePageImmediate(extraId, extra); + header1.NextOverflowPage = extraId; + header1.WriteTo(page1); + storage.WritePageImmediate(1, page1); + + Assert.Equal(3, storage.GetAllCollectionsMetadata().Count); + + // The next save drops the copy and the page that held it. + storage.GetNextSequence("c2"); + var compacted = Inspect(storage); + Assert.Equal(new uint[] { 1 }, compacted.Pages); + Assert.Equal(3, compacted.Live); + + // With a copy back, a delete removes both. + var againId = storage.AllocatePage(); + extraHeader.PageId = againId; + extraHeader.WriteTo(extra); + storage.WritePageImmediate(againId, extra); + storage.ReadPage(1, null, page1); + header1 = SlottedPageHeader.ReadFrom(page1); + header1.NextOverflowPage = againId; + header1.WriteTo(page1); + storage.WritePageImmediate(1, page1); + Assert.Equal(3, storage.GetAllCollectionsMetadata().Count); + + storage.DeleteCollectionMetadata("c1"); + Assert.Null(storage.GetCollectionMetadata("c1")); + Assert.Equal(new[] { "c0", "c2" }, storage.GetAllCollectionsMetadata().Select(m => m.Name).OrderBy(n => n)); + } +}