specwrk 0.15.3 → 0.15.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 +4 -4
- data/lib/specwrk/cli.rb +18 -1
- data/lib/specwrk/cli_reporter.rb +0 -2
- data/lib/specwrk/version.rb +1 -1
- data/lib/specwrk/web/app.rb +7 -1
- data/lib/specwrk/web/endpoints/base.rb +130 -0
- data/lib/specwrk/web/endpoints/complete_and_pop.rb +85 -0
- data/lib/specwrk/web/endpoints/health.rb +15 -0
- data/lib/specwrk/web/endpoints/heartbeat.rb +15 -0
- data/lib/specwrk/web/endpoints/pop.rb +15 -0
- data/lib/specwrk/web/endpoints/popable.rb +50 -0
- data/lib/specwrk/web/endpoints/report.rb +19 -0
- data/lib/specwrk/web/endpoints/seed.rb +78 -0
- data/lib/specwrk/web/endpoints/shutdown.rb +25 -0
- metadata +10 -2
- data/lib/specwrk/web/endpoints.rb +0 -360
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: e7c81e77211a27632313439e1fb98376798e5c606151fa0e110ae2f64823ccda
|
4
|
+
data.tar.gz: 9fdb03226d2d3a57e79ee3701ede4e36bf12a43369ecb298dca40999813d4606
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: c3822e6d7f2de884e56ce1374abf05809f7c7bc24c8c06fafc46e69a1b5a2ae796abedc7fc0b0be2ab6b468f36cf9370469ac1e5c0e9696b96496f21a5bf391e
|
7
|
+
data.tar.gz: ef4829b50269101858c6b95dfd51ed4671eda52319c552e624f47a8b7d028c8f4347d38fd8c8acfcc0853625dff6f3838d5ae2330b3db0a02a0d07d2ee35dedd
|
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
|
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"
|
data/lib/specwrk/cli_reporter.rb
CHANGED
data/lib/specwrk/version.rb
CHANGED
data/lib/specwrk/web/app.rb
CHANGED
@@ -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,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.
|
4
|
+
version: 0.15.4
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Daniel Westendorf
|
@@ -231,7 +231,15 @@ files:
|
|
231
231
|
- lib/specwrk/web.rb
|
232
232
|
- lib/specwrk/web/app.rb
|
233
233
|
- lib/specwrk/web/auth.rb
|
234
|
-
- lib/specwrk/web/endpoints.rb
|
234
|
+
- lib/specwrk/web/endpoints/base.rb
|
235
|
+
- lib/specwrk/web/endpoints/complete_and_pop.rb
|
236
|
+
- lib/specwrk/web/endpoints/health.rb
|
237
|
+
- lib/specwrk/web/endpoints/heartbeat.rb
|
238
|
+
- lib/specwrk/web/endpoints/pop.rb
|
239
|
+
- lib/specwrk/web/endpoints/popable.rb
|
240
|
+
- lib/specwrk/web/endpoints/report.rb
|
241
|
+
- lib/specwrk/web/endpoints/seed.rb
|
242
|
+
- lib/specwrk/web/endpoints/shutdown.rb
|
235
243
|
- lib/specwrk/web/logger.rb
|
236
244
|
- lib/specwrk/worker.rb
|
237
245
|
- 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
|