Skip to content

[fix][broker] Fix forced topic/namespace deletion hanging or failing when compaction is in progress - #26016

Merged
lhotari merged 4 commits into
apache:masterfrom
lhotari:lh-fix-compaction-cursor-delete
Jun 13, 2026
Merged

[fix][broker] Fix forced topic/namespace deletion hanging or failing when compaction is in progress#26016
lhotari merged 4 commits into
apache:masterfrom
lhotari:lh-fix-compaction-cursor-delete

Conversation

@lhotari

@lhotari lhotari commented Jun 13, 2026

Copy link
Copy Markdown
Member

Fixes #24148
Related: #22736

Motivation

Symptom (who is affected)

On a topic that uses compaction, a forced deletion can hang forever or fail on every retry:

  • admin.topics().delete(topic, true) never completes (or keeps failing), and
  • admin.namespaces().deleteNamespace(ns, true) never completes — it cascades into deleting the namespace's topics, and the always-compacted __change_events system topic (plus any user topic with a compaction threshold) hits the same problem.

This is the long-standing issue reported in #24148 and the flakiness in #22736 (e.g. deleteNamespaceWithRetry timing out). In production it shows up as a force-delete that hangs, or that fails on every attempt until the topic is unloaded/reloaded or the broker restarts.

The trigger is a compaction that is running when the forced deletion fences the topic. Forcing deletion is expected to abort the in-flight compaction and then delete the topic; instead, the deletion gets stuck on the compaction.

Root causes

