solid-jobs 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8bbfb02b4261c0c6164bae65b6a47ebceda70a1dd0b3b59835e1a48a9c58bc97
4
+ data.tar.gz: f4306875809f7d8c5287ba9de5022ee1ac29da163a3448f0ed7de0492c9beccd
5
+ SHA512:
6
+ metadata.gz: 71c1383a610c4d230f7db3b02f09664de638ff5009b22f3b44fe3b7dedbfe0f2e0aad5fb791c6816adea711b0e3e20766dc406b40e21cfe109ae429e8d07d7fd
7
+ data.tar.gz: d5e9beff88328ba42cc3a2bdf3167fd12acd7391b8c61e373664bc408b4055bcdb9fdb0915308b0fde7c521aea7527be63ba8e18d332458938a95a9401897832
data/CHANGELOG.md ADDED
@@ -0,0 +1,27 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## [Unreleased]
6
+
7
+ ## [0.1.0] - 2026-09-30
8
+
9
+ ### Added
10
+
11
+ - Ractor-oriented client, Processor, Scheduler, Heartbeat, and Server runtime.
12
+ - Sidekiq-compatible Redis queue keys and open-source job payload format.
13
+ - Immediate, scheduled, retry, and dead-job handling.
14
+ - At-least-once reservations with atomic journaling, generation-fenced ACK
15
+ and requeue operations, and crashed-process recovery.
16
+ - `SolidJobs::IntegrityCheck` for lost jobs, orphaned references, invalid
17
+ reservations, dangling indexes, and impossible duplicate states.
18
+ - `SolidJobs::StartupBarrier`, which preconnects every component before
19
+ releasing Processors into their fetch loops.
20
+ - Graceful quiet and shutdown handling with requeue of interrupted work.
21
+ - Minitest unit, Redis integration, bounded stress, torture, startup-torture,
22
+ and soak suites.
23
+ - Reliable-hot-path and CPU-scaling profilers plus the independent Sidekiq
24
+ comparison benchmark.
25
+
26
+ [Unreleased]: https://github.com/nicolasva/solid-jobs/compare/v0.1.0...HEAD
27
+ [0.1.0]: https://github.com/nicolasva/solid-jobs/releases/tag/v0.1.0
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nicolas Vandenbogaerde
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
data/README.md ADDED
@@ -0,0 +1,159 @@
1
+ # SolidJobs
2
+
3
+ [![Build Status](https://github.com/nicolasva/solid-jobs/actions/workflows/ci.yml/badge.svg)](https://github.com/nicolasva/solid-jobs/actions/workflows/ci.yml)
4
+ [![Code Climate](https://codeclimate.com/github/nicolasva/solid-jobs/badges/gpa.svg)](https://codeclimate.com/github/nicolasva/solid-jobs)
5
+ [![Gem Version](https://badge.fury.io/rb/solid-jobs.svg)](https://rubygems.org/gems/solid-jobs)
6
+ [![Documentation Status](https://img.shields.io/badge/docs-rubydoc.info-blue.svg)](https://www.rubydoc.info/gems/solid-jobs)
7
+ [![Downloads](https://img.shields.io/gem/dt/solid-jobs.svg)](https://rubygems.org/gems/solid-jobs)
8
+
9
+ SolidJobs is a Ractor-oriented Redis background job system for Ruby. It uses
10
+ `solid-redis` for Redis access and keeps mutable clients, pools, middleware,
11
+ and runtime state local to their owning Ractor.
12
+
13
+ SolidJobs is an independent implementation. It does not depend on or load the
14
+ Sidekiq gem. Its Redis job payloads and queue keys are designed to be
15
+ compatible with the Sidekiq 8 open-source data format.
16
+
17
+ SolidJobs and its required gems use pure Ruby and do not require native
18
+ extensions. Hot paths are designed around bounded buffers, reusable immutable
19
+ configuration, and low-allocation command batches.
20
+
21
+ ## Installation
22
+
23
+ Add SolidJobs 0.1 to your bundle:
24
+
25
+ ```ruby
26
+ gem "solid-jobs", "~> 0.1.0"
27
+ ```
28
+
29
+ Then run:
30
+
31
+ ```sh
32
+ bundle install
33
+ ```
34
+
35
+ ## Delivery semantics
36
+
37
+ SolidJobs provides **at-least-once** job delivery. A worker atomically moves a
38
+ job from `queue:<name>` to a process reservation list before execution and
39
+ removes it only after a successful ACK. Graceful shutdown requeues unfinished
40
+ jobs; reservations owned by a crashed process are recovered into their
41
+ original queues.
42
+
43
+ A process can still crash after the application side effect and before the
44
+ ACK. The recovered job will then run again. Jobs must therefore be idempotent
45
+ or implement an application-level deduplication key when duplicate side
46
+ effects are unsafe. SolidJobs does not claim exactly-once execution.
47
+
48
+ ```ruby
49
+ class HardJob
50
+ include SolidJobs::Job
51
+
52
+ solid_jobs_options queue: "critical", retry: 10
53
+
54
+ def perform(account_id)
55
+ Account.find(account_id).recalculate!
56
+ end
57
+ end
58
+
59
+ HardJob.perform_async(42)
60
+ HardJob.perform_in(30, 42)
61
+ ```
62
+
63
+ Run workers:
64
+
65
+ ```sh
66
+ bundle exec solid-jobs --require ./config/environment \
67
+ --concurrency 8 --queue critical,3 --queue default
68
+ ```
69
+
70
+ ## Tests
71
+
72
+ SolidJobs uses Minitest exclusively:
73
+
74
+ ```sh
75
+ # Unit and bounded Redis integration tests
76
+ bundle exec rake test
77
+
78
+ # Bounded concurrency and multi-process recovery stress
79
+ STRESS_JOBS=10000 bundle exec rake stress
80
+
81
+ # Reproducible random-fault campaign
82
+ SOLID_JOBS_TORTURE=1 STRESS_JOBS=100000 bundle exec rake torture
83
+
84
+ # Re-run an exact failure sequence
85
+ SOLID_JOBS_TORTURE=1 STRESS_JOBS=100000 STRESS_SEED=123456 bundle exec rake torture
86
+
87
+ # Repeated fresh-process RESP/Ractor startup torture
88
+ STARTUP_TORTURE_CYCLES=1000 \
89
+ STARTUP_TORTURE_READERS=100 \
90
+ bundle exec rake startup_torture
91
+
92
+ # Long-running stability; defaults to 24 hours
93
+ SOLID_JOBS_SOAK=1 SOLID_JOBS_SOAK_SECONDS=86400 bundle exec rake soak
94
+ ```
95
+
96
+ The torture report reconciles enqueued, uniquely completed, duplicate,
97
+ dead, queued, and reserved jobs. Any non-zero `LOST` value fails the test.
98
+
99
+ Profile the reliable execution path independently from application work:
100
+
101
+ ```sh
102
+ REDIS_URL=redis://127.0.0.1:6379/0 \
103
+ HOT_PATH_JOBS=10000 \
104
+ bundle exec rake benchmark:hot_path
105
+ ```
106
+
107
+ The report separates time and allocations for reservation, payload reuse,
108
+ observability registration, dispatch/perform wrapping, observability cleanup,
109
+ and fenced ACK. Fetch decodes the payload once for both reservation metadata
110
+ and dispatch; its job body is intentionally empty.
111
+
112
+ Diagnose CPU scaling independently from Redis:
113
+
114
+ ```sh
115
+ CPU_SCALING_JOBS=1000 \
116
+ CPU_SCALING_ITERATIONS=210000 \
117
+ bundle exec rake benchmark:cpu_scaling
118
+ ```
119
+
120
+ This runs the identical CPU loop through pure Ractors and through the
121
+ SolidJobs in-memory dispatch path. Each 1/2/4/8 case uses fresh processes and
122
+ reports execution latency, scaling efficiency, CPU-seconds per 1,000 jobs,
123
+ RSS, allocations, GC time, heap slots, and malloc growth.
124
+
125
+ ## Sidekiq comparison
126
+
127
+ The separate `benchmark_sidekiq_solid-jobs` bundle runs Sidekiq and SolidJobs
128
+ against the same isolated Redis server. It covers enqueue, bulk enqueue,
129
+ CPU-bound processing, I/O-bound processing, mixed processing, and long-running
130
+ stability. Every measurement runs in a fresh Ruby process; client order
131
+ alternates and the default report uses the median of six repetitions.
132
+
133
+ The current homogeneous CPU-processing reference uses Ruby 4.0.1,
134
+ Sidekiq 8.1.7, a 210,000-iteration integer workload, and 1,000 jobs per case:
135
+
136
+ | Concurrency | Sidekiq jobs/s | SolidJobs jobs/s | SolidJobs scaling | Sidekiq CPU-s/1k | SolidJobs CPU-s/1k | Sidekiq RSS | SolidJobs RSS | Sidekiq alloc/job | SolidJobs alloc/job |
137
+ |---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|
138
+ | 1 | 142 | 138 | 100.0% | 6.95 | 7.11 | 41.5 MiB | 72.0 MiB | 129.8 | 89.9 |
139
+ | 2 | 144 | 272 | 98.7% | 6.94 | 7.16 | 41.8 MiB | 77.6 MiB | 117.5 | 81.3 |
140
+ | 4 | 143 | 539 | 97.8% | 6.99 | 7.18 | 41.9 MiB | 71.4 MiB | 111.9 | 77.2 |
141
+ | 8 | 144 | 957 | 86.7% | 6.94 | 8.01 | 42.2 MiB | 69.5 MiB | 108.7 | 75.4 |
142
+
143
+ At eight concurrency units, SolidJobs reaches 957 jobs/s versus 144 jobs/s
144
+ for one Sidekiq process. This is Ractor parallelism rather than equal CPU
145
+ efficiency: SolidJobs consumes 766% CPU and 8.01 CPU-seconds per 1,000 jobs,
146
+ while Sidekiq consumes 100% CPU and 6.94 CPU-seconds per 1,000 jobs. SolidJobs
147
+ also uses more RSS, but reaches 13.77 jobs/s/MiB versus 3.41 for Sidekiq.
148
+
149
+ These are local synthetic measurements, not application-capacity claims.
150
+ Queue p95/p99 values in this run use sparse sampling and are excluded from the
151
+ summary until the final latency campaign increases the sample count. Ruby
152
+ 3.4.4 eight-Ractor results are also excluded: concurrent TCP/RESP
153
+ initialization triggered a reproducible native crash on the tested Apple
154
+ Silicon environment. Ruby 4.0.1 passed the equivalent reproducer 100/100
155
+ times, and `StartupBarrier` serializes component initialization before
156
+ releasing normal parallel processing.
157
+
158
+ The project is under active development. The Web UI and commercial Sidekiq
159
+ features are not part of the initial scope.
@@ -0,0 +1,114 @@
1
+ # Reliability model
2
+
3
+ SolidJobs uses a Redis-backed at-least-once state machine:
4
+
5
+ ```text
6
+ READY
7
+ |
8
+ | BLMOVE reservation
9
+ v
10
+ IN_PROGRESS ---- ACK/LREM ----> removed
11
+ |
12
+ +---- application error ----> RETRY or DEAD, then ACK
13
+ +---- graceful timeout -----> READY
14
+ +---- process crash --------> reservation retained
15
+ |
16
+ +---- recovery ----> READY
17
+ ```
18
+
19
+ The reservation list is named `<process-identity>:reserved:<processor-id>`.
20
+ Each execution receives a distinct reservation journal entry:
21
+
22
+ ```text
23
+ job_id stable across replay
24
+ reservation_id unique per execution attempt
25
+ process_id owning process identity
26
+ worker_id owning Processor Ractor
27
+ attempt monotonically increasing execution count
28
+ reserved_at wall-clock reservation time
29
+ ```
30
+
31
+ ACK and requeue are fenced by `reservation_id`. Their Lua scripts first
32
+ verify that the worker still owns the current journal generation. A delayed
33
+ or revived worker cannot remove or requeue a newer reservation, even when a
34
+ supervisor reuses the same processor slot.
35
+
36
+ The payload retains its canonical `queue` field, allowing another process to
37
+ restore it after the owner is no longer alive. Recovery uses process liveness
38
+ and heartbeat, never reservation age alone, so a legitimate long-running job
39
+ is not stolen while its owner remains alive.
40
+
41
+ ## Failure boundaries
42
+
43
+ - Before reservation: the job remains in `queue:<name>`.
44
+ - After reservation or during `perform`: the job remains reserved.
45
+ - After the application effect but before ACK: recovery replays the job.
46
+ - Redis unavailable during ACK: the job remains reserved and is replayed.
47
+ - Graceful shutdown: the active job may finish within the configured timeout;
48
+ otherwise it is interrupted and requeued.
49
+ - `SIGKILL`: no handler runs; recovery relies exclusively on Redis state.
50
+
51
+ This design intentionally favors no job loss over duplicate suppression.
52
+ Exactly-once side effects require application-level idempotency.
53
+
54
+ ## Startup isolation
55
+
56
+ The server does not reserve work while components are booting. Heartbeat,
57
+ Processor, and Scheduler Ractors initialize their local configuration and
58
+ Redis pool, report `READY` exactly once, and wait behind
59
+ `SolidJobs::StartupBarrier`. Processing starts only after every component is
60
+ ready:
61
+
62
+ ```text
63
+ BOOTING -> ALL_READY -> RUNNING
64
+ \-------> BOOT_FAILED -> cleanup
65
+ ```
66
+
67
+ Boot failure is terminal. Already-ready components receive `:abort`, close
68
+ their local resources, and never enter their fetch loops. Cleanup is
69
+ idempotent. Component startup is serialized, avoiding concurrent TCP/RESP
70
+ initialization paths known to crash Ruby 3.4.4 on the tested Apple Silicon
71
+ environment; normal processing remains parallel after `RUNNING`.
72
+
73
+ ## Integrity auditing
74
+
75
+ Fault and chaos tests can reconcile a known set of job IDs against every
76
+ Redis-backed state:
77
+
78
+ ```ruby
79
+ report = SolidJobs::IntegrityCheck.call(
80
+ expected_job_ids: submitted_job_ids,
81
+ acked_key: "test:completed",
82
+ )
83
+
84
+ raise report.inspect unless report.ok?
85
+ ```
86
+
87
+ The report separates lost jobs, unexpected/orphaned jobs, inconsistent
88
+ reservation journals, dangling attempt indexes, duplicate active states, and
89
+ malformed payloads. ACK removes the completed job's attempt index atomically;
90
+ requeue retains it so a recovered reservation increments the same attempt
91
+ sequence.
92
+
93
+ ## Backpressure and queues
94
+
95
+ Each Processor owns at most one reservation and reserves only immediately
96
+ before execution. A process with concurrency `N` therefore holds at most `N`
97
+ active reservations, regardless of Redis queue depth.
98
+
99
+ Queue mode defaults to `:weighted`:
100
+
101
+ - `:weighted` uses bounded weighted round-robin and does not starve configured
102
+ queues;
103
+ - `:strict` always checks queues in declaration order and may intentionally
104
+ starve lower-priority queues while a higher-priority queue remains busy;
105
+ - `:random` samples the configured weighted queue list on each reservation.
106
+
107
+ Paused queues are excluded before reservation.
108
+
109
+ ## Retry storms
110
+
111
+ Retries use capped exponential backoff with equal jitter. For attempt `n`, the
112
+ ceiling is `min(retry_base_delay * 2**n, retry_max_delay)` and the actual delay
113
+ is distributed between half and all of that ceiling. Defaults are 15 seconds
114
+ and one hour. This spreads recovery traffic after a shared external outage.
data/exe/solid-jobs ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "solid_jobs"
5
+ require "solid_jobs/cli"
6
+
7
+ exit SolidJobs::CLI.new.run
8
+
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "solid_jobs"
4
+ require "solid_jobs/active_job"
5
+
6
+ module ActiveJob
7
+ module QueueAdapters
8
+ class SolidJobsAdapter
9
+ def enqueue(job)
10
+ job.provider_job_id = push(job)
11
+ end
12
+
13
+ def enqueue_at(job, timestamp)
14
+ job.provider_job_id = push(job, at: timestamp)
15
+ end
16
+
17
+ def enqueue_all(jobs)
18
+ jobs.count do |job|
19
+ job.provider_job_id = job.scheduled_at ? enqueue_at(job, job.scheduled_at.to_f) : enqueue(job)
20
+ end
21
+ end
22
+
23
+ def stopping?
24
+ false
25
+ end
26
+
27
+ private
28
+
29
+ def push(job, at: nil)
30
+ payload = {
31
+ "class" => SolidJobs::ActiveJob::Wrapper,
32
+ "wrapped" => job.class.name,
33
+ "queue" => job.queue_name,
34
+ "args" => [job.serialize],
35
+ }
36
+ payload["at"] = at if at
37
+ SolidJobs::Client.push(payload)
38
+ end
39
+ end
40
+ end
41
+ end
42
+
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidJobs
4
+ module ActiveJob
5
+ class Wrapper
6
+ include SolidJobs::Job
7
+ solid_jobs_options retry: true
8
+
9
+ def perform(job_data)
10
+ ::ActiveJob::Base.execute(job_data.merge("provider_job_id" => jid))
11
+ end
12
+ end
13
+ JobWrapper = Wrapper
14
+ end
15
+ end
16
+