Skip to content

Make table deletion mutually exclusive with a deletion marker znode - #19254

Open
guruguha wants to merge 5 commits into
apache:masterfrom
guruguha:fix-table-deletion-atomicity
Open

Make table deletion mutually exclusive with a deletion marker znode#19254
guruguha wants to merge 5 commits into
apache:masterfrom
guruguha:fix-table-deletion-atomicity

Conversation

@guruguha

@guruguha guruguha commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Description

DELETE /tables/{tableName} has no mutual exclusion. The endpoint is not @LeaderOnly, so any controller can serve it, and PinotHelixResourceManager.deleteTable() is not synchronized (unlike deleteSegment() and addInstance()). Two concurrent deletes of the same table therefore interleave freely, and because the table config is removed last, a POST /tables can also re-create a table while an in-flight deletion is still tearing its metadata down — the new table then loses metadata to the older delete.

Note that synchronized alone cannot fix this: the request can land on any controller, so the exclusion has to be cluster-wide.

What this changes

Deletion marker znode (/TABLE_DELETION_IN_PROGRESS/<tableNameWithType>)

  • deleteTable() acquires a marker before touching any metadata and releases it in a finally. A second concurrent delete of the same table is rejected with IllegalStateException.
  • addTable() refuses to create a table while a valid marker exists for it.
  • The marker records the owning controller and a start timestamp, and is considered stale after 24 hours so a controller that crashes mid-deletion cannot block the table forever.
  • Acquisition uses propertyStore.create() (atomic) for the common case, and a version-checked set() when reclaiming a stale marker, following the existing ZkBadVersionException idiom in ZKMetadataProvider.setSegmentZKMetadata(). A remove-then-create reclaim would be a non-atomic read-modify-write: two controllers could both observe the same stale marker and both believe they acquired it, and it would leave the path briefly empty, which would let addTable() through.
  • Release is ownership-checked. A controller that stalled past the expiry window and was taken over must not delete the new owner's marker on its way out.

Honour the table config's deletedSegmentsRetentionPeriod in deleteTable()

deleteTable() called removeSegmentsFromStoreInBatch() with only the request's retention query parameter, so when that was absent the table's own deletedSegmentsRetentionPeriod was ignored and the cluster-wide default applied. It now falls back to SegmentDeletionManager.getRetentionMsFromTableConfig(), which is the existing helper used by the deleteSegments(..., TableConfig) path and which tolerates an empty or malformed period rather than throwing.

Post-deletion metadata audit (warn only)

After a delete completes, the property store is checked for artifacts that deleteTable() should have removed, and anything left behind is logged at warn for an operator to clean up.

This deliberately does not throw: by that point the table config is already gone, so the delete has succeeded from the caller's point of view, and failing the request would report a misleading error for a table that no longer exists. It also deliberately does not check the ExternalView, which Helix removes asynchronously after the IdealState drops and which is therefore routinely still present at that point. Tier instance partitions, minion task metadata and materialized view metadata are not audited, because covering them would mean scanning every instance-partitions / task znode in the cluster on every delete; this is called out in the javadoc.

Status codes

Both guards report 409 CONFLICT rather than 500. A concurrent delete, or a create racing an in-flight delete, is expected and retryable, not a server fault, and a 500 would register as an outage in monitoring. addTable() already rethrows ControllerApplicationException untouched; the DELETE handler wrapped every exception into a 500, so it now rethrows a deliberate status rather than downgrading it. PinotHelixResourceManager already throws ControllerApplicationException elsewhere, so this follows the existing pattern.

Ordering

The materialized-view dependency check now runs before the marker is acquired, so a refused delete does not leave a marker that makes an unrelated addTable() report a deletion in progress.

Minor: segment move loop in SegmentDeletionManager

