raptor 0.20.2 → 0.22.0
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 +19 -0
- data/README.md +41 -64
- data/docs/raptor-vs-puma.md +22 -20
- data/ext/raptor_http2/raptor_http2.c +12 -0
- data/lib/raptor/binder.rb +39 -13
- data/lib/raptor/cli.rb +10 -0
- data/lib/raptor/cluster.rb +6 -3
- data/lib/raptor/detached_body.rb +293 -0
- data/lib/raptor/http.rb +18 -0
- data/lib/raptor/http1.rb +87 -35
- data/lib/raptor/http2.rb +501 -112
- data/lib/raptor/reactor.rb +992 -50
- data/lib/raptor/server.rb +12 -0
- data/lib/raptor/version.rb +1 -1
- data/lib/raptor.rb +1 -0
- data/sig/generated/raptor/binder.rbs +26 -13
- data/sig/generated/raptor/detached_body.rbs +127 -0
- data/sig/generated/raptor/http.rbs +12 -0
- data/sig/generated/raptor/http1.rbs +22 -16
- data/sig/generated/raptor/http2.rbs +240 -25
- data/sig/generated/raptor/reactor.rbs +388 -19
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fc05eb1a4a916e5094c4496f18a3d3b179869a42efc1a7fd42ccda546b46e0bc
|
|
4
|
+
data.tar.gz: 1b975f682fcbaea183226775277cf441d4996899f4348237b96bf073586b1082
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c39ee7c2d2c4f68b8015a841c3fdadfb26b42f587041d6e85e69fc930abcb9fe65ce4841e3562c31b2ff6a0ac904787ba97ca1a26dafa25ba7a0f71b0069b2a1
|
|
7
|
+
data.tar.gz: 71604474925af588347c5e82f294eca27b5d51c3dc161ece9bb63508b9ea389196173cf890be8f8ebbd030dea284fc1e0d9f4aac26650f02c05303eb09707c7c
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.22.0] - 2026-10-03
|
|
4
|
+
|
|
5
|
+
- Add detached HTTP/1.1 response bodies
|
|
6
|
+
- Add detached HTTP/2 response bodies
|
|
7
|
+
- Move HTTP/2 writes to the reactor
|
|
8
|
+
|
|
9
|
+
## [0.21.0] - 2026-09-27
|
|
10
|
+
|
|
11
|
+
- Add HTTP/2 keepalive
|
|
12
|
+
- Support HTTP/2 response trailers
|
|
13
|
+
- Gracefully drain HTTP/2 connections
|
|
14
|
+
- Support cleartext HTTP/2 bindings
|
|
15
|
+
- Accept HTTP/2 request trailers
|
|
16
|
+
- Propagate HTTP/2 stream cancellation to response bodies
|
|
17
|
+
- Support Rack response lifecycle over HTTP/2
|
|
18
|
+
- Support Rack streaming bodies over HTTP/2
|
|
19
|
+
- Stream HTTP/2 response bodies incrementally
|
|
20
|
+
- Suppress HTTP/2 response bodies when required
|
|
21
|
+
|
|
3
22
|
## [0.20.2] - 2026-09-26
|
|
4
23
|
|
|
5
24
|
- Close idle connections before reforking workers
|
data/README.md
CHANGED
|
@@ -37,7 +37,7 @@ run proc { |_env| [200, { "content-type" => "text/plain" }, ["Hello, World!"]] }
|
|
|
37
37
|
```
|
|
38
38
|
> bundle exec raptor -w 10 -t 3 hello_world.ru
|
|
39
39
|
[Raptor 72876|Main|Main] Cluster initializing:
|
|
40
|
-
[Raptor 72876|Main|Main] ├─ Version: 0.
|
|
40
|
+
[Raptor 72876|Main|Main] ├─ Version: 0.22.0
|
|
41
41
|
[Raptor 72876|Main|Main] ├─ Ruby Version: ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +YJIT +PRISM [arm64-darwin23]
|
|
42
42
|
[Raptor 72876|Main|Main] ├─ Environment: development
|
|
43
43
|
[Raptor 72876|Main|Main] ├─ Master PID: 72876
|
|
@@ -78,12 +78,11 @@ a separately supervised process or container instead.
|
|
|
78
78
|
|
|
79
79
|
## Configuration
|
|
80
80
|
|
|
81
|
-
Raptor
|
|
82
|
-
|
|
81
|
+
Raptor reads options from a Ruby config file, environment variables, and command line flags, in increasing order of
|
|
82
|
+
precedence. Run `bundle exec raptor --help` for the full flag list.
|
|
83
83
|
|
|
84
|
-
The config file
|
|
85
|
-
|
|
86
|
-
`connection:` (shared across protocols), `http1:` (HTTP/1.1-specific), and `http2:` (HTTP/2-specific).
|
|
84
|
+
The config file evaluates to a hash of options. By default Raptor loads `raptor.rb` then `config/raptor.rb` from the
|
|
85
|
+
working directory. Pass `-c PATH` to point at a specific file.
|
|
87
86
|
|
|
88
87
|
```ruby
|
|
89
88
|
# raptor.rb
|
|
@@ -116,6 +115,8 @@ The config file is a Ruby file that evaluates to a hash of options. By default R
|
|
|
116
115
|
http2: {
|
|
117
116
|
ractors: nil,
|
|
118
117
|
max_concurrent_streams: 100,
|
|
118
|
+
keepalive_interval: 10,
|
|
119
|
+
keepalive_timeout: 5,
|
|
119
120
|
},
|
|
120
121
|
worker_boot_timeout: 60,
|
|
121
122
|
worker_timeout: 60,
|
|
@@ -135,35 +136,20 @@ The config file is a Ruby file that evaluates to a hash of options. By default R
|
|
|
135
136
|
}
|
|
136
137
|
```
|
|
137
138
|
|
|
138
|
-
`
|
|
139
|
-
without a fixed limit when queued work is held up by blocking operations. It does not add threads when waiting for the
|
|
140
|
-
GVL is the bottleneck, and temporary threads leave after the queue drains. Set `max_threads` to cap growth, or set it
|
|
141
|
-
to the same value as `threads` for a fixed pool.
|
|
139
|
+
`RAPTOR_WORKERS`, `RAPTOR_THREADS`, and `RAPTOR_MAX_THREADS` set the corresponding options without a config file.
|
|
142
140
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
Raptor clears application thread locals after each request by default. Set `clean_thread_locals` to `false` to disable
|
|
148
|
-
it. Set `clean_fiber_locals` to `true` to run each request in a fresh Fiber, isolating Fiber-local state as well.
|
|
149
|
-
|
|
150
|
-
`RAPTOR_WORKERS`, `RAPTOR_THREADS`, and `RAPTOR_MAX_THREADS` can set the corresponding options without a config file.
|
|
151
|
-
Config files override defaults, environment variables override config files, and command-line options override both.
|
|
152
|
-
`RAPTOR_MAX_THREADS=unlimited` leaves adaptive growth uncapped.
|
|
153
|
-
|
|
154
|
-
`before_worker_boot` and `before_worker_shutdown` hooks receive the worker index.
|
|
141
|
+
By default each worker adds threads beyond `threads` only while queued requests are waiting on blocking work such as
|
|
142
|
+
database or network calls. It stops adding them once Ruby execution becomes the bottleneck, and the extra threads
|
|
143
|
+
retire when the queue is idle. Set `max_threads` to cap this growth, or set it to `threads` for a fixed pool.
|
|
155
144
|
|
|
156
145
|
## Bindings
|
|
157
146
|
|
|
158
|
-
|
|
147
|
+
`binds` accepts any combination of these URIs.
|
|
159
148
|
|
|
160
|
-
- `tcp://host:port` for TCP.
|
|
161
|
-
|
|
162
|
-
- `
|
|
163
|
-
|
|
164
|
-
- `ssl://host:port?cert=/path/to.crt&key=/path/to.key` for TLS. HTTP/1.1 and HTTP/2 are negotiated via ALPN.
|
|
165
|
-
|
|
166
|
-
Multiple binds can be combined freely.
|
|
149
|
+
- `tcp://host:port` for TCP. `localhost` binds both IPv4 and IPv6 loopback addresses.
|
|
150
|
+
- `unix:///path/to/socket` for a Unix domain socket.
|
|
151
|
+
- `ssl://host:port?cert=/path/to.crt&key=/path/to.key` for TLS, negotiating HTTP/1.1 or HTTP/2 via ALPN.
|
|
152
|
+
- `h2c://host:port` for cleartext HTTP/2.
|
|
167
153
|
|
|
168
154
|
## Signals
|
|
169
155
|
|
|
@@ -175,22 +161,16 @@ Send to the master process.
|
|
|
175
161
|
| `TERM` | Graceful shutdown |
|
|
176
162
|
| `HUP` | Reopen `stdout_file`, `stderr_file`, and `access_log_file` |
|
|
177
163
|
| `USR1` | Phased restart (rolling worker replacement) |
|
|
178
|
-
| `USR2` | Hot restart (
|
|
179
|
-
|
|
180
|
-
## Restarts
|
|
164
|
+
| `USR2` | Hot restart (restart master, keeping listening sockets) |
|
|
181
165
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
- **Hot restart** (`USR2`) re-execs the master process with its original command line, inheriting the listening sockets
|
|
186
|
-
so accepted connections continue to be served across the swap. The successor master re-runs initialization from
|
|
187
|
-
scratch. Use to pick up changes that affect master-level state (config layout, dependency upgrades, Raptor itself).
|
|
166
|
+
A phased restart replaces workers one at a time while the master keeps running, picking up application code changes.
|
|
167
|
+
A hot restart starts a new master with the original command line and the same listening sockets, picking up changes
|
|
168
|
+
to configuration, dependencies, or Raptor itself.
|
|
188
169
|
|
|
189
170
|
## systemd
|
|
190
171
|
|
|
191
|
-
Raptor
|
|
192
|
-
|
|
193
|
-
place of `binds:`. `READY=1`, `STOPPING=1`, and `RELOADING=1` lifecycle messages are emitted automatically.
|
|
172
|
+
Raptor supports socket activation and `sd_notify`, so it works with `Type=notify` units. When the socket unit is
|
|
173
|
+
active, Raptor serves the listening sockets systemd passes in place of `binds`.
|
|
194
174
|
|
|
195
175
|
```ini
|
|
196
176
|
# /etc/systemd/system/myapp.socket
|
|
@@ -213,8 +193,7 @@ KillMode=mixed
|
|
|
213
193
|
|
|
214
194
|
## Stats
|
|
215
195
|
|
|
216
|
-
|
|
217
|
-
memory and to a JSON file (default `tmp/raptor.json`; set via `stats_file`).
|
|
196
|
+
Workers publish their stats to `stats_file`, which defaults to `tmp/raptor.json`.
|
|
218
197
|
|
|
219
198
|
```
|
|
220
199
|
> bundle exec raptor stats
|
|
@@ -224,14 +203,12 @@ Worker 1 (phase 0): pid=91351, requests=1199, busy=1/3, backlog=0, booted, last_
|
|
|
224
203
|
...
|
|
225
204
|
```
|
|
226
205
|
|
|
227
|
-
Set `control_url` to a Unix socket URL such as `unix:///tmp/raptor-control.sock` to
|
|
228
|
-
|
|
229
|
-
`pool_capacity / max_threads` measures the capacity available at that moment rather than comparing against an
|
|
230
|
-
unbounded configured limit. The control server is read-only and currently exposes only `/stats`.
|
|
206
|
+
Set `control_url` to a Unix socket URL such as `unix:///tmp/raptor-control.sock` to serve the same stats over
|
|
207
|
+
`GET /stats`.
|
|
231
208
|
|
|
232
209
|
## (Micro) Benchmarks
|
|
233
210
|
|
|
234
|
-
Raptor 0.
|
|
211
|
+
Raptor 0.22.0 vs Puma 8.0.2 vs Falcon 0.57.0 across two workload profiles. **IO-bound** is a GET endpoint that
|
|
235
212
|
interleaves 5-10 short sleeps (total 2.5-15ms) with small CPU work, simulating a read path that makes several DB or
|
|
236
213
|
cache calls. **CPU-bound** is a POST endpoint that accepts a small JSON body, interleaves 3-5 chunks of JSON item
|
|
237
214
|
building (total 450-1500 items) with sub-100µs sleeps, and returns the built array, simulating a write path that does
|
|
@@ -245,24 +222,24 @@ disabled, and both threaded servers allow 999 requests per HTTP/1.1 keep-alive c
|
|
|
245
222
|
Each cell reports the median throughput and median p95 latency independently across 3 runs, so the two numbers in a row
|
|
246
223
|
may come from different runs. Every run starts a fresh server process so the samples are independent of each other;
|
|
247
224
|
state accumulated in a previous run cannot bias the next. Across the whole table, the widest spread
|
|
248
|
-
((max - min) / 2 / median) between runs of a single cell was ±
|
|
225
|
+
((max - min) / 2 / median) between runs of a single cell was ±13.2% for throughput and ±25.6% for p95.
|
|
249
226
|
|
|
250
227
|
| Protocol | Workload | Raptor mode | Raptor req/s | Raptor p95 | Puma req/s | Puma p95 | vs Puma req/s | vs Puma p95 | Falcon req/s | Falcon p95 | vs Falcon req/s | vs Falcon p95 |
|
|
251
228
|
| --------------------- | -------- | ----------- | ------------ | ---------- | ----------- | --------- | ------------- | ------------ | ------------ | ---------- | --------------- | ------------- |
|
|
252
|
-
| HTTP/1.1 | IO | Fixed | 2.
|
|
253
|
-
| HTTP/1.1 | IO | Scaling |
|
|
254
|
-
| HTTP/1.1 | CPU | Fixed | 7.
|
|
255
|
-
| HTTP/1.1 | CPU | Scaling | 6.
|
|
256
|
-
| HTTP/1.1 (keep-alive) | IO | Fixed | 2.
|
|
257
|
-
| HTTP/1.1 (keep-alive) | IO | Scaling |
|
|
258
|
-
| HTTP/1.1 (keep-alive) | CPU | Fixed | 7.
|
|
259
|
-
| HTTP/1.1 (keep-alive) | CPU | Scaling | 7.
|
|
260
|
-
| HTTP/2 | IO | Fixed | 1.
|
|
261
|
-
| HTTP/2 | IO | Scaling | 6.
|
|
262
|
-
| HTTP/2 | CPU | Fixed | 6.
|
|
263
|
-
| HTTP/2 | CPU | Scaling |
|
|
264
|
-
|
|
265
|
-
> ruby 4.0.
|
|
229
|
+
| HTTP/1.1 | IO | Fixed | 2.91k req/s | 82.40 ms | 1.51k req/s | 125.20 ms | 92.2% higher | 34.2% lower | 12.08k req/s | 14.40 ms | 75.9% lower | 472.2% higher |
|
|
230
|
+
| HTTP/1.1 | IO | Scaling | 7.01k req/s | 31.70 ms | 1.51k req/s | 125.20 ms | 362.9% higher | 74.7% lower | 12.08k req/s | 14.40 ms | 42.0% lower | 120.1% higher |
|
|
231
|
+
| HTTP/1.1 | CPU | Fixed | 7.24k req/s | 37.90 ms | 8.50k req/s | 23.10 ms | 14.9% lower | 64.1% higher | 6.44k req/s | 28.90 ms | 12.4% higher | 31.1% higher |
|
|
232
|
+
| HTTP/1.1 | CPU | Scaling | 6.48k req/s | 39.50 ms | 8.50k req/s | 23.10 ms | 23.8% lower | 71.0% higher | 6.44k req/s | 28.90 ms | 0.6% higher | 36.7% higher |
|
|
233
|
+
| HTTP/1.1 (keep-alive) | IO | Fixed | 2.26k req/s | 67.20 ms | 1.47k req/s | 105.60 ms | 53.7% higher | 36.4% lower | 6.22k req/s | 28.20 ms | 63.7% lower | 138.3% higher |
|
|
234
|
+
| HTTP/1.1 (keep-alive) | IO | Scaling | 8.20k req/s | 21.80 ms | 1.47k req/s | 105.60 ms | 458.3% higher | 79.4% lower | 6.22k req/s | 28.20 ms | 32.0% higher | 22.7% lower |
|
|
235
|
+
| HTTP/1.1 (keep-alive) | CPU | Fixed | 7.21k req/s | 27.10 ms | 8.38k req/s | 23.60 ms | 14.0% lower | 14.8% higher | 6.87k req/s | 34.20 ms | 5.0% higher | 20.8% lower |
|
|
236
|
+
| HTTP/1.1 (keep-alive) | CPU | Scaling | 7.56k req/s | 28.10 ms | 8.38k req/s | 23.60 ms | 9.8% lower | 19.1% higher | 6.87k req/s | 34.20 ms | 10.1% higher | 17.8% lower |
|
|
237
|
+
| HTTP/2 | IO | Fixed | 1.64k req/s | 112.08 ms | N/A | N/A | - | - | 6.34k req/s | 28.16 ms | 74.2% lower | 298.1% higher |
|
|
238
|
+
| HTTP/2 | IO | Scaling | 6.90k req/s | 27.33 ms | N/A | N/A | - | - | 6.34k req/s | 28.16 ms | 8.8% higher | 2.9% lower |
|
|
239
|
+
| HTTP/2 | CPU | Fixed | 6.90k req/s | 29.30 ms | N/A | N/A | - | - | 6.69k req/s | 65.77 ms | 3.2% higher | 55.4% lower |
|
|
240
|
+
| HTTP/2 | CPU | Scaling | 7.51k req/s | 26.86 ms | N/A | N/A | - | - | 6.69k req/s | 65.77 ms | 12.2% higher | 59.2% lower |
|
|
241
|
+
|
|
242
|
+
> ruby 4.0.7 (2026-09-15 revision 229531a6cf) +YJIT +PRISM [aarch64-linux]
|
|
266
243
|
> 10 worker processes; fixed Raptor and Puma run 3 threads per worker; scaling Raptor starts at 3 with no fixed limit;
|
|
267
244
|
> Falcon runs unbounded fibers per worker; 120 concurrent HTTP/1.1 client connections; 40 concurrent HTTP/2 client
|
|
268
245
|
> connections × 3 streams each
|
data/docs/raptor-vs-puma.md
CHANGED
|
@@ -43,7 +43,7 @@ The rest of this doc explains why the shape looks like that.
|
|
|
43
43
|
| I/O multiplexing | `nio4r` reactor for keep-alive idle and slow reads | `nio4r` reactor for the same, plus a red-black tree for O(log n) timeouts |
|
|
44
44
|
| Cluster dispatch | Workers race on inherited listeners with a load-proportional accept delay | Two-choice load-aware BPF dispatch for TCP on Linux; shared-listener fallback |
|
|
45
45
|
| Work queue | Ruby `Queue` coordinated under the pool mutex | Lock-free Michael-Scott FIFO queue |
|
|
46
|
-
| HTTP/2 | Not implemented | Native C parser + HPACK,
|
|
46
|
+
| HTTP/2 | Not implemented | Native C parser + HPACK, reactor-owned frame scheduler |
|
|
47
47
|
| Keep-alive fast path | Same-thread inline dispatch when spare threads exist | Same-thread inline dispatch for bytes that are already waiting |
|
|
48
48
|
| Native extensions | 1 (Ragel HTTP/1 parser + MiniSSL) | 3, all Ractor-safe (Ragel HTTP/1 parser; HTTP/2 parser + HPACK; `writev`, `sched_setaffinity`, `prctl` wrappers) |
|
|
49
49
|
| Shared state (worker↔master) | Pipes and signals | Anonymous shared-memory `mmap` region |
|
|
@@ -238,7 +238,7 @@ Your Rack app still runs on ordinary threads under one worker GVL, so the app do
|
|
|
238
238
|
|
|
239
239
|
**How the Ractor pools actually work.** Raptor uses the `ractor-pool` gem, which is another one of my libraries. Each pool has one coordinator Ractor and M pipeline Ractors. When a pipeline Ractor is idle, it sends itself back to the coordinator via `coordinator.send(Ractor.current, move: true)`. When work arrives at the coordinator, it either forwards it to a waiting Ractor (if any) or queues it. This coordinator-dispatch pattern guarantees that no Ractor sits idle while there is work. Results flow back through a shared `Ractor::Port` (a many-to-one channel added in recent Ruby versions and stable in 4.0) to a Ruby-side collector thread. If `M == 1` the coordinator is skipped and work goes straight to the single pipeline Ractor.
|
|
240
240
|
|
|
241
|
-
Raptor always runs an HTTP/1.1 pool because
|
|
241
|
+
Raptor always runs an HTTP/1.1 pool because TCP, Unix, and SSL bindings support HTTP/1.1. It starts an HTTP/2 pool when an SSL or cleartext HTTP/2 binding is present. SSL bindings need both pools because ALPN allows each client to choose HTTP/2 or HTTP/1.1, while `h2c://` bindings accept only HTTP/2 clients using prior knowledge. Both pool sizes scale with headroom via `round(cores / workers)`, clamped to `[1, 3]` for `http1.ractors` and `[1, 2]` for `http2.ractors`. Keeping the pools separate means h1 and h2 parsing never share ractor slots, so a burst of small HTTP/1.1 requests cannot delay HTTP/2 frame handling on the same connection, and vice versa.
|
|
242
242
|
|
|
243
243
|
**Why a custom thread pool.** The `AtomicThreadPool` in `atomic-ruby` (another one of my libraries) is backed by an `AtomicQueue`. The queue is a Michael-Scott multi-producer, multi-consumer FIFO: a singly linked list with a dummy sentinel and atomic head and tail pointers. Producers append nodes at the tail; consumers advance the head. Both operations are O(1) and make progress through compare-and-swap rather than a queue-wide mutex. Separate atoms track queue size and active app threads for backpressure.
|
|
244
244
|
|
|
@@ -289,7 +289,7 @@ Three timeout classes are tracked:
|
|
|
289
289
|
- `chunk_data_timeout` (10s): applied once data has started arriving but the request is incomplete.
|
|
290
290
|
- `persistent_data_timeout` (65s): applied to a keep-alive socket sitting idle between requests.
|
|
291
291
|
|
|
292
|
-
|
|
292
|
+
An incomplete HTTP/1.1 request receives `408 Request Timeout` before the connection closes. Once an HTTP/2 connection has received its preface, its deadline instead sends a PING; an acknowledgement restores the idle deadline, while a missing acknowledgement closes the connection.
|
|
293
293
|
|
|
294
294
|
### HTTP/1.1 request lifecycle
|
|
295
295
|
|
|
@@ -326,32 +326,33 @@ Puma has a similar shape. It checks buffered back-to-back requests, then eagerly
|
|
|
326
326
|
|
|
327
327
|
The `reactor.persist` call re-registers the socket with the reactor using `persistent_data_timeout` (65s) as the new deadline. When the next bytes arrive, the reactor treats the socket like any other partially-read connection.
|
|
328
328
|
|
|
329
|
+
A `Raptor::DetachedBody` returns the application thread while keeping the response open, chunked on HTTP/1.1 and ended by closing the connection on HTTP/1.0. The reactor owns the response until it closes, buffers later writes within fixed limits, and pauses the connection's next request without occupying an application thread.
|
|
330
|
+
|
|
329
331
|
### HTTP/2 request lifecycle
|
|
330
332
|
|
|
331
|
-
Raptor speaks HTTP/2 on TLS connections where the client negotiates it via ALPN. The binder sets `alpn_protocols = ["h2", "http/1.1"]` on the SSL context and the ALPN callback picks h2 whenever the client offers it. Puma does not do this. Puma's SSL context does not advertise `h2` in ALPN, so clients transparently fall back to HTTP/1.1.
|
|
333
|
+
Raptor speaks HTTP/2 on TLS connections where the client negotiates it via ALPN and on `h2c://` listeners for cleartext clients using prior knowledge. The binder sets `alpn_protocols = ["h2", "http/1.1"]` on the SSL context and the ALPN callback picks h2 whenever the client offers it. Puma does not do this. Puma's SSL context does not advertise `h2` in ALPN, so clients transparently fall back to HTTP/1.1.
|
|
332
334
|
|
|
333
|
-
Once ALPN selects h2
|
|
335
|
+
Once ALPN selects h2 or an `h2c://` listener accepts a connection, the server calls `Http2#eager_accept`, which creates the per-connection `Writer` and `FlowControl`, attaches them to the reactor, writes the initial `SETTINGS` frame, and tries a non-blocking read on the socket. If bytes are already there, it parses the first frame batch inline via `Http2.process_frames` and dispatches completed streams to the thread pool without ever touching the ractor pool. If not, it hands the socket to the reactor to watch for the first bytes.
|
|
334
336
|
|
|
335
337
|
From there the shape is similar to HTTP/1.1:
|
|
336
338
|
|
|
337
339
|
1. Reactor reads frames.
|
|
338
340
|
2. The HTTP/2 parser (native C, with an HPACK decoder using a static Huffman table) parses the frames in the HTTP/2 Ractor pool.
|
|
339
341
|
3. Completed requests (once `HEADERS` and `DATA` are complete for a stream) go to the thread pool as separate work items. **A single connection can be servicing many streams in parallel across the thread pool.**
|
|
340
|
-
4. Each stream's response
|
|
341
|
-
|
|
342
|
-
The `Writer` is worth a paragraph. Naive per-connection writing would need a mutex around every socket write. Contention grows with concurrent streams. Raptor's `Writer` stores the "pending frames" queue in an `Atom` whose value is either `:idle` (nobody is writing) or an array of frames waiting to go out. A thread that wants to write does a CAS:
|
|
342
|
+
4. Each stream's response frames are queued for the reactor, which owns every socket write after connection setup.
|
|
343
343
|
|
|
344
|
-
|
|
345
|
-
|
|
344
|
+
Responses to `HEAD` requests and statuses that prohibit a message body end with the response `HEADERS` frame.
|
|
345
|
+
Early hints and response-finished callbacks follow the same Rack lifecycle as HTTP/1.1.
|
|
346
|
+
Trailing request `HEADERS` complete an open request. Rack has no standard request-trailer key, so Raptor validates them without adding them to the environment.
|
|
346
347
|
|
|
347
|
-
|
|
348
|
+
The `Writer` hands serialized frames to the reactor, which writes as the socket becomes ready and closes clients that stop reading. Application threads never wait for socket writability, and the connection has a single I/O owner without a per-connection mutex.
|
|
348
349
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
Flow control uses similar CAS-protected atoms. The connection-level window and the per-stream windows live in separate `Atom` cells. `acquire` atomically reserves connection capacity and, where per-stream tracking is needed, deducts the same grant from that stream's window. If either window is exhausted, the caller sleeps 1ms and retries until a `WINDOW_UPDATE` makes progress possible.
|
|
350
|
+
Flow control uses similar CAS-protected atoms. Ordinary Rack bodies wait for connection and stream capacity as they yield. A `Raptor::DetachedBody` instead returns its application thread immediately; the reactor schedules its bounded buffer as capacity becomes available and closes it when the client cancels. This keeps long-lived streams from consuming one application thread each.
|
|
352
351
|
|
|
353
352
|
Frame processing also has an eager loop. After processing one batch of frames, the h2 handler tries to `read_nonblock` one more time to see if the next batch is already available. Up to eight rounds are consumed inline before handing back to the reactor, and the loop bails out early once the app thread pool has more queued work than worker slots so one busy connection cannot starve the collector. This is the same principle as the HTTP/1.1 eager keep-alive: amortise the reactor round-trip when the client is actively sending, but back off under saturation.
|
|
354
353
|
|
|
354
|
+
During worker shutdown, Raptor stops accepting connections and sends GOAWAY with the last stream handed to the Rack application. Later streams are refused while application work and detached bodies drain. The reactor remains active so in-flight responses can receive flow-control updates; detached bodies still open when the drain period expires are cancelled before their connections close.
|
|
355
|
+
|
|
355
356
|
### Raptor request flow diagram
|
|
356
357
|
|
|
357
358
|
```mermaid
|
|
@@ -402,12 +403,13 @@ flowchart TB
|
|
|
402
403
|
COL --> CHK
|
|
403
404
|
CHK -->|"no, more bytes needed"| RCT
|
|
404
405
|
CHK -->|"yes, push proc"| ATP
|
|
405
|
-
ATP -->|"
|
|
406
|
+
ATP -->|"HTTP/2 response frames"| RCT
|
|
407
|
+
ATP -->|"HTTP/1.1 response write"| KA
|
|
406
408
|
KA -->|"no, close"| CLS["close socket"]
|
|
407
409
|
KA -->|"yes"| EAG
|
|
408
410
|
EAG -.->|"bytes ready, parse+dispatch on same thread"| ATP
|
|
409
411
|
EAG -->|"no bytes, reactor.persist"| RCT
|
|
410
|
-
RCT -->|"
|
|
412
|
+
RCT -->|"deadline expired"| TO["PING or close"]
|
|
411
413
|
|
|
412
414
|
STA -.->|"writes slot"| SHM[("mmap shared memory")]
|
|
413
415
|
end
|
|
@@ -489,11 +491,11 @@ For external monitoring, `control_url` can expose a read-only `GET /stats` endpo
|
|
|
489
491
|
|
|
490
492
|
**Puma.** Not implemented. Puma's [position](https://github.com/puma/puma/issues/2697) is that HTTP/2 belongs at the edge (nginx, Caddy, ALB), which terminates it and speaks HTTP/1.1 to the app server. That's a reasonable call for the deployments Puma is aimed at, and it's where most Rails production actually sits.
|
|
491
493
|
|
|
492
|
-
**Raptor.** Native C parser plus HPACK, per-stream flow control,
|
|
494
|
+
**Raptor.** Native C parser plus HPACK, per-stream flow control, reactor-owned response writes, detached long-lived responses, stream multiplexing over a single connection, configurable PING keepalive, and response trailers exposed through `env["raptor.response_trailers"]`. Once a request is complete it takes the same path as HTTP/1.1 and enters the same thread pool. Under HTTP/2, a single client connection can be issuing many concurrent requests, and Raptor services all of them in parallel on the same thread pool.
|
|
493
495
|
|
|
494
496
|
Whether that matters depends on your setup. If you terminate TLS at an edge proxy that already speaks HTTP/2, both servers see HTTP/1.1 and it doesn't matter which of them you pick on this axis. If you're building an all-Ruby stack with no proxy in front, serving direct HTTP/2 clients, or measuring the app server itself, HTTP/2 support is where Raptor and Puma stop being comparable.
|
|
495
497
|
|
|
496
|
-
At the throughput numbers the benchmark shows, a small set of concurrent connections multiplex many streams, so responses from several app threads share each socket.
|
|
498
|
+
At the throughput numbers the benchmark shows, a small set of concurrent connections multiplex many streams, so responses from several app threads share each socket. Those threads queue frames while the reactor owns non-blocking writes for the connection.
|
|
497
499
|
|
|
498
500
|
### Response writing
|
|
499
501
|
|
|
@@ -598,13 +600,13 @@ On the CPU-bound benchmark profile, each POST request accepts a small JSON body
|
|
|
598
600
|
|
|
599
601
|
Puma doesn't implement HTTP/2, and most Rails production terminates HTTP/2 at nginx or a similar edge proxy before it reaches the app server. If that describes your stack, Raptor's HTTP/2 support isn't going to help you. Both servers see HTTP/1.1 from the proxy and the throughput numbers above are what actually matter. Puma's [position](https://github.com/puma/puma/issues/2697) is that this is where h2 belongs, and it's a reasonable one.
|
|
600
602
|
|
|
601
|
-
Where Raptor's HTTP/2 support does matter is the all-Ruby stack:
|
|
603
|
+
Where Raptor's HTTP/2 support does matter is the all-Ruby stack: clients can speak h2 directly over TLS, or a trusted proxy can use cleartext HTTP/2 to an `h2c://` listener. In that setup, Puma uses HTTP/1.1 instead, so the app-server connection does not get HTTP/2 multiplexing or HPACK header compression.
|
|
602
604
|
|
|
603
605
|
Falcon also speaks HTTP/2 natively, so it's the interesting comparison there rather than Puma. On the CPU profile, scaling narrows Raptor's throughput gap from 26.9% to 14.2% and improves its p95 advantage from 12.3% to 21.3%. On IO, scaling raises Raptor from 1.14k to 4.46k req/s, but remains 30.3% behind Falcon. The h2 samples vary substantially more than the h1 samples, so these results establish broad shape rather than a precise ranking.
|
|
604
606
|
|
|
605
607
|
The benchmark's h2 listener uses TLS, while Raptor's BPF reuseport path only wraps plain TCP listeners. BPF dispatch therefore cannot explain the h2 variance. With 40 physical connections spread across 10 workers, each carrying three streams, placement and per-connection scheduling have coarse granularity; more instrumentation is needed before assigning the variance to a specific mechanism.
|
|
606
608
|
|
|
607
|
-
Raptor's HTTP/2 CPU-bound throughput remains in the same broad range as its HTTP/1.1 result while multiplexing streams onto shared sockets. The
|
|
609
|
+
Raptor's HTTP/2 CPU-bound throughput remains in the same broad range as its HTTP/1.1 result while multiplexing streams onto shared sockets. The reactor-owned frame scheduler and flow-control atoms are part of how it coordinates that work, but this benchmark does not provide an alternative Raptor control case from which to quantify their individual effect.
|
|
608
610
|
|
|
609
611
|
## Part V: What Raptor gives up
|
|
610
612
|
|
|
@@ -630,6 +630,7 @@ static int response_headers_iter(VALUE key, VALUE value, VALUE data) {
|
|
|
630
630
|
const char *lname = RSTRING_PTR(lowered);
|
|
631
631
|
long lname_len = RSTRING_LEN(lowered);
|
|
632
632
|
|
|
633
|
+
if (lname_len > 0 && lname[0] == ':') return ST_CONTINUE;
|
|
633
634
|
if (lname_len >= 5 && memcmp(lname, "rack.", 5) == 0) return ST_CONTINUE;
|
|
634
635
|
if (is_hop_by_hop(lname, lname_len)) return ST_CONTINUE;
|
|
635
636
|
|
|
@@ -658,6 +659,16 @@ static VALUE h2_encode_response_headers(VALUE self, VALUE status, VALUE headers)
|
|
|
658
659
|
return hpack_encode_header_block(pairs);
|
|
659
660
|
}
|
|
660
661
|
|
|
662
|
+
static VALUE h2_encode_response_trailers(VALUE self, VALUE trailers) {
|
|
663
|
+
(void)self;
|
|
664
|
+
Check_Type(trailers, T_HASH);
|
|
665
|
+
|
|
666
|
+
VALUE pairs = rb_ary_new();
|
|
667
|
+
rb_hash_foreach(trailers, response_headers_iter, pairs);
|
|
668
|
+
|
|
669
|
+
return hpack_encode_header_block(pairs);
|
|
670
|
+
}
|
|
671
|
+
|
|
661
672
|
static VALUE h2_parse_frame(VALUE self, VALUE buffer) {
|
|
662
673
|
(void)self;
|
|
663
674
|
Check_Type(buffer, T_STRING);
|
|
@@ -825,6 +836,7 @@ RUBY_FUNC_EXPORTED void Init_raptor_http2(void) {
|
|
|
825
836
|
rb_define_method(cHttp2Parser, "parse_headers", h2_parse_headers, 2);
|
|
826
837
|
rb_define_method(cHttp2Parser, "encode_headers", h2_encode_headers, 1);
|
|
827
838
|
rb_define_method(cHttp2Parser, "encode_response_headers", h2_encode_response_headers, 2);
|
|
839
|
+
rb_define_method(cHttp2Parser, "encode_response_trailers", h2_encode_response_trailers, 1);
|
|
828
840
|
rb_define_method(cHttp2Parser, "parse_settings", h2_parse_settings, 1);
|
|
829
841
|
rb_define_method(cHttp2Parser, "build_settings", h2_build_settings, 1);
|
|
830
842
|
rb_define_method(cHttp2Parser, "build_frame", h2_build_frame, 4);
|
data/lib/raptor/binder.rb
CHANGED
|
@@ -5,9 +5,9 @@ require "socket"
|
|
|
5
5
|
require "uri"
|
|
6
6
|
|
|
7
7
|
module Raptor
|
|
8
|
-
# Binds
|
|
9
|
-
# holds them for the server. Reconstructs listeners from inherited
|
|
10
|
-
# descriptors when provided (systemd socket activation, hot restart).
|
|
8
|
+
# Binds TCP, Unix, SSL, and cleartext HTTP/2 URIs to listening sockets
|
|
9
|
+
# and holds them for the server. Reconstructs listeners from inherited
|
|
10
|
+
# file descriptors when provided (systemd socket activation, hot restart).
|
|
11
11
|
#
|
|
12
12
|
class Binder
|
|
13
13
|
SOCKET_BACKLOG = 1024
|
|
@@ -31,11 +31,27 @@ module Raptor
|
|
|
31
31
|
def close = tcp_server.close
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
+
# Marks a TCPServer as accepting cleartext HTTP/2 connections.
|
|
35
|
+
#
|
|
36
|
+
H2cListener = Data.define(:tcp_server) do
|
|
37
|
+
# @rbs (*untyped) -> TCPSocket
|
|
38
|
+
def accept_nonblock(...) = tcp_server.accept_nonblock(...)
|
|
39
|
+
|
|
40
|
+
# @rbs () -> TCPServer
|
|
41
|
+
def to_io = tcp_server
|
|
42
|
+
|
|
43
|
+
# @rbs () -> Addrinfo
|
|
44
|
+
def local_address = tcp_server.local_address
|
|
45
|
+
|
|
46
|
+
# @rbs () -> void
|
|
47
|
+
def close = tcp_server.close
|
|
48
|
+
end
|
|
49
|
+
|
|
34
50
|
# @rbs @bind_uris: Array[String]
|
|
35
51
|
# @rbs @socket_backlog: Integer
|
|
36
52
|
# @rbs @inherited_fds: Hash[String, Array[Integer]]
|
|
37
|
-
# @rbs @listeners: Array[TCPServer | UNIXServer | SslListener]
|
|
38
|
-
# @rbs @uri_listeners: Hash[String, Array[TCPServer | UNIXServer | SslListener]]
|
|
53
|
+
# @rbs @listeners: Array[TCPServer | UNIXServer | SslListener | H2cListener]
|
|
54
|
+
# @rbs @uri_listeners: Hash[String, Array[TCPServer | UNIXServer | SslListener | H2cListener]]
|
|
39
55
|
|
|
40
56
|
# Returns the array of bind URIs.
|
|
41
57
|
#
|
|
@@ -49,7 +65,7 @@ module Raptor
|
|
|
49
65
|
|
|
50
66
|
# Returns the array of listening sockets.
|
|
51
67
|
#
|
|
52
|
-
# @return [Array<TCPServer, UNIXServer, SslListener>]
|
|
68
|
+
# @return [Array<TCPServer, UNIXServer, SslListener, H2cListener>]
|
|
53
69
|
attr_reader :listeners
|
|
54
70
|
|
|
55
71
|
# Creates a new Binder and binds each URI. `localhost` expands to both
|
|
@@ -73,7 +89,8 @@ module Raptor
|
|
|
73
89
|
end
|
|
74
90
|
|
|
75
91
|
# Returns the bound addresses as strings: TCP as `host:port`, Unix as
|
|
76
|
-
# the socket path, SSL as `ssl://host:port
|
|
92
|
+
# the socket path, SSL as `ssl://host:port`, and cleartext HTTP/2 as
|
|
93
|
+
# `h2c://host:port`.
|
|
77
94
|
#
|
|
78
95
|
# @return [Array<String>]
|
|
79
96
|
#
|
|
@@ -86,6 +103,9 @@ module Raptor
|
|
|
86
103
|
when SslListener
|
|
87
104
|
address = listener.local_address
|
|
88
105
|
"ssl://#{address.ip_address}:#{address.ip_port}"
|
|
106
|
+
when H2cListener
|
|
107
|
+
address = listener.local_address
|
|
108
|
+
"h2c://#{address.ip_address}:#{address.ip_port}"
|
|
89
109
|
else
|
|
90
110
|
address = listener.local_address
|
|
91
111
|
"#{address.ip_address}:#{address.ip_port}"
|
|
@@ -100,7 +120,9 @@ module Raptor
|
|
|
100
120
|
#
|
|
101
121
|
# @rbs () -> Integer
|
|
102
122
|
def server_port
|
|
103
|
-
tcp_listener = @listeners.find
|
|
123
|
+
tcp_listener = @listeners.find do |listener|
|
|
124
|
+
listener.is_a?(TCPServer) || listener.is_a?(SslListener) || listener.is_a?(H2cListener)
|
|
125
|
+
end
|
|
104
126
|
return 0 unless tcp_listener
|
|
105
127
|
|
|
106
128
|
tcp_listener.local_address.ip_port
|
|
@@ -112,7 +134,7 @@ module Raptor
|
|
|
112
134
|
#
|
|
113
135
|
# @rbs () -> bool
|
|
114
136
|
def http2?
|
|
115
|
-
@listeners.any? { |listener| listener.is_a?(SslListener) }
|
|
137
|
+
@listeners.any? { |listener| listener.is_a?(SslListener) || listener.is_a?(H2cListener) }
|
|
116
138
|
end
|
|
117
139
|
|
|
118
140
|
# Closes all listening sockets.
|
|
@@ -164,14 +186,16 @@ module Raptor
|
|
|
164
186
|
# Creates fresh listeners for the given URI.
|
|
165
187
|
#
|
|
166
188
|
# @param uri [URI] the parsed bind URI
|
|
167
|
-
# @return [Array<TCPServer, UNIXServer, SslListener>]
|
|
189
|
+
# @return [Array<TCPServer, UNIXServer, SslListener, H2cListener>]
|
|
168
190
|
# @raise [UnknownBindSchemeError] if the URI scheme is not supported
|
|
169
191
|
#
|
|
170
|
-
# @rbs (URI::Generic uri) -> Array[TCPServer | UNIXServer | SslListener]
|
|
192
|
+
# @rbs (URI::Generic uri) -> Array[TCPServer | UNIXServer | SslListener | H2cListener]
|
|
171
193
|
def create_listeners(uri)
|
|
172
194
|
case uri.scheme
|
|
173
195
|
when "tcp"
|
|
174
196
|
create_tcp_listeners(uri.host, uri.port)
|
|
197
|
+
when "h2c"
|
|
198
|
+
create_tcp_listeners(uri.host, uri.port).map { |listener| H2cListener.new(tcp_server: listener) }
|
|
175
199
|
when "unix"
|
|
176
200
|
create_unix_listeners(uri.path)
|
|
177
201
|
when "ssl"
|
|
@@ -186,14 +210,16 @@ module Raptor
|
|
|
186
210
|
#
|
|
187
211
|
# @param uri [URI] the parsed bind URI the FDs were bound to
|
|
188
212
|
# @param filenos [Array<Integer>] file descriptors to wrap
|
|
189
|
-
# @return [Array<TCPServer, UNIXServer, SslListener>]
|
|
213
|
+
# @return [Array<TCPServer, UNIXServer, SslListener, H2cListener>]
|
|
190
214
|
# @raise [UnknownBindSchemeError] if the URI scheme is not supported
|
|
191
215
|
#
|
|
192
|
-
# @rbs (URI::Generic uri, Array[Integer] filenos) -> Array[TCPServer | UNIXServer | SslListener]
|
|
216
|
+
# @rbs (URI::Generic uri, Array[Integer] filenos) -> Array[TCPServer | UNIXServer | SslListener | H2cListener]
|
|
193
217
|
def restore_listeners(uri, filenos)
|
|
194
218
|
case uri.scheme
|
|
195
219
|
when "tcp"
|
|
196
220
|
filenos.map { |fileno| TCPServer.for_fd(fileno) }
|
|
221
|
+
when "h2c"
|
|
222
|
+
filenos.map { |fileno| H2cListener.new(tcp_server: TCPServer.for_fd(fileno)) }
|
|
197
223
|
when "unix"
|
|
198
224
|
register_unix_socket_cleanup(uri.path)
|
|
199
225
|
filenos.map { |fileno| UNIXServer.for_fd(fileno) }
|
data/lib/raptor/cli.rb
CHANGED
|
@@ -56,6 +56,8 @@ module Raptor
|
|
|
56
56
|
http2: {
|
|
57
57
|
ractors: nil,
|
|
58
58
|
max_concurrent_streams: 100,
|
|
59
|
+
keepalive_interval: 10,
|
|
60
|
+
keepalive_timeout: 5,
|
|
59
61
|
},
|
|
60
62
|
worker_boot_timeout: 60,
|
|
61
63
|
worker_timeout: 60,
|
|
@@ -350,6 +352,14 @@ module Raptor
|
|
|
350
352
|
@options[:http2][:max_concurrent_streams] = num
|
|
351
353
|
end
|
|
352
354
|
|
|
355
|
+
opts.on("--http2-keepalive-interval SECONDS", Integer, "HTTP/2 idle time before sending a PING, or 0 to disable (default: 10)") do |seconds|
|
|
356
|
+
@options[:http2][:keepalive_interval] = seconds
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
opts.on("--http2-keepalive-timeout SECONDS", Integer, "HTTP/2 PING acknowledgement timeout in seconds (default: 5)") do |seconds|
|
|
360
|
+
@options[:http2][:keepalive_timeout] = seconds
|
|
361
|
+
end
|
|
362
|
+
|
|
353
363
|
opts.on("--worker-boot-timeout SECONDS", Integer, "Worker boot timeout in seconds (default: 60)") do |timeout|
|
|
354
364
|
@options[:worker_boot_timeout] = timeout
|
|
355
365
|
end
|
data/lib/raptor/cluster.rb
CHANGED
|
@@ -876,7 +876,8 @@ module Raptor
|
|
|
876
876
|
http2_ractor_pool,
|
|
877
877
|
thread_pool,
|
|
878
878
|
connection_options: @connection_options,
|
|
879
|
-
http1_options: @http1_options
|
|
879
|
+
http1_options: @http1_options,
|
|
880
|
+
http2_options: @http2_options
|
|
880
881
|
)
|
|
881
882
|
reactor_thread = reactor.run
|
|
882
883
|
|
|
@@ -943,12 +944,14 @@ module Raptor
|
|
|
943
944
|
end
|
|
944
945
|
|
|
945
946
|
server_thread.join
|
|
947
|
+
http1.shutdown
|
|
948
|
+
http2.shutdown(reactor)
|
|
949
|
+
reactor.drain_detached_bodies(@worker_drain_timeout)
|
|
950
|
+
drain_thread_pool(thread_pool)
|
|
946
951
|
reactor.shutdown
|
|
947
952
|
reactor_thread.join
|
|
948
953
|
http1_ractor_pool.shutdown
|
|
949
954
|
http2_ractor_pool&.shutdown
|
|
950
|
-
http1.shutdown
|
|
951
|
-
drain_thread_pool(thread_pool)
|
|
952
955
|
stats_thread.join
|
|
953
956
|
|
|
954
957
|
run_seed_loop(index) if promote_to_seed
|