Skip to content

Commit 2c833d6

Browse files
fix: make copy-on-read complete, so no read hands out the stored instance (#1294)
A stored document on MVStore is the live object held in the page, and MVStore serializes pages on a background thread that any write can start through tryCommit. Whatever a read hands out therefore must not share mutable state with the stored instance, or a caller's in-place edit is written straight into the store, bypasses the indexes, and can race the serialization into a ConcurrentModificationException and a store panic. The cursor has cloned each document it yields since 4.x, but two gaps remained: - Document.clone() copied the top-level map and embedded documents only. A List, Set, Map, array, byte[], Date or Calendar inside the copy was still the instance in the store, so `found.get("tags").add(x)` reached the page. clone() is now a deep copy: containers and arrays are copied recursively, preserving the concrete collection class when it has a public no-arg constructor and the comparator of sorted sets and maps; Dates and Calendars are cloned; immutable values are shared; values of a type the copy does not know are shared as well, which the javadoc now states. - NitriteCollection.getById() returned the stored instance itself. It now hands out a clone, as find() does, and returns null for a missing id instead of passing null through the processor chain. The extra cost is a structural copy of each result, which is what find() already paid at the top level. No serialization round-trip is involved. Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 11fdd9f commit 2c833d6

4 files changed

Lines changed: 292 additions & 13 deletions

File tree

‎nitrite/src/main/java/org/dizitart/no2/collection/NitriteDocument.java‎

Lines changed: 110 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
import java.io.ObjectInputStream;
2828
import java.io.ObjectOutputStream;
2929
import java.io.Serializable;
30+
import java.lang.reflect.Array;
31+
import java.lang.reflect.Modifier;
3032
import java.text.MessageFormat;
3133
import java.util.*;
3234

@@ -155,23 +157,122 @@ public void remove(String field) {
155157
}
156158
}
157159

