fix(s3): use recursive listing to avoid excessive API calls - #313
fix(s3): use recursive listing to avoid excessive API calls#3130byte-coding wants to merge 1 commit into
Conversation
0f25306 to
ca35336
Compare
|
@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. |
There was a problem hiding this comment.
Pull request overview
Adds recursive block listing to reduce costly S3 API requests while retaining fallback behavior for unsupported drivers.
Changes:
- Introduces the optional
RecursiveListerinterface. - 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.
| for _, b := range blocks { | ||
| assert.NoError(afero.WriteFile(m.fs, b, []byte("data"), 0644)) | ||
| } |
| paths, err := recDriver.ListRecursive(blockPathBase) | ||
| if err != nil { | ||
| // Directory doesn't exist | ||
| return []string{}, nil | ||
| } |
| paths, err := recDriver.ListRecursive(blockPathBase) | ||
| if err != nil { | ||
| // Directory doesn't exist | ||
| return []string{}, nil | ||
| } |
| 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>
ca35336 to
9813edd
Compare
|
Thanks for the confirmation and the pointer to #8060 - that lines up with what I found. Force-pushed with:
Also added a regression test for point 2 ( Agreed per-block removal in GC (the DELETE side) looks like a good separately-benchmarked follow-up - this PR only touches the LIST side. |
Which issue(s) this PR fixes:
Issue longhorn/longhorn#1547
What this PR does / why we need it:
getBlockNamesForVolumeandgetAllBlockNameswalk the 2-level sharded block directory tree with onedriver.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
RecursiveListerinterface that a driver can implement to provide a native flat/recursive listing:s3:ListRecursiveusesListObjectsV2without 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):ListRecursivewalks the local mount in a single process pass instead of oneList()round-trip per directory level.getBlockNamesForVolume/getAllBlockNamesprefer the recursive path when the driver supports it, and fall back to the original walk-based implementation otherwise, so drivers that don't implementRecursiveLister(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
longhornengine CLI binary (same binarylonghorn-managerexecs forbackup rm/GC) against a real k3s + Longhorn v1.10.1 cluster and a real Backblaze B2 bucket with valid, working credentials. Ranbackup rmon an identical 135-block backup with both the stock and patched binary, capturing every HTTP request via a local MITM proxy:Both runs produced identical, correct results (
Removed 135 unused blocks, bucket left with onlyvolume.cfg) - confirming no functional regression. The 5 remaining directory-listing calls (root check,locks/,backups/x2,blocks/) are legitimate; theblocks/listing that used to cost 243 nested calls is now a singleListRecursivecall. 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 unmodifiedmaster.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.