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 12144f43429..47f02a4e9c6 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 @@ -387,6 +387,9 @@ private void activate(BundleContext bundleContext, Configuration config) throws 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(FulltextIndexEditor.FT_OAK_12365, FulltextIndexEditor.FT_OAK_12365_DISABLE), + emptyMap())); initializeIndexDir(bundleContext, config); initializeExtractedTextCache(bundleContext, config, statisticsProvider); tracker = createTracker(bundleContext, config); 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 8ccffdc9a10..cb24cd3ee01 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 @@ -69,11 +69,13 @@ public class LuceneIndexEditor2Test { @Before public void resetToggles() { FulltextIndexEditor.FT_OAK_12244_DISABLE.set(false); + FulltextIndexEditor.FT_OAK_12365_DISABLE.set(false); } @After public void restoreToggles() { FulltextIndexEditor.FT_OAK_12244_DISABLE.set(false); + FulltextIndexEditor.FT_OAK_12365_DISABLE.set(false); } private final NodeState root = INITIAL_CONTENT; @@ -308,6 +310,120 @@ public void nodeLosesMixinDoesNotTriggerDocumentDeletionWhenToggleDisabled() thr assertFalse("Mixin tracking disabled: removing mixin should not trigger deleteDocument", writer.deletedPaths.contains("/a")); } + @Test + public void nodeLosesLastAggregatedPropertyTriggersDocumentDeletion() throws Exception { + // OAK-12365: root's rule/type is unchanged; only the aggregated child content disappears. + LuceneIndexDefinitionBuilder defnb = new LuceneIndexDefinitionBuilder(); + defnb.indexRule("nt:base").property("jcr:content/status").propertyIndex(); + defnb.aggregateRule("nt:base").include("*"); + + 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: aggregated child property present — root must be indexed + NodeBuilder builder = before.builder(); + builder.child("a").child("jcr:content").setProperty("status", "published"); + before = hook.processCommit(root, builder.getNodeState(), CommitInfo.EMPTY); + assertTrue("Root with aggregated property should be indexed", writer.docs.containsKey("/a")); + + // Commit 2: last aggregated property removed, root's own rule/type unchanged — + // stale root document must be deleted + builder = before.builder(); + builder.child("a").child("jcr:content").removeProperty("status"); + hook.processCommit(before, builder.getNodeState(), CommitInfo.EMPTY); + assertTrue("Removing the last aggregated property should trigger deleteDocument for the root", + writer.deletedPaths.contains("/a")); + } + + @Test + public void nodeLosesLastAggregatedPropertyTriggersDocumentDeletionWhenMixinToggleDisabled() throws Exception { + // Fix lives in addOrUpdate(), so it also covers the legacy (FT_OAK_12244 disabled) path. + FulltextIndexEditor.FT_OAK_12244_DISABLE.set(true); + + LuceneIndexDefinitionBuilder defnb = new LuceneIndexDefinitionBuilder(); + defnb.indexRule("nt:base").property("jcr:content/status").propertyIndex(); + defnb.aggregateRule("nt:base").include("*"); + + 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: aggregated child property present — root must be indexed + NodeBuilder builder = before.builder(); + builder.child("a").child("jcr:content").setProperty("status", "published"); + before = hook.processCommit(root, builder.getNodeState(), CommitInfo.EMPTY); + assertTrue("Root with aggregated property should be indexed", writer.docs.containsKey("/a")); + + // Commit 2: last aggregated property removed — stale root document must still be + // deleted even with mixin-transition tracking disabled + builder = before.builder(); + builder.child("a").child("jcr:content").removeProperty("status"); + hook.processCommit(before, builder.getNodeState(), CommitInfo.EMPTY); + assertTrue("Removing the last aggregated property should trigger deleteDocument regardless of FT_OAK_12244", + writer.deletedPaths.contains("/a")); + } + + @Test + public void nodeLosesLastAggregatedPropertyDoesNotTriggerDeletionWhenOak12365ToggleDisabled() throws Exception { + FulltextIndexEditor.FT_OAK_12365_DISABLE.set(true); + + LuceneIndexDefinitionBuilder defnb = new LuceneIndexDefinitionBuilder(); + defnb.indexRule("nt:base").property("jcr:content/status").propertyIndex(); + defnb.aggregateRule("nt:base").include("*"); + + NodeState defnState = defnb.build(); + IndexDefinition defn = new IndexDefinition(root, defnState, indexPath); + LuceneIndexEditorContext ctx = newContext(defnState.builder(), defn, true); + EditorHook hook = createHook(ctx); + + updateBefore(defnb); + + NodeBuilder builder = before.builder(); + builder.child("a").child("jcr:content").setProperty("status", "published"); + before = hook.processCommit(root, builder.getNodeState(), CommitInfo.EMPTY); + assertTrue("Root with aggregated property should be indexed", writer.docs.containsKey("/a")); + + builder = before.builder(); + builder.child("a").child("jcr:content").removeProperty("status"); + hook.processCommit(before, builder.getNodeState(), CommitInfo.EMPTY); + assertFalse("OAK-12365 disabled: removing the last aggregated property should not trigger deleteDocument", + writer.deletedPaths.contains("/a")); + } + + @Test + public void nodeKeepsOneOfMultipleAggregatedPropertiesDoesNotTriggerDeletion() throws Exception { + LuceneIndexDefinitionBuilder defnb = new LuceneIndexDefinitionBuilder(); + defnb.indexRule("nt:base").property("jcr:content/status").propertyIndex(); + defnb.indexRule("nt:base").property("jcr:content/type").propertyIndex(); + defnb.aggregateRule("nt:base").include("*"); + + NodeState defnState = defnb.build(); + IndexDefinition defn = new IndexDefinition(root, defnState, indexPath); + LuceneIndexEditorContext ctx = newContext(defnState.builder(), defn, true); + EditorHook hook = createHook(ctx); + + updateBefore(defnb); + + NodeBuilder builder = before.builder(); + builder.child("a").child("jcr:content").setProperty("status", "published"); + builder.child("a").child("jcr:content").setProperty("type", "page"); + before = hook.processCommit(root, builder.getNodeState(), CommitInfo.EMPTY); + assertTrue("Root with aggregated properties should be indexed", writer.docs.containsKey("/a")); + + builder = before.builder(); + builder.child("a").child("jcr:content").removeProperty("status"); + hook.processCommit(before, builder.getNodeState(), CommitInfo.EMPTY); + assertFalse("Removing one of several aggregated properties should not delete the root document", + writer.deletedPaths.contains("/a")); + } + private void updateBefore(LuceneIndexDefinitionBuilder defnb) { NodeBuilder builder = before.builder(); NodeBuilder cb = TestUtil.child(builder, PathUtils.getParentPath(indexPath)); 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 1a85f6229db..cd054c2c894 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 @@ -234,6 +234,9 @@ private void activate(BundleContext bundleContext, Config config) { 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(FulltextIndexEditor.FT_OAK_12365, FulltextIndexEditor.FT_OAK_12365_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/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 13985c2f076..bb2ac454ff7 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 @@ -89,6 +89,22 @@ public class FulltextIndexEditor implements IndexEditor, Aggregate.AggregateR */ public static final AtomicBoolean FT_OAK_12244_DISABLE = new AtomicBoolean(false); + /** + * Feature toggle name for OAK-12365. + * When active (default), a node whose last indexed property is removed (rule/type + * unchanged) has its stale index document deleted, instead of being silently left + * behind because {@code makeDocument()} now returns {@code null}. + */ + public static final String FT_OAK_12365 = "FT_OAK-12365"; + + /** + * Kill switch for the OAK-12365 stale-document deletion. Set to {@code true} to + * revert to the pre-OAK-12365 behavior where a node losing its last indexed property + * leaves its previous document in the index. Default is {@code false} (deletion + * active). Wired to the {@link #FT_OAK_12365} feature toggle at runtime. + */ + public static final AtomicBoolean FT_OAK_12365_DISABLE = new AtomicBoolean(false); + private static final List EMPTY_AGGREGATE_MATCHER_LIST = List.of(); private final FulltextIndexEditorContext context; @@ -376,6 +392,11 @@ private boolean addOrUpdate(String path, NodeState state, boolean isUpdate) context.indexUpdate(); context.getWriter().updateDocument(path, d); return true; + } else if (isUpdate && !FT_OAK_12365_DISABLE.get()) { + // OAK-12365: node still matches the rule but has no content left to index. + log.debug("[{}] Deleting stale document for {}", getIndexName(), path); + context.indexUpdate(); + context.getWriter().deleteDocument(path); } } catch (IOException e) { log.warn("Failed to index the node [{}] due to {}", path, e.toString()); 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 bb96e14f16c..ff7133c1a1f 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 @@ -568,6 +568,33 @@ public void nodeLosesMixinDisappearsFromMixinBasedIndex() throws Exception { assertEventually(() -> assertQuery(query, List.of())); } + @Test + public void nodeLosesLastAggregatedPropertyDisappearsFromFulltextIndex() throws Exception { + // OAK-12365: root's rule/type is unchanged; only the aggregated child content + // disappears. CONTAINS() is required here since equality queries are revalidated + // against the live tree and wouldn't surface a stale document. + IndexDefinitionBuilder builder = indexOptions.createIndexDefinitionBuilder(); + builder.noAsync(); // wildcard aggregateRule never converges on the async lane in time + builder.indexRule("nt:base").property("jcr:content/status").propertyIndex(); + builder.aggregateRule("nt:base").include("*"); + indexOptions.setIndex(root, "test1", builder); + root.commit(); + + Tree test = root.getTree("/").addChild("test"); + test.addChild("a").addChild("jcr:content").setProperty("status", "published"); + root.commit(); + + // ISCHILDNODE('/test'): the wildcard aggregate also propagates the fulltext + // content up to "/test" itself, so scope the query to exclude that ancestor. + String query = "select [jcr:path] from [nt:base] where ISCHILDNODE('/test') and CONTAINS(*, 'published')"; + assertEventually(() -> assertQuery(query, List.of("/test/a"))); + + root.getTree("/test/a/jcr:content").removeProperty("status"); + root.commit(); + + assertEventually(() -> assertQuery(query, List.of())); + } + @Test public void parentLosesMixinDoesNotCascadeDeleteChildWithSameMixin() throws Exception { indexOptions.setIndex(