fix(search): journal content deletes so lost index removals are retried (#37276) - #37320
Conversation
…ed (#37276) Content destruction deleted the database rows transactionally, then handed the index removal to an in-memory post-commit listener that recorded nothing durable. Any loss of that task — the JVM stopping between commit and execution, the shared pool rejecting it, or a bulk returning per-item failures that were logged and treated as success — orphaned the index document permanently, with no error and nothing to retry. Adds are already backed by dist_reindex_journal: the intent is written inside the transaction and ReindexThread retries it until acknowledged. The equivalent delete machinery existed end to end — ReindexAction.DELETE, addIdentifierDelete, the read-back flag, and both the ES and OS bulk consumers — with zero production callers. This wires it up. - destroyContentlets and deleteAllVersionsandBackup enqueue a DELETE entry in the same transaction that deletes the rows. delete(List, User, boolean, boolean) is deliberately excluded: it can delete a subset of an identifier's versions, and a journal entry is identifier-wide. - ReindexQueueFactory.findContentToReindex resolves a REINDEX/DELETE collision for one identifier by row id instead of poll order. The batch is keyed by identifier alone while ReindexEntry equality includes the delete flag, so the pair was not deduplicated but did overwrite each other. Unreachable until deletes are enqueued; routine afterwards (content saved, then destroyed). - ContentletIndexOperationsES and ...OS raise on a partially failed bulk instead of logging and returning. The escalation is in the providers, not the router, so the dual-write shadow leg stays isolated per ADR-0009. - A removal skipped because the PRIMARY provider's index pointers will not load no longer looks like a completed removal. Shadow providers keep warn-and-continue. - Bulk failure messages no longer say "Error reindexing" for removals — that wording is why searching production logs for failed deletes came back empty. - Corrects IndexPolicyProvider javadoc claiming a WAIT_FOR default; it is DEFER. Spec approved in #37297. Includes the data model, the putToIndex contract change and the release note. Verification: 278 unit tests green across the index and reindex packages, including 12 new ones. The 8 new integration tests are registered in MainSuite1b/2b but have NOT been run locally — the Docker daemon died mid-verification. They must go green in CI before merge. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…37276) Integration testing surfaced a second way the removal path goes silent, adjacent to the one this PR already fixed. loadProviderIndicesQuietly returns null only when loading the pointers *throws*. When the pointers load but hold no active index, it returns a non-null ProviderIndices with null slots: no delete operations are added, putToIndex early-returns on the empty batch, and the caller sees success — exactly as invisibly as the throwing case. Count the delete operations the primary contributed instead of testing for a null ProviderIndices. Zero operations means the removal did not happen, whichever way the primary came up empty. Also fixes the test's failure injection: emptying the indicies table is not enough on its own, because IndiciesAPI reads through IndiciesCache and the stale pointers stayed visible, so the removal proceeded normally and the test proved nothing. Flush the cache to force it, and restore via IndiciesAPI#point rather than leaving the table empty for the rest of the suite. Verified locally: all 9 integration tests across the four classes pass, run together in one JVM; 278 unit tests still green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Integration tests now verified locally — the caveat in the description is resolved. All 9 integration tests pass, run together in one JVM: Getting there produced one substantive code change, pushed in 36b77be: A second silent path on the removal, adjacent to L2. That one is worth a reviewer's eye: it is a real widening of the check, found only because the integration test could not make the original narrow guard fire. Two notes on how the verification went, since they affect how you'd reproduce it:
|
4326b50
into
37276-silent-index-delete-loss
Four gaps from review of #37297 and #37320, all of which were decisions taken in code without being written down — the same failure mode the reviewer flagged. In scope, now stated: - deleteAllVersionsandBackup, the second site that defers an index removal. Its version list comes from findAllVersions(identifier), so an identifier-wide journal entry is correct there. - The OpenSearch write leg once OpenSearch serves reads. From Phase 2 onwards reads come from OS while writes stay ES-primary / OS-shadow, so a removal lost on the OS leg orphaned a document in the index being queried — this defect, in the phase the migration spends the longest in. Non-goals, now stated: - ContentletAPI#delete(List, User, boolean, boolean), the third site with this shape. It can delete a subset of an identifier's versions, and a journal entry is identifier-wide. - Surfacing drift in the Maintenance portlet (F4 in the issue). AC-007 already makes the residue enumerable; the operator view is separate work. This had been dropped silently, which was the fair complaint. - Phase 1 shadow durability. It stays fire-and-forget per ADR-0009; only the Phase 2 assumption that a shadow is not read from is corrected. New acceptance criteria: - AC-009: enumerating putToIndex callers is a prerequisite for AC-003, not a follow-up. It was previously only prose in Regression Risk, which is exactly how it could have slipped through planning. - AC-010: where OS serves reads, a failed OS write reaches the caller and marks the journal entry for retry; where nothing reads OS, it stays swallowed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…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>
…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>
Implements the spec approved in #37297. Fixes #37276.
The problem
Content destruction deleted the database rows transactionally, then handed the index removal to an in-memory post-commit listener that recorded nothing durable. Any loss of that task orphaned the index document permanently:
No error surfaced, nothing retried, and the divergence never healed. The application reads from the index — search, listings, URL maps, widget counts — so the symptom reaching users was an inflated result count above a shorter list.
Adds never had this problem: they write their intent to
dist_reindex_journalinside the transaction, andReindexThreadretries until the index acknowledges. The asymmetry is visible on the sameIndexPolicy.DEFERcheck, two branches apart — add → journal (ContentletIndexAPIImpl:2308), delete → commit listener (:3055).The equivalent delete machinery already existed end to end —
ReindexAction.DELETE,addIdentifierDelete, the read-back flag, and both the ES and OS bulk consumers, all covered by integration tests — with zero production callers. This PR wires it up.What changed
destroyContentletsanddeleteAllVersionsandBackupenqueue aDELETEjournal entry in the same transaction that deletes the rowsReindexQueueFactory.findContentToReindexresolves a REINDEX/DELETE collision by rowidinstead of poll orderContentletIndexOperationsESand...OSraise on a partially failed bulkError reindexingfor removalsIndexPolicyProviderjavadoc correctedWAIT_FORdefault; the code defaults toDEFER.Three things worth a reviewer's attention
1. The batch collision had to be fixed first, or the fix reintroduces the bug.
findContentToReindexbuilds its batch as aMapkeyed by identifier alone, whileReindexEntryequality includes the delete flag — so a REINDEX and a DELETE for the same identifier are not equal (the duplicate-drain loop below does not collapse them) yet collide on the key, and the laterpoll()silently overwrites the earlier. Unreachable today because nothing enqueues deletes; routine the momentdestroyContentletsdoes, since content is saved and then destroyed.Two ways the pair arrives, both broken: at equal priority (the common case — both
addIdentifierReindexandaddIdentifierDeletedefault toNORMAL) the drain query'sORDER BY priority ASChas no tiebreaker, so the order is undefined by contract; at unequal priority (a full reindex atREINDEX/300 against a removal atNORMAL/100) the stale reindex wins deterministically, every time.Resolution is by
id, not by "deletes always win": an identifier destroyed and later reused is a legitimate reindex. Priority deliberately does not break the tie — a staleASAPreindex would beat a newer removal, which is the defect again.2. The escalation is in the providers, not the router — that placement is what keeps ADR-0009 intact.
ContentletIndexAPIImpl#putToIndexalready isolates the dual-write shadow leg: it catches the ES failure, always calls OS, logs an OS failure as shadow divergence, and rethrows only the ES one. So raising inside the providers yields ADR-0009's contract in every phase — phases 1/2 swallow the OS failure while OS is the shadow, phase 3 propagates it once OS is primary.ContentletIndexPartialFailurePhaseTestpins this so a well-meaning refactor cannot move it up.3. One delete path is deliberately left alone.
delete(List, User, boolean, boolean)can delete a subset of an identifier's versions or languages, and a journal entry is identifier-wide — journalling there would remove index documents for languages that still exist. Same reasoning excludes unpublish/archive. Both are marked with comments explaining why, so nobody "completes" them later.ContentletDestroyIndexRemovalTesthas a regression test for the unpublish case.Behaviour change operators will notice
Partial bulk failures now raise instead of being swallowed. In an environment that has been quietly losing index writes this will look like a regression and is not — the failures were always happening, only their visibility changed. Full detail in
specs/37276-silent-index-delete-loss/release-note.md, including the query for enumerating removals the index still owes.putToIndexis on the publicContentletIndexAPIinterface. Signature unchanged and source/binary compatible, but an out-of-tree plugin relying on it returning normally after a partial failure will now see an exception. Out-of-tree callers cannot be enumerated from this repo; that residual risk is covered by the release note rather than by a compatibility flag, which would make the defect a supported configuration.Testing
278 unit tests green across the index and reindex packages, 12 of them new:
ReindexQueueFactoryBatchKeyTest(5) — collision resolution, both arrival orders, dedup preservedContentletIndexOperationsESPartialFailureTest(4) /...OSPartialFailureTest(3) — raise on partial failure, silent on clean and null responses, message wording8 new integration tests, registered in
MainSuite1b/MainSuite2b, NOT yet run locally. The Docker daemon died partway through verification. They compile and are wired into their suites; they need to go green in CI before this merges.ReindexDeleteJournalTest(3) — durable entry written in-transaction, rollback leaves none, an exhausted removal stays discoverable aboveERRORpriorityContentletDestroyIndexRemovalTest(2) — removal applied once the write path recovers; unpublish leaves other languages liveContentletIndexPartialFailurePhaseTest(2) — ADR-0009 shadow isolation, ordinary writes unaffectedContentletIndexProviderSkipTest(2) — primary pointer failure surfaces, shadow skip still completesContentletIndexProviderSkipTestempties theindiciestable to force the primary-provider failure and restores it in afinally. It is the most fragile test here and may deserve a different injection mechanism — flagging it rather than hiding it.Not in scope
Repairing documents already orphaned (a full reindex remains the remedy); retrying past
REINDEX_MAX_FAILURE_ATTEMPTS; the unpublish/archive path; push-publish bundle handling; and the four othernull → continueprovider-skip sites outside the delete path.🤖 Generated with Claude Code
This PR fixes: #37276