Skip to content

fix(storage): preserve multipart part identity across retries - #8174

Merged
Xuanwo merged 6 commits into
mainfrom
gatekeeper/fix-7956-1
Aug 5, 2026
Merged

fix(storage): preserve multipart part identity across retries#8174
Xuanwo merged 6 commits into
mainfrom
gatekeeper/fix-7956-1

Conversation

@lance-gatekeeper

@lance-gatekeeper lance-gatekeeper Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Summary

  • retry native S3, Azure, and GCS multipart part requests inside the object_store HTTP connector so every attempt retains the provider-assigned part identity
  • feed retry outcomes into the existing AIMD write controller while avoiding duplicate part throttling at the outer multipart wrapper
  • leave every OpenDAL provider and writer path unchanged
  • remove writer-level multipart resubmission that allocated a new part number on each retry
  • cover the native path with a fault-injection regression that verifies the same part URI and number across retries

Root cause

The AIMD layer retried a failed upload by invoking MultipartUpload::put_part again. Native cloud stores allocate a new part number when that method is called, so the retry skipped the failed part and completion reported Missing part.

Native S3, Azure, and GCS now retry the same HTTP request after part allocation. The outer multipart wrapper recognizes those native stores and does not retry the logical part again. OpenDAL stores retain their existing behavior and are outside this repair.

Validation

  • cargo fmt --all and cargo fmt --all -- --check
  • cargo clippy --all --tests --benches -- -D warnings
  • cargo clippy -p lance-io --all-features --tests --benches -- -D warnings
  • cargo test -p lance-io --all-features test_multipart_http_retry_reuses_part_number -- --nocapture (1 passed)
  • cargo test -p lance-io --lib --all-features -- --test-threads=1 --skip uring::tests (251 passed)
  • git diff --check

Fixes #7956

@github-actions github-actions Bot added A-encoding Encoding, IO, file reader/writer bug Something isn't working labels Aug 3, 2026

@lance-gatekeeper lance-gatekeeper Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Gate recommendation: request changes. The native connector moves retries below part allocation correctly, but the change also removes transient multipart recovery from supported OpenDAL paths. Preserve the native fix and add an identity-safe retry at OpenDAL's writer boundary before removing the global fallback.

}
}
Ok(Err(err)) => return Err(err.source.into()),
Ok(Err(err)) => return Err(err.into()),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The new immediate return removes the only transient multipart recovery for OpenDAL-backed stores. Native S3/Azure/GCS now retry inside their connectors, but use_opendal=true plus OpenDAL-only OSS/COS/TOS/GooseFS build bare Operator::from_iter values, and pinned OpenDAL installs no RetryLayer. A connection reset or RequestTimeout therefore aborts the whole upload instead of receiving the previous retry budget.

Keep trait-level re-entry disabled for native providers, but install identity-preserving retry at the OpenDAL writer boundary (e.g. RetryLayer, which retries the same writer and cloned buffer), with fault injection.

Reproducer

I ran cargo run --quiet against this head with a standalone store whose MultipartUpload::put_part returns Error::Generic(...ConnectionReset...) once and succeeds on its second invocation. The 5 MiB write drives ObjectWriter through multipart shutdown:

writer.write_all(&vec![0; 5 * 1024 * 1024]).await.unwrap();
let error = writer.shutdown().await.unwrap_err();
let observed = attempts.load(Ordering::SeqCst);
assert!(observed > 1, "transient multipart failure should be retried below provider part identity");

Expected: the retry-capable provider path gets a second attempt. Observed:

observed attempts=1; error=Generic OpenDAL error: connection reset by peer
transient multipart failure should be retried below provider part identity

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

No OpenDAL retry was added. The later maintainer review 4862887045 explicitly limits this bug to object_store and requires OpenDAL to remain unchanged; remote head c17039c restores every OpenDAL provider to its original path while retaining the native connector fix.

@lance-gatekeeper lance-gatekeeper Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Gate recommendation: request changes. The identity-safe writer retry now fixes the OpenDAL regression, but installing the 20-retry policy on the whole operator compounds ordinary transient failures with the outer AIMD retry loop. Keep retries at OpenDAL writer methods so multipart parts retain identity while reads, lists, deletes, and other operations continue to surface throttle feedback to AIMD.

.with_max_delay(Duration::from_secs(8))
.with_max_times(max_retries())
.with_jitter();
OpendalStore::new(operator.layer(retry_layer))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

RetryLayer wraps the entire Operator, so the multipart recovery budget also applies to stat, reads, lists, deletes, copies, and renames before the outer AIMD layer sees an error. With the defaults, a temporary throttle response receives 21 OpenDAL attempts; AIMD then repeats that exhausted logical operation three more times, delaying shared rate reduction and producing 84 provider requests from one call. The exponential 2/4/8-second schedule plus additive jitter can also turn a single ordinary operation into a many-minute wait. Use an OpenDAL Layer/Service wrapper that passes access operations through unchanged and applies stateful retry only to the returned oio::Writer methods.

