diff --git a/Common/OpenShockDb/ShockerControlLogQueries.cs b/Common/OpenShockDb/ShockerControlLogQueries.cs new file mode 100644 index 00000000..04080dfa --- /dev/null +++ b/Common/OpenShockDb/ShockerControlLogQueries.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore; + +namespace OpenShock.Common.OpenShockDb; + +/// +/// Reusable commands over the shocker_control_logs table. Kept here (rather than inline in the +/// Cron cleanup job) so the raw SQL is a single source of truth that integration tests can execute +/// directly - a table or column rename then breaks the test rather than surfacing only in production. +/// +public static class ShockerControlLogQueries +{ + /// + /// Retention trim: per owning user, keeps the newest control logs (by + /// created_at) and deletes the rest. Ownership is resolved through + /// shocker -> device -> owner, so the cap is per user, not per shocker or device. Runs as a + /// single set-based statement (a window function ranks each user's logs newest-first; anything past the + /// cap is deleted). Returns the number of rows deleted. + /// + public static Task DeleteControlLogsBeyondPerUserLimitAsync(this OpenShockContext db, int maxPerUser, + CancellationToken cancellationToken = default) + { + if (maxPerUser < 0) throw new ArgumentOutOfRangeException(nameof(maxPerUser)); + + return db.Database.ExecuteSqlAsync( + $""" + WITH ranked_logs AS ( + SELECT + l.id, + ROW_NUMBER() OVER (PARTITION BY d.owner_id ORDER BY l.created_at DESC) AS rn + FROM shocker_control_logs l + JOIN shockers s ON s.id = l.shocker_id + JOIN devices d ON d.id = s.device_id + ) + DELETE FROM shocker_control_logs l + USING ranked_logs rl + WHERE l.id = rl.id + AND rl.rn > {maxPerUser} + """, cancellationToken); + } +} diff --git a/Cron.IntegrationTests/Tests/ControlLogRetentionTests.cs b/Cron.IntegrationTests/Tests/ControlLogRetentionTests.cs new file mode 100644 index 00000000..e4f436d9 --- /dev/null +++ b/Cron.IntegrationTests/Tests/ControlLogRetentionTests.cs @@ -0,0 +1,137 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using OpenShock.Common.Models; +using OpenShock.Common.OpenShockDb; +using OpenShock.Common.Utils; + +namespace OpenShock.Cron.IntegrationTests.Tests; + +/// +/// End-to-end coverage for the Cron retention trim against real Postgres. Runs the cleanup job's *actual* +/// statement (, the single +/// source of truth the job uses) over a seeded graph, so the real table/column names, the +/// shocker -> device -> owner join, the per-user window function, and newest-first ordering are exercised. +/// A schema rename breaks this test rather than only the Cron host at runtime. +/// +public sealed class ControlLogRetentionTests +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required CronApplicationFactory Factory { get; init; } + + [Test] + public async Task DeleteControlLogsBeyondPerUserLimit_TrimsPerOwner_KeepsNewest_AndSparesOwnersUnderTheLimit() + { + const int keep = 10; + + // Owner A is over the limit; owner B is under it. A's logs are attributed to two *different* + // controller users (each under the limit on its own) and B's logs to B itself - so the trim is only + // correct if it partitions by the shocker's OWNER (d.owner_id). If it instead partitioned by the + // controller (l.controlled_by_user_id) it would delete nothing (no controller exceeds the limit); if + // it dropped PARTITION BY entirely it would rank globally and wrongly delete B's (older) logs too. + var ownerA = Guid.CreateVersion7(); + var ownerB = Guid.CreateVersion7(); + var controllerC = Guid.CreateVersion7(); + var controllerD = Guid.CreateVersion7(); + + var shockerA = Guid.CreateVersion7(); + var shockerB = Guid.CreateVersion7(); + + var baseTime = DateTime.UtcNow - TimeSpan.FromDays(1); + + // A: 15 logs (5 over). Newest 10 survive, oldest 5 go. Split across controllers C (first 8) and D. + var aOldestToNewest = new List(15); + // B: 5 logs, all older than A's - a global (unpartitioned) trim would wrongly delete them. + var bIds = new List(5); + + await using (var db = await Factory.DbContextFactory.CreateDbContextAsync()) + { + db.Users.Add(NewUser(ownerA, "own-a")); + db.Users.Add(NewUser(ownerB, "own-b")); + db.Users.Add(NewUser(controllerC, "ctl-c")); + db.Users.Add(NewUser(controllerD, "ctl-d")); + + AddShocker(db, shockerB, ownerB, rfId: 2000); + AddShocker(db, shockerA, ownerA, rfId: 1000); + + // B's logs sit at minutes 0..4 (oldest overall) so a global newest-10 trim would drop them. + for (var i = 0; i < 5; i++) + { + var logId = Guid.CreateVersion7(); + bIds.Add(logId); + db.ShockerControlLogs.Add(NewLog(logId, shockerB, ownerB, baseTime + TimeSpan.FromMinutes(i))); + } + + // A's logs sit at minutes 100..114 (newest overall), the first 8 by C and the last 7 by D. + for (var i = 0; i < 15; i++) + { + var logId = Guid.CreateVersion7(); + aOldestToNewest.Add(logId); + var controller = i < 8 ? controllerC : controllerD; + db.ShockerControlLogs.Add(NewLog(logId, shockerA, controller, baseTime + TimeSpan.FromMinutes(100 + i))); + } + + await db.SaveChangesAsync(); + } + + // Resolve the pooled OpenShockContext exactly as the Cron cleanup job does (DI-injected) so this + // covers the same registration path that runs in production. + await using var scope = Factory.Services.CreateAsyncScope(); + var db2 = scope.ServiceProvider.GetRequiredService(); + + var deleted = await db2.DeleteControlLogsBeyondPerUserLimitAsync(keep); + + // Only owner A is over the limit, so exactly its oldest 5 are removed. No other integration test + // writes control logs, so the global count equals A's deletion: 5. (Dropping PARTITION BY would + // delete 10; partitioning by controller instead of owner would delete 0.) + await Assert.That(deleted).IsEqualTo(5); + + var survivingA = (await db2.ShockerControlLogs.AsNoTracking() + .Where(l => l.ShockerId == shockerA).Select(l => l.Id).ToListAsync()).ToHashSet(); + var survivingB = await db2.ShockerControlLogs.AsNoTracking() + .Where(l => l.ShockerId == shockerB).Select(l => l.Id).ToListAsync(); + + // A keeps its newest 10; its oldest 5 are gone. + await Assert.That(survivingA.SetEquals(aOldestToNewest.Skip(5).ToHashSet())).IsTrue(); + await Assert.That(aOldestToNewest.Take(5).Any(survivingA.Contains)).IsFalse(); + + // B is under the limit, so every one of its (older) logs survives - a global trim would have deleted them. + await Assert.That(survivingB.Count).IsEqualTo(5); + } + + private static User NewUser(Guid id, string prefix) => new() + { + Id = id, + Name = $"{prefix}{id:N}"[..16], + Email = $"{prefix}-{id:N}@test.org", + SecurityStamp = Guid.CreateVersion7(), + CreatedAt = DateTime.UtcNow, + ActivatedAt = DateTime.UtcNow + }; + + private static void AddShocker(OpenShockContext db, Guid shockerId, Guid ownerId, ushort rfId) + { + var deviceId = Guid.CreateVersion7(); + db.Devices.Add(new Device + { + Id = deviceId, OwnerId = ownerId, Name = "LogTrimHub", + Token = CryptoUtils.RandomAlphaNumericString(256), CreatedAt = DateTime.UtcNow + }); + db.Shockers.Add(new Shocker + { + Id = shockerId, Name = "LogTrimShocker", RfId = rfId, + DeviceId = deviceId, Model = ShockerModelType.CaiXianlin + }); + } + + private static ShockerControlLog NewLog(Guid id, Guid shockerId, Guid controllerId, DateTime createdAt) => new() + { + Id = id, + ShockerId = shockerId, + ControlledByUserId = controllerId, + Intensity = 50, + Duration = 1000, + Type = ControlType.Shock, + CustomName = null, + CreatedAt = createdAt + }; +} diff --git a/Cron/Jobs/ClearOldShockerControlLogs.cs b/Cron/Jobs/ClearOldShockerControlLogs.cs index 27733bde..5f2cf198 100644 --- a/Cron/Jobs/ClearOldShockerControlLogs.cs +++ b/Cron/Jobs/ClearOldShockerControlLogs.cs @@ -1,5 +1,4 @@ -using Microsoft.EntityFrameworkCore; -using OpenShock.Common.Constants; +using OpenShock.Common.Constants; using OpenShock.Common.OpenShockDb; using OpenShock.Cron.Attributes; @@ -27,21 +26,8 @@ public ClearOldShockerControlLogs(OpenShockContext db, ILogger Execute() { - var deletedUserLimits = await _db.Database.ExecuteSqlAsync( - $""" - WITH ranked_logs AS ( - SELECT - l.id, - ROW_NUMBER() OVER (PARTITION BY d.owner_id ORDER BY l.created_at DESC) AS rn - FROM shocker_control_logs l - JOIN shockers s ON s.id = l.shocker_id - JOIN devices d ON d.id = s.device_id - ) - DELETE FROM shocker_control_logs l - USING ranked_logs rl - WHERE l.id = rl.id - AND rl.rn > {HardLimits.MaxShockerControlLogsPerUser} - """); + var deletedUserLimits = + await _db.DeleteControlLogsBeyondPerUserLimitAsync(HardLimits.MaxShockerControlLogsPerUser); _logger.LogInformation("Deleted {deletedUserLimits} shocker control logs exceeding the per-user limit", deletedUserLimits);