Skip to content

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

Draft
CodyCBakerPhD wants to merge 16 commits into
claude/determined-wright-5tcr6nfrom
claude/datalad-fuse-readable
Draft

CodyCBakerPhD wants to merge 16 commits into
claude/determined-wright-5tcr6nfrom
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 0.8.0 with remfile, for now pinned to its git tag (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 datalad-fuse 0.8.0, not yet on PyPI

This uses the remfile backend and pluggable backends of datalad/datalad-fuse#131, released in datalad-fuse 0.8.0. That release is tagged on GitHub but isn't on PyPI, where the latest release is still 0.5.1. Until it is, the datalad extra pins the tag:

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

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

Before merging, the pin must be replaced by datalad-fuse[remfile] >= 0.8.0, once 0.8.0 is on PyPI. PyPI rejects distributions with direct URL dependencies, so a release of dandi with the pin would fail to upload.

With an older datalad-fuse, without the remfile backend, nothing breaks: get_annexed_readable() returns None and the content is reported as missing.

The adapter's init_annex option (datalad/datalad-fuse#141, also in 0.8.0), which stops it from initializing git-annex in an uninitialized clone, isn't used: dandi checks annex.uuid itself, before loading a dataset.

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

@codecov

codecov Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.47644% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.49%. Comparing base (c1064f3) to head (03b825f).

Files with missing lines Patch % Lines
dandi/tests/range_http_server.py 97.46% 2 Missing ⚠️
Additional details and impacted files
@@                         Coverage Diff                         @@
##           claude/determined-wright-5tcr6n    #1955      +/-   ##
===================================================================
+ Coverage                            91.28%   91.49%   +0.21%     
===================================================================
  Files                                   96       99       +3     
  Lines                                14819    15198     +379     
===================================================================
+ Hits                                 13527    13905     +378     
- Misses                                1292     1293       +1     
Flag Coverage Δ
unittests 91.49% <99.47%> (+0.21%) ⬆️

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
@CodyCBakerPhD
CodyCBakerPhD force-pushed the claude/datalad-fuse-readable branch from 478fb93 to b75c824 Compare October 16, 2026 03:23
@CodyCBakerPhD
CodyCBakerPhD force-pushed the claude/datalad-fuse-readable branch 2 times, most recently from dc550fb to 3c02fee Compare November 4, 2026 20:51
@CodyCBakerPhD
CodyCBakerPhD removed this pull request from stack #1965 November 9, 2026 07:13
@CodyCBakerPhD
CodyCBakerPhD changed the base branch from master to claude/determined-wright-5tcr6n November 9, 2026 07:13
@CodyCBakerPhD
CodyCBakerPhD added this pull request to stack #1982 November 9, 2026 07:13
claude added 10 commits November 9, 2026 02:13
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qn5WBSiQgoZoL4fytF6nEr
As documented in datalad/datalad-fuse#138.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qn5WBSiQgoZoL4fytF6nEr
Since the previously pinned commit, datalad/datalad-fuse#131 merged
datalad-fuse 0.7.0 (mfusepy rather than fusepy) and updated docs and
tests; the adapter API used by dandi is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qn5WBSiQgoZoL4fytF6nEr
…kend

datalad-fuse 0.8.0 includes datalad/datalad-fuse#131 (remfile backend)
and #141 (`init_annex` option of the adapters), but is so far only
tagged on GitHub, not uploaded to PyPI, so the `datalad` extra still
needs a direct URL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qn5WBSiQgoZoL4fytF6nEr
@CodyCBakerPhD
CodyCBakerPhD force-pushed the claude/datalad-fuse-readable branch from 9fb1887 to 03b825f Compare November 9, 2026 07:13
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