Chapter 10 — Watching it run
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(*).
1. The tables
| 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, mapped into the same RaskAppDbContext as your products. They aren't
aggregates, so there's no OutboxMessage.Where(…) — you reach them through the context itself. Inject
IDbContextFactory<RaskAppDbContext> into the page as dbFactory and open one per refresh:
await using var db = await dbFactory.CreateDbContextAsync(ct);
2. Count what matters
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.
3. Make it live
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 OnMount()
{
await Refresh().ConfigureAwait(false);
_ = Poll();
}
private async Task Poll()
{
for (var tick = 0; tick < MaxTicks && !_stopped.IsCancellationRequested; tick++)
{
await Task.Delay(1.Second, _stopped.Token).ConfigureAwait(false);
var before = _stats;
await Refresh().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.
4. Prove the pragmas
While you're here, read the connection settings back from the live database, rather than trusting that the database was opened the way Chapter 8 said:
JournalMode = await ScalarAsync(db, "PRAGMA journal_mode"), // → wal
ForeignKeys = await ScalarAsync(db, "PRAGMA foreign_keys"), // → 1
Verify
- Buy a product and watch, without touching anything: outbox processed goes up, then jobs processed, then mail sent — the chain from Chapter 7 moving through three pillars.
- Outbox failed stays at
0. journal_modereadswalandforeign_keysreads1.
Beyond counters.
RaskAppalso answers/health, including whether the live-session pool has room, andrask deployprobes it to gate a zero-downtime swap. StandardILoggeroutput goes wherever you point it. See observability.
5. You don't have to write this one
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: /_rask 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. It is a battery like the others, so your Shop already has it — nothing in
Program.cs, and app.Configure(c => c.Ops.Off()) (or --no-ops) leaves it out.
The dashboard shows job payloads, stored email bodies and log lines, so it is gated on the admin role — the one the first account to register holds — rather than on merely being signed in, which on an app with open registration would mean everyone. Sign in as that first account and visit https://localhost:5001/_rask to compare it with the page you just built.
Learn more: dashboard · observability · jobs · outbox