using System.Diagnostics.CodeAnalysis; using MicCheck.Api.Data; using Microsoft.EntityFrameworkCore; namespace MicCheck.Api.Features.Usage; [ExcludeFromCodeCoverage(Justification = "Timer-driven BackgroundService that issues raw SQL through a DI-scoped concrete MicCheckDbContext; exercising it cleanly requires a live DB, which CLAUDE.md disallows (no WebApplicationFactory/InMemory). DrainAccumulated's bucketing logic is covered by FeatureUsageMetricsTests.")] public class FeatureUsageFlushBackgroundService( FeatureUsageMetrics metrics, IServiceScopeFactory scopeFactory, ILogger logger) : BackgroundService { private static readonly TimeSpan FlushInterval = TimeSpan.FromSeconds(30); protected override async Task ExecuteAsync(CancellationToken stoppingToken) { using var timer = new PeriodicTimer(FlushInterval); while (await timer.WaitForNextTickAsync(stoppingToken)) { await FlushAsync(stoppingToken); } } public override async Task StopAsync(CancellationToken cancellationToken) { await base.StopAsync(cancellationToken); await FlushAsync(CancellationToken.None); } private async Task FlushAsync(CancellationToken ct) { var deltas = metrics.DrainAccumulated(); if (deltas.Count == 0) return; var updatedAt = DateTimeOffset.UtcNow; try { await using var scope = scopeFactory.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); foreach (var (key, count) in deltas) { await db.Database.ExecuteSqlAsync( $""" INSERT INTO "FeatureUsageDaily" ("EnvironmentId", "FeatureId", "FeatureName", "UsageDate", "Count", "UpdatedAt") VALUES ({key.EnvironmentId}, {key.FeatureId}, {key.FeatureName}, {key.UsageDate}, {count}, {updatedAt}) ON CONFLICT ("EnvironmentId", "FeatureId", "UsageDate") DO UPDATE SET "Count" = "FeatureUsageDaily"."Count" + EXCLUDED."Count", "FeatureName" = EXCLUDED."FeatureName", "UpdatedAt" = EXCLUDED."UpdatedAt" """, ct); } } catch (Exception ex) { logger.LogError(ex, "Failed to flush feature usage data ({BucketCount} buckets)", deltas.Count); } } }