Skip to content

fix(search): journal content deletes so lost index removals are retried (#37276) - #37320

Merged
fabrizzio-dotCMS merged 2 commits into
37276-silent-index-delete-lossfrom
37276-silent-index-delete-loss-impl
Sep 1, 2026
Merged

fix(search): journal content deletes so lost index removals are retried (#37276)#37320
fabrizzio-dotCMS merged 2 commits into
37276-silent-index-delete-lossfrom
37276-silent-index-delete-loss-impl

Conversation

@fabrizzio-dotCMS

@fabrizzio-dotCMS fabrizzio-dotCMS commented Sep 1, 2026

Copy link
Copy Markdown
Member

Implements the spec approved in #37297. Fixes #37276.

Base branch note: this targets 37276-silent-index-delete-loss (the spec branch), not main, because the spec has not merged yet. Until it does, the diff here also shows the spec commit — that is the shared ancestor, not a duplicate. Once #37297 lands this collapses to just the implementation.

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:

  • the JVM stopped between commit and execution (a rolling deploy is exactly this window),
  • the shared listener pool rejected the task,
  • or the bulk write returned per-item failures that were logged and treated as success.

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_journal inside the transaction, and ReindexThread retries until the index acknowledges. The asymmetry is visible on the same IndexPolicy.DEFER check, 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

Change Why
destroyContentlets and deleteAllVersionsandBackup enqueue a DELETE journal entry in the same transaction that deletes the rows The durable record is what survives a restart or a rejected task. The commit listener stays as the low-latency path.
ReindexQueueFactory.findContentToReindex resolves a REINDEX/DELETE collision by row id instead of poll order From review feedback on #37297 — see below.
ContentletIndexOperationsES and ...OS raise on a partially failed bulk Loss point L3. The issue named only ES; OpenSearch carries the identical defect.
A removal skipped because the primary provider's index pointers will not load no longer reads as completed Loss point L2. 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.
IndexPolicyProvider javadoc corrected It claimed a WAIT_FOR default; the code defaults to DEFER.

Three things worth a reviewer's attention

1. The batch collision had to be fixed first, or the fix reintroduces the bug.
findContentToReindex builds its batch as a Map keyed by identifier alone, while ReindexEntry equality 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 later poll() silently overwrites the earlier. Unreachable today because nothing enqueues deletes; routine the moment destroyContentlets does, since content is saved and then destroyed.

Two ways the pair arrives, both broken: at equal priority (the common case — both addIdentifierReindex and addIdentifierDelete default to NORMAL) the drain query's ORDER BY priority ASC has no tiebreaker, so the order is undefined by contract; at unequal priority (a full reindex at REINDEX/300 against a removal at NORMAL/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 stale ASAP reindex 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#putToIndex already 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. ContentletIndexPartialFailurePhaseTest pins 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. ContentletDestroyIndexRemovalTest has 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.

putToIndex is on the public ContentletIndexAPI interface. 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 preserved
  • ContentletIndexOperationsESPartialFailureTest (4) / ...OSPartialFailureTest (3) — raise on partial failure, silent on clean and null responses, message wording

8 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 above ERROR priority
  • ContentletDestroyIndexRemovalTest (2) — removal applied once the write path recovers; unpublish leaves other languages live
  • ContentletIndexPartialFailurePhaseTest (2) — ADR-0009 shadow isolation, ordinary writes unaffected
  • ContentletIndexProviderSkipTest (2) — primary pointer failure surfaces, shadow skip still completes

⚠️ ContentletIndexProviderSkipTest empties the indicies table to force the primary-provider failure and restores it in a finally. 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 other null → continue provider-skip sites outside the delete path.

🤖 Generated with Claude Code

This PR fixes: #37276

…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>
@fabrizzio-dotCMS

Copy link
Copy Markdown
Member Author

Integration tests now verified locally — the caveat in the description is resolved.

All 9 integration tests pass, run together in one JVM: ReindexDeleteJournalTest 3/3, ContentletDestroyIndexRemovalTest 2/2, ContentletIndexPartialFailurePhaseTest 2/2, ContentletIndexProviderSkipTest 2/2. 278 unit tests still green.

Getting there produced one substantive code change, pushed in 36b77be:

A second silent path on the removal, adjacent to L2. loadProviderIndicesQuietly returns null only when loading the pointers throws. When they 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 just as invisibly as in the throwing case. The guard now counts the delete operations the primary contributed rather than testing for a null ProviderIndices, so zero operations means the removal did not happen whichever way the primary came up empty.

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:

  • dotcms-integration unpacks the installed dotcms-core WAR, not target/classes. Running mvn compile and then verify silently tests whatever WAR was last installed — three runs were spent chasing a phantom failure in code that was never executing. mvn install -pl :dotcms-core -DskipTests first.
  • ContentletIndexProviderSkipTest's injection needed a cache flush: emptying the indicies table alone does nothing because IndiciesAPI reads through IndiciesCache. It now restores via IndiciesAPI#point instead of leaving the table empty for the rest of the suite — the fragility I flagged in the description is addressed, though it still mutates shared state and is the test I'd watch first if CI goes red.

@fabrizzio-dotCMS
fabrizzio-dotCMS merged commit 4326b50 into 37276-silent-index-delete-loss Sep 1, 2026
31 checks passed
@fabrizzio-dotCMS
fabrizzio-dotCMS deleted the 37276-silent-index-delete-loss-impl branch September 1, 2026 20:05
fabrizzio-dotCMS added a commit that referenced this pull request Sep 1, 2026
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>
fabrizzio-dotCMS added a commit that referenced this pull request Sep 1, 2026
…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>
fabrizzio-dotCMS added a commit that referenced this pull request Sep 2, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

1 participant