Skip to content

fix(storage): don't let lock-free metadata readers miss records a compaction moves (follow-up to #196) - #215

Merged
mrdevrobot merged 4 commits into
EntglDb:mainfrom
CaffeinatedCoder:fix/196-unlocked-metadata-readers
Oct 15, 2026
Merged

mrdevrobot merged 4 commits into
EntglDb:mainfrom
CaffeinatedCoder:fix/196-unlocked-metadata-readers

Conversation

@CaffeinatedCoder

Copy link
Copy Markdown
Collaborator

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

  • GetCollectionMetadata keeps 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 the DynamicCollection / CollectionIndexManager constructors saved fresh metadata over B's record.
  • GetAllCollectionsMetadata now 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.
  • For longer chains it plans the packing from slot lengths and copies nothing unless it actually rewrites. The rewrite appends straight from the loaded page buffers.
  • Documented that a crash between the last compaction write and the FreePage loop leaks the tail pages (file space only).

Tests (MetadataChainTests)

  • The race, made deterministic with an IPageStorage wrapper that runs the compaction on another thread right after the reader reads a given page.
  • Up-front size check: a record of exactly the page maximum is stored, and one byte more throws InvalidOperationException and 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)

#196 this PR
GetNextSequence, 1-page chain 66 60–72
GetNextSequence, 3-page chain 95 75–82
GetCollectionMetadata hit 1.3 1.2–1.4
GetCollectionMetadata miss (now a second, locked walk) 1.3 2.6

Full suite: 2402 / 0 / 4 skipped (branch not yet merged with main).

Note: a miss in GetCollectionMetadata, and every GetAllCollectionsMetadata call, now wait for _metadataLock under the same WriteTimeoutMs as saves.

🤖 Generated with Claude Code

CaffeinatedCoder and others added 2 commits October 12, 2026 20:33
…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>
@CaffeinatedCoder

Copy link
Copy Markdown
Collaborator Author

Updated to main (merge 0a42bef; #196 and #194 are in), so the diff now shows only the follow-up: StorageEngine.Collections.cs and MetadataChainTests.cs.

  • feat(storage): per-collection required features, refused by versions that lack them (#187) #194's guard placement and read-path checks are kept. The feature checks run on the metadata the readers return, and the locked re-walk uses the same parser, so they apply there too.
  • One merge-specific change: DeleteCollectionMetadata (feat(storage): per-collection required features, refused by versions that lack them (#187) #194's guard) looked the record up through GetCollectionMetadata while holding _metadataLock. With the locked re-walk, deleting a missing collection would have waited on that non-reentrant lock until it timed out. It now uses FindCollectionMetadata. New test DeleteOfAMissingCollection_DoesNotWaitForTheLockItHolds fails with the old call.
  • On the merged code, the race tests still fail with the reader fix reverted. The full suite passes except for timing-sensitive concurrency tests under parallel load. One of them, HighContention_ManySmallTransactions_ConsistentFinalState, fails the same way on main.

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 mrdevrobot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@mrdevrobot
mrdevrobot merged commit cf1dc57 into EntglDb:main Oct 15, 2026
2 checks passed

This branch was successfully deployed

1 active deployment
Production — 932a35d9 Deployed Oct 15, 2026 by mrdevrobot via build_and_publish #699
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants