specwrk 0.20.1 → 0.21.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: f54d9828f2a51417c4956185d141b8a3a74876a1379df8701182885ad932c515
4
- data.tar.gz: a48f11d3a7977d02a5967e2d674ee68cf54a874a598e48db5b58ca3049ad80ac
3
+ metadata.gz: ec2663b93b4ed3469ecfaebea825d97a2bd2b6764ccae67bc316efd6de2f53a6
4
+ data.tar.gz: 23e5e30cb0e6d9644085b0597e36f3c7380baea67ee01aaad70af9341b347fd8
5
5
  SHA512:
6
- metadata.gz: d013fd261c119d58e7b607c4c9aaddd02c020d45e2ae56776ea111ea3217c248808d1fab8e93b4e440322b1ef9096efdad19de22e79683fa9e5116275ed91ce3
7
- data.tar.gz: 2905267a34c6daab6e7f771b61d1bd0c4ffcef368646391ede9e9065e69452c047b4bd88695b930df953a3cbc0beab404cd73185d0f020556c1b09f6c14c5a25
6
+ metadata.gz: 99066768c088081d7c3c69a52ce51898a4ea7cd533581fc70829f70edc25b423b583d9883dda079070a17c5ef5f135ca330afeb62a6027897650cfc1f2b82318
7
+ data.tar.gz: c3a606f594a5dc018556149b79846ec3ccee01b89ef016396c327e250aeba6d84cfcb7c1bdfd7b7a9c121f835f5c7818356be5c5cdbef826cc794506dd4bc55b
data/CHANGELOG.md CHANGED
@@ -1,7 +1,17 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
4
- [Compare](https://github.com/danielwestendorf/specwrk/compare/v0.20.1...main)
3
+ ## Unreleased
4
+ [Compare](https://github.com/danielwestendorf/specwrk/compare/v0.21.0...main)
5
+
6
+ ## v0.21.0 — 2026-09-13
7
+ [Compare](https://github.com/danielwestendorf/specwrk/compare/v0.20.2...v0.21.0)
8
+ - Add configurable lifecycle hooks — [#193](https://github.com/danielwestendorf/specwrk/pull/193) by [@danielwestendorf](https://github.com/danielwestendorf)
9
+ - Add opt-in gzip request compression and negotiated gzip responses — [#195](https://github.com/danielwestendorf/specwrk/pull/195) by [@danielwestendorf](https://github.com/danielwestendorf)
10
+
11
+ ## v0.20.2 — 2026-08-27
12
+ [Compare](https://github.com/danielwestendorf/specwrk/compare/v0.20.1...v0.20.2)
13
+ - Add a seed option for configuring the target bucket timing duration — [#191](https://github.com/danielwestendorf/specwrk/pull/191) by [@danielwestendorf](https://github.com/danielwestendorf)
14
+ - Print an indicator while workers wait for outstanding examples to finish — [#192](https://github.com/danielwestendorf/specwrk/pull/192) by [@danielwestendorf](https://github.com/danielwestendorf)
5
15
 
6
16
  ## v0.20.1 — 2026-08-23
7
17
  [Compare](https://github.com/danielwestendorf/specwrk/compare/v0.20.0...v0.20.1)
data/README.md CHANGED
@@ -53,6 +53,7 @@ Options:
53
53
  --run=VALUE, -r VALUE # The run identifier for this job execution. Overrides SPECWRK_RUN, default: "main"
54
54
  --timeout=VALUE, -t VALUE # The amount of time to wait for the server to respond. Overrides SPECWRK_TIMEOUT, default: "5"
55
55
  --network-retries=VALUE # The number of times to retry in the event of a network failure. Overrides SPECWRK_NETWORK_RETRIES, default: "1"
56
+ --[no-]http-compression # Compress large JSON request bodies sent to the server. Overrides SPECWRK_HTTP_COMPRESSION, default: false
56
57
  --id=VALUE # The identifier for this worker. Overrides SPECWRK_ID. If none provided one in the format of specwrk-worker-8_RAND_CHARS-COUNT_INDEX will be used
57
58
  --count=VALUE, -c VALUE # The number of worker processes you want to start, default: 1
58
59
  --output=VALUE, -o VALUE # Directory where worker output is stored. Overrides SPECWRK_OUT, default: ".specwrk/"
@@ -115,7 +116,9 @@ Options:
115
116
  --run=VALUE, -r VALUE # The run identifier for this job execution. Overrides SPECWRK_RUN, default: "main"
116
117
  --timeout=VALUE, -t VALUE # The amount of time to wait for the server to respond. Overrides SPECWRK_TIMEOUT, default: "5"
117
118
  --network-retries=VALUE # The number of times to retry in the event of a network failure. Overrides SPECWRK_NETWORK_RETRIES, default: "1"
119
+ --[no-]http-compression # Compress large JSON request bodies sent to the server. Overrides SPECWRK_HTTP_COMPRESSION, default: false
118
120
  --max-retries=VALUE # Number of times an example will be re-run should it fail, default: 0
121
+ --target-bucket-timing-duration=VALUE # Target runtime duration per bucket in seconds, overriding the average timings calculation. Overrides SPECWRK_TARGET_BUCKET_TIMING_DURATION, default: "0"
119
122
  --help, -h # Print this help
120
123
  ```
121
124
 
@@ -143,6 +146,7 @@ Options:
143
146
  --run=VALUE, -r VALUE # The run identifier for this job execution. Overrides SPECWRK_RUN, default: "main"
144
147
  --timeout=VALUE, -t VALUE # The amount of time to wait for the server to respond. Overrides SPECWRK_TIMEOUT, default: "5"
145
148
  --network-retries=VALUE # The number of times to retry in the event of a network failure. Overrides SPECWRK_NETWORK_RETRIES, default: "1"
149
+ --[no-]http-compression # Compress large JSON request bodies sent to the server. Overrides SPECWRK_HTTP_COMPRESSION, default: false
146
150
  --help, -h # Print this help
147
151
  ```
148
152
 
@@ -163,15 +167,40 @@ Description:
163
167
  Options:
164
168
  --watchfile=VALUE # Path to watchfile configuration, default: "Specwrk.watchfile.rb"
165
169
  --count=VALUE, -c VALUE # The number of worker processes you want to start, default: 1
170
+ --[no-]http-compression # Compress large JSON request bodies sent to the server. Overrides SPECWRK_HTTP_COMPRESSION, default: false
166
171
  --help, -h # Print this help
167
172
  ```
168
173
 
174
+ ## Lifecycle hooks
175
+
176
+ Lifecycle hooks run synchronously in the process performing the corresponding work. Add hook registrations to `Specwrk.hooks.rb` in the directory where you run `specwrk`; the file is loaded automatically by the `seed`, `work`, `serve`, `start`, and `watch` commands. Use `--hooks=path/to/hooks.rb` to load a different file.
177
+
178
+ The hooks file is ordinary Ruby and is loaded into the parent, server, seed, and worker processes as applicable. Hooks remain process-local and are not serialized between processes.
179
+
180
+ | Hook Method | Description | Arguments |
181
+ | --- | --- | --- |
182
+ | `Specwrk.before_seed(&blk)` | Runs before seeding examples to the server | none |
183
+ | `Specwrk.after_seed(&blk)` | Runs after seeding examples to the server | `examples` RSpec examples |
184
+ | `Specwrk.before_server_seed(&blk)` | Runs in the server before seeding examples | none |
185
+ | `Specwrk.after_server_seed(&blk)` | Runs in the server after seeding examples | `examples` RSpec examples |
186
+ | `Specwrk.before_worker_fork(&blk)` | Runs in the parent process before spawning each worker | none |
187
+ | `Specwrk.after_worker_fork(&blk)` | Runs in each worker process immediately after it is spawned | none |
188
+ | `Specwrk.before_worker_examples_execute(&blk)` | Runs in a worker before an examples group is passed to the RSpec runner | `examples` RSpec examples in the group |
189
+ | `Specwrk.after_worker_examples_execute(&blk)` | Runs in a worker after an examples group is executed by the RSpec runner | `examples` RSpec examples in the group |
190
+ | `Specwrk.before_worker_exit(&blk)` | Runs in each worker after work completes and before the process exits | `status` Worker exit status |
191
+ | `Specwrk.after_all_workers_exit(&blk)` | Runs in the parent process after all workers exit | `status` Overall worker exit status |
192
+
193
+ Response compression is negotiated automatically by HTTP clients and the queue server. Request compression is disabled by default; enable it with `--http-compression` or `SPECWRK_HTTP_COMPRESSION=1` on client commands to gzip seed data and worker results that are at least 1 KiB. Set `SPECWRK_HTTP_COMPRESSION_MINIMUM_SIZE` to change this byte threshold. Upgrade the queue server before enabling request compression because it does not fall back when a server cannot decode gzip.
194
+
169
195
  ## Configuring your test environment
170
196
  If your test suite tracks state, starts servers, etc. and you plan on running many processes on the same node, you'll need to make
171
197
  adjustments to avoid conflicting port usage or database/state mutations.
172
198
 
173
199
  `specwrk` workers will have `TEST_ENV_NUMBER={i}` set to help you configure approriately.
174
200
 
201
+ Workers print `W` without a newline while waiting for outstanding examples to finish. Set
202
+ `SPECWRK_WAITING_OUTPUT=0` to disable this output.
203
+
175
204
  ### Rails
176
205
  Rails has had easy multi-process test setup for a while now by creating unique test databases per process. For my rails v7.2 app which uses PostgreSQL and Capybara, I made these changes to my `spec/rails_helper.rb`:
177
206
 
data/Specwrk.hooks.rb ADDED
@@ -0,0 +1,68 @@
1
+ # specwrk_hook_log = lambda do |event, **details|
2
+ # specwrk_env = ENV.each_with_object({}) do |(name, value), values|
3
+ # next unless name.start_with?("SPECWRK_")
4
+ #
5
+ # values[name] = if name.match?(/KEY|TOKEN|SECRET|PASSWORD/)
6
+ # "[REDACTED]"
7
+ # else
8
+ # value
9
+ # end
10
+ # end.sort.to_h
11
+ #
12
+ # context = {
13
+ # event: event,
14
+ # pid: Process.pid,
15
+ # ppid: Process.ppid,
16
+ # specwrk_env: specwrk_env
17
+ # }.merge(details)
18
+ #
19
+ # puts context.map { |name, value| "#{name}=#{value.inspect}" }.join(" ")
20
+ # end
21
+ #
22
+ # Specwrk.before_seed do
23
+ # specwrk_hook_log.call("before_seed")
24
+ # end
25
+ #
26
+ # Specwrk.after_seed do |examples|
27
+ # specwrk_hook_log.call("after_seed", example_count: examples.length)
28
+ # end
29
+ #
30
+ # Specwrk.before_server_seed do
31
+ # specwrk_hook_log.call("before_server_seed")
32
+ # end
33
+ #
34
+ # Specwrk.after_server_seed do |examples|
35
+ # specwrk_hook_log.call("after_server_seed", example_count: examples.length)
36
+ # end
37
+ #
38
+ # Specwrk.before_worker_fork do
39
+ # specwrk_hook_log.call("before_worker_fork")
40
+ # end
41
+ #
42
+ # Specwrk.after_worker_fork do
43
+ # specwrk_hook_log.call("after_worker_fork")
44
+ # end
45
+ #
46
+ # Specwrk.before_worker_examples_execute do |examples|
47
+ # specwrk_hook_log.call(
48
+ # "before_worker_examples_execute",
49
+ # example_count: examples.length,
50
+ # example_ids: examples.map { |example| example[:id] }
51
+ # )
52
+ # end
53
+ #
54
+ # Specwrk.after_worker_examples_execute do |examples|
55
+ # specwrk_hook_log.call(
56
+ # "after_worker_examples_execute",
57
+ # example_count: examples.length,
58
+ # example_ids: examples.map { |example| example[:id] }
59
+ # )
60
+ # end
61
+ #
62
+ # Specwrk.before_worker_exit do |status|
63
+ # specwrk_hook_log.call("before_worker_exit", status: status)
64
+ # end
65
+ #
66
+ # Specwrk.after_all_workers_exit do |status|
67
+ # specwrk_hook_log.call("after_all_workers_exit", status: status)
68
+ # end
data/lib/specwrk/cli.rb CHANGED
@@ -14,8 +14,26 @@ module Specwrk
14
14
  module CLI
15
15
  extend Dry::CLI::Registry
16
16
 
17
+ module HooksConfigurable
18
+ extend Hookable
19
+
20
+ on_included do |base|
21
+ base.unique_option :hooks, type: :string, default: "Specwrk.hooks.rb", desc: "Path to lifecycle hooks configuration"
22
+ end
23
+
24
+ on_setup do |hooks: "Specwrk.hooks.rb", **|
25
+ hooks_file = Pathname.new(hooks).expand_path(Dir.pwd).to_s
26
+ ENV["SPECWRK_HOOKS_FILE"] = hooks_file
27
+ Hooks.load_file(hooks_file)
28
+ end
29
+ end
30
+
17
31
  module WorkerProcesses
18
32
  WORKER_INIT_SCRIPT = <<~RUBY
33
+ require "specwrk"
34
+ Specwrk::Hooks.load_file(ENV["SPECWRK_HOOKS_FILE"])
35
+ Specwrk::Hooks.run(:after_worker_fork)
36
+
19
37
  writer = IO.for_fd(Integer(ENV.fetch("SPECWRK_FINAL_FD")))
20
38
  $final_output = writer # standard:disable Style/GlobalVars
21
39
  $final_output.sync = true # standard:disable Style/GlobalVars
@@ -31,6 +49,7 @@ module Specwrk
31
49
  end
32
50
 
33
51
  status = Specwrk::Worker.run!
52
+ Specwrk::Hooks.run(:before_worker_exit, status)
34
53
  $final_output.close # standard:disable Style/GlobalVars
35
54
  exit(status)
36
55
  RUBY
@@ -45,6 +64,7 @@ module Specwrk
45
64
  "SPECWRK_FINAL_FD" => writer.fileno.to_s
46
65
  )
47
66
 
67
+ Hooks.run(:before_worker_fork)
48
68
  Process.spawn(
49
69
  env, RbConfig.ruby, "-e", WORKER_INIT_SCRIPT,
50
70
  writer.fileno => writer,
@@ -95,14 +115,16 @@ module Specwrk
95
115
  base.unique_option :run, type: :string, default: ENV.fetch("SPECWRK_RUN", "main"), aliases: ["-r"], desc: "The run identifier for this job execution. Overrides SPECWRK_RUN"
96
116
  base.unique_option :timeout, type: :integer, default: ENV.fetch("SPECWRK_TIMEOUT", "5"), aliases: ["-t"], desc: "The amount of time to wait for the server to respond. Overrides SPECWRK_TIMEOUT"
97
117
  base.unique_option :network_retries, type: :integer, default: ENV.fetch("SPECWRK_NETWORK_RETRIES", "1"), desc: "The number of times to retry in the event of a network failure. Overrides SPECWRK_NETWORK_RETRIES"
118
+ base.unique_option :http_compression, type: :boolean, default: ENV.fetch("SPECWRK_HTTP_COMPRESSION", "0") == "1", desc: "Compress large JSON request bodies sent to the server. Overrides SPECWRK_HTTP_COMPRESSION"
98
119
  end
99
120
 
100
- on_setup do |uri:, key:, run:, timeout:, network_retries:, **|
121
+ on_setup do |uri:, key:, run:, timeout:, network_retries:, http_compression: false, **|
101
122
  ENV["SPECWRK_SRV_URI"] = uri
102
123
  ENV["SPECWRK_SRV_KEY"] = key
103
124
  ENV["SPECWRK_RUN"] = run
104
125
  ENV["SPECWRK_TIMEOUT"] = timeout
105
126
  ENV["SPECWRK_NETWORK_RETRIES"] = network_retries
127
+ ENV["SPECWRK_HTTP_COMPRESSION"] = http_compression ? "1" : "0"
106
128
  end
107
129
  end
108
130
 
@@ -164,12 +186,14 @@ module Specwrk
164
186
 
165
187
  class Seed < Dry::CLI::Command
166
188
  include Clientable
189
+ include HooksConfigurable
167
190
 
168
191
  desc "Seed the server with a list of specs for the run"
169
192
  option :max_retries, default: 0, desc: "Number of times an example will be re-run should it fail"
193
+ option :target_bucket_timing_duration, default: ENV.fetch("SPECWRK_TARGET_BUCKET_TIMING_DURATION", "0"), desc: "Target runtime duration per bucket in seconds, overriding the average timings calculation. Overrides SPECWRK_TARGET_BUCKET_TIMING_DURATION"
170
194
  argument :dir, type: :array, required: false, desc: "Relative spec directory to run against, default: spec/"
171
195
 
172
- def call(max_retries:, dir:, **args)
196
+ def call(max_retries:, dir:, target_bucket_timing_duration:, **args)
173
197
  dir = ["spec"] if dir.length.zero?
174
198
 
175
199
  self.class.setup(**args)
@@ -178,10 +202,12 @@ module Specwrk
178
202
  require "specwrk/client"
179
203
 
180
204
  ENV["SPECWRK_SEED"] = "1"
205
+ target_bucket_timing_duration = Float(target_bucket_timing_duration)
206
+ ENV["SPECWRK_TARGET_BUCKET_TIMING_DURATION"] = target_bucket_timing_duration.to_s
181
207
  examples = ListExamples.new(dir).examples
182
208
 
183
209
  Client.wait_for_server!
184
- Client.new.seed(examples, max_retries)
210
+ Client.new.seed(examples, max_retries, target_bucket_timing_duration: target_bucket_timing_duration)
185
211
  file_count = examples.group_by { |e| e[:file_path] }.keys.size
186
212
  puts "🌱 Seeded #{examples.size} examples across #{file_count} files"
187
213
  rescue Errno::ECONNREFUSED
@@ -196,6 +222,7 @@ module Specwrk
196
222
  class Work < Dry::CLI::Command
197
223
  include Workable
198
224
  include Clientable
225
+ include HooksConfigurable
199
226
 
200
227
  desc "Start one or more worker processes"
201
228
 
@@ -213,7 +240,10 @@ module Specwrk
213
240
  end
214
241
 
215
242
  def wait_for_workers_exit
216
- @exited_pids = Specwrk.wait_for_pids_exit(@worker_pids)
243
+ Specwrk.wait_for_pids_exit(@worker_pids).tap do |exited_pids|
244
+ @exited_pids = exited_pids
245
+ Hooks.run(:after_all_workers_exit, status)
246
+ end
217
247
  end
218
248
 
219
249
  def status
@@ -223,6 +253,7 @@ module Specwrk
223
253
 
224
254
  class Serve < Dry::CLI::Command
225
255
  include Servable
256
+ include HooksConfigurable
226
257
 
227
258
  desc "Start a queue server"
228
259
  option :single_run, type: :boolean, default: false, desc: "Act on shutdown requests from clients"
@@ -243,9 +274,12 @@ module Specwrk
243
274
  include Clientable
244
275
  include Workable
245
276
  include Servable
277
+ include HooksConfigurable
246
278
 
247
279
  SEED_INIT_SCRIPT = <<~'RUBY'
248
280
  require "json"
281
+ require "specwrk"
282
+ Specwrk::Hooks.load_file(ENV["SPECWRK_HOOKS_FILE"])
249
283
  require "specwrk/list_examples"
250
284
  require "specwrk/client"
251
285
 
@@ -344,8 +378,11 @@ module Specwrk
344
378
  class Watch < Dry::CLI::Command
345
379
  include WorkerProcesses
346
380
  include PortDiscoverable
381
+ include HooksConfigurable
347
382
 
348
383
  SEED_LOOP_INIT_SCRIPT = <<~RUBY
384
+ require "specwrk"
385
+ Specwrk::Hooks.load_file(ENV["SPECWRK_HOOKS_FILE"])
349
386
  require "specwrk/ipc"
350
387
  require "specwrk/seed_loop"
351
388
 
@@ -359,8 +396,9 @@ module Specwrk
359
396
  desc "Start a server and workers, watch for file changes in the current directory, and execute specs"
360
397
  option :watchfile, type: :string, default: "Specwrk.watchfile.rb", desc: "Path to watchfile configuration"
361
398
  option :count, type: :integer, default: 1, aliases: ["-c"], desc: "The number of worker processes you want to start"
399
+ option :http_compression, type: :boolean, default: ENV.fetch("SPECWRK_HTTP_COMPRESSION", "0") == "1", desc: "Compress large JSON request bodies sent to the server. Overrides SPECWRK_HTTP_COMPRESSION"
362
400
 
363
- def call(count:, watchfile:, **args)
401
+ def call(count:, watchfile:, http_compression: false, **args)
364
402
  $stdout.sync = true
365
403
 
366
404
  # nil this env var if it exists to prevent never-ending workers
@@ -374,6 +412,9 @@ module Specwrk
374
412
  ENV["SPECWRK_MAX_BUCKET_SIZE"] = "1"
375
413
  ENV["SPECWRK_COUNT"] = count.to_s
376
414
  ENV["SPECWRK_RUN"] = "watch"
415
+ ENV["SPECWRK_HTTP_COMPRESSION"] = http_compression ? "1" : "0"
416
+
417
+ self.class.setup(**args)
377
418
 
378
419
  web_pid
379
420
 
@@ -3,6 +3,7 @@
3
3
  require "uri"
4
4
  require "net/http"
5
5
  require "json"
6
+ require "zlib"
6
7
 
7
8
  require "specwrk"
8
9
 
@@ -13,6 +14,7 @@ Specwrk.net_http = Net::HTTP
13
14
  module Specwrk
14
15
  class Client
15
16
  LockedResponseError = Class.new(StandardError)
17
+ HTTP_COMPRESSION_MINIMUM_SIZE = 1024
16
18
 
17
19
  def self.connect?
18
20
  http = build_http
@@ -119,8 +121,12 @@ module Specwrk
119
121
  end
120
122
  end
121
123
 
122
- def seed(examples, max_retries)
123
- response = post "/seed", body: {max_retries: max_retries, examples: examples}.to_json
124
+ def seed(examples, max_retries, target_bucket_timing_duration: 0)
125
+ response = post "/seed", body: {
126
+ max_retries: max_retries,
127
+ examples: examples,
128
+ target_bucket_timing_duration: target_bucket_timing_duration
129
+ }.to_json
124
130
 
125
131
  (response.code == "200") ? true : raise(UnhandledResponseError.new("#{response.code}: #{response.body}"))
126
132
  end
@@ -128,56 +134,64 @@ module Specwrk
128
134
  private
129
135
 
130
136
  def get(path, headers: default_headers, body: nil)
131
- request = Specwrk.net_http::Get.new(path, headers)
132
- request.body = body if body
133
-
134
- make_request(request)
137
+ make_request Specwrk.net_http::Get, path, headers: headers, body: body
135
138
  end
136
139
 
137
140
  def post(path, headers: default_headers, body: nil)
138
- request = Specwrk.net_http::Post.new(path, headers)
139
- request.body = body if body
140
-
141
- make_request(request)
141
+ make_request Specwrk.net_http::Post, path, headers: headers, body: body
142
142
  end
143
143
 
144
144
  def put(path, headers: default_headers, body: nil)
145
- request = Specwrk.net_http::Put.new(path, headers)
146
- request.body = body if body
147
-
148
- make_request(request)
145
+ make_request Specwrk.net_http::Put, path, headers: headers, body: body
149
146
  end
150
147
 
151
148
  def delete(path, headers: default_headers, body: nil)
152
- request = Specwrk.net_http::Delete.new(path, headers)
153
- request.body = body if body
154
-
155
- make_request(request)
149
+ make_request Specwrk.net_http::Delete, path, headers: headers, body: body
156
150
  end
157
151
 
158
- def make_request(request)
159
- @mutex.synchronize do
160
- @last_request_at = Time.now
161
- @http.request(request).tap do |response|
162
- @retry_count = 0
163
- raise LockedResponseError if response.code == "423"
152
+ def make_request(request_class, path, headers:, body:)
153
+ headers = headers.dup
164
154
 
165
- @worker_status = response["x-specwrk-status"].to_i if response["x-specwrk-status"]
166
- end
155
+ if http_compression? && headers["Content-Type"] == "application/json" && body&.bytesize.to_i >= http_compression_minimum_size
156
+ body = Zlib.gzip(body)
157
+ headers["Content-Encoding"] = "gzip"
167
158
  end
168
- rescue LockedResponseError
169
- sleep rand
170
- retry
171
- rescue Net::ReadTimeout, Net::WriteTimeout => e
172
- @retry_count ||= 0
173
159
 
174
- raise e if @retry_count == ENV["SPECWRK_NETWORK_RETRIES"].to_i
175
- @retry_count += 1
160
+ request = request_class.new(path, headers)
161
+ request.body = body if body
176
162
 
177
- warn e
178
- sleep @retry_count
163
+ begin
164
+ @mutex.synchronize do
165
+ @last_request_at = Time.now
166
+ @http.request(request).tap do |response|
167
+ @retry_count = 0
168
+ raise LockedResponseError if response.code == "423"
169
+
170
+ @worker_status = response["x-specwrk-status"].to_i if response["x-specwrk-status"]
171
+ end
172
+ end
173
+ rescue LockedResponseError
174
+ sleep rand
175
+ retry
176
+ rescue Net::ReadTimeout, Net::WriteTimeout => e
177
+ @retry_count ||= 0
178
+
179
+ raise e if @retry_count == ENV["SPECWRK_NETWORK_RETRIES"].to_i
180
+ @retry_count += 1
181
+
182
+ warn e
183
+ sleep @retry_count
184
+
185
+ retry
186
+ end
187
+ end
188
+
189
+ def http_compression?
190
+ ENV.fetch("SPECWRK_HTTP_COMPRESSION", "0") == "1"
191
+ end
179
192
 
180
- retry
193
+ def http_compression_minimum_size
194
+ ENV.fetch("SPECWRK_HTTP_COMPRESSION_MINIMUM_SIZE", HTTP_COMPRESSION_MINIMUM_SIZE).to_i
181
195
  end
182
196
 
183
197
  def default_headers
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Specwrk
4
+ module Hooks
5
+ class << self
6
+ def register(name, &block)
7
+ raise ArgumentError, "a block is required" unless block
8
+
9
+ hooks[name] << block
10
+ block
11
+ end
12
+
13
+ def run(name, *objects)
14
+ hooks.fetch(name, []).dup.each { |hook| hook.call(*objects) }
15
+ nil
16
+ end
17
+
18
+ def load_file(file)
19
+ Kernel.load(file) if file && File.file?(file)
20
+ end
21
+
22
+ def reset!
23
+ @hooks = nil
24
+ end
25
+
26
+ private
27
+
28
+ def hooks
29
+ @hooks ||= Hash.new { |registry, name| registry[name] = [] }
30
+ end
31
+ end
32
+ end
33
+ end
@@ -6,6 +6,7 @@ require "specwrk/client"
6
6
  module Specwrk
7
7
  class SeedLoop
8
8
  def self.loop!(ipc)
9
+ Hooks.run(:before_seed)
9
10
  Client.wait_for_server!
10
11
 
11
12
  loop do
@@ -18,6 +19,7 @@ module Specwrk
18
19
 
19
20
  client = Client.new
20
21
  client.seed(examples, 0)
22
+ Hooks.run(:after_seed, examples)
21
23
  client.close
22
24
 
23
25
  ipc.write examples.length
@@ -19,6 +19,17 @@ module Specwrk
19
19
  @run_time_bucket_maximum ||= self[RUN_TIME_BUCKET_MAXIMUM_KEY]
20
20
  end
21
21
 
22
+ def update_run_time_bucket_maximum(calculated_run_time_bucket_maximum, target_bucket_timing_duration: 0)
23
+ target_bucket_timing_duration = target_bucket_timing_duration.to_f
24
+
25
+ if target_bucket_timing_duration.positive?
26
+ self.run_time_bucket_maximum = target_bucket_timing_duration
27
+ else
28
+ run_time_bucket_maximums = [run_time_bucket_maximum, calculated_run_time_bucket_maximum.to_f].compact
29
+ self.run_time_bucket_maximum = run_time_bucket_maximums.sum.to_f / run_time_bucket_maximums.length.to_f
30
+ end
31
+ end
32
+
22
33
  def max_retries=(val)
23
34
  @max_retries = self[MAX_RETRIES_KEY] = val
24
35
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Specwrk
4
- VERSION = "0.20.1"
4
+ VERSION = "0.21.0"
5
5
  end
@@ -5,6 +5,7 @@ require "fileutils"
5
5
 
6
6
  require "webrick"
7
7
  require "rack"
8
+ require "rack/deflater"
8
9
 
9
10
  # rack v3 or v2
10
11
  begin
@@ -67,6 +68,8 @@ module Specwrk
67
68
 
68
69
  def rackup
69
70
  Rack::Builder.new do
71
+ use Rack::Deflater, include: ["application/json"]
72
+
70
73
  if ENV["SPECWRK_SRV_VERBOSE"]
71
74
  use Rack::Runtime
72
75
  use Specwrk::Web::Logger, $stdout, %w[/health]
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "zlib"
4
5
 
5
6
  require "specwrk/store"
6
7
 
@@ -9,6 +10,8 @@ module Specwrk
9
10
  module Endpoints
10
11
  class Base
11
12
  MUTEX = Mutex.new
13
+ InvalidGzipError = Class.new(StandardError)
14
+ UnsupportedContentEncodingError = Class.new(StandardError)
12
15
 
13
16
  attr_reader :started_at
14
17
 
@@ -32,6 +35,10 @@ module Specwrk
32
35
  end
33
36
  rescue Store::LockUnavailableError
34
37
  locked
38
+ rescue InvalidGzipError
39
+ [400, {"content-type" => "text/plain"}, ["Invalid gzip request body"]]
40
+ rescue UnsupportedContentEncodingError
41
+ [415, {"content-type" => "text/plain"}, ["Unsupported content encoding"]]
35
42
  end
36
43
 
37
44
  def with_response
@@ -75,7 +82,21 @@ module Specwrk
75
82
  end
76
83
 
77
84
  def body
78
- @body ||= request.body.read
85
+ @body ||= begin
86
+ body = request.body.read
87
+ encoding = request.get_header("HTTP_CONTENT_ENCODING")&.strip&.downcase
88
+
89
+ case encoding
90
+ when nil, "", "identity"
91
+ body
92
+ when "gzip"
93
+ Zlib.gunzip(body)
94
+ else
95
+ raise UnsupportedContentEncodingError
96
+ end
97
+ rescue Zlib::Error
98
+ raise InvalidGzipError
99
+ end
79
100
  end
80
101
 
81
102
  def pending
@@ -7,6 +7,7 @@ module Specwrk
7
7
  module Endpoints
8
8
  class Seed < Base
9
9
  def with_response
10
+ Hooks.run(:before_server_seed)
10
11
  examples_with_run_times
11
12
 
12
13
  pending.clear
@@ -16,13 +17,16 @@ module Specwrk
16
17
 
17
18
  pending.max_retries = payload.fetch(:max_retries, "0").to_i
18
19
 
19
- new_run_time_bucket_maximums = [pending.run_time_bucket_maximum, @seeds_run_time_bucket_maximum.to_f].compact
20
- pending.run_time_bucket_maximum = new_run_time_bucket_maximums.sum.to_f / new_run_time_bucket_maximums.length.to_f
20
+ pending.update_run_time_bucket_maximum(
21
+ @seeds_run_time_bucket_maximum,
22
+ target_bucket_timing_duration: payload.fetch(:target_bucket_timing_duration, 0)
23
+ )
21
24
 
22
25
  with_lock do
23
26
  pending.merge!(examples_with_run_times)
24
27
  end
25
28
 
29
+ Hooks.run(:after_server_seed, payload[:examples])
26
30
  ok
27
31
  end
28
32
 
@@ -25,7 +25,10 @@ module Specwrk
25
25
  example_ids = examples.map { |example| example[:id] }
26
26
 
27
27
  options = RSpec::Core::ConfigurationOptions.new ["--format", "Specwrk::Worker::NullFormatter"] + example_ids
28
- RSpec::Core::Runner.new(options).run($stderr, $stdout)
28
+ Hooks.run(:before_worker_examples_execute, examples)
29
+ RSpec::Core::Runner.new(options).run($stderr, $stdout).tap do
30
+ Hooks.run(:after_worker_examples_execute, examples)
31
+ end
29
32
  end
30
33
 
31
34
  # https://github.com/skroutz/rspecq/blob/341383ce3ca25f42fad5483cbb6a00ba1c405570/lib/rspecq/worker.rb#L208-L224
@@ -40,6 +40,7 @@ module Specwrk
40
40
  # Wait for the other processes (workers) on the same host to finish
41
41
  # This will cause workers to 'hang' until all work has been completed
42
42
  # TODO: break here if all the other worker processes on this host are done executing examples
43
+ $stdout.printf "W" unless ENV.fetch("SPECWRK_WAITING_OUTPUT", "1") == "0"
43
44
  sleep 0.5
44
45
  rescue WaitingForSeedError
45
46
  @seed_wait_count ||= 0
data/lib/specwrk.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "specwrk/version"
4
+ require "specwrk/hooks"
4
5
 
5
6
  module Specwrk
6
7
  Error = Class.new(StandardError)
@@ -19,6 +20,46 @@ module Specwrk
19
20
  attr_accessor :force_quit, :net_http
20
21
  attr_reader :starting_pid
21
22
 
23
+ def before_seed(&block)
24
+ Hooks.register(:before_seed, &block)
25
+ end
26
+
27
+ def after_seed(&block)
28
+ Hooks.register(:after_seed, &block)
29
+ end
30
+
31
+ def before_server_seed(&block)
32
+ Hooks.register(:before_server_seed, &block)
33
+ end
34
+
35
+ def after_server_seed(&block)
36
+ Hooks.register(:after_server_seed, &block)
37
+ end
38
+
39
+ def before_worker_fork(&block)
40
+ Hooks.register(:before_worker_fork, &block)
41
+ end
42
+
43
+ def after_worker_fork(&block)
44
+ Hooks.register(:after_worker_fork, &block)
45
+ end
46
+
47
+ def before_worker_examples_execute(&block)
48
+ Hooks.register(:before_worker_examples_execute, &block)
49
+ end
50
+
51
+ def after_worker_examples_execute(&block)
52
+ Hooks.register(:after_worker_examples_execute, &block)
53
+ end
54
+
55
+ def before_worker_exit(&block)
56
+ Hooks.register(:before_worker_exit, &block)
57
+ end
58
+
59
+ def after_all_workers_exit(&block)
60
+ Hooks.register(:after_all_workers_exit, &block)
61
+ end
62
+
22
63
  def wait_for_pids_exit(pids)
23
64
  exited_pids = {}
24
65
 
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.20.1
4
+ version: 0.21.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel Westendorf
@@ -206,6 +206,7 @@ files:
206
206
  - README.md
207
207
  - RELEASING.md
208
208
  - Rakefile
209
+ - Specwrk.hooks.rb
209
210
  - Specwrk.watchfile.rb
210
211
  - config.ru
211
212
  - docker/Dockerfile.server
@@ -220,6 +221,7 @@ files:
220
221
  - lib/specwrk/cli_reporter.rb
221
222
  - lib/specwrk/client.rb
222
223
  - lib/specwrk/hookable.rb
224
+ - lib/specwrk/hooks.rb
223
225
  - lib/specwrk/ipc.rb
224
226
  - lib/specwrk/list_examples.rb
225
227
  - lib/specwrk/seed_loop.rb