solid-jobs 0.5.0 → 0.6.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: 7c1b35b5001c3c0467635a9fb46e3cab6305700a629c23babb604fc026895ec4
4
- data.tar.gz: 441859a1e6880c259a61a8e9bf1c7004caf9c25d967335a322558ec7227da3e2
3
+ metadata.gz: 926751135fceec06e8dc722a4e69fc2397b5abdbccc2b555ec9ffddd064e30ca
4
+ data.tar.gz: a5e48fa286ed78aa8d568de6946ae139c3d1a1f125e5090b17062cca61f46d28
5
5
  SHA512:
6
- metadata.gz: 0ece103959e94a949ad6c85e3591dbebcc3d82e495c9b4ab3d17f8729dd37bdaa591d12f5bdfe6adf8f4b7298078ef2c5326e1b36bffb3024d236a054980b999
7
- data.tar.gz: 5f0e56b6900bec66f374f3446d4b553c0b84a934c06718abc77219286002b4b977de68072c3f524f295ceb0c0e81c5f4bd4b5b25a6b42776f791ddb2626614ab
6
+ metadata.gz: 4290b8368db3b6ae96393dd9437fdf39b98b996b7363a9e9929cffd75cb9539e393ddbcdfa6d37d01dc4a36eaa1439054792ecaa032d41341735c5cb76fcff25
7
+ data.tar.gz: 706dc9eae83cfee673b202bfe7ce098837f5726ef65206c62aaba5bc3c1a6cdd15cd9461a4061a07c525397c6da41a23d9f512064ff9170a31f8ba75039cccc1
data/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [Unreleased]
6
+
7
+ ## [0.6.0] - 2026-10-19
8
+
9
+ ### Added
10
+
11
+ - Real-process reliability tests for SolidEngine (worker SIGKILL, lost ACK, server
12
+ restart, clean stop); `Backends::Worker` now survives connection errors while
13
+ settling a job and lets the lease expire for redelivery.
14
+ - Backend adapters: `Backends::Base` contract, `Backends::Redis` and
15
+ `Backends::SolidEngine`; `config.backends`, `config.channel_backends` and
16
+ per-channel routing in `Publisher` and `Conductor`.
17
+ - `config.execution_mode` (`:ractor` default, or `:thread`): non-Redis backend
18
+ workers run in Ractors, each with its own client, so the backend no longer
19
+ determines CPU parallelism. Ruby 4 is recommended: on 3.4.4 Ractor CPU cost per
20
+ job rises sharply beyond 4 workers.
21
+
5
22
  ## [0.5.0] - 2026-10-12
6
23
 
7
24
  ### Added
data/README.md CHANGED
@@ -177,6 +177,139 @@ application-level deduplication when duplicate effects are unsafe.
177
177
  See [docs/reliability.md](docs/reliability.md) for the state machine, claim
178
178
  fencing, recovery rules, and Ruby Ractor caveats.
179
179
 
