fix(data): retry absent pending uploads without changing identity

This commit is contained in:
aj committed 2026-10-11 17:15:16 -04:00
1 parent 94ed8a0f8f
commit fae37dd1d0
5 files changed
+78 -9

No files matched your search

+8 -3
View File
@@ -12,9 +12,11 @@ public sealed class NestSaveSession
public int Revision { get; private set; }
public bool RequiresReload { get; private set; }
public Guid PendingCreateId { get; private set; }
public bool PendingCreateWasCopy => PendingCreateId != Guid.Empty && pendingCreateWasCopy;
private string pendingCreateUrl = "";
private byte[]? pendingCreateArchive;
private NestRecord? pendingCreateMetadata;
private bool pendingCreateWasCopy;
public void Bind(Guid id, string serverUrl, int revision)
{
@@ -34,6 +36,7 @@ public sealed class NestSaveSession
PendingCreateId = Guid.Empty;
pendingCreateArchive = null;
pendingCreateMetadata = null;
pendingCreateWasCopy = false;
pendingCreateUrl = "";
}
@@ -72,10 +75,11 @@ public sealed class NestSaveSession
if (normalizedUrl.Length == 0)
throw new ArgumentException("A database server URL is required.", nameof(serverUrl));
if (PendingCreateId != Guid.Empty && (RequiresReload || normalizedUrl != pendingCreateUrl || saveCopy))
throw new IOException("The previous upload may have succeeded. Reconcile its pending ID on the original server before saving or copying.");
if (PendingCreateId != Guid.Empty && (RequiresReload || normalizedUrl != pendingCreateUrl
|| saveCopy != pendingCreateWasCopy))
throw new IOException("Reconcile the pending upload on its original server, then retry with the same Save or Save As action.");
var id = !saveCopy && ServerUrl == normalizedUrl ? RemoteId : Guid.Empty;
var creating = id == Guid.Empty;
var creating = PendingCreateId != Guid.Empty || id == Guid.Empty;
var uploadId = creating ? (PendingCreateId == Guid.Empty ? Guid.NewGuid() : PendingCreateId) : id;
var record = NestRecordFactory.FromNest(nest, uploadId, archive.LongLength);
if (!creating && (Revision < 1 || RequiresReload))
@@ -86,6 +90,7 @@ public sealed class NestSaveSession
pendingCreateUrl = normalizedUrl;
pendingCreateArchive = archive.ToArray();
pendingCreateMetadata = record;
pendingCreateWasCopy = saveCopy;
}
NestRecord saved;
try
@@ -66,6 +66,68 @@ public sealed class NestHistoryClientTests
Assert.Single(await repository.ListAsync());
}
private sealed class FirstPostFailsBeforeSend : DelegatingHandler
{
private bool failed;
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
if (request.Method == HttpMethod.Post && !failed)
{
failed = true;
throw new IOException("Upload was not sent.");
}
return base.SendAsync(request, ct);
}
}
[Fact]
public async Task AbsentPendingUpload_RetriesSameIdOnlyAfterExplicitReconciliation()
{
using var factory = new ServerFactory();
using var http = factory.CreateDefaultClient(new FirstPostFailsBeforeSend());
using var repository = new RemoteNestRepository(http, http.BaseAddress!.ToString());
var session = new NestSaveSession();
var nest = NestApiTests.SyntheticNest();
var archive = NestApiTests.Archive(nest);
await Assert.ThrowsAsync<IOException>(() => session.SaveAsync(repository, http.BaseAddress.ToString(), nest, archive));
var pending = session.PendingCreateId;
Assert.Empty(await repository.ListAsync());
await Assert.ThrowsAsync<IOException>(() => session.SaveAsync(repository, http.BaseAddress.ToString(), nest, archive));
Assert.Null(await session.ReconcilePendingCreateAsync(repository, http.BaseAddress.ToString()));
Assert.False(session.RequiresReload);
var created = await session.SaveAsync(repository, http.BaseAddress.ToString(), nest, archive);
Assert.Equal(pending, created.Id);
Assert.Equal(1, created.Revision);
Assert.Single(await repository.ListAsync());
}
[Fact]
public async Task AbsentPendingSaveAs_NeverUpdatesBoundOriginalOnRetry()
{
using var factory = new ServerFactory();
using var seedHttp = factory.CreateClient();
using var seed = new RemoteNestRepository(seedHttp, seedHttp.BaseAddress!.ToString());
var nest = NestApiTests.SyntheticNest();
var originalBytes = NestApiTests.Archive(nest);
var original = await seed.UploadAsync(originalBytes, new NestRecord { Name = "Original" });
using var http = factory.CreateDefaultClient(new FirstPostFailsBeforeSend());
using var repository = new RemoteNestRepository(http, http.BaseAddress!.ToString());
var session = new NestSaveSession();
session.Bind(original.Id, http.BaseAddress.ToString(), original.Revision);
nest.Name = "Copied nest";
var copyBytes = NestApiTests.Archive(nest);
await Assert.ThrowsAsync<IOException>(() => session.SaveAsync(repository, http.BaseAddress.ToString(), nest, copyBytes, saveCopy: true));
var pending = session.PendingCreateId;
Assert.Null(await session.ReconcilePendingCreateAsync(repository, http.BaseAddress.ToString()));
await Assert.ThrowsAsync<IOException>(() => session.SaveAsync(repository, http.BaseAddress.ToString(), nest, copyBytes));
var copy = await session.SaveAsync(repository, http.BaseAddress.ToString(), nest, copyBytes, saveCopy: true);
Assert.Equal(pending, copy.Id);
Assert.NotEqual(original.Id, copy.Id);
Assert.Equal(1, (await repository.GetMetadataAsync(original.Id))!.Revision);
Assert.Equal(originalBytes, await repository.GetFileAsync(original.Id));
Assert.Equal(copyBytes, await repository.GetFileAsync(copy.Id));
}
[Fact]
public async Task RealClient_CreatesAndUpdatesOneNestWithExactHistoricalArchives()
{
+2
View File
@@ -19,6 +19,8 @@ namespace OpenNest
public Guid RemoteId => remoteSession.RemoteId;
public Guid PendingCreateId => remoteSession.PendingCreateId;
public bool PendingCreateRequiresReconciliation => remoteSession.PendingCreateId != Guid.Empty && remoteSession.RequiresReload;
public bool PendingCreateWasCopy => remoteSession.PendingCreateWasCopy;
public void BindRemote(Guid id, string serverUrl) => remoteSession.Bind(id, serverUrl);
public void BindRemote(Guid id, string serverUrl, int revision) => remoteSession.Bind(id, serverUrl, revision);
+2 -2
View File
@@ -722,12 +722,12 @@ namespace OpenNest.Forms
{
var serverUrl = NestStorage.Settings.ServerUrl;
var repository = NestStorage.CreateRepository();
if (form.Document.PendingCreateId != Guid.Empty)
if (form.Document.PendingCreateRequiresReconciliation)
{
var reconciled = await form.Document.ReconcilePendingCreateAsync(repository, serverUrl);
if (!IsDisposed)
statusLabel1.Text = reconciled is null
? "No pending nest was found. Review your work, then Save again to retry the same ID."
? $"No pending nest was found. Review your work, then {(form.Document.PendingCreateWasCopy ? "Save As" : "Save")} again to retry the same ID."
: $"Found saved version {reconciled.Revision}. Review the current nest before saving again.";
return; // Never combine reconciliation with a write in the same click.
}
+4 -4
View File
@@ -151,10 +151,10 @@ For an ambiguous first upload (including Save As or a server switch), OpenNest
keeps the client-generated pending ID and blocks another upload. The next Save
checks that exact ID on the original server without writing: if it exists with
the exact attempted archive and metadata, the document binds to its revision;
if it is absent, a further explicit Save may retry with the same ID. If it
differs, reopen the server record to review it; do not discard the pending
identity or blindly create another record. Older servers remain read-only for
existing nests until upgraded.
if it is absent, another explicit Save (or Save As for a pending copy) may
retry with the same ID. If it differs, reopen the server record to review it;
do not discard the pending identity or blindly create another record. Older
servers remain read-only for existing nests until upgraded.
## Server integration tests