raptor 0.19.0 → 0.20.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6620d52a503c84e57e9aea223f0980e2215aeb58da7154e077c31c0d1858bf12
4
- data.tar.gz: 36c546ae9bb960f6049a82e123af665375785872533b4657f0c336abd8ab7a9f
3
+ metadata.gz: a59e1627962060bd0aa0a45f3b86eb1b1da18c77257b859f3861402a8012bfc2
4
+ data.tar.gz: 6d7b855e83333ccd6ed0b1af2d3eea2a6bf26d45edbd3431c55d6ec4f7df1c73
5
5
  SHA512:
6
- metadata.gz: 729594070744ec522ec48852af6cce2c5aeef4df2050359473bdbf3f11aeaa167dbf7124e9a2fa21e622f77b6351d508fa3f4cf7438839e1b4e8a037784a7c89
7
- data.tar.gz: 6812627b196892d9ffbc7608d34fc54c790d2d96ecdd4fa0823946389cc147a6e1640028cd057b958e91e9a2fc6307397e693bd5e6e7998b1c41b9fc9d13195b
6
+ metadata.gz: d68cae66c278dcb69d0452c02a7ebc2f28e7a7e0bcf076000843a8b401690629270b95eec3cf6e733d7b75c0c99e69f07f30f8cfa89040ddbfa38613e4dfb542
7
+ data.tar.gz: 8e8aa0ce95ceab5627b2fa28d5b9d006960ed0d6a5817521d6fb4e4b82559848870f05734c1f95ba0967b41c0b6ad4958ba049be5041456ee1e308ac5c86838c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.20.0] - 2026-08-30
4
+
5
+ - Close connected control socket clients during shutdown
6
+ - Skip TCP corking when `writev(2)` emits a response in one call
7
+ - Return idle HTTP/1.1 keep-alive connections to the reactor without waiting
8
+ - Increase the HTTP/1.1 keep-alive request limit to 1,000 and requeue the connection when other work is waiting
9
+ - Add a read-only `/stats` control socket
10
+ - Pass the worker index to worker boot and shutdown hooks
11
+ - Add `RAPTOR_WORKERS`, `RAPTOR_THREADS`, and `RAPTOR_MAX_THREADS`
12
+ - Make worker CPU affinity opt-in
13
+ - Add request-local thread and Fiber cleanup options
14
+
3
15
  ## [0.19.0] - 2026-08-29
4
16
 
5
17
  - Skip HTTP/2 Ractor pools without SSL bindings
data/README.md CHANGED
@@ -36,29 +36,29 @@ run proc { |_env| [200, { "content-type" => "text/plain" }, ["Hello, World!"]] }
36
36
 
37
37
  ```
38
38
  > bundle exec raptor -w 10 -t 3 hello_world.ru
39
- [Raptor 46475|Main|Main] Cluster initializing:
40
- [Raptor 46475|Main|Main] ├─ Version: 0.19.0
41
- [Raptor 46475|Main|Main] ├─ Ruby Version: ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +YJIT +PRISM [arm64-darwin23]
42
- [Raptor 46475|Main|Main] ├─ Environment: development
43
- [Raptor 46475|Main|Main] ├─ Master PID: 46475
44
- [Raptor 46475|Main|Main] │ └─ 10 worker processes
45
- [Raptor 46475|Main|Main] │ ├─ 1 server thread
46
- [Raptor 46475|Main|Main] │ ├─ 1 reactor thread
47
- [Raptor 46475|Main|Main] │ ├─ 1 HTTP/1.1 pipeline ractor
48
- [Raptor 46475|Main|Main] │ ├─ 1 pipeline collector thread
49
- [Raptor 46475|Main|Main] │ ├─ 3 worker threads (scaling, no limit)
50
- [Raptor 46475|Main|Main] │ └─ 1 stats thread
51
- [Raptor 46475|Main|Main] └─ Listening on 0.0.0.0:9292
52
- [Raptor 46480|Main|Main] Worker 0 booted
53
- [Raptor 46484|Main|Main] Worker 4 booted
54
- [Raptor 46482|Main|Main] Worker 2 booted
55
- [Raptor 46481|Main|Main] Worker 1 booted
56
- [Raptor 46483|Main|Main] Worker 3 booted
57
- [Raptor 46485|Main|Main] Worker 5 booted
58
- [Raptor 46486|Main|Main] Worker 6 booted
59
- [Raptor 46487|Main|Main] Worker 7 booted
60
- [Raptor 46488|Main|Main] Worker 8 booted
61
- [Raptor 46489|Main|Main] Worker 9 booted
39
+ [Raptor 72876|Main|Main] Cluster initializing:
40
+ [Raptor 72876|Main|Main] ├─ Version: 0.20.0
41
+ [Raptor 72876|Main|Main] ├─ Ruby Version: ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +YJIT +PRISM [arm64-darwin23]
42
+ [Raptor 72876|Main|Main] ├─ Environment: development
43
+ [Raptor 72876|Main|Main] ├─ Master PID: 72876
44
+ [Raptor 72876|Main|Main] │ └─ 10 worker processes
45
+ [Raptor 72876|Main|Main] │ ├─ 1 server thread
46
+ [Raptor 72876|Main|Main] │ ├─ 1 reactor thread
47
+ [Raptor 72876|Main|Main] │ ├─ 1 HTTP/1.1 pipeline ractor
48
+ [Raptor 72876|Main|Main] │ ├─ 1 pipeline collector thread
49
+ [Raptor 72876|Main|Main] │ ├─ 3 worker threads (scaling, no limit)
50
+ [Raptor 72876|Main|Main] │ └─ 1 stats thread
51
+ [Raptor 72876|Main|Main] └─ Listening on 0.0.0.0:9292
52
+ [Raptor 72884|Main|Main] Worker 0 booted
53
+ [Raptor 72885|Main|Main] Worker 1 booted
54
+ [Raptor 72886|Main|Main] Worker 2 booted
55
+ [Raptor 72887|Main|Main] Worker 3 booted
56
+ [Raptor 72891|Main|Main] Worker 7 booted
57
+ [Raptor 72888|Main|Main] Worker 4 booted
58
+ [Raptor 72890|Main|Main] Worker 6 booted
59
+ [Raptor 72889|Main|Main] Worker 5 booted
60
+ [Raptor 72892|Main|Main] Worker 8 booted
61
+ [Raptor 72893|Main|Main] Worker 9 booted
62
62
  ```
63
63
 
