specwrk 0.15.3 → 0.15.5

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: 9dfdda735e233530059c547ef897824d74169be0a5bbd95e806b69902aafe984
4
- data.tar.gz: 101e932e579f2f3e396b4e93e0ce9f4cb1f2675fe972b113b0a2280f69e3d15b
3
+ metadata.gz: 0b06e2988f761496b2c8b06507471e99345b05ddf15716439d2717680f1e711d
4
+ data.tar.gz: 2eef3783b87531850419f3fd0b527097145994d37cdb31a1b830800f25664b9a
5
5
  SHA512:
6
- metadata.gz: cb76e30cd4a80bf4d4678c9fcffa863138b4740f1c6cad666e16276381e831554ec316f35dde7d672c3a384aa779c4fdb35c8b61793b4188473a518ba5982529
7
- data.tar.gz: 309e8a705d452b5a62214b398990864d26d8c265c4a9a07f9e9240d4911273ec8633bf2452a4c689c0cabc37778bc6b610e99ce680b168ff37903529ba77f9ea
6
+ metadata.gz: 84c2d341c807d8a6aa77938cfe53b1636b8aa51736ed04ed16052f514d1121f275ee62458e9fc3bd84477af3d3694ae3303244cf20da1976e5e72e03fd22ab69
7
+ data.tar.gz: 51be843628fa155eecab895f03d445d2ea2359f5eaaed82f9b3e20a52fefa78dee496b4d4bc6fba42e36c6e60017c807ac039228f77943ba70c910921eb47bd8
@@ -14,7 +14,7 @@ COPY $GEM_FILE ./
14
14
  RUN gem install ./$GEM_FILE --no-document
15
15
  RUN rm ./$GEM_FILE
16
16
 
17
- RUN gem install pitchfork thruster
17
+ RUN gem install pitchfork thruster specwrk-store-redis_adapter
18
18
  COPY config.ru ./
19
19
  COPY docker/pitchfork.conf ./
20
20
 
data/lib/specwrk/cli.rb CHANGED
@@ -288,6 +288,8 @@ module Specwrk
288
288
 
289
289
  require "specwrk/cli_reporter"
290
290
 
291
+ title "👀 for changes"
292
+
291
293
  loop do
292
294
  status "👀 Watching for file changes..."
293
295
 
@@ -309,6 +311,7 @@ module Specwrk
309
311
  end
310
312
 
311
313
  next if example_count.zero?
314
+ title "👷 on #{example_count} examples"
312
315
 
313
316
  return if Specwrk.force_quit
314
317
  start_workers
@@ -318,8 +321,17 @@ module Specwrk
318
321
  drain_outputs
319
322
  return if Specwrk.force_quit
320
323
 
321
- Specwrk::CLIReporter.new.report
324
+ reporter = Specwrk::CLIReporter.new
325
+
326
+ status = reporter.report
322
327
  puts
328
+
329
+ if status.zero?
330
+ title "🟢 #{reporter.example_count} examples passed"
331
+ else
332
+ title " 🔴 #{reporter.failure_count}/#{reporter.example_count} examples failed"
333
+ end
334
+
323
335
  $stdout.flush
324
336
  end
325
337
 
@@ -329,6 +341,11 @@ module Specwrk
329
341
 
330
342
  private
331
343
 
344
+ def title(str)
345
+ $stdout.write "\e]0;#{str}\a"
346
+ $stdout.flush
347
+ end
348
+
332
349
  def web_pid
333
350
  @web_pid ||= Process.fork do
334
351
  require "specwrk/web"
@@ -54,8 +54,6 @@ module Specwrk
54
54
  1
55
55
  end
56
56
 
57
- private
58
-
59
57
  def totals_line
60
58
  summary = RSpec::Core::Formatters::Helpers.pluralize(example_count, "example") +
61
59
  ", " + RSpec::Core::Formatters::Helpers.pluralize(failure_count, "failure")
data/lib/specwrk/store.rb CHANGED
@@ -19,6 +19,11 @@ module Specwrk
19
19
  require "specwrk/store/file_adapter" unless defined?(FileAdapter)
