diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/RestoreTablePreflightValidatorTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/RestoreTablePreflightValidatorTests.cs index fa2e9db3..7cd28c79 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/RestoreTablePreflightValidatorTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/RestoreTablePreflightValidatorTests.cs @@ -102,6 +102,23 @@ public class RestoreTablePreflightValidatorTests Assert.Equal("InvalidKeys", ex.Code); } + [Fact] + public void ValidateRowRequest_accepts_empty_partition_and_row_keys() + { + // Azure Tables permits empty-string PK/RK; the contract explicitly says + // "may be empty string but not null". Lock this so a future tightening of + // the validator doesn't silently break legal Azure Tables keys (UI in + // [backupId]/page.tsx mirrors this — Codex finding 2). + var req = new RestoreRowRequest + { + TableName = Constants.TableNames.AnalyzeRules, + PartitionKey = "", + RowKey = "", + Mode = RestoreRowMode.Preview, + }; + Sut.ValidateRowRequest("backup1", req); // no throw + } + [Fact] public void ValidateRowRequest_commit_requires_ifSha256() { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Backup/RestoreRowFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Backup/RestoreRowFunction.cs index 01afd7ea..16ff7169 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Backup/RestoreRowFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Backup/RestoreRowFunction.cs @@ -136,6 +136,7 @@ namespace AutopilotMonitor.Functions.Functions.Backup "RowChangedSinceValidation" => HttpStatusCode.Conflict, "CurrentRowChanged" => HttpStatusCode.Conflict, "MaintenanceInProgress" => HttpStatusCode.Conflict, + "MaintenanceLeaseLost" => HttpStatusCode.Conflict, _ => HttpStatusCode.BadRequest, }; diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Backup/CriticalTableRestoreService.cs b/src/Backend/AutopilotMonitor.Functions/Services/Backup/CriticalTableRestoreService.cs index 111b2f12..196558ad 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Backup/CriticalTableRestoreService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Backup/CriticalTableRestoreService.cs @@ -255,6 +255,20 @@ namespace AutopilotMonitor.Functions.Services.Backup }; } } + catch (OperationCanceledException ex) when (!ct.IsCancellationRequested) + { + // The renewal loop cancelled handlerCts after a failed RenewAsync — the + // external client did NOT go away (ct still alive), so this is a server-side + // lease loss, not a client abort. Map to a stable 409 so the UI can prompt + // the operator to retry (the row-level write may not even have started). + // Without this guard the storage SDK's OperationCanceledException bubbles + // up to the HTTP layer's generic catch and renders as a 500. + var reason = holder.RenewalFailureReason ?? "handler cancelled without explicit renewal failure"; + throw new BackupTerminalException( + "MaintenanceLeaseLost", + $"maintenance lease lost during commit — retry shortly ({reason})", + ex); + } finally { // DisposeAsync stops the renewal loop deterministically before releasing. diff --git a/src/Web/autopilot-monitor-web/app/admin/backups/[backupId]/page.tsx b/src/Web/autopilot-monitor-web/app/admin/backups/[backupId]/page.tsx index 84f058f8..1363beeb 100644 --- a/src/Web/autopilot-monitor-web/app/admin/backups/[backupId]/page.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/backups/[backupId]/page.tsx @@ -263,7 +263,10 @@ function RestoreInputRow({