feat(backend/backup): PR1 — critical-table backup feature (daily timer + manual GA trigger)

Adds the full backup pipeline that snapshots 15 critical tables (Admin/Tenant
config, rules, identities, audit, preview) into a logically-isolated blob
container, daily at 04:00 UTC and on operator demand. Restore endpoints follow
in PR2/PR3.

Architecture:
* Daily TimerTrigger + manual POST /api/global/backups/trigger (GA-only,
  fail-hard producer). HTTP returns 202 + jobId on enqueue, 500 if the queue
  write fails (and the BackupJobs row is rolled to Failed).
* BackgroundService queue worker (not QueueTrigger — matches the Analyze/
  IndexReconcile pattern that avoids the Functions-host connection-binding
  app-setting). Worker holds the maintenance lease for the whole run; service
  is intentionally lease-unaware so it stays unit-testable.
* BlobBackupStore wraps the new 'critical-table-backups' container with a
  shared maintenance-lease sentinel at '_lock/maintenance.lock' that
  serialises every backup or restore op across worker/timer/single-row paths.
* CriticalTableBackupService streams each table through CryptoStream(SHA256)
  into per-table NDJSON blobs, then writes manifest.json LAST as the
  durability anchor. Per-table failures degrade Outcome to Partial without
  aborting; per-run budget (50 min) → Skipped status.
* BackupJobsRepository persists the six-state machine
  (Queued/Running/Completed/Failed/Skipped/BlockedTerminal) with ETag-CAS
  updates. Enum→string mapping rejects unknown values on read so refactors
  surface loudly instead of silently shifting state.
* BackupJobWatchdog (30 min timer) heals stuck rows via a probe lease on the
  shared sentinel: Queued > 60 min → Failed, Running with stale heartbeat
  AND free lease → Failed (lease-busy means a live worker — leave alone).
* OpsEvents: distinct CriticalTableBackupPartial (Warning) vs Failed (Error)
  so operators can wire softer Telegram rules for partial captures.
* MaintenanceService.MonitoredPoisonQueues + OPS_EVENT_TYPES catalog
  registered (dual-register memory).

Test coverage (15 new tests, 1952/1952 backend xUnit green):
* BackupManifestJsonTests: enum-as-string (allowIntegerValues=false rejects
  numeric values), DictionaryKeyPolicy unset (Azure column names preserved
  case-faithfully), manifest field round-trip.
* BackupJobsRepositoryMappingTests: full entity↔domain round-trip, unknown
  Kind/State/BackupOutcome rejected, legacy BackupOutcome="Failed" rejected.
* CriticalTableBackupServiceTests: happy/partial/empty/budget-exceeded paths
  exercise the per-table loop with in-memory table + blob fakes.

