Skip to content

Fix CombinedLock deadlocks & flaky tests - #11622

Merged
headtr1ck merged 14 commits into
pydata:mainfrom
headtr1ck:fix-combined-lock-leak
Sep 28, 2026
Merged

headtr1ck merged 14 commits into
pydata:mainfrom
headtr1ck:fix-combined-lock-leak

Conversation

@headtr1ck

@headtr1ck headtr1ck commented Sep 27, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #10787

Description:

Since 2026-09-27, the test-py314 (flaky) CI job hangs until GitHub's 6h job limit. The culprit is TestDask::test_dask_roundtrip: every dask thread ends up waiting on a netCDF/HDF5 SerializableLock, a reader thread in NetCDF4ArrayWrapper._getitem and the writer in NetCDF4ArrayWrapper.__setitem__.

Root cause

CombinedLock removed duplicate locks with tuple(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 from NETCDFC_LOCK and HDF5_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_LOCK and HDF5_LOCK are 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 why test_dask_roundtrip has 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

  • CombinedLock acquires its locks in one global order, sorted by the identity of the underlying lock, and releases them in reverse.
  • Unpickled SerializableLocks wrap the same threading.Lock as the original, so they now count as one lock instead of being acquired twice.
  • CombinedLock.acquire releases the locks it already holds when a non-blocking acquire fails.
  • Regression tests in 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.
  • CI hardening, so a hung test can't run for 6 hours again:
    • a timeout-minutes: 60 limit on the test matrix (the slowest jobs normally take about 20 minutes)
    • --timeout-method=thread for 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.rst

AI Disclosure

  • This PR contains AI-generated content.
    • I have tested any AI-generated content in my PR.
    • I take responsibility for any AI-generated content in my PR. Tools: Claude Code with Opus 5.5

[This description was updated by Claude Code on behalf of Michael Niklas]

🤖 Generated with Claude Code

headtr1ck and others added 2 commits September 27, 2026 21:05
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>
@github-actions github-actions Bot added topic-backends Automation Github bots, testing workflows, release automation io labels Sep 27, 2026
headtr1ck and others added 3 commits September 27, 2026 21:12
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>
@headtr1ck headtr1ck changed the title Fix combined lock leak & Flaky tests Fix CombinedLock deadlocks & flaky tests Sep 27, 2026
@headtr1ck headtr1ck added the plan to merge Final call for comments label Sep 28, 2026
@headtr1ck

Copy link
Copy Markdown
Collaborator Author

We should merge that asap to fix the failing flaky tests.
Not sure if this means we can move this test out of flaky?

Comment thread xarray/backends/locks.py

@dcherian dcherian 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.

LGTM. I believe there is an xfail around mfdataset and dask that may pass now.

Any thoughts on this assert: #10787?

Co-authored-by: Deepak Cherian <dcherian@users.noreply.github.com>
@headtr1ck

Copy link
Copy Markdown
Collaborator Author

LGTM. I believe there is an xfail around mfdataset and dask that may pass now.

I will have a look. Thanks.

Any thoughts on this assert: #10787?

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, __del__ raises AssertionError but python prints it as "Exception ignored in del", and the file is never closed.

@headtr1ck

Copy link
Copy Markdown
Collaborator Author

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.
And maybe a second PR to enable strict xfail checking...

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>
@headtr1ck
headtr1ck enabled auto-merge (squash) September 28, 2026 20:43
headtr1ck and others added 4 commits September 28, 2026 22:58
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>
@headtr1ck
headtr1ck merged commit 84b9d3d into pydata:main Sep 28, 2026
44 checks passed
@headtr1ck
headtr1ck deleted the fix-combined-lock-leak branch September 28, 2026 21:46
headtr1ck added a commit to headtr1ck/xarray that referenced this pull request Sep 30, 2026
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>
headtr1ck added a commit that referenced this pull request Oct 1, 2026
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Automation Github bots, testing workflows, release automation io plan to merge Final call for comments topic-backends

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants