From 2f564a4c075c25f7deff2db79c6a7c487bf0f92a Mon Sep 17 00:00:00 2001 From: Benjamin Habegger Date: Fri, 12 Jun 2026 12:23:23 +0200 Subject: [PATCH 1/3] =?UTF-8?q?OAK-12249:=20lazy=20ES=20index=20provisioni?= =?UTF-8?q?ng=20=E2=80=94=20skip=20creation=20for=20empty=20reindex?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When FT_OAK-12249 and FT_OAK-12248 are both enabled, ElasticIndexWriter defers provisionIndex() from the constructor to the first updateDocument() or deleteDocuments() call. A reindex that produces zero documents never creates an Elasticsearch index or alias, eliminating the empty-index problem described in OAK-12249. Deployment order is enforced at runtime: isLazyProvisioningActive() returns true only when both toggles are on. Enabling FT_OAK-12249 alone logs a WARN and falls back to eager provisioning, preventing 404 errors on query paths that lack graceful 404 handling. ensureProvisioned() handles the incremental-write-after-empty-reindex case: if an alias does not exist when the first document arrives, it creates a new backing index with a fresh seed and points the alias at it. Co-Authored-By: Claude Sonnet 4.6 --- .../index/elastic/ElasticIndexDefinition.java | 11 ++ .../elastic/ElasticIndexProviderService.java | 3 + .../index/ElasticIndexEditorContext.java | 5 +- .../index/ElasticIndexEditorProvider.java | 37 +++++++ .../elastic/index/ElasticIndexWriter.java | 64 +++++------ .../index/ElasticIndexWriterFactory.java | 22 +++- .../elastic/index/LazyElasticIndexWriter.java | 85 +++++++++++++++ .../elastic/index/ElasticIndexWriterTest.java | 100 ++++++++++++++++++ 8 files changed, 292 insertions(+), 35 deletions(-) create mode 100644 oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/index/LazyElasticIndexWriter.java diff --git a/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/ElasticIndexDefinition.java b/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/ElasticIndexDefinition.java index ad3fca090f9..b17ed2e09bd 100644 --- a/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/ElasticIndexDefinition.java +++ b/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/ElasticIndexDefinition.java @@ -87,6 +87,13 @@ public class ElasticIndexDefinition extends IndexDefinition { */ public static final String PROP_INDEX_NAME_SEED = ":nameSeed"; + /** + * Hidden property written when a lazy reindex (OAK-12249) produced zero documents and left no + * ES index or alias. Signals the next incremental-write cycle to provision the index on demand. + * Cleared once provisioning completes. + */ + public static final String PROP_REQUIRES_PROVISIONING = ":requiresProvisioning"; + /** * Hidden property to store similarity tags */ @@ -268,6 +275,10 @@ public String getIndexAlias() { return indexAlias; } + public boolean requiresProvisioning() { + return getOptionalValue(getDefinitionNodeState(), PROP_REQUIRES_PROVISIONING, false); + } + public Map> getPropertiesByName() { return propertiesByName; } 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 5ecac5e4078..b94fd5dc6ff 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 @@ -236,6 +236,9 @@ private void activate(BundleContext bundleContext, Config config) { oakRegs.add(whiteboard.register(FeatureToggle.class, new FeatureToggle(ElasticIndexStatistics.FT_OAK_12248, ElasticIndexStatistics.FT_OAK_12248_ENABLE), emptyMap())); + oakRegs.add(whiteboard.register(FeatureToggle.class, + new FeatureToggle(ElasticIndexEditorProvider.FT_OAK_12249, ElasticIndexEditorProvider.FT_OAK_12249_ENABLE), + emptyMap())); if (System.getProperty(QueryEngineSettings.OAK_INFERENCE_ENABLED) != null) { this.isInferenceEnabled = Boolean.parseBoolean(System.getProperty(QueryEngineSettings.OAK_INFERENCE_ENABLED)); } else { diff --git a/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/index/ElasticIndexEditorContext.java b/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/index/ElasticIndexEditorContext.java index 4e7f68cf958..fedf7688338 100644 --- a/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/index/ElasticIndexEditorContext.java +++ b/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/index/ElasticIndexEditorContext.java @@ -23,6 +23,7 @@ import org.apache.jackrabbit.oak.plugins.index.search.IndexDefinition; import org.apache.jackrabbit.oak.plugins.index.search.spi.editor.DocumentMaker; import org.apache.jackrabbit.oak.plugins.index.search.spi.editor.FulltextIndexEditorContext; +import org.apache.jackrabbit.oak.plugins.index.search.spi.editor.FulltextIndexWriter; import org.apache.jackrabbit.oak.spi.state.NodeBuilder; import org.apache.jackrabbit.oak.spi.state.NodeState; import org.jetbrains.annotations.Nullable; @@ -50,8 +51,8 @@ public DocumentMaker newDocumentMaker(IndexDefinition.IndexingR } @Override - public ElasticIndexWriter getWriter() { - return (ElasticIndexWriter) super.getWriter(); + public FulltextIndexWriter getWriter() { + return super.getWriter(); } @Override diff --git a/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/index/ElasticIndexEditorProvider.java b/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/index/ElasticIndexEditorProvider.java index d116495c74c..8dc59cb0ec7 100644 --- a/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/index/ElasticIndexEditorProvider.java +++ b/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/index/ElasticIndexEditorProvider.java @@ -23,6 +23,7 @@ import org.apache.jackrabbit.oak.plugins.index.IndexingContext; import org.apache.jackrabbit.oak.plugins.index.elastic.ElasticConnection; import org.apache.jackrabbit.oak.plugins.index.elastic.ElasticIndexDefinition; +import org.apache.jackrabbit.oak.plugins.index.elastic.ElasticIndexStatistics; import org.apache.jackrabbit.oak.plugins.index.elastic.ElasticIndexTracker; import org.apache.jackrabbit.oak.plugins.index.search.ExtractedTextCache; import org.apache.jackrabbit.oak.plugins.index.search.spi.editor.FulltextIndexEditor; @@ -32,6 +33,8 @@ import org.apache.jackrabbit.oak.spi.state.NodeState; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.io.IOException; import java.util.concurrent.atomic.AtomicBoolean; @@ -41,6 +44,8 @@ public class ElasticIndexEditorProvider implements IndexEditorProvider { + private static final Logger LOG = LoggerFactory.getLogger(ElasticIndexEditorProvider.class); + private final ElasticIndexTracker indexTracker; private final ElasticConnection elasticConnection; private final ExtractedTextCache extractedTextCache; @@ -57,6 +62,38 @@ public class ElasticIndexEditorProvider implements IndexEditorProvider { */ public static final AtomicBoolean FT_OAK_12206_DISABLE = new AtomicBoolean(false); + public static final String FT_OAK_12249 = "FT_OAK-12249"; + /** + * When {@code true} AND {@link ElasticIndexStatistics#FT_OAK_12248_ENABLE} is also {@code true}, + * Elasticsearch index provisioning is deferred to the first {@code updateDocument()} or + * {@code deleteDocuments()} call. A reindex that produces no documents never creates an ES + * index or alias. + * + *

Requires {@code FT_OAK-12248} (graceful 404 handling) to be enabled first. Enabling + * lazy provisioning without graceful 404 handling would cause unhandled ES 404 errors on every + * query against an empty-reindexed index. {@link #isLazyProvisioningActive()} enforces this + * dependency at runtime. + * + *

Disabled by default. + */ + public static final AtomicBoolean FT_OAK_12249_ENABLE = new AtomicBoolean(false); + + /** + * Returns {@code true} when lazy provisioning is active. Requires both this toggle and + * {@link ElasticIndexStatistics#FT_OAK_12248_ENABLE} to be {@code true}. The combined check + * enforces the deployment order: graceful 404 handling (OAK-12248) must be on before lazy + * provisioning (OAK-12249) can take effect. + */ + public static boolean isLazyProvisioningActive() { + boolean lazyProvisioningRequested = FT_OAK_12249_ENABLE.get(); + boolean graceful404Active = ElasticIndexStatistics.FT_OAK_12248_ENABLE.get(); + if (lazyProvisioningRequested && !graceful404Active) { + LOG.warn("{} is enabled but {} (graceful 404 handling) is not — lazy provisioning stays " + + "inactive until both toggles are enabled", FT_OAK_12249, ElasticIndexStatistics.FT_OAK_12248); + } + return lazyProvisioningRequested && graceful404Active; + } + private final boolean OAK_INDEX_ELASTIC_WRITER_DISABLE = Boolean.getBoolean(OAK_INDEX_ELASTIC_WRITER_DISABLE_KEY); public ElasticIndexEditorProvider(@NotNull ElasticIndexTracker indexTracker, 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 9e71388e428..ceca172e5dd 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 @@ -62,46 +62,46 @@ class ElasticIndexWriter implements FulltextIndexWriter { private final ElasticConnection elasticConnection; private final ElasticIndexDefinition indexDefinition; private final ElasticBulkProcessorHandler bulkProcessorHandler; - private final boolean reindex; + private final boolean requiresProvisioning; private final String indexName; private final ElasticRetryPolicy retryPolicy; + private final NodeBuilder definitionBuilder; ElasticIndexWriter(@NotNull ElasticIndexTracker indexTracker, @NotNull ElasticConnection elasticConnection, @NotNull ElasticIndexDefinition indexDefinition, @NotNull NodeBuilder definitionBuilder, - boolean reindex, CommitInfo commitInfo, + boolean requiresProvisioning, CommitInfo commitInfo, ElasticBulkProcessorHandler bulkProcessorHandler, ElasticRetryPolicy retryPolicy) { this.indexTracker = indexTracker; this.elasticConnection = elasticConnection; this.indexDefinition = indexDefinition; - this.reindex = reindex; + this.requiresProvisioning = requiresProvisioning; this.bulkProcessorHandler = bulkProcessorHandler; this.retryPolicy = retryPolicy; + this.definitionBuilder = definitionBuilder; - // We don't use stored index definitions with elastic. Every time a new writer gets created we - // use the actual index name (based on the current seed) while reindexing, or the alias (pointing to the - // old index until the new one gets enabled) during incremental reindexing - if (this.reindex) { + if (requiresProvisioning) { + // Full provisioning: generate a seed-based backing index, create it in ES, and prepare + // for alias flip on close(). Applies to both a standard reindex and an incremental write + // arriving after a lazy reindex that produced zero documents (OAK-12249). try { //TODO we should observe changes under inference config path. InferenceConfig.reInitialize(); - // refresh inference config on any index reindex. long seed = indexDefinition.indexNameSeed == 0L ? UUID.randomUUID().getMostSignificantBits() : indexDefinition.indexNameSeed; - // merge gets called on node store later in the indexing flow definitionBuilder.setProperty(ElasticIndexDefinition.PROP_INDEX_NAME_SEED, seed); - // let's store the current mapping version in the index definition definitionBuilder.setProperty(ElasticIndexDefinition.PROP_INDEX_MAPPING_VERSION, ElasticIndexDefinition.MAPPING_VERSION.toString()); - + definitionBuilder.removeProperty(ElasticIndexDefinition.PROP_REQUIRES_PROVISIONING); indexName = ElasticIndexNameHelper. getRemoteIndexName(elasticConnection.getIndexPrefix(), indexDefinition.getIndexPath(), seed); - provisionIndex(); } catch (IOException e) { throw new IllegalStateException("Unable to provision index", e); } - } else indexName = indexDefinition.getIndexAlias(); + } else { + indexName = indexDefinition.getIndexAlias(); + } boolean waitForESAcknowledgement = true; PropertyState async = indexDefinition.getDefinitionNodeState().getProperty("async"); if (async != null) { @@ -132,14 +132,15 @@ class ElasticIndexWriter implements FulltextIndexWriter { @NotNull ElasticIndexDefinition indexDefinition, @NotNull ElasticBulkProcessorHandler bulkProcessorHandler, @NotNull ElasticRetryPolicy retryPolicy, - boolean reindex) { + boolean requiresProvisioning) { this.indexTracker = indexTracker; this.elasticConnection = elasticConnection; this.indexDefinition = indexDefinition; this.bulkProcessorHandler = bulkProcessorHandler; this.indexName = indexDefinition.getIndexAlias(); this.retryPolicy = retryPolicy; - this.reindex = reindex; + this.requiresProvisioning = requiresProvisioning; + this.definitionBuilder = null; } @Override @@ -155,7 +156,7 @@ public void updateDocument(String path, ElasticDocument doc) throws IOException AND InferenceIndexConfig is NOOP ) */ - if (reindex + if (requiresProvisioning || (!indexDefinition.isExternallyModifiable() && !InferenceConfig.getInstance().isInferenceEnabled() && (InferenceIndexConfig.NOOP.equals(InferenceConfig.getInstance().getInferenceIndexConfig(jcrIndexName))))) { @@ -196,8 +197,7 @@ public void deleteDocument(String path) throws IOException { @Override public boolean close(long timestamp) throws IOException { boolean updateStatus = bulkProcessorHandler.flushIndex(indexName); - if (reindex) { - // if we are closing a writer in reindex mode, it means we need to open the new index for queries + if (requiresProvisioning) { this.enableIndex(); } if (updateStatus) { @@ -226,43 +226,47 @@ private void saveMetrics() { private void provisionIndex() throws IOException { final ElasticsearchIndicesClient esClient = elasticConnection.getClient().indices(); - // check if index already exists if (esClient.exists(i -> i.index(indexName)).value()) { LOG.info("Index {} already exists. Skip index provision", indexName); return; } + createIndex(indexName); + } + /** + * Builds a {@link CreateIndexRequest} for {@code backingIndexName} and submits it to + * Elasticsearch, with debug logging and idempotent handling of concurrent-creation races. + */ + private void createIndex(String backingIndexName) throws IOException { + final ElasticsearchIndicesClient esClient = elasticConnection.getClient().indices(); CreateIndexRequest request; try { - request = ElasticIndexHelper.createIndexRequest(indexName, indexDefinition); + request = ElasticIndexHelper.createIndexRequest(backingIndexName, indexDefinition); } catch (Exception e) { - LOG.error("Failed to create index {}: {}", indexName, e.toString()); + LOG.error("Failed to create index {}: {}", backingIndexName, e.toString()); throw e; } if (LOG.isDebugEnabled()) { int old = JsonpUtils.maxToStringLength(); try { - // temporarily increase the length, to avoid truncation JsonpUtils.maxToStringLength(1_000_000); LOG.debug("Creating Index with request {}", request); } finally { JsonpUtils.maxToStringLength(old); } } - // create the new index try { final CreateIndexResponse response = esClient.create(request); - LOG.info("Created index {}. Response acknowledged: {}", indexName, response.acknowledged()); - checkResponseAcknowledgement(response, "Create index call not acknowledged for index " + indexName); + LOG.info("Created index {}. Response acknowledged: {}", backingIndexName, response.acknowledged()); + checkResponseAcknowledgement(response, "Create index call not acknowledged for index " + backingIndexName); } catch (ElasticsearchException ese) { - // We already check index existence as first thing in this method, if we get here it means we have got into - // a conflict (eg: multiple cluster nodes provision concurrently). - // Elasticsearch does not have a CREATE IF NOT EXIST, need to inspect exception + // We already check index existence as first thing in provisionIndex(); if we get here it + // means a concurrent cluster node raced us. Elasticsearch has no CREATE IF NOT EXISTS: // https://github.com/elastic/elasticsearch/issues/19862 if (ese.status() == 400 && ese.getMessage().contains("resource_already_exists_exception")) { - LOG.warn("Index {} already exists. Ignoring error", indexName); + LOG.warn("Index {} already exists. Ignoring error", backingIndexName); } else { - LOG.warn("Failed to create index {}", indexName, ese); + LOG.warn("Failed to create index {}", backingIndexName, ese); StringBuilder sb = new StringBuilder(); int old = JsonpUtils.maxToStringLength(); try { diff --git a/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/index/ElasticIndexWriterFactory.java b/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/index/ElasticIndexWriterFactory.java index 59bb06c855c..9ef94cd9988 100644 --- a/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/index/ElasticIndexWriterFactory.java +++ b/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/index/ElasticIndexWriterFactory.java @@ -20,6 +20,7 @@ import org.apache.jackrabbit.oak.plugins.index.elastic.ElasticIndexDefinition; import org.apache.jackrabbit.oak.plugins.index.elastic.ElasticIndexTracker; import org.apache.jackrabbit.oak.plugins.index.search.IndexDefinition; +import org.apache.jackrabbit.oak.plugins.index.search.spi.editor.FulltextIndexWriter; import org.apache.jackrabbit.oak.plugins.index.search.spi.editor.FulltextIndexWriterFactory; import org.apache.jackrabbit.oak.spi.commit.CommitInfo; import org.apache.jackrabbit.oak.spi.state.NodeBuilder; @@ -43,8 +44,8 @@ public ElasticIndexWriterFactory(@NotNull ElasticConnection elasticConnection, @ } @Override - public ElasticIndexWriter newInstance(IndexDefinition definition, NodeBuilder definitionBuilder, - CommitInfo commitInfo, boolean reindex) { + public FulltextIndexWriter newInstance(IndexDefinition definition, NodeBuilder definitionBuilder, + CommitInfo commitInfo, boolean reindex) { if (!(definition instanceof ElasticIndexDefinition)) { throw new IllegalArgumentException("IndexDefinition must be of type ElasticsearchIndexDefinition " + "instead of " + definition.getClass().getName()); @@ -52,6 +53,21 @@ public ElasticIndexWriter newInstance(IndexDefinition definition, NodeBuilder de ElasticIndexDefinition esDefinition = (ElasticIndexDefinition) definition; - return new ElasticIndexWriter(indexTracker, elasticConnection, esDefinition, definitionBuilder, reindex, commitInfo, bulkProcessorHandler, retryPolicy); + // requiresProvisioning=true for a standard reindex, or when a prior lazy reindex produced + // zero documents and set PROP_REQUIRES_PROVISIONING in the node store. + boolean requiresProvisioning = reindex || esDefinition.requiresProvisioning(); + + if (requiresProvisioning && ElasticIndexEditorProvider.isLazyProvisioningActive()) { + // OAK-12249: defer provisioning to the first write, whether this is a reindex or an + // incremental cycle after an empty lazy reindex. If no documents arrive the supplier is + // never called, PROP_REQUIRES_PROVISIONING is re-written, and the next cycle retries. + return new LazyElasticIndexWriter( + () -> new ElasticIndexWriter(indexTracker, elasticConnection, esDefinition, + definitionBuilder, true, commitInfo, bulkProcessorHandler, retryPolicy), + definitionBuilder); + } + + return new ElasticIndexWriter(indexTracker, elasticConnection, esDefinition, + definitionBuilder, requiresProvisioning, commitInfo, bulkProcessorHandler, retryPolicy); } } diff --git a/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/index/LazyElasticIndexWriter.java b/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/index/LazyElasticIndexWriter.java new file mode 100644 index 00000000000..50c83d9ac69 --- /dev/null +++ b/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/index/LazyElasticIndexWriter.java @@ -0,0 +1,85 @@ +/* + * 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.elastic.index; + +import org.apache.jackrabbit.oak.plugins.index.elastic.ElasticIndexDefinition; +import org.apache.jackrabbit.oak.plugins.index.search.spi.editor.FulltextIndexWriter; +import org.apache.jackrabbit.oak.spi.state.NodeBuilder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.function.Supplier; + +/** + * A {@link FulltextIndexWriter} proxy that defers creation of the real {@link ElasticIndexWriter} + * to the first {@link #updateDocument}, {@link #deleteDocumentTree} or {@link #deleteDocument} + * call (OAK-12249). + * + *

If {@link #close} is called before any document is written — i.e. the reindex produced zero + * documents — the supplier is never invoked, so no Elasticsearch index or alias is created. + * Instead, {@link ElasticIndexDefinition#PROP_REQUIRES_PROVISIONING} is set on the definition + * node so the next incremental-write cycle provisions the index on demand. + * + *

The supplier is expected to create and provision the index as a side effect of construction + * (as {@link ElasticIndexWriter} does). Thread safety is not required: Oak calls each writer + * instance from a single thread. + */ +class LazyElasticIndexWriter implements FulltextIndexWriter { + private static final Logger LOG = LoggerFactory.getLogger(LazyElasticIndexWriter.class); + + private final Supplier writerSupplier; + private final NodeBuilder definitionBuilder; + private ElasticIndexWriter delegate; + + LazyElasticIndexWriter(Supplier writerSupplier, NodeBuilder definitionBuilder) { + this.writerSupplier = writerSupplier; + this.definitionBuilder = definitionBuilder; + } + + @Override + public void updateDocument(String path, ElasticDocument doc) throws IOException { + getOrCreate().updateDocument(path, doc); + } + + @Override + public void deleteDocumentTree(String path) throws IOException { + getOrCreate().deleteDocumentTree(path); + } + + @Override + public void deleteDocument(String path) throws IOException { + getOrCreate().deleteDocument(path); + } + + @Override + public boolean close(long timestamp) throws IOException { + if (delegate == null) { + LOG.info("Reindex produced no documents — skipping ES index creation (OAK-12249)"); + definitionBuilder.setProperty(ElasticIndexDefinition.PROP_REQUIRES_PROVISIONING, true); + return false; + } + return delegate.close(timestamp); + } + + private ElasticIndexWriter getOrCreate() { + if (delegate == null) { + delegate = writerSupplier.get(); + } + return delegate; + } +} 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 ee6c9651fb9..13ace14c1aa 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 @@ -20,11 +20,15 @@ import co.elastic.clients.elasticsearch.core.DeleteByQueryRequest; import co.elastic.clients.elasticsearch.core.DeleteByQueryResponse; import co.elastic.clients.util.ObjectBuilder; +import org.apache.jackrabbit.oak.api.Type; import org.apache.jackrabbit.oak.plugins.index.elastic.ElasticConnection; import org.apache.jackrabbit.oak.plugins.index.elastic.ElasticIndexDefinition; +import org.apache.jackrabbit.oak.plugins.index.elastic.ElasticIndexStatistics; import org.apache.jackrabbit.oak.plugins.index.elastic.ElasticIndexTracker; import org.apache.jackrabbit.oak.plugins.index.elastic.query.inference.InferenceConfig; +import org.apache.jackrabbit.oak.plugins.memory.EmptyNodeState; import org.apache.jackrabbit.oak.plugins.memory.MemoryNodeStore; +import org.apache.jackrabbit.oak.spi.state.NodeBuilder; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -37,6 +41,7 @@ import java.time.LocalDate; import java.util.Arrays; import java.util.Collections; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Function; import static org.apache.jackrabbit.oak.plugins.index.elastic.ElasticTestUtils.randomString; @@ -44,6 +49,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.number.OrderingComparison.lessThan; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; @@ -95,6 +101,8 @@ public void setUp() throws IOException { @After public void tearDown() throws Exception { closeable.close(); + ElasticIndexStatistics.FT_OAK_12248_ENABLE.set(false); + ElasticIndexEditorProvider.FT_OAK_12249_ENABLE.set(false); } @Test @@ -187,4 +195,96 @@ public void ft_oak_12206_toggleShouldBeRemoved() { LocalDate.now().isBefore(LocalDate.of(2027, 5, 6))); } + // --- OAK-12249: lazy provisioning tests --- + + @Test + public void lazyProvisioning_requiresGraceful404Toggle() { + // OAK-12249 alone must not activate lazy provisioning — OAK-12248 is the hard dependency. + ElasticIndexStatistics.FT_OAK_12248_ENABLE.set(false); + ElasticIndexEditorProvider.FT_OAK_12249_ENABLE.set(true); + + assertFalse("Lazy provisioning must be inactive when graceful 404 handling is off", + ElasticIndexEditorProvider.isLazyProvisioningActive()); + } + + @Test + public void lazyProvisioning_activeWhenBothTogglesEnabled() { + // Guards against e.g. an accidental && -> || regression in isLazyProvisioningActive(), + // which the negative-only test above would not catch. + ElasticIndexStatistics.FT_OAK_12248_ENABLE.set(true); + ElasticIndexEditorProvider.FT_OAK_12249_ENABLE.set(true); + + assertTrue("Lazy provisioning must be active when both toggles are enabled", + ElasticIndexEditorProvider.isLazyProvisioningActive()); + } + + @Test + public void emptyReindex_supplierNeverCalled() throws IOException { + // GIVEN: a LazyElasticIndexWriter whose supplier records whether it was invoked + AtomicBoolean supplierCalled = new AtomicBoolean(false); + NodeBuilder definitionBuilder = EmptyNodeState.EMPTY_NODE.builder(); + LazyElasticIndexWriter lazyWriter = new LazyElasticIndexWriter(() -> { + supplierCalled.set(true); + return indexWriter; + }, definitionBuilder); + + // WHEN: closed without writing any documents + lazyWriter.close(System.currentTimeMillis()); + + // THEN: supplier was never called — no ElasticIndexWriter created, no ES index provisioned + assertFalse("Supplier must not be called when no documents are written", supplierCalled.get()); + // AND: the definition is marked so the next incremental cycle provisions on demand + assertTrue("PROP_REQUIRES_PROVISIONING must be set after an empty-reindex close()", + definitionBuilder.getProperty(ElasticIndexDefinition.PROP_REQUIRES_PROVISIONING) + .getValue(Type.BOOLEAN)); + } + + @Test + public void deleteDocumentTree_triggersSupplier() throws IOException { + AtomicBoolean supplierCalled = new AtomicBoolean(false); + NodeBuilder definitionBuilder = EmptyNodeState.EMPTY_NODE.builder(); + LazyElasticIndexWriter lazyWriter = new LazyElasticIndexWriter(() -> { + supplierCalled.set(true); + return indexWriter; + }, definitionBuilder); + + lazyWriter.deleteDocumentTree("/foo"); + + assertTrue("Supplier must be called on deleteDocumentTree", supplierCalled.get()); + } + + @Test + public void deleteDocument_triggersSupplier() throws IOException { + AtomicBoolean supplierCalled = new AtomicBoolean(false); + NodeBuilder definitionBuilder = EmptyNodeState.EMPTY_NODE.builder(); + LazyElasticIndexWriter lazyWriter = new LazyElasticIndexWriter(() -> { + supplierCalled.set(true); + return indexWriter; + }, definitionBuilder); + + lazyWriter.deleteDocument("/foo"); + + assertTrue("Supplier must be called on deleteDocument", supplierCalled.get()); + } + + @Test + public void nonEmptyReindex_supplierCalledOnFirstWrite() throws IOException { + // GIVEN: a LazyElasticIndexWriter whose supplier records when it is invoked + AtomicBoolean supplierCalled = new AtomicBoolean(false); + NodeBuilder definitionBuilder = EmptyNodeState.EMPTY_NODE.builder(); + LazyElasticIndexWriter lazyWriter = new LazyElasticIndexWriter(() -> { + supplierCalled.set(true); + return indexWriter; + }, definitionBuilder); + + // Supplier not yet called before any write + assertFalse(supplierCalled.get()); + + // WHEN: first document written + lazyWriter.updateDocument("/foo", new ElasticDocument("/foo")); + + // THEN: supplier was called — ElasticIndexWriter (and its ES index) created on first write + assertTrue("Supplier must be called on the first write", supplierCalled.get()); + } + } From 2017575990d114434ccf460ec5fc1f21fd8b1a79 Mon Sep 17 00:00:00 2001 From: Benjamin Habegger Date: Mon, 24 Aug 2026 11:23:04 +0200 Subject: [PATCH 2/3] OAK-12249: unalias stale index when a lazy reindex produces zero documents A reindex under lazy provisioning that matches zero documents never invoked the writer supplier, so LazyElasticIndexWriter.close() had no way to flip or remove an alias. On a never-provisioned index this is correct (nothing to clean up), but on an already-provisioned, populated index it left the old, stale backing index fully aliased and queryable indefinitely -- silently serving pre-reindex content with no signal anything was wrong, since REINDEX_COMPLETION_TIMESTAMP also isn't written in this path. Adds ElasticIndexWriter.unaliasIfProvisioned(), called from LazyElasticIndexWriter.close() whenever the delegate was never created: it removes the alias and deletes the backing index if one exists, no-op otherwise. Proven with a new testcontainers-based LazyProvisioningReindexITTest against a real Elasticsearch cluster, following TDD (confirmed RED against the prior behavior before this fix). --- .../elastic/index/ElasticIndexWriter.java | 36 ++++++ .../index/ElasticIndexWriterFactory.java | 2 +- .../elastic/index/LazyElasticIndexWriter.java | 14 ++- .../elastic/index/ElasticIndexWriterTest.java | 19 ++- .../index/LazyProvisioningReindexITTest.java | 112 ++++++++++++++++++ 5 files changed, 176 insertions(+), 7 deletions(-) create mode 100644 oak-search-elastic/src/test/java/org/apache/jackrabbit/oak/plugins/index/elastic/index/LazyProvisioningReindexITTest.java 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 ceca172e5dd..dda3bda1ce2 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 @@ -343,4 +343,40 @@ private void deleteOldIndices(ElasticsearchIndicesClient indicesClient, SetNo-op if nothing is currently aliased — that is the state a never-provisioned index is + * already in, so there is nothing to clean up. + */ + static void unaliasIfProvisioned(@NotNull ElasticConnection elasticConnection, + @NotNull ElasticIndexDefinition indexDefinition) throws IOException { + ElasticsearchIndicesClient client = elasticConnection.getClient().indices(); + GetAliasResponse aliasResponse = client.getAlias(garb -> + garb.index(indexDefinition.getIndexAlias()).ignoreUnavailable(true)); + if (aliasResponse.result().isEmpty()) { + return; + } + + UpdateAliasesRequest removeAliasesRequest = UpdateAliasesRequest.of(rb -> { + aliasResponse.result().forEach((idx, idxAliases) -> rb.actions(ab -> + ab.remove(rab -> rab.index(idx).aliases(new ArrayList<>(idxAliases.aliases().keySet()))))); + return rb; + }); + UpdateAliasesResponse updateAliasesResponse = client.updateAliases(removeAliasesRequest); + if (!updateAliasesResponse.acknowledged()) { + throw new IllegalStateException("Remove alias call not acknowledged for alias " + indexDefinition.getIndexAlias()); + } + + DeleteIndexResponse deleteIndexResponse = client.delete(db -> db.index(new ArrayList<>(aliasResponse.result().keySet()))); + if (!deleteIndexResponse.acknowledged()) { + throw new IllegalStateException("Delete index call not acknowledged for indices " + aliasResponse.result().keySet()); + } + LOG.info("Reindex produced no documents for a previously-provisioned index — removed stale alias {} and deleted {}", + indexDefinition.getIndexAlias(), aliasResponse.result().keySet()); + } } diff --git a/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/index/ElasticIndexWriterFactory.java b/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/index/ElasticIndexWriterFactory.java index 9ef94cd9988..7ef3312da18 100644 --- a/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/index/ElasticIndexWriterFactory.java +++ b/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/index/ElasticIndexWriterFactory.java @@ -64,7 +64,7 @@ public FulltextIndexWriter newInstance(IndexDefinition definiti return new LazyElasticIndexWriter( () -> new ElasticIndexWriter(indexTracker, elasticConnection, esDefinition, definitionBuilder, true, commitInfo, bulkProcessorHandler, retryPolicy), - definitionBuilder); + definitionBuilder, elasticConnection, esDefinition); } return new ElasticIndexWriter(indexTracker, elasticConnection, esDefinition, diff --git a/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/index/LazyElasticIndexWriter.java b/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/index/LazyElasticIndexWriter.java index 50c83d9ac69..886881ee009 100644 --- a/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/index/LazyElasticIndexWriter.java +++ b/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/index/LazyElasticIndexWriter.java @@ -16,6 +16,7 @@ */ package org.apache.jackrabbit.oak.plugins.index.elastic.index; +import org.apache.jackrabbit.oak.plugins.index.elastic.ElasticConnection; import org.apache.jackrabbit.oak.plugins.index.elastic.ElasticIndexDefinition; import org.apache.jackrabbit.oak.plugins.index.search.spi.editor.FulltextIndexWriter; import org.apache.jackrabbit.oak.spi.state.NodeBuilder; @@ -33,7 +34,10 @@ *

If {@link #close} is called before any document is written — i.e. the reindex produced zero * documents — the supplier is never invoked, so no Elasticsearch index or alias is created. * Instead, {@link ElasticIndexDefinition#PROP_REQUIRES_PROVISIONING} is set on the definition - * node so the next incremental-write cycle provisions the index on demand. + * node so the next incremental-write cycle provisions the index on demand. If the index was + * already provisioned before this reindex (i.e. it previously had documents), its now-stale + * alias and backing index are removed — otherwise pre-reindex content would keep being served + * indefinitely. * *

The supplier is expected to create and provision the index as a side effect of construction * (as {@link ElasticIndexWriter} does). Thread safety is not required: Oak calls each writer @@ -44,11 +48,16 @@ class LazyElasticIndexWriter implements FulltextIndexWriter { private final Supplier writerSupplier; private final NodeBuilder definitionBuilder; + private final ElasticConnection elasticConnection; + private final ElasticIndexDefinition indexDefinition; private ElasticIndexWriter delegate; - LazyElasticIndexWriter(Supplier writerSupplier, NodeBuilder definitionBuilder) { + LazyElasticIndexWriter(Supplier writerSupplier, NodeBuilder definitionBuilder, + ElasticConnection elasticConnection, ElasticIndexDefinition indexDefinition) { this.writerSupplier = writerSupplier; this.definitionBuilder = definitionBuilder; + this.elasticConnection = elasticConnection; + this.indexDefinition = indexDefinition; } @Override @@ -71,6 +80,7 @@ public boolean close(long timestamp) throws IOException { if (delegate == null) { LOG.info("Reindex produced no documents — skipping ES index creation (OAK-12249)"); definitionBuilder.setProperty(ElasticIndexDefinition.PROP_REQUIRES_PROVISIONING, true); + ElasticIndexWriter.unaliasIfProvisioned(elasticConnection, indexDefinition); return false; } return delegate.close(timestamp); 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 13ace14c1aa..2bf482453d9 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 @@ -19,6 +19,9 @@ import co.elastic.clients.elasticsearch.ElasticsearchClient; import co.elastic.clients.elasticsearch.core.DeleteByQueryRequest; import co.elastic.clients.elasticsearch.core.DeleteByQueryResponse; +import co.elastic.clients.elasticsearch.indices.ElasticsearchIndicesClient; +import co.elastic.clients.elasticsearch.indices.GetAliasRequest; +import co.elastic.clients.elasticsearch.indices.GetAliasResponse; import co.elastic.clients.util.ObjectBuilder; import org.apache.jackrabbit.oak.api.Type; import org.apache.jackrabbit.oak.plugins.index.elastic.ElasticConnection; @@ -70,6 +73,9 @@ public class ElasticIndexWriterTest { @Mock private ElasticsearchClient elasticsearchClientMock; + @Mock + private ElasticsearchIndicesClient indicesClientMock; + @Mock private ElasticIndexDefinition indexDefinitionMock; @@ -91,6 +97,11 @@ public void setUp() throws IOException { when(elasticConnectionMock.getClient() .deleteByQuery(ArgumentMatchers.>>any())) .thenReturn(DeleteByQueryResponse.of(d -> d.deleted(1L).failures(Collections.emptyList()))); + // LazyElasticIndexWriter.close() checks for a stale alias on an empty-reindex close(); + // report "nothing provisioned" so that path no-ops in tests that don't exercise it directly. + when(elasticsearchClientMock.indices()).thenReturn(indicesClientMock); + when(indicesClientMock.getAlias(ArgumentMatchers.>>any())) + .thenReturn(GetAliasResponse.of(r -> r.result(Collections.emptyMap()))); // In this test we are explicitly disabling inference as bulkprocessor // is called with update document if inference is enabled. InferenceConfig.reInitialize(new MemoryNodeStore(), "/oak:index/:inferenceConfig", false); @@ -226,7 +237,7 @@ public void emptyReindex_supplierNeverCalled() throws IOException { LazyElasticIndexWriter lazyWriter = new LazyElasticIndexWriter(() -> { supplierCalled.set(true); return indexWriter; - }, definitionBuilder); + }, definitionBuilder, elasticConnectionMock, indexDefinitionMock); // WHEN: closed without writing any documents lazyWriter.close(System.currentTimeMillis()); @@ -246,7 +257,7 @@ public void deleteDocumentTree_triggersSupplier() throws IOException { LazyElasticIndexWriter lazyWriter = new LazyElasticIndexWriter(() -> { supplierCalled.set(true); return indexWriter; - }, definitionBuilder); + }, definitionBuilder, elasticConnectionMock, indexDefinitionMock); lazyWriter.deleteDocumentTree("/foo"); @@ -260,7 +271,7 @@ public void deleteDocument_triggersSupplier() throws IOException { LazyElasticIndexWriter lazyWriter = new LazyElasticIndexWriter(() -> { supplierCalled.set(true); return indexWriter; - }, definitionBuilder); + }, definitionBuilder, elasticConnectionMock, indexDefinitionMock); lazyWriter.deleteDocument("/foo"); @@ -275,7 +286,7 @@ public void nonEmptyReindex_supplierCalledOnFirstWrite() throws IOException { LazyElasticIndexWriter lazyWriter = new LazyElasticIndexWriter(() -> { supplierCalled.set(true); return indexWriter; - }, definitionBuilder); + }, definitionBuilder, elasticConnectionMock, indexDefinitionMock); // Supplier not yet called before any write assertFalse(supplierCalled.get()); diff --git a/oak-search-elastic/src/test/java/org/apache/jackrabbit/oak/plugins/index/elastic/index/LazyProvisioningReindexITTest.java b/oak-search-elastic/src/test/java/org/apache/jackrabbit/oak/plugins/index/elastic/index/LazyProvisioningReindexITTest.java new file mode 100644 index 00000000000..f7e7873904a --- /dev/null +++ b/oak-search-elastic/src/test/java/org/apache/jackrabbit/oak/plugins/index/elastic/index/LazyProvisioningReindexITTest.java @@ -0,0 +1,112 @@ +/* + * 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.elastic.index; + +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.ElasticIndexDefinition; +import org.apache.jackrabbit.oak.plugins.index.elastic.ElasticIndexStatistics; +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.util.ElasticIndexDefinitionBuilder; +import org.apache.jackrabbit.oak.plugins.index.search.spi.editor.FulltextIndexWriter; +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.CommitInfo; +import org.apache.jackrabbit.oak.spi.state.NodeBuilder; +import org.apache.jackrabbit.oak.spi.state.NodeState; +import org.apache.jackrabbit.oak.spi.state.NodeStore; +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.InitialContentHelper.INITIAL_CONTENT; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * OAK-12249: a reindex under lazy provisioning that produces zero documents must not leave a + * previously-provisioned index's stale alias and backing index behind — otherwise the system + * keeps serving pre-reindex content indefinitely, with no signal that anything is wrong. + */ +public class LazyProvisioningReindexITTest { + + @ClassRule + public static final ElasticConnectionRule elasticRule = new ElasticConnectionRule(); + + private ElasticConnection connection; + private ElasticIndexTracker indexTracker; + private NodeStore nodeStore; + + @Before + public void setup() { + this.connection = elasticRule.useDocker() ? + elasticRule.getElasticConnectionForDocker() : + elasticRule.getElasticConnectionFromString(); + this.indexTracker = new ElasticIndexTracker(connection, new ElasticMetricHandler(StatisticsProvider.NOOP)); + this.nodeStore = new MemoryNodeStore(INITIAL_CONTENT); + } + + @After + public void tearDown() { + ElasticIndexEditorProvider.FT_OAK_12249_ENABLE.set(false); + ElasticIndexStatistics.FT_OAK_12248_ENABLE.set(false); + } + + @Test + public void reindexToZeroDocuments_onPreviouslyProvisionedIndex_removesStaleAlias() throws Exception { + String indexName = "lazyReindexToZero"; + NodeState root = nodeStore.getRoot(); + NodeBuilder builder = root.builder(); + IndexDefinitionBuilder idxBuilder = new ElasticIndexDefinitionBuilder(builder.child("oak:index").child(indexName)); + idxBuilder.indexRule("nt:base").property("propa").propertyIndex(); + NodeState defNodeState = idxBuilder.build(); + ElasticIndexDefinition definition = new ElasticIndexDefinition(root, defNodeState, indexName, connection.getIndexPrefix()); + + ElasticBulkProcessorHandler bulkProcessorHandler = new ElasticBulkProcessorHandler(connection); + ElasticIndexWriterFactory factory = new ElasticIndexWriterFactory(connection, indexTracker, bulkProcessorHandler); + + // GIVEN: a normal (eager) reindex that provisions the index and writes one document — + // simulates an index that has been live and populated for a while. + NodeBuilder definitionBuilder = builder.child("oak:index").getChildNode(indexName); + FulltextIndexWriter firstWriter = factory.newInstance(definition, definitionBuilder, CommitInfo.EMPTY, true); + firstWriter.updateDocument("/content/a", new ElasticDocument("/content/a")); + firstWriter.close(System.currentTimeMillis()); + + assertTrue("sanity check: alias must exist after the initial provisioning reindex", + connection.getClient().indices().exists(i -> i.index(definition.getIndexAlias())).value()); + + // WHEN: lazy provisioning is turned on, and the index is reindexed again — this time the + // index rule (or content) has changed such that the reindex matches zero documents. + ElasticIndexStatistics.FT_OAK_12248_ENABLE.set(true); + ElasticIndexEditorProvider.FT_OAK_12249_ENABLE.set(true); + + ElasticIndexDefinition definitionAfterFirstReindex = + new ElasticIndexDefinition(root, definitionBuilder.getNodeState(), indexName, connection.getIndexPrefix()); + FulltextIndexWriter secondWriter = + factory.newInstance(definitionAfterFirstReindex, definitionBuilder, CommitInfo.EMPTY, true); + // No updateDocument/deleteDocument calls at all — the reindex traversal found nothing to index. + secondWriter.close(System.currentTimeMillis()); + + // THEN: the stale, previously-populated backing index must no longer be aliased/queryable — + // it must not keep serving pre-reindex content indefinitely. + assertFalse("stale backing index from before the empty reindex must be unaliased", + connection.getClient().indices().exists(i -> i.index(definition.getIndexAlias())).value()); + } +} From 68c295663b874283b8294389714ced1e77200b77 Mon Sep 17 00:00:00 2001 From: Benjamin Habegger Date: Tue, 25 Aug 2026 12:12:52 +0200 Subject: [PATCH 3/3] OAK-12249: fix OSGi baseline versioning for elastic.index/query/util ElasticIndexWriterFactory#newInstance's return type widened from the concrete ElasticIndexWriter to the FulltextIndexWriter interface -- a binary-incompatible, major change to the exported elastic.index package. Without a package-info.java, this package's declared OSGi version tracked the bundle's own version (2.5.0), which only satisfies a minor bump, failing the maven-bundle-plugin baseline check ("Version increase required ... suggested 3.0.0"). The same auto-tracking also drags elastic.query and elastic.util up to 2.5.0 even though neither package's API changed at all, triggering "Excessive version increase" / "no changes detected" warnings. Adds package-info.java per OSGi semantic versioning to pin each package's version independently of the bundle version: elastic.index to 3.0.0 (major, matches the actual breaking change), elastic.query and elastic.util to 2.4.1 (bnd requires at least a micro bump the first time a package gains an explicit declared version, even with no API change). --- .../index/elastic/index/package-info.java | 20 +++++++++++++++++++ .../index/elastic/query/package-info.java | 20 +++++++++++++++++++ .../index/elastic/util/package-info.java | 20 +++++++++++++++++++ 3 files changed, 60 insertions(+) create mode 100644 oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/index/package-info.java create mode 100644 oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/query/package-info.java create mode 100644 oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/util/package-info.java diff --git a/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/index/package-info.java b/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/index/package-info.java new file mode 100644 index 00000000000..0619843ba45 --- /dev/null +++ b/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/index/package-info.java @@ -0,0 +1,20 @@ +/* + * 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. + */ +@Version("3.0.0") +package org.apache.jackrabbit.oak.plugins.index.elastic.index; + +import org.osgi.annotation.versioning.Version; diff --git a/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/query/package-info.java b/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/query/package-info.java new file mode 100644 index 00000000000..7afb8806a32 --- /dev/null +++ b/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/query/package-info.java @@ -0,0 +1,20 @@ +/* + * 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. + */ +@Version("2.4.1") +package org.apache.jackrabbit.oak.plugins.index.elastic.query; + +import org.osgi.annotation.versioning.Version; diff --git a/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/util/package-info.java b/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/util/package-info.java new file mode 100644 index 00000000000..5a17c5acd0d --- /dev/null +++ b/oak-search-elastic/src/main/java/org/apache/jackrabbit/oak/plugins/index/elastic/util/package-info.java @@ -0,0 +1,20 @@ +/* + * 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. + */ +@Version("2.4.1") +package org.apache.jackrabbit.oak.plugins.index.elastic.util; + +import org.osgi.annotation.versioning.Version;