specwrk 0.15.2 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 361cb3f6e2abf6270f164554f9b6c251c4337353490dd90c46d15c8c8bec102a
4
- data.tar.gz: d12fd20a3fca8159fdbf67495314052763fb7f6d867cdb1234ca744af6e2d8cf
3
+ metadata.gz: e7c81e77211a27632313439e1fb98376798e5c606151fa0e110ae2f64823ccda
4
+ data.tar.gz: 9fdb03226d2d3a57e79ee3701ede4e36bf12a43369ecb298dca40999813d4606
5
5
  SHA512:
6
- metadata.gz: 67f5e4f5b98b12f8ca5b0d1a97da4b997a26d055e0f241433b7bdf3fa7f02e2b45cfeacb98ec1b851956e092de8a3fe16390a6bb3c4b67c6a3343ebfe7d09e69
7
- data.tar.gz: 17b9ee079bb353bfe1c0ca295029f1653552886470ac93b9c3a24bf643b36f0cd2dbf7a80fe31d22adc9e9ab5f57e435922daa7162e352482100be36e9d332e7
6
+ metadata.gz: c3822e6d7f2de884e56ce1374abf05809f7c7bc24c8c06fafc46e69a1b5a2ae796abedc7fc0b0be2ab6b468f36cf9370469ac1e5c0e9696b96496f21a5bf391e
7
+ data.tar.gz: ef4829b50269101858c6b95dfd51ed4671eda52319c552e624f47a8b7d028c8f4347d38fd8c8acfcc0853625dff6f3838d5ae2330b3db0a02a0d07d2ee35dedd
data/CHANGELOG.md CHANGED
@@ -1,5 +1,119 @@
1
1
  ## [Unreleased]
2
2
 
