Skip to content

Commit 3411086

Browse files
anidotnetclaude
andcommitted
fix: restore CopyOnWriteArrayList for unique and text index value lists
The #1260 index rework (4.4.0) switched the per-key `List<NitriteId>` value used by unique and full-text indexes from CopyOnWriteArrayList to a plain ArrayList mutated in place. MVStore serializes dirty page values on a background thread, so mutating that list after it is written to the map races with the serializer, throwing ConcurrentModificationException (and could corrupt the id list, later surfacing as a spurious UniqueConstraintException) even under single-threaded use. Restore CopyOnWriteArrayList for these classic list-valued index paths so each mutation swaps the backing array atomically and the background serializer always sees a stable snapshot. The composite-key layout for non-unique indexes (the actual #1260 optimization) is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 5c90141 commit 3411086

3 files changed

Lines changed: 21 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@
66
- Opening a file-based store runs `MVStoreUtils.testForMigration()`, which for a legacy v1-format file deserialized stored values through `ObjectInputStream.readObject()` with no class restriction. Any `Serializable` class on the embedding application's classpath could be instantiated, so a suitable gadget chain (e.g. from commons-collections) made a malicious `.db` file a remote-code-execution vector.
77
- The v1-compat deserializer now enforces a JEP 290 allowlist filter that only permits Nitrite's own types and standard JDK types; any other class is rejected before its `readObject`/`readResolve` callbacks can run. Applications that open Nitrite database files from untrusted sources (e.g. "import"/"restore backup" features) should upgrade.
88

9+
### Issue Fixes
10+
11+
- Fix intermittent `ConcurrentModificationException` and spurious `UniqueConstraintException` from unique and full-text indexes on the MVStore backend (regression introduced by the #1260 index rework in 4.4.0)
12+
- Unique and full-text indexes still store a `List<NitriteId>` value per key. 4.4.0 switched that list from `CopyOnWriteArrayList` to a plain `ArrayList`, which is mutated in place after being written to the map. MVStore serializes dirty page values on a background thread, so that in-place mutation races with the serializer and threw `ConcurrentModificationException` (and could corrupt the id list, surfacing later as a false unique-key violation) — even under single-threaded use.
13+
- These index value lists are `CopyOnWriteArrayList` again, so each mutation swaps the backing array atomically and the background serializer always sees a stable snapshot. The composite-key layout for non-unique indexes (the actual #1260 optimization) is unchanged.
14+
915
## Release 4.4.0 - Jul 2, 2026
1016

1117
### Upgrade Notes

nitrite/src/main/java/org/dizitart/no2/index/NitriteIndex.java

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@
2323
import org.dizitart.no2.exceptions.UniqueConstraintException;
2424
import org.dizitart.no2.exceptions.ValidationException;
2525

26-
import java.util.ArrayList;
2726
import java.util.LinkedHashSet;
2827
import java.util.List;
28+
import java.util.concurrent.CopyOnWriteArrayList;
2929

3030
import static org.dizitart.no2.common.util.ValidationUtils.validateArrayIndexField;
3131
import static org.dizitart.no2.common.util.ValidationUtils.validateIterableIndexField;
@@ -109,11 +109,15 @@ default void validateIndexField(Object value, String field) {
109109
*/
110110
default List<NitriteId> addNitriteIds(List<NitriteId> nitriteIds, FieldValues fieldValues) {
111111
if (nitriteIds == null) {
112-
// a plain ArrayList gives amortized O(1) appends; index reads and
113-
// writes are guarded by the collection's read-write lock, so the
114-
// copy-on-write semantics previously used here were unnecessary and
115-
// made every insert O(n) for keys with many values (issue #1260)
116-
nitriteIds = new ArrayList<>();
112+
// this list is stored as a value in the backing NitriteMap. MVStore serializes
113+
// dirty page values on a background thread, so a plain ArrayList mutated in place
114+
// after being put races with that serialization and throws
115+
// ConcurrentModificationException. CopyOnWriteArrayList swaps its backing array
116+
// atomically on each mutation, so the background serializer always sees a stable
117+
// snapshot. Non-unique indexes avoid list values entirely via the composite layout
118+
// (issue #1260); only unique indexes and the text index reach this path, where the
119+
// per-key list is small enough that copy-on-write cost is negligible.
120+
nitriteIds = new CopyOnWriteArrayList<>();
117121
}
118122

119123
if (isUnique() && nitriteIds.size() == 1) {

nitrite/src/main/java/org/dizitart/no2/index/TextIndex.java

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,11 @@
3030
import org.dizitart.no2.store.NitriteMap;
3131
import org.dizitart.no2.store.NitriteStore;
3232

33-
import java.util.ArrayList;
3433
import java.util.HashSet;
3534
import java.util.LinkedHashSet;
3635
import java.util.List;
3736
import java.util.Set;
37+
import java.util.concurrent.CopyOnWriteArrayList;
3838

3939
import static org.dizitart.no2.common.util.IndexUtils.deriveIndexMapName;
4040
import static org.dizitart.no2.common.util.ObjectUtils.convertToObjectArray;
@@ -157,7 +157,7 @@ public LinkedHashSet<NitriteId> findNitriteIds(FindPlan findPlan) {
157157

158158
private NitriteMap<String, List<?>> findIndexMap() {
159159
String mapName = deriveIndexMapName(indexDescriptor);
160-
return nitriteStore.openMap(mapName, String.class, ArrayList.class);
160+
return nitriteStore.openMap(mapName, String.class, CopyOnWriteArrayList.class);
161161
}
162162

163163
@SuppressWarnings("unchecked")
@@ -168,7 +168,9 @@ private void addIndexElement(NitriteMap<String, List<?>> indexMap, FieldValues f
168168
List<NitriteId> nitriteIds = (List<NitriteId>) indexMap.get(word);
169169

170170
if (nitriteIds == null) {
171-
nitriteIds = new ArrayList<>();
171+
// CopyOnWriteArrayList so in-place mutation is safe against MVStore's
172+
// background page serializer (see NitriteIndex.addNitriteIds)
173+
nitriteIds = new CopyOnWriteArrayList<>();
172174
}
173175

174176
nitriteIds = addNitriteIds(nitriteIds, fieldValues);

0 commit comments

Comments
 (0)