mirror of
https://github.com/MeshCore-Beacon/beacon-docs.git
synced 2026-11-04 22:37:34 +00:00
Merge current upstream release and API documentation
This commit is contained in:
commit
dffe9862f2
15 files changed
+511
-150
No files matched your search
@@ -21,8 +21,8 @@ validation, operational budgets and remaining release gates.
|
||||
## Deploy the all-in-one stack
|
||||
|
||||
One server runs everything: the API, Postgres, Redis, the web frontend and Caddy with automatic
|
||||
HTTPS. You need a domain pointed at the server, ports 80 and 443 open, Docker with the compose
|
||||
plugin, and a subscriber account on a MeshCore MQTT broker
|
||||
HTTPS. You need a domain pointed at the server, TCP ports 80 and 443 and UDP port 443 open,
|
||||
Docker with the compose plugin, and a subscriber account on a MeshCore MQTT broker
|
||||
([where packets come from](docs/getting-packets-in.md)).
|
||||
|
||||
If you would rather run the API and the web frontend on different hosts, see
|
||||
@@ -108,7 +108,9 @@ Every other key, with its default, is documented in
|
||||
### 4. Point DNS at the server
|
||||
|
||||
Create an `A` or `AAAA` record for `DOMAIN` pointing at the server's public IP. Caddy needs
|
||||
ports 80 and 443 reachable from the internet to get its certificate.
|
||||
ports 80 and 443 reachable from the internet to get its certificate. Open UDP 443 too, in the
|
||||
host firewall and any cloud security group, so browsers can use
|
||||
[HTTP/3](docs/reverse-proxy.md#http3).
|
||||
|
||||
### 5. Bring it up
|
||||
|
||||
|
||||
@@ -73,9 +73,12 @@ there must be an obvious scraper, otherwise raise `maxretry`.
|
||||
|
||||
**Enforce.** Caddy box: traffic to Docker-published ports is DNAT'ed through the FORWARD path, which
|
||||
the default nftables action (input hook) never sees, so switch to
|
||||
`banaction = nftables[type=allports, chain=f2b-forward, chain_hook=forward]` (a second chain in the
|
||||
existing `f2b-table`; `iptables-allports[chain=DOCKER-USER]` is equivalent). Apache box: remove the
|
||||
`dummy` line, the inherited nftables input-hook action works because Apache listens on the host.
|
||||
`banaction = nftables[type=allports, protocol="tcp,udp", chain=f2b-forward, chain_hook=forward]`
|
||||
(a second chain in the existing `f2b-table`;
|
||||
`iptables-allports[chain=DOCKER-USER, protocol="tcp,udp"]` is equivalent). The action bans TCP
|
||||
only by default; `protocol="tcp,udp"` also stops a banned address reaching Caddy over HTTP/3 on
|
||||
UDP 443. Apache box: remove the `dummy` line, the inherited nftables input-hook action works
|
||||
because Apache listens on the host.
|
||||
Then:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
# - banaction: traffic to Docker-published 80/443 is DNAT'ed through the FORWARD path, so the
|
||||
# default nftables action (input hook) never sees it. Start in observe mode (dummy), then switch
|
||||
# to a forward-hook chain in the existing f2b-table:
|
||||
# nftables[type=allports, chain=f2b-forward, chain_hook=forward]
|
||||
# (equivalent: iptables-allports[chain=DOCKER-USER])
|
||||
# nftables[type=allports, protocol="tcp,udp", chain=f2b-forward, chain_hook=forward]
|
||||
# (equivalent: iptables-allports[chain=DOCKER-USER, protocol="tcp,udp"])
|
||||
# protocol: the default is tcp only, which leaves HTTP/3 on UDP 443 open to a banned address.
|
||||
# - ignoreip: loopback, RFC1918 (Docker bridges), Tailscale. Add the operator's home IP if wanted.
|
||||
# - bantime is a flat 10m: a banned CGNAT address hurts every user behind it.
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ services:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
# Room for a clean shutdown (docs/operations.md, Stopping the server); Docker's default is 10 s.
|
||||
stop_grace_period: 75s
|
||||
|
||||
db:
|
||||
logging: *default-logging
|
||||
@@ -58,6 +60,7 @@ services:
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "443:443/udp" # HTTP/3 (QUIC)
|
||||
environment:
|
||||
- DOMAIN=${DOMAIN:?DOMAIN environment variable is required}
|
||||
env_file: .env
|
||||
|
||||
@@ -12,7 +12,8 @@ docker-deployment-type2/
|
||||
|
||||
## What you need
|
||||
|
||||
- Two hosts with Docker and ports 80 and 443 open.
|
||||
- Two hosts with Docker, TCP ports 80 and 443 and UDP port 443 open (UDP is for
|
||||
[HTTP/3](../docs/reverse-proxy.md#http3)).
|
||||
- Two DNS names pointing at them, for example `api.example.com` and `beacon.example.com`.
|
||||
- A subscriber account on a MeshCore MQTT broker. See
|
||||
[Getting packets in](../docs/getting-packets-in.md).
|
||||
|
||||
@@ -19,6 +19,8 @@ services:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
# Room for a clean shutdown (docs/operations.md, Stopping the server); Docker's default is 10 s.
|
||||
stop_grace_period: 75s
|
||||
|
||||
db:
|
||||
logging: *default-logging
|
||||
@@ -58,6 +60,7 @@ services:
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "443:443/udp" # HTTP/3 (QUIC)
|
||||
environment:
|
||||
- DOMAIN=${DOMAIN:?DOMAIN environment variable is required}
|
||||
- WEB_DOMAIN=${WEB_DOMAIN:?WEB_DOMAIN environment variable is required}
|
||||
|
||||
@@ -11,6 +11,7 @@ services:
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "443:443/udp" # HTTP/3 (QUIC)
|
||||
environment:
|
||||
- DOMAIN=${DOMAIN:?DOMAIN environment variable is required}
|
||||
volumes:
|
||||
|
||||
+29
-12
@@ -60,7 +60,7 @@ Retry-After: 5
|
||||
|
||||
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.
|
||||
|
||||
Separately from the guards, and on every server, a page whose database statement runs past its pool's statement limit (35 s; 120 s for the slow region and message statements) answers `503` with code `deadline_exceeded` and `Retry-After: 10`. This covers route evidence (`/routes/{iata}/{pathKey}/observations`) and the [background-data pages](#pages-that-wait-for-server-data) (region packets, region and scope messages, observer adverts). Other endpoints still answer `500` in that case.
|
||||
Separately from the guards, and on every server, a page whose database statement runs past its pool's statement limit (35 s) answers `503` with code `deadline_exceeded` and `Retry-After: 10`. This covers every `/api/v1` endpoint that reads the database; account, backup and plan-check admin routes keep their own errors. The heavy stats endpoints (`series`, `signal`, `paths`, `observer-comparison`) and route searches (`/routes/search`, `/routes/cross`) answer the same `503` when their own request time limit runs out, with a message that names the query. Any other failed read is a `500` without `Retry-After`.
|
||||
|
||||
Each request falls in one family and one class:
|
||||
|
||||
@@ -91,10 +91,10 @@ Some pages are served only from data the server prepares in the background. Whil
|
||||
| Pages | Ready when |
|
||||
|---|---|
|
||||
| `/packets` with `iata`, `iatas`, `region` or `regionId`, without `hashPrefix` (region pages) | the server trusts its `packet_sites` read model |
|
||||
| `/channels/{id}/messages` with `iatas`, or with `scope` and no `since`; `/messages` with `iatas` and neither `since` nor `scope`, or with `scope`, no `since` and no `iatas` | the server trusts its `channel_message_iatas` read model |
|
||||
| `/channels/{id}/messages` with `iatas`, or with `scope` and no `since`; `/messages` with `iatas` (with or without `since` or `scope`), or with `scope`, no `since` and no `iatas` | the server trusts its `channel_message_iatas` read model |
|
||||
| `/observers/{observerId}/adverts` | every observation carries its payload type |
|
||||
|
||||
That happens during the first fill after an upgrade, during a repair, after a failed verification, until the observation payload-type backfill is done, and for as long as the operator turns a read model off (see [Operations](operations.md#pages-that-wait-for-a-read-model)). `/messages` with `iatas` and `since` or `scope` reads no read model and is always served, on a small pool of its own. The same pool answers trusted region `/packets` pages whose filter is too sparse for the read model's bounded reads. When that pool is busy, those requests answer `503` `service_unavailable` with `Retry-After: 5`.
|
||||
That happens during the first fill after an upgrade, during a repair, after a failed verification, until the observation payload-type backfill is done, and for as long as the operator turns a read model off (see [Operations](operations.md#pages-that-wait-for-a-read-model)). `/messages` with `iatas` and `since` or `scope` is in the table since 2.1: development builds of 2.1 served it from the observations whatever the read model's state, and answered `503` with `Retry-After: 5` when that pool was busy; that `503` is gone.
|
||||
|
||||
```
|
||||
HTTP/1.1 503 Service Unavailable
|
||||
@@ -117,7 +117,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`, and `deadline_exceeded` (a `503` from the [cost guards](#cost-guards)). A `503` `service_unavailable` with `Retry-After` can also mean a page is [waiting for server data](#pages-that-wait-for-server-data). 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)). A `503` `service_unavailable` with `Retry-After` can also mean a page is [waiting for server data](#pages-that-wait-for-server-data). A `503` `deadline_exceeded` with `Retry-After` from any endpoint means its query ran out of time; retry later or ask for 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 `[]`.
|
||||
|
||||
@@ -129,7 +129,7 @@ Most lists return a page:
|
||||
{ "items": [ ... ], "nextCursor": 1747665456000, "hasMore": true }
|
||||
```
|
||||
|
||||
Pass `nextCursor` back as `cursor` for the next page; it is omitted on the last page. What the cursor holds depends on the list (a timestamp in epoch ms, or an ID), as noted per endpoint. `limit` has a per-endpoint default, must be positive, and is clamped to 200.
|
||||
Pass `nextCursor` back as `cursor` for the next page; it is omitted on the last page. What the cursor holds depends on the list (a timestamp in epoch ms, or an ID), as noted per endpoint. `limit` has a per-endpoint default, must be positive, and is clamped to 200 (2000 for `GET /nodes`).
|
||||
|
||||
ID cursors are 64-bit integers: the observation ID `cursor` of `/observers/{id}/adverts` and `/nodes/{id}/observations`, telemetry `afterId`, the message ID `cursor` of `/messages`, and the backfill `afterObservationId` and `afterId` ([Backpressure and reconnection](#backpressure-and-reconnection)). The same goes for the IDs they come from. Store and send them as full integers; never narrow them to 32 bits, since a busy server passes 2^31. As JSON numbers they stay exact up to 2^53, so a JavaScript `number` or a Dart `int` holds them.
|
||||
|
||||
@@ -139,8 +139,8 @@ A few lists return a plain array instead: the backfill endpoints, `/routes*`, `/
|
||||
|
||||
A numeric `cursor` holds a millisecond, so rows that share the last row's millisecond can fall
|
||||
between two pages. `/packets`, `/nodes`, `/observers` and `/channels` also return
|
||||
`nextPageCursor` on a page with `hasMore`: an opaque string of at most 160 characters that marks
|
||||
the exact last row. Send it back as `pageCursor`. A walk by `pageCursor` returns every row once,
|
||||
`nextPageCursor` on a page with `hasMore`: an opaque string of at most 160 characters (512 on a
|
||||
sorted `/nodes` list) that marks the exact last row. Send it back as `pageCursor`. A walk by `pageCursor` returns every row once,
|
||||
including those ties. Don't parse or build it; today it reads `v1:<epoch µs>:<packet hash hex>`
|
||||
on `/packets` and `v1:<epoch µs>:<id>` on `/nodes` and `/observers`.
|
||||
|
||||
@@ -352,12 +352,20 @@ Adverts with a bad signature are not decoded and have no `parsedPayload`.
|
||||
|
||||
| Endpoint | Notes |
|
||||
|---|---|
|
||||
| `GET /nodes` | Page of node summaries, newest `lastSeen` first, ties by `id` descending. Cursor is `lastSeen` epoch ms; `pageCursor` is exact. Default limit 50. |
|
||||
| `GET /nodes` | Page of node summaries, newest `lastSeen` first, ties by `id` descending, or in a `sort` order (see below). Cursor is `lastSeen` epoch ms; `pageCursor` is exact. Default limit 50, at most 2000 (older servers clamp to 200, so ask for 2000 and keep paging): clients that load a whole region, like the map, use big pages. |
|
||||
| `GET /nodes/{nodeId}` | Node detail: capability flags, `minFirmwareVersion`, `iatas` (`[{iata, lastHeard}]`), `neighbors`, clock drift for repeaters and room servers, and `meshmapper` when imported (see [MeshMapper repeater details](#meshmapper-repeater-details)). |
|
||||
| `GET /nodes/{nodeId}/observations` | Page of observations of packets the node originated. Cursor is the observation ID. |
|
||||
| `GET /nodes/{nodeId}/neighbors` | Plain array of neighbours: `{ id, name, publicKey, nodeType, nodeTypeName, lat, lng, iata, observationCount, firstSeen, lastSeen, snr }`. |
|
||||
|
||||
`/nodes` params: `type` (1 companion, 2 repeater, 3 room server, 4 sensor) or `typeName`, the location filters, `name` (partial, case-insensitive), `scope`, `pubkey` (exact hex), `pubkeyPrefix`, `supportsMultibytePaths`, `supportsMultibyteTraces`, `neighbors` (adds `neighborIds`), `cursor`, `pageCursor`, `limit`. Summaries include `stale` (not seen within `nodes.stale_threshold`) and, when `nodes.mark_foreign` is on, `possiblyForeign`.
|
||||
`/nodes` params: `type` (1 companion, 2 repeater, 3 room server, 4 sensor) or `typeName`, the location filters, `name` (partial, case-insensitive), `scope`, `pubkey` (exact hex), `pubkeyPrefix`, `supportsMultibytePaths`, `supportsMultibyteTraces`, `neighbors` (adds `neighborIds`), `sort`, `order`, `cursor`, `pageCursor`, `limit`. Summaries include `stale` (not seen within `nodes.stale_threshold`), `defaultScope` (the transport scope last matched from the node's adverts, omitted when none; list rows carry it from 2.1.0, node detail always has) and, when `nodes.mark_foreign` is on, `possiblyForeign`. To list one scope's nodes, send `scope` rather than filtering rows: a server before 2.1.0 leaves `defaultScope` off list rows.
|
||||
|
||||
Sorted node lists (2.1.0): `sort` is `lastSeen` (the default), `name`, `type` or `neighbors`, and `order` is `asc` (the default) or `desc`; `lastSeen` is `desc` only. Every page says which order it is in with `sort` and `order` (`lastSeen` and `desc` by default). The orders match what the web's node table shows:
|
||||
|
||||
- `name`: natural order, as `Intl.Collator` with `numeric: true` sorts (symbols and emoji before digits, digits before letters, `Node 9` before `Node 10`, lowercase before uppercase). Nodes without a name come last in both directions.
|
||||
- `type`: companion, repeater, room server, sensor, then any other type.
|
||||
- `neighbors`: `knownNeighborCount`.
|
||||
|
||||
Ties go newest `lastSeen` first, then by `id`. Filters work the same as without `sort`. Page a sorted list with `pageCursor` only: a numeric `cursor`, or a `pageCursor` from another sort or order, is a `400`. A sorted page has no `nextCursor`. An unknown `sort` or `order` is a `400`. A server older than 2.1.0 ignores both and returns pages in `lastSeen` order with no `sort` field; a client that sees no `sort` must sort for itself.
|
||||
|
||||
#### MeshMapper repeater details
|
||||
|
||||
@@ -567,7 +575,7 @@ Channel keys come from the server config file and, optionally, MeshMapper.
|
||||
| `GET /iatas` | All IATAs, including ones created from traffic. `[]` when there are none. |
|
||||
| `GET /iatas/{iata}` | One IATA. `404` if unknown or not three letters. |
|
||||
| `GET /iatas/{iata}/border` | GeoJSON Feature (Polygon or MultiPolygon, with `bbox`); `204` when the IATA has no border, `404` if the IATA is unknown. |
|
||||
| `GET /regions` | Regions in display order. `[]` when none are configured. |
|
||||
| `GET /regions` | Regions in display order, each with the same fields as `GET /regions/{regionId}` (`iatas` always present, `[]` for a region with none), so one call resolves every region. `[]` when none are configured. Older servers list only `id`, `slug` and `name`: when `iatas` is missing, fetch each region. |
|
||||
| `GET /regions/{regionId}` | One region with its IATAs. `400` for a non-numeric ID, `404` if unknown. |
|
||||
| `GET /scopes` | Array of scope names. Location filters narrow it to manual scopes configured for a matching region and imported scopes whose MeshMapper catalogue includes a matching IATA. |
|
||||
| `GET /scopes/{name}` | Scope detail (`%23bc` for `#bc`). |
|
||||
@@ -581,14 +589,18 @@ Known routes are fully resolved multi-hop paths distilled from packet history.
|
||||
|
||||
| Endpoint | Notes |
|
||||
|---|---|
|
||||
| `GET /routes` | Plain array, newest `lastSeen` first. Params: `iata`, `hopCount`, `cursor` (last item's `lastSeen`, epoch ms), `cursorId` (last item's `id`), `limit` (default 50). |
|
||||
| `GET /routes` | Plain array, newest `lastSeen` first. Params: `iatas` (comma list, at most 100), `iata`, `hopCount`, `cursor` (last item's `lastSeen`, epoch ms), `cursorId` (last item's `id`), `limit` (default 50, larger values clamp to 200). With neither `iatas` nor `iata`, every IATA. |
|
||||
| `GET /routes/search?from=&to=[&iatas=]` | Routes within one IATA between two node hash prefixes. `from`, `to` required (hex, 1–4 bytes; widths may differ). `iatas` optional: comma list, at most 100, omitted = every IATA; the single `iata` is still accepted. At most 500 results, shortest first. `hops` is trimmed to source…destination and `hopCount` = `hops.length`. |
|
||||
| `GET /routes/cross?fromHash=&toHash=[&iatas=]` | Routes that cross from one IATA into another. `fromHash`, `toHash` required. `iatas` optional: 2–100 codes, omitted = every IATA. The legacy `fromIata`+`toIata` pair is still accepted, but not together with `iatas`. At most 500 results, sorted by `totalHops`, then `crossHop.lastSeen` newest first. |
|
||||
| `GET /routes/{iata}/{pathKey}/observations` | Retained reports that match a saved route exactly. `pathKey` comes from a route response. Window: `range` (default `24h`, max `720h`) or `since`+`until` (max 30 days); `pageCursor` for the next page; `limit` default 50. A lookup past the statement limit answers `503` + `Retry-After` ([Cost guards](#cost-guards)). |
|
||||
|
||||
To page `/routes`, send the last item's `lastSeen` as `cursor` and its `id` as `cursorId`. Routes share a millisecond often (one batch of upserts), and `cursorId` returns the ones that didn't fit on the previous page. `cursor` alone still works but can skip those ties. `cursorId` must be a positive integer and requires `cursor`; otherwise `400`.
|
||||
|
||||
An `iata` that ends in a blank (`YYZ` followed by a space) matches no region and returns `200 []`.
|
||||
`iatas` returns the routes of every listed IATA, in the same order and with the same cursors as one IATA's list, so a region pages like a single IATA. Codes are trimmed, uppercased and deduplicated; one that isn't three characters, more than 100 codes, or a list with no codes (`iatas=,`) is a `400`. `iatas` takes precedence over `iata`. An empty `iatas=` counts as absent.
|
||||
|
||||
The single `iata` is matched exactly as sent, so lowercase matches nothing. An `iata` that ends in a blank (`YYZ` followed by a space) matches no region and returns `200 []`.
|
||||
|
||||
`iatas` is new in 2.1.0: older servers, and 2.1.0 development builds from before it, ignore it and answer with every IATA's routes. A client that asks for a region checks each route's `iata`; when one is outside the list, it pages without `iatas` and filters, as before.
|
||||
|
||||
`/routes` and `/routes/search` return a plain array of routes:
|
||||
|
||||
@@ -707,6 +719,9 @@ clamped to the oldest hour the rollups still hold. An empty region returns zeros
|
||||
`{ observerA, observerB, since, until, totalPackets, onlyA, onlyB, both }`.
|
||||
- `clock-drift` lists repeaters and room servers whose last advert clock is off by more than
|
||||
`nodes.clock_drift_threshold`, worst first. It is current state, not windowed.
|
||||
`clockDriftSeconds` (here and on node detail) is the advert's clock minus the server's, in seconds.
|
||||
A device clock decades off gives values from about -1.8 × 10⁹ to +2.5 × 10⁹ (the negative bound
|
||||
grows over time); every value fits a JSON number exactly.
|
||||
- `radio-presets` is `[{ preset, iata, sourceType, count }]`, where `preset` is `freqMhz,bwKhz,sf` and
|
||||
`sourceType` is `observer` or `node`; it is rebuilt every `background.view_refresh` (default `1h`).
|
||||
`node-types` is `[{ nodeType, nodeTypeName, count }]` over all known nodes.
|
||||
@@ -896,6 +911,8 @@ The upgrade is checked before anything else:
|
||||
- **Origin.** A browser `Origin` must match the request's host, or one of `websocket.allowed_origins`. Anything else gets `403`. Set `allowed_origins` when the web app is served from a different host than the API.
|
||||
- **Concurrent connections.** At most `websocket.max_connections_per_ip` (default 5) open sockets per IP. Past that, the socket is accepted and immediately closed with code `1013` (try again later), reason `connection limit reached`, so the browser can back off.
|
||||
|
||||
**Compression.** When the client offers `permessage-deflate` (browsers and `dart:io` do by default), the server accepts it with context takeover, unless the client asks for `no_context_takeover`, and compresses messages of 128 bytes or more. The live feed shrinks about 3× on the wire. Clients that don't offer the extension get uncompressed frames, as before. Message content is the same either way.
|
||||
|
||||
On connect the server sends a `hello`:
|
||||
|
||||
```json
|
||||
|
||||
@@ -29,8 +29,10 @@ changes, PostgreSQL roles and cluster settings, Redis and deployment files are n
|
||||
- `pg_dump` must be in the **same runtime** as the exporter (the CLI's environment, or the
|
||||
server's PATH for the download), with a major version at least as new as the database's. A client on
|
||||
the Docker host or in another container doesn't count.
|
||||
- The Docker image ships the PostgreSQL 16 client. Its Alpine 3.19 base has no 17/18
|
||||
packages, so a newer database needs a different runtime, not just `POSTGRES_CLIENT_MAJOR`.
|
||||
- The published Docker image ships the PostgreSQL 16 client on an Alpine 3.24 base. For a
|
||||
PostgreSQL 17 or 18 database, build your own image from a beacon-server checkout, e.g.
|
||||
`docker build -f .build/Dockerfile --build-arg POSTGRES_CLIENT_MAJOR=17 -t beacon:pg17 .`,
|
||||
and point the stack's `image:` at that tag.
|
||||
- The temp directory (`TMPDIR`) needs about twice the SQL limit free. A small tmpfs won't do.
|
||||
- The output filesystem must support hard links (used to publish atomically). Existing
|
||||
destinations, symlinks included, are never replaced. Files are 0600 and staging dirs 0700;
|
||||
|
||||
+16
-25
@@ -126,11 +126,11 @@ cannot honour. These are the ones people hit:
|
||||
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_slow` | `api_custom` | `api_cached` | `ingest` | `maint` | Total |
|
||||
|---|---:|---:|---:|---:|---:|---:|---:|
|
||||
| `custom_all` (default) | - | 2 (`slow_pool_size`) | 8 | - | 6 | 4 | 20 |
|
||||
| `split` | - | 2 (`slow_pool_size`) | 7 | 5 | 6 | 4 | 24 |
|
||||
| `single` (rollback) | 8 | - | - | - | 6 | 4 | 18 |
|
||||
| `database.api_plan_layout` | `api_legacy` | `api_custom` | `api_cached` | `ingest` | `maint` | Total |
|
||||
|---|---:|---:|---:|---:|---:|---:|
|
||||
| `custom_all` (default) | - | 8 | - | 6 | 4 | 18 |
|
||||
| `split` | - | 7 | 5 | 6 | 4 | 22 |
|
||||
| `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`
|
||||
@@ -149,24 +149,13 @@ layout sets the sizes.
|
||||
- **`single`** is the rollback: one API pool (`api_legacy`) on the server's own settings, and
|
||||
every guard family only observes, whatever `guards.enforce` says.
|
||||
|
||||
`api_slow` runs only the statements that may read a large part of the observations in one call:
|
||||
the region `/packets` statement that answers when the site read model's bounded reads run out
|
||||
of budget, and the all-channel message statement for `iatas` combined with `since` or `scope`.
|
||||
Two connections by default (`database.slow_pool_size`), a 120 s `statement_timeout`, no parallel
|
||||
workers. A request that finds every connection busy waits at most 5 s and then answers `503`
|
||||
with `Retry-After: 5`; it never takes a connection from `api_custom`. A statement that runs past
|
||||
120 s ends with a `500`.
|
||||
|
||||
```yaml
|
||||
database:
|
||||
slow_pool_size: 2 # api_slow's connections under custom_all and split; at least 1, at most 1000
|
||||
```
|
||||
|
||||
Omit the key to take the default; set it only to override it. Raise it if
|
||||
`beacon_api_unavailable_total{reason="slow_pool_busy"}` keeps growing, and count it in
|
||||
`max_connections`. The default was 8 while untrusted read-model pages also ran here; reverting the
|
||||
change that removed those fallbacks restores that default by itself (see
|
||||
[Operations](operations.md#reverting-to-the-read-model-fallbacks)).
|
||||
`database.slow_pool_size` is no longer read. It sized the `api_slow` pool of the 2.1 development
|
||||
builds, which ran region and message statements that could read whole site histories; those
|
||||
pages now seek from their window (see
|
||||
[Deep region and message pages](operations.md#deep-region-and-message-pages)) and run on the
|
||||
pools above. A config that still sets the key loads as before, and the server logs
|
||||
`config: database.slow_pool_size is no longer used and is ignored; remove it` at `WARN` at
|
||||
startup. Remove the key.
|
||||
|
||||
The layout changes at a restart. What each pool sets is in
|
||||
[Operations](operations.md#database-pools-and-connections).
|
||||
@@ -298,7 +287,9 @@ writes the tables; it trusts each one only while the writing server keeps it up
|
||||
|
||||
`readmodels.fill` paces the one-off backfills: the `packet_sites` and `channel_message_iatas`
|
||||
fills after an upgrade or a rebuild, and the
|
||||
[payload-type backfill](operations.md#after-upgrading-observation-payload-types). After each
|
||||
[payload-type backfill](operations.md#after-upgrading-observation-payload-types). The
|
||||
[channel message backfill](operations.md#channel-message-backfill) after each start uses `duty`
|
||||
only, with no `min_pause`. After each
|
||||
slice or batch a fill sleeps `max(min_pause, elapsed × (1 − duty) / duty)`, where `elapsed` is
|
||||
how long that slice took. A slow disk makes each slice take longer, so it stretches the pauses
|
||||
on its own. Pacing changes only when the fills run, never what they write.
|
||||
@@ -307,7 +298,7 @@ on its own. Pacing changes only when the fills run, never what they write.
|
||||
|---|---|---|---|
|
||||
| `duty` | `0.5` | 0.01 to 1 | The share of time a fill may be busy. `0.5` waits as long as each slice took; `0.25` waits three times as long; `1` waits only `min_pause`. No single pause is longer than 10 minutes. |
|
||||
| `min_pause` | `100ms` | `0s` to `1m` | The shortest pause after a slice or batch. |
|
||||
| `packet_sites_slice` | `500000` | 1 to 10,000,000 | Observation ids one `packet_sites` backfill slice covers. |
|
||||
| `packet_sites_slice` | `500000` | 1 to 10,000,000 | Observations one `packet_sites` backfill slice covers, however far apart their ids are. |
|
||||
| `cmi_batch` | `10000` | 1 to 1,000,000 | Message ids one `channel_message_iatas` backfill batch covers. |
|
||||
|
||||
The defaults suit a small server. On a slow (rotational or shared) disk, set `duty: 0.25`. A
|
||||
|
||||
@@ -761,7 +761,7 @@ retention under 24h) fail startup rather than being ignored.
|
||||
|
||||
IATAs are still auto-created when packets arrive from unrecognized codes. The config file is only needed to override the default display name/coordinates, or to assign an IATA to a super-region.
|
||||
|
||||
Channel hashes also auto-populate as messages arrive; adding a key in the config decrypts the stored hash-only rows for that channel on the next startup.
|
||||
Channel hashes also auto-populate as messages arrive; adding a key in the config decrypts the stored hash-only rows for that channel after the next startup, in the background.
|
||||
|
||||
### Admin API and access control
|
||||
|
||||
|
||||
+279
-78
@@ -57,6 +57,43 @@ Everything persistent is under `data/` next to `docker-compose.yml`:
|
||||
- `data/app/config.yaml` and `.env` are your configuration. Keep copies of both somewhere
|
||||
else; no backup below includes `.env`.
|
||||
|
||||
## Channel message backfill
|
||||
|
||||
After every start, once the server listens, Beacon retries every stored channel message it
|
||||
could not decrypt against the configured keys, so a key added to `config.yaml` surfaces its
|
||||
channel's history. Keys a MeshMapper import adds queue their channels on the same background
|
||||
job. It runs one pass at a time, in batches of 500 packets, paced by
|
||||
[`readmodels.fill.duty`](#pacing-the-backfills) alone (no `min_pause`), and stops after 30
|
||||
minutes, pauses included; whatever it did not reach is retried after the next start. A packet it
|
||||
has started is finished (within 30 s), so a stop does not leave a message half stored. The log shows `channel message backfill started`
|
||||
with `packets`, then `channel message backfill done: N previously-undecrypted channel
|
||||
message(s)` with `decrypted` and `duration` (or a `WARN` that it stopped after 30 minutes, or
|
||||
`channel message backfill stopped by shutdown`).
|
||||
Messages it stores are not broadcast over the WebSocket, and their hours are re-rolled for the
|
||||
historical stats. A packet heard live before the backfill reaches it is decrypted by ingest
|
||||
instead: its message is stored once either way, its hours are re-rolled too, and, as a live
|
||||
message, it is broadcast over the WebSocket with its original `sentAt`.
|
||||
|
||||
## Stopping the server
|
||||
|
||||
On `SIGTERM` (`docker compose stop`, a redeploy) the server stops in order, so nothing it has
|
||||
accepted is lost:
|
||||
|
||||
1. It stops taking HTTP requests and lets the ones in flight finish (up to 30 s). At the same
|
||||
time, ingest stops reading MQTT and stores what it has queued (up to 20 s).
|
||||
2. Background jobs (rollups, retention, read-model upkeep, the channel message backfill, imports)
|
||||
stop on their next check; a statement in flight finishes or is cancelled. Shutdown waits up
|
||||
to 15 s for them, then logs `shutdown: background jobs still running` and goes on.
|
||||
3. Observer and packet presence held in memory is written out (up to 15 s), then the database
|
||||
connections close.
|
||||
|
||||
A normal stop takes a few seconds; the worst case is about 60 s. Docker sends `SIGKILL` after the
|
||||
service's `stop_grace_period`, 10 s by default, which can cut any step short. The example compose
|
||||
files set `stop_grace_period: 75s` on the `app` service; set the same on your own. A server killed
|
||||
before step 1 ends loses the MQTT messages still queued for storage (the broker does not resend
|
||||
them). One killed later loses at most the last presence interval (`last_seen` freshness). Either
|
||||
way, a background job resumes where it stopped.
|
||||
|
||||
## Retention
|
||||
|
||||
Everything below is a duration in `config.yaml`. Shorten them if disk is tight, lengthen them
|
||||
@@ -76,6 +113,15 @@ minutes after it closes, and packet cleanup holds back up to 24 hours of raw row
|
||||
hours are rolled, so raw data lives a little longer than `packets.retention` says. An hour
|
||||
whose raw rows were deleted first is reported as partial, with no values.
|
||||
|
||||
`packets.retention` counts from the last time any observer heard a packet, and a packet goes
|
||||
together with all its observations and its channel message. A packet that is still being heard
|
||||
keeps its older observations, so `packet_observations` always holds a small tail of rows older
|
||||
than the retention, with ids far below the current range. Most of it is byte-identical packets
|
||||
that nodes send again and again, such as adverts from nodes whose clock was never set (their
|
||||
advert timestamp stays the same, so every advert has the same hash). A later hearing by an
|
||||
observer that already reported the packet stores no new observation, but it still counts as
|
||||
heard. On a 72 h retention this tail was about 0.5 % of observations.
|
||||
|
||||
## Backing up
|
||||
|
||||
Two options.
|
||||
@@ -165,6 +211,17 @@ four tables during the upgrade; a blocked drop stops startup after about two min
|
||||
described below, and the next start resumes where it stopped. Afterwards, compact the two
|
||||
tables once; see [Compacting observers and channels](#after-upgrading-compacting-observers-and-channels).
|
||||
|
||||
Migration `058` sets `fillfactor = 50` on `nodes`. It only changes the table's settings (no
|
||||
rewrite, a lock that does not block reads or writes), and applies to pages written from then on;
|
||||
the same compaction section covers `nodes`.
|
||||
|
||||
Migration `060` widens `nodes.device_clock_drift_seconds` to `bigint`. Before it, a device clock
|
||||
more than about 68 years ahead wrapped to a large negative drift. It rewrites `nodes` once under
|
||||
an `ACCESS EXCLUSIVE` lock at startup: about 0.1 s for 70,000 nodes, so no pre-build is needed.
|
||||
A read-only server already connected to the same database may log one error per cached statement
|
||||
that reads `nodes` while the column changes type; it recovers by itself. Rolling back past `060`
|
||||
needs one `UPDATE` first; see [Rolling back from 2.1](upgrading.md#rolling-back-from-21).
|
||||
|
||||
Migrations `025` to `033` (2.1.0) change `packets`:
|
||||
|
||||
- `025` and `027` add the checks `packets_payload_type_domain` (`payload_type` 0 to 15) and
|
||||
@@ -230,7 +287,8 @@ Migrations `042` to `045` add the region packet read model; see
|
||||
`idx_packet_sites_site_scope` with `CREATE INDEX CONCURRENTLY` on the empty table, which
|
||||
takes seconds. No pre-build is needed.
|
||||
|
||||
After the deploy, check the table's four indexes:
|
||||
After the deploy, check the table's seven indexes (four from `042` to `045`, three from `054`,
|
||||
`055` and `059` below):
|
||||
|
||||
```sql
|
||||
SELECT c.relname, i.indisvalid AND i.indisready AS ok
|
||||
@@ -238,7 +296,7 @@ FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
|
||||
WHERE i.indrelid = 'packet_sites'::regclass ORDER BY 1;
|
||||
```
|
||||
|
||||
It must return 4 rows, all `ok = t`.
|
||||
It must return 7 rows, all `ok = t`.
|
||||
|
||||
Migrations `047` to `053` finish the cleanup; see
|
||||
[Lookup-table indexes and the route stamp column](#lookup-table-indexes-and-the-route-stamp-column).
|
||||
@@ -255,6 +313,44 @@ Migrations `047` to `053` finish the cleanup; see
|
||||
carry it. **It is a one-way door for older images**; see
|
||||
[Upgrading](upgrading.md).
|
||||
|
||||
### Indexes for deep region and message pages (`054` to `057`, `059`)
|
||||
|
||||
Migrations `054` to `057` and `059` add five indexes for region and message pages that reach far
|
||||
back or filter sparsely; see [Deep region and message pages](#deep-region-and-message-pages).
|
||||
Each is one `CREATE INDEX CONCURRENTLY`:
|
||||
|
||||
- `054` `idx_packet_sites_late` and `055` `idx_packet_sites_early` on `packet_sites`: partial
|
||||
indexes on the rows heard at a site more than 30 minutes after, or before, their packet's
|
||||
first hearing (about 0.8 % of the rows, 4 MB on a large deployment; the second stays about
|
||||
empty).
|
||||
- `056` `idx_cmi_site_scope` on `channel_message_iatas` (6 MB on a large deployment).
|
||||
- `057` `idx_channel_messages_sent` on `channel_messages` (4 MB on a large deployment).
|
||||
- `059` `idx_packet_sites_site_scope_type` on `packet_sites`: one range per site, scope and type
|
||||
pair, on the scoped rows (61 MB on a large deployment).
|
||||
|
||||
Upgrading from 2.0.x, `054` to `056` and `059` build on tables the same startup created and
|
||||
filled nothing into yet, so they are instant, and `057` reads `channel_messages` (about 200,000
|
||||
rows on a large deployment) in well under a second. On a server that already runs a 2.1
|
||||
development build, the tables are full: on a production-size copy `054` took 2.0 s, `055` 0.5 s,
|
||||
`056` 0.3 s, `057` 0.2 s and `059` 1.9 s. No pre-build is needed. To pre-build them anyway, run
|
||||
the pre-deploy check above, then each statement on its own in autocommit mode, exactly as written
|
||||
(`054`, `055` and `059` only where `packet_sites` exists, `056` only where
|
||||
`channel_message_iatas` exists):
|
||||
|
||||
```sql
|
||||
CREATE INDEX CONCURRENTLY idx_packet_sites_late ON packet_sites USING btree (iata, site_heard_at DESC, packet_hash DESC) INCLUDE (first_heard_at, payload_type, route_type, scope_id) WHERE ((site_heard_at - first_heard_at) > '00:30:00'::interval);
|
||||
CREATE INDEX CONCURRENTLY idx_packet_sites_early ON packet_sites USING btree (iata, first_heard_at) INCLUDE (site_heard_at, packet_hash, payload_type, route_type, scope_id) WHERE ((first_heard_at - site_heard_at) > '00:30:00'::interval);
|
||||
CREATE INDEX CONCURRENTLY idx_cmi_site_scope ON channel_message_iatas USING btree (iata, scope_id, message_id DESC) WHERE (scope_id IS NOT NULL);
|
||||
CREATE INDEX CONCURRENTLY idx_channel_messages_sent ON channel_messages USING btree (sent_at);
|
||||
CREATE INDEX CONCURRENTLY idx_packet_sites_site_scope_type ON packet_sites USING btree (iata, scope_id, payload_type, route_type, site_heard_at DESC, packet_hash DESC) INCLUDE (first_heard_at) WHERE (scope_id IS NOT NULL);
|
||||
SELECT c.relname, i.indisvalid AND i.indisready FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
|
||||
WHERE c.relname IN ('idx_packet_sites_late', 'idx_packet_sites_early', 'idx_cmi_site_scope', 'idx_channel_messages_sent',
|
||||
'idx_packet_sites_site_scope_type');
|
||||
```
|
||||
|
||||
Every row of the last statement must read `t`. When the server finds a valid index with one of
|
||||
these names, it records that migration as applied without building anything.
|
||||
|
||||
### Pre-building the route hop index
|
||||
|
||||
Migration `006_known_routes_hop_iata.sql` builds `idx_known_routes_hop_iata`, which `/routes`
|
||||
@@ -409,7 +505,10 @@ typed, each statement reads its span and updates nothing.
|
||||
Until a server process reads `done`, `/observers/{observerId}/adverts` answers `503` with
|
||||
`Retry-After: 30` (the statement that served those pages before `done` is gone). The job logs
|
||||
`observation payload type backfill progress: observer advert pages answer 503 until it is done`
|
||||
with `next_id`, `max_id`, `ids_done`, `ids_remaining` and `eta` once a minute while it walks. Once a server process reads `done`,
|
||||
with `next_id`, `max_id`, `ids_done`, `ids_remaining`, `observations_done`,
|
||||
`observations_remaining` and `eta` once a minute while it walks. The observation counts are the
|
||||
planner's estimates from the table statistics, and `eta` follows them, so a long, nearly empty
|
||||
old id range does not count as work. Once a server process reads `done`,
|
||||
it serves the pages from `idx_observations_adverts` and keeps that until restart. On a database
|
||||
whose observations already all carry a payload type (every deployment that started on a release
|
||||
with the column), the job's first run finds no untyped row and records `done` at once
|
||||
@@ -469,9 +568,13 @@ They are also guarded, but a guard's deadline applies only if its family is list
|
||||
|
||||
For top nodes, top advertisers and paths, requests with at most 32 distinct IATAs and
|
||||
whole-hour bounds use new statements that read the hourly rollups one hour and IATA at a time.
|
||||
Only wider lists, such as every IATA at once, still run the previous statements, now without
|
||||
parallel workers. Measured over all 625 IATAs for 7 days on a copy of a large production
|
||||
database, they stay far inside the timeout:
|
||||
Top nodes with no IATA filter run their own statement over the whole window: the only API
|
||||
statement allowed parallel workers. The server raises `max_parallel_workers_per_gather` to 2 for
|
||||
that statement alone, inside its own transaction (with 2 workers it took 300-400 ms instead of
|
||||
620-850 ms over 7 days on the same copy; it is cached for an hour). If the server has no free
|
||||
worker, the statement runs without one, at the speed shown below. Lists of more than 32 IATAs
|
||||
still run the previous statements, without parallel workers. Measured over all 625 IATAs for
|
||||
7 days on a copy of a large production database, they stay far inside the timeout:
|
||||
|
||||
| Statement | With parallel workers | Without |
|
||||
|---|---:|---:|
|
||||
@@ -482,6 +585,32 @@ database, they stay far inside the timeout:
|
||||
Responses are unchanged. Under `split`, `ListObserverAdverts` and `GetObserverTelemetrySince`
|
||||
run on cached generic plans; see [Configuration](configuration.md#database-pools-and-cost-guards).
|
||||
|
||||
### After upgrading: stats rollup row order
|
||||
|
||||
2.1 writes each hourly rollup in its primary-key order, so one hour's rows for an IATA sit on a
|
||||
few adjacent pages. Older servers wrote them in hash order: one page per row. The per-(hour, IATA)
|
||||
reads (top nodes, top advertisers and paths with up to 32 IATAs) read about a tenth of the pages
|
||||
on the new layout: for a 13-IATA region over 7 days, about 11,000 buffers instead of about 116,000.
|
||||
|
||||
Rows written before the upgrade keep the old layout, so these pages get faster as old hours leave
|
||||
the requested window: fully within 7 days for the default 7-day view, and within 30 days for a
|
||||
30-day one. Nothing needs to be done.
|
||||
|
||||
**Optional: heal at once.** Rewrite the two tables those reads use, off-peak:
|
||||
|
||||
```sql
|
||||
CLUSTER analytics_hourly_advert_hearings USING analytics_hourly_advert_hearings_pkey;
|
||||
CLUSTER analytics_hourly_paths USING analytics_hourly_paths_pkey;
|
||||
```
|
||||
|
||||
- Each `CLUSTER` holds an `ACCESS EXCLUSIVE` lock for its whole run. Stats pages that read the
|
||||
table and the hourly rollup wait for it.
|
||||
- It needs free disk for a full copy of the table and its indexes until it finishes.
|
||||
- Measured: 9.6 s for 2.0 million rows (226 MB heap) on a fast workstation. Rollups are kept for
|
||||
90 days, so these tables grow by about 0.3 million rows a day on a large deployment. At about
|
||||
35 days, expect around 10 million rows (1.3 GB heap) for `analytics_hourly_advert_hearings` and
|
||||
1 to 3 minutes per table on a busy server.
|
||||
|
||||
### After upgrading: compacting observers and channels
|
||||
|
||||
The small-table release (migrations `011` to `024`) drops the indexes that made every observer
|
||||
@@ -491,23 +620,32 @@ only applies to pages written from then on. A table that bloated before the upgr
|
||||
size until it is rewritten once: on a large deployment `observers` held about 4,000 rows in
|
||||
52 MB, and about 6 MB after the rewrite.
|
||||
|
||||
**When.** Once, off-peak, after the release is live and its last migration is recorded. This
|
||||
must return `1`:
|
||||
2.1 also sets `fillfactor = 50` on `nodes` (migration `058`). Every node advert rewrites its
|
||||
node's `last_seen` (about 1.1 million updates a day on a large deployment), and with packed pages
|
||||
almost none of those updates could stay on their page. In a benchmark of 343,000 `last_seen`
|
||||
updates over 68,700 nodes, fillfactor 50 made 60 % of them HOT instead of 0.5 %, with 47 % less
|
||||
WAL and 37 % less time. Node page times did not change: the table is about twice its packed size
|
||||
(about 23 MB instead of 11 MB for 68,700 nodes), a few more cached pages per read. Like the others,
|
||||
the setting only applies to pages written from then on; the rewrite below compacts `nodes` too.
|
||||
|
||||
**When.** Once, off-peak, after the release is live and its last migration is recorded:
|
||||
|
||||
```sql
|
||||
SELECT count(*) FROM schema_migrations WHERE filename LIKE '024_%';
|
||||
SELECT count(*) FROM schema_migrations WHERE filename LIKE '058_%';
|
||||
```
|
||||
|
||||
Not before: without the fillfactor and the dropped indexes, the tables bloat again.
|
||||
Both must return `1`. Not before: without the fillfactor and the dropped indexes, the tables
|
||||
bloat again.
|
||||
|
||||
**Is it worth it?** Check the sizes in `psql` as above:
|
||||
|
||||
```sql
|
||||
SELECT relname, relpages, pg_size_pretty(pg_relation_size(oid)) AS heap, reltuples::bigint AS rows
|
||||
FROM pg_class WHERE relname IN ('observers', 'channels');
|
||||
FROM pg_class WHERE relname IN ('observers', 'channels', 'nodes');
|
||||
```
|
||||
|
||||
If both are already a few MB, skip it.
|
||||
If they are already a few MB (`nodes` about twice its packed size), skip it.
|
||||
|
||||
**Run** as the table owner (the `beacon` user) or a superuser, each statement on its own in
|
||||
autocommit mode, not inside a transaction block:
|
||||
@@ -516,12 +654,13 @@ autocommit mode, not inside a transaction block:
|
||||
SET lock_timeout = '3s';
|
||||
VACUUM FULL observers;
|
||||
VACUUM FULL channels;
|
||||
VACUUM FULL nodes;
|
||||
```
|
||||
|
||||
- Each `VACUUM FULL` rewrites the table and its indexes under an `ACCESS EXCLUSIVE` lock, which
|
||||
blocks reads and writes on that table while it runs. On tables of this size that is a few
|
||||
seconds, so ingest's observer and channel updates and the observer and channel API pages
|
||||
wait that long.
|
||||
seconds, so ingest's observer, channel and node updates and the observer, channel and node
|
||||
API pages wait that long.
|
||||
- If it reports `canceling statement due to lock timeout`, another session held the table;
|
||||
run the same statement again.
|
||||
- It needs free disk equal to the table's new size plus its indexes, a few MB here. The old
|
||||
@@ -535,7 +674,7 @@ apart and compare the change:
|
||||
```sql
|
||||
SELECT relname, n_tup_upd, n_tup_hot_upd,
|
||||
round(100.0 * n_tup_hot_upd / nullif(n_tup_upd, 0), 1) AS hot_pct
|
||||
FROM pg_stat_user_tables WHERE relname IN ('observers', 'channels');
|
||||
FROM pg_stat_user_tables WHERE relname IN ('observers', 'channels', 'nodes');
|
||||
```
|
||||
|
||||
Between the two samples, nearly all new updates (`n_tup_hot_upd` growth over `n_tup_upd`
|
||||
@@ -544,6 +683,13 @@ snapshots lower it somewhat.
|
||||
|
||||
**Rollback.** None needed for the rewrite; it changes no data.
|
||||
|
||||
Type-filtered node pages (`/nodes` with a node type) are slower in this release on purpose.
|
||||
They now read the whole `nodes` table like the unfiltered page: about 5-12 ms on a large
|
||||
deployment's 68,000 nodes, against 1.4 ms with the dropped `(node_type, last_seen)` index. That
|
||||
index held `last_seen`, which every node advert rewrites (about 1.1 million times a day on that
|
||||
deployment). In a benchmark of 100,000 `last_seen` updates it added 31 % WAL and 22 % time, more
|
||||
than doubled the table's index size, and stopped every one of those updates from being HOT.
|
||||
|
||||
To go back to the previous server image, just deploy it: it works without the dropped
|
||||
indexes, but type-filtered node pages (`/nodes` with a node type) are slower until the indexes
|
||||
are rebuilt. It ignores the channel id cache and goes back to using a sequence value per
|
||||
@@ -562,20 +708,33 @@ of the same name on `(iata)`; leave it in place.
|
||||
`channel_message_iatas` is a small table with one row for each channel message and each site
|
||||
that heard it. Each row also copies the message's channel and its packet's scope. Ingest keeps
|
||||
it up to date, and a background reconcile repairs it every 5 minutes. On a large deployment it
|
||||
takes about 120-150 MB with its five indexes.
|
||||
takes about 125-155 MB with its six indexes.
|
||||
|
||||
While the server process trusts the table, these channel message pages read it:
|
||||
|
||||
- `/channels/{id}/messages` with `iatas`, with `iatas` and `scope`, with `scope` alone, and
|
||||
with `since` and `iatas`;
|
||||
- `/messages` with `iatas`, and with `scope` alone.
|
||||
- `/messages` with `iatas`, with `iatas` and `scope`, with `iatas` and `since`, and with
|
||||
`scope` alone.
|
||||
|
||||
Each such page reads about as many index entries as it returns, where the previous
|
||||
statement read every observation of the channel. While the process does not trust the table,
|
||||
those pages answer `503` with `Retry-After: 30`. Two shapes always use the previous all-channel
|
||||
statement, trusted or not, on the two-connection `api_slow` pool: `/messages` with `iatas` and
|
||||
`since`, and `/messages` with `iatas` and `scope`. Pages without `iatas` or `scope` never read
|
||||
the table. Responses are the same as the previous statements' in every case.
|
||||
statement read every observation of the channel. `/messages` with `iatas` and `scope` reads one
|
||||
range per site and scope (`idx_cmi_site_scope`). `/messages` with `iatas` and `since` first
|
||||
counts the messages sent since then, up to 5,001 (`idx_channel_messages_sent`): up to 5,000, it
|
||||
reads exactly those and keeps the ones heard at a requested site. More, and it reads the newest
|
||||
5,000 messages first, which fills the page for sites heard often; a page that leaves short is read
|
||||
from all the messages sent since then, which is what it asked for. Sender clocks put
|
||||
`sentAt` anywhere, so neither the message order nor a site's range bounds `since` by itself.
|
||||
While the process does not trust the table, those pages answer `503` with `Retry-After: 30`;
|
||||
until this release `/messages` with `iatas` and `since` or `scope` ran the previous statement
|
||||
instead, trusted or not. Pages without `iatas` or `scope` never read the table. Responses are
|
||||
the same as the previous statements' in every case.
|
||||
|
||||
On a production-size copy (warm): `/messages` with one busy site and a scope it never had went
|
||||
from 241,633 buffers (916 ms) to 6 buffers; a busy site and its busiest scope, five pages, from
|
||||
1,560,771 buffers to 3,606; the 13 busiest sites with a scope and `since` 3 days back, two
|
||||
pages, from 438,827 buffers to 29,461; one busy site with `since` an hour back from 48,664
|
||||
buffers to 3,255.
|
||||
|
||||
### Installing it
|
||||
|
||||
@@ -696,10 +855,12 @@ Run this release for at least a week before upgrading to that one.
|
||||
latest hearing. Each row also copies the packet's payload type, route type, scope and first
|
||||
hearing. The observation insert writes the row in the same statement, so a stored observation
|
||||
always has its row. A background reconcile repairs the newest rows every 5 minutes, an hourly
|
||||
audit refills one random older slice, and a front sweep every 30 seconds keeps the newest
|
||||
audit refills a random older slice of 500,000 observations (picked by row, so every
|
||||
observation is as likely to be audited however sparse the old ids are), and a front sweep every 30 seconds keeps the newest
|
||||
index entries dense. On a large deployment (about 7.4 million rows) it takes about 1.6 GB:
|
||||
heap 484 MB, primary key 285 MB, `idx_packet_sites_site` 419 MB, `idx_packet_sites_site_type`
|
||||
353 MB, `idx_packet_sites_site_scope` 53 MB.
|
||||
353 MB, `idx_packet_sites_site_scope` 53 MB, `idx_packet_sites_late` 4 MB and
|
||||
`idx_packet_sites_early` (about empty) 8 kB and `idx_packet_sites_site_scope_type` 61 MB.
|
||||
|
||||
While the server process trusts the table, `/packets` pages with `iatas`, `iata`, `region` or
|
||||
`regionId` (region pages) read it:
|
||||
@@ -707,10 +868,12 @@ While the server process trusts the table, `/packets` pages with `iatas`, `iata`
|
||||
- one site: one range of the site's index;
|
||||
- several sites: one range per site;
|
||||
- payload and route types, up to 8 (payload, route) pairs: one exact range per site and pair;
|
||||
- a `since`/`until` window, scopes, or more than 8 type pairs: bounded reads of each site range
|
||||
that continue, within the same request, until the page is full. If 8 reads do not fill the
|
||||
page, the previous release's statement answers that request, on the two-connection
|
||||
`api_slow` pool with a 120 s statement limit (see [Slow region and message statements](#slow-region-and-message-statements)).
|
||||
- a `since`/`until` window, or scopes: each site's range (each site and scope's, with scopes;
|
||||
with a type filter, each type pair's range in it) from the page start down to the window; see
|
||||
[Deep region and message pages](#deep-region-and-message-pages);
|
||||
- more than 8 type pairs without a window: bounded reads of each site range that continue,
|
||||
within the same request, until the page is full; if 8 reads do not fill it, one exact range
|
||||
per site and type pair answers it.
|
||||
|
||||
Unknown site codes are dropped by a cheap check first. Requests with `hashPrefix` never read
|
||||
the table. A region page served from the table now returns `nextPageCursor`; see
|
||||
@@ -725,14 +888,18 @@ skips nothing. Packets tied on their site time come out by packet hash, descendi
|
||||
On a production-size copy, the heaviest region pages went from seconds to under a second: all
|
||||
561 sites for the last hour from 19.5 s to 0.3 s, and the 13 busiest sites for the last 75 minutes at
|
||||
limit 200 from 4.5 s to 31 ms. A filter of rare types on a very sparse site can read a few
|
||||
buffers more than before. An `until` well in the past often runs out of the read budget, so
|
||||
the previous statement answers many of those pages, at the previous cost.
|
||||
buffers more than before.
|
||||
|
||||
### Ingest cost
|
||||
|
||||
The site row makes each observation insert about 15-21% slower (mean 104 µs to 119-125 µs)
|
||||
and adds about 37-53% WAL per stored observation (1,425-1,470 B to 2,008-2,188 B), measured
|
||||
over 100,000 inserts on a scratch database. Re-sends of an observation still write nothing.
|
||||
The two partial indexes from `054` and `055` add about 1-6 % to the insert time and no
|
||||
measurable WAL (three runs of 150,000 inserts with 1 % of the hearings arriving hours late:
|
||||
within 0.4 %): only a row heard more than 30 minutes away from its first hearing enters them.
|
||||
`059` adds no measurable insert time (-1.2 to +1.4 %) and 0.5-0.9 % WAL, with one packet in
|
||||
eight scoped: only scoped rows enter it.
|
||||
|
||||
After the deploy, compare `InsertObservation` in `pg_stat_statements` (`mean_exec_time`, and
|
||||
`wal_bytes / calls`) for the hour before and the hour after, and watch
|
||||
@@ -754,17 +921,23 @@ On the first start the server fills the table in the background:
|
||||
1. It waits until every transaction that started before boot and holds a transaction id has
|
||||
finished (`packet_sites boot repair waits for transactions from before boot` while it
|
||||
waits), then runs one reconcile from the saved watermark.
|
||||
2. It backfills the table newest observation first, in slices of 500,000 observation ids
|
||||
2. It backfills the table newest observation first, in slices of 500,000 observations
|
||||
(`readmodels.fill.packet_sites_slice`), pausing after each slice as
|
||||
[`readmodels.fill`](#pacing-the-backfills) sets: at least 0.1 s, and by default as long as
|
||||
the slice took, so the fill is busy at most half the time. Once a minute the log shows a
|
||||
`packet_sites backfill progress` line with `ids_done`, `ids_remaining` and `eta`, then
|
||||
`packet_sites backfill complete`. On a large deployment (about 766 million ids, 29.4 million
|
||||
observations) that is about 1,530 slices; expect roughly twice the fill work, about 10 to 40
|
||||
minutes, longer on a slow disk or a lower `duty`, and a few GB of WAL.
|
||||
the slice took, so the fill is busy at most half the time. A slice counts observations, not
|
||||
ids: the few old observations [retention keeps](#retention) spread over a long id range cost
|
||||
a few slices, not one per 500,000 ids. Once a minute the log shows a
|
||||
`packet_sites backfill progress` line with `ids_done`, `ids_remaining`,
|
||||
`observations_done`, `observations_remaining` and `eta`, then
|
||||
`packet_sites backfill complete`. The observation counts are the planner's estimates from
|
||||
the table statistics, and `eta` follows them, so it no longer counts empty ids as work.
|
||||
On a large deployment (29.4 million observations over about 766 million ids) that is about
|
||||
60 slices; expect roughly twice the fill work, about 10 to 40 minutes, longer on a slow disk
|
||||
or a lower `duty`, and a few GB of WAL.
|
||||
3. It runs a one-time verification. It first runs `VACUUM (ANALYZE) packet_sites` (a few
|
||||
minutes on a large deployment), then an exact check of two slices of 500,000 observation
|
||||
ids, the newest and one random older one, and a page-by-page comparison of region pages of
|
||||
minutes on a large deployment), then an exact check of the newest 500,000 observation ids
|
||||
and of a random 500,000 older observations (picked by row, like the audit), and a page-by-page
|
||||
comparison of region pages of
|
||||
the busiest recent sites with pages read straight from the observations. On success it logs
|
||||
`packet_sites first-time verification passed` and records the time, so later starts skip it.
|
||||
4. It logs `packet_sites trusted: region packet pages read packet_sites`.
|
||||
@@ -928,7 +1101,7 @@ logged (`read-model ANALYZE failed; the fill goes on`) and the fill continues; a
|
||||
already has statistics is left to autovacuum.
|
||||
|
||||
Measured on a laptop Postgres with 3 million synthetic observations (`packet_sites`, slices of
|
||||
500,000 ids):
|
||||
500,000 observations, dense ids):
|
||||
|
||||
| `duty` | Fill time | Busy in fill statements |
|
||||
|---|---|---|
|
||||
@@ -936,7 +1109,7 @@ Measured on a laptop Postgres with 3 million synthetic observations (`packet_sit
|
||||
| `0.5` (default) | 153 s | 50 % |
|
||||
| `0.25` | 316 s | 25 % |
|
||||
|
||||
Slices of 100,000 ids cost about 7 % more fill work (85 s at `duty: 1`) but cut the longest
|
||||
Slices of 100,000 observations cost about 7 % more fill work (85 s at `duty: 1`) but cut the longest
|
||||
burst from about 14 s to about 3 s.
|
||||
|
||||
Settings, under [`readmodels.fill`](configuration.md#backfill-pacing) in `config.yaml`:
|
||||
@@ -953,59 +1126,86 @@ values in force. While a fill runs, check `/proc/pressure/io` on the host: `full
|
||||
stay well below 50 %. Lower `duty` further if it does not. The reconcile and the hourly audit
|
||||
are not paced: they cover only the observations since the last run, or one slice an hour.
|
||||
|
||||
## Slow region and message statements
|
||||
## Deep region and message pages
|
||||
|
||||
Two kinds of request still read a large part of the observations in one statement: a region
|
||||
`/packets` page whose bounded reads ran out of budget (a sparse filter such as a narrow window
|
||||
or rare payload types over busy sites), and `/messages` with `iatas` and `since` or `scope`.
|
||||
They run on their own pool, `api_slow`: 2 connections by default
|
||||
([`database.slow_pool_size`](configuration.md#database-pools-and-cost-guards)),
|
||||
`statement_timeout=120s`, no parallel workers, not gated by the cost guards. When every
|
||||
connection is busy a request waits at most 5 s and then answers `503` with `Retry-After: 5`,
|
||||
without touching the other pools; `beacon_api_unavailable_total{reason="slow_pool_busy"}` counts
|
||||
those, and the server logs `api: slow requests refused, every slow connection is busy` at most
|
||||
once a minute. A statement that passes 120 s ends with a `500`; narrowing the request (fewer
|
||||
sites, a wider window) avoids it.
|
||||
Region `/packets` pages with a window, scopes or types, and `/messages` pages with `iatas` and
|
||||
`since` or `scope`, read at most their page per index range (site, scope, type pair) or, for
|
||||
`/messages` with `since`, at most the messages sent since then. The worst case is a quiet site
|
||||
with `since` near the oldest retained message: it reads every message sent since then, which for an
|
||||
old `since` is effectively the whole message history. On a production-size copy (196,000
|
||||
messages) that took 2,907 buffers and 148 ms. Until this release a region page whose bounded reads ran out of budget (a sparse filter such as
|
||||
a narrow window, an `until` well in the past, or rare payload types over busy sites), and
|
||||
`/messages` with `iatas` and `since` or `scope`, ran the previous release's statements on a
|
||||
two-connection `api_slow` pool with a 120 s statement limit. That pool and its
|
||||
`database.slow_pool_size` key are gone; those pages run on the pool their plan class picks
|
||||
(`api_custom`, 35 s; `api_legacy` under the `single` layout), like every other page.
|
||||
|
||||
Before this release the region statement ran on `api_legacy` without a time limit for a
|
||||
millisecond cursor, and on `api_custom` (35 s, gated by `packets_region`) for an exact page
|
||||
cursor; the all-channel message statement ran on `api_legacy` without a limit.
|
||||
A region page with a window seeks from it. Its rows are first heard in `[since, until]`, so
|
||||
almost all of them were heard at a site within 30 minutes of that: each site's range is read
|
||||
from `until` + 30 minutes (or the page cursor, when lower) down to `since` − 30 minutes, and
|
||||
stops at the page. Two partial indexes hold the only rows that can lie outside those bounds:
|
||||
`idx_packet_sites_late`, the rows heard at a site more than 30 minutes after their packet was
|
||||
first heard (a re-hearing hours or days later; about 0.8 % of the rows), and
|
||||
`idx_packet_sites_early`, the rows heard more than 30 minutes before it (ingest clamps hearing
|
||||
times to 30 minutes of its own clock, so it stays about empty). A page reads about its own rows,
|
||||
the 30 minutes above `until` and the late rows above that; a sparse filter reads at most its
|
||||
window. Scoped pages do the same per site and scope. With a type filter, each site's (or site and
|
||||
scope's) range is read per (payload type, route type) pair instead, on `idx_packet_sites_site_type`
|
||||
or `idx_packet_sites_site_scope_type`, so a rare type reads at most a page per pair: a page costs
|
||||
at most the pairs times its size, however sparse the filter. That holds up to 4,096 ranges (sites
|
||||
times scopes times type pairs; a region page with up to 8 pairs is always under it). Above it the
|
||||
page reads each site or site-and-scope range with the types as a filter, as an untyped page does:
|
||||
cheap while the filter is dense, but a sparse filter then reads each range down to the window.
|
||||
Only requests naming thousands of combinations get there; the worst measured, every known site
|
||||
(562) with every scope (1,491) and one rare payload type, took 2.5 million buffers and 2.1 s
|
||||
without a window and 5.0 million buffers and 3.0 s with `until` a day back, on a warm
|
||||
production-size copy, nearly all of it probing the 838,000 mostly empty site-and-scope ranges.
|
||||
Pages are full until the last, and the
|
||||
rows, their order and `nextPageCursor` are those of the observations; where the previous
|
||||
statement had ended a page short at its scan floor, the new page is full and the walk returns
|
||||
the same rows in the same order.
|
||||
|
||||
Measured on a copy of a large production database (29.4 million observations, 561 IATAs), with
|
||||
the pool's settings:
|
||||
Measured on a copy of a large production database (29.4 million observations; warm; every region
|
||||
and message statement a request runs, page by page):
|
||||
|
||||
| Request | Warm | Cold cache |
|
||||
| Request | Before: buffers, time | After |
|
||||
|---|---:|---:|
|
||||
| The busiest region (13 IATAs), three rare payload types | 3.1-3.3 s | 22.9 s |
|
||||
| The 13 busiest IATAs, a one-hour window 60 hours back | 5.5 s | 23.3 s (first run) |
|
||||
| Every IATA (561), a one-minute window | 50 s | 34 s |
|
||||
| Every IATA (561), a one-hour window 120 hours back | 115 s | not measured |
|
||||
| The busiest site, `until` 24 hours back, page 1 | 131,591, 2.1 s | 2,442, 47 ms |
|
||||
| The same, 20 pages | 383,754, 3.8 s | 34,830, 434 ms |
|
||||
| The 13 busiest IATAs, `until` 24 hours back, 10 pages | 4,431,750, 3.6 s | 45,140, 117 ms |
|
||||
| The 13 busiest IATAs, a one-hour window 60 hours back | 3,694,758, 16.5 s | 2,326, 25 ms |
|
||||
| Three sites and a scope, `until` 24 hours back, 3 pages | 1,294,780, 3.7 s | 6,766, 52 ms |
|
||||
| The 13 busiest IATAs, the last 75 minutes, limit 200, 2 pages | 65,485, 826 ms | 14,361, 119 ms |
|
||||
|
||||
Requests naming every IATA are not something the web or app send; the largest region on that
|
||||
database has 22 IATAs. `beacon_db_time_seconds{pool="api_slow"}` and
|
||||
`beacon_pool_acquire_seconds{pool="api_slow"}` show how busy the pool is.
|
||||
On production the busiest-site `until` page took 48-51 s with a cold cache before this change
|
||||
(about 181,000 blocks read per call). Message pages are in
|
||||
[Read models: channel messages by site](#read-models-channel-messages-by-site).
|
||||
|
||||
A server whose `config.yaml` still sets `database.slow_pool_size` starts as before and logs
|
||||
`config: database.slow_pool_size is no longer used and is ignored; remove it` at `WARN`.
|
||||
|
||||
### Reverting to the read-model fallbacks
|
||||
|
||||
If the read-model pages must be served while a model is not trusted, as in earlier releases,
|
||||
revert the server change that removed the fallbacks rather than rolling back the image. It is a
|
||||
single commit on the server's `dev` branch ("region, scope and advert pages answer 503 until
|
||||
their read model is ready, instead of falling back"); reverting it gives back exactly the
|
||||
previous release's tree:
|
||||
revert two server commits on `dev` rather than rolling back the image, newest first: the one
|
||||
that removed the `api_slow` pool (it adds migrations `054` to `057`), then the one that removed
|
||||
the fallbacks (region, scope and advert pages answer `503` until their read model is ready).
|
||||
Find them with `git log --oneline -- db/pools.go`:
|
||||
|
||||
```bash
|
||||
git revert --no-edit <that commit>
|
||||
git revert --no-edit <the pool commit> <the fallback commit>
|
||||
```
|
||||
|
||||
Then build, deploy and restart; no configuration change is needed, because the revert also
|
||||
restores `slow_pool_size`'s default of 8 (an explicit `slow_pool_size` in `config.yaml` still
|
||||
wins). What to expect:
|
||||
Then build, deploy and restart; no configuration change is needed, because the reverts also
|
||||
restore the pool and its default of 8 connections (an explicit `slow_pool_size` in `config.yaml`
|
||||
still wins). Migrations `054` to `057` stay applied; the restored statements don't read their
|
||||
indexes. What to expect:
|
||||
|
||||
- Untrusted region `/packets` pages, channel message pages and observer advert pages are served
|
||||
by the previous statements again, with no `503` from a read model.
|
||||
- All four previous statements (region, per-channel and all-channel messages, adverts) run on
|
||||
`api_slow`: 8 connections, like the old `api_legacy`, ungated, with the 5 s connection wait and
|
||||
a 120 s statement limit (the old pool had no limit). The total connection count grows by 6.
|
||||
a 120 s statement limit (the old pool had no limit). The total connection count grows by 8.
|
||||
- If migration `046` (the drops release) has run, rebuild `idx_observations_observer_id` first,
|
||||
because the restored adverts statement reads it:
|
||||
|
||||
@@ -1192,7 +1392,6 @@ connection belongs to:
|
||||
| Pool | `application_name` | Size | Session settings |
|
||||
|---|---|---:|---|
|
||||
| `api_legacy` | `beacon-api_legacy` | 8, `single` only | Server defaults. The rollback layout's only API pool. |
|
||||
| `api_slow` | `beacon-api_slow` | 2 (`slow_pool_size`), not under `single` | Server defaults, with `statement_timeout=120s` and `max_parallel_workers_per_gather=0`. Only the [slow region and message statements](#slow-region-and-message-statements). |
|
||||
| `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 |
|
||||
@@ -1201,7 +1400,7 @@ connection belongs to:
|
||||
The `single` layout opens only `api_legacy`, `ingest` and `maint`; the other layouts never open
|
||||
`api_legacy`.
|
||||
|
||||
- **Headroom.** `SHOW max_connections;` must leave room for the total (20 by default on up to
|
||||
- **Headroom.** `SHOW max_connections;` must leave room for the total (18 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
|
||||
@@ -1324,7 +1523,7 @@ scrape_configs:
|
||||
| `beacon_packet_sites_front_live` | gauge | `index` | Index entries written in the last two minutes that the last front sweep visited |
|
||||
| `beacon_packet_sites_bytes` | gauge | | Total size of `packet_sites`, heap and indexes, hourly |
|
||||
| `beacon_db_sequence_used_ratio` | gauge | `sequence`, `cycle` | Share of a sequence's usable values already handed out, 0 to 1, each minute. The usable range ends at the sequence's own limit or its column's type limit (`integer` 2,147,483,647), whichever is lower. `cycle="true"` marks a `CYCLE` sequence, whose ratio is its position, not used-up headroom. See [Sequence headroom](#sequence-headroom). |
|
||||
| `beacon_api_unavailable_total` | counter | `reason`, `model` | `503`s a page answered: `not_ready` while its read model or backfill is not ready, `slow_pool_busy` when `api_slow` had no free connection, `timeout` when its statement ran past its pool's statement limit or an enforced guard's deadline. `model` is `packet_sites`, `cmi` or `observation_payload_types`. |
|
||||
| `beacon_api_unavailable_total` | counter | `reason`, `model` | `503`s a page answered: `not_ready` while its read model or backfill is not ready, `timeout` when its statement ran past its pool's statement limit, an enforced guard's deadline or the endpoint's own time limit. `model` is the read model the page reads (`packet_sites`, `cmi` or `observation_payload_types`), or `none` for every other `/api/v1` page. A request an enforced guard deadline stops is also counted in `beacon_guard_refused_total{guard="deadline"}`; admission and cap refusals happen before the page runs and are not counted here. |
|
||||
|
||||
`guard` is `deadline`, `cap`, `admission` or `budget`. The Postgres counters are read once a
|
||||
minute; the index gauges first appear an hour after startup. The two `beacon_planguard_*`
|
||||
@@ -1394,8 +1593,10 @@ groups:
|
||||
- alert: BeaconAdvertsNotReady # observer advert pages answering 503 for over 2 h
|
||||
expr: sum(increase(beacon_api_unavailable_total{reason="not_ready",model="observation_payload_types"}[15m])) > 0
|
||||
for: 2h
|
||||
- alert: BeaconSlowPoolBusy # region or message requests refused for want of a slow connection
|
||||
expr: sum(increase(beacon_api_unavailable_total{reason="slow_pool_busy"}[15m])) > 10
|
||||
- alert: BeaconQueryTimeouts # pages answering 503 because their statement ran out of time
|
||||
# One client retrying a slow page adds a few dozen in 15 minutes; tune the floor to your traffic.
|
||||
expr: sum by (model) (increase(beacon_api_unavailable_total{reason="timeout"}[15m])) > 100
|
||||
for: 30m
|
||||
# The sequence rules leave out CYCLE sequences, which wrap instead of running out.
|
||||
- alert: BeaconSequenceHalfUsed
|
||||
expr: beacon_db_sequence_used_ratio{cycle="false"} > 0.5
|
||||
|
||||
@@ -45,6 +45,29 @@ server:
|
||||
trusted_proxies: [172.30.0.0/24]
|
||||
```
|
||||
|
||||
### HTTP/3
|
||||
|
||||
Caddy serves HTTP/3 (QUIC) by default and announces it in an `alt-svc` header. QUIC runs on
|
||||
UDP, so the compose files publish `443:443/udp` next to `443:443`. Open UDP 443 wherever TCP
|
||||
443 is open: the host firewall and the cloud provider's security group. HTTP/3 helps most on
|
||||
lossy mobile links: a lost packet no longer holds up every request on the connection, and
|
||||
connections start faster and survive a change of network.
|
||||
|
||||
After `docker compose up -d`, responses carry the header:
|
||||
|
||||
```bash
|
||||
curl -sI https://beacon.example.com/ | grep -i alt-svc
|
||||
# alt-svc: h3=":443"; ma=2592000
|
||||
```
|
||||
|
||||
Caddy sends it even when UDP is blocked, so it only shows that HTTP/3 is on. To see HTTP/3 get
|
||||
through, open the browser's network panel and reload the page, more than once if needed: the
|
||||
Protocol column shows `h3`. A curl built with HTTP/3 (`curl -V` lists `HTTP3`) checks it
|
||||
directly with `curl --http3-only -sI https://beacon.example.com/`.
|
||||
|
||||
Nothing breaks where UDP is blocked, by your firewall or the visitor's network: browsers fall
|
||||
back to HTTP/2. The nginx and Apache setups below have no HTTP/3.
|
||||
|
||||
## Behind a CDN
|
||||
|
||||
Behind Cloudflare or another CDN, `{remote_host}` is the CDN, not the visitor. The comment at
|
||||
|
||||
@@ -89,9 +89,9 @@ advertised. This fills in on its own as the mesh is observed.
|
||||
## Channel messages show a hash but no text
|
||||
|
||||
The channel's key is not configured, or it was added without a restart. Add it under
|
||||
`channel_keys:` in `config.yaml` and run `docker compose restart app`. On start, Beacon decrypts
|
||||
the stored messages for the new key and logs
|
||||
`config: backfilled N previously-undecrypted channel message(s)`.
|
||||
`channel_keys:` in `config.yaml` and run `docker compose restart app`. Once it listens, Beacon
|
||||
decrypts the stored messages for the new key in the background and logs
|
||||
`channel message backfill done: N previously-undecrypted channel message(s)`.
|
||||
|
||||
## MeshMapper imports are unconfigured or stale
|
||||
|
||||
|
||||
+133
-20
@@ -134,13 +134,34 @@ ratelimit:
|
||||
Then run `docker compose restart app`. If you run the [fail2ban jails](../app_config/fail2ban/README.md),
|
||||
the flood jail's `maxretry` of 900 still sits above the new limit.
|
||||
|
||||
## HTTP/3 needs UDP 443
|
||||
|
||||
The compose files now publish UDP 443 so Caddy can serve HTTP/3. An existing deployment keeps
|
||||
its own `docker-compose.yml`, so add the UDP line to the `caddy` service by hand (on both hosts
|
||||
of a split deployment):
|
||||
|
||||
```yaml
|
||||
caddy:
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "443:443/udp"
|
||||
```
|
||||
|
||||
Open UDP 443 in the host firewall and any cloud security group, then run
|
||||
`docker compose up -d caddy`, which recreates the Caddy container. [HTTP/3](reverse-proxy.md#http3)
|
||||
shows how to check it. If you enforce the [fail2ban jails](../app_config/fail2ban/README.md),
|
||||
add `protocol="tcp,udp"` to the ban action as shown there: the default bans TCP only, so a
|
||||
banned address could still reach the site over HTTP/3.
|
||||
|
||||
## Upgrading to 2.1
|
||||
|
||||
Server 2.1.0 is one upgrade from any 2.0.x release. It runs migrations `004` to `053` at startup,
|
||||
Server 2.1.0 is one upgrade from any 2.0.x release. It runs migrations `004` to `061` at startup,
|
||||
adds two read models that region and scope pages are served from, and drops indexes and one
|
||||
column nothing reads any more. No config change is required. Beacon Web must move to 2.1 at the
|
||||
same time (`minWebVersion` is 2.1.0; an older web build shows the reload screen). The minimum app
|
||||
version stays 2.0.0, so installed apps keep working.
|
||||
same time (`minWebVersion` is 2.1.0; an older web build shows the reload screen). BEACON Mobile
|
||||
needs 2.1 too (`minAppVersion` is 2.1.0; an older release build shows the update screen). Beta
|
||||
builds are never blocked, so installed betas keep working.
|
||||
|
||||
The upgrade has two phases. First the server runs its migrations while the API and ingest are
|
||||
down. Then it serves the API again and fills the read models in the background; until each one
|
||||
@@ -159,7 +180,7 @@ then show an error with a retry button.
|
||||
`readmodels.fill.duty: 0.25` and `readmodels.fill.packet_sites_slice: 100000` in `config.yaml`
|
||||
before upgrading. At full speed the first fill can stall such a machine; paced, it takes
|
||||
longer but the server stays responsive. See [Pacing the backfills](operations.md#pacing-the-backfills).
|
||||
4. **Connections.** The server now opens one pool per workload: 20 connections by default, plus
|
||||
4. **Connections.** The server now opens one pool per workload: 18 connections by default, plus
|
||||
one for each CPU above 6. Check that Postgres `max_connections` leaves room for that and your
|
||||
own sessions; see [Database pools and connections](operations.md#database-pools-and-connections).
|
||||
5. **Large deployments only** (tens of millions of routes, hundreds of millions of observations):
|
||||
@@ -170,7 +191,12 @@ then show an error with a retry button.
|
||||
- [the four packets indexes](operations.md#pre-building-the-packets-indexes),
|
||||
- [the observer adverts index](operations.md#pre-building-the-observer-adverts-index).
|
||||
|
||||
Everyone else can skip this: the server builds them at startup.
|
||||
Everyone else can skip this: the server builds them at startup. The five indexes of
|
||||
[`054` to `057` and `059`](operations.md#indexes-for-deep-region-and-message-pages-054-to-057-059)
|
||||
need no pre-build: four build on tables the same startup creates, and
|
||||
`idx_channel_messages_sent` reads `channel_messages` in well under a second. A server already
|
||||
on a 2.1 development build builds all five on full tables in a few seconds; pre-build them
|
||||
there only if you want, with the text in that section.
|
||||
6. Run the [pre-deploy check](operations.md#before-deploying-a-release) (no long transactions,
|
||||
no anti-wraparound vacuum, no `pg_dump` running), and pick a quiet hour.
|
||||
|
||||
@@ -201,16 +227,18 @@ routes, 30 million observations and 3 million packets; default pacing):
|
||||
| Step | Time |
|
||||
|---|---|
|
||||
| Pre-building the eight indexes, old version still serving | 1 min 50 s (route hop index 72 s, adverts index 16 s, the rest under 8 s each) |
|
||||
| Migrations `004` to `053` at startup | 7.4 s (the two `packets` checks 3.7 s and 1.8 s) |
|
||||
| Migrations `004` to `053` at startup (a 2.1 development build; the release adds `054` to `061`, a few seconds more) | 7.4 s (the two `packets` checks 3.7 s and 1.8 s) |
|
||||
| API and ingest down | about 15 s |
|
||||
| Channel message pages by site back | 8 min after startup |
|
||||
| Observer advert pages | at once (no untyped observations) |
|
||||
| Region packet pages back | 50 min after startup |
|
||||
| Database size | 91 GB before, 71 GB after |
|
||||
|
||||
The region packet fill walks the whole observation id range, newest to oldest, so a database that
|
||||
still holds a few very old observations fills for longer than its row count suggests, and the
|
||||
`eta` in its progress lines overstates the time left.
|
||||
That development build walked the whole observation id range in fixed id slices, so the few very
|
||||
old observations the database still held stretched its fill and its `eta`. The release slices the
|
||||
fill by observations instead and reports progress and `eta` in rows (see
|
||||
[Pacing the backfills](operations.md#pacing-the-backfills)); on a copy of the same data it needs
|
||||
about 60 slices instead of 1,533.
|
||||
|
||||
Watch the log for these lines; each one ends a phase:
|
||||
|
||||
@@ -229,15 +257,22 @@ see [Pages that wait for a read model](operations.md#pages-that-wait-for-a-read-
|
||||
- **Ingest cost.** Each stored observation now costs about 15 to 21 % more insert time and 37 to
|
||||
53 % more WAL. Watch `beacon_ingest_queue_depth` for the first hours; see
|
||||
[Ingest cost](operations.md#ingest-cost).
|
||||
- **Alerts.** Load the read-model `Untrusted` rules, `BeaconAdvertsNotReady`,
|
||||
`BeaconSlowPoolBusy` and the sequence rules from
|
||||
- **Alerts.** Load the read-model `Untrusted` rules, `BeaconAdvertsNotReady` and the sequence
|
||||
rules from
|
||||
[Example alert rules](operations.md#example-alert-rules).
|
||||
- **Route maintenance.** The first route retention run after the upgrade is a full pass; on a
|
||||
large deployment it takes 12 to 14 minutes in short statements. See
|
||||
[After upgrading: route maintenance](operations.md#after-upgrading-route-maintenance).
|
||||
- **Optional, large deployments, once, off-peak:**
|
||||
[compact `observers` and `channels`](operations.md#after-upgrading-compacting-observers-and-channels)
|
||||
[compact `observers`, `channels` and `nodes`](operations.md#after-upgrading-compacting-observers-and-channels)
|
||||
and [rebuild the route keyset indexes](operations.md#rebuilding-the-route-keyset-indexes).
|
||||
- **Stats pages.** Top nodes, top advertisers and paths for a region get faster as hours rolled
|
||||
before the upgrade leave the window: fully within 7 days for the default view. See
|
||||
[After upgrading: stats rollup row order](operations.md#after-upgrading-stats-rollup-row-order)
|
||||
for an optional one-off `CLUSTER` that heals it at once.
|
||||
- **Stop grace period.** If you run your own compose file, give the `app` service
|
||||
`stop_grace_period: 75s` (Docker's default is 10 s), so a stop finishes storing what ingest has
|
||||
queued; see [Stopping the server](operations.md#stopping-the-server).
|
||||
- **Plan checks.** Turn on the [daily plan check](operations.md#turning-on-the-daily-check) only
|
||||
after the upgrade, if you want it.
|
||||
|
||||
@@ -260,6 +295,15 @@ CREATE INDEX CONCURRENTLY idx_observations_observer_id ON packet_observations US
|
||||
SELECT indisvalid AND indisready FROM pg_index WHERE indexrelid = 'idx_observations_observer_id'::regclass;
|
||||
```
|
||||
|
||||
Also clear clock drifts an older server cannot read: `060` lets drifts exceed the 32-bit range, and
|
||||
2.0.7 fails node detail and the clock-drift stats on such a row. It is a few rows at most, and
|
||||
each node measures its drift again on its next advert:
|
||||
|
||||
```sql
|
||||
UPDATE nodes SET device_clock_drift_seconds = NULL
|
||||
WHERE device_clock_drift_seconds NOT BETWEEN -2147483648 AND 2147483647;
|
||||
```
|
||||
|
||||
The `ALTER` is a catalog change (milliseconds, brief lock on `known_routes`). Run the `CREATE` on
|
||||
its own in autocommit mode; on a large deployment it takes about a minute or two, and the
|
||||
`SELECT` must print `t`. Then deploy the 2.0.7 server and a matching 2.0 web build. The other
|
||||
@@ -304,31 +348,100 @@ See [Read models: packets by site](operations.md#read-models-packets-by-site).
|
||||
|
||||
#### Pools, dropped indexes and a dropped column (`046` to `053`)
|
||||
|
||||
- `api_legacy` opens only under the `single` layout. `api_slow` (2 connections by default,
|
||||
`database.slow_pool_size`, 120 s statement limit) runs the few statements that may read whole
|
||||
site histories; see
|
||||
[Slow region and message statements](operations.md#slow-region-and-message-statements).
|
||||
- `api_legacy` opens only under the `single` layout. The 2.1 development builds also had an
|
||||
`api_slow` pool and a `database.slow_pool_size` key; both are gone (see below).
|
||||
- `046` drops `idx_observations_observer_id`, `047` to `052` drop six cold lookup-table indexes,
|
||||
and `053` drops `known_routes.last_reconfirmed_at`. Every 2.0.x image reads or writes that
|
||||
column, hence the rollback steps above.
|
||||
|
||||
#### Deep region and message pages (`054` to `057`, `059`)
|
||||
|
||||
Region `/packets` pages with a `since`/`until` window or scopes seek from the window instead of
|
||||
reading each site from its newest row, and `/messages` with `iatas` and `since` or `scope` read
|
||||
`channel_message_iatas`; no page reads a whole site history in one statement any more. Two
|
||||
partial indexes on `packet_sites` (`054`, `055`), one on `channel_message_iatas` (`056`), one on
|
||||
`channel_messages` (`057`), and one on the scoped rows of `packet_sites` for a scope with a type
|
||||
filter (`059`, 61 MB on a large deployment). Pages are full until the last; rows, order and cursors
|
||||
are unchanged. On production the busiest region with `until` 24 hours back took 48-51 s before;
|
||||
on a production-size copy it went from 131,591 buffers to 2,442. See
|
||||
[Deep region and message pages](operations.md#deep-region-and-message-pages).
|
||||
|
||||
The 2.1 development builds ran those pages on a two-connection `api_slow` pool. It is gone, and
|
||||
so is `beacon_api_unavailable_total{reason="slow_pool_busy"}` (and the `BeaconSlowPoolBusy`
|
||||
alert in the examples). A `database.slow_pool_size` left in `config.yaml` is ignored, with a
|
||||
`WARN` at startup; remove it. The default connection total drops from 20 to 18 (22 under
|
||||
`split`).
|
||||
|
||||
#### Nodes table (`058`, `060`)
|
||||
|
||||
`058` sets `fillfactor = 50` on `nodes` so the `last_seen` update of every advert can stay on its
|
||||
page (fewer index writes, about half the WAL); it changes settings only and applies to pages
|
||||
written from then on. `060` widens `nodes.device_clock_drift_seconds` to `bigint`, so device
|
||||
clocks more than about 68 years off are stored as they are instead of wrapping; it rewrites
|
||||
`nodes` once at startup (about 0.1 s for 70,000 nodes). See
|
||||
[How startup migrations wait for locks](operations.md#how-startup-migrations-wait-for-locks) and,
|
||||
before going back to 2.0.x, the extra step in [Rolling back from 2.1](#rolling-back-from-21).
|
||||
|
||||
#### Read-model pages without fallback
|
||||
|
||||
Region `/packets` pages, channel message pages filtered by `iatas` (or by `scope` without
|
||||
`since`), and observer advert pages are served only from their read model or backfilled data.
|
||||
While that is not ready they answer `503` with `Retry-After: 30`; there is no fallback to the
|
||||
previous statements. The kill switches above turn those pages off instead of serving them from
|
||||
the previous statements. A page whose statement runs past its pool's limit answers `503` with
|
||||
`Retry-After: 10`. To get the fallbacks back, revert their one server commit; see
|
||||
the previous statements. To get the fallbacks back, revert their two server commits; see
|
||||
[Reverting to the read-model fallbacks](operations.md#reverting-to-the-read-model-fallbacks).
|
||||
|
||||
#### Packet sites fill counts observations
|
||||
|
||||
The `packet_sites` first fill slices by observations instead of observation ids, so the few
|
||||
old observations far below the live ids no longer cost a paced slice per 500,000 ids, and its
|
||||
progress line adds `observations_done` and `observations_remaining` (planner estimates) and
|
||||
bases `eta` on them. `readmodels.fill.packet_sites_slice` keeps its default and range but now
|
||||
counts observations. The hourly audit and the first-time verification pick their random older
|
||||
slice by observations too, so on a database with a long, sparse old id range they check real
|
||||
rows. The observation payload-type backfill's progress line gains the same `observations_*`
|
||||
estimates and bases its `eta` on them.
|
||||
|
||||
#### Statement timeouts answer 503
|
||||
|
||||
Every `/api/v1` read whose statement runs past its pool's limit (35 s)
|
||||
now answers `503` `deadline_exceeded` with `Retry-After: 10` instead of a `500`. The heavy
|
||||
stats endpoints and route searches already answered `503` when their own time limit ran out;
|
||||
that `503` now carries `Retry-After: 10` and code `deadline_exceeded` (was
|
||||
`service_unavailable`), and also covers a statement timeout. Web and app retry it like the other
|
||||
`503`s. In the server log a timeout is one `request complete` line at `WARN` with `status=503`
|
||||
instead of an `ERROR` pair; a real `500` still logs `api: error 500: <message>` at `ERROR`, now
|
||||
with the database error in `error`. Every timeout `503` is counted in
|
||||
`beacon_api_unavailable_total{reason="timeout"}`: under the read model a page reads, or
|
||||
`model="none"` for the other pages.
|
||||
|
||||
#### Runtime image on Alpine 3.24
|
||||
|
||||
The server image moved from Alpine 3.19, which is out of support, to Alpine 3.24 with the
|
||||
PostgreSQL 16 client tools. Nothing to change for a PostgreSQL 16 database. A self-built image
|
||||
with `POSTGRES_CLIENT_MAJOR=17` or `18` now works too; see
|
||||
[Backup export](backup-export.md).
|
||||
|
||||
#### Channel message backfill after startup
|
||||
|
||||
The retry of stored, undecrypted channel messages against the configured keys no longer
|
||||
delays startup: it runs in the background once the server listens. On a slow disk that was
|
||||
most of the boot time (reading every undecrypted packet), so MQTT ingest and the API start
|
||||
sooner. Its log lines changed: `config: backfilled N previously-undecrypted channel
|
||||
message(s)` is now `channel message backfill started` (with `packets`) and `channel message
|
||||
backfill done: N previously-undecrypted channel message(s)`, logged on every run. Keys that a
|
||||
MeshMapper import adds queue on the same job. A message a live hearing decrypts first, before
|
||||
the backfill reaches its packet, is stored once and its earlier hours are re-rolled, as the
|
||||
backfill does.
|
||||
|
||||
## Changes that need a restart
|
||||
|
||||
Beacon reads `config.yaml` once at startup. After editing it, run
|
||||
`docker compose restart app`. On the next start:
|
||||
|
||||
- A newly added channel key decrypts the matching stored messages. Look for
|
||||
`config: backfilled N previously-undecrypted channel message(s)` in the log.
|
||||
- A newly added channel key decrypts the matching stored messages, in the background once
|
||||
the server listens. Look for
|
||||
`channel message backfill done: N previously-undecrypted channel message(s)` in the log.
|
||||
- Border file and MeshMapper import changes take effect.
|
||||
- Changed admin or MeshMapper keys in the YAML file are picked up.
|
||||
|
||||
|
||||
Reference in new issue
Block a user