unify the three archive-as-filesystem implementations - #10023
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #10023 +/- ##
==========================================
+ Coverage 86.20% 86.63% +0.42%
==========================================
Files 96 97 +1
Lines 17455 16912 -543
Branches 2667 2550 -117
==========================================
- Hits 15047 14651 -396
+ Misses 1667 1570 -97
+ Partials 741 691 -50 ☔ View full report in Codecov by Harness. |
|
CI found one real failure ( Root cause is a latent bug in Fixed in addad81, with unit tests for both flavours of a missing chunk and an end-to-end webdav test for downloading a file with a chunk missing. Happy to split that fix into its own PR if you prefer. |
addad81 to
fb5da8f
Compare
|
Moved the So expect this PR's CI to be red until #10024 is merged; I will rebase then. The last run that had both commits was fully green: all 3 FUSE legs, windows, the 4 VM legs, docs, mypy, lint, security, asan/ubsan, CodeQL and codecov (https://github.com/borgbackup/borg/actions/runs/30796758829). |
fb5da8f to
2573ddf
Compare
|
@PhrozenByte if you have time, give this some practical testing, please. |
Practical testing on Linux (podman via
|
| implementation | master | this PR |
|---|---|---|
| llfuse | 1.1 GB/s | 1.1 GB/s |
| pyfuse3 | 522 MB/s | 1.1 GB/s |
| mfusepy (the default) | 291 MB/s | 1.0 GB/s |
For reference, borg extract of the same file takes 1.18 s (~0.9 GB/s) and reading the extracted file from local disk is 4.8 GB/s. So the mount is now roughly at extract speed for all three bindings.
The one regression I found: scattered single-chunk reads are slower. 64 random range reads over a 512 MiB file, mfusepy: 0.32-0.34 s on master, 0.51-0.58 s here (llfuse and pyfuse3 are equal within noise). Cause: the mounts now read via DownloadPipeline.fetch_many() -> Repository.get_many(), which loads the whole pack (up to DEFAULT_PACK_MAX_SIZE = 50 MB) into _pack_cache, whereas Repository.get() - what the mounts used before - reads only that object's byte range. That is what makes sequential reads much faster (the next ~26 chunks come from RAM), and it costs a 50 MB load whenever a scattered read touches a new pack; memory can grow by up to PACK_READER_CACHE_SIZE (3) x 50 MB. borg webdav already had this behaviour. Happy to put the mounts back on the per-object read path if you prefer the old trade-off - it would cost the zeros shortcut and the parsed-chunk cache of #1678.
12d3d4a to
1f25d22
Compare
|
Rebased onto #10041, so this now shows its two lrucache commits underneath (they disappear from here once #10041 is merged). The trees are disjoint - #10041 only touches With the thread-safe Re-verified after the rebase: mount/webdav/vfs/fuse tests green with llfuse and with mfusepy on macFUSE, and the 16-threads-hammering-one-mfusepy-mount stress run (the one that found the original locking bug) reports no errors and no exceptions in the FUSE layer. |
3bd51a4 to
3c4c223
Compare
…0020 borg materialized an archive as a browsable tree in three independent places: fuse.py (llfuse/pyfuse3, low-level FUSE), hlfuse.py (mfusepy, high-level FUSE) and webdav.py (ArchiveVFS + WebDAV/HTTP server). All three re-implemented tree building, hardlink handling, the versions view, uid/gid/mode/time mapping and reading file content from chunk lists - so every behaviour fix had to be applied N times (e.g. the ACL/xattr exposure fix borgbackup#9954 touched both FUSE variants). New module vfs.py has that logic exactly once: - ArchiveVFS: archive selection and name deduplication, lazily built per-archive trees, the versions view, hardlinks (nodes sharing one inode), item storage (msgpacked, path-less, as hlfuse did it), attribute mapping, xattrs/ACLs. - DataReader: reads byte ranges out of chunk lists, with the decrypted-chunk cache (BORG_MOUNT_DATA_CACHE_ENTRIES) and the sequential-read position hint. - parse_mount_options(): the "borg mount -o ..." parsing both mounts duplicated. fuse.py, hlfuse.py and webdav.py are now thin protocol adapters over it (2604 -> 2009 lines in total). Behaviour changes that fell out of the unification: - webdav reads now go through DownloadPipeline.fetch_many(), so the all-zero chunk shortcut and the parsed-chunk cache (borgbackup#1678) apply to mounts as well. - the mounts get webdav's Unicode NFC lookup fallback (macOS decomposes names). - directories report st_nlink >= 2 (hlfuse behaviour) in both mounts. - a chunk that is read to its end is no longer put into the data cache, so a full download does not evict the chunks partial (range) reads need - this was the FUSE behaviour, now webdav shares it. Threading: mfusepy serves the FUSE requests of one mount from a thread pool and webdav one request per thread, so the VFS is used from several threads. It serializes the repository access (borgstore connections are not thread-safe) and leaves the caches to LRUCache, which is thread-safe itself. Two threads can miss on the same inode and both unpack the item, or store a read position over each other - they get equal items, and the position is only a hint. The ACL emulation and the NFC lookup are now tested against the core (testsuite/vfs_test.py, no FUSE dependency); fuse_test.py keeps testing what is left in the adapters: the errno mapping. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3c4c223 to
0d6fffb
Compare
|
@PhrozenByte Now in master. Claude did some testing. :) |
Fixes #10020.
borg materialized an archive as a browsable tree in three independent places:
fuse.py(llfuse/pyfuse3, low-level FUSE),
hlfuse.py(mfusepy, high-level FUSE) andwebdav.py(its own
ArchiveVFS+ the WebDAV/HTTP server). All three re-implemented tree building,hardlink handling, the versions view, uid/gid/mode/time mapping and reading file content
from chunk lists - so every behaviour fix had to be applied N times (e.g. the ACL/xattr
exposure fix #9954 had to touch both FUSE variants separately).
What this does
New module
src/borg/vfs.pyhas that logic exactly once:ArchiveVFS: archive selection and name deduplication, lazily built per-archive trees,the versions view, hardlinks (nodes sharing one inode), item storage (msgpacked and
path-less, as
hlfuse.pydid it), attribute mapping, xattrs/ACLs.DataReader: reads byte ranges out of chunk lists, with the decrypted-chunk cache(
BORG_MOUNT_DATA_CACHE_ENTRIES) and the sequential-read position hint.parse_mount_options(): theborg mount -o ...parsing that both mounts duplicated.fuse.py(805 -> 251 lines),hlfuse.py(737 -> 184) andwebdav.py(1062 -> 858) are nowthin protocol adapters over it - 2604 -> 2009 lines in total, and both
FuseBackendclassesand
ItemCacheare gone.Behaviour changes that fell out of the unification
DownloadPipeline.fetch_many()now, so the all-zero chunk shortcutand the parsed-chunk cache (better handling of repeated chunks to speed up extracting sparse files #1678) finally cover the FUSE path, too (FUSE micro-opt benchmarking #5110).
st_nlink >= 2(thehlfuse.pybehaviour) in both mounts.does not evict the chunks that partial (range) reads need. This was the FUSE behaviour,
webdav shares it now.
webdav, and now do so in the mounts as well.
Trade-off worth a look: dropping
ItemCachemeans the llfuse/pyfuse3 mount now has thesame memory profile as the mfusepy mount (a msgpacked item per inode, kept in memory)
instead of the 9-bytes-per-item meta-array that re-fetched metadata chunks from the
repository on access. That is what the default implementation (mfusepy) already does, but
it is more memory than the low-level mount used for very large archives.
Tests
(
testsuite/vfs_test.py, no FUSE dependency at all);testsuite/fuse_test.pykeepstesting what is left in the adapters: the errno mapping.
against both implementations (llfuse and mfusepy on macFUSE).
Also fixes
borg mountshows a file but can not open it, when the archived nameand the name the client asks for use different Unicode normalizations). The shared VFS
looks a name up verbatim first and falls back to comparing NFC forms, skipping names that
are ambiguous after normalization. Verified on macOS with macFUSE: with an NFD name in the
archive, opening its NFC spelling fails on master and works here - and the other way round.
kernel's readahead threads fetch the same chunk concurrently, both insert it into the
(not thread-safe) data cache, and
LRUCacherefuses that with an assertion, which mfusepyturns into EINVAL.
sha256sum < fileon a mount reproduces it on master. The shared readerdoes all cache access under the repository lock, which fixes it.
🤖 Generated with Claude Code