From 74954a2871d5f0a171924c8505b32df9af326f22 Mon Sep 17 00:00:00 2001 From: Brett Wooldridge Date: Fri, 4 Sep 2026 18:08:07 +0900 Subject: [PATCH] fix: copy the catalog's stored name set instead of mutating it in place 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 --- .../org/dizitart/no2/store/MapMetaData.java | 10 ++-- .../store/StoreCatalogCopyOnWriteTest.java | 57 +++++++++++++++++++ 2 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 nitrite/src/test/java/org/dizitart/no2/store/StoreCatalogCopyOnWriteTest.java diff --git a/nitrite/src/main/java/org/dizitart/no2/store/MapMetaData.java b/nitrite/src/main/java/org/dizitart/no2/store/MapMetaData.java index 3ee287239..c8e183e88 100644 --- a/nitrite/src/main/java/org/dizitart/no2/store/MapMetaData.java +++ b/nitrite/src/main/java/org/dizitart/no2/store/MapMetaData.java @@ -51,9 +51,11 @@ public Document getInfo() { @SuppressWarnings("unchecked") private void populateInfo(Document metadata) { - mapNames = (Set) metadata.get(TAG_MAP_METADATA, Set.class); - if (mapNames == null) { - mapNames = new HashSet<>(); - } + // copy, never adopt: the set handed back here is the instance stored in the catalog + // page, and MVStore serializes pages on a background thread that any write may start. + // Adding to it in place raced that thread (ConcurrentModificationException inside + // HashSet.writeObject, then a store panic) whenever collections were created in a burst. + Set stored = (Set) metadata.get(TAG_MAP_METADATA, Set.class); + mapNames = stored == null ? new HashSet<>() : new HashSet<>(stored); } } diff --git a/nitrite/src/test/java/org/dizitart/no2/store/StoreCatalogCopyOnWriteTest.java b/nitrite/src/test/java/org/dizitart/no2/store/StoreCatalogCopyOnWriteTest.java new file mode 100644 index 000000000..4fba0a339 --- /dev/null +++ b/nitrite/src/test/java/org/dizitart/no2/store/StoreCatalogCopyOnWriteTest.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2017-2020. Nitrite author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.dizitart.no2.store; + +import org.dizitart.no2.collection.Document; +import org.dizitart.no2.store.memory.InMemoryStore; +import org.junit.Test; + +import java.util.Set; + +import static org.dizitart.no2.common.Constants.COLLECTION_CATALOG; +import static org.dizitart.no2.common.Constants.TAG_COLLECTIONS; +import static org.dizitart.no2.common.Constants.TAG_MAP_METADATA; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertTrue; + +/** + * The catalog must replace the stored name set on every write, never mutate it: MVStore hands + * out the stored instance and may be serializing it on another thread at that moment. + */ +public class StoreCatalogCopyOnWriteTest { + @Test + @SuppressWarnings("unchecked") + public void testWritingAnEntryLeavesTheStoredSetUntouched() { + InMemoryStore store = new InMemoryStore(); + StoreCatalog catalog = new StoreCatalog(store); + catalog.writeCollectionEntry("first"); + + NitriteMap catalogMap = store.openMap(COLLECTION_CATALOG, String.class, Document.class); + Set storedBefore = (Set) catalogMap.get(TAG_COLLECTIONS).get(TAG_MAP_METADATA, Set.class); + assertEquals(Set.of("first"), storedBefore); + + catalog.writeCollectionEntry("second"); + + Set storedAfter = (Set) catalogMap.get(TAG_COLLECTIONS).get(TAG_MAP_METADATA, Set.class); + assertNotSame("the write must store a new set, not the one a serializer may be reading", storedBefore, storedAfter); + assertFalse("the previously stored set must not have been mutated", storedBefore.contains("second")); + assertEquals(Set.of("first", "second"), storedAfter); + assertTrue(catalog.getCollectionNames().containsAll(Set.of("first", "second"))); + } +}