specbandit 1.1.1 → 1.3.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: 70efc56f9de13cd1e141cbb1ad39ff22cf1a07cdfbcd2c1257cefc55f5cb73fe
4
- data.tar.gz: 6070e8b72aec64162a3e37190e787abbd23bf464faf74d06212e401c7c312fed
3
+ metadata.gz: d9934a98dc6ad0d278306da5cb4fdc818f56dceee9118e8581b49393f9d26150
4
+ data.tar.gz: a783e737d7d478397c689e82952c8c6c67e0fbab71c4ddc3ccb6ea34fee2e8b6
5
5
  SHA512:
6
- metadata.gz: c7fc604a65cba2387b1177fef33ad2994e13677777d6d87b53f68aa29a00616c864dc874b5571947c4e837d0c3e25b24cc4d99d0841c94b79433258e8f5faba7
7
- data.tar.gz: 7c6acd6833e1f082e2c764de7d32ab67407726d6905cc6246cbbe0921410f2ad3db4a503ee5932240414ebf413538830db54e8275adb4cd6c1f3d9bc71a195e4
6
+ metadata.gz: 5ab66609668cfd0d41cab6c243d94268da69f4659f9640f2391707dc74381e3566c459a6646010bc7c2197c7552c3818e78d4b066ba7c9f5e7727da2e0ffe50d
7
+ data.tar.gz: f352d8beb6637eabd22b3e11c0d2c62bce44498ba5da20bdd050785b4588913ecdbb9dfe53c799471bd7b75b9e74722e1efb6f4aeb89e6f9779beb4cbb149f35
data/README.md CHANGED
@@ -109,6 +109,14 @@ specbandit push --key pr-123-run-456 spec/models/user_spec.rb spec/models/order_
109
109
 
110
110
  File input priority: **stdin > --pattern > direct args**.
111
111
 
