···11+# builder off-box: completing the lambda shape
22+33+status: **in progress** (2026-07-25). companion to [scaling-plan.md](scaling-plan.md)
44+(this executes its steps 2-revision and 3) and to typeahead's
55+`docs/typeahead-index-design.md` / the "pendulum backswing" post, which this
66+deliberately copies. typeahead lifted its builder off fly onto heavypad via
77+prefect in june 2026 (`my-prefect-server/flows/typeahead_index.py`); pub-search
88+adopted typeahead's snapshot pipeline but kept the builder on a fly machine
99+with an hourly schedule — and that residue is the recurring failure.
1010+1111+## why the fly builder keeps paging us
1212+1313+freshness currently comes ONLY from snapshot cadence (no overlay), so the
1414+builder is on an hourly leash with a wall-clock watchdog:
1515+1616+- build time grew 17→40min with the corpus (full 600MB content re-page from
1717+ turso every hour); turso slow patches push past the watchdog → killed build
1818+ → stale-freshness alert (2026-07-23, -24, -25)
1919+- the watchdog value itself was reverted by the deploy-time reconcile script
2020+ (2026-07-24) — machine-level tuning does not survive the pipeline
2121+- hourly full-table scans of turso are standing load the serving path feels
2222+2323+the lambda framing (see notes repo `architecture/serving-from-snapshots.md`):
2424+batch cadence is load-bearing *only because the speed layer is missing*.
2525+typeahead's answer: builder = pure batch, off-SLA, on the home box ("a
2626+late/failed run just leaves the prior snapshot serving"), freshness = overlay.
2727+2828+## target
2929+3030+```
3131+heavypad (home-pool prefect worker) fly
3232+───────────────────────────────── ─────────────────────────────────
3333+pub-search-snapshot flow (daily): backend serves search unchanged:
3434+ clone pub-search → zig build → promote watcher polls R2,
3535+ BUILDER_MODE=1 CHANNEL=prod → verifies manifest, adopts.
3636+ gated build → rclone → R2 (adoption is ~1s since sidecar
3737+ attestation)
3838+```
3939+4040+- same binary, same gates (doc-count vs turso, FTS sentinel, quick_check),
4141+ same R2 bucket/channels, same manifest contract. the serving side cannot
4242+ tell where a snapshot was built — that is what makes the cutover clean.
4343+- no watchdog pressure: prefect flow timeout is generous (hours); a slow or
4444+ failed run just leaves the previous snapshot serving. no SLA, no restart
4545+ loop, no fly scan-storm.
4646+- cadence: hourly-equivalent freshness is NOT preserved by this phase alone;
4747+ see sequencing. overlay (phase 2) is what buys the relaxed cadence.
4848+4949+## sequencing (no downtime, verify before delete)
5050+5151+1. **flow lands** in my-prefect-server (`flows/pub_search_snapshot.py`,
5252+ modeled 1:1 on `typeahead_index.py`): clone → `cd backend && zig build
5353+ -Doptimize=ReleaseSafe` → run `pub-search` with `BUILDER_MODE=1`.
5454+ env via secret blocks: shared `turso-url`/`turso-token` (already the leaf
5555+ db — same blocks atlas uses) + new `leaflet-index-r2-*` blocks
5656+ (`INDEX_R2_{ENDPOINT,BUCKET,ACCESS_KEY_ID,SECRET_ACCESS_KEY}`, bucket
5757+ `leaflet-search-index`). `RCLONE_BWLIMIT` set like typeahead's.
5858+2. **staging soak**: run the deployment with `BUILDER_CHANNEL=staging` from
5959+ heavypad; verify `latest.staging.json` + manifest sha/counts against a
6060+ fly-built prod manifest of the same hour.
6161+3. **prod flip**: set `BUILDER_CHANNEL=prod` + `BUILDER_ALLOW_PROD=1` on the
6262+ deployment; watch one publish → fly promote watcher adopts it (log:
6363+ `local db: adopted pending snapshot`). fly builder machine still scheduled
6464+ at this point — pointer-last upload makes concurrent builders safe (last
6565+ writer wins a whole snapshot, never a torn one).
6666+4. **decommission fly builder** (same day): destroy machine
6767+ `snapshot-builder-hourly`; DELETE `scripts/reconcile-snapshot-builder`,
6868+ `scripts/guard-snapshot-builder`, their workflow step + path triggers in
6969+ `.github/workflows/deploy-backend.yml`, and the builder-machine runbooks
7070+ in CLAUDE.md. builder *code* (builder.zig, r2.zig, BUILDER_* env handling)
7171+ stays — it is what heavypad runs. the watchdog stays too (flow-level belt
7272+ + binary-level suspenders), default bumped so ad-hoc runs aren't leashed.
7373+5. **cadence**: start at every 2h (bandwidth ~14GB/day upload at 600MB —
7474+ fine on the uplink with RCLONE_BWLIMIT; revisit vs typeahead's every-3-day
7575+ + overlay endgame). freshness alert threshold adjusted to cadence + build
7676+ time + margin.
7777+6. **phase 2 — live overlay** (separate design, lands independently): ingest
7878+ path projects creates/updates/deletes into a small overlay next to the
7979+ snapshot (authoritative, not a cache; compacted at adoption by
8080+ `source_watermark`). then cadence relaxes to daily and the alert measures
8181+ something the overlay has already fixed.
8282+8383+## R2 credential note
8484+8585+the fly builder used `INDEX_R2_*` app secrets (values not readable back).
8686+heavypad needs its own: mint a fresh R2 API token scoped to
8787+`leaflet-search-index` (account has `CLOUDFLARE_API_TOKEN` locally) and store
8888+as prefect secret blocks. fly serving machines keep their existing secrets —
8989+read path is untouched.
9090+9191+## what "solved completely" means here
9292+9393+the failure class was: *builder duration/health coupled to serving freshness
9494+through a tight schedule on infrastructure that fights long batch jobs*.
9595+after this: builds are batch work on a 24-core box with no wall-clock
9696+opponent, publishing is best-effort with gates, and a missed run costs
9797+nothing until the overlay (phase 2) makes even cadence mostly cosmetic.