Goal: be able to answer "is the background work actually happening?" without SSH-ing into anything. You'll add: an
/opspage that reads every pillar's own table.
Shop now has five background workers: the outbox relay, the job processor, the mail sender, the cache purger and the snapshot service. All of them succeed silently and fail silently. Before you deploy, you want one page that says what they're doing.
Here is where the DB-backed design pays off a second time. There is no broker console to open and no
separate metrics stack to stand up, because every pillar's state is a table in the database you already
query. Queue depth is SELECT count(*).
| Pillar | Table | The question it answers |
|---|---|---|
Rask.Outbox |
OutboxMessage |
How many events are waiting, delivered, or erroring? |
Rask.Jobs |
Job, RecurringJobState |
Is anything queued, and did the recurring work fire? |
Rask.Mail |
QueuedMail |
Is mail going out, or piling up? |
Rask.Cache |
CacheEntry |
How much is cached right now? |
They're ordinary EF entities. db.Set<OutboxMessage>() works exactly like db.Products.
Three counts tell you almost everything:
OutboxProcessed = await db.Set<OutboxMessage>().CountAsync(m => m.ProcessedAt != null, ct),
OutboxFailed = await db.Set<OutboxMessage>().CountAsync(m => m.Error != null, ct),
JobsProcessed = await db.Set<Job>().CountAsync(j => j.ProcessedAt != null, ct),The failed count is the one people leave out, and it's the one that matters. Delivery is at-least-once
with retries: a message the processor can't handle doesn't throw and doesn't disappear — it records an
Error, increments Attempts, and comes back on the next poll until it hits MaxAttempts. A dashboard
that only shows "processed" reports a healthy system while an event retries itself to death.
So: processed climbing is good, failed above zero is the alert.
A page that needs refreshing won't get looked at. Poll in the background and re-render on a real change:
protected override async Task OnMountAsync()
{
await RefreshAsync().ConfigureAwait(false);
_ = PollAsync();
}
private async Task PollAsync()
{
for (var tick = 0; tick < MaxTicks && !_stopped.IsCancellationRequested; tick++)
{
await Task.Delay(TimeSpan.FromSeconds(1), _stopped.Token).ConfigureAwait(false);
var before = _stats;
await RefreshAsync().ConfigureAwait(false);
if (_stats != before)
{
StateHasChanged();
}
}
}Three things in that loop are deliberate:
ConfigureAwait(false)on every await. It keeps the loop off the lifecycle sync-context, so the framework doesn't render once perawait. You callStateHasChanged()yourself, once per real change.- Compare before re-rendering.
_statsis areadonly record struct, so!=is a value comparison. An idle system produces no diffs and no WebSocket traffic. - The loop is bounded. Every open tab is a reader competing with five writers for SQLite's single write lock. An unbounded 1 Hz poll per session is a real cost — stop after a few minutes.
While you're here, read the connection settings back from the live database, rather than trusting that
UseRaskSqlite did what Chapter 8 said:
JournalMode = await ScalarAsync(db, "PRAGMA journal_mode"), // → wal
ForeignKeys = await ScalarAsync(db, "PRAGMA foreign_keys"), // → 1- Place an order and watch, without touching anything: outbox processed goes up, then mail sent, then jobs processed — the chain from Chapter 7 moving through three pillars.
- Outbox failed stays at
0. journal_modereadswalandforeign_keysreads1.- See it running:
samples/Rask.Example.Shop's/opsis exactly this page, and its browser tests assert each of those counters moves.
Beyond counters. Rask also exposes a live-session health check (
AddHealthChecks().AddRaskLiveSessions()) thatrask deployprobes to gate a zero-downtime swap, and standardILoggeroutput goes wherever you point it. See observability.
You just did, and it was worth doing — the point of the exercise is that every pillar's state is an ordinary
table you can SELECT from, and now you've proved it.
For the parts you'd rather not hand-roll, Rask.Dashboard ships the same idea finished: /_ops with the
queue rows behind each counter, the error that caused each dead letter, a one-click retry, a log tail, and
the pragmas you read above. rask new --ops (included in --all-batteries, so your Shop already has it)
wires it in two lines:
builder.Services.AddRaskDashboard<AppDbContext>();
builder.Services.AddAuthorization(o =>
o.AddPolicy(RaskDashboardPolicies.Access, p => p.RequireAuthenticatedUser()));That second line is not optional decoration. The dashboard shows job payloads and stored email bodies, so without a policy it refuses to serve anyone outside Development. Sign in and visit https://localhost:5001/_ops to compare it with the page you just built.
Learn more: dashboard · observability · jobs · outbox