20
20
 
21
21
  FileAdapter
22
+ when /redis/
23
+ # Expects the specwrk-store-redis_adapter gem to be available
24
+ require "specwrk/store/redis_adapter" unless defined?(RedisAdapter)
25
+
26
+ RedisAdapter
22
27
  end
23
28
  end
24
29
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Specwrk
4
- VERSION = "0.15.3"
4
+ VERSION = "0.15.5"
5
5
  end
@@ -16,7 +16,13 @@ end
16
16
  require "specwrk/web"
17
17
  require "specwrk/web/logger"
18
18
  require "specwrk/web/auth"
19
- require "specwrk/web/endpoints"
19
+ require "specwrk/web/endpoints/health"
20
+ require "specwrk/web/endpoints/heartbeat"
21
+ require "specwrk/web/endpoints/seed"
22
+ require "specwrk/web/endpoints/pop"
23
+ require "specwrk/web/endpoints/complete_and_pop"
24
+ require "specwrk/web/endpoints/report"
25
+ require "specwrk/web/endpoints/shutdown"
20
26
 
21
27
  module Specwrk
22
28
  class Web
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require "specwrk/store"
6
+
7
+ module Specwrk
8
+ class Web
9
+ module Endpoints
10
+ class Base
11
+ attr_reader :started_at
12
+
13
+ def initialize(request)
14
+ @request = request
15
+ end
16
+
17
+ def response
18
+ before_lock
19
+
20
+ return with_response unless run_id # No run_id, no datastore usage in the endpoint
21
+
22
+ payload # parse the payload before any locking
23
+
24
+ worker[:first_seen_at] ||= Time.now.iso8601
25
+ worker[:last_seen_at] = Time.now.iso8601
26
+
27
+ final_response = with_lock do
28
+ started_at = metadata[:started_at] ||= Time.now.iso8601
29
+ @started_at = Time.parse(started_at)
30
+
31
+ with_response
32
+ end
33
+
34
+ after_lock
35
+
36
+ final_response[1]["x-specwrk-status"] = worker_status.to_s
37
+
38
+ final_response
39
+ end
40
+
41
+ def with_response
42
+ not_found
43
+ end
44
+
45
+ private
46
+
47
+ attr_reader :request
48
+
49
+ def before_lock
50
+ end
51
+
52
+ def after_lock
53
+ end
54
+
55
+ def not_found
56
+ if request.head?
57
+ [404, {}, []]
58
+ else
59
+ [404, {"content-type" => "text/plain"}, ["This is not the path you're looking for, 'ol chap..."]]
60
+ end
61
+ end
62
+
63
+ def ok
64
+ if request.head?
65
+ [200, {}, []]
66
+ else
67
+ [200, {"content-type" => "text/plain"}, ["OK, 'ol chap"]]
68
+ end
69
+ end
70
+
71
+ def payload
72
+ return unless request.content_type&.start_with?("application/json")
73
+ return unless request.post? || request.put? || request.delete?
74
+ return if body.empty?
75
+
76
+ @payload ||= JSON.parse(body, symbolize_names: true)
77
+ end
78
+
79
+ def body
80
+ @body ||= request.body.read
81
+ end
82
+
83
+ def pending
84
+ @pending ||= PendingStore.new(ENV.fetch("SPECWRK_SRV_STORE_URI", "memory:///"), File.join(run_id, "pending"))
85
+ end
86
+
87
+ def processing
88
+ @processing ||= ProcessingStore.new(ENV.fetch("SPECWRK_SRV_STORE_URI", "memory:///"), File.join(run_id, "processing"))
89
+ end
90
+
91
+ def completed
92
+ @completed ||= CompletedStore.new(ENV.fetch("SPECWRK_SRV_STORE_URI", "memory:///"), File.join(run_id, "completed"))
93
+ end
94
+
95
+ def failure_counts
96
+ @failure_counts ||= Store.new(ENV.fetch("SPECWRK_SRV_STORE_URI", "memory:///"), File.join(run_id, "failure_counts"))
97
+ end
98
+
99
+ def metadata
100
+ @metadata ||= Store.new(ENV.fetch("SPECWRK_SRV_STORE_URI", "memory:///"), File.join(run_id, "metadata"))
101
+ end
102
+
103
+ def run_times
104
+ @run_times ||= Store.new(ENV.fetch("SPECWRK_SRV_STORE_URI", "file://#{File.join(Dir.tmpdir, "specwrk")}"), "run_times")
105
+ end
106
+
107
+ def worker
108
+ @worker ||= Store.new(ENV.fetch("SPECWRK_SRV_STORE_URI", "memory:///"), File.join(run_id, "workers", request.get_header("HTTP_X_SPECWRK_ID").to_s))
109
+ end
110
+
111
+ def worker_status
112
+ return 0 if worker[:failed].nil? && completed.any? # worker starts after run has completed
113
+
114
+ worker[:failed] || 1
115
+ end
116
+
117
+ def run_id
118
+ request.get_header("HTTP_X_SPECWRK_RUN")
119
+ end
120
+
121
+ def with_lock
122
+ Store.with_lock(URI(ENV.fetch("SPECWRK_SRV_STORE_URI", "memory:///")), "server") { yield }
123
+ end
124
+ end
125
+
126
+ # Base default response is 404
127
+ NotFound = Class.new(Base)
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "specwrk/web/endpoints/popable"
4
+
5
+ module Specwrk
6
+ class Web
7
+ module Endpoints
8
+ class CompleteAndPop < Popable
9
+ EXAMPLE_STATUSES = %w[passed failed pending]
10
+
11
+ def with_response
12
+ completed.merge!(completed_examples)
13
+ processing.delete(*(completed_examples.keys + retry_examples.keys))
14
+ pending.merge!(retry_examples)
15
+ failure_counts.merge!(retry_examples_new_failure_counts)
16
+
17
+ with_pop_response
18
+ end
19
+
20
+ private
21
+
22
+ def all_examples
23
+ @all_examples ||= payload.map { |example| [example[:id], example] if processing[example[:id]] }.compact.to_h
24
+ end
25
+
26
+ def completed_examples
27
+ @completed_examples ||= all_examples.map do |id, example|
28
+ next if retry_example?(example)
29
+
30
+ [id, example]
31
+ end.compact.to_h
32
+ end
33
+
34
+ def retry_examples
35
+ @retry_examples ||= all_examples.map do |id, example|
36
+ next unless retry_example?(example)
37
+
38
+ [id, example]
39
+ end.compact.to_h
40
+ end
41
+
42
+ def retry_examples_new_failure_counts
43
+ @retry_examples_new_failure_counts ||= retry_examples.map do |id, _example|
44
+ [id, all_example_failure_counts.fetch(id, 0) + 1]
45
+ end.to_h
46
+ end
47
+
48
+ def retry_example?(example)
49
+ return false unless example[:status] == "failed"
50
+ return false unless pending.max_retries.positive?
51
+
52
+ example_failure_count = all_example_failure_counts.fetch(example[:id], 0)
53
+
54
+ example_failure_count < pending.max_retries
55
+ end
56
+
57
+ def all_example_failure_counts
58
+ @all_example_failure_counts ||= failure_counts.multi_read(*all_examples.keys)
59
+ end
60
+
61
+ def completed_examples_status_counts
62
+ @completed_examples_status_counts ||= completed_examples.values.map { |example| example[:status] }.tally
63
+ end
64
+
65
+ def after_lock
66
+ # We don't care about exact values here, just approximate run times are fine
67
+ # So if we overwrite run times from another process it is nbd
68
+ run_times.merge! run_time_data
69
+
70
+ # workers are single proces, single-threaded, so safe to do this work without the lock
71
+ existing_status_counts = worker.multi_read(*EXAMPLE_STATUSES)
72
+ new_status_counts = EXAMPLE_STATUSES.map do |status|
73
+ [status, existing_status_counts.fetch(status, 0) + completed_examples_status_counts.fetch(status, 0)]
74
+ end.to_h
75
+
76
+ worker.merge!(new_status_counts)
77
+ end
78
+
79
+ def run_time_data
80
+ @run_time_data ||= payload.map { |example| [example[:id], example[:run_time]] }.to_h
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "specwrk/web/endpoints/base"
4
+
5
+ module Specwrk
6
+ class Web
7
+ module Endpoints
8
+ class Health < Base
9
+ def with_response
10
+ ok
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "specwrk/web/endpoints/base"
4
+
5
+ module Specwrk
6
+ class Web
7
+ module Endpoints
8
+ class Heartbeat < Base
9
+ def with_response
10
+ ok
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "specwrk/web/endpoints/popable"
4
+
5
+ module Specwrk
6
+ class Web
7
+ module Endpoints
8
+ class Pop < Popable
9
+ def with_response
10
+ with_pop_response
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "specwrk/web/endpoints/base"
4
+
5
+ module Specwrk
6
+ class Web
7
+ module Endpoints
8
+ class Popable < Base
9
+ private
10
+
11
+ def with_pop_response
12
+ if examples.any?
13
+ [200, {"content-type" => "application/json"}, [JSON.generate(examples)]]
14
+ elsif pending.empty? && processing.empty? && completed.empty?
15
+ [204, {"content-type" => "text/plain"}, ["Waiting for sample to be seeded."]]
16
+ elsif completed.any? && processing.empty?
17
+ [410, {"content-type" => "text/plain"}, ["That's a good lad. Run along now and go home."]]
18
+ elsif processing.any? && processing.expired.keys.any?
19
+ pending.merge!(processing.expired)
20
+ processing.delete(*processing.expired.keys)
21
+ @examples = nil
22
+
23
+ [200, {"content-type" => "application/json"}, [JSON.generate(examples)]]
24
+ else
25
+ not_found
26
+ end
27
+ end
28
+
29
+ def examples
30
+ @examples ||= begin
31
+ examples = pending.shift_bucket
32
+ bucket_run_time_total = examples.map { |example| example.fetch(:expected_run_time, 10.0) }.compact.sum * 2
33
+ maximum_completion_threshold = (pending.run_time_bucket_maximum || 30.0) * 2
34
+ completion_threshold = Time.now + [bucket_run_time_total, maximum_completion_threshold, 20.0].max
35
+
36
+ processing_data = examples.map do |example|
37
+ [
38
+ example[:id], example.merge(completion_threshold: completion_threshold.to_f)
39
+ ]
40
+ end
41
+
42
+ processing.merge!(processing_data.to_h)
43
+
44
+ examples
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "specwrk/web/endpoints/base"
4
+
5
+ module Specwrk
6
+ class Web
7
+ module Endpoints
8
+ class Report < Base
9
+ def with_response
10
+ completed_dump = completed.dump
11
+ completed_dump[:meta][:unexecuted] = pending.length + processing.length
12
+ completed_dump[:flakes] = failure_counts.to_h.reject { |id, _count| completed_dump.dig(:examples, id, :status) == "failed" }
13
+
14
+ [200, {"content-type" => "application/json"}, [JSON.generate(completed_dump)]]
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "specwrk/web/endpoints/base"
4
+
5
+ module Specwrk
6
+ class Web
7
+ module Endpoints
8
+ class Seed < Base
9
+ def before_lock
10
+ examples_with_run_times
11
+ end
12
+
13
+ def with_response
14
+ pending.clear
15
+ processing.clear
16
+ failure_counts.clear
17
+
18
+ pending.max_retries = payload.fetch(:max_retries, "0").to_i
19
+
20
+ new_run_time_bucket_maximums = [pending.run_time_bucket_maximum, @seeds_run_time_bucket_maximum.to_f].compact
21
+ pending.run_time_bucket_maximum = new_run_time_bucket_maximums.sum.to_f / new_run_time_bucket_maximums.length.to_f
22
+
23
+ pending.merge!(examples_with_run_times)
24
+ processing.clear
25
+ completed.clear
26
+
27
+ ok
28
+ end
29
+
30
+ def examples_with_run_times
31
+ @examples_with_run_times ||= begin
32
+ unsorted_examples_with_run_times = []
33
+ all_ids = payload[:examples].map { |example| example[:id] }
34
+ all_run_times = run_times.multi_read(*all_ids)
35
+
36
+ payload[:examples].each do |example|
37
+ run_time = all_run_times[example[:id]]
38
+
39
+ unsorted_examples_with_run_times << [example[:id], example.merge(expected_run_time: run_time)]
40
+ end
41
+
42
+ sorted_examples_with_run_times = if sort_by == :timings
43
+ unsorted_examples_with_run_times.sort_by do |entry|
44
+ -(entry.last[:expected_run_time] || Float::INFINITY)
45
+ end
46
+ else
47
+ unsorted_examples_with_run_times.sort_by do |entry|
48
+ entry.last[:file_path]
49
+ end
50
+ end
51
+
52
+ @seeds_run_time_bucket_maximum = run_time_bucket_maximum(all_run_times.values.compact)
53
+ @examples_with_run_times = sorted_examples_with_run_times.to_h
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ # Average + standard deviation
60
+ def run_time_bucket_maximum(values)
61
+ return 0 if values.length.zero?
62
+
63
+ mean = values.sum.to_f / values.size
64
+ variance = values.map { |v| (v - mean)**2 }.sum / values.size
65
+ (mean + Math.sqrt(variance)).round(2)
66
+ end
67
+
68
+ def sort_by
69
+ if ENV["SPECWRK_SRV_GROUP_BY"] == "file" || run_times.empty?
70
+ :file
71
+ else
72
+ :timings
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "specwrk/web/endpoints/base"
4
+
5
+ module Specwrk
6
+ class Web
7
+ module Endpoints
8
+ class Shutdown < Base
9
+ def with_response
10
+ interupt! if ENV["SPECWRK_SRV_SINGLE_RUN"]
11
+
12
+ [200, {"content-type" => "text/plain"}, ["✌️"]]
13
+ end
14
+
15
+ def interupt!
16
+ Thread.new do
17
+ # give the socket a moment to flush the response
18
+ sleep 0.2
19
+ Process.kill("INT", Process.pid)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ 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.15.3
4
+ version: 0.15.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel Westendorf
@@ -177,20 +177,6 @@ dependencies:
177
177
  - - ">="
