This is an automated email from the ASF dual-hosted git repository.
thomasmueller pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/jackrabbit-oak.git
The following commit(s) were added to refs/heads/trunk by this push:
new 6cfac19789 OAK-12244: index nodes that gain a mixin rule, delete stale
docs when… (#2949)
6cfac19789 is described below
commit 6cfac1978996dd149356cdcc522e9ab6fcac7a76
Author: Thomas Mueller <[email protected]>
AuthorDate: Tue Jul 7 15:32:44 2026 +0200
OAK-12244: index nodes that gain a mixin rule, delete stale docs when…
(#2949)
* OAK-12244: index nodes that gain a mixin rule, delete stale docs when
mixin rule is lost (#2938)
When an existing node's applicable indexing rule changes at runtime (e.g.
jcr:mixinTypes
added or removed), FulltextIndexEditor did not update the index because
propertiesChanged
was never set — jcr:mixinTypes is not normally listed in a rule's property
definitions.
Track wasIndexable (rule matched before) alongside isIndexable() (rule
matches after).
In leave(), act on transitions:
- !wasIndexable && isIndexable(): node gained a rule → addOrUpdate
- wasIndexable && !isIndexable(): node lost a rule → deleteDocuments
Tests added:
- PropertyIndexCommonTest: two end-to-end integration tests (all backends)
- LuceneIndexEditor2Test: two unit tests verifying writer.docs /
writer.deletedPaths
* OAK-12244: fix mixin type changes not reflected in fulltext index (#2953)
Root cause: when a node gains or loses a mixin type at runtime,
FulltextIndexEditor did not update the index because propertiesChanged
was never set — jcr:mixinTypes is not normally listed in a rule's
property definitions.
Fix: track wasIndexable (rule matched before) alongside isIndexable()
(rule matches after). In leave(), act on the indexing-rule transition:
- !wasIndexable && isIndexable(): node gained a rule → addOrUpdate
- wasIndexable && !isIndexable(): node lost a rule → deleteDocument
Split FulltextIndexWriter into two explicit operations:
- deleteDocumentTree(path): node physically removed; cascade is correct
- deleteDocument(path): node lost indexability at runtime; exact only
The original deleteDocuments used a PrefixQuery that cascaded to all
descendants; in the mixin-loss branch this was a bug — children carrying
their own mixin types were incorrectly evicted from the index.
Additional changes:
- Snapshot FT_OAK_12244_DISABLE once per commit cycle in
FulltextIndexEditorContext
as typeChangeTrackingEnabled so enter() and leave() always agree
- Skip getApplicableIndexingRule(before) on the hot path via
hasNodeTypeChange
guard when neither jcr:primaryType nor jcr:mixinTypes changed
- Register FT_OAK_12244 toggle in ElasticIndexProviderService
- Reuse CommitFailedException code 5 for the deleteDocument error path
Tests:
- PropertyIndexCommonTest: end-to-end integration tests (all backends)
- LuceneIndexEditor2Test: unit tests verifying writer.docs /
writer.deletedPaths
- Verified: 1245 tests, 0 failures in oak-lucene
---------
Co-authored-by: Benjamin Habegger <[email protected]>
---
.../index/lucene/LuceneIndexProviderService.java | 3 +
.../plugins/index/lucene/hybrid/DocumentQueue.java | 2 +-
.../lucene/hybrid/LocalIndexWriterFactory.java | 8 +-
.../oak/plugins/index/lucene/hybrid/NRTIndex.java | 7 +-
.../index/lucene/writer/DefaultIndexWriter.java | 7 +-
.../index/lucene/writer/IndexWriterPool.java | 30 ++++-
.../lucene/writer/MultiplexingIndexWriter.java | 11 +-
.../lucene/writer/PooledLuceneIndexWriter.java | 10 +-
.../index/lucene/LuceneIndexEditor2Test.java | 134 ++++++++++++++++++++-
.../index/lucene/writer/IndexWriterPoolTest.java | 12 +-
.../lucene/writer/MultiplexingIndexWriterTest.java | 6 +-
.../index/elastic/ElasticIndexProviderService.java | 5 +
.../index/elastic/index/ElasticIndexWriter.java | 8 +-
.../elastic/index/ElasticIndexWriterTest.java | 8 +-
.../search/spi/editor/FulltextIndexEditor.java | 89 ++++++++++++--
.../spi/editor/FulltextIndexEditorContext.java | 7 ++
.../search/spi/editor/FulltextIndexWriter.java | 19 ++-
.../oak/plugins/index/PropertyIndexCommonTest.java | 76 ++++++++++++
18 files changed, 403 insertions(+), 39 deletions(-)
diff --git
a/oak-lucene/src/main/java/org/apache/jackrabbit/oak/plugins/index/lucene/LuceneIndexProviderService.java
b/oak-lucene/src/main/java/org/apache/jackrabbit/oak/plugins/index/lucene/LuceneIndexProviderService.java
index ec2c5990d4..12144f4342 100644
---
a/oak-lucene/src/main/java/org/apache/jackrabbit/oak/plugins/index/lucene/LuceneIndexProviderService.java
+++
b/oak-lucene/src/main/java/org/apache/jackrabbit/oak/plugins/index/lucene/LuceneIndexProviderService.java
@@ -384,6 +384,9 @@ public class LuceneIndexProviderService {
oakRegs.add(whiteboard.register(FeatureToggle.class,
new FeatureToggle(FulltextIndexEditor.FT_OAK_12193,
FulltextIndexEditor.FT_OAK_12193_DISABLE),
emptyMap()));
+ oakRegs.add(whiteboard.register(FeatureToggle.class,
+ new FeatureToggle(FulltextIndexEditor.FT_OAK_12244,
FulltextIndexEditor.FT_OAK_12244_DISABLE),
+ emptyMap()));
initializeIndexDir(bundleContext, config);
initializeExtractedTextCache(bundleContext, config,
statisticsProvider);
tracker = createTracker(bundleContext, config);
diff --git
a/oak-lucene/src/main/java/org/apache/jackrabbit/oak/plugins/index/lucene/hybrid/DocumentQueue.java
b/oak-lucene/src/main/java/org/apache/jackrabbit/oak/plugins/index/lucene/hybrid/DocumentQueue.java
index 60e63d4e0a..2bb7f6e5f1 100644
---
a/oak-lucene/src/main/java/org/apache/jackrabbit/oak/plugins/index/lucene/hybrid/DocumentQueue.java
+++
b/oak-lucene/src/main/java/org/apache/jackrabbit/oak/plugins/index/lucene/hybrid/DocumentQueue.java
@@ -266,7 +266,7 @@ public class DocumentQueue implements Closeable,
IndexingQueue {
doc.markProcessed();
}
if (doc.delete) {
- writer.deleteDocuments(doc.docPath);
+ writer.deleteDocumentTree(doc.docPath);
} else {
writer.updateDocument(doc.docPath, doc.doc);
}
diff --git
a/oak-lucene/src/main/java/org/apache/jackrabbit/oak/plugins/index/lucene/hybrid/LocalIndexWriterFactory.java
b/oak-lucene/src/main/java/org/apache/jackrabbit/oak/plugins/index/lucene/hybrid/LocalIndexWriterFactory.java
index e98e78ef8c..5f0a7ccc6e 100644
---
a/oak-lucene/src/main/java/org/apache/jackrabbit/oak/plugins/index/lucene/hybrid/LocalIndexWriterFactory.java
+++
b/oak-lucene/src/main/java/org/apache/jackrabbit/oak/plugins/index/lucene/hybrid/LocalIndexWriterFactory.java
@@ -56,12 +56,18 @@ public class LocalIndexWriterFactory implements
FulltextIndexWriterFactory<Itera
}
@Override
- public void deleteDocuments(String path) throws IOException {
+ public void deleteDocumentTree(String path) throws IOException {
//Hybrid index logic drops the deletes. So no use to
//add them to the list
//addLuceneDoc(LuceneDoc.forDelete(definition.getIndexPathFromConfig(), path));
}
+ @Override
+ public void deleteDocument(String path) throws IOException {
+ //Hybrid index logic drops the deletes. So no use to
+ //add them to the list
+ }
+
@Override
public boolean close(long timestamp) throws IOException {
documentHolder.done(indexPath);
diff --git
a/oak-lucene/src/main/java/org/apache/jackrabbit/oak/plugins/index/lucene/hybrid/NRTIndex.java
b/oak-lucene/src/main/java/org/apache/jackrabbit/oak/plugins/index/lucene/hybrid/NRTIndex.java
index c70755d4b6..5c9ae9f145 100644
---
a/oak-lucene/src/main/java/org/apache/jackrabbit/oak/plugins/index/lucene/hybrid/NRTIndex.java
+++
b/oak-lucene/src/main/java/org/apache/jackrabbit/oak/plugins/index/lucene/hybrid/NRTIndex.java
@@ -411,7 +411,12 @@ public class NRTIndex implements Closeable {
}
@Override
- public void deleteDocuments(String path) throws IOException {
+ public void deleteDocumentTree(String path) throws IOException {
+ //Do not delete documents. Query side would handle it
+ }
+
+ @Override
+ public void deleteDocument(String path) throws IOException {
//Do not delete documents. Query side would handle it
}
diff --git
a/oak-lucene/src/main/java/org/apache/jackrabbit/oak/plugins/index/lucene/writer/DefaultIndexWriter.java
b/oak-lucene/src/main/java/org/apache/jackrabbit/oak/plugins/index/lucene/writer/DefaultIndexWriter.java
index 79a7790cbf..96a6209810 100644
---
a/oak-lucene/src/main/java/org/apache/jackrabbit/oak/plugins/index/lucene/writer/DefaultIndexWriter.java
+++
b/oak-lucene/src/main/java/org/apache/jackrabbit/oak/plugins/index/lucene/writer/DefaultIndexWriter.java
@@ -104,11 +104,16 @@ class DefaultIndexWriter implements LuceneIndexWriter {
}
@Override
- public void deleteDocuments(String path) throws IOException {
+ public void deleteDocumentTree(String path) throws IOException {
getWriter().deleteDocuments(newPathTerm(path));
getWriter().deleteDocuments(new PrefixQuery(newPathTerm(path + "/")));
}
+ @Override
+ public void deleteDocument(String path) throws IOException {
+ getWriter().deleteDocuments(newPathTerm(path));
+ }
+
void deleteAll() throws IOException {
getWriter().deleteAll();
indexUpdated = true;
diff --git
a/oak-lucene/src/main/java/org/apache/jackrabbit/oak/plugins/index/lucene/writer/IndexWriterPool.java
b/oak-lucene/src/main/java/org/apache/jackrabbit/oak/plugins/index/lucene/writer/IndexWriterPool.java
index 56ab1c6f05..d2cefdcb83 100644
---
a/oak-lucene/src/main/java/org/apache/jackrabbit/oak/plugins/index/lucene/writer/IndexWriterPool.java
+++
b/oak-lucene/src/main/java/org/apache/jackrabbit/oak/plugins/index/lucene/writer/IndexWriterPool.java
@@ -123,17 +123,31 @@ public class IndexWriterPool {
}
}
- private static class DeleteOperation extends Operation {
+ private static class DeleteTreeOperation extends Operation {
private final String path;
- DeleteOperation(LuceneIndexWriter delegate, String path) {
+ DeleteTreeOperation(LuceneIndexWriter delegate, String path) {
super(delegate);
this.path = path;
}
@Override
public void execute() throws IOException {
- delegate.deleteDocuments(path);
+ delegate.deleteDocumentTree(path);
+ }
+ }
+
+ private static class DeleteDocumentOperation extends Operation {
+ private final String path;
+
+ DeleteDocumentOperation(LuceneIndexWriter delegate, String path) {
+ super(delegate);
+ this.path = path;
+ }
+
+ @Override
+ public void execute() throws IOException {
+ delegate.deleteDocument(path);
}
}
@@ -280,10 +294,16 @@ public class IndexWriterPool {
enqueueOperation(new UpdateOperation(writer, path, doc));
}
- public void deleteDocuments(LuceneIndexWriter writer, String path) throws
IOException {
+ public void deleteDocumentTree(LuceneIndexWriter writer, String path)
throws IOException {
+ checkOpen();
+ this.deleteCount++;
+ enqueueOperation(new DeleteTreeOperation(writer, path));
+ }
+
+ public void deleteDocument(LuceneIndexWriter writer, String path) throws
IOException {
checkOpen();
this.deleteCount++;
- enqueueOperation(new DeleteOperation(writer, path));
+ enqueueOperation(new DeleteDocumentOperation(writer, path));
}
public boolean closeWriter(LuceneIndexWriter writer, long timestamp)
throws IOException {
diff --git
a/oak-lucene/src/main/java/org/apache/jackrabbit/oak/plugins/index/lucene/writer/MultiplexingIndexWriter.java
b/oak-lucene/src/main/java/org/apache/jackrabbit/oak/plugins/index/lucene/writer/MultiplexingIndexWriter.java
index 0f2b95c3ed..70c6f9526b 100644
---
a/oak-lucene/src/main/java/org/apache/jackrabbit/oak/plugins/index/lucene/writer/MultiplexingIndexWriter.java
+++
b/oak-lucene/src/main/java/org/apache/jackrabbit/oak/plugins/index/lucene/writer/MultiplexingIndexWriter.java
@@ -62,9 +62,9 @@ class MultiplexingIndexWriter implements LuceneIndexWriter {
}
@Override
- public void deleteDocuments(String path) throws IOException {
+ public void deleteDocumentTree(String path) throws IOException {
Mount mount = mountInfoProvider.getMountByPath(path);
- getWriter(mount).deleteDocuments(path);
+ getWriter(mount).deleteDocumentTree(path);
//In case of default mount look for other mounts with roots under this
path
//Note that one mount cannot be part of another mount
@@ -76,6 +76,13 @@ class MultiplexingIndexWriter implements LuceneIndexWriter {
}
}
+ @Override
+ public void deleteDocument(String path) throws IOException {
+ // Single-document delete: no mount-under-path cleanup needed
+ Mount mount = mountInfoProvider.getMountByPath(path);
+ getWriter(mount).deleteDocument(path);
+ }
+
@Override
public boolean close(long timestamp) throws IOException {
// explicitly get writers for mounts which haven't got writers even at
close.
diff --git
a/oak-lucene/src/main/java/org/apache/jackrabbit/oak/plugins/index/lucene/writer/PooledLuceneIndexWriter.java
b/oak-lucene/src/main/java/org/apache/jackrabbit/oak/plugins/index/lucene/writer/PooledLuceneIndexWriter.java
index 73e27e5ff6..208a7e2b11 100644
---
a/oak-lucene/src/main/java/org/apache/jackrabbit/oak/plugins/index/lucene/writer/PooledLuceneIndexWriter.java
+++
b/oak-lucene/src/main/java/org/apache/jackrabbit/oak/plugins/index/lucene/writer/PooledLuceneIndexWriter.java
@@ -51,8 +51,14 @@ public class PooledLuceneIndexWriter implements
LuceneIndexWriter {
}
@Override
- public void deleteDocuments(String path) throws IOException {
- writerPool.deleteDocuments(delegateWriter, path);
+ public void deleteDocumentTree(String path) throws IOException {
+ writerPool.deleteDocumentTree(delegateWriter, path);
+ deleteCount++;
+ }
+
+ @Override
+ public void deleteDocument(String path) throws IOException {
+ writerPool.deleteDocument(delegateWriter, path);
deleteCount++;
}
diff --git
a/oak-lucene/src/test/java/org/apache/jackrabbit/oak/plugins/index/lucene/LuceneIndexEditor2Test.java
b/oak-lucene/src/test/java/org/apache/jackrabbit/oak/plugins/index/lucene/LuceneIndexEditor2Test.java
index a3cd595a2e..8ccffdc9a1 100644
---
a/oak-lucene/src/test/java/org/apache/jackrabbit/oak/plugins/index/lucene/LuceneIndexEditor2Test.java
+++
b/oak-lucene/src/test/java/org/apache/jackrabbit/oak/plugins/index/lucene/LuceneIndexEditor2Test.java
@@ -21,10 +21,13 @@ package org.apache.jackrabbit.oak.plugins.index.lucene;
import java.util.HashMap;
import java.util.HashSet;
+import java.util.List;
import java.util.Map;
import java.util.Set;
+import org.apache.jackrabbit.JcrConstants;
import org.apache.jackrabbit.oak.api.PropertyState;
+import org.apache.jackrabbit.oak.api.Type;
import org.apache.jackrabbit.oak.commons.PathUtils;
import org.apache.jackrabbit.oak.plugins.index.IndexCommitCallback;
import org.apache.jackrabbit.oak.plugins.index.IndexEditorProvider;
@@ -50,13 +53,29 @@ import static
org.apache.jackrabbit.oak.plugins.index.lucene.LuceneIndexConstant
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import static org.mockito.Mockito.mock;
+import
org.apache.jackrabbit.oak.plugins.index.search.spi.editor.FulltextIndexEditor;
+import org.junit.After;
+import org.junit.Before;
+
public class LuceneIndexEditor2Test {
+ @Before
+ public void resetToggles() {
+ FulltextIndexEditor.FT_OAK_12244_DISABLE.set(false);
+ }
+
+ @After
+ public void restoreToggles() {
+ FulltextIndexEditor.FT_OAK_12244_DISABLE.set(false);
+ }
+
private final NodeState root = INITIAL_CONTENT;
private NodeState before = root;
private final IndexUpdateCallback updateCallback =
mock(IndexUpdateCallback.class);
@@ -181,6 +200,114 @@ public class LuceneIndexEditor2Test {
propCallback.reset();
}
+ @Test
+ public void nodeGainsMixinTriggersIndexUpdate() throws Exception {
+ LuceneIndexDefinitionBuilder defnb = new
LuceneIndexDefinitionBuilder();
+ defnb.indexRule("mix:title").property("jcr:title").propertyIndex();
+
+ NodeState defnState = defnb.build();
+ IndexDefinition defn = new IndexDefinition(root, defnState, indexPath);
+ LuceneIndexEditorContext ctx = newContext(defnState.builder(), defn,
true);
+ EditorHook hook = createHook(ctx);
+
+ updateBefore(defnb);
+
+ // Commit 1: node exists without the mixin — must not be indexed
+ NodeBuilder builder = before.builder();
+ builder.child("a").setProperty("jcr:title", "hello");
+ before = hook.processCommit(before, builder.getNodeState(),
CommitInfo.EMPTY);
+ assertFalse("Node without mixin should not be indexed",
writer.docs.containsKey("/a"));
+
+ // Commit 2: mixin added to existing node — must be indexed
+ builder = before.builder();
+ builder.child("a").setProperty(JcrConstants.JCR_MIXINTYPES,
List.of("mix:title"), Type.NAMES);
+ hook.processCommit(before, builder.getNodeState(), CommitInfo.EMPTY);
+ assertTrue("Node after gaining mixin should be added to index",
writer.docs.containsKey("/a"));
+ }
+
+ @Test
+ public void nodeLosesMixinTriggersDocumentDeletion() throws Exception {
+ LuceneIndexDefinitionBuilder defnb = new
LuceneIndexDefinitionBuilder();
+ defnb.indexRule("mix:title").property("jcr:title").propertyIndex();
+
+ NodeState defnState = defnb.build();
+ IndexDefinition defn = new IndexDefinition(root, defnState, indexPath);
+ LuceneIndexEditorContext ctx = newContext(defnState.builder(), defn,
true);
+ EditorHook hook = createHook(ctx);
+
+ updateBefore(defnb);
+
+ // Commit 1: node with mixin — must be indexed
+ NodeBuilder builder = before.builder();
+ builder.child("a")
+ .setProperty(JcrConstants.JCR_MIXINTYPES,
List.of("mix:title"), Type.NAMES)
+ .setProperty("jcr:title", "hello");
+ before = hook.processCommit(before, builder.getNodeState(),
CommitInfo.EMPTY);
+ assertTrue("Node with mixin should be indexed",
writer.docs.containsKey("/a"));
+
+ // Commit 2: mixin removed — existing index document must be deleted
+ builder = before.builder();
+ builder.child("a").removeProperty(JcrConstants.JCR_MIXINTYPES);
+ hook.processCommit(before, builder.getNodeState(), CommitInfo.EMPTY);
+ assertTrue("Removing mixin should trigger deleteDocument for the
node", writer.deletedPaths.contains("/a"));
+ }
+
+ @Test
+ public void nodeGainsMixinDoesNotTriggerIndexUpdateWhenToggleDisabled()
throws Exception {
+ FulltextIndexEditor.FT_OAK_12244_DISABLE.set(true);
+
+ LuceneIndexDefinitionBuilder defnb = new
LuceneIndexDefinitionBuilder();
+ defnb.indexRule("mix:title").property("jcr:title").propertyIndex();
+
+ NodeState defnState = defnb.build();
+ IndexDefinition defn = new IndexDefinition(root, defnState, indexPath);
+ LuceneIndexEditorContext ctx = newContext(defnState.builder(), defn,
true);
+ EditorHook hook = createHook(ctx);
+
+ updateBefore(defnb);
+
+ // Commit 1: node exists without the mixin
+ NodeBuilder builder = before.builder();
+ builder.child("a").setProperty("jcr:title", "hello");
+ before = hook.processCommit(before, builder.getNodeState(),
CommitInfo.EMPTY);
+ assertFalse("Node without mixin should not be indexed",
writer.docs.containsKey("/a"));
+
+ // Commit 2: mixin added — with toggle disabled, node must not be
indexed
+ builder = before.builder();
+ builder.child("a").setProperty(JcrConstants.JCR_MIXINTYPES,
List.of("mix:title"), Type.NAMES);
+ hook.processCommit(before, builder.getNodeState(), CommitInfo.EMPTY);
+ assertFalse("Mixin tracking disabled: node gaining mixin should not be
indexed", writer.docs.containsKey("/a"));
+ }
+
+ @Test
+ public void
nodeLosesMixinDoesNotTriggerDocumentDeletionWhenToggleDisabled() throws
Exception {
+ FulltextIndexEditor.FT_OAK_12244_DISABLE.set(true);
+
+ LuceneIndexDefinitionBuilder defnb = new
LuceneIndexDefinitionBuilder();
+ defnb.indexRule("mix:title").property("jcr:title").propertyIndex();
+
+ NodeState defnState = defnb.build();
+ IndexDefinition defn = new IndexDefinition(root, defnState, indexPath);
+ LuceneIndexEditorContext ctx = newContext(defnState.builder(), defn,
true);
+ EditorHook hook = createHook(ctx);
+
+ updateBefore(defnb);
+
+ // Commit 1: node with mixin — indexed because it's a new node
+ NodeBuilder builder = before.builder();
+ builder.child("a")
+ .setProperty(JcrConstants.JCR_MIXINTYPES,
List.of("mix:title"), Type.NAMES)
+ .setProperty("jcr:title", "hello");
+ before = hook.processCommit(before, builder.getNodeState(),
CommitInfo.EMPTY);
+ assertTrue("Node with mixin should be indexed",
writer.docs.containsKey("/a"));
+
+ // Commit 2: mixin removed — with toggle disabled, stale document must
not be deleted
+ builder = before.builder();
+ builder.child("a").removeProperty(JcrConstants.JCR_MIXINTYPES);
+ hook.processCommit(before, builder.getNodeState(), CommitInfo.EMPTY);
+ assertFalse("Mixin tracking disabled: removing mixin should not
trigger deleteDocument", writer.deletedPaths.contains("/a"));
+ }
+
private void updateBefore(LuceneIndexDefinitionBuilder defnb) {
NodeBuilder builder = before.builder();
NodeBuilder cb = TestUtil.child(builder,
PathUtils.getParentPath(indexPath));
@@ -290,7 +417,12 @@ public class LuceneIndexEditor2Test {
}
@Override
- public void deleteDocuments(String path) {
+ public void deleteDocumentTree(String path) {
+ deletedPaths.add(path);
+ }
+
+ @Override
+ public void deleteDocument(String path) {
deletedPaths.add(path);
}
diff --git
a/oak-lucene/src/test/java/org/apache/jackrabbit/oak/plugins/index/lucene/writer/IndexWriterPoolTest.java
b/oak-lucene/src/test/java/org/apache/jackrabbit/oak/plugins/index/lucene/writer/IndexWriterPoolTest.java
index 2d94694ae7..472d8d06cb 100644
---
a/oak-lucene/src/test/java/org/apache/jackrabbit/oak/plugins/index/lucene/writer/IndexWriterPoolTest.java
+++
b/oak-lucene/src/test/java/org/apache/jackrabbit/oak/plugins/index/lucene/writer/IndexWriterPoolTest.java
@@ -78,7 +78,13 @@ public class IndexWriterPoolTest {
}
@Override
- public void deleteDocuments(String path) {
+ public void deleteDocumentTree(String path) {
+ delay();
+ deletedPaths.add(path);
+ }
+
+ @Override
+ public void deleteDocument(String path) {
delay();
deletedPaths.add(path);
}
@@ -106,7 +112,7 @@ public class IndexWriterPoolTest {
TestWriter writer = new TestWriter();
Document doc = TestUtil.newDoc("value");
indexWriterPool.updateDocument(writer, "test", doc);
- indexWriterPool.deleteDocuments(writer, "test");
+ indexWriterPool.deleteDocumentTree(writer, "test");
boolean closeResult = indexWriterPool.closeWriter(writer, 30);
indexWriterPool.close();
@@ -163,7 +169,7 @@ public class IndexWriterPoolTest {
TestWriter writer = new TestWriter(100);
Document doc = TestUtil.newDoc("value");
indexWriterPool.updateDocument(writer, "test", doc);
- indexWriterPool.deleteDocuments(writer, "test-deletion");
+ indexWriterPool.deleteDocumentTree(writer, "test-deletion");
indexWriterPool.close();
assertEquals(Map.of("test", doc), writer.docs);
diff --git
a/oak-lucene/src/test/java/org/apache/jackrabbit/oak/plugins/index/lucene/writer/MultiplexingIndexWriterTest.java
b/oak-lucene/src/test/java/org/apache/jackrabbit/oak/plugins/index/lucene/writer/MultiplexingIndexWriterTest.java
index b3438faa8c..436a382e68 100644
---
a/oak-lucene/src/test/java/org/apache/jackrabbit/oak/plugins/index/lucene/writer/MultiplexingIndexWriterTest.java
+++
b/oak-lucene/src/test/java/org/apache/jackrabbit/oak/plugins/index/lucene/writer/MultiplexingIndexWriterTest.java
@@ -209,14 +209,14 @@ public class MultiplexingIndexWriterTest {
assertEquals(2, numDocs(defaultMount));
writer = factory.newInstance(defn, builder, null, true);
- writer.deleteDocuments("/libs/config");
+ writer.deleteDocumentTree("/libs/config");
writer.close(0);
assertEquals(1, numDocs(fooMount));
assertEquals(2, numDocs(defaultMount));
writer = factory.newInstance(defn, builder, null, true);
- writer.deleteDocuments("/content");
+ writer.deleteDocumentTree("/content");
writer.close(0);
assertEquals(1, numDocs(fooMount));
@@ -240,7 +240,7 @@ public class MultiplexingIndexWriterTest {
assertEquals(2, numDocs(defaultMount));
writer = factory.newInstance(defn, builder, null, true);
- writer.deleteDocuments("/content");
+ writer.deleteDocumentTree("/content");
writer.close(0);
assertEquals(0, numDocs(fooMount));
diff --git
a/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/ElasticIndexProviderService.java
b/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/ElasticIndexProviderService.java
index 89e64b2693..5ecac5e407 100644
---
a/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/ElasticIndexProviderService.java
+++
b/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/ElasticIndexProviderService.java
@@ -63,6 +63,8 @@ import java.util.Dictionary;
import java.util.Hashtable;
import java.util.List;
+import
org.apache.jackrabbit.oak.plugins.index.search.spi.editor.FulltextIndexEditor;
+
import static java.util.Collections.emptyMap;
import static
org.apache.jackrabbit.oak.spi.whiteboard.WhiteboardUtils.registerMBean;
@@ -228,6 +230,9 @@ public class ElasticIndexProviderService {
oakRegs.add(whiteboard.register(FeatureToggle.class,
new FeatureToggle(ElasticConnection.FT_OAK_12234,
ElasticConnection.FT_OAK_12234_DISABLE),
emptyMap()));
+ oakRegs.add(whiteboard.register(FeatureToggle.class,
+ new FeatureToggle(FulltextIndexEditor.FT_OAK_12244,
FulltextIndexEditor.FT_OAK_12244_DISABLE),
+ emptyMap()));
oakRegs.add(whiteboard.register(FeatureToggle.class,
new FeatureToggle(ElasticIndexStatistics.FT_OAK_12248,
ElasticIndexStatistics.FT_OAK_12248_ENABLE),
emptyMap()));
diff --git
a/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/index/ElasticIndexWriter.java
b/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/index/ElasticIndexWriter.java
index 893dd792f7..9e71388e42 100644
---
a/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/index/ElasticIndexWriter.java
+++
b/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/index/ElasticIndexWriter.java
@@ -166,7 +166,7 @@ class ElasticIndexWriter implements
FulltextIndexWriter<ElasticDocument> {
}
@Override
- public void deleteDocuments(String path) throws IOException {
+ public void deleteDocumentTree(String path) throws IOException {
retryPolicy.withRetries(() -> bulkProcessorHandler.delete(indexName,
ElasticIndexUtils.idFromPath(path)));
if (!ElasticIndexEditorProvider.FT_OAK_12206_DISABLE.get()) {
// Delete all descendants: mirrors Lucene's PrefixQuery on the
path term.
@@ -187,6 +187,12 @@ class ElasticIndexWriter implements
FulltextIndexWriter<ElasticDocument> {
}
}
+ @Override
+ public void deleteDocument(String path) throws IOException {
+ // Exact-document delete: no descendant sweep
+ retryPolicy.withRetries(() -> bulkProcessorHandler.delete(indexName,
ElasticIndexUtils.idFromPath(path)));
+ }
+
@Override
public boolean close(long timestamp) throws IOException {
boolean updateStatus = bulkProcessorHandler.flushIndex(indexName);
diff --git
a/oak-search-elastic/src/test/java/org/apache/jackrabbit/oak/plugins/index/elastic/index/ElasticIndexWriterTest.java
b/oak-search-elastic/src/test/java/org/apache/jackrabbit/oak/plugins/index/elastic/index/ElasticIndexWriterTest.java
index e644f2a456..ee6c9651fb 100644
---
a/oak-search-elastic/src/test/java/org/apache/jackrabbit/oak/plugins/index/elastic/index/ElasticIndexWriterTest.java
+++
b/oak-search-elastic/src/test/java/org/apache/jackrabbit/oak/plugins/index/elastic/index/ElasticIndexWriterTest.java
@@ -111,7 +111,7 @@ public class ElasticIndexWriterTest {
@Test
public void singleDeleteDocument() throws IOException {
- indexWriter.deleteDocuments("/bar");
+ indexWriter.deleteDocumentTree("/bar");
ArgumentCaptor<String> idCaptor =
ArgumentCaptor.forClass(String.class);
verify(bulkProcessorHandlerMock).delete(eq(indexAlias),
idCaptor.capture());
@@ -127,8 +127,8 @@ public class ElasticIndexWriterTest {
public void multiRequests() throws IOException {
indexWriter.updateDocument("/foo", new ElasticDocument("/foo"));
indexWriter.updateDocument("/bar", new ElasticDocument("/bar"));
- indexWriter.deleteDocuments("/foo");
- indexWriter.deleteDocuments("/bar");
+ indexWriter.deleteDocumentTree("/foo");
+ indexWriter.deleteDocumentTree("/bar");
verify(bulkProcessorHandlerMock, times(2)).index(eq(indexAlias),
anyString(), any(ElasticDocument.class));
verify(bulkProcessorHandlerMock, times(2)).delete(eq(indexAlias),
anyString());
@@ -182,7 +182,7 @@ public class ElasticIndexWriterTest {
@Test
public void ft_oak_12206_toggleShouldBeRemoved() {
// Time-bombed: if this test fails, the feature toggle FT_OAK-12206
and its guard in
- // ElasticIndexWriter#deleteDocuments should be removed — the fix has
been in production long enough.
+ // ElasticIndexWriter#deleteDocumentTree should be removed — the fix
has been in production long enough.
assertTrue("Feature toggle " + ElasticIndexEditorProvider.FT_OAK_12206
+ " is overdue for removal",
LocalDate.now().isBefore(LocalDate.of(2027, 5, 6)));
}
diff --git
a/oak-search/src/main/java/org/apache/jackrabbit/oak/plugins/index/search/spi/editor/FulltextIndexEditor.java
b/oak-search/src/main/java/org/apache/jackrabbit/oak/plugins/index/search/spi/editor/FulltextIndexEditor.java
index da7a5d29a7..13985c2f07 100644
---
a/oak-search/src/main/java/org/apache/jackrabbit/oak/plugins/index/search/spi/editor/FulltextIndexEditor.java
+++
b/oak-search/src/main/java/org/apache/jackrabbit/oak/plugins/index/search/spi/editor/FulltextIndexEditor.java
@@ -35,10 +35,13 @@ import org.apache.jackrabbit.oak.spi.state.NodeState;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import org.apache.jackrabbit.JcrConstants;
+
import java.io.IOException;
import java.util.ArrayList;
import java.util.BitSet;
import java.util.List;
+import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
/**
@@ -52,7 +55,7 @@ public class FulltextIndexEditor<D> implements IndexEditor,
Aggregate.AggregateR
/**
* Feature toggle name for OAK-12193.
- * When this toggle is active (default), deleteDocuments calls are skipped
for
+ * When this toggle is active (default), deleteDocumentTree calls are
skipped for
* removed subtrees that do not contain any node matching the index
definition's
* indexing rules. This avoids accumulating large numbers of buffered
deletes in
* the Lucene writer's DocumentsWriterDeleteQueue during delete-heavy async
@@ -62,13 +65,30 @@ public class FulltextIndexEditor<D> implements IndexEditor,
Aggregate.AggregateR
/**
* Kill switch for the OAK-12193 filtered delete behavior. Set to {@code
true} to
- * revert to the legacy behavior of always issuing a deleteDocuments call
for every
+ * revert to the legacy behavior of always issuing a deleteDocumentTree
call for every
* removed subtree regardless of whether the index could have indexed its
nodes.
* Default is {@code false} (filtered behavior active). Wired to the
* {@link #FT_OAK_12193} feature toggle at runtime.
*/
public static final AtomicBoolean FT_OAK_12193_DISABLE = new
AtomicBoolean(false);
+ /**
+ * Feature toggle name for OAK-12244.
+ * When active (default), nodes that gain or lose a mixin type at runtime
are
+ * correctly added to or removed from the fulltext index, even when
+ * {@code jcr:mixinTypes} is not listed in the rule's property definitions.
+ */
+ public static final String FT_OAK_12244 = "FT_OAK-12244";
+
+ /**
+ * Kill switch for the OAK-12244 mixin-transition tracking. Set to {@code
true} to
+ * revert to the pre-OAK-12244 behavior where mixin additions and removals
do not
+ * trigger index updates unless an indexed property also changed.
+ * Default is {@code false} (mixin-transition tracking active). Wired to
the
+ * {@link #FT_OAK_12244} feature toggle at runtime.
+ */
+ public static final AtomicBoolean FT_OAK_12244_DISABLE = new
AtomicBoolean(false);
+
private static final List<Aggregate.Matcher> EMPTY_AGGREGATE_MATCHER_LIST
= List.of();
private final FulltextIndexEditorContext<D> context;
@@ -81,6 +101,8 @@ public class FulltextIndexEditor<D> implements IndexEditor,
Aggregate.AggregateR
private boolean propertiesChanged = false;
+ private boolean wasIndexable = false;
+
private final List<PropertyState> propertiesModified = new ArrayList<>();
/*
@@ -143,17 +165,55 @@ public class FulltextIndexEditor<D> implements
IndexEditor, Aggregate.AggregateR
if (indexingRule != null) {
currentMatchers =
indexingRule.getAggregate().createMatchers(this);
}
+
+ if (context.isTypeChangeTrackingEnabled() && before.exists()) {
+ if (hasNodeTypeChange(before, after)) {
+ wasIndexable =
getDefinition().getApplicableIndexingRule(before) != null;
+ } else {
+ // Types unchanged: before and after match the same rule
+ wasIndexable = indexingRule != null;
+ }
+ }
}
}
@Override
public void leave(NodeState before, NodeState after)
throws CommitFailedException {
- if (propertiesChanged || !before.exists()) {
- if (addOrUpdate(path, after, before.exists())) {
- long indexed = context.incIndexedNodes();
- if (indexed % 1000 == 0) {
- log.debug("[{}] => Indexed {} nodes...", getIndexName(),
indexed);
+ if (context.isTypeChangeTrackingEnabled()) {
+ // OAK-12244: act on rule-gained / rule-lost transitions
+ boolean toBeDeleted = wasIndexable && !isIndexable();
+ boolean toBeAdded = !wasIndexable && isIndexable();
+ boolean toBeUpdated = wasIndexable && isIndexable() &&
propertiesChanged;
+
+ if (toBeDeleted) {
+ try {
+ // Exact-document delete: children that still carry the
mixin must not be evicted
+ context.getWriter().deleteDocument(path);
+ context.indexUpdate();
+ } catch (IOException e) {
+ CommitFailedException ce = new
CommitFailedException("Fulltext", 5,
+ "Failed to delete stale index document for " + path
+ + " in index " +
context.getIndexingContext().getIndexPath(), e);
+ context.getIndexingContext().indexUpdateFailed(ce);
+ throw ce;
+ }
+ } else if (toBeAdded || toBeUpdated) {
+ if (addOrUpdate(path, after, before.exists())) {
+ long indexed = context.incIndexedNodes();
+ if (indexed % 1000 == 0) {
+ log.debug("[{}] => Indexed {} nodes...",
getIndexName(), indexed);
+ }
+ }
+ }
+ } else {
+ // pre-OAK-12244: only property changes and new nodes trigger
indexing
+ if (propertiesChanged || !before.exists()) {
+ if (addOrUpdate(path, after, before.exists())) {
+ long indexed = context.incIndexedNodes();
+ if (indexed % 1000 == 0) {
+ log.debug("[{}] => Indexed {} nodes...",
getIndexName(), indexed);
+ }
}
}
}
@@ -243,14 +303,14 @@ public class FulltextIndexEditor<D> implements
IndexEditor, Aggregate.AggregateR
}
if (!FT_OAK_12193_DISABLE.get()) {
- // OAK-12193: skip the deleteDocuments call when no node in the
removed subtree
- // could have been indexed. Legacy behavior would route a
deleteDocuments call
+ // OAK-12193: skip the deleteDocumentTree call when no node in the
removed subtree
+ // could have been indexed. Legacy behavior would route a
deleteDocumentTree call
// for every removed subtree regardless, accumulating buffered
deletes in the
// Lucene writer during delete-heavy async cycles.
if (!isDeleted && subtreeHasIndexableNode(context.getDefinition(),
before)) {
try {
FulltextIndexWriter<D> writer = context.getWriter();
- writer.deleteDocuments(childPath);
+ writer.deleteDocumentTree(childPath);
this.context.indexUpdate();
} catch (IOException e) {
CommitFailedException ce = new
CommitFailedException("Fulltext", 5, "Failed to remove the index entries of"
@@ -265,7 +325,7 @@ public class FulltextIndexEditor<D> implements IndexEditor,
Aggregate.AggregateR
try {
FulltextIndexWriter<D> writer = context.getWriter();
// Remove all index entries in the removed subtree
- writer.deleteDocuments(childPath);
+ writer.deleteDocumentTree(childPath);
this.context.indexUpdate();
} catch (IOException e) {
CommitFailedException ce = new
CommitFailedException("Fulltext", 5, "Failed to remove the index entries of"
@@ -461,6 +521,13 @@ public class FulltextIndexEditor<D> implements
IndexEditor, Aggregate.AggregateR
return indexingRule != null;
}
+ private static boolean hasNodeTypeChange(NodeState before, NodeState
after) {
+ return !Objects.equals(before.getProperty(JcrConstants.JCR_MIXINTYPES),
+ after.getProperty(JcrConstants.JCR_MIXINTYPES))
+ ||
!Objects.equals(before.getProperty(JcrConstants.JCR_PRIMARYTYPE),
+
after.getProperty(JcrConstants.JCR_PRIMARYTYPE));
+ }
+
private String getIndexName() {
return context.getDefinition().getIndexName();
}
diff --git
a/oak-search/src/main/java/org/apache/jackrabbit/oak/plugins/index/search/spi/editor/FulltextIndexEditorContext.java
b/oak-search/src/main/java/org/apache/jackrabbit/oak/plugins/index/search/spi/editor/FulltextIndexEditorContext.java
index b877133228..38427b6bf8 100644
---
a/oak-search/src/main/java/org/apache/jackrabbit/oak/plugins/index/search/spi/editor/FulltextIndexEditorContext.java
+++
b/oak-search/src/main/java/org/apache/jackrabbit/oak/plugins/index/search/spi/editor/FulltextIndexEditorContext.java
@@ -89,6 +89,8 @@ public abstract class FulltextIndexEditorContext<D> {
private final boolean indexDefnRewritten;
+ private final boolean typeChangeTrackingEnabled;
+
private FulltextBinaryTextExtractor textExtractor;
private PropertyUpdateCallback propertyUpdateCallback;
@@ -109,6 +111,7 @@ public abstract class FulltextIndexEditorContext<D> {
this.updateCallback = updateCallback;
this.extractedTextCache = extractedTextCache;
this.asyncIndexing = asyncIndexing;
+ this.typeChangeTrackingEnabled =
!FulltextIndexEditor.FT_OAK_12244_DISABLE.get();
if (this.definition.isOfOldFormat()) {
indexDefnRewritten = true;
IndexDefinition.updateDefinition(definition,
indexingContext.getIndexPath());
@@ -121,6 +124,10 @@ public abstract class FulltextIndexEditorContext<D> {
public abstract DocumentMaker<D>
newDocumentMaker(IndexDefinition.IndexingRule rule, String path);
+ public boolean isTypeChangeTrackingEnabled() {
+ return typeChangeTrackingEnabled;
+ }
+
protected FulltextBinaryTextExtractor
createBinaryTextExtractor(ExtractedTextCache extractedTextCache,
IndexDefinition definition, boolean reindex) {
return new FulltextBinaryTextExtractor(extractedTextCache, definition,
reindex);
diff --git
a/oak-search/src/main/java/org/apache/jackrabbit/oak/plugins/index/search/spi/editor/FulltextIndexWriter.java
b/oak-search/src/main/java/org/apache/jackrabbit/oak/plugins/index/search/spi/editor/FulltextIndexWriter.java
index bc19febc66..b936b7f48b 100644
---
a/oak-search/src/main/java/org/apache/jackrabbit/oak/plugins/index/search/spi/editor/FulltextIndexWriter.java
+++
b/oak-search/src/main/java/org/apache/jackrabbit/oak/plugins/index/search/spi/editor/FulltextIndexWriter.java
@@ -36,11 +36,24 @@ public interface FulltextIndexWriter<D> {
void updateDocument(String path, D doc) throws IOException;
/**
- * Deletes documents which are same or child of given path
+ * Deletes the document at the given path and all descendant documents.
+ * Use this when a node is physically removed from the repository.
*
- * @param path path whose children need to be deleted
+ * @param path path of the node whose document and all descendants need to
be deleted
*/
- void deleteDocuments(String path) throws IOException;
+ void deleteDocumentTree(String path) throws IOException;
+
+ /**
+ * Deletes only the document at the given path, leaving descendant
documents untouched.
+ * Use this when a node loses indexability at runtime (e.g. mixin removed)
while its
+ * children may still be indexable.
+ *
+ * <p>Default implementation falls back to {@link #deleteDocumentTree} for
implementations
+ * that do not differentiate; override in backends that support
exact-document deletion.
+ *
+ * @param path path of the node whose document needs to be deleted
+ */
+ void deleteDocument(String path) throws IOException;
/**
* Closes the underlying resources.
diff --git
a/oak-search/src/test/java/org/apache/jackrabbit/oak/plugins/index/PropertyIndexCommonTest.java
b/oak-search/src/test/java/org/apache/jackrabbit/oak/plugins/index/PropertyIndexCommonTest.java
index 1d8d24e478..bb96e14f16 100644
---
a/oak-search/src/test/java/org/apache/jackrabbit/oak/plugins/index/PropertyIndexCommonTest.java
+++
b/oak-search/src/test/java/org/apache/jackrabbit/oak/plugins/index/PropertyIndexCommonTest.java
@@ -520,6 +520,82 @@ public abstract class PropertyIndexCommonTest extends
AbstractQueryTest {
});
}
+ @Test
+ public void nodeGainsMixinAppearsInMixinBasedIndex() throws Exception {
+ indexOptions.setIndex(
+ root,
+ "test1",
+
indexOptions.createIndex(indexOptions.createIndexDefinitionBuilder(),
"mix:title", false, "jcr:title")
+ );
+ root.commit();
+
+ Tree test = root.getTree("/").addChild("test");
+ Tree a = test.addChild("a");
+ a.setProperty("jcr:title", "hello");
+ root.commit();
+
+ String query = "select [jcr:path] from [mix:title] where [jcr:title] =
'hello'";
+ assertEventually(() -> assertQuery(query, List.of()));
+
+ a = root.getTree("/test/a");
+ a.setProperty(JcrConstants.JCR_MIXINTYPES, List.of("mix:title"),
Type.NAMES);
+ root.commit();
+
+ assertEventually(() -> assertQuery(query, List.of("/test/a")));
+ }
+
+ @Test
+ public void nodeLosesMixinDisappearsFromMixinBasedIndex() throws Exception
{
+ indexOptions.setIndex(
+ root,
+ "test1",
+
indexOptions.createIndex(indexOptions.createIndexDefinitionBuilder(),
"mix:title", false, "jcr:title")
+ );
+ root.commit();
+
+ Tree test = root.getTree("/").addChild("test");
+ Tree a = createNodeWithMixinType(test, "a", "mix:title");
+ a.setProperty("jcr:title", "hello");
+ root.commit();
+
+ String query = "select [jcr:path] from [mix:title] where [jcr:title] =
'hello'";
+ assertEventually(() -> assertQuery(query, List.of("/test/a")));
+
+ a = root.getTree("/test/a");
+ a.removeProperty(JcrConstants.JCR_MIXINTYPES);
+ root.commit();
+
+ assertEventually(() -> assertQuery(query, List.of()));
+ }
+
+ @Test
+ public void parentLosesMixinDoesNotCascadeDeleteChildWithSameMixin()
throws Exception {
+ indexOptions.setIndex(
+ root,
+ "test1",
+
indexOptions.createIndex(indexOptions.createIndexDefinitionBuilder(),
"mix:title", false, "jcr:title")
+ );
+ root.commit();
+
+ Tree test = root.getTree("/").addChild("test");
+ Tree a = createNodeWithMixinType(test, "a", "mix:title");
+ a.setProperty("jcr:title", "parent");
+ Tree child = createNodeWithMixinType(a, "child", "mix:title");
+ child.setProperty("jcr:title", "childTitle");
+ root.commit();
+
+ String childQuery = "select [jcr:path] from [mix:title] where
[jcr:title] = 'childTitle'";
+ assertEventually(() -> assertQuery(childQuery,
List.of("/test/a/child")));
+
+ // Remove mixin from parent only — child still has mix:title
+ a = root.getTree("/test/a");
+ a.removeProperty(JcrConstants.JCR_MIXINTYPES);
+ root.commit();
+
+ // Child must survive — deleteDocumentTree on the parent must not
cascade to it
+ assertEventually(() -> assertQuery(childQuery,
List.of("/test/a/child")));
+ }
+
@Test
public void indexingBasedOnMixinAndRelativeProps() throws Exception {
indexOptions.setIndex(