Skip to content

Commit b4216cb

Browse files
fix(opensearch): stop Phase 3 reindex from orphaning ES rows in indicies table (#36077) (#36191)
## Proposed Changes Fixes #36077. During OpenSearch migration **Phase 3** (OS primary, ES decommissioned), a full reindex left stale ES-side rows in the `indicies` table — the old `live`/`working` pair plus the transient `reindex_live`/`reindex_working` pair (all NULL version) — alongside the correctly-promoted `.os` (v3.X) pair. The table accumulated **6 rows instead of 2** on every Phase-3 reindex. ### Root cause An asymmetry in `ContentletIndexAPIImpl`: - `initAndPointReindex` wrote the legacy ES store **unconditionally** (`pointES` had no phase gate), so a Phase-3 reindex still inserted ES `reindex_*` rows and re-persisted the old ES `live`/`working` rows. - The Phase-3 switchover (`fullReindexSwitchoverOS`) only touches the OS store (`versionedIndicesAPI`) — it never promotes or clears those ES rows. So they orphaned. ### The fix (DB-only — never contacts the ES cluster, which may be down in Phase 3) 1. **Gate `pointES(...)`** in `initAndPointReindex` on `!isMigrationComplete()` so a Phase-3 reindex no longer writes ES pointers (mirrors the existing `isMigrationStarted()` gate on the OS write). 2. **Add `VersionedIndicesAPI.removeLegacyContentIndices()`** (delegating to `IndicesFactory`): deletes the NULL-version `live`/`working`/`reindex_live`/`reindex_working` rows, **preserves** the unmigrated `site_search` pointer and all OS (non-NULL version) rows, and flushes the index caches. Invoked best-effort by `fullReindexSwitchoverOS` after promoting OS (a housekeeping failure must not undo a successful switchover). **Physical index deletion is intentionally out of scope** — in Phase 3 ES may not be running, so touching the cluster is fragile. Orphaned physical indices are cleaned by the existing scheduled `DeleteInactiveLiveWorkingIndicesJob`. ### Why an explicit cleanup — and why earlier phases never needed one The legacy ES `reindex_*` rows were never purged by a dedicated call. In the classic ES path (phases 0–2) the cleanup is an **emergent property of `IndiciesFactory.point()`**: `fullReindexSwitchover()` rebuilds the entire `indicies` table from a *fresh* `IndiciesInfo.Builder` that sets only `working`/`live`/`site_search` and leaves `reindex_*` null, so `point()`'s delete-then-conditional-insert drops the reindex rows for free — and overwrites the old `working`/`live` rows in the same pass. The Phase-3 switchover (`fullReindexSwitchoverOS`) does **not** go through that full-table rebuild — it writes only the OS/versioned rows via `versionedIndicesAPI`, so the implicit cleanup that every earlier phase gets for free simply never runs and the legacy ES rows orphan. `removeLegacyContentIndices()` is the explicit equivalent of that implicit `point()` sweep, scoped to the OS-only path. It is therefore *restoring symmetry of outcome* (a clean 2-row table) across two structurally different switchover paths — not introducing a new, riskier deletion. **Scope guard:** the delete is intentionally narrow (`index_version IS NULL` + content-index types only), so it can never touch OS (non-NULL version) rows nor the still-unmigrated `site_search` pointer. The phase protection currently lives at the *call site* (`removeLegacyContentIndices()` is only invoked from `fullReindexSwitchoverOS`, which runs only in Phase 3); the method itself has no internal phase guard. ## Acceptance criteria - [x] After a Phase-3 reindex, no leftover ES `live`/`working` (NULL version) rows. - [x] Transient `reindex_live`/`reindex_working` rows removed. - [ ] Physical indices deleted — deferred to `DeleteInactiveLiveWorkingIndicesJob` (see scope note above). - [x] Re-running reindex in Phase 3 does not accumulate orphan rows. - [x] Integration coverage asserts the final `indicies` table state. ## Testing - `ContentletIndexAPIImplMigrationIntegrationTest#test_phase3_removeLegacyContentIndices_purgesEsRowsPreservesSiteSearchAndOs` — seeds the issue's exact 6-row orphan state and asserts the legacy ES content rows are purged while `site_search` and OS rows survive. - `ContentletIndexAPIImplPhaseTest` — fake updated for the new API method. - `./mvnw test-compile -pl :dotcms-core,:dotcms-integration` passes on JDK 25. > Note: the new IT still needs a run against a live OpenSearch Phase-3 environment (`opensearch-upgrade` profile) — a full-reindex E2E was deliberately avoided due to the known `inferIndexToHit` gap (#36054); the test validates the cleanup guarantee deterministically at the DB level. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c5ca97d commit b4216cb

8 files changed

Lines changed: 304 additions & 28 deletions

File tree

dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImpl.java

Lines changed: 82 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1092,10 +1092,14 @@ private void initAndPointReindex(final String ts) throws DotDataException {
10921092
"Error creating reindex indices for ts=" + ts + ": " + e.getMessage(), e);
10931093
}
10941094
// ── LEGACY ES — remove after Phase 3 migration ────────────────────────
1095-
// Persist reindex slots to the legacy ES store, preserving existing working/live pointers
1096-
pointES(null, null,
1097-
operationsES.toPhysicalName(reindexWorkingName),
1098-
operationsES.toPhysicalName(reindexLiveName));
1095+
// Persist reindex slots to the legacy ES store, preserving existing working/live pointers.
1096+
// Skipped in Phase 3: ES is decommissioned, so writing ES reindex pointers here would only
1097+
// create orphan NULL-version rows that the OS-only switchover never cleans up (#36077).
1098+
if (!isMigrationComplete()) {
1099+
pointES(null, null,
1100+
operationsES.toPhysicalName(reindexWorkingName),
1101+
operationsES.toPhysicalName(reindexLiveName));
1102+
}
10991103
// ── END LEGACY ES ─────────────────────────────────────────────────────
11001104

