Repository navigation
fix(storage): don't let lock-free metadata readers miss records a compaction moves (follow-up to #196) - #215
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>
…is compacted (EntglDb#195) Follow-up to EntglDb#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>
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>
|
Updated to
|
Conflict with EntglDb#219 in DeleteCollectionMetadata resolved by keeping main's EnsureCanReplaceStoredRecord, which reads the chain directly and so does not re-enter _metadataLock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mrdevrobot
left a comment
There was a problem hiding this comment.
Thanks. Retrying a lock-free miss under _metadataLock keeps the hot hit path lock-free and closes the case where a compaction moved a record behind the reader and the constructors saved fresh metadata over it. The deterministic race tests with the IPageStorage wrapper are very good. Checked that no caller holding _metadataLock reaches GetCollectionMetadata/GetAllCollectionsMetadata (GetNextSequence uses its own per-collection lock).
I merged main into the branch (932a35d) to resolve the conflict with #219 in DeleteCollectionMetadata: kept EnsureCanReplaceStoredRecord, which reads the chain directly and so doesn't re-enter the lock. Full suite on the result: 2538 passed, 0 failed.
Follow-up to #196, stacked on
fix/195-metadata-growth; review #196 first. Until #196 merges, the diff here also shows #196's commits; only 445d683 is new. Refs #195.This addresses the follow-up requested in the #196 review.
Lock-free readers vs. compaction
GetCollectionMetadatakeeps its lock-free walk, and a record it finds is returned as before, since saves only rewrite or move records. A miss, or a page that cannot be parsed, is now repeated under_metadataLock. Before, a compaction could move collection B onto a page the reader had already read, and rewrite or free the page B was on. The reader then returned null, and theDynamicCollection/CollectionIndexManagerconstructors saved fresh metadata over B's record.GetAllCollectionsMetadatanow walks under_metadataLock. For a full listing a miss can't be detected afterwards: records moved behind the reader leave neither a null nor a duplicate. Its callers (ListCollections, DropOrphanCollections, GDPR inspection) are not hot paths.Compaction cost
CompactMetadataChain(onlyIfShorter: true)returns immediately for a one-page chain.FreePageloop leaks the tail pages (file space only).Tests (
MetadataChainTests)IPageStoragewrapper that runs the compaction on another thread right after the reader reads a given page.GetCollectionMetadata(both shapes) andGetAllCollectionsMetadata.InvalidOperationExceptionand leaves the chain and the stored record unchanged. With the check removed, the oversized record is accepted silently: its slot entry overlaps the record's first byte, and the old record is marked deleted.Timings (Release, file-backed, median µs)
GetNextSequence, 1-page chainGetNextSequence, 3-page chainGetCollectionMetadatahitGetCollectionMetadatamiss (now a second, locked walk)Full suite: 2402 / 0 / 4 skipped (branch not yet merged with
main).Note: a miss in
GetCollectionMetadata, and everyGetAllCollectionsMetadatacall, now wait for_metadataLockunder the sameWriteTimeoutMsas saves.🤖 Generated with Claude Code