Reproducer

At this head I changed the existing synthetic service stat method to increment attempts and always return Error::new(ErrorKind::Unexpected, "429 Too Many Requests").set_temporary(), then added:

let store = Arc::new(store_with_retry(Operator::new(builder).unwrap()));
let store = AimdThrottledStore::new(store, AimdThrottleConfig::default()).unwrap();
let _ = store.head(&Path::from("object")).await.unwrap_err();
assert_eq!(attempts.load(Ordering::SeqCst), 1,
    "multipart-writer retry must not retry or hide ordinary HEAD failures");

I ran cargo test -p lance-io --all-features object_store::opendal_retry::tests::reproduce_retry_layer_scope_and_aimd_compounding -- --exact --nocapture. The assertion failed with left: 84, right: 1.

@lance-gatekeeper lance-gatekeeper Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Gate recommendation: request changes.

The writer-only boundary resolves the previous AIMD amplification, but the generic close retry is unsafe for destructive writer state machines and can convert failed nonempty writes into successful empty objects. Limit recovery to replay-safe write operations, or make retry eligibility provider- and operation-aware; close and abort should remain one-shot unless the provider preserves staged state after an error.

}

async fn close(&mut self) -> Result<Metadata> {
retry_writer_operation(&mut self.inner, |writer| Box::pin(writer.close())).await

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Retrying this close can turn nonempty GooseFS output into a successfully published empty object. The pinned GoosefsWriter::close removes self.writer before awaiting the SDK close; on error it takes and deletes tmp_path before returning. TransportError is marked temporary, so this wrapper calls close again on empty state, which enters the GooseFS zero-write branch and publishes an empty file.

Keep close one-shot, matching the removed ObjectWriter behavior, and do the same for abort unless a provider guarantees replayability. Alternatively, enable retries only for provider/method pairs whose state remains resumable; the part-recovery failure addressed here only requires retrying write.

Reproducer

A live GooseFS transport fault requires a cluster and private SDK error construction, so I ran the closest bounded state-machine test against this head. I added an oio::Write whose close mirrors GooseFS by consuming buffered state before returning one temporary error:

async fn close(&mut self) -> Result<Metadata> {
    let buffered_size = std::mem::take(&mut self.buffered_size);
    if self.close_attempts.fetch_add(1, Ordering::SeqCst) == 0 {
        Err(Error::new(ErrorKind::Unexpected, "transport closed").set_temporary())
    } else {
        self.published_size.store(buffered_size, Ordering::SeqCst);
        Ok(Metadata::new(EntryMode::FILE))
    }
}

let mut writer = RetryWriter { inner: destructive_writer };
writer.write(Buffer::from("payload")).await.unwrap();
writer.close().await.unwrap();
assert_eq!(published_size.load(Ordering::SeqCst), 7);

I ran cargo test -p lance-io --all-features object_store::opendal_retry::tests::reproduce_destructive_close_retry_publishes_empty_object -- --exact --nocapture. The assertion failed with left: 0, right: 7 after two close attempts.

@codecov

codecov Bot commented Aug 3, 2026

Copy link
Copy Markdown

@lance-gatekeeper lance-gatekeeper Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Gate recommendation: request changes.

The destructive GooseFS finalization replay is fixed, but retrying only write does not recover the common OpenDAL multipart failures this PR is intended to handle: OpenDAL queues part work and surfaces temporary failures while close drains it.

Make the retry boundary provider- and state-machine-aware: HTTP multipart/block writers need recovery at a stable part/task boundary, while GooseFS-style destructive finalization must remain one-shot.

async fn close(&mut self) -> Result<Metadata> {
// Finalization can consume staged state before returning an error, so it is not
// replay-safe.
self.inner.close().await

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This one-shot close drops transient recovery for HTTP-backed OpenDAL multipart writers. In pinned OpenDAL 0.58.1, MultipartWriter::write caches or enqueues part work and commonly returns Ok(()); MultipartWriter::close submits and drains those tasks. If a queued part returns a temporary connection error, ConcurrentTasks::next preserves and requeues the same part input but returns the error so the caller can re-enter close. This wrapper instead returns that first error, so common one- or few-part OSS/COS/TOS uploads—and OpenDAL S3/Azure/GCS when AIMD does not classify the error as throttling—fail without using the configured retry budget.

Make finalization policy aware of the underlying writer: retry resumable OpenDAL multipart/block task draining, or retry at its stable part-request boundary, while keeping destructive GooseFS close one-shot.

Reproducer

I ran this regression against the pinned oio::MultipartWriter at this head. Its first part attempt returns one temporary connection error; the writer retains that task for a retry, but this wrapper does not re-enter close:

#[derive(Debug)]
struct DeferredPartWriter {
    attempts: Arc<AtomicUsize>,
}

impl oio::MultipartWrite for DeferredPartWriter {
    async fn write_once(&self, _: u64, _: Buffer) -> Result<Metadata> {
        Ok(Metadata::new(EntryMode::FILE))
    }
    async fn initiate_part(&self) -> Result<String> {
        Ok("upload".to_string())
    }
    async fn write_part(
        &self, _: &str, part_number: usize, _: u64, _: Buffer,
    ) -> Result<oio::MultipartPart> {
        if self.attempts.fetch_add(1, Ordering::SeqCst) == 0 {
            return Err(Error::new(
                ErrorKind::Unexpected, "connection reset by peer",
            ).set_temporary());
        }
        Ok(oio::MultipartPart {
            part_number, etag: format!("part-{part_number}"),
            checksum: None, size: None,
        })
    }
    async fn complete_part(
        &self, _: &str, _: &[oio::MultipartPart],
    ) -> Result<Metadata> {
        Ok(Metadata::new(EntryMode::FILE))
    }
    async fn abort_part(&self, _: &str) -> Result<()> { Ok(()) }
}

#[tokio::test(start_paused = true)]
async fn reproduce_deferred_part_failure_escapes_write_retry() {
    let attempts = Arc::new(AtomicUsize::new(0));
    let inner = oio::MultipartWriter::new(
        opendal::Executor::default(),
        DeferredPartWriter { attempts: Arc::clone(&attempts) },
        8,
    );
    let mut writer = RetryWriter { inner };

    writer.write(Buffer::from("part-0")).await.unwrap();
    writer.write(Buffer::from("part-1")).await.unwrap();
    writer.close().await.unwrap();
    assert!(attempts.load(Ordering::SeqCst) > 2);
}

I ran cargo test -p lance-io --all-features object_store::opendal_retry::tests::reproduce_deferred_part_failure_escapes_write_retry -- --exact --nocapture. Expected: finalization succeeds after retrying the preserved part task. Observed: close().unwrap() failed on the first attempt with temporary connection reset by peer.

@lance-gatekeeper lance-gatekeeper Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Gate recommendation: request changes.

The provider-aware close policy resolves the prior finalization failures, but GooseFS still inherits unconditional whole-buffer write replay. Because its writer accepts buffers incrementally, a temporary mid-buffer failure can duplicate already-written bytes.

Classify write replay independently: bypass generic write retry for GooseFS, or retry only below a provider boundary that can prove the failed bytes were not accepted.


impl<W: oio::Write> oio::Write for RetryWriter<W> {
async fn write(&mut self, body: Buffer) -> Result<()> {
retry_replay_safe_operation(&mut self.inner, "write", move |writer| {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This retries writer.write by replaying the entire Buffer, but the GooseFS writer can consume a prefix before a later chunk fails. The same writer has then advanced, so replaying the original buffer duplicates the accepted prefix and silently corrupts object data. FinalizationRetry::OneShot only gates close; GooseFS still uses this write path.

Classify write replay independently: disable it for GooseFS, or retry below a provider boundary that can prove the failed bytes were not accepted.

Reproducer

At this head I added a synthetic incremental writer that accepted "prefix", returned one temporary error, and then accepted the retried buffer:

writer.write(Buffer::from("prefixsuffix")).await.unwrap();
assert_eq!(written, b"prefixsuffix");

I ran:

cargo test -p lance-io --all-features object_store::opendal_retry::tests::reproduce_partial_write_replay_duplicates_prefix -- --exact --nocapture

Expected: prefixsuffix. Observed: prefixprefixsuffix.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in c17039c. Removed the generic OpenDAL writer retry layer, so GooseFS no longer replays partially accepted buffers; every OpenDAL provider now uses its original OpendalStore path.

@Xuanwo Xuanwo left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This bug is object_store only, please don't touch opendal.

@lance-gatekeeper

Copy link
Copy Markdown
Contributor Author

Addressed in c17039c. Removed the OpenDAL retry module and restored every OpenDAL provider and writer path. The remaining PR diff is limited to native object_store connector retries, AIMD integration, and removal of unsafe logical part resubmission.

@lance-gatekeeper lance-gatekeeper Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Gate recommendation: approve.

The native connector now retries below multipart part allocation, preserving provider-assigned identities while feeding each physical attempt into the shared write AIMD controller. Removing generic put_part resubmission also avoids unsafe replay for OpenDAL and custom stores.

@Xuanwo
Xuanwo merged commit 86d3bd1 into main Aug 5, 2026
43 checks passed
@Xuanwo
Xuanwo deleted the gatekeeper/fix-7956-1 branch August 5, 2026 09:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-encoding Encoding, IO, file reader/writer bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AIMD retries broken for multipart uploads (Missing Part error)

1 participant