fs/accounting: add --min-bandwidth / --min-bandwidth-time to detect stalled transfers - #9895
fs/accounting: add --min-bandwidth / --min-bandwidth-time to detect stalled transfers#9895halindrome wants to merge 1 commit into
Conversation
|
Moving this to draft. I ran a proper review pass over my own branch after opening it and found that the PR description above overclaims in two places. Correcting that here rather than leaving it to waste your time, @ncw. 1. AC/objection 2 —
|
…talled transfers --timeout only resets an idle deadline on the raw connection whenever ANY Read/Write moves n > 0 bytes (fs/fshttp timeoutConn.nudgeDeadline). A peer dribbling even a single byte more often than --timeout can hold a connection open indefinitely: nothing ever errors, so --retries / --low-level-retries never get a chance to engage either. See issue rclone#9841 for the production incident. --min-bandwidth (bytes/sec, default 0/disabled) and --min-bandwidth-time (default 60s) add a minimum-throughput check on a transfer's Account: if the moving average stays below --min-bandwidth for a full --min-bandwidth-time window, the transfer's own context is cancelled with ErrorTransferStalled, which carries the measured speed and is marked retryable so --retries re-drives it. Zero behaviour change when unset. The check lives in fs/accounting rather than at the connection layer so it is immune to HTTP keep-alive pooling, measures transfer progress rather than bytes-on-the-wire, and works for backends rclone does not Dial. It is evaluated once per completed window against the EWMA that --stats already maintains, so a brief dip does not trip it, and it is raised at most once per attempt. Account.Context() is new: callers that create an Account must pass THIS (not the original ctx) to the actual Open/Put/Update call, since the cancellation is fired from averageLoop's background goroutine and can only reach an in-flight request running under the same context. Wired into fs/operations/copy.go's updateOrPut, which also maps context.Cause back onto the returned error -- the HTTP transport propagates only ctx.Err(), so the reason would otherwise reach the user as "context canceled". KNOWN LIMITATION, not yet addressed: the check has no notion of a legitimate zero-progress phase. A transfer whose source reader is exhausted while the server finalises (S3 CompleteMultipartUpload), and a server-side copy whose bytes are booked in one lump at the end, both look identical to a stall. This needs an explicit source-EOF/not-started exempt state; see the discussion on the pull request before relying on this with a server-side workload. Account call sites other than updateOrPut (multithread copy, check) are not migrated to Account.Context(), so on those paths a stall is detected but the abort cannot land. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Cp6djGjYDg518RujgZwMsr
69e7a7c to
cc327ea
Compare
Fixes #9841
Adds
--min-bandwidth(bytes/sec, default0/disabled) and--min-bandwidth-time(default60s): if a transfer averages below--min-bandwidthfor a full--min-bandwidth-timewindow it is cancelled with a retryable error, so--retriescan engage. Zero behaviour change when unset.This is the two-knob shape @ncw described in #9841, matching curl's
--speed-limit/--speed-timeand google-drive-ocamlfuse'slow_speed_limit/low_speed_time— the semantics asked for in #887 and #2122.Known limitation — the
CompleteMultipartUploadobjection is NOT yet resolvedstallCheckLockedcancels purely on the EWMA falling below the threshold. It has no notion of a legitimate zero-progress phase — no completion gate, no source-EOF gate, no not-yet-started gate. So:CompleteMultipartUpload: once the destination has consumed the whole source reader and is waiting on the server to finalise, no furtherAccountReadoccurs, soavgdecays toward 0 whileupdateOrPutis passinginAcc.Context()to that very finalisation request. At--min-bandwidth=1Mon a 5 MB/s upload the cancel lands ~85s in, orphaning the multipart upload.ServerSideCopyEnd, soavgis 0 throughout. It does not abort the copy (that runs under a different context), so the effect is a spurious stall report.Moving from
timeoutConntoAccountdoes fix keep-alive pooling and the measures-the-wrong-thing problem, but not this: the check fires on the absence of accounted bytes, and legitimate finalisation is exactly that absence. Fixing it needs an explicit exempt state rather than a guard, which changes what the feature promises — hence draft, pending @ncw's view on the shape.Why
fs/accountingand nottimeoutConnPer @ncw's review on #9841, and two of the three objections do hold here:
Accounttracks per-transfer progress rather than bytes-on-the-wire, and is fed real network bytes for multipart chunk uploads viarw.SetAccounting(acc.AccountRead)(lib/multipart.UploadMultipart).Mechanism
Checked once per completed
--min-bandwidth-timewindow againstAccount's existing EWMA (acc.values.avg— the same figure--statsuses), not the instantaneous rate, so a short dip does not trip it. Raised at most once per attempt (latched, reset inUpdateReader).A sustained shortfall cancels the transfer's own context via
context.WithCancelCause, so the cause (ErrorTransferStalled, carrying the measured speed) survives.ErrorTransferStalledis wrapped infserrors.RetryErrorsoStatsInfo.Errorclassifies it as retryable and--retriesre-drives the transfer.updateOrPutmapscontext.Causeback onto the returned error, because the HTTP transport propagates onlyctx.Err()and the reason would otherwise surface ascontext canceled.Note this does not reach
--low-level-retries: that loop runs inside the backend onfserrors.ShouldRetry, which consultsTimeout()/Temporary()rather thanRetry(), and the backend sees the transport'scontext.Canceledrather than this error. An earlier revision of this description claimed otherwise.Account.Context()is new. Callers that create anAccountmust pass that — not the originalctx— to the subsequentOpen/Put/Update, because the cancellation fires fromaverageLoop's background goroutine and only reaches an in-flight request running under the same context.Scope
Wired into
fs/operations/copy.go'supdateOrPut, the single-file Put/Update path that the incident in #9841 hit.Not migrated, and this is a defect rather than merely unfinished work — on these paths a stall is detected and logged but the abort cannot land:
fs/operations/multithread.go(multiThreadCopy/mc.acc = tr.Account(gCtx, nil)) — the default path above--multi-thread-cutoff 256Mi, i.e. exactly the wedged large-file upload from s3/fshttp: --timeout only enforces an idle deadline, so a slow trickle (e.g. wedged multipart upload) can hang indefinitely with no error and no retry #9841fs/operations/check.go(three.Account(ctx, ...)sites)Either those get migrated, or the stall check should not run for Accounts whose context is not wired through. Happy to do either in this PR.
Also worth a decision: the check hangs off every
Account, not just sync transfers —vfs/read.goopen handles,serve http/webdav GETs,HashSum,catObject. Being idle is normal for those, so a global--min-bandwidthwould cancel an idlemountread handle after--min-bandwidth-time. Probably wants scoping or explicit documentation.Tests
fs/accounting/min_bandwidth_test.go, driven through the realAccountReadpath, each on a private config viafs.AddConfig(a shared globalfs.ConfigInforacesaverageLoop, which is what made CI red on an earlier revision):TestAccountMinBandwidthDetectsStall— 10 bytes/sec against a 1000 bytes/sec minimum; surfaced viaContext()and a subsequentAccountRead, and asserts the stall latches rather than re-firing every tick.TestStallCheckResetsWindowOnRecovery— drivesstallCheckLockedat chosen instants: a dip that recovers must not leave a stale window behind, so a later dip gets a full--min-bandwidth-timeof its own.TestAccountMinBandwidthRecovers— a dip that clears is not cancelled, run past the point where a non-resetting implementation would have fired.TestAccountMinBandwidthStallIsRetryable— the error is marked retryable and survives wrapping.TestAccountMinBandwidthDisabledByDefault— no behaviour change when unset.Verified on go1.26:
go vet ./fs/...andgofmt -l fsclean;go test -race ./fs/accounting/ ./fs/operations/ ./fs/clean over the whole packages.make racequicktestshows the same nine failing packages as pristinemasterin the same container (root user defeats the permission-denial tests, no FUSE, NFS symlinks unsupported) and no others.Each fix is mutation-checked: deleting the recovery reset, removing the whole recovery branch, unlatching the stall, and un-marking the error retryable each turn the corresponding test red.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Cp6djGjYDg518RujgZwMsr