rspec-hopper 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +10 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +537 -0
  5. data/Rakefile +10 -0
  6. data/docs/DESIGN.md +386 -0
  7. data/exe/rspec-hopper +6 -0
  8. data/lib/rspec/hopper/attempt_log.rb +133 -0
  9. data/lib/rspec/hopper/ci_env.rb +87 -0
  10. data/lib/rspec/hopper/cli/formatter_args.rb +129 -0
  11. data/lib/rspec/hopper/cli/report.rb +117 -0
  12. data/lib/rspec/hopper/cli/work/parser.rb +166 -0
  13. data/lib/rspec/hopper/cli/work.rb +59 -0
  14. data/lib/rspec/hopper/cli.rb +61 -0
  15. data/lib/rspec/hopper/config.rb +48 -0
  16. data/lib/rspec/hopper/errors.rb +88 -0
  17. data/lib/rspec/hopper/example_reset.rb +41 -0
  18. data/lib/rspec/hopper/fingerprint.rb +185 -0
  19. data/lib/rspec/hopper/keys.rb +38 -0
  20. data/lib/rspec/hopper/manifest.rb +113 -0
  21. data/lib/rspec/hopper/queue/redis_streams/lua/init.lua +94 -0
  22. data/lib/rspec/hopper/queue/redis_streams/lua/transition.lua +476 -0
  23. data/lib/rspec/hopper/queue/redis_streams.rb +307 -0
  24. data/lib/rspec/hopper/queue.rb +24 -0
  25. data/lib/rspec/hopper/report.rb +286 -0
  26. data/lib/rspec/hopper/reservation.rb +18 -0
  27. data/lib/rspec/hopper/supervisor.rb +196 -0
  28. data/lib/rspec/hopper/unit.rb +15 -0
  29. data/lib/rspec/hopper/version.rb +7 -0
  30. data/lib/rspec/hopper/worker/buffering_reporter.rb +51 -0
  31. data/lib/rspec/hopper/worker/heartbeat.rb +178 -0
  32. data/lib/rspec/hopper/worker/requeue_policy.rb +86 -0
  33. data/lib/rspec/hopper/worker/runner.rb +28 -0
  34. data/lib/rspec/hopper/worker/suite.rb +205 -0
  35. data/lib/rspec/hopper/worker.rb +299 -0
  36. data/lib/rspec/hopper.rb +65 -0
  37. metadata +137 -0
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec/core"
4
+
5
+ module RSpec
6
+ module Hopper
7
+ # Makes completed examples rerunnable in the same process.
8
+ #
9
+ # rspec-core does not expose a per-example reset: `@exception` is set once
10
+ # and never cleared by `run`, `finish` reports failure whenever it is
11
+ # present, and the ExecutionResult in metadata is mutated in place. This is
12
+ # the one sanctioned touch of Example internals (see the product spec,
13
+ # "Retry state isolation"). It changes no method resolution, so it cannot
14
+ # collide with gems that prepend onto Example.
15
+ module ExampleReset
16
+ # Pinned after a run under bare rspec-core; the contract spec fails loudly
17
+ # if a patch release adds state we would have to reset too.
18
+ EXPECTED_IVARS = %i[
19
+ @clock @example_block @example_group_class @example_group_instance @exception @id @metadata @reporter
20
+ ].freeze
21
+
22
+ module_function
23
+
24
+ # Resets every selected example in the given top-level groups and all of
25
+ # their descendants. Returns the number of examples reset.
26
+ def reset(example_groups)
27
+ Array(example_groups).sum do |group|
28
+ group.descendants.sum do |descendant|
29
+ descendant.filtered_examples.each { |example| reset_example(example) }.size
30
+ end
31
+ end
32
+ end
33
+
34
+ def reset_example(example)
35
+ example.instance_variable_set(:@exception, nil)
36
+ example.metadata[:execution_result] = RSpec::Core::Example::ExecutionResult.new
37
+ example
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module RSpec
7
+ module Hopper
8
+ # Proves that workers loaded the same logical suite, not merely that they were
9
+ # given the same arguments. Computed after loading, over the normalized
10
+ # selection inputs, the ordering strategy name (never the seed), the sorted
11
+ # selected example ids and the optional revision string.
12
+ class Fingerprint
13
+ INPUT_KEYS = %w[file_args filter pattern exclude_pattern order example_ids revision].freeze
14
+ PROC_ADDRESS = /0x[0-9a-f]+@?/
15
+ # Per-input digests are recorded in the manifest so a mismatching worker
16
+ # can name the inputs that differ. Truncated: they are compared with each
17
+ # other, never used as a security boundary, and `meta` stays small.
18
+ DIGEST_LENGTH = 16
19
+
20
+ attr_reader :value, :inputs
21
+
22
+ class << self
23
+ # @param configuration [RSpec::Core::Configuration] the configured RSpec
24
+ # @param options [RSpec::Core::ConfigurationOptions] the merged options
25
+ # @param example_ids [Array<String>] ids of the selected examples
26
+ # @param file_args [Array<String>] normalized file arguments
27
+ # @param revision [String, nil]
28
+ def compute(configuration:, options:, example_ids:, file_args:, revision: nil)
29
+ filter_manager = configuration.filter_manager
30
+ new(
31
+ "file_args" => Array(file_args).map(&:to_s).sort,
32
+ "filter" => {
33
+ "inclusions" => render_rules(filter_manager.inclusions.rules),
34
+ "exclusions" => render_rules(filter_manager.exclusions.rules)
35
+ },
36
+ "pattern" => configuration.pattern.to_s,
37
+ "exclude_pattern" => configuration.exclude_pattern.to_s,
38
+ "order" => ordering_name(options.options[:order]),
39
+ "example_ids" => Array(example_ids).map(&:to_s).sort,
40
+ "revision" => revision&.to_s
41
+ )
42
+ end
43
+
44
+ # The strategy name of an `--order` option value, without any seed.
45
+ def ordering_name(option)
46
+ name = option.to_s.split(":").first.to_s
47
+ return "defined" if name.empty?
48
+
49
+ name.include?("rand") ? "random" : name
50
+ end
51
+
52
+ # Filter rules as deterministic strings: proc addresses are stripped and
53
+ # the project directory (locations are absolute) is replaced by ".".
54
+ def render_rules(rules)
55
+ project_dir = File.expand_path(".")
56
+ rules.map { |key, value| "#{key}=#{render_value(value)}".gsub(PROC_ADDRESS, "").gsub(project_dir, ".") }.sort
57
+ end
58
+
59
+ # Renders a filter value without going through the built-in `inspect`
60
+ # for containers. Ruby 3.4 changed `Hash#inspect` from `{"a"=>1}` to
61
+ # `{"a" => 1}`, which would otherwise make the same suite fingerprint
62
+ # differently on either side of that release. Hash pairs are sorted so
63
+ # insertion order cannot change the result either.
64
+ def render_value(value)
65
+ case value
66
+ when Hash
67
+ pairs = value.map { |k, v| "#{render_value(k)} => #{render_value(v)}" }.sort
68
+ "{#{pairs.join(", ")}}"
69
+ when Array
70
+ "[#{value.map { |element| render_value(element) }.join(", ")}]"
71
+ else
72
+ value.inspect
73
+ end
74
+ end
75
+
76
+ # A short digest of one input value, stable across Ruby versions
77
+ # because `render_value` already avoids the built-in container inspect.
78
+ def digest(value)
79
+ Digest::SHA256.hexdigest(JSON.generate([canonical(value)]))[0, DIGEST_LENGTH]
80
+ end
81
+
82
+ def canonical(object)
83
+ case object
84
+ when Hash then object.keys.map(&:to_s).sort.to_h { |k| [k, canonical(object[k] || object[k.to_sym])] }
85
+ when Array then object.map { |v| canonical(v) }
86
+ else object
87
+ end
88
+ end
89
+ end
90
+
91
+ def initialize(inputs)
92
+ @inputs = self.class.canonical(inputs).freeze
93
+ @value = Digest::SHA256.hexdigest(JSON.generate(@inputs)).freeze
94
+ end
95
+
96
+ def to_s = value
97
+
98
+ def ==(other)
99
+ value == (other.is_a?(Fingerprint) ? other.value : other)
100
+ end
101
+ alias eql? ==
102
+
103
+ def hash = value.hash
104
+
105
+ def to_json(*) = JSON.generate(inputs, *)
106
+
107
+ # The manifest records these, not the inputs themselves: the sorted
108
+ # example-id list of a real suite is hundreds of kilobytes, and `meta`
109
+ # has to stay small. Digests name the inputs that differ; the example-id
110
+ # count turns "example_ids differ" into something an operator can act on.
111
+ def digests
112
+ @digests ||= INPUT_KEYS.to_h { |key| [key, self.class.digest(inputs[key])] }
113
+ .merge("example_ids_count" => inputs["example_ids"].size)
114
+ .freeze
115
+ end
116
+
117
+ # One line describing the inputs, for mismatch messages.
118
+ def summary
119
+ [
120
+ "file_args=#{inputs["file_args"].inspect}",
121
+ "filter=#{inputs["filter"].inspect}",
122
+ "pattern=#{inputs["pattern"].inspect}",
123
+ "exclude_pattern=#{inputs["exclude_pattern"].inspect}",
124
+ "order=#{inputs["order"]}",
125
+ "example_ids=#{inputs["example_ids"].size}",
126
+ "revision=#{inputs["revision"].inspect}"
127
+ ].join(", ")
128
+ end
129
+
130
+ # Explains why a worker's fingerprint differs from the manifest's.
131
+ module Mismatch
132
+ module_function
133
+
134
+ # @param local [Fingerprint] this worker's fingerprint
135
+ # @param remote_value [String] the manifest's fingerprint
136
+ # @param remote_digests [String, Hash, nil] the initializer's per-input
137
+ # digests, as recorded in the manifest, when available
138
+ def explain(local, remote_value, remote_digests = nil)
139
+ lines = ["suite fingerprint mismatch: this worker computed #{local.value} " \
140
+ "but the build manifest records #{remote_value}"]
141
+ remote = parse(remote_digests)
142
+ lines.concat(differences(local.digests, remote)) if remote
143
+ lines << "local inputs: #{local.summary}"
144
+ lines.join("\n")
145
+ end
146
+
147
+ # Names the inputs whose digests differ. Only the example ids carry a
148
+ # count, because that is the difference an operator cannot see from
149
+ # their own command line: a stale checkout selecting a different set.
150
+ def differences(local, remote)
151
+ keys = Fingerprint::INPUT_KEYS.reject { |key| local[key] == remote[key] }
152
+ if keys.empty?
153
+ return ["recorded inputs are identical; the fingerprint algorithm may differ between gem versions"]
154
+ end
155
+
156
+ lines = ["differing inputs: #{keys.join(", ")}"]
157
+ lines << " #{example_id_counts(local, remote)}" if keys.include?("example_ids")
158
+ lines
159
+ end
160
+
161
+ # Equal counts with differing digests mean a checkout that renames or
162
+ # moves examples rather than one that adds or removes them.
163
+ def example_id_counts(local, remote)
164
+ mine = local["example_ids_count"]
165
+ theirs = remote["example_ids_count"]
166
+ return "example_ids: this worker selects #{mine}, the initializer selected #{theirs}" unless mine == theirs
167
+
168
+ "example_ids: this worker and the initializer both select #{mine}, but the ids differ"
169
+ end
170
+
171
+ def parse(remote_digests)
172
+ case remote_digests
173
+ when nil, "" then nil
174
+ when Hash then remote_digests.transform_keys(&:to_s)
175
+ else
176
+ parsed = JSON.parse(remote_digests.to_s)
177
+ parsed.is_a?(Hash) ? parsed.transform_keys(&:to_s) : nil
178
+ end
179
+ rescue JSON::ParserError
180
+ nil
181
+ end
182
+ end
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSpec
4
+ module Hopper
5
+ # Redis key names for one build. The braces are a Redis Cluster hash tag so
6
+ # every key of a build shares a slot.
7
+ class Keys
8
+ LIVE = %w[units units:priority attempts meta unit_state workers].freeze
9
+ ALL = (LIVE + %w[leader exists]).freeze
10
+ CONSUMER_GROUP = "workers"
11
+
12
+ attr_reader :build_id, :prefix
13
+
14
+ def initialize(build_id)
15
+ @build_id = build_id
16
+ @prefix = "hopper:{#{build_id}}:"
17
+ end
18
+
19
+ def pattern = "#{@prefix}*"
20
+ def units = key("units")
21
+ def units_priority = key("units:priority")
22
+ def attempts = key("attempts")
23
+ def meta = key("meta")
24
+ def unit_state = key("unit_state")
25
+ def workers = key("workers")
26
+ def leader = key("leader")
27
+ def exists = key("exists")
28
+
29
+ def key(name) = "#{@prefix}#{name}"
30
+
31
+ # Full key for a short stream name carried in a Reservation.
32
+ def stream(short) = key(short)
33
+
34
+ def live = LIVE.map { |n| key(n) }
35
+ def all = ALL.map { |n| key(n) }
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module RSpec
6
+ module Hopper
7
+ # Written once by the initializing worker into `meta`. Unit ids are the keys
8
+ # of `file_counts` in order; they are not stored separately.
9
+ Manifest = Data.define(
10
+ :total_units, :total_examples, :file_counts, :file_args,
11
+ :fingerprint, :fingerprint_digests, :seed, :ready_at, :revision, :load_errors
12
+ ) do
13
+ # Builds a Manifest from `meta` exactly as HGETALL returns it (all string
14
+ # values). Runtime fields such as `state` and `finalized_count` are ignored.
15
+ def self.from_meta(meta)
16
+ meta = meta.transform_keys(&:to_s)
17
+ new(
18
+ total_units: meta.fetch("total_units"),
19
+ total_examples: meta.fetch("total_examples"),
20
+ file_counts: JSON.parse(meta.fetch("file_counts", "{}")),
21
+ file_args: JSON.parse(meta.fetch("file_args", "[]")),
22
+ fingerprint: meta["fingerprint"],
23
+ fingerprint_digests: parse_digests(meta["fingerprint_digests"]),
24
+ seed: meta["seed"],
25
+ ready_at: meta["ready_at"],
26
+ revision: meta["revision"],
27
+ load_errors: JSON.parse(meta.fetch("load_errors", "[]"))
28
+ )
29
+ end
30
+
31
+ # Per-input digests of the initializer's fingerprint, for mismatch
32
+ # messages; absent from builds initialized by an older worker.
33
+ def self.parse_digests(value)
34
+ return nil if value.nil? || value.empty?
35
+
36
+ digests = JSON.parse(value)
37
+ digests.is_a?(Hash) ? digests : nil
38
+ rescue JSON::ParserError
39
+ nil
40
+ end
41
+
42
+ # Optional fields are nil when absent rather than empty strings, so a
43
+ # manifest read back from `meta` equals the one that was written.
44
+ def self.normalize_optional(fingerprint:, fingerprint_digests:, seed:, ready_at:, revision:)
45
+ {
46
+ fingerprint: presence(fingerprint)&.to_s,
47
+ fingerprint_digests: presence(fingerprint_digests)&.transform_keys(&:to_s)&.freeze,
48
+ seed: presence(seed)&.then { |s| Integer(s) },
49
+ ready_at: presence(ready_at)&.then { |ms| Integer(ms) },
50
+ revision: presence(revision)&.to_s
51
+ }
52
+ end
53
+
54
+ def self.presence(value)
55
+ value.nil? || (value.respond_to?(:empty?) && value.empty?) ? nil : value
56
+ end
57
+
58
+ def initialize(total_examples:, file_counts:, file_args:, fingerprint: nil, fingerprint_digests: nil,
59
+ seed: nil, total_units: file_counts.size, ready_at: nil, revision: nil, load_errors: [])
60
+ counts = file_counts.to_h { |path, count| [path.to_s, Integer(count)] }.freeze
61
+ units = Integer(total_units)
62
+ if units != counts.size
63
+ raise ArgumentError, "total_units (#{units}) does not match file_counts.size (#{counts.size})"
64
+ end
65
+
66
+ super(
67
+ total_units: units,
68
+ total_examples: Integer(total_examples),
69
+ file_counts: counts,
70
+ file_args: Array(file_args).map(&:to_s).freeze,
71
+ load_errors: Array(load_errors).map(&:to_s).freeze,
72
+ **self.class.normalize_optional(fingerprint: fingerprint, fingerprint_digests: fingerprint_digests,
73
+ seed: seed, ready_at: ready_at, revision: revision)
74
+ )
75
+ end
76
+
77
+ def unit_ids = file_counts.keys
78
+
79
+ def init_failed? = load_errors.any?
80
+
81
+ def empty? = total_examples.zero?
82
+
83
+ # Hash of String => String for HSET. Nested fields are JSON-encoded;
84
+ # nil fields are omitted rather than written as empty strings.
85
+ def to_meta
86
+ meta = {
87
+ "total_units" => total_units.to_s,
88
+ "total_examples" => total_examples.to_s,
89
+ "file_counts" => JSON.generate(file_counts),
90
+ "file_args" => JSON.generate(file_args),
91
+ "load_errors" => JSON.generate(load_errors)
92
+ }
93
+ optional_meta.each { |key, value| meta[key] = value unless value.nil? }
94
+ meta
95
+ end
96
+
97
+ def to_json(*args) = to_h.to_json(*args)
98
+
99
+ private
100
+
101
+ # Written only when set; an absent field must not become an empty string.
102
+ def optional_meta
103
+ {
104
+ "fingerprint" => fingerprint,
105
+ "fingerprint_digests" => fingerprint_digests && JSON.generate(fingerprint_digests),
106
+ "seed" => seed&.to_s,
107
+ "ready_at" => ready_at&.to_s,
108
+ "revision" => revision
109
+ }
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,94 @@
1
+ -- Build initialization. Publishes the manifest exactly once per build id.
2
+ --
3
+ -- KEYS: units, units:priority, attempts, meta, unit_state, leader, exists
4
+ -- ARGV: mode ("success" | "failure"), lease token, ttl_ms, tombstone_ttl_ms,
5
+ -- JSON object of meta fields (string -> string),
6
+ -- JSON array of unit ids (success mode only),
7
+ -- unit type (optional, default "file")
8
+ --
9
+ -- Aborts with a Lua error (mapped by Ruby) when the caller no longer holds the
10
+ -- lease (LEASE_LOST), the tombstone exists (ALREADY_INITIALIZED) or meta is
11
+ -- already published (ALREADY_READY). Returns "ready" or "init_failed".
12
+
13
+ local units, priority, attempts, meta, unit_state, leader, exists =
14
+ KEYS[1], KEYS[2], KEYS[3], KEYS[4], KEYS[5], KEYS[6], KEYS[7]
15
+ local mode, token = ARGV[1], ARGV[2]
16
+ local ttl_ms, tombstone_ttl_ms = tonumber(ARGV[3]), tonumber(ARGV[4])
17
+ local unit_type = ARGV[7] or "file"
18
+
19
+ local GROUP = "workers"
20
+ local INITIAL_UNIT_STATE = '{"retry_index":0,"reclaim_count":0,"entered_retry":false}'
21
+
22
+ local function now_ms()
23
+ local t = redis.call("TIME")
24
+ return tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)
25
+ end
26
+
27
+ local function fence()
28
+ if redis.call("GET", leader) ~= token then
29
+ return redis.error_reply("LEASE_LOST")
30
+ end
31
+ if redis.call("EXISTS", exists) == 1 then
32
+ return redis.error_reply("ALREADY_INITIALIZED")
33
+ end
34
+ if redis.call("EXISTS", meta) == 1 then
35
+ return redis.error_reply("ALREADY_READY")
36
+ end
37
+ return nil
38
+ end
39
+
40
+ local function write_meta(fields, extra)
41
+ local args = {}
42
+ for k, v in pairs(fields) do
43
+ args[#args + 1] = k
44
+ args[#args + 1] = tostring(v)
45
+ end
46
+ for k, v in pairs(extra) do
47
+ args[#args + 1] = k
48
+ args[#args + 1] = tostring(v)
49
+ end
50
+ redis.call("HSET", meta, unpack(args))
51
+ end
52
+
53
+ local function seed_units(unit_ids)
54
+ redis.call("DEL", units, priority)
55
+ redis.call("XGROUP", "CREATE", units, GROUP, "0", "MKSTREAM")
56
+ redis.call("XGROUP", "CREATE", priority, GROUP, "0", "MKSTREAM")
57
+ for _, id in ipairs(unit_ids) do
58
+ redis.call("XADD", units, "*", "id", id, "type", unit_type)
59
+ redis.call("HSET", unit_state, id, INITIAL_UNIT_STATE)
60
+ end
61
+ end
62
+
63
+ local function set_ttls()
64
+ redis.call("SET", exists, "1", "PX", tombstone_ttl_ms)
65
+ for _, key in ipairs({ units, priority, attempts, meta, unit_state }) do
66
+ redis.call("PEXPIRE", key, ttl_ms)
67
+ end
68
+ end
69
+
70
+ local function init_success(fields, unit_ids)
71
+ seed_units(unit_ids)
72
+ write_meta(fields, { state = "ready", ready_at = now_ms(), finalized_count = 0, requeued_units_count = 0 })
73
+ set_ttls()
74
+ return "ready"
75
+ end
76
+
77
+ local function init_failure(fields)
78
+ write_meta(fields, { state = "init_failed", ready_at = now_ms() })
79
+ set_ttls()
80
+ return "init_failed"
81
+ end
82
+
83
+ local aborted = fence()
84
+ if aborted then
85
+ return aborted
86
+ end
87
+
88
+ local fields = cjson.decode(ARGV[5])
89
+ if mode == "success" then
90
+ return init_success(fields, cjson.decode(ARGV[6]))
91
+ elseif mode == "failure" then
92
+ return init_failure(fields)
93
+ end
94
+ return redis.error_reply("ERR unknown init mode " .. tostring(mode))