Repository navigation
Hibernate connection processes after 15 seconds idle - #743
Merged
Merged
Conversation
An idle connection costs more memory than it needs to. When a keepalive HTTP/1 connection finishes a request, its process keeps whatever heap that request grew: the parsed headers, the conn, the response, all of it garbage now, but it stays until the next collection, and Bandit only forces one every five requests. So a connection that served one request and then went quiet, which is most browser connections most of the time, holds that request's leftovers for as long as it stays open. HTTP/2 connections do the same, and a WebSocket keeps the remains of its upgrade. How much depends on what the plug did: 9 KB for a plug that just sends a response, 21 KB once it does as much as parsing a query string, and 588 KB after serving a templated 60 KB page, in each case for a connection that is doing nothing. The VM has a fix for exactly this: `hibernate_after`. A GenServer started with it hibernates once it has had no message for that long, which runs a full collection, compacts the heap and parks the process; the next message wakes it as usual. This PR sets `genserver_options: [hibernate_after: 15_000]` in the options Bandit passes to Thousand Island, the same way it already defaults the port, transport and handler. Anything the user puts in `genserver_options` is kept, so `hibernate_after: :infinity` turns it off and any other value changes the delay. Nothing else changes: a connection in use never goes 15 seconds without a message, so the only connections that hibernate are the ones sitting idle. This belongs in Bandit rather than Thousand Island, I think. Thousand Island hands `genserver_options` to the handler untouched and stays out of protocol decisions, and what "idle" means and how much a request leaves behind are properties of the protocol. Bandit already owns this kind of per-connection housekeeping (`gc_every_n_keepalive_requests`, `clear_process_dict`, the collection it runs at WebSocket upgrade); this is the piece that covers the time between requests. ## Numbers Measured on `main` at `4812459` in a 2-core VM at 2.1 GHz, OTP 25.3, Elixir 1.17.3. Memory is `Process.info/2` of the connection process, read while the connection is idle; the CPU figures come from `:erlang.statistics/1` on a single scheduler with the client in the same VM, so they are upper bounds. Memory per idle connection, before and after hibernating: | Idle connection | Today | Hibernated | |---|---|---| | HTTP/1 keepalive, plug sends a short body | 8.6 KB | 2.1 KB | | HTTP/1 keepalive, plug also parses the query string | 21.1 KB | 2.1 KB | | HTTP/1 keepalive, plug renders a 60 KB page | 587.7 KB | 2.1 KB | | HTTP/2, one request served | 21.1 KB | 3.3 KB | | WebSocket, after the handshake | 8.6 KB | 2.7 KB | For scale: 3,000 idle connections, a thousand each of the first HTTP/1 case, HTTP/2 and WebSocket, hold 49.6 MB today and 7.9 MB hibernated. With the templated page it is 587 MB per thousand idle connections against 2 MB. What hibernating costs, measured over 2,000 HTTP/1 connections: | Cost | Measured | |---|---| | Hibernating a connection | 8 to 10 us CPU, once per idle period | | First request after waking, CPU | about 8 us more (55 to 64 us) | | First request after waking, latency | p50 80 to 100 us either way, p99 same | | Throughput under keepalive load | 22,089 vs 22,279 req/s, within noise | The last row is the important one for anyone worried about the hot path: a connection under load never hibernates, because it never sits idle for 15 seconds. The cost lands only on connections that were idle, and it is one collection the connection would have had to pay eventually anyway, plus a few microseconds to regrow the heap. For a WebSocket under Phoenix's 30 second heartbeat, which wakes and re-hibernates every cycle, that comes to about 20 us per connection per 30 seconds: roughly 7% of one core for 100,000 connections, against the memory in the table. One side effect that turned out to be a benefit: a connection that was woken serves its next request from a compact heap, so after that request it sits at 8.6 KB instead of growing to 26 KB as it did on `main`. Hibernation resets the growth that keepalive connections otherwise accumulate between forced collections. ## Why 15 seconds Long enough that no connection in use ever hibernates between requests; short enough that the common idle cases do: a browser holding its keepalive connections open for minutes after a page load, a WebSocket between heartbeats, an HTTP/2 connection kept around by a client that has gone quiet. It's also the figure Phoenix uses for the same purpose on channel processes, so a LiveView deployment ends up with both ends of a socket behaving the same way. ## Tests `test/bandit/hibernation_test.exs` is new. Four quick tests use a 100 ms `hibernate_after` passed through `thousand_island_options` to check that an idle connection process actually hibernates and then carries on serving (the next keepalive request, the next HTTP/2 stream, the next WebSocket frame), and that `hibernate_after: :infinity` is respected over Bandit's default, which is the `put_new` behaviour. A fifth test checks the 15 second default itself by waiting for it; it's tagged `:slow` like the other long tests in the suite. Suite, format, credo and dialyzer are clean.
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.
(Claude helped with test cases)
An idle connection costs more memory than it needs to. When a keepalive HTTP/1 connection finishes a request, its process keeps whatever heap that request grew: the parsed headers, the conn, the response, all of it garbage now, but it stays until the next collection, and Bandit only forces one every five requests. So a connection that served one request and then went quiet, which is most browser connections most of the time, holds that request's leftovers for as long as it stays open. HTTP/2 connections do the same, and a WebSocket keeps the remains of its upgrade. How much depends on what the plug did: 9 KB for a plug that just sends a response, 21 KB once it does as much as parsing a query string, and 588 KB after serving a templated 60 KB page, in each case for a connection that is doing nothing.
The VM has a fix for exactly this:
hibernate_after. A GenServer started with it hibernates once it has had no message for that long, which runs a full collection, compacts the heap and parks the process; the next message wakes it as usual. This PR setsgenserver_options: [hibernate_after: 15_000]in the options Bandit passes to Thousand Island, the same way it already defaults the port, transport and handler. Anything the user puts ingenserver_optionsis kept, sohibernate_after: :infinityturns it off and any other value changes the delay. Nothing else changes: a connection in use never goes 15 seconds without a message, so the only connections that hibernate are the ones sitting idle.This belongs in Bandit rather than Thousand Island, I think. Thousand Island hands
genserver_optionsto the handler untouched and stays out of protocol decisions, and what "idle" means and how much a request leaves behind are properties of the protocol. Bandit already owns this kind of per-connection housekeeping (gc_every_n_keepalive_requests,clear_process_dict, the collection it runs at WebSocket upgrade); this is the piece that covers the time between requests.Numbers
Measured on
mainat4812459in a 2-core VM at 2.1 GHz, OTP 25.3, Elixir 1.17.3. Memory isProcess.info/2of the connection process, read while the connection is idle; the CPU figures come from:erlang.statistics/1on a single scheduler with the client in the same VM, so they are upper bounds.Memory per idle connection, before and after hibernating:
For scale: 3,000 idle connections, a thousand each of the first HTTP/1 case, HTTP/2 and WebSocket, hold 49.6 MB today and 7.9 MB hibernated. With the templated page it is 587 MB per thousand idle connections against 2 MB.
What hibernating costs, measured over 2,000 HTTP/1 connections:
The last row is the important one for anyone worried about the hot path: a connection under load never hibernates, because it never sits idle for 15 seconds. The cost lands only on connections that were idle, and it is one collection the connection would have had to pay eventually anyway, plus a few microseconds to regrow the heap. For a WebSocket under Phoenix's 30 second heartbeat, which wakes and re-hibernates every cycle, that comes to about 20 us per connection per 30 seconds: roughly 7% of one core for 100,000 connections, against the memory in the table.
One side effect that turned out to be a benefit: a connection that was woken serves its next request from a compact heap, so after that request it sits at 8.6 KB instead of growing to 26 KB as it did on
main. Hibernation resets the growth that keepalive connections otherwise accumulate between forced collections.Why 15 seconds
Long enough that no connection in use ever hibernates between requests; short enough that the common idle cases do: a browser holding its keepalive connections open for minutes after a page load, a WebSocket between heartbeats, an HTTP/2 connection kept around by a client that has gone quiet. It's also the figure Phoenix uses for the same purpose on channel processes, so a LiveView deployment ends up with both ends of a socket behaving the same way.
Tests
test/bandit/hibernation_test.exsis new. Four quick tests use a 100 mshibernate_afterpassed throughthousand_island_optionsto check that an idle connection process actually hibernates and then carries on serving (the next keepalive request, the next HTTP/2 stream, the next WebSocket frame), and thathibernate_after: :infinityis respected over Bandit's default, which is theput_newbehaviour. A fifth test checks the 15 second default itself by waiting for it; it's tagged:slowlike the other long tests in the suite. Suite, format, credo and dialyzer are clean.