fix(switchyard): memoise the machine lookup instead of listing per request - #68
Merged
Conversation
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/conversationswas 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()andconversationsOf()inserver/tracks.tslisted 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()inserver/projects.tslisted 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 anEventSourceover 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
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,rebuildanddestroy.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'sensureRunning, 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 throughmachineOfand 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-turnreadMachineloop insrc/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 --noEmitandbun run buildare green locally.🤖 Generated with Claude Code
https://claude.ai/code/session_018R1XyyWd9MDMYXqFL71owk