specbandit 1.0.1 → 1.2.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: 9444599b748d46d177475926a3b5f3740a6534c54a0e540c7663ee4eb4208d40
4
- data.tar.gz: f7e5787ee93b270c2ba121d7edae8a2ccac5881f1bc873d65e5afcb481958e35
3
+ metadata.gz: 24148d640548adc9f3bd6714ad39f4bfd380eeaf8e904f0fffd4b777a9b13d4c
4
+ data.tar.gz: '082673e08f9b40e9c8babae73f658d91b7627a40002b1e326d62fb49c5242d4f'
5
5
  SHA512:
6
- metadata.gz: 147b0eb446363ece19f086bbdedc375f6f6a15afc8d128628cdb64881396bd7c0ad01b8fc04b2af72b8db47aa89d83af0b48c86772ea57615582e412ec80a0d6
7
- data.tar.gz: ae4357cd2cfcb1a12f539c5a7f43dacfa838a5810540ca41270c8e8fb1efb672189d898d0b4182106bb2719308b0f0ecf74495aade0f904f25d7253c48c96ed9
6
+ metadata.gz: 06ced4b5b9530b9f836d90a0e05a663acf88d16f008c6f181acc19a1711ee6260465f42b7e27c76d3e16ec8d28ae621bfd4a614d2ec2aae26312927cb40fd3fb
7
+ data.tar.gz: 6f1578c3db2a446bce8677d16020014e4fa1c30e2483789f41311c1d18ba75567875a65728fca9f48136951b722b91631d674e995d2a66b1ebe26b99b910e128
data/README.md CHANGED
@@ -319,7 +319,7 @@ The full decision table:
319
319
  | 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
320
  | 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
321
  | 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. |
322
+ | 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
323
 
324
324
  > **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
325
 
@@ -351,7 +351,7 @@ The full decision table:
351
351
 
352
352
  Key details:
353
353
 
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.
354
+ - **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
355
  - **The shared queue is never touched in replay mode**. Other runners are unaffected.
356
356
  - **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
357
 
@@ -429,6 +429,7 @@ specbandit push --key "pr-42-run-100" --key-ttl 259200 --pattern 'spec/**/*_spec
429
429
  - **Steal** uses `LPOP key count` (Redis 6.2+), which atomically pops up to N elements. No Lua scripts, no locks, no race conditions.
430
430
  - **Record** (when `--key-rerun` is set): after each steal, the batch is also `RPUSH`ed to the per-runner rerun key.
431
431
  - **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
+ - **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.
432
433
  - **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
434
  - **Run** delegates to the configured adapter:
434
435
  - **CLI adapter**: spawns a shell command per batch via `Open3`, appending file paths as arguments. Works with any test runner.
@@ -131,6 +131,10 @@ module Specbandit
131
131
  Specbandit.configuration.report = v
132
132
  end
133
133
 
134
+ opts.on('--redis-max-attempts N', Integer, 'Redis connection retry attempts (default: 5)') do |v|
135
+ Specbandit.configuration.redis_max_attempts = v
136
+ end
137
+
134
138
  opts.on('--verbose', 'Show per-batch file list and full command output (default: quiet)') do
135
139
  Specbandit.configuration.verbose = true
136
140
  end
@@ -5,7 +5,9 @@ module Specbandit
5
5
  attr_accessor :redis_url, :batch_size, :key, :rspec_opts, :key_ttl,
6
6
  :key_rerun, :verbose,
7
7
  :adapter, :command, :command_opts,
8
- :key_failed, :report
8
+ :key_failed, :report,
9
+ :redis_max_attempts, :redis_connect_timeout, :redis_timeout,
10
+ :redis_reconnect_attempts
9
11
 
10
12
  DEFAULT_REDIS_URL = 'redis://localhost:6379'
11
13
  DEFAULT_BATCH_SIZE = 5
@@ -16,6 +18,17 @@ module Specbandit
16
18
  DEFAULT_KEY_TTL = 604_800 # 1 week in seconds
17
19
  DEFAULT_ADAPTER = 'cli'
18
20
 
21
+ # Redis connection resilience. Redis is a best-effort coordination store for
22
+ # a distributed test run, and CI runners can sit a WAN hop away from it
23
+ # (e.g. cross-datacenter mesh), so a transient blip must not red the build.
24
+ # We retry a handful of times with capped exponential backoff, and give the
25
+ # underlying client explicit connect/read/write timeouts + reconnects rather
26
+ # than relying on library defaults.
27
+ DEFAULT_REDIS_MAX_ATTEMPTS = 5
28
+ DEFAULT_REDIS_CONNECT_TIMEOUT = 3.0
29
+ DEFAULT_REDIS_TIMEOUT = 5.0
30
+ DEFAULT_REDIS_RECONNECT_ATTEMPTS = 3
31
+
19
32
  def initialize
20
33
  @redis_url = ENV.fetch('SPECBANDIT_REDIS_URL', DEFAULT_REDIS_URL)
21
34
  @batch_size = Integer(ENV.fetch('SPECBANDIT_BATCH_SIZE', DEFAULT_BATCH_SIZE))
@@ -29,12 +42,18 @@ module Specbandit
29
42
  @command_opts = parse_space_separated(ENV.fetch('SPECBANDIT_COMMAND_OPTS', nil))
30
43
  @key_failed = ENV.fetch('SPECBANDIT_KEY_FAILED', nil)
31
44
  @report = ENV.fetch('SPECBANDIT_REPORT', nil)
45
+
46
+ @redis_max_attempts = Integer(ENV.fetch('SPECBANDIT_REDIS_MAX_ATTEMPTS', DEFAULT_REDIS_MAX_ATTEMPTS))
47
+ @redis_connect_timeout = Float(ENV.fetch('SPECBANDIT_REDIS_CONNECT_TIMEOUT', DEFAULT_REDIS_CONNECT_TIMEOUT))
48
+ @redis_timeout = Float(ENV.fetch('SPECBANDIT_REDIS_TIMEOUT', DEFAULT_REDIS_TIMEOUT))
49
+ @redis_reconnect_attempts = Integer(ENV.fetch('SPECBANDIT_REDIS_RECONNECT_ATTEMPTS', DEFAULT_REDIS_RECONNECT_ATTEMPTS))
32
50
  end
33
51
 
34
52
  def validate!
35
53
  raise Error, 'key is required (set via --key or SPECBANDIT_KEY)' if key.nil? || key.empty?
36
54
  raise Error, 'batch_size must be a positive integer' unless batch_size.positive?
37
55
  raise Error, 'key_ttl must be a positive integer' unless key_ttl.positive?
56
+ raise Error, 'redis_max_attempts must be a positive integer' unless redis_max_attempts.positive?
38
57
  end
39
58
 
40
59
  private
@@ -6,12 +6,28 @@ module Specbandit
6
6
  class RedisQueue
7
7
  attr_reader :redis
8
8
 
9
- def initialize(redis_url: Specbandit.configuration.redis_url)
10
- @redis = Redis.new(url: redis_url)
9
+ # Cap the exponential backoff so a real outage degrades/fails within a
10
+ # bounded window instead of sleeping for minutes on the last attempts.
11
+ MAX_BACKOFF_SECONDS = 10
12
+
13
+ def initialize(
14
+ redis_url: Specbandit.configuration.redis_url,
15
+ connect_timeout: Specbandit.configuration.redis_connect_timeout,
16
+ read_timeout: Specbandit.configuration.redis_timeout,
17
+ write_timeout: Specbandit.configuration.redis_timeout,
18
+ reconnect_attempts: Specbandit.configuration.redis_reconnect_attempts,
19
+ max_attempts: Specbandit.configuration.redis_max_attempts
20
+ )
21
+ @max_attempts = max_attempts
22
+ @redis = Redis.new(
23
+ url: redis_url,
24
+ connect_timeout: connect_timeout,
25
+ read_timeout: read_timeout,
26
+ write_timeout: write_timeout,
27
+ reconnect_attempts: reconnect_attempts
28
+ )
11
29
  end
12
30
 
13
- MAX_ATTEMPTS = 3
14
-
15
31
  # Push file paths onto the queue and set an expiry on the key.
16
32
  # Returns the new length of the list.
17
33
  def push(key, files, ttl: nil)
@@ -72,6 +88,12 @@ module Specbandit
72
88
  with_retries { redis.lrange(key, 0, -1) }
73
89
  end
74
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
+
75
97
  def close
76
98
  redis.close
77
99
  end
@@ -83,7 +105,7 @@ module Specbandit
83
105
  "#{key}:published"
84
106
  end
85
107
 
86
- def with_retries(attempts: MAX_ATTEMPTS)
108
+ def with_retries(attempts: @max_attempts)
87
109
  retries = 0
88
110
  begin
89
111
  yield
@@ -91,7 +113,7 @@ module Specbandit
91
113
  retries += 1
92
114
  raise if retries >= attempts
93
115
 
94
- delay = 2**retries
116
+ delay = [2**retries, MAX_BACKOFF_SECONDS].min
95
117
  warn "[specbandit] Redis connection failed (attempt #{retries}/#{attempts}): #{e.message}. Retrying in #{delay}s..."
96
118
  sleep(delay)
97
119
  retry
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Specbandit
4
- VERSION = '1.0.1'
4
+ VERSION = '1.2.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.0.1
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ferran Basora
@@ -109,7 +109,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
109
109
  - !ruby/object:Gem::Version
110
110
  version: '0'
111
111
  requirements: []
112
- rubygems_version: 4.0.10
112
+ rubygems_version: 4.0.16
113
113
  specification_version: 4
114
114
  summary: Distributed test runner using Redis as a work queue
115
115
  test_files: []