specbandit 1.2.0 → 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: 24148d640548adc9f3bd6714ad39f4bfd380eeaf8e904f0fffd4b777a9b13d4c
4
- data.tar.gz: '082673e08f9b40e9c8babae73f658d91b7627a40002b1e326d62fb49c5242d4f'
3
+ metadata.gz: d9934a98dc6ad0d278306da5cb4fdc818f56dceee9118e8581b49393f9d26150
4
+ data.tar.gz: a783e737d7d478397c689e82952c8c6c67e0fbab71c4ddc3ccb6ea34fee2e8b6
5
5
  SHA512:
6
- metadata.gz: 06ced4b5b9530b9f836d90a0e05a663acf88d16f008c6f181acc19a1711ee6260465f42b7e27c76d3e16ec8d28ae621bfd4a614d2ec2aae26312927cb40fd3fb
7
- data.tar.gz: 6f1578c3db2a446bce8677d16020014e4fa1c30e2483789f41311c1d18ba75567875a65728fca9f48136951b722b91631d674e995d2a66b1ebe26b99b910e128
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)
@@ -423,6 +436,34 @@ 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).
@@ -430,6 +471,7 @@ specbandit push --key "pr-42-run-100" --key-ttl 259200 --pattern 'spec/**/*_spec
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.
432
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.
433
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.
434
476
  - **Run** delegates to the configured adapter:
435
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
@@ -94,6 +94,17 @@ module Specbandit
94
94
  with_retries { redis.del(key) }
95
95
  end
96
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
+
97
108
  def close
98
109
  redis.close
99
110
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Specbandit
4
- VERSION = '1.2.0'
4
+ VERSION = '1.3.0'
5
5
  end
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.2.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ferran Basora