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);