hyperion-rb 1.2.0 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +40 -0
- data/README.md +92 -14
- data/lib/hyperion/cli.rb +6 -1
- data/lib/hyperion/config.rb +1 -0
- data/lib/hyperion/master.rb +2 -1
- data/lib/hyperion/server.rb +40 -12
- data/lib/hyperion/version.rb +1 -1
- data/lib/hyperion/worker.rb +4 -2
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 154fbd1bc72c4eeb5e0c740354ced74c6fa8ee4295fab8e8551ae83f78313c53
|
|
4
|
+
data.tar.gz: 4eaea8250dc8315318152c4c54a16803cc8a7b81236ecaa5dad9adee1d1ab95b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bed63c053e0f6d24876cde01346809ce830e3548382aab430798ee60b7056f926609190064a38d4f39a0c56b43945f4cc9b1b57fe0c938f24f3e95f1a2e499da
|
|
7
|
+
data.tar.gz: 79decaf41c5b5755e2af9e0ee5475fce3c74bade418dcd7032309a4fa9d9061388cf3757a8ca7191b373ada6c31d2b3eb2453827f569f86a9caeb7970ff27f48
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,45 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.3.1] - 2026-04-27
|
|
4
|
+
|
|
5
|
+
Documentation + observability follow-ups for the 1.3.0 `--async-io` feature. No behaviour changes to existing code paths.
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **Dispatch-path metrics** — `Hyperion::Server` now bumps two new counters so operators can verify which path served their requests:
|
|
9
|
+
- `:requests_threadpool_dispatched` — HTTP/1.1 connection handed to the worker pool (or served inline in `start_raw_loop` when `thread_count: 0`).
|
|
10
|
+
- `:requests_async_dispatched` — HTTP/1.1 connection served inline on the accept-loop fiber under `--async-io`.
|
|
11
|
+
HTTP/2 streams are not bucketed (per-stream counters cover them); the rare TLS+`thread_count: 0` config is also un-counted to avoid misclassification.
|
|
12
|
+
- **`docs/MIGRATING_FROM_PUMA.md`** — new "Fiber-cooperative I/O for PG-bound apps" section near the top, with the Linux 50 ms `pg_sleep` bench summary and the three-prerequisite checklist (`async_io: true` + `hyperion-async-pg` + fiber-aware pool).
|
|
13
|
+
- **README** — `async_io` documented in the config-DSL example block; the new dispatch-path counters listed in the Metrics table.
|
|
14
|
+
- **Specs** — two new examples in `spec/hyperion/server_async_io_spec.rb`:
|
|
15
|
+
- `async_io: true` + `thread_count: 0` boots cleanly and serves a request under a scheduler.
|
|
16
|
+
- Thread-decoupling proof: 5 concurrent requests against a 200 ms fiber-yielding handler complete in <600 ms wall (vs. ~1.0 s if serialized), locking in the architectural promise from the README.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
- N/A — no behavioural changes; metrics are additive, docs are additive.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- N/A.
|
|
23
|
+
|
|
24
|
+
## [1.3.0] - 2026-04-27
|
|
25
|
+
|
|
26
|
+
Adds the structural moat for fiber-cooperative I/O. No breaking changes.
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
- **`async_io: true` config flag** (also `--async-io` CLI flag) — when enabled, the plain HTTP/1.1 accept loop runs each connection on a fiber under `Async::Scheduler` instead of handing it to a worker thread. This is what makes [hyperion-async-pg](https://github.com/andrew-woblavobla/hyperion-async-pg) (and other Async-aware libraries) actually cooperate: each fiber yields the OS thread on socket waits, so one thread can serve N concurrent in-flight DB queries instead of 1. **Default off** to keep the 1.2.0 raw-loop perf for fiber-unaware apps. Trade-off: ~5% throughput hit on hello-world; 5–10× throughput on PG-bound workloads when paired with hyperion-async-pg + a fiber-aware connection pool.
|
|
30
|
+
- **Bench validation (macOS, 50ms PG round-trip, 200 concurrent wrk conns):**
|
|
31
|
+
|
|
32
|
+
| | r/s | p99 |
|
|
33
|
+
|---|---:|---:|
|
|
34
|
+
| Puma 7.2 `-t 5` + plain pg (pool=5) | 88.9 | 2.31 s |
|
|
35
|
+
| **Hyperion 1.3.0 `--async-io -t 5` + hyperion-async-pg (FiberPool=64)** | **1,103.7** | **237 ms** |
|
|
36
|
+
|
|
37
|
+
**12.4× throughput, 9.7× lower p99.** Theoretical ceiling at pool=64 + 50ms query is ~1280 r/s; achieved 86% of it. Linux numbers will land in a follow-up bench section.
|
|
38
|
+
|
|
39
|
+
### Changed
|
|
40
|
+
- TLS / HTTP/2 paths still always use the Async accept loop (unchanged); they ignore the `async_io` flag because they need the scheduler for ALPN handshake yields and per-stream fiber dispatch anyway.
|
|
41
|
+
- When `async_io: true`, plain HTTP/1.1 dispatch bypasses the thread pool and serves the connection inline on the calling fiber. The pool stays in use for the TLS path's `app.call` hops on each h2 stream.
|
|
42
|
+
|
|
3
43
|
## [1.2.0] - 2026-04-27
|
|
4
44
|
|
|
5
45
|
Production hardening + perf round 2. No breaking changes.
|
data/README.md
CHANGED
|
@@ -29,26 +29,27 @@ All numbers are real wrk runs against published Hyperion configs. Hyperion ships
|
|
|
29
29
|
|
|
30
30
|
### Hello-world Rack app
|
|
31
31
|
|
|
32
|
-
`bench/hello.ru`, single worker, parity threads (`-t
|
|
32
|
+
`bench/hello.ru`, single worker, parity threads (`-t 5` vs Puma `-t 5:5`), 4 wrk threads / 100 connections / 15s, macOS arm64 / Ruby 3.3.3, Hyperion 1.2.0:
|
|
33
33
|
|
|
34
|
-
| | r/s | p99 |
|
|
35
|
-
|
|
36
|
-
| **Hyperion default
|
|
37
|
-
|
|
|
38
|
-
| Puma `-t
|
|
34
|
+
| | r/s | p99 | tail vs Hyperion |
|
|
35
|
+
|---|---:|---:|---:|
|
|
36
|
+
| **Hyperion 1.2.0** (default, logs ON) | **22,496** | **502 µs** | **1×** |
|
|
37
|
+
| Falcon 0.55.3 `--count 1` | 22,199 | 5.36 ms | 11× worse |
|
|
38
|
+
| Puma 7.1.0 `-t 5:5` | 20,400 | 422.85 ms | 845× worse |
|
|
39
39
|
|
|
40
|
-
**1.
|
|
40
|
+
**Hyperion: 1.10× Puma throughput, parity with Falcon on throughput, ~10× lower p99 than Falcon and ~845× lower than Puma — while emitting structured JSON access logs the others don't.**
|
|
41
41
|
|
|
42
42
|
### Production cluster config (`-w 4`)
|
|
43
43
|
|
|
44
|
-
Same bench app, `-w 4` cluster, parity threads
|
|
44
|
+
Same bench app, `-w 4` cluster, parity threads (`-t 5` everywhere), 4 wrk threads / 200 connections / 15s, macOS arm64:
|
|
45
45
|
|
|
46
|
-
| | r/s | p99 |
|
|
47
|
-
|
|
48
|
-
|
|
|
49
|
-
|
|
|
46
|
+
| | r/s | p99 | tail vs Hyperion |
|
|
47
|
+
|---|---:|---:|---:|
|
|
48
|
+
| Falcon `--count 4` | 48,197 | 4.84 ms | 5.9× worse |
|
|
49
|
+
| **Hyperion `-w 4 -t 5`** | **40,137** | **825 µs** | **1×** |
|
|
50
|
+
| Puma `-w 4 -t 5:5` | 34,793 | 177.76 ms | 215× worse (1 timeout) |
|
|
50
51
|
|
|
51
|
-
**
|
|
52
|
+
Falcon edges Hyperion ~20% on raw rps at `-w 4` on macOS hello-world. **Hyperion still leads on tail latency by 5.9× over Falcon and 215× over Puma**, and beats Puma on throughput by 1.15×. On Linux production-config and DB-backed workloads (below) Hyperion takes the rps lead too — the macOS hello-world advantage to Falcon disappears once the workload includes any actual work or the kernel is Linux.
|
|
52
53
|
|
|
53
54
|
### Linux production-config (DB-backed Rack)
|
|
54
55
|
|
|
@@ -60,7 +61,61 @@ Same bench app, `-w 4` cluster, parity threads. macOS arm64:
|
|
|
60
61
|
| Hyperion `--no-log-requests` | 6,364 | 1.114× |
|
|
61
62
|
| Puma `-w 4 -t 10:10` (no per-req logs) | 5,715 | 1.000× |
|
|
62
63
|
|
|
63
|
-
Bench is
|
|
64
|
+
Bench is **wait-bound** — ~3-4 ms median is the PG + Redis round-trip, dwarfing the per-request CPU work where Hyperion's optimisations live. With a synchronous `pg` driver, fibers don't help: every in-flight DB call still parks an OS thread, and both servers max out at `workers × threads` concurrent queries. To widen this gap requires either an async PG driver — see [hyperion-async-pg](https://github.com/andrew-woblavobla/hyperion-async-pg) (companion gem; pair with `--async-io` and a fiber-aware pool, see "Async I/O — fiber concurrency on PG-bound apps" below) — or a CPU-bound workload, where Hyperion's lead becomes visible (next section).
|
|
65
|
+
|
|
66
|
+
### Async I/O — fiber concurrency on PG-bound apps
|
|
67
|
+
|
|
68
|
+
Ubuntu 24.04 / 16 vCPU / Ruby 3.3.3, Postgres 17 over WAN, `wrk -t4 -c200 -d20s`. Single worker (`-w 1`) unless noted. All configs returned 0 non-2xx and 0 timeouts. RSS sampled mid-run via `ps -o rss`.
|
|
69
|
+
|
|
70
|
+
**Wait-bound workload** (`bench/pg_concurrent.ru`: `SELECT pg_sleep(0.05)` + tiny JSON):
|
|
71
|
+
|
|
72
|
+
| | r/s | p99 | RSS | vs Puma `-t 5` |
|
|
73
|
+
|---|---:|---:|---:|---:|
|
|
74
|
+
| Puma 8.0 `-t 5` pool=5 | 56.5 | 3.88 s | 87 MB | 1.0× |
|
|
75
|
+
| Puma 8.0 `-t 30` pool=30 | 402.1 | 880 ms | 99 MB | 7.1× |
|
|
76
|
+
| Puma 8.0 `-t 100` pool=100 | 1067.4 | 557 ms | 121 MB | 18.9× |
|
|
77
|
+
| **Hyperion `--async-io -t 5`** pool=32 | 400.4 | 878 ms | 123 MB | 7.1× |
|
|
78
|
+
| **Hyperion `--async-io -t 5`** pool=64 | 778.9 | 638 ms | 133 MB | 13.8× |
|
|
79
|
+
| **Hyperion `--async-io -t 5`** pool=128 | 1344.2 | 536 ms | 148 MB | 23.8× |
|
|
80
|
+
| **Hyperion `--async-io -t 5` pool=200** | **2381.4** | **471 ms** | **164 MB** | **42.2×** |
|
|
81
|
+
| Hyperion `--async-io -w 4 -t 5` pool=64 | 1937.5 | 4.84 s | 416 MB | 34.3× (cold-start p99 — see note) |
|
|
82
|
+
| Falcon 0.55.3 `--count 1` pool=128 | 1665.7 | 516 ms | 141 MB | 29.5× |
|
|
83
|
+
|
|
84
|
+
**Mixed CPU+wait** (`bench/pg_mixed.ru`: same query + 50-key JSON serialization, ~5 ms CPU):
|
|
85
|
+
|
|
86
|
+
| | r/s | p99 | RSS | vs Puma `-t 30` |
|
|
87
|
+
|---|---:|---:|---:|---:|
|
|
88
|
+
| Puma 8.0 `-t 30` pool=30 | 351.7 | 963 ms | 127 MB | 1.0× |
|
|
89
|
+
| Hyperion `--async-io -t 5` pool=32 | 371.2 | 919 ms | 151 MB | 1.05× |
|
|
90
|
+
| Hyperion `--async-io -t 5` pool=64 | 741.5 | 681 ms | 161 MB | 2.1× |
|
|
91
|
+
| **Hyperion `--async-io -t 5` pool=128** | **1739.9** | **512 ms** | **201 MB** | **4.9×** |
|
|
92
|
+
| Falcon `--count 1` pool=128 | 1642.1 | 531 ms | 213 MB | 4.7× |
|
|
93
|
+
|
|
94
|
+
**Takeaways:**
|
|
95
|
+
1. **Linear scaling with pool size** under `--async-io` — `r/s ≈ pool × 12` on this WAN bench. Single-worker pool=200 hits 2381 r/s, **42× Puma `-t 5`** and **5.9× Puma's best** (`-t 30`).
|
|
96
|
+
2. **Mixed workload doesn't kill the win** — Hyperion `--async-io` pool=128 actually goes *up* on mixed (1740 vs 1344 r/s) because CPU work overlaps other fibers' PG-wait windows. This is the honest "what happens to a real Rails handler" answer.
|
|
97
|
+
3. **Hyperion ≈ Falcon within 3-7%** across pool sizes; both fiber-native architectures extract similar value from `hyperion-async-pg`.
|
|
98
|
+
4. **RSS at single-worker scale isn't the architectural moat** — Linux thread stacks are demand-paged; PG connection buffers dominate RSS at pool sizes ≤ 200. The MB-vs-GB story shows up at **idle keep-alive connection scale** (10k+ conns), not in this PG-bound throughput bench. See [Concurrency at scale](#concurrency-at-scale-architectural-advantages) for the connection-count win.
|
|
99
|
+
5. **`-w 4` cold-start caveat** — multi-worker p99 inflates because the bench rackup uses lazy per-process pool init (each worker pays full pool fill on its first request). Production apps avoid this with `on_worker_boot { Hyperion::AsyncPg::FiberPool.new(...).fill }`.
|
|
100
|
+
|
|
101
|
+
Three things must all be true to get this win:
|
|
102
|
+
1. **`async_io: true`** in your Hyperion config (or `--async-io` CLI flag). Default is off to keep 1.2.0's raw-loop perf for fiber-unaware apps.
|
|
103
|
+
2. **`hyperion-async-pg`** installed: `gem 'hyperion-async-pg', require: 'hyperion/async_pg'` + `Hyperion::AsyncPg.install!` at boot.
|
|
104
|
+
3. **Fiber-aware connection pool.** The popular `connection_pool` gem is NOT — its Mutex blocks the OS thread. Use [`async-pool`](https://github.com/socketry/async-pool), `Async::Semaphore`, or hand-roll one (see `bench/pg_concurrent.ru` for a ~30-line FiberPool example).
|
|
105
|
+
|
|
106
|
+
Skip any of these and you get parity with Puma at the same `-t`. Run the bench yourself: `MODE=async DATABASE_URL=... PG_POOL_SIZE=200 bundle exec hyperion --async-io -t 5 bench/pg_concurrent.ru` (in the [hyperion-async-pg](https://github.com/andrew-woblavobla/hyperion-async-pg) repo).
|
|
107
|
+
|
|
108
|
+
### CPU-bound JSON workload
|
|
109
|
+
|
|
110
|
+
`bench/work.ru` — handler builds a 50-key fixture, JSON-encodes a fresh response per request (~8 KB body), processes a 6-cookie header chain. wrk `-t4 -c200 -d15s`, macOS arm64 / Ruby 3.3.3, 1.2.0:
|
|
111
|
+
|
|
112
|
+
| | r/s | p99 | tail vs Hyperion |
|
|
113
|
+
|---|---:|---:|---:|
|
|
114
|
+
| Falcon `--count 4` | 46,166 | 20.17 ms | 24× worse |
|
|
115
|
+
| **Hyperion `-w 4 -t 5`** | **43,924** | **824 µs** | **1×** |
|
|
116
|
+
| Puma `-w 4 -t 5:5` | 36,383 | 166.30 ms (47 socket errors) | 200× worse |
|
|
117
|
+
|
|
118
|
+
**1.21× Puma throughput, 200× lower p99.** This is the gap that hides behind PG-round-trip noise on the DB bench. Hyperion's per-request CPU savings (lock-free per-thread metrics, frozen header keys in the Rack adapter, C-ext response head builder, cached iso8601 timestamps, cached HTTP Date header) land on the wire when the workload is CPU-bound. Falcon edges us 5% on raw r/s but with 24× worse tail — a different tradeoff curve. Reproduce: `bundle exec bin/hyperion -w 4 -t 5 -p 9292 bench/work.ru`.
|
|
64
119
|
|
|
65
120
|
### Real Rails 8.1 app (single worker, parity threads `-t 16`)
|
|
66
121
|
|
|
@@ -77,6 +132,25 @@ Health endpoint that traverses the full middleware chain (rack-attack, locale re
|
|
|
77
132
|
|
|
78
133
|
On Grape and Rails-controller workloads Puma hits wrk's 2 s timeout cap on ~⅔ of requests — its real p99 is censored above 2 s. Hyperion serves all of its requests under 1.2 s with 0 to 16 timeouts. **1.14–1.48× Puma throughput** depending on endpoint.
|
|
79
134
|
|
|
135
|
+
### Static-asset serving (sendfile zero-copy path, 1.2.0+)
|
|
136
|
+
|
|
137
|
+
`bench/static.ru` (`Rack::Files` over a 1 MiB asset), `-w 1`, `wrk -t4 -c100 -d15s`, macOS arm64 / Ruby 3.3.3:
|
|
138
|
+
|
|
139
|
+
| | r/s | p99 | transferred | tail vs winner |
|
|
140
|
+
|---|---:|---:|---:|---:|
|
|
141
|
+
| **Hyperion (sendfile path)** | **2,069** | **3.10 ms** | 30.4 GB | **1×** |
|
|
142
|
+
| Puma `-w 1 -t 5:5` | 2,109 | 566.16 ms | 31.0 GB | 183× worse |
|
|
143
|
+
| Falcon `--count 1` | 1,269 | 801.01 ms | 18.7 GB | 258× worse (28 timeouts) |
|
|
144
|
+
|
|
145
|
+
Throughput is bandwidth-bound on localhost (≈2 GB/s = the loopback memory ceiling), so the throughput column looks like parity. The actual win is in the **tail latency** column: Hyperion's `IO.copy_stream` → `sendfile(2)` path skips userspace entirely, while Puma allocates a String per response and Falcon serializes more aggressively. On real network paths sendfile widens the gap further (kernel-to-NIC zero-copy).
|
|
146
|
+
|
|
147
|
+
Reproduce:
|
|
148
|
+
```sh
|
|
149
|
+
ruby -e 'File.binwrite("/tmp/hyperion_bench_asset_1m.bin", "x" * (1024*1024))'
|
|
150
|
+
bundle exec bin/hyperion -p 9292 bench/static.ru
|
|
151
|
+
wrk --latency -t4 -c100 -d15s http://127.0.0.1:9292/hyperion_bench_asset_1m.bin
|
|
152
|
+
```
|
|
153
|
+
|
|
80
154
|
### Concurrency at scale (architectural advantages)
|
|
81
155
|
|
|
82
156
|
These workloads demonstrate structural differences between Hyperion's fiber-per-connection / fiber-per-stream model and Puma's thread-pool model. Numbers are illustrative; the architecture is what matters. Run on Ubuntu 24.04 / Ruby 3.3.3, single worker, h2load `-c <conns> -n 100000 --rps 1000 --h1`.
|
|
@@ -190,6 +264,8 @@ log_requests true
|
|
|
190
264
|
|
|
191
265
|
fiber_local_shim false
|
|
192
266
|
|
|
267
|
+
async_io false # When true, the plain HTTP/1.1 accept loop runs each connection on a fiber under Async::Scheduler instead of handing it to a worker thread. Required for fiber-cooperative I/O (e.g. hyperion-async-pg). ~5% throughput hit on hello-world; in exchange one OS thread serves N concurrent in-flight DB queries on wait-bound workloads. TLS / HTTP/2 paths always use the async loop and ignore this flag.
|
|
268
|
+
|
|
193
269
|
before_fork do
|
|
194
270
|
ActiveRecord::Base.connection_handler.clear_all_connections! if defined?(ActiveRecord)
|
|
195
271
|
end
|
|
@@ -254,6 +330,8 @@ The default-ON access log path is engineered to stay near-zero cost:
|
|
|
254
330
|
| `parse_errors` | HTTP parse failures → 400. |
|
|
255
331
|
| `app_errors` | Rack app raised → 500. |
|
|
256
332
|
| `read_timeouts` | Per-connection read deadline hit. |
|
|
333
|
+
| `requests_threadpool_dispatched` | HTTP/1.1 connection handed to the worker pool (or served inline in `start_raw_loop` when `thread_count: 0`). The default dispatch path. |
|
|
334
|
+
| `requests_async_dispatched` | HTTP/1.1 connection served inline on the accept-loop fiber under `--async-io`. Operators can use the ratio against `requests_threadpool_dispatched` to verify fiber-cooperative I/O is actually engaged. |
|
|
257
335
|
|
|
258
336
|
```ruby
|
|
259
337
|
require 'hyperion'
|
data/lib/hyperion/cli.rb
CHANGED
|
@@ -57,6 +57,10 @@ module Hyperion
|
|
|
57
57
|
'Enable Ruby YJIT (default: auto on RAILS_ENV/RACK_ENV=production/staging)') do |v|
|
|
58
58
|
cli_opts[:yjit] = v
|
|
59
59
|
end
|
|
60
|
+
o.on('--[no-]async-io',
|
|
61
|
+
'Run plain HTTP/1.1 connections under Async::Scheduler (required for hyperion-async-pg and other fiber-cooperative I/O; default off)') do |v|
|
|
62
|
+
cli_opts[:async_io] = v
|
|
63
|
+
end
|
|
60
64
|
o.on('-h', '--help', 'show help') do
|
|
61
65
|
puts o
|
|
62
66
|
exit 0
|
|
@@ -114,7 +118,8 @@ module Hyperion
|
|
|
114
118
|
read_timeout: config.read_timeout,
|
|
115
119
|
max_pending: config.max_pending,
|
|
116
120
|
max_request_read_seconds: config.max_request_read_seconds,
|
|
117
|
-
h2_settings: Master.build_h2_settings(config)
|
|
121
|
+
h2_settings: Master.build_h2_settings(config),
|
|
122
|
+
async_io: config.async_io)
|
|
118
123
|
server.listen
|
|
119
124
|
scheme = tls ? 'https' : 'http'
|
|
120
125
|
Hyperion.logger.info { { message: 'listening', url: "#{scheme}://#{server.host}:#{server.port}" } }
|
data/lib/hyperion/config.rb
CHANGED
|
@@ -31,6 +31,7 @@ module Hyperion
|
|
|
31
31
|
admin_token: nil, # String. When set, exposes admin endpoints (POST /-/quit triggers graceful drain; GET /-/metrics returns Prometheus-format Hyperion.stats). Same token guards both. nil disables admin entirely (paths fall through to the app).
|
|
32
32
|
max_pending: nil, # Integer, e.g. 256. When the per-worker accept inbox has this many queued connections, additional accepts are rejected with HTTP 503 + Retry-After:1 instead of being queued. nil disables (current behaviour: unbounded queue).
|
|
33
33
|
max_request_read_seconds: 60, # Numeric. Total wallclock budget (seconds) for reading the request line + headers + body for ONE request. Defends against slowloris-style drips that satisfy the per-recv read_timeout but never finish the request. Resets between requests on a keep-alive connection. nil disables.
|
|
34
|
+
async_io: false, # When true, the plain HTTP/1.1 accept loop runs each connection on a fiber under Async::Scheduler instead of handing it to a worker thread. Required for fiber-cooperative I/O (e.g. hyperion-async-pg). Costs ~5% throughput on hello-world; in exchange one OS thread can serve N concurrent in-flight DB queries on wait-bound workloads. TLS / HTTP/2 paths always use the async loop and ignore this flag.
|
|
34
35
|
h2_max_concurrent_streams: 128, # HTTP/2 SETTINGS_MAX_CONCURRENT_STREAMS — cap on simultaneously-open streams per connection. Falcon: 64. nil leaves protocol-http2 default (0xFFFFFFFF).
|
|
35
36
|
h2_initial_window_size: 1_048_576, # HTTP/2 SETTINGS_INITIAL_WINDOW_SIZE (octets) — flow-control window per stream at open. Bigger = fewer WINDOW_UPDATE round-trips on large bodies. Spec default is 65535. nil → leave protocol default.
|
|
36
37
|
h2_max_frame_size: 1_048_576, # HTTP/2 SETTINGS_MAX_FRAME_SIZE (octets) — biggest DATA/HEADERS frame we'll accept. Spec floor 16384, ceiling 16777215. We pick 1 MiB to match common CDNs without unbounded buffer growth. nil → leave protocol default (16384).
|
data/lib/hyperion/master.rb
CHANGED
|
@@ -166,7 +166,8 @@ module Hyperion
|
|
|
166
166
|
worker_index: worker_index,
|
|
167
167
|
max_pending: @config.max_pending,
|
|
168
168
|
max_request_read_seconds: @config.max_request_read_seconds,
|
|
169
|
-
h2_settings: Master.build_h2_settings(@config)
|
|
169
|
+
h2_settings: Master.build_h2_settings(@config),
|
|
170
|
+
async_io: @config.async_io
|
|
170
171
|
}
|
|
171
172
|
# Hand the inherited socket to the worker in :share mode. In
|
|
172
173
|
# :reuseport mode the worker binds its own with SO_REUSEPORT.
|
data/lib/hyperion/server.rb
CHANGED
|
@@ -42,7 +42,7 @@ module Hyperion
|
|
|
42
42
|
|
|
43
43
|
def initialize(app:, host: '127.0.0.1', port: 9292, read_timeout: DEFAULT_READ_TIMEOUT_SECONDS,
|
|
44
44
|
tls: nil, thread_count: DEFAULT_THREAD_COUNT, max_pending: nil,
|
|
45
|
-
max_request_read_seconds: 60, h2_settings: nil)
|
|
45
|
+
max_request_read_seconds: 60, h2_settings: nil, async_io: false)
|
|
46
46
|
@host = host
|
|
47
47
|
@port = port
|
|
48
48
|
@app = app
|
|
@@ -52,6 +52,7 @@ module Hyperion
|
|
|
52
52
|
@max_pending = max_pending
|
|
53
53
|
@max_request_read_seconds = max_request_read_seconds
|
|
54
54
|
@h2_settings = h2_settings
|
|
55
|
+
@async_io = async_io
|
|
55
56
|
@thread_pool = nil
|
|
56
57
|
@stopped = false
|
|
57
58
|
end
|
|
@@ -107,16 +108,23 @@ module Hyperion
|
|
|
107
108
|
listen unless @server
|
|
108
109
|
@thread_pool = ThreadPool.new(size: @thread_count, max_pending: @max_pending) if @thread_count.positive?
|
|
109
110
|
|
|
110
|
-
if @tls
|
|
111
|
+
if @tls || @async_io
|
|
111
112
|
# TLS path: ALPN may pick `h2`, and h2 spawns one fiber per stream
|
|
112
113
|
# inside Http2Handler. Keep the Async wrapper so the scheduler is
|
|
113
114
|
# available for those fibers and for handshake yields.
|
|
115
|
+
#
|
|
116
|
+
# async_io: true: operator opt-in for plain HTTP/1.1. The Async wrap
|
|
117
|
+
# is required when callers want fiber cooperative I/O — e.g.
|
|
118
|
+
# `hyperion-async-pg` yielding while a Postgres query is in flight.
|
|
119
|
+
# Pays ~5% throughput vs the raw-loop fast path; in exchange one
|
|
120
|
+
# OS thread can serve N concurrent in-flight DB queries instead of 1.
|
|
114
121
|
start_async_loop
|
|
115
122
|
else
|
|
116
|
-
# Plain HTTP/1.1: the worker thread owns
|
|
117
|
-
# lifetime, so the Async wrapper adds zero
|
|
118
|
-
# run on this loop's task). Skip it — pure
|
|
119
|
-
# shaves measurable overhead off the
|
|
123
|
+
# Plain HTTP/1.1, async_io: false (default): the worker thread owns
|
|
124
|
+
# each connection for its lifetime, so the Async wrapper adds zero
|
|
125
|
+
# value (no fibers ever run on this loop's task). Skip it — pure
|
|
126
|
+
# IO.select + accept_nonblock shaves measurable overhead off the
|
|
127
|
+
# accept hot path.
|
|
120
128
|
start_raw_loop
|
|
121
129
|
end
|
|
122
130
|
ensure
|
|
@@ -143,11 +151,14 @@ module Hyperion
|
|
|
143
151
|
|
|
144
152
|
apply_timeout(socket)
|
|
145
153
|
if @thread_pool
|
|
146
|
-
|
|
147
|
-
|
|
154
|
+
if @thread_pool.submit_connection(socket, @app,
|
|
155
|
+
max_request_read_seconds: @max_request_read_seconds)
|
|
156
|
+
Hyperion.metrics.increment(:requests_threadpool_dispatched)
|
|
157
|
+
else
|
|
148
158
|
reject_connection(socket)
|
|
149
159
|
end
|
|
150
160
|
else
|
|
161
|
+
Hyperion.metrics.increment(:requests_threadpool_dispatched)
|
|
151
162
|
Connection.new.serve(socket, @app, max_request_read_seconds: @max_request_read_seconds)
|
|
152
163
|
end
|
|
153
164
|
end
|
|
@@ -172,18 +183,35 @@ module Hyperion
|
|
|
172
183
|
if socket.is_a?(::OpenSSL::SSL::SSLSocket) && socket.alpn_protocol == 'h2'
|
|
173
184
|
# HTTP/2: each stream runs on a fiber inside Http2Handler. The
|
|
174
185
|
# handler still uses the pool's `#call` for app.call hops on each
|
|
175
|
-
# stream (one per stream, not one per connection).
|
|
186
|
+
# stream (one per stream, not one per connection). Per-stream
|
|
187
|
+
# counters live inside Http2Handler; we don't bump either of the
|
|
188
|
+
# H1 dispatch buckets here — neither fits the h2 model cleanly.
|
|
176
189
|
Http2Handler.new(app: @app, thread_pool: @thread_pool, h2_settings: @h2_settings).serve(socket)
|
|
190
|
+
elsif @async_io
|
|
191
|
+
# async_io plain HTTP/1.1: serve inline on the calling fiber so the
|
|
192
|
+
# request runs *under* Async::Scheduler. This is what makes
|
|
193
|
+
# hyperion-async-pg (and other Async-aware libraries) actually
|
|
194
|
+
# cooperate — each fiber yields the OS thread on socket waits, so
|
|
195
|
+
# one thread can serve N concurrent in-flight DB queries. The
|
|
196
|
+
# thread pool is intentionally bypassed here: handing the socket
|
|
197
|
+
# to a worker thread strips the scheduler context.
|
|
198
|
+
Hyperion.metrics.increment(:requests_async_dispatched)
|
|
199
|
+
Connection.new.serve(socket, @app, max_request_read_seconds: @max_request_read_seconds)
|
|
177
200
|
elsif @thread_pool
|
|
178
201
|
# HTTP/1.1 (e.g. TLS-wrapped after ALPN picked http/1.1): hand the
|
|
179
202
|
# connection to a worker thread. The fiber that called dispatch
|
|
180
203
|
# returns immediately. On overflow, reject with 503 + close.
|
|
181
|
-
|
|
182
|
-
|
|
204
|
+
if @thread_pool.submit_connection(socket, @app,
|
|
205
|
+
max_request_read_seconds: @max_request_read_seconds)
|
|
206
|
+
Hyperion.metrics.increment(:requests_threadpool_dispatched)
|
|
207
|
+
else
|
|
183
208
|
reject_connection(socket)
|
|
184
209
|
end
|
|
185
210
|
else
|
|
186
|
-
# No pool (thread_count: 0)
|
|
211
|
+
# No pool (thread_count: 0) on the TLS / async-wrap path. Rare
|
|
212
|
+
# config — neither dispatch bucket fits cleanly. Leave un-counted
|
|
213
|
+
# rather than misclassify; the request still shows up in
|
|
214
|
+
# :requests_total via Connection.
|
|
187
215
|
Connection.new.serve(socket, @app, max_request_read_seconds: @max_request_read_seconds)
|
|
188
216
|
end
|
|
189
217
|
end
|
data/lib/hyperion/version.rb
CHANGED
data/lib/hyperion/worker.rb
CHANGED
|
@@ -20,7 +20,7 @@ module Hyperion
|
|
|
20
20
|
thread_count: Server::DEFAULT_THREAD_COUNT,
|
|
21
21
|
config: nil, worker_index: 0, listener: nil,
|
|
22
22
|
max_pending: nil, max_request_read_seconds: 60,
|
|
23
|
-
h2_settings: nil)
|
|
23
|
+
h2_settings: nil, async_io: false)
|
|
24
24
|
@host = host
|
|
25
25
|
@port = port
|
|
26
26
|
@app = app
|
|
@@ -33,6 +33,7 @@ module Hyperion
|
|
|
33
33
|
@max_pending = max_pending
|
|
34
34
|
@max_request_read_seconds = max_request_read_seconds
|
|
35
35
|
@h2_settings = h2_settings
|
|
36
|
+
@async_io = async_io
|
|
36
37
|
end
|
|
37
38
|
|
|
38
39
|
def run
|
|
@@ -51,7 +52,8 @@ module Hyperion
|
|
|
51
52
|
thread_count: @thread_count,
|
|
52
53
|
max_pending: @max_pending,
|
|
53
54
|
max_request_read_seconds: @max_request_read_seconds,
|
|
54
|
-
h2_settings: @h2_settings
|
|
55
|
+
h2_settings: @h2_settings,
|
|
56
|
+
async_io: @async_io)
|
|
55
57
|
tcp_server = @listener || build_reuseport_listener
|
|
56
58
|
server.adopt_listener(tcp_server)
|
|
57
59
|
|