Skip to content

Commit c5bb7eb

Browse files
fix: copy the catalog's stored name set instead of mutating it in place (#1296)
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>
1 parent 8ed3f64 commit c5bb7eb

2 files changed

Lines changed: 63 additions & 4 deletions

File tree

‎nitrite/src/main/java/org/dizitart/no2/store/MapMetaData.java‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,11 @@ public Document getInfo() {
5151

5252
@SuppressWarnings("unchecked")
5353
private void populateInfo(Document metadata) {
54-
mapNames = (Set<String>) metadata.get(TAG_MAP_METADATA, Set.class);
55-
if (mapNames == null) {
56-
mapNames = new HashSet<>();
57-
}
54+
// copy, never adopt: the set handed back here is the instance stored in the catalog
55+
// page, and MVStore serializes pages on a background thread that any write may start.
56+
// Adding to it in place raced that thread (ConcurrentModificationException inside
57+
// HashSet.writeObject, then a store panic) whenever collections were created in a burst.
58+
Set<String> stored = (Set<String>) metadata.get(TAG_MAP_METADATA, Set.class);
59+
mapNames = stored == null ? new HashSet<>() : new HashSet<>(stored);
5860
}
5961
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/*
2+
* Copyright (c) 2017-2020. Nitrite author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.dizitart.no2.store;
18+
19+
import org.dizitart.no2.collection.Document;
20+
import org.dizitart.no2.store.memory.InMemoryStore;
21+
import org.junit.Test;
22+
23+
import java.util.Set;
24+
25+
import static org.dizitart.no2.common.Constants.COLLECTION_CATALOG;
26+
import static org.dizitart.no2.common.Constants.TAG_COLLECTIONS;
27+
import static org.dizitart.no2.common.Constants.TAG_MAP_METADATA;
28+
import static org.junit.Assert.assertEquals;
29+
import static org.junit.Assert.assertFalse;
30+
import static org.junit.Assert.assertNotSame;
31+
import static org.junit.Assert.assertTrue;
32+
33+
/**
34+
* The catalog must replace the stored name set on every write, never mutate it: MVStore hands
35+
* out the stored instance and may be serializing it on another thread at that moment.
36+
*/
37+
public class StoreCatalogCopyOnWriteTest {
38+
@Test
39+
@SuppressWarnings("unchecked")
40+
public void testWritingAnEntryLeavesTheStoredSetUntouched() {
41+
InMemoryStore store = new InMemoryStore();
42+
StoreCatalog catalog = new StoreCatalog(store);
43+
catalog.writeCollectionEntry("first");
44+
45+
NitriteMap<String, Document> catalogMap = store.openMap(COLLECTION_CATALOG, String.class, Document.class);
46+
Set<String> storedBefore = (Set<String>) catalogMap.get(TAG_COLLECTIONS).get(TAG_MAP_METADATA, Set.class);
47+
assertEquals(Set.of("first"), storedBefore);
48+
49+
catalog.writeCollectionEntry("second");
50+
51+
Set<String> storedAfter = (Set<String>) catalogMap.get(TAG_COLLECTIONS).get(TAG_MAP_METADATA, Set.class);
52+
assertNotSame("the write must store a new set, not the one a serializer may be reading", storedBefore, storedAfter);
53+
assertFalse("the previously stored set must not have been mutated", storedBefore.contains("second"));
54+
assertEquals(Set.of("first", "second"), storedAfter);
55+
assertTrue(catalog.getCollectionNames().containsAll(Set.of("first", "second")));
56+
}
57+
}

0 commit comments

Comments
 (0)