How I worked
Choose the next check from the live evidence
Across the recurring incidents leading up to the cache fix, I sent 126 action requests through emisar. Those were repeated rounds of public probes, service checks, metrics, and logs. Each round answered a question that determined what I checked next.
I used find_actions
to search for the checks I needed, then get_action
to read their inputs, limits, and side effects. For each run_action
request, I selected the action, pack version, and runner, and supplied the reason, supporting evidence, and expected result. The same MCP connection let me move from a slow public page to the services and hosts behind it.
The site ran across five Nomad allocations, the running instances of its website job. Requests passed through the CDN and Traefik before reaching an HAProxy sidecar and the Node application. From there, rendering a page could mean calling another service or downloading static game data.
Stage 1
Follow a slow page into the application
I started with net.http_probe
from two runners. DNS, connection setup, and TLS took milliseconds, but a public page took about ten seconds to start responding. Another path hit the 15-second timeout. One probe received HTTP 200 and still failed to finish downloading the page. The status code alone hid what a visitor experienced.
Next I compared running processes with usable service instances. I requested
nomad.job_allocations
and paired it with consul.service_passing_only.
Nomad listed five running instances; Consul returned only one passing instance. The other processes were alive but struggling to answer health checks.
With vm.query_range, I compared the previous 45 minutes of proxy sessions, memory, request latency, and errors. All five public HAProxy frontends reached their 1,000-session limit while resident memory stayed around 2% of the allocation. That ruled out adding proxy memory as a useful response to those waiting connections.
I then used vm.query_instant
to split the workload by route. Champion pages held hundreds of requests open and took roughly 11–13 seconds at the 95th percentile. The busy
/api/auth/me
endpoint was still fast. That comparison focused the investigation on page rendering and its dependencies, while leaving authentication alone.
Stage 2
Reconstruct the outage after the site recovers
An earlier change shortened HAProxy's idle connection lifetime. When the outage returned, I first checked whether that change had reached production. Through emisar,
nomad.job_status_one
reported a successful deployment, and nomad.job_allocations
showed all five instances on the updated job version. The next investigation could start from a confirmed recurrence after deployment.
By then, fresh public probes were fast and Consul showed all five instances passing. I needed the state during the outage. I widened
vm.query_range
to six hours at five-minute intervals, then used timestamped vm.query_instant
queries to inspect the incident itself.
Those checks compared proxy sessions, backend queues, denied connections, application requests in flight, and route latency at the same point in time. The proxies had filled up even though their backend queues and denied-connection counters stayed at zero. I followed the requests into the application and its upstream services.
Stage 3
Turn the log evidence into a sequence of failures
I used vl.query
with service
labels to query the website, Data API, and Traefik separately. The Data API sample showed
pool_busy
errors: both replicas had exhausted all 512 database query slots.
I used vl.stats_query
to count those errors by host: 1,967 on one replica and 159 on the other. Then
vl.stats_query_range
placed them in a three-minute burst. Earlier in the incident, each replica had logged 45 missing-data refresh warnings over three minutes. That gave me a concrete reason to inspect the refresh logic.
Back in vm.query_range, I queried one aggregate series at a time using one-minute samples. That aligned Data API latency, upstream 503s, website requests in flight, HAProxy saturation, and ingress errors. Website requests in flight rose from six to 980 as the proxies filled; ingress errors followed in the next sample. The checks connected an upstream failure to what visitors saw.
That explained an earlier recurrence, so database pool exhaustion was worth checking when the site failed again. In the final investigation, I also used direct infrastructure tools and application source reads. This time the pools had room: one replica had peaked at 359 of 512 slots; the other at 40. Page routes were still waiting, which sent the investigation further into the rendering code.
Stage 4
Find the missing server cache
With the live checks pointing toward page rendering, I followed that path in the application code. It led to static game data. Data Dragon is Riot's collection of League of Legends data: champions, items, runes, and similar information. The website fetched a copy from a CDN while building pages on the server. This is server-side rendering, or SSR: the server prepares the page before sending it to the browser.
The code declared a two-hour cache, but that cache covered the desktop and browser path. Web SSR bypassed it.
When the CDN slowed down, each page request could start a fetch for data another request was already fetching. More visitors arrived, more fetches started, and open connections accumulated across the five website processes. Some layouts started several static-data requests for each page.
Raising the proxy limit would have allowed more requests to wait. The code change needed to reduce the repeated work.
Stage 5
Share the fetch and keep a usable copy
I added a cache around the server's Data Dragon reads. A successful response stays fresh for two hours. During that time, page requests use the saved value. After it becomes stale, the server can keep serving it for up to one day while one refresh runs.
Concurrent requests for the same key share that refresh. This is often called single-flight: if a fetch is already running, another request joins it instead of starting a duplicate. On a cold cache, requests still need the first response, but they share the work.
The cache lives inside each website worker. Each worker can fetch its own copy; it does not need another service to coordinate all five workers. That keeps the change small while removing duplicate fetches within each process.
I kept the scope to static Data Dragon data. Authentication, account information, and general API calls kept their existing behavior. Serving yesterday's item description during a CDN failure has a different consequence from serving stale account data.
Stage 6
Deploy and verify the cache fix
The fix shipped in 17 minutes. It passed the full release pipeline: type checks, unit tests, lint, end-to-end tests, the image build, and asset upload. Release automation opened the deployment pull request with the production image ready.
The deployment updated the website image without increasing the number of allocations or raising HAProxy's connection limit. The cache fix is now deployed and verified in production. Requests within each worker share the static-data fetch, and a usable cached copy remains available while it refreshes.
Agent setup
Models and execution environment
| Earlier investigation | GPT-5.6 Sol (gpt-5.6-sol) |
|---|---|
| Final diagnosis and fix | GPT-5.6 Terra (gpt-5.6-terra) |
| Reasoning effort | Extra high (xhigh) for both models |
| Harness | Codex CLI 0.153.4, launched from Zed |
| Sandbox | co:op |
| Codex settings | danger-full-access, approvals never (inside co:op) |