diff --git a/OpenNest.Data/NestSaveSession.cs b/OpenNest.Data/NestSaveSession.cs index f48d64c..18d2bc2 100644 --- a/OpenNest.Data/NestSaveSession.cs +++ b/OpenNest.Data/NestSaveSession.cs @@ -11,6 +11,10 @@ public sealed class NestSaveSession public string ServerUrl { get; private set; } = ""; public int Revision { get; private set; } public bool RequiresReload { get; private set; } + public Guid PendingCreateId { get; private set; } + private string pendingCreateUrl = ""; + private byte[]? pendingCreateArchive; + private NestRecord? pendingCreateMetadata; public void Bind(Guid id, string serverUrl, int revision) { @@ -27,6 +31,33 @@ public sealed class NestSaveSession ServerUrl = Normalize(serverUrl); Revision = 0; // Legacy reads remain viewable, but must not become guarded saves. RequiresReload = false; + PendingCreateId = Guid.Empty; + pendingCreateArchive = null; + pendingCreateMetadata = null; + pendingCreateUrl = ""; + } + + /// Explicitly reconcile an upload whose reply was lost before any retry. + public async Task ReconcilePendingCreateAsync(INestRepository repository, + string serverUrl, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(repository); + if (PendingCreateId == Guid.Empty || Normalize(serverUrl) != pendingCreateUrl) + throw new InvalidOperationException("No pending upload on this server to reconcile."); + var record = await repository.GetMetadataAsync(PendingCreateId, cancellationToken); + if (record is null) + { + // A deliberate subsequent Save may retry this SAME ID, never create a second ID. + RequiresReload = false; + return null; + } + var bytes = await repository.GetFileAsync(PendingCreateId, cancellationToken); + if (record.Id != PendingCreateId || record.Revision < 1 || bytes is null + || !bytes.AsSpan().SequenceEqual(pendingCreateArchive) + || !SameEditableMetadata(record, pendingCreateMetadata!)) + throw new IOException("The pending upload differs from the server record. Reopen the server nest before editing."); + Bind(record.Id, pendingCreateUrl, record.Revision); + return record; } public async Task SaveAsync( @@ -41,27 +72,38 @@ 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."); var id = !saveCopy && ServerUrl == normalizedUrl ? RemoteId : Guid.Empty; - var record = NestRecordFactory.FromNest(nest, id, archive.LongLength); - if (id != Guid.Empty && (Revision < 1 || RequiresReload)) + var creating = 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)) throw new IOException("Reload the current nest revision before saving, or Save As a new nest. Upgrade the nest server if version history is unavailable."); + if (creating) + { + PendingCreateId = uploadId; + pendingCreateUrl = normalizedUrl; + pendingCreateArchive = archive.ToArray(); + pendingCreateMetadata = record; + } NestRecord saved; try { - saved = id == Guid.Empty + saved = creating ? await repository.UploadAsync(archive, record, cancellationToken) : await repository.UpdateFileAsync(id, archive, record, Revision, cancellationToken); } - catch (Exception ex) when (id != Guid.Empty && ex is IOException or System.Net.Http.HttpRequestException or OperationCanceledException or System.Text.Json.JsonException) + catch (Exception ex) when (ex is IOException or System.Net.Http.HttpRequestException or OperationCanceledException or System.Text.Json.JsonException) { // The request may have committed before its reply was lost. Do not replay // pending bytes until the operator explicitly reloads/reconciles the record. RequiresReload = true; throw; } - if (saved.Id == Guid.Empty || (id != Guid.Empty && (saved.Id != id || saved.Revision < Revision))) + if (saved.Id != uploadId || (!creating && saved.Revision < Revision)) { - if (id != Guid.Empty) RequiresReload = true; + RequiresReload = true; throw new InvalidDataException("Server returned an invalid nest id or revision. Reload to reconcile before saving again."); } @@ -70,4 +112,10 @@ public sealed class NestSaveSession } private static string Normalize(string? url) => (url ?? "").Trim().TrimEnd('/'); + + private static bool SameEditableMetadata(NestRecord a, NestRecord b) => + a.Name == b.Name && a.Customer == b.Customer && a.DateCreated == b.DateCreated + && a.DateModified == b.DateModified && a.Material == b.Material && a.Thickness == b.Thickness + && a.Status == b.Status && a.PlateCount == b.PlateCount && a.PartCount == b.PartCount + && a.Comments == b.Comments && a.MadeBy == b.MadeBy; } diff --git a/OpenNest.Server.Tests/NestHistoryClientTests.cs b/OpenNest.Server.Tests/NestHistoryClientTests.cs index 559eaaa..0b62d57 100644 --- a/OpenNest.Server.Tests/NestHistoryClientTests.cs +++ b/OpenNest.Server.Tests/NestHistoryClientTests.cs @@ -5,6 +5,67 @@ namespace OpenNest.Server.Tests; public sealed class NestHistoryClientTests { + [Fact] + public async Task LostCreateReply_BlocksReplayAndReconcilesSameId() + { + using var factory = new ServerFactory(); + using var http = factory.CreateDefaultClient(new LostReplyHandler()); + 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(() => session.SaveAsync(repository, http.BaseAddress.ToString(), nest, archive)); + var pendingId = session.PendingCreateId; + Assert.NotEqual(Guid.Empty, pendingId); + Assert.True(session.RequiresReload); + Assert.Single(await repository.ListAsync()); + await Assert.ThrowsAsync(() => session.SaveAsync(repository, http.BaseAddress.ToString(), nest, archive)); + Assert.Single(await repository.ListAsync()); + var reconciled = await session.ReconcilePendingCreateAsync(repository, http.BaseAddress.ToString()); + Assert.Equal(pendingId, reconciled!.Id); + Assert.Equal(pendingId, session.RemoteId); + Assert.Equal(Guid.Empty, session.PendingCreateId); + Assert.Single(await repository.ListAsync()); + Assert.Equal(archive, await repository.GetRevisionFileAsync(pendingId, 1)); + } + + private sealed class LostReplyHandler : DelegatingHandler + { + private bool lost; + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + var response = await base.SendAsync(request, ct); + if (request.Method == HttpMethod.Post && !lost) + { + lost = true; + response.Dispose(); + throw new IOException("Response lost after commit."); + } + return response; + } + } + + [Fact] + public async Task LostCreateReply_ChangedServerMetadataCannotBindWithoutReload() + { + using var factory = new ServerFactory(); + using var http = factory.CreateDefaultClient(new LostReplyHandler()); + 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(() => session.SaveAsync(repository, http.BaseAddress.ToString(), nest, archive)); + var id = session.PendingCreateId; + var current = (await repository.GetMetadataAsync(id))!; + current.Comments = "Changed by another editor"; + await repository.UpdateMetadataAsync(id, current, current.Revision); + await Assert.ThrowsAsync(() => session.ReconcilePendingCreateAsync(repository, http.BaseAddress.ToString())); + Assert.Equal(Guid.Empty, session.RemoteId); + Assert.True(session.RequiresReload); + await Assert.ThrowsAsync(() => session.SaveAsync(repository, http.BaseAddress.ToString(), nest, archive)); + Assert.Single(await repository.ListAsync()); + } + [Fact] public async Task RealClient_CreatesAndUpdatesOneNestWithExactHistoricalArchives() { diff --git a/OpenNest.Tests/Data/NestSaveSessionTests.cs b/OpenNest.Tests/Data/NestSaveSessionTests.cs index eb1bb68..4525c7f 100644 --- a/OpenNest.Tests/Data/NestSaveSessionTests.cs +++ b/OpenNest.Tests/Data/NestSaveSessionTests.cs @@ -43,10 +43,12 @@ public class NestSaveSessionTests session.SaveAsync(repository, "http://new:8090", new Nest("Job"), [1])); Assert.Equal(oldId, session.RemoteId); Assert.Equal("http://old:8090", session.ServerUrl); - Assert.Equal(Guid.Empty, repository.LastRecord!.Id); + Assert.NotEqual(Guid.Empty, repository.LastRecord!.Id); + Assert.Equal(session.PendingCreateId, repository.LastRecord.Id); Assert.Equal(0, repository.Updates); repository.Fail = false; + Assert.Null(await session.ReconcilePendingCreateAsync(repository, "http://new:8090")); await session.SaveAsync(repository, "http://new:8090", new Nest("Job"), [1]); Assert.NotEqual(oldId, session.RemoteId); Assert.Equal("http://new:8090", session.ServerUrl); @@ -95,7 +97,7 @@ public class NestSaveSessionTests Uploads++; LastRecord = record; if (Fail) throw new IOException("Offline"); - record.Id = Guid.NewGuid(); + if (record.Id == Guid.Empty) record.Id = Guid.NewGuid(); record.Revision = 1; return Task.FromResult(record); } @@ -118,7 +120,7 @@ public class NestSaveSessionTests public Task> ListAsync(CancellationToken ct = default) => throw new NotImplementedException(); public Task QueryAsync(NestQuery query, CancellationToken ct = default) => throw new NotImplementedException(); - public Task GetMetadataAsync(Guid id, CancellationToken ct = default) => throw new NotImplementedException(); + public Task GetMetadataAsync(Guid id, CancellationToken ct = default) => Task.FromResult(null); public Task GetFileAsync(Guid id, CancellationToken ct = default) => throw new NotImplementedException(); public Task UpdateMetadataAsync(Guid id, NestRecord record, CancellationToken ct = default) => throw new NotImplementedException(); public Task DeleteAsync(Guid id, CancellationToken ct = default) => throw new NotImplementedException(); diff --git a/OpenNest/Document.cs b/OpenNest/Document.cs index f16601e..d28e209 100644 --- a/OpenNest/Document.cs +++ b/OpenNest/Document.cs @@ -18,11 +18,15 @@ namespace OpenNest private readonly NestSaveSession remoteSession = new(); public Guid RemoteId => remoteSession.RemoteId; - + public Guid PendingCreateId => remoteSession.PendingCreateId; 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); + public Task ReconcilePendingCreateAsync(INestRepository repository, string serverUrl, + CancellationToken cancellationToken = default) => + remoteSession.ReconcilePendingCreateAsync(repository, serverUrl, cancellationToken); + public int RemoteRevision => remoteSession.Revision; public string Name => Nest?.Name; diff --git a/OpenNest/Forms/MainForm.cs b/OpenNest/Forms/MainForm.cs index 4cf7f86..7f0c094 100644 --- a/OpenNest/Forms/MainForm.cs +++ b/OpenNest/Forms/MainForm.cs @@ -721,8 +721,18 @@ namespace OpenNest.Forms try { var serverUrl = NestStorage.Settings.ServerUrl; + var repository = NestStorage.CreateRepository(); + if (form.Document.PendingCreateId != Guid.Empty) + { + 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." + : $"Found saved version {reconciled.Revision}. Review the current nest before saving again."; + return; // Never combine reconciliation with a write in the same click. + } var saved = await form.Document.SaveToDatabaseAsync( - NestStorage.CreateRepository(), serverUrl, saveCopy); + repository, serverUrl, saveCopy); if (!form.IsDisposed) form.Text = form.Document.Name; if (!IsDisposed)