diff --git a/oak-core/src/main/java/org/apache/jackrabbit/oak/plugins/index/IndexName.java b/oak-core/src/main/java/org/apache/jackrabbit/oak/plugins/index/IndexName.java index 994fe62a79a..b453804ebf4 100644 --- a/oak-core/src/main/java/org/apache/jackrabbit/oak/plugins/index/IndexName.java +++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/plugins/index/IndexName.java @@ -23,6 +23,7 @@ import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Map; import org.apache.jackrabbit.oak.api.PropertyState; import org.apache.jackrabbit.oak.api.Type; @@ -234,6 +235,36 @@ public static Collection filterReplacedIndexes(Collection indexP return result; } + /** + * Remove indexes if there is a newer, active version. + * This suppresses indexes when a newer version of a different type exists + * (e.g. lucene vs. elasticsearch). + * + * @param candidatePaths paths of one specific index type being evaluated + * @param allCompetingPaths paths of all competing index types (e.g. both lucene and elasticsearch) + * @return candidates that are not superseded by a higher-versioned entry in allCompetingPaths + */ + public static Collection filterGloballySuperseded( + Collection candidatePaths, Collection allCompetingPaths) { + Map maxByBase = new HashMap<>(); + for (String p : allCompetingPaths) { + IndexName n = IndexName.parse(PathUtils.getName(p)); + IndexName stored = maxByBase.get(n.baseName); + if (stored == null || stored.compareTo(n) < 0) { + maxByBase.put(n.baseName, n); + } + } + List result = new ArrayList<>(); + for (String p : candidatePaths) { + IndexName n = IndexName.parse(PathUtils.getName(p)); + IndexName globalMax = maxByBase.get(n.baseName); + if (globalMax == null || globalMax.compareTo(n) == 0) { + result.add(p); + } + } + return result; + } + public static Collection filterNewestIndexes(Collection indexPaths) { HashMap latestVersions = new HashMap<>(); for (String p : indexPaths) { diff --git a/oak-core/src/test/java/org/apache/jackrabbit/oak/plugins/index/IndexNameAdditionalTest.java b/oak-core/src/test/java/org/apache/jackrabbit/oak/plugins/index/IndexNameAdditionalTest.java new file mode 100644 index 00000000000..cd5ea86cf06 --- /dev/null +++ b/oak-core/src/test/java/org/apache/jackrabbit/oak/plugins/index/IndexNameAdditionalTest.java @@ -0,0 +1,77 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.jackrabbit.oak.plugins.index; + +import org.junit.Test; + +import java.util.Collection; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class IndexNameAdditionalTest { + + // ---- filterGloballySuperseded ---- + + @Test + public void filterGloballySuperseded_noCompetitors() { + // no competing paths: all candidates pass through + Collection result = IndexName.filterGloballySuperseded( + List.of("/oak:index/lucene-2"), + List.of()); + assertEquals(List.of("/oak:index/lucene-2"), List.copyOf(result)); + } + + @Test + public void filterGloballySuperseded_olderCompetitorKept() { + // lucene-2 is newer than /oak:index/lucene-1-custom-1, so it passes + Collection result = IndexName.filterGloballySuperseded( + List.of("/oak:index/lucene-2"), + List.of("/oak:index/lucene-2", "/oak:index/lucene-1-custom-1")); + assertEquals(List.of("/oak:index/lucene-2"), List.copyOf(result)); + } + + @Test + public void filterGloballySuperseded_newerCompetitorFilters() { + // lucene-1 vs. lucene-2 (same base): lucene-1 is superseded + Collection result = IndexName.filterGloballySuperseded( + List.of("/oak:index/lucene-1"), + List.of("/oak:index/lucene-1", "/oak:index/lucene-2")); + assertTrue(result.isEmpty()); + } + + @Test + public void filterGloballySuperseded_differentBaseNotAffected() { + // lucene-1 for "fooIndex" is not affected by a newer version of "barIndex" + Collection result = IndexName.filterGloballySuperseded( + List.of("/oak:index/fooIndex-1"), + List.of("/oak:index/fooIndex-1", "/oak:index/barIndex-2")); + assertEquals(List.of("/oak:index/fooIndex-1"), List.copyOf(result)); + } + + @Test + public void filterGloballySuperseded_unversionedSupersededByVersioned() { + // unversioned lucene (version 0) is superseded by lucene-1 + Collection result = IndexName.filterGloballySuperseded( + List.of("/oak:index/lucene"), + List.of("/oak:index/lucene", "/oak:index/lucene-1")); + assertTrue(result.isEmpty()); + } +} diff --git a/oak-search/src/test/java/org/apache/jackrabbit/oak/plugins/index/search/spi/query/IndexNameTest.java b/oak-core/src/test/java/org/apache/jackrabbit/oak/plugins/index/IndexNameTest.java similarity index 98% rename from oak-search/src/test/java/org/apache/jackrabbit/oak/plugins/index/search/spi/query/IndexNameTest.java rename to oak-core/src/test/java/org/apache/jackrabbit/oak/plugins/index/IndexNameTest.java index 3498651d9d5..21715b20c3f 100644 --- a/oak-search/src/test/java/org/apache/jackrabbit/oak/plugins/index/search/spi/query/IndexNameTest.java +++ b/oak-core/src/test/java/org/apache/jackrabbit/oak/plugins/index/IndexNameTest.java @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.jackrabbit.oak.plugins.index.search.spi.query; +package org.apache.jackrabbit.oak.plugins.index; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; @@ -24,7 +24,6 @@ import static org.apache.jackrabbit.oak.plugins.memory.EmptyNodeState.EMPTY_NODE; import org.apache.jackrabbit.oak.commons.junit.LogCustomizer; -import org.apache.jackrabbit.oak.plugins.index.IndexName; import org.apache.jackrabbit.oak.spi.state.NodeBuilder; import org.apache.jackrabbit.oak.spi.state.NodeState; import org.junit.Test; diff --git a/oak-it-osgi/pom.xml b/oak-it-osgi/pom.xml index ae973c61e6a..7a50b7579db 100644 --- a/oak-it-osgi/pom.xml +++ b/oak-it-osgi/pom.xml @@ -189,6 +189,19 @@ ${project.version} test + + org.apache.jackrabbit + oak-search-elastic + ${project.version} + test-jar + test + + + org.testcontainers + elasticsearch + ${testcontainers.version} + test + org.apache.jackrabbit oak-store-composite diff --git a/oak-it-osgi/src/test/java/org/apache/jackrabbit/oak/osgi/IndexVersionSelectionIT.java b/oak-it-osgi/src/test/java/org/apache/jackrabbit/oak/osgi/IndexVersionSelectionIT.java new file mode 100644 index 00000000000..5f2d5aa0e57 --- /dev/null +++ b/oak-it-osgi/src/test/java/org/apache/jackrabbit/oak/osgi/IndexVersionSelectionIT.java @@ -0,0 +1,365 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.jackrabbit.oak.osgi; + +import static org.junit.Assert.assertTrue; +import static org.ops4j.pax.exam.CoreOptions.bundle; +import static org.ops4j.pax.exam.CoreOptions.frameworkProperty; +import static org.ops4j.pax.exam.CoreOptions.junitBundles; +import static org.ops4j.pax.exam.CoreOptions.mavenBundle; +import static org.ops4j.pax.exam.CoreOptions.systemProperties; +import static org.ops4j.pax.exam.CoreOptions.vmOption; + +import java.io.File; +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Arrays; + +import org.apache.jackrabbit.oak.InitialContent; +import org.apache.jackrabbit.oak.Oak; +import org.apache.jackrabbit.oak.api.ContentRepository; +import org.apache.jackrabbit.oak.api.ContentSession; +import org.apache.jackrabbit.oak.api.QueryEngine; +import org.apache.jackrabbit.oak.api.Result; +import org.apache.jackrabbit.oak.api.Root; +import org.apache.jackrabbit.oak.api.Tree; +import org.apache.jackrabbit.oak.api.Type; +import org.apache.jackrabbit.oak.plugins.index.elastic.ElasticConnection; +import org.apache.jackrabbit.oak.plugins.index.elastic.ElasticIndexTracker; +import org.apache.jackrabbit.oak.plugins.index.elastic.ElasticMetricHandler; +import org.apache.jackrabbit.oak.plugins.index.elastic.index.ElasticIndexEditorProvider; +import org.apache.jackrabbit.oak.plugins.index.elastic.query.ElasticIndexProvider; +import org.apache.jackrabbit.oak.plugins.index.lucene.LuceneIndexEditorProvider; +import org.apache.jackrabbit.oak.plugins.index.lucene.LuceneIndexProvider; +import org.apache.jackrabbit.oak.plugins.index.property.PropertyIndexEditorProvider; +import org.apache.jackrabbit.oak.plugins.memory.MemoryNodeStore; +import org.apache.jackrabbit.oak.spi.commit.Observer; +import org.apache.jackrabbit.oak.spi.query.QueryIndexProvider; +import org.apache.jackrabbit.oak.spi.security.OpenSecurityProvider; +import org.apache.jackrabbit.oak.stats.StatisticsProvider; +import org.junit.After; +import org.junit.Assume; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.ops4j.pax.exam.Configuration; +import org.ops4j.pax.exam.CoreOptions; +import org.ops4j.pax.exam.Option; +import org.ops4j.pax.exam.junit.PaxExam; +import org.ops4j.pax.exam.options.DefaultCompositeOption; +import org.ops4j.pax.exam.options.SystemPropertyOption; +import org.ops4j.pax.exam.spi.reactors.ExamReactorStrategy; +import org.ops4j.pax.exam.spi.reactors.PerClass; +import org.osgi.framework.Version; + +/** + * OSGi integration test that mirrors {@link IndexVersionSelectionTest} but runs inside a Pax Exam + * OSGi container so that bundle classloaders naturally separate the two conflicting Lucene versions: + *
    + *
  • {@code oak-lucene} bundle embeds Lucene 4.x (inlined classes)
  • + *
  • {@code oak-search-elastic} bundle embeds Lucene 9.x (via Bundle-ClassPath)
  • + *
+ * Because each bundle's classloader resolves {@code org.apache.lucene.*} from its own embedded + * copy, there is no {@code NoClassDefFoundError} for {@code org.apache.lucene.util.ResourceLoader}. + * This allows {@link ElasticIndexEditorProvider} to be registered normally, so the Elasticsearch + * index is created during {@code root.commit()} and no manual index creation is needed. + *

+ * Index definitions are built directly via the Oak Tree API (no {@code IndexDefinitionBuilder}) + * to avoid importing {@code oak-search} packages that are not exported by any installed bundle. + *

+ * Elasticsearch is started via reflection in {@link #configuration()} (which runs in the JUnit + * runner's classloader, before Felix is launched) and the connection URL is passed into the OSGi + * container as a system property. This avoids any bytecode reference to test-jar or Testcontainers + * classes in the probe bundle. + */ +@RunWith(PaxExam.class) +@ExamReactorStrategy(PerClass.class) +public class IndexVersionSelectionIT { + + private static final String ELASTIC_INDEX_PREFIX = + "oak-it-" + Long.toHexString(System.currentTimeMillis()); + + private ElasticConnection elasticConnection; + private ContentRepository repo; + private ContentSession session; + + @Before + public void setUp() throws Exception { + String connStr = System.getProperty("elasticConnectionString"); + Assume.assumeNotNull( + "No Elasticsearch available — set elasticConnectionString or provide Docker", + connStr); + + // Lucene 4.x SPI (SPIClassIterator) uses Thread.currentThread().getContextClassLoader() + // (TCCL) to discover codec/format implementations. The static initializers of Codec and + // PostingsFormat run the first time those classes are accessed — normally during + // root.commit() / query execution, where the TCCL is not oak-lucene's classloader. + // That causes SPIClassIterator to load Lucene40PostingsFormat from the system classpath + // while PostingsFormat comes from oak-lucene's inlined copy → ClassCastException. + // Fix: force Codec (and transitively PostingsFormat) static initialization here, with + // TCCL set to oak-lucene's classloader, before Oak ever triggers it. + ClassLoader luceneClassLoader = LuceneIndexProvider.class.getClassLoader(); + ClassLoader savedTccl = Thread.currentThread().getContextClassLoader(); + Thread.currentThread().setContextClassLoader(luceneClassLoader); + LuceneIndexProvider luceneProvider; + LuceneIndexEditorProvider luceneEditorProvider; + try { + Class.forName("org.apache.lucene.codecs.Codec", true, luceneClassLoader); + luceneProvider = new LuceneIndexProvider(); + luceneEditorProvider = new LuceneIndexEditorProvider(); + } finally { + Thread.currentThread().setContextClassLoader(savedTccl); + } + + // The Elasticsearch Java client embeds jakarta.json-api and parsson in oak-search-elastic. + // JsonpUtils.provider() uses ServiceLoader with the TCCL to find JsonProvider. When the + // HTTP worker threads (created by RestClient) have the wrong TCCL, they find parsson on + // the system classpath — but JsonProvider comes from the embedded JAR → "not a subtype". + // Fix: set TCCL to oak-search-elastic's classloader while building ElasticConnection so + // the HTTP worker threads inherit the correct TCCL, and ServiceLoader finds parsson from + // the embedded JAR (same classloader as JsonProvider). + ClassLoader elasticClassLoader = ElasticConnection.class.getClassLoader(); + Thread.currentThread().setContextClassLoader(elasticClassLoader); + ElasticIndexTracker elasticTracker; + ElasticIndexProvider elasticProvider; + ElasticIndexEditorProvider elasticEditorProvider; + try { + URI uri = new URI(connStr); + elasticConnection = ElasticConnection.newBuilder() + .withIndexPrefix(ELASTIC_INDEX_PREFIX) + .withConnectionParameters(uri.getScheme(), uri.getHost(), uri.getPort()) + .build(); + elasticTracker = new ElasticIndexTracker( + elasticConnection, new ElasticMetricHandler(StatisticsProvider.NOOP)); + elasticProvider = new ElasticIndexProvider(elasticTracker); + // ElasticIndexEditorProvider can be registered here without NoClassDefFoundError + // because the class is loaded via oak-search-elastic's classloader which has + // Lucene 9.x on its Bundle-ClassPath. + elasticEditorProvider = + new ElasticIndexEditorProvider(elasticTracker, elasticConnection, null); + } finally { + Thread.currentThread().setContextClassLoader(savedTccl); + } + + repo = new Oak(new MemoryNodeStore()) + .with(new InitialContent()) + .with(new OpenSecurityProvider()) + .with((QueryIndexProvider) luceneProvider) + .with((Observer) luceneProvider) + .with(luceneEditorProvider) + .with((QueryIndexProvider) elasticProvider) + .with((Observer) elasticTracker) + .with(elasticEditorProvider) + .with(new PropertyIndexEditorProvider()) + .createContentRepository(); + + session = repo.login(null, null); + } + + @After + public void tearDown() throws Exception { + if (session != null) { + session.close(); + } + if (elasticConnection != null) { + elasticConnection.close(); + } + } + + /** + * Adds a fulltext index definition directly via the Oak Tree API, without using + * {@code IndexDefinitionBuilder} or any {@code oak-search} classes. + */ + private void addIndexDefinition(Tree oakIndex, String name, String type, boolean highCost) { + Tree def = oakIndex.addChild(name); + def.setProperty("jcr:primaryType", "oak:QueryIndexDefinition", Type.NAME); + def.setProperty("type", type); + def.setProperty("tags", Arrays.asList("myTag", type), Type.STRINGS); + def.setProperty("selectionPolicy", "tag"); + + Tree indexRules = def.addChild("indexRules"); + indexRules.setProperty("jcr:primaryType", "nt:unstructured", Type.NAME); + Tree ntBase = indexRules.addChild("nt:base"); + ntBase.setProperty("jcr:primaryType", "nt:unstructured", Type.NAME); + Tree properties = ntBase.addChild("properties"); + properties.setProperty("jcr:primaryType", "nt:unstructured", Type.NAME); + Tree assetProp = properties.addChild("asset"); + assetProp.setProperty("jcr:primaryType", "nt:unstructured", Type.NAME); + assetProp.setProperty("analyzed", true); + assetProp.setProperty("name", "asset"); + + if (highCost) { + def.setProperty("costPerEntry", 1_000_000.0); + def.setProperty("costPerExecution", 1_000_000.0); + } + } + + /** + * Verifies that the latest index version (asset-10-custom-3, of type {@code newType}) is + * selected even when its cost is set to a very high value (1 million), proving that + * version-based selection takes precedence over cost. The index family is: + *

+     *   asset-10          (oldType)
+     *   asset-10-custom-1 (oldType)
+     *   asset-10-custom-2 (oldType)
+     *   asset-10-custom-3 (newType, costPerEntry=costPerExecution=1_000_000)
+     * 
+ * Unlike {@link IndexVersionSelectionTest#latestElasticsearchVersionIsUsedEvenWithHigherCost()}, + * this test does not manually create the Elasticsearch index: {@link ElasticIndexEditorProvider} + * is registered in the Oak setup above and creates it automatically during {@code root.commit()}. + */ + private void testLatestVersionIsUsedEvenWithHigherCost(String oldType, String newType) + throws Exception { + Root root = session.getLatestRoot(); + Tree oakIndex = root.getTree("/oak:index"); + + for (String name : new String[]{"asset-10", "asset-10-custom-1", "asset-10-custom-2"}) { + addIndexDefinition(oakIndex, name, oldType, false); + } + addIndexDefinition(oakIndex, "asset-10-custom-3", newType, true); + + root.getTree("/").addChild("content").setProperty("asset", "test-value"); + root.commit(); + + root = session.getLatestRoot(); + Result result = root.getQueryEngine().executeQuery( + "explain select * from [nt:base] where contains([asset], 'test-value')" + + " option(index tag myTag)", + "JCR-SQL2", + QueryEngine.NO_BINDINGS, + QueryEngine.NO_MAPPINGS); + + String plan = result.getRows().iterator().next().getValue("plan").getValue(Type.STRING); + + // Version selection keeps only asset-10-custom-3 (the latest). The contains() constraint + // prevents traversal, so the high-cost index must be used. + assertTrue("Expected asset-10-custom-3 to be used, but got: " + plan, + plan.contains("asset-10-custom-3")); + } + + @Test + public void latestLuceneVersionIsUsedEvenWithHigherCost() throws Exception { + testLatestVersionIsUsedEvenWithHigherCost("elasticsearch", "lucene"); + } + + @Test + public void latestElasticsearchVersionIsUsedEvenWithHigherCost() throws Exception { + testLatestVersionIsUsedEvenWithHigherCost("lucene", "elasticsearch"); + } + + @Configuration + public Option[] configuration() throws IOException, URISyntaxException { + // This method runs in the JUnit runner's classloader, before Felix is launched. + // We start Elasticsearch here (via reflection, to keep the probe bytecode free of + // test-jar / Testcontainers references) and pass the URL as a system property. + String connStr = System.getProperty("elasticConnectionString"); + if (connStr == null) { + connStr = startElasticViaReflection(); + } + if (connStr != null) { + // Make the URL available to @Before (probe classloader reads System.getProperty) + System.setProperty("elasticConnectionString", connStr); + } + + DefaultCompositeOption options = new DefaultCompositeOption( + junitBundles(), + // require at least DS 1.4 supported by SCR 2.1.0+ + mavenBundle("org.apache.felix", "org.apache.felix.scr", "2.1.28"), + // transitive deps of Felix SCR 2.1.x + mavenBundle("org.osgi", "org.osgi.util.promise", "1.1.1"), + mavenBundle("org.osgi", "org.osgi.util.function", "1.1.0"), + mavenBundle("org.apache.felix", "org.apache.felix.jaas", "1.0.2"), + mavenBundle("org.osgi", "org.osgi.dto", "1.0.0"), + // require at least ConfigAdmin 1.6 supported by felix.configadmin 1.9.0+ + mavenBundle("org.apache.felix", "org.apache.felix.configadmin", "1.9.20"), + mavenBundle("org.apache.felix", "org.apache.felix.fileinstall", "3.2.6"), + mavenBundle("org.ops4j.pax.logging", "pax-logging-api", "1.7.2"), + // Jackson dependency for object serialisation. + mavenBundle().groupId("com.fasterxml.jackson.core").artifactId("jackson-core").version("2.17.2"), + mavenBundle().groupId("com.fasterxml.jackson.core").artifactId("jackson-annotations").version("2.17.2"), + mavenBundle().groupId("com.fasterxml.jackson.core").artifactId("jackson-databind").version("2.17.2"), + frameworkProperty("repository.home").value("target"), + systemProperties(new SystemPropertyOption("felix.fileinstall.dir").value(getConfigDir())), + jarBundles(), + jpmsOptions()); + if (connStr != null) { + options.add(systemProperties(new SystemPropertyOption("elasticConnectionString").value(connStr))); + } + return CoreOptions.options(options); + } + + /** + * Starts Elasticsearch via reflection so that this class has no direct bytecode reference + * to {@code ElasticTestServer} or {@code ElasticsearchContainer}. Both classes are + * available on the Maven test classpath (test-jar and Testcontainers) but are not OSGi + * bundles, so a direct import would prevent the probe bundle from resolving. + * + * @return connection URL (e.g. {@code http://localhost:9200}), or {@code null} if Docker + * is not available + */ + private static String startElasticViaReflection() { + try { + Class serverClass = Class.forName( + "org.apache.jackrabbit.oak.plugins.index.elastic.ElasticTestServer"); + Object container = serverClass.getMethod("getESTestServer").invoke(null); + String host = (String) container.getClass().getMethod("getHost").invoke(container); + int port = (Integer) container.getClass() + .getMethod("getMappedPort", int.class).invoke(container, 9200); + return "http://" + host + ":" + port; + } catch (Exception ignored) { + return null; + } + } + + private String getConfigDir() { + return new File(new File("src", "test"), "config").getAbsolutePath(); + } + + private Option jarBundles() throws MalformedURLException { + DefaultCompositeOption composite = new DefaultCompositeOption(); + for (File f : new File("target", "test-bundles").listFiles()) { + if (f.getName().endsWith(".jar") && f.isFile()) { + composite.add(bundle(f.toURI().toURL().toString())); + } + } + return composite; + } + + private Option jpmsOptions() { + DefaultCompositeOption composite = new DefaultCompositeOption(); + if (Version.parseVersion(System.getProperty("java.specification.version")).getMajor() > 1) { + if (java.nio.file.Files.exists(java.nio.file.FileSystems + .getFileSystem(URI.create("jrt:/")).getPath("modules", "java.se.ee"))) { + composite.add(vmOption("--add-modules=java.se.ee")); + } + composite.add(vmOption("--add-opens=java.base/jdk.internal.loader=ALL-UNNAMED")); + composite.add(vmOption("--add-opens=java.base/java.lang=ALL-UNNAMED")); + composite.add(vmOption("--add-opens=java.base/java.lang.invoke=ALL-UNNAMED")); + composite.add(vmOption("--add-opens=java.base/java.io=ALL-UNNAMED")); + composite.add(vmOption("--add-opens=java.base/java.net=ALL-UNNAMED")); + composite.add(vmOption("--add-opens=java.base/java.nio=ALL-UNNAMED")); + composite.add(vmOption("--add-opens=java.base/java.util=ALL-UNNAMED")); + composite.add(vmOption("--add-opens=java.base/java.util.jar=ALL-UNNAMED")); + composite.add(vmOption("--add-opens=java.base/java.util.regex=ALL-UNNAMED")); + composite.add(vmOption("--add-opens=java.base/java.util.zip=ALL-UNNAMED")); + composite.add(vmOption("--add-opens=java.base/sun.nio.ch=ALL-UNNAMED")); + } + return composite; + } +} diff --git a/oak-it-osgi/src/test/java/org/apache/jackrabbit/oak/osgi/IndexVersionSelectionTest.java b/oak-it-osgi/src/test/java/org/apache/jackrabbit/oak/osgi/IndexVersionSelectionTest.java new file mode 100644 index 00000000000..180b7f5eec2 --- /dev/null +++ b/oak-it-osgi/src/test/java/org/apache/jackrabbit/oak/osgi/IndexVersionSelectionTest.java @@ -0,0 +1,222 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.jackrabbit.oak.osgi; + +import org.apache.jackrabbit.oak.InitialContent; +import org.apache.jackrabbit.oak.Oak; +import org.apache.jackrabbit.oak.api.ContentRepository; +import org.apache.jackrabbit.oak.api.ContentSession; +import org.apache.jackrabbit.oak.api.QueryEngine; +import org.apache.jackrabbit.oak.api.Result; +import org.apache.jackrabbit.oak.api.Root; +import org.apache.jackrabbit.oak.api.Tree; +import org.apache.jackrabbit.oak.api.Type; +import org.apache.jackrabbit.oak.plugins.index.elastic.ElasticConnection; +import org.apache.jackrabbit.oak.plugins.index.elastic.ElasticConnectionRule; +import org.apache.jackrabbit.oak.plugins.index.elastic.ElasticIndexNameHelper; +import org.apache.jackrabbit.oak.plugins.index.elastic.ElasticIndexTracker; +import org.apache.jackrabbit.oak.plugins.index.elastic.ElasticMetricHandler; +import org.apache.jackrabbit.oak.plugins.index.elastic.query.ElasticIndexProvider; +import org.apache.jackrabbit.oak.plugins.index.elastic.util.ElasticIndexDefinitionBuilder; +import org.apache.jackrabbit.oak.plugins.index.lucene.LuceneIndexEditorProvider; +import org.apache.jackrabbit.oak.plugins.index.lucene.LuceneIndexProvider; +import org.apache.jackrabbit.oak.plugins.index.lucene.util.LuceneIndexDefinitionBuilder; +import org.apache.jackrabbit.oak.plugins.index.nodetype.NodeTypeIndexProvider; +import org.apache.jackrabbit.oak.plugins.index.property.PropertyIndexEditorProvider; +import org.apache.jackrabbit.oak.plugins.index.search.FulltextIndexConstants; +import org.apache.jackrabbit.oak.plugins.index.search.util.IndexDefinitionBuilder; +import org.apache.jackrabbit.oak.plugins.memory.MemoryNodeStore; +import org.apache.jackrabbit.oak.spi.commit.Observer; +import org.apache.jackrabbit.oak.spi.query.QueryIndexProvider; +import org.apache.jackrabbit.oak.spi.security.OpenSecurityProvider; +import org.apache.jackrabbit.oak.stats.StatisticsProvider; +import org.junit.After; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Test; + +import static org.apache.jackrabbit.oak.plugins.index.IndexConstants.INDEX_DEFINITIONS_NAME; +import static org.junit.Assert.assertTrue; + +/** + * Tests that verify index version selection behavior when multiple versions of an index exist + * across different index types (lucene and elasticsearch). + */ +public class IndexVersionSelectionTest { + + @ClassRule + public static ElasticConnectionRule elasticRule = + new ElasticConnectionRule(System.getProperty("elasticConnectionString")); + + private ElasticConnection elasticConnection; + private ContentRepository repo; + private ContentSession session; + + @Before + public void setUp() throws Exception { + LuceneIndexProvider luceneProvider = new LuceneIndexProvider(); + LuceneIndexEditorProvider luceneEditorProvider = new LuceneIndexEditorProvider(); + + elasticConnection = elasticRule.useDocker() + ? elasticRule.getElasticConnectionForDocker() + : elasticRule.getElasticConnectionFromString(); + ElasticIndexTracker elasticTracker = new ElasticIndexTracker( + elasticConnection, + new ElasticMetricHandler(StatisticsProvider.NOOP)); + ElasticIndexProvider elasticProvider = new ElasticIndexProvider(elasticTracker); + // ElasticIndexEditorProvider is intentionally not registered here. See the comment + // in testLatestVersionIsUsedEvenWithHigherCost() for the full explanation. + + repo = new Oak(new MemoryNodeStore()) + .with(new InitialContent()) + .with(new OpenSecurityProvider()) + .with((QueryIndexProvider) luceneProvider) + .with((Observer) luceneProvider) + .with(luceneEditorProvider) + .with((QueryIndexProvider) elasticProvider) + .with((Observer) elasticTracker) + .with(new PropertyIndexEditorProvider()) + .with(new NodeTypeIndexProvider()) + .createContentRepository(); + + session = repo.login(null, null); + } + + @After + public void tearDown() throws Exception { + if (session != null) { + session.close(); + } + if (elasticConnection != null) { + elasticConnection.close(); + } + } + + /** + * Returns a fresh index definition builder for the given type, configured with + * {@code noAsync()} so that the index definition is registered in the respective + * index tracker via the Observer callback during commit, making query plans + * available immediately after {@code root.commit()} returns. + */ + private IndexDefinitionBuilder newBuilder(String type) { + if ("lucene".equals(type)) { + return new LuceneIndexDefinitionBuilder().noAsync(); + } + return new ElasticIndexDefinitionBuilder().noAsync(); + } + + /** + * Verifies that the latest index version (asset-10-custom-3, of type {@code newType}) is + * selected even when its cost is set to a very high value (1 million), proving that + * version-based selection takes precedence over cost. The index family is: + *
+     *   asset-10          (oldType)
+     *   asset-10-custom-1 (oldType)
+     *   asset-10-custom-2 (oldType)
+     *   asset-10-custom-3 (newType, costPerEntry=costPerExecution=1_000_000)
+     * 
+ * All indexes carry the tag "myTag" and use selectionPolicy="tag". The query uses + * {@code option(index tag myTag)} and is a {@code contains()} query so that traversal + * is not an option. Version selection must filter all but asset-10-custom-3 from the + * candidate list regardless of its higher cost. + */ + private void testLatestVersionIsUsedEvenWithHigherCost(String oldType, String newType) + throws Exception { + Root root = session.getLatestRoot(); + Tree oakIndex = root.getTree("/" + INDEX_DEFINITIONS_NAME); + + for (String name : new String[]{"asset-10", "asset-10-custom-1", "asset-10-custom-2"}) { + IndexDefinitionBuilder b = newBuilder(oldType); + b.tags("myTag", oldType); + b.selectionPolicy("tag"); + b.indexRule("nt:base").property("asset").analyzed(); + b.build(oakIndex.addChild(name)); + } + + // asset-10-custom-3: same setup but with a very high cost. Version selection must + // still pick this index (it is the latest) and not fall back to a cheaper older version. + IndexDefinitionBuilder b = newBuilder(newType); + b.tags("myTag", newType); + b.selectionPolicy("tag"); + b.indexRule("nt:base").property("asset").analyzed(); + Tree custom3Tree = oakIndex.addChild("asset-10-custom-3"); + b.build(custom3Tree); + custom3Tree.setProperty(FulltextIndexConstants.COST_PER_ENTRY, 1_000_000.0); + custom3Tree.setProperty(FulltextIndexConstants.COST_PER_EXECUTION, 1_000_000.0); + + root.getTree("/").addChild("content").setProperty("asset", "test-value"); + root.commit(); + + // Why we create the ES index manually instead of registering ElasticIndexEditorProvider: + // + // Normally, ElasticIndexEditorProvider would create the Elasticsearch index during + // root.commit(). However, this module's test classpath has a Lucene version conflict: + // oak-lucene depends on Lucene 4.7.2, while oak-search-elastic requires Lucene 9.x. + // Maven's dependency resolution picks lucene-core:4.7.2 (the nearer declaration), so + // lucene-core:9.x is absent. ElasticIndexEditorProvider transitively loads + // ElasticCustomAnalyzer, which imports org.apache.lucene.util.ResourceLoader — a class + // that only exists in lucene-core 9.x (in 4.x it had a different package). The result is + // a NoClassDefFoundError at commit time, even when no custom analyzers are configured. + // + // This test does not need to index actual content — it only checks which index the query + // planner *selects* (via EXPLAIN). For plan generation, ElasticIndexStatistics.numDocs() + // is called to estimate the entry count; it issues a COUNT request to Elasticsearch and + // throws index_not_found_exception if the index does not exist. That exception propagates + // as UncheckedExecutionException and is caught in FulltextIndex.getPlans(), which silently + // skips the index — causing the test to fail with "traverse allNodes". + // + // The fix: after committing the Oak index definitions (which registers the definition in + // the ElasticIndexTracker via the Observer callback), we create an empty Elasticsearch + // index directly via the REST client. numDocs() then returns 0, the planner can generate + // a plan, and version selection is exercised correctly. + String elasticName; + if ("elasticsearch".equals(newType)) { + elasticName = "asset-10-custom-3"; + } else { + elasticName = "asset-10-custom-2"; + } + String alias = ElasticIndexNameHelper.getElasticSafeIndexName( + elasticConnection.getIndexPrefix(), + "/" + INDEX_DEFINITIONS_NAME + "/" + elasticName); + elasticConnection.getClient().indices().create(c -> c.index(alias)); + + root = session.getLatestRoot(); + Result result = root.getQueryEngine().executeQuery( + "explain select * from [nt:base] where contains([asset], 'test-value')" + + " option(index tag myTag)", + "JCR-SQL2", + QueryEngine.NO_BINDINGS, + QueryEngine.NO_MAPPINGS); + + String plan = result.getRows().iterator().next().getValue("plan").getValue(Type.STRING); + + // Version selection keeps only asset-10-custom-3 (the latest). The contains() constraint + // prevents traversal, so the high-cost index must be used. + assertTrue("Expected asset-10-custom-3 to be used, but got: " + plan, + plan.contains("asset-10-custom-3")); + } + + @Test + public void latestLuceneVersionIsUsedEvenWithHigherCost() throws Exception { + testLatestVersionIsUsedEvenWithHigherCost("elasticsearch", "lucene"); + } + + @Test + public void latestElasticsearchVersionIsUsedEvenWithHigherCost() throws Exception { + testLatestVersionIsUsedEvenWithHigherCost("lucene", "elasticsearch"); + } +} diff --git a/oak-lucene/src/main/java/org/apache/jackrabbit/oak/plugins/index/lucene/LuceneIndexProvider.java b/oak-lucene/src/main/java/org/apache/jackrabbit/oak/plugins/index/lucene/LuceneIndexProvider.java index f9c3a09ff7f..243f97497f1 100644 --- a/oak-lucene/src/main/java/org/apache/jackrabbit/oak/plugins/index/lucene/LuceneIndexProvider.java +++ b/oak-lucene/src/main/java/org/apache/jackrabbit/oak/plugins/index/lucene/LuceneIndexProvider.java @@ -25,11 +25,13 @@ import org.apache.jackrabbit.oak.spi.query.QueryIndex; import org.apache.jackrabbit.oak.spi.query.QueryIndexProvider; import org.apache.jackrabbit.oak.spi.state.NodeState; +import org.apache.jackrabbit.oak.spi.toggle.Feature; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; /** * A provider for Lucene indexes. - * + * * @see LuceneIndex */ public class LuceneIndexProvider implements QueryIndexProvider, Observer, Closeable { @@ -40,6 +42,12 @@ public class LuceneIndexProvider implements QueryIndexProvider, Observer, Closea IndexAugmentorFactory augmentorFactory; + @Nullable private Feature filterGloballySupersededFeature; + + public void setFilterGloballySupersededFeature(@Nullable Feature feature) { + this.filterGloballySupersededFeature = feature; + } + public LuceneIndexProvider() { this(new IndexTracker()); } @@ -80,7 +88,9 @@ protected LuceneIndex newLuceneIndex() { } protected LucenePropertyIndex newLucenePropertyIndex() { - return new LucenePropertyIndex(tracker, augmentorFactory); + LucenePropertyIndex index = new LucenePropertyIndex(tracker, augmentorFactory); + index.setFilterGloballySupersededFeature(filterGloballySupersededFeature); + return index; } /** 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 f2876dc527e..342c6ac031b 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 @@ -56,6 +56,7 @@ import org.apache.jackrabbit.oak.plugins.index.lucene.property.PropertyIndexCleaner; import org.apache.jackrabbit.oak.plugins.index.lucene.reader.DefaultIndexReaderFactory; import org.apache.jackrabbit.oak.plugins.index.search.ExtractedTextCache; +import org.apache.jackrabbit.oak.plugins.index.search.spi.query.FulltextIndex; import org.apache.jackrabbit.oak.plugins.index.search.IndexDefinition; import org.apache.jackrabbit.oak.plugins.index.search.TextExtractionStatsMBean; import org.apache.jackrabbit.oak.plugins.index.search.spi.query.FulltextIndexPlanner; @@ -70,6 +71,7 @@ import org.apache.jackrabbit.oak.spi.query.QueryIndexProvider; import org.apache.jackrabbit.oak.spi.state.Clusterable; import org.apache.jackrabbit.oak.spi.state.NodeStore; +import org.apache.jackrabbit.oak.spi.toggle.Feature; import org.apache.jackrabbit.oak.spi.whiteboard.Registration; import org.apache.jackrabbit.oak.spi.whiteboard.Whiteboard; import org.apache.jackrabbit.oak.stats.Clock; @@ -268,6 +270,7 @@ public class LuceneIndexProviderService { private static final int INDEX_COPIER_POOL_SIZE = 5; private LuceneIndexProvider indexProvider; + private Feature filterGloballySupersededFeature; private final List regs = new ArrayList<>(); private final List oakRegs = new ArrayList<>(); @@ -378,6 +381,9 @@ private void activate(BundleContext bundleContext, Configuration config) throws initializeExtractedTextCache(bundleContext, config, statisticsProvider); tracker = createTracker(bundleContext, config); indexProvider = new LuceneIndexProvider(tracker, augmentorFactory); + filterGloballySupersededFeature = Feature.newFeature( + FulltextIndex.FT_FILTER_GLOBALLY_SUPERSEDED, whiteboard); + indexProvider.setFilterGloballySupersededFeature(filterGloballySupersededFeature); initializeActiveBlobCollector(whiteboard, config); initializeLogging(config); initialize(); @@ -418,6 +424,10 @@ private void deactivate() throws InterruptedException, IOException { reg.unregister(); } + if (filterGloballySupersededFeature != null) { + filterGloballySupersededFeature.close(); + } + if (backgroundObserver != null){ backgroundObserver.close(); } diff --git a/oak-search-elastic/pom.xml b/oak-search-elastic/pom.xml index ba686f298e7..63dcac0cce5 100644 --- a/oak-search-elastic/pom.xml +++ b/oak-search-elastic/pom.xml @@ -44,6 +44,12 @@ maven-bundle-plugin + + org.apache.jackrabbit.oak.plugins.index.elastic, + org.apache.jackrabbit.oak.plugins.index.elastic.index, + org.apache.jackrabbit.oak.plugins.index.elastic.query, + org.apache.jackrabbit.oak.plugins.index.elastic.util + <_exportcontents> org.apache.jackrabbit.oak.plugins.index.elastic.ElasticIndexProviderService 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 37d0697086d..4d15ea8c1fd 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 @@ -30,12 +30,14 @@ import org.apache.jackrabbit.oak.plugins.index.elastic.query.inference.InferenceMBeanImpl; import org.apache.jackrabbit.oak.plugins.index.fulltext.PreExtractedTextProvider; import org.apache.jackrabbit.oak.plugins.index.search.ExtractedTextCache; +import org.apache.jackrabbit.oak.plugins.index.search.spi.query.FulltextIndex; import org.apache.jackrabbit.oak.plugins.index.search.spi.query.FulltextIndexPlanner; import org.apache.jackrabbit.oak.query.QueryEngineSettings; import org.apache.jackrabbit.oak.spi.toggle.FeatureToggle; import org.apache.jackrabbit.oak.spi.commit.Observer; import org.apache.jackrabbit.oak.spi.query.QueryIndexProvider; import org.apache.jackrabbit.oak.spi.state.NodeStore; +import org.apache.jackrabbit.oak.spi.toggle.Feature; import org.apache.jackrabbit.oak.spi.whiteboard.Registration; import org.apache.jackrabbit.oak.spi.whiteboard.Whiteboard; import org.apache.jackrabbit.oak.stats.StatisticsProvider; @@ -190,6 +192,7 @@ public class ElasticIndexProviderService { private final List oakRegs = new ArrayList<>(); private Whiteboard whiteboard; + private Feature filterGloballySupersededFeature; private ElasticConnection elasticConnection; private ElasticMetricHandler metricHandler; @@ -277,6 +280,10 @@ private void deactivate() { reg.unregister(); } + if (filterGloballySupersededFeature != null) { + filterGloballySupersededFeature.close(); + } + try { this.elasticIndexEditorProvider.close(); } catch (Exception e) { @@ -304,6 +311,9 @@ private void registerIndexProvider(BundleContext bundleContext, Config config) { long facetsEvaluationTimeoutMs = Long.getLong(PROP_ELASTIC_FACETS_EVALUATION_TIMEOUT_MS, config.elasticsearch_facetsEvaluationTimeoutMs()); ElasticIndexProvider indexProvider = new ElasticIndexProvider(indexTracker, asyncIteratorEnqueueTimeoutMs, facetsEvaluationTimeoutMs); + filterGloballySupersededFeature = Feature.newFeature( + FulltextIndex.FT_FILTER_GLOBALLY_SUPERSEDED, whiteboard); + indexProvider.setFilterGloballySupersededFeature(filterGloballySupersededFeature); Dictionary props = new Hashtable<>(); props.put("type", ElasticIndexDefinition.TYPE_ELASTICSEARCH); diff --git a/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/query/ElasticIndexProvider.java b/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/query/ElasticIndexProvider.java index a032df312bf..55bbba401d3 100644 --- a/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/query/ElasticIndexProvider.java +++ b/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/query/ElasticIndexProvider.java @@ -21,7 +21,9 @@ import org.apache.jackrabbit.oak.spi.query.QueryIndex; import org.apache.jackrabbit.oak.spi.query.QueryIndexProvider; import org.apache.jackrabbit.oak.spi.state.NodeState; +import org.apache.jackrabbit.oak.spi.toggle.Feature; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.util.List; @@ -36,6 +38,12 @@ public class ElasticIndexProvider implements QueryIndexProvider { private final long asyncIteratorEnqueueTimeoutMs; private final long facetsEvaluationTimeoutMs; + @Nullable private Feature filterGloballySupersededFeature; + + public void setFilterGloballySupersededFeature(@Nullable Feature feature) { + this.filterGloballySupersededFeature = feature; + } + public ElasticIndexProvider(ElasticIndexTracker indexTracker, long asyncIteratorEnqueueTimeoutMs, long facetsEvaluationTimeoutMs) { @@ -61,6 +69,8 @@ public long getFacetsEvaluationTimeoutMs() { @Override public @NotNull List getQueryIndexes(NodeState nodeState) { - return List.of(new ElasticIndex(indexTracker, asyncIteratorEnqueueTimeoutMs, facetsEvaluationTimeoutMs)); + ElasticIndex index = new ElasticIndex(indexTracker, asyncIteratorEnqueueTimeoutMs, facetsEvaluationTimeoutMs); + index.setFilterGloballySupersededFeature(filterGloballySupersededFeature); + return List.of(index); } } diff --git a/oak-search/src/main/java/org/apache/jackrabbit/oak/plugins/index/search/spi/query/FulltextIndex.java b/oak-search/src/main/java/org/apache/jackrabbit/oak/plugins/index/search/spi/query/FulltextIndex.java index 340e5b27449..bf796fae340 100644 --- a/oak-search/src/main/java/org/apache/jackrabbit/oak/plugins/index/search/spi/query/FulltextIndex.java +++ b/oak-search/src/main/java/org/apache/jackrabbit/oak/plugins/index/search/spi/query/FulltextIndex.java @@ -44,7 +44,9 @@ import org.apache.jackrabbit.oak.spi.query.QueryLimits; import org.apache.jackrabbit.oak.spi.query.fulltext.FullTextExpression; import org.apache.jackrabbit.oak.spi.state.NodeState; +import org.apache.jackrabbit.oak.spi.toggle.Feature; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -52,12 +54,15 @@ import java.io.IOException; import java.util.ArrayList; import java.util.Collection; +import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.function.Predicate; +import static org.apache.jackrabbit.oak.plugins.index.IndexConstants.TYPE_PROPERTY_NAME; import static org.apache.jackrabbit.oak.spi.query.QueryIndex.AdvancedQueryIndex; import static org.apache.jackrabbit.oak.spi.query.QueryIndex.NativeQueryIndex; @@ -76,6 +81,17 @@ public abstract class FulltextIndex implements AdvancedQueryIndex, QueryIndex, N private static final double MIN_COST = 2.1; + // Index types that may compete; other types (e.g. "disabled") are excluded + private static final Set COMPETING_INDEX_TYPES = Set.of("lucene", "elasticsearch"); + + public static final String FT_FILTER_GLOBALLY_SUPERSEDED = "FT_OAK-12146"; + + @Nullable private Feature filterGloballySupersededFeature; + + public void setFilterGloballySupersededFeature(@Nullable Feature feature) { + this.filterGloballySupersededFeature = feature; + } + protected abstract IndexNode acquireIndexNode(String indexPath); protected abstract String getType(); @@ -121,6 +137,29 @@ public List getPlans(Filter filter, List sortOrder, NodeS } else { indexPaths = IndexName.filterNewestIndexes(indexPaths); } + if (filterGloballySupersededFeature == null || filterGloballySupersededFeature.isEnabled()) { + // first collect indexes of the other types (collect lucene indexes if we look at elastic, + // and vice versa) + Collection allCompetingPathsOfOtherTypes = new IndexLookup(rootState, + state -> { + String type = state.getString(TYPE_PROPERTY_NAME); + if (type == null) { + // indexes without type don't compete (to avoid NPE) + return false; + } else if (getIndexDefinitionPredicate().test(state)) { + // index of this type don't compete. this is to avoid + // that indexes that are disabled are considered competing + return false; + } + return COMPETING_INDEX_TYPES.contains(type); + }) + .collectIndexNodePaths(filter); + // build a combined set: these indexes of other types, + HashSet allCompetingPaths = new HashSet<>(allCompetingPathsOfOtherTypes); + // plus the _active_ indexes of the current type + allCompetingPaths.addAll(indexPaths); + indexPaths = IndexName.filterGloballySuperseded(indexPaths, allCompetingPaths); + } List plans = new ArrayList<>(indexPaths.size()); for (String path : indexPaths) { IndexNode indexNode = null;