Repository navigation
fix(storage): stop the collection metadata chain from growing with every save (#195) - #196
Conversation
…ery save (EntglDb#195) SaveCollectionMetadata marked the old record deleted and appended the new one, and nothing reused that space: every save permanently added a record's worth of bytes to the chain at page 1, about one page per 430 saves, and every metadata read and write walked the whole chain. With one save per insert into an int/long-keyed collection (GetNextSequence), inserts got linearly slower. - A record that is no larger than the stored one is overwritten in place, in a single page write. This covers sequence values and root page ids. - A larger or new record first compacts the chain (live records packed from page 1 on, in order; deleted records, leftover bytes and duplicate copies dropped; pages no longer needed freed). Then it is written to a page with room, or to a new page linked at the end, before the old one is marked deleted, so a crash in between leaves the old record rather than neither. - After an in-place write, the chain is compacted when that frees a page, so chains grown by earlier releases shrink on their next metadata save. - Compaction rewrites pages front to back and records only move toward page 1, so a crash part-way can duplicate a record but never lose one. GetAllCollectionsMetadata lists each collection once, and DeleteCollectionMetadata deletes every copy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mrdevrobot
left a comment
There was a problem hiding this comment.
Thanks. I reproduced #195 on main (all 6 new tests fail there and with the fix reverted). Probe on the merged state: 10,000 alternating 600-byte / 5-byte saves plus 10,000 GetNextSequence calls keep the chain at one page; on main it ends at 517 pages. I also checked crash safety by splicing file images after each page write of a 4-page compaction with holes: every intermediate state read back all 26 surviving collections correctly, and the write order (new page before its link, new record before the old one's deletion) is better than before. The format is unchanged and 5.2.x reads the result. Metadata pages never go through the WAL, so there's no interaction with the deferred frees from #169. Full suite merged with main 2463 / 0 / 4 skipped.
Approving. One follow-up I'd like soon, here or right after:
Unlocked readers can miss a collection that isn't being saved (GetCollectionMetadata ~L120, GetAllCollectionsMetadata). A compaction moves collection B's record from page p+1 to p and frees the tail. A reader walking for B without the lock can read page p before the rewrite and p+1 after it, or reach a freed page, and return null. The DynamicCollection and CollectionIndexManager constructors treat null as "new collection" and save fresh metadata, which overwrites B's root page ids and orphans its data. Before, only the collection being saved could be missed, so "the old save had the same window" isn't quite right. Fix: when the walk returns null or finds a duplicate, walk again under _metadataLock.
Smaller: CompactMetadataChain(onlyIfShorter: true) copies every live record on every in-place save, including each GetNextSequence, just to count pages; counting from slot lengths first avoids that. A crash between the last compaction write and the FreePage loop leaks the tail pages (acceptable, worth a comment). A test for the new up-front size check would be good. Separately, repeated index create/drop still grows the file on main for a different reason: #209.
On merge order with #194, see my note there: the feature guard has to move before your in-place step.
|
Thanks. The follow-up is in #215. It is stacked on this branch, so this PR can merge as approved.
#209 is left for its own fix. |
Brings in EntglDb#196 (squash-merged) and EntglDb#194 (per-collection required features). StorageEngine.Collections.cs is main's version with the follow-up re-applied: lock-free GetCollectionMetadata with a locked re-walk on a miss, GetAllCollectionsMetadata under the lock, compaction planned from slot lengths, AppendRecord(ReadOnlySpan<byte>). DeleteCollectionMetadata (EntglDb#194's feature guard) now looks the record up with FindCollectionMetadata: it already holds _metadataLock, which is not reentrant, so the locked re-walk on a miss would wait for itself until the lock timeout. Test added. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…paction moves (follow-up to #196) (#215) * fix(storage): stop the collection metadata chain from growing with every save (#195) SaveCollectionMetadata marked the old record deleted and appended the new one, and nothing reused that space: every save permanently added a record's worth of bytes to the chain at page 1, about one page per 430 saves, and every metadata read and write walked the whole chain. With one save per insert into an int/long-keyed collection (GetNextSequence), inserts got linearly slower. - A record that is no larger than the stored one is overwritten in place, in a single page write. This covers sequence values and root page ids. - A larger or new record first compacts the chain (live records packed from page 1 on, in order; deleted records, leftover bytes and duplicate copies dropped; pages no longer needed freed). Then it is written to a page with room, or to a new page linked at the end, before the old one is marked deleted, so a crash in between leaves the old record rather than neither. - After an in-place write, the chain is compacted when that frees a page, so chains grown by earlier releases shrink on their next metadata save. - Compaction rewrites pages front to back and records only move toward page 1, so a crash part-way can duplicate a record but never lose one. GetAllCollectionsMetadata lists each collection once, and DeleteCollectionMetadata deletes every copy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(storage): confirm metadata misses under the lock while the chain is compacted (#195) Follow-up to #196. - GetCollectionMetadata: the lock-free walk can miss a record that a concurrent compaction moves onto a page the walk already read (and rewrite or free the page it was on). Callers treat null as "new collection" and save fresh metadata over the existing record, so a miss (or an unparsable page) is now repeated under _metadataLock. - GetAllCollectionsMetadata walks under _metadataLock: a missing entry in a full listing cannot be detected after the fact. - CompactMetadataChain plans from slot lengths and copies nothing unless it rewrites; a single-page chain returns at once, so in-place saves (GetNextSequence) no longer copy every live record. - Document that a crash between the last compaction write and the FreePage loop leaks the tail pages. - Tests: the reader/compaction race for both readers (deterministic, via an intercepting IPageStorage), and the up-front size check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: MrDevRobot <mrdevrobot@entgldb.com>
Fixes #195.
SaveCollectionMetadatano longer adds a record to the metadata chain on every save. A chain that has already grown shrinks back on its next metadata save. Inserts intoint/long-keyed collections, which save metadata once per insert throughGetNextSequence, now cost the same at the 30,000th insert as at the first.Changes
All in
StorageEngine.Collections.cs:1. In-place rewrite. A record that is no larger than the stored one is overwritten where it is, in one page write. This is the common case: a sequence value or a root page id changes, and the record keeps its size.
2. Compaction before appending. For a record that grew, or a new one,
CompactMetadataChainfirst:Then the record goes into a page with room, or into a new page linked at the end.
3. Write order. The new record is written before the old one is marked deleted, so a crash in between leaves the old record instead of neither. Before, the deletion was written first.
4. Healing. After an in-place write, the chain is compacted if that frees at least one page. A database grown by earlier releases shrinks on its first metadata save after the upgrade: opening a collection, inserting with a generated
intid, creating an index, and so on.5. Crash safety of compaction.
GetAllCollectionsMetadatareturns each collection once,DeleteCollectionMetadatadeletes every copy (before, it stopped at the first), and the next compaction drops the extra copy.Freed pages go back to the free list, so the next allocation reuses them.
Measurements
The probe from #195, on the same machine, run on this branch and on
main:GetNextSequencecalls so farmainmainThe file stayed at 2 MB on this branch; on
mainit grew from 3 MB to 6 MB.Typed inserts into
TestDbContext.Products(intid left at 0):mainTests
MetadataChainTests, 6 tests, all failing onmain:GetNextSequencecalls leave the chain unchanged with no deleted slots, and the value survives a reopen.intids give ids 1..2000 and a one-page chain.DeleteCollectionMetadata.I disabled each part in turn and checked that at least one test fails each time: the in-place path, the healing compaction, the compaction before appending, freeing the surplus pages, deleting every copy, listing once, and dropping duplicates. The write order (new record before deletion) is the one part not covered by a test, since it would need a crash between two page writes.
Full suite on macOS: 2398 passed, 0 failed, 4 skipped.
BLite.NetStandard21.Tests: 20/20.BLite.Corebuilds with no warnings.Merging with #194
#194 changes the same method, so the second of the two to land needs a small conflict resolution:
if (foundPage >= 0 && TryReadRecord(chain[foundPage].Buffer, foundSlot) is { } stored) CollectionFeatures.EnsureCanWrite(stored););TryReadRecordandTryReadRecordName.I resolved it on a scratch merge: the tests of both PRs pass (17/17), and the full suite gives 2409 passed, 0 failed, 4 skipped. Compaction moves the records of collections with unknown features without changing a byte, which keeps #194's guarantee. I'll push the resolution to whichever branch lands second. The other open PRs merge with this one without conflicts.
Not changed here
GetCollectionMetadataandGetAllCollectionsMetadatastill read without the metadata lock, as before. A reader running at the same moment as a save that moves records could miss one. The old save had the same window between writing the deletion and writing the new record. The common in-place path now leaves a much smaller window: one page write.🤖 Generated with Claude Code