From 8eaf91c8c523e3c8c0e47fea42ff64b11bb8c8bb Mon Sep 17 00:00:00 2001
From: fabrizzio-dotCMS
Date: Tue, 11 Aug 2026 09:22:53 -0600
Subject: [PATCH 01/15] fix(sitesearch): preserve the custom index alias across
a crawl (#36983)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The Site Search job scheduler resolved index aliases through the
content-index router (ESIndexAPI), which is not site-search .os-aware. In
Phases 2/3 the physical index lives in OpenSearch tagged with .os, so the
lookup missed and the index selector fell back to the raw internal index
name — which was then saved as the job's `indexAlias`.
From there a full crawl destroyed the alias: it deletes the old index
(taking the real alias with it) and re-applies the job's stored string to
the new index, so a dead index's NAME became the new index's alias.
- site_search_job_schedule.jsp: resolve aliases via the phase-aware
SiteSearchAPI#getAliasToIndexMap(), like the Indices tab already does.
- SiteSearchJobImpl: when the stored value is a raw index name, recover
that index's real alias (or none) instead of carrying the raw name
forward. This also repairs jobs already saved with a raw name.
- site_search.jsp: the scheduler's alias field caps at 255 chars (the
engine limit) instead of 60 — a crawl-built name is 62 chars, so the
old cap made those indices impossible to schedule at all.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../publishing/job/SiteSearchJobImpl.java | 42 +++++--
.../portlet/ext/sitesearch/site_search.jsp | 6 +-
.../sitesearch/site_search_job_schedule.jsp | 12 +-
.../job/SiteSearchJobAliasResolutionTest.java | 118 ++++++++++++++++++
.../publishing/job/SiteSearchJobImplTest.java | 48 +++++++
5 files changed, 215 insertions(+), 11 deletions(-)
create mode 100644 dotCMS/src/test/java/com/dotcms/publishing/job/SiteSearchJobAliasResolutionTest.java
diff --git a/dotCMS/src/main/java/com/dotcms/publishing/job/SiteSearchJobImpl.java b/dotCMS/src/main/java/com/dotcms/publishing/job/SiteSearchJobImpl.java
index 5e01d1d0396f..45aaa09078fd 100644
--- a/dotCMS/src/main/java/com/dotcms/publishing/job/SiteSearchJobImpl.java
+++ b/dotCMS/src/main/java/com/dotcms/publishing/job/SiteSearchJobImpl.java
@@ -198,7 +198,8 @@ public void run(final JobExecutionContext jobContext)
+ "; Job Identifier: " + SiteSearchAPI.ES_SITE_SEARCH_NAME);
}
- private PreparedJobContext prepareJob(final JobExecutionContext jobContext)
+ @VisibleForTesting
+ PreparedJobContext prepareJob(final JobExecutionContext jobContext)
throws DotDataException, IOException, DotSecurityException {
synchronized (SiteSearchJobImpl.class) {
final JobDataMap dataMap = jobContext.getJobDetail().getJobDataMap();
@@ -239,6 +240,12 @@ private PreparedJobContext prepareJob(final JobExecutionContext jobContext)
// Run now jobs can not get the incremental treatment.
final String indexAlias = getAliasName(dataMap.getString(INDEX_ALIAS));
final IndexMetaData indexMetaData = getIndexMetaData(indexAlias);
+ // The alias the crawl must end up with. It is NOT necessarily the string stored in the
+ // job detail: that one can be a raw index name, in which case getIndexMetaData resolves
+ // the index's real alias (or null when it has none). Everything downstream — the config
+ // handed to the publisher, which re-applies it after the index switch — must use this
+ // resolved value, never the raw stored string (issue #36983).
+ final String resolvedAlias = indexMetaData.getAlias();
final String newIndexName;
final String indexName;
@@ -298,8 +305,7 @@ private PreparedJobContext prepareJob(final JobExecutionContext jobContext)
uniqueFolderName();
// We use a new index name only on non-incremental
newIndexName = newIndexName();
- final String newAlias =
- indexMetaData.isNewIndex() ? indexMetaData.getAlias() : null;
+ final String newAlias = indexMetaData.isNewIndex() ? resolvedAlias : null;
siteSearchAPI.createSiteSearchIndex(newIndexName, newAlias, 1);
// This is the old index we will swap from.
// if it doesnt exist. It doesnt matter here since we will end up with the new one.
@@ -310,7 +316,7 @@ private PreparedJobContext prepareJob(final JobExecutionContext jobContext)
.format("Incremental mode [%s]. current index is `%s`. new index is `%s`. alias is `%s` bundle id is `%s` ",
BooleanUtils.toStringYesNo(incremental), indexName,
UtilMethods.isSet(newIndexName) ? newIndexName : "N/A",
- indexAlias,
+ UtilMethods.isSet(resolvedAlias) ? resolvedAlias : "N/A",
bundleId)
);
@@ -342,7 +348,7 @@ private PreparedJobContext prepareJob(final JobExecutionContext jobContext)
config.setHosts(hosts);
config.setNewIndexName(newIndexName);
config.setIndexName(indexName);
- config.setIndexAlias(indexAlias);
+ config.setIndexAlias(resolvedAlias);
config.setId(bundleId);
config.setStartDate(startDate);
config.setEndDate(endDate);
@@ -401,7 +407,8 @@ private String uniqueFolderName(){
* @return @see IndexMetaData
* @throws DotDataException
*/
- private IndexMetaData getIndexMetaData(String indexAlias) throws DotDataException {
+ @VisibleForTesting
+ IndexMetaData getIndexMetaData(String indexAlias) throws DotDataException {
String indexName = null;
boolean defaultIndex = false;
long recordCount = 0;
@@ -421,7 +428,13 @@ private IndexMetaData getIndexMetaData(String indexAlias) throws DotDataExceptio
// the alias comes with an index name that is already in use.
if(indices.contains(indexAlias)){
indexName = indexAlias;
- indexAlias = null;
+ // The job was saved with a raw index name where an alias was expected (the job
+ // scheduler used to fall back to raw names when alias resolution missed on
+ // OpenSearch — issue #36983). Recover the index's REAL alias so a full crawl
+ // re-applies it to the new index. Carrying the raw name forward instead would
+ // make `switchIndex` set the DEAD index's name as the new index's alias,
+ // destroying the alias the user created (issue #36983, Bug 1).
+ indexAlias = aliasOf(indexName, aliasMap);
}
}
if(UtilMethods.isSet(indexName)){
@@ -432,6 +445,21 @@ private IndexMetaData getIndexMetaData(String indexAlias) throws DotDataExceptio
return new IndexMetaData(indexName, defaultIndex, indexAlias, recordCount == 0);
}
+ /**
+ * Reverse lookup of the alias attached to {@code indexName}, given an alias→index map.
+ *
+ * @param indexName the (logical) index name to find an alias for
+ * @param aliasToIndex alias → index map as returned by {@link SiteSearchAPI#getAliasToIndexMap()}
+ * @return the alias pointing at {@code indexName}, or {@code null} when the index has none
+ */
+ private static String aliasOf(final String indexName, final Map aliasToIndex) {
+ return aliasToIndex.entrySet().stream()
+ .filter(entry -> indexName.equals(entry.getValue()))
+ .map(Map.Entry::getKey)
+ .findFirst()
+ .orElse(null);
+ }
+
private static final Pattern invalidAliasNamePattern = Pattern.compile("[^a-zA-Z0-9-_]");
/**
diff --git a/dotCMS/src/main/webapp/html/portlet/ext/sitesearch/site_search.jsp b/dotCMS/src/main/webapp/html/portlet/ext/sitesearch/site_search.jsp
index 2f7ac0a6c1b8..2d4d8ea17744 100644
--- a/dotCMS/src/main/webapp/html/portlet/ext/sitesearch/site_search.jsp
+++ b/dotCMS/src/main/webapp/html/portlet/ext/sitesearch/site_search.jsp
@@ -570,9 +570,13 @@ function submitSchedule() {
//Based on the error invalid_alias_name_exception returned by the ES
//Alias must not contain the following characters [ , \", *, \\, <, |, ,, >, /, ?]"}]
+ //The upper bound is 255 (the engine's index/alias name limit), not 60: this field also accepts a
+ //raw index name for indices that carry no alias, and a crawl-built name
+ //(sitesearch__) is 62 chars — a 60-char cap made those indices impossible to
+ //schedule at all (issue #36983).
let indexAlias = dojo.byId("indexAlias").value.trim();
indexAlias = indexAlias.replace(/\s/g, '');
- let aliasTestResult = /^(?=.{3,60}$)^(?![-_])[a-zA-Z0-9_()-]+$/.test(indexAlias);
+ let aliasTestResult = /^(?=.{3,255}$)^(?![-_])[a-zA-Z0-9_()-]+$/.test(indexAlias);
if(!aliasTestResult) {
showDotCMSErrorMessage("<%= UtilMethods.escapeSingleQuotes(LanguageUtil.get(pageContext, "Invalid-Index-Alias")) %>");
diff --git a/dotCMS/src/main/webapp/html/portlet/ext/sitesearch/site_search_job_schedule.jsp b/dotCMS/src/main/webapp/html/portlet/ext/sitesearch/site_search_job_schedule.jsp
index 9bcdda5cdf52..0ab27933d43e 100644
--- a/dotCMS/src/main/webapp/html/portlet/ext/sitesearch/site_search_job_schedule.jsp
+++ b/dotCMS/src/main/webapp/html/portlet/ext/sitesearch/site_search_job_schedule.jsp
@@ -1,4 +1,3 @@
-<%@page import="com.dotcms.content.elasticsearch.business.ESIndexAPI"%>
<%@page import="com.dotmarketing.beans.Host"%>
<%@page import="com.dotmarketing.business.APILocator"%>
<%@page import="com.dotmarketing.portlets.languagesmanager.model.Language"%>
@@ -16,9 +15,16 @@ if(request.getParameter("jobName") != null){
}
}
-ESIndexAPI iapi=new ESIndexAPI();
List indexes = ssapi.listIndices();
-Map alias = iapi.getIndexAlias(indexes);
+// Site-search .os-aware alias resolution (issue #36983): resolve through the site-search API and
+// reverse (alias->index) into index->alias for the selector. The content-index router (ESIndexAPI)
+// misses site-search aliases in Phases 2/3 because the physical OpenSearch index is .os-tagged, so
+// the selector fell back to the raw internal index name — which is then saved as the job's
+// `indexAlias` and later re-applied as the new index's alias by the crawl, destroying the real one.
+Map alias = new HashMap();
+for (Map.Entry aliasEntry : ssapi.getAliasToIndexMap().entrySet()) {
+ alias.put(aliasEntry.getValue(), aliasEntry.getKey());
+}
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
SimpleDateFormat tdf = new SimpleDateFormat("HH:mm:ss");
diff --git a/dotCMS/src/test/java/com/dotcms/publishing/job/SiteSearchJobAliasResolutionTest.java b/dotCMS/src/test/java/com/dotcms/publishing/job/SiteSearchJobAliasResolutionTest.java
new file mode 100644
index 000000000000..d70cb5863316
--- /dev/null
+++ b/dotCMS/src/test/java/com/dotcms/publishing/job/SiteSearchJobAliasResolutionTest.java
@@ -0,0 +1,118 @@
+package com.dotcms.publishing.job;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.dotcms.content.elasticsearch.business.IndiciesAPI;
+import com.dotcms.enterprise.publishing.sitesearch.SiteSearchResults;
+import com.dotcms.publishing.PublisherAPI;
+import com.dotcms.publishing.job.SiteSearchJobImpl.IndexMetaData;
+import com.dotmarketing.business.UserAPI;
+import com.dotmarketing.portlets.contentlet.business.HostAPI;
+import com.dotmarketing.sitesearch.business.SiteSearchAPI;
+import com.dotmarketing.sitesearch.business.SiteSearchAuditAPI;
+import java.util.Collections;
+import java.util.Map;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Container-free coverage for how {@link SiteSearchJobImpl} resolves the alias a crawl must end up
+ * with (issue #36983, Bug 1).
+ *
+ *
The job detail stores whatever the Site Search scheduler put in its {@code indexAlias} field,
+ * and that value is not guaranteed to be an alias: when alias resolution missed on
+ * OpenSearch (Phases 2/3) the index selector fell back to the raw internal index name and saved it
+ * there. The alias derived here is the one handed to the publisher, which re-applies it to the newly
+ * built index after the switch — so carrying a raw index name forward made a dead index's NAME
+ * become the new index's alias, wiping the alias the user created. These tests pin the resolution
+ * rules so that cannot come back.
+ */
+public class SiteSearchJobAliasResolutionTest {
+
+ private static final String EXISTING_INDEX = "sitesearch_20260810160529";
+ private static final String CUSTOM_ALIAS = "sitesearch-ph-3";
+
+ private SiteSearchAPI siteSearchAPI;
+ private SiteSearchJobImpl job;
+
+ @Before
+ public void setup() {
+ siteSearchAPI = mock(SiteSearchAPI.class);
+ when(siteSearchAPI.listIndices()).thenReturn(Collections.singletonList(EXISTING_INDEX));
+ when(siteSearchAPI.search(anyString(), anyString(), anyInt(), anyInt()))
+ .thenReturn(new SiteSearchResults());
+
+ job = new SiteSearchJobImpl(mock(IndiciesAPI.class), siteSearchAPI, mock(HostAPI.class),
+ mock(UserAPI.class), mock(SiteSearchAuditAPI.class), mock(PublisherAPI.class));
+ }
+
+ /**
+ * A job saved with the index's real alias keeps behaving exactly as before: the alias travels
+ * through untouched and resolves to the index it points at.
+ */
+ @Test
+ public void test_aliasStoredInJobDetail_isCarriedThrough() throws Exception {
+ when(siteSearchAPI.getAliasToIndexMap()).thenReturn(Map.of(CUSTOM_ALIAS, EXISTING_INDEX));
+
+ final IndexMetaData metaData = job.getIndexMetaData(CUSTOM_ALIAS);
+
+ assertEquals(CUSTOM_ALIAS, metaData.getAlias());
+ assertEquals(EXISTING_INDEX, metaData.getIndexName());
+ assertFalse(metaData.isNewIndex());
+ }
+
+ /**
+ * A job saved with a RAW INDEX NAME (the Phase 2/3 scheduler fallback) must resolve that index's
+ * real alias — not hand the raw name over as if it were one. Handing it over is what replaced the
+ * user's alias with a timestamped index name after a crawl (issue #36983).
+ */
+ @Test
+ public void test_rawIndexNameStoredInJobDetail_resolvesTheIndexRealAlias() throws Exception {
+ when(siteSearchAPI.getAliasToIndexMap()).thenReturn(Map.of(CUSTOM_ALIAS, EXISTING_INDEX));
+
+ final IndexMetaData metaData = job.getIndexMetaData(EXISTING_INDEX);
+
+ assertEquals(CUSTOM_ALIAS, metaData.getAlias());
+ assertNotEquals("The raw index name must never be re-applied as an alias", EXISTING_INDEX,
+ metaData.getAlias());
+ assertEquals(EXISTING_INDEX, metaData.getIndexName());
+ assertFalse(metaData.isNewIndex());
+ }
+
+ /**
+ * Same fallback, but the index genuinely has no alias: the crawl must end up with NO alias rather
+ * than one invented from the old index's name.
+ */
+ @Test
+ public void test_rawIndexNameWithoutAlias_resolvesToNoAlias() throws Exception {
+ when(siteSearchAPI.getAliasToIndexMap()).thenReturn(Collections.emptyMap());
+
+ final IndexMetaData metaData = job.getIndexMetaData(EXISTING_INDEX);
+
+ assertNull(metaData.getAlias());
+ assertEquals(EXISTING_INDEX, metaData.getIndexName());
+ }
+
+ /**
+ * A name that matches neither an alias nor an existing index describes a brand-new index: it is
+ * kept as the alias to apply at creation time.
+ */
+ @Test
+ public void test_unknownName_isKeptAsTheAliasOfANewIndex() throws Exception {
+ when(siteSearchAPI.getAliasToIndexMap()).thenReturn(Collections.emptyMap());
+ when(siteSearchAPI.listIndices()).thenReturn(Collections.emptyList());
+
+ final IndexMetaData metaData = job.getIndexMetaData("brand-new-alias");
+
+ assertEquals("brand-new-alias", metaData.getAlias());
+ assertTrue(metaData.isNewIndex());
+ }
+}
\ No newline at end of file
diff --git a/dotcms-integration/src/test/java/com/dotcms/publishing/job/SiteSearchJobImplTest.java b/dotcms-integration/src/test/java/com/dotcms/publishing/job/SiteSearchJobImplTest.java
index 7e588a79fe22..9903022cc043 100644
--- a/dotcms-integration/src/test/java/com/dotcms/publishing/job/SiteSearchJobImplTest.java
+++ b/dotcms-integration/src/test/java/com/dotcms/publishing/job/SiteSearchJobImplTest.java
@@ -62,6 +62,7 @@
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
+import java.util.Map;
import java.util.stream.Collectors;
import java.util.concurrent.TimeUnit;
@@ -211,6 +212,53 @@ public void Test_Non_Incremental_Create_Default_Index_Run_Non_Incrementally_Expe
}
+ /**
+ * Given a job whose stored {@code indexAlias} is a RAW INDEX NAME instead of an alias — what the
+ * Site Search scheduler saved whenever its alias lookup missed on OpenSearch (issue #36983) —
+ * when a full crawl runs, then the custom alias of that index must survive on the newly built
+ * index, and the dead index's NAME must never become an alias.
+ */
+ @Test
+ public void Test_Non_Incremental_Job_Stored_With_Raw_Index_Name_Expect_Custom_Alias_Preserved()
+ throws DotPublishingException, JobExecutionException, DotDataException, IOException, DotSecurityException {
+
+ deleteAllSiteSearchIndices();
+
+ final long timeMillis = System.currentTimeMillis();
+ final String customAlias = IndexType.SITE_SEARCH.getPrefix() + "-alias-" + timeMillis;
+ final String originalIndexName = IndexType.SITE_SEARCH.getPrefix() + "_" + timeMillis;
+ siteSearchAPI.createSiteSearchIndex(originalIndexName, customAlias, 1);
+
+ final String jobId = UUIDUtil.uuid();
+ final JobDataMap jobDataMap = new JobDataMap();
+ jobDataMap.put(SiteSearchJobImpl.RUN_NOW, Boolean.TRUE.toString());
+ jobDataMap.put(SiteSearchJobImpl.INCREMENTAL, Boolean.FALSE.toString());
+ // The defect: the index NAME where an alias is expected.
+ jobDataMap.put(SiteSearchJobImpl.INDEX_ALIAS, originalIndexName);
+ jobDataMap.put(SiteSearchJobImpl.JOB_ID, jobId);
+ jobDataMap.put(SiteSearchJobImpl.QUARTZ_JOB_NAME, SiteSearchJobImpl.RUNNING_ONCE_JOB_NAME);
+ jobDataMap.put(SiteSearchJobImpl.INCLUDE_EXCLUDE, "all");
+ jobDataMap.put(SiteSearchJobImpl.LANG_TO_INDEX, new String[]{Long.toString(defaultLang)});
+ jobDataMap.put(SiteSearchJobImpl.INDEX_HOST, site.getIdentifier());
+
+ final JobDetail jobDetail = Mockito.mock(JobDetail.class);
+ Mockito.when(jobDetail.getJobDataMap()).thenReturn(jobDataMap);
+ final JobExecutionContext context = Mockito.mock(JobExecutionContext.class);
+ Mockito.when(context.getJobDetail()).thenReturn(jobDetail);
+ Mockito.when(context.getFireTime()).thenReturn(new Date());
+ new SiteSearchJobImpl().run(context);
+
+ final List recentAudits = siteSearchAuditAPI.findRecentAudits(jobId, 0, 1);
+ Assert.assertFalse(recentAudits.isEmpty());
+ final String newIndexName = recentAudits.get(0).getIndexName();
+
+ final Map aliasToIndex = siteSearchAPI.getAliasToIndexMap();
+ Assert.assertEquals("The custom alias must follow the crawl onto the new index",
+ newIndexName, aliasToIndex.get(customAlias));
+ Assert.assertFalse("The name of the replaced index must never become an alias",
+ aliasToIndex.containsKey(originalIndexName));
+ }
+
@Test
public void Test_Non_Incremental_Create_Default_Index_Create_Second_Index_Run_Non_Incrementally_Expect_Non_Default_New_Index()
throws DotPublishingException, JobExecutionException, DotDataException, IOException, DotSecurityException {
From 9509241f9346f377984aba1529b00ea4095f73ab Mon Sep 17 00:00:00 2001
From: fabrizzio-dotCMS
Date: Tue, 11 Aug 2026 10:18:29 -0600
Subject: [PATCH 02/15] feat(migration): report the Site Search alias per
engine in the readiness endpoint (#36983)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
An operator knows a site-search index by its alias, never by its
sitesearch__ name, so the readiness report was hard to
act on. Each Site Search row now carries the alias each engine has
attached to the index.
Per engine on purpose: an index can hold its alias on one side and not
the other (created before dual-write started, counterpart built later),
and that asymmetry is exactly what has to be visible before promoting a
phase. One alias lookup per engine covers the whole set.
An alias that is itself shaped like an index name gets a NOTE appended to
`recommendation`: that is the fingerprint of the crawl overwrite fixed in
this same PR, which cannot be repaired retroactively — this is the only
way to find the indices that still need their alias restored. It does not
change `verdict`: the verdict measures data integrity, and a damaged
alias costs no data, so it must not block a phase change.
Content rows are unaffected — `alias` is null there and omitted from the
JSON.
Co-Authored-By: Claude Opus 5 (1M context)
---
docs/backend/OPENSEARCH_MIGRATION.md | 12 ++
.../content/index/migration/MirrorStatus.java | 18 ++-
.../migration/SiteSearchMirrorReconciler.java | 71 +++++++--
.../SiteSearchMirrorReconcilerTest.java | 141 ++++++++++++++++++
4 files changed, 232 insertions(+), 10 deletions(-)
create mode 100644 dotCMS/src/test/java/com/dotcms/content/index/migration/SiteSearchMirrorReconcilerTest.java
diff --git a/docs/backend/OPENSEARCH_MIGRATION.md b/docs/backend/OPENSEARCH_MIGRATION.md
index 71e8795219f6..d50b4e086df0 100644
--- a/docs/backend/OPENSEARCH_MIGRATION.md
+++ b/docs/backend/OPENSEARCH_MIGRATION.md
@@ -380,6 +380,18 @@ through the write-path gate above.
(original) — negative = behind, positive = ahead, `null` when a count is unknown — with verdict `IN_SYNC` /
`MISSING_COUNTERPART` / `COUNT_DRIFT`. The top level also carries the `clusterId` embedded in every
physical name. The response is the model itself (no `ResponseEntityView` envelope).
+- **Site Search entries also carry the `alias`, per engine.** `es.alias` / `os.alias` hold the alias
+ that engine has attached to the index (omitted when there is none; never present on content rows,
+ which are addressed by name only). Per engine on purpose: an index can hold its alias on one side
+ and not the other — e.g. created before dual-write started, counterpart built later — and that
+ asymmetry is what the operator needs to see. It is what makes the report usable at all, since a
+ site-search index is known by its alias, never by its `sitesearch__` name. One
+ alias lookup per engine covers the whole set, not one per index. When an alias is itself shaped
+ like an index name, `recommendation` appends a NOTE: that is the fingerprint of the crawl overwrite
+ fixed in issue #36983 — the fix stops new occurrences but cannot restore an alias already lost, so
+ this is the only way to find the indices that still need theirs restored. It never changes
+ `verdict`: the verdict measures data integrity (existence + counts), while a damaged alias costs no
+ data and must not block a phase change.
- **Stateless, from live counts.** Every field is derived at request time. Counts are **exact** — the
Site Search half uses `SiteSearchAPI.documentCount` and the content half reads each engine leaf's
`getIndicesStats()` (index `_stats` `primaries.docs.count`), never a search total (which the ES/OS
diff --git a/dotCMS/src/main/java/com/dotcms/content/index/migration/MirrorStatus.java b/dotCMS/src/main/java/com/dotcms/content/index/migration/MirrorStatus.java
index b581a23fdc80..3e072cbbb15f 100644
--- a/dotCMS/src/main/java/com/dotcms/content/index/migration/MirrorStatus.java
+++ b/dotCMS/src/main/java/com/dotcms/content/index/migration/MirrorStatus.java
@@ -1,6 +1,7 @@
package com.dotcms.content.index.migration;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
@@ -47,14 +48,29 @@ public enum Verdict {
/**
* One engine's copy of the index.
*
+ *
The alias is reported per engine on purpose: during the migration an index can carry
+ * its alias on one engine and not on the other (e.g. an index created before dual-write started,
+ * whose counterpart was built later), and that asymmetry is precisely what an operator needs to
+ * see. Collapsing both sides into one field would hide it.
+ *
* @param exists whether this engine holds the index
* @param docCount exact document count (0 when absent, -1 when the count query failed)
* @param physicalName the full index name as stored on that engine's server — cluster-prefixed and,
* for OpenSearch, {@code .os}-tagged (e.g. {@code cluster_08abc3.live_20260406}
* on ES, {@code cluster_08abc3.live_20260406.os} on OS). Reported whether or not
* the copy exists, so a missing copy shows the name to look for.
+ * @param alias the alias this engine has attached to the index, or {@code null} when it has
+ * none — and always {@code null} for the content indices, which are addressed by
+ * name only. Omitted from the JSON when {@code null}.
*/
- public record EngineCopy(boolean exists, long docCount, String physicalName) {}
+ public record EngineCopy(boolean exists, long docCount, String physicalName,
+ @JsonInclude(JsonInclude.Include.NON_NULL) String alias) {
+
+ /** An engine copy with no alias — the shape the content indices use. */
+ public EngineCopy(final boolean exists, final long docCount, final String physicalName) {
+ this(exists, docCount, physicalName, null);
+ }
+ }
/** Whether this index needs operator action (a re-crawl / reindex) before the phase change. */
public boolean needsAttention() {
diff --git a/dotCMS/src/main/java/com/dotcms/content/index/migration/SiteSearchMirrorReconciler.java b/dotCMS/src/main/java/com/dotcms/content/index/migration/SiteSearchMirrorReconciler.java
index c22cbe11d799..3ba522384e9b 100644
--- a/dotCMS/src/main/java/com/dotcms/content/index/migration/SiteSearchMirrorReconciler.java
+++ b/dotCMS/src/main/java/com/dotcms/content/index/migration/SiteSearchMirrorReconciler.java
@@ -10,9 +10,12 @@
import com.dotmarketing.sitesearch.business.SiteSearchAPI;
import com.google.common.annotations.VisibleForTesting;
import java.util.ArrayList;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
import java.util.TreeSet;
import java.util.function.Supplier;
+import java.util.regex.Pattern;
/**
* Site Search half of the migration-readiness report (issue #36360): compares every logical
@@ -52,14 +55,29 @@ public SiteSearchMirrorReconciler() {
public List statuses() {
final TreeSet names = new TreeSet<>(esImpl.listIndices());
names.addAll(osImpl.listIndices());
+ // One alias lookup per engine for the whole set — not one per index. Operators identify a
+ // site-search index by its alias, never by its sitesearch__ name, so the
+ // report is unusable without it (issue #36983).
+ final Map esAliases = indexToAlias(esImpl);
+ final Map osAliases = indexToAlias(osImpl);
final List statuses = new ArrayList<>(names.size());
for (final String name : names) {
- statuses.add(statusFor(name));
+ statuses.add(statusFor(name, esAliases.get(name), osAliases.get(name)));
}
return statuses;
}
- private MirrorStatus statusFor(final String name) {
+ /**
+ * Reverses one engine's {@code alias -> index} map into {@code index -> alias}. Both leaves return
+ * logical (untagged) index names, so the keys line up with {@link SiteSearchAPI#listIndices()}.
+ */
+ private static Map indexToAlias(final SiteSearchAPI engine) {
+ final Map reversed = new HashMap<>();
+ engine.getAliasToIndexMap().forEach((alias, index) -> reversed.put(index, alias));
+ return reversed;
+ }
+
+ private MirrorStatus statusFor(final String name, final String esAlias, final String osAlias) {
final boolean esExists = esImpl.existsOnAllWriteEngines(name);
final boolean osExists = osImpl.existsOnAllWriteEngines(name);
final long esCount = esExists ? esImpl.documentCount(name) : 0L;
@@ -70,24 +88,59 @@ private MirrorStatus statusFor(final String name) {
final String osPhysical = IndexTag.OS.tag(esPhysical);
final Verdict verdict = MirrorStatus.verdictFor(esExists, osExists, esCount, osCount);
return new MirrorStatus(name, IndexKind.SITE_SEARCH,
- new MirrorStatus.EngineCopy(esExists, esCount, esPhysical),
- new MirrorStatus.EngineCopy(osExists, osCount, osPhysical),
- verdict, recommend(name, verdict));
+ new MirrorStatus.EngineCopy(esExists, esCount, esPhysical, esAlias),
+ new MirrorStatus.EngineCopy(osExists, osCount, osPhysical, osAlias),
+ verdict, recommend(name, verdict, esAlias, osAlias));
+ }
+
+ /**
+ * A site-search index name: {@code sitesearch_[_]}. Used to spot an alias that is
+ * really an index name — see {@link #corruptedAlias(String, String)}.
+ */
+ private static final Pattern INDEX_NAME_SHAPED =
+ Pattern.compile("^" + SiteSearchAPI.ES_SITE_SEARCH_NAME + "_\\d{8,}.*", Pattern.CASE_INSENSITIVE);
+
+ /**
+ * The alias of {@code name} on either engine when it is really an INDEX NAME rather than an alias —
+ * the fingerprint of the defect fixed in issue #36983, where a crawl re-applied the name of the
+ * index it had just deleted as the new index's alias. The fix stops it from happening again but
+ * cannot restore an alias already overwritten, so the report surfaces it: this is the only way an
+ * operator can tell which indices still need their alias restored.
+ *
+ * @return the offending alias, or {@code null} when neither engine's alias looks like an index name
+ */
+ private static String corruptedAlias(final String esAlias, final String osAlias) {
+ if (esAlias != null && INDEX_NAME_SHAPED.matcher(esAlias).matches()) {
+ return esAlias;
+ }
+ if (osAlias != null && INDEX_NAME_SHAPED.matcher(osAlias).matches()) {
+ return osAlias;
+ }
+ return null;
}
- private static String recommend(final String name, final Verdict verdict) {
+ private static String recommend(final String name, final Verdict verdict, final String esAlias,
+ final String osAlias) {
+ // Reported alongside the sync verdict, never as part of it: the verdict measures data
+ // integrity (existence + counts), while a damaged alias is an identification problem. Folding
+ // it into the verdict would block a phase change over something that costs no data.
+ final String corrupted = corruptedAlias(esAlias, osAlias);
+ final String aliasNote = corrupted == null ? "" : String.format(
+ " NOTE: the alias '%s' is an index name, not a real alias — a crawl overwrote the "
+ + "original alias of '%s' (issue #36983). Re-crawl this index with the intended "
+ + "alias to restore it.", corrupted, name);
switch (verdict) {
case IN_SYNC:
- return "In sync — no action needed.";
+ return "In sync — no action needed." + aliasNote;
case MISSING_COUNTERPART:
return String.format("A copy of site-search index '%s' is missing on one engine. "
+ "Re-crawl it (Site Search → Run now) to rebuild the counterpart before "
- + "promoting to the OpenSearch-only phase.", name);
+ + "promoting to the OpenSearch-only phase.", name) + aliasNote;
case COUNT_DRIFT:
default:
return String.format("The two copies of site-search index '%s' hold a different "
+ "number of documents. Re-crawl it (Site Search → Run now) to rebuild "
- + "the counterpart before promoting the phase.", name);
+ + "the counterpart before promoting the phase.", name) + aliasNote;
}
}
}
diff --git a/dotCMS/src/test/java/com/dotcms/content/index/migration/SiteSearchMirrorReconcilerTest.java b/dotCMS/src/test/java/com/dotcms/content/index/migration/SiteSearchMirrorReconcilerTest.java
new file mode 100644
index 000000000000..76418d7d37fe
--- /dev/null
+++ b/dotCMS/src/test/java/com/dotcms/content/index/migration/SiteSearchMirrorReconcilerTest.java
@@ -0,0 +1,141 @@
+package com.dotcms.content.index.migration;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.dotcms.UnitTestBase;
+import com.dotcms.content.index.migration.MirrorStatus.Verdict;
+import com.dotmarketing.sitesearch.business.SiteSearchAPI;
+import java.util.List;
+import java.util.Map;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Unit tests for the alias half of {@link SiteSearchMirrorReconciler} — how the migration-readiness
+ * report identifies a site-search index (issue #36983). Both engine leaves are mocked, so no live
+ * cluster is needed.
+ *
+ *
Operators know a site-search index by its alias, never by its {@code sitesearch__}
+ * name, so the report carries the alias each engine has attached — per engine, because during the
+ * migration the two sides can legitimately disagree.
+ */
+public class SiteSearchMirrorReconcilerTest extends UnitTestBase {
+
+ private static final String PREFIX = "cluster_x.";
+ private static final String INDEX = "sitesearch_20260810160529";
+
+ private SiteSearchAPI es;
+ private SiteSearchAPI os;
+
+ @Before
+ public void setUp() {
+ es = mock(SiteSearchAPI.class);
+ os = mock(SiteSearchAPI.class);
+ when(es.listIndices()).thenReturn(List.of(INDEX));
+ when(os.listIndices()).thenReturn(List.of(INDEX));
+ when(es.existsOnAllWriteEngines(anyString())).thenReturn(true);
+ when(os.existsOnAllWriteEngines(anyString())).thenReturn(true);
+ when(es.documentCount(anyString())).thenReturn(10L);
+ when(os.documentCount(anyString())).thenReturn(10L);
+ when(es.getAliasToIndexMap()).thenReturn(Map.of());
+ when(os.getAliasToIndexMap()).thenReturn(Map.of());
+ }
+
+ private SiteSearchMirrorReconciler reconciler() {
+ return new SiteSearchMirrorReconciler(es, os, () -> PREFIX);
+ }
+
+ private MirrorStatus onlyStatus() {
+ final List statuses = reconciler().statuses();
+ assertEquals(1, statuses.size());
+ return statuses.get(0);
+ }
+
+ /** The alias each engine holds is reported on that engine's copy. */
+ @Test
+ public void alias_isReportedPerEngine() {
+ when(es.getAliasToIndexMap()).thenReturn(Map.of("sitesearch-ph-3", INDEX));
+ when(os.getAliasToIndexMap()).thenReturn(Map.of("sitesearch-ph-3", INDEX));
+
+ final MirrorStatus status = onlyStatus();
+
+ assertEquals("sitesearch-ph-3", status.es().alias());
+ assertEquals("sitesearch-ph-3", status.os().alias());
+ assertEquals(Verdict.IN_SYNC, status.verdict());
+ }
+
+ /**
+ * An alias present on one engine and absent on the other is exactly the asymmetry an operator
+ * needs to see before promoting a phase, so it must survive as two distinct values.
+ */
+ @Test
+ public void alias_missingOnOneEngine_staysVisibleOnTheOther() {
+ when(es.getAliasToIndexMap()).thenReturn(Map.of("sitesearch-ph-3", INDEX));
+ when(os.getAliasToIndexMap()).thenReturn(Map.of());
+
+ final MirrorStatus status = onlyStatus();
+
+ assertEquals("sitesearch-ph-3", status.es().alias());
+ assertNull(status.os().alias());
+ }
+
+ /** An index with no alias anywhere reports none — the field is simply absent from the payload. */
+ @Test
+ public void alias_absentOnBothEngines_isNull() {
+ final MirrorStatus status = onlyStatus();
+
+ assertNull(status.es().alias());
+ assertNull(status.os().alias());
+ assertFalse(status.recommendation().contains("NOTE"));
+ }
+
+ /**
+ * An alias that is really an index name is the fingerprint of the overwrite fixed in issue #36983.
+ * The fix cannot restore an alias already lost, so the report must call it out — while leaving the
+ * sync verdict alone, since no data is at risk.
+ */
+ @Test
+ public void aliasShapedLikeAnIndexName_isFlaggedWithoutChangingTheVerdict() {
+ final String corrupted = "sitesearch_20260806203309";
+ when(es.getAliasToIndexMap()).thenReturn(Map.of(corrupted, INDEX));
+ when(os.getAliasToIndexMap()).thenReturn(Map.of(corrupted, INDEX));
+
+ final MirrorStatus status = onlyStatus();
+
+ assertEquals(corrupted, status.es().alias());
+ assertTrue(status.recommendation().contains("is an index name, not a real alias"));
+ assertTrue(status.recommendation().contains(corrupted));
+ assertEquals("A damaged alias must not affect the data-integrity verdict", Verdict.IN_SYNC,
+ status.verdict());
+ assertFalse(status.needsAttention());
+ }
+
+ /** A real alias that merely starts with the site-search prefix is NOT mistaken for an index name. */
+ @Test
+ public void aliasStartingWithThePrefix_isNotFlagged() {
+ when(es.getAliasToIndexMap()).thenReturn(Map.of("sitesearch-ph-3", INDEX));
+ when(os.getAliasToIndexMap()).thenReturn(Map.of("sitesearch_prod", INDEX));
+
+ assertFalse(onlyStatus().recommendation().contains("NOTE"));
+ }
+
+ /** The alias lookup is one call per engine for the whole set, not one per index. */
+ @Test
+ public void aliasLookup_runsOncePerEngine() {
+ when(es.listIndices()).thenReturn(List.of(INDEX, "sitesearch_20260811090000"));
+ when(os.listIndices()).thenReturn(List.of(INDEX, "sitesearch_20260811090000"));
+
+ assertEquals(2, reconciler().statuses().size());
+
+ verify(es, times(1)).getAliasToIndexMap();
+ verify(os, times(1)).getAliasToIndexMap();
+ }
+}
\ No newline at end of file
From 921a06e939a089bf07d98f47347cedb1f1059dbb Mon Sep 17 00:00:00 2001
From: fabrizzio-dotCMS
Date: Tue, 11 Aug 2026 11:10:08 -0600
Subject: [PATCH 03/15] fix(sitesearch): resolve aliases across both engines
for management views (#36983)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
listIndices() is a UNION of both engines in the dual-write phases, while
getAliasToIndexMap() resolves against a single engine (the read
provider). Any index living only on the other engine therefore appears in
the list with a blank alias — two mirror images of one defect:
- Phase 2 + an index created in Phase 0 (Elasticsearch only).
- Phase 1 + an index created in Phase 3 (OpenSearch only), which is what
a tester hits after downgrading 3 -> 2 -> 1.
Adds SiteSearchAPI#getAliasToIndexMapAllEngines(), resolved over the same
provider set listIndices() uses, with the read provider applied last so
it wins a mirror desync and the view never contradicts what a search
would hit. The single-engine method stays as is: searching must resolve
against the engine that serves the query.
Switched to it: the Indices tab, the crawl index selector, the Search tab
selector (which also stops showing raw index IDs there) and
SiteSearchJobImpl — where an invisible alias made the crawl treat an
existing index as new and drop its alias, the same loss this PR fixes for
the raw-name case.
Docs: the two alias views and when to use each; how to read the readiness
report (including the admin + migration-role gate, 403 otherwise) with
worked examples for the downgrade case and for activating a
pre-migration backup content index, whose OpenSearch counterpart is never
built and which only a full reindex repairs.
Co-Authored-By: Claude Opus 5 (1M context)
---
docs/backend/OPENSEARCH_MIGRATION.md | 157 ++++++++++++++++++
.../sitesearch/SiteSearchAPIImpl.java | 28 ++++
.../publishing/job/SiteSearchJobImpl.java | 7 +-
.../sitesearch/business/SiteSearchAPI.java | 36 ++++
.../sitesearch/site_search_index_stats.jsp | 5 +-
.../sitesearch/site_search_job_schedule.jsp | 4 +-
.../ext/sitesearch/test_site_search.jsp | 11 +-
.../SiteSearchRouterReconciliationTest.java | 62 +++++++
.../job/SiteSearchJobAliasResolutionTest.java | 8 +-
9 files changed, 310 insertions(+), 8 deletions(-)
diff --git a/docs/backend/OPENSEARCH_MIGRATION.md b/docs/backend/OPENSEARCH_MIGRATION.md
index d50b4e086df0..775ea8d680b0 100644
--- a/docs/backend/OPENSEARCH_MIGRATION.md
+++ b/docs/backend/OPENSEARCH_MIGRATION.md
@@ -295,6 +295,40 @@ can preview `.os` (and the ES/OS twin as distinct rows) while normal users keep
view — is the one place the two UIs should converge; it does **not** require changing the internal
handle model, only the display sink.
+##### Two alias views: searching vs. managing (issue #36983)
+
+`SiteSearchAPI` exposes the alias map twice, and picking the wrong one is a bug:
+
+| Method | Resolves against | Use it for |
+|---|---|---|
+| `getAliasToIndexMap()` | the **read provider only** (ES in Phases 0/1, OS in Phases 2/3) | **searching** — resolve an alias against the engine that will actually serve the query |
+| `getAliasToIndexMapAllEngines()` | the **same provider set as `listIndices()`** (union in Phases 1/2) | **managing / displaying** — portlet columns, index selectors, choosing an index to crawl |
+
+The reason there are two: **`listIndices()` is a union of both engines in the dual-write phases,
+while alias resolution is single-engine.** Any index that lives only on the engine the current phase
+does *not* read from therefore appears in the list with a blank alias. Two mirror-image symptoms of
+the same defect:
+
+- **Phase 2 + an index created in Phase 0** (Elasticsearch only) — reads come from OpenSearch, alias
+ invisible.
+- **Phase 1 + an index created in Phase 3** (OpenSearch only; typical after a downgrade 3 → 2 → 1) —
+ reads come from Elasticsearch, alias invisible.
+
+`getAliasToIndexMapAllEngines()` merges over the write providers and applies the **read provider
+last**, so on a mirror desync (one alias resolving to different indices per engine) the management
+view agrees with what a search would hit. In the single-provider phases (0 and 3) there is nothing to
+merge and the idle engine is not consulted.
+
+Callers on the management side: `site_search_index_stats.jsp` (Indices tab), `site_search_job_schedule.jsp`
+(crawl index selector), `test_site_search.jsp` (Search tab selector) and `SiteSearchJobImpl` (the
+crawl's alias resolution — an alias invisible there makes the crawl treat an existing index as new
+and drop its alias). Everything on the search path keeps the single-engine method.
+
+**A phase change never builds counterparts retroactively.** An index created in a single-provider
+phase exists on that engine only until a crawl runs in a dual-write phase. Downgrading past that
+point leaves it listed but unsearchable (its content lives on the engine that no longer serves
+reads) — visible in the readiness report as `MISSING_COUNTERPART`; the fix is always a re-crawl.
+
#### Site Search mirror reconciliation (write path) — self-heal on crawl
The logical-handle model above makes *reads* correct, but a Site Search index can still end up
@@ -414,6 +448,129 @@ Because this endpoint is the source of truth for migration/QA, the index portlet
`.os` indices by role: `MigrationIndexVisibility` is now purely phase-based (hidden in Phases 0/1/2,
shown in Phase 3, for everyone). The role key is retained only to gate this endpoint.
+##### How to read the readiness report
+
+**Access — both conditions, or 403.** The caller must be a **CMS administrator** *and* hold the
+migration support role. The role key comes from `OS_MIGRATION_INDEX_VISIBILITY_ROLE_KEY` (default
+`os_migration_qa`); the check is `MigrationReadinessResource.isMigrationSupportUser`. A plain admin
+without the role gets a 403, and so does a role holder who is not an admin — deliberate, so a regular
+user never learns a migration is running. The endpoint is `@Hidden`, so it is absent from
+`openapi.yaml` and from the API playground: it will not show up by browsing, only by knowing the URL.
+
+```bash
+# Backend session or basic auth; both the admin role and the support role are required.
+curl -u admin@dotcms.com:admin http://localhost:8080/api/v1/index/migration/readiness | jq
+```
+
+If it returns 403, grant the `os_migration_qa` role to the admin user (Roles & Permissions), or point
+`OS_MIGRATION_INDEX_VISIBILITY_ROLE_KEY` at a role they already hold. There is no envelope: the JSON
+**is** the report.
+
+**Read it top-down, in this order:**
+
+1. **`phase`** — `current`/`name`, plus `readEngine`, `writeEngines` and `dualWrite`. Everything below
+ is relative to this: which engine answers searches *right now*, and which ones receive writes.
+2. **`verdict.safeToAdvance` / `verdict.safeToRollback`** — the go/no-go pair. They answer different
+ questions and are not opposites: *advance* is blocked when the OpenSearch mirror is behind
+ (promoting would lose data on the OpenSearch-only phase); *rollback* is blocked when OpenSearch is
+ **ahead** (downgrading would hide the delta until a reindex). Both can be `false` at once.
+3. **`verdict.summary` + `verdict.blockers`** — the sentence to paste into a ticket, then the per-index
+ list of what to fix. An empty `blockers` with `safeToAdvance: false` cannot happen; if `blockers` is
+ non-empty, each entry names the index and the action.
+4. **`content` (keyed `WORKING`/`LIVE`) and `siteSearch` (list)** — the evidence behind the verdict.
+
+**Per-index row.** `es` and `os` each carry `{exists, docCount, physicalName}` — plus `alias` on Site
+Search rows. Then:
+
+| Field | How to read it |
+|---|---|
+| `verdict` | `IN_SYNC` · `MISSING_COUNTERPART` (one engine lacks the index) · `COUNT_DRIFT` (both hold it, different counts) |
+| `driftPercent` | Signed % the OpenSearch mirror deviates from the Elasticsearch original. `0.0` in sync · negative = mirror **behind** · positive = mirror **ahead** · `-100.0` mirror empty/absent · `null` a count failed |
+| `docCount: -1` | The count could **not** be measured. Never read it as "zero" — the verdict treats it as out of sync on purpose |
+| `physicalName` | The exact name on that server (cluster-prefixed; `.os`-tagged on OpenSearch) — copy/paste it into `_cat/indices` to verify by hand |
+| `recommendation` | The concrete action (re-crawl / reindex). A trailing `NOTE:` flags an alias that is really an index name (see above) |
+
+**Worked example — the downgrade case.** After going 3 → 2 → 1, a Site Search index created by a
+crawl while in Phase 3 exists **only** on OpenSearch:
+
+```json
+{ "indexName": "sitesearch_20260811155758_6c1f7101-…",
+ "es": { "exists": false, "docCount": 0, "physicalName": "cluster_x.sitesearch_20260811155758_6c1f7101-…" },
+ "os": { "exists": true, "docCount": 412, "physicalName": "cluster_x.sitesearch_20260811155758_6c1f7101-….os",
+ "alias": "sitesearch-ph-3" },
+ "driftPercent": 100.0, "verdict": "MISSING_COUNTERPART" }
+```
+
+Read as: the index and its alias are intact on OpenSearch, but in Phase 1 reads come from
+Elasticsearch, where it does not exist — so **its content is unsearchable until it is re-crawled**,
+and `safeToRollback` is `false` because OpenSearch holds documents Elasticsearch does not. A phase
+change never builds counterparts retroactively; only a crawl (or reindex, for content) does.
+
+Note this is exactly the information the *portlet* could not show before issue #36983: the index list
+is a union of both engines while alias resolution was single-engine, so that row rendered with a blank
+Alias. The endpoint never had that blind spot — it queries both engine leaves directly, in every
+phase — which is why it stays the source of truth even when a portlet column looks empty.
+
+##### Worked example — activating a pre-migration backup content index
+
+dotCMS lets an administrator activate an **old inactive index** (Maintenance → Index → *Make Default*,
+or `PUT /api/es/activateindex/…`) to roll back to a previous reindex. If that index **predates the
+migration**, it never went through the OpenSearch create fan-out, so it has **no OpenSearch
+counterpart** — and activation does not build one.
+
+**What the code actually does.** `ContentletIndexAPIImpl.activateIndex` repoints *both* stores by pure
+name transformation: the OpenSearch pointer is set to `operationsOS.toPhysicalName(name)` =
+`..os`, with **no `indexExists` check, no create and no guard** (delete has
+`assertIndexNotActive`; activate has no equivalent). The OpenSearch store now names an index that has
+never existed. In Phases 1/2 the shadow writes to it are best-effort and swallowed, so nothing
+complains.
+
+**Why it is dangerous rather than merely wrong:**
+
+| Phase | What you see |
+|---|---|
+| 1 | Nothing. Silent divergence — writes to the OpenSearch counterpart go nowhere |
+| 2 | Still works: the Phase-2 read fallback drops back to Elasticsearch, but logs an `ERROR` per read — the early-warning signal |
+| 3 | No fallback exists. The OpenSearch pointer names an index that was never created → empty results or an exception, which reads to the customer as **lost content** |
+
+**What the readiness endpoint says — and when it can say it.** Once the backup is activated it *is*
+the `WORKING`/`LIVE` pointer, so the very next call reports it:
+
+```json
+"content": {
+ "WORKING": {
+ "indexName": "working_20251114093012",
+ "es": { "exists": true, "docCount": 148230, "physicalName": "cluster_x.working_20251114093012" },
+ "os": { "exists": false, "docCount": 0, "physicalName": "cluster_x.working_20251114093012.os" },
+ "driftPercent": -100.0,
+ "verdict": "MISSING_COUNTERPART",
+ "recommendation": "The OpenSearch copy of content index 'working_20251114093012' is missing. Run a full reindex to rebuild it before promoting to the OpenSearch-only phase."
+ }
+}
+```
+
+In Phases 1/2 this also flips `verdict.safeToAdvance` to `false` and names the index in
+`verdict.blockers` — the promotion gate does its job. **The fix is a full reindex**: that is the only
+path that fans out through the router and materializes the OpenSearch copy (a phase change never
+does, and neither does activation).
+
+**Three traps worth knowing before relying on this:**
+
+1. **You cannot pre-check a backup.** The content half of the report covers only the *active*
+ working/live pair, so a divergent backup is invisible while it sits inactive. Sequence: activate →
+ call readiness → reindex if it reports `MISSING_COUNTERPART` → only then change phase.
+2. **In Phase 3 the verdict does not protect you.** `safeToAdvance` is forced `true` there (there is no
+ phase beyond 3), so a backup activated *while already in Phase 3* still reads green at the top
+ level. Read the per-index rows and `outOfSyncCount`, never the boolean alone — and note this is
+ precisely the phase where the failure is immediate and customer-visible.
+3. **The endpoint reports, it never repairs.** It will not block the activation, and re-running it
+ changes nothing on its own.
+
+The durable fix — reconcile-on-activate, rebuilding the counterpart asynchronously through the
+existing reindex machinery (a synchronous copy of a large index is not viable, and a naive
+point-in-time copy would lose concurrent writes) — is **not implemented**. Until it is, the operational
+rule stands: after activating any pre-migration index, run a full reindex before touching the phase.
+
#### Tag manipulation is the sole responsibility of `IndexTag`
All read/write of the vendor marker on an index name MUST go through the `IndexTag` enum.
diff --git a/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/sitesearch/SiteSearchAPIImpl.java b/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/sitesearch/SiteSearchAPIImpl.java
index 33fc35e96520..26faf583ab68 100644
--- a/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/sitesearch/SiteSearchAPIImpl.java
+++ b/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/sitesearch/SiteSearchAPIImpl.java
@@ -25,6 +25,7 @@
import java.text.ParseException;
import java.util.ArrayList;
import java.util.HashMap;
+import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
@@ -201,6 +202,33 @@ public Map getAliasToIndexMap() {
return router.read(SiteSearchAPI::getAliasToIndexMap);
}
+ /**
+ * Router: alias resolution over the SAME provider set {@link #listIndices()} uses, so every listed
+ * index can show its alias — the management/display view (issue #36983).
+ *
+ *
Deliberately NOT the read provider alone. The list is a union in the dual-write phases, so an
+ * index living only on the other engine would otherwise render with a blank alias: Phase 2 + a
+ * Phase-0 (ES-only) index, or Phase 1 + a Phase-3 (OS-only) index after a downgrade. Merging over
+ * the write providers keeps the alias view and the index list exactly in step.
+ *
+ *
The read provider is applied last so it wins any collision: if the two engines resolve one
+ * alias to different logical indices (a mirror desync), the map agrees with what a search would
+ * actually hit. In the single-provider phases (0 and 3) there is nothing to merge.
+ */
+ @Override
+ public Map getAliasToIndexMapAllEngines() {
+ final List providers = router.writeProviders();
+ if (providers.size() == 1) {
+ return providers.getFirst().getAliasToIndexMap();
+ }
+ final SiteSearchAPI readProvider = router.readProvider();
+ final Map merged = new LinkedHashMap<>();
+ providers.stream().filter(provider -> provider != readProvider)
+ .forEach(provider -> merged.putAll(provider.getAliasToIndexMap()));
+ merged.putAll(readProvider.getAliasToIndexMap()); // last write wins → read provider
+ return merged;
+ }
+
// -------------------------------------------------------------------------
// Reads — read provider
// -------------------------------------------------------------------------
diff --git a/dotCMS/src/main/java/com/dotcms/publishing/job/SiteSearchJobImpl.java b/dotCMS/src/main/java/com/dotcms/publishing/job/SiteSearchJobImpl.java
index 45aaa09078fd..86af26c866e9 100644
--- a/dotCMS/src/main/java/com/dotcms/publishing/job/SiteSearchJobImpl.java
+++ b/dotCMS/src/main/java/com/dotcms/publishing/job/SiteSearchJobImpl.java
@@ -417,7 +417,12 @@ IndexMetaData getIndexMetaData(String indexAlias) throws DotDataException {
// Resolve via the site-search API so aliases are looked up with .os-aware physical names
// in Phases 2/3; the content-index router misses site-search aliases there
// and would force every crawl into full mode (issue #36360).
- final Map aliasMap = siteSearchAPI.getAliasToIndexMap();
+ // AllEngines: over the same provider set as `indices` above. A crawl can legitimately
+ // target an index that lives only on the engine the phase does not read from (e.g. an
+ // OpenSearch-only index created in Phase 3, seen again after a downgrade to Phase 1) —
+ // with a read-provider-only map its alias is invisible, so the crawl would treat it as a
+ // brand-new index and drop the alias instead of carrying it over (issue #36983).
+ final Map aliasMap = siteSearchAPI.getAliasToIndexMapAllEngines();
indexName = aliasMap.get(indexAlias);
if (UtilMethods.isSet(indexName)) {
if (siteSearchAPI.isDefaultIndex(indexAlias)) {
diff --git a/dotCMS/src/main/java/com/dotmarketing/sitesearch/business/SiteSearchAPI.java b/dotCMS/src/main/java/com/dotmarketing/sitesearch/business/SiteSearchAPI.java
index d314a30a8b88..c56df57d021d 100644
--- a/dotCMS/src/main/java/com/dotmarketing/sitesearch/business/SiteSearchAPI.java
+++ b/dotCMS/src/main/java/com/dotmarketing/sitesearch/business/SiteSearchAPI.java
@@ -139,6 +139,42 @@ public interface SiteSearchAPI {
*/
Map getAliasToIndexMap();
+ /**
+ * Alias resolution for management and display — the same map as
+ * {@link #getAliasToIndexMap()} but covering every index the current phase lists, not only
+ * those on the read provider.
+ *
+ *
Why a second method instead of changing the first
+ * {@link #listIndices()} is a union of both engines in the dual-write phases, while
+ * {@link #getAliasToIndexMap()} resolves against a single engine (the read provider). Any
+ * index that lives only on the other engine therefore appears in the list with a blank alias:
+ *
+ *
+ *
Phase 2 + an index created in Phase 0 (Elasticsearch only) — reads come from
+ * OpenSearch, so its alias is invisible.
+ *
Phase 1 + an index created in Phase 3 (OpenSearch only, e.g. after a downgrade) —
+ * reads come from Elasticsearch, so its alias is invisible.
+ *
+ *
+ * The two are mirror images of one defect (issue #36983). This method closes it by resolving over
+ * the same provider set {@code listIndices()} uses, so every listed index can show its alias.
+ *
+ *
The distinction is deliberate and must be kept: searching resolves an alias
+ * against the engine that will actually serve the query — that is {@link #getAliasToIndexMap()} and
+ * it stays single-engine. Managing (listing indices, choosing one to crawl,
+ * labelling a row in the portlet) needs to identify everything on screen, which is this method.
+ *
+ *
When both engines resolve the same alias to different logical indices — a mirror desync — the
+ * read provider's answer wins, so the map never disagrees with what a search would do.
+ *
+ * @return map of logical alias name to logical index name across the phase's provider set; empty
+ * when nothing resolves
+ */
+ default Map getAliasToIndexMapAllEngines() {
+ // A single-engine implementation (either leaf) has nothing to merge — only the router overrides.
+ return getAliasToIndexMap();
+ }
+
/**
* This basically tells you if the index passed as parameter is the default site search index or not
* @param indexName
diff --git a/dotCMS/src/main/webapp/html/portlet/ext/sitesearch/site_search_index_stats.jsp b/dotCMS/src/main/webapp/html/portlet/ext/sitesearch/site_search_index_stats.jsp
index 88453690bd6f..7cefe9df190d 100644
--- a/dotCMS/src/main/webapp/html/portlet/ext/sitesearch/site_search_index_stats.jsp
+++ b/dotCMS/src/main/webapp/html/portlet/ext/sitesearch/site_search_index_stats.jsp
@@ -49,8 +49,11 @@ Map indexInfo = esapi.getIndicesStats();
// Site-search .os-aware alias resolution (issue #36360): resolve through the site-search API and
// reverse (alias->index) into index->alias for per-row display. The content-index router (esapi)
// misses site-search aliases in Phases 2/3 because it queries OpenSearch without the .os tag.
+// AllEngines (issue #36983): the rows below come from listIndices(), a union of both engines in the
+// dual-write phases, so a read-provider-only alias map blanks the Alias column for every index that
+// lives on the other engine (a Phase-0 index seen in Phase 2, a Phase-3 index seen in Phase 1).
Map alias = new java.util.HashMap<>();
-for (Map.Entry aliasEntry : ssapi.getAliasToIndexMap().entrySet()) {
+for (Map.Entry aliasEntry : ssapi.getAliasToIndexMapAllEngines().entrySet()) {
alias.put(aliasEntry.getValue(), aliasEntry.getKey());
}
SimpleDateFormat dater = APILocator.getContentletIndexAPI().timestampFormatter;
diff --git a/dotCMS/src/main/webapp/html/portlet/ext/sitesearch/site_search_job_schedule.jsp b/dotCMS/src/main/webapp/html/portlet/ext/sitesearch/site_search_job_schedule.jsp
index 0ab27933d43e..094a9c252a1f 100644
--- a/dotCMS/src/main/webapp/html/portlet/ext/sitesearch/site_search_job_schedule.jsp
+++ b/dotCMS/src/main/webapp/html/portlet/ext/sitesearch/site_search_job_schedule.jsp
@@ -21,8 +21,10 @@ List indexes = ssapi.listIndices();
// misses site-search aliases in Phases 2/3 because the physical OpenSearch index is .os-tagged, so
// the selector fell back to the raw internal index name — which is then saved as the job's
// `indexAlias` and later re-applied as the new index's alias by the crawl, destroying the real one.
+// AllEngines: this list is a union of both engines, so the alias view must cover the same set or an
+// index living only on the non-read engine renders (and gets saved) as a raw name again.
Map alias = new HashMap();
-for (Map.Entry aliasEntry : ssapi.getAliasToIndexMap().entrySet()) {
+for (Map.Entry aliasEntry : ssapi.getAliasToIndexMapAllEngines().entrySet()) {
alias.put(aliasEntry.getValue(), aliasEntry.getKey());
}
diff --git a/dotCMS/src/main/webapp/html/portlet/ext/sitesearch/test_site_search.jsp b/dotCMS/src/main/webapp/html/portlet/ext/sitesearch/test_site_search.jsp
index 3282079e853f..d186d3bd9782 100644
--- a/dotCMS/src/main/webapp/html/portlet/ext/sitesearch/test_site_search.jsp
+++ b/dotCMS/src/main/webapp/html/portlet/ext/sitesearch/test_site_search.jsp
@@ -56,7 +56,16 @@ try {
List indices=ssapi.listIndices();
-Map alias=esapi.getIndexAlias(indices);
+// Site-search alias resolution for the index selector (issue #36983): the content-index router
+// (esapi) queries OpenSearch without the .os tag, so it resolves nothing in Phases 2/3 and the
+// dropdown showed raw internal index names instead of the aliases operators know. Resolve through
+// the site-search API, over the same provider set listIndices() uses — the list is a union of both
+// engines, so a read-provider-only map would still blank the label of any index living on the other
+// engine. Only the LABEL uses the alias; the option value stays the index name the search needs.
+Map alias=new HashMap();
+for (Map.Entry aliasEntry : ssapi.getAliasToIndexMapAllEngines().entrySet()) {
+ alias.put(aliasEntry.getValue(), aliasEntry.getKey());
+}
Map indexInfo = esapi.getIndicesStats();
SimpleDateFormat dater = APILocator.getContentletIndexAPI().timestampFormatter;
diff --git a/dotCMS/src/test/java/com/dotcms/enterprise/publishing/sitesearch/SiteSearchRouterReconciliationTest.java b/dotCMS/src/test/java/com/dotcms/enterprise/publishing/sitesearch/SiteSearchRouterReconciliationTest.java
index 7ac7b06e3fed..7f11a5c213cd 100644
--- a/dotCMS/src/test/java/com/dotcms/enterprise/publishing/sitesearch/SiteSearchRouterReconciliationTest.java
+++ b/dotCMS/src/test/java/com/dotcms/enterprise/publishing/sitesearch/SiteSearchRouterReconciliationTest.java
@@ -1,5 +1,6 @@
package com.dotcms.enterprise.publishing.sitesearch;
+import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
@@ -15,6 +16,7 @@
import com.dotmarketing.exception.DotDataException;
import com.dotmarketing.sitesearch.business.SiteSearchAPI;
import com.dotmarketing.util.Config;
+import java.util.Map;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
@@ -233,4 +235,64 @@ public void deleteIndex_activeIndex_isRejectedBeforeAnyDelete() throws Exception
verify(esImpl, never()).deleteIndex(IDX);
verify(osImpl, never()).deleteIndex(IDX);
}
+
+ // =======================================================================
+ // getAliasToIndexMapAllEngines — the management/display alias view (#36983)
+ // =======================================================================
+
+ private static final String OS_ONLY_IDX = "sitesearch_20260811155758";
+
+ /**
+ * The defect: in Phase 1 reads come from Elasticsearch, so an index that lives only on OpenSearch
+ * (created by a crawl in Phase 3, still listed after a downgrade) had no resolvable alias and the
+ * portlet rendered it blank. The management view must see both engines.
+ */
+ @Test
+ public void aliasMapAllEngines_dualWrite_includesTheEngineThePhaseDoesNotReadFrom() {
+ setPhase(PHASE_1_DUAL_WRITE_ES_READS);
+ when(esImpl.getAliasToIndexMap()).thenReturn(Map.of("es-alias", IDX));
+ when(osImpl.getAliasToIndexMap()).thenReturn(Map.of("os-alias", OS_ONLY_IDX));
+
+ final Map merged = router.getAliasToIndexMapAllEngines();
+
+ assertEquals(2, merged.size());
+ assertEquals(IDX, merged.get("es-alias"));
+ assertEquals(OS_ONLY_IDX, merged.get("os-alias"));
+ }
+
+ /**
+ * Mirror desync — one alias resolving to different indices on each engine. The read provider wins,
+ * so the management view never contradicts what a search would actually hit.
+ */
+ @Test
+ public void aliasMapAllEngines_conflictingAlias_readProviderWins() {
+ setPhase(PHASE_1_DUAL_WRITE_ES_READS); // reads = ES
+ when(esImpl.getAliasToIndexMap()).thenReturn(Map.of("shared", IDX));
+ when(osImpl.getAliasToIndexMap()).thenReturn(Map.of("shared", OS_ONLY_IDX));
+
+ assertEquals(IDX, router.getAliasToIndexMapAllEngines().get("shared"));
+ }
+
+ /** Single-provider phase: nothing to merge, and the idle engine must not be consulted. */
+ @Test
+ public void aliasMapAllEngines_phase0_onlyConsultsEs() {
+ setPhase(PHASE_0_ES_ONLY);
+ when(esImpl.getAliasToIndexMap()).thenReturn(Map.of("es-alias", IDX));
+
+ assertEquals(Map.of("es-alias", IDX), router.getAliasToIndexMapAllEngines());
+ verify(osImpl, never()).getAliasToIndexMap();
+ }
+
+ /**
+ * The single-engine view stays single-engine: searches must resolve an alias against the engine
+ * that will serve the query, so widening this one would be wrong.
+ */
+ @Test
+ public void aliasMap_singleEngine_staysOnTheReadProvider() {
+ setPhase(PHASE_1_DUAL_WRITE_ES_READS); // reads = ES
+ when(esImpl.getAliasToIndexMap()).thenReturn(Map.of("es-alias", IDX));
+
+ assertEquals(Map.of("es-alias", IDX), router.getAliasToIndexMap());
+ verify(osImpl, never()).getAliasToIndexMap();
+ }
}
diff --git a/dotCMS/src/test/java/com/dotcms/publishing/job/SiteSearchJobAliasResolutionTest.java b/dotCMS/src/test/java/com/dotcms/publishing/job/SiteSearchJobAliasResolutionTest.java
index d70cb5863316..edcdb78577af 100644
--- a/dotCMS/src/test/java/com/dotcms/publishing/job/SiteSearchJobAliasResolutionTest.java
+++ b/dotCMS/src/test/java/com/dotcms/publishing/job/SiteSearchJobAliasResolutionTest.java
@@ -60,7 +60,7 @@ public void setup() {
*/
@Test
public void test_aliasStoredInJobDetail_isCarriedThrough() throws Exception {
- when(siteSearchAPI.getAliasToIndexMap()).thenReturn(Map.of(CUSTOM_ALIAS, EXISTING_INDEX));
+ when(siteSearchAPI.getAliasToIndexMapAllEngines()).thenReturn(Map.of(CUSTOM_ALIAS, EXISTING_INDEX));
final IndexMetaData metaData = job.getIndexMetaData(CUSTOM_ALIAS);
@@ -76,7 +76,7 @@ public void test_aliasStoredInJobDetail_isCarriedThrough() throws Exception {
*/
@Test
public void test_rawIndexNameStoredInJobDetail_resolvesTheIndexRealAlias() throws Exception {
- when(siteSearchAPI.getAliasToIndexMap()).thenReturn(Map.of(CUSTOM_ALIAS, EXISTING_INDEX));
+ when(siteSearchAPI.getAliasToIndexMapAllEngines()).thenReturn(Map.of(CUSTOM_ALIAS, EXISTING_INDEX));
final IndexMetaData metaData = job.getIndexMetaData(EXISTING_INDEX);
@@ -93,7 +93,7 @@ public void test_rawIndexNameStoredInJobDetail_resolvesTheIndexRealAlias() throw
*/
@Test
public void test_rawIndexNameWithoutAlias_resolvesToNoAlias() throws Exception {
- when(siteSearchAPI.getAliasToIndexMap()).thenReturn(Collections.emptyMap());
+ when(siteSearchAPI.getAliasToIndexMapAllEngines()).thenReturn(Collections.emptyMap());
final IndexMetaData metaData = job.getIndexMetaData(EXISTING_INDEX);
@@ -107,7 +107,7 @@ public void test_rawIndexNameWithoutAlias_resolvesToNoAlias() throws Exception {
*/
@Test
public void test_unknownName_isKeptAsTheAliasOfANewIndex() throws Exception {
- when(siteSearchAPI.getAliasToIndexMap()).thenReturn(Collections.emptyMap());
+ when(siteSearchAPI.getAliasToIndexMapAllEngines()).thenReturn(Collections.emptyMap());
when(siteSearchAPI.listIndices()).thenReturn(Collections.emptyList());
final IndexMetaData metaData = job.getIndexMetaData("brand-new-alias");
From 36eeef0807c884ee5b49179f226bc67243b245f5 Mon Sep 17 00:00:00 2001
From: fabrizzio-dotCMS
Date: Tue, 11 Aug 2026 11:17:19 -0600
Subject: [PATCH 04/15] docs(migration): state the driftPercent formula and the
+100.0 case (#36983)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The field table gave the sign convention but not the formula, and omitted
+100.0 (original empty, mirror holds data) — which is the value the
downgrade example prints, so a reader could not reconcile the two. Also
names which verdict each sign blocks: negative blocks advance, positive
blocks rollback.
Co-Authored-By: Claude Opus 5 (1M context)
---
docs/backend/OPENSEARCH_MIGRATION.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/backend/OPENSEARCH_MIGRATION.md b/docs/backend/OPENSEARCH_MIGRATION.md
index 775ea8d680b0..b1540a254a7f 100644
--- a/docs/backend/OPENSEARCH_MIGRATION.md
+++ b/docs/backend/OPENSEARCH_MIGRATION.md
@@ -485,7 +485,7 @@ Search rows. Then:
| Field | How to read it |
|---|---|
| `verdict` | `IN_SYNC` · `MISSING_COUNTERPART` (one engine lacks the index) · `COUNT_DRIFT` (both hold it, different counts) |
-| `driftPercent` | Signed % the OpenSearch mirror deviates from the Elasticsearch original. `0.0` in sync · negative = mirror **behind** · positive = mirror **ahead** · `-100.0` mirror empty/absent · `null` a count failed |
+| `driftPercent` | `(OS − ES) / ES × 100`, rounded to 2 decimals. `0.0` in sync · negative = mirror **behind** (blocks *advance*) · positive = mirror **ahead** (blocks *rollback*) · `-100.0` mirror empty/absent · `+100.0` the original is empty but the mirror holds data · `null` a count could not be measured |
| `docCount: -1` | The count could **not** be measured. Never read it as "zero" — the verdict treats it as out of sync on purpose |
| `physicalName` | The exact name on that server (cluster-prefixed; `.os`-tagged on OpenSearch) — copy/paste it into `_cat/indices` to verify by hand |
| `recommendation` | The concrete action (re-crawl / reindex). A trailing `NOTE:` flags an alias that is really an index name (see above) |
From 4db71764b19f8f2b978f59743b693dd6e885e0e4 Mon Sep 17 00:00:00 2001
From: fabrizzio-dotCMS
Date: Tue, 11 Aug 2026 14:01:08 -0600
Subject: [PATCH 05/15] docs(migration): document the _stats lag on the
readiness content counts (#36983)
The report claims exact counts but did not say the two halves measure
differently: Site Search issues a count query while the content half
reads _stats primaries.docs.count, a per-shard counter that only moves on
refresh. A just-written document is searchable while the content row
still shows the old number (~1-3s locally), which reads as a lost write.
Documents the lag and its bound, that the endpoint is a phase-change
advisory and not a write monitor, how to confirm a single write by
fetching the document instead of the count, and the two traps that make
the count misleading: re-publishing is an update (id is
identifier_lang_variant) so the count does not move, and a dual-write
mirror only receives what changes from that point on.
Co-Authored-By: Claude Opus 5 (1M context)
---
docs/backend/OPENSEARCH_MIGRATION.md | 22 ++++++++++++++++++++++
1 file changed, 22 insertions(+)
diff --git a/docs/backend/OPENSEARCH_MIGRATION.md b/docs/backend/OPENSEARCH_MIGRATION.md
index b1540a254a7f..ebbde8f4c8f7 100644
--- a/docs/backend/OPENSEARCH_MIGRATION.md
+++ b/docs/backend/OPENSEARCH_MIGRATION.md
@@ -431,6 +431,28 @@ through the write-path gate above.
`getIndicesStats()` (index `_stats` `primaries.docs.count`), never a search total (which the ES/OS
clients cap at 10,000 and would hide drift on large indices). Both reconcilers query the two engine
leaves directly, not the phase-aware router, so the report shows both sides in every phase.
+- **The content counts can lag a write by a few seconds — this is not a stale report.** The two halves
+ read the count differently: Site Search issues a count query (search API, `size:0`), while the
+ content half reads `_stats` `primaries.docs.count`, a per-shard counter that only moves once the
+ shard refreshes. A document that was just written is therefore *already searchable* while the
+ content row still shows the previous number; it catches up within ~1–3s (measured locally, not a
+ contract — an asynchronous indexing policy can make it longer). The endpoint decides whether a
+ **phase change** is safe, so a few seconds of lag is irrelevant to its purpose — but do not use it
+ as a real-time write monitor.
+
+ To confirm a single write landed, ask for the **document**, not the count — that answer is immediate
+ and independent of `_stats`:
+
+ ```bash
+ curl -s "http://:9200/.os/_doc/__DEFAULT"
+ # "found": true with the modDate of your edit ⇒ the dual-write landed
+ ```
+
+ Also note what a growing count does *not* prove and an unchanged one does not disprove: the document
+ id is `identifier_languageId_variant`, so re-publishing content already present in that index is an
+ **update** and leaves the count untouched. And in a dual-write phase the OpenSearch copy only ever
+ receives what changes *from that point on* — a mirror sitting at 15 of 683 documents is the expected
+ state until a full reindex, so a `+1` there is easy to misread as "nothing happened".
- **`safeToRollback` needs no history.** A downgrade routes reads back to Elasticsearch, so it is
unsafe when any index's ES copy is behind its OpenSearch counterpart (`esDocCount < osDocCount`, or
the ES copy missing) — that delta, typically content written while OpenSearch served reads, would be
From bafc6a498ce37fe8e59b4db5b844d837d2c814fa Mon Sep 17 00:00:00 2001
From: fabrizzio-dotCMS
Date: Tue, 11 Aug 2026 14:15:19 -0600
Subject: [PATCH 06/15] fix(migration): count content indices with a query, not
the _stats counter (#36983)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The readiness report is what support consults to answer "did this write
reach OpenSearch". It read the content document counts from _stats
docs.count — a per-shard counter that only advances on shard refresh, so
it trailed a just-written document by seconds. In that window the
document is already searchable while the report still shows the previous
number, which reads as a lost write and sends a technician chasing a
non-bug (or dismissing a real one). A source of truth must not report a
number the engine can already contradict.
The count now comes from ContentletIndexOperations.getIndexDocumentCount,
per index, on each engine leaf — the same way the Site Search half has
always counted, so both halves finally answer alike. getIndicesStats()
stays, but only to decide existence: one call per engine over the whole
set, so both slots are settled from a single snapshot and an absent index
is never confused with an unreachable engine.
A failing count is reported as -1, the established unmeasurable marker,
rather than propagated: it compares unequal, so the verdict degrades to
out-of-sync and safeToRollback to false — never to a false green.
The stats entries in the unit test now carry a poison count, so reading
the number from stats again fails loudly instead of silently
reintroducing the lag.
Co-Authored-By: Claude Opus 5 (1M context)
---
docs/backend/OPENSEARCH_MIGRATION.md | 38 ++++---
.../ContentIndexMirrorReconciler.java | 57 ++++++++--
.../ContentIndexMirrorReconcilerTest.java | 100 +++++++++++++++---
3 files changed, 155 insertions(+), 40 deletions(-)
diff --git a/docs/backend/OPENSEARCH_MIGRATION.md b/docs/backend/OPENSEARCH_MIGRATION.md
index ebbde8f4c8f7..f1fea429eee8 100644
--- a/docs/backend/OPENSEARCH_MIGRATION.md
+++ b/docs/backend/OPENSEARCH_MIGRATION.md
@@ -426,33 +426,31 @@ through the write-path gate above.
this is the only way to find the indices that still need theirs restored. It never changes
`verdict`: the verdict measures data integrity (existence + counts), while a damaged alias costs no
data and must not block a phase change.
-- **Stateless, from live counts.** Every field is derived at request time. Counts are **exact** — the
- Site Search half uses `SiteSearchAPI.documentCount` and the content half reads each engine leaf's
- `getIndicesStats()` (index `_stats` `primaries.docs.count`), never a search total (which the ES/OS
+- **Stateless, from live counts.** Every field is derived at request time. Counts are **exact and
+ current**: both halves issue a real count query per index — Site Search through
+ `SiteSearchAPI.documentCount`, the content half through
+ `ContentletIndexOperations.getIndexDocumentCount`. Neither uses a search hit total (which the ES/OS
clients cap at 10,000 and would hide drift on large indices). Both reconcilers query the two engine
leaves directly, not the phase-aware router, so the report shows both sides in every phase.
-- **The content counts can lag a write by a few seconds — this is not a stale report.** The two halves
- read the count differently: Site Search issues a count query (search API, `size:0`), while the
- content half reads `_stats` `primaries.docs.count`, a per-shard counter that only moves once the
- shard refreshes. A document that was just written is therefore *already searchable* while the
- content row still shows the previous number; it catches up within ~1–3s (measured locally, not a
- contract — an asynchronous indexing policy can make it longer). The endpoint decides whether a
- **phase change** is safe, so a few seconds of lag is irrelevant to its purpose — but do not use it
- as a real-time write monitor.
-
- To confirm a single write landed, ask for the **document**, not the count — that answer is immediate
- and independent of `_stats`:
+- **Why the count is a query and not `_stats` `docs.count`.** The content half still calls
+ `getIndicesStats()` — but only to decide **existence**, one call per engine covering the whole index
+ set, so both slots are settled from a single snapshot. The count itself must not come from there:
+ `docs.count` is a per-shard counter that only advances when the shard refreshes, so it trails a
+ just-written document by seconds. During that window the document is already searchable while the
+ report still shows the previous number — and a support technician checking whether a publish reached
+ OpenSearch reads that as a **lost write**. This endpoint is the source of truth for exactly that
+ question, so it must never report a number the engine can already contradict (issue #36983).
+- **What a count still cannot tell you.** A number that does not move is not proof that nothing was
+ written: the document id is `identifier_languageId_variant`, so re-publishing content already present
+ in that index is an **update**, and the total stays put. And in a dual-write phase the OpenSearch copy
+ only ever receives what changes *from that point on* — a mirror sitting at 15 of 683 documents is the
+ expected state until a full reindex, so a `+1` there is easy to misread as "nothing happened". To
+ settle it for one specific write, ask for the **document**:
```bash
curl -s "http://:9200/.os/_doc/__DEFAULT"
# "found": true with the modDate of your edit ⇒ the dual-write landed
```
-
- Also note what a growing count does *not* prove and an unchanged one does not disprove: the document
- id is `identifier_languageId_variant`, so re-publishing content already present in that index is an
- **update** and leaves the count untouched. And in a dual-write phase the OpenSearch copy only ever
- receives what changes *from that point on* — a mirror sitting at 15 of 683 documents is the expected
- state until a full reindex, so a `+1` there is easy to misread as "nothing happened".
- **`safeToRollback` needs no history.** A downgrade routes reads back to Elasticsearch, so it is
unsafe when any index's ES copy is behind its OpenSearch counterpart (`esDocCount < osDocCount`, or
the ES copy missing) — that delta, typically content written while OpenSearch served reads, would be
diff --git a/dotCMS/src/main/java/com/dotcms/content/index/migration/ContentIndexMirrorReconciler.java b/dotCMS/src/main/java/com/dotcms/content/index/migration/ContentIndexMirrorReconciler.java
index 15390b1113d6..bf25a52adf14 100644
--- a/dotCMS/src/main/java/com/dotcms/content/index/migration/ContentIndexMirrorReconciler.java
+++ b/dotCMS/src/main/java/com/dotcms/content/index/migration/ContentIndexMirrorReconciler.java
@@ -1,13 +1,16 @@
package com.dotcms.content.index.migration;
import com.dotcms.cdi.CDIUtils;
+import com.dotcms.content.elasticsearch.business.ContentletIndexOperationsES;
import com.dotcms.content.elasticsearch.business.ESIndexAPI;
import com.dotcms.content.elasticsearch.business.IndiciesInfo;
+import com.dotcms.content.index.ContentletIndexOperations;
import com.dotcms.content.index.IndexAPI;
import com.dotcms.content.index.IndexTag;
import com.dotcms.content.index.domain.IndexStats;
import com.dotcms.content.index.migration.MirrorStatus.IndexKind;
import com.dotcms.content.index.migration.MirrorStatus.Verdict;
+import com.dotcms.content.index.opensearch.ContentletIndexOperationsOS;
import com.dotcms.content.index.opensearch.OSIndexAPIImpl;
import com.dotmarketing.business.APILocator;
import com.dotmarketing.util.Logger;
@@ -27,12 +30,22 @@
*
How the counts are read (phase-independently)
*
{@code IndiciesInfo} always holds the cluster-prefixed, un-tagged Elasticsearch name for
* working/live (its backing {@code indicies} table owns only the ES rows — {@code index_version IS
- * NULL}); the OpenSearch counterpart is that name with the {@code .os} tag. Exact per-engine document
- * counts come from each engine leaf's {@code getIndicesStats()} — the index {@code _stats}
- * {@code primaries.docs.count}, an exact total not subject to the 10,000 search hit-count cap. Those
- * stats maps are keyed by the cluster-stripped name (Elasticsearch un-tagged, OpenSearch
- * carrying {@code .os}), so each raw name is stripped of the cluster prefix and then, for the
- * OpenSearch lookup, tagged — the same strip-then-tag order the maintenance JSP uses.
+ * NULL}); the OpenSearch counterpart is that name with the {@code .os} tag.
+ *
+ *
Existence comes from each engine leaf's {@code getIndicesStats()} — one call per
+ * engine covering the whole index set, so both slots are decided from a single snapshot. Those stats
+ * maps are keyed by the cluster-stripped name (Elasticsearch un-tagged, OpenSearch carrying
+ * {@code .os}), so each raw name is stripped of the cluster prefix and then, for the OpenSearch lookup,
+ * tagged — the same strip-then-tag order the maintenance JSP uses.
+ *
+ *
The document count is a real count query per index
+ * ({@link ContentletIndexOperations#getIndexDocumentCount}), not the {@code docs.count} of
+ * those same stats. The stats counter is per-shard and only advances when the shard refreshes, so it
+ * trails a just-written document by seconds: a support technician checking whether a publish reached
+ * OpenSearch would read the previous number and conclude the write was lost. This endpoint is the
+ * source of truth for that question, so it must never report a number the engine can already
+ * contradict (issue #36983). A count query is also not subject to the 10,000 search hit-count cap, and
+ * it matches how the Site Search half has always counted — both halves now answer the same way.
*
*
It queries the two engine leaves directly (never the phase-aware router) so the report shows both
* sides regardless of which engine the current phase reads from. Scope is the active working/live
@@ -42,18 +55,25 @@ public class ContentIndexMirrorReconciler {
private final IndexAPI esImpl;
private final IndexAPI osImpl;
+ private final ContentletIndexOperations esOps;
+ private final ContentletIndexOperations osOps;
private final Supplier indiciesSupplier;
public ContentIndexMirrorReconciler() {
this(new ESIndexAPI(), CDIUtils.getBeanThrows(OSIndexAPIImpl.class),
+ new ContentletIndexOperationsES(),
+ CDIUtils.getBeanThrows(ContentletIndexOperationsOS.class),
ContentIndexMirrorReconciler::loadIndiciesQuietly);
}
@VisibleForTesting
ContentIndexMirrorReconciler(final IndexAPI esImpl, final IndexAPI osImpl,
+ final ContentletIndexOperations esOps, final ContentletIndexOperations osOps,
final Supplier indiciesSupplier) {
this.esImpl = esImpl;
this.osImpl = osImpl;
+ this.esOps = esOps;
+ this.osOps = osOps;
this.indiciesSupplier = indiciesSupplier;
}
@@ -84,10 +104,11 @@ private void addStatus(final List out, final IndexKind kind, final
final String bare = esImpl.removeClusterIdFromName(rawName);
final String osKey = IndexTag.OS.tag(bare);
+ // Existence from the stats snapshot; the count from a live count query (see class javadoc).
final boolean esExists = esStats.containsKey(bare);
- final long esCount = esExists ? esStats.get(bare).documentCount() : 0L;
+ final long esCount = esExists ? countQuietly(esOps, bare) : 0L;
final boolean osExists = osStats.containsKey(osKey);
- final long osCount = osExists ? osStats.get(osKey).documentCount() : 0L;
+ final long osCount = osExists ? countQuietly(osOps, bare) : 0L;
final Verdict verdict = MirrorStatus.verdictFor(esExists, osExists, esCount, osCount);
out.add(new MirrorStatus(bare, kind,
@@ -96,6 +117,26 @@ private void addStatus(final List out, final IndexKind kind, final
verdict, recommend(bare, verdict, osExists)));
}
+ /**
+ * Exact document count of {@code logicalName} on one engine, or {@code -1} when the query fails.
+ *
+ *
The leaf turns the logical name into its own physical form ({@code toPhysicalName}: the ES
+ * leaf cluster-prefixes it, the OpenSearch leaf also applies {@code .os}), the same convention
+ * {@code ContentletIndexAPIImpl} uses — so this never hand-builds a physical name.
+ *
+ *
Failures are reported as {@code -1} rather than propagated: a readiness report that answers
+ * "unknown" for one engine is useful, one that returns a 500 is not. {@code -1} is the established
+ * unmeasurable marker — it compares unequal, so the verdict degrades to out-of-sync and
+ * {@code safeToRollback} to false, never to a false green.
+ */
+ private static long countQuietly(final ContentletIndexOperations ops, final String logicalName) {
+ return Try.of(() -> ops.getIndexDocumentCount(ops.toPhysicalName(logicalName)))
+ .onFailure(e -> Logger.warn(ContentIndexMirrorReconciler.class,
+ "Could not count documents of '" + logicalName + "' on "
+ + ops.getClass().getSimpleName() + ": " + e.getMessage()))
+ .getOrElse(-1L);
+ }
+
private static String recommend(final String name, final Verdict verdict, final boolean osExists) {
switch (verdict) {
case IN_SYNC:
diff --git a/dotCMS/src/test/java/com/dotcms/content/index/migration/ContentIndexMirrorReconcilerTest.java b/dotCMS/src/test/java/com/dotcms/content/index/migration/ContentIndexMirrorReconcilerTest.java
index f0dfebf818a1..f2c1ff121056 100644
--- a/dotCMS/src/test/java/com/dotcms/content/index/migration/ContentIndexMirrorReconcilerTest.java
+++ b/dotCMS/src/test/java/com/dotcms/content/index/migration/ContentIndexMirrorReconcilerTest.java
@@ -2,14 +2,18 @@
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.dotcms.UnitTestBase;
import com.dotcms.content.elasticsearch.business.IndiciesInfo;
+import com.dotcms.content.index.ContentletIndexOperations;
import com.dotcms.content.index.IndexAPI;
+import com.dotmarketing.exception.DotRuntimeException;
import com.dotcms.content.index.domain.IndexStats;
import com.dotcms.content.index.migration.MirrorStatus.IndexKind;
import com.dotcms.content.index.migration.MirrorStatus.Verdict;
@@ -30,29 +34,48 @@ public class ContentIndexMirrorReconcilerTest extends UnitTestBase {
private IndexAPI es;
private IndexAPI os;
+ private ContentletIndexOperations esOps;
+ private ContentletIndexOperations osOps;
@Before
public void setUp() {
es = mock(IndexAPI.class);
os = mock(IndexAPI.class);
+ esOps = mock(ContentletIndexOperations.class);
+ osOps = mock(ContentletIndexOperations.class);
when(es.removeClusterIdFromName(anyString())).thenAnswer(inv -> {
final String n = inv.getArgument(0);
return n.startsWith(PREFIX) ? n.substring(PREFIX.length()) : n;
});
+ // Mirror each leaf's physical-name convention: ES cluster-prefixes, OS also tags with .os.
+ when(esOps.toPhysicalName(anyString())).thenAnswer(inv -> PREFIX + inv.getArgument(0));
+ when(osOps.toPhysicalName(anyString())).thenAnswer(inv -> PREFIX + inv.getArgument(0) + ".os");
}
- private static IndexStats stats(final long count) {
+ /**
+ * A stats entry that marks an index as PRESENT. Its {@code documentCount} is deliberately a poison
+ * value: existence comes from the stats snapshot but the reported count must come from a live count
+ * query, because the stats counter trails a just-written document by seconds (issue #36983). If the
+ * implementation ever reads the count from here again, every assertion below fails loudly instead of
+ * silently reintroducing the lag.
+ */
+ private static IndexStats present() {
final IndexStats s = mock(IndexStats.class);
- when(s.documentCount()).thenReturn(count);
+ when(s.documentCount()).thenReturn(-999L);
return s;
}
+ /** Stubs the live count query of one engine leaf for a logical index name. */
+ private void count(final ContentletIndexOperations ops, final String logicalName, final long n) {
+ when(ops.getIndexDocumentCount(ops.toPhysicalName(logicalName))).thenReturn(n);
+ }
+
private static IndiciesInfo indicies(final String working, final String live) {
return new IndiciesInfo.Builder().setWorking(working).setLive(live).build();
}
private ContentIndexMirrorReconciler reconciler(final IndiciesInfo info) {
- return new ContentIndexMirrorReconciler(es, os, () -> info);
+ return new ContentIndexMirrorReconciler(es, os, esOps, osOps, () -> info);
}
/** Both content indices present on both engines with equal counts → two IN_SYNC rows. */
@@ -60,10 +83,12 @@ private ContentIndexMirrorReconciler reconciler(final IndiciesInfo info) {
public void workingAndLive_inSync() {
// Build the stats maps first: nesting stats() (a when()) inside a when().thenReturn(...) would
// trip Mockito's UnfinishedStubbingException.
- final Map esStats = Map.of("working_1", stats(100), "live_1", stats(50));
- final Map osStats = Map.of("working_1.os", stats(100), "live_1.os", stats(50));
+ final Map esStats = Map.of("working_1", present(), "live_1", present());
+ final Map osStats = Map.of("working_1.os", present(), "live_1.os", present());
when(es.getIndicesStats()).thenReturn(esStats);
when(os.getIndicesStats()).thenReturn(osStats);
+ count(esOps, "working_1", 100); count(osOps, "working_1", 100);
+ count(esOps, "live_1", 50); count(osOps, "live_1", 50);
final List statuses =
reconciler(indicies(PREFIX + "working_1", PREFIX + "live_1")).statuses();
@@ -85,10 +110,12 @@ public void workingAndLive_inSync() {
/** The OpenSearch counterpart of the working index is missing → MISSING_COUNTERPART. */
@Test
public void missingOsCounterpart_onWorking() {
- final Map esStats = Map.of("working_1", stats(100), "live_1", stats(50));
- final Map osStats = Map.of("live_1.os", stats(50)); // working_1.os absent
+ final Map esStats = Map.of("working_1", present(), "live_1", present());
+ final Map osStats = Map.of("live_1.os", present()); // working_1.os absent
when(es.getIndicesStats()).thenReturn(esStats);
when(os.getIndicesStats()).thenReturn(osStats);
+ count(esOps, "working_1", 100);
+ count(esOps, "live_1", 50); count(osOps, "live_1", 50);
final List statuses =
reconciler(indicies(PREFIX + "working_1", PREFIX + "live_1")).statuses();
@@ -102,13 +129,15 @@ public void missingOsCounterpart_onWorking() {
assertTrue(working.recommendation().contains("OpenSearch"));
}
- /** Counts diverge on the live index (exact stats, no cap) → COUNT_DRIFT. */
+ /** Counts diverge on the live index (exact count query, no cap) → COUNT_DRIFT. */
@Test
public void countDrift_onLive() {
- final Map esStats = Map.of("working_1", stats(100), "live_1", stats(50));
- final Map osStats = Map.of("working_1.os", stats(100), "live_1.os", stats(40));
+ final Map esStats = Map.of("working_1", present(), "live_1", present());
+ final Map osStats = Map.of("working_1.os", present(), "live_1.os", present());
when(es.getIndicesStats()).thenReturn(esStats);
when(os.getIndicesStats()).thenReturn(osStats);
+ count(esOps, "working_1", 100); count(osOps, "working_1", 100);
+ count(esOps, "live_1", 50); count(osOps, "live_1", 40);
final List statuses =
reconciler(indicies(PREFIX + "working_1", PREFIX + "live_1")).statuses();
@@ -131,14 +160,61 @@ public void nullIndicies_emptyList() {
/** An unset working/live slot is skipped (no row, no NPE). */
@Test
public void unsetSlot_skipped() {
- final Map esStats = Map.of("live_1", stats(50));
- final Map osStats = Map.of("live_1.os", stats(50));
+ final Map esStats = Map.of("live_1", present());
+ final Map osStats = Map.of("live_1.os", present());
when(es.getIndicesStats()).thenReturn(esStats);
when(os.getIndicesStats()).thenReturn(osStats);
+ count(esOps, "live_1", 50); count(osOps, "live_1", 50);
final List statuses = reconciler(indicies(null, PREFIX + "live_1")).statuses();
assertEquals(1, statuses.size());
assertEquals(IndexKind.CONTENT_LIVE, statuses.get(0).kind());
}
+
+ /**
+ * The count is read live, not from the stats snapshot: the stats counter only advances on shard
+ * refresh, so reading it would report a just-published document as missing for seconds — which a
+ * support technician reads as a lost write (issue #36983). The stats entries here carry a poison
+ * count, so this passes only if the reported numbers came from the count query.
+ */
+ @Test
+ public void docCount_comesFromTheLiveCountQuery_notFromStats() {
+ // Build the maps first: present() calls when(), which cannot run inside another when().
+ final Map esStats = Map.of("working_1", present());
+ final Map osStats = Map.of("working_1.os", present());
+ when(es.getIndicesStats()).thenReturn(esStats);
+ when(os.getIndicesStats()).thenReturn(osStats);
+ count(esOps, "working_1", 683); count(osOps, "working_1", 15);
+
+ final MirrorStatus working = reconciler(indicies(PREFIX + "working_1", null)).statuses().get(0);
+
+ assertEquals(683, working.es().docCount());
+ assertEquals(15, working.os().docCount());
+ verify(esOps).getIndexDocumentCount("cluster_x.working_1");
+ verify(osOps).getIndexDocumentCount("cluster_x.working_1.os");
+ }
+
+ /**
+ * A failing count query is reported as {@code -1} (the unmeasurable marker) instead of propagating:
+ * an "unknown" answer for one engine still leaves a usable report, and -1 compares unequal so the
+ * verdict degrades to out-of-sync rather than to a false green.
+ */
+ @Test
+ public void countQueryFailure_isReportedAsUnmeasurable() {
+ final Map esStats = Map.of("working_1", present());
+ final Map osStats = Map.of("working_1.os", present());
+ when(es.getIndicesStats()).thenReturn(esStats);
+ when(os.getIndicesStats()).thenReturn(osStats);
+ count(esOps, "working_1", 683);
+ when(osOps.getIndexDocumentCount("cluster_x.working_1.os"))
+ .thenThrow(new DotRuntimeException("OS unreachable"));
+
+ final MirrorStatus working = reconciler(indicies(PREFIX + "working_1", null)).statuses().get(0);
+
+ assertEquals(683, working.es().docCount());
+ assertEquals(-1, working.os().docCount());
+ assertEquals(Verdict.COUNT_DRIFT, working.verdict());
+ assertNull("an unmeasurable count has no drift percentage", working.driftPercent());
+ }
}
From c276077c03fa10bff9848ab20563bab46be166fa Mon Sep 17 00:00:00 2001
From: fabrizzio-dotCMS
Date: Tue, 11 Aug 2026 14:49:14 -0600
Subject: [PATCH 07/15] docs(migration): the Site Search crawl inherits the
content index (#36983)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A Phase-3 crawl produced a Site Search index with 14 documents instead of
~443. Not a crawl defect: the bundlers build from conAPI.searchIndex, a
phase-routed search over the CONTENT index, so in Phases 2/3 the corpus
comes from OpenSearch. With the content mirror unreindexed (685 live docs
on ES, 21 on OS) the crawl could only find 14.
Documents the mechanism with the call site, and why it is worse than the
read-time cliff already described: the crawl reports success, the
bundlers swallow search failures at debug level, and the damage outlives
its cause — reindexing the content store afterwards does not repair the
Site Search index already built from the empty one, and the readiness row
reads healthy because the index exists on the right engine (the defect is
inside it, not in its shape).
States the ordering rule this implies: in Phases 2/3, full content
reindex first, Site Search crawl second.
Co-Authored-By: Claude Opus 5 (1M context)
---
docs/backend/OPENSEARCH_MIGRATION.md | 37 ++++++++++++++++++++++++++++
1 file changed, 37 insertions(+)
diff --git a/docs/backend/OPENSEARCH_MIGRATION.md b/docs/backend/OPENSEARCH_MIGRATION.md
index f1fea429eee8..725e72c4cd48 100644
--- a/docs/backend/OPENSEARCH_MIGRATION.md
+++ b/docs/backend/OPENSEARCH_MIGRATION.md
@@ -391,6 +391,43 @@ ensure every Site Search index has been crawled at least once so its OS counterp
sync. The migration-readiness endpoint below is what tells the operator *which* indices still need
that crawl, before they change the phase.
+##### The crawl inherits the content index — reindex first, crawl second
+
+A Site Search crawl does **not** read the database. It builds its bundle from a **search over the
+content index**:
+
+```java
+// FileAssetBundler:205 — same shape in HTMLPageAsContentBundler and URLMapBundler
+searchResults.addAll(this.conAPI.searchIndex(luceneQuery + " +live:true", ...));
+```
+
+That search is phase-routed, so in Phases 2/3 it is served by **OpenSearch**. If the OpenSearch
+*content* mirror has not been rebuilt by a full reindex, the crawl simply cannot see the content that
+is missing from it — and writes a Site Search index containing only what it found. Observed on a
+Phase-3 crawl: the content index held 685 live documents on Elasticsearch and 21 on OpenSearch (never
+reindexed), and the resulting Site Search index came out with **14 documents** instead of ~443. The
+crawl answered its query correctly; the corpus it queried was 3% complete.
+
+This is worse than the read-time cliff above, in three ways:
+
+1. **The crawl reports success.** Nothing warns that the input corpus was nearly empty — the counts it
+ logs are of what it bundled, so they look internally consistent.
+2. **The bundlers swallow search failures at `Logger.debug`** (`FileAssetBundler:206-208, 213-215`).
+ Even a hard search error surfaces as nothing more than a smaller bundle.
+3. **The damage outlives its cause.** Reindexing the content store afterwards fixes the content mirror
+ but does *not* repair the Site Search index that was already built from the empty one — it keeps
+ its 14 documents until it is crawled again. Nor will the readiness report flag it: the index exists
+ on OpenSearch, which in Phase 3 is exactly the expected topology, so the row reads healthy. The
+ defect is *inside* the index, not in its shape.
+
+**Ordering rule:** a Site Search crawl is only meaningful once the content index of the phase's
+**read** engine is complete. In Phases 2/3 that means **full content reindex first, Site Search crawl
+second** — the reverse order silently produces a truncated index that looks fine everywhere.
+
+Note the readiness endpoint does catch the precondition: an unreconciled content mirror shows as
+`COUNT_DRIFT` on `WORKING`/`LIVE` with `safeToAdvance: false`. It only *reports*, though — nothing
+stops a promotion or a crawl from proceeding anyway.
+
#### Migration-readiness endpoint (pre-phase-change advisory)
`GET /api/v1/index/migration/readiness` is an internal, read-only report a support technician runs
From 181b82bb650c8c7ca1137f5cdefbc0f4336f597a Mon Sep 17 00:00:00 2001
From: fabrizzio-dotCMS
Date: Tue, 11 Aug 2026 15:15:07 -0600
Subject: [PATCH 08/15] feat(migration): report content index coverage against
the database (#36983)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Every completeness signal in the readiness report compared one engine
against the other, which stops being an answer exactly where it matters
most: in Phase 3 there is no second engine, so a mirror that was never
rebuilt reads unremarkably — and everything downstream inherits its
emptiness silently, a Site Search crawl included, since it builds its
corpus from a query against this index.
Content rows now carry expectedDocCount plus esCoveragePercent /
osCoveragePercent: each engine measured against the DATABASE, the source
of truth that is identical in every phase. The case that prompted this
reads 3.06 instead of looking normal. When a copy is materially
incomplete the recommendation names it and spells out the fallout. It
never changes the verdict — the verdict states the ES<->OS relationship,
which is a different fact.
The denominator is O(1): pg_class.reltuples times 1 - null_frac of
live_inode from pg_stats, two catalog lookups and no table access. An
exact COUNT is a sequential scan (verified with EXPLAIN — no index-only
path counts non-null live_inode without walking the table), which on a
customer-sized table would mean a multi-second query on every refresh.
The estimate measured 689/682 against an exact 686/685, well inside what
this metric claims: it exists to tell 3% from 97%, never 99% from 100%. A
never-analyzed table reports reltuples = -1, treated as unknown (fields
omitted) rather than as an empty index.
Co-Authored-By: Claude Opus 5 (1M context)
---
docs/backend/OPENSEARCH_MIGRATION.md | 23 +++
.../ContentIndexMirrorReconciler.java | 134 +++++++++++++++++-
.../content/index/migration/MirrorStatus.java | 83 ++++++++++-
.../v1/index/MigrationReadinessResource.java | 5 +
.../ContentIndexMirrorReconcilerTest.java | 72 +++++++++-
5 files changed, 303 insertions(+), 14 deletions(-)
diff --git a/docs/backend/OPENSEARCH_MIGRATION.md b/docs/backend/OPENSEARCH_MIGRATION.md
index 725e72c4cd48..d23a6d4e405d 100644
--- a/docs/backend/OPENSEARCH_MIGRATION.md
+++ b/docs/backend/OPENSEARCH_MIGRATION.md
@@ -477,6 +477,28 @@ through the write-path gate above.
report still shows the previous number — and a support technician checking whether a publish reached
OpenSearch reads that as a **lost write**. This endpoint is the source of truth for exactly that
question, so it must never report a number the engine can already contradict (issue #36983).
+- **Content rows also carry coverage against the DATABASE.** `expectedDocCount` is how many documents
+ the index should hold per `contentlet_version_info` (keyed by `identifier, lang, variant_id` — the
+ same unit as an index document), and `esCoveragePercent` / `osCoveragePercent` are each engine
+ measured against it. This is the only signal in the report that does not come from a search engine,
+ and that is the point: **`driftPercent` compares the two engines against each other, which stops
+ being an answer once one of them is the only one left.** In Phase 3 a mirror that was never rebuilt
+ has nothing to be diffed against and reads unremarkably, while coverage still says `3.06`. When a
+ copy is materially incomplete the `recommendation` names it and spells out the fallout — including
+ that a Site Search crawl builds its corpus from a query against this index. It never changes the
+ `verdict`, which states a different fact (the ES↔OS relationship).
+
+ **The denominator is O(1), by design.** It comes from the PostgreSQL planner statistics —
+ `pg_class.reltuples` for the row count (every working version, since `working_inode` is `NOT NULL`)
+ times `1 − pg_stats.null_frac` of `live_inode` for the live subset — two catalog lookups, no table
+ access. An exact `COUNT` is a sequential scan (verified with `EXPLAIN`: no index-only path counts
+ non-null `live_inode` without walking the table), which on a customer-sized table would mean a
+ multi-second query every time an operator refreshes the endpoint. The estimate is accurate to a few
+ percent (measured 689/682 against an exact 686/685) — well inside what this metric claims, since it
+ exists to tell 3% from 97%, never 99% from 100%. A table autovacuum has never analyzed reports
+ `reltuples = -1`, which is treated as unknown (fields omitted), not as an empty table. Site Search
+ rows have no such denominator (their corpus is crawled pages and files), so the fields are absent
+ there too.
- **What a count still cannot tell you.** A number that does not move is not proof that nothing was
written: the document id is `identifier_languageId_variant`, so re-publishing content already present
in that index is an **update**, and the total stays put. And in a dual-write phase the OpenSearch copy
@@ -543,6 +565,7 @@ Search rows. Then:
|---|---|
| `verdict` | `IN_SYNC` · `MISSING_COUNTERPART` (one engine lacks the index) · `COUNT_DRIFT` (both hold it, different counts) |
| `driftPercent` | `(OS − ES) / ES × 100`, rounded to 2 decimals. `0.0` in sync · negative = mirror **behind** (blocks *advance*) · positive = mirror **ahead** (blocks *rollback*) · `-100.0` mirror empty/absent · `+100.0` the original is empty but the mirror holds data · `null` a count could not be measured |
+| `expectedDocCount` + `esCoveragePercent` / `osCoveragePercent` | Content rows only. How complete each engine is **against the database**, not against the other engine — the one completeness signal that still works in Phase 3, where there is nothing left to diff. `100.0` = complete; `3.06` = the mirror was never rebuilt. Absent for Site Search and when a count could not be measured |
| `docCount: -1` | The count could **not** be measured. Never read it as "zero" — the verdict treats it as out of sync on purpose |
| `physicalName` | The exact name on that server (cluster-prefixed; `.os`-tagged on OpenSearch) — copy/paste it into `_cat/indices` to verify by hand |
| `recommendation` | The concrete action (re-crawl / reindex). A trailing `NOTE:` flags an alias that is really an index name (see above) |
diff --git a/dotCMS/src/main/java/com/dotcms/content/index/migration/ContentIndexMirrorReconciler.java b/dotCMS/src/main/java/com/dotcms/content/index/migration/ContentIndexMirrorReconciler.java
index bf25a52adf14..407520f7c09d 100644
--- a/dotCMS/src/main/java/com/dotcms/content/index/migration/ContentIndexMirrorReconciler.java
+++ b/dotCMS/src/main/java/com/dotcms/content/index/migration/ContentIndexMirrorReconciler.java
@@ -13,6 +13,7 @@
import com.dotcms.content.index.opensearch.ContentletIndexOperationsOS;
import com.dotcms.content.index.opensearch.OSIndexAPIImpl;
import com.dotmarketing.business.APILocator;
+import com.dotmarketing.common.db.DotConnect;
import com.dotmarketing.util.Logger;
import com.dotmarketing.util.UtilMethods;
import com.google.common.annotations.VisibleForTesting;
@@ -58,25 +59,39 @@ public class ContentIndexMirrorReconciler {
private final ContentletIndexOperations esOps;
private final ContentletIndexOperations osOps;
private final Supplier indiciesSupplier;
+ private final Supplier expectedCountsSupplier;
public ContentIndexMirrorReconciler() {
this(new ESIndexAPI(), CDIUtils.getBeanThrows(OSIndexAPIImpl.class),
new ContentletIndexOperationsES(),
CDIUtils.getBeanThrows(ContentletIndexOperationsOS.class),
- ContentIndexMirrorReconciler::loadIndiciesQuietly);
+ ContentIndexMirrorReconciler::loadIndiciesQuietly,
+ ContentIndexMirrorReconciler::loadExpectedCountsQuietly);
}
@VisibleForTesting
ContentIndexMirrorReconciler(final IndexAPI esImpl, final IndexAPI osImpl,
final ContentletIndexOperations esOps, final ContentletIndexOperations osOps,
- final Supplier indiciesSupplier) {
+ final Supplier indiciesSupplier,
+ final Supplier expectedCountsSupplier) {
this.esImpl = esImpl;
this.osImpl = osImpl;
this.esOps = esOps;
this.osOps = osOps;
this.indiciesSupplier = indiciesSupplier;
+ this.expectedCountsSupplier = expectedCountsSupplier;
}
+ /**
+ * How many documents each content index should hold according to the database — the denominator
+ * behind the coverage percentages. A planner estimate, accurate to a few percent (see
+ * {@link #loadExpectedCountsQuietly()}); {@code null} on either field when it is unavailable.
+ *
+ * @param working one row per (identifier, language, variant): the working version always exists
+ * @param live the subset of those rows that also have a live version
+ */
+ public record ExpectedCounts(Long working, Long live) {}
+
/** Per-index mirror status for the active working and live content indices. */
public List statuses() {
final IndiciesInfo info = indiciesSupplier.get();
@@ -85,14 +100,18 @@ public List statuses() {
}
final Map esStats = esImpl.getIndicesStats();
final Map osStats = osImpl.getIndicesStats();
+ final ExpectedCounts expected = expectedCountsSupplier.get();
final List out = new ArrayList<>(2);
- addStatus(out, IndexKind.CONTENT_WORKING, info.getWorking(), esStats, osStats);
- addStatus(out, IndexKind.CONTENT_LIVE, info.getLive(), esStats, osStats);
+ addStatus(out, IndexKind.CONTENT_WORKING, info.getWorking(), esStats, osStats,
+ expected == null ? null : expected.working());
+ addStatus(out, IndexKind.CONTENT_LIVE, info.getLive(), esStats, osStats,
+ expected == null ? null : expected.live());
return out;
}
private void addStatus(final List out, final IndexKind kind, final String rawName,
- final Map esStats, final Map osStats) {
+ final Map esStats, final Map osStats,
+ final Long expectedDocCount) {
if (!UtilMethods.isSet(rawName)) {
return;
}
@@ -111,10 +130,13 @@ private void addStatus(final List out, final IndexKind kind, final
final long osCount = osExists ? countQuietly(osOps, bare) : 0L;
final Verdict verdict = MirrorStatus.verdictFor(esExists, osExists, esCount, osCount);
+ final String recommendation = recommend(bare, verdict, osExists)
+ + incompleteNote("Elasticsearch", esExists, esCount, expectedDocCount)
+ + incompleteNote("OpenSearch", osExists, osCount, expectedDocCount);
out.add(new MirrorStatus(bare, kind,
new MirrorStatus.EngineCopy(esExists, esCount, esPhysical),
new MirrorStatus.EngineCopy(osExists, osCount, osPhysical),
- verdict, recommend(bare, verdict, osExists)));
+ verdict, recommendation, expectedDocCount));
}
/**
@@ -137,6 +159,44 @@ private static long countQuietly(final ContentletIndexOperations ops, final Stri
.getOrElse(-1L);
}
+ /**
+ * Coverage below which an existing index is called out as incomplete in the recommendation. Not a
+ * tight bound on purpose: the denominator is an order-of-magnitude measure (see
+ * {@code MirrorStatus#coverageOf}), so this is meant to catch "3% of the content", not a handful of
+ * documents.
+ */
+ private static final double INCOMPLETE_COVERAGE_THRESHOLD = 95.0;
+
+ /**
+ * A sentence appended to the recommendation when an engine holds materially less content than the
+ * database says it should.
+ *
+ *
This is the half of the report that survives into Phase 3. The verdict compares the two
+ * engines against each other, so once one of them is the only one left it can read reassuringly
+ * while the surviving index is nearly empty — and everything downstream inherits that emptiness
+ * silently, including a Site Search crawl, whose corpus is a query over this very index
+ * (issue #36983). Comparing against the database keeps that visible with nothing to diff.
+ *
+ *
It never changes the {@code verdict}: the verdict states the ES↔OS relationship, which is a
+ * different fact. Reported side by side, not merged.
+ */
+ private static String incompleteNote(final String engine, final boolean exists, final long count,
+ final Long expected) {
+ if (!exists || count < 0 || expected == null || expected <= 0) {
+ return "";
+ }
+ final double coverage = count * 100.0 / expected;
+ if (coverage >= INCOMPLETE_COVERAGE_THRESHOLD) {
+ return "";
+ }
+ return String.format(" NOTE: the %s copy holds %d of the %d contentlets the database has "
+ + "(%.2f%%) — it was never fully rebuilt. Run a full reindex; until then, "
+ + "anything reading through this index sees only that fraction of the content "
+ + "(a Site Search crawl included, since it builds its corpus from a query "
+ + "against it).",
+ engine, count, expected, coverage);
+ }
+
private static String recommend(final String name, final Verdict verdict, final boolean osExists) {
switch (verdict) {
case IN_SYNC:
@@ -154,6 +214,68 @@ private static String recommend(final String name, final Verdict verdict, final
}
}
+ /**
+ * How many documents each content index should hold, from the PostgreSQL planner statistics —
+ * O(1), reading only the catalog.
+ *
+ *
Why an estimate and not {@code COUNT(*)}. {@code contentlet_version_info} is
+ * keyed by {@code (identifier, lang, variant_id)} — the same unit as an index document
+ * ({@code identifier_language_variant}) — so its row count is the natural denominator. But an exact
+ * count is a sequential scan (verified with {@code EXPLAIN}: no index-only path counts non-null
+ * {@code live_inode} without walking the table), which on a customer-sized table means a
+ * multi-second, I/O-heavy query every time an operator refreshes this endpoint. The catalog
+ * estimate costs two lookups and no table access:
+ *
+ *
{@code pg_class.reltuples} — the row count, i.e. every working version, since
+ * {@code working_inode} is {@code NOT NULL}.
+ *
{@code × (1 − pg_stats.null_frac)} of {@code live_inode} — the fraction of those rows that
+ * also have a live version.
+ *
+ *
+ *
Both are maintained by {@code ANALYZE}/autovacuum and are accurate to a few percent (measured
+ * 689/682 against an exact 686/685). That is well inside what this metric claims: coverage exists
+ * to tell 3% from 97%, never 99% from 100% — so paying a table scan for exactness would buy
+ * precision the metric explicitly does not offer. A table that has never been analyzed reports
+ * {@code reltuples = -1}; that and any failure yield {@code null}, and the coverage fields are then
+ * simply omitted rather than failing the whole report.
+ *
+ *
PostgreSQL-only, like the rest of the platform.
+ */
+ private static ExpectedCounts loadExpectedCountsQuietly() {
+ return Try.of(() -> {
+ final List
*
- * @param indexName the logical index name (no {@code .os} tag)
- * @param kind which mirrored index family this row belongs to
- * @param es the Elasticsearch copy (existence + exact document count)
- * @param os the OpenSearch ({@code .os}) copy (existence + exact document count)
- * @param verdict the diff verdict between the two copies
- * @param recommendation human-readable, action-oriented advice for a support technician
+ * @param indexName the logical index name (no {@code .os} tag)
+ * @param kind which mirrored index family this row belongs to
+ * @param es the Elasticsearch copy (existence + exact document count)
+ * @param os the OpenSearch ({@code .os}) copy (existence + exact document count)
+ * @param verdict the diff verdict between the two copies
+ * @param recommendation human-readable, action-oriented advice for a support technician
+ * @param expectedDocCount how many documents this index should hold according to the
+ * database — the engine-independent denominator behind
+ * {@link #esCoveragePercent()} / {@link #osCoveragePercent()}. A planner
+ * estimate of {@code contentlet_version_info}, accurate to a few percent and
+ * O(1) by design (an exact count would be a table scan on every request).
+ * Only the content indices have one; {@code null} for Site Search, whose
+ * corpus (crawled pages and files) has no such counterpart, and {@code null}
+ * when the statistics are unavailable.
*/
@JsonIgnoreProperties("kind") // internal grouping/label only — the report keys rows by it, never emits it
public record MirrorStatus(
@@ -30,7 +38,14 @@ public record MirrorStatus(
EngineCopy es,
EngineCopy os,
Verdict verdict,
- String recommendation) {
+ String recommendation,
+ @JsonInclude(JsonInclude.Include.NON_NULL) Long expectedDocCount) {
+
+ /** A row with no database denominator — the shape the Site Search indices use. */
+ public MirrorStatus(final String indexName, final IndexKind kind, final EngineCopy es,
+ final EngineCopy os, final Verdict verdict, final String recommendation) {
+ this(indexName, kind, es, os, verdict, recommendation, null);
+ }
/** Which mirrored index family a status row belongs to. */
public enum IndexKind { CONTENT_WORKING, CONTENT_LIVE, SITE_SEARCH }
@@ -106,6 +121,60 @@ public Double driftPercent() {
return Math.round(pct * 100.0) / 100.0;
}
+ /**
+ * How complete the Elasticsearch copy is against the database, as a percentage of
+ * {@link #expectedDocCount}. See {@link #coverageOf(EngineCopy)}.
+ */
+ @JsonProperty("esCoveragePercent")
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ @Schema(description = "Percentage of the documents the database says this index should hold that "
+ + "the Elasticsearch copy actually holds. 100.0 = complete. Absent for Site Search "
+ + "(no database denominator) and when a count could not be measured.")
+ public Double esCoveragePercent() {
+ return coverageOf(es);
+ }
+
+ /**
+ * How complete the OpenSearch copy is against the database, as a percentage of
+ * {@link #expectedDocCount}. See {@link #coverageOf(EngineCopy)}.
+ */
+ @JsonProperty("osCoveragePercent")
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ @Schema(description = "Percentage of the documents the database says this index should hold that "
+ + "the OpenSearch copy actually holds. 100.0 = complete; a low value means the mirror was "
+ + "never rebuilt — and anything reading through it (including a Site Search crawl) sees "
+ + "only that fraction of the content. Absent for Site Search (no database denominator) "
+ + "and when a count could not be measured.")
+ public Double osCoveragePercent() {
+ return coverageOf(os);
+ }
+
+ /**
+ * One engine's completeness against the database: {@code docCount / expectedDocCount × 100},
+ * rounded to two decimals.
+ *
+ *
Why this exists next to {@link #driftPercent()}. Drift compares the two
+ * engines against each other, which stops being an answer once one of them is the only
+ * one left: in Phase 3 there is no Elasticsearch side to compare against, so a mirror that was
+ * never rebuilt looks unremarkable. Coverage compares each engine against the database —
+ * the source of truth, identical in every phase — so "this index holds 3% of the content" is
+ * still visible when there is nothing to diff (issue #36983).
+ *
+ *
Read it as an order of magnitude, not an audit: the denominator counts one row per
+ * (identifier, language, variant) in {@code contentlet_version_info}, which is the same unit as an
+ * index document, but content types excluded from indexing and archived versions can move the
+ * number by a few points. It is built to tell 3% from 97%, not 99% from 100%.
+ *
+ * @return the percentage, or {@code null} when there is no denominator ({@code expectedDocCount}
+ * absent or zero) or the count was unmeasurable ({@code -1})
+ */
+ private Double coverageOf(final EngineCopy copy) {
+ if (expectedDocCount == null || expectedDocCount <= 0 || copy.docCount() < 0) {
+ return null;
+ }
+ return Math.round(copy.docCount() * 10_000.0 / expectedDocCount) / 100.0;
+ }
+
/**
* Classifies a mirror from raw existence + exact counts: a missing copy on either engine is
* {@link Verdict#MISSING_COUNTERPART}; both present with unequal counts is {@link Verdict#COUNT_DRIFT}
diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/index/MigrationReadinessResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/index/MigrationReadinessResource.java
index ea9a26cecbbd..691dbbab8274 100644
--- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/index/MigrationReadinessResource.java
+++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/index/MigrationReadinessResource.java
@@ -73,6 +73,11 @@ public MigrationReadinessResource() {
+ "Per index: `es`/`os` = {exists, docCount (exact; -1 = count failed), physicalName "
+ "(full name as stored: cluster-prefixed, .os-tagged on OpenSearch)}; `verdict` = "
+ "IN_SYNC | MISSING_COUNTERPART | COUNT_DRIFT; `recommendation` = what to run to fix it.\n\n"
+ + "Content rows also carry `expectedDocCount` (what the database says the index should "
+ + "hold) with `esCoveragePercent` / `osCoveragePercent` — each engine measured against the "
+ + "DATABASE rather than against the other engine, so an index that was never rebuilt is "
+ + "still visible in Phase 3, where there is no second engine to diff against. Absent for "
+ + "Site Search (no database denominator).\n\n"
+ "`driftPercent` = how far the OpenSearch mirror deviates from the Elasticsearch original, "
+ "as a signed % of the original: (OS − ES) / ES × 100. 0.0 = in sync; NEGATIVE = mirror "
+ "BEHIND (missing that % of docs); POSITIVE = mirror AHEAD (extra docs); -100.0 = mirror "
diff --git a/dotCMS/src/test/java/com/dotcms/content/index/migration/ContentIndexMirrorReconcilerTest.java b/dotCMS/src/test/java/com/dotcms/content/index/migration/ContentIndexMirrorReconcilerTest.java
index f2c1ff121056..665a23f1b975 100644
--- a/dotCMS/src/test/java/com/dotcms/content/index/migration/ContentIndexMirrorReconcilerTest.java
+++ b/dotCMS/src/test/java/com/dotcms/content/index/migration/ContentIndexMirrorReconcilerTest.java
@@ -12,6 +12,7 @@
import com.dotcms.UnitTestBase;
import com.dotcms.content.elasticsearch.business.IndiciesInfo;
import com.dotcms.content.index.ContentletIndexOperations;
+import com.dotcms.content.index.migration.ContentIndexMirrorReconciler.ExpectedCounts;
import com.dotcms.content.index.IndexAPI;
import com.dotmarketing.exception.DotRuntimeException;
import com.dotcms.content.index.domain.IndexStats;
@@ -75,7 +76,13 @@ private static IndiciesInfo indicies(final String working, final String live) {
}
private ContentIndexMirrorReconciler reconciler(final IndiciesInfo info) {
- return new ContentIndexMirrorReconciler(es, os, esOps, osOps, () -> info);
+ return reconciler(info, null);
+ }
+
+ /** @param expected the database denominator behind the coverage percentages, or null when absent */
+ private ContentIndexMirrorReconciler reconciler(final IndiciesInfo info,
+ final ExpectedCounts expected) {
+ return new ContentIndexMirrorReconciler(es, os, esOps, osOps, () -> info, () -> expected);
}
/** Both content indices present on both engines with equal counts → two IN_SYNC rows. */
@@ -195,6 +202,69 @@ public void docCount_comesFromTheLiveCountQuery_notFromStats() {
verify(osOps).getIndexDocumentCount("cluster_x.working_1.os");
}
+ /**
+ * Coverage is each engine measured against the DATABASE, not against the other engine — the only
+ * completeness signal that survives into Phase 3, where there is no second engine to diff against
+ * (issue #36983). The scenario is the one observed live: the content mirror was never rebuilt.
+ */
+ @Test
+ public void coverage_isMeasuredAgainstTheDatabase() {
+ final Map esStats = Map.of("working_1", present());
+ final Map osStats = Map.of("working_1.os", present());
+ when(es.getIndicesStats()).thenReturn(esStats);
+ when(os.getIndicesStats()).thenReturn(osStats);
+ count(esOps, "working_1", 686); count(osOps, "working_1", 21);
+
+ final MirrorStatus working = reconciler(indicies(PREFIX + "working_1", null),
+ new ExpectedCounts(686L, 685L)).statuses().get(0);
+
+ assertEquals(Long.valueOf(686), working.expectedDocCount());
+ assertEquals(100.0, working.esCoveragePercent(), 0.001);
+ assertEquals(3.06, working.osCoveragePercent(), 0.001);
+ // The incomplete copy is named in the recommendation, with the fallout spelled out.
+ assertTrue(working.recommendation().contains("OpenSearch copy holds 21 of the 686"));
+ assertTrue(working.recommendation().contains("Site Search crawl"));
+ assertFalse("the complete copy must not be flagged",
+ working.recommendation().contains("Elasticsearch copy holds"));
+ }
+
+ /** No denominator (the query failed, or this is a Site Search row) → the fields are simply absent. */
+ @Test
+ public void coverage_absentWithoutADatabaseDenominator() {
+ final Map esStats = Map.of("working_1", present());
+ final Map osStats = Map.of("working_1.os", present());
+ when(es.getIndicesStats()).thenReturn(esStats);
+ when(os.getIndicesStats()).thenReturn(osStats);
+ count(esOps, "working_1", 686); count(osOps, "working_1", 21);
+
+ final MirrorStatus working = reconciler(indicies(PREFIX + "working_1", null)).statuses().get(0);
+
+ assertNull(working.expectedDocCount());
+ assertNull(working.esCoveragePercent());
+ assertNull(working.osCoveragePercent());
+ assertFalse(working.recommendation().contains("NOTE"));
+ }
+
+ /**
+ * A complete mirror is not annotated, and coverage does not touch the verdict: the verdict states
+ * the ES↔OS relationship, coverage states completeness against the database. Two separate facts.
+ */
+ @Test
+ public void coverage_completeMirror_isNotFlagged() {
+ final Map esStats = Map.of("working_1", present());
+ final Map osStats = Map.of("working_1.os", present());
+ when(es.getIndicesStats()).thenReturn(esStats);
+ when(os.getIndicesStats()).thenReturn(osStats);
+ count(esOps, "working_1", 686); count(osOps, "working_1", 686);
+
+ final MirrorStatus working = reconciler(indicies(PREFIX + "working_1", null),
+ new ExpectedCounts(686L, 685L)).statuses().get(0);
+
+ assertEquals(100.0, working.osCoveragePercent(), 0.001);
+ assertEquals(Verdict.IN_SYNC, working.verdict());
+ assertFalse(working.recommendation().contains("NOTE"));
+ }
+
/**
* A failing count query is reported as {@code -1} (the unmeasurable marker) instead of propagating:
* an "unknown" answer for one engine still leaves a usable report, and -1 compares unequal so the
From ccc3e396de5cfd40ca87c550834f5e60bd09fab5 Mon Sep 17 00:00:00 2001
From: fabrizzio-dotCMS
Date: Tue, 11 Aug 2026 16:05:38 -0600
Subject: [PATCH 09/15] feat(sitesearch): warn before a crawl reads an
incomplete content index (#36983)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A crawl does not read the database: the bundlers build the bundle from
ContentletAPI#searchIndex, a phase-routed search over the CONTENT index.
So it can only find what that index holds — a Phase-3 crawl against an
OpenSearch mirror that was never rebuilt silently produced a Site Search
index with 14 documents instead of ~443, reported success, and left an
artifact that a later content reindex does NOT repair.
Before crawling, the job now measures the coverage of the content index
it is about to read — the read engine's copy against the database, the
metric the readiness endpoint already reports — and logs a WARN naming
the index, the engine, the percentage and the fact that reindexing
afterwards will not fix the result.
Advisory only, deliberately: it never stops the crawl, and a failure to
measure is swallowed, because a diagnostic must not be able to break
indexing. Only the engine the phase actually reads from is checked, so an
incomplete OpenSearch mirror stays silent in Phases 0/1 where the crawl
queries a complete Elasticsearch — warning there would train operators to
ignore the message. Threshold configurable via
SITE_SEARCH_CRAWL_MIN_CONTENT_COVERAGE_PERCENT (default 95, 0 disables).
Co-Authored-By: Claude Opus 5 (1M context)
---
docs/backend/OPENSEARCH_MIGRATION.md | 19 +++
.../publishing/job/SiteSearchJobImpl.java | 86 +++++++++++++
.../job/SiteSearchJobAliasResolutionTest.java | 115 ++++++++++++++++++
3 files changed, 220 insertions(+)
diff --git a/docs/backend/OPENSEARCH_MIGRATION.md b/docs/backend/OPENSEARCH_MIGRATION.md
index d23a6d4e405d..ab77b69d5631 100644
--- a/docs/backend/OPENSEARCH_MIGRATION.md
+++ b/docs/backend/OPENSEARCH_MIGRATION.md
@@ -424,6 +424,25 @@ This is worse than the read-time cliff above, in three ways:
**read** engine is complete. In Phases 2/3 that means **full content reindex first, Site Search crawl
second** — the reverse order silently produces a truncated index that looks fine everywhere.
+**The crawl warns when it is about to do this.** `SiteSearchJobImpl` checks the coverage of the
+content index it is about to read — the read engine's copy measured against the database, the same
+metric the readiness endpoint reports — and logs a `WARN` naming the index, the engine, the
+percentage, and the fact that reindexing afterwards will not repair the result:
+
+```
+Site Search crawl starting against an INCOMPLETE content index: 'working_20260811191012' on
+OpenSearch holds 3.06% of the 686 contentlets the database has. A crawl builds its corpus by
+querying that index, so it can only index what it finds there — this crawl will produce a partial
+Site Search index, and reindexing the content later will NOT repair it (it must be crawled again).
+Run a full reindex first.
+```
+
+It is **advisory only**: it never stops the crawl, and any failure to measure is swallowed — a
+diagnostic must not be able to break indexing. Only the engine the phase actually reads from is
+checked, so an incomplete OpenSearch mirror stays silent in Phases 0/1 where the crawl queries a
+complete Elasticsearch. Threshold: `SITE_SEARCH_CRAWL_MIN_CONTENT_COVERAGE_PERCENT` (default `95`;
+`0` disables the check).
+
Note the readiness endpoint does catch the precondition: an unreconciled content mirror shows as
`COUNT_DRIFT` on `WORKING`/`LIVE` with `safeToAdvance: false`. It only *reports*, though — nothing
stops a promotion or a crawl from proceeding anyway.
diff --git a/dotCMS/src/main/java/com/dotcms/publishing/job/SiteSearchJobImpl.java b/dotCMS/src/main/java/com/dotcms/publishing/job/SiteSearchJobImpl.java
index 86af26c866e9..f81bc057641f 100644
--- a/dotCMS/src/main/java/com/dotcms/publishing/job/SiteSearchJobImpl.java
+++ b/dotCMS/src/main/java/com/dotcms/publishing/job/SiteSearchJobImpl.java
@@ -2,6 +2,9 @@
import com.dotcms.content.elasticsearch.business.ESMappingAPIImpl;
import com.dotcms.content.elasticsearch.business.IndiciesAPI;
+import com.dotcms.content.index.IndexConfigHelper.MigrationPhase;
+import com.dotcms.content.index.migration.ContentIndexMirrorReconciler;
+import com.dotcms.content.index.migration.MirrorStatus;
import com.dotcms.enterprise.LicenseUtil;
import com.dotcms.enterprise.license.LicenseLevel;
import com.dotcms.enterprise.publishing.bundlers.FileAssetBundler;
@@ -24,6 +27,7 @@
import com.dotmarketing.sitesearch.business.SiteSearchAuditAPI;
import com.dotmarketing.sitesearch.model.SiteSearchAudit;
import com.dotmarketing.util.ActivityLogger;
+import com.dotmarketing.util.Config;
import com.dotmarketing.util.AdminLogger;
import com.dotmarketing.util.DateUtil;
import com.dotmarketing.util.Logger;
@@ -35,6 +39,7 @@
import com.google.common.collect.ImmutableList.Builder;
import com.liferay.portal.model.User;
import com.liferay.util.StringPool;
+import io.vavr.control.Try;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
@@ -44,6 +49,8 @@
import java.util.ListIterator;
import java.util.Map;
import java.util.Objects;
+import java.util.Optional;
+import java.util.function.Supplier;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@@ -83,6 +90,7 @@ public class SiteSearchJobImpl {
private final UserAPI userAPI;
private final SiteSearchAuditAPI siteSearchAuditAPI;
private final PublisherAPI publisherAPI;
+ private final Supplier> contentMirrorStatuses;
private String bundleId;
@@ -95,12 +103,27 @@ public class SiteSearchJobImpl {
final SiteSearchAuditAPI siteSearchAuditAPI,
final PublisherAPI publisherAPI
) {
+ this(indicesAPI, siteSearchAPI, hostAPI, userAPI, siteSearchAuditAPI, publisherAPI,
+ () -> new ContentIndexMirrorReconciler().statuses());
+ }
+
+ @VisibleForTesting
+ SiteSearchJobImpl(
+ final IndiciesAPI indicesAPI,
+ final SiteSearchAPI siteSearchAPI,
+ final HostAPI hostAPI,
+ final UserAPI userAPI,
+ final SiteSearchAuditAPI siteSearchAuditAPI,
+ final PublisherAPI publisherAPI,
+ final Supplier> contentMirrorStatuses
+ ) {
this.indicesAPI = indicesAPI;
this.siteSearchAPI = siteSearchAPI;
this.hostAPI = hostAPI;
this.userAPI = userAPI;
this.siteSearchAuditAPI = siteSearchAuditAPI;
this.publisherAPI = publisherAPI;
+ this.contentMirrorStatuses = contentMirrorStatuses;
}
public SiteSearchJobImpl() {
@@ -312,6 +335,11 @@ PreparedJobContext prepareJob(final JobExecutionContext jobContext)
indexName = indexMetaData.getIndexName();
}
+ // Advisory, never a gate: the crawl reads the content index, so an incomplete one silently
+ // yields a partial Site Search index (issue #36983).
+ incompleteContentIndexWarning()
+ .ifPresent(warning -> Logger.warn(SiteSearchJobImpl.class, warning));
+
Logger.info(SiteSearchJobImpl.class, () -> String
.format("Incremental mode [%s]. current index is `%s`. new index is `%s`. alias is `%s` bundle id is `%s` ",
BooleanUtils.toStringYesNo(incremental), indexName,
@@ -381,6 +409,64 @@ PreparedJobContext prepareJob(final JobExecutionContext jobContext)
}
}
+ /**
+ * Config key for the coverage below which a crawl is warned about. Percentage of the content the
+ * database says exists; {@code 0} disables the check.
+ */
+ static final String MIN_CONTENT_COVERAGE_KEY = "SITE_SEARCH_CRAWL_MIN_CONTENT_COVERAGE_PERCENT";
+
+ /** Default: warn when the content index serving the crawl is missing more than 5% of the content. */
+ static final double DEFAULT_MIN_CONTENT_COVERAGE = 95.0;
+
+ /**
+ * The warning to emit before crawling when the content index this crawl will read from is
+ * materially incomplete, or {@link Optional#empty()} when there is nothing to say.
+ *
+ *
Why a crawl cares about the CONTENT index. A crawl does not read the
+ * database: the bundlers build the bundle from {@code ContentletAPI#searchIndex}, a phase-routed
+ * search over the content index. So the crawl can only find what that index holds — if the
+ * OpenSearch content mirror was never rebuilt, a Phase-3 crawl silently produces a Site Search
+ * index containing a fraction of the site, reports success, and that truncated index survives even
+ * after the content store is reindexed (issue #36983).
+ *
+ *
Measured against the database, not against the other engine: in Phase 3 there is no
+ * other engine to compare with, which is exactly when this is most needed. Advisory only — it never
+ * stops the crawl, and any failure to compute it is swallowed, because a diagnostic must not be
+ * able to break indexing.
+ */
+ @VisibleForTesting
+ Optional incompleteContentIndexWarning() {
+ final double threshold = Config.getFloatProperty(
+ MIN_CONTENT_COVERAGE_KEY, (float) DEFAULT_MIN_CONTENT_COVERAGE);
+ if (threshold <= 0) {
+ return Optional.empty();
+ }
+ final boolean readsOpenSearch = MigrationPhase.current().isReadEnabled();
+ return Try.of(() -> contentMirrorStatuses.get().stream()
+ .map(status -> coverageShortfall(status, readsOpenSearch, threshold))
+ .flatMap(Optional::stream)
+ .findFirst())
+ .getOrElse(Optional.empty());
+ }
+
+ /** The shortfall message for one content row, or empty when that row is fine or unmeasured. */
+ private static Optional coverageShortfall(final MirrorStatus status,
+ final boolean readsOpenSearch, final double threshold) {
+ final Double coverage = readsOpenSearch
+ ? status.osCoveragePercent() : status.esCoveragePercent();
+ if (coverage == null || coverage >= threshold) {
+ return Optional.empty();
+ }
+ return Optional.of(String.format(
+ "Site Search crawl starting against an INCOMPLETE content index: '%s' on %s holds "
+ + "%.2f%% of the %d contentlets the database has. A crawl builds its corpus by "
+ + "querying that index, so it can only index what it finds there — this crawl "
+ + "will produce a partial Site Search index, and reindexing the content later "
+ + "will NOT repair it (it must be crawled again). Run a full reindex first.",
+ status.indexName(), readsOpenSearch ? "OpenSearch" : "Elasticsearch", coverage,
+ status.expectedDocCount()));
+ }
+
/**
* Unique thread safe site-search index name
* @return
diff --git a/dotCMS/src/test/java/com/dotcms/publishing/job/SiteSearchJobAliasResolutionTest.java b/dotCMS/src/test/java/com/dotcms/publishing/job/SiteSearchJobAliasResolutionTest.java
index edcdb78577af..a50b8b3d6cf8 100644
--- a/dotCMS/src/test/java/com/dotcms/publishing/job/SiteSearchJobAliasResolutionTest.java
+++ b/dotCMS/src/test/java/com/dotcms/publishing/job/SiteSearchJobAliasResolutionTest.java
@@ -11,6 +11,8 @@
import static org.mockito.Mockito.when;
import com.dotcms.content.elasticsearch.business.IndiciesAPI;
+import com.dotcms.content.index.IndexConfigHelper.MigrationPhase;
+import com.dotcms.content.index.migration.MirrorStatus;
import com.dotcms.enterprise.publishing.sitesearch.SiteSearchResults;
import com.dotcms.publishing.PublisherAPI;
import com.dotcms.publishing.job.SiteSearchJobImpl.IndexMetaData;
@@ -18,8 +20,11 @@
import com.dotmarketing.portlets.contentlet.business.HostAPI;
import com.dotmarketing.sitesearch.business.SiteSearchAPI;
import com.dotmarketing.sitesearch.business.SiteSearchAuditAPI;
+import com.dotmarketing.util.Config;
import java.util.Collections;
+import java.util.List;
import java.util.Map;
+import java.util.Optional;
import org.junit.Before;
import org.junit.Test;
@@ -115,4 +120,114 @@ public void test_unknownName_isKeptAsTheAliasOfANewIndex() throws Exception {
assertEquals("brand-new-alias", metaData.getAlias());
assertTrue(metaData.isNewIndex());
}
+
+ // =======================================================================
+ // Incomplete-content-index warning (issue #36983)
+ // =======================================================================
+
+ /** A content row with the given coverage on each engine. */
+ private static MirrorStatus contentRow(final Long expected, final long esCount,
+ final long osCount) {
+ return new MirrorStatus("working_1", MirrorStatus.IndexKind.CONTENT_WORKING,
+ new MirrorStatus.EngineCopy(true, esCount, "cluster_x.working_1"),
+ new MirrorStatus.EngineCopy(true, osCount, "cluster_x.working_1.os"),
+ MirrorStatus.Verdict.IN_SYNC, "", expected);
+ }
+
+ private SiteSearchJobImpl jobSeeing(final MirrorStatus... rows) {
+ return new SiteSearchJobImpl(mock(IndiciesAPI.class), siteSearchAPI, mock(HostAPI.class),
+ mock(UserAPI.class), mock(SiteSearchAuditAPI.class), mock(PublisherAPI.class),
+ () -> List.of(rows));
+ }
+
+ /**
+ * The case this exists for: a Phase-3 crawl reading an OpenSearch content index that was never
+ * rebuilt. The crawl queries that index to build its corpus, so it can only produce a partial Site
+ * Search index — and reindexing the content afterwards does not repair it.
+ */
+ @Test
+ public void test_incompleteContentIndexOnTheReadEngine_isWarnedAbout() {
+ Config.setProperty(MigrationPhase.FLAG_KEY, "3"); // reads = OpenSearch
+ try {
+ final Optional warning = jobSeeing(contentRow(686L, 686, 21))
+ .incompleteContentIndexWarning();
+
+ assertTrue(warning.isPresent());
+ assertTrue(warning.get().contains("INCOMPLETE content index"));
+ assertTrue(warning.get().contains("OpenSearch"));
+ assertTrue(warning.get().contains("3.06%"));
+ } finally {
+ Config.setProperty(MigrationPhase.FLAG_KEY, null);
+ }
+ }
+
+ /**
+ * The same incomplete OpenSearch copy is NOT warned about in a phase that reads Elasticsearch: the
+ * crawl will query the complete ES index, so its corpus is fine. Warning there would train
+ * operators to ignore the message.
+ */
+ @Test
+ public void test_incompleteCopyOnTheEngineNotBeingRead_isNotWarnedAbout() {
+ Config.setProperty(MigrationPhase.FLAG_KEY, "1"); // reads = Elasticsearch
+ try {
+ assertFalse(jobSeeing(contentRow(686L, 686, 21))
+ .incompleteContentIndexWarning().isPresent());
+ } finally {
+ Config.setProperty(MigrationPhase.FLAG_KEY, null);
+ }
+ }
+
+ /** A complete index says nothing. */
+ @Test
+ public void test_completeContentIndex_isNotWarnedAbout() {
+ Config.setProperty(MigrationPhase.FLAG_KEY, "3");
+ try {
+ assertFalse(jobSeeing(contentRow(686L, 686, 686))
+ .incompleteContentIndexWarning().isPresent());
+ } finally {
+ Config.setProperty(MigrationPhase.FLAG_KEY, null);
+ }
+ }
+
+ /** Without a database denominator there is no coverage to judge — silence, not a false alarm. */
+ @Test
+ public void test_noDatabaseDenominator_isNotWarnedAbout() {
+ Config.setProperty(MigrationPhase.FLAG_KEY, "3");
+ try {
+ assertFalse(jobSeeing(contentRow(null, 686, 21))
+ .incompleteContentIndexWarning().isPresent());
+ } finally {
+ Config.setProperty(MigrationPhase.FLAG_KEY, null);
+ }
+ }
+
+ /** The check is advisory: if it cannot be computed, the crawl proceeds silently. */
+ @Test
+ public void test_failureToMeasure_isSwallowed() {
+ Config.setProperty(MigrationPhase.FLAG_KEY, "3");
+ try {
+ final SiteSearchJobImpl failing = new SiteSearchJobImpl(mock(IndiciesAPI.class),
+ siteSearchAPI, mock(HostAPI.class), mock(UserAPI.class),
+ mock(SiteSearchAuditAPI.class), mock(PublisherAPI.class),
+ () -> { throw new IllegalStateException("cluster down"); });
+
+ assertFalse(failing.incompleteContentIndexWarning().isPresent());
+ } finally {
+ Config.setProperty(MigrationPhase.FLAG_KEY, null);
+ }
+ }
+
+ /** The threshold is configurable, and 0 disables the check outright. */
+ @Test
+ public void test_thresholdZero_disablesTheCheck() {
+ Config.setProperty(MigrationPhase.FLAG_KEY, "3");
+ Config.setProperty(SiteSearchJobImpl.MIN_CONTENT_COVERAGE_KEY, "0");
+ try {
+ assertFalse(jobSeeing(contentRow(686L, 686, 21))
+ .incompleteContentIndexWarning().isPresent());
+ } finally {
+ Config.setProperty(SiteSearchJobImpl.MIN_CONTENT_COVERAGE_KEY, null);
+ Config.setProperty(MigrationPhase.FLAG_KEY, null);
+ }
+ }
}
\ No newline at end of file
From 02898747312fc759a171f0cfc58485a190ab3748 Mon Sep 17 00:00:00 2001
From: fabrizzio-dotCMS
Date: Tue, 11 Aug 2026 16:37:25 -0600
Subject: [PATCH 10/15] fix(migration): count the coverage denominator exactly
(#36983)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The denominator came from pg_class.reltuples, a planner estimate that
drifts a few points between ANALYZE runs — it surfaced as coverage of
100.59% on a complete index, which reads as a defect and costs a support
question every time.
Now counted exactly: SELECT COUNT(*), COUNT(live_inode) FROM
contentlet_version_info. One row per (identifier, lang, variant_id), the
same unit as an index document, so a complete index reads exactly 100.0 —
verified against a live install at 686/685, matching the index counts
exactly.
The earlier claim that this required a sequential scan was wrong: both
aggregates resolve through index-only scans (COUNT(live_inode) over
idx_contentlet_vi_live with an IS NOT NULL condition), reading narrow
btrees rather than the heap. It runs on an admin-only endpoint on demand
and once per crawl, never on a write path.
With an exact denominator, above 100% now means something real —
documents in the index the database no longer has — instead of sampling
noise.
Co-Authored-By: Claude Opus 5 (1M context)
---
docs/backend/OPENSEARCH_MIGRATION.md | 23 ++++----
.../ContentIndexMirrorReconciler.java | 57 ++++++++-----------
.../content/index/migration/MirrorStatus.java | 18 +++---
3 files changed, 44 insertions(+), 54 deletions(-)
diff --git a/docs/backend/OPENSEARCH_MIGRATION.md b/docs/backend/OPENSEARCH_MIGRATION.md
index ab77b69d5631..1a68dfe46500 100644
--- a/docs/backend/OPENSEARCH_MIGRATION.md
+++ b/docs/backend/OPENSEARCH_MIGRATION.md
@@ -507,17 +507,18 @@ through the write-path gate above.
that a Site Search crawl builds its corpus from a query against this index. It never changes the
`verdict`, which states a different fact (the ES↔OS relationship).
- **The denominator is O(1), by design.** It comes from the PostgreSQL planner statistics —
- `pg_class.reltuples` for the row count (every working version, since `working_inode` is `NOT NULL`)
- times `1 − pg_stats.null_frac` of `live_inode` for the live subset — two catalog lookups, no table
- access. An exact `COUNT` is a sequential scan (verified with `EXPLAIN`: no index-only path counts
- non-null `live_inode` without walking the table), which on a customer-sized table would mean a
- multi-second query every time an operator refreshes the endpoint. The estimate is accurate to a few
- percent (measured 689/682 against an exact 686/685) — well inside what this metric claims, since it
- exists to tell 3% from 97%, never 99% from 100%. A table autovacuum has never analyzed reports
- `reltuples = -1`, which is treated as unknown (fields omitted), not as an empty table. Site Search
- rows have no such denominator (their corpus is crawled pages and files), so the fields are absent
- there too.
+ **The denominator is an exact count.** `SELECT COUNT(*) AS working, COUNT(live_inode) AS live FROM
+ contentlet_version_info` — one row per `(identifier, lang, variant_id)`, the same unit as an index
+ document, so a complete index reads exactly `100.0` (verified against a live install: 686/685,
+ matching the index counts exactly). Both aggregates resolve through **index-only scans**
+ (`COUNT(live_inode)` over `idx_contentlet_vi_live` with an `IS NOT NULL` condition), so they read
+ narrow btrees and never the heap; it runs on an admin-only endpoint on demand and once per crawl,
+ never on a write path. Not the `pg_class.reltuples` estimate on purpose: that drifts a few points
+ between `ANALYZE` runs and surfaced as coverage slightly over 100%, which reads as a defect. With
+ exact counts, above 100% means the index holds documents the database no longer has — orphans from a
+ delete that never propagated, worth looking at rather than rounding away. Site Search rows have no
+ such denominator (their corpus is crawled pages and files), so the fields are absent there.
+
- **What a count still cannot tell you.** A number that does not move is not proof that nothing was
written: the document id is `identifier_languageId_variant`, so re-publishing content already present
in that index is an **update**, and the total stays put. And in a dual-write phase the OpenSearch copy
diff --git a/dotCMS/src/main/java/com/dotcms/content/index/migration/ContentIndexMirrorReconciler.java b/dotCMS/src/main/java/com/dotcms/content/index/migration/ContentIndexMirrorReconciler.java
index 407520f7c09d..0a18fb5c1e65 100644
--- a/dotCMS/src/main/java/com/dotcms/content/index/migration/ContentIndexMirrorReconciler.java
+++ b/dotCMS/src/main/java/com/dotcms/content/index/migration/ContentIndexMirrorReconciler.java
@@ -84,8 +84,8 @@ public ContentIndexMirrorReconciler() {
/**
* How many documents each content index should hold according to the database — the denominator
- * behind the coverage percentages. A planner estimate, accurate to a few percent (see
- * {@link #loadExpectedCountsQuietly()}); {@code null} on either field when it is unavailable.
+ * behind the coverage percentages. Counted exactly (see {@link #loadExpectedCountsQuietly()});
+ * {@code null} on either field when it could not be read.
*
* @param working one row per (identifier, language, variant): the working version always exists
* @param live the subset of those rows that also have a live version
@@ -215,49 +215,38 @@ private static String recommend(final String name, final Verdict verdict, final
}
/**
- * How many documents each content index should hold, from the PostgreSQL planner statistics —
- * O(1), reading only the catalog.
+ * How many documents each content index should hold, counted exactly from
+ * {@code contentlet_version_info}.
*
- *
Why an estimate and not {@code COUNT(*)}. {@code contentlet_version_info} is
- * keyed by {@code (identifier, lang, variant_id)} — the same unit as an index document
- * ({@code identifier_language_variant}) — so its row count is the natural denominator. But an exact
- * count is a sequential scan (verified with {@code EXPLAIN}: no index-only path counts non-null
- * {@code live_inode} without walking the table), which on a customer-sized table means a
- * multi-second, I/O-heavy query every time an operator refreshes this endpoint. The catalog
- * estimate costs two lookups and no table access:
- *
- *
{@code pg_class.reltuples} — the row count, i.e. every working version, since
- * {@code working_inode} is {@code NOT NULL}.
- *
{@code × (1 − pg_stats.null_frac)} of {@code live_inode} — the fraction of those rows that
- * also have a live version.
- *
+ *
That table is keyed by {@code (identifier, lang, variant_id)} — the same unit as an index
+ * document ({@code identifier_language_variant}) — so its row count is the denominator directly:
+ * {@code COUNT(*)} is every working version ({@code working_inode} is {@code NOT NULL}, so every
+ * row has one) and {@code COUNT(live_inode)} skips nulls and therefore counts exactly the rows that
+ * also have a live version. Verified against a live install: 686/685, matching the index document
+ * counts exactly.
*
- *
Both are maintained by {@code ANALYZE}/autovacuum and are accurate to a few percent (measured
- * 689/682 against an exact 686/685). That is well inside what this metric claims: coverage exists
- * to tell 3% from 97%, never 99% from 100% — so paying a table scan for exactness would buy
- * precision the metric explicitly does not offer. A table that has never been analyzed reports
- * {@code reltuples = -1}; that and any failure yield {@code null}, and the coverage fields are then
- * simply omitted rather than failing the whole report.
+ *
Both aggregates resolve through index-only scans — {@code COUNT(*)} over any
+ * index of the table, {@code COUNT(live_inode)} over {@code idx_contentlet_vi_live} with an
+ * {@code IS NOT NULL} condition — so this reads narrow btrees, never the heap. It runs on an
+ * admin-only endpoint on demand and once per crawl, never on a write path.
*
- *
PostgreSQL-only, like the rest of the platform.
+ *
Exact rather than the {@code pg_class.reltuples} estimate on purpose: the estimate drifts a few
+ * points in either direction between {@code ANALYZE} runs, which surfaces as coverage slightly over
+ * 100% and reads as a defect. With exact counts, 100% means complete and any excess is real —
+ * documents in the index that no longer exist in the database.
+ *
+ *
Failure is quiet: the coverage fields are omitted rather than failing the whole report.
*/
private static ExpectedCounts loadExpectedCountsQuietly() {
return Try.of(() -> {
final List