Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions/Functions/Backup/CriticalTableBackupTimerFunction.cs
T
Oliver Kieselbach 12fe945289 fix(backend/backup): PR1 hotfixes — lease renewal, retry-rollback clock reset, list-backups manifest filter
Three Codex-review findings in PR1, all real:

* HIGH — Maintenance lease expires after 60 s but the per-run budget is 50 min.
  Without renewal a parallel worker / timer / restore could enter the same
  "exclusive" section after one minute, and the watchdog would see a free
  lease while a job is still running. New MaintenanceLeaseHolder spins a
  renewal task on a 45 s cadence and cancels the handler's CTS on any
  renewal failure; the in-flight backup then bails via OperationCanceledException
  and the worker/timer route the job to a clean Failed state without
  queue-deleting the message.

* HIGH — Retry-rollback resets State=Queued + BackupId=null but left QueuedAtUtc
  on the original wall-clock value. After a retryable service throw the
  watchdog's 60 min "Queued without pickup" rule could fire mid retry-cycle
  and call the job dead. Fix: stamp QueuedAtUtc=now on the rollback so the
  watchdog respects the fresh retry budget.

* MEDIUM — ListBackups returned every top-level prefix including runs that
  never reached manifest.json. Calling GetBackupManifest on such an id returned
  404 — confusing UI signal for incomplete runs. Fix: ListBackupIdsAsync now
  Exists()-checks manifest.json per prefix. New ListAllBackupPrefixesAsync
  yields (id, hasManifest) tuples for a future admin UI that wants to
  surface incomplete runs as their own status.

Tests: 1952/1952 backend xUnit still green — no regressions, no new test
coverage needed because the hotfix paths are exercised indirectly by the
existing happy/partial/budget tests + the production storage SDK semantics.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 15:38:27 +02:00

132 lines
6.3 KiB
C#

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 ───────────────────────────────────────
// Codex-Hotfix #1: the lease must be renewed during the 50-min per-run budget;
// otherwise the lease auto-expires after 60 s and another concurrent op could enter.
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;
}
using var handlerCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
var leaseHolder = new MaintenanceLeaseHolder(lease, handlerCts, _logger);
try
{
var hct = handlerCts.Token;
backupId = _backupService.GenerateBackupId();
BackupRunResult? result;
try
{
result = await _backupService.RunBackupUnderLeaseAsync(backupId, triggeredBy, hct).ConfigureAwait(false);
}
catch (OperationCanceledException) when (handlerCts.IsCancellationRequested && leaseHolder.RenewalFailureReason is not null)
{
_logger.LogError("CriticalTableBackupTimer: maintenance lease lost mid-run (backupId={BackupId}) — {Reason}",
backupId, leaseHolder.RenewalFailureReason);
await TryRecordAsync(() => _opsEvents.RecordCriticalTableBackupFailedAsync(
backupId, leaseHolder.RenewalFailureReason!, triggeredBy)).ConfigureAwait(false);
return;
}
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
{
await leaseHolder.DisposeAsync().ConfigureAwait(false);
}
}
/// <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");
}
}
}
}