160+
/**
161+
* Returns a deep copy of this document.
162+
* <p>
163+
* Every value that can be modified in place is copied: embedded documents, collections,
164+
* maps, arrays (including {@code byte[]}), {@link Date}s and {@link Calendar}s, recursively
165+
* at any depth. Immutable values (strings, boxed primitives, enums, {@code java.time}
166+
* types and the like) are shared. A value of a type this method does not know how to copy
167+
* is shared as well, so store immutable values or copy them yourself.
168+
* <p>
169+
* This is what makes copy-on-read safe: the cursor hands out clones, and nothing reachable
170+
* from a clone is the instance kept by the store.
171+
*/
158172
@Override
159173
@SuppressWarnings("unchecked")
160174
public Document clone() {
161175
Map<String, Object> cloned = (Map<String, Object>) super.clone();
162-
163-
// create the clone of any embedded documents as well
164176
for (Map.Entry<String, Object> entry : cloned.entrySet()) {
165-
if (entry.getValue() instanceof Document) {
166-
Document value = (Document) entry.getValue();
177+
entry.setValue(deepCopy(entry.getValue()));
178+
}
179+
return new NitriteDocument(cloned);
180+
}
167181

168-
// this will recursively take care any embedded document
169-
// of the clone as well
170-
Document clonedValue = value.clone();
171-
cloned.put(entry.getKey(), clonedValue);
182+
private static Object deepCopy(Object value) {
183+
if (value == null) {
184+
return null;
185+
}
186+
if (value instanceof Document) {
187+
return ((Document) value).clone();
188+
}
189+
if (value instanceof Collection) {
190+
return deepCopyCollection((Collection<Object>) value);
191+
}
192+
if (value instanceof Map) {
193+
return deepCopyMap((Map<Object, Object>) value);
194+
}
195+
if (value.getClass().isArray()) {
196+
return deepCopyArray(value);
197+
}
198+
if (value instanceof Date) {
199+
return ((Date) value).clone();
200+
}
201+
if (value instanceof Calendar) {
202+
return ((Calendar) value).clone();
203+
}
204+
// immutable scalars, and opaque objects that cannot be copied generically
205+
return value;
206+
}
207+
208+
@SuppressWarnings("unchecked")
209+
private static Collection<Object> deepCopyCollection(Collection<Object> source) {
210+
Collection<Object> copy;
211+
if (source instanceof SortedSet) {
212+
// a fresh instance of the same class would lose the comparator
213+
copy = new TreeSet<>(((SortedSet<Object>) source).comparator());
214+
} else {
215+
copy = newInstanceOrNull(source);
216+
if (copy == null) {
217+
copy = source instanceof Set ? new LinkedHashSet<>() : new ArrayList<>(source.size());
172218
}
173219
}
174-
return new NitriteDocument(cloned);
220+
for (Object element : source) {
221+
copy.add(deepCopy(element));
222+
}
223+
return copy;
224+
}
225+
226+
@SuppressWarnings("unchecked")
227+
private static Map<Object, Object> deepCopyMap(Map<Object, Object> source) {
228+
Map<Object, Object> copy;
229+
if (source instanceof SortedMap) {
230+
copy = new TreeMap<>(((SortedMap<Object, Object>) source).comparator());
231+
} else {
232+
copy = newInstanceOrNull(source);
233+
if (copy == null) {
234+
copy = new LinkedHashMap<>();
235+
}
236+
}
237+
// keys are expected to be immutable; only the values are copied
238+
for (Map.Entry<Object, Object> entry : source.entrySet()) {
239+
copy.put(entry.getKey(), deepCopy(entry.getValue()));
240+
}
241+
return copy;
242+
}
243+
244+
private static Object deepCopyArray(Object source) {
245+
Class<?> componentType = source.getClass().getComponentType();
246+
int length = Array.getLength(source);
247+
Object copy = Array.newInstance(componentType, length);
248+
if (componentType.isPrimitive()) {
249+
System.arraycopy(source, 0, copy, 0, length);
250+
} else {
251+
Object[] from = (Object[]) source;
252+
Object[] to = (Object[]) copy;
253+
for (int i = 0; i < length; i++) {
254+
to[i] = deepCopy(from[i]);
255+
}
256+
}
257+
return copy;
258+
}
259+
260+
/**
261+
* A new, empty instance of the same class as {@code source} when it has a public no-arg
262+
* constructor (ArrayList, LinkedList, HashSet, ...), otherwise {@code null}. Unmodifiable
263+
* and JDK-internal collections fall through to the caller's default.
264+
*/
265+
@SuppressWarnings("unchecked")
266+
private static <T> T newInstanceOrNull(T source) {
267+
Class<?> type = source.getClass();
268+
if (!Modifier.isPublic(type.getModifiers())) {
269+
return null;
270+
}
271+
try {
272+
return (T) type.getConstructor().newInstance();
273+
} catch (ReflectiveOperationException | RuntimeException e) {
274+
return null;
275+
}
175276
}
176277

177278
@Override

‎nitrite/src/main/java/org/dizitart/no2/collection/operation/ReadOperations.java‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,15 @@ public DocumentCursor find(Filter filter, FindOptions findOptions) {
7676

7777
Document getById(NitriteId nitriteId) {
7878
Document document = nitriteMap.get(nitriteId);
79+
if (document == null) {
80+
return null;
81+
}
82+
// hand out a copy, as the cursor does: the caller must never reach the stored instance
83+
Document copy = document.clone();
7984
if (processorChain != null) {
80-
document = processorChain.processAfterRead(document);
85+
copy = processorChain.processAfterRead(copy);
8186
}
82-
return document;
87+
return copy;
8388
}
8489

8590
private void prepareFilter(Filter filter) {
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
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.collection;
18+
19+
import org.dizitart.no2.Nitrite;
20+
import org.junit.After;
21+
import org.junit.Before;
22+
import org.junit.Test;
23+
24+
import java.util.ArrayList;
25+
import java.util.Arrays;
26+
import java.util.List;
27+
28+
import static org.dizitart.no2.filters.FluentFilter.where;
29+
import static org.dizitart.no2.integration.TestUtil.createDb;
30+
import static org.junit.Assert.*;
31+
32+
/**
33+
* Whatever a read hands out must not be the instance the store keeps, at any depth.
34+
* Otherwise a caller's in-place edit is written straight into the store, bypasses the
35+
* indexes, and races the background serialization of the page it lives in.
36+
*/
37+
public class CopyOnReadTest {
38+
private Nitrite db;
39+
private NitriteCollection collection;
40+
private NitriteId id;
41+
42+
@Before
43+
public void setUp() {
44+
db = createDb();
45+
collection = db.getCollection("copy-on-read");
46+
Document document = Document.createDocument("name", "one")
47+
.put("tags", new ArrayList<>(Arrays.asList("a", "b")))
48+
.put("nested", Document.createDocument("list", new ArrayList<>(Arrays.asList(1, 2))));
49+
collection.insert(document);
50+
id = collection.find().firstOrNull().getId();
51+
}
52+
53+
@After
54+
public void tearDown() {
55+
db.close();
56+
}
57+
58+
@Test
59+
public void testCursorHandsOutIndependentCopies() {
60+
Document first = collection.find(where("name").eq("one")).firstOrNull();
61+
Document second = collection.find(where("name").eq("one")).firstOrNull();
62+
assertNotSame(first, second);
63+
assertNotSame(first.get("tags"), second.get("tags"));
64+
}
65+
66+
@Test
67+
public void testMutatingNestedContainerOfFoundDocumentDoesNotReachStore() {
68+
Document found = collection.find(where("name").eq("one")).firstOrNull();
69+
((List<Object>) found.get("tags")).add("c");
70+
((List<Object>) found.get("nested", Document.class).get("list")).clear();
71+
72+
Document stored = collection.find(where("name").eq("one")).firstOrNull();
73+
assertEquals(Arrays.asList("a", "b"), stored.get("tags"));
74+
assertEquals(Arrays.asList(1, 2), stored.get("nested", Document.class).get("list"));
75+
}
76+
77+
@Test
78+
public void testGetByIdHandsOutIndependentCopies() {
79+
Document first = collection.getById(id);
80+
Document second = collection.getById(id);
81+
assertNotSame(first, second);
82+
assertNotSame(first.get("tags"), second.get("tags"));
83+
}
84+
85+
@Test
86+
public void testMutatingGetByIdResultDoesNotReachStore() {
87+
Document found = collection.getById(id);
88+
found.put("name", "changed");
89+
((List<Object>) found.get("tags")).add("c");
90+
91+
assertEquals("one", collection.getById(id).get("name"));
92+
assertEquals(Arrays.asList("a", "b"), collection.getById(id).get("tags"));
93+
assertEquals(1, collection.find(where("name").eq("one")).size());
94+
assertEquals(0, collection.find(where("name").eq("changed")).size());
95+
}
96+
97+
@Test
98+
public void testGetByIdOfMissingDocumentIsNull() {
99+
assertNull(collection.getById(NitriteId.createId(-1L)));
100+
}
101+
102+
@Test
103+
public void testCopyThenUpdateIsTheSupportedWriteShape() {
104+
Document found = collection.getById(id);
105+
found.put("name", "two");
106+
collection.update(found);
107+
assertEquals("two", collection.getById(id).get("name"));
108+
assertEquals(1, collection.find(where("name").eq("two")).size());
109+
}
110+
}

‎nitrite/src/test/java/org/dizitart/no2/collection/NitriteDocumentTest.java‎

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@
66
import org.dizitart.no2.exceptions.ValidationException;
77
import org.junit.Test;
88

9-
import java.util.ArrayList;
10-
import java.util.Set;
9+
import java.util.*;
1110

1211
import static org.junit.Assert.*;
1312

@@ -146,6 +145,70 @@ public void testClone3() {
146145
assertEquals(1, nitriteDocument.clone().size());
147146
}
148147

148+
@Test
149+
public void testCloneCopiesNestedContainers() {
150+
List<Object> tags = new LinkedList<>(Arrays.asList("a", "b"));
151+
NitriteDocument embedded = new NitriteDocument();
152+
embedded.put("n", 1);
153+
List<Document> embeddedList = new ArrayList<>(Collections.singletonList(embedded));
154+
Map<String, Object> map = new HashMap<>();
155+
map.put("k", new ArrayList<>(Collections.singletonList("v")));
156+
byte[] bytes = {1, 2, 3};
157+
int[] ints = {1, 2, 3};
158+
Date date = new Date(1000L);
159+
SortedSet<String> sorted = new TreeSet<>(Comparator.reverseOrder());
160+
sorted.addAll(Arrays.asList("x", "y"));
161+
162+
NitriteDocument original = new NitriteDocument();
163+
original.put("tags", tags);
164+
original.put("docs", embeddedList);
165+
original.put("map", map);
166+
original.put("bytes", bytes);
167+
original.put("ints", ints);
168+
original.put("date", date);
169+
original.put("sorted", sorted);
170+
original.put("fixed", Collections.unmodifiableList(Arrays.asList("p", "q")));
171+
original.put("name", "immutable");
172+
173+
Document clone = original.clone();
174+
assertEquals(original, clone);
175+
176+
// nothing mutable is shared
177+
assertNotSame(tags, clone.get("tags"));
178+
assertNotSame(embeddedList, clone.get("docs"));
179+
assertNotSame(embedded, ((List<?>) clone.get("docs")).get(0));
180+
assertNotSame(map, clone.get("map"));
181+
assertNotSame(map.get("k"), ((Map<?, ?>) clone.get("map")).get("k"));
182+
assertNotSame(bytes, clone.get("bytes"));
183+
assertNotSame(ints, clone.get("ints"));
184+
assertNotSame(date, clone.get("date"));
185+
assertNotSame(sorted, clone.get("sorted"));
186+
assertSame("immutable values are shared", original.get("name"), clone.get("name"));
187+
188+
// mutating the clone leaves the original untouched
189+
((List<Object>) clone.get("tags")).add("c");
190+
((Document) ((List<?>) clone.get("docs")).get(0)).put("n", 2);
191+
((List<Object>) ((Map<?, ?>) clone.get("map")).get("k")).clear();
192+
((byte[]) clone.get("bytes"))[0] = 9;
193+
((int[]) clone.get("ints"))[0] = 9;
194+
((Date) clone.get("date")).setTime(2000L);
195+
((List<Object>) clone.get("fixed")).add("r");
196+
assertEquals(Arrays.asList("a", "b"), tags);
197+
assertEquals(1, embedded.get("n"));
198+
assertEquals(Collections.singletonList("v"), map.get("k"));
199+
assertEquals(1, bytes[0]);
200+
assertEquals(1, ints[0]);
201+
assertEquals(1000L, date.getTime());
202+
assertEquals(2, ((List<?>) original.get("fixed")).size());
203+
204+
// container types and comparators survive the copy
205+
assertTrue(clone.get("tags") instanceof LinkedList);
206+
assertTrue(clone.get("map") instanceof HashMap);
207+
SortedSet<?> sortedCopy = (SortedSet<?>) clone.get("sorted");
208+
assertEquals("y", sortedCopy.first());
209+
assertNotNull(sortedCopy.comparator());
210+
}
211+
149212
@Test
150213
public void testContainsKey() {
151214
assertFalse((new NitriteDocument()).containsKey("key"));

0 commit comments

Comments
 (0)