Skip to content

ENH: synchronize remote IO, add free-threaded CI and concurrency docs - #781

Merged
d-chambers merged 6 commits into
devfrom
free-thread-io-ci
Jul 26, 2026
Merged

ENH: synchronize remote IO, add free-threaded CI and concurrency docs#781
d-chambers merged 6 commits into
devfrom
free-thread-io-ci

Conversation

@d-chambers

@d-chambers d-chambers commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Description

The last part of the series superseding #763 (parts 5 and 6 of the plan, combined). It synchronizes the remaining IO state, adds a free-threaded CI job, and writes down the contract the whole series establishes.

Follows #772 (per-entry index map), #773 (two-tier config) and #779 (registries, units, catalogs). Once this merges, #763 can be closed as superseded.

Remote file cache

One lock per resource, created with dict.setdefault (atomic), so two threads never download the same file at once while unrelated downloads still run together. Memoization of resolved paths stays with the lru_cache the base branch already used, which also means a failed download is not memoized and the next caller retries it. clear_remote_file_cache clears that cache and the resource registry.

clear_remote_file_cache is documented as unsynchronized: run it while nothing else is reading remote files. That matches what the base branch did and what the concurrency docs already require of callers.

An earlier revision of this PR did more, and was wrong. It added a management lock, an ExitStack acquiring every download lock during a clear, a thread-local materialization depth guard, and a hand-rolled memo. Two adversarial reviews independently reproduced two failures in it:

  • The memo was published outside the download lock, so the fence did not cover the step that mattered. A clear landing in that window left a memo entry pointing at a deleted file — permanently, for the rest of the session. The fence's own docstring was therefore false.
  • A download hook re-entering materialization for a different resource took the management lock while holding a download lock, inverting the order clear_remote_file_cache acquires them in. That is a hard deadlock, reproduced with both threads stuck.

Fixing the first without inverting the lock order needs a generation counter, to defend a guarantee the docs already say is unsupported. Removing the machinery was the better trade. Both failures are gone in the current revision, checked directly.

IO resources

IOResourceManager gets an instance RLock, so each required type is opened exactly once no matter how many threads ask. The reentrancy is load-bearing: clear_cache holds the lock and calls close_all, which re-acquires it. The contract stays "one manager per IO operation": a handle it returns is not itself safe to use from two threads at once, and that is now stated on the class.

This also fixes a live bug on dev, unrelated to threading. source was memoized with cached_method, which stores into self._cache — the same dict close_all iterates over calling .close(). Touching .source on a manager wrapping an open handle therefore made close_all() close the caller's own source. Dropping the memo fixes that; the walk it replaced is two isinstance checks, and measures faster than the memo it removed (0.20 µs → 0.06 µs).

CI

A standalone TestFreeThreaded workflow runs the suite on CPython 3.14t with PYTHON_GIL=0, after asserting sys._is_gil_enabled() is actually False so a dependency cannot quietly turn it into an ordinary job. It installs only the core dependencies (not every optional one is built for free-threading yet; their tests skip), reuses the existing test-data cache steps, and takes read-only permissions. The ordinary test matrix is untouched.

Docs

docs/recipes/parallelization.qmd gains a "Thread Safety" section covering only what the series actually establishes: which objects threads may share (immutable patches, concurrent spool reads, caller-owned dataframes, read-only coordinate arrays, the synchronized registries and remote cache), which they may not (single-writer state mutation, one open handle per operation, third-party plugin state), and how the two configuration tiers behave across threads. The stale "the GIL means threads won't help" note is updated.

Validation

Against dev at d4a21fc6:

  • Full suite, CPython 3.13: 8230 passed, 89 skipped, 2 xfailed.
  • Full suite, CPython 3.14.6 free-threading build with PYTHON_GIL=0: 7971 passed, 241 skipped, 2 xfailed. That environment runs pandas 3, so it also exercises the copy-on-write path added in ENH: synchronize registries, units, and catalogs for free-threading #779.
  • Non-network suite (what the coverage flag uploads): 8131 passed; patch coverage checked line-by-line against the diff, 0 uncovered added lines.
  • Doctests: 144 passed. pre-commit run --all (including actionlint on the new workflow): passed.
  • Benchmarks: no change across the suite. Micro-benchmarks of the touched paths, best of 9 x 20000 calls:
call dev this PR
IOResourceManager.source 0.20 µs 0.06 µs
IOResourceManager.get_resource(Path) 0.14 µs 0.27 µs
ensure_local_file (already local) 0.93 µs 0.93 µs
ensure_local_file (remote, already cached) 12.1 µs 12.0 µs

