## Why
The usage poller pays memory and query costs proportional to community
count even when per-community emission is disabled. Every pod loads the
full host map, and the leader materializes all per-community aggregates
before suppressing their labels. That makes telemetry collection itself
a scaling constraint.
## What changed
- Fleet-only usage metrics are now the default. `all` remains available
as an explicit rollback mode.
- In fleet-only mode, the poller skips the community host map and all
community-sized query results.
- Exact community counting is replaced with the PostgreSQL planner's
table-size estimate.
- Stock totals are collected as one fixed row, scanning each stock table
once per hour.
- 1d/7d/30d active-user totals come from one bounded 30-day scan per
day.
- Fleet database snapshots run only on a proved read replica, with a
telemetry-specific freshness budget and server-side statement timeouts.
If no such replica is available, the collection is skipped; it never
falls back to the writer.
- Successful fixed-row snapshots are cached and re-emitted on each poll
tick, so gauges stay scrape-visible without raising query frequency.
- `buzz_usage_snapshot_available{family}` reports whether the latest due
refresh of that family succeeded. After a skipped or failed refresh, the
last values stay visible (with `buzz_usage_snapshot_age_seconds`) but
the family reads unavailable until a 60-second retry succeeds.
- The `all` rollback collector publishes the same availability gauges,
so dashboards gated on availability keep working during a rollback.
- Each due refresh outcome is applied by `apply_stock_refresh` /
`apply_activity_refresh`, the single production mapping from `Ok(Some)`
/ `Ok(None)` / `Err` to cache, availability, skip counter, and retry;
the regression test drives that mapping directly.
- `buzz_usage_snapshot_age_seconds` is emitted in the `all` rollback
too, so age-based alerts keep firing through a rollback.
- Every fleet telemetry attempt that completes lands in
`buzz_db_route_decision`; a query error on a proved reader records
`skipped/replica_error`. An attempt abandoned at the relay deadline
records no route decision and appears only as
`buzz_usage_query_skipped_total{reason="timeout"}`. The shared
`proved_reader` warnings no longer claim "routing to writer", because
the telemetry caller never does (serving callers still record their
writer decision in the route counter).
- The PostgreSQL production-method matrix now exercises
`usage_fleet_stock_snapshot`, so the new `reader/maintenance` pool pair
is proven to be emitted by a real Db method.
- Each fleet collection, reader proof included, runs under a relay-side
`tokio::time::timeout` (10s stock, 20s activity, both above their
server-side `statement_timeout`). A reader that stops answering expires
into the same failure mapping with
`buzz_usage_query_skipped_total{reason="timeout"}`, so it cannot stall
the leader's poller, hold the advisory lock past a liveness check, or
leave a frozen snapshot reporting available.
- Fleet telemetry reader checkouts (`ReaderOperation::Maintenance`) opt
into SQLx `close_on_drop` before their first post-acquire await, so an
abandoned collection's connection is closed within SQLx's 5s
`CLOSE_ON_DROP_TIMEOUT` instead of going through the unbounded
return-to-pool ping while it holds a slot in the reader pool that
serving reads share. Serving reads keep pooled reuse; the cost is one
reader reconnect per fleet refresh.
- On leader demotion, cached snapshots and availability are cleared, and
a fresh collection is forced after the lock is reacquired.
- Fleet-only mode passes no host map to the worker-snapshot storage
emitter. It therefore omits community attribution instead of reporting
every byte as `buzz_storage_unmapped_community_bytes`.
- Lifetime stored-message and active-channel totals are no longer
queried in fleet-only mode. Event throughput remains available through
the existing stored-event counter.
- No test-selector changes: main's `config::tests::` selector in `just
test-unit` and the `run-tests.sh unit` fallback already runs the new
replica-budget config test, and the new fleet schedule, availability,
and storage tests run through the existing bin `tests::` and
`storage_sweep::tests::` selectors.
- Added seeded PostgreSQL contract tests and operator docs covering
rollout, rollback, availability, the no-replica behavior, and leader
election.
No migrations, tables, indexes, or new persistent storage are added.
### What got simpler
The relay-side storage half of the original branch (totals-only S3
listing and `buzz_storage_community_breakdown_available`) was dropped.
Main moved S3 listing out of the relay in #7543 and #7845, so that code
was dead on arrival. The rework adds no storage code path; it only gates
attribution on the presence of a host map.
## Risk
Medium. The default changes which telemetry is emitted:
- In fleet-only mode, `buzz_communities_estimated` replaces the exact
`buzz_communities_total`.
- `buzz_total_messages` and `buzz_total_active_channels` are not
refreshed in fleet-only mode (also called out in the rollout steps of
`docs/usage-metrics.md`).
- Deployments without a verified reader (including local compose and
single-node self-hosts) skip database-derived fleet snapshots instead of
querying the writer, and report both families unavailable.
- Multi-pod dashboards must filter database and storage series to
`buzz_usage_poller_is_leader == 1`.
Setting `BUZZ_USAGE_METRICS_PER_COMMUNITY=all` restores the legacy
collector for temporary rollback.
## Verification
At head `1879ee3aedf4227942120a3b521c8d35fc7839fe` (tree `78477e74`;
merges main `da651ad6`; the PR's diff against main is byte-identical to
`1613823f`'s, minus the Justfile and `run-tests.sh` selector lines that
main's `config::tests::` selector now subsumes):
- On Blox: `cargo fmt --all -- --check` and `cargo clippy -p buzz-db -p
buzz-relay --all-targets -- -D warnings` pass. Relay bin `tests::`
34/34, `config::tests::` 55/55, `storage_sweep::tests::` 10/10 and
`store::usage::tests` pass, as do the four `fleet_` and lock PostgreSQL
tests that use scratch databases, including
`abandoned_fleet_collection_releases_its_reader_slot`.
- Against a freshly migrated PostgreSQL database:
`store::usage::postgres_tests::` 14/14,
`storage_sweep::postgres_tests::` and `buzz-db --test
observability_source` 14/14 pass. The new reader code passes main's
crate-wide typed-acquisition and serving-table policy checks.
- E2E at `1613823f` is reused for this head. It ran in an isolated relay
against a real PostgreSQL primary and streaming standby. In 2/2 runs a
stock read went dark mid-result; the deadline abandoned it at about 10s,
the reader pool's active and size counts dropped to zero about 1s later,
and a fresh activity read and the scheduled stock retry succeeded on new
backends while the dark session stayed open. The `all`-mode and
healthy-replica controls also passed. Main's merged changes are on the
writer path and in startup step timing, and touch none of the reader
checkout, the `close_on_drop` seam, the fleet poller or the deadlines.
At head `1613823fa8ef1a423bc1a499c57bc40d692fcd4f` (tree `12bc6d1b`;
test/docs-only on top of `57749418`), on a Blox workstation:
- `cargo fmt --all -- --check` and `cargo clippy -p buzz-db -p
buzz-relay --all-targets -- -D warnings` pass. Relay bin `tests::` 34/34
pass.
- New PostgreSQL test
`abandoned_fleet_collection_releases_its_reader_slot`: a one-slot reader
pool behind a loopback proxy that goes dark mid-session; the collection
is cut at a deadline, then a later acquire must succeed in under 8s on a
fresh connection. The dark call runs on a fresh `Db` with a cold Aurora
capability cache, so the first post-acquire await is the capability
probe. It passes in 3.4s. With `close_on_drop` removed, or moved below
the probe await, it fails with `PoolTimedOut` after the 15s acquire
timeout. The other three `fleet_` PostgreSQL tests that use scratch
databases pass.
- `each_fleet_family_is_abandoned_at_its_own_deadline` now has an outer
timeout: replacing the stock deadline with a bare `.await` fails it
(`Elapsed`) instead of hanging.
At head `fd194ec79d6c6796d6764564de225ad56485cfc7` (tree `79e94727`,
main merged at `932228936`), on a Blox workstation:
- `cargo fmt --all -- --check` and `cargo clippy -p buzz-relay -p
buzz-db --all-targets -- -D warnings` pass.
- Relay bin `tests::` nextest selection: 27/27 pass (plus
`store::usage::tests`), including
`fleet_refresh_outcomes_drive_availability_through_production_mapping`
and the new `stalled_fleet_collection_times_out_into_failed_refresh`
(paused time: two never-completing collections are abandoned at exactly
10s + 20s; both families read unavailable with cached values kept,
`reason="timeout"` skips, retry scheduled; the next tick runs and
recovers; no route decision is recorded for an abandoned attempt) and
`each_fleet_family_is_abandoned_at_its_own_deadline`. Compile-time
asserts keep each deadline above its `statement_timeout`.
- Falsifiability: replacing the stock deadline with a bare `.await`
makes the new test fail (the tick stalls); swapping the two deadlines
fails the per-family test; deleting `schedule.mark_stock_failure(now)`
from the `Ok(None)` arm fails the mapping test. Trees restored and
re-verified.
- At `970414db`: CI PostgreSQL Tests 809/809 including the
production-method matrix with `reader/maintenance`; Unit Tests ran
`finish_usage_read_records_every_attempt_in_route_decisions`.
- `just ci` / `just test` at `5b18914a` on Blox: not green for
environmental reasons (`mobile-check` could not reach pub.dev;
`_ensure-services` failed so service-backed suites did not run;
`buzz-acp git_bootstrap` fails identically on `origin/main`). PostgreSQL
and integration coverage comes from GitHub CI at this head.
E2E at the previous head `0a01e5f22` (isolated relay, real Postgres
primary plus streaming replica, worker storage snapshot, desktop
frontend in relay mode): with no reader, both families report
skipped/unavailable and route `skipped/disabled` (no writer queries);
fleet-only storage emits totals without `buzz_community_storage_*` or
unmapped bytes; `all` mode reports both availability gauges `1` with
per-community storage series; with a fresh replica, both fleet paths
route `replica/fresh` on `reader/maintenance` acquisitions; desktop
send/receive works. The cached success → due failure → recovery
transition was not reached live (hourly/daily cadence); it is covered by
the seam test above.
The original branch also recorded live verification at `aa3e7023`,
before this rework: reduced-idle cache re-emission, two-relay leader
takeover with demoted availability clearing, the no-reader skip with
60-second retry, `all` rollback restoring per-community series, and a
desktop smoke test. Those runs predate the availability and storage
changes and are not claimed for this head.
## Linear tracking
Related work: BUZZ-167.
---------
Signed-off-by: tornquist <tornquist@squareup.com>
Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
29 KiB
Buzz Helm Chart
Buzz is a Nostr-based messaging platform for human–agent collaboration: a single relay binary serving WebSocket + REST + web UI, backed by PostgreSQL, Redis, and S3-compatible object storage.
This chart has two operating profiles selected by values:
| Profile | When | What you get |
|---|---|---|
| Production (default) | Self-hosted multi-tenant, regulated, or GitOps-managed | External managed Postgres/Redis/S3, secrets.existingSecret:, no chart-side autogen, HA-capable (replicaCount ≥ 2) |
| Quickstart (eval) | Eval, single-node, one-off demo | In-cluster Postgres + Redis + MinIO subcharts/Deployments, chart auto-generates relay + service secrets, single replica |
Quickstart (eval only)
helm install buzz oci://ghcr.io/block/buzz/charts/buzz --version 0.1.10 \
--create-namespace --namespace buzz \
--set quickstart=true \
--set postgresql.enabled=true \
--set redis.enabled=true \
--set minio.enabled=true \
--set relayUrl=wss://buzz.example.com \
--set ownerPubkey=<64-char-hex-pubkey>
This brings up everything in-cluster — Postgres, Redis, and MinIO (with
its bucket created by a post-install Job) — and composes the relay's
BUZZ_S3_ENDPOINT plus autogenerated credentials automatically. No external services required. The quickstart=true flag is an
intent marker surfaced in NOTES.txt; the bundled services are opted in via the
four *.enabled flags above (see ci/quickstart-values.yaml for the exact set
CI installs). Eval-only: every bundled service is a single replica with no HA.
For immutable delivery, pin the OCI digest instead of a tag. image.digest
overrides image.tag when both are present:
image:
repository: ghcr.io/block/buzz
digest: sha256:<64-lowercase-hex-characters>
Production (GitOps)
The chart is designed for ArgoCD and Flux. Both render charts with helm template, in which mode Helm's lookup function returns empty — any chart-side randAlphaNum call would regenerate secrets on every sync. The chart-managed Secret path is only safe for helm install / helm upgrade.
Production deploys MUST use secrets.existingSecret:. The Secret is consumed for any keys present and ignored for keys missing — extras are harmless.
To enable relay-proxied KLIPY search, add BUZZ_KLIPY_API_KEY to that Secret.
The key stays in the relay pod; clients discover the public buzz-gif
extension and gif descriptor in NIP-11, then receive KLIPY-hosted media URLs.
See docs/gif-search.md for the protocol and
security boundaries.
See:
examples/argocd-app.yaml— ArgoCD Applicationexamples/flux-helmrelease.yaml— Flux HelmRelease v2examples/secret-sample.yaml— Secret schema
Required inputs
| Key | What | When required |
|---|---|---|
relayUrl |
Public wss:// URL clients connect to |
Always |
ownerPubkey |
64-char lowercase hex Nostr pubkey of the relay operator | When relay.requireRelayMembership=true (default) |
secrets.existingSecret |
Name of pre-created Secret | Production / GitOps |
externalPostgresql.url / externalRedis.url / s3.endpoint |
External service URLs | Production — when the matching bundled service is disabled (the default) |
The chart fails at helm install / helm template time with a clear message if any of these are missing or malformed (see templates/_validate.tpl).
S3 URL addressing
Buzz uses one URL style for both media and Git/CAS object-store requests:
s3.addressingStyle |
Request shape | Use for |
|---|---|---|
path (default) |
https://endpoint/bucket/key |
Bundled MinIO and endpoints whose DNS does not resolve bucket subdomains |
virtual |
https://bucket.endpoint/key |
AWS-style providers and new Railway Storage Buckets |
The chart always renders s3.addressingStyle as
BUZZ_S3_ADDRESSING_STYLE and s3.region as BUZZ_S3_REGION. The region
defaults to us-east-1, keeping bundled MinIO and the in-pod
buzz-admin deletions workflow operable without an ambient AWS_REGION.
Production providers must set their credential region explicitly when it
differs. Existing releases that previously omitted s3.region will begin
rendering BUZZ_S3_REGION=us-east-1 after upgrade, even if an image or
relay.extraEnv entry supplied AWS_REGION; set s3.region to the provider's
actual credential region before upgrading. Only path and virtual addressing
styles are accepted; invalid
values fail chart rendering and relay startup. The bundled MinIO quickstart
deliberately keeps path because its Service DNS resolves one endpoint
hostname, not arbitrary <bucket>.<service> names.
For a Railway Storage Bucket, map its variables to chart values in the service or generated Helm configuration:
s3:
endpoint: "${{Object Storage.ENDPOINT}}"
bucket: "${{Object Storage.BUCKET}}"
region: "${{Object Storage.REGION}}"
addressingStyle: virtual
Store BUZZ_S3_ACCESS_KEY=${{Object Storage.ACCESS_KEY_ID}} and
BUZZ_S3_SECRET_KEY=${{Object Storage.SECRET_ACCESS_KEY}} in the Secret named by
secrets.existingSecret. Railway's Credentials tab is authoritative for older
buckets, which may still require path. The setting changes request routing and
SigV4 signing, so do not put the bucket into s3.endpoint; pass Railway's base
ENDPOINT and BUCKET separately.
Object storage is contacted during relay startup only when
BUZZ_GIT_CONFORMANCE_PROBE is enabled (the relay default). A probe failure is
startup-fatal, so the process exits and Kubernetes readiness never opens. If an
operator explicitly disables that probe through relay.extraEnv, configuration
is still parsed strictly, but reachability and addressing errors surface on the
first storage operation. /_readiness tests no external dependency in either
case — see the readiness contract below.
Community deletion operator job
operatorJobs.deletionDrain is a disabled-by-default, typed CronJob for
/usr/local/bin/buzz-admin deletions drain. It runs inside the relay image with
bounded Job lifetime/history, concurrencyPolicy: Forbid, backoffLimit: 0,
and no relay HTTP call. Postgres deletion requests, leases, retries, and
checkpoints remain the execution authority.
The pod receives only DATABASE_URL, REDIS_URL, and required S3
configuration/credential variables. It does not receive the relay private key,
git-hook secret, relay URL, service links, or a generic environment registry.
The chart disables the ordinary Kubernetes API service-account token mount;
platform workload-identity admission may still inject its own projected token
and provider environment variables. Schedule,
deadline, history, termination grace, resources, service account, pod labels,
and pod annotations are independently configurable under
operatorJobs.deletionDrain.
See docs/operator-community-deletion.md
for enablement, permissions, the staffed first-run procedure, failure recovery,
and the current explicit approval/alerting boundaries.
Early-startup telemetry contract
buzz_process_lifecycle JSON records are the authoritative history for the
fixed phases crypto_init, tracing_init, config_load, key_load, and
metrics_bind, plus the aggregate process_telemetry result. They use bounded
status/reason values and never contain raw configuration, keys, URLs, or errors.
These phases intentionally do not emit metrics. Most run before the Prometheus
exporter exists, and one uniform log-only contract preserves every phase's real
event time and failure without assigning an eventual scrape time to earlier work.
Startup work after metrics_bind (database connect through public listener
bind) runs with a live recorder, so each step logs Startup phase started and
Startup phase finished (phase, elapsed_ms, and status =
succeeded/degraded/failed, the same values lifecycle records use) and sets
two gauges labelled phase:
buzz_startup_phase_currentis1while the step runs and0once it ends. A pod that has not opened its health port shows the step it is stuck on.buzz_startup_phase_secondsis the finished step's duration.
degraded means the step hit a non-fatal error and startup continued — for
example, some communities failed NIP-43 reconciliation or a NIP-FI issuer did
not warm. failed means startup aborted inside the step.
These are gauges, not counters, because each value is written once per boot
and a monotonic-counter scrape drops a series' first sample. Each is written
once, so it ages out after the gauge idle timeout: the idle_timeout_secs
field of the Prometheus metrics exporter started log (2700 s with default
intervals). A step that runs longer than that timeout loses its _current
series, so a pod stuck that long shows no current step. The phase values are
the closed StartupStep vocabulary in crates/buzz-relay/src/startup_steps.rs;
they never reuse an early lifecycle phase name. The NIP-43 startup reconcile
also logs NIP-43 startup reconciliation progress every 5,000 communities.
Readiness contract
/_readiness reports local process lifecycle only. It performs no
Postgres, Redis, or deletion-catalog I/O: shutting_down returns 503, and any
other state returns 200. Shared dependencies are shared by every replica, so
gating the probe on them removed the whole deployment from the load balancer at
once and left a reconnect burst with nowhere to land. There is no separate
"starting" state — the health listener does not bind until the database,
migrations, Redis, and pub/sub are up, so a process that can answer has booted.
Shared-dependency health moved to /_status on the same private health
listener, under a dependencies object carrying the postgres, redis,
deletion_catalog, and aggregate reason fields the readiness body used to
return. Do not wire /_status to a Kubernetes probe.
/_status is a cached read. It performs no Postgres, Redis, or
deletion-catalog I/O of its own. One background loop per pod evaluates the
three dependencies every 30 seconds, awaiting each evaluation before taking
the next tick, so a pod never has more than one evaluation in flight no matter
how often — or how rarely — the endpoint is read. The loop publishes the
dependency metrics below and caches the report /_status serves. Its first
cycle runs at startup, and each evaluation is bounded by a two-second budget.
Every dependencies object therefore states how old its report is:
| Field | Meaning |
|---|---|
sample: "not_yet_sampled" |
the first cycle has not completed; no postgres/redis/deletion_catalog/reason fields are present, because there is no observation to report |
sample: "fresh" |
the report is at most two cadences (60s) old |
sample: "stale" |
the report outlived two cadences, so the sampler missed at least one cycle — read the verdict as history, not as current state |
sample_age_seconds |
age of the report at request time (absent when not_yet_sampled) |
sample_interval_seconds |
the sampling cadence, 30 |
Polling /_status more often than the cadence returns the same cached report;
it does not make the data fresher and adds no dependency load.
Readiness telemetry contract
Only requests served by the private health listener (BUZZ_HEALTH_PORT) emit
rollout telemetry. The compatibility /_readiness route on the public app
listener returns the same lifecycle answer but does not change these metrics.
| Metric | Type | Labels | Source |
|---|---|---|---|
buzz_readiness_checks_total |
counter | reason ∈ {ready, shutting_down} |
/_readiness |
buzz_readiness_state |
gauge | check="overall"; latest private probe observation, 1 ready or 0 shutting down |
/_readiness |
buzz_readiness_dependency_checks_total |
counter | dependency, typed bounded outcome |
dependency sampler |
buzz_readiness_check_duration_seconds |
histogram | check only |
dependency sampler |
buzz_readiness_dependency_sample_completed_timestamp_seconds |
gauge | none; Unix time the cached report completed | completion publisher |
The three dependency families keep their buzz_readiness_* names for dashboard
continuity, but nothing about them is request-driven any more: the 30-second
sampler publishes them whether or not anyone reads /_status, so a quiet
endpoint no longer produces a flat dashboard during the outage it exists to
explain.
buzz_readiness_dependency_sample_completed_timestamp_seconds carries when
the cached report completed, in Unix seconds. The relay sampler is the only
owner allowed to advance that epoch, and it writes it immediately after the
cache is replaced. A separate bounded publisher re-emits the stored epoch often
enough to survive local gauge idle-timeout; republishing never advances the
timestamp. If a republish races a newer completion, one scrape can briefly see
the older epoch, but the republish path verifies after writing and repairs to the
newer stored epoch before that republish call returns. So the value stands still
when sampling stops, and the time elapsed since it was written is whatever the
reader computes at read time.
Following the buzz_storage_sweep_age_seconds convention, the series is not
emitted until the first sample completes: its absence means "not yet sampled",
not "fresh".
That is the whole server-side contract. Freshness alerting is built from this gauge in the monitoring provider, and the monitor query, thresholds, and per-pod tag grouping belong with the deployment's monitor configuration rather than in this chart — they depend on the provider's query grammar and on the tags its agent attaches, neither of which this repo owns.
Two properties of the neighboring families are worth knowing when building
that alerting. buzz_readiness_dependency_checks_total and
buzz_readiness_check_duration_seconds are cumulative, so a sampler that stops
leaves their last values exported and scraped indefinitely: those series stay
present and flat rather than disappearing. And per-report freshness for a human
reading a single pod is already on the sample, sample_age_seconds, and
sample_interval_seconds fields of /_status above.
The schema has a ceiling of 87 raw Prometheus series per pod: 2 probe reasons, 11 valid dependency/outcome pairs, 72 histogram series, and 2 gauges (overall lifecycle + completion timestamp). Do not add pod, ReplicaSet, version, rollout, error text, SQL, URL, tenant, user, community, pubkey, header, query, or other request-controlled labels. A readiness probe records no dependency attempt or latency sample at all. The readiness gauge is not a monotonic lifecycle mirror: shutdown changes the authoritative lifecycle flag, and the next private readiness probe observes and publishes that state.
Community admission telemetry
| Metric | Type | Labels |
|---|---|---|
buzz_community_admission_checks_total |
counter | outcome ∈ {active, inactive, check_error} |
Counts the durable community-active check run before a socket is admitted.
Admission is fail-closed: only active serves. inactive (a confirmed
archival answer) and check_error (the lookup itself failed, so the tenant
lifecycle is unknown) both refuse the socket before any AUTH or REQ frame is
read; the client sees an ordinary dial failure and retries. The two outcomes
stay distinct so a rise in check_error reads as database pressure rather than
archival. outcome is the only dimension — community and error text are
request-controlled and must never become labels.
Operation-aware database pool acquisition contract
The operation-aware families separate four questions: when an operation asked for a connection, who is waiting now, how completed/abandoned attempts ended, and how long checkout waits took. Outcome remains on the terminal counter for historical deployment comparison; it is intentionally absent from the expensive duration histogram.
These families cover the explicitly routed deployment-critical operations listed below; they are not a count of every SQLx checkout in Buzz. In particular, a zero operation waiter does not prove that the shared SQLx pool has no uninstrumented waiter. Interpret it beside the pool active, idle, and maximum gauges when diagnosing total capacity pressure.
| Metric | Type | Labels |
|---|---|---|
buzz_db_pool_acquire_started_total |
counter | pool_role, operation |
buzz_db_pool_acquire_duration_seconds |
histogram | pool_role, operation |
buzz_db_pool_acquire_attempts_total |
counter | pool_role, operation, outcome |
buzz_db_pool_waiters |
gauge | pool_role, operation; tracked operations only, periodically refreshed including zero |
Outcomes are success, timeout, error, and cancelled. Operations are
bootstrap, readiness, tenant_resolution, authentication,
authorization, subscription_history, event_write, and maintenance.
Only the following twelve pairs are valid:
writer/bootstrap reader/bootstrap
writer/readiness
writer/tenant_resolution
writer/authentication
writer/authorization reader/authorization
writer/subscription_history reader/subscription_history
writer/event_write
writer/maintenance reader/maintenance
Nine finite checkout buckets plus +Inf, sum, and count yield 12 histogram
series per valid pair. The new contract therefore has a hard ceiling of 216
raw Prometheus series per pod: 12 × (1 + 12 + 4 + 1). The two legacy acquisition
families remain temporarily for dashboard compatibility and are not part of
that new-family budget. No other operation or request-controlled/sensitive
label is valid.
Writer connection setup contract
Writer connection setup is separate from checkout. At boot, SQLx constructs
the writer pool and creates its minimum physical connections. Later it may
create more when the pool grows or replaces a broken or expired connection.
Every connected writer session must install the created-at floor, install the
session timeouts, verify READ COMMITTED isolation, and reach ready before SQLx
can give it to a caller.
| Metric | Type | Labels |
|---|---|---|
buzz_db_connection_step_started_total |
counter | pool_role, step |
buzz_db_connection_step_duration_seconds |
histogram | pool_role, step |
buzz_db_connection_step_attempts_total |
counter | pool_role, step, outcome |
The fixed steps are writer_pool, physical_connect, created_at_floor,
session_timeouts, isolation, and ready. Measurable phases emit start,
duration, and terminal evidence. physical_connect and ready are success
milestones: SQLx 0.9 exposes after_connect only after DNS, network, TLS, and
authentication finish, so Buzz does not invent separate timings for those
internal phases. Raw connect failures before after_connect are classified on
the aggregate writer_pool phase during initial construction. Outcomes are
succeeded, failed, timed_out, and cancelled where valid.
For a measurable phase on one pod, subtract all terminal outcomes from its start counter to derive the number of attempts currently in progress. The session phases run in order, so a later phase start also proves the earlier phases succeeded for that attempt.
Four start counters, four 13-series histograms, and fifteen terminal counters create a hard ceiling of 71 raw Prometheus series per pod. Connection ordinals, database URLs, hosts, usernames, SQL, and raw errors are forbidden as metric labels.
When audit logging is enabled, the buzz_db_connection_* metric totals combine
the main writer pool and the separate audit writer pool.
Physical database pool utilization and configuration contract
Every physical Postgres pool the relay owns reports current utilization,
capacity, and configuration under role-labelled families. The role vocabulary
is closed — writer, reader, audit, search — and all four roles are always
present, so a series never appears or disappears when an optional pool is
enabled or disabled. An unconfigured optional pool reports
buzz_db_pool_configured 0 with zero utilization and capacity rather than
going missing.
| Metric | Type | Labels |
|---|---|---|
buzz_db_pool_connections |
gauge | pool_role, state |
buzz_db_pool_max_connections |
gauge | pool_role |
buzz_db_pool_configured |
gauge | pool_role; 1 configured, 0 not configured |
States are idle and active; their sum is the pool's current size.
buzz_db_pool_max_connections reports capacity separately, so summing the
connections family counts each current connection exactly once. The three
families are fixed at 16 raw Prometheus series per pod (4 × 2 current
utilization plus 4 maximum capacity plus 4 configured). The audit and
search roles are statistics-only: pool ownership, capacities, timeouts, and
query routing are unchanged, and these roles emit no acquisition telemetry.
The pre-existing unlabelled buzz_db_pool_* (writer) and buzz_db_read_pool_*
(reader) gauges are still exported unchanged for dashboard compatibility. The
reader family remains absent when no read replica is configured; use
buzz_db_pool_configured{pool_role="reader"} for the explicit signal.
Pool sizing and the aggregate connection budget across all four roles remain deployment configuration concerns. The relay does not enforce a combined connection ceiling.
Relay Pod extensions
The chart exposes narrow extension points for init containers, volumes, relay
volume mounts, and image command/argument overrides. extraManifests creates
independent Kubernetes resources but cannot modify the chart-managed relay
Deployment. These extension values insert fields into that Deployment, avoiding
duplication of its environment, probes, security context, secrets, and
chart-owned volumes.
For example, an init container can copy a wrapper binary into a shared volume and make that wrapper the relay entrypoint:
extraInitContainers:
- name: install-wrapper
image: example.com/wrapper-init:v1
args: [/opt/wrapper/wrapper]
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
resources:
requests:
cpu: 10m
memory: 16Mi
volumeMounts:
- name: wrapper
mountPath: /opt/wrapper
extraVolumes:
- name: wrapper
emptyDir: {}
relay:
command: [/opt/wrapper/wrapper]
args: [/usr/local/bin/buzz-relay]
extraVolumeMounts:
- name: wrapper
mountPath: /opt/wrapper
These values are raw Kubernetes fragments rendered with toYaml, not tpl.
The chart does not validate cross-field relationships: extension names must not
collide with chart-owned containers or volumes, mounts must reference existing
volumes, and each init container must define an appropriate security context
and resources. Empty relay.command and relay.args arrays preserve the image
defaults; non-empty values override its entrypoint and arguments respectively.
Device pairing relay
The chart can run Buzz's stateless pairing WebSocket relay as an independent Deployment and Service using the same image as the main relay:
pairingRelay:
enabled: true
url: wss://pairing.example.com
pairingRelay.url is advertised in the main relay's NIP-11 document so Buzz
clients connect directly to the dedicated endpoint. The chart does not create
an Ingress or HTTPRoute for the pairing Service; route the public hostname to
<release>-buzz-pairing:5000 with your platform's ingress configuration.
HA (production)
replicaCount > 1 hard-requires Redis:
- Redis (
redis.enabled=true,externalRedis.url, orREDIS_URLinexistingSecret) — forbuzz-pubsubfan-out
It does not require ReadWriteMany git storage. Git ref/object state is object-store-backed (each request hydrates an ephemeral repo from S3-compatible storage; writer serialization is the object-store pointer CAS — see docs/git-on-object-storage.md), and repo-name uniqueness lives in Postgres. Each replica can use its own ReadWriteOnce volume; no shared filesystem is needed.
The chart template-fails if the Redis invariant is broken at replicaCount > 1. No silent degradation.
Relay autoscaling
The optional HPA scales the relay on the larger recommendation from CPU or average active WebSockets per pod:
autoscaling:
enabled: true
minReplicas: 5
maxReplicas: 15
targetCPUUtilizationPercentage: 65
websocketMetricEnabled: true
websocketMetricName: buzz_ws_connections_active
targetWebsocketConnections: 5000
CPU scaling requires Kubernetes Metrics Server. Set websocketMetricEnabled: false
for a CPU-only HPA. WebSocket scaling additionally requires a custom-metrics
adapter (for example Prometheus Adapter) configured to expose the relay's
buzz_ws_connections_active gauge as a pod metric with the name in
websocketMetricName. The chart creates the HPA but deliberately does not
install or configure a cluster-wide metrics adapter. Scale-down is gradual by
default so long-lived WebSocket connections have time to drain.
Upgrades
Schema migrations are embedded in the relay binary via sqlx::migrate! and run at startup, gated by BUZZ_AUTO_MIGRATE (default true). Multiple replicas race-safely behind a Postgres advisory lock. helm upgrade is the entire upgrade procedure.
Migration 0032 is a hard compatibility boundary for relay versions that publish repaired channel rosters. The relay verifies the roster-fence trigger catalog and behavior before opening listeners and refuses to start if 0032 is missing or inert. Apply migrations before rolling the relay; for large installations, prefer a controlled buzz-admin migrate job with PostgreSQL lock monitoring before the code rollout.
If you prefer decoupling migrations from serving, set migrate.autoMigrate=false. In that mode the chart does not run migrations for you — you own running buzz-admin migrate (separate Pod / one-shot Job) against the database before every helm install / helm upgrade. Readiness probes only verify DB connectivity, not schema freshness, so a pod will appear healthy against an unmigrated schema and fail under load. A pre-upgrade Helm Job for this is on the chart roadmap; the values knob migrate.preUpgradeJob.enabled is reserved.
Backups
Save these. Losing any of them is data loss. See NOTES.txt printed by helm install for the live list:
BUZZ_RELAY_PRIVATE_KEY— relay identity. Rotating it = new identity (federation peers will not recognize the relay).- PostgreSQL database — the canonical event store.
- S3 bucket — media blobs (chart default bucket:
buzz-media). - Git PVC — repo on-disk state served by the relay's git endpoint.
- Owner private key — held by the operator, not by this chart. Restore by re-installing with the same
ownerPubkey.
Honest limitations (v1)
- Bundled MinIO is eval-only. The quickstart profile runs an in-cluster
MinIO (single replica, no HA,
lookup-autogenerated credentials) so the relay starts with zero external object storage. Production leavesminio.enabledoff and pointss3.endpoint(orBUZZ_S3_*inexistingSecret) at managed S3-compatible storage. The bundled Deployment is not GitOps-safe and is not intended for production traffic. - Minimal-mode is not yet supported. The relay's
BUZZ_PUBSUB=local/ filesystem media paths are upstream work in progress — even quickstart currently stands up real Redis and S3 rather than the relay's single-node fallbacks. (Full-text search already runs in Postgres, so no separate search service is provisioned.) - Cosign signing of the published chart is a follow-up (the relay image is
attested via
actions/attest-build-provenance; the chart is not yet). The chart itself is published to GHCR — see Releasing.
Releasing
The chart is published to GHCR as an OCI artifact at
oci://ghcr.io/block/buzz/charts/buzz by the helm chart workflow
(.github/workflows/helm-chart.yml), versioned independently of the desktop app
and the relay image via its own chart-v* tags. Every PR/main push still
lints, unit-tests, and render-checks the chart; only a chart-v* tag publishes,
so an in-progress main can never overwrite a released version.
To cut a release, push a chart-release/<version> branch whose <version>
matches Chart.yaml's version; merging it auto-tags chart-v<version> and
dispatches the publish job (same lane machinery as the desktop and relay
releases — see .github/workflows/auto-tag-on-release-pr-merge.yml). The publish
job fails loudly if the tag version and Chart.yaml version disagree.
Development
# Render every fixture
for f in ci/*-values.yaml tests/fixtures/*-values.yaml; do
helm template buzz . -f "$f" >/dev/null && echo "ok: $f"
done
# Unit tests
helm plugin install https://github.com/helm-unittest/helm-unittest
helm unittest .
# Lint
helm dependency build .
ct lint --config ../../../ct.yaml --charts .