Skip to content

fix: copy the catalog's stored name set instead of mutating it in place - #1296

Merged
anidotnet merged 1 commit into
nitrite:mainfrom
brettwooldridge:fix/store-catalog-copy-on-write
Sep 4, 2026
Merged

fix: copy the catalog's stored name set instead of mutating it in place#1296
anidotnet merged 1 commit into
nitrite:mainfrom
brettwooldridge:fix/store-catalog-copy-on-write

Conversation

@brettwooldridge

@brettwooldridge brettwooldridge commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

This exception was seen in production (!)

StoreCatalog.writeCollectionEntry (and the repository variants) read the catalog document, wrapped it in MapMetaData, and added the new name to the Set instance that MapMetaData had taken straight out of the document. On MVStore that instance is the one held in the catalog page, and MVStore serializes dirty pages on a background thread that any write can start through tryCommit. Creating collections in a burst interleaved with writes, which is what a data migration does, put the serializer's HashSet.writeObject and the catalog's HashSet.add on the same set at the same time:

java.util.ConcurrentModificationException
at java.util.HashSet.writeObject
...
at org.h2.mvstore.type.ObjectDataType.serialize
at org.h2.mvstore.FileStore.serializeAndStore
org.h2.mvstore.MVStoreException: Could not serialize {mapNames=[...]}
at org.h2.mvstore.MVStore.panic

after which the store is closed and every later operation throws. Observed on the first start after a Xodus-to-Nitrite migration that created eleven collections in forty milliseconds.

MapMetaData now copies the stored set, so a write always puts a new set and the instance a serializer may be reading is never touched, the same rule WriteOperations.update already follows by cloning a document before merging into it.

The test writes two entries and asserts that the set stored after the first write is not the instance stored after the second and did not gain the second name.

Summary by CodeRabbit

  • Bug Fixes
    • Improved collection metadata handling to prevent stored catalog information from being unintentionally modified during updates or serialization.
    • Ensured existing collection names remain intact when new collection entries are written.

StoreCatalog.writeCollectionEntry (and the repository variants) read the
catalog document, wrapped it in MapMetaData, and added the new name to the
Set instance that MapMetaData had taken straight out of the document. On
MVStore that instance is the one held in the catalog page, and MVStore
serializes dirty pages on a background thread that any write can start
through tryCommit. Creating collections in a burst interleaved with writes,
which is what a data migration does, put the serializer's HashSet.writeObject
and the catalog's HashSet.add on the same set at the same time:

  java.util.ConcurrentModificationException
    at java.util.HashSet.writeObject
    ...
    at org.h2.mvstore.type.ObjectDataType.serialize
    at org.h2.mvstore.FileStore.serializeAndStore
  org.h2.mvstore.MVStoreException: Could not serialize {mapNames=[...]}
    at org.h2.mvstore.MVStore.panic

after which the store is closed and every later operation throws. Observed
on the first start after a Xodus-to-Nitrite migration that created eleven
collections in forty milliseconds.

MapMetaData now copies the stored set, so a write always puts a new set and
the instance a serializer may be reading is never touched, the same rule
WriteOperations.update already follows by cloning a document before
merging into it.

The test writes two entries and asserts that the set stored after the first
write is not the instance stored after the second and did not gain the
second name.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Team

Run ID: 9fb4434d-79c9-4f38-9944-a251d4fcdd93

📥 Commits

Reviewing files that changed from the base of the PR and between 38caf34 and 74954a2.

📒 Files selected for processing (2)
  • nitrite/src/main/java/org/dizitart/no2/store/MapMetaData.java
  • nitrite/src/test/java/org/dizitart/no2/store/StoreCatalogCopyOnWriteTest.java

Included review availability: Your plan provides up to 4 included reviews per hour; 1 remains after this review.


📝 Walkthrough

Walkthrough

populateInfo now defensively copies stored metadata sets. A new test verifies that writing another collection does not mutate the previously stored set and preserves both collection names.

Changes

Metadata Copy-on-Write

Layer / File(s) Summary
Defensive metadata copy and validation
nitrite/src/main/java/org/dizitart/no2/store/MapMetaData.java, nitrite/src/test/java/org/dizitart/no2/store/StoreCatalogCopyOnWriteTest.java
populateInfo copies the stored metadata set into a new HashSet. The test verifies distinct set instances, unchanged prior contents, and both collection names after the second write.

Estimated code review effort: 2 (Simple) | ~10 minutes

Merge Risk: ⚪ Minimal · up to 74954

Catalog metadata updates now use copy-on-write semantics, preventing writes from mutating a set that may be serialized concurrently. The regression coverage confirms collection names remain intact, with no current merge-blocking risk identified.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 2 functions across 2 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: copying the catalog's stored name set instead of mutating it in place.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@anidotnet
anidotnet merged commit c5bb7eb into nitrite:main Sep 4, 2026
13 of 14 checks passed
@anidotnet

Copy link
Copy Markdown
Contributor

Merged. For the record, the Ubuntu failure here was never this change: MigrationTest.cleanUp died with

MVStoreException: java.lang.AssertionError: chunk:5,block:0,len:0,... [2.4.240/3]
  at org.h2.mvstore.Chunk.accountForRemovedPage

which is the same 0/0 chunk-accounting family as the Double mark in #1301. Rebased over #1303 it passes: nitrite 1776 and nitrite-jackson-mapper 1634, both clean.

Thanks for this one — "copy, never adopt" is the rule the codebase already followed in WriteOperations.update, and the catalog was the place it hadn't been applied.

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