The remote warm path is worth spelling out: an intermediate revision replaced the lru_cache with locking alone, which made it 35.1 µs, because every resolution of an already-cached file then re-hashed the id, rebuilt the path, took two locks and stat'ed the file — none of which the download lock needs to protect. Keeping lru_cache avoids all of it. get_resource pays one uncontended lock acquisition per required type; it runs once per file open, against an actual open costing tens of microseconds. source got faster because the removed memo cost more than the two isinstance checks it was avoiding.

New tests are deterministic (barriers and events, no sleeps) and use the in-memory filesystem, so they need no network: concurrent callers download once, distinct resources are proven not to serialize (a shared barrier inside the download would deadlock if they did), a failed download is retried, clearing from inside a materialization raises, and racing get_resource callers share one handle.

Notes

  • The remote-cache tests set configuration with the permanent set_config tier rather than config_context: a newly started thread begins with a fresh context, so a scoped override does not reach worker threads. This is the same trap noted when ENH: two-tier runtime config (permanent set_config + scoped config_context) #773 landed.
  • The free-threaded job now also saves the test-data cache it restores, which the first revision omitted.
  • codex exec is still over quota (until 2026-07-29 16:45), so the counterpart CLI review could not be run for this one either; self-reviewed instead.

Changelog

  • changed: IOResourceManager synchronizes opening and closing its handles, so concurrent callers share one handle per type; a handle it hands back is still not safe to use from several threads at once.

Checklist

I have (if applicable):

  • referenced the GitHub issue this PR closes.
  • documented the new feature with docstrings and/or appropriate doc page.
  • included tests. See testing guidelines.
  • added the "ready_for_review" tag once the PR is ready to be reviewed.

Final part of the series superseding #763.

Remote file cache:
- One download lock per cached resource, so unrelated files still
  download at the same time while callers wanting the same file take
  turns. The locks are never removed, which lets a cache clear hold all
  of them without racing new ones into existence.
- clear_remote_file_cache holds every download lock, and refuses to run
  from inside a materialization (thread-local depth guard).
- A failed download publishes nothing, so the next caller retries it.
- The lru_cache on _materialize_remote_file goes away in favor of the
  locks; the policy checks move into _download_to_cache.

IOResourceManager gets an instance lock so each required type is opened
exactly once. Its source property is no longer memoized into the handle
cache, where close_all could have closed the source itself.

Adds a standalone free-threaded CI job (3.14t, PYTHON_GIL=0, asserting
the GIL really is off) which runs the suite on the core dependencies,
and documents the concurrency contract the series establishes in the
parallelization recipe.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@d-chambers d-chambers added the ready_for_review PR is ready for review label Jul 25, 2026
@d-chambers

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

The PR adds explicit locking for remote-file caching and IOResourceManager, concurrency tests for threads and forks, documentation of thread-safety and configuration behavior, and a GitHub Actions workflow testing with free-threaded CPython.

I/O concurrency

Layer / File(s) Summary
Coordinated remote cache materialization
dascore/utils/remote_io.py
Remote cache memoization, per-resource locks, materialization tracking, coordinated clearing, and fork reinitialization replace lru_cache-based behavior.
Synchronized resource handles
dascore/utils/io.py
IOResourceManager synchronizes handle creation, cache updates, closing, and cleanup with an instance RLock.
Concurrency behavior tests
tests/test_utils/test_io_utils.py
Tests cover concurrent downloads, independent resources, retries, cache clearing, fork handling, and shared resource handles.
Free-threaded execution and guidance
.github/workflows/test_free_threaded.yml, docs/recipes/parallelization.qmd
CI runs tests with the GIL disabled, while documentation describes thread safety, parallelization, and configuration propagation.

Possibly related PRs

Suggested labels: documentation, IO, CI

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title accurately summarizes the main changes: remote IO synchronization, free-threaded CI, and concurrency docs.
Description check ✅ Passed The description follows the template, includes a clear summary and checklist, and provides sufficient detail about the changes.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch free-thread-io-ci

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot added CI continuous integration documentation Improvements or additions to documentation IO Work for reading/writing different formats labels Jul 25, 2026
@codecov

codecov Bot commented Jul 25, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.98%. Comparing base (86dddf5) to head (3b5f75f).

Additional details and impacted files
@@           Coverage Diff           @@
##              dev     #781   +/-   ##
=======================================
  Coverage   99.98%   99.98%           
