Files
buzz/docs/partition-catalog-monitoring.md
Ian Oberst ca3be55018 fix(relay): advance the partition catch-all by default (#8464)
## Summary

The migrations leave `events` and `delivery_log` with a catch-all
partition that already covers every month from 2026-07. Only catch-all
advancement replaces it with monthly partitions. Advancement was off by
default, so any deployment that left out
`BUZZ_PARTITION_MANAGER_ADVANCE_ENABLED` routed every new row into the
catch-all. An operator then had to repair it by hand: drain the
catch-all, swap it for monthly partitions, and re-attach. This has
already happened in one staging environment.

- **Advancement on by default.**
`BUZZ_PARTITION_MANAGER_ADVANCE_ENABLED` now defaults to `true`, and an
explicit `false` still disables it.
`BUZZ_PARTITION_MANAGER_CREATE_ENABLED=false` remains the kill switch
for all automatic partition DDL.
- **Prompt retry while the current month routes to the catch-all.** If a
startup or periodic pass leaves the current month routed to an empty
catch-all that the maintenance planner would replace, the audit loop
retries on its existing backoff. That is 5 s, doubling to a 60 s cap,
for at most 8 retries (about 5 minutes). After that it returns to
`BUZZ_PARTITION_AUDIT_INTERVAL_SECS`. Before this change, a failed first
pass, such as a lock timeout during a rollout, waited the full 15
minutes.
- The routine monthly advance, where only the last lookahead month falls
into the catch-all, stays on the normal interval. Each attempt queues an
`ACCESS EXCLUSIVE` request on the parent, so it shouldn't be retried
without urgency.
  - The budget resets once the catch-all no longer awaits advancement.
  - A failed attempt keeps the last known state.
- Layouts the planner refuses, such as a catch-all that holds rows, need
an operator, so they are never retried early. Neither is a policy with
create or advance disabled.
- The check is `PartitionTableAudit::awaits_catch_all_advance(policy)`,
plus a `PartitionAudit` helper that applies it to every table. It is
derived from `plan_maintenance`, so it can't drift from the planner's
rules.
- **Fresh-install test.** A new `#[ignore = "requires Postgres"]` test
runs every migration in a dedicated database. It checks that both tables
start out waiting on the catch-all and that create-only maintenance does
not fix them. It then checks that the first pass with advancement
produces canonical monthlies, an empty `{table}_p_future`, trigger
parity and a non-degraded audit, and that a second pass is a no-op.

### Related issue

None found.

### Testing

**Automated:**
- `cargo test -p buzz-db --lib partition`, including `--ignored` against
local Postgres: 21 unit tests and 55 Postgres tests pass.
- `cargo test -p buzz-relay` for the config tests and the partition
schedule tests passes.
- `cargo clippy -p buzz-db -p buzz-relay --all-targets -- -D warnings`
and `cargo fmt --check` are clean.
- Mutation checks on `awaits_catch_all_advance`. Each fails the new unit
test:
  - matching any window month instead of the current month;
  - replacing the planner check with the policy flags alone;
  - short-circuiting the current-month gate.

**Local relay** (`BUZZ_AUTO_MIGRATE=true`, no advance env var):
- **Fresh database:** startup committed `advanced` for both tables,
creating `p2026_07` through `p2027_04` plus `_p_future`.
- **Failed first pass:** I held `LOCK TABLE ONLY communities IN SHARE
ROW EXCLUSIVE MODE` for 4 s across startup. The startup pass failed with
`lock_timeout` at 17:07:20.8, and the first periodic retry committed
`advanced` for both tables at 17:07:29.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Ian Oberst <ioberst@block.xyz>
Co-authored-by: Ian Oberst <ioberst@block.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:20:18 +00:00

151 lines
7.8 KiB
Markdown

# Partition catalog monitoring
The relay audits the `events` and `delivery_log` partition catalogs at startup
and every `BUZZ_PARTITION_AUDIT_INTERVAL_SECS` (default 900 seconds). Results are
diagnostic only. They appear in `/_status.partition_catalog` and in the metrics
below, and they never change `/_readiness` or `/_liveness`. Alert on them; do not
gate traffic on them.
## Outcomes
`buzz-admin partition-audit` prints one outcome for the whole catalog.
| Outcome | Meaning | `buzz-admin` exit code |
|---|---|---|
| `ok` | Every managed table covers now and needs no operator attention. | 0 |
| `degraded` | Every table covers now, but one needs attention. | 0 |
| `unsafe` | A table cannot route a row timestamped now. | 2 |
| `error` | At least one table could not be audited. | 5 |
The relay's `buzz_partition_audit_runs_total{outcome}` is per table and is only
`ok`, `degraded`, or `error`. There is no `unsafe` outcome there: a table that
cannot route a row timestamped now is `degraded` and has
`buzz_partition_serving_safe` 0.
A table is `degraded` when it has a partition-key mismatch, a `DEFAULT` or
anomalous child, trigger drift, an occupied catch-all or `DEFAULT` leaf, or a
current or lookahead month not covered by a bounded leaf. A bounded legacy leaf
with a non-canonical name degrades the table while it covers the current month or
later. Once its upper bound is at or before the start of the current month, it is
historical. It remains in the per-child report as `legacy_leaf` but no longer
degrades the table.
## Metrics
Per-table gauges are set only by a successful audit of that table:
`buzz_partition_audit_last_success_timestamp_seconds`,
`buzz_partition_serving_safe`, `buzz_partition_uncovered_months`,
`buzz_partition_catch_all_covered_months`, `buzz_partition_default_covered_months`,
`buzz_partition_anomalous_children`, `buzz_partition_catch_all_nonempty`,
`buzz_partition_default_nonempty`, `buzz_partition_trigger_parity_missing`, and
`buzz_partition_trigger_parity_extra`. A failed audit leaves them unchanged, so a
failure never makes the last success look fresh.
The exporter removes a gauge that has not been updated within its idle timeout.
The default is 2,700 seconds: three times the larger of the usage-metrics and
partition-audit intervals. A failed audit of a table refreshes that table's
`buzz_partition_audit_last_success_timestamp_seconds` without changing its
value, so sustained failures keep the last success exported and its age keeps
rising. A table that has never been audited successfully exports `0`. The
timestamp disappears only when audits stop running for longer than the idle
timeout; the other per-table gauges can be evicted during sustained failures.
Counters are not idle-evicted while the process runs. The relay exports every
run outcome for both managed tables, and the failure counter, at 0 when its
metrics exporter starts, so `increase()` counts the first failure and the first
run with a new outcome:
- `buzz_partition_audit_runs_total{table, outcome}` increments once per table
per audit. The outcome is `ok`, `degraded`, or `error`, including the opening
audit of each partition maintenance pass.
- `buzz_partition_audit_failures_total` counts failed explicit audit attempts:
the fallback audit that runs when a maintenance pass fails, and each periodic
audit when maintenance does not run. A maintenance failure is not counted by
itself. If the fallback audit then fails, that attempt counts once.
- `buzz_partition_create_attempts_total{table, outcome}` counts per-month
decisions in each maintenance pass: `created`, `error`, or `skipped_covered`
for a month that was already covered. Maintenance runs at startup, and on
every periodic audit when `BUZZ_PARTITION_MANAGER_ADVANCE_ENABLED` is on (the
default), so `skipped_covered` then grows on every pass in steady state.
## Alerts
Use all three rules below. Aggregate with `without (...)`, not `by (...)`, so
cluster, account, environment, namespace, and pod labels stay in the alert.
When a pod terminates, its scrape target disappears and its series go stale.
Instant selectors drop a stale series at once, but range selectors such as
`[30m]` keep reading its earlier samples until they leave the window. Each
range-based rule therefore also requires the counter to be exported now, which
it is for the whole life of a relay, so a terminated pod cannot keep or start an
alert.
`BuzzPartitionAuditNotRunning` also requires the run counter to have existed one
window ago. After a successful startup audit, the first periodic audit runs a
full interval later, so a new pod's counter stays flat until then and would
otherwise read as a stopped loop.
```yaml
# The last success is older than two default audit intervals. This also fires
# for a table that has never audited successfully, whose timestamp is 0, and
# keeps firing through sustained failures. Scale 1800 with
# BUZZ_PARTITION_AUDIT_INTERVAL_SECS.
- alert: BuzzPartitionAuditStale
expr: time() - buzz_partition_audit_last_success_timestamp_seconds > 1800
for: 10m
# The relay is running but its audit loop has stopped. Use a window of about
# two audit intervals, and keep the window plus `for` below the gauge idle
# timeout (2,700 s by default), so this fires before the stopped loop's
# timestamp is evicted and the stale rule resolves. Use the same duration for
# the window and the offset. A loop that stops in a pod's first window is
# caught once the pod is a window old, at about 35 minutes.
- alert: BuzzPartitionAuditNotRunning
expr: |
sum without (outcome) (increase(buzz_partition_audit_runs_total[30m])) == 0
and sum without (outcome) (buzz_partition_audit_runs_total)
and sum without (outcome) (buzz_partition_audit_runs_total offset 30m)
for: 5m
# Explicit audit attempts keep failing, even if a later attempt succeeds.
- alert: BuzzPartitionAuditFailing
expr: |
increase(buzz_partition_audit_failures_total[1h]) >= 2
and buzz_partition_audit_failures_total
```
The counters exist from relay start, and the success timestamp exists after the
first audit attempt, whether or not it succeeded. The startup audit runs within
seconds of the zero baselines, usually before the first scrape, so
`BuzzPartitionAuditFailing` can miss a failure in that one attempt; if it does
not recover, `BuzzPartitionAuditStale` fires. The run counters are exported
before the relay connects to the database, so a live relay that is stuck before
its first audit shows flat zero counters, which `BuzzPartitionAuditNotRunning`
catches.
`degraded` asks for operator attention; it does not mean rows cannot be
written. Historical legacy leaves no longer count, so a repaired catalog
returns to `ok` once the current month passes the repaired range and every
current and lookahead month is a bounded monthly partition. Months still covered
by a catch-all stay `degraded` until advancement moves the catch-all. Coverage
problems show directly in `buzz_partition_serving_safe` and
`buzz_partition_uncovered_months`.
The tests in `crates/buzz-relay/src/metrics.rs` (`partition_alert_tests`) check
that the production exporter and audit emit the series these rules read: zero
counter baselines before the first audit, a `0` timestamp before any success,
the last success kept through failures past the idle timeout, and run counters
that outlive an evicted timestamp. They do not
evaluate the rules, their `for` durations, or label matching.
`partition-catalog-monitoring.rules.test.yml` evaluates the rules in
`partition-catalog-monitoring.rules.yml`, including their `for` durations,
against a fresh pod, a stopped loop, a terminated pod, a table that never
succeeded, and repeated failures. CI does not run it. Keep the rules file
identical to the block above, and run it after changing either:
```bash
promtool test rules docs/partition-catalog-monitoring.rules.test.yml
```
Then validate label matching against your own alerting configuration.