3
+ # Changelog
4
+
5
+ ## v0.15.2 — 2025-08-15
6
+ [Compare](https://github.com/danielwestendorf/specwrk/compare/v0.15.1...v0.15.2)
7
+ - Fix bug where ruby objects were being written to ndjson files isntead of json — [#119](https://github.com/danielwestendorf/specwrk/pull/119) by @danielwestendorf
8
+
9
+ ## v0.15.1 — 2025-08-14
10
+ [Compare](https://github.com/danielwestendorf/specwrk/compare/v0.15.0...v0.15.1)
11
+ - Add `watch` command to split spec files across processes as they change — [#117](https://github.com/danielwestendorf/specwrk/pull/117) by @danielwestendorf
12
+ - Readme formatting tweaks — @danielwestendorf
13
+
14
+ ## v0.15.0 — 2025-08-08
15
+ [Compare](https://github.com/danielwestendorf/specwrk/compare/v0.14.1...v0.15.0)
16
+ - When output path is specified, write an `.ndjson` file per worker of all examples executed — [#113](https://github.com/danielwestendorf/specwrk/pull/113) (fixes [#10](https://github.com/danielwestendorf/specwrk/issues/10)) by @danielwestendorf
17
+ - Print re-run commands for failures — [#114](https://github.com/danielwestendorf/specwrk/pull/114) (fixes [#7](https://github.com/danielwestendorf/specwrk/issues/7)) by @danielwestendorf
18
+ - Show count of examples that did not execute; make runs resumable by only clearing stores on seed — [#115](https://github.com/danielwestendorf/specwrk/pull/115) by @danielwestendorf
19
+ - Report flake counts and provide flake re-run commands — [#116](https://github.com/danielwestendorf/specwrk/pull/116) (fixes [#112](https://github.com/danielwestendorf/specwrk/issues/112)) by @danielwestendorf
20
+
21
+ ## v0.14.1 — 2025-08-07
22
+ [Compare](https://github.com/danielwestendorf/specwrk/compare/v0.14.0...v0.14.1)
23
+ - Fix CLI typo — @danielwestendorf
24
+
25
+ ## v0.14.0 — 2025-08-07
26
+ [Compare](https://github.com/danielwestendorf/specwrk/compare/v0.13.1...v0.14.0)
27
+ - Add support for example retries (`--max-retries` for `start` and `seed`) — [#111](https://github.com/danielwestendorf/specwrk/pull/111) (addresses [#96](https://github.com/danielwestendorf/specwrk/issues/96)) by @danielwestendorf
28
+
29
+ ## v0.13.1 — 2025-08-07
30
+ [Compare](https://github.com/danielwestendorf/specwrk/compare/v0.13.0...v0.13.1)
31
+ - Require `securerandom` in the FileAdapter to ensure availability — @danielwestendorf
32
+
33
+ ## v0.13.0 — 2025-08-07
34
+ [Compare](https://github.com/danielwestendorf/specwrk/compare/v0.12.0...v0.13.0)
35
+ - Avoid multi-process tempfile conflicts by adding a UUID to tempfile names — [#108](https://github.com/danielwestendorf/specwrk/pull/108) (fixes [#107](https://github.com/danielwestendorf/specwrk/issues/107)) by @danielwestendorf
36
+ - Remove deprecated `complete` endpoint — [#109](https://github.com/danielwestendorf/specwrk/pull/109) (fixes [#95](https://github.com/danielwestendorf/specwrk/issues/95)) by @danielwestendorf
37
+ - Track worker success metrics (succeeded/pending/failed) and use them to instruct worker exit behavior — [#110](https://github.com/danielwestendorf/specwrk/pull/110) (fixes [#97](https://github.com/danielwestendorf/specwrk/issues/97)) by @danielwestendorf
38
+
39
+ ## v0.12.0 — 2025-08-06
40
+ [Compare](https://github.com/danielwestendorf/specwrk/compare/v0.11.1...v0.12.0)
41
+ - Don’t return a body for `HEAD` requests in auth middleware — refs [#102](https://github.com/danielwestendorf/specwrk/issues/102) — @danielwestendorf
42
+ - Filter `specwrk` from backtraces — @danielwestendorf
43
+ - Move store order tracking to the only store class that needs it (performance improvement) — [#105](https://github.com/danielwestendorf/specwrk/pull/105) by @danielwestendorf
44
+ - Humanize seconds output — [#106](https://github.com/danielwestendorf/specwrk/pull/106) (fixes [#11](https://github.com/danielwestendorf/specwrk/issues/11)) by @danielwestendorf
45
+
46
+ ## v0.11.1 — 2025-08-05
47
+ [Compare](https://github.com/danielwestendorf/specwrk/compare/v0.11.0...v0.11.1)
48
+ - Avoid doing work in `before_lock` for `complete` and `pop` endpoints — [#104](https://github.com/danielwestendorf/specwrk/pull/104) (fixes [#103](https://github.com/danielwestendorf/specwrk/issues/103)) by @danielwestendorf
49
+
50
+ ## v0.10.2 — 2025-08-01
51
+ [Compare](https://github.com/danielwestendorf/specwrk/compare/v0.10.1...v0.10.2)
52
+ - Add `complete_and_pop` endpoint to reduce HTTP requests ~50% — [#88](https://github.com/danielwestendorf/specwrk/pull/88) by @danielwestendorf
53
+ - File-per-example file store — [#87](https://github.com/danielwestendorf/specwrk/pull/87) by @danielwestendorf
54
+ - Remove thread safety (follow-up to thread pool changes) — [#89](https://github.com/danielwestendorf/specwrk/pull/89) by @danielwestendorf
55
+ - Fix thread pool start — [#90](https://github.com/danielwestendorf/specwrk/pull/90) by @danielwestendorf
56
+
57
+ ## v0.10.1 — 2025-08-01
58
+ [Compare](https://github.com/danielwestendorf/specwrk/compare/v0.10.0...v0.10.1)
59
+ - Internal cleanups and versioning adjustments for the 0.10.x line
60
+
61
+ ## v0.10.0 — 2025-08-01
62
+ [Compare](https://github.com/danielwestendorf/specwrk/compare/v0.9.1...v0.10.0)
63
+ - Prep for 0.10 baseline (no PRs explicitly tied beyond those in 0.10.1/0.10.2)
64
+
65
+ ## v0.9.1 — 2025-08-01
66
+ [Compare](https://github.com/danielwestendorf/specwrk/compare/v0.9.0...v0.9.1)
67
+ - Minor fixes and tag adjustments
68
+
69
+ ## v0.9.0 — 2025-07-30
70
+ [Compare](https://github.com/danielwestendorf/specwrk/compare/v0.8.0...v0.9.0)
71
+ - File-per-example store groundwork — see [#87](https://github.com/danielwestendorf/specwrk/pull/87) by @danielwestendorf
72
+
73
+ ## v0.8.0 — 2025-07-22
74
+ [Compare](https://github.com/danielwestendorf/specwrk/compare/v0.7.1...v0.8.0)
75
+ - Revert datastore and related adjustments — [#86](https://github.com/danielwestendorf/specwrk/pull/86) by @danielwestendorf
76
+ - Just return response if no `run_id` header — [#85](https://github.com/danielwestendorf/specwrk/pull/85) by @danielwestendorf
77
+ - On `INT`, if RSpec is defined set `wants_to_quit = true` — [#84](https://github.com/danielwestendorf/specwrk/pull/84) by @danielwestendorf
78
+ - Datastore for queues — [#83](https://github.com/danielwestendorf/specwrk/pull/83) by @danielwestendorf
79
+
80
+ ## v0.7.1 — 2025-07-22
81
+ [Compare](https://github.com/danielwestendorf/specwrk/compare/v0.7.0...v0.7.1)
82
+ - Minor tag-only bump
83
+
84
+ ## v0.7.0 — 2025-07-22
85
+ [Compare](https://github.com/danielwestendorf/specwrk/compare/v0.6.3...v0.7.0)
86
+ - Only exit 1 when no examples processed and run not completed — [#82](https://github.com/danielwestendorf/specwrk/pull/82) by @danielwestendorf
87
+ - Switch from Puma to Pitchfork — [#79](https://github.com/danielwestendorf/specwrk/pull/79) by @danielwestendorf
88
+ - Dump all runs as unique — [#78](https://github.com/danielwestendorf/specwrk/pull/78) by @danielwestendorf
89
+
90
+ ## v0.6.3 — 2025-07-17
91
+ [Compare](https://github.com/danielwestendorf/specwrk/compare/v0.6.2...v0.6.3)
92
+ - Reap queues based on staleness of workers — [#76](https://github.com/danielwestendorf/specwrk/pull/76) by @danielwestendorf
93
+ - Track worker `first_seen_at` / `last_seen_at` — [#75](https://github.com/danielwestendorf/specwrk/pull/75) by @danielwestendorf
94
+ - Unique default ID when no `SPECWRK_ID` provided — [#74](https://github.com/danielwestendorf/specwrk/pull/74) by @danielwestendorf
95
+ - Send `X-Specwrk-Id` and `User-Agent` headers — [#71](https://github.com/danielwestendorf/specwrk/pull/71) by @danielwestendorf
96
+
97
+ ## v0.6.2 — 2025-07-17
98
+ [Compare](https://github.com/danielwestendorf/specwrk/compare/v0.6.1...v0.6.2)
99
+ - Tag-only bump
100
+
101
+ ## v0.6.1 — 2025-07-17
102
+ [Compare](https://github.com/danielwestendorf/specwrk/compare/v0.6.0...v0.6.1)
103
+ - Tag-only bump
104
+
105
+ ## v0.6.0 — 2025-07-17
106
+ [Compare](https://github.com/danielwestendorf/specwrk/compare/v0.5.0...v0.6.0)
107
+ - Output success message when seeding succeeds — [#67](https://github.com/danielwestendorf/specwrk/pull/67) by @danielwestendorf
108
+
109
+ ## v0.5.0 — 2025-07-15
110
+ [Compare](https://github.com/danielwestendorf/specwrk/compare/v0.4.11...v0.5.0)
111
+ - Support specifying if subsequent seeds should be ignored for a run — [#37](https://github.com/danielwestendorf/specwrk/pull/37) by @danielwestendorf
112
+ - Make number of seed waits configurable — [#35](https://github.com/danielwestendorf/specwrk/pull/35) by @danielwestendorf
113
+ - Worker wait for seeding — [#32](https://github.com/danielwestendorf/specwrk/pull/32) by @danielwestendorf
114
+ - Track if the worker has processed *any* examples — [#31](https://github.com/danielwestendorf/specwrk/pull/31) by @danielwestendorf
115
+
116
+
3
117
  ## v0.4.11
4
118
 
5
119
  - Move logic out of CLI methods ([#64](https://github.com/danielwestendorf/specwrk/issues/64)) by @danielwestendorf
data/README.md CHANGED
@@ -26,6 +26,7 @@ Commands:
26
26
  specwrk serve # Start a queue server
27
27
  specwrk start [DIR] # Start a server and workers, monitor until complete
28
28
  specwrk version # Print version
29
+ specwrk watch # Start a server and workers, watch for file changes in the current directory, and execute specs
29
30
  specwrk work # Start one or more worker processes
30
31
  ```
31
32
 
@@ -261,6 +262,22 @@ end
261
262
  # end
262
263
  ```
263
264
 
265
+ ## Prior/other works
266
+ There are many prior works for running rspec tests across multiple processes. Most of them combine process output making failures hard to grok. Some are good at running tests locally, but not on CI, while others are invserly true. Others are comercial or impactical without making a purchase.
267
+
268
+ specwrk is different because it:
269
+ 1. Puts your developer experience first. Easy execution. No messy outputs. Retries built in. Easy(er) debugging of flaky tests.
270
+ 2. Is the same tool you use in development on your local box, in CI with a single runner, or CI with N number of runners.
271
+ 3. Is easy to deploy and manage a queue server.
272
+ 4. RSpec-only.
273
+ 5. Is free.
274
+
275
+ [parallel_rspec](https://github.com/willbryant/parallel_rspec)
276
+ [Knapsack](https://github.com/KnapsackPro/knapsack)
277
+ [parallel_tests](https://github.com/grosser/parallel_tests)
278
+ [rspecq](https://github.com/skroutz/rspecq)
279
+ [RSpec ABQ](https://github.com/rwx-research/rspec-abq)
280
+
264
281
  ## Contributing
265
282
 
266
283
  Bug reports and pull requests are welcome on GitHub at https://github.com/dwestendorf/specwrk.
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")
@@ -59,6 +59,8 @@ module Specwrk
59
59
  RSpec.world.wants_to_quit = Specwrk.force_quit
60
60
 
61
61
  RSpec.configuration.silence_filter_announcements = true
62
+ RSpec.configuration.filter_manager.inclusions.clear
63
+ RSpec.configuration.filter_manager.exclusions.clear
62
64
 
63
65
  true
64
66
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Specwrk
4
- VERSION = "0.15.2"
4
+ VERSION = "0.15.4"
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.2
4
+ version: 0.15.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel Westendorf
@@ -202,7 +202,7 @@ files:
202
202
  - ".rspec"
203
203
  - ".standard.yml"
204
204
  - CHANGELOG.md
205
- - LICENSE.txt
205
+ - LICENSE
206
206
  - README.md
207
207
  - Rakefile
208
208
  - Specwrk.watchfile.rb
@@ -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
- maximum_completion_threshold = (Time.now + ((pending.run_time_bucket_maximum || 30) * 2)).to_i
235
-
236
- processing_data = examples.map do |example|
237
- example_run_time_completion_threshold = (Time.now + example[:expected_run_time].to_f * 2).to_i
238
-
239
- [
240
- example[:id], example.merge(completion_threshold: [maximum_completion_threshold, example_run_time_completion_threshold].compact.max)
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
File without changes