11011105
// Persist OS reindex slots, preserving existing working/live pointers.
@@ -1221,33 +1225,38 @@ public synchronized boolean fullReindexSwitchover(Connection conn, final boolean
12211225
// ── OS mirror (Phases 1 and 2, best-effort) ──────────────────────
12221226
// The OS shadow index has its own reindex slots that must be promoted
12231227
// in lock-step with ES. A failure here must never abort the ES result.
1228+
List<String> newActiveOs = List.of();
12241229
if (isMigrationStarted()) {
12251230
try {
12261231
final Optional<VersionedIndices> osExisting =
12271232
versionedIndicesAPI.loadDefaultVersionedIndices();
1233+
final Optional<String> osWorking =
1234+
osExisting.flatMap(VersionedIndices::reindexWorking);
1235+
final Optional<String> osLive =
1236+
osExisting.flatMap(VersionedIndices::reindexLive);
12281237
final VersionedIndicesImpl.Builder osBuilder = VersionedIndicesImpl.builder();
12291238
// Promote OS reindex slots → active; omitting reindexWorking/reindexLive
12301239
// from the builder clears them to Optional.empty() in the store.
1231-
osExisting.flatMap(VersionedIndices::reindexWorking).ifPresent(osBuilder::working);
1232-
osExisting.flatMap(VersionedIndices::reindexLive).ifPresent(osBuilder::live);
1240+
osWorking.ifPresent(osBuilder::working);
1241+
osLive.ifPresent(osBuilder::live);
12331242
versionedIndicesAPI.saveIndices(osBuilder.build());
1243+
// Capture the promoted OS names (.os-tagged, from the OS store) so they can be
1244+
// optimized on the OS provider directly — see optimizeNewActiveIndicesAsync.
1245+
if (osWorking.isPresent() && osLive.isPresent()) {
1246+
newActiveOs = List.of(osWorking.get(), osLive.get());
1247+
}
12341248
} catch (Exception osEx) {
12351249
Logger.warn(this, "Could not mirror reindex switchover to OS store", osEx);
12361250
}
12371251
}
12381252

1239-
// Async: merge ES index segments and expand replicas on the newly active indices.
1240-
// Uses newInfo (ES reindex → active names), not oldInfo (old active names).
1241-
final List<String> newActiveEs = List.of(newInfo.getWorking(), newInfo.getLive());
1242-
DotConcurrentFactory.getInstance().getSubmitter().submit(() -> {
1243-
try {
1244-
Logger.info(this.getClass(), "Updating and optimizing ElasticSearch Indexes");
1245-
optimize(newActiveEs);
1246-
} catch (Exception e) {
1247-
Logger.warnAndDebug(this.getClass(),
1248-
"unable to expand ES replicas:" + e.getMessage(), e);
1249-
}
1250-
});
1253+
// Async: merge index segments and expand replicas on the newly active indices,
1254+
// optimizing EACH provider with the names it actually holds — ES bare names from
1255+
// newInfo, OS .os-tagged names from the store. Routing through the phase-aware
1256+
// optimize() would send the ES names to the OS read provider in Phase 2 and hit
1257+
// index_not_found (then fall back to ES), which is noisy and skips the OS optimize.
1258+
optimizeNewActiveIndicesAsync(
1259+
List.of(newInfo.getWorking(), newInfo.getLive()), newActiveOs);
12511260

12521261
notifyAdminsOfFailedReindex();
12531262

@@ -1314,24 +1323,70 @@ private boolean fullReindexSwitchoverOS(final boolean forceSwitch) throws Except
13141323
existing.reindexLive().ifPresent(osBuilder::live);
13151324
versionedIndicesAPI.saveIndices(osBuilder.build());
13161325

1326+
// Purge leftover legacy ES content-index rows (NULL version) from the indicies table:
1327+
// old live/working plus any transient reindex_live/reindex_working that predate Phase 3.
1328+
// ES is decommissioned, so these rows are pure orphans — without this the table would
1329+
// accumulate stale ES rows on every Phase-3 reindex (#36077). DB-only: never contacts the
1330+
// ES cluster (which may be down). Best-effort — the OS promotion above already succeeded
1331+
// and must not be undone by a housekeeping failure. Physical ES index deletion is left to
1332+
// the scheduled DeleteInactiveLiveWorkingIndicesJob.
1333+
try {
1334+
versionedIndicesAPI.removeLegacyIndices();
1335+
} catch (Exception cleanupEx) {
1336+
Logger.warn(this, "Phase 3 switchover: could not purge legacy ES indicies rows", cleanupEx);
1337+
}
1338+
13171339
// Async: optimize the newly active OS indices (merge segments, adjust replicas).
13181340
// reindexWorking/reindexLive presence was validated at the top of this method.
1341+
// ES is decommissioned in Phase 3, so only the OS provider is optimized.
13191342
final String newWorking = existing.reindexWorking().orElseThrow();
13201343
final String newLive = existing.reindexLive().orElseThrow();
1321-
DotConcurrentFactory.getInstance().getSubmitter().submit(() -> {
1322-
try {
1323-
Logger.info(this.getClass(), "Updating and optimizing OpenSearch Indexes");
1324-
optimize(List.of(newWorking, newLive));
1325-
} catch (Exception e) {
1326-
Logger.warnAndDebug(this.getClass(),
1327-
"unable to optimize OS indices:" + e.getMessage(), e);
1328-
}
1329-
});
1344+
optimizeNewActiveIndicesAsync(List.of(), List.of(newWorking, newLive));
13301345

13311346
notifyAdminsOfFailedReindex();
13321347
return true;
13331348
}
13341349

