Skip to content

Commit d040e81

Browse files
committed
VPR-54 feat(scheduler): Hangfire-backed scheduler with RAPS role-refresh
Adds a shared, area-agnostic background-job scheduler on top of Hangfire 1.8 + SQL Server, with a CAS+RAPS-gated dashboard and a thin [ScheduledJob] attribute for job authors. The RAPS nightly role-refresh is the first consumer; the legacy ColdFusion runner is now a debug-only manual fallback. - Hangfire storage lives in the VIPER DB under the [HangFire] schema; Hangfire:Enabled (default true) is the master switch and Hangfire:AutoSchedule (dev: false) registers jobs with Cron.Never so developers don't run cron locally. - Dashboard at /scheduler/dashboard is gated by SVMSecure.CATS.scheduledJobs (same RAPS permission as the legacy cats/inc_scheduledTasks.cfm scheduler, so existing admins inherit access). Hangfire.Console and Hangfire.Heartbeat plugins surface per-job logs and CPU/RAM metrics. - Jobs implement IScheduledJob and carry [ScheduledJob(id, cron)]. A startup discovery pass scans the web assembly, registers each type with DI, and AddOrUpdate's the recurring job through a single ScheduledJobRunner dispatcher (Hangfire can't serialize calls against interfaces). Each execution gets a fresh DI scope and a ScheduledJobContext exposing TriggerSource + ModBy ("__sched" for scheduled runs, LoginId for manual). - Hangfire health check tagged "ready" piggybacks on /health/detail: reports Healthy / Degraded / Unhealthy based on storage reachability and recent server heartbeats. - RAPS role-refresh (raps:role-refresh, 0 0 * * * Pacific) ports the legacy ColdFusion runner. RoleViews.UpdateRoles now accepts an explicit modBy so scheduled and manual paths stamp audit rows distinguishably without UserHelper.GetCurrentUser(). - Program.cs Cloudflare/F5 forwarded-headers block is extracted to ForwardedHeadersExtensions to keep Main$ below CA1502/CA1505 limits with the new AddViperHangfire wiring added alongside it.
1 parent 858333b commit d040e81

31 files changed

Lines changed: 1575 additions & 53 deletions

.env.local.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@
1515
# Default: C:\Tools\mailpit
1616
#MAILPIT_INSTALL_DIR=C:\Tools\mailpit
1717

18+
# Hangfire Scheduler (Optional, Off by Default)
19+
# Master toggle for the background scheduler. When true, the app registers
20+
# Hangfire and starts the background server; when false (default) Hangfire
21+
# is a no-op. Hangfire's tables live in the VIPER database under the
22+
# HangFire schema.
23+
#Hangfire__Enabled=false
24+
1825
# Jenkins Build Trigger (pre-push hook)
1926
# Get your API token from Jenkins: User menu > Configure > API Token
2027
#JENKINS_USER=your-username

package-lock.json

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
using Microsoft.Extensions.Diagnostics.HealthChecks;
2+
using NSubstitute;
3+
using Viper.Classes.HealthChecks;
4+
5+
namespace Viper.test.HealthChecks
6+
{
7+
public class HangfireHealthCheckTests
8+
{
9+
private static HealthCheckContext CreateContext(HangfireHealthCheck sut)
10+
{
11+
return new HealthCheckContext
12+
{
13+
Registration = new HealthCheckRegistration("hangfire", sut, null, null)
14+
};
15+
}
16+
17+
private static (Hangfire.JobStorage storage, Hangfire.Storage.IMonitoringApi monitoring) CreateStorage()
18+
{
19+
var monitoring = Substitute.For<Hangfire.Storage.IMonitoringApi>();
20+
var storage = Substitute.For<Hangfire.JobStorage>();
21+
storage.GetMonitoringApi().Returns(monitoring);
22+
return (storage, monitoring);
23+
}
24+
25+
private static Hangfire.Storage.Monitoring.StatisticsDto SampleStats(long servers = 1) => new()
26+
{
27+
Servers = servers,
28+
Enqueued = 2,
29+
Scheduled = 3,
30+
Processing = 4,
31+
Failed = 5,
32+
Recurring = 6
33+
};
34+
35+
[Fact]
36+
public async Task CheckHealthAsync_HealthyWhenServersHaveRecentHeartbeats()
37+
{
38+
var (storage, monitoring) = CreateStorage();
39+
monitoring.GetStatistics().Returns(SampleStats());
40+
monitoring.Servers().Returns(new List<Hangfire.Storage.Monitoring.ServerDto>
41+
{
42+
new() { Name = "srv-1", Heartbeat = DateTime.UtcNow }
43+
});
44+
45+
var sut = new HangfireHealthCheck(storage);
46+
var result = await sut.CheckHealthAsync(CreateContext(sut));
47+
48+
Assert.Equal(HealthStatus.Healthy, result.Status);
49+
Assert.Contains("Hangfire OK", result.Description);
50+
Assert.Equal(1L, result.Data["servers"]);
51+
}
52+
53+
[Fact]
54+
public async Task CheckHealthAsync_DegradedWhenNoServersRegistered()
55+
{
56+
var (storage, monitoring) = CreateStorage();
57+
monitoring.GetStatistics().Returns(SampleStats(0));
58+
monitoring.Servers().Returns(new List<Hangfire.Storage.Monitoring.ServerDto>());
59+
60+
var sut = new HangfireHealthCheck(storage);
61+
var result = await sut.CheckHealthAsync(CreateContext(sut));
62+
63+
Assert.Equal(HealthStatus.Degraded, result.Status);
64+
Assert.Contains("no servers registered", result.Description);
65+
}
66+
67+
[Fact]
68+
public async Task CheckHealthAsync_UnhealthyWhenAllHeartbeatsStale()
69+
{
70+
var (storage, monitoring) = CreateStorage();
71+
monitoring.GetStatistics().Returns(SampleStats());
72+
monitoring.Servers().Returns(new List<Hangfire.Storage.Monitoring.ServerDto>
73+
{
74+
new() { Name = "srv-stale", Heartbeat = DateTime.UtcNow.AddMinutes(-10) }
75+
});
76+
77+
var sut = new HangfireHealthCheck(storage);
78+
var result = await sut.CheckHealthAsync(CreateContext(sut));
79+
80+
Assert.Equal(HealthStatus.Unhealthy, result.Status);
81+
Assert.Contains("stale", result.Description);
82+
}
83+
84+
[Fact]
85+
public async Task CheckHealthAsync_UnhealthyWhenServerHasNullHeartbeat()
86+
{
87+
var (storage, monitoring) = CreateStorage();
88+
monitoring.GetStatistics().Returns(SampleStats());
89+
monitoring.Servers().Returns(new List<Hangfire.Storage.Monitoring.ServerDto>
90+
{
91+
new() { Name = "srv-never", Heartbeat = null }
92+
});
93+
94+
var sut = new HangfireHealthCheck(storage);
95+
var result = await sut.CheckHealthAsync(CreateContext(sut));
96+
97+
Assert.Equal(HealthStatus.Unhealthy, result.Status);
98+
Assert.Contains("never", result.Description);
99+
}
100+
101+
[Fact]
102+
public async Task CheckHealthAsync_HealthyWhenAtLeastOneHeartbeatIsRecent()
103+
{
104+
var (storage, monitoring) = CreateStorage();
105+
monitoring.GetStatistics().Returns(SampleStats(2));
106+
monitoring.Servers().Returns(new List<Hangfire.Storage.Monitoring.ServerDto>
107+
{
108+
new() { Name = "srv-stale", Heartbeat = DateTime.UtcNow.AddMinutes(-10) },
109+
new() { Name = "srv-fresh", Heartbeat = DateTime.UtcNow }
110+
});
111+
112+
var sut = new HangfireHealthCheck(storage);
113+
var result = await sut.CheckHealthAsync(CreateContext(sut));
114+
115+
Assert.Equal(HealthStatus.Healthy, result.Status);
116+
Assert.Contains("Hangfire OK", result.Description);
117+
}
118+
119+
[Fact]
120+
public async Task CheckHealthAsync_UnhealthyWhenStorageThrows()
121+
{
122+
var storage = Substitute.For<Hangfire.JobStorage>();
123+
var boom = new InvalidOperationException("boom");
124+
storage.GetMonitoringApi().Returns(_ => throw boom);
125+
126+
var sut = new HangfireHealthCheck(storage);
127+
var result = await sut.CheckHealthAsync(CreateContext(sut));
128+
129+
Assert.Equal(HealthStatus.Unhealthy, result.Status);
130+
Assert.Contains("unreachable", result.Description);
131+
Assert.Same(boom, result.Exception);
132+
}
133+
134+
[Fact]
135+
public async Task CheckHealthAsync_DataDictionaryContainsAllStatsKeys()
136+
{
137+
var (storage, monitoring) = CreateStorage();
138+
monitoring.GetStatistics().Returns(new Hangfire.Storage.Monitoring.StatisticsDto
139+
{
140+
Servers = 1,
141+
Enqueued = 7,
142+
Scheduled = 8,
143+
Processing = 9,
144+
Failed = 10,
145+
Recurring = 11
146+
});
147+
monitoring.Servers().Returns(new List<Hangfire.Storage.Monitoring.ServerDto>
148+
{
149+
new() { Name = "srv-1", Heartbeat = DateTime.UtcNow }
150+
});
151+
152+
var sut = new HangfireHealthCheck(storage);
153+
var result = await sut.CheckHealthAsync(CreateContext(sut));
154+
155+
Assert.Contains("servers", result.Data.Keys);
156+
Assert.Contains("enqueued", result.Data.Keys);
157+
Assert.Contains("scheduled", result.Data.Keys);
158+
Assert.Contains("processing", result.Data.Keys);
159+
Assert.Contains("failed", result.Data.Keys);
160+
Assert.Contains("recurring", result.Data.Keys);
161+
Assert.Equal(1L, result.Data["servers"]);
162+
Assert.Equal(7L, result.Data["enqueued"]);
163+
Assert.Equal(11L, result.Data["recurring"]);
164+
}
165+
}
166+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
using System.Reflection;
2+
using Viper.Areas.RAPS.Jobs;
3+
using Viper.Areas.Scheduler.Services;
4+
5+
namespace Viper.test.RAPS
6+
{
7+
public sealed class RapsRoleRefreshScheduledJobTests
8+
{
9+
[Fact]
10+
public void Class_IsDecoratedWithScheduledJob()
11+
{
12+
var attr = typeof(RapsRoleRefreshScheduledJob).GetCustomAttribute<ScheduledJobAttribute>();
13+
14+
Assert.NotNull(attr);
15+
Assert.Equal("raps:role-refresh", attr.Id);
16+
Assert.Equal("0 0 * * *", attr.Cron);
17+
Assert.Equal("Pacific Standard Time", attr.TimeZoneId);
18+
}
19+
}
20+
}

0 commit comments

Comments
 (0)