diff --git a/src/Infrastructure/BotSharp.Abstraction/Repositories/IBotSharpRepository.cs b/src/Infrastructure/BotSharp.Abstraction/Repositories/IBotSharpRepository.cs index 81657b651..cbeff2d11 100644 --- a/src/Infrastructure/BotSharp.Abstraction/Repositories/IBotSharpRepository.cs +++ b/src/Infrastructure/BotSharp.Abstraction/Repositories/IBotSharpRepository.cs @@ -201,6 +201,9 @@ Task> GetConversationStateLogs(str #region Log Cleanup Task DeleteOldConversationLogs(int retentionDays, int batchSize) => throw new NotImplementedException(); + + Task DeleteOldInstructionLogs(int retentionDays, int batchSize) + => throw new NotImplementedException(); #endregion #region Instruction Log diff --git a/src/Infrastructure/BotSharp.Core/Repository/FileRepository/FileRepository.Log.cs b/src/Infrastructure/BotSharp.Core/Repository/FileRepository/FileRepository.Log.cs index 4e51966d1..ea350ea1a 100644 --- a/src/Infrastructure/BotSharp.Core/Repository/FileRepository/FileRepository.Log.cs +++ b/src/Infrastructure/BotSharp.Core/Repository/FileRepository/FileRepository.Log.cs @@ -185,6 +185,12 @@ public async Task DeleteOldConversationLogs(int retentionDays, int batchSiz // as it's typically used for local dev. Implementing it would require iterating all conversations. return await Task.FromResult(0); } + + public async Task DeleteOldInstructionLogs(int retentionDays, int batchSize = 2000) + { + // For file repository, keep the same cleanup behavior as conversation logs. + return await Task.FromResult(0); + } #endregion #region Instruction Log diff --git a/src/Infrastructure/BotSharp.OpenAPI/BotSharpOpenApiExtensions.cs b/src/Infrastructure/BotSharp.OpenAPI/BotSharpOpenApiExtensions.cs index 6d288bff5..333de5cd9 100644 --- a/src/Infrastructure/BotSharp.OpenAPI/BotSharpOpenApiExtensions.cs +++ b/src/Infrastructure/BotSharp.OpenAPI/BotSharpOpenApiExtensions.cs @@ -238,6 +238,10 @@ public static IServiceCollection AddCrontabServices(this IServiceCollection serv services.AddScoped(); services.AddScoped(); services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); return services; } diff --git a/src/Infrastructure/BotSharp.OpenAPI/Hooks/InstructionLogCleanupCrontabHook.cs b/src/Infrastructure/BotSharp.OpenAPI/Hooks/InstructionLogCleanupCrontabHook.cs new file mode 100644 index 000000000..4a7dc26d3 --- /dev/null +++ b/src/Infrastructure/BotSharp.OpenAPI/Hooks/InstructionLogCleanupCrontabHook.cs @@ -0,0 +1,65 @@ +using BotSharp.Abstraction.Crontab; +using BotSharp.Abstraction.Crontab.Models; +using BotSharp.Abstraction.Repositories; +using BotSharp.OpenAPI.RuleTriggers; +using Microsoft.Extensions.Hosting; + +namespace BotSharp.OpenAPI.Hooks +{ + public class InstructionLogCleanupCrontabHook : ICrontabHook + { + private readonly ConversationSetting _settings; + private readonly IBotSharpRepository _db; + private readonly ILogger _logger; + private readonly IHostApplicationLifetime _appLifetime; + + public string[]? Triggers => new[] { nameof(InstructionLogCleanupRuleTrigger) }; + + public InstructionLogCleanupCrontabHook( + ConversationSetting settings, + IBotSharpRepository db, + ILogger logger, + IHostApplicationLifetime appLifetime) + { + _settings = settings; + _db = db; + _logger = logger; + _appLifetime = appLifetime; + } + + public async Task OnCronTriggered(CrontabItem item) + { + var cleanSetting = _settings.CleanSetting; + + if (cleanSetting == null || !cleanSetting.Enable || cleanSetting.LogRetentionDays <= 0) return; + + int totalDeleted = 0; + var cancellationToken = _appLifetime.ApplicationStopping; + + while (!cancellationToken.IsCancellationRequested) + { + var deletedCount = await _db.DeleteOldInstructionLogs(cleanSetting.LogRetentionDays, cleanSetting.LogBatchSize); + if (deletedCount == 0) break; + + totalDeleted += deletedCount; + _logger.LogInformation($"Cleaned {deletedCount} instruction logs older than {cleanSetting.LogRetentionDays} days in this batch."); + + try + { + // Sleep slightly to yield database resources, will throw TaskCanceledException on shutdown + await Task.Delay(1000, cancellationToken); + } + catch (OperationCanceledException) + { + _logger.LogWarning("Instruction log cleanup was interrupted due to application shutdown."); + break; + } + } + + if (totalDeleted > 0) + { + _logger.LogInformation($"Successfully cleaned a total of {totalDeleted} instruction logs older than {cleanSetting.LogRetentionDays} days."); + } + } + } +} diff --git a/src/Infrastructure/BotSharp.OpenAPI/RuleTriggers/InstructionLogCleanupRuleTrigger.cs b/src/Infrastructure/BotSharp.OpenAPI/RuleTriggers/InstructionLogCleanupRuleTrigger.cs new file mode 100644 index 000000000..f13bfe00a --- /dev/null +++ b/src/Infrastructure/BotSharp.OpenAPI/RuleTriggers/InstructionLogCleanupRuleTrigger.cs @@ -0,0 +1,25 @@ +using BotSharp.Abstraction.Crontab; +using BotSharp.Abstraction.Crontab.Models; +using BotSharp.Abstraction.Rules; + +namespace BotSharp.OpenAPI.RuleTriggers +{ + public class InstructionLogCleanupRuleTrigger : IRuleTrigger, ICrontabSource + { + public string Channel => ConversationChannel.Crontab; + public string Name => nameof(InstructionLogCleanupRuleTrigger); + public string EntityType { get; set; } = string.Empty; + public string EntityId { get; set; } = string.Empty; + + public CrontabItem GetCrontabItem() + { + return new CrontabItem + { + Title = nameof(InstructionLogCleanupRuleTrigger), + Description = "Clean up old instruction logs daily", + Cron = "0 6 * * *", // Run at 6:00 AM UTC (Midnight Chicago Standard Time) + TriggerType = CronTabItemTriggerType.MessageQueue + }; + } + } +} diff --git a/src/Plugins/BotSharp.Plugin.MongoStorage/Repository/MongoRepository.Log.cs b/src/Plugins/BotSharp.Plugin.MongoStorage/Repository/MongoRepository.Log.cs index 2444f4bd0..9cfc04b2e 100644 --- a/src/Plugins/BotSharp.Plugin.MongoStorage/Repository/MongoRepository.Log.cs +++ b/src/Plugins/BotSharp.Plugin.MongoStorage/Repository/MongoRepository.Log.cs @@ -168,6 +168,23 @@ public async Task DeleteOldConversationLogs(int retentionDays, int batchSiz return (int)(contentDeletedCount + stateDeletedCount); } + + public async Task DeleteOldInstructionLogs(int retentionDays, int batchSize) + { + if (retentionDays <= 0) return 0; + var threshold = DateTime.UtcNow.AddDays(-retentionDays); + + var filter = Builders.Filter.Lt(x => x.CreatedTime, threshold); + var docsToDelete = await _dc.InstructionLogs.Find(filter).Limit(batchSize).Project(x => x.Id).ToListAsync(); + if (!docsToDelete.Any()) + { + return 0; + } + + var deleteFilter = Builders.Filter.In(x => x.Id, docsToDelete); + var deleted = await _dc.InstructionLogs.DeleteManyAsync(deleteFilter); + return (int)deleted.DeletedCount; + } #endregion #region Instruction Log