# 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 `/ops` page 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:

```csharp
await using var db = await dbFactory.CreateDbContextAsync(ct);
```

## 2. Count what matters

Three counts tell you almost everything:

```csharp
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:

```csharp
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 per `await`. You call `StateHasChanged()` yourself, once per real change.
- **Compare before re-rendering.** `_stats` is a `readonly 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:

```csharp
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_mode` reads `wal` and `foreign_keys` reads `1`.

> **Beyond counters.** `RaskApp` also answers `/health`, including whether the live-session pool has room, and
> `rask deploy` probes it to gate a zero-downtime swap. Standard `ILogger` output goes wherever you point it. See [observability](https://rask.sh/docs/guides/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](https://localhost:5001/_rask) to compare it with the page
you just built.

**Learn more:** [dashboard](https://rask.sh/docs/guides/dashboard/) · [observability](https://rask.sh/docs/guides/observability/) · [jobs](https://rask.sh/docs/guides/jobs/) · [outbox](https://rask.sh/docs/guides/outbox/)

Next → **[Chapter 11: Deploy to one box](https://rask.sh/docs/guides/11-deploy/)**
