diff --git a/docs/backend/OPENSEARCH_MIGRATION.md b/docs/backend/OPENSEARCH_MIGRATION.md
index 71e8795219f6..d577fc9fa4c1 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
@@ -357,6 +391,62 @@ 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.
+
+**The crawl warns when it is about to do this.** `SiteSearchJobImpl` checks how much of the content
+the index it is about to read actually holds — the read engine's copy measured against the database,
+the same `osIndexedPercent` / `esIndexedPercent` 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_INDEXED_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.
+
#### Migration-readiness endpoint (pre-phase-change advisory)
`GET /api/v1/index/migration/readiness` is an internal, read-only report a support technician runs
@@ -380,11 +470,69 @@ 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).
-- **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
+- **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 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.
+- **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).
+- **Content rows also say how much of the database's content each engine actually holds.** `databaseDocCount` 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 `esIndexedPercent` / `osIndexedPercent` 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 `osIndexedPercent` 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 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). PostgreSQL runs it as a `Parallel Seq Scan` — `COUNT(live_inode)`
+ needs the column, so the heap is read — measured at **15 ms / 171k rows, 21 ms / 394k, 22 ms / 453k**,
+ roughly linear at ~50 ns per row on a warm cache. It runs on an admin-only endpoint on demand and once
+ per crawl, never on a write path. Kept as one statement on purpose: splitting it lets `COUNT(*)` alone
+ use a `Parallel Index Only Scan` (17 ms), but the live half stays a sequential scan regardless (nearly
+ every row has a live version, so the index buys nothing), and the two together measured 41 ms against
+ 28 ms combined. Not the `pg_class.reltuples` estimate on purpose: that drifts a few points
+ between `ANALYZE` runs and surfaced as an indexed percentage 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
+ 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
+ ```
- **`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
@@ -402,6 +550,140 @@ 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` | `(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 |
+| `databaseDocCount` + `esIndexedPercent` / `osIndexedPercent` | Content rows only. What percentage of the database's content each engine actually holds — measured **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) |
+
+**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" }
+```
+
+(A Site Search row, so it carries no `databaseDocCount` / `*IndexedPercent`: those exist only for the
+content indices, whose denominator is the database.)
+
+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" },
+ "databaseDocCount": 148230,
+ "esIndexedPercent": 100.0,
+ "osIndexedPercent": 0.0,
+ "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.
+
+Note `osIndexedPercent: 0.0` next to `esIndexedPercent: 100.0`: that pair is the one part of this row
+that keeps its meaning in Phase 3, where there is no Elasticsearch side left and `driftPercent` has
+nothing to compare.
+
+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/ESSiteSearchAPI.java b/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/sitesearch/ESSiteSearchAPI.java
index fb8e14211654..41c63d663a2c 100644
--- a/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/sitesearch/ESSiteSearchAPI.java
+++ b/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/sitesearch/ESSiteSearchAPI.java
@@ -46,6 +46,7 @@
import java.util.Date;
import java.util.List;
import java.util.Map;
+import java.util.Optional;
import java.util.stream.Collectors;
import io.vavr.control.Try;
@@ -370,9 +371,16 @@ public SiteSearchResults search(String indexName, String query, int offset, int
* @return
* @throws DotDataException
*/
+ @Override
+ public Optional defaultIndexName() throws DotDataException {
+ return Optional.ofNullable(indiciesAPI.loadIndicies().getSiteSearch());
+ }
+
@Override
public boolean isDefaultIndex(final String indexName) throws DotDataException {
- return indexName.equals(indiciesAPI.loadIndicies().getSiteSearch());
+ // Defined in terms of defaultIndexName so "which index is the default" has one definition
+ // per engine (issue #36983).
+ return indexName != null && defaultIndexName().filter(indexName::equals).isPresent();
}
@Override
diff --git a/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/sitesearch/OSSiteSearchAPI.java b/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/sitesearch/OSSiteSearchAPI.java
index 253d1ef17eca..e31afa724f83 100644
--- a/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/sitesearch/OSSiteSearchAPI.java
+++ b/dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/sitesearch/OSSiteSearchAPI.java
@@ -519,9 +519,16 @@ public Map getFacets(String indexName, String query) throws
// Default index activation / inspection
// =========================================================================
+ @Override
+ public Optional defaultIndexName() {
+ return defaultSiteSearchIndex();
+ }
+
@Override
public boolean isDefaultIndex(final String indexName) throws DotDataException {
- return indexName != null && indexName.equals(defaultSiteSearchIndex().orElse(null));
+ // Defined in terms of defaultIndexName so "which index is the default" has one definition
+ // per engine (issue #36983).
+ return indexName != null && defaultIndexName().filter(indexName::equals).isPresent();
}
@Override
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..c21350d3fe0d 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,9 +25,11 @@
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;
+import java.util.Optional;
import java.util.Set;
import org.quartz.SchedulerException;
@@ -201,6 +203,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
// -------------------------------------------------------------------------
@@ -221,6 +250,23 @@ public SiteSearchResult getFromIndex(final String index, final String id) {
return router.read(impl -> impl.getFromIndex(index, id));
}
+ /**
+ * Router: the default site-search index according to the current read provider — Elasticsearch's
+ * legacy pointer in Phases 0/1, OpenSearch's {@code VersionedIndices} (with a legacy fallback) in
+ * Phases 2/3. Reading the legacy pointer directly goes stale from Phase 3 on, where
+ * {@code activateIndex} fans out to OpenSearch alone (issue #36983).
+ */
+ @Override
+ public Optional defaultIndexName() throws DotDataException {
+ try {
+ return router.readChecked(SiteSearchAPI::defaultIndexName);
+ } catch (DotDataException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new DotDataException(e.getMessage(), e);
+ }
+ }
+
@Override
public boolean isDefaultIndex(final String indexName) throws DotDataException {
try {
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..86e55b5c24da 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,15 +1,19 @@
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.common.db.DotConnect;
import com.dotmarketing.util.Logger;
import com.dotmarketing.util.UtilMethods;
import com.google.common.annotations.VisibleForTesting;
@@ -27,12 +31,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,21 +56,42 @@ public class ContentIndexMirrorReconciler {
private final IndexAPI esImpl;
private final IndexAPI osImpl;
+ private final ContentletIndexOperations esOps;
+ private final ContentletIndexOperations osOps;
private final Supplier indiciesSupplier;
+ private final Supplier databaseCountsSupplier;
public ContentIndexMirrorReconciler() {
this(new ESIndexAPI(), CDIUtils.getBeanThrows(OSIndexAPIImpl.class),
- ContentIndexMirrorReconciler::loadIndiciesQuietly);
+ new ContentletIndexOperationsES(),
+ CDIUtils.getBeanThrows(ContentletIndexOperationsOS.class),
+ ContentIndexMirrorReconciler::loadIndiciesQuietly,
+ ContentIndexMirrorReconciler::loadDatabaseCountsQuietly);
}
@VisibleForTesting
ContentIndexMirrorReconciler(final IndexAPI esImpl, final IndexAPI osImpl,
- final Supplier indiciesSupplier) {
+ final ContentletIndexOperations esOps, final ContentletIndexOperations osOps,
+ final Supplier indiciesSupplier,
+ final Supplier databaseCountsSupplier) {
this.esImpl = esImpl;
this.osImpl = osImpl;
+ this.esOps = esOps;
+ this.osOps = osOps;
this.indiciesSupplier = indiciesSupplier;
+ this.databaseCountsSupplier = databaseCountsSupplier;
}
+ /**
+ * How many documents each content index should hold according to the database — the denominator
+ * behind the indexed percentages. Counted exactly (see {@link #loadDatabaseCountsQuietly()});
+ * {@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
+ */
+ public record DatabaseCounts(Long working, Long live) {}
+
/** Per-index mirror status for the active working and live content indices. */
public List statuses() {
final IndiciesInfo info = indiciesSupplier.get();
@@ -65,14 +100,18 @@ public List statuses() {
}
final Map esStats = esImpl.getIndicesStats();
final Map osStats = osImpl.getIndicesStats();
+ final DatabaseCounts dbCounts = databaseCountsSupplier.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,
+ dbCounts == null ? null : dbCounts.working());
+ addStatus(out, IndexKind.CONTENT_LIVE, info.getLive(), esStats, osStats,
+ dbCounts == null ? null : dbCounts.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 databaseDocCount) {
if (!UtilMethods.isSet(rawName)) {
return;
}
@@ -84,16 +123,78 @@ 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);
+ final String recommendation = recommend(bare, verdict, osExists)
+ + incompleteNote("Elasticsearch", esExists, esCount, databaseDocCount)
+ + incompleteNote("OpenSearch", osExists, osCount, databaseDocCount);
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, databaseDocCount));
+ }
+
+ /**
+ * 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);
+ }
+
+ /**
+ * Indexed percentage 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#indexedPercentOf}), so this is meant to catch "3% of the content", not a handful of
+ * documents.
+ */
+ private static final double INCOMPLETE_INDEXED_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 databaseDocCount) {
+ if (!exists || count < 0 || databaseDocCount == null || databaseDocCount <= 0) {
+ return "";
+ }
+ final double indexedPercent = count * 100.0 / databaseDocCount;
+ if (indexedPercent >= INCOMPLETE_INDEXED_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, databaseDocCount, indexedPercent);
}
private static String recommend(final String name, final Verdict verdict, final boolean osExists) {
@@ -113,6 +214,72 @@ private static String recommend(final String name, final Verdict verdict, final
}
}
+ /**
+ * A fixed, fully literal statement — no interpolation, no parameters, nothing caller-supplied. Kept
+ * as a constant rather than assembled inline so that stays evident at a glance (and so a
+ * concatenation-based injection scan has nothing to flag).
+ */
+ private static final String DATABASE_COUNTS_SQL = """
+ SELECT COUNT(*) AS working_count, COUNT(live_inode) AS live_count
+ FROM contentlet_version_info
+ """;
+
+ /**
+ * How many documents each content index should hold, counted exactly from
+ * {@code contentlet_version_info}.
+ *
+ *
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.
+ *
+ *
Cost. PostgreSQL runs this as a {@code Parallel Seq Scan}: asking for
+ * {@code COUNT(live_inode)} needs the column, so the heap is read. Measured on local copies —
+ * 171k rows / 15 ms, 394k / 21 ms, 453k / 22 ms — i.e. roughly linear at ~50 ns
+ * per row (warm cache; a cold one pays the disk I/O). It runs on an admin-only endpoint on demand
+ * and once per crawl, never on a write path.
+ *
+ *
Kept as one statement deliberately. Splitting it lets {@code COUNT(*)} alone drop to a
+ * {@code Parallel Index Only Scan} (17 ms), but the live half stays a sequential scan anyway —
+ * nearly every row has a live version, so the index buys the planner nothing — and the two together
+ * measured 41 ms against 28 ms for the combined form.
+ *
+ *
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 an indexed percentage 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 indexed-percentage fields are omitted rather than failing the whole report.
+ */
+ private static DatabaseCounts loadDatabaseCountsQuietly() {
+ 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 databaseDocCount how many documents this index should hold according to the
+ * database — the engine-independent denominator behind
+ * {@link #esIndexedPercent()} / {@link #osIndexedPercent()}. An exact count
+ * of {@code contentlet_version_info}, so 100% means complete and any excess is
+ * real. 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 it could not be read.
*/
@JsonIgnoreProperties("kind") // internal grouping/label only — the report keys rows by it, never emits it
public record MirrorStatus(
@@ -29,7 +37,14 @@ public record MirrorStatus(
EngineCopy es,
EngineCopy os,
Verdict verdict,
- String recommendation) {
+ String recommendation,
+ @JsonInclude(JsonInclude.Include.NON_NULL) Long databaseDocCount) {
+
+ /** 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 }
@@ -47,14 +62,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() {
@@ -90,6 +120,61 @@ public Double driftPercent() {
return Math.round(pct * 100.0) / 100.0;
}
+ /**
+ * What percentage of the database's content the Elasticsearch copy holds — a percentage of
+ * {@link #databaseDocCount}. See {@link #indexedPercentOf(EngineCopy)}.
+ */
+ @JsonProperty("esIndexedPercent")
+ @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 esIndexedPercent() {
+ return indexedPercentOf(es);
+ }
+
+ /**
+ * What percentage of the database's content the OpenSearch copy holds — a percentage of
+ * {@link #databaseDocCount}. See {@link #indexedPercentOf(EngineCopy)}.
+ */
+ @JsonProperty("osIndexedPercent")
+ @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 osIndexedPercent() {
+ return indexedPercentOf(os);
+ }
+
+ /**
+ * One engine's completeness against the database: {@code docCount / databaseDocCount × 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. This 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).
+ *
+ *
The denominator counts one row per (identifier, language, variant) in
+ * {@code contentlet_version_info} — the same unit as an index document — so a complete index reads
+ * exactly {@code 100.0}. Above 100% means the index holds documents the database no longer has
+ * (orphans left by a delete that never propagated), which is worth looking at rather than
+ * rounding away.
+ *
+ * @return the percentage, or {@code null} when there is no denominator ({@code databaseDocCount}
+ * absent or zero) or the count was unmeasurable ({@code -1})
+ */
+ private Double indexedPercentOf(final EngineCopy copy) {
+ if (databaseDocCount == null || databaseDocCount <= 0 || copy.docCount() < 0) {
+ return null;
+ }
+ return Math.round(copy.docCount() * 10_000.0 / databaseDocCount) / 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/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/main/java/com/dotcms/publishing/job/SiteSearchJobImpl.java b/dotCMS/src/main/java/com/dotcms/publishing/job/SiteSearchJobImpl.java
index 5e01d1d0396f..2e4c706e9a6e 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() {
@@ -198,7 +221,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 +263,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,19 +328,23 @@ 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.
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,
UtilMethods.isSet(newIndexName) ? newIndexName : "N/A",
- indexAlias,
+ UtilMethods.isSet(resolvedAlias) ? resolvedAlias : "N/A",
bundleId)
);
@@ -342,7 +376,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);
@@ -375,6 +409,64 @@ private PreparedJobContext prepareJob(final JobExecutionContext jobContext)
}
}
+ /**
+ * Config key for the indexed percentage 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_INDEXED_KEY = "SITE_SEARCH_CRAWL_MIN_CONTENT_INDEXED_PERCENT";
+
+ /** Default: warn when the content index serving the crawl is missing more than 5% of the content. */
+ static final double DEFAULT_MIN_CONTENT_INDEXED = 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_INDEXED_KEY, (float) DEFAULT_MIN_CONTENT_INDEXED);
+ if (threshold <= 0) {
+ return Optional.empty();
+ }
+ final boolean readsOpenSearch = MigrationPhase.current().isReadEnabled();
+ return Try.of(() -> contentMirrorStatuses.get().stream()
+ .map(status -> indexedShortfall(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 indexedShortfall(final MirrorStatus status,
+ final boolean readsOpenSearch, final double threshold) {
+ final Double indexedPercent = readsOpenSearch
+ ? status.osIndexedPercent() : status.esIndexedPercent();
+ if (indexedPercent == null || indexedPercent >= 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", indexedPercent,
+ status.databaseDocCount()));
+ }
+
/**
* Unique thread safe site-search index name
* @return
@@ -401,7 +493,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;
@@ -410,7 +503,12 @@ private IndexMetaData getIndexMetaData(String indexAlias) throws DotDataExceptio
// 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)) {
@@ -421,7 +519,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 +536,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/java/com/dotcms/rest/api/v1/index/MigrationReadinessResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/index/MigrationReadinessResource.java
index ea9a26cecbbd..b2683202c214 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 `databaseDocCount` (what the database says the index should "
+ + "hold) with `esIndexedPercent` / `osIndexedPercent` — 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/main/java/com/dotmarketing/sitesearch/ajax/SiteSearchAjaxAction.java b/dotCMS/src/main/java/com/dotmarketing/sitesearch/ajax/SiteSearchAjaxAction.java
index e1cc5337d5e2..ce0902508a7e 100644
--- a/dotCMS/src/main/java/com/dotmarketing/sitesearch/ajax/SiteSearchAjaxAction.java
+++ b/dotCMS/src/main/java/com/dotmarketing/sitesearch/ajax/SiteSearchAjaxAction.java
@@ -265,7 +265,11 @@ public void getIndexStatus(HttpServletRequest request, HttpServletResponse respo
String indexName = ESIndexHelper.getInstance().getIndexNameOrAlias(map,"indexName",
"indexAlias", APILocator.getESIndexAPI());
response.setContentType("text/plain");
- response.getWriter().println(APILocator.getIndiciesAPI().loadIndicies().getSiteSearch().equals(indexName) ? "default" : "inactive");
+ // Phase-aware default (issue #36983): the legacy IndiciesInfo pointer freezes at the
+ // Elasticsearch-era default from Phase 3 on, where activateIndex fans out to OpenSearch
+ // alone — and dereferencing it NPE'd when no default had ever been set.
+ response.getWriter().println(
+ APILocator.getSiteSearchAPI().isDefaultIndex(indexName) ? "default" : "inactive");
}
catch(Exception ex) {
throw new RuntimeException(ex);
@@ -275,7 +279,8 @@ public void getIndexStatus(HttpServletRequest request, HttpServletResponse respo
@Override
public void getNotActiveIndexNames(HttpServletRequest request, HttpServletResponse response) throws IOException {
try {
- String defaultIndex=APILocator.getIndiciesAPI().loadIndicies().getSiteSearch();
+ // Phase-aware default (issue #36983) — see getIndexStatus above.
+ final String defaultIndex = APILocator.getSiteSearchAPI().defaultIndexName().orElse(null);
List ret=new ArrayList<>();
for(String ii : APILocator.getSiteSearchAPI().listIndices())
if(defaultIndex==null || !defaultIndex.equals(ii))
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..f51f35005ffc 100644
--- a/dotCMS/src/main/java/com/dotmarketing/sitesearch/business/SiteSearchAPI.java
+++ b/dotCMS/src/main/java/com/dotmarketing/sitesearch/business/SiteSearchAPI.java
@@ -1,208 +1,266 @@
-package com.dotmarketing.sitesearch.business;
-
-import java.io.IOException;
-import java.text.ParseException;
-import java.util.List;
-import java.util.Map;
-
-import org.quartz.SchedulerException;
-
-import com.dotcms.content.index.domain.Aggregation;
-import com.dotcms.content.index.domain.DotSearchException;
-import com.dotcms.enterprise.publishing.sitesearch.SiteSearchConfig;
-import com.dotcms.enterprise.publishing.sitesearch.SiteSearchPublishStatus;
-import com.dotcms.enterprise.publishing.sitesearch.SiteSearchResult;
-import com.dotcms.enterprise.publishing.sitesearch.SiteSearchResults;
-import com.dotmarketing.exception.DotDataException;
-import com.dotmarketing.quartz.ScheduledTask;
-
-
-public interface SiteSearchAPI {
- public static final String ES_SITE_SEARCH_NAME = "sitesearch";
- public static final String ES_SITE_SEARCH_MAPPING = "_doc";
- public static final String ES_SITE_SEARCH_EXECUTE_JOB_NAME = "runningOnce";
-
- List listIndices();
-
- /**
- * Whether {@code indexName} exists on every engine that receives writes in the current migration
- * phase (Phase 0 → ES only; Phases 1/2 → ES and OpenSearch; Phase 3 → OpenSearch
- * only).
- *
- *
Why this exists — the incremental-crawl safety gate
- * A Site Search index is one logical index mirrored across both engines. An
- * incremental crawl writes documents in place into an existing index
- * rather than rebuilding it, so it never issues a {@code createSiteSearchIndex}. If a write engine
- * is missing its copy of that index (a phase rollout that never rebuilt an old index, a Phase-0
- * index that has no OpenSearch twin yet, or a shadow-create that failed fire-and-forget), the
- * in-place document write would let the engine auto-create the index with a dynamic
- * mapping — {@code keyword} fields become {@code text}, breaking aggregations. The crawl planner
- * gates on this method: when it returns {@code false} it must fall back to a full
- * rebuild, which recreates the index (with the correct mapping) on every engine and
- * re-points the alias — self-healing the missing mirror on the next crawl (issue #36360).
- *
- *
For a single-engine implementation this is simply whether that engine holds the index; the
- * phase-aware router ({@code SiteSearchAPIImpl}) aggregates it across all current write providers.
- *
- * @param indexName the logical site-search index name (no {@code .os} tag)
- * @return {@code true} only if every current write engine already holds the index
- */
- boolean existsOnAllWriteEngines(String indexName);
-
- /**
- * Whether the index's copies on every current write engine are in sync — i.e.
- * the index exists on all of them (see {@link #existsOnAllWriteEngines(String)}) and its
- * document counts match across engines.
- *
- *
Why counts, not just existence
- * A missing twin is one kind of desync; the other is content drift — both engines hold
- * the index but with different documents (e.g. an OpenSearch shadow write failed fire-and-forget
- * during a previous incremental crawl, so ES has documents OpenSearch does not). Existence alone
- * cannot see that. Because a Site Search index is written only by the crawl job (single writer,
- * immediate refresh) and no crawl on the same index runs concurrently, at crawl-planning time the
- * copies are quiescent, so equal document counts is a sound in-sync invariant and a mismatch is
- * real drift.
- *
- *
The incremental-crawl gate uses this instead of bare existence: an incremental crawl writes
- * only the new delta and would perpetuate any pre-existing drift, so when the mirrors are out of
- * sync the crawl is demoted to a full rebuild that re-creates identical copies on every engine
- * (issue #36360). For a single write engine there is nothing to compare, so this is trivially
- * {@code true}; the phase-aware router aggregates it across all current write providers.
- *
- * @param indexName the logical site-search index name (no {@code .os} tag)
- * @return {@code true} only if the index exists on every write engine with matching document counts
- */
- boolean writeMirrorsInSync(String indexName);
-
- /**
- * Accurate document count of this index's physical copy on this engine (Elasticsearch the plain
- * index, OpenSearch the {@code .os} twin) — the primitive behind the {@link #writeMirrorsInSync(String)}
- * parity check.
- *
- *
Unlike a plain {@code search(...).getTotalResults()}, this returns an exact
- * total. Default hit-count tracking on the Elasticsearch 7.x / OpenSearch clients caps reported
- * totals at 10,000, so two large mirrors that have genuinely drifted (e.g. 15,000 vs 12,000) would
- * both read back {@code 10000} and compare equal, hiding the drift the gate exists to catch. This
- * method issues a real count (a dedicated count request / {@code track_total_hits}) so drift is
- * detected above 10k docs (issue #36360).
- *
- * @param indexName the logical site-search index name (no {@code .os} tag)
- * @return the exact document count; {@code 0} if the index does not exist on this engine; {@code -1}
- * if the count query failed — callers must treat a failed count as "not in sync" (rebuild),
- * never as an empty index
- */
- long documentCount(String indexName);
-
- /**
- * Resolves site-search aliases to their backing index names — phase-aware and OpenSearch
- * {@code .os}-aware. Keys (alias) and values (index) are both logical names.
- *
- *
Design decision — why this lives on {@code SiteSearchAPI}, not the content-index router
- * A Site Search index is one logical index mirrored across both engines, so this API's
- * surface speaks in logical (untagged) names — a vendor-neutral handle — and each engine adapter
- * translates that handle to its physical form at the boundary (ES uses it verbatim; OpenSearch
- * appends {@code .os}). Alias resolution therefore MUST live here: the OpenSearch adapter knows to
- * re-tag the lookup with {@code .os}, whereas the content-index router
- * ({@code IndexAPI#getAliasToIndexMap}) builds the OS physical name without {@code .os}
- * and, in Phases 2/3 (OS reads), queries a name that does not exist — silently returning
- * nothing ("Index Alias not found"). Routing site-search alias resolution through the content
- * router was the root cause fixed in issue #36360; callers must use this method and never the
- * content router with a logical Site Search name.
- *
- *
The {@code .os} tag never crosses this boundary: it is applied only inside the OpenSearch
- * adapter for the lookup and stripped back off the resolved value, so both the alias keys and the
- * index values returned here are logical and directly comparable against {@link #listIndices()}
- * output.
- *
- *
Why dual-write phases cannot collide the map
- * In Phases 1/2 the ES twin ({@code xxx}) and the OpenSearch twin ({@code xxx.os}) carry the
- * same alias, so a naive ES∪OS merge would map one alias key to two different index
- * values and silently drop one. This method avoids that by resolving against a single
- * engine — the current phase's read provider (ES in Phases 0/1, OS in
- * Phases 2/3), never a union. The two twins never land in the same map: Phase 1 returns
- * {@code {lol=xxx}} from ES; Phase 2 returns {@code {lol=xxx}} from OS ({@code xxx.os} with the
- * tag stripped). Because both twins share the same logical base, a synchronized cluster resolves
- * the alias to the same logical name in every phase.
- *
- *
Two residual edges, both benign here:
- *
- *
Multi-index alias within one engine (one alias pointing at two indices on
- * the same provider) would lose one entry to the reverse-map — but that state is prevented
- * upstream by the {@code createAlias} existence check (issue #36360). A healthy cluster has
- * one index per alias per engine.
- *
Mirror desync (the ES and OS aliases point at different logical
- * indices) makes the result diverge by phase — which is correct, since you resolve against
- * the engine you read from; it is a mirror-reconciliation concern, not a collision.
- *
- *
- * @return map of logical alias name to logical index name; empty when nothing resolves
- */
- Map getAliasToIndexMap();
-
- /**
- * This basically tells you if the index passed as parameter is the default site search index or not
- * @param indexName
- * @return
- * @throws DotDataException
- */
- boolean isDefaultIndex(String indexName) throws DotDataException;
-
- void activateIndex(String indexName) throws DotDataException;
-
- void deactivateIndex(String indexName) throws DotDataException, IOException;
-
- boolean createSiteSearchIndex(String indexName, String alias, int shards) throws DotSearchException, IOException;
-
- boolean setAlias(String indexName, final String alias);
-
- List getTasks() throws SchedulerException;
-
- void deleteTask(String taskName) throws SchedulerException;
-
- void scheduleTask(SiteSearchConfig config) throws SchedulerException, ParseException, ClassNotFoundException;
-
- void putToIndex(String idx, SiteSearchResult res, String resultType);
-
- void putToIndex(String idx, List res, String resultType);
-
- void deleteFromIndex(String idx, String docId);
-
- SiteSearchResults search(String query, int start, int rows);
-
- SiteSearchResults search(String indexName, String query, int start, int rows);
-
- ScheduledTask getTask(String taskName) throws SchedulerException;
-
- void pauseTask(String taskName) throws SchedulerException;
-
- SiteSearchPublishStatus getTaskProgress(String jobName) throws SchedulerException;
-
- boolean isTaskRunning(String jobName) throws SchedulerException;
-
- void executeTaskNow(SiteSearchConfig config) throws SchedulerException, ParseException, ClassNotFoundException;
-
- SiteSearchResult getFromIndex(String index, String id);
-
- Map getAggregations(String indexName, String query) throws DotDataException;
-
- /***
- * @deprecated use getAggregations instead
- */
- @Deprecated
- Map getFacets(String indexName, String query) throws DotDataException;
-
- List listClosedIndices();
-
- public void deleteOldSiteSearchIndices();
-
- /**
- * Deletes a single site-search index by name from every engine that holds it (ES and, during a
- * migration, its OpenSearch counterpart), mirroring the operator's single-index view. The
- * active (default) site-search index cannot be deleted — deactivate it first.
- *
- * @param indexName the site-search index name (must be a {@code sitesearch_*} name)
- * @throws DotDataException if the name is not a site-search index or the delete fails
- * @throws IOException on an index-engine error
- */
- void deleteIndex(String indexName) throws DotDataException, IOException;
-}
+package com.dotmarketing.sitesearch.business;
+
+import java.io.IOException;
+import java.text.ParseException;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import org.quartz.SchedulerException;
+
+import com.dotcms.content.index.domain.Aggregation;
+import com.dotcms.content.index.domain.DotSearchException;
+import com.dotcms.enterprise.publishing.sitesearch.SiteSearchConfig;
+import com.dotcms.enterprise.publishing.sitesearch.SiteSearchPublishStatus;
+import com.dotcms.enterprise.publishing.sitesearch.SiteSearchResult;
+import com.dotcms.enterprise.publishing.sitesearch.SiteSearchResults;
+import com.dotmarketing.exception.DotDataException;
+import com.dotmarketing.quartz.ScheduledTask;
+
+
+public interface SiteSearchAPI {
+ public static final String ES_SITE_SEARCH_NAME = "sitesearch";
+ public static final String ES_SITE_SEARCH_MAPPING = "_doc";
+ public static final String ES_SITE_SEARCH_EXECUTE_JOB_NAME = "runningOnce";
+
+ List listIndices();
+
+ /**
+ * Whether {@code indexName} exists on every engine that receives writes in the current migration
+ * phase (Phase 0 → ES only; Phases 1/2 → ES and OpenSearch; Phase 3 → OpenSearch
+ * only).
+ *
+ *
Why this exists — the incremental-crawl safety gate
+ * A Site Search index is one logical index mirrored across both engines. An
+ * incremental crawl writes documents in place into an existing index
+ * rather than rebuilding it, so it never issues a {@code createSiteSearchIndex}. If a write engine
+ * is missing its copy of that index (a phase rollout that never rebuilt an old index, a Phase-0
+ * index that has no OpenSearch twin yet, or a shadow-create that failed fire-and-forget), the
+ * in-place document write would let the engine auto-create the index with a dynamic
+ * mapping — {@code keyword} fields become {@code text}, breaking aggregations. The crawl planner
+ * gates on this method: when it returns {@code false} it must fall back to a full
+ * rebuild, which recreates the index (with the correct mapping) on every engine and
+ * re-points the alias — self-healing the missing mirror on the next crawl (issue #36360).
+ *
+ *
For a single-engine implementation this is simply whether that engine holds the index; the
+ * phase-aware router ({@code SiteSearchAPIImpl}) aggregates it across all current write providers.
+ *
+ * @param indexName the logical site-search index name (no {@code .os} tag)
+ * @return {@code true} only if every current write engine already holds the index
+ */
+ boolean existsOnAllWriteEngines(String indexName);
+
+ /**
+ * Whether the index's copies on every current write engine are in sync — i.e.
+ * the index exists on all of them (see {@link #existsOnAllWriteEngines(String)}) and its
+ * document counts match across engines.
+ *
+ *
Why counts, not just existence
+ * A missing twin is one kind of desync; the other is content drift — both engines hold
+ * the index but with different documents (e.g. an OpenSearch shadow write failed fire-and-forget
+ * during a previous incremental crawl, so ES has documents OpenSearch does not). Existence alone
+ * cannot see that. Because a Site Search index is written only by the crawl job (single writer,
+ * immediate refresh) and no crawl on the same index runs concurrently, at crawl-planning time the
+ * copies are quiescent, so equal document counts is a sound in-sync invariant and a mismatch is
+ * real drift.
+ *
+ *
The incremental-crawl gate uses this instead of bare existence: an incremental crawl writes
+ * only the new delta and would perpetuate any pre-existing drift, so when the mirrors are out of
+ * sync the crawl is demoted to a full rebuild that re-creates identical copies on every engine
+ * (issue #36360). For a single write engine there is nothing to compare, so this is trivially
+ * {@code true}; the phase-aware router aggregates it across all current write providers.
+ *
+ * @param indexName the logical site-search index name (no {@code .os} tag)
+ * @return {@code true} only if the index exists on every write engine with matching document counts
+ */
+ boolean writeMirrorsInSync(String indexName);
+
+ /**
+ * Accurate document count of this index's physical copy on this engine (Elasticsearch the plain
+ * index, OpenSearch the {@code .os} twin) — the primitive behind the {@link #writeMirrorsInSync(String)}
+ * parity check.
+ *
+ *
Unlike a plain {@code search(...).getTotalResults()}, this returns an exact
+ * total. Default hit-count tracking on the Elasticsearch 7.x / OpenSearch clients caps reported
+ * totals at 10,000, so two large mirrors that have genuinely drifted (e.g. 15,000 vs 12,000) would
+ * both read back {@code 10000} and compare equal, hiding the drift the gate exists to catch. This
+ * method issues a real count (a dedicated count request / {@code track_total_hits}) so drift is
+ * detected above 10k docs (issue #36360).
+ *
+ * @param indexName the logical site-search index name (no {@code .os} tag)
+ * @return the exact document count; {@code 0} if the index does not exist on this engine; {@code -1}
+ * if the count query failed — callers must treat a failed count as "not in sync" (rebuild),
+ * never as an empty index
+ */
+ long documentCount(String indexName);
+
+ /**
+ * Resolves site-search aliases to their backing index names — phase-aware and OpenSearch
+ * {@code .os}-aware. Keys (alias) and values (index) are both logical names.
+ *
+ *
Design decision — why this lives on {@code SiteSearchAPI}, not the content-index router
+ * A Site Search index is one logical index mirrored across both engines, so this API's
+ * surface speaks in logical (untagged) names — a vendor-neutral handle — and each engine adapter
+ * translates that handle to its physical form at the boundary (ES uses it verbatim; OpenSearch
+ * appends {@code .os}). Alias resolution therefore MUST live here: the OpenSearch adapter knows to
+ * re-tag the lookup with {@code .os}, whereas the content-index router
+ * ({@code IndexAPI#getAliasToIndexMap}) builds the OS physical name without {@code .os}
+ * and, in Phases 2/3 (OS reads), queries a name that does not exist — silently returning
+ * nothing ("Index Alias not found"). Routing site-search alias resolution through the content
+ * router was the root cause fixed in issue #36360; callers must use this method and never the
+ * content router with a logical Site Search name.
+ *
+ *
The {@code .os} tag never crosses this boundary: it is applied only inside the OpenSearch
+ * adapter for the lookup and stripped back off the resolved value, so both the alias keys and the
+ * index values returned here are logical and directly comparable against {@link #listIndices()}
+ * output.
+ *
+ *
Why dual-write phases cannot collide the map
+ * In Phases 1/2 the ES twin ({@code xxx}) and the OpenSearch twin ({@code xxx.os}) carry the
+ * same alias, so a naive ES∪OS merge would map one alias key to two different index
+ * values and silently drop one. This method avoids that by resolving against a single
+ * engine — the current phase's read provider (ES in Phases 0/1, OS in
+ * Phases 2/3), never a union. The two twins never land in the same map: Phase 1 returns
+ * {@code {lol=xxx}} from ES; Phase 2 returns {@code {lol=xxx}} from OS ({@code xxx.os} with the
+ * tag stripped). Because both twins share the same logical base, a synchronized cluster resolves
+ * the alias to the same logical name in every phase.
+ *
+ *
Two residual edges, both benign here:
+ *
+ *
Multi-index alias within one engine (one alias pointing at two indices on
+ * the same provider) would lose one entry to the reverse-map — but that state is prevented
+ * upstream by the {@code createAlias} existence check (issue #36360). A healthy cluster has
+ * one index per alias per engine.
+ *
Mirror desync (the ES and OS aliases point at different logical
+ * indices) makes the result diverge by phase — which is correct, since you resolve against
+ * the engine you read from; it is a mirror-reconciliation concern, not a collision.
+ *
+ *
+ * @return map of logical alias name to logical index name; empty when nothing resolves
+ */
+ 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();
+ }
+
+ /**
+ * The site-search index currently marked as the default, resolved phase-aware.
+ *
+ *
Which store holds that pointer changes with the phase: Elasticsearch owns it in Phases 0/1
+ * (the legacy {@code indicies} row), OpenSearch in Phases 2/3 ({@code VersionedIndices}, falling
+ * back to the legacy row when its slot was never populated). Reading the legacy row directly —
+ * {@code IndiciesInfo#getSiteSearch()} — is therefore correct only up to Phase 1: from Phase 2 on,
+ * {@code activateIndex} fans out to OpenSearch alone in Phase 3, so the legacy pointer freezes at
+ * whatever was default in the Elasticsearch era and every screen reading it shows a stale default
+ * (issue #36983).
+ *
+ *
Callers that only need to test one name should use {@link #isDefaultIndex(String)}, which is
+ * defined in terms of this. Callers that need the name itself — preselecting it in a dropdown,
+ * listing the non-default indices — use this one, and get an empty {@link Optional} instead of a
+ * {@code null} to dereference when no default has ever been set.
+ *
+ * @return the logical name of the default index, or empty when there is none
+ * @throws DotDataException if the pointer store cannot be read
+ */
+ Optional defaultIndexName() throws DotDataException;
+
+ /**
+ * This basically tells you if the index passed as parameter is the default site search index or not
+ * @param indexName
+ * @return
+ * @throws DotDataException
+ */
+ boolean isDefaultIndex(String indexName) throws DotDataException;
+
+ void activateIndex(String indexName) throws DotDataException;
+
+ void deactivateIndex(String indexName) throws DotDataException, IOException;
+
+ boolean createSiteSearchIndex(String indexName, String alias, int shards) throws DotSearchException, IOException;
+
+ boolean setAlias(String indexName, final String alias);
+
+ List getTasks() throws SchedulerException;
+
+ void deleteTask(String taskName) throws SchedulerException;
+
+ void scheduleTask(SiteSearchConfig config) throws SchedulerException, ParseException, ClassNotFoundException;
+
+ void putToIndex(String idx, SiteSearchResult res, String resultType);
+
+ void putToIndex(String idx, List res, String resultType);
+
+ void deleteFromIndex(String idx, String docId);
+
+ SiteSearchResults search(String query, int start, int rows);
+
+ SiteSearchResults search(String indexName, String query, int start, int rows);
+
+ ScheduledTask getTask(String taskName) throws SchedulerException;
+
+ void pauseTask(String taskName) throws SchedulerException;
+
+ SiteSearchPublishStatus getTaskProgress(String jobName) throws SchedulerException;
+
+ boolean isTaskRunning(String jobName) throws SchedulerException;
+
+ void executeTaskNow(SiteSearchConfig config) throws SchedulerException, ParseException, ClassNotFoundException;
+
+ SiteSearchResult getFromIndex(String index, String id);
+
+ Map getAggregations(String indexName, String query) throws DotDataException;
+
+ /***
+ * @deprecated use getAggregations instead
+ */
+ @Deprecated
+ Map getFacets(String indexName, String query) throws DotDataException;
+
+ List listClosedIndices();
+
+ public void deleteOldSiteSearchIndices();
+
+ /**
+ * Deletes a single site-search index by name from every engine that holds it (ES and, during a
+ * migration, its OpenSearch counterpart), mirroring the operator's single-index view. The
+ * active (default) site-search index cannot be deleted — deactivate it first.
+ *
+ * @param indexName the site-search index name (must be a {@code sitesearch_*} name)
+ * @throws DotDataException if the name is not a site-search index or the delete fails
+ * @throws IOException on an index-engine error
+ */
+ void deleteIndex(String indexName) throws DotDataException, IOException;
+}
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_index_stats.jsp b/dotCMS/src/main/webapp/html/portlet/ext/sitesearch/site_search_index_stats.jsp
index 88453690bd6f..3d406aca7e28 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;
@@ -58,6 +61,11 @@ SimpleDateFormat dater = APILocator.getContentletIndexAPI().timestampFormatter;
Map map = esapi.getClusterHealth();
+// Phase-aware default (issue #36983): the legacy IndiciesInfo pointer freezes at the Elasticsearch-era
+// default from Phase 3 on, where activateIndex fans out to OpenSearch alone. Resolved once for every row.
+String defaultSiteSearchIndex = null;
+try { defaultSiteSearchIndex = ssapi.defaultIndexName().orElse(null); } catch (Exception e) { Logger.warn(this.getClass(), "Could not resolve the default site-search index: " + e.getMessage()); }
+
%>
@@ -128,7 +136,7 @@ Map map = esapi.getClusterHealth();
<% ClusterIndexHealth health = map.get(x); if (health == null) { health = map.get(IndexTag.OS.tag(x)); } %>
<% IndexStats status = indexInfo.get(x); if (status == null) { status = indexInfo.get(IndexTag.OS.tag(x)); } %>
- <%boolean active =x.equals(info.getSiteSearch());%>
+ <%boolean active = x.equals(defaultSiteSearchIndex);%>
<% Date d = null;
String myDate = null;
try{
@@ -197,7 +205,7 @@ Map map = esapi.getClusterHealth();
<%-- RIGHT CLICK MENUS --%>
<%for(String x : indices){%>
- <%boolean active =x.equals(info.getSiteSearch());%>
+ <%boolean active = x.equals(defaultSiteSearchIndex);%>
<%ClusterIndexHealth health = map.get(x); %>
<%@page import="com.dotmarketing.beans.Host"%>
<%@page import="com.dotmarketing.business.APILocator"%>
<%@page import="com.dotmarketing.portlets.languagesmanager.model.Language"%>
@@ -16,9 +15,18 @@ 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.
+// 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.getAliasToIndexMapAllEngines().entrySet()) {
+ alias.put(aliasEntry.getValue(), aliasEntry.getKey());
+}
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
SimpleDateFormat tdf = new SimpleDateFormat("HH:mm:ss");
@@ -66,7 +74,10 @@ for(String x : indexHosts){
catch(Exception e){}
}
-boolean hasDefaultIndex = APILocator.getIndiciesAPI().loadIndicies().getSiteSearch() != null;
+// Phase-aware default (issue #36983) — see the note in site_search_index_stats.jsp.
+String defaultSiteSearchIndex = null;
+try { defaultSiteSearchIndex = ssapi.defaultIndexName().orElse(null); } catch (Exception e) { Logger.warn(this.getClass(), "Could not resolve the default site-search index: " + e.getMessage()); }
+boolean hasDefaultIndex = defaultSiteSearchIndex != null;
List langs=APILocator.getLanguageAPI().getLanguages();
@@ -77,7 +88,7 @@ String includeExclude = (String) props.get("includeExclude") ==null ? "all": (St
boolean hasPath = false;
-final String siteSearch = APILocator.getIndiciesAPI().loadIndicies().getSiteSearch();
+final String siteSearch = defaultSiteSearchIndex;
%>
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..312316b43cff 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
@@ -19,7 +19,12 @@ IndiciesInfo info=APILocator.getIndiciesAPI().loadIndicies();
-String testIndex = (request.getParameter("testIndex") == null) ? info.getSiteSearch() : request.getParameter("testIndex");
+// Phase-aware default (issue #36983): from Phase 3 on, activateIndex fans out to OpenSearch alone, so
+// the legacy IndiciesInfo pointer freezes at the Elasticsearch-era default and would preselect a stale
+// index here. Resolved once and reused below for the "(Default)" marker.
+String defaultSiteSearchIndex = null;
+try { defaultSiteSearchIndex = ssapi.defaultIndexName().orElse(null); } catch (Exception e) { Logger.warn(this.getClass(), "Could not resolve the default site-search index: " + e.getMessage()); }
+String testIndex = (request.getParameter("testIndex") == null) ? defaultSiteSearchIndex : request.getParameter("testIndex");
String testQuery = request.getParameter("testQuery");
@@ -56,7 +61,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;
@@ -108,7 +122,7 @@ dojo.connect(dijit.byId("testQuery"), 'onkeypress', function (evt) {
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..970a7c06f6d8 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,19 @@
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.migration.ContentIndexMirrorReconciler.DatabaseCounts;
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 +35,54 @@ 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 reconciler(info, null);
+ }
+
+ /** @param expected the database denominator behind the coverage percentages, or null when absent */
+ private ContentIndexMirrorReconciler reconciler(final IndiciesInfo info,
+ final DatabaseCounts expected) {
+ return new ContentIndexMirrorReconciler(es, os, esOps, osOps, () -> info, () -> expected);
}
/** Both content indices present on both engines with equal counts → two IN_SYNC rows. */
@@ -60,10 +90,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 +117,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 +136,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 +167,124 @@ 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");
+ }
+
+ /**
+ * 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 DatabaseCounts(686L, 685L)).statuses().get(0);
+
+ assertEquals(Long.valueOf(686), working.databaseDocCount());
+ assertEquals(100.0, working.esIndexedPercent(), 0.001);
+ assertEquals(3.06, working.osIndexedPercent(), 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.databaseDocCount());
+ assertNull(working.esIndexedPercent());
+ assertNull(working.osIndexedPercent());
+ 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 DatabaseCounts(686L, 685L)).statuses().get(0);
+
+ assertEquals(100.0, working.osIndexedPercent(), 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
+ * 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());
+ }
}
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
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..4dd70697eb6b 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,8 @@
import com.dotmarketing.exception.DotDataException;
import com.dotmarketing.sitesearch.business.SiteSearchAPI;
import com.dotmarketing.util.Config;
+import java.util.Map;
+import java.util.Optional;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
@@ -233,4 +236,103 @@ 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();
+ }
+
+ // =======================================================================
+ // defaultIndexName — which store owns the "default" pointer (#36983)
+ // =======================================================================
+
+ private static final int PHASE_3_OS_ONLY = 3;
+
+ /** Phases 0/1: Elasticsearch owns the pointer, so the OpenSearch store is not consulted. */
+ @Test
+ public void defaultIndexName_phase1_comesFromElasticsearch() throws Exception {
+ setPhase(PHASE_1_DUAL_WRITE_ES_READS);
+ when(esImpl.defaultIndexName()).thenReturn(Optional.of(IDX));
+
+ assertEquals(Optional.of(IDX), router.defaultIndexName());
+ verify(osImpl, never()).defaultIndexName();
+ }
+
+ /**
+ * Phase 3: OpenSearch owns it. This is the case the fix exists for — {@code activateIndex} fans out
+ * to OpenSearch alone there, so the legacy Elasticsearch pointer freezes at whatever was default in
+ * the Elasticsearch era and every screen reading it shows a stale default.
+ */
+ @Test
+ public void defaultIndexName_phase3_comesFromOpenSearch() throws Exception {
+ setPhase(PHASE_3_OS_ONLY);
+ when(osImpl.defaultIndexName()).thenReturn(Optional.of(OS_ONLY_IDX));
+
+ assertEquals(Optional.of(OS_ONLY_IDX), router.defaultIndexName());
+ verify(esImpl, never()).defaultIndexName();
+ }
+
+ /** No default set anywhere → empty, never a null to dereference. */
+ @Test
+ public void defaultIndexName_noneSet_isEmpty() throws Exception {
+ setPhase(PHASE_3_OS_ONLY);
+ when(osImpl.defaultIndexName()).thenReturn(Optional.empty());
+
+ assertTrue(router.defaultIndexName().isEmpty());
+ }
}
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..34c344f4487f
--- /dev/null
+++ b/dotCMS/src/test/java/com/dotcms/publishing/job/SiteSearchJobAliasResolutionTest.java
@@ -0,0 +1,233 @@
+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.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;
+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 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;
+
+/**
+ * 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.getAliasToIndexMapAllEngines()).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.getAliasToIndexMapAllEngines()).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.getAliasToIndexMapAllEngines()).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.getAliasToIndexMapAllEngines()).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());
+ }
+
+ // =======================================================================
+ // 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_INDEXED_KEY, "0");
+ try {
+ assertFalse(jobSeeing(contentRow(686L, 686, 21))
+ .incompleteContentIndexWarning().isPresent());
+ } finally {
+ Config.setProperty(SiteSearchJobImpl.MIN_CONTENT_INDEXED_KEY, null);
+ Config.setProperty(MigrationPhase.FLAG_KEY, null);
+ }
+ }
+}
\ 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 {