Skip to content

Memoize readable fingerprints - #1941

Draft
CodyCBakerPhD wants to merge 10 commits into
masterfrom
claude/memoize-readable-fingerprint
Draft

CodyCBakerPhD wants to merge 10 commits into
masterfrom
claude/memoize-readable-fingerprint

Conversation

@CodyCBakerPhD

@CodyCBakerPhD CodyCBakerPhD commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Split out of #1933 as a generally useful pre-PR enhancing memoization of things like git-annex links

Requires fscacher >= 0.5.0 for memoize_path(custom_fingerprint=...) (con/fscacher#113).

Caching by content fingerprint

fscacher's memoize_path keys its cache on a stat() of the path argument, so it never caches a call made on a non-path-like Readable (there is nothing to stat). Some readables can vouch for their content better than mtime and inode ever could, e.g. with a git-annex key.

  • Readable.get_fingerprint() (dandi/misctypes.py): new optional hook returning a fingerprint of the resource's own content. The default None means "unknown", so existing Readable subclasses are unaffected.
  • pynwb_utils.readable_fingerprint(source): returns (file name, fingerprint) for a Readable with a fingerprint, None otherwise, for use as custom_fingerprint. Paths and path-like readables (LocalReadableFile) keep being cached by stat() as before.
  • Applied to get_metadata, get_neurodata_types and nwb_has_external_links, the memoized functions that already accept a Readable. Each depends only on the one NWB file's own content (no sidecars or other files), so sharing results between files with the same content and name is safe.

Streaming git-annex'ed content: dandi.support.annex

  • Reads the key of an annexed file from its symlink (broken, if the content was not fetched), and the URLs registered for that key from the git-annex branch, using only git (neither git-annex nor DataLad needs to be installed).
  • AnnexReadableFile streams the content on demand from those URLs with fsspec (block cache suited to h5py's random access, first URL that opens wins); get_fingerprint() is the key. So metadata of a streamed file is cached under its key, and shared by files with the same key and name (e.g. across clones of a Dandiset).
  • get_annex_readable(path) returns one for an annexed file, or None for anything else.

Side effect: get_metadata's return annotation claimed dict | None although it never returns None (fixed to dict[str, Any]), and isort reordered a pair of local imports in RemoteReadableAsset.open().

Tested locally with fscacher 0.5.0: the above pass; test_bids_nwb_metadata_integration fails without bids-validator-deno, as on master. flake8, black, isort and mypy clean.

Claude-Session: https://claude.ai/code/session_01HFhMPs6vF5zyeHDvPTvRcA
https://claude.ai/code/session_01Qn5WBSiQgoZoL4fytF6nEr

@CodyCBakerPhD CodyCBakerPhD self-assigned this Sep 27, 2026
@CodyCBakerPhD CodyCBakerPhD changed the title Claude/memoize readable fingerprint Memoize readable fingerprints Sep 27, 2026
@CodyCBakerPhD
CodyCBakerPhD added this pull request to stack #1942 September 27, 2026 20:17
@CodyCBakerPhD CodyCBakerPhD added internal Changes only affect the internal API performance Improve performance of an existing feature labels Sep 27, 2026
@codecov

codecov Bot commented Sep 27, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 84.58244% with 72 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.63%. Comparing base (8b6a1fc) to head (1b99da9).
⚠️ Report is 4 commits behind head on master.

Files with missing lines Patch % Lines
dandi/support/annex.py 62.16% 56 Missing ⚠️
dandi/tests/fixtures.py 75.00% 11 Missing ⚠️
dandi/pynwb_utils.py 50.00% 3 Missing ⚠️
dandi/misctypes.py 66.66% 1 Missing ⚠️
dandi/support/tests/test_annex.py 99.51% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1941      +/-   ##
==========================================
+ Coverage   78.41%   78.63%   +0.22%     
==========================================
  Files          92       94       +2     
  Lines       14130    14590     +460     
==========================================
+ Hits        11080    11473     +393     
- Misses       3050     3117      +67     
Flag Coverage Δ
unittests 78.63% <84.58%> (+0.22%) ⬆️

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.

@yarikoptic

Copy link
Copy Markdown
Member

relying on git-annex'ed key is a great idea, but I feel like it should be implemented within fscacher directly not bolted on dandi-cli. WDYT?

@CodyCBakerPhD

Copy link
Copy Markdown
Contributor Author

@yarikoptic It's entirely up to you - looks like that should be perfectly fine

@yarikoptic
yarikoptic force-pushed the claude/memoize-readable-fingerprint branch from a0b7aff to 9b6930e Compare September 28, 2026 14:38
@yarikoptic

Copy link
Copy Markdown
Member

@yarikoptic It's entirely up to you - looks like that should be perfectly fine

then please submit it as PR against fscacher -- we should be able to manage a quick cycle/release there (I will also do some facelift there now -- seems to be CI having issues)

@CodyCBakerPhD

Copy link
Copy Markdown
Contributor Author

then please submit it as PR against fscacher -- we should be able to manage a quick cycle/release there (I will also do some facelift there now -- seems to be CI having issues)

Raised on con/fscacher#113 and adjusted 'here' to use dev state 'there'

Copy link
Copy Markdown
Contributor Author

codecov/project (78.34%, -0.02%) is red because there is no codecov config, so any drop fails. The patch lines it reports as uncovered are import-time lines: def/class/decorator lines and imports in pynwb_utils.py, misctypes.py, and tests/fixtures.py, which dandi/pytest_plugin.py imports before coverage starts. The one exception is the import fsspec that isort moved within RemoteReadableAsset.open(), which the offline tests don't reach. The bodies of readable_fingerprint, get_fingerprint, and the FingerprintedReadable methods are all exercised (checked locally with --cov). Leaving this as is rather than adding tests for coverage alone.


Generated by Claude Code

Comment thread pyproject.toml Outdated
@yarikoptic
yarikoptic force-pushed the claude/memoize-readable-fingerprint branch from 8efbf33 to da3d4d3 Compare September 30, 2026 00:59
@CodyCBakerPhD
CodyCBakerPhD force-pushed the claude/memoize-readable-fingerprint branch from da3d4d3 to aa299da Compare October 6, 2026 18:01
@CodyCBakerPhD

Copy link
Copy Markdown
Contributor Author

TODO: peel out the Annex fingerprinting and associated tests from 1933 to give this more of a use case

@yarikoptic yarikoptic left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

overall looks ok as a prerequuisite, but pay attention to BIDS side-car potential relevance, and likely should not appear in master independely of where/how it is actually used. May be adding git-annex support here would make it right away useful and pragmatically testable

Comment thread dandi/misctypes.py Outdated
Comment thread dandi/tests/test_pynwb_utils.py Outdated
Base automatically changed from claude/pynwb-utils-drop-legacy-pynwb to master October 6, 2026 18:32
claude added 7 commits October 6, 2026 14:32
fscacher's memoize_path derives its cache key from a stat of the file at
the given path, so it never caches a call made on a `Readable` (there is
nothing to stat).  Some readables can nevertheless vouch for their content
better than mtime/inode ever could, e.g. with a content digest such as a
git-annex key: results computed from one such resource are valid for any
other with the same fingerprint.

- `Readable.get_fingerprint()`: new optional hook returning a content
  fingerprint string, `None` (the default) meaning "unknown, do not cache".
- `pynwb_utils.memoize_source(cache, tokens)`: a decorator that behaves
  exactly like `cache.memoize_path` for path arguments and, for a
  `Readable` with a fingerprint, caches under (file name, fingerprint,
  tokens, other arguments) via `cache.memoize` instead.  The tokens are
  passed explicitly since plain `memoize` does not add them by itself.
- Applied to `get_metadata`, `get_neurodata_types`, and
  `nwb_has_external_links`, the memoized functions that already accept a
  `Readable`.  No behavior change for paths or for readables without a
  fingerprint (still none of them at this point).

Since the decorator preserves the signature, mypy now sees through
`get_metadata`, whose return annotation claimed `dict | None` although it
never returns `None`; fixed to `dict[str, Any]`.  isort also reordered a
pair of local imports in `RemoteReadableAsset.open()` while at it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HFhMPs6vF5zyeHDvPTvRcA
`PersistentCache.memoize(exclude_kwargs=...)` only exists from fscacher 0.4
on; the declared floor is 0.3.0, where the argument is called `ignore`, so
the lowest-deps job failed at import time.  Pick the name from the
signature.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HFhMPs6vF5zyeHDvPTvRcA
joblib 1.3 (the declared floor) mistakes positional arguments beyond the
fingerprint ones for the keyword-only `_source` parameter of the memoized
function ("Keyword-only parameter '_source' was passed as positional
parameter"), which broke `validate(path, readable=...)` under lowest-deps.
Bind the decorated function's other arguments by name instead (defaults
included), which also makes the cache key independent of how they were
passed; the decorated function must not take *args as a consequence.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HFhMPs6vF5zyeHDvPTvRcA
…ze_source

The fingerprint-keyed caching is being upstreamed to fscacher
(con/fscacher#113), where it fits into memoize_path itself: the source
argument is already excluded from joblib's key there, and the cache's
tokens are at hand.  That makes most of `memoize_source` unnecessary: the
separately named `by_fingerprint` function, passing tokens explicitly
(and the tokens refactor it required), the `exclude_kwargs`/`ignore`
shim, and binding the other arguments by name to dodge joblib < 1.4.

What remains is `readable_fingerprint()`, which returns (file name,
fingerprint) for a `Readable` that knows its fingerprint and `None`
otherwise, passed as `content_fingerprint` at the three call sites.

This also restores caching for fingerprint-less `LocalReadableFile`s:
being path-like, memoize_path has always cached them by their stat(), but
`memoize_source` routed every `Readable` away from it.

fscacher is pinned to the PR branch for now; to be replaced with a
version floor once it is released.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qn5WBSiQgoZoL4fytF6nEr
con/fscacher#113 renamed the `memoize_path` argument per review, and now
also consults it for each entry of a directory it fingerprints;
`readable_fingerprint` returns None for those plain paths, so nothing
changes for dandi.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qn5WBSiQgoZoL4fytF6nEr
con/fscacher#113 was merged and released as 0.5.0 (tagged), and its
branch deleted, but the upload to PyPI failed; install from the tag
until 0.5.0 is on PyPI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qn5WBSiQgoZoL4fytF6nEr
fscacher 0.5.0 (with memoize_path(custom_fingerprint=...)) is now on
PyPI, so drop the temporary git pin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qn5WBSiQgoZoL4fytF6nEr
@CodyCBakerPhD
CodyCBakerPhD force-pushed the claude/memoize-readable-fingerprint branch from aa299da to 6b5aaaf Compare October 6, 2026 18:32
claude added 2 commits October 6, 2026 19:22
…ally

Moved over from #1933 without its integration into `dandi validate`:
dandi.support.annex reads the key of an annexed file from its (possibly
broken) symlink and the URLs registered for it from the git-annex
branch, using only git, and AnnexReadableFile streams the content from
those URLs with fsspec.  Its get_fingerprint() is the key, so results
of the functions memoized with readable_fingerprint (metadata, ...) are
cached under it and shared by files with the same key and name.

Tests cover key and URL-log parsing, the git-annex branch lookup,
streaming from file:// and HTTP(S) URLs, and caching by key, using
fixtures that fake a DataLad Dandiset with plain git.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qn5WBSiQgoZoL4fytF6nEr
Per review: a fingerprint covers the bytes of one file only, not its
location nor other files such as BIDS sidecars, so only results computed
from the file alone (and its name, which readable_fingerprint adds) may
be keyed by it.  Also give the repeated-call assertion a message stating
what it checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qn5WBSiQgoZoL4fytF6nEr
Comment thread dandi/misctypes.py
file name); results that depend on other files must not be keyed by
it alone.
"""
return None

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI this returns None in the base class because that is how all current non-annex Readable paths behave (only the annexed paths then override with a fingerprint in their class)

This is now more apparent since the annex class has been moved to this PR

@CodyCBakerPhD

Copy link
Copy Markdown
Contributor Author

@yarikoptic PR should make more sense now the annex support is added to it; still only affecting internal classes, no real outward expose or use until #1933

get_annex_key() re-implemented the parsing of the symlink into the
git-annex object store, and accepted keys of any backend.  A WORM or URL
key does not pin the content, so AnnexReadableFile.get_fingerprint()
could have served cached results for modified content.  Use fscacher's
annex_key_fingerprint(), which accepts content-hash backends only.

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

@yarikoptic yarikoptic left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we really do not want to get that deep into git-annex guts. E.g. to get URLs there is git annex whereis and that is what datalad-fuse uses I believe. Let's try to datalad-fuse's Python interfaces instead: if we run into performance issues, let's see how to address those but again IMHO without looking manually into git-annex branch ATM.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

internal Changes only affect the internal API performance Improve performance of an existing feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants