Skip to content

feat(migration): internal migration-readiness endpoint (content + site search) (#36360) - #36849

Merged
fabrizzio-dotCMS merged 13 commits into
issue-36360-sitesearch-mirror-reconciliationfrom
issue-36360-migration-readiness-endpoint
Aug 4, 2026
Merged

feat(migration): internal migration-readiness endpoint (content + site search) (#36360)#36849
fabrizzio-dotCMS merged 13 commits into
issue-36360-sitesearch-mirror-reconciliationfrom
issue-36360-migration-readiness-endpoint

Conversation

@fabrizzio-dotCMS

@fabrizzio-dotCMS fabrizzio-dotCMS commented Jul 31, 2026

Copy link
Copy Markdown
Member

Problem

Support/QA had no single place to answer "is it safe to change the OpenSearch migration phase right now, and if not, what do I fix?" before promoting. QA Round 2 on #36360 showed the failure mode: promoting a phase while an index's ES and OpenSearch copies were out of sync led to silent empty/partial results, and the downgrade case (Phase-3-only content invisible after a rollback) was undocumented.

This adds an internal, read-only migration-readiness endpoint that condenses that status with actionable recommendations, and retires the role-gated .os reveal in the index portlets in favor of it.

What this adds

GET /api/v1/index/migration/readiness — internal, read-only, advisory. Never mutates anything; the fix is always the operator re-running the crawl/reindex, which self-heals through the existing write-path gates (#36797 read half, #36825 write half).

  • Not public. @Hidden (absent from the OpenAPI / API-playground schema) and gated to CMS administrators or members of the migration support role (OS_MIGRATION_INDEX_VISIBILITY_ROLE_KEY, default os_migration_qa); anyone else gets a 403.
  • Covers both mirrored families — versioned content indices (working/live) and Site Search indices. content is a keyed object (WORKING / LIVE — a fixed pair); siteSearch is a list (an open set). Each entry is a per-index ES↔OS diff: {indexName, es:{exists,docCount,physicalName}, os:{exists,docCount,physicalName}, verdict, recommendation}, verdict IN_SYNC / MISSING_COUNTERPART / COUNT_DRIFT. physicalName is the full name as stored on each server (cluster-prefixed; .os-tagged on OpenSearch), and the report carries the top-level clusterId.
  • Overall verdict: current phase + read/write engines + a dualWrite flag; safeToAdvance (toward OpenSearch-only) and safeToRollback (downgrade) with outOfSyncCount, a human summary, and per-index blockers. Field order: clusterId, phase, content, siteSearch, verdict.
  • Bare model response — returns the readiness object directly (no ResponseEntityView envelope), serialized by Jackson from typed records.
  • Stateless, exact counts. Every field derived at request time. The Site Search half uses SiteSearchAPI.documentCount; the content half reads each engine leaf's getIndicesStats() (index _stats primaries.docs.count) — never a search total (capped at 10,000, which would hide drift on large indices). Both reconcilers query the two engine leaves directly (not the phase-aware router), so both sides show in every phase.
  • 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 — derivable from the same snapshot, nothing persisted.

Teardown — index portlet visibility is now phase-only (reverts I-4). MigrationIndexVisibility no longer reveals .os by role: .os indices are hidden in Phases 0/1/2 and shown in Phase 3, for everyone. The role key is retained only to gate this endpoint, which is now the single source of truth for migration/QA.

Layers

  • com.dotcms.content.index.migration: MirrorStatus (shared per-index diff), SiteSearchMirrorReconciler, ContentIndexMirrorReconciler, MigrationReadinessService (composition + verdict), MigrationReadiness (report DTO). Pure, no mutation.
  • com.dotcms.rest.api.v1.index.MigrationReadinessResource: the JAX-RS resource + role gate.

Tests — 31 unit green

  • MigrationReadinessServiceTest (16): advance/rollback verdicts across phases 0–3, missing-counterpart and >10k count-drift blocking advance, content-keyed-by-slot / siteSearch-as-list, clusterId, driftPercent, the phase-aware outOfSyncCount (Phase 0 expected-missing excluded, other mismatches still counted), and rollback fail-safe on unmeasurable counts.
  • ContentIndexMirrorReconcilerTest (5): in-sync, missing OS counterpart, count drift, null/absent slots, physical names.
  • MigrationReadinessResourceTest (5): role gate — admin/role-member allowed, non-admin-without-role denied, null user denied, access-lookup failure fails closed.
  • MigrationIndexVisibilityTest (5): rewritten to the phase-only contract.

Remaining test (flagging): a container integration test that drives HTTP 403/200 through the JAX-RS stack is not included yet — the gate decision and both reconcilers are unit-covered. Happy to add it if you'd like it before merge.

Notes

🤖 Generated with Claude Code

This PR fixes: #36360

fabrizzio-dotCMS and others added 3 commits July 31, 2026 10:37
…+ Site Search half (#36360)

Internal, non-public pre-phase-change readiness report for support:

- GET /api/v1/index/migration/readiness — @hidden (absent from the OpenAPI /
  API playground) and gated to CMS admins or the migration support role
  (OS_MIGRATION_INDEX_VISIBILITY_ROLE_KEY, default os_migration_qa); 403 otherwise.
  Read-only, stateless — every field derived from live index state at request time.

- MigrationReadiness (report DTO): current phase + read/write engines + evaluable
  flag; overall verdict (safeToAdvance / safeToRollback / outOfSyncCount / summary /
  per-index blockers); and the per-index ES↔OS mirror diff for both mirrored families.

- MirrorStatus (shared per-index diff: kind, es/os existence + exact counts, verdict
  IN_SYNC/MISSING_TWIN/COUNT_DRIFT, recommendation).

- SiteSearchMirrorReconciler (recreated from the lost PR3 work) now uses the exact
  SiteSearchAPI.documentCount (not a 10k-capped search total), so drift on large
  indices is reported. ContentIndexMirrorReconciler is stubbed for step b.

- MigrationReadinessService composes phase + both reconcilers into the verdict:
  advance is gated on zero out-of-sync in dual-write phases; rollback is unsafe when
  any index's ES copy is behind its OS twin (a downgrade would drop that delta) —
  derived from live counts, no persisted state.

Unit test (mocked reconcilers, phase via Config): 7/7 — advance/rollback verdicts
across phases 0–3, missing-twin and >10k count-drift blocking advance.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…36360)

Step b of the migration-readiness endpoint (#36360):

- ContentIndexMirrorReconciler now real (was a stub): compares the active working
  and live content indices against their .os counterparts across both engines,
  phase-independently. IndiciesInfo holds the cluster-prefixed, un-tagged ES name;
  exact per-engine counts come from each leaf's getIndicesStats() (_stats
  primaries.docs.count, not the 10k-capped search total), keyed by the
  cluster-stripped name (ES bare, OS with .os) — strip-then-tag to match. Emits
  CONTENT_WORKING / CONTENT_LIVE rows with the same missing-counterpart / count-drift
  verdicts and a reindex recommendation. Reads leaves directly, never the router.

- Terminology: renamed "twin" -> "counterpart" across the new readiness code
  (verdict MISSING_TWIN -> MISSING_COUNTERPART); "mirror" (the feature name) kept.

Unit tests: ContentIndexMirrorReconcilerTest (mocked leaves + injected IndiciesInfo)
5/5 — in-sync, missing OS counterpart, count drift, null/absent slots; service test
still 7/7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… test + docs (#36360)

Step c of the migration-readiness endpoint (#36360):

- Teardown of the role-gated .os reveal in the index portlets (reverts I-4).
  MigrationIndexVisibility is now purely phase-based: .os indices are hidden in
  Phases 0/1/2 and shown in Phase 3, for EVERYONE — regular admins never learn a
  migration is running. The role key is retained only to gate the readiness endpoint,
  which is now the single source of truth for migration/QA. Both display sinks
  (IndexResourceHelper.indexStatsList, cmsmaintenance/index_stats.jsp) drop the user
  argument; ESIndexResource updated accordingly.

- Gate coverage: MigrationReadinessResource.isMigrationSupportUser made package-private
  and unit-tested (CMS admin / role member allowed, non-admin-without-role denied,
  null user denied, access-lookup failure fails closed) — 5/5.

- MigrationIndexVisibilityTest rewritten to the phase-only contract — 5/5.

- OPENSEARCH_MIGRATION.md: new "Migration-readiness endpoint" subsection (route,
  @hidden + role gate, what it reports, exact counts, stateless rollback verdict) and
  note that the portlets no longer reveal .os by role.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Claude finished @fabrizzio-dotCMS's task in 11m 34s —— View job


Code Review — migration-readiness endpoint

Reviewed the readiness layers (MigrationReadinessService, MirrorStatus, both reconcilers, MigrationReadinessResource), the visibility teardown, and the tests. Verified against the three prior findings addressed in e6ae14ce.

New Issues

  • 🟡 Medium: dotCMS/src/main/java/com/dotcms/content/index/migration/ContentIndexMirrorReconciler.java:66-67 — the ES/OS stats reads are unguarded, and the failure modes are asymmetric. osImpl.getIndicesStats() (OSIndexAPIImpl:475-478) catches everything and returns an empty map, so a down OpenSearch degrades to "OS copy missing". But esImpl.getIndicesStats() (ESIndexAPI:133-165) has no try/catch: it does ((Map)jsonMap.get("indices")).forEach(...) with no null check, so an unreachable/erroring Elasticsearch throws (NPE or the underlying request exception) straight through statuses()evaluate() → the resource, which returns the model directly with no catch → HTTP 500. This reconciler deliberately made index-name loading fail-safe (loadIndiciesQuietly, line 116-121) but not the count read next to it — so the one endpoint an operator reaches for while a cluster is unhealthy can hard-fail on exactly that condition instead of reporting a partial diff. The Site Search half has the same shape: documentCount is internally guarded (returns -1), but esImpl.listIndices() / existsOnAllWriteEngines() in SiteSearchMirrorReconciler:53,63 are not. Assumption: ES getIndicesStats()/listIndices() throw when the ES cluster is unreachable. What to verify: whether that's the real behavior in your deployment — if so, wrap each stats/list read in a Try-with-warn (mirroring loadIndiciesQuietly and the OS side) so a single-engine outage yields a degraded report rather than a 500. Fix this →

Resolved

  • SiteSearchMirrorReconcilercanEvaluate() dead code / phantom evaluable field removed; no remaining references in main or test.
  • MigrationReadinessService.java:63-65,152-159outOfSyncCount is now phase-aware via needsAttentionIn(phase, status); Phase 0 excludes the expected not-yet-built OS counterpart while still counting an orphan OS copy or a two-copy drift. Covered by phase0_missingOsCounterparts_notCountedAsOutOfSync and phase0_unexpectedMismatch_stillCountedAsOutOfSync.
  • MigrationReadinessService.java:169-173safeToRollback no longer greens on an unmeasurable count: blocksRollback treats docCount() < 0 on either engine as unsafe before the numeric compare, so es=100 / os=-1 no longer resolves through 100 < -1. Covered by unknownOsCount_notSafeToRollback / unknownEsCount_notSafeToRollback.

Notes (non-blocking, not flagged)

  • The role gate correctly fails closed (isMigrationSupportUser returns false on any thrown lookup), requires admin and role, and @Hidden keeps it off the OpenAPI schema — matches the PR's stated contract.
  • IndexResourceHelper.indexStatsList() / ESIndexResource dropping the User parameter is consistent with the phase-only visibility teardown; MigrationIndexVisibility.filter no longer consults a user.
  • The Phase 0 leftover-index summary note assumes "left over on OpenSearch", but a two-copy count-drift row (also counted) would render the same wording slightly imprecisely — cosmetic only.
  • As the PR body already flags, there's no container-level integration test driving 403/200 through the JAX-RS stack; the gate and reconcilers are unit-covered.

No blocking issues. The one Medium is the error-path asymmetry above — worth confirming before merge given this endpoint's role during migration troubleshooting.

· issue-36360-migration-readiness-endpoint

…#36360)

Readiness JSON now reads es:{exists,docCount} / os:{exists,docCount} per index
instead of flat esExists/esDocCount/osExists/osDocCount. MirrorStatus gains a nested
EngineCopy record; reconcilers, service verdict, and unit tests updated. 22/22 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fabrizzio-dotCMS and others added 2 commits July 31, 2026 13:30
…Id (#36360)

Each per-index row now carries the full name as stored on the server in each
engine's EngineCopy.physicalName (ES cluster-prefixed, OS additionally .os-tagged),
and the report carries the top-level clusterId embedded in those names. Cluster
prefix/id are injected (suppliers) so unit tests stay isolated. 22/22 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ss fields (#36360)

- Return the readiness model directly (Response.ok(model)) instead of wrapping it in
  ResponseEntityView — the internal endpoint has no use for the errors/messages/
  pagination/permissions envelope.
- Field order is now clusterId, phase, content, siteSearch, verdict.
- Renamed contentIndices -> content, siteSearchIndices -> siteSearch.

22/22 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…JSON (#36360)

- content and siteSearch are now JSON objects, not arrays: content keyed by slot
  (WORKING/LIVE), siteSearch keyed by logical index name — self-documenting and
  directly addressable.
- kind is excluded from the payload (@JsonIgnoreProperties on MirrorStatus); it is
  kept internally only to derive the content slot key and the blocker labels.

23/23 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…y slot (#36360)

Site Search is an open set with no natural key, so keying it by index name only
duplicated indexName. It reverts to a list; content stays a keyed object (WORKING/
LIVE — a fixed pair). The asymmetry mirrors the semantics. 23/23 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@fabrizzio-dotCMS
fabrizzio-dotCMS marked this pull request as ready for review July 31, 2026 20:51
fabrizzio-dotCMS and others added 2 commits August 3, 2026 11:24
…e content indices (#36360)

An empty content-index set made the readiness verdict pass vacuously
(outOfSync empty -> safeToAdvance true). Treat a missing/ES-copy-less
WORKING or LIVE slot as a hard blocker in phases 0/1/2; Site Search stays
optional. Also rename PhaseInfo.evaluable -> dualWrite for clarity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…endpoint (#36360)

The gate was admin OR role; a plain CMS admin could read the migration report. Change
to admin AND the migration support role (OS_MIGRATION_INDEX_VISIBILITY_ROLE_KEY) — a
plain admin without the role is now denied, so regular/other admin users never learn a
migration is running. Fail-closed on any access-lookup error. Gate test updated to the
AND semantics (5/5); docs aligned (also fixes a stale evaluable->dualWrite mention).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@mergify

mergify Bot commented Aug 3, 2026

Copy link
Copy Markdown

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request

fabrizzio-dotCMS and others added 2 commits August 3, 2026 20:42
…36360)

Each index now carries driftPercent: the signed % the OpenSearch (mirror) doc count
deviates from the Elasticsearch (original) — 0 when equal, negative when the mirror is
behind, positive when ahead, -100 for a missing mirror, null when a count is unknown.
Derived on MirrorStatus and serialized via @JsonProperty. Tests assert -20% drift and
-100% missing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…dpoint (#36360)

@Schema on MirrorStatus.driftPercent explains how to read the signed % (0 in sync,
negative = mirror behind, positive = ahead, -100 empty, null unknown) and @operation on
the resource documents the whole response shape + verdict semantics in-code. (@hidden
still keeps it out of the public playground.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three non-blocking findings from the PR review:

- Drop `SiteSearchMirrorReconciler.canEvaluate()` — dead code with no
  callers; the phase interpretation already lives in
  MigrationReadinessService. There is no `evaluable` field on the report
  (the PR body claimed one).
- `outOfSyncCount` is now phase-aware: in Phase 0 the OpenSearch
  counterparts have not been built yet (they are created during
  dual-write), so a missing OS copy is the expected state and no longer
  inflates the count next to a "nothing to reconcile yet" summary. Any
  other mismatch (OS index with no ES source, drift between two existing
  copies) stays counted and is now named in the Phase 0 summary.
- `safeToRollback` is fail-safe on unmeasurable counts: an unknown count
  (-1) on either engine is no longer compared numerically — with
  es=100/os=-1, `100 < -1` returned a false green while OpenSearch may
  hold more documents. Mirrors the drift verdict's fail-safe stance.

Tests: 31 unit green (MigrationReadinessServiceTest 12 -> 16).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@fabrizzio-dotCMS

Copy link
Copy Markdown
Member Author

Thanks — all three findings were real. Addressed in e6ae14c.

1. canEvaluate() dead code / phantom evaluable field — removed. Confirmed no callers anywhere in main or test, and there is no evaluable field on MigrationReadiness/PhaseInfo. The phase interpretation the method encoded already lives in MigrationReadinessService (the per-phase advance/rollback branches), so the method was a duplicate API surface with no wiring. Dropped it and fixed the stale PR-body sentence, which now describes the dualWrite flag that actually ships.

2. Phase 0 outOfSyncCount contradicting the summary — now phase-aware. outOfSyncCount is computed through a new needsAttentionIn(phase, status) instead of the raw needsAttention(): in Phase 0 an index whose ES copy exists but whose OpenSearch counterpart does not is the expected state (counterparts are built during dual-write), so it no longer counts. The exclusion is deliberately narrow — an OpenSearch copy with no ES source, or a drift between two copies that both exist, is still unexpected before the migration starts and stays counted. To avoid re-creating the same contradiction in that rarer case, the Phase 0 "nothing to reconcile yet" summary now appends a note naming the leftover indices when the residual count is non-zero.

3. safeToRollback false green on an unmeasurable count — fixed. The check moved into blocksRollback(status), which treats docCount() < 0 on either engine as unsafe before any numeric comparison, so es=100 / os=-1 no longer resolves through 100 < -1 to a green. Your assumption about the sentinel was right for the Site Search half; for the content half I traced it: those counts come from getIndicesStats() and are existence-gated (absent key → exists=false, count 0), so -1 does not currently arise there — the guard is uniform anyway so the two halves cannot diverge later. The ES-side -1 was already fail-safe by accident (-1 < any real count) but is now explicit, which also covers es=-1 / os=-1.

Tests: 31 unit green (MigrationReadinessServiceTest 12 → 16), covering Phase 0 expected-missing excluded, Phase 0 unexpected mismatch still counted, and unknown OS / unknown ES counts blocking rollback. docs/backend/OPENSEARCH_MIGRATION.md updated for both contract changes.

@fabrizzio-dotCMS
fabrizzio-dotCMS merged commit 4e551ab into issue-36360-sitesearch-mirror-reconciliation Aug 4, 2026
34 checks passed
@fabrizzio-dotCMS
fabrizzio-dotCMS deleted the issue-36360-migration-readiness-endpoint branch August 4, 2026 20:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

1 participant