feat(backend/backup): PR0 — lift dump converter, add UpsertReplace primitive, declare BackupJobs table

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 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-05-26 14:40:28 +02:00
parent 2ffa085ddd
commit 9ada3441cc
4 changed files with 173 additions and 87 deletions
@@ -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<DeletionManifestBuilder> _logger;
// Azure Tables system properties that should not appear in the per-row Props bag.
private static readonly HashSet<string> SystemPropertyNames = new HashSet<string>(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<DeletionRowDump>();
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<DeletionRowDump>();
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<DeletionRowDump>();
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<DeletionRowDump>();
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<DeletionRowDump> { MapEntityToDump(contributionsRow) },
Rows = new List<DeletionRowDump> { 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<string, DeletionPropValue>(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,
};
}
/// <summary>
/// Converts a single TableEntity property value into an EDM-tagged
/// <see cref="DeletionPropValue"/>. Restore reads <see cref="DeletionPropValue.EdmType"/>
/// 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.
/// </summary>
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.
/// <summary>
/// Decodes the side-row's <c>SoftwareKeysJson</c> (raw JSON or gzip+Base64 per §17.7) into
@@ -157,7 +157,7 @@ namespace AutopilotMonitor.Functions.Services
}
/// <summary>
/// Symmetric inverse of <see cref="Services.Deletion.DeletionManifestBuilder.ConvertToPropValue"/>:
/// Symmetric inverse of <see cref="Services.Tables.TableEntityDumpConverter.ConvertToPropValue"/>:
/// parses the EDM-tagged <see cref="DeletionPropValue"/> back into a typed .NET object
/// suitable for assignment into a <see cref="TableEntity"/>'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;
/// <summary>
/// Bulk-restore helper for the critical-table backup feature (plan §PR0). Symmetric to
/// <see cref="RestoreRowsByExactKeysInBatchesAsync"/> but uses
/// <see cref="TableTransactionActionType.UpsertReplace"/> instead of <c>Add</c>:
/// 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.
/// <para>
/// Fail-loud per memory <c>feedback_storage_helpers_fail_soft</c>: storage errors propagate.
/// </para>
/// </summary>
public virtual async Task<int> UpsertRowsByExactKeysInBatchesAsync(
string tableName,
IReadOnlyList<DeletionRowDump> 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;
}
/// <summary>
/// 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
@@ -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
{
/// <summary>
/// Forward converter <see cref="TableEntity"/> -> <see cref="DeletionRowDump"/>.
/// Lifted from <c>DeletionManifestBuilder</c> so both the cascade-delete manifest builder
/// and the critical-table backup pipeline can share a single, exhaustively tested
/// EDM-typed row-dump representation.
/// <para>
/// The reverse direction (Dump -> TableEntity) lives in
/// <c>TableStorageService.ConvertDumpToEntity</c> / <c>ConvertFromPropValue</c>.
/// </para>
/// </summary>
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<string> SystemPropertyNames = new HashSet<string>(StringComparer.Ordinal)
{
"PartitionKey", "RowKey", "Timestamp", "odata.etag",
};
/// <summary>
/// Captures a TableEntity as an EDM-tagged <see cref="DeletionRowDump"/>.
/// </summary>
public static DeletionRowDump MapEntityToDump(TableEntity entity)
{
if (entity == null) throw new ArgumentNullException(nameof(entity));
var props = new Dictionary<string, DeletionPropValue>(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,
};
}
/// <summary>
/// Converts a single TableEntity property value into an EDM-tagged
/// <see cref="DeletionPropValue"/>. The restore path reads <see cref="DeletionPropValue.EdmType"/>
/// 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.
/// </summary>
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();
}
}
}
@@ -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";
/// <summary>
/// Returns all table names for initialization
/// </summary>
@@ -607,6 +614,7 @@ namespace AutopilotMonitor.Shared
OffboardingAudit,
TenantOffboardingCustomsArchive,
ScriptNameCache,
BackupJobs,
};
}