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; }
}
}