mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 21:19:38 +00:00
5ba4fb7dc9
Several append-only tables had no purge mechanism and grew unbounded. Add age-based retention to the 2h MaintenanceFunction (and the manual path) via a new CleanupUnboundedTablesAsync orchestrator (per-table try/catch, fixed const windows, mirroring the DistressReports pattern): - GlobalNotifications + TenantNotifications: hybrid retention — dismissed rows pruned 30d after DismissedAt, any row pruned 180d after CreatedAt. Unread (still-actionable) admin warnings survive the dismiss window so they are never silently lost; the 180d catch-all bounds the table. Shared pure predicate NotificationRetentionFilter.BuildPredicate (unit-tested). - HardwareRejectionNotificationTracker: 30d (FirstNotifiedAt). Pruning re-arms the lifetime dedup, so a model rejected again >30d later re-bells (intended). - AuditLogs: 180d (system Timestamp, cross-partition). - UsageMetrics: 180d (PartitionKey date-string range). - BackupJobs: 365d (QueuedAtUtc). PlatformStats intentionally excluded — it is a single upserted row, not append-only. Bootstrap sessions left as-is (feature currently unused). Each repo delete mirrors DeleteUserActivityOlderThanAsync: server-side "X lt cutoff" filter + select PK/RK + delete loop. Deletes are resumable across runs, so a partial run self-heals on the next cycle. Observability: end-of-run MaintenanceLongRunning Warning OpsEvent if RunAllAsync exceeds 10min, dual-registered in OpsAlertRulesSection.tsx, to surface a run climbing toward the 60min functionTimeout before a future hard-abort (which emits nothing). Inert until backend redeploy; first post-deploy run bulk-prunes the backlog. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
116 lines
4.7 KiB
C#
116 lines
4.7 KiB
C#
using AutopilotMonitor.Functions.Services;
|
|
using AutopilotMonitor.Shared;
|
|
using AutopilotMonitor.Shared.DataAccess;
|
|
using Azure;
|
|
using Azure.Data.Tables;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
|
{
|
|
/// <summary>
|
|
/// Table Storage implementation of <see cref="IHardwareRejectionNotificationTracker"/>.
|
|
/// PartitionKey = tenantId (lowercased), RowKey = "{manufacturer-lower}|{model-lower}" (trimmed).
|
|
/// Race-safe via AddEntityAsync: Azure Table Storage returns 409 Conflict if the entity already exists.
|
|
/// </summary>
|
|
public class TableHardwareRejectionNotificationTracker : IHardwareRejectionNotificationTracker
|
|
{
|
|
private readonly TableClient _table;
|
|
private readonly ILogger<TableHardwareRejectionNotificationTracker> _logger;
|
|
|
|
public TableHardwareRejectionNotificationTracker(
|
|
TableStorageService storage,
|
|
ILogger<TableHardwareRejectionNotificationTracker> logger)
|
|
{
|
|
_logger = logger;
|
|
_table = storage.GetTableClient(Constants.TableNames.HardwareRejectionNotificationTracker);
|
|
}
|
|
|
|
public async Task<bool> TryRegisterFirstNotificationAsync(string tenantId, string manufacturer, string model)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(tenantId)
|
|
|| string.IsNullOrWhiteSpace(manufacturer)
|
|
|| string.IsNullOrWhiteSpace(model))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var partitionKey = tenantId.ToLowerInvariant();
|
|
var rowKey = BuildRowKey(manufacturer, model);
|
|
|
|
var entity = new TableEntity(partitionKey, rowKey)
|
|
{
|
|
["TenantId"] = tenantId,
|
|
["Manufacturer"] = manufacturer,
|
|
["Model"] = model,
|
|
["FirstNotifiedAt"] = DateTime.UtcNow
|
|
};
|
|
|
|
try
|
|
{
|
|
await _table.AddEntityAsync(entity);
|
|
_logger.LogInformation(
|
|
"HardwareRejection tracker registered: tenant={TenantId} mfr={Manufacturer} model={Model}",
|
|
tenantId, manufacturer, model);
|
|
return true;
|
|
}
|
|
catch (RequestFailedException ex) when (ex.Status == 409)
|
|
{
|
|
// Already notified for this (tenant, manufacturer, model) — no second bell.
|
|
return false;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex,
|
|
"HardwareRejection tracker failed: tenant={TenantId} mfr={Manufacturer} model={Model}",
|
|
tenantId, manufacturer, model);
|
|
// On unexpected failure, return false so we do not double-fire if the row was actually written.
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public async Task<int> DeleteOlderThanAsync(DateTime cutoffUtc)
|
|
{
|
|
try
|
|
{
|
|
// Rows are insert-once (AddEntityAsync, never updated), so FirstNotifiedAt == creation time.
|
|
// Pruning here resets the lifetime dedup for a given model: if the same model is rejected
|
|
// again after the cutoff, the bell fires once more — acceptable, since the portal only
|
|
// surfaces recent rejections anyway.
|
|
var filter = $"FirstNotifiedAt lt datetime'{cutoffUtc:yyyy-MM-ddTHH:mm:ss}Z'";
|
|
var query = _table.QueryAsync<TableEntity>(filter: filter, select: new[] { "PartitionKey", "RowKey" });
|
|
|
|
int deleted = 0;
|
|
await foreach (var entity in query)
|
|
{
|
|
try
|
|
{
|
|
await _table.DeleteEntityAsync(entity.PartitionKey, entity.RowKey);
|
|
deleted++;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to delete hardware-rejection tracker row {PK}/{RK}", entity.PartitionKey, entity.RowKey);
|
|
}
|
|
}
|
|
|
|
if (deleted > 0)
|
|
_logger.LogInformation("Deleted {Count} hardware-rejection tracker rows older than {Cutoff:yyyy-MM-dd}", deleted, cutoffUtc);
|
|
|
|
return deleted;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to delete old hardware-rejection tracker rows");
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
internal static string BuildRowKey(string manufacturer, string model)
|
|
{
|
|
var mfr = (manufacturer ?? string.Empty).Trim().ToLowerInvariant();
|
|
var mdl = (model ?? string.Empty).Trim().ToLowerInvariant();
|
|
return $"{mfr}|{mdl}";
|
|
}
|
|
}
|
|
}
|