specbandit 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: bc3e7d15445689d7dfb61865891f47958e87c89cf65052b7351c4ec788bec5d2
4
+ data.tar.gz: 2b54637983a2e0cac8beb4d4521d0f7722819a0ef211b5089dc51915275c26e8
5
+ SHA512:
6
+ metadata.gz: 3268930d24a06356a28c8fe472a180d8ae0a84c4cd1ee7fbf33e9ae36f2cfe5b34d2d86746c1a165e7a284e0e2ba731f52036210198d1073da96cf5313a9ec89
7
+ data.tar.gz: 22bd3603d6038e55d8540fc1a155182e71dfe95bdb21328a3c369e677185931a4fe9427b5d12286dcf2113f492323c199c97577fac8691f3e5bcbbc94f8f9ef7
data/README.md ADDED
@@ -0,0 +1,313 @@
1
+ <p align="center">
2
+ <img src="specbandit.png" alt="specbandit logo" width="200">
3
+ </p>
4
+
5
+ # specbandit
6
+
7
+ Distributed RSpec runner using Redis as a work queue. One process pushes spec file paths to a Redis list; multiple CI runners atomically steal batches and execute them in-process via `RSpec::Core::Runner`.
8
+
9
+ ```
10
+ CI Job 1 (push): RPUSH key f1 f2 f3 ... fN --> [Redis List]
11
+ CI Job 2 (worker): LPOP key 5 <-- [Redis List] --> RSpec
12
+ CI Job 3 (worker): LPOP key 5 <-- [Redis List] --> RSpec
13
+ CI Job N (worker): LPOP key 5 <-- [Redis List] --> RSpec
14
+ ```
15
+
16
+ `LPOP` with a count argument (Redis 6.2+) is atomic -- multiple workers calling it concurrently will never receive the same file.
17
+
18
+ ## Installation
19
+
20
+ Add to your Gemfile:
21
+
22
+ ```ruby
23
+ gem "specbandit"
24
+ ```
25
+
26
+ Or install directly:
27
+
28
+ ```bash
29
+ gem install specbandit
30
+ ```
31
+
32
+ **Requirements**: Ruby >= 3.0, Redis >= 6.2
33
+
34
+ ## Usage
35
+
36
+ ### 1. Push spec files to Redis
37
+
38
+ A single CI job enqueues all spec file paths before workers start.
39
+
40
+ ```bash
41
+ # Via glob pattern (resolved in Ruby, avoids shell ARG_MAX limits)
42
+ specbandit push --key pr-123-run-456 --pattern 'spec/**/*_spec.rb'
43
+
44
+ # Via stdin pipe (for large file lists or custom filtering)
45
+ find spec -name '*_spec.rb' | specbandit push --key pr-123-run-456
46
+
47
+ # Via direct arguments (for small lists)
48
+ specbandit push --key pr-123-run-456 spec/models/user_spec.rb spec/models/order_spec.rb
49
+ ```
50
+
51
+ File input priority: **stdin > --pattern > direct args**.
52
+
53
+ ### 2. Steal and run from multiple workers
54
+
55
+ Each CI runner steals batches and runs them. Start as many runners as you want -- they'll divide the work automatically.
56
+
57
+ ```bash
58
+ specbandit work --key pr-123-run-456 --batch-size 10
59
+ ```
60
+
61
+ Each worker loops:
62
+ 1. `LPOP` N file paths from Redis (atomic)
63
+ 2. Run them in-process via `RSpec::Core::Runner`
64
+ 3. Repeat until the queue is empty
65
+ 4. Exit 0 if all batches passed, 1 if any failed
66
+
67
+ A failing batch does **not** stop the worker. It continues stealing remaining work so other runners aren't blocked waiting on files that will never be consumed.
68
+
69
+ ### CLI reference
70
+
71
+ ```
72
+ specbandit push [options] [files...]
73
+ --key KEY Redis queue key (required)
74
+ --pattern PATTERN Glob pattern for file discovery
75
+ --redis-url URL Redis URL (default: redis://localhost:6379)
76
+ --key-ttl SECONDS TTL for the Redis key (default: 21600 / 6 hours)
77
+
78
+ specbandit work [options]
79
+ --key KEY Redis queue key (required)
80
+ --batch-size N Files per batch (default: 5)
81
+ --redis-url URL Redis URL (default: redis://localhost:6379)
82
+ --rspec-opts OPTS Extra options forwarded to RSpec
83
+ --key-rerun KEY Per-runner rerun key for re-run support (see below)
84
+ --key-rerun-ttl SECS TTL for rerun key (default: 604800 / 1 week)
85
+ ```
86
+
87
+ ### Environment variables
88
+
89
+ All CLI options can be set via environment variables:
90
+
91
+ | Variable | Description | Default |
92
+ |---|---|---|
93
+ | `SPECBANDIT_KEY` | Redis queue key | _(required)_ |
94
+ | `SPECBANDIT_REDIS_URL` | Redis connection URL | `redis://localhost:6379` |
95
+ | `SPECBANDIT_BATCH_SIZE` | Files per steal | `5` |
96
+ | `SPECBANDIT_KEY_TTL` | Key expiry in seconds | `21600` (6 hours) |
97
+ | `SPECBANDIT_RSPEC_OPTS` | Space-separated RSpec options | _(none)_ |
98
+ | `SPECBANDIT_KEY_RERUN` | Per-runner rerun key | _(none)_ |
99
+ | `SPECBANDIT_KEY_RERUN_TTL` | Rerun key expiry in seconds | `604800` (1 week) |
100
+
101
+ CLI flags take precedence over environment variables.
102
+
103
+ ### Ruby API
104
+
105
+ ```ruby
106
+ require "specbandit"
107
+
108
+ Specbandit.configure do |c|
109
+ c.redis_url = "redis://my-redis:6379"
110
+ c.key = "pr-123-run-456"
111
+ c.batch_size = 10
112
+ c.key_ttl = 7200 # 2 hours (default: 21600 / 6 hours)
113
+ c.key_rerun = "pr-123-run-456-runner-3"
114
+ c.key_rerun_ttl = 604_800 # 1 week (default)
115
+ c.rspec_opts = ["--format", "documentation"]
116
+ end
117
+
118
+ # Push
119
+ publisher = Specbandit::Publisher.new
120
+ publisher.publish(pattern: "spec/**/*_spec.rb")
121
+
122
+ # Work (will auto-detect steal/record/replay mode based on key_rerun state)
123
+ worker = Specbandit::Worker.new
124
+ exit_code = worker.run
125
+ ```
126
+
127
+ ## Example: GitHub Actions (basic)
128
+
129
+ ```yaml
130
+ jobs:
131
+ push-specs:
132
+ runs-on: ubuntu-latest
133
+ steps:
134
+ - uses: actions/checkout@v4
135
+ - run: bundle install
136
+ - run: |
137
+ specbandit push \
138
+ --key "pr-${{ github.event.number }}-${{ github.run_id }}" \
139
+ --redis-url "${{ secrets.REDIS_URL }}" \
140
+ --pattern 'spec/**/*_spec.rb'
141
+
142
+ run-specs:
143
+ runs-on: ubuntu-latest
144
+ needs: push-specs
145
+ strategy:
146
+ matrix:
147
+ runner: [1, 2, 3, 4]
148
+ steps:
149
+ - uses: actions/checkout@v4
150
+ - run: bundle install
151
+ - run: |
152
+ specbandit work \
153
+ --key "pr-${{ github.event.number }}-${{ github.run_id }}" \
154
+ --redis-url "${{ secrets.REDIS_URL }}" \
155
+ --batch-size 10
156
+ ```
157
+
158
+ ## Re-running failed CI jobs
159
+
160
+ ### The problem
161
+
162
+ When you use specbandit to distribute tests across multiple CI runners (e.g. a GitHub Actions matrix with 4 runners), each runner **steals** a random subset of spec files from the shared Redis queue. The distribution is non-deterministic -- which runner gets which files depends on timing.
163
+
164
+ This creates a subtle but serious problem with CI re-runs:
165
+
166
+ 1. **First run**: Runner #3 steals and executes files X, Y, Z. File Y fails. The shared queue is now empty (all files were consumed across all runners).
167
+ 2. **Re-run of runner #3**: GitHub Actions re-runs only the failed runner. It starts `specbandit work` again with the same `--key`, but the shared queue is already empty. Runner #3 sees nothing to do and **exits 0 -- the failing test silently passes**.
168
+
169
+ This happens because GitHub Actions re-runs **reuse the same `run_id`**, so the key resolves to the same (now empty) Redis list.
170
+
171
+ ### The solution: `--key-rerun`
172
+
173
+ The `--key-rerun` flag gives each matrix runner its own "memory" in Redis. It enables specbandit to **record** which files each runner executed, and **replay** exactly those files on a re-run.
174
+
175
+ Each runner gets a unique rerun key (typically including the matrix index):
176
+
177
+ ```bash
178
+ specbandit work \
179
+ --key "pr-42-run-100" \
180
+ --key-rerun "pr-42-run-100-runner-3" \
181
+ --batch-size 10
182
+ ```
183
+
184
+ ### How it works: three operating modes
185
+
186
+ Specbandit detects the mode automatically based on the state of `--key-rerun`:
187
+
188
+ | `--key-rerun` provided? | Rerun key in Redis | Mode | Behavior |
189
+ |---|---|---|---|
190
+ | No | -- | **Steal** | Original behavior. Steal from shared queue, run, done. |
191
+ | Yes | Empty | **Record** | Steal from shared queue + record each batch to the rerun key. |
192
+ | Yes | Has data | **Replay** | Ignore shared queue entirely. Re-run exactly the recorded files. |
193
+
194
+ **On first run**, the rerun key doesn't exist yet (empty), so specbandit enters **record mode**:
195
+
196
+ ```
197
+ ┌──────────────────┐ LPOP N ┌──────────────────┐ RPUSH ┌──────────────────────────────┐
198
+ │ Redis │ ─────────> │ Runner #3 │ ────────> │ Redis │
199
+ │ --key │ │ │ │ --key-rerun │
200
+ │ (shared queue) │ │ steal + record │ │ (per-runner memory) │
201
+ │ │ │ + run specs │ │ │
202
+ │ [f1,f2,...,fN] │ │ │ │ [f5,f6,f7] ← what #3 stole │
203
+ └──────────────────┘ └──────────────────┘ └──────────────────────────────┘
204
+ ```
205
+
206
+ **On re-run**, the rerun key already contains the files from the first run, so specbandit enters **replay mode**:
207
+
208
+ ```
209
+ ┌──────────────────┐ LRANGE ┌──────────────────────────────┐
210
+ --key NOT touched │ Runner #3 │ <──────── │ Redis │
211
+ │ │ │ --key-rerun │
212
+ │ replay specs │ │ (per-runner memory) │
213
+ │ f5, f6, f7 │ │ │
214
+ └──────────────────┘ │ [f5,f6,f7] ← still there │
215
+ └──────────────────────────────┘
216
+ ```
217
+
218
+ Key details:
219
+
220
+ - **Replay reads non-destructively** (`LRANGE`, not `LPOP`). The rerun key is never consumed. If you re-run the same runner multiple times, it replays the same files every time.
221
+ - **The shared queue is never touched in replay mode**. Other runners are unaffected.
222
+ - **Each runner has its own rerun key**. Only the re-run runner enters replay mode; runners that aren't re-run don't start at all.
223
+
224
+ ### Complete GitHub Actions example with re-run support
225
+
226
+ ```yaml
227
+ jobs:
228
+ push-specs:
229
+ runs-on: ubuntu-latest
230
+ steps:
231
+ - uses: actions/checkout@v4
232
+ - run: bundle install
233
+ - run: |
234
+ specbandit push \
235
+ --key "pr-${{ github.event.number }}-${{ github.run_id }}" \
236
+ --redis-url "${{ secrets.REDIS_URL }}" \
237
+ --pattern 'spec/**/*_spec.rb'
238
+
239
+ run-specs:
240
+ runs-on: ubuntu-latest
241
+ needs: push-specs
242
+ strategy:
243
+ matrix:
244
+ runner: [1, 2, 3, 4]
245
+ steps:
246
+ - uses: actions/checkout@v4
247
+ - run: bundle install
248
+ - run: |
249
+ specbandit work \
250
+ --key "pr-${{ github.event.number }}-${{ github.run_id }}" \
251
+ --key-rerun "pr-${{ github.event.number }}-${{ github.run_id }}-runner-${{ matrix.runner }}" \
252
+ --redis-url "${{ secrets.REDIS_URL }}" \
253
+ --batch-size 10
254
+ ```
255
+
256
+ The only difference from the basic example is the addition of `--key-rerun`. The key structure:
257
+
258
+ - `--key` = `pr-42-run-100` -- **shared** across all 4 runners, same on re-run (because `run_id` is reused)
259
+ - `--key-rerun` = `pr-42-run-100-runner-3` -- **unique per runner**, same on re-run
260
+
261
+ ### Walk-through: what happens step by step
262
+
263
+ **First run (all 4 runners start fresh):**
264
+
265
+ | Runner | Mode | What happens |
266
+ |---|---|---|
267
+ | Runner 1 | Record | Steals files A, B, C from shared queue. Records them to `...-runner-1`. |
268
+ | Runner 2 | Record | Steals files D, E from shared queue. Records them to `...-runner-2`. |
269
+ | Runner 3 | Record | Steals files F, G, H from shared queue. File G fails. Records them to `...-runner-3`. |
270
+ | Runner 4 | Record | Steals files I, J from shared queue. Records them to `...-runner-4`. |
271
+
272
+ **Re-run of runner 3 only:**
273
+
274
+ | Runner | Mode | What happens |
275
+ |---|---|---|
276
+ | Runner 3 | Replay | Reads F, G, H from `...-runner-3`. Runs exactly those files. G still fails = correctly reported. |
277
+
278
+ Runners 1, 2, 4 are not started at all.
279
+
280
+ ### Rerun key TTL
281
+
282
+ The rerun key defaults to a **1 week TTL** (`604800` seconds). This is intentionally longer than the shared queue TTL (6 hours) because re-runs can happen hours or even days after the original CI run.
283
+
284
+ Override via `--key-rerun-ttl` or `SPECBANDIT_KEY_RERUN_TTL`:
285
+
286
+ ```bash
287
+ # Set rerun key to expire after 3 days
288
+ specbandit work \
289
+ --key "pr-42-run-100" \
290
+ --key-rerun "pr-42-run-100-runner-3" \
291
+ --key-rerun-ttl 259200
292
+ ```
293
+
294
+ ## How it works
295
+
296
+ - **Push** uses `RPUSH` to append all file paths to a Redis list in a single command, then sets `EXPIRE` on the key (default: 6 hours) to ensure stale queues are automatically cleaned up.
297
+ - **Steal** uses `LPOP key count` (Redis 6.2+), which atomically pops up to N elements. No Lua scripts, no locks, no race conditions.
298
+ - **Record** (when `--key-rerun` is set): after each steal, the batch is also `RPUSH`ed to the per-runner rerun key with its own TTL (default: 1 week).
299
+ - **Replay** (when `--key-rerun` has data): reads all files from the rerun key via `LRANGE` (non-destructive), splits into batches, and runs them locally. The shared queue is never touched.
300
+ - **Run** uses `RSpec::Core::Runner.run` in-process with `RSpec.clear_examples` between batches to reset example state while preserving configuration. No subprocess forking overhead.
301
+ - **Exit code** is 0 if every batch passed (or the queue was already empty), 1 if any batch had failures.
302
+
303
+ ## Development
304
+
305
+ ```bash
306
+ bundle install
307
+ bundle exec rspec # unit tests (no Redis needed)
308
+ bundle exec rspec spec/integration/ # integration tests (requires Redis on localhost:6379)
309
+ ```
310
+
311
+ ## License
312
+
313
+ MIT
data/bin/specbandit ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "specbandit"
5
+ require "specbandit/cli"
6
+
7
+ exit Specbandit::CLI.run(ARGV)
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+
5
+ module Specbandit
6
+ class CLI
7
+ COMMANDS = %w[push work].freeze
8
+
9
+ def self.run(argv = ARGV)
10
+ new(argv).execute
11
+ end
12
+
13
+ attr_reader :argv
14
+
15
+ def initialize(argv)
16
+ @argv = argv.dup
17
+ end
18
+
19
+ def execute
20
+ command = argv.shift
21
+
22
+ case command
23
+ when 'push'
24
+ run_push
25
+ when 'work'
26
+ run_work
27
+ when nil, '-h', '--help'
28
+ print_usage
29
+ 0
30
+ when '-v', '--version'
31
+ puts "specbandit #{VERSION}"
32
+ 0
33
+ else
34
+ warn "Unknown command: #{command}"
35
+ print_usage
36
+ 1
37
+ end
38
+ rescue Specbandit::Error => e
39
+ warn "[specbandit] Error: #{e.message}"
40
+ 1
41
+ rescue Redis::BaseError => e
42
+ warn "[specbandit] Redis error: #{e.message}"
43
+ 1
44
+ end
45
+
46
+ private
47
+
48
+ def run_push
49
+ options = { pattern: nil }
50
+
51
+ parser = OptionParser.new do |opts|
52
+ opts.banner = 'Usage: specbandit push [options] [files...]'
53
+
54
+ opts.on('--key KEY', 'Redis queue key (required, or set SPECBANDIT_KEY)') do |v|
55
+ Specbandit.configuration.key = v
56
+ end
57
+
58
+ opts.on('--pattern PATTERN', "Glob pattern to resolve files (e.g. 'spec/**/*_spec.rb')") do |v|
59
+ options[:pattern] = v
60
+ end
61
+
62
+ opts.on('--redis-url URL', 'Redis URL (default: redis://localhost:6379)') do |v|
63
+ Specbandit.configuration.redis_url = v
64
+ end
65
+
66
+ opts.on('--key-ttl SECONDS', Integer, 'TTL for the Redis key in seconds (default: 21600 / 6 hours)') do |v|
67
+ Specbandit.configuration.key_ttl = v
68
+ end
69
+
70
+ opts.on('-h', '--help', 'Show this help') do
71
+ puts opts
72
+ return 0
73
+ end
74
+ end
75
+
76
+ parser.parse!(argv)
77
+ Specbandit.configuration.validate!
78
+
79
+ publisher = Publisher.new
80
+ files_arg = argv.empty? ? [] : argv
81
+ count = publisher.publish(files: files_arg, pattern: options[:pattern])
82
+
83
+ count.positive? ? 0 : 1
84
+ end
85
+
86
+ def run_work
87
+ parser = OptionParser.new do |opts|
88
+ opts.banner = 'Usage: specbandit work [options]'
89
+
90
+ opts.on('--key KEY', 'Redis queue key (required, or set SPECBANDIT_KEY)') do |v|
91
+ Specbandit.configuration.key = v
92
+ end
93
+
94
+ opts.on('--batch-size N', Integer, 'Number of files to steal per batch (default: 5)') do |v|
95
+ Specbandit.configuration.batch_size = v
96
+ end
97
+
98
+ opts.on('--redis-url URL', 'Redis URL (default: redis://localhost:6379)') do |v|
99
+ Specbandit.configuration.redis_url = v
100
+ end
101
+
102
+ opts.on('--rspec-opts OPTS', 'Extra options to pass to RSpec (space-separated)') do |v|
103
+ Specbandit.configuration.rspec_opts = v.split
104
+ end
105
+
106
+ opts.on('--key-rerun KEY', 'Per-runner rerun key for re-run support') do |v|
107
+ Specbandit.configuration.key_rerun = v
108
+ end
109
+
110
+ opts.on('--key-rerun-ttl SECONDS', Integer, 'TTL for rerun key in seconds (default: 604800 / 1 week)') do |v|
111
+ Specbandit.configuration.key_rerun_ttl = v
112
+ end
113
+
114
+ opts.on('-h', '--help', 'Show this help') do
115
+ puts opts
116
+ return 0
117
+ end
118
+ end
119
+
120
+ parser.parse!(argv)
121
+ Specbandit.configuration.validate!
122
+
123
+ worker = Worker.new
124
+ worker.run
125
+ end
126
+
127
+ def print_usage
128
+ puts <<~USAGE
129
+ specbandit v#{VERSION} - Distributed RSpec runner using Redis
130
+
131
+ Usage:
132
+ specbandit push [options] [files...] Enqueue spec files into Redis
133
+ specbandit work [options] Steal and run spec file batches
134
+
135
+ Push options:
136
+ --key KEY Redis queue key (required, or set SPECBANDIT_KEY)
137
+ --pattern PATTERN Glob pattern for file discovery (e.g. 'spec/**/*_spec.rb')
138
+ --redis-url URL Redis URL (default: redis://localhost:6379)
139
+ --key-ttl SECONDS TTL for the Redis key (default: 21600 / 6 hours)
140
+
141
+ Work options:
142
+ --key KEY Redis queue key (required, or set SPECBANDIT_KEY)
143
+ --batch-size N Files per batch (default: 5, or set SPECBANDIT_BATCH_SIZE)
144
+ --redis-url URL Redis URL (default: redis://localhost:6379)
145
+ --rspec-opts OPTS Extra options forwarded to RSpec
146
+ --key-rerun KEY Per-runner rerun key for re-run support
147
+ --key-rerun-ttl N TTL for rerun key (default: 604800 / 1 week)
148
+
149
+ Environment variables:
150
+ SPECBANDIT_KEY Queue key
151
+ SPECBANDIT_REDIS_URL Redis URL
152
+ SPECBANDIT_BATCH_SIZE Batch size
153
+ SPECBANDIT_KEY_TTL Key TTL in seconds (default: 21600)
154
+ SPECBANDIT_RSPEC_OPTS RSpec options
155
+ SPECBANDIT_KEY_RERUN Per-runner rerun key
156
+ SPECBANDIT_KEY_RERUN_TTL Rerun key TTL in seconds (default: 604800)
157
+
158
+ File input priority for push:
159
+ 1. stdin (piped) echo "spec/a_spec.rb" | specbandit push --key KEY
160
+ 2. --pattern specbandit push --key KEY --pattern 'spec/**/*_spec.rb'
161
+ 3. direct args specbandit push --key KEY spec/a_spec.rb spec/b_spec.rb
162
+ USAGE
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Specbandit
4
+ class Configuration
5
+ attr_accessor :redis_url, :batch_size, :key, :rspec_opts, :key_ttl,
6
+ :key_rerun, :key_rerun_ttl
7
+
8
+ DEFAULT_REDIS_URL = 'redis://localhost:6379'
9
+ DEFAULT_BATCH_SIZE = 5
10
+ DEFAULT_KEY_TTL = 21_600 # 6 hours in seconds
11
+ DEFAULT_KEY_RERUN_TTL = 604_800 # 1 week in seconds
12
+
13
+ def initialize
14
+ @redis_url = ENV.fetch('SPECBANDIT_REDIS_URL', DEFAULT_REDIS_URL)
15
+ @batch_size = Integer(ENV.fetch('SPECBANDIT_BATCH_SIZE', DEFAULT_BATCH_SIZE))
16
+ @key = ENV.fetch('SPECBANDIT_KEY', nil)
17
+ @rspec_opts = parse_rspec_opts(ENV.fetch('SPECBANDIT_RSPEC_OPTS', nil))
18
+ @key_ttl = Integer(ENV.fetch('SPECBANDIT_KEY_TTL', DEFAULT_KEY_TTL))
19
+ @key_rerun = ENV.fetch('SPECBANDIT_KEY_RERUN', nil)
20
+ @key_rerun_ttl = Integer(ENV.fetch('SPECBANDIT_KEY_RERUN_TTL', DEFAULT_KEY_RERUN_TTL))
21
+ end
22
+
23
+ def validate!
24
+ raise Error, 'key is required (set via --key or SPECBANDIT_KEY)' if key.nil? || key.empty?
25
+ raise Error, 'batch_size must be a positive integer' unless batch_size.positive?
26
+ raise Error, 'key_ttl must be a positive integer' unless key_ttl.positive?
27
+ raise Error, 'key_rerun_ttl must be a positive integer' unless key_rerun_ttl.positive?
28
+ end
29
+
30
+ private
31
+
32
+ def parse_rspec_opts(opts)
33
+ return [] if opts.nil? || opts.empty?
34
+
35
+ opts.split
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Specbandit
4
+ class Publisher
5
+ attr_reader :queue, :key, :key_ttl, :output
6
+
7
+ def initialize(key: Specbandit.configuration.key, key_ttl: Specbandit.configuration.key_ttl, queue: nil,
8
+ output: $stdout)
9
+ @key = key
10
+ @key_ttl = key_ttl
11
+ @queue = queue || RedisQueue.new
12
+ @output = output
13
+ end
14
+
15
+ # Resolve files from the three input sources (priority: stdin > pattern > args)
16
+ # and push them onto the Redis queue.
17
+ #
18
+ # Returns the number of files enqueued.
19
+ def publish(files: [], pattern: nil)
20
+ resolved = resolve_files(files: files, pattern: pattern)
21
+
22
+ if resolved.empty?
23
+ output.puts '[specbandit] No files to enqueue.'
24
+ return 0
25
+ end
26
+
27
+ queue.push(key, resolved, ttl: key_ttl)
28
+ output.puts "[specbandit] Enqueued #{resolved.size} files onto key '#{key}' (TTL: #{key_ttl}s)."
29
+ resolved.size
30
+ end
31
+
32
+ private
33
+
34
+ def resolve_files(files:, pattern:)
35
+ # Priority 1: stdin (if not a TTY)
36
+ return $stdin.each_line.map(&:strip).reject(&:empty?) unless $stdin.tty?
37
+
38
+ # Priority 2: --pattern flag (Dir.glob in Ruby, no shell expansion)
39
+ return Dir.glob(pattern).sort if pattern && !pattern.empty?
40
+
41
+ # Priority 3: direct file arguments
42
+ Array(files)
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'redis'
4
+
5
+ module Specbandit
6
+ class RedisQueue
7
+ attr_reader :redis
8
+
9
+ def initialize(redis_url: Specbandit.configuration.redis_url)
10
+ @redis = Redis.new(url: redis_url)
11
+ end
12
+
13
+ # Push file paths onto the queue and set an expiry on the key.
14
+ # Returns the new length of the list.
15
+ def push(key, files, ttl: nil)
16
+ return 0 if files.empty?
17
+
18
+ count = redis.rpush(key, files)
19
+ redis.expire(key, ttl) if ttl
20
+ count
21
+ end
22
+
23
+ # Atomically steal up to `count` file paths from the queue.
24
+ # Returns an array of file paths (empty array when exhausted).
25
+ #
26
+ # Uses LPOP with count argument (Redis 6.2+).
27
+ def steal(key, count)
28
+ result = redis.lpop(key, count)
29
+
30
+ # LPOP returns nil when the key doesn't exist or list is empty,
31
+ # and returns a single string (not array) when count is 1 on some versions.
32
+ case result
33
+ when nil then []
34
+ when String then [result]
35
+ else Array(result)
36
+ end
37
+ end
38
+
39
+ # Returns the current length of the queue.
40
+ def length(key)
41
+ redis.llen(key)
42
+ end
43
+
44
+ # Read all file paths from the list non-destructively.
45
+ # Returns an array of file paths (empty array when key doesn't exist).
46
+ def read_all(key)
47
+ redis.lrange(key, 0, -1)
48
+ end
49
+
50
+ def close
51
+ redis.close
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Specbandit
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rspec/core'
4
+
5
+ module Specbandit
6
+ class Worker
7
+ attr_reader :queue, :key, :batch_size, :rspec_opts, :key_rerun, :key_rerun_ttl, :output
8
+
9
+ def initialize(
10
+ key: Specbandit.configuration.key,
11
+ batch_size: Specbandit.configuration.batch_size,
12
+ rspec_opts: Specbandit.configuration.rspec_opts,
13
+ key_rerun: Specbandit.configuration.key_rerun,
14
+ key_rerun_ttl: Specbandit.configuration.key_rerun_ttl,
15
+ queue: nil,
16
+ output: $stdout
17
+ )
18
+ @key = key
19
+ @batch_size = batch_size
20
+ @rspec_opts = Array(rspec_opts)
21
+ @key_rerun = key_rerun
22
+ @key_rerun_ttl = key_rerun_ttl
23
+ @queue = queue || RedisQueue.new
24
+ @output = output
25
+ end
26
+
27
+ # Main entry point. Detects the operating mode and dispatches accordingly.
28
+ #
29
+ # Returns 0 if all batches passed (or nothing to do), 1 if any batch failed.
30
+ def run
31
+ if key_rerun
32
+ rerun_files = queue.read_all(key_rerun)
33
+ if rerun_files.any?
34
+ run_replay(rerun_files)
35
+ else
36
+ run_steal(record: true)
37
+ end
38
+ else
39
+ run_steal(record: false)
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ # Replay mode: run a known list of files in local batches.
46
+ # Used when re-running a failed CI job -- the rerun key already
47
+ # contains the exact files this runner executed previously.
48
+ def run_replay(files)
49
+ output.puts "[specbandit] Replay mode: found #{files.size} files in rerun key '#{key_rerun}'."
50
+ output.puts '[specbandit] Running previously recorded files (not touching shared queue).'
51
+
52
+ failed = false
53
+ batch_num = 0
54
+
55
+ files.each_slice(batch_size) do |batch|
56
+ batch_num += 1
57
+ output.puts "[specbandit] Batch ##{batch_num}: running #{batch.size} files"
58
+ batch.each { |f| output.puts " #{f}" }
59
+
60
+ exit_code = run_rspec_batch(batch)
61
+ if exit_code != 0
62
+ output.puts "[specbandit] Batch ##{batch_num} FAILED (exit code: #{exit_code})"
63
+ failed = true
64
+ else
65
+ output.puts "[specbandit] Batch ##{batch_num} passed."
66
+ end
67
+ end
68
+
69
+ output.puts "[specbandit] Replay finished: #{batch_num} batches. #{failed ? 'SOME FAILED' : 'All passed.'}"
70
+ failed ? 1 : 0
71
+ end
72
+
73
+ # Steal mode: atomically pop batches from the shared queue.
74
+ # When record is true, each stolen batch is also pushed to the
75
+ # rerun key so this runner can replay them on a re-run.
76
+ def run_steal(record:)
77
+ mode_label = record ? 'Record' : 'Steal'
78
+ output.puts "[specbandit] #{mode_label} mode: stealing batches from '#{key}'."
79
+ output.puts "[specbandit] Recording stolen files to rerun key '#{key_rerun}'." if record
80
+
81
+ failed = false
82
+ batch_num = 0
83
+
84
+ loop do
85
+ files = queue.steal(key, batch_size)
86
+
87
+ if files.empty?
88
+ output.puts '[specbandit] Queue exhausted. No more files to run.'
89
+ break
90
+ end
91
+
92
+ # Record the stolen batch so this runner can replay on re-run
93
+ queue.push(key_rerun, files, ttl: key_rerun_ttl) if record
94
+
95
+ batch_num += 1
96
+ output.puts "[specbandit] Batch ##{batch_num}: running #{files.size} files"
97
+ files.each { |f| output.puts " #{f}" }
98
+
99
+ exit_code = run_rspec_batch(files)
100
+ if exit_code != 0
101
+ output.puts "[specbandit] Batch ##{batch_num} FAILED (exit code: #{exit_code})"
102
+ failed = true
103
+ else
104
+ output.puts "[specbandit] Batch ##{batch_num} passed."
105
+ end
106
+ end
107
+
108
+ if batch_num.zero?
109
+ output.puts '[specbandit] Nothing to do (queue was empty).'
110
+ else
111
+ output.puts "[specbandit] Finished #{batch_num} batches. #{failed ? 'SOME FAILED' : 'All passed.'}"
112
+ end
113
+
114
+ failed ? 1 : 0
115
+ end
116
+
117
+ def run_rspec_batch(files)
118
+ # Clear example state from previous batches so RSpec can run cleanly
119
+ # in the same process. This preserves configuration but resets
120
+ # the world (example groups, examples, shared groups, etc.).
121
+ RSpec.clear_examples
122
+
123
+ args = files + rspec_opts
124
+ err = StringIO.new
125
+ out = StringIO.new
126
+
127
+ RSpec::Core::Runner.run(args, err, out)
128
+ ensure
129
+ # Print RSpec output through our output stream
130
+ rspec_output = out&.string
131
+ output.print(rspec_output) unless rspec_output.nil? || rspec_output.empty?
132
+
133
+ rspec_err = err&.string
134
+ output.print(rspec_err) unless rspec_err.nil? || rspec_err.empty?
135
+ end
136
+ end
137
+ end
data/lib/specbandit.rb ADDED
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'specbandit/version'
4
+ require 'specbandit/configuration'
5
+ require 'specbandit/redis_queue'
6
+ require 'specbandit/publisher'
7
+ require 'specbandit/worker'
8
+
9
+ module Specbandit
10
+ class Error < StandardError; end
11
+
12
+ class << self
13
+ def configuration
14
+ @configuration ||= Configuration.new
15
+ end
16
+
17
+ def configure
18
+ yield(configuration)
19
+ end
20
+
21
+ def reset_configuration!
22
+ @configuration = Configuration.new
23
+ end
24
+ end
25
+ end
metadata ADDED
@@ -0,0 +1,116 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: specbandit
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ferran Basora
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-04-08 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: redis
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '4.6'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '6'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '4.6'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '6'
33
+ - !ruby/object:Gem::Dependency
34
+ name: rake
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '13.0'
40
+ type: :development
41
+ prerelease: false
42
+ version_requirements: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '13.0'
47
+ - !ruby/object:Gem::Dependency
48
+ name: rspec
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '3.0'
54
+ type: :development
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '3.0'
61
+ - !ruby/object:Gem::Dependency
62
+ name: rspec-core
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '3.0'
68
+ type: :development
69
+ prerelease: false
70
+ version_requirements: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '3.0'
75
+ description: A work-stealing distributed RSpec runner. Push spec file paths to a Redis
76
+ list, then multiple CI runners atomically steal batches and execute them in-process
77
+ via RSpec::Core::Runner.
78
+ email:
79
+ executables:
80
+ - specbandit
81
+ extensions: []
82
+ extra_rdoc_files: []
83
+ files:
84
+ - README.md
85
+ - bin/specbandit
86
+ - lib/specbandit.rb
87
+ - lib/specbandit/cli.rb
88
+ - lib/specbandit/configuration.rb
89
+ - lib/specbandit/publisher.rb
90
+ - lib/specbandit/redis_queue.rb
91
+ - lib/specbandit/version.rb
92
+ - lib/specbandit/worker.rb
93
+ homepage: https://github.com/factorialco/specbandit
94
+ licenses:
95
+ - MIT
96
+ metadata: {}
97
+ post_install_message:
98
+ rdoc_options: []
99
+ require_paths:
100
+ - lib
101
+ required_ruby_version: !ruby/object:Gem::Requirement
102
+ requirements:
103
+ - - ">="
104
+ - !ruby/object:Gem::Version
105
+ version: '3.0'
106
+ required_rubygems_version: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ requirements: []
112
+ rubygems_version: 3.5.16
113
+ signing_key:
114
+ specification_version: 4
115
+ summary: Distributed RSpec runner using Redis as a work queue
116
+ test_files: []