Skip to content

Commit 11fdd9f

Browse files
fix: skip index maintenance when an update leaves the indexed value unchanged (#1297)
DocumentIndexWriter.updateIndexEntry treated an index as affected whenever the update document carried the indexed field, and then removed and rewrote the entry. An update that writes the whole document back, the common upsert shape, carries every indexed field with its old value, so every index was rewritten on every update for nothing. On the pre-4.4.0 list layout that was a copy of the whole per-key id list twice per index per write. The writer now compares the old and new values of each affected index, deeply so arrays and embedded values count as equal when their contents are, and skips the index when they match. A dirty index is not skipped: its rebuild still has to happen on the first write, so that path is unchanged. Tests cover the unchanged value, an array with equal contents, a changed value, and the dirty-index rebuild with an unchanged value. Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 5105718 commit 11fdd9f

2 files changed

Lines changed: 96 additions & 1 deletion

File tree

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,14 @@
2020
import org.dizitart.no2.collection.Document;
2121
import org.dizitart.no2.common.FieldValues;
2222
import org.dizitart.no2.common.Fields;
23+
import org.dizitart.no2.common.tuples.Pair;
2324
import org.dizitart.no2.common.util.DocumentUtils;
2425
import org.dizitart.no2.index.IndexDescriptor;
2526
import org.dizitart.no2.index.NitriteIndexer;
2627

2728
import java.util.Collection;
29+
import java.util.Objects;
30+
import java.util.List;
2831

2932
/**
3033
* @since 4.0
@@ -73,6 +76,15 @@ void updateIndexEntry(Document oldDocument, Document newDocument, Document updat
7376

7477
// if the index is affected by the update
7578
if (DocumentUtils.isAffectedByUpdate(fields, updatedFields)) {
79+
// "affected" only means the update carries the field. An update that
80+
// writes the whole document back, the common upsert shape, carries every
81+
// indexed field with its old value, and rewriting those entries is pure
82+
// cost. A dirty index still has to be rebuilt, so that case is not skipped.
83+
if (!indexOperations.shouldRebuildIndex(fields)
84+
&& sameIndexedValues(oldDocument, newDocument, fields)) {
85+
continue;
86+
}
87+
7688
String indexType = indexDescriptor.getIndexType();
7789
NitriteIndexer nitriteIndexer = nitriteConfig.findIndexer(indexType);
7890

@@ -83,6 +95,25 @@ void updateIndexEntry(Document oldDocument, Document newDocument, Document updat
8395
}
8496
}
8597

98+
/**
99+
* Whether the two documents hold the same values for every field of the index, compared
100+
* deeply so that arrays and embedded values count as equal when their contents are.
101+
*/
102+
private static boolean sameIndexedValues(Document oldDocument, Document newDocument, Fields fields) {
103+
List<Pair<String, Object>> before = DocumentUtils.getValues(oldDocument, fields).getValues();
104+
List<Pair<String, Object>> after = DocumentUtils.getValues(newDocument, fields).getValues();
105+
if (before.size() != after.size()) {
106+
return false;
107+
}
108+
for (int i = 0; i < before.size(); i++) {
109+
if (!Objects.equals(before.get(i).getFirst(), after.get(i).getFirst())
110+
|| !Objects.deepEquals(before.get(i).getSecond(), after.get(i).getSecond())) {
111+
return false;
112+
}
113+
}
114+
return true;
115+
}
116+
86117
private void writeIndexEntryInternal(IndexDescriptor indexDescriptor, Document document,
87118
NitriteIndexer nitriteIndexer) {
88119
if (indexDescriptor != null) {

‎nitrite/src/test/java/org/dizitart/no2/collection/operation/DocumentIndexWriterTest.java‎

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,15 @@
2121
import org.dizitart.no2.common.Fields;
2222
import org.dizitart.no2.index.IndexDescriptor;
2323
import org.dizitart.no2.index.IndexType;
24+
import org.dizitart.no2.index.NitriteIndexer;
2425
import org.dizitart.no2.index.UniqueIndexer;
2526
import org.dizitart.no2.store.memory.InMemoryStore;
2627
import org.junit.Test;
2728

2829
import java.util.ArrayList;
2930

3031
import static org.dizitart.no2.collection.Document.createDocument;
32+
import static org.mockito.ArgumentMatchers.any;
3133
import static org.mockito.Mockito.*;
3234

3335
public class DocumentIndexWriterTest {
@@ -72,5 +74,67 @@ public void testUpdateIndexEntry() {
7274
(new DocumentIndexWriter(nitriteConfig, indexOperations)).updateIndexEntry(createDocument("a", 1), createDocument("a", 2), createDocument("a", 3));
7375
verify(indexOperations).listIndexes();
7476
}
75-
}
7677

78+
@Test
79+
public void testUpdateSkipsIndexWhenIndexedValueIsUnchanged() {
80+
NitriteIndexer indexer = mock(NitriteIndexer.class);
81+
DocumentIndexWriter writer = writerWithIndexOn("a", indexer, false);
82+
83+
writer.updateIndexEntry(createDocument("a", 1).put("b", "old"),
84+
createDocument("a", 1).put("b", "new"),
85+
createDocument("a", 1).put("b", "new"));
86+
87+
verify(indexer, never()).removeIndexEntry(any(), any(), any());
88+
verify(indexer, never()).writeIndexEntry(any(), any(), any());
89+
}
90+
91+
@Test
92+
public void testUpdateSkipsIndexWhenArrayValueHasSameContents() {
93+
NitriteIndexer indexer = mock(NitriteIndexer.class);
94+
DocumentIndexWriter writer = writerWithIndexOn("a", indexer, false);
95+
96+
writer.updateIndexEntry(createDocument("a", new int[]{1, 2}),
97+
createDocument("a", new int[]{1, 2}),
98+
createDocument("a", new int[]{1, 2}));
99+
100+
verify(indexer, never()).removeIndexEntry(any(), any(), any());
101+
verify(indexer, never()).writeIndexEntry(any(), any(), any());
102+
}
103+
104+
@Test
105+
public void testUpdateRewritesIndexWhenIndexedValueChanges() {
106+
NitriteIndexer indexer = mock(NitriteIndexer.class);
107+
DocumentIndexWriter writer = writerWithIndexOn("a", indexer, false);
108+
109+
writer.updateIndexEntry(createDocument("a", 1), createDocument("a", 2), createDocument("a", 2));
110+
111+
verify(indexer).removeIndexEntry(any(), any(), any());
112+
verify(indexer).writeIndexEntry(any(), any(), any());
113+
}
114+
115+
@Test
116+
public void testUpdateStillRebuildsDirtyIndexWhenValueIsUnchanged() {
117+
NitriteIndexer indexer = mock(NitriteIndexer.class);
118+
IndexOperations indexOperations = mock(IndexOperations.class);
119+
IndexDescriptor descriptor = new IndexDescriptor(IndexType.NON_UNIQUE, Fields.withNames("a"), "c");
120+
when(indexOperations.listIndexes()).thenReturn(new ArrayList<>(java.util.List.of(descriptor)));
121+
when(indexOperations.shouldRebuildIndex(any())).thenReturn(true);
122+
NitriteConfig nitriteConfig = mock(NitriteConfig.class);
123+
doReturn(indexer).when(nitriteConfig).findIndexer(IndexType.NON_UNIQUE);
124+
125+
new DocumentIndexWriter(nitriteConfig, indexOperations)
126+
.updateIndexEntry(createDocument("a", 1), createDocument("a", 1), createDocument("a", 1));
127+
128+
verify(indexOperations, atLeastOnce()).buildIndex(descriptor, true);
129+
}
130+
131+
private static DocumentIndexWriter writerWithIndexOn(String field, NitriteIndexer indexer, boolean dirty) {
132+
IndexOperations indexOperations = mock(IndexOperations.class);
133+
IndexDescriptor descriptor = new IndexDescriptor(IndexType.NON_UNIQUE, Fields.withNames(field), "c");
134+
when(indexOperations.listIndexes()).thenReturn(new ArrayList<>(java.util.List.of(descriptor)));
135+
when(indexOperations.shouldRebuildIndex(any())).thenReturn(dirty);
136+
NitriteConfig nitriteConfig = mock(NitriteConfig.class);
137+
doReturn(indexer).when(nitriteConfig).findIndexer(IndexType.NON_UNIQUE);
138+
return new DocumentIndexWriter(nitriteConfig, indexOperations);
139+
}
140+
}

0 commit comments

Comments
 (0)