using System; using System.Threading; using System.Threading.Tasks; using AutopilotMonitor.Shared.DataAccess; using Microsoft.Azure.Functions.Worker; using Microsoft.Extensions.Logging; namespace AutopilotMonitor.Functions.Functions.Maintenance; /// /// Daily housekeeping for the ScriptNameCache table that backs Intune script /// display-name resolution (Graph Add-On feature). /// /// Positive (found) rows older than are deleted so a /// renamed Intune script eventually surfaces under its new name. /// Negative (NotFound) rows expire much faster () — they /// exist only to suppress repeated 404 fetches for genuinely deleted scripts; /// once the cache is gone the resolver will lazily re-check. /// Per-tenant meta rows ("{Kind}_$meta") live forever; they're tiny and /// steer the resolver's "do a full pull vs. per-ID fallback" decision. /// /// Failure on a single row is logged and skipped — the timer never poisons. Next-day /// run picks up whatever was missed. /// public class ScriptNameCacheCleanupFunction { /// Positive entries are usable for 7 days; covers all reasonable troubleshooting timeframes. public static readonly TimeSpan PositiveTtl = TimeSpan.FromDays(7); /// Negative-cache rows expire in 1h so deleted/renamed scripts come back quickly. public static readonly TimeSpan NotFoundTtl = TimeSpan.FromHours(1); /// 03:00 UTC daily — outside European working hours, off the busy maintenance window. private const string Cron = "0 0 3 * * *"; private readonly IScriptNameCacheRepository _repo; private readonly ILogger _logger; private readonly TimeProvider _time; public ScriptNameCacheCleanupFunction( IScriptNameCacheRepository repo, ILogger logger) : this(repo, logger, TimeProvider.System) { } /// Test seam — inject a fake for deterministic TTL math. internal ScriptNameCacheCleanupFunction( IScriptNameCacheRepository repo, ILogger logger, TimeProvider time) { _repo = repo; _logger = logger; _time = time; } [Function("ScriptNameCacheCleanup")] public async Task Run([TimerTrigger(Cron)] object timer, CancellationToken cancellationToken) { await RunCoreAsync(cancellationToken).ConfigureAwait(false); } /// Testable core — bypasses TimerInfo / FunctionContext. internal async Task RunCoreAsync(CancellationToken ct) { var now = _time.GetUtcNow(); var positiveCutoff = now - PositiveTtl; var notFoundCutoff = now - NotFoundTtl; var result = new CleanupResult(); await foreach (var entry in _repo.QueryExpiredAsync(positiveCutoff, notFoundCutoff, ct)) { ct.ThrowIfCancellationRequested(); result.Scanned++; try { await _repo.DeleteAsync(entry.TenantId, entry.Kind, entry.ScriptId, ct).ConfigureAwait(false); if (entry.IsNotFound) result.NotFoundDeleted++; else result.PositiveDeleted++; } catch (Exception ex) { _logger.LogWarning(ex, "ScriptNameCacheCleanup: delete failed for tenant={TenantId} kind={Kind} id={ScriptId} — will retry next run", entry.TenantId, entry.Kind, entry.ScriptId); result.Failures++; } } _logger.LogInformation( "ScriptNameCacheCleanup completed: scanned={Scanned} positiveDeleted={PositiveDeleted} notFoundDeleted={NotFoundDeleted} failures={Failures}", result.Scanned, result.PositiveDeleted, result.NotFoundDeleted, result.Failures); return result; } /// Summary counters returned from so tests can assert outcomes. internal sealed class CleanupResult { public int Scanned { get; set; } public int PositiveDeleted { get; set; } public int NotFoundDeleted { get; set; } public int Failures { get; set; } } }