fix(search): make the OpenSearch write leg durable once it serves reads (#37276) - #37333
Conversation
|
Reviewed the diff in full ( The fix is sound. Test coverage is well-targeted too. Approving ✅ |
…ds (#37276) Review of #37320 surfaced a gap that reproduces the original defect in Phase 2. ADR-0009 says a failed write to the shadow store is logged and must not impact operations. That is correct for Phase 1, where nothing reads from OpenSearch. From Phase 2 onwards PhaseRouter#readProvider serves reads from OpenSearch while writes still fan out ES-primary / OS-shadow — so a removal lost on the OS leg left an orphaned document in the very index being queried, which is #37276 itself, in the phase the migration spends the longest in. Both paths were affected: - The async path built a shadow BulkProcessorListener for OS in Phases 1 AND 2. Its failures never marked the journal entry failed, so the entry was acked on the ES result alone and the removal was never retried. - The sync path swallowed the OS exception in putToIndex. Scope the shadow treatment by who serves reads rather than by dual-write: OS stays fire-and-forget while nothing reads it, and becomes durable the moment it does. ADR-0009's intent is preserved; only its Phase 2 assumption is corrected. When both legs fail the ES exception still wins — that is what callers have always seen. Also guards against a blank exception message when a bulk reports errors but no item carries a cause (flagged in review). ContentletIndexAPIImplPhase2ReadDurabilityTest covers all three cases, including that Phase 1 still swallows — the guard that keeps this fix from over-reaching into the policy where it is correct. 281 unit tests green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…2 fix (#37276) Both documents were written before the Phase 2 gap was found and now contradict the code. The contract's per-phase table said a partial OpenSearch failure is "logged, swallowed" in phases 1 AND 2. Since OpenSearch serves reads from Phase 2 onwards, that row is now split: Phase 1 swallows, Phase 2 propagates. The rationale is stated inline, because the table is exactly where someone would look before changing this behaviour back. The release note said nothing about it at all, which is the omission that matters most operationally: Phase 2 writes can now fail where they previously did not, for every write and not only removals. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
cb407ba to
3716255
Compare
|
Claude finished @fabrizzio-dotCMS's task in 4m 32s —— View job Code Review — PR #37333
I reviewed both the sync and async legs against the migration-phase model. The sync New Issues
The sync-path change ( · branch |
3716255
into
37276-silent-index-delete-loss
Follow-up to #37320 (merged). Addresses the Phase 2 gap raised in review of that PR, plus one defensive fix. Part of #37276.
The gap
ADR-0009 says a failed write to the shadow store is logged and must not impact operations. That is correct for Phase 1, where nothing reads from OpenSearch.
From Phase 2 onwards it stops being true.
PhaseRouter#readProviderserves reads from OpenSearch (readProvider():144—isReadEnabled() ? osImpl : esImpl) while writes still fan out ES-primary / OS-shadow. So a removal lost on the OS leg left an orphaned document in the very index being queried — which is #37276 itself, in the phase the migration spends the longest in.Both paths were affected, and the async one is the reason the durable-delete fix did not cover it:
createBulkProcessorbuilt a shadowBulkProcessorListenerfor OS wheneverisDualWrite, i.e. Phases 1 and 2. A shadow listener never marks the reindex-queue entry failed, so the journal entry was acked on the ES result alone and the removal was never retried.putToIndexcaught the OS exception and logged it as shadow divergence.The change
Scope the shadow treatment by who serves reads, not by dual-write:
OpenSearch stays fire-and-forget while nothing reads it, and becomes durable the moment it does. ADR-0009's intent — don't let a store nobody reads break user operations — is preserved; only its Phase 2 assumption that a shadow is not read from is corrected.
When both legs fail, the ES exception is still the one raised. That is what callers have always seen, and demoting it would change behaviour beyond this gap.
Also guards
ContentletIndexOperationsOS.handleBulkResponseagainst throwing a blank-message exception when a bulk reportserrors()but no item carries a cause — flagged in the same review. A failure with no message is barely better than the silent return it replaced.Testing
ContentletIndexAPIImplPhase2ReadDurabilityTest, 3 cases. The first is the important one:281 unit tests green. All 9 integration tests from #37320 re-run green against this change (
ReindexDeleteJournalTest,ContentletDestroyIndexRemovalTest,ContentletIndexPartialFailurePhaseTest,ContentletIndexProviderSkipTest, one JVM).Spec
The spec on the base branch was amended in 945d27e to put this in scope with AC-010, alongside the three scope gaps raised separately in review. It had been a decision taken in code without being written down — the same complaint the reviewer had made about the other sites.
Behaviour change worth flagging
This changes failure behaviour for every write in Phase 2, not only removals. An environment whose OpenSearch cluster is unhealthy will begin surfacing errors that were previously absorbed. Same "the defect becoming visible" effect as the partial-failure escalation in #37320, and confined to phases where OpenSearch already serves reads.
🤖 Generated with Claude Code
This PR fixes: #37276