Skip to content

fix(switchyard): memoise the machine lookup instead of listing per request - #68

Merged
jhgaylor merged 1 commit into
mainfrom
fix/switchyard-api-load
Sep 7, 2026
Merged

fix(switchyard): memoise the machine lookup instead of listing per request#68
jhgaylor merged 1 commit into
mainfrom
fix/switchyard-api-load

Conversation

@jhgaylor

@jhgaylor jhgaylor commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Why

Measured in production on 2026-09-07, the demo apps running on the operator's server key against managoat.com were behind a four-day OOM / DB-pool-exhaustion incident. GET /api/conversations was being called ~50,000 times an hour (14/s); each call returns every conversation on the account (~1,000 rows) and runs an aggregate server-side.

Mechanism (switchyard's share)

  • machineOf() and conversationsOf() in server/tracks.ts listed the project's conversations on every call, and are reached from ~25 sites: every file, diff and listing read (tracks.ts), the terminal (terminal.ts), the vitals readout every 20 s per viewer (vitals.ts, src/components/Vitals.tsx), the preview reconciler every 15 s (previews.ts), the shared browser (browsers.ts), the agent preview helper (agent-previews.ts) and the native runner (native-experiment.ts).
  • machinesFor() in server/projects.ts listed the whole account unfiltered on every load of the rail (GET /api/projects, GET /api/projects/:id).
  • spriteFor() fetched the sandbox on every terminal/vitals/preview call, though a sandbox's sprite never changes.

The track stream (/api/tracks/:id/stream) is an EventSource over a pipe of Fountain's stream, which Fountain holds open with heartbeats, so switchyard was not the source of the 2/s stream reopen rate (that was paddock, #67).

What changed

  • New server/machine-cache.ts: the agent-narrowed list is memoised for 5 s per Fountain client and project, concurrent misses share one in-flight promise, and a failed read is not remembered. forgetProject() is called where the answer changes: opening a track (may provision the machine), closing one, rebuild and destroy.
  • machineOf() reads from the memo. conversationsOf(), which the sidebar's running/idle status comes from, reads fresh and writes through, so a turn that ended never shows as running for another five seconds, while the burst of machine reads around it is free.
  • machineOf(…, { fresh: true }) at the three guards whose whole job is to notice a replaced machine: the preview reconciler's ensureRunning, its readiness check, and the agent helper's grant check. The existing tests for those guards are what caught this.
  • spriteFor() memoised for a minute per sandbox id; a "not a sprite" answer for 5 s only.
  • machinesFor() makes one memoised, narrowed list per project instead of one unfiltered list of the account.

Not touched: native-experiment.ts (its callers go through machineOf and get the memo; its own replaced-workspace check runs on a session lifecycle rather than per request), the client-side polls (Vitals, previews, shared browser and native preview already pause when the tab is hidden), and the wake-turn readMachine loop in src/lib/api.ts (bounded to 30 s, only after an explicit user action; its per-second reads now hit the memo).

Tests

server/machine-cache.test.ts (7 tests) pins one call per burst and coalescing, expiry, fresh-read write-through, forgetProject, per-client isolation, failures not being remembered, and the two sprite TTLs. Full suite: 396 pass, 1 skip (pre-existing). tsc --noEmit and bun run build are green locally.

🤖 Generated with Claude Code

https://claude.ai/code/session_018R1XyyWd9MDMYXqFL71owk

…quest

Measured in production on 2026-09-07, the demo apps on the operator's server
key were behind a four-day OOM / DB-pool incident on managoat.com, with
GET /api/conversations at ~50,000 calls an hour. Switchyard's share came from
machineOf() and conversationsOf() in tracks.ts, which listed the project's
conversations on every call and were reached from ~25 sites: every file, diff
and listing read, the terminal, the vitals readout (every 20 s per viewer), the
preview reconciler (every 15 s), the shared browser and the native runner. And
machinesFor() in projects.ts listed the *whole account* — every conversation
the key has ever had, with an aggregate over each — on every load of the rail.

- server/machine-cache.ts memoises the agent-narrowed list for 5 s per Fountain
  client and project, with concurrent misses sharing one in-flight promise.
  Opening a track (which may provision the machine), closing one, rebuilding
  and destroying the project call forgetProject().
- machineOf() reads from the memo. conversationsOf(), which the sidebar's
  status comes from, reads fresh and writes through, so a turn that ended never
  shows as running for another five seconds while the burst of machine reads
  around it is free.
- machineOf({ fresh: true }) is used at the three guards whose job is to notice
  a replaced machine: the preview reconciler, its readiness check, and the
  agent helper's grant check.
- spriteFor() is memoised for a minute per sandbox id (a sandbox's sprite does
  not change); a "not a sprite" answer only for 5 s.
- machinesFor() makes one memoised, agent-narrowed list per project instead of
  one unfiltered list of the account.

machine-cache.test.ts pins the behaviour: one call per burst, expiry, fresh
reads refreshing the memo, forgetProject, per-client isolation, failures not
remembered, and the two sprite TTLs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018R1XyyWd9MDMYXqFL71owk
@jhgaylor
jhgaylor merged commit 656c390 into main Sep 7, 2026
2 checks passed
@jhgaylor
jhgaylor deleted the fix/switchyard-api-load branch September 7, 2026 04:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant