Skip to content

fix(push-publish): stop letting a stale index document block unique-field content (#36898) - #37055

Merged
fabrizzio-dotCMS merged 1 commit into
mainfrom
issue-36898-push-publish-ghost-unique-field
Aug 13, 2026
Merged

fix(push-publish): stop letting a stale index document block unique-field content (#36898)#37055
fabrizzio-dotCMS merged 1 commit into
mainfrom
issue-36898-push-publish-ghost-unique-field

Conversation

@fabrizzio-dotCMS

@fabrizzio-dotCMS fabrizzio-dotCMS commented Aug 13, 2026

Copy link
Copy Markdown
Member

Proposed Changes

Fixes #36898

Push-publish was permanently blocked for unique-field content whenever the receiver's search index held a ghost: a document whose contentlet no longer exists in the contentlet table. findUniqueContentMatch() matched the ghost in the index, failed to resolve it via API, and threw a DotDataException that aborted the whole bundle. Because nothing ever cleaned the ghost up, every retry failed identically — the only workaround was toggling the unique flag off, force-pushing, and toggling it back on (T3 intervention).

This has been confirmed in a customer Cloud environment and is reported across multiple customers.

1. Self-healing on ghost matches — ContentHandler.java

findUniqueContentMatch() no longer throws. A new resolveUniqueContentMatch() helper walks every result the Lucene query returned (previously only contentlets.get(0) was ever considered) and returns the first one that actually resolves from the database. Any match that does not resolve is logged at WARN with the ghost inode, identifier, the Lucene query used and the unique fields involved, plus a note that a reindex of the affected content is recommended — then skipped.

If every match turns out to be a ghost, the incoming content is returned untouched, so the bundle is imported as new content and unblocks itself.

2. State filter on the Lucene query — ContentHandler.java

The query now appends +working:true, so stale live documents from older versions never reach the resolution step.

+deleted:false was deliberately not added: matching archived content is intentional here — handleContent() calls unarchive() on a matched archived contentlet before check-in, so excluding archived documents would be a behavior regression.

3. Closing one ghost-creation path — ESContentletAPIImpl.java

destroyContentlets() relied solely on forceUnpublishArchive() for index removal, and that path is gated on contentlet.isLive(). Working-only content that was never published had its index document left behind when destroyed — one of the ways a ghost is born. The method now calls indexAPI.removeContentFromIndex() explicitly, deduplicated per identifier/language/variant, which is the granularity of the index document ID (removing once per version would be pure repetition).

This mirrors what the sibling delete path already does.

Checklist

  • Tests
  • Translations
  • Security Implications Contemplated (add notes if applicable)

Additional Info

Why the ES query change is safe for the ghost path. Fix 2 alone does not close the issue: a ghost whose working document is the stale one still matches. That is precisely why Fix 1 is the required part and Fix 2 is defense in depth.

Existing blocked content unblocks with no reindex and no flag toggling — the very next push-publish attempt succeeds and leaves a WARN trail pointing at the desynchronization.

Screenshots

N/A — backend only.

This PR fixes: #36898

…ield content (#36898)

When the receiver's search index holds a document whose contentlet is gone from
the database -- a "ghost" left behind by an index/database desynchronization --
findUniqueContentMatch() threw a DotDataException and killed the bundle. Since
nothing ever cleaned the ghost up, every retry failed the same way, and the only
way out was toggling the unique flag off, force-pushing, and toggling it back on.

Three changes:

- findUniqueContentMatch() now walks every match instead of only the first, and
  returns the first one that actually resolves from the database. Matches that
  do not resolve are logged at WARN with the inode, identifier and Lucene query,
  then skipped. When they all turn out to be ghosts the incoming content is
  returned untouched, so the bundle imports as new content and unblocks itself.

- The Lucene query is restricted to +working:true, which keeps stale live
  documents from older versions out of the match in the first place.

- destroyContentlets() removes the contentlets from the index explicitly. It
  used to lean on forceUnpublishArchive(), which only covers live content, so
  destroying working-only content left its document behind -- one of the ways a
  ghost is born. Removal is deduplicated per identifier/language/variant, which
  is the granularity of the index document ID.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Claude finished @fabrizzio-dotCMS's task in 2m 45s —— View job


Code Review — Push-publish ghost unique-field fix

I read all three changed files and traced the surrounding logic (saveContentfindUniqueContentMatchcheckin, destroyContentletsremoveContentFromIndex, and the ES removal granularity).

Verification notes (why the fix holds up)

  • Ghost-return path is safe at check-in. findUniqueContentMatch returning content untouched imports it as new. That incoming contentlet already has Contentlet.DONT_VALIDATE_ME=true set in handleContents (ContentHandler.java:291), so the subsequent checkin (ContentHandler.java:861) does not re-run unique-field validation against the still-present ghost index doc. The bundle genuinely unblocks — confirmed, not just asserted.
  • resolveUniqueContentMatch has no regression. When contentlets.get(0) resolves, old and new code both return it. Walking the rest only helps when the head is a ghost. find(inode, systemUser, false) returns null for a missing inode and systemUser bypasses permissions, so the ghost detection is reliable, and UtilMethods.isSet(getIdentifier()) guards the blank-identifier case.
  • +working:true is correct. Every existing version has a working doc (archived content included: working=true, deleted=true), so legitimate and archived matches still resolve — consistent with the deliberate omission of +deleted:false, since saveContent unarchives at ContentHandler.java:858.
  • Destroy dedup granularity is exactly right. removeContentFromIndex deletes the ES doc keyed by identifier_language_variant (ContentletIndexAPIImpl.java:3059-3061), which is precisely the dedup key used in ESContentletAPIImpl.java:3129-3130. Removing once per key covers all versions; the loop mirrors the sibling delete() path. Idempotent, so replay-safe.
  • Imports (Optional, VisibleForTesting, ContentletSearch, HashSet/Set, StringPool) all present. The second call site (ContentHandler.java:1348) is unaffected — signature and thrown exceptions unchanged.

New Issues

  • 🟡 Medium: dotCMS/src/main/java/com/dotcms/content/elasticsearch/business/ESContentletAPIImpl.java:3131 — The destroy-path index removal (the change that closes one ghost-creation source) has no test. The added integration test only covers the receiver-side skip in ContentHandler. A working-only contentlet destroyed and then asserted absent from the index would lock in this behavior and guard against regression. Non-blocking. Fix this →
  • 🟡 Medium: dotCMS/src/enterprise/java/com/dotcms/enterprise/publishing/remote/handler/ContentHandler.java:1161 — The new "walk every match" behavior in resolveUniqueContentMatch (first is a ghost, a later one resolves) isn't directly exercised — the test only covers the all-ghosts case. Worth a case with a ghost ahead of a real match to prove the loop returns the real one. Non-blocking.

Both changes are self-healing/idempotent and the primary customer-facing path (all-ghosts skip) is tested and verified correct. No blocking issues.
· issue-36898-push-publish-ghost-unique-field

@fabrizzio-dotCMS
fabrizzio-dotCMS added this pull request to the merge queue Aug 13, 2026
Merged via the queue into main with commit 53d730c Aug 13, 2026
68 checks passed
@fabrizzio-dotCMS
fabrizzio-dotCMS deleted the issue-36898-push-publish-ghost-unique-field branch August 13, 2026 23:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI: Safe To Rollback Area : Backend PR changes Java/Maven backend code

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

Push-publish permanently blocked for unique-field content when receiver's ES has stale ghost document

2 participants