mirror of
https://github.com/MeshCore-Beacon/beacon-docs.git
synced 2026-10-23 07:32:29 +00:00
Document database pools, cost guards, metrics and statement statistics (#28)
This commit is contained in:
1 parent
86b5a611b3
commit
30ea3d2720
5 files changed
+422
-12
No files matched your search
@@ -33,6 +33,10 @@ server:
|
||||
# With additional proxy hops, first configure their trust at the edge proxy
|
||||
# and pass its validated client address rather than a client-supplied header.
|
||||
trusted_proxies: []
|
||||
# Address of the admin listener serving Prometheus /metrics, e.g. 127.0.0.1:9090
|
||||
# (0.0.0.0:9090 inside Docker, published on the host's 127.0.0.1 only). Off when empty.
|
||||
# It has no authentication: bind it to a private interface.
|
||||
admin_listen: ""
|
||||
|
||||
# Applies to /api/v1/* only. Both counters are local to this Beacon instance.
|
||||
# Set trusted_proxies correctly before using a reverse proxy, or clients share
|
||||
@@ -45,6 +49,27 @@ ratelimit:
|
||||
# A 429 returns error.code=rate_limited and Retry-After (1 or 60 seconds).
|
||||
burst: 600
|
||||
|
||||
# Database pools. custom_all (default) opens api_legacy 8, api_custom 8, ingest 6, maint 4 connections
|
||||
# (26); split opens 30 (api_custom 7, api_cached 5); single opens 18 (api_legacy, ingest, maint).
|
||||
# ingest grows to one connection per CPU on hosts with more than 6 CPUs, adding to each total.
|
||||
# Keep max_connections well above that. A pool_max_conns in POSTGRES_DSN is overridden.
|
||||
#database:
|
||||
# # custom_all (default): every API statement gets a custom plan.
|
||||
# # split: statements admitted to the generic-plan class run on cached generic plans. No statement
|
||||
# # is admitted yet, so split has no effect; keep custom_all.
|
||||
# # single: rollback. One API pool on the previous settings; every guard family observes.
|
||||
# api_plan_layout: custom_all
|
||||
|
||||
# Cost guards on the public /api/v1 group (not /admin, not /ws). Every family observes by default:
|
||||
# nothing is refused, only beacon_guard_would_refuse_total counts. Listed families enforce:
|
||||
# 429 (4 concurrent DB requests per client, 5 s wait), 503 (pool busy 5 s; class deadline 5/10/30 s).
|
||||
# Families: routes, channel_messages, packets_global, packets_region, packets_window,
|
||||
# packet_detail, stats, other. PUT /api/v1/admin/config changes the list at runtime;
|
||||
# a restart reloads this one.
|
||||
#guards:
|
||||
# enforce: []
|
||||
# budget: shadow # the per-client DB-time budget only records; enforcement is not available
|
||||
|
||||
# MeshMapper integrations (optional, require a regional or grouped-region API key).
|
||||
# Your local MeshMapper regional or grouped-region admin can generate the key.
|
||||
# A grouped-region key covers multiple regions, including imported group members.
|
||||
|
||||
+73
-10
@@ -41,6 +41,47 @@ Retry-After: 60
|
||||
|
||||
**Client IP.** The server only accepts `X-Real-IP`, and only from a direct peer listed in `server.trusted_proxies`. `X-Forwarded-For` and `True-Client-IP` are always ignored and stripped. Behind a reverse proxy that isn't listed, every client shares the proxy's budget.
|
||||
|
||||
## Cost guards
|
||||
|
||||
Every public `/api/v1` request also passes through cost guards (not `/ws`, not `/api/v1/admin/*`). By default they only count what they would refuse, and no response changes. A guard refuses only for a family the operator has listed in `guards.enforce` (see [Configuration](configuration.md#database-pools-and-cost-guards)). Clients should still handle the three refusals:
|
||||
|
||||
| Status | `code` | `Retry-After` | When |
|
||||
|---|---|---|---|
|
||||
| `429` | `rate_limited` | `1` | More than 4 database-backed requests at once from one client, and no slot freed within 5 s. Cache hits don't count. |
|
||||
| `503` | `service_unavailable` | `2` | No database connection within 5 s. |
|
||||
| `503` | `deadline_exceeded` | `5` | The request's database time passed its class limit: entity 5 s, list 10 s, aggregation 30 s, counted from its first statement. |
|
||||
|
||||
```
|
||||
HTTP/1.1 503 Service Unavailable
|
||||
Retry-After: 5
|
||||
|
||||
{"error":{"code":"deadline_exceeded","message":"The request ran past its database time limit. Retry later."}}
|
||||
```
|
||||
|
||||
The body is the usual error envelope. Wait at least `Retry-After` seconds before retrying; a `deadline_exceeded` request will likely run as long again, so narrow it (a shorter window, a region) rather than repeat it. Clients are keyed by IP after the trusted-proxy step above.
|
||||
|
||||
Each request falls in one family and one class:
|
||||
|
||||
| Path | Condition | Family | Class |
|
||||
|---|---|---|---|
|
||||
| `/packets`, `/packets/backfill` | `since` or `until` set (`/packets` only) | `packets_window` | aggregation |
|
||||
| `/packets`, `/packets/backfill` | region set | `packets_region` | list |
|
||||
| `/packets`, `/packets/backfill` | otherwise (including `hashPrefix`) | `packets_global` | list |
|
||||
| `/packets/{hash}` | | `packet_detail` | entity |
|
||||
| `/routes` | | `routes` | list |
|
||||
| `/routes/cross`, `/routes/search` | | `routes` | aggregation |
|
||||
| `/routes/{iata}/{pathKey}/observations` | | `routes` | entity |
|
||||
| `/channels/{id}/messages`, `/messages`, `/messages/backfill` | | `channel_messages` | list |
|
||||
| `/stats/*` | | `stats` | aggregation |
|
||||
| `/channels`, `/nodes`, `/observers`, `/traces`, `/nodes/{id}/observations`, `/observers/{id}/adverts` | | `other` | list |
|
||||
| `/observers/directory` | region set | `other` | list |
|
||||
| `/observers/directory` | otherwise | `other` | aggregation |
|
||||
| `/observers/{id}/activity` | | `other` | aggregation |
|
||||
| `/observers/{id}`, `/observers/{id}/telemetry`, `/nodes/{id}`, `/nodes/{id}/neighbors`, `/channels/{id}`, `/traces/{tag}` | | `other` | entity |
|
||||
| everything else (`/iatas*`, `/regions*`, `/scopes*`, `/info`, `/brokers`, unknown paths) | | `other` | entity |
|
||||
|
||||
"Region set" means a non-empty `iata`, `iatas`, `region` or `regionId`.
|
||||
|
||||
## Versioning
|
||||
|
||||
Path is the contract: `/api/v1/`. Breaking changes get `/api/v2/`. Backward-compatible additions just expand the existing payloads. WebSocket messages include a `type` discriminator and a top-level `v: 1` field so we can evolve in place.
|
||||
@@ -53,7 +94,7 @@ All errors use one shape:
|
||||
{ "error": { "code": "not_found", "message": "packet not found" } }
|
||||
```
|
||||
|
||||
`code` is the HTTP status text in snake_case, so match on it or on the status. Codes in use: `bad_request`, `unauthorized`, `not_found`, `conflict`, `request_entity_too_large`, `unsupported_media_type`, `rate_limited`, `internal_server_error`, `service_unavailable`, `gateway_timeout`, `insufficient_storage`. The heavy stats endpoints (`series`, `signal`, `paths`, `observer-comparison`) return `503` when the query times out; try a shorter window.
|
||||
`code` is the HTTP status text in snake_case, so match on it or on the status. Codes in use: `bad_request`, `unauthorized`, `not_found`, `conflict`, `request_entity_too_large`, `unsupported_media_type`, `rate_limited`, `internal_server_error`, `service_unavailable`, `gateway_timeout`, `insufficient_storage`, and `deadline_exceeded` (a `503` from the [cost guards](#cost-guards)). The heavy stats endpoints (`series`, `signal`, `paths`, `observer-comparison`) return `503` when the query times out; try a shorter window.
|
||||
|
||||
A `404` means the requested thing doesn't exist. A database or other server-side failure is always a `500` (or `503` above), never a `404`, so clients can retry 5xx and treat 4xx as final. Empty lists are `200` with `[]`.
|
||||
|
||||
@@ -662,19 +703,41 @@ All under `/api/v1/admin/`, bearer key required (see [Auth](#auth)).
|
||||
|
||||
| Endpoint | Notes |
|
||||
|---|---|
|
||||
| `GET /admin/config` | Running CORS settings with defaults applied, `auth.configured`, `ingest.broker_count` (configured broker workers, not connection status), `mobile.min_app_version` and `web.min_web_version` (read-only effective floors; `""` when there is none). No credentials, broker addresses, channel material or database settings. |
|
||||
| `PUT /admin/config` | Replaces `cors.allowed_origins` until the next restart. JSON body up to 16 KiB. |
|
||||
| `GET /admin/config` | Running CORS settings with defaults applied, `auth.configured`, `ingest.broker_count` (configured broker workers, not connection status), `mobile.min_app_version` and `web.min_web_version` (read-only effective floors; `""` when there is none), and `guards` (below). No credentials, broker addresses, channel material or database connection settings. |
|
||||
| `PUT /admin/config` | Replaces `cors.allowed_origins`, `guards.enforce` or both until the next restart. JSON body up to 16 KiB. |
|
||||
| `GET /admin/db/statements` | `pg_stat_statements` for this database, summed by statement name. `503` when the extension is missing or not preloaded. |
|
||||
| `GET /admin/accounts`, `POST /admin/accounts` | List or create operator account records (name only; not logins). |
|
||||
| `GET /admin/accounts/{id}`, `DELETE /admin/accounts/{id}` | Fetch or deactivate one. |
|
||||
| `GET /admin/backup` | Streams a private `.tar.gz` of the database and saved config. Off unless `backup.enabled: true`; needs `pg_dump` in the server's runtime. One export at a time (`409`), `504` on timeout, `507` over the size limit. Contains secrets. |
|
||||
|
||||
`PUT /admin/config` accepts only `{"cors":{"allowed_origins":[...]}}` and applies the new list
|
||||
immediately. It is runtime-only: nothing is written to disk, and a restart reloads the file. The
|
||||
response carries `config`, `persisted: false` and `requires_restart: false`. Send 1 to 32 ASCII
|
||||
http(s) origins of at most 512 bytes each, with an optional single hostname wildcard; a lone `*`
|
||||
allows all. Empty or null lists, paths, queries, credentials, control characters and unknown
|
||||
fields are rejected. Concurrent updates apply one at a time; requests already in flight may see
|
||||
the old list.
|
||||
`PUT /admin/config` accepts `{"cors":{"allowed_origins":[...]}}`, `{"guards":{"enforce":[...]}}`
|
||||
or both, and applies them immediately. Both are validated before either is applied, so a rejected
|
||||
update changes nothing. It is runtime-only: nothing is written to disk, and a restart reloads the
|
||||
file. The response carries `config`, `persisted: false` and `requires_restart: false`. Send 1 to
|
||||
32 ASCII http(s) origins of at most 512 bytes each, with an optional single hostname wildcard; a
|
||||
lone `*` allows all. Empty or null lists, paths, queries, credentials, control characters and
|
||||
unknown fields are rejected. Concurrent updates apply one at a time; requests already in flight
|
||||
may see the old settings.
|
||||
|
||||
`guards` in the config reports the [cost guards](#cost-guards):
|
||||
|
||||
```json
|
||||
{ "enforce": ["other", "routes"], "observe_only": false, "budget": "shadow", "api_plan_layout": "custom_all" }
|
||||
```
|
||||
|
||||
`enforce` lists the families that refuse; every other family only counts. `observe_only` is true
|
||||
under `database.api_plan_layout: single`, where every family observes whatever `enforce` says.
|
||||
`budget` is always `shadow`. In a `PUT`, `guards.enforce` is a list of family names (`routes`,
|
||||
`channel_messages`, `packets_global`, `packets_region`, `packets_window`, `packet_detail`,
|
||||
`stats`, `other`), deduplicated and returned in that order. `[]` makes every family observe; an
|
||||
unknown name is a `400`. The change applies to requests that start afterwards.
|
||||
|
||||
`GET /admin/db/statements` returns `{"items":[...]}`, highest total execution time first. Each
|
||||
item has `name` (the statement name; statements without one are summed as `(unnamed)`), `calls`,
|
||||
`exec_ms`, `plan_ms`, `shared_hit` and `shared_read` (buffer blocks), `rows` and `wal_bytes`.
|
||||
Times are milliseconds rounded to microseconds. `plan_ms` stays 0 unless `pg_stat_statements.track_planning` is on.
|
||||
Counters run from the last `pg_stat_statements` reset. Setup is in
|
||||
[Operations](operations.md#statement-statistics).
|
||||
|
||||
`POST /admin/accounts` takes `{"name": "..."}` in a body of at most 4 KiB. The name is trimmed,
|
||||
case-sensitive, 1 to 128 characters with no control characters, and unique among active
|
||||
|
||||
@@ -122,6 +122,16 @@ needed extensions:
|
||||
psql -X --set ON_ERROR_STOP=on --single-transaction --file database.sql
|
||||
```
|
||||
|
||||
Dumps of a database where migration 010 created `pg_stat_statements` contain
|
||||
`CREATE EXTENSION IF NOT EXISTS pg_stat_statements` and a `COMMENT ON EXTENSION
|
||||
pg_stat_statements` line. Restoring as a superuser needs nothing extra. As any other role,
|
||||
with `ON_ERROR_STOP` the first stops the restore unless a superuser has created the extension
|
||||
in the new database first, and the comment stops it even then, because only the extension's
|
||||
owner may comment on it. So delete both lines from `database.sql`, restore, and have a
|
||||
superuser run `CREATE EXTENSION pg_stat_statements;` afterwards if you want statement
|
||||
statistics. They also need `shared_preload_libraries` on the new server; see
|
||||
[Statement statistics](operations.md#statement-statistics).
|
||||
|
||||
Check the migration journal and some records, review the config, and restore external
|
||||
secrets and files before pointing a new Beacon at it. Never test an export by restoring over
|
||||
live data. See [pg_dump](https://www.postgresql.org/docs/16/app-pgdump.html) and
|
||||
|
||||
+63
-1
@@ -67,8 +67,10 @@ which ones to read.
|
||||
|---|---|---|
|
||||
| `auth` | The admin API key, if you would rather keep it in the file than in `BEACON_API_KEY`. | You want the admin endpoints. |
|
||||
| `log` | Log level and format. | You want JSON logs for a collector. |
|
||||
| `server` | `trusted_proxies`: the proxy addresses allowed to tell Beacon the real client IP. | Always, if Beacon sits behind a proxy. See [Reverse proxy](reverse-proxy.md). |
|
||||
| `server` | `trusted_proxies`: the proxy addresses allowed to tell Beacon the real client IP. `admin_listen`: an optional private listener for Prometheus `/metrics`. | Always, if Beacon sits behind a proxy. See [Reverse proxy](reverse-proxy.md). |
|
||||
| `ratelimit` | Per-client REST limits for `/api/v1/*`. On by default at 600 requests a minute. | You get 429s you did not expect. |
|
||||
| `database` | `api_plan_layout`: how API statements are spread over database connection pools. | You check `max_connections`, or need the rollback layout. See [Database pools and cost guards](#database-pools-and-cost-guards). |
|
||||
| `guards` | Which request families the cost guards may refuse. All of them only observe by default. | After a week of observing. See [Database pools and cost guards](#database-pools-and-cost-guards). |
|
||||
| `meshmapper` | Import transport scopes, IATA borders, region groups and public channels from MeshMapper instead of listing them by hand, and add repeater site details to node detail. Requires a [MeshMapper API key](#meshmapper-api-key). | You are in a region MeshMapper covers. |
|
||||
| `iatas` | Display names, coordinates and optional border files for airport codes. IATAs are created automatically when traffic arrives; this block only decorates them. A `borderFile` path is relative to `config.yaml`; in Docker the compose file mounts only `config.yaml`, so add a mount for the folder too (`- ./data/app/borders:/app/borders:ro` under the `app` service). | You want names on the map or a border drawn. |
|
||||
| `regions` | Groups of IATAs with a name, map centre and zoom. | Always, unless MeshMapper imports them. |
|
||||
@@ -107,11 +109,71 @@ cannot honour. These are the ones people hit:
|
||||
- `log.level` or `log.format` set to anything other than the listed values.
|
||||
- `mobile.min_app_version` or `web.min_web_version` that is not a plain `X.Y.Z` release (`v1.2.3` and `1.2.3-beta` are rejected).
|
||||
- An admin key under 16 characters or containing whitespace.
|
||||
- `database.api_plan_layout` other than `custom_all`, `split` or `single`; a `guards.enforce`
|
||||
entry that is not a family name; `guards.budget` other than `shadow`; `server.admin_listen`
|
||||
that is not `host:port` with a port from 1 to 65535.
|
||||
- A negative `ratelimit.requests_per_minute`, `ratelimit.burst`, `websocket.max_connects_per_minute`
|
||||
or `routes.reconfirm_rows_per_hour`.
|
||||
A negative `websocket.max_connections_per_ip` is not caught at startup and rejects every
|
||||
connection instead, so leave it at a positive number.
|
||||
|
||||
## Database pools and cost guards
|
||||
|
||||
Beacon opens one PostgreSQL connection pool per workload. Pools open connections only when
|
||||
needed, so these are peaks:
|
||||
|
||||
| `database.api_plan_layout` | `api_legacy` | `api_custom` | `api_cached` | `ingest` | `maint` | Total |
|
||||
|---|---:|---:|---:|---:|---:|---:|
|
||||
| `custom_all` (default) | 8 | 8 | - | 6 | 4 | 26 |
|
||||
| `split` | 8 | 7 | 5 | 6 | 4 | 30 |
|
||||
| `single` (rollback) | 8 | - | - | 6 | 4 | 18 |
|
||||
|
||||
`ingest` has one connection per CPU on hosts with more than 6 CPUs, which adds to each total.
|
||||
The startup log line `database pools open` shows the real figure. Postgres `max_connections`
|
||||
must cover it plus every other client. A `pool_max_conns` in `POSTGRES_DSN` is ignored; the
|
||||
layout sets the sizes.
|
||||
|
||||
- **`custom_all`** plans every API statement for its own parameters on every call, so no
|
||||
statement can switch to a slow generic plan. Use it.
|
||||
- **`split`** would run a list of proven statements on cached generic plans. That list is empty
|
||||
in this release, so `split` only opens a pool nothing uses. Keep `custom_all`.
|
||||
- **`single`** is the rollback: one API pool on the previous session settings, and every guard
|
||||
family only observes, whatever `guards.enforce` says.
|
||||
|
||||
The layout changes at a restart. What each pool sets is in
|
||||
[Operations](operations.md#database-pools-and-connections).
|
||||
|
||||
```yaml
|
||||
database:
|
||||
api_plan_layout: custom_all
|
||||
|
||||
guards:
|
||||
enforce: [] # routes, channel_messages, packets_global, packets_region,
|
||||
# packets_window, packet_detail, stats, other
|
||||
budget: shadow
|
||||
```
|
||||
|
||||
The cost guards cover public `/api/v1` requests, not `/ws` or `/api/v1/admin/*`. Each request
|
||||
belongs to a family and a class (the table is in the
|
||||
[API contract](api-contract.md#cost-guards)). Per family, three guards decide whether a request
|
||||
may use the database: at most 4 database-backed requests at once per client IP, waiting up to
|
||||
5 s for a slot; a database connection within 5 s; and a database time limit of 5 s (entity),
|
||||
10 s (list) or 30 s (aggregation). A family listed in `guards.enforce` refuses with `429` or
|
||||
`503`. Every other family only counts what it would have refused, in
|
||||
`beacon_guard_would_refuse_total`. The default `[]` refuses nothing.
|
||||
|
||||
`guards.enforce` is the boot default. `PUT /api/v1/admin/config` replaces it at runtime, and a
|
||||
restart goes back to the file, so make the same change in both. When to enforce a family is in
|
||||
[Operations](operations.md#enforcing-a-guard-family).
|
||||
|
||||
`guards.budget` must be `shadow`. The per-client database time budget (30 s, refilling at one
|
||||
second per second) only counts; it never refuses.
|
||||
|
||||
`server.admin_listen` (default empty, off) opens a second listener, for example
|
||||
`127.0.0.1:9090`, that serves only `GET /metrics`. It has no authentication, so bind it to
|
||||
loopback or a private network and never publish it through the proxy. See
|
||||
[Operations](operations.md#metrics).
|
||||
|
||||
## MeshMapper API key
|
||||
|
||||
MeshMapper APIs require either a regional API key or a grouped-region API key covering
|
||||
|
||||
+251
-1
@@ -260,7 +260,9 @@ Notes:
|
||||
- `max_wal_size = '16GB'` lets WAL grow that far between checkpoints. Make sure the volume
|
||||
under `data/postgres/` has the room, or pick a smaller value on a small host.
|
||||
- `pg_stat_statements` only collects once it is in `shared_preload_libraries`, which needs a
|
||||
restart. If it is not there yet, add it at the restart in the next section.
|
||||
restart. If it is not there yet, add it at the restart in the next section. Beacon's
|
||||
migration 010 creates the extension itself where it can; see
|
||||
[Statement statistics](#statement-statistics).
|
||||
- `DROP INDEX CONCURRENTLY` cannot run inside a transaction block; run it on its own in
|
||||
autocommit mode, as `psql` does by default. It takes a brief `SHARE UPDATE EXCLUSIVE` lock
|
||||
and waits for older transactions, so run the pre-deploy check above first. It frees about
|
||||
@@ -363,6 +365,254 @@ SELECT indexrelid::regclass FROM pg_index WHERE NOT indisvalid;
|
||||
DROP INDEX CONCURRENTLY IF EXISTS idx_known_routes_keyset_ccnew; -- or idx_known_routes_iata_keyset_ccnew
|
||||
```
|
||||
|
||||
## Database pools and connections
|
||||
|
||||
Beacon opens a separate connection pool per workload, sized by `database.api_plan_layout`
|
||||
(see [Configuration](configuration.md#database-pools-and-cost-guards)). Each pool sets its own
|
||||
session settings and an `application_name`, so `pg_stat_activity` shows which one a
|
||||
connection belongs to:
|
||||
|
||||
| Pool | `application_name` | Size | Session settings |
|
||||
|---|---|---:|---|
|
||||
| `api_legacy` | `beacon-api_legacy` | 8 | Server defaults. Runs the few API statements that keep the previous plan behaviour. |
|
||||
| `api_custom` | `beacon-api_custom` | 8 (7 under `split`) | `plan_cache_mode=force_custom_plan`, `jit=off`, `max_parallel_workers_per_gather=0`, `statement_timeout=35s`, `idle_in_transaction_session_timeout=30s` |
|
||||
| `api_cached` | `beacon-api_cached` | 5, `split` only | As `api_custom`, with `plan_cache_mode=force_generic_plan` |
|
||||
| `ingest` | `beacon-ingest` | 6, or one per CPU above 6 | `plan_cache_mode=auto`, `jit=off`, no statement timeout: an ingest write waits out a lock rather than drop a packet |
|
||||
| `maint` | `beacon-maint` | 4 | `plan_cache_mode=force_custom_plan`, `jit=off`, `max_parallel_workers_per_gather=0`. Background jobs, importers, backups and admin queries. |
|
||||
|
||||
The `single` layout opens only `api_legacy`, `ingest` and `maint`.
|
||||
|
||||
- **Headroom.** `SHOW max_connections;` must leave room for the total (26 by default on up to
|
||||
6 CPUs) plus every other client: `psql` sessions, backups, monitoring. If your deploy starts
|
||||
the new server before the old one stops, count both.
|
||||
- **Checking.** Parallel workers carry their leader's `application_name`, so count client
|
||||
backends only:
|
||||
|
||||
```sql
|
||||
SELECT application_name, count(*) FROM pg_stat_activity
|
||||
WHERE application_name LIKE 'beacon-%' AND backend_type = 'client backend' GROUP BY 1;
|
||||
```
|
||||
|
||||
No pool should go above its size, and `beacon-api_cached` should be absent under
|
||||
`custom_all`.
|
||||
- **`pool_max_conns`.** A `pool_max_conns` in `POSTGRES_DSN` is overridden by the pool sizes;
|
||||
the server logs that at startup.
|
||||
- **PgBouncer.** The session settings are sent as startup parameters. Behind PgBouncer,
|
||||
`ignore_startup_parameters` must list `plan_cache_mode`, `jit`, `statement_timeout`,
|
||||
`idle_in_transaction_session_timeout` and `max_parallel_workers_per_gather`, or connections
|
||||
are refused. PgBouncer then drops them, so the pools run on the server defaults; connect
|
||||
Beacon to Postgres directly where you can.
|
||||
- **Background jobs** share the 4 `maint` connections without parallel plans, so a job may
|
||||
take a little longer than before. `beacon_job_duration_seconds` shows it.
|
||||
|
||||
### Small hosts
|
||||
|
||||
Each hourly analytics rollup sets `work_mem` to 256 MB for its own transaction, and Postgres
|
||||
may use that much for every sort or hash step in it. On a host with little memory, watch the
|
||||
`db` container's memory while rollups run (about 95 minutes past each hour).
|
||||
|
||||
## Statement statistics
|
||||
|
||||
`GET /api/v1/admin/db/statements` sums `pg_stat_statements` by statement name (format in the
|
||||
[API contract](api-contract.md#admin)). It needs the extension created and preloaded:
|
||||
|
||||
- **Created.** Migration `010_pg_stat_statements.sql` creates the extension when the server
|
||||
offers it and the database role may. If not, it logs a notice and is still recorded, so
|
||||
startup never fails over it. The shipped stacks connect as the superuser, so it is created.
|
||||
Otherwise run `CREATE EXTENSION pg_stat_statements;` as a superuser.
|
||||
- **Preloaded.** It only collects once `shared_preload_libraries` includes it, which needs a
|
||||
Postgres restart. Add it to the `db` service's command at the next restart (see
|
||||
[Settings that need a restart](#settings-that-need-a-restart)), keeping any `-c` values
|
||||
already there:
|
||||
|
||||
```yaml
|
||||
db:
|
||||
command: ["postgres", "-c", "shared_preload_libraries=pg_stat_statements"]
|
||||
```
|
||||
|
||||
Until both are true the endpoint returns `503`. Check with `SHOW shared_preload_libraries;`
|
||||
and a request to the endpoint with the admin key.
|
||||
|
||||
Planning time (`plan_ms`) is only recorded while `pg_stat_statements.track_planning` is on. It
|
||||
adds overhead, so turn it on for one day to measure, then off again; both apply on reload:
|
||||
|
||||
```sql
|
||||
ALTER SYSTEM SET pg_stat_statements.track_planning = on;
|
||||
SELECT pg_reload_conf();
|
||||
-- a day later
|
||||
ALTER SYSTEM RESET pg_stat_statements.track_planning;
|
||||
SELECT pg_reload_conf();
|
||||
```
|
||||
|
||||
API statements are planned on every call, so summed `plan_ms` should stay a small fraction of
|
||||
summed `exec_ms`, under about 7%.
|
||||
|
||||
## Metrics
|
||||
|
||||
With `server.admin_listen` set, the server opens a second listener that serves only
|
||||
`GET /metrics` in Prometheus format. It has no authentication. `/metrics` is not served on the
|
||||
public port.
|
||||
|
||||
In the Docker stack the listener runs inside the `app` container, so bind it to all of the
|
||||
container's interfaces and publish it on the host's loopback only:
|
||||
|
||||
```yaml
|
||||
# config.yaml
|
||||
server:
|
||||
admin_listen: "0.0.0.0:9090"
|
||||
```
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml, app service
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
- "127.0.0.1:9090:9090"
|
||||
```
|
||||
|
||||
Never route it through Caddy. Scrape it from the host:
|
||||
|
||||
```yaml
|
||||
scrape_configs:
|
||||
- job_name: beacon
|
||||
static_configs:
|
||||
- targets: ["127.0.0.1:9090"]
|
||||
```
|
||||
|
||||
| Metric | Type | Labels | What it is |
|
||||
|---|---|---|---|
|
||||
| `beacon_http_request_seconds` | histogram | `route`, `family` | HTTP request time by route pattern |
|
||||
| `beacon_db_time_seconds` | histogram | `statement`, `pool` | Time per database statement |
|
||||
| `beacon_pool_acquire_seconds` | histogram | `pool` | Wait for a pooled connection |
|
||||
| `beacon_guard_db_seconds` | histogram | `family`, `class` | Database time per guarded request |
|
||||
| `beacon_guard_would_refuse_total` | counter | `family`, `guard` | Requests a guard would refuse if its family enforced |
|
||||
| `beacon_guard_refused_total` | counter | `family`, `guard` | Requests a guard refused |
|
||||
| `beacon_ingest_queue_depth` | gauge | | Messages waiting in the ingest queue |
|
||||
| `beacon_ingest_dropped_total` | counter | | Messages dropped because the ingest queue was full |
|
||||
| `beacon_job_duration_seconds` | histogram | `job` | Background job run time (`cleanup`, `analytics_rollup`, `reconfirm`, `route_retention`, ...) |
|
||||
| `beacon_job_rows_total` | counter | `job` | Rows removed by background jobs, today `reconfirm` and `route_retention` |
|
||||
| `beacon_index_bytes` | gauge | `index` | Size of each index, hourly |
|
||||
| `beacon_index_bloat_ratio` | gauge | `index` | Bytes per row against the last rebuild, hourly. See [Index health](#index-health). |
|
||||
| `beacon_pg_wal_bytes_total` | counter | | WAL written, cluster-wide |
|
||||
| `beacon_pg_checkpoints_total` | counter | `kind` | Checkpoints, `timed` or `requested` |
|
||||
| `beacon_pg_xid_age` | gauge | | Age of the oldest transaction ID in the database |
|
||||
|
||||
`guard` is `deadline`, `cap`, `admission` or `budget`. The Postgres counters are read once a
|
||||
minute; the index gauges first appear an hour after startup. Go runtime and process metrics
|
||||
are included too.
|
||||
|
||||
### Example alert rules
|
||||
|
||||
```yaml
|
||||
groups:
|
||||
- name: beacon
|
||||
rules:
|
||||
- alert: BeaconGuardWouldRefuse # any would-refuse delays enforcing that family
|
||||
expr: sum by (family, guard) (increase(beacon_guard_would_refuse_total[1h])) > 0
|
||||
- alert: BeaconDeadlineHits
|
||||
expr: sum(increase(beacon_guard_refused_total{guard="deadline"}[1h])) > 20
|
||||
- alert: BeaconListSlow # list-class database time p99 over 1 s
|
||||
expr: histogram_quantile(0.99, sum by (le) (rate(beacon_guard_db_seconds_bucket{class="list"}[15m]))) > 1
|
||||
for: 15m
|
||||
- alert: BeaconPoolAcquireSlow
|
||||
expr: histogram_quantile(0.99, sum by (le, pool) (rate(beacon_pool_acquire_seconds_bucket[5m]))) > 0.5
|
||||
for: 10m
|
||||
- alert: BeaconCleanupLong # a cleanup run over 10 min in the last 2 h
|
||||
expr: increase(beacon_job_duration_seconds_count{job="cleanup"}[2h]) - increase(beacon_job_duration_seconds_bucket{job="cleanup",le="600"}[2h]) > 0
|
||||
- alert: BeaconKeysetBloat
|
||||
expr: beacon_index_bloat_ratio{index=~"idx_known_routes_(iata_)?keyset"} > 4
|
||||
- alert: BeaconCheckpointsRequested
|
||||
expr: increase(beacon_pg_checkpoints_total{kind="requested"}[1d]) > increase(beacon_pg_checkpoints_total{kind="timed"}[1d])
|
||||
- alert: BeaconWALAboveBaseline # set the baseline from your own steady state
|
||||
expr: rate(beacon_pg_wal_bytes_total[1h]) > <baseline bytes/s>
|
||||
- alert: BeaconXIDAge
|
||||
expr: beacon_pg_xid_age > 300e6
|
||||
```
|
||||
|
||||
Replace `<baseline bytes/s>` with your normal WAL rate before loading the rules. The route
|
||||
recheck has no metric yet; at `debug` level it logs a `route reconfirm` line per slice, and
|
||||
`wrapped=true` marks the end of a full cycle.
|
||||
|
||||
## Enforcing a guard family
|
||||
|
||||
Every guard family only observes until you enforce it (see
|
||||
[Configuration](configuration.md#database-pools-and-cost-guards)). Observe for 7 days with
|
||||
metrics on, then enforce a family only when all three hold for it over those 7 days. With
|
||||
`routes` as the example (Prometheus needs at least 7 days of retention):
|
||||
|
||||
1. No would-refuse from any guard. Every series must be 0:
|
||||
|
||||
```promql
|
||||
sum by (guard) (increase(beacon_guard_would_refuse_total{family="routes"}[7d]))
|
||||
```
|
||||
|
||||
2. The p99 database time of each class is at most a quarter of its limit: entity 1.25 s, list
|
||||
2.5 s, aggregation 7.5 s.
|
||||
|
||||
```promql
|
||||
histogram_quantile(0.99, sum by (le, class) (rate(beacon_guard_db_seconds_bucket{family="routes"}[7d])))
|
||||
```
|
||||
|
||||
3. No request used more than half its limit. Histograms have no exact maximum, so check that
|
||||
no request landed above the bucket at or under half the limit: `le="2.5"` for entity,
|
||||
`le="5"` for list, `le="10"` for aggregation (stricter than 15 s, which has no bucket). Each
|
||||
must be 0:
|
||||
|
||||
```promql
|
||||
sum(increase(beacon_guard_db_seconds_count{family="routes",class="list"}[7d]))
|
||||
- sum(increase(beacon_guard_db_seconds_bucket{family="routes",class="list",le="5"}[7d]))
|
||||
```
|
||||
|
||||
Then enforce it at runtime, listing every family that should enforce (the list replaces the
|
||||
current one):
|
||||
|
||||
```bash
|
||||
curl -s -X PUT https://<your-domain>/api/v1/admin/config \
|
||||
-H "Authorization: Bearer $BEACON_API_KEY" -H 'Content-Type: application/json' \
|
||||
-d '{"guards":{"enforce":["other","routes"]}}'
|
||||
```
|
||||
|
||||
The response's `config.guards.enforce` shows the new list. Add the same list to
|
||||
`guards.enforce` in `config.yaml`, or the next restart goes back to the old one. To stop
|
||||
enforcing, `PUT` the list without that family (`[]` for none). `beacon_guard_refused_total`
|
||||
then counts real refusals.
|
||||
|
||||
`other` and `routes` are the families to start with. The rest are not ready in this release:
|
||||
`channel_messages` statements are not guarded yet, and the packet and stats families still
|
||||
run statements that later releases make cheaper. Their release notes will say when each is
|
||||
ready, roughly `channel_messages` first, then `packets_global`, then `packets_region` and
|
||||
`packets_window`, then `stats` and `packet_detail`. `packets_window` may stay observed for
|
||||
good.
|
||||
|
||||
## Index health
|
||||
|
||||
Once an hour the server records each index's size (`beacon_index_bytes`) and its bytes per
|
||||
row against a baseline (`beacon_index_bloat_ratio`). The baseline is taken the first time the
|
||||
server sees the index and again whenever it is rebuilt, so the ratio is growth since the last
|
||||
rebuild, not absolute bloat: 1 means as compact as then, 4 means four times the bytes per
|
||||
row. An index that has never been analyzed has no ratio.
|
||||
|
||||
The first run, and then one run a week, also logs `indexes with no scans since the last
|
||||
statistics reset` with each unused non-unique index and its size. An index that stays on that
|
||||
list for weeks is a candidate to drop; check that no rare job reads it first.
|
||||
|
||||
When `BeaconKeysetBloat` fires, rebuild that index as in
|
||||
[Rebuilding the route keyset indexes](#rebuilding-the-route-keyset-indexes). The rebuild resets
|
||||
its baseline, and the ratio drops back to about 1 on the next hourly run.
|
||||
|
||||
## Rolling back the pool layout
|
||||
|
||||
If the new pools or plans misbehave, set this and restart the server:
|
||||
|
||||
```yaml
|
||||
database:
|
||||
api_plan_layout: single
|
||||
```
|
||||
|
||||
It puts every API statement on one pool with the previous session settings and makes every
|
||||
guard family observe, whatever `guards.enforce` says. The alternative is the previous server
|
||||
image. It ignores migration 010; the `pg_stat_statements` extension stays installed and
|
||||
harmless.
|
||||
|
||||
## CPU profiling
|
||||
|
||||
Bounded CPU captures on a running instance are described in [Profiling](profiling.md).
|
||||
Reference in new issue
Block a user