docs/deploy/local-converge.md section 6): 3 edge, 2 core and
1 backup Debian 13 systemd node on one Proxmox LXC (16 GB, memory.high 12 GB), three
location networks joined by a router with tc netem (about 3 ms fsn1 to nbg1, 23 ms to
hel1, assumed values). Not staging: real servers, Hetzner Object Storage and bunny.net DNS
do not exist (inputs I4, I5, I6).
Result: phase 16 is not done. Only the first load run produced numbers that can be set
against the pass bounds. Every other drill is either blocked on an input or was not run to a
valid result, because the host machine stopped being usable as a test bed (details below).
Tooling
tests/drills/drillctl: setup (24 tenants, 40 sites, 24 OTLP tokens through the console API),loadwith a ledger of every acknowledged event,verify(every 202 looked up by path in Umami, OTLP sums inapi.metrics_minute),lag,probe(dashboard gap on both core nodes),commits(acknowledged-commit writer),snapshotandchecksum(table checksums at time T under write locks),clock(NATS/varztime and HTTPDate).- Load client: one Go process on the host. Web events solve the real proof; a connection failure moves to the next edge address (what a browser does with several A records); the tracker never resends an answered event. OTLP exports retry connection errors and 429, 502, 503, 504 up to 6 times. A DNS stand-in checks port 80 every 30 s and answers the healthy addresses; it has no resolver cache, no TTL and no multi-region checks, so its removal times are not bunny.net’s.
- Drill scripts:
tests/drills/{edge-loss,nats-member,db-leader,core-outage,partition,nats-restart,pitr,cert-renewal,clock-skew,rolling-deploy}.sh, load profiletests/load/run.shwithtests/load/profiles.env(web 50/s and API 116/s expected peak, factor 10).
Topology checks (pass)
site.ymlfrom fresh nodes, then a second run:changed=0on all six nodes.make local-converge-e2e:TestConvergedStackpasses.- Patroni: core 2 leader, core 1
Sync Standby,streaming, lag 0; live DCS config showssynchronous_mode: true,synchronous_mode_strict: false, ttl 30, loop_wait 10. - pgBackRest:
stanza-createandchecksucceed against the posix repository on the backup node over TLS (WAL archived). The schedules exist as timers (full Sunday 01:00, differential Monday to Saturday 01:00, verify plus check daily 05:30), the retain-web timer on both core nodes (03:17 and 03:47 UTC).
Drills
Blocked on inputs regardless of the host: bunny.net health-check removal timing (I6), the
S3 repository on Hetzner Object Storage including
repo1-s3-uri-style=host (I5), real
cross-location latency (I5), ACME issuance (I4, I6). The local substitutes (port-80 stand-in,
posix repository node, netem delays, Caddy’s local CA) are not equivalent.
What the load runs showed
- Consumers were the bottleneck, not the edge. Run A’s edge answered every request
within bounds. Draining is what failed. Measured drain rates while the backlog was
being worked off:
- first configuration: web about 77/s, OTLP about 150/s. The replayer’s 16 lanes each spend several confirmed NATS round trips per event (duplicate guard, visit state, ack). The metricswriter confirmed its 256 acks one after another, each a round trip to a consumer leader that can sit in another location (150 a second means about 6.7 ms each).
- Changes:
REPLAYER_WORKERS16 to 128 andREPLAYER_MAX_IN_FLIGHT512 to 900 (core role defaults), metricswriter acks sent 64 at a time after the batch commits (internal/metricswriter/writer.go). - after the change: OTLP about 1,417/s (above the 1,160/s load); web about 280/s, with Umami’s single Node process at about 65% of a core. At 10x the web load (500/s) outruns one Umami process. The backlog grows by about 220 events/s while the load runs, and drains at about 280/s once it stops. A 5-minute run then needs about 4 minutes to drain, inside the 600 s bound, but this was never measured cleanly, and longer peaks do not drain within the bound. The real fix is more Umami processes behind the replayer (Umami is stateless apart from the database). That is a design change for the next run.
- The 11 duplicate rows in run A come from restarting the replayer during the drain to apply the new settings. That is the documented at-least-once window (plan 4.4 step 16).
Web drain with several Umami processes (follow-up, 2026-11-13 05:09 to 05:14 UTC)
Change: each core node runscore_umami_processes Umami processes (default 4) on the one
database with one APP_SECRET (roles/core/templates/compose.yml.j2: umami plus
umami-2 to umami-N). UMAMI_URL of the replayer lists all of them, and the replayer
sends each visitor to Umami[visitorHash % N] with the same fnv-1a hash that picks the
visitor’s lane, so a visitor’s events go to one process, one at a time, in stream order.
Why the process does not matter: docs/reference/umami-contract.md item 14 (source and
TestUmamiProcessesAgreeOnSessionAndVisit).
Measurement: TestDrainRateByUmamiProcesses (internal/replayer,
REPLAYER_DRAIN_MEASURE=<events>). Not the 6-node topology: one NATS server, one
postgres:18.6 container, N Umami 3.5.0 containers each capped at one core
(NanoCPUs), one replayer in the test process with the core role’s 128 lanes and 900 in
flight. A backlog of 12,000 pageviews (2,000 visitors, 6 each) is published first, then
the replayer starts and the time until the stream is empty is taken; every run checks that
Umami holds exactly 12,000 views. All processes are warmed with 600 events each first.
The host was shared (load average 3 to 5).
Sends split evenly (6,000 events over 2 processes: 3,018 and 2,982). One process per run
always sat at the one-core cap while the others stayed below it; the cause was not found.
Because the drain ends only when the slowest process’s visitors are done, that process
sets the rate.
Reading: one process here (about 450 to 500/s at one full core) is faster than in run A
(280/s at 65% of a core), where every event paid cross-location NATS round trips on a
saturated host. The local numbers are therefore an upper bound for one process. Scaling
is 1.5x for 2, 1.85x for 3 and 2.3x for 4. Applied to run A’s 280/s that is about 520/s
for 3 and 650/s for 4, so the default is 4: the smallest count clearly above the
500/s of the 10x profile. Each idle process holds about 115 MB. The 10x drill has not been
rerun on the topology, so the 600 s drain bound is still not shown to pass.
Restart after power-off (follow-up, 2026-11-13)
tests/drills/start-unit.sh: one Debian 13 systemd node from infra/local (no other
node), converged with the base role and the edge role’s start_unit.yml (shared word for
word with the core role but for the project directory), running a test-only compose project
in /opt/veristar/edge with the pinned nats and etcd images and the edge
project’s restart policy. A power-off is docker kill of the node container (no shutdown),
followed by docker start.
Finding, fixed: the unit as phase 16 wrote it had systemd’s 90 s default timeout for a
oneshot.
docker compose start waits for each dependency’s health condition before it
starts a dependent, so on a core node (the replayer, controlplane and jobs wait for umami’s
health, 120 s start period) systemd killed it. Run with START_UNIT_TIMEOUT=90s (the old
behaviour put back by a drop-in) at 05:24 to 05:32: start operation timed out. Terminating. at 90 s, Result=timeout, client left exited. Both units now set
TimeoutStartSec=15min.
Local artifact, not a design finding: after a power-on of the node container, docker
attached its two networks in the other order in one of three boots (eth0 became the
private address). The base role’s firewall names interfaces, so ssh to the node then timed
out and a converge after that boot failed; the drill disables the unit over docker exec
for that reason. Cloud servers keep their interface names across reboots; the local
converge’s drills that converge after a power-on can hit this.
Why the host stopped the drills
The LXC is shared with other agents’ stacks and builds. During the runs the load average reached 1,129, available memory fell to about 600 MB with swap 78% used, and the LXC’s 12 GBmemory.high throttled every process in it. The 6-node topology needed about
5.7 GB. Freezes of more than 100 s hit every process, the load client included. Numbers
taken in that state measure the machine, not the design, so they are not reported as drill
results. Failure-mode drills at expected peak (factor 1) were attempted, and the first one
(edge loss) hit such a freeze. The topology was then removed to give the memory back.
Rerunning needs a host where the topology has about 8 GB to itself, or staging.
Fixes made from what ran
deploy/images/go-service.Dockerfilecopiesdocs/complianceanddocs/reference/openapi. Without them the platformctl and jobs images no longer built after phase 12.- Edge and core roles install
sa-{edge,core}-start.service, which runsdocker compose startafter Docker comes up. A start that fails during daemon startup is not retried by the restart policy. Verified by power-off on one node on 2026-11-13 (section “restart after power-off”), which also found that it needsTimeoutStartSec. bootstrap.ymlruns on the current Patroni leader (it ran oncore[0], which is a replica after a failover).- Core compose: the pgbackrest server container is created after postgres (two containers
creating one new volume raced). Repository
tls-server-authuses one line per client CN (the value after=lists stanzas).