180
+ ## SolidEngine backend (SolidRedis or SolidEngine)
181
+
182
+ SolidJobs is the job API; the storage and execution engine sits behind a
183
+ backend adapter. Two adapters ship with the gem and your tasks do not change:
184
+
185
+ ```
186
+ Application → SolidJobs → Backend adapter ─┬─ Backends::Redis → SolidRedis → Redis
187
+ └─ Backends::SolidEngine → Rust server (Unix socket / TCP)
188
+ ```
189
+
190
+ ```ruby
191
+ SolidJobs.configure do |config|
192
+ config.backend = :redis # default for every channel
193
+
194
+ config.backends = {
195
+ redis: {adapter: :solid_redis, url: "redis://localhost:6379"},
196
+ engine: {adapter: :solid_engine, url: "unix:///var/run/solidengine.sock"},
197
+ # TCP also works: "tcp://127.0.0.1:7777"
198
+ }
199
+
200
+ # Per-channel routing: run both engines side by side and migrate gradually.
201
+ config.channel_backends = {"critical" => :redis, "default" => :engine, "bulk" => :engine}
202
+ config.channels = %w[critical default bulk]
203
+ end
204
+ ```
205
+
206
+ Setting only `config.backend = :solid_engine` and `config.url = "unix://..."`
207
+ routes every channel to SolidEngine. Without these options nothing changes:
208
+ the Redis path is the unchanged default.
209
+
210
+ ```ruby
211
+ class SendEmail
212
+ include SolidJobs::Task
213
+ task_options channel: "critical"
214
+ def execute_task(user_id) = # ...
215
+ end
216
+ SendEmail.enqueue(42) # Redis or SolidEngine, decided by the channel
217
+ ```
218
+
219
+ `Conductor` starts Ractor processors for channels served by Redis and, in
220
+ the same process, `concurrency` workers per non-Redis backend (one connection
221
+ each, lease keep-alive while a task runs). If every channel is
222
+ routed to SolidEngine, no Ractor, heartbeat or Redis scheduler is started and
223
+ Redis is not required.
224
+
225
+ ### Backend contract
226
+
227
+ `SolidJobs::Backends::Base` defines `enqueue`, `schedule(at:)`, `claim(channel)`,
228
+ `ack(claim)`, `retry_job(claim, delay:)`, `fail_job`, `get_job`, `recover`,
229
+ `capabilities` and `supports?`. A *claim* is the object returned by `claim`; it
230
+ carries the reservation and fencing tokens, so `ack` and `retry_job` take the
231
+ claim rather than a bare job id. Delivery is at-least-once on both adapters.
232
+
233
+ Guarantees are not hidden. Operations a backend cannot honor raise
234
+ `SolidJobs::Backends::NotSupported`; check `backend.supports?(:capability)`:
235
+
236
+ | Capability | SolidRedis | SolidEngine |
237
+ |---|---|---|
238
+ | At-least-once, claim/ack, retries, scheduling | yes | yes |
239
+ | Crash recovery | yes (node heartbeat scan) | yes (server-side lease expiry) |
240
+ | Deduplication by job id | no | yes (`push` of an existing id is idempotent) |
241
+ | Fencing tokens, lease extension | claim-token check only | yes (stale `ack`/`nack` is rejected) |
242
+ | `get_job` by id, `cancel` | no | yes |
243
+ | Durability | Redis persistence settings | `memory`, `balanced` or `strict` (server config) |
244
+
245
+ Job ids must be UUIDs on SolidEngine (the default). Channel names map to
246
+ engine channel ids by CRC32 unless `channel_ids:` is set in the backend spec:
247
+ pin the mapping in production. Retry/discard follows the task options
248
+ (`max_failures`, `retry_within`, `retry_delay`, `after_final_failure`);
249
+ `discarded` archives stay Redis-only, SolidEngine keeps dead jobs itself.
250
+
251
+ ### Switching a channel
252
+
253
+ Changing a channel's backend **does not migrate jobs already queued** in the
254
+ old engine. Drain the old queue first (keep a worker on it until empty), then
255
+ switch producers, then workers. To roll back, keep a worker on the SolidEngine
256
+ channel until `stats` shows it is empty. Jobs must be idempotent in both
257
+ directions.
258
+
259
+ ### Execution mode (independent of the backend)
260
+
261
+ ```ruby
262
+ SolidJobs.configure do |config|
263
+ config.backend = :solid_engine
264
+ config.execution_mode = :ractor # default; or :thread
265
+ config.concurrency = 4
266
+ end
267
+ ```
268
+
269
+ - `:ractor` (default): each worker is a Ractor that builds **its own**
270
+ SolidEngine client (own socket, no shared state, no central relay), so CPU
271
+ tasks run in parallel exactly as with Redis.
272
+ - `:thread`: workers are threads of the main Ractor, bound by the GVL. Use it as
273
+ an explicit fallback when a task depends on code that is not Ractor-compatible
274
+ (non-shareable globals, unsupported native extensions, closures as callbacks).
275
+
276
+ Redis channels always use Ractor processors. The mode is never changed silently
277
+ by the backend choice.
278
+
279
+ ### Reliability of the SolidEngine backend
280
+
281
+ Guarantees (verified by `test/solid_engine_reliability_test.rb`, which uses real
282
+ `solid-engine-server` and worker processes):
283
+
284
+ - **At-least-once.** A task is acknowledged only after it succeeds. A worker that
285
+ dies (SIGKILL) mid-job loses its lease; the job is claimed again after
286
+ `lease_ms` and may run more than once. Exactly-once is **not** provided.
287
+ - **Fencing.** A late ACK from a reservation that was reclaimed is rejected
288
+ (`stale_reservation`) and ignored by the worker.
289
+ - **Connection errors.** An error while claiming or settling does not stop the
290
+ worker; an unsettled job is redelivered once its lease expires.
291
+ - **Clean stop.** `stop` lets the active job finish and claims nothing new.
292
+ - **Durability is a server setting.** With `durability = "memory"` (default) a
293
+ server restart loses every job. With `"strict"` or `"balanced"` and a
294
+ `data_dir`, confirmed jobs survive a SIGKILL of the server. The 60 s
295
+ retention of completed jobs is not a persistence guarantee.
296
+ - **Recovery delay** is roughly `lease_ms` (default 30 s) after a worker crash.
297
+
298
+ ### Limits of this release
299
+
300
+ The Python (`solid-jobs-py`) and Node.js (`solid-jobs-node`) packages contain
301
+ the SolidEngine client but not yet this routing layer. `Catalog`, `Observations`,
302
+ `Console` and `IntegrityCheck` still read Redis only.
303
+
304
+ ### SolidEngine benchmark
305
+
306
+ Run `bundle exec rake comparison` in `benchmark_sidekiq_solid-jobs` with
307
+ `SOLID_ENGINE_BIN` pointing at `solid-engine-server`; the `process-*`
308
+ scenarios then add a `solid-jobs-engine` client (jobs through SolidEngine in
309
+ `memory` durability, matching Redis without persistence; Redis only holds the
310
+ benchmark counters). Results: see [Performance](#performance-solidjobs-vs-sidekiq)
311
+ and [SolidEngine A/B on CPU tasks](#solidengine-ab-on-cpu-tasks-process-cpu).
312
+
180
313
  ## Node control
181
314
 
182
315
  ```ruby
@@ -266,14 +399,40 @@ reports `disconnected`. No monitoring-only Redis command is added. Redis
266
399
  latency, command, memory, and connection-count metrics remain explicitly
267
400
  `unavailable` until a reliable source exists.
268
401
 
269
- ## Transparent Ractor-safe hybrid execution (warm worker routing)
402
+ ## Transparent Ractor-Safe Hybrid Execution Architecture
403
+
404
+ Ractors enforce a strict shared-nothing model. Referencing mutable global
405
+ state, legacy connection pools, or thread-based dependencies (**ActiveRecord**,
406
+ **ActionMailer**, or gems built on `Mutex`, `Thread` and `Fiber`) inside a
407
+ Ractor raises `ArgumentError: can't share mutable object`.
408
+
409
+ SolidJobs handles this with an **adaptive router** in `enqueue`: tasks that are
410
+ safe stay Ractor-native, and tasks that hold non-shareable state are
411
+ transparently forwarded to a persistent **warm worker** process. You do not
412
+ have to rewrite your gem stack.
413
+
414
+ ### How it works
415
+
416
+ ![SolidJobs hybrid execution: heuristic Ractor-compatibility detection routes to a Ractor worker (native) or a warm worker (fallback), communicating over a Unix socket with JSON](docs/images/hybrid-execution.svg)
417
+
418
+ ```text
419
+ Task.enqueue(*args)
420
+ │
421
+ ├─ requires_warm_pool? = false ─► Ractor-native path (Redis → worker Ractors)
422
+ │
423
+ └─ requires_warm_pool? = true ──► JSON payload ─► SolidJobsGenericExecutor
424
+ ─► UNIXSocket ─► script/warm_worker.rb
425
+ (booted Rails process, runs the task)
426
+ ```
427
+
428
+ | Context | Path | Typical tasks |
429
+ |---|---|---|
430
+ | Pure / stateless | Ractor-native, runs on all cores | CPU work, stateless I/O |
431
+ | Holds a `Mutex`, `Thread`, `Fiber` or pool in class-level state | Warm worker over a Unix socket | Legacy gems, shared connection pools |
270
432
 
271
- Ractors enforce a shared-nothing model: referencing mutable global state, legacy
272
- connection pools, or thread-based dependencies (ActiveRecord, ActionMailer, gems
273
- using `Mutex`, `Thread` or `Fiber`) inside a Ractor raises
274
- `ArgumentError: can't share mutable object`. `SolidJobs::Task#enqueue` detects
275
- such tasks and reroutes them to a persistent **warm worker** process, so you do
276
- not have to rewrite them.
433
+ The warm worker boots your application once and stays resident, so a routed job
434
+ pays no per-job boot cost, only a local socket round trip. The latency has not
435
+ been benchmarked.
277
436
 
278
437
  ### How routing is decided
279
438
 
@@ -299,7 +458,7 @@ as a `SolidJobsGenericExecutor` task, which forwards them over a Unix socket.
299
458
  connects to `/tmp/solid_jobs_rails_<worker_id>.sock`, sends
300
459
  `{"target_class": ..., "arguments": [...]}` as one JSON line, and returns the
301
460
  result or raises `SolidJobsGenericExecutor::RemoteError`. The worker is chosen
302
- as `hash(task name) % SOLID_JOBS_WARM_WORKERS` (default `1`). Require this file
461
+ as `crc32(task name) % SOLID_JOBS_WARM_WORKERS` (default `1`, stable across processes). Require this file
303
462
  explicitly (it is loaded automatically on first routing).
304
463
  - `script/warm_worker.rb` — a standalone process, run outside any Ractor, that
305
464
  loads `config/environment.rb` from its working directory once, listens on the
@@ -326,7 +485,11 @@ The boot cost is paid once per worker; the process then stays warm. Memory is
326
485
  reclaimed only when a worker is restarted, so supervise and recycle them as you
327
486
  would any long-lived Rails process.
328
487
 
329
- ### Example
488
+ ### Usage
489
+
490
+ Include the task module as usual; the only requirement is that the class-level
491
+ state that makes the task unsafe is visible to the detector, and that your
492
+ arguments are JSON-serializable:
330
493
 
331
494
  ```ruby
332
495
  class SyncAccounts
@@ -343,156 +506,52 @@ SyncAccounts.enqueue([1, 2, 3]) # routed to the warm worker
343
506
 
344
507
  Tests live in `test/warm_pool_test.rb` and spawn a real worker process.
345
508
 
346
- ## The Ractor concurrency challenge
347
-
348
- Core Rails frameworks like `ActiveRecord` and `ActionMailer` rely on global
349
- variables, shared connection pools, and mutable global state. Referencing these
350
- modules directly inside a strict, isolated `Ractor` raises
351
- `ArgumentError: can't share mutable object`.
352
-
353
- To keep SolidJobs' Ractor-native execution while preserving your existing Rails
354
- business logic, use a **hybrid execution bridge**: delegate mutable work to an
355
- isolated sub-process via `bin/rails runner`. The worker stays Ractor-native and
356
- the heavy Rails operations are offloaded to the operating system layer.
357
-
358
- ### Hybrid execution examples
359
-
360
- > **Important:** These task classes must be loaded explicitly (e.g., inside
361
- > `config/boot.rb`) outside of Zeitwerk management, as standard Rails
362
- > autoloading is restricted inside isolated Ractors.
363
-
364
- #### Isolated ActionMailer delivery task
365
-
366
- Trigger an email workflow using your existing Rails mailers and templates
367
- without memory isolation crashes:
368
-
369
- ```ruby
370
- # frozen_string_literal: true
371
-
372
- require "open3"
373
-
374
- class SendEmailTask
375
- include SolidJobs::Task
376
-
377
- # Define your processing channel and failure thresholds
378
- task_options channel: "default", max_failures: 3
379
-
380
- APP_ROOT = File.expand_path("../..", __dir__).freeze
381
- SCRIPT = "WelcomeMailer.hello(ARGV[0]).deliver_now"
382
-
383
- def execute_task(recipient = "nicolas.vandenbogaerde@gmail.com")
384
- output, status = Open3.capture2e(
385
- { "RAILS_ENV" => ENV.fetch("RAILS_ENV", "development") },
386
- "bin/rails", "runner", SCRIPT, recipient,
387
- chdir: APP_ROOT
388
- )
389
-
390
- unless status.success?
391
- raise "Email delivery failed: #{output.lines.grep_v(/^\s+from /).first(5).join}"
392
- end
393
- end
394
- end
395
- ```
396
-
397
- #### Isolated ActiveRecord data sync task
398
-
399
- Load database records and run model mutations safely outside of the Ractor:
400
-
401
- ```ruby
402
- # frozen_string_literal: true
403
-
404
- require "open3"
405
-
406
- class ProcessUserDataTask
407
- include SolidJobs::Task
408
-
409
- # Dedicated data synchronization channel
410
- task_options channel: "data_sync", max_failures: 3
411
-
412
- APP_ROOT = File.expand_path("../..", __dir__).freeze
413
- SCRIPT = "User.find(ARGV[0]).update_metrics!"
414
-
415
- def execute_task(user_id)
416
- output, status = Open3.capture2e(
417
- { "RAILS_ENV" => ENV.fetch("RAILS_ENV", "development") },
418
- "bin/rails", "runner", SCRIPT, user_id.to_s,
419
- chdir: APP_ROOT
420
- )
421
-
422
- unless status.success?
423
- raise "ActiveRecord task failed: #{output.lines.grep_v(/^\s+from /).first(5).join}"
424
- end
425
- end
426
- end
427
- ```
428
-
429
- #### Complete business service object execution (zero-leak memory architecture)
430
-
431
- In complex production monoliths, a single service object often encapsulates
432
- multiple `ActiveRecord` updates, pricing mutations, external API calls, and
433
- email notifications. Running this orchestration layer directly inside a
434
- `Ractor` triggers memory isolation failures.
435
-
436
- By serializing the service input into a lightweight, Ractor-safe **JSON
437
- payload**, SolidJobs can hand the task details to an isolated `bin/rails runner`
438
- sub-process.
439
-
440
- A major benefit is **automatic memory recovery**: once the service has
441
- allocated thousands of transient Ruby objects, the sub-process terminates and
442
- the operating system reclaims all of its memory, keeping the worker immune to
443
- memory bloat and leaks.
444
-
445
- Example: processing a heavy order checkout service.
446
-
447
- ```ruby
448
- # frozen_string_literal: true
449
-
450
- require "open3"
451
- require "json"
452
-
453
- # Loaded explicitly (e.g., in config/boot.rb) outside of Zeitwerk.
454
- # SolidJobs executors run within Ractors where standard Rails autoloading is restricted.
455
- class ExecuteServiceTask
456
- include SolidJobs::Task
457
-
458
- # Dedicated high-priority business services channel
459
- task_options channel: "services", max_failures: 2
460
-
461
- APP_ROOT = File.expand_path("../..", __dir__).freeze
462
-
463
- # The runner script parses the input JSON from the command line and executes the service
464
- SCRIPT = <<~RUBY
465
- require 'json'
466
- params = JSON.parse(ARGV[0])
467
- OrderCheckoutService.call(user_id: params['user_id'], cart_id: params['cart_id'])
468
- RUBY
469
-
470
- def execute_task(params_hash)
471
- # Serialize the pure Hash into a strict JSON string for safe Ractor transmission
472
- json_payload = params_hash.to_json
473
-
474
- output, status = Open3.capture2e(
475
- { "RAILS_ENV" => ENV.fetch("RAILS_ENV", "development") },
476
- "bin/rails", "runner", SCRIPT, json_payload,
477
- chdir: APP_ROOT
478
- )
479
-
480
- unless status.success?
481
- raise "Service object execution failed: #{output.lines.first(5).join}"
482
- end
483
- end
484
- end
485
- ```
486
-
487
- Enqueuing from your Rails application:
488
-
489
- ```ruby
490
- # Construct a clean, 100% Ractor-safe arguments structure
491
- task_payload = { user_id: 42, cart_id: 891 }
492
-
493
- # Push into the Redis pipeline
494
- ExecuteServiceTask.enqueue(task_payload)
495
- ```
509
+ ### Hybrid architecture benchmark: Ractor vs Unix-socket IPC
510
+
511
+ Run with `bundle exec rake hybrid` in `benchmark_sidekiq_solid-jobs`
512
+ (Ruby 4.0.7, 4 Conductor Ractors, 4 warm workers, 10,000 mutable tasks per
513
+ mode, all routed to the warm pool). Each latency series is stored as its own
514
+ microsecond array and histogram, in JSON and Markdown under `results/`.
515
+
516
+ - **Ractor pure:** time inside a Conductor Ractor between the `job.reserved`
517
+ event (payload returned by Redis) and `job.started` (just before the task
518
+ runs). SolidJobs reserves with an atomic Lua `RPOP`, not `BLPOP`, so this is
519
+ the closest equivalent.
520
+ - **IPC roundtrip:** `UNIXSocket` open → status line read, reported by the
521
+ `warm_pool.roundtrip` instrumentation event of `SolidJobsGenericExecutor`.
522
+ - `transport` uses zero-work warm tasks to isolate the pure socket cost;
523
+ `sustained` paces the 10,000 tasks linearly over 30 s; `burst` enqueues them
524
+ at once. In `sustained` and `burst`, each warm task sleeps 5 ms to simulate
525
+ service time, so their IPC roundtrip is dominated by that work (macOS timer
526
+ granularity makes it ≈6.4 ms).
527
+
528
+ | Mode | Series | p50 (µs) | p95 (µs) | p99 (µs) |
529
+ |---|---|---:|---:|---:|
530
+ | Transport | Ractor pure | 9 | 21 | 39 |
531
+ | Transport | IPC roundtrip (no work) | 43 | 77 | 192 |
532
+ | Sustained 30 s (≈333 jobs/s) | Ractor pure | 25 | 51 | 119 |
533
+ | Sustained 30 s (≈333 jobs/s) | IPC roundtrip (5 ms work) | 6,355 | 11,713 | 12,680 |
534
+ | Burst | Ractor pure | 23 | 58 | 249 |
535
+ | Burst | IPC roundtrip (5 ms work) | 6,426 | 12,522 | 13,183 |
536
+
537
+ All 30,000 tasks completed with zero failures and **zero warm pool exhaustion
538
+ events**: the 4 Conductor Ractors never have more requests in flight than there
539
+ are warm workers. The pure socket overhead is tens of microseconds (p50 43 µs,
540
+ p99 192 µs); the rest of the IPC latency is the task's own work in the warm
541
+ worker.
542
+
543
+ **Saturation (oversubscribed pool).** With 8 Ractors on a single warm worker, a
544
+ 30 ms timeout and 1,000 burst tasks, the pool is exhausted: 432 timeouts and 563
545
+ refused connections (the worker's Unix socket listen backlog is full), only 5
546
+ tasks completed on the first attempt, and the rest failed and went through
547
+ SolidJobs retries. Exhaustion is counted as `timeout` + `refused` + `ok`
548
+ roundtrips above `HYBRID_EXHAUSTION_MS`. Size the number of warm workers at
549
+ least equal to the Conductor concurrency, and set `SOLID_JOBS_WARM_TIMEOUT`
550
+ (default 30 s) accordingly. A timed-out request is not cancelled in the worker,
551
+ so make routed tasks idempotent.
552
+
553
+ *Environment: Ruby 4.0.7 (arm64-darwin27); SolidJobs 0.5.0; local loopback,
554
+ synthetic work, single run per mode.*
496
555
 
497
556
  ## Commercial licensing & observability
498
557
 
@@ -527,7 +586,151 @@ scales linearly across cores:
527
586
  | 4 | 303 | 1,094 | 96.9% | 100.3% | 378.2% |
528
587
  | 8 | 302 | **2,170** | **96.2%** | 100.1% | **761.0%** |
529
588
 
530
- *At 8 concurrency, SolidJobs delivers **7.2× the throughput** of Sidekiq on pure CPU workloads.*
589
+ *At 8 concurrency, SolidJobs delivers **7.2× the throughput** of a single Sidekiq process on pure CPU workloads
590
+ (one process, N threads; see the multi-process comparison below).
591
+ This table comes from the Ruby 4.x campaign (see the environment line at the end
592
+ of this section); it predates the A/B campaign below and is not directly comparable with it.*
593
+
594
+ ### SolidEngine A/B on CPU tasks (`process-cpu`)
595
+
596
+ Same tasks and repetitions for every client; median of 6 runs (min–max), jobs/s.
597
+ Sidekiq is one process with N threads. Machine: 12 cores (8 performance).
598
+
599
+ **Ruby 4.0.7** (separate campaign: `ruby-4.0.7-process-cpu-procs.md`)
600
+
601
+ "Sidekiq (N procs)" is N Sidekiq processes with one thread each, i.e. the same
602
+ number of cores as SolidJobs' N Ractors.
603
+
604
+ | Concurrency | Sidekiq (1 proc, N threads) | Sidekiq (N procs) | SolidJobs + Redis (Ractors) | SolidJobs + SolidEngine (Ractors) |
605
+ |---:|---:|---:|---:|---:|
606
+ | 1 | 287 (283–288) | 286 (278–288) | 269 (260–271) | 286 (284–289) |
607
+ | 2 | 297 (296–298) | 563 (561–564) | 516 (510–517) | **568** (566–570) |
608
+ | 4 | 298 (295–300) | **1,107** (1,104–1,127) | 953 (947–964) | 1,103 (1,100–1,117) |
609
+ | 8 | 299 (298–301) | **2,156** (2,145–2,161) | 1,667 (1,644–1,691) | 2,058 (2,049–2,086) |
610
+
611
+ Ruby-worker CPU-seconds per 1,000 jobs: 3.4–3.6 for every client at every concurrency.
612
+
613
+ Whole-infrastructure memory and CPU (separate run of the same workload,
614
+ `ruby-4.0.7-process-cpu-memory.md`; peak physical footprint measured with macOS
615
+ `footprint` during the run, workers + Redis + SolidEngine server):
616
+
617
+ | Concurrency | Client | jobs/s | Workers MiB | Redis MiB | SolidEngine MiB | Total MiB | MiB per 1,000 jobs/s | Total CPU-s/1,000 jobs |
618
+ |---:|---|---:|---:|---:|---:|---:|---:|---:|
619
+ | 4 | Sidekiq (N procs) | 1,090 | 138.0 | 5.8 | – | **143.9** | 132 | 3.55 |
620
+ | 4 | SolidJobs + Redis | 954 | 32.3 | 5.8 | – | **38.1** | 40 | 3.55 |
621
+ | 4 | SolidJobs + SolidEngine | 1,101 | 54.7 | 5.8 | 6.0 | **66.6** | 61 | 3.51 |
622
+ | 8 | Sidekiq (N procs) | 2,025 | 243.7 | 6.1 | – | **249.8** | 123 | 3.83 |
623
+ | 8 | SolidJobs + Redis | 1,663 | 32.4 | 6.1 | – | **38.6** | 23 | 3.65 |
624
+ | 8 | SolidJobs + SolidEngine | 1,968 | 55.0 | 6.1 | 6.4 | **67.6** | 34 | 3.71 |
625
+
626
+ At 8 workers SolidJobs + SolidEngine uses about 73% less physical memory than 8
627
+ Sidekiq processes (68 vs 250 MiB) for 3% less throughput in this run, with a
628
+ similar total CPU per job. Redis holds only the benchmark counters here (about
629
+ 2,000 queued jobs), so its footprint is small; a real backlog would grow it.
630
+
631
+ **Ruby 3.4.4**
632
+
633
+ | Concurrency | Sidekiq (threads) | SolidJobs + Redis (Ractors) | SolidJobs + SolidEngine (Ractors) | SolidJobs + SolidEngine (threads) |
634
+ |---:|---:|---:|---:|---:|
635
+ | 1 | 202 (200–207) | 196 (192–199) | 209 (196–210) | 206 (205–208) |
636
+ | 2 | 211 (209–213) | 296 (277–319) | **327** (310–337) | 205 (202–209) |
637
+ | 4 | 210 (204–213) | **464** (281–497) | 401 (376–446) | 208 (203–209) |
638
+ | 8 | 214 (211–216) | **313** (290–317) | 303 (299–329) | 209 (207–211) |
639
+
640
+ CPU-seconds per 1,000 jobs on Ruby 3.4.4: 4.7–4.8 at concurrency 1; at concurrency 8
641
+ Redis Ractors 24.3, Engine Ractors 25.8, Engine threads 4.8, Sidekiq 4.7.
642
+
643
+ CPU covers the Ruby worker process only, not Redis or the SolidEngine server.
644
+
645
+ Separate conclusions:
646
+
647
+ 1. **Execution model** (same SolidEngine backend): Ractors reach 2,058 jobs/s at
648
+ concurrency 8 on Ruby 4.0.7 against about 290 for threads (about 7×). The earlier
649
+ plateau was the GVL, not the server.
650
+ 2. **Backend** (both in Ractors, Ruby 4.0.7): SolidEngine leads Redis at every
651
+ concurrency with non-overlapping ranges (+10% at 2, +16% at 4, +23% at 8). On
652
+ Ruby 3.4.4 the two are comparable and ranges overlap.
653
+ 3. **SolidJobs vs Sidekiq:** against one Sidekiq process with N threads SolidJobs is
654
+ about 6.9× faster at concurrency 8, but that comparison uses one core for Sidekiq.
655
+ Against N Sidekiq processes (same cores) throughput is **on par**: Sidekiq is
656
+ within ±5% of SolidJobs + SolidEngine and ahead of SolidJobs + Redis at
657
+ concurrency 4–8. SolidJobs' measured advantage on this workload is memory
658
+ (one process with Ractors instead of N processes), not throughput.
659
+ 4. **Ruby 3.4.4:** throughput drops and CPU per job rises sharply at concurrency 8 for
660
+ both backends (Ractor coordination cost in the runtime); use Ruby 4.
661
+
662
+ Not covered: Linux PSS (macOS `footprint` is used instead), memory under large
663
+ backlogs, more than 8 performance cores, Ruby 3.4.4 with multi-process Sidekiq.
664
+ Reports: `benchmark_sidekiq_solid-jobs/results/ruby-3.4.4-process-cpu-ab.md` and
665
+ `ruby-4.0.7-process-cpu-procs.md`.
666
+
667
+ ### SolidEngine re-run (Ruby 4.0.7, all processing scenarios)
668
+
669
+ Re-run with the Rust `solid-engine-server` (release build, `memory` durability):
670
+ `process-cpu`, `process-io` and `process-mixed`, median of 6 runs, jobs/s, 0 errors
671
+ everywhere. Sidekiq is one process with N threads; "Engine (threads)" is SolidJobs +
672
+ SolidEngine with threads instead of Ractors.
673
+
674
+ | Scenario | Concurrency | Sidekiq | SolidJobs + Redis | SolidJobs + SolidEngine | Engine (threads) |
675
+ |---|---:|---:|---:|---:|---:|
676
+ | `process-cpu` | 1 | 280 | 268 | **285** | 283 |
677
+ | `process-cpu` | 2 | 292 | 516 | **565** | 288 |
678
+ | `process-cpu` | 4 | 296 | 956 | **1,109** | 291 |
679
+ | `process-cpu` | 8 | 292 | 1,635 | **2,021** | 286 |
680
+ | `process-io` | 1 | 156 | 160 | **166** | 153 |
681
+ | `process-io` | 2 | 308 | 300 | **322** | 300 |
682
+ | `process-io` | 4 | 625 | 594 | **653** | 616 |
683
+ | `process-io` | 8 | 1,221 | 1,076 | **1,242** | 1,200 |
684
+ | `process-mixed` | 1 | 189 | 194 | **204** | 192 |
685
+ | `process-mixed` | 2 | 402 | 396 | **426** | 398 |
686
+ | `process-mixed` | 4 | 568 | 752 | **874** | 554 |
687
+ | `process-mixed` | 8 | 572 | 1,471 | **1,763** | 552 |
688
+
689
+ - CPU and mixed work: Ractors scale with SolidEngine (about 6.9× Sidekiq on
690
+ `process-cpu` and 3.1× on `process-mixed` at concurrency 8), while threads stay
691
+ GVL-bound at the Sidekiq level. SolidEngine is +20% to +24% ahead of Redis at
692
+ concurrency 8.
693
+ - I/O work: all clients are within about 15% of each other because the wait
694
+ dominates; SolidEngine is on par with or slightly ahead of Sidekiq.
695
+ - Footprint at `process-cpu`, concurrency 8 (workers / Redis / SolidEngine MiB):
696
+ Sidekiq 28.8 / 6.0 / –; SolidJobs + Redis 32.4 / 6.0 / –; SolidJobs + SolidEngine
697
+ 54.8 / 6.0 / 7.3. Single process, N Ractors: the Sidekiq column is one process, not N.
698
+ - Reports: `benchmark_sidekiq_solid-jobs/results/ruby-4.0.7-solid-engine.md` and
699
+ `.csv`. Command: `RBENV_VERSION=4.0.7 BENCHMARK_SCENARIOS=process-cpu,process-io,process-mixed bundle exec rake comparison`.
700
+
701
+ ### SolidRedis backend re-run (solid-redis 1.0.13, Ruby 4.0.7)
702
+
703
+ Second run with the local `solid-redis` 1.0.13 checkout wired into the benchmark
704
+ bundle (`SOLID_REDIS_PATH`, default `../solid-redis`; the previous run resolved
705
+ 1.0.12 from rubygems). Only Sidekiq and SolidJobs + SolidRedis (Ractors) were run
706
+ (144 cases, median of 6, 0 errors). The SolidEngine column is the run above, for
707
+ comparison.
708
+
709
+ | Scenario | Concurrency | Sidekiq | SolidJobs + SolidRedis | SolidJobs + SolidEngine |
710
+ |---|---:|---:|---:|---:|
711
+ | `process-cpu` | 1 | 282 | 266 | **285** |
712
+ | `process-cpu` | 2 | 281 | 490 | **565** |
713
+ | `process-cpu` | 4 | 292 | 946 | **1,109** |
714
+ | `process-cpu` | 8 | 291 | 1,603 | **2,021** |
715
+ | `process-io` | 1 | 154 | 160 | **166** |
716
+ | `process-io` | 2 | 307 | 302 | **322** |
717
+ | `process-io` | 4 | 604 | 566 | **653** |
718
+ | `process-io` | 8 | 1,172 | 998 | **1,242** |
719
+ | `process-mixed` | 1 | 185 | 192 | **204** |
720
+ | `process-mixed` | 2 | 400 | 397 | **426** |
721
+ | `process-mixed` | 4 | 566 | 746 | **874** |
722
+ | `process-mixed` | 8 | 573 | 1,468 | **1,763** |
723
+
724
+ - SolidRedis scales like SolidEngine but stays 10–20% below it on CPU and mixed work
725
+ (concurrency 8: 1,603 vs 2,021 on `process-cpu`, 1,468 vs 1,763 on `process-mixed`),
726
+ and is still 5.5× Sidekiq (threads) on `process-cpu` and 2.6× on `process-mixed`.
727
+ - On `process-io` SolidRedis is within about 15% of Sidekiq and below it at
728
+ concurrency 4–8 (998 vs 1,172 at 8); SolidEngine matches or beats Sidekiq.
729
+ - The SolidEngine column comes from a separate run on the same machine, so
730
+ differences of a few percent are within run-to-run noise.
731
+ - Reports: `benchmark_sidekiq_solid-jobs/results/ruby-4.0.7-solid-redis-1.0.13.md`
732
+ and `.csv`. Command:
733
+ `RBENV_VERSION=4.0.7 BENCHMARK_CLIENTS=sidekiq,solid-jobs BENCHMARK_SCENARIOS=process-cpu,process-io,process-mixed bundle exec rake comparison`.
531
734
 
532
735
  ### Mixed Workload (`process-mixed` — CPU + I/O)
533
736
 
@@ -549,6 +752,35 @@ scales linearly across cores:
549
752
  | 4 | 593 | **604** | 50 |
550
753
  | 8 | 1,121 | **1,133** | 52 |
551
754
 
755
+ ### Stress test: concurrency 4, 8 and 16
756
+
757
+ Run with `bundle exec rake stress` (5,000 pre-seeded jobs per case, medians of
758
+ 3 runs; `STRESS_CONCURRENCY`, `STRESS_REPETITIONS` and `STRESS_JOBS` override
759
+ the defaults). It writes a Markdown report and a CSV for plotting curves.
760
+
761
+ | Workload | Concurrency | Sidekiq (jobs/s) | SolidJobs (jobs/s) | Ratio | Sidekiq CPU | SolidJobs CPU |
762
+ |---|---:|---:|---:|---:|---:|---:|
763
+ | `process-cpu` | 4 | 295 | 1,021 | 3.5× | 100% | 358% |
764
+ | `process-cpu` | 8 | 288 | 1,916 | 6.7× | 100% | 683% |
765
+ | `process-cpu` | 16 | 293 | 2,117 | 7.2× | 100% | 875% |
766
+ | `process-mixed` | 4 | 557 | 792 | 1.4× | 99% | 144% |
767
+ | `process-mixed` | 8 | 567 | 1,656 | 2.9× | 101% | 305% |
768
+ | `process-mixed` | 16 | 568 | 3,073 | 5.4× | 101% | 590% |
769
+ | `process-io` | 4 | 595 | 574 | 0.96× | 21% | 28% |
770
+ | `process-io` | 8 | 1,177 | 1,053 | 0.89× | 31% | 43% |
771
+ | `process-io` | 16 | 2,295 | 1,932 | 0.84× | 48% | 75% |
772
+
773
+ - Sidekiq stays flat near one core on CPU and mixed work at every level, while
774
+ SolidJobs keeps scaling; CPU gains flatten past 8 because the machine runs out
775
+ of cores.
776
+ - On pure I/O (sleep) work, Sidekiq's threads are slightly faster at these
777
+ levels and use less CPU. SolidJobs is not faster on I/O-bound jobs.
778
+ - Latencies in the report measure the drain of the pre-seeded backlog, not
779
+ steady-state latency. Per-job execution p99 stayed under 10 ms for both.
780
+ - Zero errors in all 54 measurements.
781
+
782
+ *Environment: Ruby 4.0.7 (arm64-darwin27); Sidekiq 8.1.7; SolidJobs 0.5.0.*
783
+
552
784
  ### Client Enqueue Throughput
553
785
 
554
786
  | Concurrency | Sidekiq Enqueue (jobs/s) | SolidJobs Enqueue (jobs/s) | Sidekiq Bulk (jobs/s) | SolidJobs Bulk (jobs/s) |