The retention > 0 path previously called a per-segment helper that did an exists() check before each move and logged one line per segment. getFileToDeleteURI() already establishes that the file exists, so the inner check is redundant; removing it saves one round trip per segment and the logging is now aggregated. PinotFS has no batch move, so this is still one move() per segment — the saving is one exists() call and one log line per segment, not an order-of-magnitude change. Also fixed: touch() is now only called on destinations whose move actually succeeded, since touching a failed destination can materialize an empty object on object stores and leave a phantom entry for the retention manager to track.

What this does NOT fix

  • The addTable() guard is still TOCTOU. The marker check is a point-in-time read; a delete can begin immediately after it passes. This narrows the window substantially but does not close it. Closing it properly needs table epoch / creation-UUID fencing, which is not in this PR.
  • Removal of the final table config is still unconditional (no compare-and-set on removeResourceConfigFromPropertyStore).
  • DELETE still proceeds when the table does not exist, by design in PinotTableRestletResource. Unchanged here.
  • A stale marker blocks re-creation of that table for up to 24 hours. The error messages name the znode path so an operator can clear it sooner. There is no automatic startup cleanup; metrics for active/expired markers would be a reasonable follow-up.

Testing

  • TableDeletionMarkerTest (new, 16 tests) against an embedded ZkStarter server: acquisition, duplicate rejection, marker contents, expiry boundaries, malformed markers, stale takeover, ownership-checked release, per-table independence, and two controllers racing to acquire the marker via two independent property stores.
  • SegmentDeletionManagerTest: added a regression test for the touch-on-failed-move bug, verified to fail before the fix and pass after. Existing 8 tests still pass.
  • PinotTableRestletResourceTest (27 existing tests) passes, covering the modified DELETE handler including the 404-when-the-table-does-not-exist path.
  • Verified locally on JDK 25: pinot-common and pinot-controller compile, the above suites pass, checkstyle reports 0 violations, license:check clean, spotless:apply applied.

Known gap in test coverage: the single-winner test asserts the invariant but is not a regression test for the non-atomic takeover it replaced. That window is sub-millisecond and could not be hit reliably from a test, and a ZK data watch cannot distinguish remove-then-create from an in-place set because zkclient dispatches on the node's state at event-processing time. The takeover's correctness rests on the ZooKeeper version check itself rather than on a red-then-green test, and is worth reviewing on that basis.

Backward compatibility / operational notes

  • Adds one new top-level property store path, /TABLE_DELETION_IN_PROGRESS, matching the existing convention (/SEGMENTS, /MINION_TASK_METADATA, …). Nothing in the repo enumerates the property store root, so no existing tooling is affected.
  • New user-visible failure modes: POST /tables and DELETE /tables/{tableName} can now fail with HTTP 409 CONFLICT and "a deletion is in progress" where they previously proceeded. This is the intended behaviour change and may warrant the release-notes label.
  • No public API removals. ZKMetadataProvider.removeTableDeletionMarker takes an owner id (added in this PR, no external callers).

Files changed

  • pinot-common/src/main/java/org/apache/pinot/common/metadata/ZKMetadataProvider.java
  • pinot-common/src/test/java/org/apache/pinot/common/metadata/TableDeletionMarkerTest.java (new)
  • pinot-controller/src/main/java/org/apache/pinot/controller/helix/core/PinotHelixResourceManager.java
  • pinot-controller/src/main/java/org/apache/pinot/controller/helix/core/SegmentDeletionManager.java
  • pinot-controller/src/main/java/org/apache/pinot/controller/api/resources/PinotTableRestletResource.java
  • pinot-controller/src/test/java/org/apache/pinot/controller/helix/core/util/SegmentDeletionManagerTest.java

Suggested labels: bug, release-notes

Guruguha Marur Sreenivasa added 3 commits August 13, 2026 15:01
…mance

Fixed critical bug where DELETE /tables/{tableName} was not atomic, allowing
concurrent deletions to run in parallel with no mutual exclusion. This could
lead to race conditions, metadata corruption, and inconsistent cluster state.

Key changes:
- Added deletion marker znode-based distributed locking mechanism to prevent
  concurrent table deletions across controllers
- Enhanced addTable() to check for deletion markers and prevent table recreation
  during deletion operations
