specwrk 0.19.2 → 0.19.4

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: befb2589fe879bbe901360402374acb906359ff133c877a9169bcb0f928d1833
4
- data.tar.gz: 30c49a123a026d5a4af2da4fadf01cfc92637b095328c83c660d5a3ee7c26d43
3
+ metadata.gz: ed4bee81f0d4eef75556d4987bfa36101083bd1ee124770857f76ea2827ad036
4
+ data.tar.gz: e33ddadc340adf10fc4d66bd36598ad3322b27e5cb37dd9e0f32c0d74c65c305
5
5
  SHA512:
6
- metadata.gz: 9e997305868b8cca5ce07ea2285462ed26b01079ccebe09617749ae76c42380787b68ed693af0df4aa6cf1e196b941c5449ae7034cf21614b16650c73de4fc6b
7
- data.tar.gz: 21bb6a0af32806ed771f0e49ac0295e87ac6f57afb6259b5d74e09971adc5be23bdb1a5bcfb6762578f3f85b0387478d23767f1068e1fc53dd0dd2a63e84bbd3
6
+ metadata.gz: 2ce4eeb7ca55eae597425b10eb3a1236ae8b04fa6bae13f3564d761a88eb138671c1ffae85cf89f7944fb64c463f9ff96b261e9b650df3dbf34675237d91df7d
7
+ data.tar.gz: 81b3f08f0870b9d7af23476ad2282793e2891c42fa110d18e5f5ae1b567c843eaa945b125fc16d4af72a1b124f5f3e8d5bd214bd8e7804222f6f7c6a1d613649
data/CHANGELOG.md CHANGED
@@ -1,7 +1,23 @@
1
1
  # Changelog
2
2
 
3
3
  ## Unreleased
