Skip to content

fix(s3): use recursive listing to avoid excessive API calls - #313

Open
0byte-coding wants to merge 1 commit into
longhorn:masterfrom
0byte-coding:fix-excessive-s3-calls
Open

fix(s3): use recursive listing to avoid excessive API calls#313
0byte-coding wants to merge 1 commit into
longhorn:masterfrom
0byte-coding:fix-excessive-s3-calls

Conversation

@0byte-coding

@0byte-coding 0byte-coding commented Jul 17, 2026

Copy link
Copy Markdown

Which issue(s) this PR fixes:

Issue longhorn/longhorn#1547

What this PR does / why we need it:

getBlockNamesForVolume and getAllBlockNames walk the 2-level sharded block directory tree with one driver.List() call per directory, issuing up to 256*256 List calls per volume. On backends that bill per API request (e.g. Backblaze B2 Class C transactions), this is the main driver of the excessive call counts reported in longhorn/longhorn#1547 (one user measured up to 65,000 requests per backup).

This PR adds an optional RecursiveLister interface that a driver can implement to provide a native flat/recursive listing:

  • s3: ListRecursive uses ListObjectsV2 without a delimiter, returning every key under a prefix via normal pagination (1 API call per 1000 objects) instead of one call per directory.
  • fsops (nfs/cifs/vfs): ListRecursive walks the local mount in a single process pass instead of one List() round-trip per directory level.

getBlockNamesForVolume / getAllBlockNames prefer the recursive path when the driver supports it, and fall back to the original walk-based implementation otherwise, so drivers that don't implement RecursiveLister (e.g. azblob) keep working unchanged.

Special notes for your reviewer:

End-to-end verified, not just unit tested. Built this patch into a full longhorn engine CLI binary (same binary longhorn-manager execs for backup rm/GC) against a real k3s + Longhorn v1.10.1 cluster and a real Backblaze B2 bucket with valid, working credentials. Ran backup rm on an identical 135-block backup with both the stock and patched binary, capturing every HTTP request via a local MITM proxy:

Stock binary Patched binary Change
Total S3 requests 534 296 -44.6%
Directory-listing GET calls 243 5 -97.9%
GC wall time ~24s ~13s -46%

Both runs produced identical, correct results (Removed 135 unused blocks, bucket left with only volume.cfg) - confirming no functional regression. The 5 remaining directory-listing calls (root check, locks/, backups/ x2, blocks/) are legitimate; the blocks/ listing that used to cost 243 nested calls is now a single ListRecursive call. The other ~137 GET calls in both runs are unrelated per-block existence checks (FileSize/HeadObject) done during GC bookkeeping, outside the scope of this fix.

Unit tests (list_recursive_test.go, fsops/list_recursive_test.go) cover the recursive listing path and the "directory doesn't exist" fallback case. Pre-existing test failures (TestInspectBackup, NFS mount tests requiring superuser) are unrelated to this change and reproduce identically on unmodified master.

Additional documentation or context

See discussion in longhorn/longhorn#1547, particularly joshimoo's analysis of the O(nodes) * O(vol * 4) call cost and sedlund's independent proof-of-concept for the same recursive-listing approach.

@christophersherman

christophersherman commented Aug 2, 2026

Copy link
Copy Markdown

@0byte-coding This looks directly relevant to a current production failure mode also covered by longhorn/longhorn#8060: a volume with roughly 1.4 million block objects can spend longer than an hourly backup interval in retention GC while the deletion lock prevents the next backup from starting.

A source audit confirms that current backupstore still walks the two-level block shard tree serially before serial per-block cleanup, so this recursive-listing change is the right first optimization and avoids changing deletion semantics. The DCO check is currently ACTION_REQUIRED; could you sign off and update the commit?

@mantissahz Since #8060 is assigned to you and marked priority/0, could you help route review once DCO is green? Per-block removal may merit a separately benchmarked bounded-concurrency follow-up, but #313 already removes the largest fixed LIST amplification.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds recursive block listing to reduce costly S3 API requests while retaining fallback behavior for unsupported drivers.

Changes:

  • Introduces the optional RecursiveLister interface.
  • Implements recursive listing for S3 and filesystem drivers.
  • Uses recursive listing for volume and backing-image block discovery.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
