Skip to content

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

Open
fabrizzio-dotCMS wants to merge 8 commits into
issue-36360-sitesearch-mirror-reconciliationfrom
issue-36360-migration-readiness-endpoint
Open

feat(migration): internal migration-readiness endpoint (content + site search) (#36360)#36849
fabrizzio-dotCMS wants to merge 8 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 + evaluable; 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 — 23 unit green

  • MigrationReadinessServiceTest (8): advance/rollback verdicts across phases 0–3, missing-counterpart and >10k count-drift blocking advance, content-keyed-by-slot / siteSearch-as-list, clusterId.
  • 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

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 16m 18s —— View job


Review — migration-readiness endpoint (#36360)

  • Read the diff vs base branch
  • Review new reconcilers + service + resource
  • Review visibility teardown + JSP/helper changes
  • Post review findings

Reviewed against origin/issue-36360-sitesearch-mirror-reconciliation. The role gate, phase-only visibility teardown, and callers are all consistent; the OS-key strip/tag logic in the content reconciler matches OSIndexAPIImpl.getIndicesStats()'s key format (verified against the impl). No critical/high issues — three non-blocking observations below.

New Issues

  • 🟡 Medium: dotCMS/src/main/java/com/dotcms/content/index/migration/ContentIndexMirrorReconciler.java:72esImpl.getIndicesStats() is called unguarded, and ESIndexAPI.getIndicesStats() (ESIndexAPI.java:133) is not wrapped in try/catch — performLowLevelRequest throws unchecked when the ES cluster is unreachable. The failure then propagates out of statuses()evaluate() → the resource, so the endpoint returns 500 instead of a degraded report. This is asymmetric: the OS side (OSIndexAPIImpl.getIndicesStats(), line 475) catches and returns an empty map, and the Site Search half's documentCount returns -1 on failure. For a diagnostic endpoint whose whole purpose is to be usable when things are broken, an unreachable ES cluster is exactly the moment an operator would hit this — and it hard-fails rather than showing "ES copy unavailable." Consider guarding the ES stats call the same way (log + empty map → surfaces as MISSING_COUNTERPART, fail-safe). Fix this →

  • 🟡 Medium: dotCMS/src/main/java/com/dotcms/content/index/migration/MigrationReadinessService.java:262 — In the non-dual-write phases (0 and 3), outOfSyncCount is still outOfSync.size(), computed across the raw mirror rows. In Phase 0 the OS counterparts don't exist yet, so every content and Site Search row is MISSING_COUNTERPART and outOfSyncCount reports e.g. 2 (working+live) while safeToAdvance=true and the summary says "nothing to reconcile yet." The payload contradicts itself for an operator reading the JSON (count > 0, empty blockers, "safe"). Consider zeroing outOfSyncCount/scoping it to the evaluable phases, or wording the count so it reads as advisory in phases 0/3.

  • 🟡 Medium: dotCMS/src/main/java/com/dotcms/content/index/migration/SiteSearchMirrorReconciler.java:55canEvaluate() is a public method that no one calls. The service derives evaluable from phase.isDualWrite() directly (MigrationReadinessService.java:301), not from this method. Dead code — remove it, or wire the service through it so there's a single definition of "evaluable." Fix this →

Everything else checks out: the MigrationIndexVisibility filter(...)/showMigrationIndices() signature change has all callers updated (ESIndexResource, IndexResourceHelper, index_stats.jsp, tests); the role gate fails closed on lookup failure and is unit-covered; -1 failed-count values sort correctly through verdictFor and the esBehindAnywhere rollback check (fail-safe); @Hidden correctly keeps it out of the OpenAPI schema so no @Schema is required. The flagged missing container-level 403/200 integration test is a reasonable follow-up given the gate and reconcilers are unit-covered.
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
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