fix(index): Phase 2 ES read fallback now fires for content search (#37413) - #37500
fix(index): Phase 2 ES read fallback now fires for content search (#37413)#37500fabrizzio-dotCMS wants to merge 5 commits into
Conversation
…37413) Issue-resolution spec for #37413. In Phase 2 the documented OpenSearch-> Elasticsearch read fallback never fires for content search: the five read call sites of ESContentFactoryImpl.indexOperationsDelegate() invoke the selected provider directly, so PhaseRouter.read/readChecked — where the fallback lives — is never reached. Root cause was verified against the tree rather than taken from the issue body, which attributes the observed 200/total=0 to the OpenSearchException -> ERROR_HIT branch. A ConnectException is not an OpenSearchException, so that branch is never reached in the reported case. The spec records the verified mechanism instead (count-first cache key, ContentUtils catch(Throwable), ContentHelper resultsSize overwrite) and flags the issue body for correction. Spec only — no implementation. /speckit-plan follows once this is approved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… fallback fires (#37413) The Phase 2 Elasticsearch read fallback was implemented and correct; the content read path never reached it. ESContentFactoryImpl selected a provider with a bare ternary and called it directly from its five read call sites, so PhaseRouter.read -- which holds the fallback -- was unreachable from the busiest read path in the product. With OpenSearch down in Phase 2 and Elasticsearch healthy and dual-written, POST /api/content/_search returned either a well-formed 200 with zero results or a 500. The empty-200 variant is the dangerous one: a caller cannot tell it apart from "this content type has no content", so a live site rendered as missing content with nothing for 5xx monitoring to catch. All five read methods on ContentFactoryIndexOperations are declared without checked exceptions, so read() covers every call site and readChecked() is not needed. A connection failure already reaches the provider's generic handler and is rethrown as DotRuntimeException, which read() catches -- so routing alone closes the reported outage. The ternary is removed rather than left beside the router: a second provider-selection mechanism in this class is how the bug happened. The two provider fields and the two phase static imports became dead with it. createScrollQuery is routed for provider selection only and its fallback can never fire. Both implementations just construct a cursor -- no I/O, nothing to throw -- and the requests happen later inside that cursor, outside the router. A mid-scroll fallback is impossible in principle anyway: an OpenSearch scroll id is meaningless to Elasticsearch. The residual gap (a consumer already iterating when OpenSearch dies still fails) is documented at the call site. PhaseRouter gains a read(operation, fn) overload so the fallback log line names what failed and its root cause. The plain read() can only report the cause, because the operation it runs is an opaque lambda -- enough to know something fell back, not enough for monitoring to say what stopped working, which is the early-warning signal the migration design promises. Providers also wrap failures in a generic message, so the actionable text lives further down the cause chain: OS read failed in Phase 2 [indexCount] - falling back to ES. OS index may be stale or unavailable. Cause: An error occurred when executing the Lucene Query / root cause: ConnectException: Connection refused Tests: the per-phase contract is pinned on the router against the real provider interface, because ESContentFactoryImpl cannot be constructed outside a container -- its read methods run through static CacheLocator/APILocator calls before reaching a provider. The wiring proof therefore lives in the integration test, which injects an OpenSearch provider that fails every read and asserts the factory still returns real Elasticsearch data. That is the one thing a unit test on the router cannot show, and it was the entire defect: 13 of the 14 unit tests pass against unmodified code. Phases 0, 1 and 3 are verified unchanged by test rather than by inspection. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…into an empty result in Phase 2 (#37413) Routing the read path through PhaseRouter fixes an unreachable OpenSearch, because a connection failure reaches the provider's generic handler and is rethrown as a runtime exception the router catches. A node that is reachable but answers with an error is a different failure class, and routing does not fix it: the provider absorbs it into a legitimate-looking success. It then reports no failure at all, so the router has nothing to catch and cannot fall back. Four such absorbing paths, all now conditional on Phase 2: - searchHits() index resolution. A catch(Exception) around inferIndexToHit returns an empty SearchHits, and a null index does the same. This one was NOT in the issue's analysis and is the most consequential of the four: it sits on the exact path the issue reports, and it converts ANY failure to determine which index to query into a clean empty result while logging at ERROR and returning success. - cachedIndexSearch() ERROR_HIT sentinel. - cachedIndexCount() -1 sentinel. - indexSearchScroll(), which carries its own separate handler returning an empty list -- also absent from the issue's analysis. This explains the asymmetry the issue described (200/total=0 for some content types, 500 for others) more precisely than the spec does. The spec attributed the empty 200 to the legacy ContentUtils catch(Throwable). There is an earlier swallow, in the provider: the SAME index-resolution failure propagates on the count and scroll paths -- so the router falls back and the caller gets correct data -- while the search path converts it to empty on the spot. The spec's explanation is not wrong about the legacy layer, it just stops one layer short of the provider. Scoping the change to Phase 2 is what makes it safe, and it is why AC-006's enumeration of callers relying on empty-instead-of-throw is unnecessary: in Phase 2 the router catches the raised failure immediately above this class and turns it into a successful Elasticsearch read, so no caller ever observes a new exception type. Phases 0 and 1 do not read from OpenSearch at all; Phase 3 has no Elasticsearch to fall back to, so raising there would expose every such caller with nothing gained. The one genuine change: both engines failing on the same read now surfaces an error instead of a silent empty result, which is the improvement being asked for. ContentFactoryIndexOperationsES is deliberately untouched (AC-006). On the two cached paths the raise happens BEFORE the cache write. The provider caches its sentinel for parse_exception and search_phase_execution_exception; such an entry would be replayed to every later identical query as a successful empty result, outliving the outage and defeating the fallback even after OpenSearch recovers. A test asserts the ordering rather than trusting it. Testing notes. The first version of this test relied on the container having no OpenSearch counterpart indices, which looked like the real customer scenario but was not: it produced dotCMS's own index-resolution failure, thrown before the client is ever called, so it re-exercised the routing fix and index_not_found_exception never appeared once in the run. Its precondition check was too weak -- it verified OpenSearch did not return the right answer, not that it failed for the right reason. Mocking the client is what makes the failure the one AC-005 is about, and the precondition now asserts it. Two gaps, deliberate and recorded rather than silently skipped: - The scroll swallow has no behavioural test. The scroll resolves its client through CDI rather than the injected provider, so a mocked client cannot reach it. The fix covers it; only the proof is missing. - That ContentFactoryIndexOperationsES is unchanged is evidenced by the diff, not by a behavioural test: its client is a static singleton with no injection seam, so no index_not_found can be forced through it. The Phase 0 and Phase 1 tests do show Elasticsearch-served reads behaving identically. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… TC-039 gave a false pass (#37413) The migration design has stated the Phase 2 read fallback as a guarantee in four places. For content search it was not true until #37413: the read path selected a provider with a bare ternary and never reached PhaseRouter, so an OpenSearch outage returned a well-formed 200 with zero results, or a 500. Cloud and Support have been quoting that guarantee to customers. The migration doc now states what actually has to hold for the fallback to fire -- the read must go through the router, AND the provider must raise rather than absorb the failure into a legitimate-looking success -- and, more useful operationally, what still does not fall back: Phase 3, a failure mid-scroll, an OpenSearch that answers successfully with stale data, a repeated identical query served from the cache, and the legacy layer that turns any read failure into an empty 200 in every phase including pure-ES installs. It also records the fallback log line, which now names the operation and root cause. TC-039 in the test plan is the case that should have caught this, and it was written correctly except for one instruction: step 3 said to run "the same search again". An identical request body is served from the query cache and returns the pre-outage result, which is indistinguishable from a working fallback -- so the case was structurally guaranteed to pass whether the fallback worked or not. It now requires varying the query, adds the never-queried-content-type variant that used to fail as a 500 rather than an empty 200, and warns about the second false-pass route: checking only for the absence of an error while the returned total is quietly zero. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#37413) The four failures this change raises in Phase 2 are caught by PhaseRouter and logged at ERROR via e.getMessage(). Three of the messages embedded the full SearchRequest, CountRequest or query string, which promoted possible customer content -- a Lucene query can carry end-user search terms and field values -- from a pre-existing WARN to a new ERROR line (Constitution Principle III). They now carry the index name and OpenSearch's own reason instead. That is the actionable part: the operation name comes from the router, the index says where it failed, and the reason says why. The request body is still on the WARN block directly above for anyone diagnosing a specific query. Residual and deliberately unchanged: those pre-existing WARN blocks still log the full query in every phase, and ContentFactoryIndexOperationsES does the same. Changing that would alter Phase 0 and Phase 1 logging, which the spec declares out of scope. What this commit fixes is the part the fix introduced. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @fabrizzio-dotCMS's task in 2m 13s —— View job Review: Phase 2 ES read fallback (#37413)
The core change is sound: all five read call sites now go through New Issues
Notes (non-blocking)
No critical or high-severity issues. The two mediums above are worth addressing but do not block merge. |
✅ ApprovingSolid analysis, matches what the diff actually does.
|
Fixes #37413. Spec approved in #37438 — this PR branches off that spec branch, so it carries the spec commit too.
What was broken
The Phase 2 Elasticsearch read fallback was implemented and correct. The content read path never reached it.
ESContentFactoryImplselected a provider with a bare ternary and called it directly from its five read call sites, soPhaseRouter— which holds the fallback — was unreachable from the busiest read path in the product:/api/content/_search,ContentletAPIsearch and count, Velocity$dotcontent.pull, URL maps, Site Search, scroll consumers and the admin content browser.With OpenSearch down in Phase 2 and Elasticsearch healthy and dual-written, a content search returned a well-formed
200with zero results for content types whose count was already cached, and a500for the rest. The empty result is the dangerous variant: a caller cannot tell it apart from "this content type has no content", so a live site rendered as missing content with nothing for 5xx monitoring to catch.This blocked more than the outage case. Cloud and Support have been telling customers "Phase 2 is safe, OpenSearch failures fall back to Elasticsearch" — quoting
OPENSEARCH_MIGRATION.md, which states it in four places. For content search that was false.Two failure classes, not one
Routing alone fixes an unreachable OpenSearch: a connection failure reaches the provider's generic handler and is rethrown as a runtime exception the router catches.
A node that is reachable but answering with an error is different, and routing does not fix it. The provider absorbs the failure into a legitimate-looking success, reports no failure, and the router has nothing to catch. Four such paths, all now conditional on Phase 2:
searchHitsindex resolutionSearchHits— acatch (Exception)aroundinferIndexToHit, plus a null-index guardcachedIndexSearchERROR_HITsentinelcachedIndexCount-1indexSearchScrollTwo of those four were not in the issue's analysis — the index-resolution guard and the scroll handler. The first is the most consequential: it sits on the exact path the issue reports and converts any failure to determine which index to query into a clean empty result.
It also explains the reported asymmetry more precisely than the spec does. The spec attributes the empty
200to the legacyContentUtilscatch (Throwable). That is true but stops one layer short — there is an earlier swallow in the provider, and the same index-resolution failure propagates on the count and scroll paths (so the router falls back and the caller gets correct data) while the search path converted it to empty on the spot.Why the Phase 2 scoping is what makes this safe
AC-006 asked for an enumeration of every caller that depends on receiving an empty result rather than an exception, warning that if the list were large the provider change should be dropped.
Scoping the raise to Phase 2 makes the enumeration unnecessary: in Phase 2 the router catches the new exception immediately above the provider and turns it into a successful Elasticsearch read, so no caller ever observes a new exception type. Phases 0 and 1 do not read from OpenSearch at all. Phase 3 has no Elasticsearch to fall back to, so raising there would expose every such caller with nothing gained.
The one genuine behaviour change: both engines failing on the same read now surfaces an error instead of a silent empty result. That is the improvement being asked for.
ContentFactoryIndexOperationsESis deliberately untouched.The log line
The fallback is the migration design's early-warning signal, so it now names the operation and the root cause. Before, it could only report the wrapper's message — enough to know something fell back, not what stopped working.
The raised messages carry the index name and OpenSearch's own reason, not the query body: in Phase 2 the router logs the message at
ERROR, and a Lucene query can carry end-user search terms (Constitution Principle III). Residual, unchanged: the provider's pre-existingWARNblocks still log the full query, in every phase, and the Elasticsearch provider does the same. Changing that would alter Phase 0/1 logging, which the spec declares out of scope.Cache poisoning
On the two cached paths the raise happens before the cache write. The provider caches its sentinel for
parse_exceptionandsearch_phase_execution_exception; such an entry would be replayed to every later identical query as a successful empty result, outliving the outage and defeating the fallback even after OpenSearch recovers. A test asserts the ordering rather than trusting it.Testing
ContentFactoryIndexOperationsPhaseRoutingTest, in no suite): all five read operations × all four phases, on the router against the real provider interface;ERRORlevel and one event per read; both-engines-fail surfaces after exactly one attempt each; a healthy Phase 2 never touches Elasticsearch.MainSuite1b):ESContentFactoryImplPhase2FallbackTestproves the wiring by injecting an OpenSearch provider that fails every read;ESContentFactoryImplMissingOsIndexTestcovers the reachable-but-erroring class with a mocked client raisingindex_not_found_exception.Phases 0, 1 and 3 are verified unchanged by test, not by inspection — these call sites carry essentially all content search, so a behaviour change here is felt site-wide in every phase.
Two notes on how the tests got there, because both are traps worth knowing:
The unit tests do not go red for this bug. 13 of the 14 pass against unmodified code, because the router was never broken — the defect was that nobody called it. Only the integration test can catch that, and it is why the wiring proof is not optional.
The first version of the missing-index test passed for the wrong reason. It relied on the container simply having no OpenSearch counterpart indices, which looked like the customer scenario but produced dotCMS's own index-resolution failure instead — thrown before the client is ever called, so it re-exercised the routing fix, and
index_not_found_exceptionnever appeared once in the run. Its precondition only checked that OpenSearch did not return the right answer, not that it failed for the right reason. The class now mocks the client and asserts the precondition explicitly.Reviewer decisions I need
Two signed-off test gaps
ContentFactoryIndexOperationsESis unchanged is evidenced by the diff, not by a test: its client is a static singleton with no injection seam, so noindex_not_foundcan be forced through it. The Phase 0/1 tests do show Elasticsearch-served reads behaving identically.Docs
OPENSEARCH_MIGRATION.mdnow records what must hold for the fallback to fire, and — more useful operationally — what still does not fall back: Phase 3, a failure mid-scroll, an OpenSearch answering successfully with stale data, a repeated identical query served from cache, and the legacy layer that turns any read failure into an empty200in every phase including pure-Elasticsearch installs.TC-039 in the test plan is the QA case that should have caught this, and it was structurally guaranteed to pass. Step 3 told the tester to run "the same search again" — an identical request body is served from the query cache and returns the pre-outage result, indistinguishable from a working fallback. It now requires varying the query, adds the never-queried-content-type variant that used to fail as a
500, and warns about checking the returned total rather than just the absence of an error.Not in scope
ContentUtilsandContentHelperthat turns any read failure into an empty200in every phase — pre-existing, affects pure-Elasticsearch installs, and is what made this outage silent rather than loud. Being filed separately.IndexAPI<F>generic parameterization.Still to do before merge
Manual validation on the Phase 2 migration environment, varying
offseton every call, plus confirming Phase 3 still propagates there. Not yet done.