178
178
  - !ruby/object:Gem::Version
179
179
  version: '0'
180
- - !ruby/object:Gem::Dependency
181
- name: gem-release
182
- requirement: !ruby/object:Gem::Requirement
183
- requirements:
184
- - - ">="
185
- - !ruby/object:Gem::Version
186
- version: '0'
187
- type: :development
188
- prerelease: false
189
- version_requirements: !ruby/object:Gem::Requirement
190
- requirements:
191
- - - ">="
192
- - !ruby/object:Gem::Version
193
- version: '0'
194
180
  email:
195
181
  - daniel@prowestech.com
196
182
  executables:
@@ -231,7 +217,15 @@ files:
231
217
  - lib/specwrk/web.rb
232
218
  - lib/specwrk/web/app.rb
233
219
  - lib/specwrk/web/auth.rb
234
- - lib/specwrk/web/endpoints.rb
220
+ - lib/specwrk/web/endpoints/base.rb
221
+ - lib/specwrk/web/endpoints/complete_and_pop.rb
222
+ - lib/specwrk/web/endpoints/health.rb
223
+ - lib/specwrk/web/endpoints/heartbeat.rb
224
+ - lib/specwrk/web/endpoints/pop.rb
225
+ - lib/specwrk/web/endpoints/popable.rb
226
+ - lib/specwrk/web/endpoints/report.rb
227
+ - lib/specwrk/web/endpoints/seed.rb
228
+ - lib/specwrk/web/endpoints/shutdown.rb
235
229
  - lib/specwrk/web/logger.rb
