diff --git a/.claude/commands b/.claude/commands
index acaf0040..95b417d3 160000
--- a/.claude/commands
+++ b/.claude/commands
@@ -1 +1 @@
-Subproject commit acaf00402e10e8f7e7d06020a736757434c14f10
+Subproject commit 95b417d3c70f7ec4cdf372e9432382f428ff71f7
diff --git a/internal b/internal
index a71f08b7..c5861a3e 160000
--- a/internal
+++ b/internal
@@ -1 +1 @@
-Subproject commit a71f08b7a1afd17a74146e0d1d4d3975879451e6
+Subproject commit c5861a3e1bf58aa128a2031d7c78a5a84b61f190
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AdminWireParityTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AdminWireParityTests.cs
index b488b06e..d0576a86 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/AdminWireParityTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/AdminWireParityTests.cs
@@ -447,41 +447,27 @@ public class AdminWireParityTests
// ---- TriggerMaintenance --------------------------------------------------------------
[Fact]
- public void TriggerMaintenanceResponse_matches_the_manual_trigger_shape()
+ public void TriggerMaintenanceResponse_matches_the_queued_trigger_shape()
{
- var result = new MaintenanceResult
- {
- Success = true,
- TriggeredBy = "admin@contoso.com",
- TriggeredAt = new DateTime(2026, 8, 30, 13, 10, 0, DateTimeKind.Utc),
- DurationMs = 1234,
- StalledSessionsChecked = true,
- MetricsAggregated = true,
- AggregatedDate = "2026-08-29",
- DataCleanupExecuted = false,
- PlatformStatsRecomputed = true,
- DevicesBlockedForExcessiveData = 0,
- ContactEmailsBackfilled = 2,
- };
var userEmail = "admin@contoso.com";
var triggeredAt = new DateTime(2026, 8, 30, 13, 10, 5, DateTimeKind.Utc);
AssertParity(
new
{
- success = true,
- message = "Maintenance tasks completed",
- result = result,
+ message = "Maintenance run queued",
triggeredBy = userEmail,
- triggeredAt
+ triggeredAt,
+ targetDate = "2026-08-29",
+ aggregateOnly = true
},
new TriggerMaintenanceResponse
{
- Success = true,
- Message = "Maintenance tasks completed",
- Result = result,
+ Message = "Maintenance run queued",
TriggeredBy = userEmail,
- TriggeredAt = triggeredAt
+ TriggeredAt = triggeredAt,
+ TargetDate = "2026-08-29",
+ AggregateOnly = true
});
}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/MaintenanceRunGateTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/MaintenanceRunGateTests.cs
new file mode 100644
index 00000000..34a16686
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/MaintenanceRunGateTests.cs
@@ -0,0 +1,137 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using AutopilotMonitor.Functions.Services;
+using AutopilotMonitor.Functions.Services.Backup;
+using AutopilotMonitor.Functions.Services.Maintenance;
+using AutopilotMonitor.Shared.DataAccess;
+using AutopilotMonitor.Shared.Models;
+using Azure.Storage.Blobs.Specialized;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// Single-flight of the platform maintenance run: the 2h timer and the manual trigger enter
+/// through one gate, so a second run is skipped instead of aggregating, cleaning up and
+/// backfilling the same tables in parallel — and a skip never looks like an active run.
+///
+public class MaintenanceRunGateTests
+{
+ private sealed class Harness
+ {
+ public Mock LockStore { get; }
+ public Mock Lease { get; } = new();
+ public List OpsEvents { get; } = new();
+ public MaintenanceRunGate Sut { get; }
+
+ public Harness()
+ {
+ LockStore = new Mock(new SessionDeletionMaintenanceFunctionTests.MaintenanceBlobStub());
+ LockStore.Setup(l => l.AcquireLeaseAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(Lease.Object);
+
+ var opsRepo = new Mock();
+ opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny()))
+ .Returns(e => { lock (OpsEvents) OpsEvents.Add(e.EventType); return Task.CompletedTask; });
+ var adminConfig = new Mock(
+ Mock.Of(), NullLogger.Instance, new MemoryCache(new MemoryCacheOptions()));
+ var opsService = new OpsEventService(opsRepo.Object, NullLogger.Instance,
+ TestNotifications.InertOpsAlertDispatch(adminConfig.Object));
+
+ Sut = new MaintenanceRunGate(LockStore.Object, opsService, NullLogger.Instance);
+ }
+
+ public void HoldLease() =>
+ LockStore.Setup(l => l.AcquireLeaseAsync(It.IsAny(), It.IsAny()))
+ .ThrowsAsync(new LeaseHeldException("held", new Exception("409")));
+ }
+
+ [Fact]
+ public async Task A_held_lease_skips_the_run_and_never_reports_it_as_started()
+ {
+ var h = new Harness();
+ h.HoldLease();
+ var ran = false;
+
+ var result = await h.Sut.RunExclusiveAsync("Timer", () => { ran = true; return Task.CompletedTask; });
+
+ Assert.False(result);
+ Assert.False(ran);
+ Assert.Equal(new[] { OpsEventTypes.MaintenanceSkippedLocked }, h.OpsEvents);
+ }
+
+ [Fact]
+ public async Task Started_is_written_under_the_lease_before_the_body_and_the_lease_is_released_last()
+ {
+ var h = new Harness();
+ var order = new List();
+ h.Lease.Setup(l => l.ReleaseAsync(It.IsAny(), It.IsAny()))
+ .Callback(() => order.Add("release"))
+ .ReturnsAsync(Mock.Of>());
+
+ var result = await h.Sut.RunExclusiveAsync("admin@example.com", () =>
+ {
+ order.Add($"body after [{string.Join(",", h.OpsEvents)}]");
+ return Task.CompletedTask;
+ });
+
+ Assert.True(result);
+ Assert.Equal(new[] { $"body after [{OpsEventTypes.MaintenanceStarted}]", "release" }, order);
+ }
+
+ [Fact]
+ public async Task The_lease_is_released_when_the_body_throws()
+ {
+ var h = new Harness();
+ var released = false;
+ h.Lease.Setup(l => l.ReleaseAsync(It.IsAny(), It.IsAny()))
+ .Callback(() => released = true)
+ .ReturnsAsync(Mock.Of>());
+
+ await Assert.ThrowsAsync(() =>
+ h.Sut.RunExclusiveAsync("Timer", () => throw new InvalidOperationException("boom")));
+
+ Assert.True(released);
+ }
+
+ [Fact]
+ public async Task Probe_reports_an_active_run_while_the_lease_is_held()
+ {
+ var h = new Harness();
+ h.HoldLease();
+
+ Assert.True(await h.Sut.IsRunActiveAsync());
+ Assert.Empty(h.OpsEvents);
+ }
+
+ [Fact]
+ public async Task Probe_releases_its_lease_and_reports_no_active_run()
+ {
+ var h = new Harness();
+ var released = false;
+ h.Lease.Setup(l => l.ReleaseAsync(It.IsAny(), It.IsAny()))
+ .Callback(() => released = true)
+ .ReturnsAsync(Mock.Of>());
+
+ Assert.False(await h.Sut.IsRunActiveAsync());
+ Assert.True(released);
+ }
+
+ [Fact]
+ public async Task Probe_lets_a_failed_release_surface_so_nothing_is_queued_behind_a_stuck_lease()
+ {
+ // A queued run picked up seconds later would find the probe's own lease still held,
+ // skip as "locked" and delete the message — the operator's run would silently vanish.
+ var h = new Harness();
+ h.Lease.Setup(l => l.ReleaseAsync(It.IsAny(), It.IsAny()))
+ .ThrowsAsync(new Azure.RequestFailedException(500, "storage down"));
+
+ await Assert.ThrowsAsync(() => h.Sut.IsRunActiveAsync());
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TriggerMaintenanceFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TriggerMaintenanceFunction.cs
index f9003e9a..b64d81c1 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TriggerMaintenanceFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TriggerMaintenanceFunction.cs
@@ -2,7 +2,7 @@ using System;
using System.Net;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Helpers;
-using AutopilotMonitor.Functions.Services;
+using AutopilotMonitor.Functions.Services.Maintenance;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
@@ -11,24 +11,29 @@ using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Admin
{
///
- /// Allows Global Admins to manually trigger maintenance tasks
+ /// POST /api/maintenance/trigger — manual Global Admin trigger of the platform
+ /// maintenance run. The run takes minutes, so the request only queues it:
+ /// 202 Accepted when queued, 409 while a run (timer or manual) is active,
+ /// 500 when the enqueue failed — never a hollow success. Progress and the run report
+ /// surface as Maintenance* ops events.
///
public class TriggerMaintenanceFunction
{
private readonly ILogger _logger;
- private readonly MaintenanceService _maintenanceService;
+ private readonly IMaintenanceTriggerProducer _producer;
+ private readonly MaintenanceRunGate _runGate;
public TriggerMaintenanceFunction(
ILogger logger,
- MaintenanceService maintenanceService)
+ IMaintenanceTriggerProducer producer,
+ MaintenanceRunGate runGate)
{
_logger = logger;
- _maintenanceService = maintenanceService;
+ _producer = producer;
+ _runGate = runGate;
}
///
- /// POST /api/maintenance/trigger
- /// Manually trigger maintenance tasks (Global Admin only)
/// Query parameters:
/// - date: Optional date to aggregate (yyyy-MM-dd). If not provided, uses yesterday.
/// - aggregateOnly: If true, only runs aggregation (skips timeout and cleanup)
@@ -38,45 +43,42 @@ namespace AutopilotMonitor.Functions.Functions.Admin
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "maintenance/trigger")] HttpRequestData req,
FunctionContext context)
{
- _logger.LogInformation("Manual maintenance trigger requested");
-
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware
- var userEmail = TenantHelper.GetUserIdentifier(req);
-
- _logger.LogInformation($"Maintenance trigger initiated by Global Admin: {userEmail}");
+ var userEmail = TenantHelper.GetUserIdentifier(req) ?? "GlobalAdmin";
+ var ct = context.CancellationToken;
try
{
- // Parse query parameters
- var dateParam = req.Query["date"];
- var aggregateOnlyParam = req.Query["aggregateOnly"];
-
- if (!QueryParams.TryUtcInstant(dateParam, "date", out var dateInstant, out _))
+ if (!QueryParams.TryUtcInstant(req.Query["date"], "date", out var dateInstant, out _))
{
return await req.BadRequestAsync("Invalid date format. Use yyyy-MM-dd");
}
DateTime? targetDate = dateInstant?.Date;
- if (targetDate.HasValue)
+ bool aggregateOnly = req.Query["aggregateOnly"]?.ToLower() == "true";
+
+ if (await _runGate.IsRunActiveAsync(ct))
{
- _logger.LogInformation($"Manual maintenance for date: {targetDate:yyyy-MM-dd}");
+ return await req.ConflictAsync("A maintenance run is already active.");
}
- bool aggregateOnly = aggregateOnlyParam?.ToLower() == "true";
-
- // Execute maintenance tasks
- var result = await _maintenanceService.RunManualAsync(targetDate, aggregateOnly, userEmail);
-
- var response = req.CreateResponse(HttpStatusCode.OK);
- await response.WriteAsJsonAsync(new TriggerMaintenanceResponse
+ await _producer.EnqueueAsync(new MaintenanceTriggerEnvelope
{
- Success = true,
- Message = "Maintenance tasks completed",
- Result = result,
TriggeredBy = userEmail,
- TriggeredAt = DateTime.UtcNow
- });
+ TargetDate = targetDate,
+ AggregateOnly = aggregateOnly,
+ }, ct);
- return response;
+ _logger.LogInformation("TriggerMaintenance: run queued (triggeredBy={TriggeredBy}, date={Date:yyyy-MM-dd}, aggregateOnly={AggregateOnly})",
+ userEmail, targetDate, aggregateOnly);
+
+ return await req.JsonAsync(HttpStatusCode.Accepted, new TriggerMaintenanceResponse
+ {
+ Message = "Maintenance run queued",
+ TriggeredBy = userEmail,
+ TriggeredAt = DateTime.UtcNow,
+ TargetDate = targetDate?.ToString("yyyy-MM-dd"),
+ AggregateOnly = aggregateOnly,
+ });
}
catch (Exception ex)
{
diff --git a/src/Backend/AutopilotMonitor.Functions/Program.cs b/src/Backend/AutopilotMonitor.Functions/Program.cs
index 25179a66..f6da9d08 100644
--- a/src/Backend/AutopilotMonitor.Functions/Program.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Program.cs
@@ -283,7 +283,17 @@ builder.Services.AddSingleton();
builder.Services.AddHttpClient()
.AddPolicyHandler((sp, _) => sp.GetRequiredService().ExternalDataApi);
+// Platform maintenance run: blob-lease gate (serializes the 2h timer, the manual trigger and a
+// second host instance), fail-hard queue producer for POST /api/maintenance/trigger, and the
+// BackgroundService worker that runs a queued manual run.
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton<
+ AutopilotMonitor.Functions.Services.Maintenance.IMaintenanceTriggerProducer,
+ AutopilotMonitor.Functions.Services.Maintenance.AzureQueueMaintenanceTriggerProducer>();
builder.Services.AddSingleton();
+builder.Services.AddHostedService<
+ AutopilotMonitor.Functions.Services.Maintenance.MaintenanceTriggerQueueWorker>();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton<
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Deletion/SessionDeletionMaintenanceLockStore.cs b/src/Backend/AutopilotMonitor.Functions/Services/Deletion/SessionDeletionMaintenanceLockStore.cs
index e370894f..4cd62ed7 100644
--- a/src/Backend/AutopilotMonitor.Functions/Services/Deletion/SessionDeletionMaintenanceLockStore.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Services/Deletion/SessionDeletionMaintenanceLockStore.cs
@@ -1,12 +1,6 @@
-using System;
-using System.IO;
-using System.Threading;
-using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services.Backup;
+using AutopilotMonitor.Functions.Services.Locking;
using AutopilotMonitor.Shared;
-using Azure;
-using Azure.Storage.Blobs.Models;
-using Azure.Storage.Blobs.Specialized;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services.Deletion
@@ -19,89 +13,25 @@ namespace AutopilotMonitor.Functions.Services.Deletion
/// Deliberately a DEDICATED sentinel ( in the
/// deletion-manifests container) rather than the backup sentinel in
/// — sharing that lease would serialize backups against the
- /// retention fanout, which have no reason to exclude each other. Lease mechanics
- /// (60s lease + 45s renewal,
- /// on contention) are identical to the backup path.
+ /// retention fanout, which have no reason to exclude each other.
///
///
/// The deletion-manifests container has a 30-day lifecycle policy that may delete an
- /// idle sentinel; re-ensures it on every acquire, so the
- /// lock self-heals. The manifest-TTL sweep skips the sentinel because _lock/… does
- /// not parse as a manifest blob name.
+ /// idle sentinel; the base class re-ensures it on every acquire, so the lock self-heals.
+ /// The manifest-TTL sweep skips the sentinel because _lock/… does not parse as a
+ /// manifest blob name.
///
///
- public class SessionDeletionMaintenanceLockStore
+ public class SessionDeletionMaintenanceLockStore : BlobLeaseLockStore
{
/// Sentinel blob path — leased by the timer / manual-trigger worker to serialize runs.
public const string LockBlobName = "_lock/session-deletion-maintenance.lock";
- /// Lease duration (Azure spec allows 15..60s for renewable leases).
- public static readonly TimeSpan LeaseDuration = TimeSpan.FromSeconds(60);
-
- private readonly BlobStorageService _blobs;
- private readonly ILogger _logger;
- private int _containerEnsured;
-
public SessionDeletionMaintenanceLockStore(
BlobStorageService blobs,
ILogger logger)
+ : base(blobs, Constants.BlobContainers.DeletionManifests, LockBlobName, "session-deletion maintenance")
{
- _blobs = blobs;
- _logger = logger;
- }
-
- ///
- /// Attempts to acquire the session-deletion maintenance lease. Throws
- /// on 409 (another holder); other errors propagate.
- /// Caller is responsible for renewing (via ) and
- /// releasing. virtual so tests can stub it.
- ///
- public virtual async Task AcquireLeaseAsync(TimeSpan? leaseDuration = null, CancellationToken ct = default)
- {
- await EnsureSentinelAsync(ct).ConfigureAwait(false);
-
- var blob = _blobs.GetContainerClient(Constants.BlobContainers.DeletionManifests).GetBlobClient(LockBlobName);
- var leaseClient = blob.GetBlobLeaseClient();
- try
- {
- await leaseClient.AcquireAsync(leaseDuration ?? LeaseDuration, conditions: null, ct).ConfigureAwait(false);
- return leaseClient;
- }
- catch (RequestFailedException ex) when (ex.Status == 409)
- {
- throw new LeaseHeldException("session-deletion maintenance lease is held by another run", ex);
- }
- }
-
- ///
- /// Idempotently ensures the sentinel blob exists — an AcquireLease on a missing blob
- /// throws 404. If-None-Match=* lets concurrent callers cooperate; both 409
- /// BlobAlreadyExists and 412 ConditionNotMet are the steady state.
- ///
- private async Task EnsureSentinelAsync(CancellationToken ct)
- {
- var container = _blobs.GetContainerClient(Constants.BlobContainers.DeletionManifests);
- if (Interlocked.CompareExchange(ref _containerEnsured, 0, 0) == 0)
- {
- await container.CreateIfNotExistsAsync(cancellationToken: ct).ConfigureAwait(false);
- Interlocked.Exchange(ref _containerEnsured, 1);
- }
-
- var blob = container.GetBlobClient(LockBlobName);
- 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());
- await blob.UploadAsync(empty, options, ct).ConfigureAwait(false);
- }
- catch (RequestFailedException ex) when (ex.Status == 409 || ex.Status == 412)
- {
- // Sentinel exists already — steady state.
- }
}
}
}
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Locking/BlobLeaseLockStore.cs b/src/Backend/AutopilotMonitor.Functions/Services/Locking/BlobLeaseLockStore.cs
new file mode 100644
index 00000000..60b6bbd2
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions/Services/Locking/BlobLeaseLockStore.cs
@@ -0,0 +1,95 @@
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using AutopilotMonitor.Functions.Services.Backup;
+using Azure;
+using Azure.Storage.Blobs.Models;
+using Azure.Storage.Blobs.Specialized;
+
+namespace AutopilotMonitor.Functions.Services.Locking
+{
+ ///
+ /// Blob-lease lock on a sentinel blob: serializes a run against every other entry point of
+ /// the same work (timer, manual trigger, a second host instance). One subclass per lock —
+ /// each names its own sentinel, because two kinds of work that have no reason to exclude
+ /// each other must not share a lease.
+ ///
+ /// Lease mechanics: 60s lease, renewed by every 45s,
+ /// on contention. The sentinel is re-ensured on every
+ /// acquire, so a lifecycle policy that deletes an idle sentinel cannot break the lock.
+ ///
+ ///
+ public abstract class BlobLeaseLockStore
+ {
+ /// Lease duration (Azure spec allows 15..60s for renewable leases).
+ public static readonly TimeSpan LeaseDuration = TimeSpan.FromSeconds(60);
+
+ private readonly BlobStorageService _blobs;
+ private readonly string _containerName;
+ private readonly string _lockBlobName;
+ private readonly string _description;
+ private int _containerEnsured;
+
+ protected BlobLeaseLockStore(BlobStorageService blobs, string containerName, string lockBlobName, string description)
+ {
+ _blobs = blobs;
+ _containerName = containerName;
+ _lockBlobName = lockBlobName;
+ _description = description;
+ }
+
+ ///
+ /// Attempts to acquire the lease. Throws on 409 (another
+ /// holder); other errors propagate. Caller is responsible for renewing (via
+ /// ) and releasing. virtual so tests can stub it.
+ ///
+ public virtual async Task AcquireLeaseAsync(TimeSpan? leaseDuration = null, CancellationToken ct = default)
+ {
+ await EnsureSentinelAsync(ct).ConfigureAwait(false);
+
+ var blob = _blobs.GetContainerClient(_containerName).GetBlobClient(_lockBlobName);
+ var leaseClient = blob.GetBlobLeaseClient();
+ try
+ {
+ await leaseClient.AcquireAsync(leaseDuration ?? LeaseDuration, conditions: null, ct).ConfigureAwait(false);
+ return leaseClient;
+ }
+ catch (RequestFailedException ex) when (ex.Status == 409)
+ {
+ throw new LeaseHeldException($"{_description} lease is held by another run", ex);
+ }
+ }
+
+ ///
+ /// Idempotently ensures the sentinel blob exists — an AcquireLease on a missing blob
+ /// throws 404. If-None-Match=* lets concurrent callers cooperate; both 409
+ /// BlobAlreadyExists and 412 ConditionNotMet are the steady state.
+ ///
+ private async Task EnsureSentinelAsync(CancellationToken ct)
+ {
+ var container = _blobs.GetContainerClient(_containerName);
+ if (Interlocked.CompareExchange(ref _containerEnsured, 0, 0) == 0)
+ {
+ await container.CreateIfNotExistsAsync(cancellationToken: ct).ConfigureAwait(false);
+ Interlocked.Exchange(ref _containerEnsured, 1);
+ }
+
+ var blob = container.GetBlobClient(_lockBlobName);
+ 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());
+ await blob.UploadAsync(empty, options, ct).ConfigureAwait(false);
+ }
+ catch (RequestFailedException ex) when (ex.Status == 409 || ex.Status == 412)
+ {
+ // Sentinel exists already — steady state.
+ }
+ }
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Maintenance/AzureQueueMaintenanceTriggerProducer.cs b/src/Backend/AutopilotMonitor.Functions/Services/Maintenance/AzureQueueMaintenanceTriggerProducer.cs
new file mode 100644
index 00000000..343caab1
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions/Services/Maintenance/AzureQueueMaintenanceTriggerProducer.cs
@@ -0,0 +1,60 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using AutopilotMonitor.Functions.Services.Queueing;
+using AutopilotMonitor.Shared;
+using Azure.Storage.Queues;
+using Microsoft.Extensions.Logging;
+using Newtonsoft.Json;
+
+namespace AutopilotMonitor.Functions.Services.Maintenance
+{
+ ///
+ /// Azure-Storage-Queue producer for the manual maintenance trigger. Same shape as the
+ /// session-deletion and backup trigger producers:
+ /// (Managed Identity preferred), fail-hard.
+ ///
+ public sealed class AzureQueueMaintenanceTriggerProducer : IMaintenanceTriggerProducer
+ {
+ private readonly QueueClient _queueClient;
+ private readonly ILogger _logger;
+ private int _queueEnsured;
+
+ public AzureQueueMaintenanceTriggerProducer(
+ QueueClientFactory queueFactory,
+ ILogger logger)
+ {
+ _logger = logger;
+ _queueClient = queueFactory.Create(Constants.QueueNames.MaintenanceTrigger);
+ }
+
+ public async Task EnqueueAsync(MaintenanceTriggerEnvelope envelope, CancellationToken ct = default)
+ {
+ if (envelope == null) throw new ArgumentNullException(nameof(envelope));
+ if (string.IsNullOrEmpty(envelope.TriggeredBy)) throw new ArgumentException("TriggeredBy required", nameof(envelope));
+
+ await EnsureQueueExistsAsync(ct).ConfigureAwait(false);
+
+ // Fail-hard: do NOT swallow. Caller (HTTP trigger) catches and returns 500.
+ var body = JsonConvert.SerializeObject(envelope);
+ await _queueClient.SendMessageAsync(body, ct).ConfigureAwait(false);
+ _logger.LogInformation("Maintenance trigger enqueued (triggeredBy={TriggeredBy})", envelope.TriggeredBy);
+ }
+
+ 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, "Maintenance trigger queue CreateIfNotExists failed");
+ throw;
+ }
+ }
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Maintenance/IMaintenanceTriggerProducer.cs b/src/Backend/AutopilotMonitor.Functions/Services/Maintenance/IMaintenanceTriggerProducer.cs
new file mode 100644
index 00000000..79e766f4
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions/Services/Maintenance/IMaintenanceTriggerProducer.cs
@@ -0,0 +1,29 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace AutopilotMonitor.Functions.Services.Maintenance
+{
+ ///
+ /// Queue envelope of a manually triggered maintenance run. There is no per-run state row —
+ /// the Maintenance* ops events are the status surface.
+ ///
+ public sealed class MaintenanceTriggerEnvelope
+ {
+ public string TriggeredBy { get; set; } = string.Empty;
+
+ /// Date to aggregate (UTC date); null = yesterday.
+ public DateTime? TargetDate { get; set; }
+
+ public bool AggregateOnly { get; set; }
+ }
+
+ ///
+ /// Producer for the manual maintenance trigger. Fail-hard: send failures propagate so
+ /// the HTTP trigger answers 5xx instead of a hollow 202.
+ ///
+ public interface IMaintenanceTriggerProducer
+ {
+ Task EnqueueAsync(MaintenanceTriggerEnvelope envelope, CancellationToken ct = default);
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Maintenance/MaintenanceRunGate.cs b/src/Backend/AutopilotMonitor.Functions/Services/Maintenance/MaintenanceRunGate.cs
new file mode 100644
index 00000000..33357e6a
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions/Services/Maintenance/MaintenanceRunGate.cs
@@ -0,0 +1,92 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using AutopilotMonitor.Functions.Services.Backup;
+using Azure.Storage.Blobs.Specialized;
+using Microsoft.Extensions.Logging;
+
+namespace AutopilotMonitor.Functions.Services.Maintenance
+{
+ ///
+ /// Single-flight gate for the platform maintenance run: the 2h timer, the manual trigger
+ /// and a second host instance all enter through , so two
+ /// runs never aggregate, clean up and backfill the same tables at the same time.
+ ///
+ public class MaintenanceRunGate
+ {
+ private readonly MaintenanceRunLockStore _lockStore;
+ private readonly OpsEventService _opsEvents;
+ private readonly ILogger _logger;
+
+ public MaintenanceRunGate(MaintenanceRunLockStore lockStore, OpsEventService opsEvents, ILogger logger)
+ {
+ _lockStore = lockStore;
+ _opsEvents = opsEvents;
+ _logger = logger;
+ }
+
+ ///
+ /// Acquire+release probe for the manual trigger: a held lease means a run is active RIGHT
+ /// NOW, so the operator is told instead of queuing a message that would only produce a
+ /// SkippedLocked event. Racing the probe (a run starts between probe and worker pickup)
+ /// is benign: the worker's own acquire lands on the SkippedLocked path.
+ ///
+ public virtual async Task IsRunActiveAsync(CancellationToken ct = default)
+ {
+ BlobLeaseClient lease;
+ try
+ {
+ lease = await _lockStore.AcquireLeaseAsync(ct: ct).ConfigureAwait(false);
+ }
+ catch (LeaseHeldException)
+ {
+ return true;
+ }
+
+ // A probe lease that cannot be released would still be held when the worker picks the
+ // message up seconds later — the run would be skipped as "locked" and the message
+ // deleted. Let the failure surface: the trigger answers 5xx and nothing is queued.
+ await lease.ReleaseAsync(cancellationToken: ct).ConfigureAwait(false);
+ return false;
+ }
+
+ ///
+ /// Runs under the maintenance-run lease. Returns false without
+ /// running it when another run holds the lease (SkippedLocked ops event). The Started
+ /// event is written AFTER the lease is held, so a skip never looks like an active run,
+ /// and the lease is released LAST, so the body's terminal ops event is still exclusive.
+ ///
+ public virtual async Task RunExclusiveAsync(string triggeredBy, Func body)
+ {
+ BlobLeaseClient lease;
+ try
+ {
+ lease = await _lockStore.AcquireLeaseAsync().ConfigureAwait(false);
+ }
+ catch (LeaseHeldException)
+ {
+ _logger.LogWarning("Maintenance: lease held by another run — skipping (triggeredBy={TriggeredBy})", triggeredBy);
+ await _opsEvents.RecordMaintenanceSkippedLockedAsync(triggeredBy).ConfigureAwait(false);
+ return false;
+ }
+
+ // The maintenance body takes no cancellation token; a lost lease is reported, not
+ // enforced — the run is minutes long and the lease renews every 45s.
+ using var leaseLost = new CancellationTokenSource();
+ var leaseHolder = new MaintenanceLeaseHolder(lease, leaseLost, _logger);
+ try
+ {
+ await _opsEvents.RecordMaintenanceStartedAsync(triggeredBy).ConfigureAwait(false);
+ await body().ConfigureAwait(false);
+ return true;
+ }
+ finally
+ {
+ if (leaseHolder.RenewalFailureReason != null)
+ _logger.LogWarning("Maintenance: the run lease could not be renewed during the run ({Reason}) — another run may have overlapped",
+ leaseHolder.RenewalFailureReason);
+ await leaseHolder.DisposeAsync().ConfigureAwait(false);
+ }
+ }
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Maintenance/MaintenanceRunLockStore.cs b/src/Backend/AutopilotMonitor.Functions/Services/Maintenance/MaintenanceRunLockStore.cs
new file mode 100644
index 00000000..fce499af
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions/Services/Maintenance/MaintenanceRunLockStore.cs
@@ -0,0 +1,21 @@
+using AutopilotMonitor.Functions.Services.Locking;
+using AutopilotMonitor.Shared;
+
+namespace AutopilotMonitor.Functions.Services.Maintenance
+{
+ ///
+ /// Blob-lease lock for the platform maintenance run (2h timer and manual trigger). Its own
+ /// sentinel: the session-deletion maintenance and the critical-table backup have no reason
+ /// to exclude this run, so they keep their own leases.
+ ///
+ public class MaintenanceRunLockStore : BlobLeaseLockStore
+ {
+ /// Sentinel blob path. _lock/… does not parse as a manifest blob name, so the manifest-TTL sweep skips it.
+ public const string LockBlobName = "_lock/platform-maintenance.lock";
+
+ public MaintenanceRunLockStore(BlobStorageService blobs)
+ : base(blobs, Constants.BlobContainers.DeletionManifests, LockBlobName, "platform maintenance")
+ {
+ }
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Maintenance/MaintenanceTriggerQueueWorker.cs b/src/Backend/AutopilotMonitor.Functions/Services/Maintenance/MaintenanceTriggerQueueWorker.cs
new file mode 100644
index 00000000..3b923e85
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions/Services/Maintenance/MaintenanceTriggerQueueWorker.cs
@@ -0,0 +1,49 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using AutopilotMonitor.Functions.Services.Queueing;
+using AutopilotMonitor.Shared;
+using Microsoft.Extensions.Logging;
+
+namespace AutopilotMonitor.Functions.Services.Maintenance
+{
+ ///
+ /// Background worker for the maintenance-trigger queue — runs the manually triggered
+ /// maintenance off the HTTP request, which would otherwise have to stay open for minutes
+ /// and dies at the gateway limit.
+ ///
+ /// A self-polling worker, not a queue-triggered function, because the run holds a lease
+ /// for its whole lifetime. Concurrency against the 2h timer is handled inside
+ /// by the run gate: a lease-held run
+ /// returns normally (SkippedLocked ops event), so the message is deleted rather than
+ /// retried — the operator asked for "a run", and one is already active. A failing run
+ /// reports MaintenanceFailed and returns as well: maintenance is not retried blindly.
+ ///
+ ///
+ public sealed class MaintenanceTriggerQueueWorker : QueuePollingWorker
+ {
+ private readonly MaintenanceService _maintenance;
+
+ public MaintenanceTriggerQueueWorker(
+ QueueClientFactory queueFactory,
+ MaintenanceService maintenance,
+ ILogger logger)
+ : base(queueFactory, Constants.QueueNames.MaintenanceTrigger, logger, Constants.QueueNames.MaintenanceTriggerPoison)
+ {
+ _maintenance = maintenance ?? throw new ArgumentNullException(nameof(maintenance));
+ }
+
+ // The host functionTimeout (60min) is the upper bound of a run; the message must not
+ // reappear while its run is still going.
+ protected override TimeSpan VisibilityTimeout => TimeSpan.FromMinutes(60);
+
+ protected override bool TryValidate(MaintenanceTriggerEnvelope envelope)
+ => !string.IsNullOrEmpty(envelope.TriggeredBy);
+
+ protected override string DescribeForLog(MaintenanceTriggerEnvelope envelope)
+ => $"triggeredBy={envelope.TriggeredBy} targetDate={envelope.TargetDate:yyyy-MM-dd} aggregateOnly={envelope.AggregateOnly}";
+
+ protected override Task HandleAsync(MaintenanceTriggerEnvelope envelope, CancellationToken ct)
+ => _maintenance.RunManualAsync(envelope.TargetDate, envelope.AggregateOnly, envelope.TriggeredBy);
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs
index 98f9d15d..729bbfb8 100644
--- a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs
@@ -55,6 +55,7 @@ namespace AutopilotMonitor.Functions.Services
private readonly IHttpClientFactory _httpClientFactory;
private readonly PreviewWhitelistService _previewWhitelistService;
private readonly IStorageInitializer _storageInitializer;
+ private readonly Maintenance.MaintenanceRunGate _runGate;
private readonly ILogger _logger;
private const string PlatformStatsAliasFileName = "platform-stats.json";
@@ -90,6 +91,7 @@ namespace AutopilotMonitor.Functions.Services
IHttpClientFactory httpClientFactory,
PreviewWhitelistService previewWhitelistService,
IStorageInitializer storageInitializer,
+ Maintenance.MaintenanceRunGate runGate,
ILogger logger)
{
_maintenanceRepo = maintenanceRepo;
@@ -120,6 +122,7 @@ namespace AutopilotMonitor.Functions.Services
_httpClientFactory = httpClientFactory;
_previewWhitelistService = previewWhitelistService;
_storageInitializer = storageInitializer;
+ _runGate = runGate;
_logger = logger;
}
@@ -136,9 +139,12 @@ namespace AutopilotMonitor.Functions.Services
}
///
- /// Runs all maintenance tasks (used by the daily timer trigger)
+ /// Runs all maintenance tasks (used by the 2h timer trigger). Single-flight with the
+ /// manual trigger: skipped when another run holds the maintenance-run lease.
///
- public async Task RunAllAsync()
+ public Task RunAllAsync() => _runGate.RunExclusiveAsync("Timer", RunAllCoreAsync);
+
+ private async Task RunAllCoreAsync()
{
_logger.LogInformation($"Daily maintenance started at {DateTime.UtcNow}");
var maintenanceStart = Stopwatch.StartNew();
@@ -246,9 +252,27 @@ namespace AutopilotMonitor.Functions.Services
=> window.Where(s => s.StartedAt >= windowStart).ToList();
///
- /// Manually triggered maintenance with flexible date selection
+ /// Manually triggered maintenance with flexible date selection. Single-flight with the
+ /// timer: when another run holds the maintenance-run lease, nothing runs and the result
+ /// says so.
///
public async Task RunManualAsync(DateTime? targetDate = null, bool aggregateOnly = false, string triggeredBy = "Unknown")
+ {
+ MaintenanceResult? result = null;
+ var ran = await _runGate.RunExclusiveAsync(triggeredBy,
+ async () => result = await RunManualCoreAsync(targetDate, aggregateOnly, triggeredBy));
+ return ran && result != null
+ ? result
+ : new MaintenanceResult
+ {
+ TriggeredBy = triggeredBy,
+ TriggeredAt = DateTime.UtcNow,
+ Success = false,
+ Error = "Another maintenance run is active",
+ };
+ }
+
+ private async Task RunManualCoreAsync(DateTime? targetDate, bool aggregateOnly, string triggeredBy)
{
_logger.LogInformation($"Manual maintenance triggered by {triggeredBy} at {DateTime.UtcNow}");
var maintenanceStart = Stopwatch.StartNew();
@@ -329,7 +353,7 @@ namespace AutopilotMonitor.Functions.Services
result.Success = true;
_logger.LogInformation($"Manual maintenance completed in {maintenanceStart.ElapsedMilliseconds}ms");
- await _opsEventService.RecordMaintenanceCompletedAsync(result.DurationMs, triggeredBy);
+ await _opsEventService.RecordMaintenanceCompletedAsync(result.DurationMs, triggeredBy, result);
return result;
}
catch (Exception ex)
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs b/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs
index ad1e84c4..5cb68acf 100644
--- a/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs
@@ -64,10 +64,42 @@ namespace AutopilotMonitor.Functions.Services
// ── Maintenance ────────────────────────────────────────────────────────
- public Task RecordMaintenanceCompletedAsync(int durationMs, string triggeredBy)
+ ///
+ /// Emitted after the maintenance-run lease was acquired, so a lease-skip never
+ /// masquerades as an active run — the portal treats "latest Started newer than latest
+ /// Completed/Failed" as run-active.
+ ///
+ public Task RecordMaintenanceStartedAsync(string triggeredBy)
+ => WriteAsync(OpsEventCategory.Maintenance, OpsEventTypes.MaintenanceStarted, OpsEventSeverity.Info,
+ $"Maintenance run started (triggered by {triggeredBy})",
+ null, triggeredBy, new { triggeredBy });
+
+ /// Another run (timer, manual trigger or a second host instance) held the maintenance-run lease.
+ public Task RecordMaintenanceSkippedLockedAsync(string triggeredBy)
+ => WriteAsync(OpsEventCategory.Maintenance, OpsEventTypes.MaintenanceSkippedLocked, OpsEventSeverity.Info,
+ $"Maintenance run skipped — another run holds the lease (triggered by {triggeredBy})",
+ null, triggeredBy, new { reason = "lease held by another run", triggeredBy });
+
+ ///
+ /// is the run report of a manual run: the trigger answers 202
+ /// before the run starts, so this event is where the operator reads what the run did.
+ ///
+ public Task RecordMaintenanceCompletedAsync(int durationMs, string triggeredBy, MaintenanceResult? report = null)
=> WriteAsync(OpsEventCategory.Maintenance, OpsEventTypes.MaintenanceCompleted, OpsEventSeverity.Info,
$"Maintenance completed in {durationMs}ms (triggered by {triggeredBy})",
- null, triggeredBy, new { durationMs });
+ null, triggeredBy,
+ report == null
+ ? (object)new { durationMs }
+ : new
+ {
+ durationMs,
+ aggregatedDate = report.AggregatedDate,
+ stalledSessionsChecked = report.StalledSessionsChecked,
+ dataCleanupExecuted = report.DataCleanupExecuted,
+ platformStatsRecomputed = report.PlatformStatsRecomputed,
+ devicesBlockedForExcessiveData = report.DevicesBlockedForExcessiveData,
+ contactEmailsBackfilled = report.ContactEmailsBackfilled,
+ });
public Task RecordMaintenanceFailedAsync(string error, string triggeredBy)
=> WriteAsync(OpsEventCategory.Maintenance, OpsEventTypes.MaintenanceFailed, OpsEventSeverity.Error,
diff --git a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts
index 760083d8..95d093a8 100644
--- a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts
+++ b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts
@@ -2787,22 +2787,6 @@ export interface LogQueryRequest {
budgetSeconds?: number | null;
}
-/** Run report of a manual maintenance run (POST maintenance/trigger). */
-export interface MaintenanceResult {
- success: boolean;
- error?: string;
- triggeredBy: string;
- triggeredAt: string;
- durationMs: number;
- stalledSessionsChecked: boolean;
- metricsAggregated: boolean;
- aggregatedDate?: string;
- dataCleanupExecuted: boolean;
- platformStatsRecomputed: boolean;
- devicesBlockedForExcessiveData: number;
- contactEmailsBackfilled: number;
-}
-
/** One managed tenant as the managing tenant sees it. */
export interface ManagedTenantItem {
tenantId: string;
@@ -5158,13 +5142,14 @@ export interface TriggerEpssSyncResponse {
syncedAt: string;
}
-/** Response of POST maintenance/trigger. */
+/** Response of POST maintenance/trigger (202 Accepted): the run is queued, not done. Its progress and its report surface as Maintenance* ops events (Started, Completed, Failed, SkippedLocked). */
export interface TriggerMaintenanceResponse {
- success: boolean;
message: string;
- result: MaintenanceResult;
triggeredBy: string;
triggeredAt: string;
+ /** Date to aggregate (yyyy-MM-dd); null = yesterday. */
+ targetDate?: string;
+ aggregateOnly: boolean;
}
/** Response of POST vulnerability/sync-msrc (dual-purpose: success reflects whether the MSRC index refresh finished without error). */
diff --git a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-vocabularies.generated.ts b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-vocabularies.generated.ts
index 9b05adfd..1f99b4ae 100644
--- a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-vocabularies.generated.ts
+++ b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-vocabularies.generated.ts
@@ -64,6 +64,8 @@ export const OPS_EVENT_TYPES = [
"MaintenanceCompleted",
"MaintenanceFailed",
"MaintenanceLongRunning",
+ "MaintenanceStarted",
+ "MaintenanceSkippedLocked",
"SessionSweepCompleted",
"SessionSweepFailed",
"OpsEventCleanup",
diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs
index bbcd8dc9..9030ebeb 100644
--- a/src/Shared/AutopilotMonitor.Shared/Constants.cs
+++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs
@@ -1701,6 +1701,20 @@ namespace AutopilotMonitor.Shared
/// Poison sibling of .
public const string SessionDeletionMaintenancePoison = "session-deletion-maintenance-poison";
+ ///
+ /// Manual-trigger queue for the platform maintenance run. Producer = HTTP trigger
+ /// /api/maintenance/trigger (fail-hard, answers 202); consumer =
+ /// MaintenanceTriggerQueueWorker (self-managed BackgroundService,
+ /// VisibilityTimeout=60min = the host functionTimeout). Envelope
+ /// { triggeredBy, targetDate, aggregateOnly }; concurrency against the 2h timer
+ /// is serialized by the maintenance-run blob lease, not the queue.
+ /// Poison-suffix -poison, max-dequeue 5.
+ ///
+ public const string MaintenanceTrigger = "maintenance-trigger";
+
+ /// Poison sibling of .
+ public const string MaintenanceTriggerPoison = "maintenance-trigger-poison";
+
///
/// IME-MSI archive fan-out. Producer = EventIngestProcessor when
/// RecordImeVersionAsync reports a genuinely NEW IME version (first sighting
diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/OpsEventTypes.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/OpsEventTypes.cs
index 6ee5d8c8..5d89a9e8 100644
--- a/src/Shared/AutopilotMonitor.Shared/DataAccess/OpsEventTypes.cs
+++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/OpsEventTypes.cs
@@ -32,6 +32,8 @@ namespace AutopilotMonitor.Shared.DataAccess
public const string MaintenanceCompleted = "MaintenanceCompleted";
public const string MaintenanceFailed = "MaintenanceFailed";
public const string MaintenanceLongRunning = "MaintenanceLongRunning";
+ public const string MaintenanceStarted = "MaintenanceStarted";
+ public const string MaintenanceSkippedLocked = "MaintenanceSkippedLocked";
public const string SessionSweepCompleted = "SessionSweepCompleted";
public const string SessionSweepFailed = "SessionSweepFailed";
public const string OpsEventCleanup = "OpsEventCleanup";
@@ -135,7 +137,7 @@ namespace AutopilotMonitor.Shared.DataAccess
public static readonly IReadOnlyList All = new[]
{
ConsentFlowStarted, ConsentFlowSuccess, ConsentFlowFailed, ConsentRedirectUriMismatch, AppHomingFlipped, AppHomingFlippedWithEntraRoles,
- MaintenanceCompleted, MaintenanceFailed, MaintenanceLongRunning, SessionSweepCompleted, SessionSweepFailed, OpsEventCleanup, OrphanEventsCleaned, SessionDeletionMaintenanceStarted, SessionDeletionMaintenanceBudgetExceeded, SessionDeletionMaintenanceSkippedLocked, SessionDeletionMaintenanceLongRunning, SessionDeletionMaintenanceLongRunningSevere, SessionDeletionMaintenanceFailed, SessionDeletionStrandedQueued, SessionDeletionPoisoned, SessionDeletionMaintenanceCompleted, SessionDeletionMaintenanceFanoutSkipped, CriticalTableBackupCompleted, CriticalTableBackupPartial, CriticalTableBackupFailed, CriticalTableBackupSkippedLocked, BackupRowRestored, VerdictCalibrationDrift,
+ MaintenanceCompleted, MaintenanceFailed, MaintenanceLongRunning, MaintenanceStarted, MaintenanceSkippedLocked, SessionSweepCompleted, SessionSweepFailed, OpsEventCleanup, OrphanEventsCleaned, SessionDeletionMaintenanceStarted, SessionDeletionMaintenanceBudgetExceeded, SessionDeletionMaintenanceSkippedLocked, SessionDeletionMaintenanceLongRunning, SessionDeletionMaintenanceLongRunningSevere, SessionDeletionMaintenanceFailed, SessionDeletionStrandedQueued, SessionDeletionPoisoned, SessionDeletionMaintenanceCompleted, SessionDeletionMaintenanceFanoutSkipped, CriticalTableBackupCompleted, CriticalTableBackupPartial, CriticalTableBackupFailed, CriticalTableBackupSkippedLocked, BackupRowRestored, VerdictCalibrationDrift,
DeviceBlocked, VersionBlocked, SessionTenantConflict, SessionOwnerMismatch, KillSignalDelivered, EmbeddedCertExpiringSoon, EmbeddedCertExpiringUrgent, EmbeddedCertExpired, EmbeddedCertBundleEmpty, SignalRConnectionsHigh, SignalRConnectionsCritical, SignalRMessagesHigh, SignalRMessagesCritical, PoisonQueueBacklogHigh, PoisonQueueBacklogCritical, ExcessiveSessionEventsAutoActioned, PrivilegedRouteDenied, McpServicePrincipalFirstSeen,
OffboardingFeedbackReceived, TenantOffboarded, TenantOffboardingFailed, TenantAutoApproved, WelcomeEmailSent, WelcomeEmailSkipped, WelcomeEmailFailed, TenantTrialStarted, TenantTrialExpiring, TenantTrialExpired, TenantPlanDowngraded, TenantRetentionGraceExpiring, TenantRetentionGraceEnded, RuleFrequencyRegression, AppVersionDurationRegression, CollectLogsQuickConfigEnabled, DiagnosticsUploadEnabled, DiagnosticsUploadDisabled,
SessionActionQueued, SessionTimeouts, AgentEmergencyBreak, AgentBinaryIntegrityMismatch, CmTraceTimeSkewRegression, ExcessiveSessionEvents, NewImeVersionDetected, ImePatternDriftSuspected, BlobStorageMissing, BlobStorageUnreachable, TelemetryItemsRejected,
diff --git a/src/Shared/AutopilotMonitor.Shared/Models/AdminApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/AdminApiModels.cs
index ecf02cb4..cf88bf97 100644
--- a/src/Shared/AutopilotMonitor.Shared/Models/AdminApiModels.cs
+++ b/src/Shared/AutopilotMonitor.Shared/Models/AdminApiModels.cs
@@ -145,15 +145,20 @@ namespace AutopilotMonitor.Shared.Models
public DateTime TriggeredAt { get; set; }
}
- /// Response of POST maintenance/trigger.
+ ///
+ /// Response of POST maintenance/trigger (202 Accepted): the run is queued, not done. Its
+ /// progress and its report surface as Maintenance* ops events (Started, Completed, Failed,
+ /// SkippedLocked).
+ ///
// Declaration order == wire order.
public class TriggerMaintenanceResponse : IApiResponse
{
- public bool Success { get; set; }
public string Message { get; set; } = default!;
- public MaintenanceResult Result { get; set; } = default!;
public string TriggeredBy { get; set; } = default!;
public DateTime TriggeredAt { get; set; }
+ /// Date to aggregate (yyyy-MM-dd); null = yesterday.
+ public string? TargetDate { get; set; }
+ public bool AggregateOnly { get; set; }
}
///
@@ -218,7 +223,7 @@ namespace AutopilotMonitor.Shared.Models
public string Reason { get; set; } = string.Empty;
}
- /// Run report of a manual maintenance run (POST maintenance/trigger).
+ /// Run report of a manual maintenance run; its figures travel in the MaintenanceCompleted ops event.
// Declaration order == wire order.
public class MaintenanceResult
{
diff --git a/src/Web/autopilot-monitor-web/app/admin/components/MaintenanceTriggerSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/MaintenanceTriggerSection.tsx
index d45aee27..4b9b0b12 100644
--- a/src/Web/autopilot-monitor-web/app/admin/components/MaintenanceTriggerSection.tsx
+++ b/src/Web/autopilot-monitor-web/app/admin/components/MaintenanceTriggerSection.tsx
@@ -2,16 +2,32 @@
import { useState } from "react";
import { api } from "@/lib/api";
-import { apiErrorText, fetchJson, fetchOk } from "@/lib/apiClient";
+import { ApiError, apiErrorText, fetchJson, fetchOk } from "@/lib/apiClient";
+import { parseOpsDetails, useOpsRunStatus, type OpsRunLifecycle } from "@/hooks/useOpsRunStatus";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import type { GetLatestVersionsResponse } from "@/utils/wire-types.generated";
+const LIFECYCLE: OpsRunLifecycle = {
+ category: "Maintenance",
+ started: "MaintenanceStarted",
+ completed: "MaintenanceCompleted",
+ failed: "MaintenanceFailed",
+};
+
+// A run takes a few minutes; while one is active the status line follows it on its own.
+const POLL_WHILE_ACTIVE_MS = 15_000;
+
interface MaintenanceTriggerSectionProps {
getAccessToken: () => Promise;
setError: (error: string | null) => void;
setSuccessMessage: (message: string | null) => void;
}
+function formatRunDuration(durationMs: number | undefined): string {
+ if (typeof durationMs !== "number") return "";
+ return durationMs < 60_000 ? ` in ${Math.round(durationMs / 1000)} s` : ` in ${Math.round(durationMs / 60_000)} min`;
+}
+
export function MaintenanceTriggerSection({
getAccessToken,
setError,
@@ -23,6 +39,8 @@ export function MaintenanceTriggerSection({
// (react-hooks/purity) and the cap doesn't need a live midnight rollover.
const [maxMaintenanceDate] = useState(() => new Date(Date.now() - 86400000).toISOString().split('T')[0]);
const [refreshingVersions, setRefreshingVersions] = useState(false);
+ const { status: runStatus, refresh: refreshRunStatus } = useOpsRunStatus(getAccessToken, setError, LIFECYCLE, POLL_WHILE_ACTIVE_MS);
+ const runActive = runStatus.kind === "active";
const handleRefreshLatestVersions = async () => {
try {
@@ -53,15 +71,20 @@ export function MaintenanceTriggerSection({
setError(null);
setSuccessMessage(null);
- // The run answers when it is done (minutes), far beyond the default fetch timeout;
- // the platform gateway cuts a request at 230 s, so waiting longer gains nothing.
- await fetchOk(api.maintenance.trigger(maintenanceDate || undefined), getAccessToken, {
- method: "POST",
- signal: AbortSignal.timeout(230_000),
- });
+ // 202 = queued (the run reports through Maintenance* ops events); 409 = a run is
+ // already active, which is not an error for the operator.
+ const queued = await fetchOk(api.maintenance.trigger(maintenanceDate || undefined), getAccessToken, { method: "POST" })
+ .then(() => true)
+ .catch((err: unknown) => {
+ if (err instanceof ApiError && err.status === 409) return false;
+ throw err;
+ });
const dateInfo = maintenanceDate ? ` for ${maintenanceDate}` : '';
- setSuccessMessage(`Maintenance job completed successfully${dateInfo}!`);
+ setSuccessMessage(queued
+ ? `Maintenance run queued${dateInfo} — its status appears below shortly.`
+ : "A maintenance run is already active.");
+ refreshRunStatus();
setTimeout(() => setSuccessMessage(null), 5000);
} catch (err) {
@@ -142,8 +165,8 @@ export function MaintenanceTriggerSection({
- Warning: This operation runs across all tenants and may take several minutes to complete.
- Use this only for testing or when immediate cleanup is needed.
+ Warning: This operation runs across all tenants. The run is queued and takes a few minutes in the background;
+ only one run is active at a time, the 2h timer included. Use this only for testing or when immediate cleanup is needed.
@@ -179,16 +202,35 @@ export function MaintenanceTriggerSection({
-
+
+
+ {runStatus.kind === "active" && (
+ <>
+ Maintenance run active — started{" "}
+ {new Date(runStatus.since).toLocaleString()} (triggered by {runStatus.triggeredBy}).
+ >
+ )}
+ {runStatus.kind === "failed" && (
+ <>
+ Last run failed ({new Date(runStatus.at).toLocaleString()}): {runStatus.message}
+ >
+ )}
+ {runStatus.kind === "completed" && (
+ <>
+ Last run completed {new Date(runStatus.at).toLocaleString()}
+ {formatRunDuration(parseOpsDetails<{ durationMs?: number }>(runStatus.details).durationMs)}.
+ >
+ )}
+