M1-72: Load tests and performance gate of the server core (ADR 0063)¶
Status: ready | Depends on: M1-57, M1-65, M1-71 | Milestone: M1
Goal¶
After this ticket make perf measures every performance target of the server core on a recorded
host profile, compares each value with its named threshold from 02 "Performance targets", writes
value and threshold per target and fails when one is missed. Chaos tests prove that losing the
LISTEN connection, restarting the database during the log tail and killing the server during
commits leave no gap and no partial change. This closes exit criterion 7 of M1 and turns the
capacity start values of 16 into measured facts.
Read first¶
docs/spec/02-architecture.md: "Event path" ("Log tail per api process", "Hub and delivery"), "Start and graceful shutdown" ("Health"), "Scaling path", "Performance targets", "Open", "Configuration values of this chapter" (allperf.*rows; "Performance targets (perf.*) are thresholds for tests, not runtime configuration").docs/spec/04-sync-protocol.md: "Global, commit-ordered sequence" ("Pattern with a counter row"), "Lifecycle", "Catch-up", "Delivery guarantee", "Ordering".docs/spec/03-data-model.md: "Transaction rules", tablesevent_log,sequence_counters,messages,threads,idempotency_keys,audit_events.docs/spec/07-permissions.md: "Query pattern" rule 5 (large visible sets).docs/spec/16-operations.md: "Two listeners", "Database" ("Roles"), "Capacity and resources", "Configuration values of this chapter" (database.max_conns, start value until the load test).docs/observability/signals.md:bothy.delivery.duration,bothy.db.event_seq.lock_wait,bothy.db.pool.connections.- ADRs 0009 (Confirmation), 0004, 0063 "Performance gate for the event log" (profile modes in its decision, point 3).
docs/PLAN.md: "Way of working per ticket" item 6, "Closing a milestone" item 3, M1 "Exit criteria" item 7, rows M1-16, M1-28 and M1-72, decisions P-07, P-09, P-10, P-12.- Operations:
createMessage,listEvents,getSnapshot,verifyAuditChain,createSpace,createChannel,createThread,createGrant,createUser,redeemEnrollmentCode,refreshToken; gateway opshello,identify,ready,heartbeat,heartbeat_ack,dispatch,reauth. - Code:
perf/event_commitand theperftarget from M1-16,BenchmarkAuthorizeCachedfrom M1-28,internal/platform/pg/pgtest(M1-12),internal/synclog tail andLISTEN(M1-16),cmd/bothy(serve,migrate,setup,secrets).
Fixed requirements that apply¶
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
- Test first: every unit test is written and seen failing before the code that makes it pass.
- No en dash and no em dash in any file; white label.
- Configuration only through named keys from
docs/operations/configuration.md; thresholds only by theirperf.*names, never as literals in code or docs. - 02 "Performance targets":
make perfmeasuresperf.message_create_p99underperf.sustained_messages_per_s,perf.fanout_local_p99,perf.catchup_page_p99,perf.authorize_cached_p99,perf.ws_connections_per_node,perf.ws_idle_conn_mem_max,perf.idle_rss_max,perf.startup_ready_maxand keepsperf.event_commit_min_per_sfrom M1-16. - P-07:
make perfruns at the milestone close and the release, not in the pre-push gate; values are valid onperf.reference_profile(ADR 0063). - 04 "Pattern with a counter row" and "Delivery guarantee": no gap in
seq, no lost or duplicated delivery after deduplication, order equals commit order. - PLAN "Way of working per ticket" item 6: no ticket lowers a threshold.
Scope¶
services/server/perf/(build constraint//go:build perf, or the tag M1-16 chose; one tag for all perf and chaos code):load_messages/,gateway_fanout/,catchup/,ws_soak/,startup/: one test package each.internal/report/(untagged, unit tested inmake test): threshold loader, units, percentile, profile detection, results writer. Move M1-16's own reporting ofperf/event_commitonto it so there is one results file and one table.README.md: what each test measures, how, its parameters (named constants), how to read the results, profile modes.
services/server/test/chaos/(same tag):listen_loss_test.go,db_restart_test.go,sigkill_commit_test.go,invariants.go,README.md.BenchmarkAuthorizeCached(M1-28): if it does not report a p99, extend it with a minimal change in its test file to record per-operation durations andb.ReportMetric(p99, "p99-ns").internal/platform/pg/pgtest: additive options for perf and chaos: resource limits for the container (--cpus,--memory) and a restart that keeps the host port (pick a free loopback port first, publish it explicitly as127.0.0.1:<port>:5432, retry on a bind conflict). Every perf and chaos database usespgtest.Durable()from M1-16 (nofsync=off,synchronous_commit=off,full_page_writes=off; ADR 0063 point 2); the speed flags of M1-12 D7 stay for normal tests.- Shared test harness
services/server/test/harness(extend, text below); this ticket addsStartProcessand the fieldOptions.DurableDB bool, which passespgtest.Durable(); every perf and chaos test sets it. Makefiletargetperf(keep M1-01's frame): builds nothing into the repository; runscd services/server && go test -tags perf -count=1 -timeout $(PERF_TIMEOUT) ./perf/... ./test/chaos/...without-race; prints the result table; exits non-zero when any target fails or any chaos test fails.make checkcompiles and vets the tagged code (go vet -tags perf ./perf/... ./test/chaos/...inlint, golangci-lint with the tag) so it cannot rot between milestones.- ADR 0063 exists: extend its "Confirmation" and "More Information" with the M1-72 targets, the
chaos tests,
TestProfileModesandTestProfileRejectsNonDurablewithout changing the decision; runmake adr-index. - Documentation:
docs/quality/gates.md(a section "Not inmake check" withperfandportability),docs/spec/16-operations.md"Capacity and resources" and thedatabase.max_connsrow (Design notes),sonar-project.properties(services/server/perf/**insonar.test.inclusions),services/server/README.md,services/server/CHANGELOG.mdunder "Unreleased".
Shared test harness (the same interface in the briefs M1-69 to M1-72)¶
M1-71 (a dependency of this ticket) created services/server/test/harness with this interface.
Use it and extend it only additively; the parts this ticket adds are named in "Scope".
- Package
harness, non-test Go files, architecture componenttestsupport(M1-05). func Start(tb testing.TB, opts Options) *Server: the realinternal/wiregraph with roleallin this process; PostgreSQL 18 frominternal/platform/pg/pgtest; migrations asbothy migrate up --bootstrapruns them; secrets created asbothy secrets initcreates them, intb.TempDir(); public and internal listener on127.0.0.1:0; the first owner created through the same application service thatbothy setup bootstrap-ownercalls (08 "Initial installation"), then enrolled with its enrollment code and fresh ES256 device keys (08 "Enrollment code", "Registration"); everything stopped intb.Cleanupin reverse order.type Options struct { Config map[string]string; LogWriter io.Writer; Telemetry *TelemetrySinks; EgressDenyAll bool; Clock clock.Clock }.Configtakes dotted keys fromdocs/operations/configuration.mdand is applied throughplatform/configwith flag precedence, so an unknown key still fails.type Server struct { PublicURL, APIBase, GatewayURL, InternalURL, SecretsDir string; Owner *Client }withAPIBase = PublicURL + "/api/v1"andfunc (s *Server) AdminDB() *pgxpool.Pool(superuser of the disposable container; assertions only).func (s *Server) NewUser(tb, UserSpec{Handle, DisplayName, Email string}) *Clientandfunc (s *Server) NewAgent(tb, AgentSpec{Handle, DisplayName, BillingMode, CredentialHolder string}) *Client, only through the public API (createUserwithlocal_account: true, which returns the enrollment code, thenredeemEnrollmentCodewith device keys and DPoP, 08 section 3.2;createAgent,createToken), owner step-up viacreateStepUpChallengeandcompleteStepUpWithDeviceKey.type Client struct { PrincipalID, DeviceID string; ... }withDo(ctx, Request) Response(DPoP proof per RFC 9449 with the latestDPoP-Nonce, one retry onuse_dpop_nonce, refresh ontoken_expired,Idempotency-KeyUUIDv7 on POST and PATCH except the list in 06 "Idempotency", optionalObserver func(Request, Response)),StepUp(ctx),Gateway(ctx) *GatewayConn(hello,identify,ready,Next,Send,Close, automaticheartbeatandreauth).type Response struct { Status int; Header http.Header; Body []byte; Elapsed time.Duration }.func BuildMatrixWorld(tb, s *Server) *World: the M1 subset of 07 "Examples" and "Test matrix": spaces Support, Marketing, Engineering, Research; channels Support/Allgemein, Support/Eskalation, Marketing/Kampagnen, Engineering/Projekt-A (forum, no project binding), Engineering/Werkzeuge, Research/Notizen; people Alex, Sam, Robin; agents Lotse (organization, operator of Support), Texter (organization, operator of Marketing), Runner (organization, operator of Engineering/Werkzeuge), Assistent (personal, credential holder the owner, operator of Research); group Leser with member Sam (membership with expiry); grants G-1 (Alex,trigger,uploadandapprove_actions, space Support), G-2 (Sam,trigger,upload, space Marketing), G-3 (Robin,trigger,upload, channel Engineering/Projekt-A, with expiry), G-4 (group Leser,read, Support/Allgemein), G-5 (Lotse,write,upload, space Support); one thread with messages per text channel and one forum post in Projekt-A. Left out until their milestone: app Tracker and G-6 (M2), the DM Alex-Sam (V3), project binding and tool classhost(M2). Expiry times are relative to the harness clock, never calendar dates.func StartProcess(tb, ProcessOptions) *Process: builds./cmd/bothyonce per test binary into a temp directory; runsbothy secrets init,bothy migrate up --bootstrap,bothy setup bootstrap-owneragainst a pgtest container, thenbothy serve --role=allwithserver.listen_addrandserver.internal_listen_addron free loopback ports picked just before start (retry on bind conflict);ProcessOptions{Config map[string]string; Env []string; GOMAXPROCS int};ProcessoffersPID(),RSSBytes()(Linux/proc/<pid>/statusVmRSS, macOSps -o rss= -p <pid>),WaitReady(ctx)(polls/readyzon the internal listener),Signal(sig),Restart(ctx),APIBase,GatewayURL; log output into a file in the temp directory; stopped by PID intb.Cleanup.- Unit tests for every part you add (at least: DPoP proof claims and the nonce retry; RSS parsing).
Out of scope¶
- Targets of later milestones:
perf.fanout_cross_node_p99(M7 multi-node),perf.invocation_delivery_p99,perf.run_patch_fanout_p99(M2),perf.push_handoff_p95(M3). - Optimizations beyond what a missed M1 target requires.
- Changing any
perf.*threshold orperf.reference_profile. - The milestone-close run on the reference host itself (owner operation after merge).
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M1-72-performance-gate. - Conventional Commits with scope (
test(perf): ...,test(chaos): ...,perf(<module>): ...,docs(adr): ...) and DCO sign-off; description language as in the existinggit log. - Never push, tag, open pull requests or create remote repositories.
- Do not modify files outside this scope, except the docs and changelog entries named here, the shared harness, pgtest options, and fixes in M1 code needed to meet a target or to remove a chaos defect, each with a test and listed in the report.
- Follow the host rules supplied by the launcher. Stop only processes and containers you started,
by PID or container ID. Temporary directories only via
mktemport.TempDir(), removed at the end. - Never read or print secrets. No network calls except package and image downloads for pinned tools;
bind only
127.0.0.1; PostgreSQL as a disposable container. - Never lower a threshold, disable a gate or delete a test. A missed target on a host below the reference profile is reported as not proven, not "fixed" by changing numbers.
Design notes¶
Thresholds and units (perf/internal/report):
- Source: the rows
perf.*of the table in section "Configuration values of this chapter" ofdocs/spec/02-architecture.md(Ruling L4: chapter tables are the source; 02 saysperf.*are not runtime configuration, so they are not read from the config schema). Find the section by its title, not its number; a missing row or an unparseable value fails the run. - Units:
us,ms,sfor durations,KiB,MiB,GiBfor sizes, plain integers for counts and rates. Comparison by the meaning column: "target (minimum)" means value >= threshold, "target (maximum)" and plain "target" mean value <= threshold. - Percentiles: nearest-rank over all samples of the measured window; warm-up samples excluded.
- Results:
.cache/perf/results.jsonat the repository root (directory already used for coverage), schema{"profile":{"mode","cpus","memory_bytes","os","postgres_version","gomaxprocs","fsync","synchronous_commit"}, "results":[{"name","value","unit","threshold","comparison","pass","samples","test"}], "extra":{...}}, plus a table on standard output.extraholds supporting measurements: pool acquire wait p99,bothy.db.event_seq.lock_waitp99, database and index size per thousand messages, DB clock skew. - Profile: parse vCPU count and memory from
perf.reference_profile. Modematchwhen the host has exactly that many logical CPUs and at least that memory;emulatedwhen the host is larger: the server process getsGOMAXPROCSequal to the reference vCPU count and the PostgreSQL container--cpusand--memoryfrom the profile;belowwhen the host is smaller. Targets are checked in every mode and the mode is printed first and stored; onlymatchandemulatedcan end green,belownever does (ADR 0063, point 3). - Durability: before the first measurement
make perfreadsSHOW fsyncandSHOW synchronous_commitof every measured database and stores the values inprofile. A value other thanonends the run not green; the measurements are still printed and stored (ADR 0063, points 2 and 3).
Measurements (parameters such as durations, warm-ups, sender and receiver counts are named
constants in each package, documented in perf/README.md, and copied into results.json):
- Seeding at scale goes through the public API, never raw SQL: a seeding server
(
harness.StartProcesswith everyratelimit.*raised) creates users, devices, spaces, channels, threads and grants, then stops; the measured server starts afterwards on the same database with default configuration. Reason: real flows, default limits during measurement. load_messages(perf.message_create_p99): open-loop arrivals atperf.sustained_messages_per_sspread over enough senders that each stays withinratelimit.rl.message.*, into shared threads, while gateway receivers are connected. Latency of one send is measured from its scheduled send time (avoids coordinated omission) to the response on loopback, which bounds the server time from above. Any non-2xx response fails the test.gateway_fanout(perf.fanout_local_p99): connected receivers with read on the channel, sender at the sustained rate. Latency per event and receiver = receive time minus envelopeoccurred_at(transaction time, at or before commit, so an upper bound), corrected by the measured clock offset between the database and the host (bracketedSELECT clock_timestamp()samples). Every receiver must get every event exactly once inseqorder.catchup(perf.catchup_page_p99): a log with many events across many channels; the principal sees a fraction. MeasurelistEventswithlimit=sync.catchup_page_sizeper full page. Second variant: the principal sees a large number of channels (07 "Query pattern" rule 5 names this test); the same threshold holds.authorize_cached(perf.authorize_cached_p99): runBenchmarkAuthorizeCachedwith-run '^$' -bench '^BenchmarkAuthorizeCached$'and readp99-ns.ws_soak(perf.ws_connections_per_node,perf.ws_idle_conn_mem_max): server subprocess; openperf.ws_connections_per_nodeconnections, each a distinct seeded device (one device holds one connection, 04 "Endpoint and version"), each throughidentifytoready; keep them idle with heartbeats for at least twosync.heartbeat_interval. Pass: all connected, no close frame, every heartbeat acknowledged; memory per connection = (RSS with all connections after settling minus RSS after ready) divided by the count. CheckRLIMIT_NOFILEof the test process first and fail with a clear message if the hard limit is too low.startup(perf.startup_ready_max,perf.idle_rss_max): on a migrated database, repeated starts ofbothy serve --role=all; time from exec to the first200of/readyz; the maximum over the repetitions is compared; RSS after an idle settle period, maximum compared.event_commit(perf.event_commit_min_per_s): M1-16's test, reported through the shared report.database.max_conns: record pool acquire wait p99 and saturation duringload_messages. If the default holds the targets without saturation, remove "(start value until the load test)" from the 16 row and point "Capacity and resources" tomake perfandperf/README.md(no numbers in the chapter). If another default is needed, change the 16 row and the config schema in this ticket with the measurement in the report.
Chaos tests (test/chaos, all under load from several writers and with gateway receivers):
TestChaos_ListenConnectionLoss: terminate theLISTENbackend withpg_terminate_backend(identify it byapplication_name; if M1-16 set none, setbothy_event_listenininternal/syncin this ticket). Receivers still get every event in order; delivery resumes within the poll and backoff bounds (sync.log_poll_interval,database.listen_reconnect_backoff_cap).TestChaos_DatabaseRestartDuringTail: restart the pgtest container (stable port) while writers and the tail run. Writers retry failed sends with the sameclient_msg_iduntil success.TestChaos_SigkillDuringCommit: server subprocess under write load,SIGKILLat random moments (repetitions as a named constant), restart, writers retry unacknowledged sends with the sameclient_msg_id.- Invariants after each chaos test (
invariants.go, read-only queries throughAdminDB()):seqinevent_logis gapless and theevent_seqcounter equals its maximum; every message has exactly onemessage.createdevent and every such event has its message; per threadthread_seqis gapless andthreads.last_message_seqequals its maximum; every acknowledged send exists exactly once; noidempotency_keysrow is leftin_progress;verifyAuditChainreports a valid chain; every receiver saw a gapless, duplicate-free (afterseqdedupe) stream inseqorder.
Architecture register and coverage: test/** and perf/** under services/server form the
component testsupport (M1-05; 02 "Code layout"), and the Go coverage total leaves them out
(M1-01). Nothing to add here unless the build proves the component wrong.
Tests (write them first)¶
Unit tests (untagged, in make test):
perf/internal/report:TestThresholdsFromChapterTable(all M1 names found in a fixture copy and in the real 02),TestThresholdMissingFails,TestUnitParsing(100us,150ms,5s,64KiB,256MiB, plain counts),TestComparisonFromMeaning,TestNearestRankPercentile,TestProfileModes(match,emulated,belowfrom fixture hosts),TestProfileRejectsNonDurable(fixture settings withsynchronous_commit = offand withfsync = offeach end not green although every target passes),TestResultsJSONSchema,TestClockSkewCorrection.- Harness:
TestRSSParsing(Linux and macOS formats),TestStartProcessPicksLoopbackPorts. - pgtest:
TestRestartKeepsHostPort.
Tagged tests (in make perf): TestPerf_MessageCreateP99, TestPerf_FanoutLocalP99,
TestPerf_CatchupPageP99, TestPerf_CatchupPageP99LargeVisibleSet, TestPerf_AuthorizeCachedP99,
TestPerf_WSConnectionsPerNode (also yields perf.ws_idle_conn_mem_max),
TestPerf_StartupReadyAndIdleRSS, M1-16's event commit test, TestChaos_ListenConnectionLoss,
TestChaos_DatabaseRestartDuringTail, TestChaos_SigkillDuringCommit.
Commands that must pass:
cd services/server && go test -race -count=1 ./perf/internal/... ./test/harness/... ./internal/platform/pg/...
cd services/server && go vet -tags perf ./perf/... ./test/chaos/...
make perf
make check
Acceptance criteria¶
-
make perfmeasures every target named in requirement 9, writes.cache/perf/results.jsonand the table with value, threshold, comparison and pass per target, and the profile mode. -
make perfexits non-zero when one target is missed (proved by a unit test of the report exit logic with a failing fixture). - Thresholds come only from the 02 table; no
perf.*value appears as a literal in code. - Every perf and chaos database runs with
fsyncandsynchronous_commiton (pgtest.Durable());results.jsonstores both values, and a run with one of them off ends not green (TestProfileRejectsNonDurable). - The three chaos tests pass and check every invariant listed.
-
database.max_connsis confirmed or changed with evidence; 16 updated accordingly. - ADR 0063 exists and names the M1 targets and the profile rule;
make docspasses. - Tagged code compiles and passes vet and lint inside
make check. - On the build host every target passes; if the host mode is
below, the report lists missed targets as not proven instead of claiming them. -
make checkis green.
Definition of done¶
- Code and tests as above; README in
services/server/perf/,services/server/test/chaos/and the harness (updated for what this ticket adds); doc comment on every exported symbol. - ADR 0063 "Confirmation" extended with the M1 targets and the profile modes.
- CHANGELOG entry under "Unreleased" in
services/server/CHANGELOG.md. make checkgreen andmake perfrun with its table in the report.- Independent review and red team over the diff (
docs/quality/red-team-gate.md) with verdict recorded in the report; merge locally into the base branch only if both are green, thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}: first line Verdict: DONE or Verdict: BLOCKED <reason>; then commits,
files with line counts, unit test counts, the make perf table with profile mode and host data, the
chaos results, the extra measurements (pool, lock wait, index share), fixes made to meet targets,
what is not proven (at least: values on the reference profile if the mode was not match or
emulated), follow-ups that could not be done in scope with reason. Then write {{DONE_PATH}} with
exit=0 sha=<sha> or exit=1 <reason>.