-
-
Notifications
You must be signed in to change notification settings - Fork 96
fix: make copy-on-read complete, so no read hands out the stored instance #1294
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -27,6 +27,8 @@ | |
| import java.io.ObjectInputStream; | ||
| import java.io.ObjectOutputStream; | ||
| import java.io.Serializable; | ||
| import java.lang.reflect.Array; | ||
| import java.lang.reflect.Modifier; | ||
| import java.text.MessageFormat; | ||
| import java.util.*; | ||
|
|
||
|
|
@@ -155,23 +157,122 @@ public void remove(String field) { | |
| } | ||
| } | ||
|
|
||
| /** | ||
| * Returns a deep copy of this document. | ||
| * <p> | ||
| * Every value that can be modified in place is copied: embedded documents, collections, | ||
| * maps, arrays (including {@code byte[]}), {@link Date}s and {@link Calendar}s, recursively | ||
| * at any depth. Immutable values (strings, boxed primitives, enums, {@code java.time} | ||
| * types and the like) are shared. A value of a type this method does not know how to copy | ||
| * is shared as well, so store immutable values or copy them yourself. | ||
| * <p> | ||
| * This is what makes copy-on-read safe: the cursor hands out clones, and nothing reachable | ||
| * from a clone is the instance kept by the store. | ||
| */ | ||
| @Override | ||
| @SuppressWarnings("unchecked") | ||
| public Document clone() { | ||
| Map<String, Object> cloned = (Map<String, Object>) super.clone(); | ||
|
|
||
| // create the clone of any embedded documents as well | ||
| for (Map.Entry<String, Object> entry : cloned.entrySet()) { | ||
| if (entry.getValue() instanceof Document) { | ||
| Document value = (Document) entry.getValue(); | ||
| entry.setValue(deepCopy(entry.getValue())); | ||
| } | ||
| return new NitriteDocument(cloned); | ||
| } | ||
|
|
||
| // this will recursively take care any embedded document | ||
| // of the clone as well | ||
| Document clonedValue = value.clone(); | ||
| cloned.put(entry.getKey(), clonedValue); | ||
| private static Object deepCopy(Object value) { | ||
| if (value == null) { | ||
| return null; | ||
| } | ||
| if (value instanceof Document) { | ||
| return ((Document) value).clone(); | ||
| } | ||
| if (value instanceof Collection) { | ||
| return deepCopyCollection((Collection<Object>) value); | ||
| } | ||
| if (value instanceof Map) { | ||
| return deepCopyMap((Map<Object, Object>) value); | ||
| } | ||
| if (value.getClass().isArray()) { | ||
| return deepCopyArray(value); | ||
| } | ||
| if (value instanceof Date) { | ||
| return ((Date) value).clone(); | ||
| } | ||
| if (value instanceof Calendar) { | ||
| return ((Calendar) value).clone(); | ||
| } | ||
| // immutable scalars, and opaque objects that cannot be copied generically | ||
| return value; | ||
| } | ||
|
|
||
| @SuppressWarnings("unchecked") | ||
| private static Collection<Object> deepCopyCollection(Collection<Object> source) { | ||
| Collection<Object> copy; | ||
| if (source instanceof SortedSet) { | ||
| // a fresh instance of the same class would lose the comparator | ||
| copy = new TreeSet<>(((SortedSet<Object>) source).comparator()); | ||
| } else { | ||
| copy = newInstanceOrNull(source); | ||
| if (copy == null) { | ||
| copy = source instanceof Set ? new LinkedHashSet<>() : new ArrayList<>(source.size()); | ||
| } | ||
| } | ||
| return new NitriteDocument(cloned); | ||
| for (Object element : source) { | ||
| copy.add(deepCopy(element)); | ||
| } | ||
| return copy; | ||
| } | ||
|
|
||
| @SuppressWarnings("unchecked") | ||
| private static Map<Object, Object> deepCopyMap(Map<Object, Object> source) { | ||
| Map<Object, Object> copy; | ||
| if (source instanceof SortedMap) { | ||
| copy = new TreeMap<>(((SortedMap<Object, Object>) source).comparator()); | ||
| } else { | ||
| copy = newInstanceOrNull(source); | ||
| if (copy == null) { | ||
| copy = new LinkedHashMap<>(); | ||
| } | ||
| } | ||
| // keys are expected to be immutable; only the values are copied | ||
| for (Map.Entry<Object, Object> entry : source.entrySet()) { | ||
| copy.put(entry.getKey(), deepCopy(entry.getValue())); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- NitriteDocument structure ---'
ast-grep outline nitrite/src/main/java/org/dizitart/no2/collection/NitriteDocument.java
printf '%s\n' '--- deep-copy implementation ---'
sed -n '150,275p' nitrite/src/main/java/org/dizitart/no2/collection/NitriteDocument.java
printf '%s\n' '--- deepCopy references ---'
rg -n -C 3 'deepCopy|clone\\(' nitrite/src/main/java/org/dizitart/no2/collection/NitriteDocument.javaRepository: nitrite/nitrite-java Length of output: 5770 🤖 get_repo_knowledge executed:
Length of output: 944 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- map/document value contracts ---'
rg -n -C 3 'Map|Collection|Serializable|serializ|clone\\(' nitrite/src/main/java nitrite/src/test 2>/dev/null | head -n 260
printf '%s\n' '--- NitriteDocument remaining copy and serialization code ---'
sed -n '275,390p' nitrite/src/main/java/org/dizitart/no2/collection/NitriteDocument.java
printf '%s\n' '--- Document and store copy callers ---'
rg -n -C 4 'Document.*clone|\\.clone\\(\\)|NitriteDocument' nitrite/src/main/java | head -n 220Repository: nitrite/nitrite-java Length of output: 28292 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- mapper and map serialization bindings ---'
rg -n -C 4 'Map.class|instanceof Map|Map<|Collection.class|instanceof Collection|Serializable|serialize|deserialize' nitrite/src/main/java | head -n 320
printf '%s\n' '--- tests and examples for map keys ---'
rg -n -C 4 'HashMap|TreeMap|Map<|ArrayList|mutable key|map key' --glob '*.java' --glob '*.md' . | head -n 260Repository: nitrite/nitrite-java Length of output: 50376 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- serializeFields definition and callers ---'
rg -n -C 8 'void serializeFields|serializeFields\\(' nitrite/src/main/java
printf '%s\n' '--- mapper implementations and map branches ---'
rg -n -C 6 'class .*Mapper|interface NitriteMapper|Map\\.class|value instanceof Map|instanceof Map' --glob '*.java' . | head -n 240
printf '%s\n' '--- relevant document tests ---'
fd -i '.*Document.*Test.*\\.java$' . | head -n 40Repository: nitrite/nitrite-java Length of output: 304 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- serializeFields ---'
rg -n -F -C 8 'serializeFields' nitrite/src/main/java
printf '%s\n' '--- map branches ---'
rg -n -F -C 6 'instanceof Map' --glob '*.java' nitrite/src/main/java
printf '%s\n' '--- document test files ---'
fd -i 'Document.*Test.*\.java$' . | head -n 40Repository: nitrite/nitrite-java Length of output: 10998 Deep-copy mutable map keys with the same copy context as values. If a document contains a map with a mutable key such as 🤖 Prompt for AI Agents |
||
| } | ||
| return copy; | ||
| } | ||
|
|
||
| private static Object deepCopyArray(Object source) { | ||
| Class<?> componentType = source.getClass().getComponentType(); | ||
| int length = Array.getLength(source); | ||
| Object copy = Array.newInstance(componentType, length); | ||
| if (componentType.isPrimitive()) { | ||
| System.arraycopy(source, 0, copy, 0, length); | ||
| } else { | ||
| Object[] from = (Object[]) source; | ||
| Object[] to = (Object[]) copy; | ||
| for (int i = 0; i < length; i++) { | ||
| to[i] = deepCopy(from[i]); | ||
| } | ||
| } | ||
| return copy; | ||
| } | ||
|
|
||
| /** | ||
| * A new, empty instance of the same class as {@code source} when it has a public no-arg | ||
| * constructor (ArrayList, LinkedList, HashSet, ...), otherwise {@code null}. Unmodifiable | ||
| * and JDK-internal collections fall through to the caller's default. | ||
| */ | ||
| @SuppressWarnings("unchecked") | ||
| private static <T> T newInstanceOrNull(T source) { | ||
| Class<?> type = source.getClass(); | ||
| if (!Modifier.isPublic(type.getModifiers())) { | ||
| return null; | ||
| } | ||
| try { | ||
| return (T) type.getConstructor().newInstance(); | ||
| } catch (ReflectiveOperationException | RuntimeException e) { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| @Override | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| /* | ||
| * 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.collection; | ||
|
|
||
| import org.dizitart.no2.Nitrite; | ||
| import org.junit.After; | ||
| import org.junit.Before; | ||
| import org.junit.Test; | ||
|
|
||
| import java.util.ArrayList; | ||
| import java.util.Arrays; | ||
| import java.util.List; | ||
|
|
||
| import static org.dizitart.no2.filters.FluentFilter.where; | ||
| import static org.dizitart.no2.integration.TestUtil.createDb; | ||
| import static org.junit.Assert.*; | ||
|
|
||
| /** | ||
| * Whatever a read hands out must not be the instance the store keeps, at any depth. | ||
| * Otherwise a caller's in-place edit is written straight into the store, bypasses the | ||
| * indexes, and races the background serialization of the page it lives in. | ||
| */ | ||
| public class CopyOnReadTest { | ||
| private Nitrite db; | ||
| private NitriteCollection collection; | ||
| private NitriteId id; | ||
|
|
||
| @Before | ||
| public void setUp() { | ||
| db = createDb(); | ||
| collection = db.getCollection("copy-on-read"); | ||
| Document document = Document.createDocument("name", "one") | ||
| .put("tags", new ArrayList<>(Arrays.asList("a", "b"))) | ||
| .put("nested", Document.createDocument("list", new ArrayList<>(Arrays.asList(1, 2)))); | ||
| collection.insert(document); | ||
| id = collection.find().firstOrNull().getId(); | ||
| } | ||
|
|
||
| @After | ||
| public void tearDown() { | ||
| db.close(); | ||
| } | ||
|
|
||
| @Test | ||
| public void testCursorHandsOutIndependentCopies() { | ||
| Document first = collection.find(where("name").eq("one")).firstOrNull(); | ||
| Document second = collection.find(where("name").eq("one")).firstOrNull(); | ||
| assertNotSame(first, second); | ||
| assertNotSame(first.get("tags"), second.get("tags")); | ||
| } | ||
|
|
||
| @Test | ||
| public void testMutatingNestedContainerOfFoundDocumentDoesNotReachStore() { | ||
| Document found = collection.find(where("name").eq("one")).firstOrNull(); | ||
| ((List<Object>) found.get("tags")).add("c"); | ||
| ((List<Object>) found.get("nested", Document.class).get("list")).clear(); | ||
|
|
||
| Document stored = collection.find(where("name").eq("one")).firstOrNull(); | ||
| assertEquals(Arrays.asList("a", "b"), stored.get("tags")); | ||
| assertEquals(Arrays.asList(1, 2), stored.get("nested", Document.class).get("list")); | ||
| } | ||
|
|
||
| @Test | ||
| public void testGetByIdHandsOutIndependentCopies() { | ||
| Document first = collection.getById(id); | ||
| Document second = collection.getById(id); | ||
| assertNotSame(first, second); | ||
| assertNotSame(first.get("tags"), second.get("tags")); | ||
| } | ||
|
|
||
| @Test | ||
| public void testMutatingGetByIdResultDoesNotReachStore() { | ||
| Document found = collection.getById(id); | ||
| found.put("name", "changed"); | ||
| ((List<Object>) found.get("tags")).add("c"); | ||
|
|
||
| assertEquals("one", collection.getById(id).get("name")); | ||
| assertEquals(Arrays.asList("a", "b"), collection.getById(id).get("tags")); | ||
| assertEquals(1, collection.find(where("name").eq("one")).size()); | ||
| assertEquals(0, collection.find(where("name").eq("changed")).size()); | ||
| } | ||
|
|
||
| @Test | ||
| public void testGetByIdOfMissingDocumentIsNull() { | ||
| assertNull(collection.getById(NitriteId.createId(-1L))); | ||
| } | ||
|
|
||
| @Test | ||
| public void testCopyThenUpdateIsTheSupportedWriteShape() { | ||
| Document found = collection.getById(id); | ||
| found.put("name", "two"); | ||
| collection.update(found); | ||
| assertEquals("two", collection.getById(id).get("name")); | ||
| assertEquals(1, collection.find(where("name").eq("two")).size()); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift
🔎 Supported by static analysis
🤖 get_repo_knowledge executed:
get_repo_knowledge nitrite/nitrite-java /tmp/coderabbit-repo-knowledge/nitrite-nitrite-java-ee794b6fLength of output: 976
🏁 Script executed:
Repository: nitrite/nitrite-java
Length of output: 32172
🤖 get_repo_knowledge executed:
get_repo_knowledge nitrite/nitrite-java /tmp/coderabbit-repo-knowledge/nitrite-nitrite-java-ee794b6f/conventionsLength of output: 1763
🏁 Script executed:
Repository: nitrite/nitrite-java
Length of output: 7210
🏁 Script executed:
Repository: nitrite/nitrite-java
Length of output: 6693
Track objects during recursive cloning. When a serializable
NitriteDocument,Collection,Map, or reference array contains itself,clone()dispatches back todeepCopywithout cycle detection and can throwStackOverflowError. Use oneIdentityHashMapper top-level clone, register each destination before copying children, and add regression tests for cyclic values.🤖 Prompt for AI Agents