64
64
  ```
@@ -93,6 +93,9 @@ The config file is a Ruby file that evaluates to a hash of options. By default R
93
93
  workers: 4, # `Etc.nprocessors`
94
94
  threads: 3,
95
95
  max_threads: Float::INFINITY, # set to `threads` for a fixed pool
96
+ cpu_affinity: false,
97
+ clean_thread_locals: true,
98
+ clean_fiber_locals: false,
96
99
  chdir: nil,
97
100
  environment: nil, # falls back to `RAILS_ENV`, then `RACK_ENV`, then `"development"`
98
101
  connection: {
@@ -105,7 +108,7 @@ The config file is a Ruby file that evaluates to a hash of options. By default R
105
108
  http1: {
106
109
  ractors: nil,
107
110
  persistent_data_timeout: 65,
108
- max_keepalive_requests: 100,
111
+ max_keepalive_requests: 1000,
109
112
  },
110
113
  http2: {
111
114
  ractors: nil,
@@ -121,6 +124,7 @@ The config file is a Ruby file that evaluates to a hash of options. By default R
121
124
  before_worker_shutdown: [],
122
125
  before_refork: [],
123
126
  stats_file: "tmp/raptor.json",
127
+ control_url: nil,
124
128
  pid_file: nil,
125
129
  stdout_file: nil,
126
130
  stderr_file: nil,
@@ -133,6 +137,19 @@ without a fixed limit when queued work is held up by blocking operations. It doe
133
137
  GVL is the bottleneck, and temporary threads leave after the queue drains. Set `max_threads` to cap growth, or set it
134
138
  to the same value as `threads` for a fixed pool.
135
139
 
140
+ Set `cpu_affinity` to `true` to pin each worker to a distinct CPU when the worker count fits within the process's
141
+ allowed CPU set. It is off by default because container runtimes commonly expose CPUs that are shared with other
142
+ containers.
143
+
144
+ Raptor clears application thread locals after each request by default. Set `clean_thread_locals` to `false` to disable
145
+ it. Set `clean_fiber_locals` to `true` to run each request in a fresh Fiber, isolating Fiber-local state as well.
146
+
147
+ `RAPTOR_WORKERS`, `RAPTOR_THREADS`, and `RAPTOR_MAX_THREADS` can set the corresponding options without a config file.
148
+ Config files override defaults, environment variables override config files, and command-line options override both.
149
+ `RAPTOR_MAX_THREADS=unlimited` leaves adaptive growth uncapped.
150
+
151
+ `before_worker_boot` and `before_worker_shutdown` hooks receive the worker index.
152
+
136
153
  ## Bindings
137
154
 
138
155
  Raptor accepts multiple `binds:` URIs across three schemes.
@@ -204,9 +221,14 @@ Worker 1 (phase 0): pid=91351, requests=1199, busy=1/3, backlog=0, booted, last_
204
221
  ...
205
222
  ```
206
223
 
224
+ Set `control_url` to a Unix socket URL such as `unix:///tmp/raptor-control.sock` to expose cluster stats over `/stats`.
225
+ For adaptive pools, `max_threads` in each worker's status is its current thread count, so
226
+ `pool_capacity / max_threads` measures the capacity available at that moment rather than comparing against an
227
+ unbounded configured limit. The control server is read-only and currently exposes only `/stats`.
228
+
207
229
  ## (Micro) Benchmarks
208
230
 
209
- Raptor 0.19.0 vs Puma 8.0.2 vs Falcon 0.57.0 across two workload profiles. **IO-bound** is a GET endpoint that
231
+ Raptor 0.20.0 vs Puma 8.0.2 vs Falcon 0.57.0 across two workload profiles. **IO-bound** is a GET endpoint that
210
232
  interleaves 5-10 short sleeps (total 2.5-15ms) with small CPU work, simulating a read path that makes several DB or
211
233
  cache calls. **CPU-bound** is a POST endpoint that accepts a small JSON body, interleaves 3-5 chunks of JSON item
212
234
  building (total 450-1500 items) with sub-100µs sleeps, and returns the built array, simulating a write path that does
@@ -214,27 +236,28 @@ most of its work in Ruby with a few near-zero-cost cache hits.
214
236
 
215
237
  Raptor is run in two modes: **Fixed** keeps 3 application threads per worker, matching Puma, while **Scaling** starts
216
238
  with 3 and may add threads without a fixed limit when queued work is blocked outside the GVL. Both modes are compared
217
- with both Puma and Falcon in the table below.
239
+ with both Puma and Falcon in the table below. Raptor request-local cleanup and Puma's Fiber-per-request mode are
240
+ disabled, and both threaded servers allow 999 requests per HTTP/1.1 keep-alive connection.
218
241
 
219
242
  Each cell reports the median throughput and median p95 latency independently across 3 runs, so the two numbers in a row
220
243
  may come from different runs. Every run starts a fresh server process so the samples are independent of each other;
221
244
  state accumulated in a previous run cannot bias the next. Across the whole table, the widest spread
222
- ((max - min) / 2 / median) between runs of a single cell was ±26.6% for throughput and ±45.2% for p95.
245
+ ((max - min) / 2 / median) between runs of a single cell was ±21.7% for throughput and ±31.6% for p95.
223
246
 
224
247
  | 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 |
225
248
  | --------------------- | -------- | ----------- | ------------ | ---------- | ----------- | --------- | ------------- | ------------ | ------------ | ---------- | --------------- | ------------- |