Setup: scripts/Backup/setup-backup-container.ps1 — idempotent az-CLI bootstrap
for container + lifecycle rule (90 d delete on prefix). Soft-Delete and
Versioning are NOT applied automatically (storage-account-wide blast radius
— operator must validate per separate doc note).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-05-26 15:19:07 +02:00
parent 9ada3441cc
commit d4034842ee
32 changed files with 2860 additions and 3 deletions
+92
View File
@@ -0,0 +1,92 @@
# setup-backup-container.ps1
#
# Idempotent setup for the critical-table backup feature (plan §PR1).
#
# Creates the 'critical-table-backups' container in the backend storage account
# and applies a lifecycle rule that deletes blobs older than 90 days under the
# 'critical-table-backups/' prefix. Run once per environment, then again any
# time the storage account is rebuilt.
#
# WARNING (plan §Wave15 Azure-Hinweis): Blob Versioning and Blob Soft-Delete
# are STORAGE-ACCOUNT settings (not container-scoped). Enabling them affects
# every container in the account (e.g. 'diagnostics', 'offboarding-state').
# This script does NOT enable them automatically — operator must validate the
# blast radius separately and run the corresponding `az storage account
# blob-service-properties update` command if desired.
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)] [string] $StorageAccountName,
[Parameter(Mandatory = $true)] [string] $ResourceGroupName,
[string] $ContainerName = 'critical-table-backups',
[int] $RetentionDays = 90
)
$ErrorActionPreference = 'Stop'
Write-Host "==> Verifying Azure CLI login"
az account show --only-show-errors > $null
Write-Host "==> Ensuring container '$ContainerName' in account '$StorageAccountName'"
az storage container create `
--account-name $StorageAccountName `
--name $ContainerName `
--auth-mode login `
--only-show-errors `
--fail-on-exist false | Out-Null
Write-Host "==> Building lifecycle rule (delete > $RetentionDays days, prefix '$ContainerName/')"
$policyJson = @"
{
"rules": [
{
"enabled": true,
"name": "delete-critical-table-backups",
"type": "Lifecycle",
"definition": {
"actions": {
"baseBlob": {
"delete": { "daysAfterModificationGreaterThan": $RetentionDays }
}
},
"filters": {
"blobTypes": [ "blockBlob" ],
"prefixMatch": [ "$ContainerName/" ]
}
}
}
]
}
"@
$tmpFile = [System.IO.Path]::GetTempFileName()
try {
Set-Content -Path $tmpFile -Value $policyJson -Encoding UTF8
Write-Host "==> Applying lifecycle policy to storage account '$StorageAccountName'"
Write-Host " NOTE: this REPLACES the account's lifecycle policy. If other rules"
Write-Host " exist (e.g. for 'deletion-manifests'), merge them in manually."
az storage account management-policy create `
--account-name $StorageAccountName `
--resource-group $ResourceGroupName `
--policy "@$tmpFile" `
--only-show-errors | Out-Null
}
finally {
Remove-Item -Path $tmpFile -ErrorAction SilentlyContinue
}
Write-Host ""
Write-Host "==> Done."
Write-Host " Container ready: $ContainerName"
Write-Host " Lifecycle: delete blobs > $RetentionDays days under '$ContainerName/' prefix"
Write-Host ""
Write-Host "Operator follow-ups (NOT applied by this script):"
Write-Host " * If you also want Blob Versioning + Soft-Delete (defence-in-depth against"
Write-Host " operator mistakes), run separately and confirm the blast radius first:"
Write-Host " az storage account blob-service-properties update \\"
Write-Host " --account-name $StorageAccountName \\"
Write-Host " --resource-group $ResourceGroupName \\"
Write-Host " --enable-versioning true \\"
Write-Host " --enable-delete-retention true \\"
Write-Host " --delete-retention-days 14"
Write-Host " Both settings affect ALL containers in the account."
@@ -0,0 +1,135 @@
using System;
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Shared.Models.Backup;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pure Entity ↔ Domain roundtrip + unknown-string-rejection for
/// <see cref="BackupJobsRepository"/>. Enum refactorings on
/// <see cref="BackupJobState"/> / <see cref="BackupJobKind"/> / <see cref="BackupOutcome"/>
/// must NOT silently shift the persisted column — the mapper throws on unknown
/// values so the operator sees a clear "schema drift" error instead of an
/// unexplained state collapse.
/// </summary>
public class BackupJobsRepositoryMappingTests
{
[Fact]
public void Roundtrip_Completed_PartialOutcome_AllFieldsPreserved()
{
var now = new DateTime(2026, 5, 22, 4, 5, 6, DateTimeKind.Utc);
var dto = new BackupJobStatus
{
JobId = "abc123",
Kind = BackupJobKind.Backup,
State = BackupJobState.Completed,
RequestedBy = "alice@contoso.test",
QueuedAtUtc = now,
StartedAtUtc = now.AddSeconds(1),
CompletedAtUtc = now.AddMinutes(3),
LastHeartbeatUtc = now.AddMinutes(3),
BackupId = "20260522T040000Z_aa",
BackupOutcome = BackupOutcome.Partial,
};
var entity = BackupJobsRepository.MapToEntity(dto);
var roundtripped = BackupJobsRepository.MapToDomain(entity);
Assert.Equal(dto.JobId, roundtripped.JobId);
Assert.Equal(BackupJobKind.Backup, roundtripped.Kind);
Assert.Equal(BackupJobState.Completed, roundtripped.State);
Assert.Equal(dto.RequestedBy, roundtripped.RequestedBy);
Assert.Equal(dto.QueuedAtUtc, roundtripped.QueuedAtUtc);
Assert.Equal(dto.StartedAtUtc, roundtripped.StartedAtUtc);
Assert.Equal(dto.CompletedAtUtc, roundtripped.CompletedAtUtc);
Assert.Equal(dto.LastHeartbeatUtc, roundtripped.LastHeartbeatUtc);
Assert.Equal(dto.BackupId, roundtripped.BackupId);
Assert.Equal(BackupOutcome.Partial, roundtripped.BackupOutcome);
}
[Fact]
public void Roundtrip_RestoreTable_FieldsPreserved()
{
var dto = new BackupJobStatus
{
JobId = "restore01",
Kind = BackupJobKind.RestoreTable,
State = BackupJobState.Running,
RequestedBy = "bob@contoso.test",
QueuedAtUtc = DateTime.UtcNow,
LastHeartbeatUtc = DateTime.UtcNow,
SourceBackupId = "20260522T040000Z_aa",
TableName = "AnalyzeRules",
Strategy = "upsert-only",
Progress = "{\"phase\":\"Upsert\"}",
};
var roundtripped = BackupJobsRepository.MapToDomain(BackupJobsRepository.MapToEntity(dto));
Assert.Equal(BackupJobKind.RestoreTable, roundtripped.Kind);
Assert.Equal(BackupJobState.Running, roundtripped.State);
Assert.Equal(dto.SourceBackupId, roundtripped.SourceBackupId);
Assert.Equal(dto.TableName, roundtripped.TableName);
Assert.Equal(dto.Strategy, roundtripped.Strategy);
Assert.Equal(dto.Progress, roundtripped.Progress);
Assert.Null(roundtripped.BackupOutcome);
}
[Fact]
public void Map_ThrowsOnUnknownStateString()
{
var entity = new BackupJobStatusEntity
{
RowKey = "x",
Kind = "Backup",
State = "FuturisticGhostState", // unknown — refactor / hand-edit
RequestedBy = "y",
};
Assert.Throws<InvalidOperationException>(() => BackupJobsRepository.MapToDomain(entity));
}
[Fact]
public void Map_ThrowsOnUnknownKindString()
{
var entity = new BackupJobStatusEntity
{
RowKey = "x",
Kind = "RestoreShard", // unknown
State = "Queued",
RequestedBy = "y",
};
Assert.Throws<InvalidOperationException>(() => BackupJobsRepository.MapToDomain(entity));
}
[Fact]
public void Map_ThrowsOnLegacyFailedBackupOutcome()
{
// Pre-Wave21 plans allowed BackupOutcome=Failed; the current schema restricts
// BackupOutcome to {Success, Partial} (real failures live on JobState=Failed
// with BackupOutcome=null). A legacy "Failed" row must surface loudly.
var entity = new BackupJobStatusEntity
{
RowKey = "x",
Kind = "Backup",
State = "Completed",
RequestedBy = "y",
BackupOutcome = "Failed", // legacy
};
Assert.Throws<InvalidOperationException>(() => BackupJobsRepository.MapToDomain(entity));
}
[Fact]
public void Map_BackupOutcomeNull_RoundtripsAsNull()
{
var entity = new BackupJobStatusEntity
{
RowKey = "x",
Kind = "Backup",
State = "Failed",
RequestedBy = "y",
BackupOutcome = null,
};
var dto = BackupJobsRepository.MapToDomain(entity);
Assert.Null(dto.BackupOutcome);
}
}
@@ -0,0 +1,110 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using AutopilotMonitor.Shared.Models.Backup;
using AutopilotMonitor.Shared.Models.Deletion;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Locks the JSON shape produced by <see cref="BackupManifestJson.SerializerOptions"/>
/// so the manifest blob format stays implementation-stable across enum refactors.
/// </summary>
public class BackupManifestJsonTests
{
[Fact]
public void Enums_Serialize_AsStrings_NotNumbers()
{
var manifest = new CriticalTableBackupManifest
{
BackupId = "test",
Outcome = BackupOutcome.Partial,
Tables = { new CriticalTableBackupTableEntry { TableName = "X", Status = TableBackupStatus.Skipped } },
};
var json = JsonSerializer.Serialize(manifest, BackupManifestJson.SerializerOptions);
Assert.Contains("\"outcome\":\"Partial\"", json);
Assert.Contains("\"status\":\"Skipped\"", json);
Assert.DoesNotContain("\"outcome\":1", json);
Assert.DoesNotContain("\"status\":2", json);
}
[Fact]
public void Deserialize_RejectsNumericEnumValues()
{
var jsonWithNumber = "{\"backupId\":\"x\",\"outcome\":1,\"tables\":[]}";
Assert.Throws<JsonException>(() =>
JsonSerializer.Deserialize<CriticalTableBackupManifest>(jsonWithNumber, BackupManifestJson.SerializerOptions));
}
[Fact]
public void DictionaryKeyPolicy_Unset_PreservesAzureTableColumnNames()
{
// DeletionRowDump.Props uses Azure Table column names as dictionary keys; camelcasing them
// would manufacture new columns on restore. The serializer-options block in
// BackupManifestJson deliberately leaves DictionaryKeyPolicy unset for this reason.
var dump = new DeletionRowDump
{
Pk = "p", Rk = "r",
Props = new Dictionary<string, DeletionPropValue>
{
["IsEnabled"] = new() { EdmType = DeletionPropEdmType.Boolean, Value = ParseJson("true") },
["EventType"] = new() { EdmType = DeletionPropEdmType.String, Value = ParseJson("\"x\"") },
},
};
var json = JsonSerializer.Serialize(dump, BackupManifestJson.SerializerOptions);
Assert.Contains("\"IsEnabled\"", json);
Assert.Contains("\"EventType\"", json);
// Negative — camelcased variants would silently break restore against the live table.
Assert.DoesNotContain("\"isEnabled\":", json);
Assert.DoesNotContain("\"eventType\":", json);
}
[Fact]
public void Roundtrip_ManifestFieldsPreserved()
{
var startedAt = new DateTime(2026, 5, 22, 4, 0, 0, DateTimeKind.Utc);
var manifest = new CriticalTableBackupManifest
{
SchemaVersion = 1,
BackupId = "20260522T040000Z_abcdef01",
StartedAtUtc = startedAt,
CompletedAtUtc = startedAt.AddMinutes(5),
TriggeredBy = "alice@contoso.test",
Outcome = BackupOutcome.Success,
Tables =
{
new CriticalTableBackupTableEntry
{
TableName = "AdminConfiguration",
Status = TableBackupStatus.Ok,
RowCount = 12,
ByteSize = 4096,
Sha256Hex = "ABCDEF",
BlobName = "20260522T040000Z_abcdef01/AdminConfiguration.ndjson",
},
},
};
var json = JsonSerializer.Serialize(manifest, BackupManifestJson.SerializerOptions);
var roundtripped = JsonSerializer.Deserialize<CriticalTableBackupManifest>(json, BackupManifestJson.SerializerOptions);
Assert.NotNull(roundtripped);
Assert.Equal(manifest.BackupId, roundtripped!.BackupId);
Assert.Equal(manifest.TriggeredBy, roundtripped.TriggeredBy);
Assert.Equal(BackupOutcome.Success, roundtripped.Outcome);
Assert.Single(roundtripped.Tables);
Assert.Equal("AdminConfiguration", roundtripped.Tables[0].TableName);
Assert.Equal(TableBackupStatus.Ok, roundtripped.Tables[0].Status);
Assert.Equal(12, roundtripped.Tables[0].RowCount);
}
private static JsonElement ParseJson(string json)
{
using var doc = JsonDocument.Parse(json);
return doc.RootElement.Clone();
}
}
@@ -0,0 +1,237 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Azure;
using Azure.Data.Tables;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Backup;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models.Backup;
using Microsoft.Extensions.Logging.Abstractions;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Service-level happy/partial/empty/budget paths for the critical-table backup feature.
/// Uses in-memory fakes for table enumeration and blob I/O so the loop's structural
/// invariants (manifest written LAST, per-table failure → Partial, budget → Skipped)
/// can be locked without touching Azurite.
/// </summary>
public class CriticalTableBackupServiceTests
{
[Fact]
public async Task Happy_AllTables_OutcomeSuccess_ManifestWritten()
{
var tables = new FakeTableStorage(new Dictionary<string, List<TableEntity>>
{
// Use real critical-table names so the service finds them in CriticalBackupTables.All.
[Constants.TableNames.AdminConfiguration] = new()
{
new TableEntity("admin", "row1") { ["Setting"] = "value" },
},
[Constants.TableNames.AnalyzeRules] = new()
{
new TableEntity("tenant1", "rule1") { ["Enabled"] = true },
},
}, defaultEmpty: true);
var store = new FakeBlobStore();
var clock = new FakeClock(new DateTime(2026, 5, 22, 4, 0, 0, DateTimeKind.Utc));
var svc = new CriticalTableBackupService(tables, store, NullLogger<CriticalTableBackupService>.Instance, clock);
var backupId = svc.GenerateBackupId();
var result = await svc.RunBackupUnderLeaseAsync(backupId, "Timer", CancellationToken.None);
Assert.Equal(BackupOutcome.Success, result.Outcome);
Assert.True(store.ManifestWritten, "manifest must be written LAST");
Assert.Equal(15, result.Manifest.Tables.Count); // critical-tables catalog count
Assert.All(result.Manifest.Tables, t =>
Assert.True(t.Status == TableBackupStatus.Ok || t.Status == TableBackupStatus.Empty));
}
[Fact]
public async Task Partial_MiddleTableThrows_OutcomePartial_ManifestStillWritten()
{
var tables = new FakeTableStorage(new Dictionary<string, List<TableEntity>>
{
[Constants.TableNames.AdminConfiguration] = new() { new TableEntity("a", "b") { ["X"] = 1 } },
}, defaultEmpty: true, throwOnTable: Constants.TableNames.AnalyzeRules);
var store = new FakeBlobStore();
var clock = new FakeClock(new DateTime(2026, 5, 22, 4, 0, 0, DateTimeKind.Utc));
var svc = new CriticalTableBackupService(tables, store, NullLogger<CriticalTableBackupService>.Instance, clock);
var result = await svc.RunBackupUnderLeaseAsync(svc.GenerateBackupId(), "Timer", CancellationToken.None);
Assert.Equal(BackupOutcome.Partial, result.Outcome);
Assert.True(store.ManifestWritten);
var failedEntry = result.Manifest.Tables.First(t => t.TableName == Constants.TableNames.AnalyzeRules);
Assert.Equal(TableBackupStatus.Failed, failedEntry.Status);
Assert.NotNull(failedEntry.ErrorMessage);
}
[Fact]
public async Task Empty_AllTablesEmpty_OutcomeSuccess_EveryEntryEmptyStatus()
{
var tables = new FakeTableStorage(new(), defaultEmpty: true);
var store = new FakeBlobStore();
var clock = new FakeClock(new DateTime(2026, 5, 22, 4, 0, 0, DateTimeKind.Utc));
var svc = new CriticalTableBackupService(tables, store, NullLogger<CriticalTableBackupService>.Instance, clock);
var result = await svc.RunBackupUnderLeaseAsync(svc.GenerateBackupId(), "Timer", CancellationToken.None);
Assert.Equal(BackupOutcome.Success, result.Outcome);
Assert.All(result.Manifest.Tables, t => Assert.Equal(TableBackupStatus.Empty, t.Status));
Assert.All(result.Manifest.Tables, t => Assert.Equal(0, t.RowCount));
}
[Fact]
public async Task BudgetExceeded_PreLoop_RemainingTablesSkipped_OutcomePartial()
{
// Clock jumps forward by 51 min after the very first GetUtcNow call so the
// per-run budget (50 min) is exceeded before any subsequent table iteration.
var startUtc = new DateTime(2026, 5, 22, 4, 0, 0, DateTimeKind.Utc);
var clock = new FakeClock(startUtc);
var tables = new FakeTableStorage(new(), defaultEmpty: true);
var store = new FakeBlobStore();
// First clock read = startedAtUtc inside RunBackupUnderLeaseAsync; after that we
// jump 51 min so the very first per-table budget check trips. Use a hand-crafted
// backupId so GenerateBackupId() doesn't pre-consume a clock read.
clock.AdvanceOnNextRead = TimeSpan.FromMinutes(51);
clock.AdvanceAfterReadNumber = 1;
var svc = new CriticalTableBackupService(tables, store, NullLogger<CriticalTableBackupService>.Instance, clock);
var result = await svc.RunBackupUnderLeaseAsync("20260522T040000Z_deadbeef", "Timer", CancellationToken.None);
Assert.Equal(BackupOutcome.Partial, result.Outcome);
Assert.True(store.ManifestWritten);
Assert.All(result.Manifest.Tables, t => Assert.Equal(TableBackupStatus.Skipped, t.Status));
}
[Fact]
public void GenerateBackupId_FormatIsStable()
{
var clock = new FakeClock(new DateTime(2026, 5, 22, 4, 0, 0, DateTimeKind.Utc));
var tables = new FakeTableStorage(new(), defaultEmpty: true);
var store = new FakeBlobStore();
var svc = new CriticalTableBackupService(tables, store, NullLogger<CriticalTableBackupService>.Instance, clock);
var id = svc.GenerateBackupId();
Assert.Matches(@"^\d{8}T\d{6}Z_[0-9a-f]{8}$", id);
Assert.StartsWith("20260522T040000Z_", id);
}
// ── Fakes ───────────────────────────────────────────────────────────────
private sealed class FakeTableStorage : TableStorageService
{
private readonly Dictionary<string, List<TableEntity>> _data;
private readonly bool _defaultEmpty;
private readonly string? _throwOnTable;
public FakeTableStorage(Dictionary<string, List<TableEntity>> data, bool defaultEmpty = false, string? throwOnTable = null)
: base(new TableServiceClient(new Uri("https://example.invalid"), new TableSharedKeyCredential("x", Convert.ToBase64String(new byte[32]))), NullLogger<TableStorageService>.Instance)
{
_data = data;
_defaultEmpty = defaultEmpty;
_throwOnTable = throwOnTable;
}
public override async IAsyncEnumerable<TableEntity> EnumerateAllAsync(string tableName, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
if (string.Equals(tableName, _throwOnTable, StringComparison.Ordinal))
throw new InvalidOperationException($"simulated table failure: {tableName}");
if (_data.TryGetValue(tableName, out var rows))
{
foreach (var row in rows)
{
cancellationToken.ThrowIfCancellationRequested();
yield return row;
}
yield break;
}
if (_defaultEmpty) yield break;
throw new InvalidOperationException($"unexpected table: {tableName}");
}
}
private sealed class FakeBlobStore : BlobBackupStore
{
public bool ManifestWritten { get; private set; }
public readonly List<string> WrittenNdjsonBlobs = new();
public readonly Dictionary<string, byte[]> NdjsonBytes = new();
public FakeBlobStore()
: base(new FakeBlobStorageService(), NullLogger<BlobBackupStore>.Instance)
{
}
public override Task EnsureContainerAsync(CancellationToken ct = default) => Task.CompletedTask;
public override Task EnsureMaintenanceLockSentinelAsync(CancellationToken ct = default) => Task.CompletedTask;
public override Task<Stream> OpenNdjsonWriteStreamAsync(string backupId, string tableName, CancellationToken ct = default)
{
var name = BuildNdjsonBlobName(backupId, tableName);
WrittenNdjsonBlobs.Add(name);
var ms = new RecordingMemoryStream(b => NdjsonBytes[name] = b);
return Task.FromResult<Stream>(ms);
}
public override Task<bool> TryWriteManifestAsync(string backupId, byte[] manifestBytes, CancellationToken ct = default)
{
// Plan invariant: every NDJSON blob must be committed (= disposed) before the manifest is written.
// Our recording stream finalises on dispose; if a stream is still open at this point a bug exists.
ManifestWritten = true;
return Task.FromResult(true);
}
}
private sealed class RecordingMemoryStream : MemoryStream
{
private readonly Action<byte[]> _onClose;
private bool _closed;
public RecordingMemoryStream(Action<byte[]> onClose) { _onClose = onClose; }
protected override void Dispose(bool disposing)
{
if (!_closed)
{
_closed = true;
_onClose(this.ToArray());
}
base.Dispose(disposing);
}
}
/// <summary>Minimal stub — never actually used because all BlobBackupStore methods are overridden.</summary>
private sealed class FakeBlobStorageService : BlobStorageService
{
public FakeBlobStorageService()
: base(new Azure.Storage.Blobs.BlobServiceClient(new Uri("https://example.invalid"), new Azure.Storage.StorageSharedKeyCredential("x", Convert.ToBase64String(new byte[32]))), NullLogger<BlobStorageService>.Instance) { }
}
private sealed class FakeClock : TimeProvider
{
private DateTime _now;
private int _readCount;
/// <summary>Time to advance AFTER the Nth read (default: after the 1st).</summary>
public TimeSpan AdvanceOnNextRead { get; set; } = TimeSpan.Zero;
public int AdvanceAfterReadNumber { get; set; } = 1;
public FakeClock(DateTime startUtc) { _now = DateTime.SpecifyKind(startUtc, DateTimeKind.Utc); }
public override DateTimeOffset GetUtcNow()
{
var currentValue = new DateTimeOffset(_now, TimeSpan.Zero);
_readCount++;
if (_readCount == AdvanceAfterReadNumber && AdvanceOnNextRead != TimeSpan.Zero)
{
_now += AdvanceOnNextRead;
AdvanceOnNextRead = TimeSpan.Zero;
}
return currentValue;
}
}
}
@@ -122,8 +122,8 @@ public class MaintenanceServicePoisonQueueTests
public void MonitoredPoisonQueues_CoversAllProducerQueues()
{
// Guard against silently adding a new producer queue without a poison entry.
// If a fourth queue is introduced, MonitoredPoisonQueues + this assertion must move together.
Assert.Equal(3, MaintenanceService.MonitoredPoisonQueues.Length);
// If a fifth queue is introduced, MonitoredPoisonQueues + this assertion must move together.
Assert.Equal(4, MaintenanceService.MonitoredPoisonQueues.Length);
Assert.Contains(
Constants.QueueNames.AnalyzeOnEnrollmentEnd + "-poison",
MaintenanceService.MonitoredPoisonQueues);
@@ -133,6 +133,10 @@ public class MaintenanceServicePoisonQueueTests
Assert.Contains(
Constants.QueueNames.TelemetryIndexReconcile + "-poison",
MaintenanceService.MonitoredPoisonQueues);
// PR1 critical-table backup queue (plan §Wave5 #2).
Assert.Contains(
Constants.QueueNames.CriticalTableBackupPoison,
MaintenanceService.MonitoredPoisonQueues);
}
[Fact]
@@ -208,8 +208,10 @@ Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
# Pinned to top-level so Services/Backup, Functions/Backup, etc. for the
# critical-table backup feature (plan §PR1) are not accidentally excluded.
_UpgradeReport_Files/
Backup*/
/Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
@@ -0,0 +1,63 @@
using System;
using Azure;
using Azure.Data.Tables;
namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{
/// <summary>
/// Azure Table Storage row for the <c>BackupJobs</c> table. Kind / State / BackupOutcome
/// are persisted as strings because Azure Tables has no EDM enum type; the
/// <see cref="BackupJobsRepository"/> maps to and from the strongly-typed
/// <c>BackupJobStatus</c> DTO in <c>AutopilotMonitor.Shared.Models.Backup</c>.
/// Unknown strings on read throw so enum refactorings cannot silently shift
/// the persisted state.
/// <para>
/// Lives in the Functions project (not Shared) because <c>ITableEntity</c> is an
/// Azure-SDK dependency we deliberately keep out of the Shared layer.
/// </para>
/// <para>
/// Single-partition design (<c>PartitionKey="BackupJobs"</c>): job IDs are guids,
/// the table never grows large enough to need partitioning, and a single PK lets
/// the watchdog enumerate the whole table cheaply.
/// </para>
/// </summary>
public sealed class BackupJobStatusEntity : ITableEntity
{
/// <summary>Always <c>"BackupJobs"</c>.</summary>
public string PartitionKey { get; set; } = "BackupJobs";
/// <summary>Job id, Guid-N format.</summary>
public string RowKey { get; set; } = string.Empty;
/// <summary>Filled by the SDK on read; ignored on write.</summary>
public DateTimeOffset? Timestamp { get; set; }
/// <summary>Filled by the SDK on read; used by callers for CAS updates.</summary>
public ETag ETag { get; set; }
/// <summary>"Backup" or "RestoreTable".</summary>
public string Kind { get; set; } = string.Empty;
/// <summary>"Queued" | "Running" | "Completed" | "Failed" | "Skipped" | "BlockedTerminal".</summary>
public string State { get; set; } = string.Empty;
public string RequestedBy { get; set; } = string.Empty;
public DateTime QueuedAtUtc { get; set; }
public DateTime? StartedAtUtc { get; set; }
public DateTime? CompletedAtUtc { get; set; }
/// <summary>Watchdog stale-detection. Refreshed every renewal-tick and per-phase progress write.</summary>
public DateTime LastHeartbeatUtc { get; set; }
public string? BackupId { get; set; }
public string? SourceBackupId { get; set; }
public string? TableName { get; set; }
public string? Strategy { get; set; }
public string? Progress { get; set; }
public string? Error { get; set; }
/// <summary>"Success" or "Partial" (only when Kind=Backup AND State=Completed). Null otherwise.</summary>
public string? BackupOutcome { get; set; }
}
}
@@ -0,0 +1,189 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models.Backup;
using Azure;
using Azure.Data.Tables;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{
/// <summary>
/// Per-job state machine persistence for the critical-table backup feature.
/// Single partition (<c>"BackupJobs"</c>), RowKey = jobId (Guid-N). Lifecycle is
/// driven by ETag-CAS updates so the worker, the watchdog, and the duplicate-detection
/// path cannot race each other into an inconsistent state.
/// <para>
/// Enum-to-string mapping for Kind / State / BackupOutcome lives here in the
/// repository because Azure Tables has no EDM enum and enum refactorings must not
/// silently shift the persisted value. Unknown strings on read throw with a clear
/// message — no silent fallback.
/// </para>
/// </summary>
public class BackupJobsRepository
{
private const string PartitionKey = "BackupJobs";
private readonly TableClient _table;
private readonly ILogger<BackupJobsRepository> _logger;
public BackupJobsRepository(TableStorageService storage, ILogger<BackupJobsRepository> logger)
{
_logger = logger;
_table = storage.GetTableClient(Constants.TableNames.BackupJobs);
}
/// <summary>
/// Initial-insert of a freshly-created job. Returns false on 409 — Guid-N jobIds
/// should never collide, so a 409 is an operator-visible bug.
/// </summary>
public virtual async Task<bool> CreateAsync(BackupJobStatus job, CancellationToken ct = default)
{
if (job == null) throw new ArgumentNullException(nameof(job));
if (string.IsNullOrEmpty(job.JobId)) throw new ArgumentException("JobId required", nameof(job));
try
{
await _table.AddEntityAsync(MapToEntity(job), ct).ConfigureAwait(false);
return true;
}
catch (RequestFailedException ex) when (ex.Status == 409)
{
_logger.LogError(
"BackupJobsRepository.CreateAsync: jobId={JobId} already exists — possible Guid collision",
job.JobId);
return false;
}
}
/// <summary>Returns (null, null) if the job does not exist.</summary>
public virtual async Task<(BackupJobStatus? Job, ETag? ETag)> GetWithETagAsync(string jobId, CancellationToken ct = default)
{
if (string.IsNullOrEmpty(jobId)) throw new ArgumentException("jobId required", nameof(jobId));
try
{
var response = await _table.GetEntityAsync<BackupJobStatusEntity>(PartitionKey, jobId, cancellationToken: ct).ConfigureAwait(false);
return (MapToDomain(response.Value), response.Value.ETag);
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
return (null, null);
}
}
/// <summary>
/// CAS-update with If-Match. Returns true on success, false on 412 (concurrent
/// modification) or 404 (job vanished). Any other failure propagates.
/// </summary>
public virtual async Task<bool> TryUpdateWithCasAsync(BackupJobStatus job, ETag ifMatch, CancellationToken ct = default)
{
if (job == null) throw new ArgumentNullException(nameof(job));
if (string.IsNullOrEmpty(job.JobId)) throw new ArgumentException("JobId required", nameof(job));
try
{
await _table.UpdateEntityAsync(MapToEntity(job), ifMatch, TableUpdateMode.Replace, ct).ConfigureAwait(false);
return true;
}
catch (RequestFailedException ex) when (ex.Status == 412 || ex.Status == 404)
{
return false;
}
}
/// <summary>
/// Streams every job, optionally filtered server-side. Used by the watchdog
/// (state Queued OR Running) and the listing API. ETag is per-row from the SDK.
/// </summary>
public virtual async IAsyncEnumerable<(BackupJobStatus Job, ETag ETag)> QueryAsync(
string? filter = null,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default)
{
await foreach (var entity in _table.QueryAsync<BackupJobStatusEntity>(filter: filter, cancellationToken: ct).ConfigureAwait(false))
{
yield return (MapToDomain(entity), entity.ETag);
}
}
// ── Mapping: Entity ↔ Domain ───────────────────────────────────────────
internal static BackupJobStatusEntity MapToEntity(BackupJobStatus job)
{
return new BackupJobStatusEntity
{
PartitionKey = PartitionKey,
RowKey = job.JobId,
Kind = job.Kind.ToString(),
State = job.State.ToString(),
RequestedBy = job.RequestedBy,
QueuedAtUtc = job.QueuedAtUtc,
StartedAtUtc = job.StartedAtUtc,
CompletedAtUtc = job.CompletedAtUtc,
LastHeartbeatUtc = job.LastHeartbeatUtc,
BackupId = job.BackupId,
SourceBackupId = job.SourceBackupId,
TableName = job.TableName,
Strategy = job.Strategy,
Progress = job.Progress,
Error = job.Error,
BackupOutcome = job.BackupOutcome?.ToString(),
};
}
internal static BackupJobStatus MapToDomain(BackupJobStatusEntity entity)
{
return new BackupJobStatus
{
JobId = entity.RowKey,
Kind = ParseKind(entity.Kind),
State = ParseState(entity.State),
RequestedBy = entity.RequestedBy,
QueuedAtUtc = entity.QueuedAtUtc,
StartedAtUtc = entity.StartedAtUtc,
CompletedAtUtc = entity.CompletedAtUtc,
LastHeartbeatUtc = entity.LastHeartbeatUtc,
BackupId = entity.BackupId,
SourceBackupId = entity.SourceBackupId,
TableName = entity.TableName,
Strategy = entity.Strategy,
Progress = entity.Progress,
Error = entity.Error,
BackupOutcome = ParseBackupOutcome(entity.BackupOutcome),
};
}
private static BackupJobKind ParseKind(string value)
{
if (string.IsNullOrEmpty(value))
throw new InvalidOperationException("BackupJobStatusEntity.Kind is empty — entity corrupt or partially written");
if (!Enum.TryParse<BackupJobKind>(value, ignoreCase: false, out var kind))
throw new InvalidOperationException($"BackupJobStatusEntity.Kind has unknown value '{value}' — enum refactoring or hand-edit detected");
return kind;
}
private static BackupJobState ParseState(string value)
{
if (string.IsNullOrEmpty(value))
throw new InvalidOperationException("BackupJobStatusEntity.State is empty — entity corrupt or partially written");
if (!Enum.TryParse<BackupJobState>(value, ignoreCase: false, out var state))
throw new InvalidOperationException($"BackupJobStatusEntity.State has unknown value '{value}' — enum refactoring or hand-edit detected");
return state;
}
private static BackupOutcome? ParseBackupOutcome(string? value)
{
if (string.IsNullOrEmpty(value)) return null;
// Legacy "Failed" outcomes from pre-Wave21 plans must reject (BackupOutcome is now
// Success | Partial only; real failures live on JobState=Failed with BackupOutcome=null).
if (!Enum.TryParse<BackupOutcome>(value, ignoreCase: false, out var outcome))
throw new InvalidOperationException(
$"BackupJobStatusEntity.BackupOutcome has unknown value '{value}' — must be 'Success' or 'Partial' (no 'Failed' in current schema)");
return outcome;
}
}
}
@@ -0,0 +1,40 @@
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services.Backup;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Backup
{
/// <summary>
/// Drives <see cref="BackupJobWatchdog.SweepAsync"/> every 30 minutes so stuck
/// Queued / Running rows in <c>BackupJobs</c> are eventually transitioned to
/// Failed (with the watchdog's lease-probe + ETag-CAS safeguards). Independent
/// timer because the cadence is shorter than the daily backup itself, and the
/// watchdog is cheap (single filtered table query + 0..1 lease probe per sweep).
/// </summary>
public class BackupJobWatchdogTimerFunction
{
private readonly BackupJobWatchdog _watchdog;
private readonly ILogger<BackupJobWatchdogTimerFunction> _logger;
public BackupJobWatchdogTimerFunction(
BackupJobWatchdog watchdog,
ILogger<BackupJobWatchdogTimerFunction> logger)
{
_watchdog = watchdog;
_logger = logger;
}
/// <summary>NCRONTAB "0 */30 * * * *" — every 30 minutes on the half-hour.</summary>
[Function("BackupJobWatchdogTimer")]
public async Task Run([TimerTrigger("0 */30 * * * *")] object timer, CancellationToken ct)
{
var transitioned = await _watchdog.SweepAsync(ct).ConfigureAwait(false);
if (transitioned > 0)
{
_logger.LogWarning("BackupJobWatchdogTimer: {Count} stale job(s) transitioned to Failed", transitioned);
}
}
}
}
@@ -0,0 +1,118 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Backup;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models.Backup;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Backup
{
/// <summary>
/// Daily 04:00 UTC trigger of the critical-table backup feature. The TIMER is
/// worker-equivalent topology-wise (Lease → Service → Release) but has no
/// JobStatus + no queue pop: failure paths emit OpsEvents only, and the
/// daily re-run is the natural recovery mechanism (no per-fire retries).
/// </summary>
public class CriticalTableBackupTimerFunction
{
private readonly BlobBackupStore _blobStore;
private readonly ICriticalTableBackupService _backupService;
private readonly OpsEventService _opsEvents;
private readonly ILogger<CriticalTableBackupTimerFunction> _logger;
public CriticalTableBackupTimerFunction(
BlobBackupStore blobStore,
ICriticalTableBackupService backupService,
OpsEventService opsEvents,
ILogger<CriticalTableBackupTimerFunction> logger)
{
_blobStore = blobStore;
_backupService = backupService;
_opsEvents = opsEvents;
_logger = logger;
}
/// <summary>
/// NCRONTAB "0 0 4 * * *" — every day at 04:00 UTC. After the daily
/// VulnerabilityDataSync (03:00) and well outside the agent ingest peak.
/// </summary>
[Function("CriticalTableBackupTimer")]
public async Task Run([TimerTrigger("0 0 4 * * *")] object timer, CancellationToken ct)
{
_logger.LogInformation("CriticalTableBackupTimer fired");
string triggeredBy = "Timer";
string? backupId = null;
// ── Acquire maintenance lease ───────────────────────────────────────
await _blobStore.EnsureMaintenanceLockSentinelAsync(ct).ConfigureAwait(false);
Azure.Storage.Blobs.Specialized.BlobLeaseClient? lease;
try
{
lease = await _blobStore.AcquireMaintenanceLeaseAsync(BlobBackupStore.MaintenanceLeaseDuration, ct).ConfigureAwait(false);
}
catch (LeaseHeldException)
{
_logger.LogInformation("CriticalTableBackupTimer: maintenance lease held by another op — skipping today's run");
await TryRecordAsync(() => _opsEvents.RecordCriticalTableBackupSkippedLockedAsync(
"another maintenance operation holds the lease at timer fire", triggeredBy)).ConfigureAwait(false);
return;
}
try
{
backupId = _backupService.GenerateBackupId();
BackupRunResult? result;
try
{
result = await _backupService.RunBackupUnderLeaseAsync(backupId, triggeredBy, ct).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "CriticalTableBackupTimer: backup run failed (backupId={BackupId})", backupId);
await TryRecordAsync(() => _opsEvents.RecordCriticalTableBackupFailedAsync(backupId, ex.Message, triggeredBy)).ConfigureAwait(false);
return;
}
var durationMs = (int)(result.Manifest.CompletedAtUtc - result.Manifest.StartedAtUtc).TotalMilliseconds;
var failedOrSkipped = 0;
foreach (var t in result.Manifest.Tables)
{
if (t.Status == TableBackupStatus.Failed || t.Status == TableBackupStatus.Skipped) failedOrSkipped++;
}
if (result.Outcome == BackupOutcome.Success)
{
await TryRecordAsync(() => _opsEvents.RecordCriticalTableBackupCompletedAsync(
result.Manifest.BackupId, result.Manifest.Tables.Count, durationMs,
Constants.BlobContainers.CriticalTableBackups, result.ManifestBlobName, triggeredBy)).ConfigureAwait(false);
}
else
{
await TryRecordAsync(() => _opsEvents.RecordCriticalTableBackupPartialAsync(
result.Manifest.BackupId, result.Manifest.Tables.Count, failedOrSkipped, durationMs,
Constants.BlobContainers.CriticalTableBackups, result.ManifestBlobName, triggeredBy)).ConfigureAwait(false);
}
}
finally
{
try { await lease.ReleaseAsync(cancellationToken: ct).ConfigureAwait(false); }
catch (Exception ex) { _logger.LogWarning(ex, "CriticalTableBackupTimer: lease release failed (auto-expires)"); }
}
}
/// <summary>OpsEvent write must not bubble — backup itself already succeeded (or failed by other means).</summary>
private async Task TryRecordAsync(Func<Task> writeAsync)
{
try { await writeAsync().ConfigureAwait(false); }
catch (Exception ex)
{
_logger.LogWarning(ex, "CriticalTableBackupTimer: ops-event recording failed");
}
}
}
}
@@ -0,0 +1,53 @@
using System.Net;
using System.Text.Json;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Shared.Models.Backup;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Backup
{
/// <summary>
/// <c>GET /api/global/backups/jobs/{jobId}</c> — polling endpoint for the
/// 202-Accepted async backup workflow. GA-only via <c>EndpointAccessPolicyCatalog</c>.
/// Returns the live <see cref="BackupJobStatus"/> DTO including
/// <c>backupOutcome</c> (Success / Partial) and <c>lastHeartbeatUtc</c> so the
/// UI can render Completed / Partial / Failed banners and the operator can spot
/// stalled jobs at a glance.
/// </summary>
public class GetBackupJobStatusFunction
{
private readonly BackupJobsRepository _jobs;
private readonly ILogger<GetBackupJobStatusFunction> _logger;
public GetBackupJobStatusFunction(BackupJobsRepository jobs, ILogger<GetBackupJobStatusFunction> logger)
{
_jobs = jobs;
_logger = logger;
}
[Function("GetBackupJobStatus")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/backups/jobs/{jobId}")] HttpRequestData req,
string jobId)
{
var (job, _) = await _jobs.GetWithETagAsync(jobId, req.FunctionContext.CancellationToken);
if (job is null)
{
return await WriteJsonAsync(req, HttpStatusCode.NotFound, new { error = "JobNotFound", jobId });
}
return await WriteJsonAsync(req, HttpStatusCode.OK, job);
}
private static async Task<HttpResponseData> WriteJsonAsync(HttpRequestData req, HttpStatusCode status, object body)
{
var response = req.CreateResponse(status);
response.Headers.Add("Content-Type", "application/json; charset=utf-8");
var json = JsonSerializer.Serialize(body, BackupManifestJson.SerializerOptions);
await response.WriteStringAsync(json);
return response;
}
}
}
@@ -0,0 +1,50 @@
using System.Net;
using System.Text.Json;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services.Backup;
using AutopilotMonitor.Shared.Models.Backup;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Backup
{
/// <summary>
/// <c>GET /api/global/backups/{backupId}</c> — fetches the manifest JSON for a
/// specific backup run. 404 if the manifest is missing (i.e. the prefix exists but
/// the durability anchor never got written — treated as <c>Incomplete</c> per plan).
/// GA-only.
/// </summary>
public class GetBackupManifestFunction
{
private readonly BlobBackupStore _store;
private readonly ILogger<GetBackupManifestFunction> _logger;
public GetBackupManifestFunction(BlobBackupStore store, ILogger<GetBackupManifestFunction> logger)
{
_store = store;
_logger = logger;
}
[Function("GetBackupManifest")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/backups/{backupId}")] HttpRequestData req,
string backupId)
{
var (payload, _) = await _store.ReadManifestAsync(backupId, req.FunctionContext.CancellationToken);
if (payload is null)
{
var notFound = req.CreateResponse(HttpStatusCode.NotFound);
notFound.Headers.Add("Content-Type", "application/json; charset=utf-8");
await notFound.WriteStringAsync(JsonSerializer.Serialize(new { error = "ManifestNotFound", backupId }, BackupManifestJson.SerializerOptions));
return notFound;
}
// Stream the bytes verbatim — the manifest is already JSON, no need to deserialise + re-serialise.
var response = req.CreateResponse(HttpStatusCode.OK);
response.Headers.Add("Content-Type", "application/json; charset=utf-8");
await response.Body.WriteAsync(payload, req.FunctionContext.CancellationToken);
return response;
}
}
}
@@ -0,0 +1,51 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Text.Json;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services.Backup;
using AutopilotMonitor.Shared.Models.Backup;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Backup
{
/// <summary>
/// <c>GET /api/global/backups</c> — lists all backupIds in the
/// <c>critical-table-backups</c> container. PR1 returns just the ids; the manifest
/// detail (table breakdown, sizes, SHAs) is fetched separately via
/// <c>GET /api/global/backups/{backupId}</c>. GA-only.
/// </summary>
public class ListBackupsFunction
{
private readonly BlobBackupStore _store;
private readonly ILogger<ListBackupsFunction> _logger;
public ListBackupsFunction(BlobBackupStore store, ILogger<ListBackupsFunction> logger)
{
_store = store;
_logger = logger;
}
[Function("ListBackups")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/backups")] HttpRequestData req)
{
var ids = new List<string>();
await foreach (var id in _store.ListBackupIdsAsync(req.FunctionContext.CancellationToken))
{
ids.Add(id);
}
// newest first — ids are yyyyMMddTHHmmssZ_{guid8} so reverse-string-sort = reverse-time-sort.
ids.Sort(StringComparer.Ordinal);
ids.Reverse();
var response = req.CreateResponse(HttpStatusCode.OK);
response.Headers.Add("Content-Type", "application/json; charset=utf-8");
var json = JsonSerializer.Serialize(new { backupIds = ids }, BackupManifestJson.SerializerOptions);
await response.WriteStringAsync(json);
return response;
}
}
}
@@ -0,0 +1,118 @@
using System;
using System.Net;
using System.Text.Json;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services.Backup.Queue;
using AutopilotMonitor.Shared.Models.Backup;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Backup
{
/// <summary>
/// <c>POST /api/global/backups/trigger</c> — manual GA trigger for a one-off
/// critical-table backup run. GA-only via <c>EndpointAccessPolicyCatalog</c>.
/// <para>
/// Semantics (plan Wave11 #2 — fail-hard producer): on successful enqueue we
/// return <c>202 Accepted</c> with the jobId; on enqueue failure we transition
/// the BackupJobs row to <c>Failed</c> and return <c>500</c> so the operator
/// sees an immediate error instead of an apparent success with no work scheduled.
/// </para>
/// </summary>
public class TriggerCriticalTableBackupFunction
{
private readonly ICriticalTableBackupProducer _producer;
private readonly BackupJobsRepository _jobs;
private readonly ILogger<TriggerCriticalTableBackupFunction> _logger;
private readonly TimeProvider _clock;
public TriggerCriticalTableBackupFunction(
ICriticalTableBackupProducer producer,
BackupJobsRepository jobs,
ILogger<TriggerCriticalTableBackupFunction> logger,
TimeProvider? clock = null)
{
_producer = producer;
_jobs = jobs;
_logger = logger;
_clock = clock ?? TimeProvider.System;
}
[Function("TriggerCriticalTableBackup")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "global/backups/trigger")] HttpRequestData req)
{
var actor = TenantHelper.GetUserIdentifier(req) ?? "GlobalAdmin";
var jobId = Guid.NewGuid().ToString("N");
var nowUtc = _clock.GetUtcNow().UtcDateTime;
var job = new BackupJobStatus
{
JobId = jobId,
Kind = BackupJobKind.Backup,
State = BackupJobState.Queued,
RequestedBy = actor,
QueuedAtUtc = nowUtc,
LastHeartbeatUtc = nowUtc,
};
var inserted = await _jobs.CreateAsync(job, req.FunctionContext.CancellationToken);
if (!inserted)
{
// Guid-N collision (effectively impossible) — surface so the operator can retry.
return await WriteJsonAsync(req, HttpStatusCode.Conflict, new
{
error = "JobIdCollision",
message = "Failed to allocate a unique jobId — please retry.",
});
}
try
{
await _producer.EnqueueAsync(new CriticalTableBackupEnvelope { JobId = jobId }, req.FunctionContext.CancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "TriggerCriticalTableBackup: enqueue failed for jobId {JobId} — rolling status to Failed", jobId);
// Roll the row forward so the operator sees the failure immediately
// (otherwise the BackupJobsWatchdog would eventually pick this up
// after 60min — but for HTTP this is too slow).
var (fresh, etag) = await _jobs.GetWithETagAsync(jobId, req.FunctionContext.CancellationToken);
if (fresh is not null && etag is not null)
{
fresh.State = BackupJobState.Failed;
fresh.CompletedAtUtc = _clock.GetUtcNow().UtcDateTime;
fresh.LastHeartbeatUtc = fresh.CompletedAtUtc.Value;
fresh.Error = $"enqueue failed: {ex.Message}";
await _jobs.TryUpdateWithCasAsync(fresh, etag.Value, req.FunctionContext.CancellationToken);
}
return await WriteJsonAsync(req, HttpStatusCode.InternalServerError, new
{
error = "EnqueueFailed",
message = "Failed to enqueue backup job — job status has been marked Failed.",
jobId,
});
}
return await WriteJsonAsync(req, HttpStatusCode.Accepted, new
{
jobId,
statusUrl = $"/api/global/backups/jobs/{jobId}",
});
}
private static async Task<HttpResponseData> WriteJsonAsync(HttpRequestData req, HttpStatusCode status, object body)
{
var response = req.CreateResponse(status);
response.Headers.Add("Content-Type", "application/json; charset=utf-8");
var json = JsonSerializer.Serialize(body, BackupManifestJson.SerializerOptions);
await response.WriteStringAsync(json);
return response;
}
}
}
@@ -41,6 +41,12 @@ public class NoStoreCacheMiddleware : IFunctionsWorkerMiddleware
"/api/global/sessions",
"/api/global/distress-reports",
"/api/global/session-reports", // covers list; /download-url + /{id}/note via prefix
// Critical-table backup feature — manifest + job-status responses can carry
// property values from AdminConfiguration / TenantConfiguration (SAS URLs,
// webhook secrets, API keys) and must never be cached. Trigger endpoint also
// here so a returned jobId / statusUrl doesn't end up in proxy cache.
"/api/global/backups",
"/api/global/backups/trigger",
};
/// <summary>
@@ -56,6 +62,7 @@ public class NoStoreCacheMiddleware : IFunctionsWorkerMiddleware
"/api/global/raw/", // GA cross-tenant raw event/session/table surfaces
"/api/global/search/", // GA cross-tenant search results
"/api/global/session-reports/", // /download-url (SAS) + /{reportId}/note
"/api/global/backups/", // covers /{backupId}, /jobs/{jobId} — same secret-bearing rationale
};
public async Task Invoke(FunctionContext context, FunctionExecutionDelegate next)
@@ -277,6 +277,25 @@ builder.Services.AddSingleton<
builder.Services.AddHostedService<
AutopilotMonitor.Functions.Services.Vulnerability.VulnerabilityCorrelateQueueWorker>();
// ── Critical-Table Backup (plan §PR1) ─────────────────────────────────────────
// Lease-aware service registered separately from the timer/worker so the lease
// boundary is testable in isolation. JobsRepository is the canonical persistence
// for the 6-state machine.
builder.Services.AddSingleton<AutopilotMonitor.Functions.DataAccess.TableStorage.BackupJobsRepository>();
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Backup.BlobBackupStore>();
builder.Services.AddSingleton<
AutopilotMonitor.Functions.Services.Backup.ICriticalTableBackupService,
AutopilotMonitor.Functions.Services.Backup.CriticalTableBackupService>();
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Backup.BackupJobWatchdog>();
// Producer = fail-hard SendMessage (HTTP trigger handles the rollback to Failed).
builder.Services.AddSingleton<
AutopilotMonitor.Functions.Services.Backup.Queue.ICriticalTableBackupProducer,
AutopilotMonitor.Functions.Services.Backup.Queue.AzureQueueCriticalTableBackupProducer>();
// Worker = BackgroundService, not QueueTrigger (matches AnalyzeOnEnrollmentEndQueueWorker
// pattern per plan §Wave4 #1 — avoids the QueueTrigger-specific connection app-setting).
builder.Services.AddHostedService<
AutopilotMonitor.Functions.Services.Backup.Queue.CriticalTableBackupQueueWorker>();
// Programmatic SignalR push for background tasks (rule engine, vulnerability correlation)
builder.Services.AddSingleton<SignalRNotificationService>();
builder.Services.AddSingleton<ISignalRNotificationService>(sp => sp.GetRequiredService<SignalRNotificationService>());
@@ -288,6 +288,12 @@ public static class EndpointAccessPolicyCatalog
new("DELETE", "versions/block/{encodedPattern}", EndpointPolicy.GlobalAdminOnly),
new("POST", "maintenance/trigger", EndpointPolicy.GlobalAdminOnly),
// ── Critical-Table Backup (plan §PR1) ──────────────────────────────────
new("POST", "global/backups/trigger", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/backups", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/backups/{backupId}", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/backups/jobs/{jobId}", EndpointPolicy.GlobalAdminOnly),
// ── Tenant Offboarding Customs Archive (PR3.B) ─────────────────────────
// Snapshot of each tenant's custom GatherRules / AnalyzeRules / ImeLogPatterns
// rows from prior offboarding runs. Operator-driven cleanup: GA reviews + deletes.
@@ -0,0 +1,132 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Shared.Models.Backup;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services.Backup
{
/// <summary>
/// Periodic safety-net for stuck backup jobs. Two failure modes are healed:
/// <list type="bullet">
/// <item><b>Queued</b> longer than <see cref="QueuedStaleThreshold"/>: producer wrote
/// the row but the queue never delivered (worker cold-start lag, storage outage).
/// No heartbeat is expected on Queued, so the only signal is wall-clock age.</item>
/// <item><b>Running</b> with stale <c>LastHeartbeatUtc</c> AND a free maintenance lease.
/// A live worker holds the lease while running, so a free lease is the proof that
/// the previous worker is gone. Without the lease-probe a watchdog flip would race
/// a still-running worker into a Failed → Completed flap.</item>
/// </list>
/// All transitions are ETag-CAS; 412 is silently absorbed (worker beat us to the punch).
/// </summary>
public class BackupJobWatchdog
{
/// <summary>How long a Queued job is allowed to sit before the watchdog calls it dead.</summary>
public static readonly TimeSpan QueuedStaleThreshold = TimeSpan.FromMinutes(60);
/// <summary>How long a Running job may go without a heartbeat before the watchdog inspects the lease.</summary>
public static readonly TimeSpan RunningHeartbeatStaleThreshold = TimeSpan.FromMinutes(5);
private readonly BackupJobsRepository _jobs;
private readonly BlobBackupStore _blobStore;
private readonly ILogger<BackupJobWatchdog> _logger;
private readonly TimeProvider _clock;
public BackupJobWatchdog(
BackupJobsRepository jobs,
BlobBackupStore blobStore,
ILogger<BackupJobWatchdog> logger,
TimeProvider? clock = null)
{
_jobs = jobs;
_blobStore = blobStore;
_logger = logger;
_clock = clock ?? TimeProvider.System;
}
/// <summary>
/// Single sweep — invoked by a timer (every ~30min) or on demand from a test.
/// Returns the number of jobs transitioned to Failed.
/// </summary>
public async Task<int> SweepAsync(CancellationToken ct = default)
{
var nowUtc = _clock.GetUtcNow().UtcDateTime;
var queuedCutoff = nowUtc - QueuedStaleThreshold;
var runningCutoff = nowUtc - RunningHeartbeatStaleThreshold;
var transitioned = 0;
bool leaseProbedThisSweep = false;
bool leaseHeldThisSweep = false;
// Query non-terminal jobs only — keeps the watchdog cheap and avoids
// touching Completed/Failed rows whose lifecycle is done.
var filter = $"PartitionKey eq 'BackupJobs' and (State eq 'Queued' or State eq 'Running')";
await foreach (var (job, etag) in _jobs.QueryAsync(filter, ct).ConfigureAwait(false))
{
ct.ThrowIfCancellationRequested();
bool shouldFail;
string reason;
if (job.State == BackupJobState.Queued && job.QueuedAtUtc < queuedCutoff)
{
shouldFail = true;
reason = $"watchdog: Queued > {QueuedStaleThreshold.TotalMinutes}min without worker pickup";
}
else if (job.State == BackupJobState.Running && job.LastHeartbeatUtc < runningCutoff)
{
// Probe the maintenance lease at most once per sweep — cheap, but each
// probe is a real Azure call.
if (!leaseProbedThisSweep)
{
leaseProbedThisSweep = true;
try
{
var probe = await _blobStore.AcquireMaintenanceLeaseAsync(TimeSpan.FromSeconds(15), ct).ConfigureAwait(false);
try { await probe.ReleaseAsync(cancellationToken: ct).ConfigureAwait(false); }
catch (Exception ex) { _logger.LogWarning(ex, "BackupJobWatchdog: probe-lease release failed (auto-expires)"); }
}
catch (LeaseHeldException)
{
leaseHeldThisSweep = true;
}
}
if (leaseHeldThisSweep)
{
// Live worker still holds the lease — leave the job alone.
continue;
}
shouldFail = true;
reason = $"watchdog: Running with stale heartbeat (> {RunningHeartbeatStaleThreshold.TotalMinutes}min) and free lease — previous worker likely died";
}
else
{
continue;
}
if (!shouldFail) continue;
job.State = BackupJobState.Failed;
job.CompletedAtUtc = nowUtc;
job.LastHeartbeatUtc = nowUtc;
job.Error = reason;
var ok = await _jobs.TryUpdateWithCasAsync(job, etag, ct).ConfigureAwait(false);
if (ok)
{
transitioned++;
_logger.LogWarning("BackupJobWatchdog: transitioned job {JobId} to Failed — {Reason}", job.JobId, reason);
}
else
{
// 412 — a worker raced us with a legitimate state update. Drop and move on.
_logger.LogInformation("BackupJobWatchdog: CAS 412 on jobId {JobId} — worker beat us, skipping", job.JobId);
}
}
return transitioned;
}
}
}
@@ -0,0 +1,219 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Shared;
using Azure;
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using Azure.Storage.Blobs.Specialized;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services.Backup
{
/// <summary>
/// Blob I/O for the critical-table backup feature (plan §PR1). Wraps the
/// <c>critical-table-backups</c> container with a small surface tailored to the
/// service + worker + timer flows: lease sentinel, per-table streaming uploads,
/// manifest read/write, listing.
/// <para>
/// All write paths take the maintenance lease as a precondition — the WORKER /
/// TIMER acquires + releases it; this store never owns lease lifecycle (plan
/// §Wave17/18 Lease-Ownership-Boundary).
/// </para>
/// </summary>
public class BlobBackupStore
{
/// <summary>Sentinel blob path — leased by the worker/timer to serialize maintenance ops.</summary>
public const string MaintenanceLockBlobName = "_lock/maintenance.lock";
/// <summary>Default lease duration when the timer or worker takes the maintenance lock.</summary>
public static readonly TimeSpan MaintenanceLeaseDuration = TimeSpan.FromSeconds(60);
private readonly BlobStorageService _blobs;
private readonly ILogger<BlobBackupStore> _logger;
private int _containerEnsured;
public BlobBackupStore(BlobStorageService blobs, ILogger<BlobBackupStore> logger)
{
_blobs = blobs;
_logger = logger;
}
// ── Container + sentinel bootstrap ──────────────────────────────────────
/// <summary>
/// Idempotently ensures the container exists. Process-local once-flag avoids hammering
/// CreateIfNotExists on every call.
/// </summary>
public virtual async Task EnsureContainerAsync(CancellationToken ct = default)
{
if (_containerEnsured == 1) return;
var container = GetContainer();
await container.CreateIfNotExistsAsync(cancellationToken: ct).ConfigureAwait(false);
Interlocked.Exchange(ref _containerEnsured, 1);
}
/// <summary>
/// Idempotently ensures the maintenance-lease sentinel blob exists. Without this an
/// AcquireLease call on a missing blob throws 404. Uses If-None-Match=* so concurrent
/// callers cooperate; both 409 BlobAlreadyExists and 412 ConditionNotMet are no-ops.
/// </summary>
public virtual async Task EnsureMaintenanceLockSentinelAsync(CancellationToken ct = default)
{
await EnsureContainerAsync(ct).ConfigureAwait(false);
var blob = GetContainer().GetBlobClient(MaintenanceLockBlobName);
var options = new BlobUploadOptions
{
HttpHeaders = new BlobHttpHeaders { ContentType = "application/octet-stream" },
Conditions = new BlobRequestConditions { IfNoneMatch = ETag.All },
};
try
{
using var empty = new MemoryStream(Array.Empty<byte>());
await blob.UploadAsync(empty, options, ct).ConfigureAwait(false);
}
catch (RequestFailedException ex) when (IsAlreadyExists(ex))
{
// Sentinel exists already — perfect, that's the steady state.
}
}
/// <summary>
/// Attempts to acquire the maintenance lease for <paramref name="leaseDuration"/>
/// seconds (15..60 per Azure spec; null = MaintenanceLeaseDuration). Throws
/// <see cref="LeaseHeldException"/> on 409 (another holder); other errors propagate.
/// Caller is responsible for renewing (every &lt;duration) and releasing.
/// </summary>
public virtual async Task<BlobLeaseClient> AcquireMaintenanceLeaseAsync(TimeSpan? leaseDuration = null, CancellationToken ct = default)
{
await EnsureMaintenanceLockSentinelAsync(ct).ConfigureAwait(false);
var blob = GetContainer().GetBlobClient(MaintenanceLockBlobName);
var leaseClient = blob.GetBlobLeaseClient();
try
{
await leaseClient.AcquireAsync(leaseDuration ?? MaintenanceLeaseDuration, conditions: null, ct).ConfigureAwait(false);
return leaseClient;
}
catch (RequestFailedException ex) when (ex.Status == 409)
{
throw new LeaseHeldException("maintenance lease is held by another operation", ex);
}
}
// ── Per-table dump I/O ──────────────────────────────────────────────────
/// <summary>
/// Opens a writable stream for the per-table NDJSON dump. The block-blob is committed
/// when the returned stream is disposed (await using), so the caller MUST wrap it in
/// an <c>await using</c> block and dispose it BEFORE the manifest is written —
/// otherwise the "manifest last" invariant breaks.
/// </summary>
public virtual async Task<Stream> OpenNdjsonWriteStreamAsync(string backupId, string tableName, CancellationToken ct = default)
{
if (string.IsNullOrEmpty(backupId)) throw new ArgumentException("backupId required", nameof(backupId));
if (string.IsNullOrEmpty(tableName)) throw new ArgumentException("tableName required", nameof(tableName));
await EnsureContainerAsync(ct).ConfigureAwait(false);
var blob = GetContainer().GetBlockBlobClient(BuildNdjsonBlobName(backupId, tableName));
return await blob.OpenWriteAsync(overwrite: true, options: new BlockBlobOpenWriteOptions
{
HttpHeaders = new BlobHttpHeaders { ContentType = "application/x-ndjson" },
}, cancellationToken: ct).ConfigureAwait(false);
}
/// <summary>
/// Final manifest write — fails (returns false) on If-None-Match collision so a
/// backupId reuse is detected loudly. Manifest is the durability anchor (plan §3).
/// </summary>
public virtual async Task<bool> TryWriteManifestAsync(string backupId, byte[] manifestBytes, CancellationToken ct = default)
{
if (string.IsNullOrEmpty(backupId)) throw new ArgumentException("backupId required", nameof(backupId));
if (manifestBytes == null) throw new ArgumentNullException(nameof(manifestBytes));
await EnsureContainerAsync(ct).ConfigureAwait(false);
var blob = GetContainer().GetBlobClient(BuildManifestBlobName(backupId));
var options = new BlobUploadOptions
{
HttpHeaders = new BlobHttpHeaders { ContentType = "application/json" },
Conditions = new BlobRequestConditions { IfNoneMatch = ETag.All },
};
try
{
using var ms = new MemoryStream(manifestBytes);
await blob.UploadAsync(ms, options, ct).ConfigureAwait(false);
return true;
}
catch (RequestFailedException ex) when (IsAlreadyExists(ex))
{
_logger.LogError("BlobBackupStore.TryWriteManifestAsync: manifest for backupId {BackupId} already exists — backupId collision", backupId);
return false;
}
}
/// <summary>Reads the manifest bytes + ETag, or returns (null, null) if absent.</summary>
public virtual async Task<(byte[]? Payload, ETag? ETag)> ReadManifestAsync(string backupId, CancellationToken ct = default)
{
if (string.IsNullOrEmpty(backupId)) throw new ArgumentException("backupId required", nameof(backupId));
await EnsureContainerAsync(ct).ConfigureAwait(false);
var blob = GetContainer().GetBlobClient(BuildManifestBlobName(backupId));
try
{
using var ms = new MemoryStream();
await blob.DownloadToAsync(ms, ct).ConfigureAwait(false);
var props = await blob.GetPropertiesAsync(cancellationToken: ct).ConfigureAwait(false);
return (ms.ToArray(), props.Value.ETag);
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
return (null, null);
}
}
/// <summary>
/// Lists all backupIds present in the container (one entry per distinct first-level
/// prefix), ignoring the <c>_lock/</c> prefix. Whether the manifest is actually
/// present is up to the caller — this method returns ids of all <c>{id}/</c>
/// prefixes regardless.
/// </summary>
public virtual async IAsyncEnumerable<string> ListBackupIdsAsync(
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default)
{
await EnsureContainerAsync(ct).ConfigureAwait(false);
var container = GetContainer();
// GetBlobsByHierarchyAsync with delimiter="/" returns the top-level prefixes.
var seen = new HashSet<string>(StringComparer.Ordinal);
await foreach (var item in container.GetBlobsByHierarchyAsync(traits: BlobTraits.None, states: BlobStates.None, prefix: null, delimiter: "/", cancellationToken: ct).ConfigureAwait(false))
{
if (item.IsPrefix && item.Prefix is { Length: > 0 } p)
{
var trimmed = p.TrimEnd('/');
if (trimmed.StartsWith("_lock", StringComparison.Ordinal)) continue;
if (seen.Add(trimmed)) yield return trimmed;
}
}
}
// ── Internals ───────────────────────────────────────────────────────────
internal static string BuildNdjsonBlobName(string backupId, string tableName) => $"{backupId}/{tableName}.ndjson";
internal static string BuildManifestBlobName(string backupId) => $"{backupId}/manifest.json";
private BlobContainerClient GetContainer() => _blobs.GetContainerClient(Constants.BlobContainers.CriticalTableBackups);
private static bool IsAlreadyExists(RequestFailedException ex)
=> ex.Status == 409
|| ex.Status == 412
|| ex.ErrorCode == BlobErrorCode.BlobAlreadyExists
|| ex.ErrorCode == BlobErrorCode.ConditionNotMet;
}
/// <summary>Thrown by <see cref="BlobBackupStore.AcquireMaintenanceLeaseAsync"/> on 409 conflict.</summary>
public sealed class LeaseHeldException : Exception
{
public LeaseHeldException(string message, Exception inner) : base(message, inner) { }
}
}
@@ -0,0 +1,257 @@
using System;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services.Tables;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models.Backup;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services.Backup
{
/// <summary>
/// Streams all <see cref="Constants.CriticalBackupTables.All"/> tables into per-table
/// NDJSON blobs, then writes a <c>manifest.json</c> LAST as the durability anchor.
/// Plan §PR1.
/// <para>
/// LEASE OWNERSHIP boundary: this service neither acquires nor releases the
/// maintenance lease. It assumes the caller (timer or queue worker) already holds it.
/// The service also does not write to <c>BackupJobStatus</c> — that responsibility
/// lives with the caller too, so the service stays Unit-testable without touching
/// the table-storage layer.
/// </para>
/// </summary>
public sealed class CriticalTableBackupService : ICriticalTableBackupService
{
/// <summary>Per-run wall-clock budget. 10 min safety headroom below the 60 min Function timeout.</summary>
internal static readonly TimeSpan PerRunBudget = TimeSpan.FromMinutes(50);
private readonly TableStorageService _tables;
private readonly BlobBackupStore _store;
private readonly ILogger<CriticalTableBackupService> _logger;
private readonly TimeProvider _clock;
public CriticalTableBackupService(
TableStorageService tables,
BlobBackupStore store,
ILogger<CriticalTableBackupService> logger,
TimeProvider? clock = null)
{
_tables = tables;
_store = store;
_logger = logger;
_clock = clock ?? TimeProvider.System;
}
public string GenerateBackupId()
{
var now = _clock.GetUtcNow().UtcDateTime;
var stamp = now.ToString("yyyyMMddTHHmmssZ", System.Globalization.CultureInfo.InvariantCulture);
var guidPart = Guid.NewGuid().ToString("N").Substring(0, 8);
return $"{stamp}_{guidPart}";
}
public async Task<BackupRunResult> RunBackupUnderLeaseAsync(string backupId, string triggeredBy, CancellationToken ct = default)
{
if (string.IsNullOrEmpty(backupId)) throw new ArgumentException("backupId required", nameof(backupId));
if (string.IsNullOrEmpty(triggeredBy)) triggeredBy = "Unknown";
var startedAtUtc = _clock.GetUtcNow().UtcDateTime;
var deadlineUtc = startedAtUtc + PerRunBudget;
var manifest = new CriticalTableBackupManifest
{
SchemaVersion = 1,
BackupId = backupId,
StartedAtUtc = startedAtUtc,
TriggeredBy = triggeredBy,
};
var degraded = false;
foreach (var tableName in Constants.CriticalBackupTables.All)
{
ct.ThrowIfCancellationRequested();
if (_clock.GetUtcNow().UtcDateTime >= deadlineUtc)
{
manifest.Tables.Add(new CriticalTableBackupTableEntry
{
TableName = tableName,
Status = TableBackupStatus.Skipped,
BlobName = BlobBackupStore.BuildNdjsonBlobName(backupId, tableName),
ErrorMessage = "per-run budget exceeded before table started",
});
degraded = true;
continue;
}
var entry = await DumpOneTableAsync(backupId, tableName, deadlineUtc, ct).ConfigureAwait(false);
manifest.Tables.Add(entry);
if (entry.Status == TableBackupStatus.Failed || entry.Status == TableBackupStatus.Skipped)
{
degraded = true;
}
}
manifest.CompletedAtUtc = _clock.GetUtcNow().UtcDateTime;
manifest.Outcome = degraded ? BackupOutcome.Partial : BackupOutcome.Success;
// Manifest LAST. If this throws, caller treats run as Failed (no JobState=Completed,
// no BackupOutcome). Existence of manifest.json is the durability anchor.
var manifestBytes = JsonSerializer.SerializeToUtf8Bytes(manifest, BackupManifestJson.SerializerOptions);
var ok = await _store.TryWriteManifestAsync(backupId, manifestBytes, ct).ConfigureAwait(false);
if (!ok)
{
throw new InvalidOperationException($"Manifest for backupId {backupId} already exists — collision");
}
return new BackupRunResult
{
Outcome = manifest.Outcome,
Manifest = manifest,
ManifestBlobName = BlobBackupStore.BuildManifestBlobName(backupId),
};
}
// ── Per-table loop body ─────────────────────────────────────────────────
private async Task<CriticalTableBackupTableEntry> DumpOneTableAsync(
string backupId, string tableName, DateTime deadlineUtc, CancellationToken ct)
{
var entry = new CriticalTableBackupTableEntry
{
TableName = tableName,
BlobName = BlobBackupStore.BuildNdjsonBlobName(backupId, tableName),
};
long rows = 0;
long byteSize = 0;
string sha256Hex;
try
{
using var sha = SHA256.Create();
// await using ensures the block-blob CommitBlockList runs BEFORE the next
// table starts and BEFORE the manifest is written (plan Wave14 #3).
await using var blobStream = await _store.OpenNdjsonWriteStreamAsync(backupId, tableName, ct).ConfigureAwait(false);
// CountingStream tracks byte count without buffering; CryptoStream feeds the hash;
// both wrap the actual blob stream so writes pass through each layer once.
await using var counting = new CountingStream(blobStream);
await using var crypto = new CryptoStream(counting, sha, CryptoStreamMode.Write, leaveOpen: true);
await foreach (var entity in _tables.EnumerateAllAsync(tableName, ct).ConfigureAwait(false))
{
ct.ThrowIfCancellationRequested();
if (_clock.GetUtcNow().UtcDateTime >= deadlineUtc)
throw new BudgetExceededException("per-run budget exceeded during table dump");
var dump = TableEntityDumpConverter.MapEntityToDump(entity);
var line = JsonSerializer.SerializeToUtf8Bytes(dump, BackupManifestJson.SerializerOptions);
await crypto.WriteAsync(line, ct).ConfigureAwait(false);
await crypto.WriteAsync(NewlineBytes, ct).ConfigureAwait(false);
rows++;
}
// Final-block must flush BEFORE we read sha.Hash; CryptoStream does this on dispose,
// but we need the bytes BEFORE the CountingStream/blobStream dispose. Manual flush:
crypto.FlushFinalBlock();
sha256Hex = Convert.ToHexString(sha.Hash ?? Array.Empty<byte>());
byteSize = counting.BytesWritten;
}
catch (OperationCanceledException)
{
// Renewal-loss / external cancel — never swallow into a per-table Failed.
throw;
}
catch (BudgetExceededException ex)
{
_logger.LogWarning(ex, "CriticalTableBackupService: budget exceeded during {Table}", tableName);
entry.Status = TableBackupStatus.Skipped;
entry.ErrorMessage = ex.Message;
return entry;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "CriticalTableBackupService: table {Table} dump failed", tableName);
entry.Status = TableBackupStatus.Failed;
entry.ErrorMessage = ex.Message;
return entry;
}
entry.RowCount = rows;
entry.ByteSize = byteSize;
entry.Sha256Hex = sha256Hex;
entry.Status = rows == 0 ? TableBackupStatus.Empty : TableBackupStatus.Ok;
return entry;
}
private static readonly byte[] NewlineBytes = Encoding.UTF8.GetBytes("\n");
// ── Counting stream wrapper ─────────────────────────────────────────────
/// <summary>
/// Tracks the byte-count of writes that flow through it. Wraps the blob stream so
/// the service can stamp ByteSize on the manifest entry without re-reading the blob.
/// </summary>
private sealed class CountingStream : Stream
{
private readonly Stream _inner;
private long _bytesWritten;
public CountingStream(Stream inner) { _inner = inner; }
public long BytesWritten => _bytesWritten;
public override bool CanRead => false;
public override bool CanSeek => false;
public override bool CanWrite => true;
public override long Length => _bytesWritten;
public override long Position { get => _bytesWritten; set => throw new NotSupportedException(); }
public override void Flush() => _inner.Flush();
public override Task FlushAsync(CancellationToken ct) => _inner.FlushAsync(ct);
public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count)
{
_inner.Write(buffer, offset, count);
_bytesWritten += count;
}
public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken ct)
{
await _inner.WriteAsync(buffer.AsMemory(offset, count), ct).ConfigureAwait(false);
_bytesWritten += count;
}
public override async ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken ct = default)
{
await _inner.WriteAsync(buffer, ct).ConfigureAwait(false);
_bytesWritten += buffer.Length;
}
protected override void Dispose(bool disposing)
{
// Don't dispose _inner — the outer await using on the blob stream owns disposal.
base.Dispose(disposing);
}
}
}
/// <summary>
/// Thrown when the per-run wall-clock budget is exceeded mid-loop. Routed to
/// <see cref="TableBackupStatus.Skipped"/> by its own catch — distinct from generic
/// failures so the Outcome math (Partial) and operator action items stay clear.
/// </summary>
public sealed class BudgetExceededException : Exception
{
public BudgetExceededException(string message) : base(message) { }
}
}
@@ -0,0 +1,39 @@
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Shared.Models.Backup;
namespace AutopilotMonitor.Functions.Services.Backup
{
/// <summary>
/// Backup orchestrator contract. The lease is ALWAYS owned by the caller (worker
/// or timer); the service is intentionally lease-unaware so it can be tested in
/// isolation. <see cref="GenerateBackupId"/> is split out so the caller can stamp
/// the id onto <c>BackupJobStatus</c> before the heavy work begins (UI deep-link).
/// </summary>
public interface ICriticalTableBackupService
{
/// <summary>Pure id generator — no side effects. Format <c>yyyyMMddTHHmmssZ_{guid8}</c>.</summary>
string GenerateBackupId();
/// <summary>
/// Runs the full per-table backup loop under an externally-held maintenance lease.
/// Returns a populated <see cref="BackupRunResult"/>; per-table errors are folded
/// into the result with <see cref="BackupOutcome.Partial"/>. Fatal failures
/// (manifest write throws, stream infra dies) propagate to the caller.
/// </summary>
Task<BackupRunResult> RunBackupUnderLeaseAsync(string backupId, string triggeredBy, CancellationToken ct = default);
}
/// <summary>
/// Outcome of a single <c>RunBackupUnderLeaseAsync</c> call. The worker maps
/// <see cref="BackupOutcome.Success"/>/<see cref="BackupOutcome.Partial"/> onto the
/// matching JobStatus + OpsEvent; a thrown exception maps to JobState=Failed without
/// a result object.
/// </summary>
public sealed class BackupRunResult
{
public BackupOutcome Outcome { get; set; }
public CriticalTableBackupManifest Manifest { get; set; } = new();
public string ManifestBlobName { get; set; } = string.Empty;
}
}
@@ -0,0 +1,93 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Shared;
using Azure.Identity;
using Azure.Storage.Queues;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace AutopilotMonitor.Functions.Services.Backup.Queue
{
/// <summary>
/// Azure-Storage-Queue producer for the critical-table backup feature.
/// Mirrors <c>AzureQueueAnalyzeOnEnrollmentEndProducer</c>'s connection setup
/// (Managed Identity preferred, connection string fallback) but flips the failure
/// semantics: SendMessageAsync errors propagate so the HTTP trigger can mark
/// the job Failed + return 5xx instead of leaving the operator with a 202 and
/// no actual work scheduled.
/// </summary>
public sealed class AzureQueueCriticalTableBackupProducer : ICriticalTableBackupProducer
{
private readonly QueueClient _queueClient;
private readonly ILogger<AzureQueueCriticalTableBackupProducer> _logger;
private int _queueEnsured;
public AzureQueueCriticalTableBackupProducer(
IConfiguration configuration,
ILogger<AzureQueueCriticalTableBackupProducer> logger)
{
_logger = logger;
var options = new QueueClientOptions
{
MessageEncoding = QueueMessageEncoding.Base64,
};
var storageAccountName = configuration["AzureStorageAccountName"];
var connectionString = configuration["AzureTableStorageConnectionString"];
if (!string.IsNullOrEmpty(storageAccountName))
{
var queueUri = new Uri(
$"https://{storageAccountName}.queue.core.windows.net/{Constants.QueueNames.CriticalTableBackup}");
_queueClient = new QueueClient(queueUri, new DefaultAzureCredential(), options);
_logger.LogInformation(
"CriticalTableBackup producer initialized with Managed Identity (account: {Account})",
storageAccountName);
}
else if (!string.IsNullOrEmpty(connectionString))
{
_queueClient = new QueueClient(
connectionString, Constants.QueueNames.CriticalTableBackup, options);
_logger.LogInformation("CriticalTableBackup producer initialized with connection string");
}
else
{
throw new InvalidOperationException(
"Queue Storage not configured. Set either 'AzureStorageAccountName' or 'AzureTableStorageConnectionString'.");
}
}
public async Task EnqueueAsync(CriticalTableBackupEnvelope envelope, CancellationToken ct = default)
{
if (envelope == null) throw new ArgumentNullException(nameof(envelope));
if (string.IsNullOrEmpty(envelope.JobId)) throw new ArgumentException("JobId required", nameof(envelope));
await EnsureQueueExistsAsync(ct).ConfigureAwait(false);
// Fail-hard: do NOT swallow. Caller (HTTP trigger) catches and rolls the
// BackupJobStatus row to Failed + returns 500.
var body = JsonConvert.SerializeObject(envelope);
await _queueClient.SendMessageAsync(body, ct).ConfigureAwait(false);
_logger.LogInformation("CriticalTableBackup enqueued (jobId={JobId})", envelope.JobId);
}
private async Task EnsureQueueExistsAsync(CancellationToken ct)
{
if (_queueEnsured == 1) return;
try
{
await _queueClient.CreateIfNotExistsAsync(cancellationToken: ct).ConfigureAwait(false);
Interlocked.Exchange(ref _queueEnsured, 1);
}
catch (Exception ex)
{
// Ensure failure during enqueue is itself a fail-hard signal — surface to the caller.
_logger.LogError(ex, "CriticalTableBackup queue CreateIfNotExists failed");
throw;
}
}
}
}
@@ -0,0 +1,438 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models.Backup;
using Azure;
using Azure.Identity;
using Azure.Storage.Blobs.Specialized;
using Azure.Storage.Queues;
using Azure.Storage.Queues.Models;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace AutopilotMonitor.Functions.Services.Backup.Queue
{
/// <summary>
/// Background worker for the <c>critical-table-backup-jobs</c> queue. Drains messages,
/// holds the maintenance lease for the duration of the per-job work, and runs
/// <see cref="ICriticalTableBackupService.RunBackupUnderLeaseAsync"/> with the lease held.
/// <para>
/// <b>State-machine boundary (plan Wave17/18/19/22/23):</b> the WORKER owns the lease
/// and JobStatus; the SERVICE is intentionally lease-unaware and stateless. State
/// transitions follow this normative order: Duplicate-Detection → Acquire-Lease →
/// CAS Queued→Running → Run (under lease) → CAS Running→Completed (under lease) →
/// Release Lease → SafeDelete. Service-throw of a retryable exception triggers
/// CAS Running→Queued (so the next dequeue sees Queued, not Running+free-lease) plus
/// rethrow so the outer poll-loop applies VisibilityTimeout retry semantics.
/// </para>
/// <para>
/// <b>Queue-config differs from short-lived workers</b> (plan Wave6): BatchSize=1,
/// VisibilityTimeout=60min. The per-run service budget is 50min so the 60min visibility
/// gives a 10min cushion — no explicit renewal loop is needed in PR1 (deferred as a
/// hardening follow-up; Renewal-Loop adds ~150 LOC of race-handling that is only
/// load-bearing for runs &gt; 50min).
/// </para>
/// </summary>
public sealed class CriticalTableBackupQueueWorker : BackgroundService
{
internal const int BatchSize = 1;
internal static readonly TimeSpan VisibilityTimeout = TimeSpan.FromMinutes(60);
internal static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(10);
internal static readonly TimeSpan ErrorBackoff = TimeSpan.FromSeconds(30);
internal const int MaxDequeueCount = 5;
private const string PoisonQueueSuffix = "-poison";
private readonly QueueClient _mainQueue;
private readonly QueueClient _poisonQueue;
private readonly ICriticalTableBackupService _backupService;
private readonly BlobBackupStore _blobStore;
private readonly BackupJobsRepository _jobs;
private readonly OpsEventService _opsEvents;
private readonly ILogger<CriticalTableBackupQueueWorker> _logger;
private readonly TimeProvider _clock;
public CriticalTableBackupQueueWorker(
IConfiguration configuration,
ICriticalTableBackupService backupService,
BlobBackupStore blobStore,
BackupJobsRepository jobs,
OpsEventService opsEvents,
ILogger<CriticalTableBackupQueueWorker> logger,
TimeProvider? clock = null)
{
_backupService = backupService ?? throw new ArgumentNullException(nameof(backupService));
_blobStore = blobStore ?? throw new ArgumentNullException(nameof(blobStore));
_jobs = jobs ?? throw new ArgumentNullException(nameof(jobs));
_opsEvents = opsEvents ?? throw new ArgumentNullException(nameof(opsEvents));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_clock = clock ?? TimeProvider.System;
var options = new QueueClientOptions { MessageEncoding = QueueMessageEncoding.Base64 };
var storageAccountName = configuration["AzureStorageAccountName"];
var connectionString = configuration["AzureTableStorageConnectionString"];
if (!string.IsNullOrEmpty(storageAccountName))
{
var mainUri = new Uri($"https://{storageAccountName}.queue.core.windows.net/{Constants.QueueNames.CriticalTableBackup}");
var poisonUri = new Uri($"https://{storageAccountName}.queue.core.windows.net/{Constants.QueueNames.CriticalTableBackup}{PoisonQueueSuffix}");
var credential = new DefaultAzureCredential();
_mainQueue = new QueueClient(mainUri, credential, options);
_poisonQueue = new QueueClient(poisonUri, credential, options);
_logger.LogInformation(
"CriticalTableBackupQueueWorker initialized with Managed Identity (account: {Account})",
storageAccountName);
}
else if (!string.IsNullOrEmpty(connectionString))
{
_mainQueue = new QueueClient(connectionString, Constants.QueueNames.CriticalTableBackup, options);
_poisonQueue = new QueueClient(connectionString, Constants.QueueNames.CriticalTableBackup + PoisonQueueSuffix, options);
_logger.LogInformation("CriticalTableBackupQueueWorker initialized with connection string");
}
else
{
throw new InvalidOperationException(
"Queue Storage not configured. Set either 'AzureStorageAccountName' or 'AzureTableStorageConnectionString'.");
}
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await TryCreateQueueAsync(_mainQueue, "main", stoppingToken).ConfigureAwait(false);
await TryCreateQueueAsync(_poisonQueue, "poison", stoppingToken).ConfigureAwait(false);
_logger.LogInformation("CriticalTableBackupQueueWorker: poll loop started");
while (!stoppingToken.IsCancellationRequested)
{
try
{
var batch = await _mainQueue
.ReceiveMessagesAsync(BatchSize, VisibilityTimeout, stoppingToken)
.ConfigureAwait(false);
if (batch?.Value is null || batch.Value.Length == 0)
{
await Task.Delay(PollInterval, stoppingToken).ConfigureAwait(false);
continue;
}
foreach (var msg in batch.Value)
{
if (stoppingToken.IsCancellationRequested) break;
await ProcessOneAsync(msg, stoppingToken).ConfigureAwait(false);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "CriticalTableBackupQueueWorker: poll-loop error — backing off {Backoff}", ErrorBackoff);
try { await Task.Delay(ErrorBackoff, stoppingToken).ConfigureAwait(false); }
catch (OperationCanceledException) { break; }
}
}
_logger.LogInformation("CriticalTableBackupQueueWorker: poll loop stopped");
}
private async Task ProcessOneAsync(QueueMessage msg, CancellationToken ct)
{
if (msg.DequeueCount > MaxDequeueCount)
{
await MoveToPoisonAsync(msg, ct).ConfigureAwait(false);
return;
}
CriticalTableBackupEnvelope? envelope;
try
{
envelope = JsonConvert.DeserializeObject<CriticalTableBackupEnvelope>(msg.Body.ToString());
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "CriticalTableBackupQueueWorker: malformed envelope — dropping (msg {Id})", msg.MessageId);
await SafeDeleteAsync(msg, ct).ConfigureAwait(false);
return;
}
if (envelope is null || string.IsNullOrEmpty(envelope.JobId))
{
_logger.LogWarning("CriticalTableBackupQueueWorker: empty envelope — dropping (msg {Id})", msg.MessageId);
await SafeDeleteAsync(msg, ct).ConfigureAwait(false);
return;
}
await ProcessJobAsync(envelope.JobId, msg, ct).ConfigureAwait(false);
}
private async Task ProcessJobAsync(string jobId, QueueMessage msg, CancellationToken ct)
{
// ── Duplicate-Detection (plan Wave8 #2 + Wave9 #1) ──────────────────
var (job, jobETag) = await _jobs.GetWithETagAsync(jobId, ct).ConfigureAwait(false);
if (job is null)
{
_logger.LogWarning(
"CriticalTableBackupQueueWorker: jobId {JobId} not in BackupJobs — dropping orphan message {Id}",
jobId, msg.MessageId);
await SafeDeleteAsync(msg, ct).ConfigureAwait(false);
return;
}
if (IsTerminal(job.State))
{
_logger.LogInformation(
"CriticalTableBackupQueueWorker: jobId {JobId} already terminal ({State}) — dropping reappearance",
jobId, job.State);
await SafeDeleteAsync(msg, ct).ConfigureAwait(false);
return;
}
// Running on dequeue is rare but possible (timer race, prior worker crashed
// post-CAS pre-release). Defer-visibility instead of clobbering a possibly
// still-running peer's status (plan Wave9 #1 / Wave10 #4).
if (job.State == BackupJobState.Running)
{
_logger.LogWarning(
"CriticalTableBackupQueueWorker: jobId {JobId} state=Running on dequeue — deferring visibility, not mutating status",
jobId);
try
{
await _mainQueue.UpdateMessageAsync(msg.MessageId, msg.PopReceipt, visibilityTimeout: VisibilityTimeout, cancellationToken: ct).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"CriticalTableBackupQueueWorker: defer-visibility failed for jobId {JobId} — message will reappear naturally",
jobId);
}
return;
}
// ── Acquire maintenance lease BEFORE CAS Running ────────────────────
BlobLeaseClient? lease = null;
try
{
lease = await _blobStore.AcquireMaintenanceLeaseAsync(BlobBackupStore.MaintenanceLeaseDuration, ct).ConfigureAwait(false);
}
catch (LeaseHeldException)
{
_logger.LogInformation(
"CriticalTableBackupQueueWorker: maintenance lease held by another op — jobId {JobId} → Skipped",
jobId);
var skippedJob = job;
skippedJob.State = BackupJobState.Skipped;
skippedJob.LastHeartbeatUtc = _clock.GetUtcNow().UtcDateTime;
skippedJob.CompletedAtUtc = _clock.GetUtcNow().UtcDateTime;
skippedJob.Error = "maintenance lease held by another operation";
await _jobs.TryUpdateWithCasAsync(skippedJob, jobETag!.Value, ct).ConfigureAwait(false);
await TryRecordOpsEventAsync(() => _opsEvents.RecordCriticalTableBackupSkippedLockedAsync(skippedJob.Error, skippedJob.RequestedBy)).ConfigureAwait(false);
await SafeDeleteAsync(msg, ct).ConfigureAwait(false);
return;
}
try
{
// ── CAS Queued→Running + heartbeat (plan Wave14 #1) ─────────────
var nowUtc = _clock.GetUtcNow().UtcDateTime;
var runningJob = job;
runningJob.State = BackupJobState.Running;
runningJob.StartedAtUtc = nowUtc;
runningJob.LastHeartbeatUtc = nowUtc;
var casOk = await _jobs.TryUpdateWithCasAsync(runningJob, jobETag!.Value, ct).ConfigureAwait(false);
if (!casOk)
{
// Concurrent state change (watchdog, parallel worker). Re-read + dispatch.
var (fresh, _) = await _jobs.GetWithETagAsync(jobId, ct).ConfigureAwait(false);
if (fresh is not null && IsTerminal(fresh.State))
{
await SafeDeleteAsync(msg, ct).ConfigureAwait(false);
}
else
{
_logger.LogWarning("CriticalTableBackupQueueWorker: CAS Queued→Running 412 with non-terminal fresh state — leaving message for retry");
}
return;
}
// ── Service-call: backup itself (Wave22 #3 — service is lease-unaware) ──
BackupRunResult? result = null;
Exception? retryableException = null;
try
{
var backupId = _backupService.GenerateBackupId();
// Stamp BackupId via CAS so UI can deep-link to the manifest the moment the worker has chosen the id.
var (currentJob, currentETag) = await _jobs.GetWithETagAsync(jobId, ct).ConfigureAwait(false);
if (currentJob is not null && currentETag is not null)
{
currentJob.BackupId = backupId;
currentJob.LastHeartbeatUtc = _clock.GetUtcNow().UtcDateTime;
await _jobs.TryUpdateWithCasAsync(currentJob, currentETag.Value, ct).ConfigureAwait(false);
}
result = await _backupService.RunBackupUnderLeaseAsync(backupId, runningJob.RequestedBy, ct).ConfigureAwait(false);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
// Retryable: storage 5xx / network / generic bug. Roll JobState back to Queued
// so the next dequeue sees a clean state (plan Wave25 #1+#2). Throw to outer
// so visibility-timeout-retry kicks in. After 5 attempts the poison path
// converts to JobState=Failed.
retryableException = ex;
}
if (retryableException is not null)
{
var (rb, rbETag) = await _jobs.GetWithETagAsync(jobId, ct).ConfigureAwait(false);
if (rb is not null && rbETag is not null)
{
rb.State = BackupJobState.Queued;
rb.BackupId = null; // next attempt gets a fresh id (orphan blobs are cleaned by lifecycle)
rb.LastHeartbeatUtc = _clock.GetUtcNow().UtcDateTime;
rb.Error = retryableException.Message;
await _jobs.TryUpdateWithCasAsync(rb, rbETag.Value, ct).ConfigureAwait(false);
}
_logger.LogWarning(retryableException,
"CriticalTableBackupQueueWorker: jobId {JobId} backup attempt failed — rolling to Queued for retry (dequeue {N})",
jobId, msg.DequeueCount);
throw retryableException; // outer loop applies visibility-timeout retry
}
// ── Terminal CAS Completed (under lease) ────────────────────────
var completedAt = _clock.GetUtcNow().UtcDateTime;
var (finalJob, finalETag) = await _jobs.GetWithETagAsync(jobId, ct).ConfigureAwait(false);
if (finalJob is not null && finalETag is not null)
{
finalJob.State = BackupJobState.Completed;
finalJob.CompletedAtUtc = completedAt;
finalJob.LastHeartbeatUtc = completedAt;
finalJob.BackupOutcome = result!.Outcome;
finalJob.Error = null;
await _jobs.TryUpdateWithCasAsync(finalJob, finalETag.Value, ct).ConfigureAwait(false);
}
// ── OpsEvent (outside the service try — non-throwing per Wave25 #3) ──
var durationMs = (int)(completedAt - runningJob.StartedAtUtc!.Value).TotalMilliseconds;
var failedOrSkipped = 0;
foreach (var t in result!.Manifest.Tables)
{
if (t.Status == TableBackupStatus.Failed || t.Status == TableBackupStatus.Skipped) failedOrSkipped++;
}
await TryRecordOpsEventAsync(() => result.Outcome == BackupOutcome.Success
? _opsEvents.RecordCriticalTableBackupCompletedAsync(
result.Manifest.BackupId, result.Manifest.Tables.Count, durationMs,
Constants.BlobContainers.CriticalTableBackups, result.ManifestBlobName, runningJob.RequestedBy)
: _opsEvents.RecordCriticalTableBackupPartialAsync(
result.Manifest.BackupId, result.Manifest.Tables.Count, failedOrSkipped, durationMs,
Constants.BlobContainers.CriticalTableBackups, result.ManifestBlobName, runningJob.RequestedBy))
.ConfigureAwait(false);
}
finally
{
// Plan Wave16 #2 / Wave17 #1: terminal status set BEFORE release; release before delete.
if (lease is not null)
{
try { await lease.ReleaseAsync(cancellationToken: ct).ConfigureAwait(false); }
catch (Exception ex) { _logger.LogWarning(ex, "CriticalTableBackupQueueWorker: lease release failed (auto-expires)"); }
}
}
// Successful path: delete the message.
await SafeDeleteAsync(msg, ct).ConfigureAwait(false);
}
// ── Helpers ─────────────────────────────────────────────────────────────
private async Task MoveToPoisonAsync(QueueMessage msg, CancellationToken ct)
{
// Plan Wave5 #1 + Wave22 #2: persist Failed BEFORE poison-move so the job
// status never hangs in Running while the message is already poison.
try
{
var envelope = JsonConvert.DeserializeObject<CriticalTableBackupEnvelope>(msg.Body.ToString());
if (envelope is not null && !string.IsNullOrEmpty(envelope.JobId))
{
var (job, etag) = await _jobs.GetWithETagAsync(envelope.JobId, ct).ConfigureAwait(false);
if (job is not null && etag is not null && !IsTerminal(job.State))
{
job.State = BackupJobState.Failed;
job.CompletedAtUtc = _clock.GetUtcNow().UtcDateTime;
job.LastHeartbeatUtc = job.CompletedAtUtc.Value;
job.Error = $"poison-move after {msg.DequeueCount - 1} failed attempts";
await _jobs.TryUpdateWithCasAsync(job, etag.Value, ct).ConfigureAwait(false);
await TryRecordOpsEventAsync(() => _opsEvents.RecordCriticalTableBackupFailedAsync(job.BackupId, job.Error!, job.RequestedBy)).ConfigureAwait(false);
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "CriticalTableBackupQueueWorker: failed-state persistence before poison-move threw — proceeding with poison move anyway");
}
try
{
await _poisonQueue.SendMessageAsync(msg.Body.ToString(), ct).ConfigureAwait(false);
await SafeDeleteAsync(msg, ct).ConfigureAwait(false);
_logger.LogWarning(
"CriticalTableBackupQueueWorker: moved message {Id} to poison queue after {N} failed attempts",
msg.MessageId, msg.DequeueCount - 1);
}
catch (Exception ex)
{
_logger.LogError(ex, "CriticalTableBackupQueueWorker: poison move failed (will retry)");
}
}
private async Task SafeDeleteAsync(QueueMessage msg, CancellationToken ct)
{
try
{
await _mainQueue.DeleteMessageAsync(msg.MessageId, msg.PopReceipt, ct).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"CriticalTableBackupQueueWorker: delete failed for message {Id} — will reappear after visibility-timeout",
msg.MessageId);
}
}
private async Task TryCreateQueueAsync(QueueClient queue, string label, CancellationToken ct)
{
try { await queue.CreateIfNotExistsAsync(cancellationToken: ct).ConfigureAwait(false); }
catch (Exception ex)
{
_logger.LogWarning(ex,
"CriticalTableBackupQueueWorker: CreateIfNotExists failed for {Label} queue — send/receive will retry",
label);
}
}
/// <summary>OpsEvent write must not bubble — backup itself already succeeded (or failed by other means).</summary>
private async Task TryRecordOpsEventAsync(Func<Task> writeAsync)
{
try { await writeAsync().ConfigureAwait(false); }
catch (Exception ex)
{
_logger.LogWarning(ex, "CriticalTableBackupQueueWorker: ops-event recording failed — backup state remains authoritative");
}
}
private static bool IsTerminal(BackupJobState state)
=> state == BackupJobState.Completed
|| state == BackupJobState.Failed
|| state == BackupJobState.Skipped
|| state == BackupJobState.BlockedTerminal;
}
}
@@ -0,0 +1,26 @@
using System.Threading;
using System.Threading.Tasks;
namespace AutopilotMonitor.Functions.Services.Backup.Queue
{
/// <summary>
/// Envelope for a single backup-queue message. Tiny on purpose — all state lives
/// in <c>BackupJobs</c>, the worker re-reads it on dequeue.
/// </summary>
public sealed class CriticalTableBackupEnvelope
{
public string JobId { get; set; } = string.Empty;
}
/// <summary>
/// Producer for the <c>critical-table-backup-jobs</c> queue. <b>Fail-hard</b>:
/// SendMessageAsync exceptions propagate so the HTTP trigger can roll the
/// BackupJobStatus row forward to Failed and return 5xx — silent enqueue loss
/// would leave the job stuck in Queued forever (no Manual-Analyze-Now-style
/// fallback exists here).
/// </summary>
public interface ICriticalTableBackupProducer
{
Task EnqueueAsync(CriticalTableBackupEnvelope envelope, CancellationToken ct = default);
}
}
@@ -34,6 +34,7 @@ namespace AutopilotMonitor.Functions.Services
Constants.QueueNames.AnalyzeOnEnrollmentEnd + "-poison",
Constants.QueueNames.VulnerabilityCorrelate + "-poison",
Constants.QueueNames.TelemetryIndexReconcile + "-poison",
Constants.QueueNames.CriticalTableBackupPoison,
};
/// <summary>Default warning threshold — every poison message is a 5x-failed handler call.</summary>
@@ -254,6 +254,36 @@ namespace AutopilotMonitor.Functions.Services
null, "System.Maintenance",
new { queueName, count, threshold });
// ── Critical-Table Backup ─────────────────────────────────────────────
/// <summary>Backup run finished with all tables successfully captured. Info-level — visible in the timeline, not alertable by default.</summary>
public Task RecordCriticalTableBackupCompletedAsync(string backupId, int tableCount, int durationMs, string container, string manifestBlobName, string triggeredBy)
=> WriteAsync(OpsEventCategory.Maintenance, "CriticalTableBackupCompleted", OpsEventSeverity.Info,
$"Critical-table backup {backupId} completed: {tableCount} tables, {durationMs}ms (triggeredBy={triggeredBy})",
null, "System.Maintenance",
new { backupId, tableCount, durationMs, container, manifestBlobName, triggeredBy });
/// <summary>Backup run wrote a manifest but at least one table Failed or Skipped. Warning-level — operator should inspect manifest perTableFailures.</summary>
public Task RecordCriticalTableBackupPartialAsync(string backupId, int totalTables, int failedOrSkipped, int durationMs, string container, string manifestBlobName, string triggeredBy)
=> WriteAsync(OpsEventCategory.Maintenance, "CriticalTableBackupPartial", OpsEventSeverity.Warning,
$"Critical-table backup {backupId} PARTIAL: {failedOrSkipped}/{totalTables} tables failed or skipped, manifest written ({durationMs}ms, triggeredBy={triggeredBy})",
null, "System.Maintenance",
new { backupId, totalTables, failedOrSkipped, durationMs, container, manifestBlobName, triggeredBy });
/// <summary>Backup run never produced a valid manifest (fatal exception, storage outage). Error-level. Queue-path emits this AFTER 5x retry + poison-move; timer emits immediately.</summary>
public Task RecordCriticalTableBackupFailedAsync(string? backupId, string errorMessage, string triggeredBy)
=> WriteAsync(OpsEventCategory.Maintenance, "CriticalTableBackupFailed", OpsEventSeverity.Error,
$"Critical-table backup FAILED (backupId={backupId ?? "n/a"}, triggeredBy={triggeredBy}): {errorMessage}",
null, "System.Maintenance",
new { backupId, errorMessage, triggeredBy });
/// <summary>Backup or restore was skipped because the maintenance lease was already held by another job. Info-level — not a failure.</summary>
public Task RecordCriticalTableBackupSkippedLockedAsync(string reason, string triggeredBy)
=> WriteAsync(OpsEventCategory.Maintenance, "CriticalTableBackupSkippedLocked", OpsEventSeverity.Info,
$"Critical-table backup skipped — {reason} (triggeredBy={triggeredBy})",
null, "System.Maintenance",
new { reason, triggeredBy });
// ── Tenant ─────────────────────────────────────────────────────────────
/// <summary>
@@ -0,0 +1,37 @@
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Azure.Data.Tables;
namespace AutopilotMonitor.Functions.Services
{
/// <summary>
/// Critical-table backup primitives on <see cref="TableStorageService"/> (plan §PR1).
/// </summary>
public partial class TableStorageService
{
/// <summary>
/// Streams every row of <paramref name="tableName"/> as a generic
/// <see cref="TableEntity"/>. Used by <c>CriticalTableBackupService</c> to feed
/// the per-table NDJSON dump without loading the whole table into memory.
/// Fail-loud per <c>feedback_storage_helpers_fail_soft</c>: storage errors
/// propagate so the caller can decide whether to mark the table Failed and
/// continue or to bubble.
/// </summary>
public virtual async IAsyncEnumerable<TableEntity> EnumerateAllAsync(
string tableName,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(tableName))
throw new ArgumentException("tableName is required", nameof(tableName));
var tableClient = _tableServiceClient.GetTableClient(tableName);
await foreach (var entity in tableClient.QueryAsync<TableEntity>(cancellationToken: cancellationToken).ConfigureAwait(false))
{
yield return entity;
}
}
}
}
@@ -215,6 +215,12 @@ namespace AutopilotMonitor.Shared
// Pre-auth distress channel (no authentication required)
public const string ReportDistress = "/api/agent/distress";
// Critical-table backup feature (plan §PR1+, all GA-only)
public const string GlobalBackupsTrigger = "/api/global/backups/trigger"; // POST → 202 + jobId
public const string GlobalBackupsList = "/api/global/backups"; // GET → list of backups
public const string GlobalBackupsManifest = "/api/global/backups/{backupId}"; // GET → manifest detail
public const string GlobalBackupsJobStatus = "/api/global/backups/jobs/{jobId}"; // GET → BackupJobStatus
}
// -----------------------------------------------------------------------
@@ -670,6 +676,17 @@ namespace AutopilotMonitor.Shared
/// the blob in the customer's own storage and never touches this container.
/// </summary>
public const string HostedDiagnostics = "diagnostics";
/// <summary>
/// Critical-table backup destination. Daily timer + manual GA trigger write
/// per-table NDJSON dumps under <c>{backupId}/{tableName}.ndjson</c> plus a
/// final <c>{backupId}/manifest.json</c>. Maintenance lease sentinel lives
/// at <c>_lock/maintenance.lock</c> in the same container — coordinates
/// backup + restore jobs across worker/timer/single-row paths.
/// Lifecycle: 90-day delete on prefix <c>critical-table-backups/</c>; account
/// settings (Soft-Delete + Versioning) provide defence-in-depth.
/// </summary>
public const string CriticalTableBackups = "critical-table-backups";
}
/// <summary>
@@ -729,6 +746,73 @@ namespace AutopilotMonitor.Shared
/// <summary>Poison sibling of <see cref="TenantOffboarding"/>.</summary>
public const string TenantOffboardingPoison = "tenant-offboarding-poison";
/// <summary>
/// Critical-table backup job queue. Producer = HTTP trigger
/// <c>/api/global/backups/trigger</c> (fail-hard); consumer =
/// <c>CriticalTableBackupQueueWorker</c> (BackgroundService, BatchSize=1,
/// VisibilityTimeout=60min with 25min PopReceipt-renewal). Carries a small
/// envelope <c>{ jobId }</c> — full state lives in <see cref="TableNames.BackupJobs"/>.
/// Poison-suffix <c>-poison</c>, max-dequeue 5; Failed-state is persisted on
/// Poison-Move (NOT on first throw — that would defeat retry).
/// </summary>
public const string CriticalTableBackup = "critical-table-backup-jobs";
/// <summary>Poison sibling of <see cref="CriticalTableBackup"/>.</summary>
public const string CriticalTableBackupPoison = "critical-table-backup-jobs-poison";
}
/// <summary>
/// Critical tables that the daily backup feature snapshots. Order matters only for
/// deterministic test output; the per-table loop is independent so a failure in
/// one table does NOT block the others (Outcome=Partial covers that case).
/// Three of these (<c>GlobalAdmins</c>, <c>TenantAdmins</c>, <c>McpUsers</c>)
/// are gated to single-row restore in the API layer — full-table restore would
/// reactivate disabled or re-create removed identities.
/// <c>OffboardingAudit</c> additionally blocks replace-all (append-only audit).
/// </summary>
public static class CriticalBackupTables
{
public static readonly string[] All = new[]
{
TableNames.AdminConfiguration,
TableNames.AnalyzeRules,
TableNames.Feedback,
TableNames.GatherRules,
TableNames.GlobalAdmins,
TableNames.ImeLogPatterns,
TableNames.ImeVersionHistory,
TableNames.McpUsers,
TableNames.OffboardingAudit,
TableNames.PreviewConfig,
TableNames.PreviewWhitelist,
TableNames.RuleStates,
TableNames.TenantAdmins,
TableNames.TenantConfiguration,
TableNames.TenantOffboardingCustomsArchive,
};
/// <summary>
/// Full-table restore is API-blocked on these — the rows carry IsEnabled
/// semantics whose accidental re-activation is a security incident.
/// Single-row restore (with mandatory diff preview) remains allowed.
/// </summary>
public static readonly string[] AuthTablesFullRestoreForbidden = new[]
{
TableNames.GlobalAdmins,
TableNames.TenantAdmins,
TableNames.McpUsers,
};
/// <summary>
/// replace-all strategy is API-blocked on these — append-only audit semantics
/// (orphan-deletion would erase audit history added after the backup).
/// upsert-only remains allowed.
/// </summary>
public static readonly string[] AuditTablesReplaceAllForbidden = new[]
{
TableNames.OffboardingAudit,
};
}
}
}
@@ -0,0 +1,86 @@
using System;
namespace AutopilotMonitor.Shared.Models.Backup
{
/// <summary>
/// Per-job state machine for asynchronous backup + restore work. Persisted as
/// <see cref="BackupJobStatusEntity"/> in <c>BackupJobs</c> (PartitionKey="BackupJobs",
/// RowKey={jobId}). The domain DTO carries strongly-typed enums; the Repository
/// layer maps from/to the string-typed Azure Table columns (Azure Tables has no
/// EDM enum). Unknown strings on read MUST throw — silent fallback is a regression
/// risk after enum refactorings.
/// </summary>
public sealed class BackupJobStatus
{
public string JobId { get; set; } = string.Empty;
public BackupJobKind Kind { get; set; }
public BackupJobState State { get; set; }
/// <summary>UPN for manual triggers; <c>"Timer"</c> never reaches this DTO — Timer skips the queue.</summary>
public string RequestedBy { get; set; } = string.Empty;
public DateTime QueuedAtUtc { get; set; }
public DateTime? StartedAtUtc { get; set; }
public DateTime? CompletedAtUtc { get; set; }
/// <summary>Updated by the renewal-loop and per-phase progress writes; drives the watchdog.</summary>
public DateTime LastHeartbeatUtc { get; set; }
/// <summary>
/// For Kind=Backup: the worker-generated id of the manifest this job produced
/// (null until the worker has acquired the lease and stamped it). UI uses this
/// to deep-link from a completed job to its manifest detail page.
/// </summary>
public string? BackupId { get; set; }
/// <summary>For Kind=RestoreTable: the manifest id the operator is restoring FROM.</summary>
public string? SourceBackupId { get; set; }
/// <summary>For Kind=RestoreTable: the table being restored.</summary>
public string? TableName { get; set; }
/// <summary>For Kind=RestoreTable: <c>upsert-only</c> | <c>replace-all</c>.</summary>
public string? Strategy { get; set; }
/// <summary>Free-form JSON for in-flight worker progress (phase, counters). Informational only — recovery does not depend on it.</summary>
public string? Progress { get; set; }
public string? Error { get; set; }
/// <summary>
/// For Kind=Backup with State=Completed: <c>Success</c> | <c>Partial</c>.
/// Null for any other (Kind, State) combination.
/// </summary>
public BackupOutcome? BackupOutcome { get; set; }
}
public enum BackupJobKind
{
Backup,
RestoreTable,
}
/// <summary>
/// Six-state job machine. Terminal states (Completed / Failed / Skipped / BlockedTerminal)
/// short-circuit the worker's duplicate-detection on reappearance: message is dropped, no
/// re-run. Failed vs BlockedTerminal vs Skipped is intentional so operator-action follow-up
/// can be distinguished (logs / re-trigger / nothing-to-do).
/// </summary>
public enum BackupJobState
{
Queued,
Running,
/// <summary>Manifest written (Backup) or restore phase succeeded (RestoreTable).</summary>
Completed,
/// <summary>Unexpected abort: storage 5xx / network / bug. After 5 retries → poison-move sets this.</summary>
Failed,
/// <summary>Maintenance-lease held by another job — no work performed. Operator retriggers when the parallel job is done.</summary>
Skipped,
/// <summary>Deterministic domain error: manifest missing, SHA mismatch, auth-table block, confirmation-token typo. Not retryable.</summary>
BlockedTerminal,
}
}
@@ -0,0 +1,33 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace AutopilotMonitor.Shared.Models.Backup
{
/// <summary>
/// Centralised JSON options for the critical-table backup feature: manifest write,
/// NDJSON-line serialisation, HTTP response bodies. Deliberately distinct from
/// <c>DeletionManifestJson.SerializerOptions</c> because the backup pipeline needs a
/// <see cref="JsonStringEnumConverter"/> with <c>allowIntegerValues=false</c> so
/// legacy or hand-crafted manifests with numeric enum values fail loudly instead of
/// silently mapping to the wrong member.
/// <para>
/// <b>DictionaryKeyPolicy is intentionally NOT set</b> — <c>DeletionRowDump.Props</c>
/// uses the original Azure Table column names (case-sensitive) as dictionary keys;
/// CamelCasing them would create a new column on restore.
/// </para>
/// </summary>
public static class BackupManifestJson
{
public static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
// DictionaryKeyPolicy intentionally unset (see remarks above).
Converters =
{
new JsonStringEnumConverter(namingPolicy: null, allowIntegerValues: false),
},
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false,
};
}
}
@@ -0,0 +1,79 @@
using System;
using System.Collections.Generic;
namespace AutopilotMonitor.Shared.Models.Backup
{
/// <summary>
/// Manifest written as the LAST blob in a backup run. Existence of
/// <c>{backupId}/manifest.json</c> is the durability anchor: orphan NDJSON
/// blobs under a prefix without a manifest are treated as <c>Incomplete</c>
/// by the listing API and never offered for restore.
/// <para>
/// Schema-version is intentionally bumpable but starts at 1; restore code
/// rejects unknown EDM types and unknown outcome strings with a clear error.
/// </para>
/// </summary>
public sealed class CriticalTableBackupManifest
{
public int SchemaVersion { get; set; } = 1;
/// <summary>Format <c>yyyyMMddTHHmmssZ_{guid8}</c>.</summary>
public string BackupId { get; set; } = string.Empty;
public DateTime StartedAtUtc { get; set; }
public DateTime CompletedAtUtc { get; set; }
/// <summary>UPN of the operator who manually triggered the job, or <c>"Timer"</c> for the daily cron.</summary>
public string TriggeredBy { get; set; } = "Timer";
/// <summary>Manifest-level outcome. NOT a JobState — see plan §JobStatus.</summary>
public BackupOutcome Outcome { get; set; }
public List<CriticalTableBackupTableEntry> Tables { get; set; } = new();
}
public sealed class CriticalTableBackupTableEntry
{
public string TableName { get; set; } = string.Empty;
public TableBackupStatus Status { get; set; }
public long RowCount { get; set; }
public long ByteSize { get; set; }
public string Sha256Hex { get; set; } = string.Empty;
/// <summary>Canonical: <c>{backupId}/{TableName}.ndjson</c>.</summary>
public string BlobName { get; set; } = string.Empty;
public string? ErrorMessage { get; set; }
}
/// <summary>
/// Manifest-level outcome. Job-level state (Failed for fatal errors) is tracked
/// separately on <c>BackupJobStatus</c>; a manifest only exists when the run
/// reached the "all tables attempted, manifest write succeeded" milestone.
/// </summary>
public enum BackupOutcome
{
/// <summary>All tables backed up successfully.</summary>
Success,
/// <summary>At least one table Failed or Skipped, but the manifest was still written
/// for the remaining tables. Operationally valuable — 14 of 15 tables are still restorable.</summary>
Partial,
}
/// <summary>Per-table outcome inside a manifest.</summary>
public enum TableBackupStatus
{
/// <summary>NDJSON blob written, SHA matched, RowCount &gt; 0.</summary>
Ok,
/// <summary>Table had 0 rows — empty NDJSON blob (and SHA-of-empty-string) is still committed for symmetry.</summary>
Empty,
/// <summary>Per-run budget exceeded before this table was attempted (or finished). Operator can retry next run.</summary>
Skipped,
/// <summary>An unexpected exception aborted this table's dump. Other tables continued.</summary>
Failed,
}
}
@@ -28,6 +28,15 @@ const OPS_EVENT_TYPES: Record<string, string[]> = {
// tenant admins see only the lifecycle endpoints (deletion_started/completed/restored).
// Operators wire Telegram on this event + read it in the Session Cleanup admin page.
"SessionDeletionPoisoned",
// Critical-table backup (plan §PR1) — dual-register per memory feedback_ops_event_types_dual_register.
// Dispatched by CriticalTableBackupQueueWorker + CriticalTableBackupTimerFunction.
// Backend helpers: RecordCriticalTableBackup{Completed,Partial,Failed,SkippedLocked}Async.
// Partial is a distinct type (Warning severity) so operators can wire a softer
// Telegram rule than for Failed (Error severity, fatal manifest-write failure).
"CriticalTableBackupCompleted",
"CriticalTableBackupPartial",
"CriticalTableBackupFailed",
"CriticalTableBackupSkippedLocked",
],
Security: [
"DeviceBlocked",