HDDS-14768. Fix lock leak during snapshot cache cleanup and handle eviction race appropriately.#9869
Conversation
There was a problem hiding this comment.
Pull request overview
Fixes SnapshotCache eviction/cleanup edge cases that could previously throw during cleanup, leak the snapshot DB write lock, or leave stale eviction entries behind—improving correctness and reliability of snapshot purge / checkpoint coordination in Ozone Manager.
Changes:
- Remove snapshot IDs from the pending eviction queue on invalidate, and tolerate stale eviction entries during cleanup.
- Ensure SnapshotCache write lock is released if cleanup throws (including unchecked exceptions).
- Adjust cleanup behavior so snapshot close failures are logged and retried later, with added unit tests covering these races/failures.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 6 comments.
| File | Description |
|---|---|
| hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/snapshot/SnapshotCache.java | Updates eviction bookkeeping, cleanup behavior on stale/missing entries and close failures, and hardens lock() to release locks on exceptions. |
| hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/snapshot/TestSnapshotCache.java | Adds unit tests for stale-eviction cleanup, close-failure retry behavior, and write-lock release when cleanup throws. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
...ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/snapshot/TestSnapshotCache.java
Outdated
Show resolved
Hide resolved
...ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/snapshot/TestSnapshotCache.java
Outdated
Show resolved
Hide resolved
| LOG.warn("SnapshotId '" + k + "' does not exist in cache. The RocksDB " + | ||
| "instance of the Snapshot may not be closed properly."); |
There was a problem hiding this comment.
The new warning for stale eviction keys (v == null) says the RocksDB instance “may not be closed properly”, but this condition can also occur in the expected invalidate + late close race (snapshot was closed and removed from dbMap, and the callback re-queued the UUID). Consider rewording the log to reflect that this can be a benign stale-queue entry, and include guidance only if it indicates a real leak signal.
| LOG.warn("SnapshotId '" + k + "' does not exist in cache. The RocksDB " + | |
| "instance of the Snapshot may not be closed properly."); | |
| LOG.warn("SnapshotId '{}' not found in cache during cleanup. " | |
| + "This can happen if the snapshot was already closed and " | |
| + "removed from the cache, leaving a stale entry in the eviction " | |
| + "queue. If this message appears frequently for the same " | |
| + "snapshot or the cache size keeps growing, it may indicate that " | |
| + "a RocksDB instance was not closed properly."); |
| } else if (v.getTotalRefCount() > 0) { | ||
| LOG.debug("SnapshotId {} is still being referenced ({}), skipping its clean up.", k, v.getTotalRefCount()); | ||
| return v; | ||
| result = v; | ||
| } else { | ||
| LOG.debug("Closing SnapshotId {}. It is not being referenced anymore.", k); | ||
| // Close the instance, which also closes its DB handle. | ||
| try { | ||
| v.get().close(); | ||
| } catch (IOException ex) { | ||
| throw new IllegalStateException("Error while closing snapshot DB.", ex); | ||
| LOG.error("Error while closing snapshot DB.", ex); | ||
| return v; | ||
| } | ||
| omMetrics.decNumSnapshotCacheSize(); | ||
| return null; | ||
| } | ||
| pendingEvictionQueue.remove(k); | ||
| return result; | ||
| }); |
There was a problem hiding this comment.
In cleanup(evictionKey, ...), the pending eviction key is removed even when the snapshot is still referenced (totalRefCount > 0). If another thread decrements the refcount to 0 and the callback re-adds the key concurrently, this removal can win and drop the key permanently, leaving an unreferenced snapshot in dbMap that will never be retried for cleanup. Consider either not removing the key when refcount > 0, or removing it conditionally and re-adding if the refcount becomes 0 after the decision (or otherwise synchronizing queue updates).
hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/snapshot/SnapshotCache.java
Outdated
Show resolved
Hide resolved
| private UncheckedAutoCloseableSupplier<OMLockDetails> lock(Supplier<OMLockDetails> lockFunction, | ||
| Supplier<OMLockDetails> unlockFunction, Supplier<Boolean> cleanupFunction) { | ||
| Supplier<OMLockDetails> emptyLockFunction = () -> getEmptyOmLockDetails(lockFunction.get()); | ||
| Supplier<OMLockDetails> emptyUnlockFunction = () -> getEmptyOmLockDetails(unlockFunction.get()); | ||
|
|
||
| AtomicReference<OMLockDetails> lockDetails = new AtomicReference<>(emptyLockFunction.get()); | ||
| if (lockDetails.get().isLockAcquired()) { | ||
| if (!cleanupFunction.get()) { | ||
| try { | ||
| if (!cleanupFunction.get()) { | ||
| lockDetails.set(emptyUnlockFunction.get()); | ||
| } | ||
| } catch (Throwable t) { | ||
| lockDetails.set(emptyUnlockFunction.get()); | ||
| throw t; | ||
| } |
There was a problem hiding this comment.
SnapshotCache.lock(...) can return a supplier whose OMLockDetails reports lock not acquired when cleanupFunction returns false (eg, cache not drained). The public lock() / lock(UUID) Javadocs currently describe the lock as ensuring thread-safety, but don’t mention that callers must check isLockAcquired() (some call sites use try-with-resources without checking). Consider either documenting this contract explicitly here or throwing when cleanup can’t satisfy the precondition so callers can’t proceed without the intended lock.
There was a problem hiding this comment.
Updated the patch to throw an exception when cleanup returns false so that callers do not proceed without the lock.
hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/snapshot/SnapshotCache.java
Outdated
Show resolved
Hide resolved
| Field f = SnapshotCache.class.getDeclaredField("pendingEvictionQueue"); | ||
| f.setAccessible(true); | ||
| return (Set<UUID>) f.get(cache); |
There was a problem hiding this comment.
Using reflection to access private field in SnapshotCache is unstable. Why not just expose it via a getPendingEvictionQueue() method in SnapshotCache that has @VisibleForTesting annotation?
| * @param key SnapshotId | ||
| */ | ||
| public void invalidate(UUID key) { | ||
| dbMap.compute(key, (k, v) -> { |
There was a problem hiding this comment.
SnapshotCache.invalidate() is called during OMSnapshotPurgeResponse.
| } | ||
| omMetrics.decNumSnapshotCacheSize(); | ||
| } | ||
| pendingEvictionQueue.remove(k); |
There was a problem hiding this comment.
pendingEvictionQueue may have the entry k, if snapshot purge response is happening after, and all references of the snapshot is decremented (releasing the SnapshotCache lock of the key), but before the periodic cleanup thread kicks in.
There was a problem hiding this comment.
note: this case should not happen during checkpointing, because checkpoints holds a write lock of the snapshot cache, and once released, it invokes cleanup() immediately.
|
|
||
| AtomicReference<OMLockDetails> lockDetails = new AtomicReference<>(emptyLockFunction.get()); | ||
| if (lockDetails.get().isLockAcquired()) { | ||
| if (!cleanupFunction.get()) { |
There was a problem hiding this comment.
the lock is released by calling emptyUnlockFunction.get(), if cleanup operation was not successful or if it throws a Throwable.
|
Thanks for the review @sadanand48 , @smengcl and @jojochuang I addressed your comments and relevant copilot suggestions as well Additional updates:
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/snapshot/SnapshotCache.java
Show resolved
Hide resolved
| if (!cleanupFunction.get()) { | ||
| lockDetails.set(emptyUnlockFunction.get()); | ||
| throw new IllegalStateException("Failed to acquire lock as cleanup did not drain the cache."); | ||
| } |
There was a problem hiding this comment.
lock(...) now throws an IllegalStateException when cleanup does not fully drain the cache. Since this is a public API and some callers (e.g., checkpoint streaming) may not be expecting a runtime exception here, it would be safer to either (a) document this behavior explicitly in the lock()/lock(UUID) javadocs, or (b) preserve the previous contract by returning non-acquired lock details instead of throwing.
| LOG.warn("SnapshotId '{}' does not exist in cache. The RocksDB " + | ||
| "instance of the Snapshot may not be closed properly.", k); |
There was a problem hiding this comment.
cleanup() now treats a missing cache entry as non-fatal (to handle stale eviction keys), but still logs it at WARN with a message implying a RocksDB resource leak. Since this case is now expected/benign (see the new stale-eviction-key test), consider lowering this to DEBUG/INFO and/or updating the message to reflect that the key may have been invalidated and already closed.
| LOG.warn("SnapshotId '{}' does not exist in cache. The RocksDB " + | |
| "instance of the Snapshot may not be closed properly.", k); | |
| LOG.debug("SnapshotId '{}' is not present in cache during cleanup; " | |
| + "it may have already been invalidated, closed, and removed.", k); |
| @@ -377,26 +388,25 @@ private synchronized Void cleanup(UUID evictionKey, boolean expectKeyToBePresent | |||
| } | |||
There was a problem hiding this comment.
this means the entry in the SnapshotCache is removed before cleanup() is called.
it should not happen during Snapshot Purge Response, because it explicitly invalidate snapshot cache.
| } | ||
|
|
||
| if (v.getTotalRefCount() > 0) { | ||
| LOG.warn("SnapshotId '{}' does not exist in cache. The RocksDB " + |
There was a problem hiding this comment.
this scenario is possible if SnapshotDeletingService closes the reference to the snapshot after the snapshot is deleted, which is entirely possible.
| LOG.warn("SnapshotId '{}' does not exist in cache. The RocksDB " + | ||
| "instance of the Snapshot may not be closed properly.", k); |
There was a problem hiding this comment.
i'd suggest to make this warning message even more benign, as suggested by Copilot. We do not want to spook users.
In fact, since this is expected. We could reduce the log level to INFO.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/snapshot/SnapshotCache.java
Show resolved
Hide resolved
| throw new IllegalStateException("Failed to acquire lock as cleanup did not drain the cache."); | ||
| } | ||
| } catch (Throwable t) { | ||
| lockDetails.set(emptyUnlockFunction.get()); |
| @DisplayName("Close failure keeps snapshot in eviction queue for retry") | ||
| void testCloseFailureRetriesSnapshot() throws Exception { | ||
|
|
||
| snapshotCache = new SnapshotCache(cacheLoader, CACHE_SIZE_LIMIT, omMetrics, 0, true, newAcquiringLock()); |
| assertTrue(snapshotCache.getPendingEvictionQueue().contains(snapshotId)); | ||
|
|
||
| // First cleanup attempt fails to close; entry should remain in dbMap and key should stay queued for retry. | ||
| assertThrows(IllegalStateException.class, () -> snapshotCache.lock()); |
|
lgtm. I'll commit with the few suggestions Copilot added. |
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Change-Id: I8a8dc358745d0dbcce0623b8cda37718de9e76ac
Change-Id: Ibb1c44266a13dc1f10d222b1dbc4a5fbd27a19c0
|
Merged. Thanks @SaketaChalamchala |
What changes were proposed in this pull request?
Currently,
IllegalStateExceptionwhen it finds stale entries inpendingEvictionQueuefor snapshots that have already been removed from dbMapEx., say SnapshotPurge invalidates the entry right before the last thread with a reference to the snapshot just closes adding the snapshotID back to the evictionQueue
invalidateremoves snapshot entry from dbMap but does not remove it frompendingEvictionQueueif it exists.dbMapunlesssome other thread explicitly invalidates it or references it again. This means
SnapshotCache.lock()during this time cannot hold the write lock becauselock()expects the cache to be drained.cleanup(true)throws an exception write lock is not released inSnapshotCache.lock()Proposed solution:
SnapshotCache.lock()What is the link to the Apache JIRA
https://issues.apache.org/jira/browse/HDDS-14768
(Please replace this section with the link to the Apache JIRA)
How was this patch tested?
Unit tests.