Skip to content

Commit de3193e

Browse files
committed
Reconcile lost immutable storage write responses
1 parent 8c0ab91 commit de3193e

5 files changed

Lines changed: 93 additions & 5 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.12</Version>
33-
<PackageVersion>10.0.12</PackageVersion>
32+
<Version>10.0.13</Version>
33+
<PackageVersion>10.0.13</PackageVersion>
3434

3535
</PropertyGroup>
3636

‎ManagedCode.Storage.Core/Primitives/VerifiedObjectUpload.Bytes.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ public static async Task<VerifiedObjectUploadResult> WriteBytesIfAbsentOrSameAsy
3232
info = await storage.WriteObjectAsync(path, source, options with { IfAbsent = true },
3333
cancellationToken).ConfigureAwait(false);
3434
}
35-
catch (StorageOperationException conflict) when (conflict.IsConflict)
35+
catch (IOException) when (!cancellationToken.IsCancellationRequested)
3636
{
3737
info = await storage.GetObjectInfoAsync(path, cancellationToken).ConfigureAwait(false);
3838
if (info.Length != content.Length ||

‎ManagedCode.Storage.Core/Primitives/VerifiedObjectUpload.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ public static async Task<VerifiedObjectUploadResult> WriteIfAbsentOrSameAsync(
5151
info = await multipart.CommitPartsAsync(path, partIds,
5252
options with { IfAbsent = true }, cancellationToken).ConfigureAwait(false);
5353
}
54-
catch (StorageOperationException conflict) when (conflict.IsConflict)
54+
catch (IOException) when (!cancellationToken.IsCancellationRequested)
5555
{
5656
info = await multipart.GetObjectInfoAsync(path, cancellationToken).ConfigureAwait(false);
5757
if (info.Length != expectedLength ||
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.IO;
4+
using System.Text;
5+
using System.Threading;
6+
using System.Threading.Tasks;
7+
using ManagedCode.Storage.Core.Primitives;
8+
using Shouldly;
9+
using Xunit;
10+
11+
namespace ManagedCode.Storage.Tests.Storages;
12+
13+
public sealed class VerifiedObjectUploadBytesTests
14+
{
15+
[Fact]
16+
public async Task LostWriteResponse_ReconcilesOnlyExactStoredBytesAndMetadata()
17+
{
18+
var storage = new LostResponseStorage();
19+
var options = new StorageWriteOptions
20+
{
21+
ContentType = "application/json",
22+
Metadata = new Dictionary<string, string> { ["owner"] = "one" }
23+
};
24+
var bytes = Encoding.UTF8.GetBytes("{\"value\":1}");
25+
26+
var result = await storage.WriteBytesIfAbsentOrSameAsync("payload.json", bytes, options);
27+
28+
result.ReusedExisting.ShouldBeTrue();
29+
result.Info.Length.ShouldBe(bytes.Length);
30+
(await Should.ThrowAsync<StorageOperationException>(() =>
31+
storage.WriteBytesIfAbsentOrSameAsync("payload.json", bytes,
32+
options with { Metadata = new Dictionary<string, string> { ["owner"] = "two" } })))
33+
.IsConflict.ShouldBeTrue();
34+
(await Should.ThrowAsync<StorageOperationException>(() =>
35+
storage.WriteBytesIfAbsentOrSameAsync("payload.json", Encoding.UTF8.GetBytes("different"),
36+
options))).IsConflict.ShouldBeTrue();
37+
}
38+
39+
private sealed class LostResponseStorage : IObjectStorage
40+
{
41+
private byte[]? _bytes;
42+
private StorageObjectInfo? _info;
43+
44+
public Uri ContainerUri { get; } = new("https://example.test/container");
45+
46+
public Task<StorageObjectInfo> GetObjectInfoAsync(string path, CancellationToken cancellationToken = default) =>
47+
Task.FromResult(_info ?? throw new StorageOperationException("Missing.", 404, new IOException()));
48+
49+
public Task<Stream> OpenObjectReadAsync(string path, StorageReadOptions? options = null,
50+
CancellationToken cancellationToken = default) =>
51+
Task.FromResult<Stream>(new MemoryStream(_bytes ?? throw new IOException("Missing."), writable: false));
52+
53+
public async Task<StorageObjectInfo> WriteObjectAsync(string path, Stream content,
54+
StorageWriteOptions? options = null, CancellationToken cancellationToken = default)
55+
{
56+
if (_info is not null)
57+
{
58+
throw new StorageOperationException("Conflict.", 412, new IOException());
59+
}
60+
61+
using var target = new MemoryStream();
62+
await content.CopyToAsync(target, cancellationToken);
63+
_bytes = target.ToArray();
64+
_info = new StorageObjectInfo(path, "etag-1", _bytes.Length, options?.ContentType,
65+
options?.ContentEncoding, options?.Metadata ?? new Dictionary<string, string>());
66+
throw new IOException("The write committed but its response was lost.");
67+
}
68+
69+
public Task<StorageContainerInfo> GetContainerInfoAsync(CancellationToken cancellationToken = default) =>
70+
throw new NotSupportedException();
71+
public Task CreatePrivateContainerAsync(IReadOnlyDictionary<string, string>? metadata = null,
72+
CancellationToken cancellationToken = default) => throw new NotSupportedException();
73+
public Task SetContainerMetadataAsync(IReadOnlyDictionary<string, string> metadata,
74+
CancellationToken cancellationToken = default) => throw new NotSupportedException();
75+
public Task<bool> DeleteContainerIfExistsAsync(CancellationToken cancellationToken = default) =>
76+
throw new NotSupportedException();
77+
public Task<bool> ObjectExistsAsync(string path, CancellationToken cancellationToken = default) =>
78+
throw new NotSupportedException();
79+
public Task SetObjectMetadataAsync(string path, IReadOnlyDictionary<string, string> metadata,
80+
string? ifMatch = null, CancellationToken cancellationToken = default) =>
81+
throw new NotSupportedException();
82+
public Task<bool> DeleteObjectIfExistsAsync(string path, bool includeSnapshots = false,
83+
CancellationToken cancellationToken = default) => throw new NotSupportedException();
84+
public Task<StorageObjectPage> ListObjectsAsync(string? prefix = null, string? continuationToken = null,
85+
int pageSize = 100, CancellationToken cancellationToken = default) => throw new NotSupportedException();
86+
}
87+
}

‎docs/Architecture.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ metadata cache after a successful write or verified retry. Providers without
5252
atomic object writes reject that operation rather than silently using a
5353
check-then-upload sequence. Core also owns `VerifiedContentSnapshot` for a
5454
bounded, file-backed SHA-256 read and `VerifiedObjectUpload` for immutable
55-
stream or byte writes with exact retry comparison.
55+
stream or byte writes with exact retry comparison, including reconciliation
56+
after a write commits but its response is lost.
5657

5758
## Scoping (read first)
5859

0 commit comments

Comments
 (0)