=======================================
  Files         164      164           
  Lines       17659    17703   +44     
=======================================
+ Hits        17657    17701   +44     
  Misses          2        2           
Flag Coverage Δ
network 48.21% <93.02%> (+0.10%) ⬆️
unittests 99.98% <100.00%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🧹 Nitpick comments (4)
tests/test_utils/test_io_utils.py (2)

1264-1276: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

finally only restores _REMOTE_CACHE_LOCK.

_reinit_remote_cache_locks() also rebinds _REMOTE_KEY_LOCKS and _REMOTE_CACHE_LOCAL, which stay replaced after this test. Harmless today, but it leaks module state into later tests and would mask a depth counter left set by an earlier failure. Restore all three.

♻️ Proposed fix
     def test_fork_handler_replaces_held_locks(self):
         """Locks held at fork time are replaced so the child cannot deadlock."""
         old_lock = remote_io._REMOTE_CACHE_LOCK
+        old_key_locks = remote_io._REMOTE_KEY_LOCKS
+        old_local = remote_io._REMOTE_CACHE_LOCAL
         try:
@@
         finally:
             remote_io._REMOTE_CACHE_LOCK = old_lock
+            remote_io._REMOTE_KEY_LOCKS = old_key_locks
+            remote_io._REMOTE_CACHE_LOCAL = old_local
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_utils/test_io_utils.py` around lines 1264 - 1276, Update
test_fork_handler_replaces_held_locks so its finally block snapshots and
restores _REMOTE_CACHE_LOCK, _REMOTE_KEY_LOCKS, and _REMOTE_CACHE_LOCAL,
preserving all module state even when assertions fail. Keep the existing lock
replacement assertions unchanged.

1210-1223: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Barrier party count implicitly depends on run_in_threads' default count=4.

threading.Barrier(len(resources)) only matches because run_in_threads defaults to 4 threads (tests/conftest.py:114-139). Changing that default turns this into a 30s barrier timeout inside a download. Pass the count explicitly to bind the two together.

♻️ Proposed fix
-        results = run_in_threads(lambda index: ensure_local_file(resources[index]))
+        results = run_in_threads(
+            lambda index: ensure_local_file(resources[index]), count=len(resources)
+        )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_utils/test_io_utils.py` around lines 1210 - 1223, Update
test_distinct_resources_are_not_serialized so run_in_threads receives an
explicit worker count matching len(resources), and use the same count for
threading.Barrier. Do not rely on run_in_threads’s default count.
dascore/utils/remote_io.py (1)

145-161: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

get_remote_cache_path() already recreates the directory.

Per the definition at dascore/utils/remote_io.py:67-71, get_remote_cache_path() does mkdir(parents=True, exist_ok=True), so line 159 creates the directory before rmtree removes it and line 160 re-creates it. Capturing the path once makes the intent (and the fact that the second call is only there for the mkdir side effect) clearer.

