mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
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:
+13
-86
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user