From d7254dc0858e4812703218219a479fd9ead8fafa Mon Sep 17 00:00:00 2001 From: Rockford Lhotka Date: Wed, 18 Mar 2026 23:27:26 -0700 Subject: [PATCH] feat: add data retention service to purge old posts and notifications Background service runs every 24h (configurable) and deletes posts and notifications older than 30 days (configurable). Uses EF Core bulk ExecuteDeleteAsync for efficiency. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Repositories/ISocialDataRepository.cs | 4 ++ .../Repositories/SocialDataRepository.cs | 14 +++++ src/SocialAgent.Host/Program.cs | 1 + .../Services/DataRetentionService.cs | 54 +++++++++++++++++++ src/SocialAgent.Host/appsettings.json | 2 + 5 files changed, 75 insertions(+) create mode 100644 src/SocialAgent.Host/Services/DataRetentionService.cs diff --git a/src/SocialAgent.Data/Repositories/ISocialDataRepository.cs b/src/SocialAgent.Data/Repositories/ISocialDataRepository.cs index 986e62a..2e75b21 100644 --- a/src/SocialAgent.Data/Repositories/ISocialDataRepository.cs +++ b/src/SocialAgent.Data/Repositories/ISocialDataRepository.cs @@ -21,4 +21,8 @@ public interface ISocialDataRepository // Poll State Task GetPollStateAsync(string providerId, CancellationToken ct = default); Task UpsertPollStateAsync(PollState state, CancellationToken ct = default); + + // Retention + Task PurgeOldPostsAsync(DateTimeOffset olderThan, CancellationToken ct = default); + Task PurgeOldNotificationsAsync(DateTimeOffset olderThan, CancellationToken ct = default); } diff --git a/src/SocialAgent.Data/Repositories/SocialDataRepository.cs b/src/SocialAgent.Data/Repositories/SocialDataRepository.cs index af8ec48..5a86a69 100644 --- a/src/SocialAgent.Data/Repositories/SocialDataRepository.cs +++ b/src/SocialAgent.Data/Repositories/SocialDataRepository.cs @@ -138,4 +138,18 @@ public async Task UpsertPollStateAsync(PollState state, CancellationToken ct = d } await db.SaveChangesAsync(ct); } + + public async Task PurgeOldPostsAsync(DateTimeOffset olderThan, CancellationToken ct = default) + { + return await db.Posts + .Where(p => p.CreatedAt < olderThan) + .ExecuteDeleteAsync(ct); + } + + public async Task PurgeOldNotificationsAsync(DateTimeOffset olderThan, CancellationToken ct = default) + { + return await db.Notifications + .Where(n => n.CreatedAt < olderThan) + .ExecuteDeleteAsync(ct); + } } diff --git a/src/SocialAgent.Host/Program.cs b/src/SocialAgent.Host/Program.cs index 0235674..81b8974 100644 --- a/src/SocialAgent.Host/Program.cs +++ b/src/SocialAgent.Host/Program.cs @@ -45,6 +45,7 @@ // Background services builder.Services.AddHostedService(); builder.Services.AddHostedService(); +builder.Services.AddHostedService(); // Authentication (API key required in non-Development environments) builder.Services.AddApiKeyAuthentication(builder.Configuration); diff --git a/src/SocialAgent.Host/Services/DataRetentionService.cs b/src/SocialAgent.Host/Services/DataRetentionService.cs new file mode 100644 index 0000000..3e957e4 --- /dev/null +++ b/src/SocialAgent.Host/Services/DataRetentionService.cs @@ -0,0 +1,54 @@ +using SocialAgent.Data.Repositories; + +namespace SocialAgent.Host.Services; + +public class DataRetentionService( + IServiceScopeFactory scopeFactory, + ILogger logger, + IConfiguration configuration) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var retentionDays = configuration.GetValue("SocialAgent:RetentionDays", 30); + var intervalHours = configuration.GetValue("SocialAgent:RetentionCheckIntervalHours", 24); + + logger.LogInformation( + "Data retention service starting: {RetentionDays} day retention, checking every {Interval}h", + retentionDays, intervalHours); + + // Delay startup to let migrations complete + await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await PurgeOldDataAsync(retentionDays, stoppingToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogError(ex, "Error during data retention purge"); + } + + await Task.Delay(TimeSpan.FromHours(intervalHours), stoppingToken); + } + } + + private async Task PurgeOldDataAsync(int retentionDays, CancellationToken ct) + { + var cutoff = DateTimeOffset.UtcNow.AddDays(-retentionDays); + + using var scope = scopeFactory.CreateScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + + var postsDeleted = await repository.PurgeOldPostsAsync(cutoff, ct); + var notificationsDeleted = await repository.PurgeOldNotificationsAsync(cutoff, ct); + + if (postsDeleted > 0 || notificationsDeleted > 0) + { + logger.LogInformation( + "Data retention purge: deleted {Posts} posts and {Notifications} notifications older than {Days} days", + postsDeleted, notificationsDeleted, retentionDays); + } + } +} diff --git a/src/SocialAgent.Host/appsettings.json b/src/SocialAgent.Host/appsettings.json index 2e181ab..4b69de6 100644 --- a/src/SocialAgent.Host/appsettings.json +++ b/src/SocialAgent.Host/appsettings.json @@ -16,6 +16,8 @@ }, "SocialAgent": { "PollingIntervalMinutes": 5, + "RetentionDays": 30, + "RetentionCheckIntervalHours": 24, "DatabaseProvider": "Sqlite", "Providers": { "Mastodon": {