Repository navigation
fix(store): run every LocalStore filesystem call off the event loop - #4353
Conversation
LocalStore.get and .set already hand their I/O to asyncio.to_thread, but open/_open, clear, delete, delete_dir, list, list_prefix, list_dir, move and getsize still called pathlib/shutil directly inside `async def`, stalling every task that shares the loop for the duration of a stat, a directory walk or an rmtree. Each of those now runs exactly one small module-level sync helper via to_thread; the synchronous methods (_ensure_open_sync, delete_sync) call the same helpers directly so the two paths cannot drift. Because _open now suspends, concurrent lazy opens (every `set` of a `set_many` on a store that has not been opened yet) all pass the `_is_open` check and the second to finish hit Store._open's "already open" error. LocalStore._ensure_open is overridden to tolerate that race; explicit double-open still raises as the conformance suite requires. The LocalStore test class gains an autouse fixture that patches the os/io entry points every pathlib and shutil helper bottoms out in and fails the test if any of them is reached on a running loop's thread from inside a LocalStore coroutine or async generator (calls from asyncio.to_thread workers, and from the store's synchronous methods, are allowed). Ruff's ASYNC rules cannot see these calls because it does not infer `self.root / key` to be a Path. Before this fix the detector flagged open/_open (also reached lazily via get and set), clear, delete, delete_dir, list, list_prefix, list_dir, move and getsize. A companion test asserts the hooks really observe the store's I/O in worker threads so the detector cannot pass vacuously, and another pins the concurrent lazy-open race. list_prefix now materializes its directory walk in the worker thread, as list already did; the key relativization uses str.removeprefix instead of str.replace, which also removed any later occurrence of the root path inside a key. Assisted-by: ClaudeCode:claude-fable-5-1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review nits: patch os.open, os.fsync, os.utime and os.access as well so the blocking-call detector is not tied to the calls LocalStore makes today; note on LocalStore._ensure_open that it calls Store._open directly and so bypasses a subclass's _open override on lazy open; use single backticks in the changelog fragment and the _list_files docstring. Assisted-by: ClaudeCode:claude-fable-5-1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Assisted-by: ClaudeCode:claude-fable-5-1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #4353 +/- ##
==========================================
+ Coverage 94.33% 94.35% +0.01%
==========================================
Files 93 93
Lines 13127 13131 +4
==========================================
+ Hits 12384 12390 +6
+ Misses 743 741 -2
🚀 New features to boost your workflow:
|
Documentation build overview
24 files changed ·
|
… detector; cover delete_dir on a file coverage.py resolves a source file's path with os.path.realpath the first time its tracer sees code from that file, on whatever thread runs that code. The event-loop detector patched os.lstat and attributed those calls to the LocalStore coroutine that happened to be executing, so the LocalStore suite errored whenever it ran under coverage with a fresh tracer. Calls carrying coverage's own frames are now ignored. Also add the missing error-case test for delete_dir on a prefix that names a file. Assisted-by: ClaudeCode:claude-fable-5-1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Merging this PR will improve performance by 10.38%
Performance Changes
Tip Curious why performance improved? Comment Comparing Footnotes
|
Resolves the import conflict in tests/test_store/test_local.py: main's LocalStore rename-retry tests (zarr-developers#4358, zarr-developers#4359) import `time`, this branch's event-loop detector imports `dataclass`/`field` and typing names. Both are kept. The retry helpers main added (`_move_with_retry`, `_atomic_write`) are only reached from `_put`, which this branch already runs under `asyncio.to_thread`, so their `time.sleep` blocks a worker thread rather than the event loop and the detector stays quiet. Main's new tests are module-level, so the class-scoped autouse detector does not apply to them. Assisted-by: ClaudeCode:claude-opus-5 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Resolves the conflict in `LocalStore.get`, where main's zarr-developers#4363 removed the redundant `assert isinstance(key, str)` on a line adjacent to this branch's switch from the inline `if not self._is_open: await self._open()` to `await self._ensure_open()`. Both changes are kept: the lazy open goes through the race-tolerant `_ensure_open`, without the assert. The four other asserts zarr-developers#4363 removed from this file merged cleanly, and the S101 lint it enabled is satisfied - this branch adds asserts only under tests/, which the rule excludes. Assisted-by: ClaudeCode:claude-opus-5 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Parametrize the non-vacuity test over all fifteen async entry points instead of only get and set. Doing so showed exists and getsize were invisible to the detector: they handed a bare Path method to to_thread, so no _local.py frame was on the worker stack. Both now go through module helpers like every other method. Set the open flag directly in _ensure_open rather than re-checking and calling Store._open, and reword the comment so it no longer reads as if Store._open's error is what flips the flag. Assisted-by: ClaudeCode:claude-opus-5-5
…ector On Windows, ntpath binds isfile, isdir, islink, exists and lexists to nt builtins that never call os.stat, and from Python 3.14 Path.is_file, is_dir and exists go through them. The detector was therefore blind to those checks on Windows, which the new exists case caught on the windows-latest / py3.14 job. Assisted-by: ClaudeCode:claude-opus-5-5
AI-authored PR that does what it says. Keeps expensive
LocalStoreIO off the event loop withto_thread. I'm going to benchmark this to ensure that nothing insane happens.🤖 AI text below 🤖
Summary
LocalStore.getandsetalready run their disk I/O in a worker thread viaasyncio.to_thread, but nine other async methods did not:open/_open(and so the lazy open reached fromget/set),clear,delete,delete_dir,list,list_prefix,list_dir,moveandgetsizeall calledpathlib/shutil/osdirectly from the event loop thread. Walking or modifying a directory tree therefore stalled every other task sharing the loop. Ruff'sASYNCrules cannot see these, since they cannot infer thatself.root / keyis aPath.Each method now delegates to one small synchronous helper run under
asyncio.to_thread. Exception types and ordering are unchanged. Two things fell out of that:_opensuspends, concurrent lazy opens (for example fromset_many) hit the ABC's "store is already open" error.LocalStore._ensure_opennow verifies the root and sets the open flag directly, both of which are idempotent, so concurrent lazy opens cannot conflict; an explicit second_open()still raises as the conformance suite requires.list_prefixmaterializes its walk in the thread (aslistalready did) and usesstr.removeprefixinstead ofstr.replace, which also fixes a latent key-mangling case when the root path recurs inside a key.Tests
tests/test_store/test_local.pygains an autouse detector fixture for theLocalStoreconformance class: it patches theosandioentry points everypathlib/shutilhelper bottoms out in and records a violation whenever one is reached on the event-loop thread from inside a_local.pycoroutine. On unfixedmainit flags all nine methods above. A companion test, parametrized over every asyncLocalStoremethod (open, lazy open,get,get_partial_values,set,set_if_not_exists,exists,getsize,delete,delete_dir,list,list_prefix,list_dir,clear,move), asserts the hooks really observe each method's I/O off-loop, so the detector cannot pass vacuously. Extending it beyondget/setshowed thatexistsandgetsizewere invisible to the detector: they passed a barePathmethod toto_thread, leaving no_local.pyframe on the worker stack. Both now go through module helpers like the other methods.test_concurrent_lazy_openpins the race.Full
tests/suite on the merged branch: 8787 passed, 1291 skipped, 4 xfailed. mypy, ruff and ruff-format clean.Interaction with changes that landed on main since this was opened
Both touch the code this PR moves off the loop, so to spell out why neither is weakened:
LocalStorerename when the destination is busy)._move_with_retryand_atomic_writeare reached only from_put, which this branch already ran underasyncio.to_thread, so the retry'stime.sleepblocks a worker thread rather than the event loop. This is checked rather than assumed: the detector patchesos.rename,os.replace,os.linkandos.unlink, which is exactly what those helpers bottom out in, so the new retry path is policed like everything else. The retry's own tests are module-level and therefore outside the class-scoped detector fixture, so the two do not interfere in either direction.assert isinstance(key, str)it removed from_local.pystay removed; one of them sat on the line where this branch switchesgettoawait self._ensure_open(), which is the only place the two needed reconciling. TheS101lint that PR enabled is satisfied, since the asserts this branch adds are all undertests/, which the rule excludes.Benchmarks
CodSpeed on the pre-merge head: one benchmark improved 10.38% (
test_sharded_morton_single_chunk[(30, 30, 30)-memory]), 112 untouched, no regressions — so the extrato_threadhops cost nothing measurable. The run against the merged head should repost on this push.Split out of d-v-b#191, whose remaining content is lint-rule enablement.
🤖 Generated with Claude Code