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 +4 -4
- data/CHANGELOG.md +12 -2
- data/README.md +29 -0
- data/Specwrk.hooks.rb +68 -0
- data/lib/specwrk/cli.rb +46 -5
- data/lib/specwrk/client.rb +50 -36
- data/lib/specwrk/hooks.rb +33 -0
- data/lib/specwrk/seed_loop.rb +2 -0
- data/lib/specwrk/store/pending_store.rb +11 -0
- data/lib/specwrk/version.rb +1 -1
- data/lib/specwrk/web/app.rb +3 -0
- data/lib/specwrk/web/endpoints/base.rb +22 -1
- data/lib/specwrk/web/endpoints/seed.rb +6 -2
- data/lib/specwrk/worker/executor.rb +4 -1
- data/lib/specwrk/worker.rb +1 -0
- data/lib/specwrk.rb +41 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ec2663b93b4ed3469ecfaebea825d97a2bd2b6764ccae67bc316efd6de2f53a6
|
|
4
|
+
data.tar.gz: 23e5e30cb0e6d9644085b0597e36f3c7380baea67ee01aaad70af9341b347fd8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
-
|
|
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
|
|
data/lib/specwrk/client.rb
CHANGED
|
@@ -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: {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
159
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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
|
-
|
|
175
|
-
|
|
160
|
+
request = request_class.new(path, headers)
|
|
161
|
+
request.body = body if body
|
|
176
162
|
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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
|
data/lib/specwrk/seed_loop.rb
CHANGED
|
@@ -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
|
data/lib/specwrk/version.rb
CHANGED
data/lib/specwrk/web/app.rb
CHANGED
|
@@ -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 ||=
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
data/lib/specwrk/worker.rb
CHANGED
|
@@ -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.
|
|
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
|