4
- [Compare](https://github.com/danielwestendorf/specwrk/compare/v0.19.1...main)
4
+ [Compare](https://github.com/danielwestendorf/specwrk/compare/v0.19.4...main)
5
+
6
+ ## v0.19.4 — 2026-08-23
7
+ [Compare](https://github.com/danielwestendorf/specwrk/compare/v0.19.3...v0.19.4)
8
+ - Return HTTP 423 on lock contention and retry clients with jitter — [#189](https://github.com/danielwestendorf/specwrk/pull/189) by [@danielwestendorf](https://github.com/danielwestendorf)
9
+ - Document the gem and Docker release process — [#190](https://github.com/danielwestendorf/specwrk/pull/190) by [@danielwestendorf](https://github.com/danielwestendorf)
10
+
11
+ ## v0.19.3 — 2026-07-26
12
+ [Compare](https://github.com/danielwestendorf/specwrk/compare/v0.19.2...v0.19.3)
13
+ - Retry transient file store read descriptor errors — [@danielwestendorf](https://github.com/danielwestendorf)
14
+ - Complete omitted worker processing examples as failures — [@danielwestendorf](https://github.com/danielwestendorf), [@benjaminwood](https://github.com/benjaminwood)
15
+
16
+
17
+ ## v0.19.2 — 2025-12-09
18
+ [Compare](https://github.com/danielwestendorf/specwrk/compare/v0.19.1...v0.19.2)
19
+ - Optimize msgpack usage by using one packer/unpacker — [#180](https://github.com/danielwestendorf/specwrk/pull/180) by [@danielwestendorf](https://github.com/danielwestendorf)
20
+ - Standard style to v3.4 — [#179](https://github.com/danielwestendorf/specwrk/pull/179) by [@danielwestendorf](https://github.com/danielwestendorf)
5
21
  - Add msgpack to the docker server — [#178](https://github.com/danielwestendorf/specwrk/pull/178) by [@danielwestendorf](https://github.com/danielwestendorf)
6
22
 
7
23
  ## v0.19.1 — 2025-12-08
data/README.md CHANGED
@@ -292,6 +292,8 @@ specwrk is different because it:
292
292
 
293
293
  Bug reports and pull requests are welcome on GitHub at https://github.com/danielwestendorf/specwrk.
294
294
 
295
+ Maintainers can follow [RELEASING.md](RELEASING.md) to publish the gem and Docker image.
296
+
295
297
  ## License
296
298
 
297
299
  The gem is available as open source under the terms of the [LGPLv3 License](http://www.gnu.org/licenses/lgpl-3.0.html).
data/RELEASING.md ADDED
@@ -0,0 +1,126 @@
1
+ # Releasing Specwrk
2
+
3
+ Releases are cut from `main` in two stages:
4
+
5
+ 1. `gem bump` updates the version and creates the release commit.
6
+ 2. Bundler's `rake release` task tags the commit, pushes Git, and publishes the gem.
7
+
8
+ The Docker image is published separately after the gem release.
9
+
10
+ ## One-time setup
11
+
12
+ Install `gem-release`, which provides the `gem bump` command. It is intentionally
13
+ not a development dependency because it is only needed by maintainers cutting a
14
+ release.
15
+
16
+ ```sh
17
+ gem install gem-release
18
+ ```
19
+
20
+ Configure credentials for RubyGems and Docker Hub:
21
+
22
+ ```sh
23
+ gem signin
24
+ docker login docker.io
25
+ ```
26
+
27
+ The Docker release also requires a buildx builder capable of building `linux/arm64`
28
+ and `linux/amd64` images. Check the active builder with:
29
+
30
+ ```sh
31
+ docker buildx inspect --bootstrap
32
+ ```
33
+
34
+ ## Cut the gem release
35
+
36
+ Start from an up-to-date, clean `main` branch. This is important because
37
+ `rake release` pushes the current branch.
38
+
39
+ ```sh
40
+ git switch main
41
+ git pull --ff-only origin main
42
+ git status --short
43
+ ```
44
+
45
+ Bump the version without committing. Use `patch`, `minor`, `major`, or an
46
+ explicit version number:
47
+
48
+ ```sh
49
+ gem bump --version patch --no-commit
50
+ ```
51
+
52
+ This updates `lib/specwrk/version.rb` while leaving the release changes
53
+ uncommitted. Update `CHANGELOG.md` using the new version:
54
+
55
+ 1. Change the Unreleased comparison link to start at the new version.
56
+ 2. Add a dated section for the new version.
57
+ 3. Move the Unreleased entries into that section.
58
+
59
+ Stage the version, changelog, and any release documentation, then create the
60
+ version commit:
61
+
62
+ ```sh
63
+ git add lib/specwrk/version.rb CHANGELOG.md RELEASING.md
64
+ git commit -m "Bump specwrk to VERSION"
65
+ ```
66
+
67
+ Replace `VERSION` with the new version, for example `0.19.4`. Confirm that the
68
+ commit contains the version and changelog updates:
69
+
70
+ ```sh
71
+ git show --stat --oneline HEAD
72
+ git status --short
73
+ ```
74
+
75
+ Run the full checks:
76
+
77
+ ```sh
78
+ bundle exec rake
79
+ ```
80
+
81
+ Publish the release:
82
+
83
+ ```sh
84
+ bundle exec rake release
85
+ ```
86
+
87
+ The release task requires a clean tracked worktree and then:
88
+
89
+ 1. Builds `pkg/specwrk-VERSION.gem`.
90
+ 2. Creates the annotated `vVERSION` tag.
91
+ 3. Pushes `main` and the tag to `origin`.
92
+ 4. Pushes the gem to RubyGems.org.
93
+
94
+ Use `gem bump` only to update the version file; use `bundle exec rake release`,
95
+ not `gem release`, for publishing this project.
96
+
97
+ If the RubyGems push fails after the tag was pushed, fix the authentication or
98
+ network problem and rerun `bundle exec rake release`. The task recognizes the
99
+ existing tag and retries the gem publication.
100
+
101
+ ## Build and publish the Docker image
102
+
103
+ After the gem release succeeds, run:
104
+
105
+ ```sh
106
+ scripts/build-server
107
+ ```
108
+
109
+ The script reads the version from `bundle exec exe/specwrk --version`, builds the
110
+ gem from the current checkout, and runs a multi-platform `docker buildx build`
111
+ with `--push`. It publishes both of these tags:
112
+
113
+ - `docker.io/danielwestendorf/specwrk-server:VERSION`
114
+ - `docker.io/danielwestendorf/specwrk-server:latest`
115
+
116
+ It builds images for both `linux/arm64` and `linux/amd64`. The temporary gem in
117
+ the repository root is removed after a successful build.
118
+
119
+ Verify the published manifest with:
120
+
121
+ ```sh
122
+ docker buildx imagetools inspect docker.io/danielwestendorf/specwrk-server:VERSION
123
+ ```
124
+
125
+ Replace `VERSION` with the released version without the `v` prefix, for example
126
+ `0.19.4`.
data/lib/specwrk/cli.rb CHANGED
@@ -67,7 +67,7 @@ module Specwrk
67
67
 
68
68
  def worker_env_for(idx)
69
69
  {
70
- "TEST_ENV_NUMBER" => idx.to_s,
70
+ "TEST_ENV_NUMBER" => (idx == 1) ? "" : idx.to_s,
71
71
  "SPECWRK_FORKED" => idx.to_s,
72
72
  "SPECWRK_ID" => "#{ENV.fetch("SPECWRK_ID", "specwrk-worker")}-#{idx}"
73
73
  }
@@ -12,6 +12,8 @@ Specwrk.net_http = Net::HTTP
12
12
 
13
13
  module Specwrk
14
14
  class Client
15
+ LockedResponseError = Class.new(StandardError)
16
+
15
17
  def self.connect?
16
18
  http = build_http
17
19
  http.start
@@ -158,10 +160,14 @@ module Specwrk
158
160
  @last_request_at = Time.now
159
161
  @http.request(request).tap do |response|
160
162
  @retry_count = 0
163
+ raise LockedResponseError if response.code == "423"
161
164
 
162
165
  @worker_status = response["x-specwrk-status"].to_i if response["x-specwrk-status"]
163
166
  end
164
167
  end
168
+ rescue LockedResponseError
169
+ sleep rand
170
+ retry
165
171
  rescue Net::ReadTimeout, Net::WriteTimeout => e
166
172
  @retry_count ||= 0
167
173
 
@@ -4,6 +4,8 @@ require "uri"
4
4
 
5
5
  module Specwrk
6
6
  class Store
7
+ LockUnavailableError = Class.new(StandardError)
8
+
7
9
  class << self
8
10
  def with_lock(uri, key)
9
11
  adapter_klass(uri).with_lock(uri, key) { yield }
@@ -10,6 +10,9 @@ require "specwrk/store/base_adapter"
10
10
  module Specwrk
11
11
  class Store
12
12
  class FileAdapter < BaseAdapter
13
+ READ_ATTEMPTS = 3
14
+ READ_RETRY_DELAY = 0.01
15
+
13
16
  @work_queue = Queue.new
14
17
  @threads = []
15
18
 
@@ -21,11 +24,13 @@ module Specwrk
21
24
 
22
25
  lock_file = File.open(lock_file_path, "a")
23
26
 
24
- Thread.pass until lock_file.flock(File::LOCK_EX)
27
+ locked = lock_file.flock(File::LOCK_EX | File::LOCK_NB)
28
+ raise LockUnavailableError unless locked
25
29
 
26
30
  yield
27
31
  ensure
28
- lock_file.flock(File::LOCK_UN)
32
+ lock_file&.flock(File::LOCK_UN) if locked
33
+ lock_file&.close
29
34
  end
30
35
 
31
36
  def schedule_work(&blk)
@@ -91,18 +96,21 @@ module Specwrk
91
96
 
92
97
  read_keys.each do |key|
93
98
  self.class.schedule_work do
94
- result_queue.push([key.to_s, read(key)])
99
+ result_queue.push([:ok, key.to_s, read(key)])
100
+ rescue => e
101
+ result_queue.push([:error, key.to_s, e])
95
102
  end
96
103
  end
97
104
 
98
- Thread.pass until result_queue.length == read_keys.length
105
+ raw_results = read_keys.length.times.map { result_queue.pop }
106
+ error = raw_results.find { |status, _key, _result| status == :error }
107
+ raise error.last if error
99
108
 
100
109
  results = {}
101
- until result_queue.empty?
102
- result = result_queue.pop
103
- next if result.last.nil?
110
+ raw_results.each do |_status, key, content|
111
+ next if content.nil?
104
112
 
105
- results[result.first] = self.class.serializer.load(result.last)
113
+ results[key] = self.class.serializer.load(content)
106
114
  end
107
115
 
108
116
  read_keys.map { |key| [key.to_s, results[key.to_s]] if results.key?(key.to_s) }.compact.to_h # respect order requested in the returned hash
@@ -140,7 +148,17 @@ module Specwrk
140
148
 
141
149
  def read(key)
142
150
  filename = filename_for_key key
143
- File.binread(filename)
151
+ attempts = 0
152
+
153
+ begin
154
+ attempts += 1
155
+ File.binread(filename)
156
+ rescue Errno::EBADF
157
+ raise if attempts >= READ_ATTEMPTS
158
+
159
+ sleep(READ_RETRY_DELAY * attempts)
160
+ retry
161
+ end
144
162
  rescue Errno::ENOENT
145
163
  nil
146
164
  end
@@ -36,14 +36,14 @@ module Specwrk
36
36
 
37
37
  def dump(value)
38
38
  packer.tap(&:clear)
39
- .tap { it.write(value) }
39
+ .tap { |msgpack| msgpack.write(value) }
40
40
  .then(&:to_s)
41
41
  end
42
42
 
43
43
  def load(payload)
44
44
  unpacker.tap(&:reset)
45
- .tap { it.feed(payload) }
46
- .then { it.read }
45
+ .tap { |msgpack| msgpack.feed(payload) }
46
+ .then { |msgpack| msgpack.read }
47
47
  end
48
48
 
49
49
  def packer
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Specwrk
4
- VERSION = "0.19.2"
4
+ VERSION = "0.19.4"
5
5
  end
@@ -30,6 +30,8 @@ module Specwrk
30
30
  with_response.tap do |response|
31
31
  response[1]["x-specwrk-status"] = worker_status.to_s
32
32
  end
33
+ rescue Store::LockUnavailableError
34
+ locked
33
35
  end
34
36
 
35
37
  def with_response
@@ -56,6 +58,14 @@ module Specwrk
56
58
  end
57
59
  end
58
60
 
61
+ def locked
62
+ if request.head?
63
+ [423, {}, []]
64
+ else
65
+ [423, {"content-type" => "text/plain"}, ["Locked. Try again later."]]
66
+ end
67
+ end
68
+
59
69
  def payload
60
70
  return unless request.content_type&.start_with?("application/json")
61
71
  return unless request.post? || request.put? || request.delete?
@@ -7,12 +7,14 @@ module Specwrk
7
7
  module Endpoints
8
8
  class CompleteAndPop < Popable
9
9
  EXAMPLE_STATUSES = %w[passed failed pending]
10
+ SYNTHETIC_FAILURE_CLASS = "Specwrk::WorkerExecutionError"
10
11
 
11
12
  def with_response
12
13
  retry_examples # pre-calculate before lock
14
+ omitted_processing_examples # pre-calculate before lock
13
15
 
14
16
  with_lock do
15
- processing.delete(*(completed_examples.keys + retry_examples.keys))
17
+ processing.delete(*(completed_examples.keys + retry_examples.keys).map(&:to_s))
16
18
  pending.merge!(retry_examples)
17
19
  end
18
20
 
@@ -26,24 +28,30 @@ module Specwrk
26
28
 
27
29
  private
28
30
 
29
- def all_examples
30
- @all_examples ||= payload.map { |example| [example[:id], example] if processing_examples[example[:id]] }.compact.to_h
31
+ def reported_processing_examples
32
+ @reported_processing_examples ||= payload.map do |example|
33
+ [example[:id], example] if processing_examples_for_payload[example[:id]]
34
+ end.compact.to_h
31
35
  end
32
36
 
33
- def processing_examples
34
- @processing_examples ||= processing.multi_read(*payload.map { |example| example[:id] })
37
+ def processing_examples_for_payload
38
+ @processing_examples_for_payload ||= processing.multi_read(*payload.map { |example| example[:id] })
35
39
  end
36
40
 
37
41
  def completed_examples
38
- @completed_examples ||= all_examples.map do |id, example|
39
- next if retry_example?(example)
42
+ @completed_examples ||= begin
43
+ reported_completed_examples = reported_processing_examples.map do |id, example|
44
+ next if retry_example?(example)
40
45
 
41
- [id, example]
42
- end.compact.to_h
46
+ [id, example]
47
+ end.compact.to_h
48
+
49
+ reported_completed_examples.merge(unreported_completed_examples)
50
+ end
43
51
  end
44
52
 
45
53
  def retry_examples
46
- @retry_examples ||= all_examples.map do |id, example|
54
+ @retry_examples ||= reported_processing_examples.map do |id, example|
47
55
  next unless retry_example?(example)
48
56
 
49
57
  [id, example]
@@ -66,7 +74,48 @@ module Specwrk
66
74
  end
67
75
 
68
76
  def all_example_failure_counts
69
- @all_example_failure_counts ||= failure_counts.multi_read(*all_examples.keys)
77
+ @all_example_failure_counts ||= failure_counts.multi_read(*reported_processing_examples.keys)
78
+ end
79
+
80
+ def omitted_processing_examples
81
+ @omitted_processing_examples ||= processing.to_h.select do |_id, example|
82
+ example[:worker_id].to_s == worker_id && !payload_example_ids.include?(example[:id].to_s)
83
+ end
84
+ end
85
+
86
+ def unreported_completed_examples
87
+ @unreported_completed_examples ||= omitted_processing_examples.transform_values { |example| synthetic_failure_for(example) }
88
+ end
89
+
90
+ def payload_example_ids
91
+ @payload_example_ids ||= payload.map { |example| example[:id].to_s }.uniq
92
+ end
93
+
94
+ def synthetic_failure_for(example)
95
+ finished_at = Time.now
96
+
97
+ {
98
+ id: example[:id],
99
+ full_description: "Specwrk worker execution failed before completing #{example[:id]}",
100
+ status: "failed",
101
+ file_path: example[:file_path],
102
+ line_number: line_number_for(example),
103
+ started_at: finished_at.iso8601(6),
104
+ finished_at: finished_at.iso8601(6),
105
+ run_time: 0.0,
106
+ exception: {
107
+ class: SYNTHETIC_FAILURE_CLASS,
108
+ message: "Worker #{worker_id} claimed this example but did not submit a completion result. Check worker stderr/stdout for the underlying RSpec output.",
109
+ backtrace: []
110
+ }
111
+ }
112
+ end
113
+
114
+ def line_number_for(example)
115
+ return example[:line_number] if example[:line_number]
116
+
117
+ match = example[:id].to_s.match(/:(\d+)\z/)
118
+ match[1].to_i if match
70
119
  end
71
120
 
72
121
  def completed_examples_status_counts
@@ -87,7 +136,9 @@ module Specwrk
87
136
  end
88
137
 
89
138
  def run_time_data
90
- @run_time_data ||= payload.map { |example| [example[:id], example[:run_time]] }.to_h
139
+ @run_time_data ||= payload.map { |example| [example[:id], example[:run_time]] }.to_h.merge(
140
+ unreported_completed_examples.transform_values { |example| example[:run_time] }
141
+ )
91
142
  end
92
143
  end
93
144
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: specwrk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.19.2
4
+ version: 0.19.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel Westendorf
@@ -204,6 +204,7 @@ files:
204
204
  - CHANGELOG.md
205
205
  - LICENSE
206
206
  - README.md
207
+ - RELEASING.md
207
208
  - Rakefile
208
209
  - Specwrk.watchfile.rb
209
210
  - config.ru
@@ -274,7 +275,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
274
275
  - !ruby/object:Gem::Version
275
276
  version: '0'
276
277
  requirements: []
277
- rubygems_version: 3.6.9
278
+ rubygems_version: 4.0.10
278
279
  specification_version: 4
279
280
  summary: Parallel rspec test runner from a queue of pending jobs.
280
281
  test_files: []