Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,23 @@ Every package releases together, at one version
that changes something consumers notice adds a line under the next version's heading. `release.yml` publishes a
version's section as its GitHub release notes.

## 2027.1.1

### Fixed

- **An append ends the same way on every provider** (Physical, Amazon S3, MinIO, Google Cloud Storage, Box,
SharePoint, and a provider of your own built on `AppendByRewriteAsync`): an append with no condition of yours that
meets another writer's change or create, or a file deleted since it was read, reads the file again and appends after
what is there now, up to five attempts in all, then throws `PreconditionFailedException`, as Azure Blob's append always
did. These providers threw at the first conflict: `PreconditionFailedException`, or `FileAlreadyExistsException` for a
file created meanwhile. With your own `IfMatch` or `IfNotExists` the first conflict still throws. Where a store's
answer to a write is lost and its SDK sends the write again, the append can meet its own write and append its bytes a
second time, as a retry by hand would. Physical copies your stream to a hidden `.wfp-<guid>.tmp` beside the file
first. On Linux and macOS, Physical still throws `FileAlreadyExistsException` for an append that loses a new name
while the file there is empty (a move's destination, a file system without hard links, or a file another writer left
empty), since it can't tell such a file from one a rename is about to fill, which would undo the append; on Windows,
one of two appends to one file at once can still fail with `UnauthorizedAccessException`.

## 2027.1.0 (2026-10-13)

Additions and fixes, nothing that breaks: a create-only write's own exception, an injectable clock, Physical's link
Expand Down
4 changes: 2 additions & 2 deletions docs/guide/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ for when you need its status or error code.
| `ArgumentException` (and `ArgumentNullException`, `ArgumentOutOfRangeException`) | A path or an option the provider refuses. |
| `FileNotFoundException` | The file doesn't exist, or is a folder. |
| `FileExistsException` | The destination of a copy or a move exists. |
| `FileAlreadyExistsException` | A write with `IfNotExists` found a file at its path; or, where the provider refuses it, an append to a missing file that another writer created meanwhile ([providers compared](providers.md#reads-and-writes)). It is a `PreconditionFailedException`. |
| `PreconditionFailedException` | `IfMatch` didn't hold, a pinned read's file changed, an append found its file changed, a copy's or a move's source changed while it was copied, or the store reported a conflict with a concurrent operation (the write can be retried); and, as its subtype above, `IfNotExists` didn't hold. |
| `FileAlreadyExistsException` | A write with `IfNotExists` found a file at its path; or, on Physical on Linux and macOS, an append lost a new name to a file still empty ([appends](reading-and-writing.md#append)). It is a `PreconditionFailedException`. |
| `PreconditionFailedException` | `IfMatch` didn't hold, a pinned read's file changed, an append with no condition found its file changed five times in a row ([appends](reading-and-writing.md#append)), a copy's or a move's source changed while it was copied, or the store reported a conflict with a concurrent operation (the write can be retried); and, as its subtype above, `IfNotExists` didn't hold. |
| `NotSupportedException` | The provider or its store can't do it. |
| `InvalidOperationException` | The store isn't set up as configured (a missing container, bucket, library or root folder, or no credentials to find), or you tried to modify the root. |
| `UnauthorizedAccessException` | The store refused the credential or the permission: HTTP 401 or 403, or a refused sign-in. |
Expand Down
8 changes: 4 additions & 4 deletions docs/guide/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,10 @@ whose condition fails. **Checked** means the provider compares just before writi
`IfNotExists` that finds a file throws `FileAlreadyExistsException` on every provider. Only a file counts: where a
folder has the name, the write gets the answer it gets without conditions.

An append to a missing file that another writer creates at the same moment ends differently: Azure Blob appends after
the new content, and every other provider refuses with `FileAlreadyExistsException`. Where the store enforces the
condition (**Atomic** in the `IfNotExists` column), the other writer's content is kept either way; where it is only
checked, a file created in between can be replaced.
An append with no condition of yours that meets another writer's change or create reads the file again and appends
after it, on every provider, up to five attempts in all. Where the store enforces the condition (**Atomic** in the
`IfMatch` and `IfNotExists` columns), the other writer's content is kept; where it is only checked, a write that lands
between the check and the rewrite can be replaced.

Every provider reads byte ranges natively; Box and SharePoint read them from the file's download URL.

Expand Down
18 changes: 13 additions & 5 deletions docs/guide/reading-and-writing.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,22 @@ append and a local stream's commit keep the file's type.
## Append

Where the store has no native append, the provider rewrites the file on condition that it is still the version it read.
If another write landed in between, the append throws `PreconditionFailedException`: retry it. An append to a missing
file that another writer creates at the same moment ends differently by provider: most refuse it with
`FileAlreadyExistsException`, and Azure Blob appends after the new content. Where the store enforces `IfNotExists`,
the other writer's content is kept either way ([providers compared](providers.md#reads-and-writes)).
If another writer changed or created the file in between, or deleted it, the append reads it again and appends after
what is there now, on every provider, as Azure Blob's native append does: up to five attempts in all, then
`PreconditionFailedException`. With your own `IfMatch` or `IfNotExists`, the first conflict throws instead, since you
asked for that version, or for no file at all.

- The condition is atomic on Azure Blob, Google Cloud Storage and S3 on AWS.
- Elsewhere it is checked, so a write that lands between the check and the rewrite can be lost.
- Elsewhere it is checked, so a write that lands between the check and the rewrite can be lost
([providers compared](providers.md#reads-and-writes)).
- An append by rewrite needs the file's `ETag`; a file without one throws `NotSupportedException`.
- A write whose answer was lost, and which the store's SDK sent again, can meet itself: the append then reads its own
write as another writer's and appends its bytes a second time, as a caller who retried by hand would.
- On Windows, which doesn't rename over a file that is open, Physical can't replace a file another append is reading: of
two appends to one file at once, one can fail with `UnauthorizedAccessException`.
- On Linux and macOS, Physical's append that loses a new name throws `FileAlreadyExistsException` while the file there is
still empty (a move's destination, a file system without hard links, or a file another writer left empty): it can't
tell such a file from one a rename is about to fill, which would undo an append made to it.

## Write only if unchanged, or only if absent

Expand Down
3 changes: 2 additions & 1 deletion docs/guide/writing-a-provider.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,8 @@ Declare what the store can do. Until you do, the base refuses or weakens the ope

## Use the helpers

- `AppendByRewriteAsync`: an append for a store without one, conditional on the version read.
- `AppendByRewriteAsync`: an append for a store without one, conditional on the version read, which reads the file
again after a concurrent change, as every provider's append does when the caller gave no condition.
- `SpoolToTempFileAsync`: copies a stream to a local temp file, for an SDK that needs a length or a seekable stream.
- `NotFound(path)`: the `FileNotFoundException` for a missing file, naming the path under the root.
- `ReadWithContractAsync`: a read by a public member of your own, under the same error rules as `ReadFileAsync`.
Expand Down
7 changes: 4 additions & 3 deletions docs/migration-2027.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,8 @@ Azure.Storage.Blobs moves from 12.23 to 12.30.
| A URL lived as asked | Short-lived credentials need `AllowShorterValidity` | Use long-term keys, or set it. |
| Any endpoint scheme | Plain `http` to a non-loopback host: `ArgumentException` | Use `https`, or set `AllowInsecureHttp`. |
| Writes disposed your stream | Left open | Dispose it yourself. |
| A racing append or move went through | `PreconditionFailedException` | Retry. |
| A racing append lost a write | The append reads the file again and lands after the other write; `PreconditionFailedException` after five attempts (2027.1.1) | Nothing; retry the rare failure. |
| A racing move went through | `PreconditionFailedException` | Retry. |

- **`ReadFileAsync(versionId, "a", "b.txt")` still compiles**: it reads the path `<versionId>/a/b.txt`.
- `AllowShorterValidity` is needed for a credential that expires first, a session token, or a client you pass in.
Expand Down Expand Up @@ -271,7 +272,7 @@ Azure.Storage.Blobs moves from 12.23 to 12.30.
| `GetFileInfo` read one object | It also lists | Grant `storage.objects.list` (`roles/storage.objectViewer`). |
| `ReadFile` returned a seekable temp file | A forward-only stream | Use `OpenReadSeekableAsync` or `ReadRangeAsync`. |
| The SDK retried a failed copy | Not retried | Check the destination before you retry. |
| A racing append lost a write | `PreconditionFailedException` | Retry. |
| A racing append lost a write | The append reads the file again and lands after the other write; `PreconditionFailedException` after five attempts (2027.1.1) | Nothing; retry the rare failure. |
| Writes disposed your stream | Left open | Dispose it yourself. |
| Over 7 days: `ArgumentOutOfRangeException`; can't sign: `PlatformNotSupportedException` | `NotSupportedException` | Catch it, or use the gateway. |

Expand Down Expand Up @@ -325,7 +326,7 @@ now place files elsewhere:
| Any Graph error read as "missing" | Only 404; 401, 403: `UnauthorizedAccessException`; else `IOException` | Catch those too. |
| `ReadFile` returned a seekable temp file | A forward-only download | Use `OpenReadSeekableAsync` to seek. |
| Writes rewound your stream, some disposed it | Read from its position, leave it open | Rewind and dispose it yourself. |
| A racing append lost a write | `PreconditionFailedException` | Retry. |
| A racing append lost a write | The append reads the file again and lands after the other write; `PreconditionFailedException` after five attempts (2027.1.1) | Nothing; retry the rare failure. |
| `a%41` in a path reached the service as `aA` | Names `a%41` | Read 2026's files by the decoded name. |
| Download URLs ignored the validity | 1 hour to 7 days, with `AllowShorterValidity` | Set it, or use the gateway. |

Expand Down
Loading
Loading