@polymorphism-tech/morph-spec 8.46.0 → 8.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +5 -5
- package/README.md +2 -2
- package/bin/morph-spec.js +1 -4
- package/bin/validate.js +4 -25
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +2 -2
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +1 -1
- package/framework/MORPH.md +8 -8
- package/framework/commands/create-umbraco-lp.md +2 -0
- package/framework/commands/migrate-lp-to-umbraco.md +2 -0
- package/framework/commands/morph-archive.md +1 -1
- package/framework/commands/morph-hotfix.md +1 -1
- package/framework/commands/morph-infra.md +17 -203
- package/framework/commands/morph-media.md +3 -0
- package/framework/commands/morph-preflight.md +5 -28
- package/framework/commands/morph-proposal.md +2 -2
- package/framework/commands/morph-troubleshoot.md +2 -14
- package/framework/docs/CLI.md +3 -3
- package/framework/hooks/claude-code/core/state-sync.js +13 -1
- package/framework/hooks/shared/e2e-check.js +3 -4
- package/framework/hooks/shared/state-writer.js +1 -0
- package/framework/rules/ai-agents-standards.md +18 -16
- package/framework/rules/asaas-standards.md +3 -1
- package/framework/rules/csharp-standards.md +10 -8
- package/framework/rules/data-standards.md +5 -5
- package/framework/rules/dotnet-templates.md +1 -1
- package/framework/rules/frontend-standards.md +4 -2
- package/framework/rules/gsap-standards.md +10 -5
- package/framework/rules/hangfire-standards.md +3 -1
- package/framework/rules/infrastructure-standards.md +4 -2
- package/framework/rules/morph-workflow.md +2 -2
- package/framework/rules/neon-auth-standards.md +3 -1
- package/framework/rules/neon-standards.md +6 -4
- package/framework/rules/nextjs-standards.md +13 -11
- package/framework/rules/nextjs-templates.md +1 -1
- package/framework/rules/resend-standards.md +3 -1
- package/framework/schemas/feature.schema.json +1 -3
- package/framework/skills/README.md +1 -1
- package/framework/skills/core/create-umbraco-lp/SKILL.md +3 -3
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +3 -3
- package/framework/skills/core/morph-apply/SKILL.md +3 -3
- package/framework/skills/core/morph-brainstorming/SKILL.md +3 -3
- package/framework/skills/core/morph-hotfix/SKILL.md +4 -4
- package/framework/skills/core/morph-implement/SKILL.md +3 -3
- package/framework/skills/core/morph-machine/SKILL.md +3 -3
- package/framework/skills/core/morph-media/SKILL.md +3 -3
- package/framework/skills/core/morph-plan/SKILL.md +3 -3
- package/framework/skills/core/morph-proposal/SKILL.md +3 -3
- package/framework/skills/core/morph-review/SKILL.md +3 -3
- package/framework/skills/core/morph-stack-scan/SKILL.md +3 -3
- package/framework/skills/core/morph-standards/SKILL.md +6 -6
- package/framework/skills/core/morph-uiux/SKILL.md +3 -3
- package/framework/standards/STANDARDS.json +59 -56
- package/framework/standards/ai-agents/evals-with-cache.md +1 -1
- package/framework/standards/ai-agents/llm-runtime-defaults.md +1 -1
- package/framework/standards/ai-agents/modalities-speech-to-text.md +36 -26
- package/framework/standards/ai-agents/prompt-sources.md +6 -4
- package/framework/standards/ai-agents/providers/model-registry.md +1 -1
- package/framework/standards/ai-agents/rag-custom-pgvector.md +67 -39
- package/framework/standards/ai-agents/setup.md +1 -1
- package/framework/standards/ai-agents/structured-output.md +1 -1
- package/framework/standards/ai-agents/sweet-spot.md +1 -1
- package/framework/standards/architecture/code-comments.md +2 -2
- package/framework/standards/architecture/vertical-slice/vertical-slice.md +18 -9
- package/framework/standards/backend/api/validation.md +135 -47
- package/framework/standards/backend/database/postgresql/database.md +11 -7
- package/framework/standards/backend/database/vector-search-rag.md +207 -324
- package/framework/standards/backend/dotnet/fluent-validation-vsa.md +14 -8
- package/framework/standards/backend/dotnet/result-pattern.md +40 -6
- package/framework/standards/backend/dotnet/scalar-ui.md +13 -7
- package/framework/standards/backend/dotnet/scrutor.md +1 -1
- package/framework/standards/backend/dotnet/testing.md +1 -1
- package/framework/standards/backend/dotnet/vsa-handler-patterns.md +7 -7
- package/framework/standards/backend/integrations/asaas/asaas-api.md +309 -165
- package/framework/standards/backend/integrations/hangfire/hangfire-jobs.md +134 -154
- package/framework/standards/data/nosql/cache/redis.md +120 -44
- package/framework/standards/data/nosql/cosmos-db.md +114 -71
- package/framework/standards/frontend/design-system/animations.md +1 -1
- package/framework/standards/frontend/design-system/naming.md +1 -1
- package/framework/standards/frontend/nextjs/motion-patterns.md +45 -29
- package/framework/standards/frontend/nextjs/naming-conventions.md +1 -1
- package/framework/standards/infrastructure/docker/coolify-deploy.md +8 -6
- package/framework/standards/infrastructure/neon/neon-pgvector.md +120 -75
- package/framework/standards/infrastructure/neon/neon-setup.md +53 -31
- package/framework/templates/code/dotnet/contracts/contracts-vsa.cs +2 -2
- package/framework/templates/code/dotnet/extensions/database-extensions.cs +1 -1
- package/framework/templates/code/dotnet/extensions/result-extensions.cs +27 -0
- package/framework/templates/code/dotnet/feature/endpoint.cs.hbs +2 -2
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaDisclosureTests.cs +1 -1
- package/framework/templates/infrastructure/docker/coolify.template.json +3 -3
- package/framework/templates/infrastructure/github/README.md +2 -1
- package/framework/templates/infrastructure/github/workflows/deploy-coolify.yml.hbs +2 -1
- package/package.json +1 -1
- package/src/commands/observability/telemetry.js +3 -5
- package/src/commands/project/init.js +2 -1
- package/src/commands/project/update.js +16 -18
- package/src/commands/state/approve.js +17 -0
- package/src/commands/state/gate-check.js +4 -14
- package/src/commands/validation/verify.js +3 -12
- package/src/core/paths/output-schema.js +1 -1
- package/src/dashboard/app/App.jsx +0 -2
- package/src/dashboard/app/components/CommandPalette.jsx +2 -3
- package/src/dashboard/app/components/EntityLink.jsx +0 -1
- package/src/dashboard/app/components/Markdown.jsx +1 -1
- package/src/dashboard/app/components/Sidebar.jsx +1 -2
- package/src/dashboard/app/pages/RulesMap.jsx +3 -3
- package/src/dashboard/app/pages/SkillsLibrary.jsx +3 -3
- package/src/dashboard/scanner/activity-scanner.js +1 -1
- package/src/dashboard/scanner/docs-scanner.js +5 -23
- package/src/dashboard/scanner/features-scanner.js +1 -1
- package/src/dashboard/scanner/index.js +0 -2
- package/src/dashboard/scanner/rules-scanner.js +15 -10
- package/src/dashboard/scanner/skills-scanner.js +2 -3
- package/src/lib/evaluator-report.js +13 -0
- package/src/lib/risk-detector.js +8 -0
- package/src/lib/stack/stack-profile.js +1 -1
- package/src/lib/validators/reuse/reuse-validator.js +2 -3
- package/src/lib/validators/reuse/symbol-scanner.js +2 -4
- package/src/lib/validators/validation-runner.js +8 -88
- package/src/lib/validators/verify-runner.js +22 -72
- package/src/scripts/setup-infra.js +33 -22
- package/src/utils/agents-installer.js +20 -8
- package/src/utils/file-copier.js +7 -5
- package/framework/commands/commit.md +0 -166
- package/framework/rubrics/backend.json +0 -41
- package/framework/rubrics/default.json +0 -36
- package/framework/rubrics/frontend.json +0 -36
- package/framework/templates/project/validate.js +0 -97
- package/src/dashboard/app/pages/Rubrics.jsx +0 -97
- package/src/dashboard/scanner/rubrics-scanner.js +0 -12
- package/src/lib/validators/architecture/architecture-validator.js +0 -270
- package/src/lib/validators/architecture/index.js +0 -7
- package/src/lib/validators/css/css-validator.js +0 -340
- package/src/lib/validators/css/index.js +0 -7
- package/src/lib/validators/design-system/design-system-validator.js +0 -237
- package/src/lib/validators/design-system/index.js +0 -7
- package/src/lib/validators/score-aggregator.js +0 -244
- package/src/lib/validators/ui/index.js +0 -7
- package/src/lib/validators/ui/ui-contrast-validator.js +0 -422
|
@@ -5,34 +5,29 @@
|
|
|
5
5
|
> **Keywords:** hangfire, background job, recurring, fire-and-forget, scheduled
|
|
6
6
|
> **Load When:** hangfire or background job keywords detected
|
|
7
7
|
|
|
8
|
-
**Verified against:** .NET 10 + Hangfire 1.8 (
|
|
8
|
+
**Verified against:** .NET 10 (SDK 10.0.400-preview) + Hangfire.Core/Hangfire.AspNetCore 1.8.25 + Hangfire.PostgreSql 1.21.1 + Newtonsoft.Json 13.0.4. The startup, job/handler, dashboard-filter and idempotency blocks compile with `TreatWarningsAsErrors`; the Job Types calls compile inside a wrapper method; the per-job DI scope (`AspNetCoreJobActivator` → `IServiceScopeFactory`) and the `PostgreSqlStorageOptions` defaults were read by reflection on those assemblies; the pooled-endpoint limits (LISTEN/NOTIFY, migrations on the direct string) come from the Neon connection-pooling docs. Cron strings are documentary. Last-verified: 2026-10-03.
|
|
9
9
|
|
|
10
10
|
---
|
|
11
11
|
|
|
12
|
-
Background job processing for .NET applications with Hangfire.
|
|
12
|
+
Background job processing for .NET 10 VSA applications with Hangfire on Neon Postgres.
|
|
13
13
|
|
|
14
14
|
---
|
|
15
15
|
|
|
16
|
-
##
|
|
16
|
+
## When to use Hangfire
|
|
17
17
|
|
|
18
|
-
Hangfire
|
|
19
|
-
- Fire-and-forget jobs
|
|
20
|
-
- Delayed jobs
|
|
21
|
-
- Recurring jobs (cron schedules)
|
|
22
|
-
- Job continuations
|
|
23
|
-
- Built-in dashboard
|
|
24
|
-
- Retry logic with exponential backoff
|
|
18
|
+
Hangfire is in the stack **only with a real reason**: work that must survive a process restart, run on a schedule, or be retried later (daily reindex, delayed reminders, retrying a flaky third party). Work that fits in the request (send one email after a signup, index one uploaded document) runs in the slice handler. Record the reason in `decisions.md`.
|
|
25
19
|
|
|
26
|
-
|
|
20
|
+
Hangfire provides fire-and-forget, delayed and recurring (cron) jobs, continuations, a dashboard and automatic retries.
|
|
27
21
|
|
|
28
22
|
---
|
|
29
23
|
|
|
30
24
|
## Core Principles
|
|
31
25
|
|
|
32
|
-
1. **
|
|
33
|
-
2. **
|
|
34
|
-
3. **
|
|
35
|
-
4. **
|
|
26
|
+
1. **Jobs are thin entry points**: a job class lives in the slice folder and calls the slice handler; the logic and the `Result` live in the handler
|
|
27
|
+
2. **Idempotent**: a job can run twice (retry, server restart) -- check state and return if already done
|
|
28
|
+
3. **No HTTP context**: jobs run outside a request; pass ids, never entities or `HttpContext` data
|
|
29
|
+
4. **Explicit retries**: `[AutomaticRetry]` per job; no hand-written retry loops
|
|
30
|
+
5. **Protected dashboard**: authenticated + Admin in production
|
|
36
31
|
|
|
37
32
|
---
|
|
38
33
|
|
|
@@ -40,195 +35,190 @@ Hangfire provides:
|
|
|
40
35
|
|
|
41
36
|
### Install Packages
|
|
42
37
|
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
38
|
+
```xml
|
|
39
|
+
<PackageReference Include="Hangfire.Core" Version="1.8.25" />
|
|
40
|
+
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.25" />
|
|
41
|
+
<PackageReference Include="Hangfire.PostgreSql" Version="1.21.1" />
|
|
42
|
+
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
|
47
43
|
```
|
|
48
44
|
|
|
45
|
+
`Hangfire.Core` 1.8.25 depends on `Newtonsoft.Json` 11.0.1, which carries a high-severity advisory (GHSA-5crp-9r3c-p9vr): restore fails with `NU1903` under `TreatWarningsAsErrors`. The explicit `Newtonsoft.Json` reference lifts it to a patched version.
|
|
46
|
+
|
|
49
47
|
### Program.cs Configuration
|
|
50
48
|
|
|
51
49
|
```csharp
|
|
52
50
|
// Program.cs
|
|
53
51
|
using Hangfire;
|
|
54
|
-
using Hangfire.
|
|
52
|
+
using Hangfire.PostgreSql;
|
|
55
53
|
|
|
56
54
|
var builder = WebApplication.CreateBuilder(args);
|
|
57
55
|
|
|
58
|
-
// Add Hangfire services
|
|
59
56
|
builder.Services.AddHangfire(configuration => configuration
|
|
60
57
|
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
|
|
61
58
|
.UseSimpleAssemblyNameTypeSerializer()
|
|
62
59
|
.UseRecommendedSerializerSettings()
|
|
63
|
-
.
|
|
64
|
-
builder.Configuration.GetConnectionString("HangfireConnection")
|
|
65
|
-
new SqlServerStorageOptions
|
|
66
|
-
{
|
|
67
|
-
CommandBatchMaxTimeout = TimeSpan.FromMinutes(5),
|
|
68
|
-
SlidingInvisibilityTimeout = TimeSpan.FromMinutes(5),
|
|
69
|
-
QueuePollInterval = TimeSpan.Zero,
|
|
70
|
-
UseRecommendedIsolationLevel = true,
|
|
71
|
-
DisableGlobalLocks = true
|
|
72
|
-
}));
|
|
60
|
+
.UsePostgreSqlStorage(options =>
|
|
61
|
+
options.UseNpgsqlConnection(builder.Configuration.GetConnectionString("HangfireConnection"))));
|
|
73
62
|
|
|
74
63
|
builder.Services.AddHangfireServer();
|
|
75
64
|
|
|
76
65
|
var app = builder.Build();
|
|
77
66
|
|
|
78
|
-
// Hangfire Dashboard (protect in production!)
|
|
79
67
|
app.UseHangfireDashboard("/hangfire", new DashboardOptions
|
|
80
68
|
{
|
|
81
|
-
Authorization =
|
|
69
|
+
Authorization = [new HangfireAuthorizationFilter()]
|
|
82
70
|
});
|
|
83
71
|
|
|
84
72
|
app.Run();
|
|
85
73
|
```
|
|
86
74
|
|
|
75
|
+
- **Storage on Neon Postgres** (`Hangfire.PostgreSql`), not SQL Server. The `UsePostgreSqlStorage(string, ...)` overloads are `[Obsolete]` in 1.21; use the `options => options.UseNpgsqlConnection(...)` form above.
|
|
76
|
+
- **`HangfireConnection` points at the direct (unpooled) Neon endpoint**, like migrations (`infrastructure/neon/neon-setup.md`): the storage installs its schema at startup (`PrepareSchemaIfNecessary = true` by default), and Neon recommends the direct string for migrations; and if you turn on `EnableLongPolling` it uses `LISTEN/NOTIFY`, which Neon's PgBouncer (transaction mode) does not support.
|
|
77
|
+
- Defaults read from `PostgreSqlStorageOptions` 1.21.1: `SchemaName = "hangfire"`, `QueuePollInterval = 15 s`, `InvisibilityTimeout = 30 min`, `DistributedLockTimeout = 10 min`, `EnableLongPolling = false`.
|
|
78
|
+
- The connection string comes from configuration (user-secrets in development, Coolify environment variables in production), never from code.
|
|
79
|
+
|
|
87
80
|
---
|
|
88
81
|
|
|
89
82
|
## Job Types
|
|
90
83
|
|
|
84
|
+
The examples enqueue the job classes of the next section. Hangfire replaces a `CancellationToken` argument (pass `CancellationToken.None`) with the job's own cancellation token at execution time.
|
|
85
|
+
|
|
91
86
|
### Fire-and-Forget Jobs
|
|
92
87
|
|
|
93
88
|
```csharp
|
|
94
|
-
|
|
95
|
-
BackgroundJob.Enqueue<IEmailService>(x => x.SendWelcomeEmail(userId));
|
|
96
|
-
|
|
97
|
-
// Alternative: Static method
|
|
98
|
-
BackgroundJob.Enqueue(() => Console.WriteLine("Hello, world!"));
|
|
89
|
+
BackgroundJob.Enqueue<SendWelcomeEmailJob>(job => job.RunAsync(userId, CancellationToken.None));
|
|
99
90
|
```
|
|
100
91
|
|
|
101
92
|
### Delayed Jobs
|
|
102
93
|
|
|
103
94
|
```csharp
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
x => x.GenerateMonthlyReport(),
|
|
95
|
+
BackgroundJob.Schedule<SendWelcomeEmailJob>(
|
|
96
|
+
job => job.RunAsync(userId, CancellationToken.None),
|
|
107
97
|
TimeSpan.FromHours(1));
|
|
108
98
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
DateTimeOffset.UtcNow.AddDays(7));
|
|
99
|
+
BackgroundJob.Schedule<SendWelcomeEmailJob>(
|
|
100
|
+
job => job.RunAsync(userId, CancellationToken.None),
|
|
101
|
+
timeProvider.GetUtcNow().AddDays(7));
|
|
113
102
|
```
|
|
114
103
|
|
|
115
104
|
### Recurring Jobs
|
|
116
105
|
|
|
117
106
|
```csharp
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
x => x.CleanupOldRecords(),
|
|
107
|
+
RecurringJob.AddOrUpdate<CleanupExpiredSessionsJob>(
|
|
108
|
+
"cleanup-expired-sessions",
|
|
109
|
+
job => job.RunAsync(CancellationToken.None),
|
|
122
110
|
Cron.Daily(2));
|
|
123
111
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
x => x.SyncData(),
|
|
112
|
+
RecurringJob.AddOrUpdate<CleanupExpiredSessionsJob>(
|
|
113
|
+
"cleanup-expired-sessions-frequent",
|
|
114
|
+
job => job.RunAsync(CancellationToken.None),
|
|
128
115
|
"*/15 * * * *");
|
|
129
116
|
```
|
|
130
117
|
|
|
118
|
+
Recurring job ids are stable strings (`AddOrUpdate` with the same id replaces the schedule). Cron times are UTC.
|
|
119
|
+
|
|
131
120
|
### Job Continuations
|
|
132
121
|
|
|
133
122
|
```csharp
|
|
134
|
-
var parentJobId = BackgroundJob.Enqueue<
|
|
135
|
-
|
|
123
|
+
var parentJobId = BackgroundJob.Enqueue<SendWelcomeEmailJob>(
|
|
124
|
+
job => job.RunAsync(userId, CancellationToken.None));
|
|
136
125
|
|
|
137
|
-
BackgroundJob.ContinueJobWith<
|
|
126
|
+
BackgroundJob.ContinueJobWith<CleanupExpiredSessionsJob>(
|
|
138
127
|
parentJobId,
|
|
139
|
-
|
|
128
|
+
job => job.RunAsync(CancellationToken.None));
|
|
140
129
|
```
|
|
141
130
|
|
|
142
131
|
---
|
|
143
132
|
|
|
144
|
-
## Job
|
|
133
|
+
## Job + Handler Pattern (in the slice)
|
|
145
134
|
|
|
146
135
|
```csharp
|
|
147
|
-
//
|
|
148
|
-
|
|
136
|
+
// Features/UserFeature/SendWelcomeEmail/SendWelcomeEmail.cs
|
|
137
|
+
using Hangfire;
|
|
138
|
+
using Microsoft.EntityFrameworkCore;
|
|
139
|
+
using Microsoft.Extensions.Logging;
|
|
140
|
+
|
|
141
|
+
public sealed record SendWelcomeEmailRequest(Guid UserId);
|
|
142
|
+
|
|
143
|
+
public static class UserErrors
|
|
149
144
|
{
|
|
150
|
-
|
|
151
|
-
|
|
145
|
+
public static Error NotFound(Guid userId) =>
|
|
146
|
+
Error.NotFound("Users.NotFound", $"The User with Id '{userId}' was not found");
|
|
152
147
|
}
|
|
153
148
|
|
|
154
|
-
|
|
155
|
-
public class EmailJobService : IEmailJobService
|
|
149
|
+
public interface IEmailSender
|
|
156
150
|
{
|
|
157
|
-
|
|
158
|
-
|
|
151
|
+
Task SendAsync(string to, string subject, string body, CancellationToken cancellationToken);
|
|
152
|
+
}
|
|
159
153
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
154
|
+
public sealed class SendWelcomeEmailHandler(
|
|
155
|
+
ApplicationDbContext db,
|
|
156
|
+
IEmailSender emailSender,
|
|
157
|
+
TimeProvider timeProvider) : IHandler<SendWelcomeEmailRequest, Result>
|
|
158
|
+
{
|
|
159
|
+
public async Task<Result> HandleAsync(SendWelcomeEmailRequest request, CancellationToken cancellationToken)
|
|
163
160
|
{
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
161
|
+
var user = await db.Users.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
|
|
162
|
+
if (user is null)
|
|
163
|
+
return UserErrors.NotFound(request.UserId);
|
|
167
164
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
// Use IDbContextFactory for background jobs
|
|
171
|
-
await using var db = await _dbFactory.CreateDbContextAsync();
|
|
165
|
+
if (user.WelcomeEmailSentAt is not null)
|
|
166
|
+
return Result.Success();
|
|
172
167
|
|
|
173
|
-
|
|
174
|
-
if (user == null) return;
|
|
168
|
+
await emailSender.SendAsync(user.Email, "Welcome!", $"Hello {user.Name}!", cancellationToken);
|
|
175
169
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
$"Hello {user.Name}!");
|
|
170
|
+
user.WelcomeEmailSentAt = timeProvider.GetUtcNow();
|
|
171
|
+
await db.SaveChangesAsync(cancellationToken);
|
|
172
|
+
return Result.Success();
|
|
180
173
|
}
|
|
181
174
|
}
|
|
182
175
|
|
|
183
|
-
|
|
184
|
-
|
|
176
|
+
public sealed class SendWelcomeEmailJob(
|
|
177
|
+
IHandler<SendWelcomeEmailRequest, Result> handler,
|
|
178
|
+
ILogger<SendWelcomeEmailJob> logger)
|
|
179
|
+
{
|
|
180
|
+
[AutomaticRetry(Attempts = 3, DelaysInSeconds = [60, 300, 900])]
|
|
181
|
+
public async Task RunAsync(Guid userId, CancellationToken cancellationToken)
|
|
182
|
+
{
|
|
183
|
+
var result = await handler.HandleAsync(new SendWelcomeEmailRequest(userId), cancellationToken);
|
|
184
|
+
if (result.IsFailure)
|
|
185
|
+
logger.LogWarning("Welcome email for {UserId} not sent: {Code}", userId, result.Error.Code);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
185
188
|
```
|
|
186
189
|
|
|
190
|
+
- **The handler and the `ApplicationDbContext` are injected directly.** `Hangfire.AspNetCore` activates each job through `AspNetCoreJobActivator`, which opens an `IServiceScope` per job execution (`IServiceScopeFactory`) and disposes it at the end, so scoped services, including the `DbContext`, get a fresh instance per job, exactly like a request. `IDbContextFactory<ApplicationDbContext>` is only needed when **one** job runs several units of work in parallel.
|
|
191
|
+
- No `IEmailJobService`/`XService` layer: the job is the entry point, the handler is the logic, `IEmailSender` is the port to the external provider.
|
|
192
|
+
- Register the job class nowhere: the activator resolves it from the scope (its constructor dependencies must be registered).
|
|
193
|
+
|
|
187
194
|
---
|
|
188
195
|
|
|
189
196
|
## Retry & Error Handling
|
|
190
197
|
|
|
191
|
-
###
|
|
198
|
+
### Expected failures vs. unexpected failures
|
|
192
199
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
// Job logic with automatic retry on failure
|
|
198
|
-
}
|
|
199
|
-
```
|
|
200
|
+
| Failure | Where it surfaces | What Hangfire does |
|
|
201
|
+
|---------|-------------------|--------------------|
|
|
202
|
+
| Expected (user gone, already processed, business rule) | `Result` failure from the handler | Job logs and **completes** -- retrying would not change the outcome |
|
|
203
|
+
| Unexpected (database down, provider timeout, HTTP 5xx) | Exception thrown by EF Core/HttpClient, not caught anywhere | Job **fails** and `[AutomaticRetry]` schedules the next attempt |
|
|
200
204
|
|
|
201
|
-
|
|
205
|
+
There is no `try/catch` in the job or the handler: the exception the infrastructure already throws is the retry signal, and a transient provider failure is not mapped into a `Result` (that would turn a retryable outage into a completed job).
|
|
206
|
+
|
|
207
|
+
### Custom Retry Attribute
|
|
202
208
|
|
|
203
209
|
```csharp
|
|
204
|
-
|
|
205
|
-
{
|
|
206
|
-
int maxRetries = 3;
|
|
207
|
-
int attempt = 0;
|
|
210
|
+
using Hangfire;
|
|
208
211
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
await _emailSender.SendAsync(email, "Subject", "Body");
|
|
214
|
-
return; // Success
|
|
215
|
-
}
|
|
216
|
-
catch (Exception ex)
|
|
217
|
-
{
|
|
218
|
-
attempt++;
|
|
219
|
-
if (attempt >= maxRetries)
|
|
220
|
-
{
|
|
221
|
-
// Log and give up
|
|
222
|
-
_logger.LogError(ex, "Failed to send email after {Attempts} attempts", maxRetries);
|
|
223
|
-
throw;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)));
|
|
227
|
-
}
|
|
228
|
-
}
|
|
212
|
+
public sealed class SyncInvoicesJob
|
|
213
|
+
{
|
|
214
|
+
[AutomaticRetry(Attempts = 5, DelaysInSeconds = [60, 300, 900, 3600, 21600])]
|
|
215
|
+
public Task RunAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
|
229
216
|
}
|
|
230
217
|
```
|
|
231
218
|
|
|
219
|
+
- `Attempts` counts retries after the first failure; after the last one the job moves to the Failed list on the dashboard (`OnAttemptsExceeded = AttemptsExceededAction.Delete` drops it instead).
|
|
220
|
+
- Never write a manual `while (attempt < max) { try ... catch ... }` loop inside a job: it hides the attempts from the dashboard and doubles the retry policy.
|
|
221
|
+
|
|
232
222
|
---
|
|
233
223
|
|
|
234
224
|
## Dashboard Authorization
|
|
@@ -237,71 +227,59 @@ public async Task SendEmailWithRetryAsync(string email)
|
|
|
237
227
|
// Filters/HangfireAuthorizationFilter.cs
|
|
238
228
|
using Hangfire.Dashboard;
|
|
239
229
|
|
|
240
|
-
public class HangfireAuthorizationFilter : IDashboardAuthorizationFilter
|
|
230
|
+
public sealed class HangfireAuthorizationFilter : IDashboardAuthorizationFilter
|
|
241
231
|
{
|
|
242
232
|
public bool Authorize(DashboardContext context)
|
|
243
233
|
{
|
|
244
234
|
var httpContext = context.GetHttpContext();
|
|
245
235
|
|
|
246
|
-
|
|
247
|
-
if (httpContext.Request.Host.Host.Contains("localhost"))
|
|
236
|
+
if (httpContext.RequestServices.GetRequiredService<IHostEnvironment>().IsDevelopment())
|
|
248
237
|
return true;
|
|
249
238
|
|
|
250
|
-
// Require authenticated user with Admin role
|
|
251
239
|
return httpContext.User.Identity?.IsAuthenticated == true
|
|
252
240
|
&& httpContext.User.IsInRole("Admin");
|
|
253
241
|
}
|
|
254
242
|
}
|
|
255
243
|
```
|
|
256
244
|
|
|
245
|
+
Never trust `Request.Host` to detect "localhost": the `Host` header is set by the client, so `Host: localhost` from the internet would open the dashboard. Decide by the hosting environment (server-side), and require an authenticated Admin everywhere else.
|
|
246
|
+
|
|
257
247
|
---
|
|
258
248
|
|
|
259
249
|
## Best Practices
|
|
260
250
|
|
|
261
|
-
###
|
|
251
|
+
### Scoped services: inject them, the activator scopes per job
|
|
262
252
|
|
|
263
253
|
```csharp
|
|
264
|
-
|
|
265
|
-
public class BadJobService
|
|
266
|
-
{
|
|
267
|
-
private readonly AppDbContext _db; // Scoped service!
|
|
254
|
+
using Microsoft.EntityFrameworkCore;
|
|
268
255
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
// ✅ GOOD: Use IDbContextFactory
|
|
273
|
-
public class GoodJobService
|
|
256
|
+
// ✅ Hangfire.AspNetCore creates a DI scope per job: a scoped DbContext is safe here
|
|
257
|
+
public sealed class RecalculateTotalsJob(ApplicationDbContext db)
|
|
274
258
|
{
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
public GoodJobService(IDbContextFactory<AppDbContext> dbFactory)
|
|
278
|
-
=> _dbFactory = dbFactory;
|
|
279
|
-
|
|
280
|
-
public async Task ProcessAsync()
|
|
281
|
-
{
|
|
282
|
-
await using var db = await _dbFactory.CreateDbContextAsync();
|
|
283
|
-
// Use db...
|
|
284
|
-
}
|
|
259
|
+
public Task<int> RunAsync(CancellationToken cancellationToken) =>
|
|
260
|
+
db.Orders.Where(o => o.Status == OrderStatus.Pending).CountAsync(cancellationToken);
|
|
285
261
|
}
|
|
286
262
|
```
|
|
287
263
|
|
|
264
|
+
What is **not** safe: capturing a `DbContext` (or any scoped service) from a request and passing it into `BackgroundJob.Enqueue(() => ...)` -- the request scope is disposed long before the job runs. Pass ids; let the job resolve its own dependencies.
|
|
265
|
+
|
|
288
266
|
### Idempotent Jobs
|
|
289
267
|
|
|
290
268
|
```csharp
|
|
291
|
-
|
|
292
|
-
{
|
|
293
|
-
await using var db = await _dbFactory.CreateDbContextAsync();
|
|
269
|
+
using Microsoft.EntityFrameworkCore;
|
|
294
270
|
|
|
295
|
-
|
|
296
|
-
|
|
271
|
+
public sealed class ProcessOrderHandler(ApplicationDbContext db)
|
|
272
|
+
{
|
|
273
|
+
public async Task<Result> HandleAsync(Guid orderId, CancellationToken cancellationToken)
|
|
297
274
|
{
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
275
|
+
var order = await db.Orders.FirstOrDefaultAsync(o => o.Id == orderId, cancellationToken);
|
|
276
|
+
if (order is null || order.Status == OrderStatus.Processed)
|
|
277
|
+
return Result.Success();
|
|
301
278
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
279
|
+
order.Status = OrderStatus.Processed;
|
|
280
|
+
await db.SaveChangesAsync(cancellationToken);
|
|
281
|
+
return Result.Success();
|
|
282
|
+
}
|
|
305
283
|
}
|
|
306
284
|
```
|
|
307
285
|
|
|
@@ -337,9 +315,10 @@ Access the dashboard at `/hangfire` (configured in Program.cs).
|
|
|
337
315
|
| Issue | Cause | Solution |
|
|
338
316
|
|-------|-------|----------|
|
|
339
317
|
| Jobs not processing | Hangfire server not started | Ensure `AddHangfireServer()` is called |
|
|
340
|
-
| DbContext
|
|
318
|
+
| `ObjectDisposedException` on DbContext | A request-scoped instance captured into the job | Pass ids; inject the DbContext into the job class (scoped per job) |
|
|
341
319
|
| Jobs timing out | Long-running job | Split into smaller jobs with continuations |
|
|
342
|
-
|
|
|
320
|
+
| Restore fails with `NU1903` (Newtonsoft.Json) | `Hangfire.Core` pulls 11.0.1 | Reference `Newtonsoft.Json` 13.0.4 explicitly |
|
|
321
|
+
| `EnableLongPolling` never wakes the server on the `-pooler` endpoint | `LISTEN/NOTIFY` is unsupported in PgBouncer transaction mode (Neon docs) | `HangfireConnection` on the direct endpoint |
|
|
343
322
|
|
|
344
323
|
---
|
|
345
324
|
|
|
@@ -347,6 +326,7 @@ Access the dashboard at `/hangfire` (configured in Program.cs).
|
|
|
347
326
|
|
|
348
327
|
- [Hangfire Documentation](https://docs.hangfire.io/)
|
|
349
328
|
- [Hangfire Best Practices](https://docs.hangfire.io/en/latest/best-practices.html)
|
|
329
|
+
- [Hangfire.PostgreSql](https://github.com/hangfire-postgres/Hangfire.PostgreSql)
|
|
350
330
|
- [Cron Expression Generator](https://crontab.guru/)
|
|
351
331
|
|
|
352
332
|
---
|