hfutatzhanghb commented on code in PR #7818: URL: https://github.com/apache/opendal/pull/7818#discussion_r3467316024
########## core/core/src/docs/rfcs/7818_rename_if_not_exists.md: ########## @@ -0,0 +1,450 @@ +- Proposal Name: `rename_if_not_exists` +- Start Date: 2026-06-24 +- RFC PR: [apache/opendal#7818](https://github.com/apache/opendal/pull/7818) + +# Summary + +Extend rename with an `if_not_exists` option: + +```rust +op.rename_with("staging/file", "published/file") + .if_not_exists(true) + .await?; +``` + +The existing `rename` API keeps its overwrite semantics. When +`if_not_exists` is enabled, rename succeeds only if the destination does not +exist. A destination conflict returns `ConditionNotMatch` without modifying the +source or destination. + +# Motivation + +OpenDAL defines `rename` as an overwrite operation. Some applications also need +an atomic publish primitive: move a completed staging file into place only when +no other writer has already published that destination. + +A caller cannot implement this safely with `stat` followed by `rename`. Another +writer can create the destination after `stat` reports that it is absent but +before rename runs. A service configuration flag is also unsuitable because it +would make the meaning of the same `rename` call depend on backend construction +rather than an explicit call-site condition. + +OpenDAL already models the equivalent destination condition for write and copy +through options: + +```rust +op.write_with("path", content) + .if_not_exists(true) + .await?; + +op.copy_with("source", "target") + .if_not_exists(true) + .await?; +``` + +Rename should follow the same public API and error model. + +# Guide-level explanation + +Use `rename` when the destination may be replaced: + +```rust +use opendal::{Operator, Result}; + +async fn replace(op: Operator) -> Result<()> { + op.rename("staging/file", "published/file").await?; + Ok(()) +} +``` + +Use `rename_with(...).if_not_exists(true)` when an existing destination must be +preserved: + +```rust +use opendal::{ErrorKind, Operator, Result}; + +async fn publish(op: Operator) -> Result<()> { + match op + .rename_with("staging/file", "published/file") + .if_not_exists(true) + .await + { + Ok(()) => Ok(()), + Err(err) if err.kind() == ErrorKind::ConditionNotMatch => Err(err), + Err(err) => Err(err), + } +} +``` + +The conditional operation has the following outcomes: + +- If the destination does not exist, the source is renamed to the destination. +- If the destination exists, the operation returns `ConditionNotMatch` and + leaves both paths unchanged. +- If the service cannot enforce the destination condition atomically, the + operation returns `Unsupported`. +- If source and destination are the same path, the operation returns + `IsSameFile`, matching normal rename. + +Users can inspect `Capability::rename_with_if_not_exists` before enabling the +option. + +Blocking users configure the same condition through `RenameOptions`: + +```rust +use opendal::blocking; +use opendal::options::RenameOptions; +use opendal::Result; + +fn publish(op: blocking::Operator) -> Result<()> { + let mut options = RenameOptions::default(); + options.if_not_exists = true; + op.rename_options("staging/file", "published/file", options)?; + Ok(()) +} +``` + +# Reference-level explanation + +## Public API + +Add `RenameOptions`: + +```rust +#[derive(Debug, Clone, Default, Eq, PartialEq)] +pub struct RenameOptions { + pub if_not_exists: bool, +} +``` + +The asynchronous operator exposes: + +```rust +impl Operator { + pub async fn rename(&self, from: &str, to: &str) -> Result<()>; + + pub fn rename_with( + &self, + from: &str, + to: &str, + ) -> FutureRename<impl Future<Output = Result<()>>>; + + pub async fn rename_options( + &self, + from: &str, + to: &str, + options: impl Into<RenameOptions>, + ) -> Result<()>; +} +``` + +`rename` delegates to `rename_options` with default options. `FutureRename` +provides: + +```rust +impl<F: Future<Output = Result<()>>> FutureRename<F> { + pub fn if_not_exists(self, value: bool) -> Self; +} +``` + +The blocking operator follows existing blocking options APIs: + +```rust +impl blocking::Operator { + pub fn rename_options( + &self, + from: &str, + to: &str, + options: RenameOptions, + ) -> Result<()>; +} +``` + +No standalone `rename_if_not_exists` method is added. The options API matches +write and copy and leaves room for future composable rename conditions. + +The rename API follows the copy API at every layer: + +| Layer | Copy | Rename | +| --- | --- | --- | +| Default operation | `copy` | `rename` | +| Fluent options | `copy_with(...).if_not_exists(true)` | `rename_with(...).if_not_exists(true)` | +| Options struct | `CopyOptions` | `RenameOptions` | +| Explicit options call | `copy_options` | `rename_options` | +| Raw arguments | `OpCopy::if_not_exists()` | `OpRename::if_not_exists()` | +| Capability | `copy_with_if_not_exists` | `rename_with_if_not_exists` | + +This RFC does not add a public `copy_if_not_exists`-style standalone method, +because copy itself exposes the condition through its options APIs. + +## Service API + +`RenameOptions` converts into the raw `OpRename`: + +```rust +pub struct OpRename { + if_not_exists: bool, +} +``` + +The `Service::rename` signature does not change. Services inspect +`OpRename::if_not_exists()` to select their native overwrite or no-overwrite +operation. + +Add a capability field: + +```rust +pub struct Capability { + pub rename: bool, + pub rename_with_if_not_exists: bool, +} +``` + +`rename_with_if_not_exists` is meaningful only when `rename` is also supported. +The correctness check returns `Unsupported` before dispatch when the option is +enabled but the service does not advertise the capability. + +## Error semantics + +`if_not_exists` is a destination precondition, so a destination conflict maps +to `ConditionNotMatch`. This is consistent with existing write and copy +behavior. + +Backend errors caused by the native no-replace condition must be translated to +`ConditionNotMatch`, even when the backend or operating system reports a native +error such as `AlreadyExists`. + +Other errors retain their normal meanings. For example, a missing source +returns `NotFound`, a directory passed where a file is required returns the +corresponding directory error, and an unsupported condition returns +`Unsupported`. + +## Atomicity requirement Review Comment: Thanks! -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