1350+
/**
1351+
* Optimizes (force-merges) the newly-promoted indices after a reindex switchover, targeting
1352+
* each provider with the names it actually holds: ES with its bare names, OS with its
1353+
* {@code .os}-tagged names. Both calls go to the vendor implementation directly
1354+
* ({@code operationsES/OS.indexAPI()}) rather than the phase-aware {@code optimize()} router,
1355+
* because the router routes optimize through the read provider — in Phase 2 that is OS, so the
1356+
* ES names would be sent to OS, miss (the OS index carries the {@code .os} tag), and only
1357+
* succeed via the ES fallback while logging a misleading error and skipping the OS optimize.
1358+
*
1359+
* <p>Runs asynchronously and is best-effort per provider: a force-merge failure (including an
1360+
* OS shadow failure in dual-write phases) is logged and swallowed and never affects the
1361+
* completed switchover.</p>
1362+
*
1363+
* @param esNames ES physical (bare) names to optimize, or empty to skip ES (e.g. Phase 3)
1364+
* @param osNames OS physical ({@code .os}-tagged) names to optimize, or empty to skip OS
1365+
*/
1366+
private void optimizeNewActiveIndicesAsync(final List<String> esNames,
1367+
final List<String> osNames) {
1368+
DotConcurrentFactory.getInstance().getSubmitter().submit(() -> {
1369+
if (esNames != null && !esNames.isEmpty()) {
1370+
try {
1371+
Logger.info(this.getClass(), "Updating and optimizing ElasticSearch Indexes");
1372+
operationsES.indexAPI().optimize(esNames);
1373+
} catch (Exception e) {
1374+
Logger.warnAndDebug(this.getClass(),
1375+
"unable to optimize ES indices:" + e.getMessage(), e);
1376+
}
1377+
}
1378+
if (osNames != null && !osNames.isEmpty()) {
1379+
try {
1380+
Logger.info(this.getClass(), "Updating and optimizing OpenSearch Indexes");
1381+
operationsOS.indexAPI().optimize(osNames);
1382+
} catch (Exception e) {
1383+
Logger.warnAndDebug(this.getClass(),
1384+
"unable to optimize OS indices:" + e.getMessage(), e);
1385+
}
1386+
}
1387+
});
1388+
}
1389+
13351390
/**
13361391
* Sends a system-wide warning message to CMS administrators if any content documents
13371392
* failed to be reindexed during the last full-reindex run.

dotCMS/src/main/java/com/dotcms/content/index/IndexAPIImpl.java

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,29 @@ public OSIndexAPIImpl osImpl() {
131131

132132
@Override
133133
public boolean optimize(final List<String> indexNames) {
134-
return router.read(impl -> impl.optimize(indexNames));
134+
// Tag-dispatch: each index name declares its owning provider via its vendor tag
135+
// (OS names carry the .os suffix; untagged names are legacy ES). The list handed in
136+
// comes from getIndices(), which in dual-write phases aggregates BOTH providers, so a
137+
// mixed list (ES bare names + OS .os-tagged names) routed to a single provider via
138+
// router.read() would hit index_not_found_exception on the foreign-tagged names —
139+
// the exact Phase 2 optimize misrouting reported against the /optimize endpoint.
140+
// Force-merge each provider only with the names it actually holds; skip a provider
141+
// when its subset is empty so Phase 0 never contacts OS and Phase 3 never contacts ES.
142+
if (indexNames == null || indexNames.isEmpty()) {
143+
return true;
144+
}
145+
final Map<IndexTag, List<String>> byVendor = indexNames.stream()
146+
.collect(Collectors.groupingBy(IndexTag::resolve));
147+
boolean result = true;
148+
final List<String> esNames = byVendor.getOrDefault(IndexTag.ES, List.of());
149+
if (!esNames.isEmpty()) {
150+
result &= router.esImpl().optimize(esNames);
151+
}
152+
final List<String> osNames = byVendor.getOrDefault(IndexTag.OS, List.of());
153+
if (!osNames.isEmpty()) {
154+
result &= router.osImpl().optimize(osNames);
155+
}
156+
return result;
135157
}
136158

137159
@Override

dotCMS/src/main/java/com/dotcms/content/index/IndicesFactory.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,21 @@ public interface IndicesFactory {
132132
void insertIndexIfPresent(String indexName, String indexType,
133133
String version) throws DotDataException;
134134

135+
/**
136+
* Removes the legacy ElasticSearch content-index pointers (NULL {@code index_version})
137+
* from the {@code indicies} table: the {@code live} / {@code working} pair and the transient
138+
* {@code reindex_live} / {@code reindex_working} pair.
139+
*
140+
* <p>Intended for Phase 3 (OpenSearch-only) cleanup, where ES is decommissioned and these
141+
* NULL-version rows are pure orphans. The {@code site_search} pointer — also stored
142+
* NULL-versioned but NOT part of the content-index migration — is deliberately preserved.
143+
* OS rows (which carry a non-NULL {@code index_version}) are never touched.</p>
144+
*
145+
* @return the number of rows removed
146+
* @throws DotDataException if a SQL error occurs
147+
*/
148+
int removeLegacyIndices() throws DotDataException;
149+
135150
/**
136151
* Validates that every present index name in {@code indicesInfo} carries the
137152
* {@link IndexTag#OS} marker ({@code .os} suffix) required for DB storage.

dotCMS/src/main/java/com/dotcms/content/index/IndicesFactoryImpl.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ public class IndicesFactoryImpl implements IndicesFactory {
4343
"SELECT COUNT(*) as count FROM indicies WHERE index_version = ? AND index_version IS NOT NULL";
4444
private static final String COUNT_INDICES_BY_VERSION_SQL =
4545
"SELECT COUNT(*) as count FROM indicies WHERE index_version = ? AND index_version IS NOT NULL";
46+
// Phase 3 cleanup: remove the legacy ES content-index pointers (NULL version) while
47+
// preserving the still-unmigrated site_search pointer. OS rows carry a non-NULL version.
48+
private static final String DELETE_LEGACY_CONTENT_INDICES_SQL =
49+
"DELETE FROM indicies WHERE index_version IS NULL " +
50+
"AND index_type IN ('live', 'working', 'reindex_live', 'reindex_working')";
4651

4752
@Override
4853
public Optional<VersionedIndices> loadIndices(String version) throws DotDataException {
@@ -168,6 +173,20 @@ public void removeVersion(String version) throws DotDataException {
168173
}
169174
}
170175

176+
@Override
177+
public int removeLegacyIndices() throws DotDataException {
178+
try {
179+
final DotConnect dotConnect = new DotConnect();
180+
final int deletedRows = dotConnect.executeUpdate(DELETE_LEGACY_CONTENT_INDICES_SQL);
181+
Logger.info(this, "Removed " + deletedRows
182+
+ " legacy ES content-index row(s) from indicies (Phase 3 cleanup)");
183+
return deletedRows;
184+
} catch (Exception e) {
185+
Logger.error(this, "Failed to remove legacy ES content-index rows", e);
186+
throw new DotDataException("Failed to remove legacy ES content-index rows", e);
187+
}
188+
}
189+
171190
@Override
172191
public boolean versionExists(String version) throws DotDataException {
173192
if (!UtilMethods.isSet(version)) {

dotCMS/src/main/java/com/dotcms/content/index/VersionedIndicesAPI.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,21 @@ public interface VersionedIndicesAPI {
104104
*/
105105
Optional<VersionedIndices> loadDefaultVersionedIndices() throws DotDataException;
106106