236
230
  - lib/specwrk/worker.rb
237
231
  - lib/specwrk/worker/completion_formatter.rb
@@ -1,360 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
-
5
- require "specwrk/store"
6
-
7
- module Specwrk
8
- class Web
9
- module Endpoints
10
- class Base
11
- attr_reader :started_at
12
-
13
- def initialize(request)
14
- @request = request
15
- end
16
-
17
- def response
18
- before_lock
19
-
20
- return with_response unless run_id # No run_id, no datastore usage in the endpoint
21
-
22
- payload # parse the payload before any locking
23
-
24
- worker[:first_seen_at] ||= Time.now.iso8601
25
- worker[:last_seen_at] = Time.now.iso8601
26
-
27
- final_response = with_lock do
28
- started_at = metadata[:started_at] ||= Time.now.iso8601
29
- @started_at = Time.parse(started_at)
30
-
31
- with_response
32
- end
33
-
34
- after_lock
35
-
36
- final_response[1]["x-specwrk-status"] = worker_status.to_s
37
-
38
- final_response
39
- end
40
-
41
- def with_response
42
- not_found
43
- end
44
-
45
- private
46
-
47
- attr_reader :request
48
-
49
- def before_lock
50
- end
51
-
52
- def after_lock
53
- end
54
-
55
- def not_found
56
- if request.head?
57
- [404, {}, []]
58
- else
59
- [404, {"content-type" => "text/plain"}, ["This is not the path you're looking for, 'ol chap..."]]
60
- end
61
- end
62
-
63
- def ok
64
- if request.head?
65
- [200, {}, []]
66
- else
67
- [200, {"content-type" => "text/plain"}, ["OK, 'ol chap"]]
68
- end
69
- end
70
-
71
- def payload
72
- return unless request.content_type&.start_with?("application/json")
73
- return unless request.post? || request.put? || request.delete?
74
- return if body.empty?
75
-
76
- @payload ||= JSON.parse(body, symbolize_names: true)
77
- end
78
-
79
- def body
80
- @body ||= request.body.read
81
- end
82
-
83
- def pending
84
- @pending ||= PendingStore.new(ENV.fetch("SPECWRK_SRV_STORE_URI", "memory:///"), File.join(run_id, "pending"))
85
- end
86
-
87
- def processing
88
- @processing ||= ProcessingStore.new(ENV.fetch("SPECWRK_SRV_STORE_URI", "memory:///"), File.join(run_id, "processing"))
89
- end
90
-
91
- def completed
92
- @completed ||= CompletedStore.new(ENV.fetch("SPECWRK_SRV_STORE_URI", "memory:///"), File.join(run_id, "completed"))
93
- end
94
-
95
- def failure_counts
96
- @failure_counts ||= Store.new(ENV.fetch("SPECWRK_SRV_STORE_URI", "memory:///"), File.join(run_id, "failure_counts"))
97
- end
98
-
99
- def metadata
100
- @metadata ||= Store.new(ENV.fetch("SPECWRK_SRV_STORE_URI", "memory:///"), File.join(run_id, "metadata"))
101
- end
102
-
103
- def run_times
104
- @run_times ||= Store.new(ENV.fetch("SPECWRK_SRV_STORE_URI", "file://#{File.join(Dir.tmpdir, "specwrk")}"), "run_times")
105
- end
106
-
107
- def worker
108
- @worker ||= Store.new(ENV.fetch("SPECWRK_SRV_STORE_URI", "memory:///"), File.join(run_id, "workers", request.get_header("HTTP_X_SPECWRK_ID").to_s))
109
- end
110
-
111
- def worker_status
112
- return 0 if worker[:failed].nil? && completed.any? # worker starts after run has completed
113
-
114
- worker[:failed] || 1
115
- end
116
-
117
- def run_id
118
- request.get_header("HTTP_X_SPECWRK_RUN")
119
- end
120
-
121
- def with_lock
122
- Store.with_lock(URI(ENV.fetch("SPECWRK_SRV_STORE_URI", "memory:///")), "server") { yield }
123
- end
124
- end
125
-
126
- # Base default response is 404
127
- NotFound = Class.new(Base)
128
-
129
- class Health < Base
130
- def with_response
131
- ok
132
- end
133
- end
134
-
135
- class Heartbeat < Base
136
- def with_response
137
- ok
138
- end
139
- end
140
-
141
- class Seed < Base
142
- def before_lock
143
- examples_with_run_times
144
- end
145
-
146
- def with_response
147
- pending.clear
148
- processing.clear
149
- failure_counts.clear
150
-
151
- pending.max_retries = payload.fetch(:max_retries, "0").to_i
152
-
153
- new_run_time_bucket_maximums = [pending.run_time_bucket_maximum, @seeds_run_time_bucket_maximum.to_f].compact
154
- pending.run_time_bucket_maximum = new_run_time_bucket_maximums.sum.to_f / new_run_time_bucket_maximums.length.to_f
155
-
156
- pending.merge!(examples_with_run_times)
157
- processing.clear
158
- completed.clear
159
-
160
- ok
161
- end
162
-
163
- def examples_with_run_times
164
- @examples_with_run_times ||= begin
165
- unsorted_examples_with_run_times = []
166
- all_ids = payload[:examples].map { |example| example[:id] }
167
- all_run_times = run_times.multi_read(*all_ids)
168
-
169
- payload[:examples].each do |example|
170
- run_time = all_run_times[example[:id]]
171
-
172
- unsorted_examples_with_run_times << [example[:id], example.merge(expected_run_time: run_time)]
173
- end
174
-
175
- sorted_examples_with_run_times = if sort_by == :timings
176
- unsorted_examples_with_run_times.sort_by do |entry|
177
- -(entry.last[:expected_run_time] || Float::INFINITY)
178
- end
179
- else
180
- unsorted_examples_with_run_times.sort_by do |entry|
181
- entry.last[:file_path]
182
- end
183
- end
184
-
185
- @seeds_run_time_bucket_maximum = run_time_bucket_maximum(all_run_times.values.compact)
186
- @examples_with_run_times = sorted_examples_with_run_times.to_h
187
- end
188
- end
189
-
190
- private
191
-
192
- # Average + standard deviation
193
- def run_time_bucket_maximum(values)
194
- return 0 if values.length.zero?
195
-
196
- mean = values.sum.to_f / values.size
197
- variance = values.map { |v| (v - mean)**2 }.sum / values.size
198
- (mean + Math.sqrt(variance)).round(2)
199
- end
200
-
201
- def sort_by
202
- if ENV["SPECWRK_SRV_GROUP_BY"] == "file" || run_times.empty?
203
- :file
204
- else
205
- :timings
206
- end
207
- end
208
- end
209
-
210
- class Popable < Base
211
- private
212
-
213
- def with_pop_response
214
- if examples.any?
215
- [200, {"content-type" => "application/json"}, [JSON.generate(examples)]]
216
- elsif pending.empty? && processing.empty? && completed.empty?
217
- [204, {"content-type" => "text/plain"}, ["Waiting for sample to be seeded."]]
218
- elsif completed.any? && processing.empty?
219
- [410, {"content-type" => "text/plain"}, ["That's a good lad. Run along now and go home."]]
220
- elsif processing.any? && processing.expired.keys.any?
221
- pending.merge!(processing.expired)
222
- processing.delete(*processing.expired.keys)
223
- @examples = nil
224
-
225
- [200, {"content-type" => "application/json"}, [JSON.generate(examples)]]
226
- else
227
- not_found
228
- end
229
- end
230
-
231
- def examples
232
- @examples ||= begin
233
- examples = pending.shift_bucket
234
- bucket_run_time_total = examples.map { |example| example.fetch(:expected_run_time, 10.0) }.compact.sum * 2
235
- maximum_completion_threshold = (pending.run_time_bucket_maximum || 30.0) * 2
236
- completion_threshold = Time.now + [bucket_run_time_total, maximum_completion_threshold, 20.0].max
237
-
238
- processing_data = examples.map do |example|
239
- [
240
- example[:id], example.merge(completion_threshold: completion_threshold.to_f)
241
- ]
242
- end
243
-
244
- processing.merge!(processing_data.to_h)
245
-
246
- examples
247
- end
248
- end
249
- end
250
-
251
- class Pop < Popable
252
- def with_response
253
- with_pop_response
254
- end
255
- end
256
-
257
- class CompleteAndPop < Popable
258
- EXAMPLE_STATUSES = %w[passed failed pending]
259
-
260
- def with_response
261
- completed.merge!(completed_examples)
262
- processing.delete(*(completed_examples.keys + retry_examples.keys))
263
- pending.merge!(retry_examples)
264
- failure_counts.merge!(retry_examples_new_failure_counts)
265
-
266
- with_pop_response
267
- end
268
-
269
- private
270
-
271
- def all_examples
272
- @all_examples ||= payload.map { |example| [example[:id], example] if processing[example[:id]] }.compact.to_h
273
- end
274
-
275
- def completed_examples
276
- @completed_examples ||= all_examples.map do |id, example|
277
- next if retry_example?(example)
278
-
279
- [id, example]
280
- end.compact.to_h
281
- end
282
-
283
- def retry_examples
284
- @retry_examples ||= all_examples.map do |id, example|
285
- next unless retry_example?(example)
286
-
287
- [id, example]
288
- end.compact.to_h
289
- end
290
-
291
- def retry_examples_new_failure_counts
292
- @retry_examples_new_failure_counts ||= retry_examples.map do |id, _example|
293
- [id, all_example_failure_counts.fetch(id, 0) + 1]
294
- end.to_h
295
- end
296
-
297
- def retry_example?(example)
298
- return false unless example[:status] == "failed"
299
- return false unless pending.max_retries.positive?
300
-
301
- example_failure_count = all_example_failure_counts.fetch(example[:id], 0)
302
-
303
- example_failure_count < pending.max_retries
304
- end
305
-
306
- def all_example_failure_counts
307
- @all_example_failure_counts ||= failure_counts.multi_read(*all_examples.keys)
308
- end
309
-
310
- def completed_examples_status_counts
311
- @completed_examples_status_counts ||= completed_examples.values.map { |example| example[:status] }.tally
312
- end
313
-
314
- def after_lock
315
- # We don't care about exact values here, just approximate run times are fine
316
- # So if we overwrite run times from another process it is nbd
317
- run_times.merge! run_time_data
318
-
319
- # workers are single proces, single-threaded, so safe to do this work without the lock
320
- existing_status_counts = worker.multi_read(*EXAMPLE_STATUSES)
321
- new_status_counts = EXAMPLE_STATUSES.map do |status|
322
- [status, existing_status_counts.fetch(status, 0) + completed_examples_status_counts.fetch(status, 0)]
323
- end.to_h
324
-
325
- worker.merge!(new_status_counts)
326
- end
327
-
328
- def run_time_data
329
- @run_time_data ||= payload.map { |example| [example[:id], example[:run_time]] }.to_h
330
- end
331
- end
332
-
333
- class Report < Base
334
- def with_response
335
- completed_dump = completed.dump
336
- completed_dump[:meta][:unexecuted] = pending.length + processing.length
337
- completed_dump[:flakes] = failure_counts.to_h.reject { |id, _count| completed_dump.dig(:examples, id, :status) == "failed" }
338
-
339
- [200, {"content-type" => "application/json"}, [JSON.generate(completed_dump)]]
340
- end
341
- end
342
-
343
- class Shutdown < Base
344
- def with_response
345
- interupt! if ENV["SPECWRK_SRV_SINGLE_RUN"]
346
-
347
- [200, {"content-type" => "text/plain"}, ["✌️"]]
348
- end
349
-
350
- def interupt!
351
- Thread.new do
352
- # give the socket a moment to flush the response
353
- sleep 0.2
354
- Process.kill("INT", Process.pid)
355
- end
356
- end
357
- end
358
- end
359
- end
360
- end