226
- | HTTP/1.1 | IO | Fixed | 3.11k req/s | 60.50 ms | 1.51k req/s | 123.80 ms | 106.1% higher | 51.1% lower | 12.20k req/s | 14.20 ms | 74.5% lower | 326.1% higher |
227
- | HTTP/1.1 | IO | Scaling | 8.63k req/s | 21.60 ms | 1.51k req/s | 123.80 ms | 471.4% higher | 82.6% lower | 12.20k req/s | 14.20 ms | 29.3% lower | 52.1% higher |
228
- | HTTP/1.1 | CPU | Fixed | 8.22k req/s | 25.40 ms | 8.65k req/s | 20.90 ms | 5.0% lower | 21.5% higher | 6.52k req/s | 27.40 ms | 26.0% higher | 7.3% lower |
229
- | HTTP/1.1 | CPU | Scaling | 8.22k req/s | 25.10 ms | 8.65k req/s | 20.90 ms | 4.9% lower | 20.1% higher | 6.52k req/s | 27.40 ms | 26.1% higher | 8.4% lower |
230
- | HTTP/1.1 (keep-alive) | IO | Fixed | 3.21k req/s | 45.90 ms | 1.46k req/s | 105.50 ms | 119.5% higher | 56.5% lower | 6.23k req/s | 28.20 ms | 48.6% lower | 62.8% higher |
231
- | HTTP/1.1 (keep-alive) | IO | Scaling | 9.43k req/s | 21.60 ms | 1.46k req/s | 105.50 ms | 545.8% higher | 79.5% lower | 6.23k req/s | 28.20 ms | 51.3% higher | 23.4% lower |
232
- | HTTP/1.1 (keep-alive) | CPU | Fixed | 8.49k req/s | 21.80 ms | 8.54k req/s | 22.20 ms | 0.7% lower | 1.8% lower | 6.86k req/s | 32.70 ms | 23.8% higher | 33.3% lower |
233
- | HTTP/1.1 (keep-alive) | CPU | Scaling | 8.49k req/s | 22.20 ms | 8.54k req/s | 22.20 ms | 0.6% lower | 0.0% higher | 6.86k req/s | 32.70 ms | 23.8% higher | 32.1% lower |
234
- | HTTP/2 | IO | Fixed | 1.14k req/s | 150.03 ms | N/A | N/A | - | - | 6.40k req/s | 28.07 ms | 82.1% lower | 434.4% higher |
235
- | HTTP/2 | IO | Scaling | 4.46k req/s | 39.65 ms | N/A | N/A | - | - | 6.40k req/s | 28.07 ms | 30.3% lower | 41.2% higher |
236
- | HTTP/2 | CPU | Fixed | 5.35k req/s | 35.99 ms | N/A | N/A | - | - | 7.32k req/s | 41.05 ms | 26.9% lower | 12.3% lower |
237
- | HTTP/2 | CPU | Scaling | 6.28k req/s | 32.32 ms | N/A | N/A | - | - | 7.32k req/s | 41.05 ms | 14.2% lower | 21.3% lower |
249
+ | HTTP/1.1 | IO | Fixed | 2.81k req/s | 82.40 ms | 1.51k req/s | 124.90 ms | 86.5% higher | 34.0% lower | 11.81k req/s | 14.70 ms | 76.2% lower | 460.5% higher |
250
+ | HTTP/1.1 | IO | Scaling | 7.17k req/s | 30.50 ms | 1.51k req/s | 124.90 ms | 375.3% higher | 75.6% lower | 11.81k req/s | 14.70 ms | 39.2% lower | 107.5% higher |
251
+ | HTTP/1.1 | CPU | Fixed | 7.20k req/s | 37.00 ms | 8.59k req/s | 21.00 ms | 16.1% lower | 76.2% higher | 6.51k req/s | 28.10 ms | 10.6% higher | 31.7% higher |
252
+ | HTTP/1.1 | CPU | Scaling | 6.91k req/s | 38.60 ms | 8.59k req/s | 21.00 ms | 19.6% lower | 83.8% higher | 6.51k req/s | 28.10 ms | 6.1% higher | 37.4% higher |
253
+ | HTTP/1.1 (keep-alive) | IO | Fixed | 2.81k req/s | 50.60 ms | 1.46k req/s | 109.90 ms | 92.0% higher | 54.0% lower | 6.33k req/s | 27.60 ms | 55.6% lower | 83.3% higher |
254
+ | HTTP/1.1 (keep-alive) | IO | Scaling | 7.71k req/s | 24.20 ms | 1.46k req/s | 109.90 ms | 426.9% higher | 78.0% lower | 6.33k req/s | 27.60 ms | 21.8% higher | 12.3% lower |
255
+ | HTTP/1.1 (keep-alive) | CPU | Fixed | 5.90k req/s | 33.10 ms | 8.27k req/s | 22.70 ms | 28.7% lower | 45.8% higher | 6.86k req/s | 32.90 ms | 14.0% lower | 0.6% higher |
256
+ | HTTP/1.1 (keep-alive) | CPU | Scaling | 5.96k req/s | 34.90 ms | 8.27k req/s | 22.70 ms | 28.0% lower | 53.7% higher | 6.86k req/s | 32.90 ms | 13.2% lower | 6.1% higher |
257
+ | HTTP/2 | IO | Fixed | 1.23k req/s | 146.17 ms | N/A | N/A | - | - | 6.57k req/s | 27.30 ms | 81.3% lower | 435.4% higher |
258
+ | HTTP/2 | IO | Scaling | 6.53k req/s | 28.69 ms | N/A | N/A | - | - | 6.57k req/s | 27.30 ms | 0.6% lower | 5.1% higher |
259
+ | HTTP/2 | CPU | Fixed | 6.25k req/s | 32.60 ms | N/A | N/A | - | - | 8.39k req/s | 26.63 ms | 25.5% lower | 22.4% higher |
260
+ | HTTP/2 | CPU | Scaling | 5.64k req/s | 32.63 ms | N/A | N/A | - | - | 8.39k req/s | 26.63 ms | 32.8% lower | 22.5% higher |
238
261
 
239
262
  > ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +YJIT +PRISM [aarch64-linux]
240
263
  > 10 worker processes; fixed Raptor and Puma run 3 threads per worker; scaling Raptor starts at 3 with no fixed limit;
@@ -1087,10 +1087,10 @@ The bit that matters most in practice:
1087
1087
 
1088
1088
  The pool starts at `threads` and scales automatically when more threads would help.
1089
1089
 
1090
- - <big>A native CRuby thread hook measures time running, blocked outside the GVL, and waiting for the GVL</big>
1090
+ - <big>When scaling is enabled, a native CRuby thread hook measures time running, blocked outside the GVL, and waiting for the GVL</big>
1091
1091
  - <big>The queue has to stay non-empty, and every current worker has to be active</big>
1092
1092
  - <big>Blocked time has to exceed half of worker time</big>
1093
- - <big>GVL wait has to stay below two percent</big>
1093
+ - <big>The workers have to use less than half of one CPU during the sample</big>
1094
1094
  - <big>Only then does the pool add a temporary thread</big>
1095
1095
  - <big>When the queue drains, temporary threads leave and the pool returns to `threads`</big>
1096
1096
 
@@ -1098,6 +1098,8 @@ The distinction matters. More threads help when requests are asleep in database
1098
1098
 
1099
1099
  Growth has no fixed limit by default. Set `max_threads` to cap it, or set it to `threads` to keep the pool fixed. OS threads still are not as cheap as fibers.
1100
1100
 
1101
+ Raptor clears application thread locals when each request finishes. Running every request in a fresh Fiber is also available when an application needs Fiber-local isolation. Parser and response buffers are kept separately so the server can still reuse them without carrying application state into the next request.
1102
+
1101
1103
  <br>
1102
1104
  <br>
1103
1105
  <br>
@@ -1318,7 +1320,7 @@ flowchart TB
1318
1320
  Col -->|"complete"| ATP
1319
1321
  Col -->|"incomplete"| Rct
1320
1322
  ATP -->|"response bytes"| Client
1321
- ATP -.->|"keep-alive: wait_readable(1ms), parse next inline"| ATP
1323
+ ATP -.->|"keep-alive: parse ready bytes inline"| ATP
1322
1324
  ```
1323
1325
 
1324
1326
  <br>
@@ -1386,20 +1388,20 @@ After writing a response, if keep-alive is on:
1386
1388
 
1387
1389
  ```ruby