- Implemented 24-hour expiry on deletion markers to prevent permanent blocking
- Optimized SegmentDeletionManager to batch segment move operations in the
  retention>0 path, reducing deletion time from hours to minutes for large tables
- Added logic to honour table config's deletedSegmentsRetentionPeriod setting
- Implemented post-deletion validation to ensure all table-related znodes are
  successfully removed before marking deletion complete
- Added comprehensive unit tests for deletion marker functionality

The deletion marker approach provides mutual exclusion for table deletions while
allowing for controller failover scenarios through the expiry mechanism. The
validation step ensures data consistency by detecting partial deletions.
The previous commit did not compile, so CI never validated any of it.

- ZKMetadataProvider: getLongField takes a default value and returns a
  primitive, so read the marker start time with a -1 sentinel instead of
  assigning to a Long.
- PinotHelixResourceManager: HelixManager exposes getInstanceName(), not
  getInstanceId().
- Replace validateTableDeletionCompleteness with auditTableDeletionCompleteness.
  Every property store getter it called was invented and does not exist. It now
  uses the real APIs (InstancePartitionsUtils.fetchInstancePartitions,
  ZKMetadataProvider.getSegments, SegmentLineageAccessHelper) and only audits
  artifacts that deleteTable removes with a synchronous write. The ExternalView
  check is dropped because Helix removes it asynchronously, so it is routinely
  still present and would report a false leak. It now logs a warning rather than
  throwing: the table config is already gone by that point, so failing the
  request would report a misleading error for a table that no longer exists.
- SegmentDeletionManager: only touch a destination whose move succeeded.
  Touching a failed destination can materialize an empty object on object
  stores and leave a phantom deleted segment for the retention manager. Failed
  moves are now reported at warn level instead of debug so they stay visible.
  There is no PinotFS.moveBatch, so this path is still one move per segment; it
  saves an exists() round trip and a log line per segment, nothing more.
- Rewrite TableDeletionMarkerTest against an embedded ZkStarter server instead
  of a hard-coded localhost:2181, make the cases order independent, and assert
  marker contents plus real expiry and takeover behaviour.
- Add a SegmentDeletionManagerTest regression test for the touch-on-failed-move
  bug, verified to fail before the fix.
…rsing

Adversarial re-review of the change turned up three real defects.

- createOrTakeoverTableDeletionMarker performed a non-atomic read-modify-write:
  exists, get, remove, create. Two controllers could both observe the same stale
  marker, and the second one's remove would delete the first one's freshly
  created marker, leaving both believing they held the lock and deleting the same
  table concurrently, which is the exact failure this marker exists to prevent.
  It also left the path briefly empty, during which addTable() would allow a
  re-create mid-deletion. Replaced with a version checked set, the same
  ZkBadVersionException idiom already used by setSegmentZKMetadata, so exactly
  one racing controller wins and the marker is never absent.

- removeTableDeletionMarker released the marker unconditionally. A controller
  that stalled past the expiry window and was taken over would delete the new
  owner's marker on its way out. It now takes the expected owner id and is a
  no-op when the marker belongs to someone else.

- deleteTable hand-rolled the table config retention lookup, duplicating
  SegmentDeletionManager.getRetentionMsFromTableConfig. The hand-rolled version
  guarded on null rather than emptiness and had no try/catch, so a table with an
  empty or malformed deletedSegmentsRetentionPeriod would throw out of
  TimeUtils.convertPeriodToMillis and become permanently undeletable. Now calls
  the existing helper: 24 lines become 2 and both cases are handled.

Also moved the materialized view dependency check ahead of marker acquisition so
a refused delete no longer leaves a marker that makes addTable() report a
deletion in progress for a table nobody is deleting.