112
+ Add `--reset` when the producer can run more than once for the same key:
113
+
114
+ ```bash
115
+ specbandit push --key pr-123-run-456 --reset --pattern 'spec/**/*_spec.rb'
116
+ ```
117
+
118
+ See [Repeated pushes: `--reset`](#repeated-pushes---reset).
119
+
112
120
  ### 2. Steal and run from multiple workers
113
121
 
114
122
  Each CI runner steals batches and runs them. Start as many runners as you want -- they'll divide the work automatically.
@@ -137,6 +145,11 @@ specbandit push [options] [files...]
137
145
  --pattern PATTERN Glob pattern for file discovery
138
146
  --redis-url URL Redis URL (default: redis://localhost:6379)
139
147
  --key-ttl SECONDS TTL for all Redis keys (default: 604800 / 1 week)
148
+ --reset Empty the key before pushing (see below)
149
+
150
+ specbandit reset [options]
151
+ --key KEY Redis queue key (required)
152
+ --redis-url URL Redis URL (default: redis://localhost:6379)
140
153
 
141
154
  specbandit work [options] [-- extra-opts...]
142
155
  --key KEY Redis queue key (required)
@@ -319,7 +332,7 @@ The full decision table:
319
332
  | Yes | Drained / empty | Empty | **OK, exit 0.** Worker arriving late -- everything was already taken by peers and this runner has no re-run memory. |
320
333
  | Yes | Has data | Empty | **Steal.** Classic run: pop batches from the shared queue (and record them to the rerun key if one is configured). |
321
334
  | Yes | Drained / empty | Has data | **Replay.** Classic re-run: ignore the shared queue and re-run exactly the recorded files. |
322
- | Yes | Has data | Has data | **Crash** (exit 1). Inconsistent state -- refuses to run to avoid double-executing. |
335
+ | Yes | Has data | Has data | **Full rerun.** The queue was re-pushed while this runner still holds rerun memory from a previous run. The stale rerun key is deleted and the runner steals from the shared queue like a classic run, re-recording as it goes. |
323
336
 
324
337
  > **Empty key names count as "not provided".** A `--key-rerun` whose value is an empty string -- e.g. `--key-rerun "$VAR"` where `$VAR` is unset in CI -- is treated exactly like `--key-rerun` being absent. The same applies to `--key-failed`: an empty/unset name is treated as "not configured" and no failed files are recorded.
325
338
 
@@ -351,7 +364,7 @@ The full decision table:
351
364
 
352
365
  Key details:
353
366
 
354
- - **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.
367
+ - **Replay reads non-destructively** (`LRANGE`, not `LPOP`). Replay never consumes the rerun key, so re-running the same runner multiple times replays the same files every time. The one case where the rerun key is deleted (`DEL`) is a **full rerun** -- the shared queue has data again while the rerun key still holds files from a previous run -- where the stale memory is reset and rebuilt from the newly stolen batches.
355
368
  - **The shared queue is never touched in replay mode**. Other runners are unaffected.
356
369
  - **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.
357
370
 
@@ -423,12 +436,42 @@ Override via `--key-ttl` or `SPECBANDIT_KEY_TTL` (set it on `push`, which is whe
423
436
  specbandit push --key "pr-42-run-100" --key-ttl 259200 --pattern 'spec/**/*_spec.rb'
424
437
  ```
425
438
 
439
+ ## Repeated pushes: `--reset`
440
+
441
+ The queue key is scoped by CI run, not by CI attempt. It has to be: re-running a single failed runner does not re-run the job that pushed, so that runner must still find the queue and the published marker the first attempt created.
442
+
443
+ The cost is that the producer is not idempotent. A producer that pushes and then fails, or that is re-run as part of the whole workflow, appends a second copy of the work list to the same key. Every file is then enqueued twice, so the suite runs twice, and any two copies that reach the same worker are loaded twice in one process. For test files that define constants at file scope, the second load is fatal.
444
+
445
+ `--reset` makes the push idempotent:
446
+
447
+ ```bash
448
+ specbandit push --key "pr-42-run-100" --reset --pattern 'spec/**/*_spec.rb'
449
+ ```
450
+
451
+ The queue and its `<key>:published` marker are deleted, then the work list is pushed, so the key holds exactly one copy however many times the producer runs. The leftover count is logged, and a non-zero one tells you an earlier attempt pushed a list nobody consumed:
452
+
453
+ ```
454
+ [specbandit] Reset key 'pr-42-run-100': discarded 4213 queued files from a previous push.
455
+ ```
456
+
457
+ There is also a standalone command, for callers that clean up separately from the push:
458
+
459
+ ```bash
460
+ specbandit reset --key "pr-42-run-100"
461
+ ```
462
+
463
+ Both leave the per-runner rerun keys and the failed keys alone, so a single-runner re-run can still replay its own files. A runner that finds data in both the shared queue and its rerun key is the **full rerun** case in the table above, and it resets its own memory.
464
+
465
+ A reset with nothing to reset is not an error. Neither command clears the key when there is nothing to push in its place: dropping the marker on its own would make every worker on that key crash as "never published".
466
+
426
467
  ## How it works
427
468
 
428
469
  - **Push** uses `RPUSH` to append all file paths to a Redis list in a single command, sets `EXPIRE` on the key, and writes a durable `<key>:published` marker (with the same TTL). Empty Redis lists are auto-deleted, so this marker is what lets `work` tell "never pushed" (crash) apart from "drained, arriving late" (exit 0).
429
470
  - **Steal** uses `LPOP key count` (Redis 6.2+), which atomically pops up to N elements. No Lua scripts, no locks, no race conditions.
430
471
  - **Record** (when `--key-rerun` is set): after each steal, the batch is also `RPUSH`ed to the per-runner rerun key.
431
472
  - **Replay** (when the rerun key has data and the shared queue is drained): reads all files from the rerun key via `LRANGE` (non-destructive), splits into batches, and runs them locally. The shared queue is never touched.
473
+ - **Full rerun** (when both the shared queue and the rerun key have data): the stale rerun key is removed with `DEL`, then the runner steals from the shared queue and re-records as in a classic run.
474
+ - **Reset** (`push --reset` or `specbandit reset`) removes the queue and its `<key>:published` marker in a single `DEL`, so a producer that runs twice cannot enqueue the work list twice. Rerun and failed keys are untouched.
432
475
  - **Mode selection** is derived entirely from Redis (published marker + queue/rerun state) -- see the decision table above. There is no re-run flag or environment variable.
433
476
  - **Run** delegates to the configured adapter:
434
477
  - **CLI adapter**: spawns a shell command per batch via `Open3`, appending file paths as arguments. Works with any test runner.
@@ -4,7 +4,7 @@ require 'optparse'
4
4
 
5
5
  module Specbandit
6
6
  class CLI
7
- COMMANDS = %w[push work].freeze
7
+ COMMANDS = %w[push work reset].freeze
8
8
 
9
9
  def self.run(argv = ARGV)
10
10
  new(argv).execute
@@ -24,6 +24,8 @@ module Specbandit
24
24
  run_push
25
25
  when 'work'
26
26
  run_work
27
+ when 'reset'
28
+ run_reset
27
29
  when nil, '-h', '--help'
28
30
  print_usage
29
31
  0
@@ -46,7 +48,7 @@ module Specbandit
46
48
  private
47
49
 
48
50
  def run_push
49
- options = { pattern: nil }
51
+ options = { pattern: nil, reset: false }
50
52
 
51
53
  parser = OptionParser.new do |opts|
52
54
  opts.banner = 'Usage: specbandit push [options] [files...]'
@@ -67,6 +69,10 @@ module Specbandit
67
69
  Specbandit.configuration.key_ttl = v
68
70
  end
69
71
 
72
+ opts.on('--reset', 'Empty the key before pushing, so a re-run cannot enqueue a second copy') do
73
+ options[:reset] = true
74
+ end
75
+
70
76
  opts.on('-h', '--help', 'Show this help') do
71
77
  puts opts
72
78
  return 0
@@ -78,11 +84,51 @@ module Specbandit
78
84
 
79
85
  publisher = Publisher.new
80
86
  files_arg = argv.empty? ? [] : argv
81
- count = publisher.publish(files: files_arg, pattern: options[:pattern])
87
+ count = publisher.publish(files: files_arg, pattern: options[:pattern], reset: options[:reset])
82
88
 
83
89
  count.positive? ? 0 : 1
84
90
  end
85
91
 
92
+ # Empty a queue key and its published marker, leaving it as if nothing had
93
+ # ever been pushed. Standalone counterpart to `push --reset`, for callers
94
+ # that clean up separately from the push.
95
+ def run_reset
96
+ parser = OptionParser.new do |opts|
97
+ opts.banner = 'Usage: specbandit reset [options]'
98
+
99
+ opts.on('--key KEY', 'Redis queue key (required, or set SPECBANDIT_KEY)') do |v|
100
+ Specbandit.configuration.key = v
101
+ end
102
+
103
+ opts.on('--redis-url URL', 'Redis URL (default: redis://localhost:6379)') do |v|
104
+ Specbandit.configuration.redis_url = v
105
+ end
106
+
107
+ opts.on('-h', '--help', 'Show this help') do
108
+ puts opts
109
+ return 0
110
+ end
111
+ end
112
+
113
+ parser.parse!(argv)
114
+ Specbandit.configuration.validate!
115
+
116
+ key = Specbandit.configuration.key
117
+ queue = RedisQueue.new
118
+
119
+ begin
120
+ stale = queue.length(key)
121
+ queue.clear(key)
122
+ puts "[specbandit] Reset key '#{key}': discarded #{stale} queued files."
123
+ ensure
124
+ queue.close
125
+ end
126
+
127
+ # Removing nothing is a valid outcome: the caller asked for an empty key
128
+ # and got one.
129
+ 0
130
+ end
131
+
86
132
  def run_work
87
133
  parser = OptionParser.new do |opts|
88
134
  opts.banner = 'Usage: specbandit work [options] [-- extra-opts...]'
@@ -200,12 +246,22 @@ module Specbandit
200
246
  Usage:
201
247
  specbandit push [options] [files...] Enqueue test files into Redis
202
248
  specbandit work [options] [-- extra-opts...] Steal and run test file batches
249
+ specbandit reset [options] Empty a queue key and its published marker
203
250
 
204
251
  Push options:
205
252
  --key KEY Redis queue key (required, or set SPECBANDIT_KEY)
206
253
  --pattern PATTERN Glob pattern for file discovery (e.g. 'spec/**/*_spec.rb')
207
254
  --redis-url URL Redis URL (default: redis://localhost:6379)
208
255
  --key-ttl SECONDS TTL for all Redis keys (default: 604800 / 1 week)
256
+ --reset Empty the key before pushing (see Reset options)
257
+
258
+ Reset options:
259
+ --key KEY Redis queue key (required, or set SPECBANDIT_KEY)
260
+ --redis-url URL Redis URL (default: redis://localhost:6379)
261
+
262
+ Deletes the queue list and its ':published' marker, leaving the key as if
263
+ nothing had ever been pushed. Per-runner rerun and failed keys are left
264
+ alone, so a re-run of a single shard can still replay its own files.
209
265
 
210
266
  Work options:
211
267
  --key KEY Redis queue key (required, or set SPECBANDIT_KEY)
@@ -17,8 +17,11 @@ module Specbandit
17
17
  # Resolve files from the three input sources (priority: stdin > pattern > args)
18
18
  # and push them onto the Redis queue.
19
19
  #
20
+ # With `reset: true` the key is emptied first, so the queue ends up holding
21
+ # exactly this work list even if an earlier attempt already pushed one.
22
+ #
20
23
  # Returns the number of files enqueued.
21
- def publish(files: [], pattern: nil)
24
+ def publish(files: [], pattern: nil, reset: false)
22
25
  resolved = resolve_files(files: files, pattern: pattern)
23
26
 
24
27
  if resolved.empty?
@@ -26,6 +29,11 @@ module Specbandit
26
29
  return 0
27
30
  end
28
31
 
32
+ # Only reset once there is something to put in its place. Clearing on an
33
+ # empty push would drop the marker too and leave workers crashing on a
34
+ # key that looks like it was never published.
35
+ reset_key if reset
36
+
29
37
  push_ms = measure { queue.push(key, resolved, ttl: key_ttl) }
30
38
  # Record a durable "published" marker so workers can tell a drained
31
39
  # queue ("worker arriving late", OK) apart from one that was never
@@ -40,6 +48,20 @@ module Specbandit
40
48
 
41
49
  private
42
50
 
51
+ # Empty the key before pushing. The leftover count is reported because a
52
+ # non-zero one means an earlier attempt pushed a list that no worker ever
53
+ # consumed, which is worth seeing in the producer's log.
54
+ def reset_key
55
+ stale = queue.length(key)
56
+ queue.clear(key)
57
+
58
+ if stale.positive?
59
+ output.puts "[specbandit] Reset key '#{key}': discarded #{stale} queued files from a previous push."
60
+ else
61
+ output.puts "[specbandit] Reset key '#{key}': nothing left over."
62
+ end
63
+ end
64
+
43
65
  # Run the block and return the wall-clock time it took, in milliseconds.
44
66
  # Used to surface how long the Redis round-trips took in the push log.
45
67
  def measure
@@ -88,6 +88,23 @@ module Specbandit
88
88
  with_retries { redis.lrange(key, 0, -1) }
89
89
  end
90
90
 
91
+ # Remove a key entirely. Used to discard stale rerun memory when a
92
+ # full rerun starts over from the shared queue.
93
+ def delete(key)
94
+ with_retries { redis.del(key) }
95
+ end
96
+
97
+ # Remove a queue and its published marker together, so the key is back to
98
+ # the state it had before anything was ever pushed to it. A producer calls
99
+ # this before pushing: the queue key is not scoped by run attempt, so a
100
+ # second attempt would otherwise stack another copy of the work list on
101
+ # top of whatever the first attempt left behind.
102
+ #
103
+ # Returns the number of keys removed (0, 1 or 2).
104
+ def clear(key)
105
+ with_retries { redis.del(key, published_marker(key)) }
106
+ end
107
+
91
108
  def close
92
109
  redis.close
93
110
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Specbandit
4
- VERSION = '1.1.1'
4
+ VERSION = '1.3.0'
5
5
  end
@@ -76,7 +76,7 @@ module Specbandit
76
76
  # Yes | empty/drained | empty | OK: worker arriving late (0)
77
77
  # Yes | has data | empty | Steal (record if rerun key set)
78
78
  # Yes | empty/drained | has data | Replay recorded files
79
- # Yes | has data | has data | Crash: inconsistent (weird case)
79
+ # Yes | has data | has data | Full rerun: reset rerun key, steal
80
80
  #
81
81
  # "Published" is a durable marker written by `specbandit push`; it is the
82
82
  # only reliable signal that work was ever enqueued, because Redis
@@ -89,7 +89,7 @@ module Specbandit
89
89
  rerun_files = key_present?(key_rerun) ? queue.read_all(key_rerun) : []
90
90
 
91
91
  if key_has_data && rerun_files.any?
92
- fail_inconsistent_state
92
+ run_full_rerun
93
93
  elsif rerun_files.any?
94
94
  run_replay(rerun_files)
95
95
  elsif key_has_data
@@ -119,14 +119,16 @@ module Specbandit
119
119
  1
120
120
  end
121
121
 
122
- # Both the shared queue and this runner's rerun key hold files at once.
123
- # That should never happen: a fresh run has no rerun memory yet, and a
124
- # re-run reads from a drained queue. Crash instead of double-executing.
125
- def fail_inconsistent_state
126
- output.puts "[specbandit] ERROR: inconsistent state — shared queue '#{key}' still has files " \
127
- "while rerun key '#{key_rerun}' also has recorded files."
128
- output.puts '[specbandit] Refusing to run to avoid double-execution / undefined behavior.'
129
- 1
122
+ # Both the shared queue and this runner's rerun key hold files: the queue
123
+ # was re-pushed while this runner still carries rerun memory from a
124
+ # previous run (a full rerun). The stored memory is stale -- discard it
125
+ # and steal from the shared queue like a classic run, re-recording each
126
+ # stolen batch as we go.
127
+ def run_full_rerun
128
+ output.puts "[specbandit] Shared queue '#{key}' and rerun key '#{key_rerun}' both have files. " \
129
+ "Full rerun: resetting '#{key_rerun}' and working from '#{key}'."
130
+ queue.delete(key_rerun)
131
+ run_steal(record: true)
130
132
  end
131
133
 
132
134
  # Replay mode: run a known list of files in local batches.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: specbandit
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.1
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ferran Basora