318 lines
12 KiB
C#
318 lines
12 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using Omika.Diagnostics.Configuration;
|
|
using Omika.Diagnostics.Models;
|
|
using Omika.Diagnostics.Queries;
|
|
using Omika.Diagnostics.Repositories;
|
|
using Omika.Diagnostics.Tests.Fakes;
|
|
using Omika.Diagnostics.Worker;
|
|
|
|
namespace Omika.Diagnostics.Tests;
|
|
|
|
/// <summary>Phase 5C.2C — rétention PostgreSQL : lots bornés, fail-open, boucle séparée de l'import.</summary>
|
|
public class DiagnosticRetentionTests
|
|
{
|
|
private static DiagnosticEventRecord Old(DateTimeOffset t) =>
|
|
new() { EventId = Guid.NewGuid(), EventCode = "TEST", TimestampUtc = t, Source = "mobile", IngestedAtUtc = t };
|
|
|
|
private static DiagnosticEventRecord New() =>
|
|
Old(DateTimeOffset.UtcNow);
|
|
|
|
/// <summary>Événement d'ERREUR (level error) ancien : soumis à la règle « traité/non traité ».</summary>
|
|
private static DiagnosticEventRecord OldError(DateTimeOffset t) =>
|
|
new() { EventId = Guid.NewGuid(), EventCode = "TEST", Level = "error", TimestampUtc = t, Source = "mobile", IngestedAtUtc = t };
|
|
|
|
/// <summary>Identifiant URL-safe du groupe d'un événement (même normalisation que la purge).</summary>
|
|
private static string GroupId(DiagnosticEventRecord e) =>
|
|
DiagnosticErrorGroupLink.Encode(new DiagnosticErrorGroupIdentity(
|
|
e.EventCode,
|
|
DiagnosticErrorGrouping.NormalizeKey(e.ErrorCode),
|
|
DiagnosticErrorGrouping.NormalizeKey(e.ExceptionType),
|
|
DiagnosticErrorGrouping.NormalizeKey(e.Component)));
|
|
|
|
/// <summary>Marque le groupe d'un événement comme « traité » (tâche terminale), donc purgable.</summary>
|
|
private static void MarkTreated(FakeDiagnosticEventRepository repo, DiagnosticEventRecord e) =>
|
|
repo.MarkGroupTreated(GroupId(e));
|
|
|
|
// ---------------------------------------------------------------- repo ----
|
|
|
|
[Fact]
|
|
public async Task DeleteOlderThan_RemovesOnlyOlderThanCutoff()
|
|
{
|
|
var repo = new FakeDiagnosticEventRepository();
|
|
DateTimeOffset cutoff = DateTimeOffset.UtcNow.AddDays(-60);
|
|
|
|
DiagnosticEventRecord old1 = Old(cutoff.AddDays(-10));
|
|
DiagnosticEventRecord old2 = Old(cutoff.AddDays(-1));
|
|
DiagnosticEventRecord recent = New();
|
|
|
|
await repo.InsertBatchAsync([old1, old2, recent]);
|
|
|
|
int deleted = await repo.DeleteOlderThanAsync(cutoff, batchSize: 100);
|
|
|
|
Assert.Equal(2, deleted);
|
|
DiagnosticQueryResult<DiagnosticEventRecord> remaining =
|
|
await repo.QueryAsync(new DiagnosticEventQuery { PageSize = 10 });
|
|
Assert.Equal(1, remaining.TotalCount);
|
|
Assert.Equal(recent.EventId, remaining.Items.Single().EventId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeleteOlderThan_RespectsBatchSize()
|
|
{
|
|
var repo = new FakeDiagnosticEventRepository();
|
|
DateTimeOffset cutoff = DateTimeOffset.UtcNow.AddDays(-60);
|
|
|
|
var old = new List<DiagnosticEventRecord>();
|
|
for (int i = 0; i < 5; i++)
|
|
{
|
|
old.Add(Old(cutoff.AddDays(-i - 1)));
|
|
}
|
|
|
|
await repo.InsertBatchAsync(old);
|
|
|
|
// Un lot de 2 ne supprime que 2 lignes ; la purge est itérative.
|
|
int first = await repo.DeleteOlderThanAsync(cutoff, batchSize: 2);
|
|
int second = await repo.DeleteOlderThanAsync(cutoff, batchSize: 2);
|
|
int third = await repo.DeleteOlderThanAsync(cutoff, batchSize: 2);
|
|
|
|
Assert.Equal(2, first);
|
|
Assert.Equal(2, second);
|
|
Assert.Equal(1, third);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeleteOlderThan_EmptyTable_ReturnsZero()
|
|
{
|
|
var repo = new FakeDiagnosticEventRepository();
|
|
int deleted = await repo.DeleteOlderThanAsync(DateTimeOffset.UtcNow.AddDays(-60), batchSize: 100);
|
|
Assert.Equal(0, deleted);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeleteOlderThan_NonPositiveBatchSize_ReturnsZero()
|
|
{
|
|
var repo = new FakeDiagnosticEventRepository();
|
|
await repo.InsertBatchAsync([Old(DateTimeOffset.UtcNow.AddDays(-100))]);
|
|
|
|
int deleted = await repo.DeleteOlderThanAsync(DateTimeOffset.UtcNow.AddDays(-60), batchSize: 0);
|
|
|
|
Assert.Equal(0, deleted);
|
|
// Aucune suppression malgré un batch invalide (config invalide => inerte).
|
|
DiagnosticQueryResult<DiagnosticEventRecord> remaining =
|
|
await repo.QueryAsync(new DiagnosticEventQuery { PageSize = 10 });
|
|
Assert.Equal(1, remaining.TotalCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeleteOlderThan_SkipsUntreatedGroup()
|
|
{
|
|
var repo = new FakeDiagnosticEventRepository();
|
|
DateTimeOffset cutoff = DateTimeOffset.UtcNow.AddDays(-60);
|
|
|
|
// Une ERREUR ancienne SANS tâche terminale : elle ne doit JAMAIS être purgée,
|
|
// même largement au-delà du cutoff.
|
|
DiagnosticEventRecord untreated = OldError(cutoff.AddDays(-200));
|
|
await repo.InsertBatchAsync([untreated]);
|
|
|
|
int deleted = await repo.DeleteOlderThanAsync(cutoff, batchSize: 100);
|
|
|
|
Assert.Equal(0, deleted);
|
|
DiagnosticQueryResult<DiagnosticEventRecord> remaining =
|
|
await repo.QueryAsync(new DiagnosticEventQuery { PageSize = 10 });
|
|
Assert.Equal(1, remaining.TotalCount);
|
|
Assert.Equal(untreated.EventId, remaining.Items.Single().EventId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeleteOlderThan_PurgesOnlyTreatedGroups()
|
|
{
|
|
var repo = new FakeDiagnosticEventRepository();
|
|
DateTimeOffset cutoff = DateTimeOffset.UtcNow.AddDays(-60);
|
|
|
|
DiagnosticEventRecord treated = OldError(cutoff.AddDays(-100));
|
|
DiagnosticEventRecord untreated = new()
|
|
{
|
|
EventId = Guid.NewGuid(),
|
|
EventCode = "AUTRE",
|
|
Level = "error",
|
|
TimestampUtc = cutoff.AddDays(-100),
|
|
Source = "mobile",
|
|
IngestedAtUtc = cutoff.AddDays(-100)
|
|
};
|
|
|
|
await repo.InsertBatchAsync([treated, untreated]);
|
|
MarkTreated(repo, treated); // seul le groupe « TEST » est traité
|
|
|
|
int deleted = await repo.DeleteOlderThanAsync(cutoff, batchSize: 100);
|
|
|
|
Assert.Equal(1, deleted);
|
|
DiagnosticQueryResult<DiagnosticEventRecord> remaining =
|
|
await repo.QueryAsync(new DiagnosticEventQuery { PageSize = 10 });
|
|
Assert.Single(remaining.Items);
|
|
Assert.Equal(untreated.EventId, remaining.Items.Single().EventId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeleteOlderThan_OpenTaskGroup_NotPurged()
|
|
{
|
|
// Un groupe possède une tâche NON terminale (Todo/InProgress) : il n'est pas
|
|
// « traité » et ses événements sont conservés (équivalent à l'absence de
|
|
// tâche terminale dans le rapprochement SQL).
|
|
var repo = new FakeDiagnosticEventRepository();
|
|
DateTimeOffset cutoff = DateTimeOffset.UtcNow.AddDays(-60);
|
|
|
|
DiagnosticEventRecord old = OldError(cutoff.AddDays(-100));
|
|
await repo.InsertBatchAsync([old]);
|
|
// Pas de MarkGroupTreated : la tâche (Todo/InProgress) ne rend pas le groupe purgable.
|
|
|
|
int deleted = await repo.DeleteOlderThanAsync(cutoff, batchSize: 100);
|
|
|
|
Assert.Equal(0, deleted);
|
|
DiagnosticQueryResult<DiagnosticEventRecord> remaining =
|
|
await repo.QueryAsync(new DiagnosticEventQuery { PageSize = 10 });
|
|
Assert.Equal(1, remaining.TotalCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeleteOlderThan_NonError_AlwaysPurged()
|
|
{
|
|
// Un événement NON-erreur (trace/debug/info/warning, ou level NULL) est purgé
|
|
// après le cutoff MÊME sans aucune tâche de traitement : la règle « traité/non
|
|
// traité » ne s'applique qu'aux erreurs.
|
|
var repo = new FakeDiagnosticEventRepository();
|
|
DateTimeOffset cutoff = DateTimeOffset.UtcNow.AddDays(-60);
|
|
|
|
DiagnosticEventRecord info = new()
|
|
{
|
|
EventId = Guid.NewGuid(),
|
|
EventCode = "MOBILE_SCREEN_VIEW",
|
|
Level = "info",
|
|
TimestampUtc = cutoff.AddDays(-100),
|
|
Source = "mobile",
|
|
IngestedAtUtc = cutoff.AddDays(-100)
|
|
};
|
|
|
|
await repo.InsertBatchAsync([info]);
|
|
// Aucun MarkGroupTreated : les non-erreurs ne dépendent pas des tâches.
|
|
|
|
int deleted = await repo.DeleteOlderThanAsync(cutoff, batchSize: 100);
|
|
|
|
Assert.Equal(1, deleted);
|
|
DiagnosticQueryResult<DiagnosticEventRecord> remaining =
|
|
await repo.QueryAsync(new DiagnosticEventQuery { PageSize = 10 });
|
|
Assert.Equal(0, remaining.TotalCount);
|
|
}
|
|
|
|
// -------------------------------------------------------------- worker ----
|
|
|
|
private static DiagnosticRetentionWorker CreateWorker(
|
|
IDiagnosticEventRepository repo,
|
|
DiagnosticStoreOptions options) =>
|
|
new(repo, Options.Create(options), NullLogger<DiagnosticRetentionWorker>.Instance);
|
|
|
|
[Fact]
|
|
public async Task RetentionWorker_PurgesOlderEvents()
|
|
{
|
|
var repo = new FakeDiagnosticEventRepository();
|
|
DateTimeOffset cutoff = DateTimeOffset.UtcNow.AddDays(-60);
|
|
|
|
DiagnosticEventRecord oldA = Old(cutoff.AddDays(-5));
|
|
DiagnosticEventRecord oldB = Old(cutoff.AddDays(-1));
|
|
DiagnosticEventRecord recent = New();
|
|
await repo.InsertBatchAsync([oldA, oldB, recent]);
|
|
|
|
var worker = CreateWorker(repo, new DiagnosticStoreOptions
|
|
{
|
|
RetentionDays = 60,
|
|
RetentionBatchSize = 100,
|
|
RetentionIntervalHours = 1
|
|
});
|
|
|
|
using var cts = new CancellationTokenSource();
|
|
await worker.StartAsync(cts.Token);
|
|
|
|
// La première passe est immédiate : on attend la purge (poll borné).
|
|
int total = 3;
|
|
for (int i = 0; i < 50; i++)
|
|
{
|
|
DiagnosticQueryResult<DiagnosticEventRecord> r =
|
|
await repo.QueryAsync(new DiagnosticEventQuery { PageSize = 10 });
|
|
total = r.TotalCount;
|
|
if (total == 1)
|
|
{
|
|
break;
|
|
}
|
|
|
|
await Task.Delay(100);
|
|
}
|
|
|
|
cts.Cancel();
|
|
await worker.StopAsync(cts.Token);
|
|
|
|
Assert.Equal(1, total); // seul l'événement récent subsiste
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RetentionWorker_Disabled_DoesNotPurge()
|
|
{
|
|
var repo = new FakeDiagnosticEventRepository();
|
|
await repo.InsertBatchAsync([Old(DateTimeOffset.UtcNow.AddDays(-100))]);
|
|
|
|
var worker = CreateWorker(repo, new DiagnosticStoreOptions
|
|
{
|
|
RetentionDays = 0, // désactivé
|
|
RetentionBatchSize = 100,
|
|
RetentionIntervalHours = 1
|
|
});
|
|
|
|
using var cts = new CancellationTokenSource();
|
|
await worker.StartAsync(cts.Token);
|
|
await Task.Delay(300);
|
|
cts.Cancel();
|
|
await worker.StopAsync(cts.Token);
|
|
|
|
DiagnosticQueryResult<DiagnosticEventRecord> remaining =
|
|
await repo.QueryAsync(new DiagnosticEventQuery { PageSize = 10 });
|
|
Assert.Equal(1, remaining.TotalCount); // rien de purgé
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RetentionWorker_DbDown_DoesNotCrash()
|
|
{
|
|
// PostgreSQL down : la purge lève, le worker attrape (fail-open) et ne
|
|
// crashe pas. L'import (boucle séparée) n'est jamais impacté.
|
|
var worker = CreateWorker(new ThrowingDiagnosticRepository(), new DiagnosticStoreOptions
|
|
{
|
|
RetentionDays = 60,
|
|
RetentionBatchSize = 100,
|
|
RetentionIntervalHours = 1
|
|
});
|
|
|
|
using var cts = new CancellationTokenSource();
|
|
await worker.StartAsync(cts.Token); // ne doit pas lever
|
|
await Task.Delay(300);
|
|
cts.Cancel();
|
|
await worker.StopAsync(cts.Token); // arrêt propre
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RetentionWorker_Cancellation_Stops()
|
|
{
|
|
var repo = new FakeDiagnosticEventRepository();
|
|
var worker = CreateWorker(repo, new DiagnosticStoreOptions
|
|
{
|
|
RetentionDays = 60,
|
|
RetentionBatchSize = 100,
|
|
RetentionIntervalHours = 1
|
|
});
|
|
|
|
using var cts = new CancellationTokenSource();
|
|
await worker.StartAsync(cts.Token);
|
|
cts.Cancel();
|
|
|
|
// L'annulation doit terminer ExecuteAsync sans exception.
|
|
await worker.StopAsync(cts.Token);
|
|
}
|
|
}
|