1388
1390
  loop do
1389
- unless socket.wait_readable(0.001) # 1 millisecond
1391
+ unless socket.wait_readable(0)
1390
1392
  reactor.persist(socket, id, ...)
1391
1393
  return
1392
1394
  end
1393
1395
 
1394
- # Bytes arrived. Parse the next request inline on this thread.
1396
+ # Bytes are ready. Parse the next request inline on this thread.
1395
1397
  # ...
1396
1398
  end
1397
1399
  ```
1398
1400
 
1399
- - <big>Wait 1ms for the next request on the same connection</big>
1400
- - <big>If bytes arrive in that window: parse and dispatch inline, on the same thread</big>
1401
- - <big>Return to the reactor when no bytes arrive inside the 1ms window, or when a request is incomplete</big>
1402
- - <big>The trade: occupy an app thread for up to 1ms to widen the no-reactor fast path</big>
1401
+ - <big>Check for the next request on the same connection without waiting</big>
1402
+ - <big>If bytes are ready: parse and dispatch inline, on the same thread</big>
1403
+ - <big>Return to the reactor immediately when no bytes are ready, or when a request is incomplete</big>
1404
+ - <big>Back-to-back requests avoid a reactor round-trip without holding an app thread open</big>
1403
1405
 
1404
1406
  <br>
1405
1407
  <br>
@@ -1706,7 +1708,8 @@ flowchart TB
1706
1708
  - <big>Every worker inherits the mapping</big>
1707
1709
  - <big>Each worker writes a 49-byte slot every second: pid, phase, requests, backlog, busy and available threads, boot time, checkin time, booted flag</big>
1708
1710
  - <big>Master reads the whole region directly. No JSON. No pipe drain. No signal.</big>
1709
- - <big>`bundle exec raptor stats` prints the region as JSON, essentially instantly</big>
1711
+ - <big>`bundle exec raptor stats` reads the master's JSON stats file and prints each worker's status</big>
1712
+ - <big>An optional read-only Unix socket exposes the cluster snapshot at `GET /stats` for monitoring</big>
1710
1713
 
1711
1714
  Wrapped in a small C extension I wrote: **`mmap-ruby`**.
1712
1715
 
@@ -2240,10 +2243,10 @@ I didn't set out to build a small library ecosystem. It's what happens when you
2240
2243
 
2241
2244
  Real numbers are in the [README benchmarks section](../README.md#micro-benchmarks). The shape:
2242
2245
 
2243
- - <big>**IO-bound HTTP/1.1**: Scaling lifts Raptor from 3.11k to 8.63k req/s without keep-alive and from 3.21k to 9.43k with it. Falcon wins the first; Raptor wins the second.</big>
2244
- - <big>**CPU-bound HTTP/1.1**: Fixed and scaling Raptor are effectively identical. Puma leads by 5% without keep-alive and less than 1% with it; Raptor leads Falcon.</big>
2246
+ - <big>**IO-bound HTTP/1.1**: Scaling handles far more requests than the fixed pool. It closes much of Falcon's lead without keep-alive and leads it with keep-alive.</big>
2247
+ - <big>**CPU-bound HTTP/1.1**: Scaling stays close to the fixed pool instead of adding threads while Ruby execution is the bottleneck. Puma leads both Raptor modes in the current run.</big>
2245
2248
  - Tail latency ("p95") is the response time that 5% of requests exceed. It's what your slowest users see. Lower is better.
2246
- - <big>**HTTP/2**: Scaling lifts Raptor from 1.14k to 4.46k req/s on IO and narrows Falcon's CPU-throughput lead from 27% to 14%. Falcon still leads throughput; Raptor has the lower CPU p95.</big>
2249
+ - <big>**HTTP/2**: Scaling brings Raptor close to Falcon on the IO workload. Falcon leads the CPU workload in the current run.</big>
2247
2250
  - <big>**Variance**: HTTP/1.1 is stable. HTTP/2 is noisy enough that I treat it as direction, not a precise ranking.</big>
2248
2251
 
2249
2252
  Different workloads, different winners. That's fine.
@@ -1,6 +1,6 @@
1
1
  # Raptor vs Puma: A Design Comparison
2
2
 
3
- Raptor is a Ruby web server built around Ractor-parallel protocol pipelines, a lock-free app thread pool, and an opinionated cluster architecture. Against the latest Puma release on the same hardware, it leads clearly on the IO-heavy HTTP/1.1 benchmark and lands within a few percent on CPU-heavy throughput, while Falcon's fibers remain the strongest fit for highly concurrent IO. Raptor also speaks HTTP/2 natively, which Puma does not. This document explains how those designs move requests and where each trade-off shows up.
3
+ Raptor is a Ruby web server built around Ractor-parallel protocol pipelines, a lock-free app thread pool, and an opinionated cluster architecture. Its benchmark against Puma and Falcon shows how differently the three servers respond to IO-heavy and CPU-heavy work. Raptor also speaks HTTP/2 natively, which Puma does not. This document explains how the Puma and Raptor designs move requests and where each trade-off shows up.
4
4
 
5
5
  ## Why this document exists
6
6
 
@@ -26,9 +26,9 @@ Two workload profiles are measured. **IO-bound** is a GET endpoint that does 5 t
26
26
 
27
27
  Each cell in the table reports the median throughput and median p95 latency independently across 3 runs, and every run boots a fresh server process so state cannot accumulate across measurements. Both Raptor modes are compared with both servers: fixed mode is the like-for-like comparison with Puma, while scaling mode tests whether adaptive OS threads can close the IO-concurrency gap with Falcon's much cheaper fibers. Rather than pin every number into this document (they drift with Ruby versions and hardware), the shape of the result is what matters.
28
28
 
29
- - On IO-bound HTTP/1.1, fixed Raptor delivers a little over twice Puma's throughput with less than half its p95 latency. Scaling lifts Raptor to 8.63k req/s without keep-alive and 9.43k with it: still 29.3% behind Falcon on fresh connections, but 51.3% ahead with keep-alive.
30
- - On CPU-bound HTTP/1.1, fixed and scaling Raptor are effectively identical. Puma leads by 5.0% without keep-alive and 0.7% with it, while both Raptor modes lead Falcon. This is the intended result: the pool gets its IO gains without trading away CPU throughput.
31
- - On HTTP/2, Raptor and Falcon both implement it; Puma doesn't. Scaling raises Raptor's IO throughput from 1.14k to 4.46k req/s, though it remains 30.3% behind Falcon. On the CPU profile it narrows Falcon's throughput lead from 26.9% to 14.2% while keeping a lower p95. HTTP/2 also varies substantially more between runs in this benchmark, so those medians deserve less confidence than the stable HTTP/1.1 results.
29
+ - On IO-bound HTTP/1.1, both Raptor modes lead Puma. Scaling handles far more requests than the fixed pool, closes much of Falcon's lead without keep-alive, and leads Falcon with keep-alive.
30
+ - On CPU-bound HTTP/1.1, Puma leads both Raptor modes in the current run. Fixed and scaling Raptor stay close to each other, which is the important guardrail: the pool gets its IO gains without adding threads while Ruby execution is the bottleneck.
31
+ - On HTTP/2, Raptor and Falcon both implement it; Puma doesn't. Scaling brings Raptor close to Falcon on the IO profile, while Falcon leads the CPU profile in the current run. HTTP/2 also varies substantially more between runs in this benchmark, so those medians deserve less confidence than the stable HTTP/1.1 results.
32
32
 
33
33
  The rest of this doc explains why the shape looks like that.
34
34
 
@@ -44,7 +44,7 @@ The rest of this doc explains why the shape looks like that.
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
46
  | HTTP/2 | Not implemented | Native C parser + HPACK, lock-free per-connection frame writer |
47
- | Keep-alive fast path | Same-thread inline dispatch when spare threads exist | Same-thread inline plus a `wait_readable(1ms)` micro-poll on the app thread |
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 |
50
50
  | Restart primitives | Phased (USR1), hot (USR2 re-exec, inherits FDs via env), refork (SIGURG) | Phased (USR1), hot (USR2 re-exec, inherits FDs via env), refork (SIGURG) |
@@ -181,8 +181,12 @@ Raptor takes a different position on nearly every axis. It is opinionated in a w
181
181
 
182
182
  There is no single mode. Raptor is always a cluster. A master forks N workers, monitors them, and restarts crashed workers. The Rack app is always loaded in the master before forking, so copy-on-write is preserved by default (no user-visible `preload_app` knob).
183
183
 
184
+ The process and app-thread counts can be set with `RAPTOR_WORKERS`, `RAPTOR_THREADS`, and `RAPTOR_MAX_THREADS`, which lets a deployment tune them without generating a config file. Config files override the built-in defaults, environment variables override config files, and command-line options override both. `RAPTOR_MAX_THREADS=unlimited` leaves adaptive growth uncapped.
185
+
184
186
  The master is a supervisor. It never handles requests. It forks workers, watches them via a shared-memory region (more on that in a moment), traps signals, restarts crashed workers, and orchestrates restarts.
185
187
 
188
+ Worker boot and shutdown hooks receive the worker's slot index, so setup and cleanup can identify the same slot across restarts. Hooks that do not take an argument continue to work.
189
+
186
190
  Two kinds of restart are supported:
187
191
 
188
192
  1. **Phased restart on SIGUSR1.** Same idea as Puma. Kill each worker in sequence, wait for its replacement to boot, move on. Existing workers drain their connections while their replacements come up. This is cheap and safe when the change does not require a fresh master.
@@ -191,9 +195,9 @@ Two kinds of restart are supported:
191
195
 
192
196
  Systemd socket activation is a native feature and slots straight into this model. When the service unit is `Type=notify` and there is a socket unit, systemd passes listener FDs via `LISTEN_FDS`. Raptor detects this exactly the same way it detects a hot restart handoff: `Systemd.listen_fds` returns the FDs, the binder is built from them, and the master sends `READY=1` back to systemd once workers have booted. `STOPPING=1` and `RELOADING=1` fire on the corresponding lifecycle events.
193
197
 
194
- Routine worker monitoring does not use pipes. Every worker writes its stats (pid, request count, backlog, busy and available threads, last checkin timestamp, booted flag) into a fixed-size slot in an anonymous shared-memory region allocated with `mmap-ruby` before the fork. The master reads the region directly. There is no serialisation, pipe drain, or signal to trigger the read; it is 49 bytes per worker of native memory. `bundle exec raptor stats` prints a JSON snapshot. Refork coordination is separate and does use a pair of pipes between the master and seed.
198
+ Routine worker monitoring does not use pipes. Every worker writes its stats (pid, request count, backlog, busy and available threads, last checkin timestamp, booted flag) into a fixed-size slot in an anonymous shared-memory region allocated with `mmap-ruby` before the fork. The master reads the region directly. There is no serialisation, pipe drain, or signal to trigger the read; it is 49 bytes per worker of native memory. The master writes the same data to the configured JSON stats file, which `bundle exec raptor stats` reads and formats. Refork coordination is separate and does use a pair of pipes between the master and seed.
195
199
 
196
- On Linux, each worker pins itself to a distinct CPU via `sched_setaffinity` when the worker count fits within the process's allowed CPU set, so it stays on one core and its L1/L2 caches stay warm. When workers outnumber available CPUs the pin is skipped and the kernel scheduler manages placement.
200
+ On Linux, `cpu_affinity: true` pins each worker to a distinct CPU via `sched_setaffinity` when the worker count fits within the process's allowed CPU set, so it stays on one core and its L1/L2 caches stay warm. It is off by default because an allowed CPU in a container is not necessarily dedicated to that container. When workers outnumber available CPUs the pin is skipped and the kernel scheduler manages placement.
197
201
 
198
202
  ### Refork
199
203
 
@@ -238,10 +242,12 @@ Raptor always runs an HTTP/1.1 pool because every binding supports HTTP/1.1. It
238
242
 
239
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.
240
244
 
241
- The pool starts at `threads` and scales without a fixed limit by default. Set `max_threads` to cap its growth, or set it to the same value as `threads` to keep the pool fixed. A native CRuby thread-event hook measures how much active workers spend running, blocked outside the GVL, and waiting to acquire it. The pool only adds a temporary thread after work has remained queued across several samples, every current worker is active, blocked time is above half of measured worker time, and GVL wait is below two percent. That last check matters: adding threads helps when existing threads are asleep in database or network calls, but hurts when CPU-bound Ruby threads are already fighting over the GVL. Temporary threads retire after the queue has remained empty for a second.
245
+ The pool starts at `threads` and scales without a fixed limit by default. Set `max_threads` to cap its growth, or set it to the same value as `threads` to keep the pool fixed. When growth is enabled, a native CRuby thread-event hook measures how much active workers spend running, blocked outside the GVL, and waiting to acquire it. The pool only adds a temporary thread after work has remained queued across several samples, every current worker is active, blocked time is above half of measured worker time, and the workers have used less than half of one CPU during the sample. That last check matters: adding threads helps when existing threads are asleep in database or network calls, but hurts when CPU-bound Ruby threads are already fighting over the GVL. Temporary threads retire after the queue has remained empty for a second.
242
246
 
243
247
  The pool still uses an `AtomicConditionVariable` under the hood to park idle threads (idle threads call `Thread.stop` and get woken with `Thread#wakeup`; there is no spinning), because idle spinning would waste CPU. The difference from Puma's pool is not "no locks anywhere" but rather "the hot path (enqueue and dequeue when the queue has items) is lock-free". Once every worker is busy the mechanics look similar; where things diverge is under contention when you have many threads all trying to push and pop.
244
248
 
249
+ Application thread locals are cleared when each request finishes. Running every request in a fresh Fiber is independently configurable, but opt-in. The pool keeps its own parser and response buffers in private thread variables so the server can reuse them without preserving application state between requests.
250
+
245
251
  The knock-on effect is that the server thread can read `pool.queue_size + pool.active_count` on every accept-loop iteration without acquiring the queue's mutation lock. Those are still synchronised atomic reads, but they do not serialise producers and consumers behind one mutex.
246
252
 
247
253
  ### I/O model
@@ -305,7 +311,7 @@ The eager keep-alive loop is one of Raptor's more deliberate latency/occupancy t
305
311
 
306
312
  ```ruby
307
313
  loop do
308
- unless socket.wait_readable(KEEPALIVE_READ_TIMEOUT) # 0.001 s
314
+ unless socket.wait_readable(0)
309
315
  reactor.persist(socket, id, request_count, ...)
310
316
  return
311
317
  end
@@ -314,9 +320,9 @@ loop do
314
320
  end
315
321
  ```
316
322
 
317
- The thread waits up to 1 millisecond for the next request. If bytes arrive in that window, it parses them inline on the same thread and calls the Rack app again. If no bytes arrive, the connection returns to the reactor. Pipelined requests therefore avoid a reactor round-trip, while an idle keep-alive connection occupies an app thread for at most that short polling window.
323
+ The thread checks for bytes without waiting. If they are already available, it parses them inline and calls the Rack app again. Otherwise the connection returns to the reactor immediately. Pipelined requests avoid a reactor round-trip without letting an idle keep-alive connection occupy an app thread.
318
324
 
319
- Puma has a similar shape but does not wait. It checks buffered back-to-back requests, then eagerly drains bytes already available on the socket. If a complete request is ready and the pool has a waiting thread, the current thread loops inline; otherwise Puma queues the client or returns it to the reactor. Raptor's 1ms poll deliberately widens the window in which the next request can stay on the current app thread.
325
+ Puma has a similar shape. It checks buffered back-to-back requests, then eagerly drains bytes already available on the socket. If a complete request is ready and the pool has a waiting thread, the current thread loops inline; otherwise Puma queues the client or returns it to the reactor.
320
326
 
321
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.
322
328
 
@@ -384,7 +390,7 @@ flowchart TB
384
390
 
385
391
  CHK{"Request<br/>complete?"}
386
392
  KA{"Keep-alive?"}
387
- EAG{"wait_readable<br/>1ms"}
393
+ EAG{"wait_readable(0)<br/>bytes ready?"}
388
394
 
389
395
  SRV -->|"HTTP/1.1 eager_accept, parse inline, push proc"| ATP
390
396
  SRV -->|"HTTP/2 eager_accept, parse inline, push proc"| ATP
@@ -399,7 +405,7 @@ flowchart TB
399
405
  ATP -->|"app.call + write"| KA
400
406
  KA -->|"no, close"| CLS["close socket"]
401
407
  KA -->|"yes"| EAG
402
- EAG -.->|"bytes within 1ms, parse+dispatch on same thread"| ATP
408
+ EAG -.->|"bytes ready, parse+dispatch on same thread"| ATP
403
409
  EAG -->|"no bytes, reactor.persist"| RCT
404
410
  RCT -->|"timeout expired"| TO["write 408, close"]
405
411
 
@@ -431,7 +437,7 @@ The critical structural difference from Puma is that Raptor has a separate proto
431
437
 
432
438
  **Puma.** Parsing happens on an app thread. The C parser callbacks build the env hash. A fresh client first enters the thread pool, where eager reads may complete it immediately; partial and idle keep-alive connections wait in the reactor before returning to the pool. Parsing shares the worker's GVL with the app.
433
439
 
434
- **Raptor.** Fresh and immediate keep-alive requests parse inline on the server or app thread. A connection that needs more bytes takes the longer path: reactor (I/O) → protocol Ractor pool (parse) → collector → app thread pool (Rack + write). Between keep-alive requests, the app thread does a 1ms micro-poll before returning an idle connection to the reactor. Parsing in the Ractor pipeline has its own GVL; parsing on an eager path does not.
440
+ **Raptor.** Fresh and immediate keep-alive requests parse inline on the server or app thread. A connection that needs more bytes takes the longer path: reactor (I/O) → protocol Ractor pool (parse) → collector → app thread pool (Rack + write). Between keep-alive requests, the app thread checks for bytes without waiting before returning an idle connection to the reactor. Parsing in the Ractor pipeline has its own GVL; parsing on an eager path does not.
435
441
 
436
442
  In practice, Puma has one process-wide GVL per worker. Every Ruby thread inside that worker takes turns holding it. Raptor has the same main-Ractor GVL plus one GVL per protocol Ractor, so a pipeline Ractor can parse one connection while an app thread executes Rack for another. That parallelism is real, but so are the costs of making state shareable and crossing the Ractor and collector boundaries. Which side wins depends on how much work the request gives the protocol pipeline; the current CPU benchmark leaves Raptor and Puma close rather than proving a universal parsing advantage.
437
443
 
@@ -457,11 +463,11 @@ Under moderate load, queue mechanics are unlikely to dominate either server. Und
457
463
 
458
464
  **Puma.** After a response, if the connection is keep-alive and there are already buffered bytes for the next request (`has_back_to_back_requests?`) and there is a spare app thread, loop inline. Otherwise, if `eagerly_finish` (non-blocking reads while data is already buffered) returns true, either loop inline (if spare threads) or hand back to the thread pool (`@thread_pool << client`). Otherwise, back to the reactor with `@persistent_timeout`.
459
465
 
460
- **Raptor.** After a response, the app thread does `socket.wait_readable(0.001)`, waiting up to 1ms for bytes. If bytes arrive, it parses the next request inline. If the thread pool queue is at least as deep as the pool, the parsed request is handed back to the pool so other threads share the load; otherwise the same thread dispatches it inline. If no bytes arrive, `reactor.persist` and return.
466
+ **Raptor.** After a response, the app thread checks `socket.wait_readable(0)`. If bytes are already available, it parses the next request inline. If other work is waiting, the parsed request goes to the back of the pool queue; otherwise the same thread dispatches it inline. If no bytes are ready, `reactor.persist` and return.
461
467
 
462
- The difference is subtle. Puma's `eagerly_finish` catches bytes already available on the socket; Raptor's `wait_readable(1ms)` catches those plus bytes arriving during the next millisecond. A request caught there parses on the response-writing thread and avoids a reactor round-trip. The cost is that an app thread can spend up to 1ms waiting on an otherwise idle connection.
468
+ Both servers therefore keep a back-to-back request on an app thread when its bytes are already waiting, and return an idle connection to the reactor without deliberately holding an app thread open. The details of their parsing and pool handoff still differ, but neither widens the inline window by waiting for the client.
463
469
 
464
- This fast path is one plausible contributor to Raptor's keep-alive result. Requests arriving inside the polling window are parsed without a reactor round-trip: the response-writing thread either continues serving that connection inline (when the pool queue is shallower than the pool) or hands the parsed request back to the pool (when it is not). Puma's app threads also continue inline when data is already available and capacity permits. The material difference is Raptor's short wait for data that has not arrived yet.
470
+ Raptor closes an HTTP/1.1 connection after 1,000 requests by default. The finite limit bounds connection-lifetime state without forcing frequent TCP teardown and reconnection under sustained keep-alive traffic.
465
471
 
466
472
  ### Backpressure
467
473
 
@@ -477,6 +483,8 @@ This fast path is one plausible contributor to Raptor's keep-alive result. Reque
477
483
 
478
484
  The performance difference here is negligible because the update happens once per worker per second, outside request processing. The design mainly gives the master a fixed-size snapshot it can inspect without draining per-worker messages.
479
485
 
486
+ For external monitoring, `control_url` can expose a read-only `GET /stats` endpoint over a Unix socket. Its per-worker status includes backlog, busy threads, free capacity, request count, and the thread count currently available. Adaptive pools report their current size as `max_threads`, rather than their configured limit, so `pool_capacity / max_threads` remains a useful measure while the pool grows and shrinks.
487
+
480
488
  ### HTTP/2
481
489
 
482
490
  **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.
@@ -489,7 +497,7 @@ At the throughput numbers the benchmark shows, a small set of concurrent connect
489
497
 
490
498
  ### Response writing
491
499
 
492
- Both servers support the same fundamental response shapes: file bodies through `IO.copy_stream`, non-blocking writes with `wait_writable(timeout)` on EAGAIN, and chunked transfer encoding for enumerable bodies without a known length. Puma uses `TCP_CORK` on Linux around HTTP/1.1 responses. Raptor corks responses that will close the connection, but skips the cork/uncork socket options on keep-alive responses where its write batching already supplies the important grouping.
500
+ Both servers support the same fundamental response shapes: file bodies through `IO.copy_stream`, non-blocking writes with `wait_writable(timeout)` on EAGAIN, and chunked transfer encoding for enumerable bodies without a known length. Puma uses `TCP_CORK` on Linux around HTTP/1.1 responses. Raptor only corks a response that will close the connection when its body cannot already be emitted with the headers in one `writev` call.
493
501
 
494
502
  On the HTTP/1.1 path, Raptor has a small `writev(2)` wrapper (`Raptor::VectorIO`) that can scatter-write the status line, headers, and body in one call for non-chunked responses. Puma sends the same content over multiple `write` calls batched by `TCP_CORK` at the kernel; Raptor groups the buffers in userspace and lets `writev` handle partial writes when necessary.
495
503
 
@@ -501,7 +509,7 @@ Around the response boundary, HTTP/1.1 also amortises the common per-request all
501
509
 
502
510
  ### Keep-alive request by request
503
511
 
504
- To make the keep-alive distinction concrete, here is one possible timing for three requests on the same connection. The third request arrives after Puma's non-blocking eager read but inside Raptor's 1ms polling window. Drawn separately so the participant columns stay wide enough to read.
512
+ To make the keep-alive paths concrete, here is one possible timing for three requests on the same connection. The second request is already buffered when the first response completes; the third arrives after the non-blocking check. Drawn separately so the participant columns stay wide enough to read.
505
513
 
506
514
  **Puma, three keep-alive requests:**
507
515
 
@@ -541,6 +549,7 @@ sequenceDiagram
541
549
  autonumber
542
550
  participant Client
543
551
  participant RS as Server thread
552
+ participant RR as Reactor thread
544
553
  participant RP as App thread
545
554
 
546
555
  Note over Client,RP: Request 1, initial
@@ -549,21 +558,21 @@ sequenceDiagram
549
558
  RS->>RP: push proc to thread pool
550
559
  RP->>Client: response 1
551
560
 
552
- Note over Client,RP: Request 2, pipelined a few hundred us later
553
- RP-->>RP: wait_readable 1ms
554
- Client->>RP: request bytes
561
+ Note over Client,RP: Request 2, already buffered
562
+ Client->>RP: request bytes already waiting
563
+ RP->>RP: wait_readable(0) returns true
555
564
  RP->>RP: parse inline on app thread
556
565
  RP->>Client: response 2
557
566
 
558
- Note over Client,RP: Request 3, arrives sub-millisecond later
559
- RP-->>RP: wait_readable 1ms
560
- Note right of RP: still within the 1ms window
561
- Client->>RP: request bytes
562
- RP->>RP: parse inline
567
+ Note over Client,RP: Request 3, not yet available
568
+ RP->>RP: wait_readable(0) returns false
569
+ RP->>RR: reactor.persist
570
+ Client->>RR: request bytes
571
+ RR->>RP: dispatch back to thread pool
563
572
  RP->>Client: response 3
564
573
  ```
565
574
 
566
- In this timing, Puma returns Request 3 to the reactor while Raptor catches it on the current thread. If the bytes were already available, both could stay inline; if they arrived after 1ms, both would use the reactor. The optimisation trades up to 1ms of app-thread occupancy for a wider inline window.
575
+ In this timing, both servers keep Request 2 inline and return Request 3 to the reactor. Their implementations differ, but the occupancy rule is the same: use the current app thread for bytes that are ready, not to wait for future bytes.
567
576
 
568
577
  ## Part IV: What Raptor's design buys you
569
578
 
@@ -571,7 +580,7 @@ In this timing, Puma returns Request 3 to the reactor while Raptor catches it on
571
580
 
572
581
  On the IO-bound benchmark profile, each request does 5 to 10 short sleeps interleaved with small CPU work, simulating a request that makes several DB or cache calls throughout its lifetime. The bottleneck is how many requests a worker can keep in flight while they wait on IO. Fixed Raptor and Puma cap application execution at three threads per worker. Falcon spawns a fiber per connection and cooperatively yields on every sleep, so many more client connections can make progress while others wait. That advantage gives Falcon the clear lead over both fixed-thread servers, especially without keep-alive.
573
582
 
574
- Scaling Raptor starts with the same three threads, then adds temporary threads while work is queued and the active threads are mostly blocked outside the GVL. In the current results that raises throughput from 3.11k to 8.63k req/s without keep-alive, and from 3.21k to 9.43k with it. Falcon still leads the first by 29.3%; scaling Raptor leads the second by 51.3%. The benchmark reports fixed and scaling Raptor separately rather than hiding that difference in one result.
583
+ Scaling Raptor starts with the same three threads, then adds temporary threads while work is queued and the active threads are mostly blocked outside the GVL. In the current results that raises throughput substantially, closes much of Falcon's lead without keep-alive, and puts Raptor ahead with keep-alive. The benchmark reports fixed and scaling Raptor separately rather than hiding that difference in one result.
575
584
 
576
585
  Between the thread-based servers, Raptor holds a clear lead over Puma on both throughput and p95. Its eager paths, explicit admission control, response batching, and app pool are all designed to reduce coordination, but the benchmark does not isolate enough variables to assign the result to one of them.
577
586
 
@@ -581,9 +590,9 @@ Real applications that spend most of their time waiting on a database or an upst
581
590
 
582
591
  On the CPU-bound benchmark profile, each POST request accepts a small JSON body and builds a JSON response in 3 to 5 chunks totalling 450 to 1500 items, with sub-100µs sleeps between chunks. It's roughly 95% CPU by wall time, so fibers can't multiplex their way to an advantage. The CPU work happens under a single Ruby VM regardless of concurrency model.
583
592
 
584
- **Without keep-alive**, every request opens a fresh TCP connection, gets parsed, dispatched, served, and closes. Puma leads fixed Raptor by 5.0% on throughput and has the lower p95; Raptor still leads Falcon. Fixed and scaling Raptor both deliver 8.22k req/s. The Puma/Raptor gap is small enough that neither architecture has overwhelmed the CPU cost of the Rack workload.
593
+ **Without keep-alive**, every request opens a fresh TCP connection, gets parsed, dispatched, served, and closes. Puma leads both Raptor modes in the current run. Fixed and scaling Raptor stay close to each other relative to the gain scaling produces on the IO workload, which shows that the pool is not adding threads freely when Ruby execution is already the bottleneck.
585
594
 
586
- **With keep-alive**, Puma leads fixed Raptor by 0.7% on throughput, while Raptor's p95 is 0.4ms lower; Raptor leads Falcon on both. Fixed and scaling Raptor both deliver 8.49k req/s, with scaling p95 matching Puma. The result is more useful as a guardrail than a victory claim: adaptive scaling provides the IO-bound gains without hurting this CPU-bound profile.
595
+ **With keep-alive**, Puma again leads both Raptor modes in the current run, while Raptor and Falcon are closer. As above, fixed and scaling Raptor stay close enough that the result is more useful as a guardrail than a victory claim: adaptive scaling provides the IO-bound gains without treating CPU pressure as a reason to keep creating threads.
587
596
 
588
597
  ### HTTP/2, when it matters
589
598
 
@@ -591,7 +600,7 @@ Puma doesn't implement HTTP/2, and most Rails production terminates HTTP/2 at ng
591
600
 
592
601
  Where Raptor's HTTP/2 support does matter is the all-Ruby stack: no proxy in front, TLS terminated at the app, and browsers or API clients speaking h2 directly to it. In that setup, Puma negotiates HTTP/1.1 instead, so the app-server connection does not get HTTP/2 multiplexing or HPACK header compression.
593
602
 
594
- 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.
603
+ Falcon also speaks HTTP/2 natively, so it's the interesting comparison there rather than Puma. In the current run, scaling brings Raptor close to Falcon on the IO profile, while Falcon leads the CPU profile. The h2 samples vary substantially more than the h1 samples, so these results establish broad shape rather than a precise ranking.
595
604
 
596
605
  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.
597
606
 
@@ -71,8 +71,8 @@ module Rackup
71
71
  cli_defaults = ::Raptor::CLI::DEFAULT_OPTIONS
72
72
  config_path = options[:Config] || ::Raptor::CLI.default_config_path
73
73
  config = config_path ? ::Raptor::CLI.load_config_file(config_path) : {}
74
- threads = (options[:Threads] || config[:threads] || cli_defaults[:threads]).to_i
75
- max_threads = options[:MaxThreads] || config.fetch(:max_threads, cli_defaults[:max_threads])
74
+ threads = (options[:Threads] || ENV["RAPTOR_THREADS"] || config[:threads] || cli_defaults[:threads]).to_i
75
+ max_threads = options[:MaxThreads] || ENV["RAPTOR_MAX_THREADS"] || config.fetch(:max_threads, cli_defaults[:max_threads])
76
76
 
77
77
  result = {
78
78
  binds: if options[:Host] || options[:Port]
@@ -82,9 +82,12 @@ module Rackup
82
82
  end,
83
83
  socket_backlog: (config[:socket_backlog] || cli_defaults[:socket_backlog]).to_i,
84
84
  drain_accept_queue: config.key?(:drain_accept_queue) ? config[:drain_accept_queue] : cli_defaults[:drain_accept_queue],
85
- workers: (options[:Workers] || config[:workers] || Concurrent.available_processor_count).to_i,
85
+ workers: (options[:Workers] || ENV["RAPTOR_WORKERS"] || config[:workers] || Concurrent.available_processor_count).to_i,
86
86
  threads: threads,
87
87
  max_threads: ::Raptor::CLI.parse_max_threads(max_threads, threads: threads),
88
+ cpu_affinity: config.key?(:cpu_affinity) ? config[:cpu_affinity] : cli_defaults[:cpu_affinity],
89
+ clean_thread_locals: config.key?(:clean_thread_locals) ? config[:clean_thread_locals] : cli_defaults[:clean_thread_locals],
90
+ clean_fiber_locals: config.key?(:clean_fiber_locals) ? config[:clean_fiber_locals] : cli_defaults[:clean_fiber_locals],
88
91
  app: app
89
92
  }
90
93
  result[:rackup] = config[:rackup] if config.key?(:rackup)
@@ -98,6 +101,7 @@ module Rackup
98
101
  result[:worker_drain_timeout] = (config[:worker_drain_timeout] || cli_defaults[:worker_drain_timeout]).to_i
99
102
  result[:worker_shutdown_timeout] = (config[:worker_shutdown_timeout] || cli_defaults[:worker_shutdown_timeout]).to_i
100
103
  result[:stats_file] = config.key?(:stats_file) ? config[:stats_file] : cli_defaults[:stats_file]
104
+ result[:control_url] = config[:control_url] if config.key?(:control_url)
101
105
  result[:pid_file] = config[:pid_file] if config.key?(:pid_file)
102
106
  result[:stdout_file] = config[:stdout_file] if config.key?(:stdout_file)
103
107
  result[:stderr_file] = config[:stderr_file] if config.key?(:stderr_file)