Chapter 4 — Background jobs
Goal: send order processing off the request thread with a durable background job that survives restarts. You'll write: a job record and its handler under
Features/Shared/.
When a customer places an order you don't want to make them wait while you do slow follow-up work (charging,
emailing, updating stock). Rask.Jobs enqueues that work as a durable job — a row in your SQLite
database that a background worker picks up and runs, retrying on failure. No Redis, no broker; it rides the
same app.db.
1. Write a job
Create Features/Shared/SendOrderReceipt.cs — a job record and its handler:
namespace Shop.Features.Shared;
public sealed record SendOrderReceipt : IJob;
public sealed class SendOrderReceiptHandler : ICommandHandler<SendOrderReceipt>
{
public Task Handle(SendOrderReceipt job)
{
// TODO: do the work.
return Task.CompletedTask;
}
}
A job is a record marked IJob plus the class that does the work. ICommandHandler<T> is simply
the interface the job worker hands a job to, and nothing registers the handler: it is found at build time.
Give the job the data it needs by adding a parameter:
public sealed record SendOrderReceipt(Guid OrderId) : IJob;
Pass the id, not the order. The job runs later — possibly after a restart — and should read the order as it is then.
Fill in the handler with whatever the work is (we'll make it send an email in the next chapter). Reading the order is the same call a page makes:
using Shop.Features.Orders; // for Order
public sealed class SendOrderReceiptHandler : ICommandHandler<SendOrderReceipt>
{
public async Task Handle(SendOrderReceipt job)
{
var order = await Order.Where(o => o.Id == job.OrderId).First(Current.Cancellation);
// … process the order …
}
}
There's nothing to inject for that read. Order.Where(…) opens its own context and disposes it before it
returns, which is as right on a background worker as it is on a page.
2. What's already wired
Nothing to add: the jobs battery is on, and the handler above was found at build time. Its tuning has
defaults, so there is nothing for it in appsettings.json yet. To change one, add a Rask:Jobs section:
"Rask": {
"Jobs": {
"PollInterval": "00:00:05", // how often the worker checks for due jobs
"MaxAttempts": 25 // retry a failing job up to N times
}
}
Both values are the defaults, so this changes nothing until you edit a number.
The jobs live in two tables — Job and RecurringJobState — in the same app.db. RaskAppDbContext maps
them, and the first migration rask new applied already created them, so there is nothing to migrate. When a
handler needs the context itself — to save several things in one transaction — it injects
IDbContextFactory<RaskAppDbContext> and opens one per job. Never a scoped DbContext: the worker is
long-lived, and a scoped context would outlive any unit of work.
3. Enqueue from your code
Open chapter 3's CreateOrder page in Features/Orders/CreateOrder.cs and enqueue right after the order is
saved. Nothing is injected: Jobs reaches the queue of the work it runs in, and is cancelled with it.
SendOrderReceipt lives in Features/Shared/, so the page also needs using Shop.Features.Shared;:
public sealed partial class CreateOrder : Component
{
// … the fields and Render() are unchanged …
private async Task Save(OrderModel model)
{
try
{
var order = await Order.Create(model);
await Jobs.Enqueue(new SendOrderReceipt(order.Id)); // ← enqueue
Routes.OrdersPage().Go();
}
catch (Exception)
{
_error = "Something went wrong — please try again.";
}
}
}
Order.Create hands back the saved order, with the Id it was given. The enqueue returns as soon as the
job row is written — the customer's request finishes immediately, and the worker runs the job moments later.
Need it later? Jobs.Enqueue(job).In(24.Hours), or .At(aMoment). Need it repeatedly? That one is code,
in Program.cs:
var app = RaskApp.Create(args);
app.Configure(c =>
{
c.Jobs.Run<PurgeStaleCarts>().Every(1.Hour);
c.Jobs.Run<NightlyBackup>().Daily.At(3, 0);
});
app.Run<App>();
The job row also records who placed the order, and the worker runs the handler as that user — so a handler
that needs to know whose order it is reads Current.UserId, exactly as the page could have, though nobody is
signed in on the worker's thread (more).
Two writes, not one. The order and the job are saved separately, so a crash between them keeps the order and loses its receipt. For a receipt that is a real gap, and Chapter 7 closes it.
Verify
- Placing an order returns instantly and a row appears in the jobs table.
- Add a
Console.WriteLine(or a breakpoint) inSendOrderReceiptHandler.Handle— it fires withinPollIntervalof the order being created. - Throw from the handler once and watch it retry (up to
MaxAttempts) rather than losing the work.
Learn more: background jobs