Repository navigation
Fix CombinedLock deadlocks & flaky tests - #11622
Conversation
CombinedLock.acquire(blocking=False) short-circuited on the first lock it could not take and never released the locks it had already acquired. CachingFileManager.__del__ uses exactly this call when garbage collecting an unclosed file, so a GC in the middle of a dask read/write could leak NETCDFC_LOCK or HDF5_LOCK and deadlock every later netCDF operation. This is what made test_dask_roundtrip flaky, and since platformdirs 4.12.0 shifted GC timing it hangs the flaky CI job until the 6h limit. Co-authored-by: Claude <noreply@anthropic.com>
Add a 60 minute job timeout to the test matrix and use the thread timeout method for the flaky job, so deadlocked tests are killed and reported. Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Claude <noreply@anthropic.com>
CombinedLock deduplicated its locks with a set, so the acquisition order depended on memory addresses and insertion order. The netCDF4 reader lock (NETCDFC + HDF5) and the writer lock built from it (+ file write lock) could therefore take NETCDFC and HDF5 in opposite orders and deadlock each other when dask reads and writes overlap, which is what hangs test_dask_roundtrip in the flaky CI job. Order the locks by the identity of the underlying lock and release them in reverse. Unpickled SerializableLocks wrap the same threading.Lock, so they are now treated as one lock instead of being acquired twice. Co-authored-by: Claude <noreply@anthropic.com>
|
We should merge that asap to fix the failing flaky tests. |
Co-authored-by: Deepak Cherian <dcherian@users.noreply.github.com>
I will have a look. Thanks.
I never found this, thanks for spotting. I think @dschwoerer found the root cause of the issue but the assert won't work properly in all cases. E.g. when an unclosed manager is garbage collected, |
|
I checked the mfdataset tests, there is one that is entirely skipped. But unfortunately it is not fixed with this PR. I will investigate and open a follow-up PR. |
With more open files than file_cache_maxsize, a thread inserting a file into the global cache could evict and close a file that another thread was still reading, e.g. in open_mfdataset(parallel=True). Files are now pinned while used inside CachingFileManager.acquire_context(); a pinned file that gets evicted is closed by its last user instead, or reused if acquired again. Pin files during the metadata load of the scipy, netCDF4 and h5netcdf backends, and un-skip test_open_mfdataset_manyfiles except for netCDF4 with parallel=True, which still fails because of GH9779. Co-authored-by: Claude <noreply@anthropic.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Claude <noreply@anthropic.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CachingFileManager.__del__ closes its file, which takes the pin lock and the file cache's lock. Garbage collection can run it at almost any point, e.g. while the same thread holds the non-reentrant pin lock, which deadlocked, or while holding the pin lock when another thread evicting a file holds the cache's lock and waits for the pin lock. The pin state is now guarded by the reentrant lock of the file cache itself. Co-authored-by: Claude <noreply@anthropic.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Claude <noreply@anthropic.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The acquisition order is based on the ids of the locks, which differ between processes. An unpickled CombinedLock kept the order of the process it was pickled in, so a dask worker could acquire the same locks in opposite orders and deadlock. Also fix a comment that was garbled by an applied suggestion. Co-authored-by: Claude <noreply@anthropic.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Claude <noreply@anthropic.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CombinedLock sorted its locks by id(), but every dask task unpickles its own copies of the locks it uses, so the order differed between tasks. Two threads of a distributed worker could then acquire HDF5_LOCK and a per-file distributed lock in opposite orders and deadlock, which made test_serializable_locks hang in CI since pydata#11622. Sort by a lock hierarchy instead: per-file write locks before the process-wide library locks, and within a level by the token of a SerializableLock or the name of a distributed lock, which are the same for all copies. This also removes duplicate copies of a distributed lock, which would otherwise be acquired twice and deadlock. Co-authored-by: Claude <noreply@anthropic.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Order combined locks by a lock hierarchy that survives pickling CombinedLock sorted its locks by id(), but every dask task unpickles its own copies of the locks it uses, so the order differed between tasks. Two threads of a distributed worker could then acquire HDF5_LOCK and a per-file distributed lock in opposite orders and deadlock, which made test_serializable_locks hang in CI since #11622. Sort by a lock hierarchy instead: per-file write locks before the process-wide library locks, and within a level by the token of a SerializableLock or the name of a distributed lock, which are the same for all copies. This also removes duplicate copies of a distributed lock, which would otherwise be acquired twice and deadlock. * simplify lock sorting order * make DummyLock reentrant --------- Co-authored-by: Claude <noreply@anthropic.com>
Closes #10787
Description:
Since 2026-09-27, the
test-py314 (flaky)CI job hangs until GitHub's 6h job limit. The culprit isTestDask::test_dask_roundtrip: every dask thread ends up waiting on a netCDF/HDF5SerializableLock, a reader thread inNetCDF4ArrayWrapper._getitemand the writer inNetCDF4ArrayWrapper.__setitem__.Root cause
CombinedLockremoved duplicate locks withtuple(set(locks)), so the order in which it acquired them depended on memory addresses and on insertion order when hash slots collide. The netCDF4 backend builds its reader lock fromNETCDFC_LOCKandHDF5_LOCK, and its writer lock from that reader lock plus the file's write lock. With an unlucky address layout, the reader takes NETCDFC → HDF5 while the writer takes HDF5 → NETCDFC. When dask reads the source and writes the target at the same time, each thread holds one lock and waits forever for the other.NETCDFC_LOCKandHDF5_LOCKare created once at import, so their addresses are fixed for a given environment: it either never happens or deadlocks every time reads and writes overlap. That is whytest_dask_roundtriphas long been marked flaky, why it never reproduced locally, and why adding instrumentation made it go away. The only package difference between the last green run and the first hung run was platformdirs 4.11.14 → 4.12.0; importing it shifted the addresses into a bad layout. Nothing in platformdirs itself is wrong.While debugging I also found a second bug:
CombinedLock.acquire(blocking=False)stopped at the first lock it could not take and never released the ones it already held.CachingFileManager.__del__makes exactly this call when garbage collection frees a manager whose file is still open, so a lock could stay held forever.Changes
CombinedLockacquires its locks in one global order, sorted by the identity of the underlying lock, and releases them in reverse.SerializableLocks wrap the samethreading.Lockas the original, so they now count as one lock instead of being acquired twice.CombinedLock.acquirereleases the locks it already holds when a non-blocking acquire fails.test_backends_locks.py. The lock-order test uses fixed hash values to force the reordering; it and the unpickled-lock test fail without the fix.timeout-minutes: 60limit on the test matrix (the slowest jobs normally take about 20 minutes)--timeout-method=threadfor the flaky job. The default signal method only interrupts the main thread; deadlocked worker threads survive and keep the pytest process from exiting.Verification
The flaky job passes on this PR in 1m22s. Every run of it since 2026-09-27 01:46 UTC had hung or crashed on
test_dask_roundtrip.Built the way the netCDF4 backend builds them, 2 in 20000 reader/writer lock pairs acquired NETCDFC and HDF5 in opposite orders before this change, and 0 after.
Locally, the lock, file-manager, netCDF4/h5netcdf/dask backend and distributed tests pass, including the flaky-marked ones.
Tests added
User visible changes documented in
whats-new.rstAI Disclosure
[This description was updated by Claude Code on behalf of Michael Niklas]
🤖 Generated with Claude Code