flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/docs/PERFORMANCE.md

274 lines14,485 bytesCodeBlame
1# Performance
2
3How g1t.sh answers a page: where the code runs, where the data lives, how
4reads and writes travel, what is cached, and the budget pages are held to.
5Internal. The tools: `scripts/perf/measure.ps1` (time pages from your
6machine), `scripts/perf/placement-probe.mjs` (measure placements without
7touching production), and the Server-Timing header on every page.
8
9## The shape of a request
10
11```
12browser ──► Cloudflare edge (nearest data centre)
13 │
14 ▼
15 g1t (apps/web, React Router) root loader + layout + page loaders, in parallel
16 │ service bindings: POST /rpc/<method>, JSON
17 ▼
18 g1t-identity, g1t-repos, g1t-work, g1t-projects, g1t-billing, … (services/*)
19 │
20 ▼
21 D1 (one SQLite database per service; primary in WNAM, US West)
22 Artifacts (git objects and refs, for g1t-repos)
23```
24
25- **The site holds no data.** Every loader calls services; every service
26 owns one D1 database. A page is a few rounds of service calls; each call
27 is a few D1 queries.
28- **Latency is round trips times distance.** A query from a Worker next to
29 its database takes 1 to 5 ms. The same query from Amsterdam to WNAM took
30 about 150 ms. A page with three rounds of calls, each with two or three
31 queries in turn, costs about a second and a half when the code and the
32 data are on different continents. That was the slowness.
33
34## Where the code runs
35
36| Worker | Placement | Why |
37| --- | --- | --- |
38| `g1t` (site), `g1t-api`, `g1t-sudo` | `off` | Run next to the person. With D1 replicas (below), most reads are local too. |
39| identity, repos, work, search, billing, projects, deployments | `off` | They read with D1 sessions: the nearest replica, or the primary when consistency needs it. |
40| actions, events, webhooks, integrations, context, security | `off` now; a region near WNAM once the probe has picked one | They read the primary only. Pinned beside it, a page from Europe pays one ocean crossing per call instead of one per query. |
41| pages, models, og, runner, status, docs | none | Edge-serving or no data of their own. |
42
43### What happened with Smart Placement
44
45Every data-holding Worker and the site had `"placement": { "mode": "smart" }`.
46Production answered with `cf-placement: remote-AMS` for a visitor in Denver:
47the site ran in Amsterdam, and so did the services it called through
48bindings (a binding runs the callee where the caller is unless the callee
49is placed). Every D1 query then crossed the Atlantic. Smart Placement only
50considers locations where the Worker has already run and needs traffic
51from many places to decide, so it settled on a poor spot and stayed there.
52Measured from Colorado, signed out, warm:
53
54| Page | Smart (AMS) | Off |
55| --- | --- | --- |
56| `/` | 270 ms | 140 ms |
57| `/explore` | 850 ms | 170 ms |
58| `/flagon-io/g1t/pulls` | 600 ms | 220 ms |
59| `/flagon-io/g1t/issues` | 780 ms | 230 ms |
60| `/pricing` | 500 ms | 120 ms |
61| `/flagon-io/g1t` (overview) | 1.4 s | 1.67 s (its own problem, below) |
62
63Placement now applies only to `fetch` handlers; the services are reached
64through `fetch` (`/rpc/<method>`), so it applies to them.
65
66### Choosing a region (the probe)
67
68Cloudflare's placement hints (`"placement": { "region": "aws:us-west-1" }`,
69GCP and Azure regions too, Wrangler 4.146 accepts them) run a Worker next
70to a cloud region. D1 is not a cloud region, and Cloudflare does not say
71which city WNAM is, so measure:
72
73```powershell
74node scripts/perf/placement-probe.mjs deploy # g1t-probe-* on workers.dev, SELECT 1 against g1t-repos
75node scripts/perf/placement-probe.mjs measure # a table: where each ran, ms per D1 query, through a binding too
76node scripts/perf/placement-probe.mjs delete
77```
78
79Pick the region with the lowest **D1 primary ms/query**. To pin the
80primary-only services there, edit their configs (or use
81`node scripts/perf/placement-probe.mjs apply '{"region":"<it>"}'`, which
82sets every config it lists, then put `"mode": "off"` back on the site, API,
83sudo and the session services). `apply '{"mode":"off"}'` is what the
84working tree has now.
85
86## Where the data lives, and how reads travel
87
88Every database's primary is in WNAM. D1 read replication puts read-only
89copies in every region (ENAM, WNAM, WEUR, EEUR, APAC, OC) at no extra
90cost. A copy trails the primary, so reading one needs care.
91
92### Sessions and bookmarks
93
94Seven services read through D1's Sessions API when asked
95(`crates/kit/src/d1.rs`, `packages/contracts/src/d1.ts`). The caller asks
96with the `x-d1-bookmark` request header:
97
98| Header | Reads go to |
99| --- | --- |
100| absent | the primary, with no session: exactly as before |
101| `first-primary` | the primary, then any copy at least as new |
102| `first-unconstrained` | the nearest copy |
103| a bookmark | any copy at least as new as the bookmark |
104
105Writes always go to the primary. A session is sequentially consistent: it
106reads its own writes. The service returns the session's latest bookmark
107in `x-d1-bookmark`.
108
109**Only the site asks for anything.** Service-to-service calls
110(`g1t_kit::call`), queues, crons, the API and MCP send no header, so they
111read the primary as they always did. Billing's `can_start`/`start_run`,
112credential checks for git, and everything agents do stay on the primary.
113
114The site decides per call (`apps/web/app/lib/perf.ts`, `sessionFor`):
115
1161. A request that writes (any method but GET and HEAD) starts every session
117 on the primary, so what an action checks before writing is current.
1182. Within 30 seconds of the person's last write, every service reads its
119 primary. A write can reach a service the site did not call (work
120 writing to repos during a merge) whose bookmark the site never sees;
121 replicas trail by well under a second, so 30 seconds is a wide margin.
1223. Otherwise, the bookmark that service returned after the last write.
1234. Otherwise, the nearest copy.
124
125After a request that may have written, the site sets the `g1t_d1` cookie:
126`at:<unix seconds>` and `service:<bookmark>` pairs, HttpOnly, five minutes.
127"May have written" is: a non-GET request; a GET that called a method not
128on the known-read list (`READS` in `perf.ts`; anything new counts as a
129write until listed); or a GET that started a session (signing in with
130GitHub). GETs that only read set no cookie, so public pages stay cacheable.
131
132What a person can still see out of date: something someone else (or an
133agent, or the API) changed in the last fraction of a second, which a page
134would have missed by loading a moment earlier anyway; and a session
135revoked from another device working for that same fraction of a second
136on reads (sign-out from this browser is a write, so it is immediate).
137
138### Turning replication on
139
140Not on yet: the code above works the same without it (every read is then
141the primary). Turn it on per database once the site and the seven services
142are deployed with sessions. There is no Wrangler command; use the
143dashboard (**D1 → database → Settings → Read replication → Enable**) or
144the API with a token that has D1 Edit:
145
146```powershell
147$token = $env:CLOUDFLARE_D1_TOKEN # D1: Edit on account syntaqx
148$account = "1e6f2cffa3f445920836e8ebe446bb58"
149$databases = @{
150 "g1t" = "b7d49c93-2666-4006-a3c3-073a01838dc9" # identity
151 "g1t-repos" = "f9544c51-c3bf-4621-a96f-8a6d5cf24a97"
152 "g1t-work" = "f35a9022-e36b-4547-b596-9ab9d5f1c47a"
153 "g1t-projects" = "0af698be-2b60-4bd9-aa93-81e84828e991"
154 "g1t-deployments" = "aa935a8f-845b-4132-8fa3-98f1afba1db5"
155 "g1t-search" = "9d04cbf3-cd02-4544-b3e3-2d78767e13bf"
156 "g1t-billing" = "695a6979-fd07-4850-bc97-904a6b4b7a04"
157}
158foreach ($name in $databases.Keys) {
159 curl.exe -s -X PUT "https://api.cloudflare.com/client/v4/accounts/$account/d1/database/$($databases[$name])" `
160 -H "Authorization: Bearer $token" -H "Content-Type: application/json" `
161 --data '{\"read_replication\":{\"mode\":\"auto\"}}'
162 Write-Host ""
163}
164npx wrangler d1 info g1t-repos # read_replication: { mode: "auto" }
165```
166
167Turning it off is `{"read_replication":{"mode":"disabled"}}` and takes up
168to a day to finish. Read-heavy over the last 24 hours: g1t-repos (60,103
169reads to 111 writes), g1t-work (43,219 / 1,462), g1t-projects (28,861 /
17022), g1t-billing (20,600 / 507), g1t (5,434 / 202). g1t-search writes more
171than it reads (indexing), so replicas help it least.
172
173## Caching
174
175| What | Where | For how long | Rules |
176| --- | --- | --- | --- |
177| Static assets (`/assets/*`) | browser and edge | a year, immutable | hashed file names |
178| Avatars | edge cache | a year, immutable | by content hash |
179| Public pages for people signed out | the data centre's cache (`workers/app.ts`, `servePublic`) | fresh 30 s, then served once more while a new copy is made, up to 5 min | GET, no `g1t_session` cookie, an allowlisted path (home, pricing, explore, policies, a project's pages), status 200 or 404, no `Set-Cookie`, nothing private. Reserved first segments and workspace pages (`-`) are never kept. The answer says `server-timing: cache;desc="hit, Ns old"`. |
180| Sidebar data (projects, spend, limit, entitlements) | per isolate (`lib/cache.server.ts`) | 15 s, per person and workspace | skipped during a write and for 30 s after the person's last one; failures not kept; only settled answers kept |
181| Registration mode | per isolate | 60 s | |
182| A commit's log by hash | repos' data-centre cache | for good | history from a commit never changes; Active branches asks by hash |
183| Git objects, trees, refs | repos' caches | see services/repos | |
184
185## Server-Timing
186
187Every page and `.data` response from the site carries a `Server-Timing`
188header (DevTools → Network → the request → Timing):
189
190```
191total;dur=180;desc="web to first byte",
192loader.root;dur=40, loader.repo.layout;dur=60, loader.repo.pull;dur=150,
193rpc;dur=140;desc="9 service calls, overlap counted once",
194work;dur=120;desc="4 calls, 70ms inside", repos;dur=30;desc="2 calls, 12ms inside", …,
195d1;desc="work=unconstrained repos=bookmark identity=primary"
196```
197
198- `total`: from the request reaching the site to the response headers.
199 Streamed panels finish after it.
200- `loader.<route>` / `action.<route>`: each route's loader or action
201 (React Router instrumentation in `app/entry.server.tsx`).
202- `rpc`: time waiting on services, overlapping calls counted once.
203- One entry per service: summed wall time of its calls from the site,
204 and how much of that the service itself reported (`svc;dur` from
205 `Served::finish`). The difference is the trip between them.
206- `d1`: how each session-capable service was asked to read.
207
208Git requests keep their own header (`repos;dur` plus the repos service's
209steps). Mission control keeps its per-section timings.
210
211## What a page does, in rounds
212
213Rounds are what cost: calls in the same round overlap.
214
215| Page | Before | Now |
216| --- | --- | --- |
217| Any in-app navigation | root (sidebar: 6 calls, then up to 20 `get_by_id` for shared repositories) and the project layout re-ran when moving between pages of a project | root re-runs only when the workspace or project changes, or after a form; the project layout likewise; shared repositories are one `readable` call, in the same round; the sidebar's workspace data is cached for 15 s; open counts are read once per request for both |
218| Pull request ("Review and respond") | access, then 8 calls, then checks' runs / comparison / session, then up to 5 more deployments lookups for stacked previews | one round of 9 (access-dependent ones start as soon as the repository lookup returns), then the comparison on Changes; the workflow jobs and stacked previews stream in |
219| Project overview | access, then 17 calls, one of which (Active branches) read the default branch's last 120 commits and up to 10 branches' last 40 | one round; Active branches streams in with a skeleton, reading logs by commit hash so a branch that has not moved costs nothing |
220| Mission control | per project: open pulls, closed pulls and events (3 × up to 10), then `get_by_id` per unknown repository | one `pulls_for_repos` call for every project (one access check, one query), events per project alongside, one `readable` for the rest |
221| Issue, issues | access, then the rest | one round |
222
223## Client navigation
224
225- `<Link prefetch="intent">` on the sidebar, project tabs, breadcrumbs,
226 list rows and Mission control rows: hovering loads the next page's code
227 and data.
228- A thin progress bar while a navigation is pending (`Progress` in
229 `components/shell.tsx`).
230- Mission control's code loads while the browser is idle after the app
231 shell paints; its skeleton is rarely seen.
232- Mission control's quick actions show as done when sent and undo on
233 failure.
234- Skeletons (`components/ui/skeleton.tsx`: `Skeleton`, `SkeletonText`,
235 `SkeletonRows`) stand in, at the same size, wherever something arrives
236 after the page: the account menu's name, address and invites (now also
237 fetched when the pointer reaches the button), Active branches, the
238 statement's next entries, the command palette's results, blame's "why".
239
240## Static assets
241
242Hashed, immutable, a year. Signed in, the first page loads about 120 KB of
243JavaScript gzipped for React and the router, plus the page's own (the pull
244request page about 240 KB in all, most of it the markdown renderer and
245shared components); later pages load only what they add, usually on hover.
246Shiki's grammars load only when code is highlighted, on the server too
247(`lib/highlight.server.ts`), so a Worker starting up no longer evaluates
248them.
249
250## Budget
251
252| | Target (from the US) |
253| --- | --- |
254| Public page, signed out (cached) | p50 time to first byte under 200 ms |
255| Signed-in page | p50 under 400 ms to first byte |
256| In-app navigation | under 300 ms until the new page shows (data prefetched on hover) |
257| Streamed panels | within 1 s |
258
259The status page's **Page speed** part checks a public project page and
260Explore every minute and shows them as degraded over 800 ms
261(`apps/status/src/components.ts`, `SPEED_BUDGET_MS`).
262
263## Measuring
264
265```powershell
266# Signed out
267powershell -File scripts/perf/measure.ps1 -Runs 7 -Out before.csv
268# Signed in: your g1t_session cookie's value, from DevTools; never printed
269$env:G1T_SESSION = "<64 hex>"
270powershell -File scripts/perf/measure.ps1 -Runs 7 -Pull 12 -Issue 11 -Out before-signed-in.csv
271```
272
273It prints p50 and p90 of the server's share (TLS handshake done to first
274byte), where the Worker ran, and the slowest Server-Timing entries.