mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
12fe945289
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>
132 lines
6.3 KiB
C#
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");
|
|
}
|
|
}
|
|
}
|
|
}
|