Skip to content

unify the three archive-as-filesystem implementations - #10023

Merged
ThomasWaldmann merged 1 commit into
borgbackup:masterfrom
ThomasWaldmann:unify-vfs-10020
Aug 5, 2026
Merged

unify the three archive-as-filesystem implementations#10023
ThomasWaldmann merged 1 commit into
borgbackup:masterfrom
ThomasWaldmann:unify-vfs-10020

Conversation

@ThomasWaldmann

@ThomasWaldmann ThomasWaldmann commented Aug 3, 2026

Copy link
Copy Markdown
Member

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) and webdav.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.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 and
    path-less, as hlfuse.py 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 that both mounts duplicated.

fuse.py (805 -> 251 lines), hlfuse.py (737 -> 184) and webdav.py (1062 -> 858) are now
thin protocol adapters over it - 2604 -> 2009 lines in total, and both FuseBackend classes
and ItemCache are gone.

Behaviour changes that fell out of the unification

  • The mounts read via DownloadPipeline.fetch_many() now, so the all-zero chunk shortcut
    and 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).
  • The mounts get webdav's Unicode NFC lookup fallback (macOS decomposes file names).
  • Directories report st_nlink >= 2 (the hlfuse.py 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 that partial (range) reads need. This was the FUSE behaviour,
    webdav shares it now.
  • Synthesized (never archived) directories keep showing the mtime of their archive in
    webdav, and now do so in the mounts as well.

Trade-off worth a look: dropping ItemCache means the llfuse/pyfuse3 mount now has the
same 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

  • The ACL/xattr emulation and the NFC lookup are tested against the core now
    (testsuite/vfs_test.py, no FUSE dependency at all); testsuite/fuse_test.py keeps
    testing what is left in the adapters: the errno mapping.
  • The full test suite is green locally, and the mount tests were run with real mounts
    against both implementations (llfuse and mfusepy on macFUSE).

Also fixes

  • Fixes macOS: borg mount problem with unicode normalisation #4771 (macOS: borg mount shows a file but can not open it, when the archived name
    and 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.
  • Reading a file from an mfusepy mount could fail with EINVAL ("Invalid argument"): the
    kernel's readahead threads fetch the same chunk concurrently, both insert it into the
    (not thread-safe) data cache, and LRUCache refuses that with an assertion, which mfusepy
    turns into EINVAL. sha256sum < file on a mount reproduces it on master. The shared reader
    does all cache access under the repository lock, which fixes it.

🤖 Generated with Claude Code

@codecov

codecov Bot commented Aug 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.40397% with 64 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.63%. Comparing base (fce88ee) to head (0d6fffb).
⚠️ Report is 3 commits behind head on master.

Files with missing lines Patch % Lines
src/borg/vfs.py 89.29% 31 Missing and 10 partials ⚠️
src/borg/hlfuse.py 85.93% 6 Missing and 3 partials ⚠️
src/borg/fuse.py 91.17% 6 Missing ⚠️
src/borg/webdav.py 92.59% 4 Missing and 2 partials ⚠️
src/borg/archiver/mount_cmds.py 75.00% 2 Missing ⚠️
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.
📢 Have feedback on the report? Share it here.

@ThomasWaldmann

Copy link
Copy Markdown
Member Author

CI found one real failure (test_fuse_allow_damaged_files, all three FUSE legs): a chunk missing in the repository produced TypeError: object of type 'NoneType' has no len() instead of the intended EIO.

Root cause is a latent bug in DownloadPipeline.fetch_many(), not in the refactoring: with replacement_chunk=False it is documented (and used) to yield None for a missing chunk, but the size check right before the yield then did len(None). Nothing hit it so far, because borg webdav was the only caller passing replacement_chunk=False for file content - so webdav's "chunk missing" path (abort the connection instead of serving corrupted data) never actually ran, the TypeError ended up as a 500. Now that the mounts read via fetch_many(), too, the damaged-files test found it.

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.

@ThomasWaldmann

ThomasWaldmann commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Moved the fetch_many() fix into its own PR: #10024. This PR is back to the single refactoring commit and now depends on #10024 - without it, test_fuse_allow_damaged_files fails on all three FUSE legs (the mounts read via fetch_many() now, and a missing chunk raised TypeError instead of yielding None).

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).

@ThomasWaldmann

Copy link
Copy Markdown
Member Author

@PhrozenByte if you have time, give this some practical testing, please.

@ThomasWaldmann

Copy link
Copy Markdown
Member Author

Practical testing on Linux (podman via scripts/linux-run, all three FUSE bindings)

Container: python:3.13 + libfuse2/libfuse3 + llfuse 1.5.2, pyfuse3 3.5.0, mfusepy 3.1.1. Test data: small files, a 3 MB file, a 1 GiB incompressible file, a file with a big run of zeros, symlinks, 3 hard links to one inode, a fifo, an empty dir, a non-ASCII name, a setgid dir, ns timestamps, user.* xattrs and POSIX ACLs. Every check was run against this branch and against master (the merge base) with the same script.

Correctness - identical results on both, no regressions:

  • the mounted tree matches the source exactly: entries, type, mode, uid/gid, size, mtime (ns), symlink targets, hard link groups + st_nlink, and user.* xattr values (including an empty and a binary one)
  • content: sha256 of every file read through the mount matches the source; copying a file out of the mount matches; 64 random range reads (1 B ... 5 MB, incl. EOF edges) match byte for byte
  • -o versions: both versions of a changed file appear, contents are right, and hard links still share an inode
  • ACLs: the archived ACLs show up as system.posix_acl_* in listxattr. Reading their value is refused by the kernel for FUSE mounts inside a user namespace (rootless podman, see fuse: expose POSIX ACLs on Linux mounts, fixes #1042 #9954), so that part can only be checked on CI - unchanged from master.

Throughput (1 GiB incompressible file, fresh mount per measurement, dd bs=1M):

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.

@ThomasWaldmann

Copy link
Copy Markdown
Member Author

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 helpers/lrucache.py and its test - so the rebase was conflict-free.

With the thread-safe LRUCache underneath, the explicit locking here became redundant, so the last commit removes it: the VFS now serializes only what actually needs it (the repository access - borgstore connections are not thread-safe), while item lookups and the sequential-read position hints just use the caches. Two threads can miss on the same inode and both unpack the item, or overwrite each other's read position - they get equal items, and the position is only a hint (a read starting before it just starts over).

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.

@ThomasWaldmann
ThomasWaldmann force-pushed the unify-vfs-10020 branch 2 times, most recently from 3bd51a4 to 3c4c223 Compare August 5, 2026 12:22
…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>
@ThomasWaldmann
ThomasWaldmann merged commit d0b4ebd into borgbackup:master Aug 5, 2026
20 checks passed
@ThomasWaldmann
ThomasWaldmann deleted the unify-vfs-10020 branch August 5, 2026 13:14
@ThomasWaldmann

Copy link
Copy Markdown
Member Author

@PhrozenByte Now in master. Claude did some testing. :)

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

Labels

None yet

Projects

None yet

1 participant