feat(backend/opendal): add opt-in per-part write progress via ProgressLayer + chunked writer PR Description - #540
Open
543069760 wants to merge 2 commits into
Open
Conversation
Add a cooperative cancellation mechanism to the backup pipeline so a running backup can be aborted from outside (e.g. an Android/JNI UI thread or a Ctrl-C handler) without relying on any platform-specific signal. Motivation: Previously the backup flow (Repository::backup -> commands::backup::backup -> archive -> Archiver::archive) had no way to be interrupted; it could only return early on error via '?'. Environments like Android have no SIGINT, so cancellation must be driven by an explicit, thread-safe flag. Approach: Introduce an Arc<AtomicBool> cancellation token threaded through the public backup API down into the archiver's main loop. The token is checked at file/tree-entry granularity; when set, archiving returns ErrorKind::Cancelled via '?' before finalize/save_file, guaranteeing no snapshot is persisted. AtomicBool is used instead of tokio-util's CancellationToken to avoid adding a dependency and because it is Send + Sync and fits the existing pariter parallel structure and rayon scope threads. Changes: - error.rs: add a new ErrorKind::Cancelled variant (enum is #[non_exhaustive], so this is not a breaking change for external matches). - archiver.rs: add a 'cancel: &Arc<AtomicBool>' parameter to Archiver::archive; poll cancel.load(Ordering::Relaxed) inside the try_for_each closure (before tree_archiver.add) and in the background size-scan thread; return RusticError::new(ErrorKind::Cancelled, ...) when cancelled. - commands/backup.rs: thread 'cancel' through archive() and backup() and pass it into archiver.archive(...). - repository.rs: add the 'cancel: &Arc<AtomicBool>' parameter to the public Repository::backup and Repository::archive methods and forward it; document the new argument (setting it to true aborts at the next checkpoint and returns ErrorKind::Cancelled without writing a snapshot). - Update all call sites (integration tests + doctests in lib.rs and both README.md files) to pass a non-cancelling token. - Add integration test test_backup_cancelled_writes_no_snapshot asserting that a pre-cancelled token makes backup return a cancellation error and persists no snapshot. Notes: - Cancellation is cooperative and granular to file/tree entries; an in-flight chunked write of a single large file is not interrupted mid-write. - Ordering::Relaxed is sufficient as the flag is a one-way cancel signal. - The JNI/Android trigger side is not part of this crate; rustic_core only exposes the &Arc<AtomicBool> entry point. Verified locally on Windows: cargo build passes; the cancellation test passes. Not yet verified on a Unix host: tests gated by #[cfg(not(windows))] (e.g. restore::test_restore_preserves_hardlinks, backup::test_backup_excludes_xattr_entries) and external-command tests (echo-based) were not compiled/run on Windows and must be validated on Linux/WSL/macOS.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR adds opt-in, real-time write progress reporting to the OpenDAL backend.
Previously,
OpenDALBackend::write_bytesuploaded each file with a singleoperator.write(path, buf)call. For services backed by an S3-style multipart API (e.g. Tencent COS), OpenDAL'sMultipartWriterapplies awrite_onceoptimization when the whole buffer is handed over in one shot: with a known size and a single write, it issues a singlePutObjectrequest instead of initiating a multipart upload. As a result, any progress observer only ever sees a single jump from 0 to the full object size at the very end of the upload — there is no intra-object progress.This makes it impossible to surface smooth upload progress (e.g. to a JNI caller driving a progress bar) for large packs.
Approach
We introduce a custom OpenDAL Layer (
ProgressLayer) that wraps the underlying writer and atomically accumulates the number of bytes written each time aBufferis flushed down to the inner writer. To actually produce multiple flushes (and therefore multiple progress steps), the backend switches from a single one-shotoperator.writeto a chunked writer that feeds the data in fixed-size chunks, which forces OpenDAL past itswrite_onceoptimization and into the real multipart path (InitiateMultipartUpload+ multipleUploadPart+CompleteMultipartUpload).The behavior is opt-in and fully backward compatible:
OpenDALBackend::new(...)constructor is unchanged and storescounter: None. Backends created this way keep the original single-shotoperator.write(buf)path — zero behavior change for all existing backends (fs / sftp / rest / s3 / …).OpenDALBackend::new_with_progress(...)constructor accepts anOption<WrittenCounter>. Only when a counter is provided is theProgressLayerassembled and the chunked writer path taken.Changes
crates/backend/src/progress_layer.rspub type WrittenCounter = Arc<AtomicU64>— shared, cheaply-cloneable counter handle. The caller keeps oneArcclone to poll progress.ProgressLayer— anopendal::raw::Layerholding aWrittenCounter.ProgressAccessor/ProgressWriter—LayeredAccess/oio::Writewrappers.ProgressWriter::writerecordsbs.len()only after the inner write succeeds (so failed writes / retries don't inflate the count), usingOrdering::Relaxed.close/abortare passed straight through.crates/backend/src/lib.rsWrittenCounterand callnew_with_progress.crates/backend/src/opendal.rsopendal::options::WriteOptions(used to set the writer chunk size).mod constants: addpub(super) const CHUNK_SIZE: usize = 8 * 1024 * 1024;(8 MiB). Chosen to be larger than the ~5 MiB minimum part size required by S3/COS-style services, so it reliably crosses OpenDAL'swrite_onceoptimization and triggers a real multipart upload.OpenDALBackendstruct: add acounter: Option<WrittenCounter>field that records whether aProgressLayerwas assembled, sowrite_bytescan branch on it.new(...): unchanged logic; now setscounter: None.new_with_progress(path, options, counter): same operator-construction logic asnew, but whencounter.is_some()it assembles theProgressLayeron the async operator (using a cheapArcclone so the counter is also stored in the struct), then wraps it in the blockingOperator. Stores the counter in the struct.write_bytes(...): branch onself.counter:Some(_)→ build a chunked blocking writer viaoperator.writer_options(path, WriteOptions { chunk: Some(constants::CHUNK_SIZE), ..Default::default() }), loop overbuf.chunks(constants::CHUNK_SIZE)callingwriter.write(chunk), thenwriter.close()(return value discarded to satisfy-W unused-results). This drivesProgressLayerto fire once per part.None→ keep the original single-shotoperator.write(&filename, buf).Tests
crates/backend/tests/progress_layer.rs(runs on the memory service, always executed):progress_layer_counts_written_bytes— a backend built with a counter reportscounter >= bytes_writtenafter a write.default_path_without_counter_still_writes— regression: withcounter = Nonethe write works and reads back identically (default path unaffected).progress_layer_cos.rs(real Tencent COS,#[ignore]by default):cos_progress_layer_per_part_granularity— requiresCOS_SECRET_ID,COS_SECRET_KEY,COS_BUCKET,COS_ENDPOINT,COS_ROOT. Uploads a 128 MiB pack and asserts the counter advances in multiple steps, cleaning up the object afterwards.Verification
cargo build -p rustic_backend --features opendal— clean (no new warnings).cargo clippy -p rustic_backend --features opendal— no new lints introduced (pre-existing repo warnings left untouched).cargo test -p rustic_backend --features opendal— memory regression tests pass; the COS test is correctly reported as ignored.cargo test -p rustic_backend --features opendal --test progress_layer_cos -- --ignored --nocapture): the network trace showsInitiateMultipartUpload+ multipleUploadPart(partNumber=…&uploadId=…,content-length: 8388608) +CompleteMultipartUpload, and thePROGRESS-COSlog shows the counter stepping in 8 MiB increments:0 → 8388608 → 16777216 → … → 134217728(16 parts for 128 MiB).Backward Compatibility
new(...)callers are unaffected:counter = None→ original single-shotoperator.writepath, identical behavior for all backends.new_with_progress(...)with a counter.Summary
This PR adds opt-in, real-time write progress reporting to the OpenDAL backend.
Closes #538