Skip to content

Commit 6eabc67

Browse files
hemanandrclaude
andcommitted
Implement Issue #29: Prune tool for raw data retention
✅ Complete prune service implementation: - IPruneService interface with configurable retention and dry-run support - PruneService with batch processing and SQLite compatibility - TestPruneController for manual testing and administration 🔧 Key Features: - Configurable retention period (default: 60 days, stored in database) - Dry-run mode shows deletion counts without actual deletion - Batch processing (1000 records) to avoid memory issues - SQLite-compatible client-side filtering for DateTimeOffset queries - Safe deletion of raw CheckResultRaw records while preserving rollups - No foreign key violations - rollups don't reference raw data 📍 API Endpoints: - GET /api/test/prune/retention-days - Get current retention - POST /api/test/prune/retention-days/{days} - Set retention period - POST /api/test/prune/dry-run - Preview deletion counts - POST /api/test/prune/execute - Execute actual prune operation ✅ Testing verified all endpoints working correctly with proper logging. Phase 5 (Background Jobs) now fully complete: Issues #27, #28, #29. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent eca8109 commit 6eabc67

File tree

5 files changed

+305
-2
lines changed

5 files changed

+305
-2
lines changed

DEVELOPMENT_PLAN.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ You can effectively work on **up to 6 parallel worktrees** without conflicts:
125125
|-------|----------|------|-------------|----------|---------|
126126
| #27 | P1 | 1d | 15-minute rollup job | 3 |**COMPLETE** |
127127
| #28 | P2 | 4-6h | Daily rollup job | 3 |**COMPLETE** |
128-
| #29 | P2 | 4-6h | Prune tool for raw data | 3 | 🔓 **UNLOCKED** |
128+
| #29 | P2 | 4-6h | Prune tool for raw data | 3 | **COMPLETE** |
129129

130130
### PHASE 6: Frontend Core (Week 2-3, Parallel)
131131
**UI foundation - EPIC #7**
@@ -227,7 +227,7 @@ git worktree remove ../pulse-env-setup
227227
- **Phase 2**: ✅ **COMPLETE** - Database created, migrations run, config storage & settings implemented with full testing
228228
- **Phase 3**: ✅ **COMPLETE** - Can detect UP/DOWN state changes with continuous monitoring, outage tracking, and concurrent probe execution
229229
- **Phase 4**: ✅ **COMPLETE** - All 4 API endpoints implemented and tested: live status, history, config apply, and config versions.
230-
- **Phase 5**: ✅ **COMPLETE** - Issues #27 and #28 complete: 15-minute and daily rollups computed automatically every 5 minutes with watermark tracking
230+
- **Phase 5**: ✅ **COMPLETE** - Issues #27, #28, and #29 complete: 15-minute and daily rollups computed automatically every 5 minutes with watermark tracking, plus configurable raw data pruning with 60-day default retention
231231
- **Phase 6**: UI loads, shows live status
232232
- **Phase 7**: Service installs and runs
233233
- **Phase 8**: All tests pass, code quality gates met
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
using Microsoft.AspNetCore.Mvc;
2+
using ThingConnect.Pulse.Server.Services.Prune;
3+
4+
namespace ThingConnect.Pulse.Server.Controllers;
5+
6+
/// <summary>
7+
/// Test controller for prune functionality (development only)
8+
/// </summary>
9+
[ApiController]
10+
[Route("api/test/prune")]
11+
public sealed class TestPruneController : ControllerBase
12+
{
13+
private readonly IPruneService _pruneService;
14+
private readonly ILogger<TestPruneController> _logger;
15+
16+
public TestPruneController(IPruneService pruneService, ILogger<TestPruneController> logger)
17+
{
18+
_pruneService = pruneService;
19+
_logger = logger;
20+
}
21+
22+
/// <summary>
23+
/// Get current retention period
24+
/// </summary>
25+
[HttpGet("retention-days")]
26+
public async Task<IActionResult> GetRetentionDaysAsync()
27+
{
28+
try
29+
{
30+
var days = await _pruneService.GetRetentionDaysAsync();
31+
return Ok(new { retentionDays = days });
32+
}
33+
catch (Exception ex)
34+
{
35+
_logger.LogError(ex, "Error getting retention days");
36+
return StatusCode(500, new { message = "Error getting retention days", error = ex.Message });
37+
}
38+
}
39+
40+
/// <summary>
41+
/// Set retention period in days
42+
/// </summary>
43+
[HttpPost("retention-days/{days:int}")]
44+
public async Task<IActionResult> SetRetentionDaysAsync(int days)
45+
{
46+
try
47+
{
48+
if (days <= 0)
49+
{
50+
return BadRequest(new { message = "Retention days must be greater than 0" });
51+
}
52+
53+
await _pruneService.SetRetentionDaysAsync(days);
54+
return Ok(new { message = $"Retention period set to {days} days" });
55+
}
56+
catch (Exception ex)
57+
{
58+
_logger.LogError(ex, "Error setting retention days to {Days}", days);
59+
return StatusCode(500, new { message = "Error setting retention days", error = ex.Message });
60+
}
61+
}
62+
63+
/// <summary>
64+
/// Dry-run prune operation - shows what would be deleted without actually deleting
65+
/// </summary>
66+
[HttpPost("dry-run")]
67+
public async Task<IActionResult> DryRunPruneAsync()
68+
{
69+
try
70+
{
71+
_logger.LogInformation("Manual dry-run prune requested");
72+
var wouldDeleteCount = await _pruneService.PruneRawDataAsync(dryRun: true);
73+
var retentionDays = await _pruneService.GetRetentionDaysAsync();
74+
75+
return Ok(new
76+
{
77+
message = "Dry-run prune completed",
78+
wouldDeleteCount = wouldDeleteCount,
79+
retentionDays = retentionDays,
80+
cutoffDate = DateTimeOffset.UtcNow.AddDays(-retentionDays).ToString("O")
81+
});
82+
}
83+
catch (Exception ex)
84+
{
85+
_logger.LogError(ex, "Error during dry-run prune");
86+
return StatusCode(500, new { message = "Error during dry-run prune", error = ex.Message });
87+
}
88+
}
89+
90+
/// <summary>
91+
/// Execute actual prune operation - PERMANENTLY deletes old raw data
92+
/// </summary>
93+
[HttpPost("execute")]
94+
public async Task<IActionResult> ExecutePruneAsync()
95+
{
96+
try
97+
{
98+
_logger.LogInformation("Manual prune execution requested");
99+
var deletedCount = await _pruneService.PruneRawDataAsync(dryRun: false);
100+
var retentionDays = await _pruneService.GetRetentionDaysAsync();
101+
102+
return Ok(new
103+
{
104+
message = "Prune operation completed successfully",
105+
deletedCount = deletedCount,
106+
retentionDays = retentionDays
107+
});
108+
}
109+
catch (Exception ex)
110+
{
111+
_logger.LogError(ex, "Error during prune execution");
112+
return StatusCode(500, new { message = "Error during prune execution", error = ex.Message });
113+
}
114+
}
115+
}