Tests: added coverage for ownership checked release and for two controllers
racing to acquire the marker (using two independent property stores, since one
ZkHelixPropertyStore serializes its own operations). The single-winner test
asserts the invariant but is NOT a regression test for the non-atomic takeover:
that window is sub-millisecond and could not be hit reliably, and a ZK watch
cannot distinguish remove-then-create from an in-place set because zkclient
dispatches on the node state at event processing time. The takeover's
correctness rests on the ZK version check itself.
@guruguha guruguha changed the title Fix table deletion atomicity bug and optimize segment deletion perfor… Make table deletion mutually exclusive with a deletion marker znode Aug 14, 2026
A concurrent delete, or a create racing an in-flight delete, is an expected and
retryable condition, not a server fault. Both guards threw IllegalStateException,
which PinotTableRestletResource maps to INTERNAL_SERVER_ERROR, so a routine race
surfaced as a 500 and would show up in monitoring as an outage.

Both now throw ControllerApplicationException with Response.Status.CONFLICT.
addTable already rethrows ControllerApplicationException untouched, so it needed
no REST change. The DELETE handler wrapped every exception into a 500, so it now
rethrows ControllerApplicationException first rather than downgrading a
deliberate status.

PinotHelixResourceManager already throws ControllerApplicationException
elsewhere, so this follows the existing pattern in the class.

Verified: PinotTableRestletResourceTest (27 tests, covers the DELETE handler
including the 404-when-absent path), SegmentDeletionManagerTest (9),
TableDeletionMarkerTest (16). Checkstyle and license clean.
@codecov-commenter

codecov-commenter commented Aug 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 75.16340% with 38 lines in your changes missing coverage. Please review.
✅ Project coverage is 66.96%. Comparing base (ffa97d9) to head (7a76958).
⚠️ Report is 7 commits behind head on master.

Files with missing lines Patch % Lines
...ntroller/helix/core/PinotHelixResourceManager.java 62.82% 17 Missing and 12 partials ⚠️
.../controller/helix/core/SegmentDeletionManager.java 79.31% 5 Missing and 1 partial ⚠️
...ache/pinot/common/metadata/ZKMetadataProvider.java 93.18% 3 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master   #19254      +/-   ##
============================================
+ Coverage     66.94%   66.96%   +0.02%     
  Complexity     1423     1423              
============================================
  Files          3453     3453              
  Lines        218858   219029     +171     
  Branches      34787    34825      +38     
============================================
+ Hits         146512   146676     +164     
- Misses        60621    60633      +12     
+ Partials      11725    11720       -5     
Flag Coverage Δ
integration 100.00% <ø> (ø)
integration1 100.00% <ø> (ø)
integration2 0.00% <ø> (ø)
java-25 66.96% <75.16%> (+0.02%) ⬆️
lane-a 100.00% <ø> (ø)
lane-b 0.00% <ø> (ø)
temurin 66.96% <75.16%> (+0.02%) ⬆️
unittests 66.96% <75.16%> (+0.02%) ⬆️
unittests1 57.72% <93.18%> (+0.02%) ⬆️
unittests2 39.03% <60.78%> (+0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Codecov flagged 4 uncovered lines, all in the stale-marker takeover, which is
the load-bearing branch of this change: the version check is the only thing
stopping two controllers from both reclaiming a stale marker.

Extracted the compare-and-set step into takeoverStaleTableDeletionMarker(...,
expectedVersion), marked @VisibleForTesting, so a test can supply a deliberately
stale version. That is exactly the state a losing controller is in, and it makes
the race deterministically testable where the real sub-millisecond interleaving
could not be forced.

Two tests added: a takeover with a superseded version must fail and must leave
the winner's ownership intact, and a takeover with the current version wins.

This also settles a factual question: on a version mismatch ZkHelixPropertyStore
returns false rather than throwing ZkBadVersionException, so the false branch is
the path a losing controller actually takes. The catch is kept because
setSegmentZKMetadata in this same file documents Helix throwing for the same
accessor type, and the comment now records both. Those 3 catch-block lines remain
uncovered by design; they are unreachable for this accessor.

TableDeletionMarkerTest: 16 -> 18 tests, all passing. Checkstyle and license
clean.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants