Skip to content

Commit 08d9ada

Browse files
committed
fix Azure object ETag consistency across reads
1 parent ef7b6b3 commit 08d9ada

6 files changed

Lines changed: 11 additions & 7 deletions

File tree

‎Directory.Build.props‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@
2929
<RepositoryUrl>https://github.com/managedcode/Storage</RepositoryUrl>
3030
<PackageProjectUrl>https://github.com/managedcode/Storage</PackageProjectUrl>
3131
<Product>Managed Code - Storage</Product>
32-
<Version>10.0.16</Version>
33-
<PackageVersion>10.0.16</PackageVersion>
32+
<Version>10.0.17</Version>
33+
<PackageVersion>10.0.17</PackageVersion>
3434

3535
</PropertyGroup>
3636

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ Cloud storage vendors expose distinct SDKs, option models, and authentication pa
106106
- ASP.NET storage controllers, chunk orchestration services, and a SignalR hub/client pair that deliver resumable uploads, ranged downloads, CRC32 validation, and real-time progress.
107107
- `ManagedCode.Storage.Client` brings streaming uploads/downloads, CRC32 helpers, and MIME discovery via `MimeHelper` to any .NET app.
108108
- Strongly typed option objects (`UploadOptions`, `DownloadOptions`, `DeleteOptions`, `MetadataOptions`, `LegalHoldOptions`, etc.) let you configure directories, metadata, and legal holds in one place.
109-
- Azure object listings return bounded pages with opaque continuation tokens and `null` after the last page, including empty listings.
109+
- Azure object listings return bounded pages with opaque continuation tokens and `null` after the last page, including empty listings. Listing and properties reads expose the same opaque ETag for the same revision.
110110
- Azure metadata preserves Unicode filenames and other logical string values through a provider-owned ASCII transport envelope; ordinary native ASCII metadata remains interoperable. See [metadata transport](https://github.com/managed-code-hub/Storage/blob/main/docs/Features/provider-azure-blob.md#metadata-transport).
111111
- Virtual File System package provides a file/directory API (`IVirtualFileSystem`) on top of the configured `IStorage` and can cache metadata for faster repeated operations, including browser storage verified through real Playwright flows in both Blazor WebAssembly and Interactive Server hosts.
112112
- For decisions requiring current storage state, `IVirtualFileSystem.StorageFileExistsAsync` bypasses that cache and propagates provider errors. `WriteBytesIfAbsentOrSameAsync` uses an atomic provider capability and accepts only an exact immutable retry. `ManagedCode.Storage.Core.Primitives.VerifiedContentSnapshot` verifies bounded reads by length and SHA-256.

‎Storages/ManagedCode.Storage.Azure/AzureObjectOperations.cs‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ internal sealed class AzureObjectOperations(BlobContainerClient container) : IMu
1919
public Task<StorageContainerInfo> GetContainerInfoAsync(CancellationToken cancellationToken = default) => ExecuteAsync(async () =>
2020
{
2121
var value = (await container.GetPropertiesAsync(cancellationToken: cancellationToken)).Value;
22-
return new StorageContainerInfo(value.ETag.ToString(), value.PublicAccess == PublicAccessType.None,
22+
return new StorageContainerInfo(value.ETag.ToString("H"), value.PublicAccess == PublicAccessType.None,
2323
AzureMetadataTransport.Decode(value.Metadata));
2424
});
2525

@@ -50,7 +50,7 @@ public Task<Stream> OpenObjectReadAsync(string path, StorageReadOptions? options
5050
ArgumentOutOfRangeException.ThrowIfNegative(options.Offset);
5151
if (options.Length is <= 0) throw new ArgumentOutOfRangeException(nameof(options));
5252
var blob = container.GetBlobClient(path);
53-
var etag = options.IfMatch ?? (await blob.GetPropertiesAsync(cancellationToken: cancellationToken)).Value.ETag.ToString();
53+
var etag = options.IfMatch ?? (await blob.GetPropertiesAsync(cancellationToken: cancellationToken)).Value.ETag.ToString("H");
5454
if (options.Length is null)
5555
{
5656
return await blob.OpenReadAsync(new BlobOpenReadOptions(false)
@@ -98,7 +98,7 @@ public Task<StorageObjectPage> ListObjectsAsync(string? prefix = null, string? c
9898
.AsPages(continuationToken, pageSize))
9999
{
100100
return new StorageObjectPage(page.Values.Select(item => new StorageObjectInfo(item.Name,
101-
item.Properties.ETag?.ToString() ?? throw new InvalidDataException("Object listing returned no ETag."),
101+
item.Properties.ETag?.ToString("H") ?? throw new InvalidDataException("Object listing returned no ETag."),
102102
item.Properties.ContentLength ?? throw new InvalidDataException("Object listing returned no length."),
103103
item.Properties.ContentType, item.Properties.ContentEncoding, AzureMetadataTransport.Decode(item.Metadata), item.Properties.LastModified)).ToArray(), string.IsNullOrEmpty(page.ContinuationToken) ? null : page.ContinuationToken);
104104
}
@@ -126,7 +126,7 @@ public Task<StorageObjectInfo> CommitPartsAsync(string path, IReadOnlyList<strin
126126
private async Task<StorageObjectInfo> ReadWrittenInfoAsync(string path, ETag etag, CancellationToken cancellationToken) =>
127127
Info(path, (await container.GetBlobClient(path).GetPropertiesAsync(new BlobRequestConditions { IfMatch = etag }, cancellationToken)).Value);
128128

129-
private static StorageObjectInfo Info(string path, BlobProperties value) => new(path, value.ETag.ToString(), value.ContentLength,
129+
private static StorageObjectInfo Info(string path, BlobProperties value) => new(path, value.ETag.ToString("H"), value.ContentLength,
130130
value.ContentType, value.ContentEncoding, AzureMetadataTransport.Decode(value.Metadata), value.LastModified);
131131

132132
private static BlobRequestConditions Conditions(StorageWriteOptions options)

‎Tests/ManagedCode.Storage.Tests/Storages/Azure/AzureObjectStorageTests.cs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ public async Task MetadataPaginationAndDeletion_PreserveContainerBoundary()
227227
(await objects.ListObjectsAsync("files/", pageSize: 1000)).ContinuationToken.ShouldBeNull();
228228
(await objects.ListObjectsAsync("absent/")).ContinuationToken.ShouldBeNull();
229229
var info = next.Items[0];
230+
(await objects.GetObjectInfoAsync(info.Path)).ETag.ShouldBe(info.ETag);
230231
await objects.SetObjectMetadataAsync(info.Path, new Dictionary<string, string> { ["hash"] = "value" }, info.ETag);
231232
(await objects.GetObjectInfoAsync(info.Path)).Metadata["hash"].ShouldBe("value");
232233
(await Should.ThrowAsync<StorageOperationException>(() => objects.SetObjectMetadataAsync(info.Path, new Dictionary<string, string>(), info.ETag))).IsConflict.ShouldBeTrue();

‎docs/Architecture.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ flowchart LR
3232

3333
For immutable uploads, `WriteIfAbsentOrSameAsync` streams an expected-length request through SHA-256, retries a conflicting write by reading the current ETag-pinned object, and accepts only byte-identical content with matching content type, encoding, and metadata. It returns the stored ETag, digest, and whether the object was reused; a mismatch keeps the provider conflict. Neither input nor stored content is buffered as a whole.
3434

35+
Azure object ETags use the SDK header format across listing and properties reads, so the same revision has the same opaque token and can be compared before a conditional operation.
36+
3537
Object conditions (`IfAbsent`, `IfMatch`) are enforced by the service for writes,
3638
metadata updates and reads. Read ranges use a fixed ETag and stream data without
3739
loading the object into memory. Multipart part IDs are opaque base64 identifiers

‎docs/Features/provider-azure-blob.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ builder.Services.AddAzureStorageAsDefault(options =>
5050

5151
- Supports container creation when `CreateContainerIfNotExists = true`.
5252
- `IObjectStorage.ListObjectsAsync` returns one bounded page and preserves opaque nonempty continuation tokens. The final page returns `null`, including empty listings; Azure's empty terminal marker never becomes another continuation request.
53+
- Returned object/container ETags use the SDK header format, including quotes, across listing and properties reads. An unchanged object retains the same revision token; stale conditional mutations remain rejected.
5354
- Uses Azure SDK transfer options when configured (`UploadTransferOptions`).
5455
- Builds the upload result from the successful Azure upload response and the caller's options, without issuing a second blob-properties request that can race with deletion or lifecycle processing.
5556
- Returns a failed metadata result for an absent blob without logging the expected Azure `404 BlobNotFound` response as an unhandled exception; other metadata failures retain error logging.

0 commit comments

Comments
 (0)