Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TriggerMaintenanceFunction.cs
T
Oliver Kieselbach e164eac1eb feat(backend,web): manual maintenance trigger answers 202, the run is single-flight (D-255)
The trigger ran the whole maintenance inside the HTTP request (about three
minutes against a 230 s gateway limit): the portal reported a timeout while the
run went on, a second click started a second run, and nothing serialized those
runs against each other or against the 2h timer.

Backend
- MaintenanceRunGate: the timer and the manual run enter through one blob
  lease. A held lease writes MaintenanceSkippedLocked and runs nothing;
  MaintenanceStarted is written after the lease is held, the lease is released
  last.
- POST maintenance/trigger validates the date, probes the lease (409 while a
  run is active), enqueues on maintenance-trigger (fail-hard) and answers 202
  with the typed TriggerMaintenanceResponse. MaintenanceTriggerQueueWorker
  (self-polling) runs it; a failing or skipped run is not retried.
- The run report travels in the details of MaintenanceCompleted.
- BlobLeaseLockStore is the shared base of the lease lock stores;
  SessionDeletionMaintenanceLockStore derives from it, behaviour unchanged.
- The probe lets a failed lease release surface as 5xx: a probe lease still
  held at worker pickup would turn the queued run into a silent skip.

Web
- useOpsRunStatus: run status from ops events (latest Started newer than the
  latest Completed/Failed = active), used by the session-cleanup banner
  (rendering unchanged) and the maintenance trigger section.
- Trigger section: queued / already-active messages, status line, polls every
  15 s while a run is active, button disabled meanwhile. The 230 s fetch
  timeout is gone.

Shared manifest, generated wire types and the MCP ops-event vocabulary
regenerated (two new ops event types, reshaped response).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 13:50:06 +02:00

90 lines
3.7 KiB
C#

using System;
using System.Net;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services.Maintenance;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Admin
{
/// <summary>
/// <c>POST /api/maintenance/trigger</c> — manual Global Admin trigger of the platform
/// maintenance run. The run takes minutes, so the request only queues it:
/// <c>202 Accepted</c> when queued, <c>409</c> while a run (timer or manual) is active,
/// <c>500</c> when the enqueue failed — never a hollow success. Progress and the run report
/// surface as <c>Maintenance*</c> ops events.
/// </summary>
public class TriggerMaintenanceFunction
{
private readonly ILogger<TriggerMaintenanceFunction> _logger;
private readonly IMaintenanceTriggerProducer _producer;
private readonly MaintenanceRunGate _runGate;
public TriggerMaintenanceFunction(
ILogger<TriggerMaintenanceFunction> logger,
IMaintenanceTriggerProducer producer,
MaintenanceRunGate runGate)
{
_logger = logger;
_producer = producer;
_runGate = runGate;
}
/// <summary>
/// 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)
/// </summary>
[Function("TriggerMaintenance")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "maintenance/trigger")] HttpRequestData req,
FunctionContext context)
{
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware
var userEmail = TenantHelper.GetUserIdentifier(req) ?? "GlobalAdmin";
var ct = context.CancellationToken;
try
{
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;
bool aggregateOnly = req.Query["aggregateOnly"]?.ToLower() == "true";
if (await _runGate.IsRunActiveAsync(ct))
{
return await req.ConflictAsync("A maintenance run is already active.");
}
await _producer.EnqueueAsync(new MaintenanceTriggerEnvelope
{
TriggeredBy = userEmail,
TargetDate = targetDate,
AggregateOnly = aggregateOnly,
}, ct);
_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)
{
return await req.InternalServerErrorAsync(_logger, ex, "TriggerMaintenance");
}
}
}
}