Skip to content

Stream missing annexed content with remfile, cached by annex key - #1955

Draft
CodyCBakerPhD wants to merge 15 commits into
masterfrom
claude/datalad-fuse-readable
Draft

CodyCBakerPhD wants to merge 15 commits into
masterfrom
claude/datalad-fuse-readable

Conversation

@CodyCBakerPhD

@CodyCBakerPhD CodyCBakerPhD commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

This PR and the one stacked on it (#1956) are an alternative to #1941 and #1933. The goal is the same: dandi validate on a DataLad clone of a Dandiset, streaming file content without downloading it. Here, dandi leaves the git-annex and HTTP work to datalad-fuse's adapter, and datalad-fuse reads NWB/HDF5 content with remfile.

Changes

  • dandi.support.annex_stream.AnnexedReadableFile is a Readable for a locked annexed file whose content isn't present locally.
    • It is backed by datalad-fuse's RemoteFilesystemAdapter, used directly with no FUSE mount. One adapter is shared and closed at exit.
      • caching=False: nothing is written into the clone.
      • backends="remfile,fsspec": NWB/HDF5 files are read with remfile even if a dataset configures datalad.fusefs.backends otherwise.
    • What the adapter does:
      • reads the key from the target of the broken symlink;
      • gets the URLs from git annex whereis, in the order git-annex lists them. For a DataLad Dandiset that is the DANDI Archive's API download URL, which redirects to S3, then the direct S3 URL;
      • opens the first URL that can be read. .nwb/.h5 files go through datalad-fuse's remfile backend.
    • What dandi adds: only a thin io.RawIOBase wrapper around the file object the adapter returns, so it works with h5py (which reads with readinto()) and pynwb.
    • Why remfile: it reads in the calling thread, in requests sized for HDF5's access pattern. To read the structure of a 16 MB NWB file in the tests it downloads under a tenth of the file. fsspec with its default cache downloaded 64 MB, four times the file.
  • get_annexed_readable(path) returns None when:
    • datalad-fuse (with its adapter) or remfile isn't installed;
    • the path isn't a broken symlink to annexed content;
    • git-annex isn't initialized in the repository;
    • datalad-fuse can't handle the repository, e.g. one without commits;
    • the key records no size;
    • git-annex knows no URL for the content.
  • No implicit git annex init. Before handing a file to datalad-fuse, dandi checks git config annex.uuid. So the adapter, which would otherwise run git annex commands that initialize it, is never used in such a clone. This also avoids loading a DataLad dataset for every broken symlink of a fresh git clone. The tests check that a fresh clone keeps no annex UUID.
  • Caching by annex key: get_metadata, get_neurodata_types and nwb_has_external_links use annex_fingerprint as their custom_fingerprint.
    • It wraps fscacher 0.5.0's annex_key_fingerprint, which keys on the path paired with the annex key.
    • Locked annexed files are cached by key, whether they are streamed or present locally. Only content-hash backends count, so WORM and URL keys fall back to stat().
    • Unlike Memoize readable fingerprints #1941, this needs no fingerprint hook on Readable.
  • Dependencies:
    • datalad extra: datalad-fuse with remfile, for now pinned to a commit (see below). git-annex is also required.
    • fscacher >= 0.5.0.
  • Tests:
    • make_git_annex_dandiset() builds a real git-annex repository: content added, one or more URLs registered, then dropped.
    • A range_http_server fixture serves files with range requests from a separate process (dandi/tests/range_http_server.py). No thread of the test process serves requests, so a garbage collection there can't run h5py finalizers while h5py's lock is held waiting for a response. The server exits normally when stopped, so coverage records its process.
    • New tests cover:
      • the file object's seek/read/end-of-file behavior;
      • reading through whichever URL works;
      • the error when no URL can be read;
      • small reads of a large NWB file;
      • an uninitialized clone left untouched;
      • every case in which the content isn't streamed;
      • caching by key.
    • New skipif.no_git_annex condition.
    • A new CI job (mode: datalad-fuse, ubuntu, Python 3.12) installs git-annex and .[datalad]. The other jobs skip these tests.

Depends on unreleased datalad-fuse

This uses the remfile backend and pluggable backends of datalad/datalad-fuse#131, which datalad-fuse hasn't released yet. It works with that PR as it is. Until it is released, the datalad extra pins the PR's head commit in datalad/datalad-fuse:

"datalad-fuse[remfile] @ git+https://github.com/datalad/datalad-fuse@b4dbee656eeb8ed54b1396c8d7e5c8b369b64a4a"

With the pin, pip install "dandi[datalad]" gives working streaming, and CI just installs .[datalad].

Before merging, the pin must be replaced by a requirement on the first datalad-fuse release with #131. PyPI rejects distributions with direct URL dependencies, so a release of dandi with the pin would fail to upload.

With a released datalad-fuse that lacks #131, nothing breaks: get_annexed_readable() returns None and the content is reported as missing.

An init_annex option of the adapter (datalad/datalad-fuse#141), to stop it from initializing git-annex in an uninitialized clone, would be useful to other users of the adapter, but dandi doesn't need it: it checks annex.uuid itself.

Why datalad-fuse's remfile backend

An earlier version of this PR streamed through datalad-fuse's fsspec adapter. That needed three workarounds in dandi:

  • a custom fsspec cache, so HDF5's scattered reads didn't download most of the file;
  • pausing garbage collection while streamed files were open;
  • dandi's own git-annex initialization guard.

The garbage collection pause was needed because fsspec fetches data in a separate I/O thread. If a collection runs in that thread while h5py holds its global lock waiting for the data, any finalizer that calls h5py deadlocks. A patched h5py wasn't enough: hdmf 6.2.0's HDMFIO.__del__ calls is_open(), which waits for the same lock.

remfile fetches data in the reading thread, so with datalad-fuse's remfile backend none of these workarounds is needed. dandi keeps no streaming code of its own beyond a file-object wrapper.

Compared to #1941

746 added lines, 257 excluding tests and the test server, against #1941's 990. It drops AnnexKey, AnnexRepo, the URL-log parsing, the fake-annex fixtures and the Readable.get_fingerprint hook. In exchange, streaming requires git-annex and datalad-fuse with remfile.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Qn5WBSiQgoZoL4fytF6nEr


Generated by Claude Code

@codecov

codecov Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.56710% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.16%. Comparing base (44fd1a6) to head (b75c824).

Files with missing lines Patch % Lines
dandi/tests/range_http_server.py 97.46% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1955      +/-   ##
==========================================
+ Coverage   90.83%   91.16%   +0.33%     
==========================================
  Files          92       96       +4     
  Lines       14190    14648     +458     
==========================================
+ Hits        12889    13354     +465     
+ Misses       1301     1294       -7     
Flag Coverage Δ
unittests 91.16% <99.56%> (+0.33%) ⬆️

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.

@CodyCBakerPhD
CodyCBakerPhD added this pull request to stack #1959 October 12, 2026 21:30
@CodyCBakerPhD CodyCBakerPhD added cmd-validate enhancement New feature or request minor Increment the minor version when merged labels Oct 12, 2026
@CodyCBakerPhD
CodyCBakerPhD force-pushed the claude/datalad-fuse-readable branch 2 times, most recently from 2d461dd to 608f3ab Compare October 15, 2026 17:58
@CodyCBakerPhD
CodyCBakerPhD removed this pull request from stack #1959 October 15, 2026 18:34
@CodyCBakerPhD CodyCBakerPhD changed the title Read missing annexed content with datalad-fuse, cached by annex key Stream missing annexed content with remfile, cached by annex key Oct 15, 2026
@CodyCBakerPhD
CodyCBakerPhD added this pull request to stack #1965 October 15, 2026 19:28
claude added 15 commits October 15, 2026 23:23
Add `dandi.support.datalad_fuse.AnnexedReadableFile`, a `Readable` for a
locked annexed file whose content is not present locally, streamed with
the adapter of datalad-fuse (no FUSE mount), which asks git-annex where
the content is.  It is available with the new `datalad` extra.

Cache NWB metadata of locked annexed files, streamed or local, under
their git-annex key paired with their path, with fscacher 0.5.0's
`annex_key_fingerprint`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Qn5WBSiQgoZoL4fytF6nEr
annex_fingerprint is not specific to NWB files, and living in
dandi.support.datalad_fuse it needs no deferred import.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Qn5WBSiQgoZoL4fytF6nEr
fscacher imports joblib, which imports numpy, so importing it with the
module made the CLI (via dandi.validate in the stacked PR) import numpy,
failing test_no_heavy_imports.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Qn5WBSiQgoZoL4fytF6nEr
h5py holds its global lock while reading from a Python file object, and
the read waits for another thread to fetch the data over HTTP (fsspec's
I/O thread, or an HTTP server in the same process as in the tests).  A
garbage collection in that thread could then run the finalizer of an
unreferenced h5py-backed object (e.g., hdmf's HDF5IO.__del__), which
waits for the same lock: a deadlock, as seen in CI in
test_validate_stream, ended by pytest-timeout with a segfault.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Qn5WBSiQgoZoL4fytF6nEr
datalad-fuse opens the files with fsspec's defaults: 5 MiB of read-ahead,
discarded whenever a read jumps elsewhere.  h5py reads many small pieces
scattered over an NWB file, so each open downloaded about as much as the
whole file (64 MB for the neurodata types of a 16 MB file in the new
test), and dandi opens each file several times during validation.  Read
in cached blocks of 256 KiB instead (1.3 MB for the same file).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Qn5WBSiQgoZoL4fytF6nEr
fsspec's "blockcache" fetches each block of a read with its own request,
so a large read (e.g., of a dataset's data, by NWB Inspector) took one
round trip per 256 KiB, i.e., a couple of MB/s however fast the
connection.  Use instead a block cache that fetches each run of
consecutive missing blocks with a single request, and keeps up to 512
blocks (128 MiB) per open file rather than 32, which h5py's scattered
reads of metadata kept evicting and fetching again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Qn5WBSiQgoZoL4fytF6nEr
Rather than through datalad-fuse's adapter (and fsspec), the content of a
locked annexed file whose content is missing is now streamed by dandi with
remfile, from the URLs git-annex knows for its key:

- The key, and the size of the content, are taken from the target of the
  (broken) symbolic link, and the URLs from `git annex whereis --json
  --key`, run only in repositories in which git-annex is initialized (as
  any `git annex` command would initialize it).
- The content is read from the first URL answering a range request, with
  remfile, which reads in the calling thread, in requests sized for HDF5's
  access pattern: reading the structure of a 16 MB NWB file downloads
  0.6 MB.  No fsspec I/O thread means no deadlock with h5py's lock, so
  garbage collection is no longer paused (`_GCPausedFile`), and the
  coalescing fsspec cache is gone.
- The module is renamed `dandi.support.annex_stream`, and the `datalad`
  extra (datalad-fuse) is replaced by a `streaming` extra (remfile); the CI
  job is renamed accordingly.
- The tests' HTTP server runs in a separate process (dandi.tests.
  range_http_server), so that no thread of the tests' process serving
  requests can run h5py finalizers while h5py's lock is held.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Qn5WBSiQgoZoL4fytF6nEr
…rver

The HTTP server for streaming tests now exits normally when stopped, so
that coverage saves the data of its process.  Test that annexed content
is not streamed when it cannot be (no remfile, not annexed content, no
size in the key, no URL), and that URLs which fail to connect are skipped.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Qn5WBSiQgoZoL4fytF6nEr
…ckend

Rather than dandi reading the keys and URLs of annexed files from
git-annex and streaming them with remfile itself, the content is again
streamed with datalad-fuse's adapter, now that it has a remfile backend
and can be told not to initialize git-annex (datalad/datalad-fuse#131,
and CodyCBakerPhD/datalad-fuse's remfile-dev branch, which CI installs
until those are released):

- The adapter (RemoteFilesystemAdapter, with caching=False and
  init_annex=False) gives the state and key of a file, the URLs git-annex
  knows for it, and opens it from the first URL that can be read, with
  remfile for HDF5-based files such as NWB.  dandi only wraps the file
  object it returns as a raw binary file, for h5py.
- git-annex being initialized is still checked first (`git config
  annex.uuid`), so that a DataLad dataset is not loaded for each broken
  symbolic link of a fresh clone.
- The `streaming` extra is again a `datalad` extra, requiring
  datalad-fuse[remfile], and the CI job is named `datalad-fuse`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Qn5WBSiQgoZoL4fytF6nEr
…ackend

Until datalad-fuse releases its remfile backend and the adapter's
`init_annex` option (datalad/datalad-fuse#131), the `datalad` extra
requires them from CodyCBakerPhD/datalad-fuse at a fixed commit, so that
`pip install "dandi[datalad]"` gives working streaming, and CI installs
just `.[datalad]`.  This must be replaced by a release requirement before
merging, as PyPI rejects distributions with direct URL dependencies.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Qn5WBSiQgoZoL4fytF6nEr
Pass `backends="remfile,fsspec"` to the adapter rather than leaving it to
a dataset's `datalad.fusefs.backends` configuration: a dataset configured
to use fsspec would otherwise have NWB files read by fsspec, which fetches
in another thread, where a garbage collection running h5py finalizers
while h5py's lock is held waiting for the data would deadlock.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Qn5WBSiQgoZoL4fytF6nEr
Pin the datalad extra to the head commit of datalad/datalad-fuse#131 rather
than a fork combining it with an init_annex option of the adapter: dandi
hands files to the adapter only in repositories in which git-annex is
initialized, so the adapter never initializes it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Qn5WBSiQgoZoL4fytF6nEr
h5py holds its global lock while it reads from a Python file object, and
a read of a RemoteReadableAsset waits for fsspec's IO thread to fetch
the data.  If a garbage collection runs in that thread meanwhile, the
finalizer of an unreferenced object closing an HDF5 file (e.g., hdmf's
HDMFIO.__del__ -> HDF5IO.close() -> h5py's File.close()) waits for the
same lock, and neither thread can proceed.  The aiohttp timeouts cannot
end this, as the event loop thread itself is blocked.

This is the hang of test_nwb2asset_remote_asset (#1450), and it made
test_reextract_metadata time out in the lowest-deps CI job of #1955.

RemoteReadableAsset.open() now wraps fsspec's file object in a
_GCPausedFile, which disables automatic garbage collection while any
such file is open (and restores the previous state once all are
closed).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Qn5WBSiQgoZoL4fytF6nEr
@CodyCBakerPhD
CodyCBakerPhD force-pushed the claude/datalad-fuse-readable branch from 478fb93 to b75c824 Compare October 16, 2026 03:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cmd-validate enhancement New feature or request minor Increment the minor version when merged

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants