From 9ada3441ccc56924113fef440fa66d3d25d1e4ab Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Tue, 26 May 2026 14:40:28 +0200 Subject: [PATCH] =?UTF-8?q?feat(backend/backup):=20PR0=20=E2=80=94=20lift?= =?UTF-8?q?=20dump=20converter,=20add=20UpsertReplace=20primitive,=20decla?= =?UTF-8?q?re=20BackupJobs=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for the critical-table backup feature (plan §PR0). Pure lib additions + refactor, no behavior change, no new endpoints. * Lift MapEntityToDump / ConvertToPropValue out of DeletionManifestBuilder into Services/Tables/TableEntityDumpConverter (public static) so the upcoming backup pipeline can share the exact EDM-typed row-dump shape. * Add TableStorageService.UpsertRowsByExactKeysInBatchesAsync with TableTransactionActionType.UpsertReplace — the only safe primitive for "overwrite a corrupt live row from backup" (the existing Add-only RestoreRowsByExactKeysInBatchesAsync would skip 409s). * Declare Constants.TableNames.BackupJobs and add it to TableNames.All so startup auto-creates the per-job state table for PR1. Build: 0 errors, 0 new warnings. 1937/1937 backend xUnit tests pass — the existing DeletionManifestBuilder suite now runs through the lifted converter and confirms byte identity. Co-Authored-By: Claude Opus 4.7 --- .../Deletion/DeletionManifestBuilder.cs | 99 +++------------- .../Services/TableStorageService.Restore.cs | 46 +++++++- .../Tables/TableEntityDumpConverter.cs | 107 ++++++++++++++++++ .../AutopilotMonitor.Shared/Constants.cs | 8 ++ 4 files changed, 173 insertions(+), 87 deletions(-) create mode 100644 src/Backend/AutopilotMonitor.Functions/Services/Tables/TableEntityDumpConverter.cs diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Deletion/DeletionManifestBuilder.cs b/src/Backend/AutopilotMonitor.Functions/Services/Deletion/DeletionManifestBuilder.cs index f26f7c07..336498a1 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Deletion/DeletionManifestBuilder.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Deletion/DeletionManifestBuilder.cs @@ -10,6 +10,7 @@ using System.Threading; using System.Threading.Tasks; using Azure.Data.Tables; using AutopilotMonitor.Functions.Security; +using AutopilotMonitor.Functions.Services.Tables; using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.Models.Deletion; using Microsoft.Extensions.Logging; @@ -31,12 +32,6 @@ namespace AutopilotMonitor.Functions.Services.Deletion private readonly ISessionDeletionInventoryReader _reader; private readonly ILogger _logger; - // Azure Tables system properties that should not appear in the per-row Props bag. - private static readonly HashSet SystemPropertyNames = new HashSet(StringComparer.Ordinal) - { - "PartitionKey", "RowKey", "Timestamp", "odata.etag", - }; - // Per-row dump for the rare 0-row table is allowed; per-class step is always emitted. private static readonly JsonSerializerOptions HashSerializerOptions = new JsonSerializerOptions { @@ -152,7 +147,7 @@ namespace AutopilotMonitor.Functions.Services.Deletion var rows = new List(); await foreach (var entity in _reader.QueryAsync(table, filter, cancellationToken)) { - rows.Add(MapEntityToDump(entity)); + rows.Add(TableEntityDumpConverter.MapEntityToDump(entity)); } manifest.Steps.Add(new DeletionStep { @@ -173,7 +168,7 @@ namespace AutopilotMonitor.Functions.Services.Deletion var rows = new List(); await foreach (var entity in _reader.QueryAsync(table, filter, cancellationToken)) { - rows.Add(MapEntityToDump(entity)); + rows.Add(TableEntityDumpConverter.MapEntityToDump(entity)); } manifest.Steps.Add(new DeletionStep { @@ -194,7 +189,7 @@ namespace AutopilotMonitor.Functions.Services.Deletion var rows = new List(); if (entity != null) { - rows.Add(MapEntityToDump(entity)); + rows.Add(TableEntityDumpConverter.MapEntityToDump(entity)); } manifest.Steps.Add(new DeletionStep { @@ -219,7 +214,7 @@ namespace AutopilotMonitor.Functions.Services.Deletion { if (entity.RowKey != null && entity.RowKey.EndsWith(suffix, StringComparison.Ordinal)) { - rows.Add(MapEntityToDump(entity)); + rows.Add(TableEntityDumpConverter.MapEntityToDump(entity)); } } manifest.Steps.Add(new DeletionStep @@ -244,7 +239,7 @@ namespace AutopilotMonitor.Functions.Services.Deletion { if (string.Equals(entity.RowKey, sessionId, StringComparison.Ordinal)) { - rows.Add(MapEntityToDump(entity)); + rows.Add(TableEntityDumpConverter.MapEntityToDump(entity)); } } manifest.Steps.Add(new DeletionStep @@ -269,7 +264,7 @@ namespace AutopilotMonitor.Functions.Services.Deletion var rows = new List(); await foreach (var entity in _reader.QueryAsync(table, filter, cancellationToken)) { - rows.Add(MapEntityToDump(entity)); + rows.Add(TableEntityDumpConverter.MapEntityToDump(entity)); } manifest.Steps.Add(new DeletionStep { @@ -303,7 +298,7 @@ namespace AutopilotMonitor.Functions.Services.Deletion Table = Constants.TableNames.SessionInventoryContributions, Class = DeletionStepClass.PkRkExact, RowCount = 1, - Rows = new List { MapEntityToDump(contributionsRow) }, + Rows = new List { TableEntityDumpConverter.MapEntityToDump(contributionsRow) }, }); } @@ -313,11 +308,11 @@ namespace AutopilotMonitor.Functions.Services.Deletion // §5 PR4 mandates SessionsIndex deleted FIRST so UI listings drop the session before the canonical row goes. if (sessionsIndexRow != null) { - rows.Add(MapEntityToDump(sessionsIndexRow)); + rows.Add(TableEntityDumpConverter.MapEntityToDump(sessionsIndexRow)); } if (sessionRow != null) { - rows.Add(MapEntityToDump(sessionRow)); + rows.Add(TableEntityDumpConverter.MapEntityToDump(sessionRow)); } manifest.Steps.Add(new DeletionStep { @@ -360,77 +355,9 @@ namespace AutopilotMonitor.Functions.Services.Deletion return null; } - private static DeletionRowDump MapEntityToDump(TableEntity entity) - { - var props = new Dictionary(entity.Count, StringComparer.Ordinal); - foreach (var key in entity.Keys) - { - if (SystemPropertyNames.Contains(key)) continue; - props[key] = ConvertToPropValue(entity[key]); - } - return new DeletionRowDump - { - Pk = entity.PartitionKey ?? string.Empty, - Rk = entity.RowKey ?? string.Empty, - Etag = entity.ETag.ToString(), - Props = props, - }; - } - - /// - /// Converts a single TableEntity property value into an EDM-tagged - /// . Restore reads - /// to choose the right strongly-typed entity setter — without the tag, a DateTime - /// timestamp coming out of JSON would be re-inserted as a plain string and the Azure - /// Tables column would silently change EDM type. - /// - private static DeletionPropValue ConvertToPropValue(object? value) - { - // null → null JsonElement, EdmType=String (Azure Tables has no first-class null type). - if (value == null) - { - return new DeletionPropValue - { - EdmType = DeletionPropEdmType.String, - Value = ParseJson("null"), - }; - } - - switch (value) - { - case string s: - return new DeletionPropValue { EdmType = DeletionPropEdmType.String, Value = ParseJson(JsonSerializer.Serialize(s)) }; - case bool b: - return new DeletionPropValue { EdmType = DeletionPropEdmType.Boolean, Value = ParseJson(b ? "true" : "false") }; - case int i: - return new DeletionPropValue { EdmType = DeletionPropEdmType.Int32, Value = ParseJson(i.ToString(System.Globalization.CultureInfo.InvariantCulture)) }; - case long l: - return new DeletionPropValue { EdmType = DeletionPropEdmType.Int64, Value = ParseJson(l.ToString(System.Globalization.CultureInfo.InvariantCulture)) }; - case double d: - return new DeletionPropValue { EdmType = DeletionPropEdmType.Double, Value = ParseJson(JsonSerializer.Serialize(d)) }; - case System.Guid g: - return new DeletionPropValue { EdmType = DeletionPropEdmType.Guid, Value = ParseJson(JsonSerializer.Serialize(g.ToString("D"))) }; - case byte[] bytes: - return new DeletionPropValue { EdmType = DeletionPropEdmType.Binary, Value = ParseJson(JsonSerializer.Serialize(Convert.ToBase64String(bytes))) }; - case DateTimeOffset dto: - return new DeletionPropValue { EdmType = DeletionPropEdmType.DateTime, Value = ParseJson(JsonSerializer.Serialize(dto.UtcDateTime.ToString("o", System.Globalization.CultureInfo.InvariantCulture))) }; - case DateTime dt: - return new DeletionPropValue { EdmType = DeletionPropEdmType.DateTime, Value = ParseJson(JsonSerializer.Serialize(dt.ToUniversalTime().ToString("o", System.Globalization.CultureInfo.InvariantCulture))) }; - default: - // Unknown type — fall back to round-tripping through JSON as a string. - return new DeletionPropValue - { - EdmType = DeletionPropEdmType.String, - Value = ParseJson(JsonSerializer.Serialize(value.ToString() ?? string.Empty)), - }; - } - } - - private static JsonElement ParseJson(string json) - { - using var doc = JsonDocument.Parse(json); - return doc.RootElement.Clone(); - } + // MapEntityToDump / ConvertToPropValue / ParseJson lifted to TableEntityDumpConverter + // (Services/Tables) so the critical-table backup pipeline can share the exact same + // EDM-typed row-dump representation. No behavior change. /// /// Decodes the side-row's SoftwareKeysJson (raw JSON or gzip+Base64 per §17.7) into diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Restore.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Restore.cs index d06ab8a2..97e0ff05 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Restore.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Restore.cs @@ -157,7 +157,7 @@ namespace AutopilotMonitor.Functions.Services } /// - /// Symmetric inverse of : + /// Symmetric inverse of : /// parses the EDM-tagged back into a typed .NET object /// suitable for assignment into a 's dictionary. The SDK then /// re-serializes with the original column type on the wire. @@ -249,6 +249,50 @@ namespace AutopilotMonitor.Functions.Services // PartitionKey. Mirrors the const in the deletion partial. private const int RestoreBatchActionLimit = 100; + /// + /// Bulk-restore helper for the critical-table backup feature (plan §PR0). Symmetric to + /// but uses + /// instead of Add: + /// existing rows are overwritten, missing rows are inserted, no 409 fallback path is + /// needed. This is the only safe primitive for "restore a row even if a corrupt version + /// is currently live" — the cascade-delete restore (Add-only with 409-skip) would leave + /// the corrupt live row in place. + /// + /// Fail-loud per memory feedback_storage_helpers_fail_soft: storage errors propagate. + /// + /// + public virtual async Task UpsertRowsByExactKeysInBatchesAsync( + string tableName, + IReadOnlyList rows, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(tableName)) throw new ArgumentException("tableName is required", nameof(tableName)); + if (rows == null) throw new ArgumentNullException(nameof(rows)); + if (rows.Count == 0) return 0; + + var tableClient = _tableServiceClient.GetTableClient(tableName); + var upserted = 0; + + foreach (var group in rows.GroupBy(r => r.Pk, StringComparer.Ordinal)) + { + var rowsInGroup = group.ToList(); + for (var i = 0; i < rowsInGroup.Count; i += RestoreBatchActionLimit) + { + var chunk = rowsInGroup.Skip(i).Take(RestoreBatchActionLimit).ToList(); + var actions = chunk + .Select(dump => new TableTransactionAction( + TableTransactionActionType.UpsertReplace, + ConvertDumpToEntity(dump, tableName))) + .ToList(); + + await tableClient.SubmitTransactionAsync(actions, cancellationToken); + upserted += chunk.Count; + } + } + + return upserted; + } + /// /// True when the Azure SDK exception encodes a "row already exists" outcome — the only /// case where we treat the failed Add as Skipped during restore. Production Azure Tables diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Tables/TableEntityDumpConverter.cs b/src/Backend/AutopilotMonitor.Functions/Services/Tables/TableEntityDumpConverter.cs new file mode 100644 index 00000000..656959aa --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/Tables/TableEntityDumpConverter.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text.Json; +using Azure.Data.Tables; +using AutopilotMonitor.Shared.Models.Deletion; + +namespace AutopilotMonitor.Functions.Services.Tables +{ + /// + /// Forward converter -> . + /// Lifted from DeletionManifestBuilder so both the cascade-delete manifest builder + /// and the critical-table backup pipeline can share a single, exhaustively tested + /// EDM-typed row-dump representation. + /// + /// The reverse direction (Dump -> TableEntity) lives in + /// TableStorageService.ConvertDumpToEntity / ConvertFromPropValue. + /// + /// + public static class TableEntityDumpConverter + { + // Azure Tables system properties that must not appear in the per-row Props bag. + // PartitionKey + RowKey + Timestamp + odata.etag are carried separately on + // DeletionRowDump or assigned server-side on restore. + private static readonly HashSet SystemPropertyNames = new HashSet(StringComparer.Ordinal) + { + "PartitionKey", "RowKey", "Timestamp", "odata.etag", + }; + + /// + /// Captures a TableEntity as an EDM-tagged . + /// + public static DeletionRowDump MapEntityToDump(TableEntity entity) + { + if (entity == null) throw new ArgumentNullException(nameof(entity)); + + var props = new Dictionary(entity.Count, StringComparer.Ordinal); + foreach (var key in entity.Keys) + { + if (SystemPropertyNames.Contains(key)) continue; + props[key] = ConvertToPropValue(entity[key]); + } + return new DeletionRowDump + { + Pk = entity.PartitionKey ?? string.Empty, + Rk = entity.RowKey ?? string.Empty, + Etag = entity.ETag.ToString(), + Props = props, + }; + } + + /// + /// Converts a single TableEntity property value into an EDM-tagged + /// . The restore path reads + /// to choose the right strongly-typed entity setter; without the tag a DateTime + /// timestamp coming out of JSON would be re-inserted as a plain string and the + /// Azure Tables column would silently change EDM type. + /// + public static DeletionPropValue ConvertToPropValue(object? value) + { + // null -> null JsonElement, EdmType=String (Azure Tables has no first-class null type). + if (value == null) + { + return new DeletionPropValue + { + EdmType = DeletionPropEdmType.String, + Value = ParseJson("null"), + }; + } + + switch (value) + { + case string s: + return new DeletionPropValue { EdmType = DeletionPropEdmType.String, Value = ParseJson(JsonSerializer.Serialize(s)) }; + case bool b: + return new DeletionPropValue { EdmType = DeletionPropEdmType.Boolean, Value = ParseJson(b ? "true" : "false") }; + case int i: + return new DeletionPropValue { EdmType = DeletionPropEdmType.Int32, Value = ParseJson(i.ToString(CultureInfo.InvariantCulture)) }; + case long l: + return new DeletionPropValue { EdmType = DeletionPropEdmType.Int64, Value = ParseJson(l.ToString(CultureInfo.InvariantCulture)) }; + case double d: + return new DeletionPropValue { EdmType = DeletionPropEdmType.Double, Value = ParseJson(JsonSerializer.Serialize(d)) }; + case Guid g: + return new DeletionPropValue { EdmType = DeletionPropEdmType.Guid, Value = ParseJson(JsonSerializer.Serialize(g.ToString("D"))) }; + case byte[] bytes: + return new DeletionPropValue { EdmType = DeletionPropEdmType.Binary, Value = ParseJson(JsonSerializer.Serialize(Convert.ToBase64String(bytes))) }; + case DateTimeOffset dto: + return new DeletionPropValue { EdmType = DeletionPropEdmType.DateTime, Value = ParseJson(JsonSerializer.Serialize(dto.UtcDateTime.ToString("o", CultureInfo.InvariantCulture))) }; + case DateTime dt: + return new DeletionPropValue { EdmType = DeletionPropEdmType.DateTime, Value = ParseJson(JsonSerializer.Serialize(dt.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture))) }; + default: + // Unknown type -> fall back to round-tripping through JSON as a string. + return new DeletionPropValue + { + EdmType = DeletionPropEdmType.String, + Value = ParseJson(JsonSerializer.Serialize(value.ToString() ?? string.Empty)), + }; + } + } + + private static JsonElement ParseJson(string json) + { + using var doc = JsonDocument.Parse(json); + return doc.RootElement.Clone(); + } + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index 7032205d..e5bf616b 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -549,6 +549,13 @@ namespace AutopilotMonitor.Shared // Daily cleanup function evicts entries older than the TTL. public const string ScriptNameCache = "ScriptNameCache"; + // Critical-table backup/restore job tracking (plan §PR1). Single-partition + // PK="BackupJobs", RK={jobId}. Persists per-job state machine + // (Queued / Running / Completed / Failed / Skipped / BlockedTerminal), + // backupOutcome (Success / Partial) for Kind=Backup jobs, and lastHeartbeatUtc + // for the watchdog (5min staleness threshold for Running, 60min for Queued). + public const string BackupJobs = "BackupJobs"; + /// /// Returns all table names for initialization /// @@ -607,6 +614,7 @@ namespace AutopilotMonitor.Shared OffboardingAudit, TenantOffboardingCustomsArchive, ScriptNameCache, + BackupJobs, }; }