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(


Reply via email to