M3-24: Push handoff performance target¶
Status: ready | Depends on: M1-72, M3-20 | Milestone: M3
Goal¶
After this ticket make perf measures the performance target perf.push_handoff_p95 on a recorded host profile, writes value and threshold and fails when the value exceeds the threshold: the time from the commit of a message that mentions a person to the moment Fake-APNs accepts the push for that person's device with 200 (13 section 9, t0 to t1; 02 17 "commit to handoff to the push service (fake)"; test perf/push_handoff). The measured interval holds the whole server path of a hint: the worker consumer push reading the log (ADR 0051), the candidates and facts, the pure decision, the job notify.push_send on queue delivery, the rights check at send time, the badge, the APNs adapter with its JWT and payload and the HTTP/2 request through the egress client. The test runs under the sustained design load perf.sustained_messages_per_s with the default rate limit of the measured class on a durable database, and it counts only when the path behaved correctly while it was measured: every mention in the window yields exactly one push per registration, with the right IDs, a badge and no text. This closes the push part of M3 exit criterion 3, the confirmation "Load test perf/push_handoff" of ADR 0051 and the delay that ADR 0051 accepts as a consequence; the target stops being "unchecked" (02 17).
Read first¶
docs/PLAN.md: section 3 "Way of working per ticket" (item 6:make perfin every ticket that touches a performance target, no lowered threshold); section 4 "Closing a milestone" item 3; section 5 "Test strategy" rows "Test doubles" (Fake-APNs) and "Load, endurance, chaos"; section 9 "M3 Platform services" (Goal, Exit criteria 3 and 4, Test strategy "fake APNs (HTTP/2 with TLS)", Red team "content in push payloads") with the rows M3-17 to M3-19 (what the measured path is made of), M3-20 (a dependency), M3-22 and M3-23 (beside it, not in its closure) and M3-24 (this ticket, the contract); section 7 rows M1-01, M1-16, M1-71 and M1-72 (a dependency); section 8 row M2-36 (the sibling load test); section 19 P-07, P-09, P-10, P-11. No ticket of the plan depends on M3-24; its result is the push part of M3 exit criterion 3.- Dependencies: the header names M1-72 (the perf frame: profile modes,
.cache/perf/results.json, the clock skew helper,Options.DurableDB; D-W-108 point 4) and M3-20. Implied through them and therefore not in the header: M3-19 (consumerpush,notify.push_send,push_registrations, the provider port, the badge of 13 8), M3-18 (putNotificationPreference), M3-17 (the decision), M2-01 (consumer frame), M2-09 and the M2 run chain below it, M1-38, M1-71 (test/harnesswithStart,Options,NewUser,AdminDB), M1-69, M1-68, M1-65, M1-56 (createMessage, mentions with M1-60), M1-53 (createChannel,main_thread_id), M1-48 (createGrant), M1-37 (createUserwithlocal_account,redeemEnrollmentCode), M1-33 (kernel/jose), M1-22 (platform/egress,Options.HTTP2of M3-20), M1-16 (perf/event_commit, the firstperf/internal/report,pgtest.Durable()), M1-14, M1-12 (pgtest), M1-09 (secret provider,filedriver), M1-07 and M1-08 (configuration), M1-01 (theperfframe target andPERF_TIMEOUT). M3-22 (registerPush) is not in the closure either; Design note 5 writes the registrations as fixture rows. docs/spec/02-architecture.md: 6 "Code layout" (perf/, componenttestsupport), 11.1 "Writing", 11.5 "Worker consumers (push, webhooks)", 12.2 "Queues" (delivery), 12.3 rownotify.push_send, 12.4 "Rules for jobs", 14.2 "Deadlines" (row "outbound calls"), 17 "Performance targets" (rowperf.push_handoff_p95), 22 "Configuration values of this chapter" (allperf.*rows,consumer.batch_size,consumer.poll_interval,jobs.queue.delivery.max_workers, and the sentence thatperf.*are test thresholds read bymake perf).docs/spec/13-push-notifications.md: 1 "Goals" (points 1 and 5); 2 "Kinds of notifications and recipients" (kindmention); 3.1 "Effective level"; 4 "Decision per recipient" (4.1 to 4.6, in particular 4.5 point 3:mentiongoes at once, and 4.6: a device without a live connection gets a push to each active registration); 5.1 "iOS and macOS: APNs with Notification Service Extension"; 6.1 "APNs" (the keys of the payload,badge,b.m,b.t,b.k,b.nid); 7.3 "Common"; 8 "Badge and sync"; 9 "Delivery measurement" (t0, t1); 12 "Execution in the server" (all five points); 14 "Tests"; 17 "Configuration values of this chapter".docs/spec/03-data-model.md(core contract, read only):event_log(occurred_at"time of the transaction",type,resource_kind,resource_id, the index(resource_kind, resource_id)),event_consumer_cursors(name,last_seq),push_registrations(all columns and checks),auth_sessions(device_id),devices(state).docs/spec/04-sync-protocol.md(core contract, read only): 3.2 "Pattern with a counter row" (commit order is log order), 7.6 "Consumers in the worker (push, webhooks)".docs/spec/05-actions.md(core contract, read only): 3 "Rate-limit classes" (rl.messageper principal), 4.7 rowmessage.create, 4.9 rowspush.deliverandnotification_pref.update, 5 paragraph "Check without a request", R-AZ-29, R-AZ-30.docs/spec/23-quality.md: 2 (make perfat the end of a milestone and in the release) and 3 (coverage leaves outperf/**).- ADRs 0063 "Performance gate for the event log" (points 2 to 5, Confirmation), 0051 "Worker consumers of the event log with a cursor instead of job inserts in the write path" (Consequences: the delay until the next round; Confirmation: "Load test
perf/push_handoff"), 0031 (push without content), 0009. docs/api/openapi.yaml: operationscreateSpace,createChannel,createGrant,createUser,redeemEnrollmentCode,createStepUpChallenge,completeStepUpWithDeviceKey,putNotificationPreference,createMessage; schemasMessageCreate(content_md,mention_ids,client_msg_id),Message,Channel(main_thread_id),NotificationKind,NotificationLevel,Problem(x-problem-codes).docs/operations/configuration.md: the keys of Design note 12.docs/observability/signals.md4.4 (bothy.push.sent,bothy.push.send.duration,bothy.push.skipped) and 5 spanpush.send: production signals of the same path; this ticket adds none and scrapes none.docs/observability/slos.mdbothy-push/provider-acceptance(the acceptance, not the delay).docs/security/threat-model.md5.12 "Push" and T-PU-01 (no content at the provider; the load test checks it on every request).docs/RISKS.mdRK-37 (presence per api node; this test runs one node in roleall).docs/DECISIONS.md: D-W-100, D-W-106 and D-W-108 (the perf frame edges of M2-36 and M3-16; the push block); section 10 for the rows of this ticket. No item ofdocs/OPEN-QUESTIONS.mdconcerns this ticket.- Briefs (merged names win, D-W-100):
docs/briefs/M1-72-performance-gate.md(the perf frame:perf/internal/report,.cache/perf/results.json, profile modes, durability check, the clock skew correction ofgateway_fanout,Options.DurableDB, the open-loop scheduler ofload_messages),M1-71-contract-tests.md(test/harness:Start,Options,Server,NewUser,AdminDB,Client.Do),M1-16-event-log.md(origin of the report,pgtest.Durable()),M1-01-server-gates.md(perftarget,PERF_TIMEOUT),M2-36-agent-path-perf.md(the sibling pattern: named constants,-p 1, the move of the scheduler toperf/internal/load, failure conditions besides the threshold). Briefs of M3-19 and M3-20 as merged (D-W-100; their names below are the expected ones): M3-19 Design notes 11 to 13 (notify.push_sendwithDueAt= t0 of 13 9,PushProvider,Registration,NotificationwithBadge,HasBadgeandKindSync, the result classes,push.max_concurrent_requests) and Design note 16 (the badge of 13 8 throughBadgeCounter.For, D-W-108 (6)); M3-20 Design notes 1, 2, 5 and 8 (internal/notify/adapters/apns,Deps.Endpointsfor tests only,egress.Options.HTTP2, the payload table, Fake-APNsapnstestwithStart,Options,Request,Requests,EgressRule,RootCAs,URL,Close). - Code on the base branch (all under
services/server/unless it names the rootMakefile): the rootMakefile(targetperf, variablePERF_TIMEOUT);perf/(event_commit,load_messages,internal/report,internal/loadif moved,README.md);test/harness;internal/platform/pg/pgtest;internal/platform/config(defaults);internal/wire(the provider set,push_apns.go, the override struct through which the harness passesClock,TelemetryandEgressDenyAll);internal/notify(adapters/apns,adapters/apns/apnstest,app/send.go,adapters/jobs);internal/platform/egress.
Fixed requirements that apply¶
Owner's fixed build rules (apply verbatim):
- 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.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. - Limits, windows and thresholds only through named configuration keys listed in
docs/operations/configuration.md; code comments and docs name the key, never the number. - Specification first: a change to behaviour, API, event, right or configuration updates
docs/spec/ordocs/api/in the same change. The core contract (docs/spec/00-glossary.md,03-data-model.md,04-sync-protocol.md,05-actions.md) is not edited by this ticket.
Note on rule 9: the parameters of the load (counts, shares, durations of Design note 4) are named constants of a test, not limits of the product; their values stand in perf/internal/pushhandoff/constants.go and perf/README.md, and every product limit they depend on is read by its key name.
Product requirements implemented here:
make perfmeasuresperf.push_handoff_p95("commit to handoff to the push service (fake)", testperf/push_handoff) against Fake-APNs and fails when it exceeds its threshold (PLAN row M3-24; 02 17; M3 exit criterion 3; ADR 0051 Confirmation).- The measuring points are those of 13 9: t0 is the commit of the triggering event (for a hint that goes at once), t1 is the moment the provider accepted with
200. - The threshold comes only by name from the table of 02 22 (meaning
target: the measured value must not exceed it);perf.*values are test thresholds, not runtime configuration, and never appear as literals in code or docs (02 22, ADR 0063, the loader of M1-16 and M1-72). - Measured only on databases with
fsyncandsynchronous_commiton; every result names the profile mode ofperf.reference_profile; onlymatchandemulatedcan end green,belownever does (ADR 0063 points 2 and 3). make perfruns at the milestone close, in the release and in every ticket that touches a target, never inmake checkor the pre-push gate (P-07; PLAN 3 item 6; ADR 0063 point 4).- No content reaches the provider: every push carries only IDs, the kind, flags and numbers (13 1 point 1, 6.1, ADR 0031, T-PU-01); the load test checks every request it measures.
- Every sender of the load stays within
rl.messageunder its default limit (05 3); no request of the measurement is answered429 rate_limited. - No threshold is lowered and no default of a key named in this brief is changed to pass the target (PLAN 3 item 6; ADR 0063 point 5).
Scope¶
All paths under services/server/ unless they start with docs/ or name the root Makefile. Names of M1-72's perf frame, of the harness and of M3-19 and M3-20 below are the expected ones; where the merged code names them differently, follow the code (Boundaries).
Create:
perf/push_handoff/(build tagperf, the one tag of M1-16 and M1-72):push_handoff_test.gowithTestPerf_PushHandoffP95(Design notes 6 to 9) and a package comment naming the target. The directory name is that of 02 17 and the plan row; the package clause follows the convention M1-72 chose forload_messages.perf/internal/pushhandoff/(untagged, unit tested inmake testwithout a container):constants.go(the named test parameters of Design note 4),plan.go(Planderived from configuration defaults, the 02 table and the constants),requests.go(strict decoding of a Fake-APNs request into a hint record, Design note 8 a),samples.go(samples, window filter, skew correction through the frame helper),checks.go(the matcher and the failure conditions of Design note 9),doc.go.perf/internal/pushworld/(untagged):Seedbuilds the world of Design note 5 through the public API and writes the registrations as fixture rows;seed_test.goholds the small-world integration test of "Tests" (container runtime like everypgtesttest, runs inmake test).perf/internal/load/only if M1-72 kept its open-loop scheduler insideperf/load_messagesand no other ticket moved it yet: move it here without a change of behaviour so thatload_messagesandperf/push_handoffuse one scheduler; M1-72's tests keep their names and stay green. Never write a second scheduler.
Change:
internal/notify/adapters/apns/apnstest(M3-20, additive): the fieldRequest.AcceptedAt(Design note 7 b) with its test.test/harness(M1-71, M1-72, additive):Options.APNsFake(Design note 3) with its test and README line.internal/wire(additive), only if M3-20 left no Go-level path by which a test points the wired APNs adapter at Fake-APNs: the two test overrides of Design note 3 withTestProductionWiringHasNoTestOverrides.perf/internal/report(M1-16, M1-72): no new mechanism. If the threshold loader or its tests hold a list of expected names, addperf.push_handoff_p95; extendTestThresholdsFromChapterTable(or its merged equivalent); add the exit-logic test of "Tests".perf/README.md: a sectionpush_handoff(what is measured, the measuring points, world and load shape, parameters with their values, failure conditions, how to readextra, that the server runs in the test process).- Root
Makefile, targetperf: add-p 1to itsgo testcall if it does not serialize the packages yet (Design note 10). No new target. docs/spec/02-architecture.md: 17 rowperf.push_handoff_p95and 22 rowperf.sustained_messages_per_s(Design note 11).docs/spec/13-push-notifications.md: 9 a paragraph on the load test, 14 a row (Design note 11).docs/operations/configuration.md: regenerate withmake config-reference(the meaning ofperf.sustained_messages_per_schanges).docs/adr/0063-performance-gate-for-the-event-log.md: "Confirmation" gains a bullet for M3-24, "More Information" names the push target; the decision stays unchanged; thenmake adr-index.docs/DECISIONS.mdsection 10: rowsD-M3-24-1toD-M3-24-6(Design note 13).- READMEs:
internal/notify/README.md(one English line in the Fake-APNs section onAcceptedAt; keep the lineMay use the ports of these modules: ...and the section## Tablesexactly as they are, because M1-06 parses them),test/harness/README.md,internal/wire/README.mdif the overrides were added;services/server/CHANGELOG.mdunder "Unreleased".
If the base branch already carries a sentence of Design note 11 (a planning change made after this brief), keep it and change nothing there.
This ticket creates no migration, no operation, no event, no metric, no job kind, no configuration key and no new top-level make target.
Out of scope¶
- The push path itself: the consumer
push, the decision, bundling, the jobnotify.push_send, the badge (M3-17 to M3-19) and the APNs adapter (M3-20). Only a fix of Design note 14 may touch them. - FCM and Fake-FCM (M3-21, not in the closure): the plan row measures against Fake-APNs, and after the decision both providers share the job; a second provider would measure the same server path twice.
- Hints over the gateway (
notification.created, t1 = "the gateway has written the frame" of 13 9) and the delayed push while a desktop is active (M3-23); bundled hints, whose t0 is the window end (13 4.5, 9). registerPush, the preview endpoint and t2 of 13 9 (M3-22; device side M5-04).- A production signal for t1 minus t0 or an SLO on it (13 9 "Push sending"; signals and SLOs are Team F's files, M6-13 measures own statistics); this test reads no metrics endpoint.
- The thresholds and
perf.reference_profilethemselves; the other targets ofmake perf(M1-16, M1-72, M2-36, M3-16). cluster.mode = multiand separateapiandworkerprocesses (M7-17, RK-37).- The milestone-close run on the reference host itself (owner operation after the merge, PLAN 4 item 3).
- Changes to the core contract.
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M3-24-push-handoff-perf; one writer per worktree. - Conventional Commits with scope (for example
test(perf): ...,test(harness): ...,docs(spec): ...,perf(<module>): ...for a fix of Design note 14) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md). - Never push to
main, never tag and never create remote repositories; the only remote steps are the branch push and the pull request ofAGENTS.md. - Do not modify files outside the scope above, except the CHANGELOG, README and
docs/DECISIONS.mdentries named in this brief and the fixes of Design note 14, each with a test and listed in the report. - Follow the host rules supplied by the launcher (ports, machine rules).
- Tests bind only
127.0.0.1and port 0: the measured server runs in the test process throughharness.Startwith loopback listeners, and Fake-APNs is an in-process TLS server on loopback. PostgreSQL runs only as a throwaway container throughpgtestwithpgtest.Durable()(P-09, P-10). Without a Docker-compatible runtime the tests fail; they never skip. - Stop only processes and containers you started, by PID or container ID. Temporary directories only via
mktemp -dort.TempDir(), removed at the end. - Never read or print secrets. Device tokens, the provider JWT, the test key, API tokens and DPoP proofs never appear in logs, test output,
results.jsonor failure messages (name registrations by their ID). No network calls except package and image downloads for pinned tools and Go modules. - No real Apple service, no Apple account and no real key: the key is generated per run with
crypto/ecdsaand written with mode0600into at.TempDir()for thefiledriver; team and key IDs containEXAMPLE; the topic iscom.example.chat. Nothing in this ticket costs money. - Never lower a threshold, disable a gate or delete a test. A missed target on a host in mode
belowis reported as not proven, never "fixed" by changing numbers. - If the core contract lacks something this ticket needs, stop with
Verdict: BLOCKED <reason>instead of building a deviation (P-11). This ticket touches no core table, event or action. - This brief was written before milestone M1 closed (D-W-100). Where the merged code of an M1 or M2 ticket names a package, type, function, file or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with
Verdict: BLOCKED <reason>. - The same holds for the merged code of M3-17 to M3-20 and of the perf frame: follow their names for
PushProvider,Notification,notify.push_send,apnstest, the report, the harness and the scheduler, and list each difference in the report. - This ticket uses the perf frame (profile modes, results file, durability check, clock skew helper,
Options.DurableDBof the harness) of M1-72 and never builds a second one. - Test overrides in production packages (Design note 3) are Go fields that no configuration key, flag or environment variable reaches;
cmd/bothynever sets them. - Direct SQL writes only in
perf/internal/pushworld(the fixture registrations of Design note 5), only through the admin pool of the throwaway container and before the measurement starts; every other SQL of this ticket is read-only.
Design notes¶
-
Start check. Run
make checkon the fresh worktree; if it is red, stop withVerdict: BLOCKED base branch redand the failing gate. Confirm that the base has M3-20 (internal/notify/adapters/apnswithapnstest,egress.Options.HTTP2) and through it M3-19 (notify.push_send, the consumerpush,push_registrations), elseVerdict: BLOCKED dependency M3-20 missing, and M1-72 (the perf frame of Boundaries), elseVerdict: BLOCKED dependency M1-72 missing. Runmake perfonce only at the end (Definition of done); if a target of another ticket fails in that run, run that package on the base commit and report both values, so that a host limit is not mistaken for a regression of this ticket. -
Packages.
perf/push_handoffholds only the tagged test and its orchestration. Everything that can be tested without a server lives untagged inperf/internal/pushhandoff(plan, request decoding, samples, matcher), somake testruns its unit tests andmake checkcompiles, vets and lints it;perf/internal/pushworldholds the seeding and its small-world test.perf/**is the architecture componenttestsupport(02 6; M1-05) and may importtest/harness,internal/notify/adapters/apns/apnstestand the report; nothing outsidetestsupportimports these packages. Coverage leavesperf/**out (23 3). -
The measured server runs in the test process (Ruling for docs/DECISIONS.md,
D-M3-24-1). The wired APNs adapter reaches Fake-APNs only throughDeps.Endpointsand the egress root CAs, which are Go values thatinternal/wirenever sets and that no configuration key reaches (M3-20 Design note 1 e); a server subprocess (harness.StartProcess) could therefore only talk to the real Apple hosts. SoTestPerf_PushHandoffP95starts the server withharness.Start(the realinternal/wiregraph in roleall, M1-71) andOptions.DurableDB:a.
harness.Options.APNsFake *apnstest.Server(additive): when set,Startpoints the wired APNs adapter at the Fake for both environments, adds the Fake's root CAs to the egress dependencies and appendsAPNsFake.EgressRule()toegress.allowed_hosts. The test still sets thepush.apns.*keys of Design note 12 throughOptions.Config, so the adapter is enabled exactly as in production.b. If M3-20 left a Go-level path for this (for example the one its
TestPushSendThroughAPNsuses),Startuses it. Otherwise add two fields to the merged override struct through which the harness passesClock,TelemetryandEgressDenyAllintointernal/wire:APNsEndpoints map[string]string(environment to base URL, passed toapns.Deps.Endpoints) andEgressRootCAs *x509.CertPool(passed toegress.Deps.RootCAs).TestProductionWiringHasNoTestOverridesbuilds the graph the waycmd/bothydoes, with APNs enabled, and asserts that the adapter targets the two Apple base URLs and that the egress root CAs are the system pool.c. Profile: in mode
emulatedthe test setsruntime.GOMAXPROCSto the reference vCPU count beforeStartand the PostgreSQL container gets the profile limits through the pgtest options of M1-72, as M1-72's mode decides; in modematchnothing is capped. The load generator and Fake-APNs share the CPUs with the server, so the value is an upper bound of the server's own time;results.jsonsays so inextra. -
Load shape and parameters (Ruling for docs/DECISIONS.md,
D-M3-24-3; 02 17 names no load for this target). The test runs underperf.sustained_messages_per_shuman messages, the sustained design load of 02 22, because the consumerpushreads every event of the log and the job shares the process, the pool andevent_seqwith chat. Hints are of kindmentiononly: 13 4.5 point 3 sends a mention at once, so its t0 is the commit (13 9); amessagehint can be collected in a window whose t0 is the window end, and after the decision every kind takes the same job and adapter. Named constants inperf/internal/pushhandoff/constants.go, documented with their values inperf/README.mdand copied intoresults.json:BudgetShare = 0.5: every sender plans at most half ofrl.message; the other half absorbs jitter.SendersPerChannel = 10,RecipientsPerChannel = 4: small team channels; every channel's readers are its senders, its recipients and the owner.MentionEvery = 10: every tenth scheduled message mentions recipients;MentionsPerMessage = 2: it mentions two recipients of its channel, round robin, so every recipient is mentioned in turn.Warmup = 30s,Window = 3m,Drain = 30s,MinSamples = 1000,LagSampleEvery = 1s.
Planis a pure function, unit tested, that derives every count from the default ofratelimit.rl.message.per_minute(read throughinternal/platform/config, so a changed default changes the plan), fromperf.sustained_messages_per_s(read through the report loader) and from the constants: senders =ceil(perf.sustained_messages_per_s / (BudgetShare * ratelimit.rl.message.per_minute / 60)); channels =ceil(senders / SendersPerChannel); recipients = channels timesRecipientsPerChannel; mention rate =perf.sustained_messages_per_s / MentionEvery; expected pushes per second = mention rate timesMentionsPerMessage;Windowmust hold at leastMinSamplesexpected pushes, else the plan is an error that names the constants. Senders send evenly spaced, so no planned burst approachesratelimit.rl.message.burst. -
World and seeding (Ruling for docs/DECISIONS.md,
D-M3-24-4). One server for seeding and measurement, because the in-process server of Design note 3 cannot be restarted on the same database: itsOptions.Configraises everyratelimit.*key exceptratelimit.rl.message.per_minuteandratelimit.rl.message.burst. The measured phase sends onlycreateMessage(classrl.message, 05 4.7 rowmessage.create), and the path after the commit uses no rate-limit class, so the raised keys never touch what is measured; seeding sends no message.pushworld.Seedcreates, with the owner and step-up as the harness does:- one space and
channelstext channels (createSpace,createChannel), keeping eachmain_thread_id; - senders and recipients through
harness.NewUser(createUserwithlocal_account,redeemEnrollmentCodewith one device each), with generated handles (sender-001,recipient-001) and addresses atexample.com; - per channel a grant of level
writeto itsSendersPerChannelsenders and of levelreadto itsRecipientsPerChannelrecipients (createGranton the channel); - per recipient
putNotificationPreferencewith levelmentionson its channel, so the kindmessagenever reaches a recipient whatevernotify.user_default_levelsays (13 3.1, 4.3); - per recipient one
push_registrationsrow, written throughAdminDB()becauseregisterPush(M3-22) is not in the closure:provider = 'apns',environment = 'production',app_id = 'com.example.chat', a token of 64 random hex digits,device_idthe recipient's device,auth_session_idthe newestauth_sessions.idof that device; columns as 03, no other write.
No recipient ever opens a gateway connection, so every hint goes to its registration (13 4.6 point 2); no recipient reads, so no badge sync arises (13 8). Senders hold no registration.
- one space and
-
TestPerf_PushHandoffP95, sequence. Generate the P-256 key and write it; start Fake-APNs withapnstest.Start(apnstest.Options{TeamID, KeyID, PublicKey}); start the server (Design note 3) withpush.apns.team_id = EXAMPLETEAM,push.apns.key_id = EXAMPLEKEY,push.apns.private_key_refpointing at the key file,push.apns.topics = com.example.chatand the raised rate limits; readSHOW fsyncandSHOW synchronous_committhrough the frame; seed; start the open-loop scheduler at the planned rate, round robin over the senders, each sender into the main thread of its channel; every message carries a fresh UUIDv7client_msg_idand acontent_mdwith a per-run canary string, a mention message names its two recipients by handle incontent_mdand lists their IDs inmention_ids(the server checks both sets,MessageCreate); every answer must be201; record message ID, scheduled time and mentioned recipients. Sample the consumer lag everyLagSampleEvery(Design note 8 d). Measure forWindowafterWarmup, stop the scheduler, waitDrain, then run the checks of Design note 9. -
Measuring points (Ruling for docs/DECISIONS.md,
D-M3-24-2).a. Start t0: the
occurred_atof themessage.createdevent of the mention message, the time of its transaction (03event_log), read afterDrainin one read-only query throughAdminDB()on the index(resource_kind, resource_id)(type = 'message.created',resource_kind = 'message',resource_id = ANY(<the window's mention messages>)). It lies at or before the commit, so each sample is an upper bound of "commit to handoff"; it is also theDueAtthat M3-19 carries as t0 of 13 9.b. End t1:
apnstest.Request.AcceptedAt(additive in M3-20's Fake), the test process time taken after the Fake has read the whole request and verified the JWT, immediately before it writes the status200: "the provider has accepted (200)" (13 9).c. Sample = t1 minus t0, corrected by the clock offset between database and host with the helper that M1-72's
gateway_fanoutuses. One sample per mention message whose scheduled time lies in the window and per mentioned recipient (one registration each); the value is the nearest-rank p95 of the report. -
Requests at the Fake.
a.
requests.godecodes each recorded request strictly: path token,apns-id,apns-collapse-id, and the body as{"aps": {...}, "b": {"v", "nid", "k", "i", "t", "m"}}with unknown members rejected; a body withoutbis a badge sync form (13 8, M3-20 Design note 5 d).b. A hint record is (registration by token,
b.nid,b.k,b.t,b.m,aps.badge,AcceptedAt). Tokens map to registration IDs inside the test only and are never printed.c. Matching: the expected set is every (mention message, mentioned recipient) of the run; a record matches by
b.mand the recipient of its token.d. Extras in
results.json: achieved message and mention rates, expected and received pushes, p50, p99 and maximum of the samples, the largest and the mean consumer lag (max(event_log.seq)minusevent_consumer_cursors.last_seqforname = 'push', read-only everyLagSampleEvery), the counts of senders, recipients and channels,GOMAXPROCS, the note of Design note 3 c. -
Results and failure conditions (Ruling for docs/DECISIONS.md,
D-M3-24-5: a measurement counts only when the path behaved correctly while it was taken).- The test writes through
perf/internal/report:nameperf.push_handoff_p95,valuethe p95 in the unit of the 02 row,thresholdandcomparison(<=, meaningtarget) from the 02 table,pass,samples,test; parameters, plan counts and extras underextra; profile and durability fields from the frame unchanged. - The test fails, and
make perfexits non-zero, when the p95 exceeds the threshold or on any of: an answer ofcreateMessageother than201, among them429 rate_limited; an expected pair in the window without a matching request afterDrain; two requests for one pair or two requests with onenid; a request whoseb.mis not a mention message of the run, whose token belongs to no seeded registration, whose recipient was not mentioned in that message, whoseb.kis notmention, whoseb.torapns-collapse-idis not the main thread of the message's channel, or whoseaps.badgeis missing or below one; a badge sync request (no recipient reads, 13 8); the canary string in any request body or header (13 1 point 1, T-PU-01); a Fake answer other than200(a rejected JWT); a registration withdisabled_atset orfailure_countabove zero afterDrain(read-only query); fewer thanMinSamplessamples. - Failure messages name counts, message IDs and registration IDs, never tokens.
- The test writes through
-
Serial packages and duration. Two load tests on one host at the same time measure each other: if the
perfrecipe does not pass-p 1togo testyet, add it. The test adds its seeding plusWarmup + Window + Drain; report the total duration ofmake perf. If the time limit of theperftarget (PERF_TIMEOUT, M1-01) no longer suffices, raise it together with its named valuequality.perf.timeoutin chapter 23 in the same change (the guard test of M1-01 keeps both equal); never shortenWindowbelow whatMinSamplesneeds. Ruling for docs/DECISIONS.md,D-M3-24-6, only for what was changed. -
Spec texts (rule 10; nothing in 00, 03, 04 or 05 changes; all new text in English, existing text stays as it is; keep every existing column):
- 02 17, row
perf.push_handoff_p95, column "What is measured": keep the existing text and append "; measured fromoccurred_atof the transaction of a message that mentions the recipient (an upper bound of the commit, 13 section 9 t0) to the200of Fake-APNs (t1), formentionhints underperf.sustained_messages_per_s, with the server in the test process (perf/README.md)". - 02 22, row
perf.sustained_messages_per_s, column "Meaning": keep the existing text, whose first words "sustained load" let the loader classify the row as a load parameter, and append "; also the load ofperf.push_handoff_p95".TestThresholdsFromChapterTableagainst the real 02 proves that the row is still a parameter. - 13 9, a paragraph after the SLIs: the load test
perf/push_handoffmeasures t1 minus t0 formentionhints against Fake-APNs under the sustained load, with t0 read asoccurred_atof the triggering transaction and t1 as the200of the Fake; the value is held byperf.push_handoff_p95(02 section 17), the operational SLI stays as written. - 13 14, a new row: "Load test
perf/push_handoff(make perf)" | "perf.push_handoff_p95; every mention gives exactly one push per registration, with IDs, badge and no text". - ADR 0063: "Confirmation" bullet "Build ticket M3-24:
make perfmeasuresperf.push_handoff_p95against Fake-APNs on a durable database in the profile modes and fails when it is exceeded"; "More Information" names the push target.
- 02 17, row
-
Configuration keys used, read by name, no new key and no changed default:
perf.reference_profile,perf.sustained_messages_per_s,perf.push_handoff_p95(through the report from 02);ratelimit.rl.message.per_minute,ratelimit.rl.message.burst(defaults, never raised); every otherratelimit.*key (raised for seeding throughOptions.Config, Design note 5);push.apns.team_id,push.apns.key_id,push.apns.private_key_ref,push.apns.topics(set to the test values of Design note 6);egress.allowed_hosts(the Fake's rule, throughOptions.APNsFake); and at their defaults, named inperf/README.mdas the configuration the value holds for:consumer.batch_size,consumer.poll_interval,jobs.queue.delivery.max_workers,push.max_concurrent_requests,push.max_age,push.ttl,push.apns.jwt_refresh_interval,notify.coalesce_window,egress.timeout. -
Rulings for
docs/DECISIONS.md(section 10, one row each with date, decision, reason and place):D-M3-24-1the server in the test process withOptions.APNsFakeand, if needed, the two wire overrides (Design note 3);D-M3-24-2the measuring points t0 =occurred_at, t1 =AcceptedAtbefore the200(Design note 7);D-M3-24-3the load shape:mentionhints only, underperf.sustained_messages_per_s, with the constants (Design note 4);D-M3-24-4one server for seeding and measurement with everyratelimit.*key raised exceptrl.message, and the fixture registrations (Design note 5);D-M3-24-5the failure conditions besides the threshold (Design note 9);D-M3-24-6-p 1and a raised time limit, each only if made (Design note 10). -
A missed target in mode
matchoremulatedwith a durable database: find the cause (the consumer lag of Design note 8 d shows whether the round or the job is slow; then a CPU, block or mutex profile of the suspected step through a Go benchmark of that code) and fix it in the module that owns the slow step, with a test that holds the behaviour, each fix listed in the report. Never lower a threshold, never change a default of a key named in this brief, never weaken a check of the path (push.deliverat decision and send time, the payload rules, the JWT) to gain time. If the target cannot be met without a change to 02, 13 or a new ADR decision (for example a wake signal for the job queue, ADR 0051 "Bad, because consequences have a delay until the next round"), stop withVerdict: BLOCKED target missedand the table and findings (ADR 0063 point 5). In modebelowreport the value as not proven.
Tests (write them first)¶
Unit tests (untagged, in make test, no container needed):
perf/internal/report:TestThresholdsFromChapterTable(or its merged equivalent) findsperf.push_handoff_p95in the fixture copy and in the real 02 with unitsand comparison<=, and still classifiesperf.sustained_messages_per_sas a parameter after its new meaning;TestReport_PushHandoffFailsAboveThreshold: a fixture result forperf.push_handoff_p95at the threshold plus one millisecond makes the exit logic fail and marks the row failed, a result exactly at the threshold passes, and a passing value withsynchronous_commitoff in the profile still ends not green.perf/internal/pushhandoff:TestPlan_DefaultsWithinRateLimits: with the default configuration every sender plans at mostBudgetShareofrl.messageand never a burst aboveratelimit.rl.message.burst; the sender count is minimal (one sender less would exceed the share); channels, recipients and the expected pushes per second follow the formulas of Design note 4;Windowholds at leastMinSamplesexpected pushes.TestPlan_FollowsKeys: halvingratelimit.rl.message.per_minutedoubles the senders; doublingperf.sustained_messages_per_sdoubles the mention rate; aWindowtoo short forMinSamplesis an error naming the constants.TestDecodeRequest: a body built like the example of 13 6.1 decodes into a hint record; an unknown member inapsorbis an error; a body with onlyaps.badgeis a sync form.TestHintSample: sample =AcceptedAtminusoccurred_at, corrected by the skew; messages scheduled inWarmupor after the window give no sample; the p95 is the nearest rank of the report.TestMatcher: one subtest per failure condition of Design note 9 (missing pair, duplicate pair, duplicatenid, unknown message, unknown token, recipient not mentioned, kindmessage, wrong thread, wrong collapse ID, missing badge, badge zero, sync form, canary in the body, canary in a header, a Fake answer403), each makes the result fail with a message that contains no token; a clean set passes.TestMinSamples: fewer thanMinSamplessamples make the result fail.
Unit, internal/notify/adapters/apns/apnstest:
TestFakeStampsAcceptedAt: every recorded request hasAcceptedAtafter its send time in the client and before the client received the200; a request rejected with403hasAcceptedAtzero.
Integration (container runtime, in make test):
harness:TestStartWithAPNsFake:StartwithOptions.APNsFakeand thepush.apns.*keys; Alex mentions Sam in a channel both can read, Sam holds anapnsfixture registration and levelmentions; exactly one request reaches the Fake over HTTP/2 (Request.Proto), withb.k = mention,b.mthe message andaps.badgeat least one.internal/wire(only if the overrides were added):TestProductionWiringHasNoTestOverridesas in Design note 3 b.perf/internal/pushworld:TestSeedSmallWorld: a plan with two channels, two senders and two recipients each;Seedcreates the grants, the preferences and one registration per recipient with the columns of Design note 5; one scheduled mention per channel gives exactly one matched request per mentioned recipient, the matcher passes and the plain messages give none.
Unit tests may use Alex, Sam and Robin as fixture names; the seeded world uses the generated handles of Design note 5.
Tagged test (build tag perf, run by make perf): TestPerf_PushHandoffP95 as in Design notes 6 to 9. It has no red run before its package exists; the red runs to keep for the report are those of the tests above.
Commands that must pass, from the repository root:
cd services/server && go test -race -count=1 ./perf/internal/... ./test/harness/... ./internal/notify/adapters/apns/... ./internal/wire/...
cd services/server && go vet -tags perf ./perf/...
make config-reference
make adr-index
make docs
make perf
make check
Acceptance criteria¶
-
make perfrunsTestPerf_PushHandoffP95against Fake-APNs and writesperf.push_handoff_p95with value, threshold, comparison, pass and samples, together with the profile mode and the durability fields, into.cache/perf/results.jsonand the table. -
make perfexits non-zero when the value exceeds the threshold (TestReport_PushHandoffFailsAboveThreshold). - The threshold comes only from the 02 table; no
perf.*value is a literal in code or docs. - Each sample runs from
occurred_atof the mention's transaction to the200of Fake-APNs; every mention in the window gives exactly one push per mentioned registration with the right message, thread, kind and a badge, and no request carries text (TestMatcher,TestSeedSmallWorld). - The load runs at
perf.sustained_messages_per_swith the defaultrl.messagelimits and no request is answered429 rate_limited(TestPlan_DefaultsWithinRateLimits). - The production wiring cannot reach the test overrides (
TestProductionWiringHasNoTestOverrideswhere the overrides were added). - 02 17 and 22, 13 9 and 14, the configuration reference, ADR 0063, the READMEs and
docs/DECISIONS.mdcarry the measuring points, the load and the rulings. - The tagged code compiles and passes vet and lint inside
make check; the untagged tests run inmake test. - On the build host the target passes in mode
matchoremulated; in modebelowthe report lists it as not proven. -
make checkis green.
Definition of done¶
- Code and tests as specified; doc comment on every public symbol (purpose, parameters, return value, errors, example where useful); comments explain why.
services/server/perf/README.mdupdated (the test, measuring points, world, load shape, parameters with values, failure conditions, extras, the in-process server); READMEs of the harness, ofnotify(Fake-APNs) and ofinternal/wire(if changed) name their additions.docs/spec/02-architecture.md17 and 22,docs/spec/13-push-notifications.md9 and 14, the regenerateddocs/operations/configuration.mdand the "Confirmation" of ADR 0063 carry this ticket; rulings of Design note 13 recorded indocs/DECISIONS.mdsection 10 (one row each with reason).services/server/CHANGELOG.mdentry under 'Unreleased'.- An ADR only if you take an architecture decision beyond this brief (MADR 4 as in
docs/adr/0000-template-madr-4.md, headings and text in English, next free number,make adr-index). make checkgreen andmake perfrun on the build host with its full table in the report (PLAN 3 item 6).- An independent code review over the diff, then the red team gate (
docs/quality/red-team-gate.md, verdictJAorNEIN); onNEINfix with a test per finding and have the delta checked again. Record both verdicts. The red team checks at least: a lost, duplicated or misdirected push cannot end green; the measuring points cannot under-report (t0 at or before the commit, t1 at the200); no token, JWT or key in any output; the test overrides are unreachable fromcmd/bothy; the load stays within the defaultrl.message; no threshold or key default was changed. - Land the change as
AGENTS.md("How work lands onmain") describes: a pull request from this branch, squash-merged only when review, red team andmake check,ci-okand the review threads are green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}:
- First line exactly
Verdict: DONEorVerdict: BLOCKED <reason>. - Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of
make check, the fullmake perftable with profile mode, host data (logical CPUs, memory,GOMAXPROCS) and durability fields, the extras of the test (rates, pushes, p50, p99, maximum, consumer lag), the plan counts and the total duration ofmake perf. - Review and red team verdicts and the findings fixed; every fix of Design note 14 with its test.
- What is not proven (at least: the value on
perf.reference_profilewhen the mode was neithermatchnoremulated; the delay against the real APNs service and over a real network; FCM and gateway hints), and each difference between this brief and the merged names of the perf frame, the harness, M3-19 and M3-20. - Follow-ups that could not be done in this scope, each with its reason, and spec gaps found.
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.