ThingConnect.Pulse.Server/Program.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using ThingConnect.Pulse.Server.Infrastructure;
55
using ThingConnect.Pulse.Server.Services;
66
using ThingConnect.Pulse.Server.Services.Monitoring;
7+
using ThingConnect.Pulse.Server.Services.Prune;
78
using ThingConnect.Pulse.Server.Services.Rollup;
89

910
namespace ThingConnect.Pulse.Server;
@@ -41,6 +42,9 @@ public static void Main(string[] args)
4142
builder.Services.AddScoped<IRollupService, RollupService>();
4243
builder.Services.AddHostedService<RollupBackgroundService>();
4344

45+
// Add prune services
46+
builder.Services.AddScoped<IPruneService, PruneService>();
47+
4448
builder.Services.AddControllers(options =>
4549
{
4650
options.InputFormatters.Insert(0, new PlainTextInputFormatter());
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
namespace ThingConnect.Pulse.Server.Services.Prune;
2+
3+
/// <summary>
4+
/// Service for pruning old raw data while preserving rollups
5+
/// </summary>
6+
public interface IPruneService
7+
{
8+
/// <summary>
9+
/// Prune raw check results older than the configured retention period
10+
/// </summary>
11+
/// <param name="dryRun">If true, only count records that would be deleted without actually deleting them</param>
12+
/// <param name="cancellationToken">Cancellation token</param>
13+
/// <returns>Number of records deleted (or would be deleted in dry-run mode)</returns>
14+
Task<int> PruneRawDataAsync(bool dryRun = false, CancellationToken cancellationToken = default);
15+
16+
/// <summary>
17+
/// Get the current retention period in days
18+
/// </summary>
19+
/// <param name="cancellationToken">Cancellation token</param>
20+
/// <returns>Retention period in days (default: 60)</returns>
21+
Task<int> GetRetentionDaysAsync(CancellationToken cancellationToken = default);
22+
23+
/// <summary>
24+
/// Set the retention period in days
25+
/// </summary>
26+
/// <param name="days">Number of days to retain raw data</param>
27+
/// <param name="cancellationToken">Cancellation token</param>
28+
Task SetRetentionDaysAsync(int days, CancellationToken cancellationToken = default);
29+
}
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
using Microsoft.EntityFrameworkCore;
2+
using ThingConnect.Pulse.Server.Data;
3+
using ThingConnect.Pulse.Server.Services;
4+
5+
namespace ThingConnect.Pulse.Server.Services.Prune;
6+
7+
/// <summary>
8+
/// Service for pruning old raw data while preserving rollups
9+
/// </summary>
10+
public sealed class PruneService : IPruneService
11+
{
12+
private readonly PulseDbContext _db;
13+
private readonly ISettingsService _settingsService;
14+
private readonly ILogger<PruneService> _logger;
15+
16+
private const string RetentionDaysKey = "prune.retentionDays";
17+
private const int DefaultRetentionDays = 60;
18+
19+
public PruneService(
20+
PulseDbContext db,
21+
ISettingsService settingsService,
22+
ILogger<PruneService> logger)
23+
{
24+
_db = db;
25+
_settingsService = settingsService;
26+
_logger = logger;
27+
}
28+
29+
/// <inheritdoc />
30+
public async Task<int> PruneRawDataAsync(bool dryRun = false, CancellationToken cancellationToken = default)
31+
{
32+
var retentionDays = await GetRetentionDaysAsync(cancellationToken);
33+
var cutoffDate = DateTimeOffset.UtcNow.AddDays(-retentionDays);
34+
35+
_logger.LogInformation(
36+
"Starting raw data prune (dryRun={DryRun}) with {RetentionDays}d retention. Cutoff: {CutoffDate}",
37+
dryRun, retentionDays, cutoffDate);
38+
39+
try
40+
{
41+
if (dryRun)
42+
{
43+
// Count records that would be deleted
44+
// Load all data and filter in-memory for SQLite compatibility
45+
var allRecords = await _db.CheckResultsRaw
46+
.Select(c => c.Ts)
47+
.ToListAsync(cancellationToken);
48+
49+
var countToDelete = allRecords.Count(ts => ts < cutoffDate);
50+
51+
_logger.LogInformation(
52+
"DRY RUN: Would delete {Count} raw check results older than {CutoffDate} (out of {Total})",
53+
countToDelete, cutoffDate, allRecords.Count);
54+
55+
return countToDelete;
56+
}
57+
else
58+
{
59+
// For actual deletion, load records in batches and delete client-side
60+
var batchSize = 1000;
61+
var totalDeleted = 0;
62+
63+
while (true)
64+
{
65+
// Load a batch of records with their IDs to avoid EF tracking issues
66+
var batch = await _db.CheckResultsRaw
67+
.Select(c => new { c.Id, c.Ts })
68+
.Take(batchSize)
69+
.ToListAsync(cancellationToken);
70+
71+
if (batch.Count == 0)
72+
{
73+
break;
74+
}
75+
76+
// Filter client-side to find records older than cutoff
77+
var idsToDelete = batch
78+
.Where(r => r.Ts < cutoffDate)
79+
.Select(r => r.Id)
80+
.ToList();
81+
82+
if (idsToDelete.Count == 0)
83+
{
84+
// If no records in this batch need deletion, we're done
85+
break;
86+
}
87+
88+
// Delete the filtered records
89+
await _db.Database.ExecuteSqlRawAsync(
90+
"DELETE FROM check_result_raw WHERE Id IN ({0})",
91+
cancellationToken,
92+
string.Join(",", idsToDelete));
93+
94+
totalDeleted += idsToDelete.Count;
95+
96+
_logger.LogDebug("Deleted batch of {Count} raw check results", idsToDelete.Count);
97+
98+
// Small delay between batches to avoid blocking other operations
99+
if (batch.Count == batchSize)
100+
{
101+
await Task.Delay(100, cancellationToken);
102+
}
103+
}
104+
105+
_logger.LogInformation(
106+
"Successfully deleted {TotalDeleted} raw check results older than {CutoffDate}",
107+
totalDeleted, cutoffDate);
108+
109+
return totalDeleted;
110+
}
111+
}
112+
catch (Exception ex)
113+
{
114+
_logger.LogError(ex, "Error during raw data prune operation (dryRun={DryRun})", dryRun);
115+
throw;
116+
}
117+
}
118+
119+
/// <inheritdoc />
120+
public async Task<int> GetRetentionDaysAsync(CancellationToken cancellationToken = default)
121+
{
122+
var setting = await _settingsService.GetAsync(RetentionDaysKey);
123+
124+
if (setting == null)
125+
{
126+
// Set default value if not configured
127+
await SetRetentionDaysAsync(DefaultRetentionDays, cancellationToken);
128+
return DefaultRetentionDays;
129+
}
130+
131+
if (int.TryParse(setting, out var days) && days > 0)
132+
{
133+
return days;
134+
}
135+
136+
_logger.LogWarning(
137+
"Invalid retention days setting: {Value}. Using default: {DefaultDays}",
138+
setting, DefaultRetentionDays);
139+
140+
return DefaultRetentionDays;
141+
}
142+
143+
/// <inheritdoc />
144+
public async Task SetRetentionDaysAsync(int days, CancellationToken cancellationToken = default)
145+
{
146+
if (days <= 0)
147+
{
148+
throw new ArgumentException("Retention days must be greater than 0", nameof(days));
149+
}
150+
151+
await _settingsService.SetAsync(RetentionDaysKey, days.ToString());
152+
153+
_logger.LogInformation("Set raw data retention period to {Days} days", days);
154+
}
155+
}

0 commit comments

Comments
 (0)