♻️ Suggested tidy-up
-        shutil.rmtree(get_remote_cache_path(), ignore_errors=True)
-        get_remote_cache_path().mkdir(parents=True, exist_ok=True)
+        cache_path = get_remote_cache_path()
+        shutil.rmtree(cache_path, ignore_errors=True)
+        cache_path.mkdir(parents=True, exist_ok=True)
         _REMOTE_RESOURCE_CACHE.clear()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dascore/utils/remote_io.py` around lines 145 - 161, Update
clear_remote_file_cache to capture get_remote_cache_path() once before removing
the cache, then pass that captured path to shutil.rmtree and call
get_remote_cache_path() only afterward for its existing directory-recreation
side effect. Preserve the current locking and cache-clearing behavior.
dascore/utils/io.py (1)

278-280: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Sentinel ordering is correct — worth a brief note.

_lock is assigned last in __init__, so its presence implies _cache exists; that's what makes it a valid sentinel. Adding a short comment on line 225 would keep a future reordering of __init__ from silently reintroducing the AttributeError in __del__.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dascore/utils/io.py` around lines 278 - 280, Add a brief comment near the
`_lock` assignment in `__init__` explaining that it is deliberately assigned
last and serves as the sentinel confirming `_cache` exists before `__del__`
calls `close_all()`. Preserve the existing sentinel check and cleanup behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/recipes/parallelization.qmd`:
- Line 38: Update the Spool API references in this recipe to use the fully
qualified dascore.core.spool.BaseSpool form consistently, including map. Add
dascore.utils.remote_io.clear_remote_file_cache to the API-index generation
configuration so the qmd reference resolves.

---

Nitpick comments:
In `@dascore/utils/io.py`:
- Around line 278-280: Add a brief comment near the `_lock` assignment in
`__init__` explaining that it is deliberately assigned last and serves as the
sentinel confirming `_cache` exists before `__del__` calls `close_all()`.
Preserve the existing sentinel check and cleanup behavior.

In `@dascore/utils/remote_io.py`:
- Around line 145-161: Update clear_remote_file_cache to capture
get_remote_cache_path() once before removing the cache, then pass that captured
path to shutil.rmtree and call get_remote_cache_path() only afterward for its
existing directory-recreation side effect. Preserve the current locking and
cache-clearing behavior.

In `@tests/test_utils/test_io_utils.py`:
- Around line 1264-1276: Update test_fork_handler_replaces_held_locks so its
finally block snapshots and restores _REMOTE_CACHE_LOCK, _REMOTE_KEY_LOCKS, and
_REMOTE_CACHE_LOCAL, preserving all module state even when assertions fail. Keep
the existing lock replacement assertions unchanged.
- Around line 1210-1223: Update test_distinct_resources_are_not_serialized so
run_in_threads receives an explicit worker count matching len(resources), and
use the same count for threading.Barrier. Do not rely on run_in_threads’s
default count.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 9b209568-b730-4eb5-8b10-d7784b2cca20

📥 Commits

Reviewing files that changed from the base of the PR and between d4a21fc and 095708b.

📒 Files selected for processing (5)
  • .github/workflows/test_free_threaded.yml
  • dascore/utils/io.py
  • dascore/utils/remote_io.py
  • docs/recipes/parallelization.qmd
  • tests/test_utils/test_io_utils.py

Comment thread docs/recipes/parallelization.qmd
Dropping the lru_cache made every resolution of an already-cached remote
file re-hash the id, rebuild the path, take two locks and stat the file:
12.2us -> 35.1us per call, none of which the download lock needs to
protect. Keep a dict of published paths, checked before that work and
emptied by clear_remote_file_cache, which puts the warm path back at
12.8us. Only successful downloads are recorded, so a failed one is still
retried by the next caller.
The materializer and the cached-path probe each built
cache_root / sha256(remote_id) / name themselves, so they had to agree
by inspection or one would download a file the other could not find.
Both now call one helper.

Deletes _get_remote_cache_dir, which had no callers. Its "pragma: no
cover" is why that went unnoticed: coverage cannot report dead code it
has been told to ignore.

Also drops the pragma from _annotate_handle_path, which the non-network
suite does reach. The remaining pragmas in hdf5.py, chunk_plan.py and
io/core.py were checked the same way and are still needed.
@github-actions

github-actions Bot commented Jul 25, 2026

Copy link
Copy Markdown

✅ Documentation built:
👉 Download
Note: You must be logged in to github and a DASDAE member to access the link.

The file mixed dascore.BaseSpool.map with the
dascore.core.spool.BaseSpool form used by its other links and elsewhere
in the docs. Both resolve, but only one form should appear.
Two adversarial reviews of the previous design both reproduced the same
defect: the memo was published outside the download lock, so the fence
clear_remote_file_cache put up did not cover the step that mattered. A
clear landing in that window left a memo entry pointing at a deleted
file, permanently, for the rest of the session. One review also produced
a hard deadlock: a download hook re-entering materialization for another
resource took the management lock while holding a download lock, which
inverts the order clear_remote_file_cache acquires them in.

Rather than add a generation counter to defend a guarantee the docs
already say is unsupported, this drops the machinery that was buying it:
the management lock, the ExitStack over every download lock, the
thread-local depth guard, and the hand-rolled memo. What remains is one
lock per resource, created with setdefault, plus the lru_cache the base
branch used. That is enough for the property this PR is actually for:
two threads never download the same file at once, unrelated downloads
still run together, and a failed download is retried because lru_cache
does not memoize exceptions.

Clearing is now documented as unsynchronized, matching both the base
branch's behavior and what the concurrency docs already required of
callers.

Measured: an already-cached remote resolution is 12.0us against the base
branch's 12.1us, so the memo the previous commit added is not needed.
@d-chambers
d-chambers merged commit 076e049 into dev Jul 26, 2026
27 of 28 checks passed
@d-chambers
d-chambers deleted the free-thread-io-ci branch July 26, 2026 17:56
@d-chambers d-chambers removed the ready_for_review PR is ready for review label Aug 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CI continuous integration documentation Improvements or additions to documentation IO Work for reading/writing different formats

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant