feat(inventory): prune acquire history above 300-row cap

Adds PruneAcquireHistoryAsync to InventoryTransaction.CommitAsync; runs
inside the open DB transaction after history rows are flushed, keeping at
most 300 rows per viewer (oldest discarded). Adds a covering test that
verifies the cap and per-viewer isolation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-09 14:41:04 -04:00
parent fb1e6829b7
commit 77ad614258
2 changed files with 54 additions and 0 deletions

View File

@@ -9,6 +9,8 @@ namespace SVSim.Database.Services.Inventory;
internal sealed class InventoryTransaction : IInventoryTransaction
{
private const int AcquireHistoryRetention = 300;
private readonly SVSimDbContext _db;
private readonly IDbContextTransaction _dbTx;
private readonly ILogger _log;
@@ -282,6 +284,8 @@ internal sealed class InventoryTransaction : IInventoryTransaction
WriteAcquireHistory();
await _db.SaveChangesAsync(ct);
await PruneAcquireHistoryAsync(ct);
await _dbTx.CommitAsync(ct);
_committed = true;
@@ -290,6 +294,22 @@ internal sealed class InventoryTransaction : IInventoryTransaction
return new InventoryCommitResult(rewardList, deltas);
}
private async Task PruneAcquireHistoryAsync(CancellationToken ct)
{
var overflowIds = await _db.ViewerAcquireHistory
.Where(h => h.ViewerId == Viewer.Id)
.OrderByDescending(h => h.AcquireTime).ThenByDescending(h => h.Id)
.Skip(AcquireHistoryRetention)
.Select(h => h.Id)
.ToListAsync(ct);
if (overflowIds.Count == 0) return;
await _db.ViewerAcquireHistory
.Where(h => overflowIds.Contains(h.Id))
.ExecuteDeleteAsync(ct);
}
private void WriteAcquireHistory()
{
var now = DateTime.UtcNow;

View File

@@ -241,4 +241,38 @@ public class InventoryHistoryTests
.Where(h => h.ViewerId == viewerId).FirstAsync();
Assert.That(row.RewardDetailId, Is.EqualTo(0));
}
[Test]
public async Task Commit_prunes_history_above_retention_cap_per_viewer()
{
using var factory = new SVSim.UnitTests.Infrastructure.SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_001UL);
long otherViewerId = await factory.SeedViewerAsync(steamId: 76_561_198_000_000_002UL);
using var scope = factory.Services.CreateScope();
var inv = scope.ServiceProvider.GetRequiredService<SVSim.Database.Services.Inventory.IInventoryService>();
// Pre-seed 305 rows for the primary viewer via 305 single-grant commits.
for (int i = 0; i < 305; i++)
{
await using var tx = await inv.BeginAsync(viewerId, configure: c => c.Source = GrantSource.DailyBonus);
await tx.GrantAsync(SVSim.Database.Enums.UserGoodsType.Rupy, 0, 1);
await tx.CommitAsync();
}
// Seed 50 rows for an unrelated viewer to verify pruning is per-viewer.
for (int i = 0; i < 50; i++)
{
await using var tx = await inv.BeginAsync(otherViewerId, configure: c => c.Source = GrantSource.DailyBonus);
await tx.GrantAsync(SVSim.Database.Enums.UserGoodsType.Rupy, 0, 1);
await tx.CommitAsync();
}
using var verifyScope = factory.Services.CreateScope();
var ctx = verifyScope.ServiceProvider.GetRequiredService<SVSim.Database.SVSimDbContext>();
var primaryCount = await ctx.ViewerAcquireHistory.CountAsync(h => h.ViewerId == viewerId);
var otherCount = await ctx.ViewerAcquireHistory.CountAsync(h => h.ViewerId == otherViewerId);
Assert.That(primaryCount, Is.EqualTo(300), "primary viewer pruned to cap");
Assert.That(otherCount, Is.EqualTo(50), "other viewer untouched by primary's prune");
}
}