107+
/**
108+
* Removes the legacy ElasticSearch content-index pointers (NULL {@code index_version}) from
109+
* the {@code indicies} table — the {@code live} / {@code working} and transient
110+
* {@code reindex_live} / {@code reindex_working} rows — and flushes the index caches.
111+
*
112+
* <p>Intended for Phase 3 (OpenSearch-only) reindex cleanup: ES is decommissioned and these
113+
* rows are orphans. The unmigrated {@code site_search} pointer and all OS (non-NULL version)
114+
* rows are preserved. This is a DB-only operation — it never contacts the ES cluster, which
115+
* may not be running in Phase 3.</p>
116+
*
117+
* @return the number of rows removed
118+
* @throws DotDataException if a SQL error occurs
119+
*/
120+
int removeLegacyIndices() throws DotDataException;
121+
107122
/**
108123
* Clears all cached indices data.
109124
* This should be called when indices are modified outside of this API

dotCMS/src/main/java/com/dotcms/content/index/VersionedIndicesAPIImpl.java

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,33 @@ public Optional<VersionedIndices> loadDefaultVersionedIndices() throws DotDataEx
228228
return loadIndices(VersionedIndices.OPENSEARCH_3X);
229229
}
230230

231+
/**
232+
* {@inheritDoc}
233+
*/
234+
@WrapInTransaction
235+
@Override
236+
public int removeLegacyIndices() throws DotDataException {
237+
// Defense in depth: this purges the legacy ES content-index pointers (NULL version) and is
238+
// only safe once ES is decommissioned (Phase 3). The sole caller (fullReindexSwitchoverOS)
239+
// already runs only in Phase 3, but guard here too so a stray call in an earlier phase can
240+
// never delete the still-active ES live/working rows.
241+
if (!IndexConfigHelper.isMigrationComplete()) {
242+
Logger.warn(this, "removeLegacyIndices() called outside Phase 3 "
243+
+ "(PHASE_3_OPENSEARCH_ONLY); skipping — the legacy ES live/working rows are "
244+
+ "still active in this phase.");
245+
return 0;
246+
}
247+
final int removed = indicesFactory.removeLegacyIndices();
248+
// Flush all index-related caches so no stale legacy names survive the deletion:
249+
// 1. VersionedIndicesCache — our own versioned-index cache
250+
cache.clearCache();
251+
// 2. IndiciesCache — legacy (ES, non-versioned) index cache used by IndiciesFactory
252+
CacheLocator.getIndiciesCache().clearCache();
253+
// 3. ESQueryCache — cached search queries that may reference a deleted index name
254+
CacheLocator.getESQueryCache().clearCache();
255+
return removed;
256+
}
257+
231258
/**
232259
* {@inheritDoc}
233260
*/

dotCMS/src/test/java/com/dotcms/content/elasticsearch/business/ContentletIndexAPIImplPhaseTest.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -576,12 +576,19 @@ public void point(final IndiciesInfo newInfo) {
576576
static class FakeVersionedIndicesAPI implements VersionedIndicesAPI {
577577

578578
VersionedIndices stored = null;
579+
int removeLegacyIndicesCalls = 0;
579580

580581
@Override
581582
public Optional<VersionedIndices> loadDefaultVersionedIndices() {
582583
return Optional.ofNullable(stored);
583584
}
584585

586+
@Override
587+
public int removeLegacyIndices() {
588+
removeLegacyIndicesCalls++;
589+
return 0;
590+
}
591+
585592
@Override
586593
public void saveIndices(final VersionedIndices info) {
587594
this.stored = info;

0 commit comments

Comments
 (0)