Skip to content

HDDS-14768. Fix lock leak during snapshot cache cleanup and handle eviction race appropriately.#9869

Open
SaketaChalamchala wants to merge 3 commits intoapache:masterfrom
SaketaChalamchala:HDDS-14768
Open

HDDS-14768. Fix lock leak during snapshot cache cleanup and handle eviction race appropriately.#9869
SaketaChalamchala wants to merge 3 commits intoapache:masterfrom
SaketaChalamchala:HDDS-14768

Conversation

@SaketaChalamchala
Copy link
Contributor

@SaketaChalamchala SaketaChalamchala commented Mar 5, 2026

What changes were proposed in this pull request?

Currently,

  1. Eviction race: SnapshotCache cleanup throws an IllegalStateException when it finds stale entries in pendingEvictionQueue for snapshots that have already been removed from dbMap
    Ex., 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
  2. Inconsistent Bookkeeping: invalidate removes snapshot entry from dbMap but does not remove it from pendingEvictionQueue if it exists.
  3. Potential snapshot leak: Snapshot close failure during cleanup removes the snapshotID from eviction queue and throws an exception. This causes the snapshot to remain in cache even is refCount = 0 and the snapshot entry remains in dbMap unless
    some other thread explicitly invalidates it or references it again. This means SnapshotCache.lock() during this time cannot hold the write lock because lock() expects the cache to be drained.
  4. Write lock leak: Fix write lock leak in SnapshotCache. If the cache drain cleanup(true) throws an exception write lock is not released in SnapshotCache.lock()

Proposed solution:

  1. Handle eviction race appropriately. Log the stale snapshot entry in eviction queue and remove it from the queue.
  2. Remove snapshot entry from eviction queu upon successful invalidation.
  3. Log snapshot close failure during cleanup but do not remove it from eviction queue so that it's cleanup can be retried later.
  4. Catch any unchecked exception during cleanup and release the write lock in 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.

@jojochuang jojochuang marked this pull request as ready for review March 7, 2026 00:04
@smengcl smengcl requested review from Copilot and removed request for aswinshakil, sadanand48 and smengcl March 9, 2026 02:59
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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.

Comment on lines 388 to 389
LOG.warn("SnapshotId '" + k + "' does not exist in cache. The RocksDB " +
"instance of the Snapshot may not be closed properly.");
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
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.");

Copilot uses AI. Check for mistakes.
Comment on lines +390 to 406
} 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;
});
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

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).

Copilot uses AI. Check for mistakes.
Comment on lines 320 to 334
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;
}
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Updated the patch to throw an exception when cleanup returns false so that callers do not proceed without the lock.

Comment on lines +522 to +524
Field f = SnapshotCache.class.getDeclaredField("pendingEvictionQueue");
f.setAccessible(true);
return (Set<UUID>) f.get(cache);
Copy link
Contributor

Choose a reason for hiding this comment

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

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?

@jojochuang jojochuang added the snapshot https://issues.apache.org/jira/browse/HDDS-6517 label Mar 9, 2026
* @param key SnapshotId
*/
public void invalidate(UUID key) {
dbMap.compute(key, (k, v) -> {
Copy link
Contributor

Choose a reason for hiding this comment

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

SnapshotCache.invalidate() is called during OMSnapshotPurgeResponse.

}
omMetrics.decNumSnapshotCacheSize();
}
pendingEvictionQueue.remove(k);
Copy link
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Contributor

Choose a reason for hiding this comment

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

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()) {
Copy link
Contributor

Choose a reason for hiding this comment

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

the lock is released by calling emptyUnlockFunction.get(), if cleanup operation was not successful or if it throws a Throwable.

@SaketaChalamchala
Copy link
Contributor Author

Thanks for the review @sadanand48 , @smengcl and @jojochuang I addressed your comments and relevant copilot suggestions as well

Additional updates:

  • reverted the error log to an exception in cleanup so that it fails fast.
  • Throwing an exception when cleanup function returns false so that callers that do not check for isLockAcquired() do not proceed without the lock.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

snapshot https://issues.apache.org/jira/browse/HDDS-6517

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants