Premiere commit - Omika
This commit is contained in:
@@ -0,0 +1,317 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user