26 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, estimate, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | estimate | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| quick-261001-qoa | 01 | execute | 1 |
|
true |
|
|
|
Purpose: the user approved the content analysis in conversation (items 1-9 in the planning context); this plan turns it into a page that passes the docs checker and matches the existing guide pages. Output: docs/architecture/performance-and-scaling.md, a link from docs/index.md and from docs/setup/coming-from-wintercms.md, one docs commit.
Placement decision (planner's discretion): the page goes in the Architecture section, order 50, after Request lifecycle (order 40). It is an explanation of the process model, which is what that section covers (introduction, Go modules, application lifecycle, request lifecycle); Setup holds procedures. Pages are discovered by walking docs/ (internal/docsite/load.go walkPages), the sidebar is ordered by site.yaml section order then front matter order, so no site.yaml change is needed.
<execution_context>
@/.claude/gsd-core/workflows/execute-plan.md
@/.claude/gsd-core/templates/summary.md
</execution_context>
Verified facts the page relies on (planner checked these against the code on 2026-10-01; the executor re-checks every identifier before writing):
- Front matter rules (internal/docsite/load.go): fields title, description, section, order; section must equal the directory and be listed in docs/site.yaml; the first body line must be exactly "# " + title; description at most 160 characters; order unique within the section. Architecture currently uses orders 10, 20, 30, 40.
- Policy (internal/docsite/check_policy.go): headings in docs/ pages are plain ASCII with no links, code spans or raw HTML; callouts are only NOTE, TIP or WARNING (written as a blockquote whose first line is [!NOTE] etc.); every Go-lexer fence needs a src= reference, so the page carries no Go code fences. YAML and sh fences are allowed without src=.
- Commands (internal/docsite/check_commands.go): every
summer <cmd>and./bin/<app> <cmd>in sh/shell/bash/console fences and in code spans must be a real command. Real ones used here:serve,queue:work,migrate,route:list(application binary) andparity:record,parity:replay(summer tool). Other programs (vegeta, k6) are not checked. - Consuming-application names are rejected by internal/docsite/check_forbidden.go (case-insensitive pattern in that file). Say "the application" or "host application"; use
acme/blogas example names, as the other pages do. surf.MemoryStore(in-process, mutex-guarded store),surf.Store(interface with Attempt),surf.NewFixedWindowLimiter(store surf.Store, trusted)exist. BUT surf.BuildRouter (modules/surf/router.go around line 444) always constructs the throttle limiter with NewMemoryStore; neitherservenor any option lets an application supply a different store today. docs/services/rate-limiting.md already states that counters live per instance and the effective limit is the configured limit times the number of instances.bouncer.PostgresBlacklist,bouncer.NewPostgresBlacklist,bouncer.NewMemoryBlacklist,bouncer.BlacklistStoreexist; docs/services/authentication.md section "Refreshing and revoking" documents them.queue.work_in_serveis read in modules/conga/client.go (default true, overridden when the key is set); docs/services/jobs.md section "Running workers" documents it andqueue:work. The worker listens on one dedicated Postgres connection opened fromdatabase.dsn(PgBouncer needs session pooling for it).lagoon.Publish(app, sqlDB, gdb)stores the one shared*sql.DBpool and the GORM handle on the app; conga builds River on that same*sql.DB(riverdatabasesql). lagoon exposes NO pool-size or connection-limit config key; the only database key isdatabase.dsn. Do not name or invent one.party.Activate,cabana.CompiledController(YAML compiled at boot),surf.Assemble(registers routes and compiles the ServeMux) exist; docs/architecture/application-lifecycle.md has the anchor #start-up-sequence.- wire keeps API bodies byte-compatible with a PHP backend (
wire.WriteJSON,wire.Time,wire.TriBool,wire.Sliceexist). - Thumbnails (
attach.File.Thumb, modules/lagoon/attach/thumb.go) are produced with the pure-Go github.com/disintegration/imaging library, not GD/Imagick. - Uploads: the framework registers only the file:// and mem:// gocloud.dev bucket drivers (modules/lagoon/attach/bucket.go); docs/services/storage.md documents only those two. With several replicas a file:// bucket must sit on storage every replica mounts. Do not promise S3/GCS support.
- tide records and replays parity fixtures;
summer parity:record --spec ... --target ... --output ... --vars ...andsummer parity:replay --fixtures ... --target ... --vars ...are the documented forms (docs/services/parity-testing.md, section "The parity commands"). - Realtime: lighthouse publishes to Centrifugo through its Centrifugo driver (docs/services/realtime.md, section "The Centrifugo driver"); the Centrifugo server runs separately.
- Style of existing pages: plain declarative prose, short paragraphs, second person, WinterCMS/Laravel comparison first, British spelling (organised, behaviour), module names linked to their README on first mention (for example surf), example binary ./bin/acme. No emojis, no marketing superlatives.
- Baseline: go test ./cmd/summer -run 'TestDocsTree|TestDocsBuildRealTree' -count=1 passes in about 3 s; go run ./cmd/summer docs:build --check prints "docs:build: no problems found".
Write the introduction (two short paragraphs): WinterCMS runs under PHP-FPM, which boots the application for every request; a SummerCMS application is one Go process that boots once and serves every request from memory. Say the page explains where the speed and memory savings come from, where a port will not get faster, and how to scale and measure an application. State plainly that the figures are typical for PHP-FPM with Laravel compared with Go services and that SummerCMS has no published benchmarks yet; point to the measuring section at the end of the page.
Write the first content section (content item 1), heading "## Boot once, not per request": PHP-FPM is shared-nothing, so every request re-runs the Laravel/Winter bootstrap: service providers, plugin registration, configuration, routes and YAML cache lookups; even with OPcache this typically costs about 15-60 ms before the controller runs. SummerCMS does that work once at start-up: party runs every plugin's Register and Boot (party.Activate), cabana compiles each plugin's fields.yaml and columns.yaml into a cabana.CompiledController at boot, and surf assembles every route into one net/http ServeMux (surf.Assemble). A request then only matches a route and runs its middleware chain, which costs microseconds. Link Application lifecycle and Request lifecycle. End the tracer content there; the remaining sections are added in Task 2 (headings for them are not written yet, so the page never shows an empty section).
Add the inbound links:
- docs/index.md: in the "Where to start" Architecture bullet, extend the list so it also names performance and scaling compared with PHP-FPM, linking performance and scaling; keep the sentence's existing shape.
- docs/setup/coming-from-wintercms.md: append one sentence to the intro paragraph that begins "This page maps the concepts." pointing readers who want to know how the single-binary process model changes speed, memory and scaling compared with PHP-FPM to Performance and scaling.
Rules: headings plain ASCII (write "will not", not contractions with apostrophes, to keep anchors simple), no Go code fences, no consuming-application names (use "the application"), British spelling. Do not commit yet: the user wants the page to land as one commit, made at the end of Task 2. cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go test ./cmd/summer -run 'TestDocsTree|TestDocsBuildRealTree' -count=1 && go run ./cmd/summer docs:build --check && bash -c 'd=$(mktemp -d) && go run ./cmd/summer docs:build --out "$d/site" && test -f "$d/site/architecture/performance-and-scaling.html" && grep -q "performance-and-scaling" "$d/site/index.html"' The page exists with valid front matter (section architecture, order 50, first line "# Performance and scaling"), an introduction and the "Boot once, not per request" section; docs/index.md and docs/setup/coming-from-wintercms.md link to it; TestDocsTree, TestDocsBuildRealTree and docs:build --check pass; a scratch build writes architecture/performance-and-scaling.html and the landing page links it. Nothing committed yet.
Task 2: Write the remaining sections (memory, slow I/O, connections, hydration, expectations, caveats, scaling, measuring), verify every identifier, run the gates and commit once docs/architecture/performance-and-scaling.md docs/services/rate-limiting.md (Named buckets: the per-instance counter sentence), docs/services/jobs.md (Running workers), docs/services/authentication.md lines 50-60 (Refreshing and revoking), docs/services/parity-testing.md lines 80-95 (The parity commands), docs/services/storage.md lines 11-30 (Bucket URLs), docs/services/realtime.md (The Centrifugo driver), modules/surf/router.go lines 434-450, modules/conga/client.go lines 30-60 First verify every identifier and command the page will name, from the repository root: go doc ./modules/surf MemoryStore; go doc ./modules/surf Store; go doc ./modules/surf NewFixedWindowLimiter; go doc ./modules/surf Assemble; go doc ./modules/bouncer PostgresBlacklist; go doc ./modules/bouncer NewPostgresBlacklist; go doc ./modules/lagoon Publish; go doc ./modules/party Activate; go doc ./modules/cabana CompiledController; go doc ./modules/wire WriteJSON; go doc ./modules/lagoon/attach File.Thumb. Hand-check config keys by grep: queue.work_in_serve in modules/conga/client.go, database.dsn in modules/lagoon/connection.go, storage.uploads.bucket_url in modules/lagoon/attach/bucket.go. If any check fails, reword the page around what exists; never name an identifier, command or config key you did not confirm.Then append these sections to docs/architecture/performance-and-scaling.md, in this order (headings are plain ASCII):
"## Memory" (item 2): an FPM worker typically holds 30-80 MB, and FPM needs one worker per concurrent request, so 20 workers take roughly 1-1.5 GB. One SummerCMS process typically sits at 50-150 MB while serving thousands of concurrent requests.
"## Slow I/O and concurrency" (item 3): a request waiting on an external API, SMTP, the search engine or the realtime server holds its FPM worker the whole time; when every worker is busy (pm.max_children reached), new requests queue and p99 latency climbs sharply. In Go each request runs on a goroutine that costs a few KB, so waiting requests do not block others.
"## Database connections" (item 4): PHP-FPM usually opens about one Postgres connection per worker. SummerCMS opens one database/sql pool per process: lagoon publishes it with lagoon.Publish, GORM queries use it and conga runs River jobs on the same pool. When the job worker runs, it adds one dedicated listening connection; link Running workers for the PgBouncer note. Do not name any pool-size or connection-limit config key: lagoon has none.
"## Model hydration" (item 5): Eloquent hydration builds attribute arrays and runs magic accessors and casts per model; GORM scans rows into plain structs through reflection, which costs less per row. Keep this qualitative; do not quote a number.
"## What to expect" (item 6): open with a NOTE callout (a blockquote whose first line is [!NOTE]) that contains the exact phrase "not measurements of SummerCMS": the ranges are typical for PHP-FPM with Laravel compared with Go services, SummerCMS has no benchmarks yet, and the measuring section shows how to get real numbers for an application. Then a table with columns Endpoint shape, Examples, Typical gain over PHP-FPM; rows: trivial or cached responses (health checks, settings or lookup endpoints) - 10-30x throughput per core, sub-millisecond latency in Go; typical CRUD (paginated lists, a record with a relation or two, validated writes) - 3-10x; database-heavy (large joins, aggregates, full-text queries) - 1.2-2x, Postgres time dominates.
"## Where you will not win" (item 7), as a bullet list:
- A line-by-line port keeps the PHP queries, so slow queries and N+1 patterns come over unchanged; fix them (for example with GORM's Preload) once the port passes parity.
- wire keeps response bodies byte-compatible with the PHP backend, so payload size and client-side parsing do not change.
- Rate limiting, accurate to the code: the throttle middleware that
servebuilds keeps its counters in the process (surf.MemoryStore, behind thesurf.Storeinterface), and serve offers no way to supply a different store yet, so with N replicas the effective limit is N times the configured one. Put this in a WARNING callout and give the options that exist today: lower the per-route limits by the replica count, or enforce the limit at the load balancer. Link Rate limiting. Do NOT write that a shared store can be configured. - Revoked tokens: use
bouncer.NewPostgresBlacklist(abouncer.PostgresBlacklist) rather than the in-memory blacklist, so a revocation applies on every replica; link Refreshing and revoking. - Jobs:
serveruns the conga job worker in the same process by default; under load setqueue.work_in_serveto false and run./bin/acme queue:workas separate processes (link Running workers). - Thumbnails are generated in pure Go (
attach.File.Thumb), which can be slower than GD or Imagick for large images. - Garbage collection pauses are sub-millisecond and do not matter at this scale.
"## Scaling and operations" (item 8): the application is one stateless binary that starts in milliseconds, ships in a small container image and needs no OPcache warm-up. Vertical scaling is more cores, with no worker-count tuning. Horizontal scaling is N replicas behind a load balancer sharing Postgres; with several replicas the uploads bucket must also be shared: a file:// bucket on storage every replica mounts (link Storage); apply the rate-limit and blacklist notes above first. Centrifugo scales on its own: lighthouse only publishes to it (link The Centrifugo driver). A short sh fence may show ./bin/acme migrate once before a rollout and ./bin/acme serve --addr 127.0.0.1:8080 per replica behind a reverse proxy.
"## Measuring it yourself" (item 9): record fixtures from the PHP backend with tide, using the summer parity commands exactly as docs/services/parity-testing.md writes them (an sh fence with summer parity:record against the PHP backend and summer parity:replay against the port, loopback addresses, and link The parity commands); once replay passes, drive the same routes under load against both backends with a load tool such as vegeta or k6, on identical hardware and the same Postgres instance, with OPcache enabled and warm on the PHP side, and compare per-route p50 and p99 latency, requests per second and resident memory (RSS). Keep vegeta and k6 in prose; do not invent their flags.
Finally run the gates (see verify), fix every reported problem, then make ONE commit containing only docs/architecture/performance-and-scaling.md, docs/index.md and docs/setup/coming-from-wintercms.md, with the message "docs(architecture): add performance and scaling page compared to PHP-FPM". Per the user's global and project CLAUDE.md, the commit message carries no co-author line and no session attribution trailer of any kind. Do not stage the untracked landing-page zip in the repository root or any .planning file (planning docs are committed separately by the orchestrator). cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./... && go test ./cmd/summer -run 'TestDocsTree|TestDocsBuildRealTree' -count=1 && go run ./cmd/summer docs:build --check && grep -q 'not measurements of SummerCMS' docs/architecture/performance-and-scaling.md && grep -q '[!NOTE]' docs/architecture/performance-and-scaling.md && grep -q '[!WARNING]' docs/architecture/performance-and-scaling.md && grep -q 'surf.MemoryStore' docs/architecture/performance-and-scaling.md && grep -q 'bouncer.NewPostgresBlacklist' docs/architecture/performance-and-scaling.md && grep -q 'queue.work_in_serve' docs/architecture/performance-and-scaling.md && grep -q 'queue:work' docs/architecture/performance-and-scaling.md && grep -q 'parity:record' docs/architecture/performance-and-scaling.md && test "$(grep -ciE 'max_open|max_idle|pool_size' docs/architecture/performance-and-scaling.md)" = 0 && bash -c 'set -e; msg=$(git log -1 --format=%B); files=$(git show --name-only --format= HEAD); ! grep -qi -e co-authored-by -e claude-session <<< "$msg"; test "$(echo $files)" = "docs/architecture/performance-and-scaling.md docs/index.md docs/setup/coming-from-wintercms.md"' The page covers content items 1-9 in the listed sections; the expectations table sits under a NOTE that says the ranges are not measurements of SummerCMS; the rate-limit caveat sits in a WARNING and states that serve always uses surf.MemoryStore with no way to supply another store; no pool-size key is named; every identifier, command and config key was confirmed; go vet, TestDocsTree, TestDocsBuildRealTree and docs:build --check pass; HEAD is a single docs commit touching exactly the three docs files, with no co-author or session trailer.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| framework docs -> public docs site | docs/ pages are published as HTML, Markdown, llms.txt and a search index; anything written here becomes public |
STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-qoa-01 | Information disclosure | docs/architecture/performance-and-scaling.md | low | mitigate | No consuming-application names; TestDocsTree and docs:build --check run the forbidden-name rule over sources and rendered outputs (internal/docsite/check_forbidden.go) |
| T-qoa-02 | Repudiation (misleading guidance) | rate-limit and scaling advice | medium | mitigate | Caveats are written against the code (surf.BuildRouter always uses surf.MemoryStore; bouncer.NewPostgresBlacklist; queue.work_in_serve), and the page must not claim a configurable shared rate-limit store or pool-size key; acceptance greps enforce the latter |
| T-qoa-SC | Tampering | npm/pip/cargo/go installs | low | accept | No dependency is installed or added; docs-only change |
| </threat_model> |
<success_criteria>
- A WinterCMS developer finds "Performance and scaling" last in the Architecture sidebar and from the landing page and the Coming from WinterCMS page.
- The page covers all nine approved content items, frames every number as typical PHP-FPM/Laravel versus Go ranges rather than SummerCMS measurements, and states the multi-replica caveats as the code behaves today.
- All docs gates and go vet are green; one docs commit. </success_criteria>