driver.go Defines the recursive listing extension.
s3/s3.go Adds flat, paginated S3 listing.
fsops/fsops.go Adds filesystem tree traversal.
deltablock.go Uses recursive volume block discovery.
backupbackingimage/config.go Uses recursive backing-image block discovery.
list_recursive_test.go Tests recursive volume listing.
fsops/list_recursive_test.go Tests filesystem recursive listing.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread list_recursive_test.go
Comment on lines +88 to +90
for _, b := range blocks {
assert.NoError(afero.WriteFile(m.fs, b, []byte("data"), 0644))
}
Comment thread deltablock.go
Comment on lines +1504 to +1508
paths, err := recDriver.ListRecursive(blockPathBase)
if err != nil {
// Directory doesn't exist
return []string{}, nil
}
Comment on lines +175 to +179
paths, err := recDriver.ListRecursive(blockPathBase)
if err != nil {
// Directory doesn't exist
return []string{}, nil
}
Comment thread fsops/fsops.go
base := f.LocalPath(path)
var result []string

err := filepath.Walk(base, func(p string, info os.FileInfo, err error) error {
getBlockNamesForVolume and getAllBlockNames walked the 2-level sharded
block directory tree with one driver.List() call per directory level,
issuing up to 256*256 List calls per volume. On backends that bill per
API request (e.g. Backblaze B2 Class C transactions), this translates
directly into runaway costs, as reported repeatedly in
longhorn/longhorn#1547 (up to 65,000 requests
per backup according to one user).

Add an optional RecursiveLister interface that drivers can implement to
provide a native flat/recursive listing:
  - s3: ListRecursive uses ListObjectsV2 without a delimiter, returning
    every key under a prefix via normal pagination (1 API call per 1000
    objects) instead of one call per directory.
  - vfs: ListRecursive walks the local mount in a single process pass
    instead of one List() round-trip per directory level. nfs and cifs
    deliberately do NOT get this via fsops.FileSystemOperator embedding:
    filepath.Walk over a network mount still issues one remote
    metadata round-trip per directory entry, so it could turn one
    round-trip per directory level into one round-trip per block -
    worse than the List()-per-directory traversal it would replace on
    backends with very large block counts. Renamed the underlying
    fsops helper to ListRecursiveLocal to make this an explicit opt-in
    per driver rather than an accidental promotion.

getBlockNamesForVolume and getAllBlockNames now prefer the recursive
path when the driver supports it, falling back to the original
walk-based implementation otherwise (azblob and any future drivers
that don't implement RecursiveLister keep working unchanged). A
missing directory is not an error - both the s3 and fsops
implementations already return (nil, nil) for a prefix/path that
doesn't exist - so any error ListRecursive does return is a genuine
failure (auth, network, pagination, etc.) and is now propagated to the
caller instead of being silently treated as "no blocks found", which
previously risked GC persisting BlockCount = 0 for a volume whose
blocks simply failed to list.

Signed-off-by: 0byte <git@susnext.com>
@0byte-coding
0byte-coding force-pushed the fix-excessive-s3-calls branch from ca35336 to 9813edd Compare August 4, 2026 05:42
@0byte-coding

Copy link
Copy Markdown
Author

Thanks for the confirmation and the pointer to #8060 - that lines up with what I found.

Force-pushed with:

  • DCO sign-off (git commit -s) - now green.
  • Addressed all 4 Copilot review comments:
    1. list_recursive_test.go: made shard directory creation explicit instead of relying on afero.MemMapFs auto-vivifying parent dirs (an implementation detail of that specific in-memory fake, not something afero.WriteFile itself guarantees).
    2. deltablock.go: getBlockNamesForVolume now propagates genuine ListRecursive errors instead of treating every error as "directory doesn't exist" - both the s3 and fsops implementations already return (nil, nil) for a missing prefix/path, so any error is a real failure (auth/network/pagination) that should abort GC rather than silently persisting BlockCount = 0.
    3. backupbackingimage/config.go: same fix applied to getAllBlockNames.
    4. fsops/fsops.go: renamed the walk-based helper to ListRecursiveLocal and stopped it being promoted automatically via embedding. Only vfs (a plain local directory) now opts into RecursiveLister explicitly; nfs/cifs (network mounts) deliberately do not, since filepath.Walk still issues one remote metadata round-trip per directory entry there - it could turn one round-trip per directory level into one round-trip per block on a volume with a large number of blocks. Added compile-time interface-assertion tests locking this in for all three drivers.

Also added a regression test for point 2 (TestGetBlockNamesForVolumePropagatesRecursiveListError).

Agreed per-block removal in GC (the DELETE side) looks like a good separately-benchmarked follow-up - this PR only touches the LIST side.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants