raptor 0.16.1 → 0.18.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: 0e564259e8919bb36ded41097d9f3930fcf6ad2f2d090cd4bf0a2ef4cbf424b4
4
- data.tar.gz: 1bf2f2e1077af1c2b61ba4b52bd5b13460bf0f27105a355b5dc01ff05e62b7b8
3
+ metadata.gz: df28f67647ac4c928757722392b5a0da9f61a31c6f81ad7126b6023b12311355
4
+ data.tar.gz: 5f7e1298a2aa0fb6622db15749b503261b62a4321549b39f955282285bd74c0c
5
5
  SHA512:
6
- metadata.gz: 103f96d2f2490ea4bd1f844f29a8980deba5881e52b3c684ec2db963c0b0b0d426d616ce66ff22be93736550583891862502c1216663426249e015e1c68ab29d
7
- data.tar.gz: e377a59417305334b255cf08be1c8c257971840d244b8b14b461ebbdfb857dc5a8cfbd6d37bee26ab2dc95642601bd1b7640100277c0b401f225fab66bbf8c77
6
+ metadata.gz: 00bc3616aa1877c4b79f3acdf00165981ed6401e3a2fb16a2c1565ef92b557772dd1960b31f85b370778e3f2f735904eca6b8cf969ca4aa1f62de3687aea1839
7
+ data.tar.gz: af947a551e8f0e3b8d07bd8c07e9b1e686e8f36a1d48af458fb331e1ca8c937f44504b4c9895c233cfe3905d22bce1176d57438baa428e9d44257edc46ba5c1f
@@ -41,7 +41,7 @@ steps:
41
41
  soft_fail: true
42
42
  image: "rubylang/ruby:master-dev"
43
43
  commands:
44
- - apt-get update && apt-get install -y libyaml-dev
44
+ - apt-get update && apt-get install -y libyaml-dev libssl-dev
45
45
  - ruby -v
46
46
  - bundle install
47
47
  - bundle exec rake ci
@@ -52,7 +52,7 @@ steps:
52
52
  env:
53
53
  RUBY_YJIT_ENABLE: "1"
54
54
  commands:
55
- - apt-get update && apt-get install -y libyaml-dev
55
+ - apt-get update && apt-get install -y libyaml-dev libssl-dev
56
56
  - ruby -v
57
57
  - bundle install
58
58
  - bundle exec rake ci
@@ -63,7 +63,7 @@ steps:
63
63
  env:
64
64
  RUBY_GC_STRESS: "1"
65
65
  commands:
66
- - apt-get update && apt-get install -y libyaml-dev
66
+ - apt-get update && apt-get install -y libyaml-dev libssl-dev
67
67
  - ruby -v
68
68
  - bundle install
69
69
  - bundle exec rake ci
@@ -75,7 +75,7 @@ steps:
75
75
  RUBY_YJIT_ENABLE: "1"
76
76
  RUBY_GC_STRESS: "1"
77
77
  commands:
78
- - apt-get update && apt-get install -y libyaml-dev
78
+ - apt-get update && apt-get install -y libyaml-dev libssl-dev
79
79
  - ruby -v
80
80
  - bundle install
81
81
  - bundle exec rake ci
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.18.0] - 2026-08-29
4
+
5
+ - Add adaptive application thread scaling with `max_threads`
6
+
7
+ ## [0.17.0] - 2026-08-29
8
+
9
+ - Reserve BPF-routed connections before accept
10
+ - Skip unnecessary HTTP/1.1 response work
11
+ - Assemble HTTP/2 response headers in C
12
+ - Emit HTTP/1.1 response headers and chunked bodies in C
13
+ - Decode chunked request bodies in C
14
+
3
15
  ## [0.16.1] - 2026-08-23
4
16
 
5
17
  - Drop the stale `Ractors=NUM` option from the Rackup handler
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 83654|Main|Main] Cluster initializing:
40
- [Raptor 83654|Main|Main] ├─ Version: 0.16.1
40
+ [Raptor 83654|Main|Main] ├─ Version: 0.18.0
41
41
  [Raptor 83654|Main|Main] ├─ Ruby Version: ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +YJIT +PRISM [arm64-darwin23]
42
42
  [Raptor 83654|Main|Main] ├─ Environment: development
43
43
  [Raptor 83654|Main|Main] ├─ Master PID: 83654
@@ -93,6 +93,7 @@ The config file is a Ruby file that evaluates to a hash of options. By default R
93
93
  drain_accept_queue: false,
94
94
  workers: 4, # `Etc.nprocessors`
95
95
  threads: 3,
96
+ max_threads: nil, # `Float::INFINITY` for no limit
96
97
  chdir: nil,
97
98
  environment: nil, # falls back to `RAILS_ENV`, then `RACK_ENV`, then `"development"`
98
99
  connection: {
@@ -128,6 +129,11 @@ The config file is a Ruby file that evaluates to a hash of options. By default R
128
129
  }
129
130
  ```
130
131
 
132
+ `threads` sets the number of application threads each worker keeps running. Set `max_threads` above it to let Raptor
133
+ add temporary threads when queued work is held up by blocking operations, or use `Float::INFINITY` for no limit.
134
+ Raptor does not add threads when waiting for the GVL is the bottleneck. Temporary threads leave after the queue
135
+ drains. The default `nil` keeps the pool fixed at `threads`.
136
+
131
137
  ## Bindings
132
138
 
133
139
  Raptor accepts multiple `binds:` URIs across three schemes.
@@ -188,8 +194,8 @@ KillMode=mixed
188
194
 
189
195
  ## Stats
190
196
 
191
- Each worker writes per-worker stats (request count, busy threads, backlog, last check-in) to shared memory and to a
192
- JSON file (default `tmp/raptor.json`; set via `stats_file`).
197
+ Each worker writes per-worker stats (request count, busy and available threads, backlog, last check-in) to shared
198
+ memory and to a JSON file (default `tmp/raptor.json`; set via `stats_file`).
193
199
 
194
200
  ```
195
201
  > bundle exec raptor stats
@@ -201,29 +207,40 @@ Worker 1 (phase 0): pid=91351, requests=1199, busy=1/3, backlog=0, booted, last_
201
207
 
202
208
  ## (Micro) Benchmarks
203
209
 
204
- Raptor 0.16.1 vs Puma 8.0.2 vs Falcon 0.57.0 across two workload profiles. **IO-bound** is a GET endpoint that
210
+ Raptor 0.18.0 vs Puma 8.0.2 vs Falcon 0.57.0 across two workload profiles. **IO-bound** is a GET endpoint that
205
211
  interleaves 5-10 short sleeps (total 2.5-15ms) with small CPU work, simulating a read path that makes several DB or
206
212
  cache calls. **CPU-bound** is a POST endpoint that accepts a small JSON body, interleaves 3-5 chunks of JSON item
207
213
  building (total 450-1500 items) with sub-100µs sleeps, and returns the built array, simulating a write path that does
208
214
  most of its work in Ruby with a few near-zero-cost cache hits.
209
215
 
210
- Each cell reports the median throughput and median p95 latency independently across 5 runs, so the two numbers in a row
216
+ Raptor is run in two modes: **Fixed** keeps 3 application threads per worker, matching Puma, while **Scaling** starts
217
+ with 3 and may add threads without a fixed limit when queued work is blocked outside the GVL. Both modes are compared
218
+ with both Puma and Falcon in the table below.
219
+
220
+ Each cell reports the median throughput and median p95 latency independently across 3 runs, so the two numbers in a row
211
221
  may come from different runs. Every run starts a fresh server process so the samples are independent of each other;
212
222
  state accumulated in a previous run cannot bias the next. Across the whole table, the widest spread
213
- ((max - min) / 2 / median) between runs of a single cell was ±24.8% for throughput and ±40.3% for p95.
214
-
215
- | Protocol | Workload | 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 |
216
- | --------------------- | -------- | ------------ | ---------- | ----------- | --------- | ------------- | ------------ | ------------ | ---------- | --------------- | ------------- |
217
- | HTTP/1.1 | IO | 3.21k req/s | 63.20 ms | 1.48k req/s | 131.00 ms | 117.7% higher | 51.8% lower | 12.27k req/s | 14.00 ms | 73.8% lower | 351.4% higher |
218
- | HTTP/1.1 | CPU | 8.29k req/s | 33.30 ms | 8.56k req/s | 21.20 ms | 3.2% lower | 57.1% higher | 6.62k req/s | 27.00 ms | 25.2% higher | 23.3% higher |
219
- | HTTP/1.1 (keep-alive) | IO | 2.43k req/s | 54.40 ms | 1.44k req/s | 110.20 ms | 68.7% higher | 50.6% lower | 6.28k req/s | 27.90 ms | 61.3% lower | 95.0% higher |
220
- | HTTP/1.1 (keep-alive) | CPU | 8.40k req/s | 18.80 ms | 8.49k req/s | 21.90 ms | 1.1% lower | 14.2% lower | 7.09k req/s | 31.50 ms | 18.5% higher | 40.3% lower |
221
- | HTTP/2 | IO | 0.97k req/s | 197.97 ms | N/A | N/A | - | - | 6.59k req/s | 27.21 ms | 85.2% lower | 627.6% higher |
222
- | HTTP/2 | CPU | 6.61k req/s | 31.98 ms | N/A | N/A | - | - | 8.09k req/s | 45.04 ms | 18.3% lower | 29.0% lower |
223
+ ((max - min) / 2 / median) between runs of a single cell was ±26.6% for throughput and ±45.2% for p95.
224
+
225
+ | 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 |
226
+ | --------------------- | -------- | ----------- | ------------ | ---------- | ----------- | --------- | ------------- | ------------ | ------------ | ---------- | --------------- | ------------- |
227
+ | 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 |
228
+ | 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 |
229
+ | 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 |
230
+ | 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 |
231
+ | 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 |
232
+ | 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 |
233
+ | 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 |
234
+ | 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 |
235
+ | 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 |
236
+ | 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 |
237
+ | 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 |
238
+ | 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 |
223
239
 
224
240
  > ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +YJIT +PRISM [aarch64-linux]
225
- > 10 worker processes; Raptor and Puma run 3 threads per worker, Falcon runs unbounded fibers per worker;
226
- > 120 concurrent HTTP/1.1 client connections; 40 concurrent HTTP/2 client connections × 3 streams each
241
+ > 10 worker processes; fixed Raptor and Puma run 3 threads per worker; scaling Raptor starts at 3 with no fixed limit;
242
+ > Falcon runs unbounded fibers per worker; 120 concurrent HTTP/1.1 client connections; 40 concurrent HTTP/2 client
243
+ > connections × 3 streams each
227
244
 
228
245
  See [bin/benchmark](bin/benchmark) for more details.
229
246
 
@@ -37,7 +37,7 @@ _[luma.com/t3l24s8h](https://luma.com/t3l24s8h)_
37
37
  - <big>I built a Ruby web server over the last few months</big>
38
38
  - <big>It's called Raptor</big>
39
39
  - <big>It runs Rack apps, like Puma and Falcon do</big>
40
- - <big>It's built around Ruby 4's Ractors</big>
40
+ - <big>It's built around a Ractor-parallel HTTP parser</big>
41
41
  - <big>It's fast enough that the numbers are interesting</big>
42
42
  - <big>It's not production-ready. Nobody's Rails app should be behind it yet.</big>
43
43
 
@@ -72,7 +72,7 @@ _[luma.com/t3l24s8h](https://luma.com/t3l24s8h)_
72
72
  - Red-black trees
73
73
  - Anonymous shared memory via mmap
74
74
  - eBPF programs running inside the Linux kernel
75
- - <big>All in Ruby. All working together in one gem.</big>
75
+ - <big>Ruby APIs, native extensions, and one tiny BPF program, all working together in one server.</big>
76
76
 
77
77
  <br>
78
78
  <br>
@@ -239,7 +239,7 @@ One thing to note: the whole `SYN` → `SYN-ACK` → `ACK` handshake at the top
239
239
 
240
240
  - <big>Kernel does the handshake and puts the completed connection on the listener's accept queue</big>
241
241
  - <big>Server calls `accept_nonblock` to pull the next connection off the queue</big>
242
- - <big>The BPF program we'll see later runs during the SYN phase, deciding which listener's queue the connection gets placed in</big>
242
+ - <big>For Raptor's plain-TCP listener, the BPF program we'll see later can choose which worker socket receives each new connection</big>
243
243
 
244
244
  <br>
245
245
  <br>
@@ -273,8 +273,8 @@ One thing to note: the whole `SYN` → `SYN-ACK` → `ACK` handshake at the top
273
273
 
274
274
  **[Falcon](https://github.com/socketry/falcon)**
275
275
 
276
- - <big>One fiber per request instead of threads</big>
277
- - A fiber is a lightweight coroutine, about 5KB versus a thread's ~1MB
276
+ - <big>Lightweight async tasks backed by fibers instead of a fixed app thread pool</big>
277
+ - Fibers are cooperatively scheduled coroutines and much cheaper to create than OS threads
278
278
  - <big>Built on the [`async`](https://github.com/socketry/async) gem, mostly written by Samuel Williams ([`@ioquatix`](https://github.com/ioquatix))</big>
279
279
  - <big>Speaks HTTP/2 natively</big>
280
280
  - <big>Excellent at long-lived connections and streaming</big>
@@ -535,9 +535,9 @@ If parsing runs in a Ractor:
535
535
 
536
536
  - <big>It uses a **different GVL** than the app</big>
537
537
  - <big>On a two-core machine, the parser and the app can run at the exact same instant</big>
538
- - <big>Actual parallelism inside one request pipeline</big>
538
+ - <big>Actual protocol and app parallelism inside one worker process</big>
539
539
 
540
- That's the whole idea. Everything else in Raptor grew out of taking it seriously.
540
+ That's the whole idea. Raptor uses that pipeline when a connection needs the reactor; complete requests on its eager paths parse inline instead of paying for a Ractor handoff.
541
541
 
542
542
  <br>
543
543
  <br>
@@ -591,6 +591,7 @@ flowchart TB
591
591
 
592
592
  STA["Stats thread<br/>writes 1 Hz"]
593
593
 
594
+ SRV -->|"complete first read"| ATP
594
595
  SRV --> RCT
595
596
  RCT --> RP1
596
597
  RCT --> RP2
@@ -759,7 +760,7 @@ flowchart LR
759
760
 
760
761
  - <big>App threads share one GVL among themselves. Only one runs Ruby at a time.</big>
761
762
  - <big>The pipeline Ractor has its own GVL. It runs Ruby independently.</big>
762
- - <big>Two Ruby threads can run Ruby code at the same time, on two CPU cores, on the same request pipeline</big>
763
+ - <big>A pipeline Ractor and an app thread can run Ruby code at the same time, on two CPU cores</big>
763
764
  - <big>That is not something you get out of the box in Ruby</big>
764
765
 
765
766
  <br>
@@ -786,7 +787,7 @@ flowchart LR
786
787
 
787
788
  Where your Rack app runs, and where the response gets written back to the socket.
788
789
 
789
- - <big>Puma's pool is a `Mutex + ConditionVariable + Array`</big>
790
+ - <big>Puma's pool coordinates a Ruby `Queue` with `Mutex + ConditionVariable`</big>
790
791
  - <big>Every enqueue and every dequeue takes the mutex</big>
791
792
  - <big>Under low concurrency, it's fine</big>
792
793
  - <big>Under contention, the mutex becomes a serialisation point</big>
@@ -794,7 +795,7 @@ Where your Rack app runs, and where the response gets written back to the socket
794
795
 
795
796
  Raptor's pool is **lock-free on the hot path**.
796
797
 
797
- To explain that, one minute on one CPU instruction.
798
+ To explain that, one minute on one atomic primitive.
798
799
 
799
800
  <br>
800
801
  <br>
@@ -831,13 +832,13 @@ flowchart LR
831
832
  T2 -->|"CAS(42, 77)<br/>fails because current is now 99"| Memory
832
833
  ```
833
834
 
834
- - <big>A single CPU instruction</big>
835
+ - <big>A hardware-supported atomic operation</big>
835
836
  - <big>Reads a memory word</big>
836
837
  - <big>Compares to what you expected</big>
837
838
  - <big>If they match, replaces with a new value</big>
838
839
  - <big>All atomically. No lock.</big>
839
840
 
840
- On x86 it's `LOCK CMPXCHG`. On ARM it's `LDXR` / `STXR`.
841
+ On x86 it is commonly `LOCK CMPXCHG`. On ARM it is commonly an exclusive load/store pair such as `LDXR` / `STXR`.
841
842
 
842
843
  <br>
843
844
  <br>
@@ -867,15 +868,15 @@ On x86 it's `LOCK CMPXCHG`. On ARM it's `LDXR` / `STXR`.
867
868
  cas(@slot, expected, new_value)
868
869
  ```
869
870
 
870
- - <big>If you can build a data structure so "publish a new version" is one CAS, you never need a lock</big>
871
+ - <big>If a state transition can be published with CAS, threads do not need a lock for that transition</big>
871
872
  - <big>If the CAS fails, you retry (someone else got there first)</big>
872
- - <big>Every high-performance concurrent data structure you've heard of is built on this</big>
873
+ - <big>Many high-performance concurrent data structures are built on this</big>
873
874
  - Java's `ConcurrentHashMap`
874
875
  - Rust's `AtomicUsize`
875
876
  - Go's `sync/atomic`
876
877
  - <big>[`concurrent-ruby`](https://github.com/ruby-concurrency/concurrent-ruby) has had CAS primitives (`AtomicReference`, `AtomicBoolean`, `AtomicFixnum`) since 2013</big>
877
- - But its primitives aren't Ractor-safe, they touch class-level state Ractors can't reach
878
- - Ractor-safe CAS in Ruby is what Raptor needed, and it's what `atomic-ruby` gives you
878
+ - I could have built around those primitives, but Raptor also needed a lock-free FIFO and lock-free waiter parking
879
+ - I built that smaller, native set of pieces as `atomic-ruby`, with its C extension explicitly marked Ractor-safe
879
880
 
880
881
  <br>
881
882
  <br>
@@ -950,10 +951,10 @@ Where it pays off:
950
951
 
951
952
  Why we picked CAS for Raptor's thread pool:
952
953
 
953
- - <big>Enqueue and dequeue are each one publish operation, exactly what CAS is for</big>
954
+ - <big>The queue can publish head, tail, and link changes through small atomic steps</big>
954
955
  - <big>The server thread reads pool metrics on every accept iteration, thousands of times per second</big>
955
- - <big>Under a mutex, every read of the queue length blocks writers, and every write blocks readers</big>
956
- - <big>Under CAS, reads are just atomic loads. Free. Never contend with writes.</big>
956
+ - <big>Under a queue-wide mutex, readers and writers serialise around the same lock</big>
957
+ - <big>Atomic counters avoid that lock. They still cost a synchronised memory access, but they do not park another thread.</big>
957
958
 
958
959
  <br>
959
960
  <br>
@@ -980,11 +981,12 @@ Why we picked CAS for Raptor's thread pool:
980
981
  Another gem I wrote. Native C extension. Exposes CAS-based primitives to Ruby:
981
982
 
982
983
  - <big>`Atom` – a CAS-protected reference cell holding one Ruby value</big>
983
- - <big>`AtomicBoolean`, `AtomicInteger` – type-specialised versions</big>
984
+ - <big>`AtomicBoolean` – a type-specialised boolean</big>
985
+ - <big>`AtomicQueue` – a multi-producer, multi-consumer FIFO</big>
984
986
  - <big>`AtomicThreadPool` – the thread pool Raptor uses</big>
985
987
  - <big>`AtomicConditionVariable` – lock-free waiter parking</big>
986
988
 
987
- Ruby's `VALUE` type on 64-bit is a tagged pointer. Fits in one CPU word. So `atom.swap` compiles down to one instruction.
989
+ Ruby's `VALUE` type on 64-bit is a tagged pointer. It fits in one machine word, so the native extension can compare and replace the reference atomically.
988
990
 
989
991
  ```c
990
992
  old = ATOMIC_VALUE_CAS(&atom->value, expected, new_value);
@@ -1010,27 +1012,19 @@ old = ATOMIC_VALUE_CAS(&atom->value, expected, new_value);
1010
1012
  <br>
1011
1013
  <br>
1012
1014
 
1013
- ## The Banker's queue
1015
+ ## The Michael-Scott queue
1014
1016
 
1015
- Raptor's thread pool stores its queue as a frozen `Hash` inside an `Atom`:
1016
-
1017
- ```ruby
1018
- { in: [...], out: [...], count:, shutdown: }
1019
- ```
1017
+ Raptor's thread pool uses a lock-free linked FIFO from `atomic-ruby`:
1020
1018
 
1021
1019
  ```mermaid
1022
1020
  flowchart LR
1023
- subgraph Q["Atom value"]
1024
- In["in: [C, B, A]<br/>latest first"]
1025
- Out["out: []"]
1026
- end
1027
-
1028
- Push["enqueue D"] -->|"one CAS to<br/>{in: [D,C,B,A], out: []}"| Q2[""]
1029
-
1030
- subgraph Q3["When out is empty, flip"]
1031
- In3["in: []"]
1032
- Out3["out: [A, B, C, D]"]
1033
- end
1021
+ H["atomic head"] --> S["dummy sentinel"]
1022
+ S --> A["work A"]
1023
+ A --> B["work B"]
1024
+ B --> N["nil"]
1025
+ T["atomic tail"] --> B
1026
+ P["producer: work C"] -->|"CAS B.next from nil to C<br/>then advance tail"| C["work C"]
1027
+ Q["consumer"] -->|"read A<br/>CAS head from S to A"| H
1034
1028
  ```
1035
1029
 
1036
1030
  <br>
@@ -1055,19 +1049,54 @@ flowchart LR
1055
1049
 
1056
1050
  ## Why this pattern works
1057
1051
 
1058
- - <big>Two stacks. Push to `in`. Pop from `out`.</big>
1059
- - <big>When `out` is empty, atomically flip `in` (reversed) into `out`</big>
1060
- - <big>Amortised O(1) per operation</big>
1061
- - <big>One CAS per operation</big>
1052
+ - <big>A dummy node separates the queue's head position from its first value</big>
1053
+ - <big>Producers append at the tail; consumers advance the head</big>
1054
+ - <big>Multiple producers and consumers can make progress without one queue-wide lock</big>
1055
+ - <big>Push and pop are O(1), with CAS retries when another thread wins a race</big>
1062
1056
  - <big>Lock-free</big>
1063
- - <big>It's a classic pattern from purely functional data structures</big>
1057
+ - <big>It's the Michael-Scott queue, a classic concurrent FIFO design</big>
1064
1058
 
1065
1059
  The bit that matters most in practice:
1066
1060
 
1067
- - <big>Backpressure metrics (queue length, active count) are lock-free reads of the atom's state</big>
1061
+ - <big>Queue length and active count are tracked in separate atoms</big>
1068
1062
  - <big>The server thread reads them every iteration of the accept loop</big>
1069
- - <big>On Puma: take a mutex, or accept fuzzy stats</big>
1070
- - <big>On Raptor: essentially free</big>
1063
+ - <big>Those reads do not acquire the queue's mutation lock, because there isn't one</big>
1064
+ - <big>The values are point-in-time snapshots; they can change immediately after being read</big>
1065
+
1066
+ <br>
1067
+ <br>
1068
+ <br>
1069
+ <br>
1070
+ <br>
1071
+ <br>
1072
+ <br>
1073
+ <br>
1074
+
1075
+ ---
1076
+
1077
+ <br>
1078
+ <br>
1079
+ <br>
1080
+ <br>
1081
+ <br>
1082
+ <br>
1083
+ <br>
1084
+ <br>
1085
+
1086
+ ## Scaling app threads without making CPU contention worse
1087
+
1088
+ The pool stays fixed at `threads` unless you set `max_threads`.
1089
+
1090
+ - <big>A native CRuby thread hook measures time running, blocked outside the GVL, and waiting for the GVL</big>
1091
+ - <big>The queue has to stay non-empty, and every current worker has to be active</big>
1092
+ - <big>Blocked time has to exceed half of worker time</big>
1093
+ - <big>GVL wait has to stay below two percent</big>
1094
+ - <big>Only then does the pool add a temporary thread, up to `max_threads`</big>
1095
+ - <big>When the queue drains, temporary threads leave and the pool returns to `threads`</big>
1096
+
1097
+ The distinction matters. More threads help when requests are asleep in database or network calls. They make CPU-bound Ruby slower when the GVL is already the bottleneck.
1098
+
1099
+ `max_threads: Float::INFINITY` allows the pool to grow without a fixed limit. That still does not make OS threads as cheap as fibers.
1071
1100
 
1072
1101
  <br>
1073
1102
  <br>
@@ -1281,7 +1310,7 @@ flowchart TB
1281
1310
  Col["Collector threads<br/>drain Ractor::Ports"]
1282
1311
  ATP["App thread pool<br/>calls Rack app<br/>writes response"]
1283
1312
 
1284
- Client -->|"SYN"| Srv
1313
+ Client -->|"connected socket + request bytes"| Srv
1285
1314
  Srv -->|"fast path: complete on first read"| ATP
1286
1315
  Srv -->|"slow path: bytes not ready"| Rct
1287
1316
  Rct -->|"got bytes; hand raw buffer to pool"| RP
@@ -1369,8 +1398,8 @@ end
1369
1398
 
1370
1399
  - <big>Wait 1ms for the next request on the same connection</big>
1371
1400
  - <big>If bytes arrive in that window: parse and dispatch inline, on the same thread</big>
1372
- - <big>Only bounce back to the reactor when the client actually stops sending</big>
1373
- - <big>For clients that pipeline requests (browsers, gRPC-Ruby transports), this is a huge tail-latency win</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>
1374
1403
 
1375
1404
  <br>
1376
1405
  <br>
@@ -1493,7 +1522,7 @@ Notable: a single HTTP/2 client connection in Raptor can have many streams in fl
1493
1522
  - <big>Each stream is a separate work item in the queue</big>
1494
1523
  - <big>Different streams from the same connection can end up on different app threads</big>
1495
1524
  - <big>Those threads still share the main GVL, so they overlap productively when the app is in I/O (the common Rails case), the same way Puma's keep-alive requests do</big>
1496
- - <big>The parsing for each stream happens in the HTTP/2 Ractor pool on its own GVL, actually in parallel with the app work</big>
1525
+ - <big>On the reactor path, frame batches parse in the HTTP/2 Ractor pool on its own GVL, in parallel with app work</big>
1497
1526
 
1498
1527
  <br>
1499
1528
  <br>
@@ -1560,7 +1589,7 @@ The master needs to know what the workers are doing.
1560
1589
  - <big>How busy?</big>
1561
1590
  - <big>Have they crashed?</big>
1562
1591
 
1563
- **Puma**: pipes. Workers write JSON messages every check interval. Master reads.
1592
+ **Puma**: pipes. Workers write status messages every check interval. Master reads.
1564
1593
 
1565
1594
  **Raptor**: anonymous shared memory via `mmap`.
1566
1595
 
@@ -1675,7 +1704,7 @@ flowchart TB
1675
1704
 
1676
1705
  - <big>Master calls `mmap` for the region **before** forking</big>
1677
1706
  - <big>Every worker inherits the mapping</big>
1678
- - <big>Each worker writes a 49-byte slot every second: pid, phase, requests, backlog, busy threads, boot time, checkin time, booted flag</big>
1707
+ - <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>
1679
1708
  - <big>Master reads the whole region directly. No JSON. No pipe drain. No signal.</big>
1680
1709
  - <big>`bundle exec raptor stats` prints the region as JSON, essentially instantly</big>
1681
1710
 
@@ -1760,9 +1789,10 @@ The problem:
1760
1789
  - <big>Default is a deterministic hash of the connection's four-tuple (client IP, client port, server IP, server port)</big>
1761
1790
  - Same four-tuple always lands on the same worker. Not random. Not round-robin.
1762
1791
  - Good spread on average across many clients, but no awareness of which worker is actually busy
1763
- - One client hammering from the same source port lands on the same worker every SYN
1764
1792
 
1765
- The Linux answer since kernel 4.5: **attach a BPF program to the reuseport group** via the `SO_ATTACH_REUSEPORT_EBPF` socket option. Kernel runs your program on every incoming SYN to decide which listener wins.
1793
+ The Linux answer since kernel 4.5: **attach a BPF program to the reuseport group** via the `SO_ATTACH_REUSEPORT_EBPF` socket option. When the kernel selects a socket from that group for a new connection, your program can make the choice.
1794
+
1795
+ Raptor uses this for plain `tcp://` listeners. TLS and Unix listeners continue to use sockets inherited from the master.
1766
1796
 
1767
1797
  <br>
1768
1798
  <br>
@@ -1791,9 +1821,9 @@ Puma has [`ClusterAcceptLoopDelay`](https://github.com/puma/puma/blob/master/lib
1791
1821
  - <big>Before each accept, a worker sleeps for a fraction of `max_delay` (default 5ms)</big>
1792
1822
  - <big>The sleep is proportional to the worker's own load: 0 when idle, `max_delay` when very busy</big>
1793
1823
  - <big>Every worker still accepts. Nobody refuses. Nobody gets skipped.</big>
1794
- - <big>Busier workers wake later. Less-busy workers wake first and win the accept race on the shared `SO_REUSEPORT` listener.</big>
1824
+ - <big>Busier workers wake later. Less-busy workers wake first and win the accept race on the listener inherited from Puma's master.</big>
1795
1825
 
1796
- It's clever, but the signal is a proxy for load, not the load itself. And it does the choosing in userspace, one worker sleeping past its turn. BPF does the choosing in the kernel with real numbers.
1826
+ It's clever, portable, and deliberately imprecise. Each worker only needs its own load. Raptor's BPF path instead publishes cluster-wide load to a kernel map and chooses before a worker calls `accept`.
1797
1827
 
1798
1828
  <br>
1799
1829
  <br>
@@ -1826,7 +1856,7 @@ It's clever, but the signal is a proxy for load, not the load itself. And it doe
1826
1856
  - No out-of-bounds memory access
1827
1857
  - Provably terminates
1828
1858
  - <big>If any of that can't be proved, the program is rejected at load time</big>
1829
- - <big>Once loaded, runs at essentially zero overhead per event</big>
1859
+ - <big>Once loaded, runs inside the kernel without a userspace round-trip for each decision</big>
1830
1860
 
1831
1861
  Modern Linux observability (`bcc`, `bpftrace`, `cilium`, `perf`) is all built on this. It's genuinely one of the most exciting things that has happened to Linux in the last decade.
1832
1862
 
@@ -1854,25 +1884,26 @@ Modern Linux observability (`bcc`, `bpftrace`, `cilium`, `perf`) is all built on
1854
1884
 
1855
1885
  ```mermaid
1856
1886
  flowchart TB
1857
- Client(["client SYN"])
1858
- K["Kernel: reuseport group has 4 sockets"]
1859
- BPF["Raptor's BPF program<br/>reads load map"]
1860
- W0["Worker 0<br/>load = 5"]
1861
- W1["Worker 1<br/>load = 2 ← min"]
1862
- W2["Worker 2<br/>load = 8"]
1863
- W3["Worker 3<br/>load = 6"]
1864
-
1865
- Client --> K
1866
- K -->|"which socket?"| BPF
1867
- BPF -->|"pick W1"| K
1868
- K -->|"deliver to W1's accept queue"| W1
1887
+ Client(["new connection"])
1888
+ Hash["connection hash"]
1889
+ A["candidate A<br/>Worker 0, load 5"]
1890
+ B["candidate B<br/>Worker 3, load 6"]
1891
+ Pick["choose Worker 0<br/>reserve load 5 6"]
1892
+ Queue["Worker 0 accept queue"]
1893
+
1894
+ Client --> Hash
1895
+ Hash -->|"sample 1"| A
1896
+ Hash -->|"sample 2"| B
1897
+ A --> Pick
1898
+ B --> Pick
1899
+ Pick -->|"atomic increment, then route"| Queue
1869
1900
  ```
1870
1901
 
1871
1902
  - <big>Each worker has a **load reporter thread**</big>
1872
1903
  - <big>Publishes its current backlog into a BPF map every millisecond</big>
1873
- - <big>BPF program reads all slots, finds the minimum</big>
1874
- - <big>Loads within 1 of that minimum are treated as tied. That range from `min` to `min + 1` is what I call the **tie band**.</big>
1875
- - <big>Ties are broken by the four-tuple hash. If nobody's in the tie band with the min, the min wins outright.</big>
1904
+ - <big>The connection hash picks two distinct workers</big>
1905
+ - <big>The BPF program compares those two load slots and chooses the lower one</big>
1906
+ - <big>That is **power of two choices**: near-global balance without scanning every worker</big>
1876
1907
 
1877
1908
  <br>
1878
1909
  <br>
@@ -1894,18 +1925,17 @@ flowchart TB
1894
1925
  <br>
1895
1926
  <br>
1896
1927
 
1897
- ## Why the tie band matters
1928
+ ## Why the reservation matters
1898
1929
 
1899
- - <big>Without it, a worker that briefly drains one request looks like the least-loaded worker</big>
1900
- - <big>Every new SYN gets routed to it</big>
1901
- - <big>By the time the next load-map update happens (1ms later), that worker is now the most loaded</big>
1902
- - <big>You end up herding bursts onto whichever worker most recently reported the lowest load</big>
1930
+ The reporter only publishes once per millisecond. A burst can contain many connections.
1903
1931
 
1904
- With the tie band:
1932
+ - <big>Without a reservation, every connection in that burst can observe the same stale low value</big>
1933
+ - <big>They can all choose the same worker before Ruby reports the new backlog</big>
1934
+ - <big>Raptor atomically increments the chosen BPF slot **before** routing</big>
1935
+ - <big>The next connection sees that reservation immediately</big>
1936
+ - <big>When Ruby accepts a socket, it also publishes `backlog + 1` rather than waiting for the reporter</big>
1905
1937
 
1906
- - <big>Small differences don't cause herding</big>
1907
- - <big>Load spreads across all near-idle workers via the four-tuple hash</big>
1908
- - <big>Under bursts, the effect is measurable in the p95 latency numbers</big>
1938
+ The load number is partly measurement and partly admission ledger. That closes the stale-reporting window that caused herding in the earlier design.
1909
1939
 
1910
1940
  <br>
1911
1941
  <br>
@@ -1929,9 +1959,9 @@ With the tie band:
1929
1959
 
1930
1960
  ## BPF in Raptor, by the numbers
1931
1961
 
1932
- - <big>The whole BPF program is about 70 lines of C</big>
1962
+ - <big>The whole BPF program is under 70 lines of C</big>
1933
1963
  - <big>Compiles down to a few hundred bytes of BPF bytecode</big>
1934
- - <big>Runs in the kernel, on every incoming SYN</big>
1964
+ - <big>Runs in the kernel whenever the reuseport group selects a socket for a new connection</big>
1935
1965
  - <big>To load it from Ruby, I wrote **`libbpf-ruby`**</big>
1936
1966
  - `libbpf` is the standard C library that user-space programs use to load, verify and attach BPF programs
1937
1967
  - `libbpf-ruby` is my binding around it
@@ -1939,7 +1969,7 @@ With the tie band:
1939
1969
 
1940
1970
  Graceful fallback:
1941
1971
 
1942
- - <big>If `libbpf-ruby` isn't installed, or the BPF object hasn't been compiled, Raptor silently falls back to the kernel's default four-tuple-hash routing</big>
1972
+ - <big>If `libbpf-ruby` isn't installed, or the BPF object hasn't been compiled, Raptor silently falls back to the listener inherited from the master</big>
1943
1973
  - <big>Everything still works. You just don't get load-aware routing.</big>
1944
1974
  - <big>If the kernel refuses the program (verifier error, missing features), startup raises. Loud failure, not silent misbehaviour.</big>
1945
1975
 
@@ -2180,9 +2210,9 @@ The libraries that came out of building Raptor:
2180
2210
 
2181
2211
  Each of them is small, focused, tested, and useful outside of Raptor.
2182
2212
 
2183
- - <big>If you want lock-free primitives in Ruby, `atomic-ruby` is the fastest way in</big>
2213
+ - <big>If you want lock-free primitives in Ruby, `atomic-ruby` gives you a small, direct API</big>
2184
2214
  - <big>If you want to try Ractor-based parallelism without writing the coordination yourself, `ractor-pool` is that</big>
2185
- - <big>If you want to load a BPF program from Ruby, `libbpf-ruby` is currently the only option</big>
2215
+ - <big>If you want to load a BPF program from Ruby, `libbpf-ruby` gives you a direct binding to libbpf</big>
2186
2216
 
2187
2217
  I didn't set out to build a small library ecosystem. It's what happens when you refuse to fold every helper back into the main gem.
2188
2218
 
@@ -2210,10 +2240,11 @@ I didn't set out to build a small library ecosystem. It's what happens when you
2210
2240
 
2211
2241
  Real numbers are in the [README benchmarks section](../README.md#micro-benchmarks). The shape:
2212
2242
 
2213
- - <big>**IO-bound work**: Falcon wins by a wide margin. Its fibers keep every client connection in flight at once. Thread-based servers cap out at their pool size.</big>
2214
- - <big>**CPU-bound HTTP/1.1**: Raptor beats Puma on both throughput and tail latency.</big>
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>
2215
2245
  - Tail latency ("p95") is the response time that 5% of requests exceed. It's what your slowest users see. Lower is better.
2216
- - <big>**HTTP/2**: Raptor and Falcon both implement it. Puma doesn't.</big>
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>
2247
+ - <big>**Variance**: HTTP/1.1 is stable. HTTP/2 is noisy enough that I treat it as direction, not a precise ranking.</big>
2217
2248
 
2218
2249
  Different workloads, different winners. That's fine.
2219
2250