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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +537 -0
- data/Rakefile +10 -0
- data/docs/DESIGN.md +386 -0
- data/exe/rspec-hopper +6 -0
- data/lib/rspec/hopper/attempt_log.rb +133 -0
- data/lib/rspec/hopper/ci_env.rb +87 -0
- data/lib/rspec/hopper/cli/formatter_args.rb +129 -0
- data/lib/rspec/hopper/cli/report.rb +117 -0
- data/lib/rspec/hopper/cli/work/parser.rb +166 -0
- data/lib/rspec/hopper/cli/work.rb +59 -0
- data/lib/rspec/hopper/cli.rb +61 -0
- data/lib/rspec/hopper/config.rb +48 -0
- data/lib/rspec/hopper/errors.rb +88 -0
- data/lib/rspec/hopper/example_reset.rb +41 -0
- data/lib/rspec/hopper/fingerprint.rb +185 -0
- data/lib/rspec/hopper/keys.rb +38 -0
- data/lib/rspec/hopper/manifest.rb +113 -0
- data/lib/rspec/hopper/queue/redis_streams/lua/init.lua +94 -0
- data/lib/rspec/hopper/queue/redis_streams/lua/transition.lua +476 -0
- data/lib/rspec/hopper/queue/redis_streams.rb +307 -0
- data/lib/rspec/hopper/queue.rb +24 -0
- data/lib/rspec/hopper/report.rb +286 -0
- data/lib/rspec/hopper/reservation.rb +18 -0
- data/lib/rspec/hopper/supervisor.rb +196 -0
- data/lib/rspec/hopper/unit.rb +15 -0
- data/lib/rspec/hopper/version.rb +7 -0
- data/lib/rspec/hopper/worker/buffering_reporter.rb +51 -0
- data/lib/rspec/hopper/worker/heartbeat.rb +178 -0
- data/lib/rspec/hopper/worker/requeue_policy.rb +86 -0
- data/lib/rspec/hopper/worker/runner.rb +28 -0
- data/lib/rspec/hopper/worker/suite.rb +205 -0
- data/lib/rspec/hopper/worker.rb +299 -0
- data/lib/rspec/hopper.rb +65 -0
- 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))
|