A cold cache is not free
My feature-flags page felt instant once I cached it, and the first request to each fresh worker after a deploy still paid about fifteen seconds.
By Andrew Pyle
Clicking a page twice to see whether it is fast only ever measures the second click.
01Twenty-two seconds
The page I thought I had fixed
The production numbers came back as three runs of the feature-flags list view: 22,812, 22,278 and 22,456 milliseconds, each with 85 queries. Flipping a single flag took between 15.7 and 16.7 seconds. I had already written a fix for this page by then, and on my machine it measured 31 milliseconds. Both numbers were real, taken at different moments in the life of a process.
The page is an admin view over the app's feature flags, 84 of them. To build it, the server scans the codebase and the docs for flags and reads each flag's state from the database. The page and the flip had both been slow, slow enough that I went looking.
02Three costs per request
Every load redid all of the work
The page was paying for three things on every request. It re-ran a code scan that walked the source tree and parsed about 4,000 Python files, plus a scan of the docs. It queried the database once per flag to resolve its state. And a flip re-ran both scans, after which the page threw away its data and fetched the whole board again, paying for the full computation a second time.
Locally, against the production database in read-only mode, the computation took 7,263 milliseconds and 85 queries for 84 flags. Enumerating the flags alone was 4,378 of those milliseconds. The query count grew with the number of flags, which is the shape I like least, because it gets worse by itself as the app grows.
The first change went after all three. Scan results are cached, keyed on the git commit the process booted with plus the scan roots, and nothing is cached when that commit is unknown. All flag rows load in one query. A flip now returns only the row that changed, and the front end patches that row into its cache instead of refetching the board, with a refetch as the fallback if the response has no row. Cached results are deep-copied on every call, so one caller cannot corrupt what the next one sees.
Warm, the page dropped to 31 milliseconds with one query, and a flip to 26 milliseconds with one query. A guard test pins the query count so it cannot grow with the flag count. Reverting to per-flag lookups fails it with 4 queries for 3 flags but 41 for 40.
03The note I left myself
The first request still paid
In that same change, under the things I had not checked, I wrote down the hole in plain words:
First request per gunicorn worker after a deploy still pays the cold scan. No boot-time pre-warm was added.
Locally that first call took 4.4 seconds. I listed it as a known gap and moved on.
The production baseline is what made the gap expensive. I ran it on the production host, in-process, with query counts:
production, cold worker time (ms) queries
list view 22,812 / 22,278 / 22,456 85
flip view 16,666 / 16,201 / 15,674 5
enumerate_flags() about 13,900
scan_documented_flags() about 530Enumerating the flags alone took about 13,900 milliseconds, more than three times the local figure, and the docs scan about 530. The app deploys itself several times a day. Each deploy starts fresh web workers, and each worker begins with an empty cache, because the cache lives in that process's memory. So the first request to each worker after each deploy paid about 15 seconds, and since I am the one who uses that page, I was often the one who landed on a cold worker.
Every check I had run was against a warm process. The cached page answered in 31 milliseconds because some earlier request had already paid the full price. Clicking the page a second time to see whether it was fast told me nothing about the first click.
04Pay earlier
Build the cache before anyone asks
The second change warms the scan when each web worker boots. A background thread starts from the app's startup hook, records the boot commit if nothing has yet, and builds both scans. It reads files only and never touches the database. If it fails, it logs a warning and never raises, and requests fall back to the lazy scan they used before.
It only starts under the real web server. The thread checks whether the process was launched by gunicorn, so management commands, Celery workers, tests and the dev server never start it. They do not need a warm flag cache, and they should not spend fourteen seconds of CPU at startup on one.
The cost does not go away. Each web worker still spends about 14 seconds of CPU on the scan, the same work it used to do on the first request. The warm-up moves that cost onto boot, where nobody is waiting on it, and the total stays the same.
Two things are still open. The gunicorn check depends on the process name matching the path in the service definition, and I did not run it under real gunicorn locally. The target I set in advance was a console page load under one second right after a deploy, and that after-deploy timing has not been recorded yet. Until it is, the warm-up is a change I expect to work and have not yet watched work. The part I would redo is the note in the first change. I wrote down that the first request still paid and treated it as a footnote, when that first request was the one I kept hitting.
Related
writing
A limit that was never enforced
Eighty of my scheduled tasks ran on a time limit different from the one they declared, because the loader quietly threw it away, and the first plan I built on those numbers was wrong.
writing
A deploy should drain, not kill
A routine deploy quietly ate a day of work, and the monitor that was supposed to notice reported nothing wrong.
writing
A 200 is not the same as access
My leak probe failed with seventeen leaks on its first real run. Every one of them was the same page that was never a leak.
writing
The list that shipped the whole record
A decisions list took almost eight seconds to show 117 rows, because it was sending the entire record for a view that only renders a line.
writing
Deployed is not the same as served
The build passed, the deploy passed, and the page a visitor got was still the old one.