Two independent bugs can each wedge a forced deletion. They sit at different stages of the same flow, so depending on timing you hit one or the other.

  1. A failed/aborted compaction permanently poisons the compaction-cursor deletion. When the deletion fences the topic, the compaction aborts and currentCompaction completes exceptionally (the reader-close behavior from [fix][broker]Fix deadlock when compaction and topic deletion execute concurrently #24366). But asyncDeleteCursorWithCleanCompactionLedger() chained the cursor deletion with currentCompaction.thenCompose(...), so an exceptional completion skipped the cursor deletion and failed the unsubscribe instead. That failed currentCompaction future is never reset (a new compaction can't start while the topic is deleting), so every subsequent delete attempt fails immediately until the topic instance is reloaded.

  2. The compaction's reader can hang, so currentCompaction never completes at all. The compactor reads the topic through an internal RawReader. When the forced deletion makes that reader hit an unrecoverable error and close, a readNextAsync() issued by the compactor at just the wrong instant was enqueued after the consumer's close had already drained its pending-receive queue — leaving that read future uncompleted forever. currentCompaction then never completes, and the cursor deletion that waits on it blocks the entire forced deletion indefinitely.

What about unloads?

Unloading a topic does not itself hang — unload does not wait on currentCompaction. However, an unload/fence of a compacting topic is one of the events that can leave a compaction's reader in the hung state of cause 2 (the compaction reader is configured to treat "service not ready" as unrecoverable). The user-visible deadlock is on forced deletion, but the cause-2 fix applies no matter what drove the reader to close — a delete, a namespace delete, or an unload.

Modifications

  1. PersistentTopic.asyncDeleteCursorWithCleanCompactionLedger() — proceed with the compaction-cursor deletion once currentCompaction has finished, whether it completed normally or exceptionally (only an in-flight compaction is awaited). Fixes cause 1.
  2. RawReaderImpl.RawConsumerImpl.receiveRawAsync() — after enqueuing a pending receive, re-check the consumer state and fail the pending raw receives if the consumer is in a terminal state (Closing/Closed/Failed). This completes the read future deterministically once the consumer is terminal, regardless of why it closed. Fixes cause 2. Re-checking after the enqueue is what closes the race: every close path moves the consumer to a terminal state before it drains the pending-receive queue, so the recheck either observes the terminal state (and self-drains) or is ordered before the close's drain (which then covers it).
  3. Strengthened testConcurrentCompactionAndTopicDelete to assert the deletion actually succeeded (deleteTopicFuture.get(...)), instead of only asserting it completed (which is also true when it fails).
  4. Added regression tests:
    • CompactionTest.testForcedDeleteSucceedsAfterFailedCompaction — forced topic deletion after a deterministically failed compaction (cause 1).
    • CompactionTest.testForcedNamespaceDeleteWithInflightCompaction — end-to-end forced namespace deletion with an in-flight compaction ([Bug] Compaction of __change_events topic is blocking forceful namespace/topic deletion #24148 reproduction).
    • RawReaderTest.testReadNextAsyncCompletesAfterConsumerClosed — a read issued once the consumer is terminal must complete rather than hang (cause 2).

Verifying this change

  • Make sure that the change passes the CI checks.

This change added tests and can be verified as follows:

  • Each of RawReaderTest.testReadNextAsyncCompletesAfterConsumerClosed, CompactionTest.testForcedDeleteSucceedsAfterFailedCompaction, and the strengthened testConcurrentCompactionAndTopicDelete was verified red without its corresponding fix and green with it.
  • CompactionTest.testForcedNamespaceDeleteWithInflightCompaction and the full CompactionTest / RawReaderTest suites pass locally.

Does this pull request potentially affect one of the following parts:

If the box was checked, please highlight the changes

  • Dependencies (add or upgrade a dependency)
  • The public API
  • The schema
  • The default values of configurations
  • The threading model
  • The binary protocol
  • The REST endpoints
  • The admin CLI options
  • The metrics
  • Anything that affects deployment

Forced topic/namespace deletion deadlocks with an in-flight compaction
(issue apache#24148). PR apache#24366 broke the infinite wait by closing the
compactor's reader with an unrecoverable error when the topic gets
fenced for deletion, but the deletion still fails every time:
asyncDeleteCursorWithCleanCompactionLedger chains the compaction cursor
deletion with thenCompose on currentCompaction, so a compaction that
completed exceptionally skips the cursor deletion and fails the
unsubscribe instead. The failed future is never reset, so every
subsequent deletion attempt fails immediately until the topic instance
is reloaded, and deleteNamespaceWithRetry in tests times out (apache#22736).

Proceed with the compaction cursor deletion once the compaction has
finished, regardless of whether it completed normally or exceptionally.

Also strengthen testConcurrentCompactionAndTopicDelete to assert that
the deletion succeeded instead of only asserting that it completed.

Assisted-by: Claude Code (claude-fable-5)
…d conflicting with PR apache#26015

Assisted-by: Claude Code (claude-fable-5)
@lhotari lhotari added this to the 5.0.0-M1 milestone Jun 13, 2026
… a terminal state

The CI failure on testForcedNamespaceDeleteWithInflightCompaction exposed a
second cause of forced topic/namespace deletion hanging (issue apache#24148).

RawConsumerImpl.receiveRawAsync() enqueued a pending receive without checking
the consumer state. When a topic/namespace is force-deleted while a compaction
is in flight, the compaction's RawReader closes on an unrecoverable error
(TopicDoesNotExistException). If phaseTwoLoop issues readNextAsync() right after
that close (it runs on a separate scheduler thread), the receive is enqueued
after failPendingRawReceives() has already drained the queue, so the future
never completes. The compaction future then never completes, and the cursor
deletion in asyncDeleteCursorWithCleanCompactionLedger() - which waits on it -
blocks forever, hanging the deletion.

Re-check the consumer state after enqueueing in receiveRawAsync() and fail the
pending receives if the consumer has reached a terminal state. Re-checking after
the enqueue closes the race with a concurrent close(), which drains the queue
only after moving to a terminal state.

Assisted-by: Claude Code (claude-fable-5)

@void-ptr974 void-ptr974 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the patch and did not find a blocking issue. The failed-compaction handling in PersistentTopic and the terminal-state recheck in RawReaderImpl both look consistent with the deletion race being fixed.

LGTM.

@lhotari
lhotari marked this pull request as draft June 13, 2026 05:17
@lhotari lhotari changed the title [fix][broker] Fix forced topic deletion failing permanently after a failed compaction [fix][broker] Fix forced topic/namespace deletion hanging or failing when compaction is in progress Jun 13, 2026
@lhotari
lhotari marked this pull request as ready for review June 13, 2026 05:53
@lhotari
lhotari merged commit ec56aea into apache:master Jun 13, 2026
45 checks passed
lhotari added a commit that referenced this pull request Jun 22, 2026
…when compaction is in progress (#26016)

(cherry picked from commit ec56aea)
lhotari added a commit that referenced this pull request Jun 22, 2026
…when compaction is in progress (#26016)

(cherry picked from commit ec56aea)
sandeep-ctds pushed a commit to datastax/pulsar that referenced this pull request Jul 31, 2026
…when compaction is in progress (apache#26016)

(cherry picked from commit ec56aea)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Compaction of __change_events topic is blocking forceful namespace/topic deletion

3 participants