polyrun 2.2.4 → 2.2.6
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/CHANGELOG.md +28 -0
- data/README.md +4 -1
- data/docs/SETUP_PROFILE.md +1 -0
- data/lib/polyrun/cli/database_commands.rb +21 -7
- data/lib/polyrun/cli/default_run.rb +28 -6
- data/lib/polyrun/cli/help.rb +4 -2
- data/lib/polyrun/cli/helpers.rb +41 -6
- data/lib/polyrun/cli/plan_command.rb +10 -29
- data/lib/polyrun/cli/run_queue_command.rb +10 -1
- data/lib/polyrun/cli/run_shards_command.rb +2 -8
- data/lib/polyrun/cli/run_shards_parallel_children.rb +52 -39
- data/lib/polyrun/cli/run_shards_parallel_wait.rb +2 -2
- data/lib/polyrun/cli/run_shards_plan_boot_phases.rb +3 -0
- data/lib/polyrun/cli/run_shards_planning.rb +16 -29
- data/lib/polyrun/cli/run_shards_run.rb +1 -1
- data/lib/polyrun/cli/run_shards_worker_interrupt.rb +16 -14
- data/lib/polyrun/cli/start_bootstrap.rb +51 -22
- data/lib/polyrun/cli.rb +8 -1
- data/lib/polyrun/config.rb +9 -4
- data/lib/polyrun/coverage/example_diff.rb +6 -7
- data/lib/polyrun/coverage/merge/formatters_html.rb +20 -2
- data/lib/polyrun/coverage/merge.rb +9 -3
- data/lib/polyrun/coverage/merge_fragment_meta.rb +6 -1
- data/lib/polyrun/coverage/rails.rb +1 -1
- data/lib/polyrun/database/clone_shards.rb +28 -5
- data/lib/polyrun/database/provision.rb +15 -9
- data/lib/polyrun/database/url_builder/connection/infer.rb +4 -0
- data/lib/polyrun/database/url_builder.rb +31 -3
- data/lib/polyrun/partition/paths.rb +23 -4
- data/lib/polyrun/partition/plan.rb +6 -24
- data/lib/polyrun/partition/suite.rb +116 -0
- data/lib/polyrun/partition/timing_diagnostics.rb +2 -3
- data/lib/polyrun/queue/file_store.rb +133 -30
- data/lib/polyrun/queue/file_store_intent.rb +188 -0
- data/lib/polyrun/queue/worker_loop.rb +41 -7
- data/lib/polyrun/spec_quality/config.rb +1 -1
- data/lib/polyrun/spec_quality/profile.rb +0 -1
- data/lib/polyrun/spec_quality/report.rb +16 -7
- data/lib/polyrun/templates/rails_prepare.polyrun.yml +3 -1
- data/lib/polyrun/version.rb +1 -1
- data/lib/polyrun/worker_output.rb +30 -13
- data/lib/polyrun.rb +1 -0
- metadata +3 -1
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
module Polyrun
|
|
2
|
+
module Partition
|
|
3
|
+
# Chooses the parallel runner suite for bare +polyrun+ and validates command vs path lists.
|
|
4
|
+
module Suite
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
# Resolve suite for default CLI dispatch.
|
|
8
|
+
# Order: explicit +partition.suite+ (not auto) → infer from +paths_file+ → +detect_auto_suite+.
|
|
9
|
+
# Returns +{ suite: :rspec|:minitest|:quick }+ or +{ error: "..." }+.
|
|
10
|
+
def resolve_default(cwd: Dir.pwd, partition: {})
|
|
11
|
+
configured = configured_suite(partition)
|
|
12
|
+
return {error: "unknown partition.suite: #{configured.inspect} (expected auto, rspec, minitest, quick)"} unless
|
|
13
|
+
%w[auto rspec minitest quick].include?(configured)
|
|
14
|
+
|
|
15
|
+
inferred = infer_from_paths_file(cwd, partition)
|
|
16
|
+
return inferred if inferred.is_a?(Hash) && inferred[:error]
|
|
17
|
+
|
|
18
|
+
if configured != "auto"
|
|
19
|
+
suite = configured.to_sym
|
|
20
|
+
if inferred && inferred != suite
|
|
21
|
+
return {
|
|
22
|
+
error: "partition.suite is #{suite} but paths_file infers #{inferred} " \
|
|
23
|
+
"(fix partition.suite or the paths file)"
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
return {suite: suite}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
return {suite: inferred} if inferred
|
|
30
|
+
|
|
31
|
+
auto = Paths.detect_auto_suite(cwd)
|
|
32
|
+
return {suite: auto} if auto
|
|
33
|
+
|
|
34
|
+
{
|
|
35
|
+
error: "no tests found (spec/**/*_spec.rb, test/**/*_test.rb, or Polyrun quick files). See polyrun help."
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def infer_from_command(cmd)
|
|
40
|
+
tokens = Array(cmd).map(&:to_s)
|
|
41
|
+
return nil if tokens.empty?
|
|
42
|
+
|
|
43
|
+
return :rspec if tokens.any? { |t| File.basename(t) == "rspec" }
|
|
44
|
+
return :quick if tokens.include?("quick") && tokens.any? { |t| File.basename(t).include?("polyrun") }
|
|
45
|
+
return :minitest if minitest_command?(tokens)
|
|
46
|
+
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def command_mismatch_message(items, cmd)
|
|
51
|
+
path_suite = Paths.infer_suite_from_paths(Array(items))
|
|
52
|
+
cmd_suite = infer_from_command(cmd)
|
|
53
|
+
|
|
54
|
+
if path_suite == :invalid
|
|
55
|
+
return nil unless cmd_suite
|
|
56
|
+
|
|
57
|
+
return "mixing _spec.rb and _test.rb paths is not supported with a #{cmd_suite} command " \
|
|
58
|
+
"(use a suite-agnostic custom command, or split the path list)"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
return nil if path_suite.nil?
|
|
62
|
+
return nil if cmd_suite.nil?
|
|
63
|
+
return nil if path_suite == cmd_suite
|
|
64
|
+
|
|
65
|
+
"paths infer #{path_suite} but command looks like #{cmd_suite} " \
|
|
66
|
+
"(use parallel-#{path_suite}, fix partition.paths_file, or pass a matching command after --)"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def configured_suite(partition)
|
|
70
|
+
suite = (partition["suite"] || partition[:suite] || "auto").to_s.downcase
|
|
71
|
+
suite.empty? ? "auto" : suite
|
|
72
|
+
end
|
|
73
|
+
private_class_method :configured_suite
|
|
74
|
+
|
|
75
|
+
def infer_from_paths_file(cwd, partition)
|
|
76
|
+
paths_file = partition["paths_file"] || partition[:paths_file]
|
|
77
|
+
return nil unless paths_file
|
|
78
|
+
|
|
79
|
+
abs = File.expand_path(paths_file.to_s, cwd)
|
|
80
|
+
return nil unless File.file?(abs)
|
|
81
|
+
|
|
82
|
+
items = Paths.read_lines(abs)
|
|
83
|
+
inferred = Paths.infer_suite_from_paths(items)
|
|
84
|
+
return {error: "paths_file mixes _spec.rb and _test.rb (not supported in one run)"} if inferred == :invalid
|
|
85
|
+
|
|
86
|
+
inferred
|
|
87
|
+
end
|
|
88
|
+
private_class_method :infer_from_paths_file
|
|
89
|
+
|
|
90
|
+
def minitest_command?(tokens)
|
|
91
|
+
return true if tokens.any? { |t| File.basename(t) == "rails" } && tokens.include?("test")
|
|
92
|
+
return true if ruby_with_test_load_path?(tokens)
|
|
93
|
+
|
|
94
|
+
false
|
|
95
|
+
end
|
|
96
|
+
private_class_method :minitest_command?
|
|
97
|
+
|
|
98
|
+
def ruby_with_test_load_path?(tokens)
|
|
99
|
+
tokens.each_with_index do |tok, i|
|
|
100
|
+
next unless File.basename(tok) == "ruby"
|
|
101
|
+
|
|
102
|
+
rest = tokens[(i + 1)..] || []
|
|
103
|
+
rest.each_with_index do |arg, j|
|
|
104
|
+
return true if arg == "-Itest"
|
|
105
|
+
return true if arg == "-I" && rest[j + 1] == "test"
|
|
106
|
+
next unless arg.start_with?("-I") && arg.length > 2
|
|
107
|
+
|
|
108
|
+
return true if arg[2..].split(/[=:]/).include?("test")
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
false
|
|
112
|
+
end
|
|
113
|
+
private_class_method :ruby_with_test_load_path?
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -16,8 +16,9 @@ module Polyrun
|
|
|
16
16
|
item_keys = items.map { |p| lookup_key(p, root, g) }
|
|
17
17
|
cost_keys = costs&.keys || []
|
|
18
18
|
|
|
19
|
+
item_keys_set = item_keys.to_set
|
|
20
|
+
stale = cost_keys.reject { |k| item_keys_set.include?(k) }
|
|
19
21
|
if g == :example
|
|
20
|
-
item_keys_set = item_keys.to_set
|
|
21
22
|
cost_keys_set = cost_keys.to_set
|
|
22
23
|
cost_file_keys_set = cost_keys.map { |k| file_from_locator(k) }.uniq.to_set
|
|
23
24
|
known = item_keys.count do |ik|
|
|
@@ -26,11 +27,9 @@ module Polyrun
|
|
|
26
27
|
missing = item_keys.reject do |ik|
|
|
27
28
|
cost_keys_set.include?(ik) || cost_file_keys_set.include?(file_from_locator(ik))
|
|
28
29
|
end
|
|
29
|
-
stale = cost_keys.reject { |k| item_keys_set.include?(k) }
|
|
30
30
|
else
|
|
31
31
|
known = item_keys.count { |k| costs&.key?(k) }
|
|
32
32
|
missing = item_keys.reject { |k| costs&.key?(k) }
|
|
33
|
-
stale = cost_keys.reject { |k| item_keys.include?(k) }
|
|
34
33
|
end
|
|
35
34
|
total = item_keys.size
|
|
36
35
|
|
|
@@ -39,25 +39,38 @@ module Polyrun
|
|
|
39
39
|
batch_size = Integer(batch_size)
|
|
40
40
|
raise Polyrun::Error, "batch_size must be >= 1" if batch_size < 1
|
|
41
41
|
|
|
42
|
-
lease_id =
|
|
42
|
+
lease_id = nil
|
|
43
43
|
batch = []
|
|
44
44
|
with_lock do
|
|
45
45
|
meta = load_meta!
|
|
46
46
|
batch = take_pending_batch!(meta, batch_size)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
47
|
+
unless batch.empty?
|
|
48
|
+
lease_id = SecureRandom.uuid
|
|
49
|
+
now = Time.now.utc.iso8601
|
|
50
|
+
write_intent!(
|
|
51
|
+
"op" => "claim",
|
|
52
|
+
"lease_id" => lease_id,
|
|
53
|
+
"worker_id" => worker_id.to_s,
|
|
54
|
+
"paths" => batch
|
|
55
|
+
)
|
|
56
|
+
leases = read_leases
|
|
57
|
+
leases[lease_id] = {
|
|
58
|
+
"worker_id" => worker_id.to_s,
|
|
59
|
+
"paths" => batch,
|
|
60
|
+
"claimed_at" => now,
|
|
61
|
+
"heartbeat_at" => now,
|
|
62
|
+
"attempts" => 0
|
|
63
|
+
}
|
|
64
|
+
write_meta!(meta)
|
|
65
|
+
write_leases!(leases)
|
|
66
|
+
clear_intent!
|
|
67
|
+
append_ledger(
|
|
68
|
+
"CLAIM" => lease_id,
|
|
69
|
+
"worker_id" => worker_id.to_s,
|
|
70
|
+
"paths" => batch,
|
|
71
|
+
"pending_remaining" => meta["pending_count"]
|
|
72
|
+
)
|
|
73
|
+
end
|
|
61
74
|
end
|
|
62
75
|
{"lease_id" => lease_id, "paths" => batch}
|
|
63
76
|
end
|
|
@@ -72,19 +85,73 @@ module Polyrun
|
|
|
72
85
|
raise Polyrun::Error, "lease worker mismatch"
|
|
73
86
|
end
|
|
74
87
|
|
|
75
|
-
leases.delete(lease_id)
|
|
76
|
-
write_leases!(leases)
|
|
77
|
-
|
|
78
88
|
paths = lease["paths"] || []
|
|
89
|
+
write_intent!(
|
|
90
|
+
"op" => "ack",
|
|
91
|
+
"lease_id" => lease_id,
|
|
92
|
+
"worker_id" => worker_id.to_s,
|
|
93
|
+
"paths" => paths
|
|
94
|
+
)
|
|
95
|
+
# done.jsonl before removing the lease so a crash still has durable completion evidence.
|
|
96
|
+
append_done_lines!(paths)
|
|
79
97
|
meta = load_meta!
|
|
80
98
|
meta["done_count"] = Integer(meta["done_count"]) + paths.size
|
|
81
|
-
append_done_lines!(paths)
|
|
82
99
|
write_meta!(meta)
|
|
100
|
+
leases.delete(lease_id)
|
|
101
|
+
write_leases!(leases)
|
|
102
|
+
clear_intent!
|
|
83
103
|
append_ledger("ACK" => lease_id, "worker_id" => worker_id.to_s, "paths" => paths)
|
|
84
104
|
end
|
|
85
105
|
true
|
|
86
106
|
end
|
|
87
107
|
|
|
108
|
+
# Marks a subset of a lease's paths done; removes the lease when none remain.
|
|
109
|
+
def ack_paths!(lease_id:, worker_id:, paths:)
|
|
110
|
+
paths = Array(paths).map(&:to_s)
|
|
111
|
+
raise Polyrun::Error, "ack_paths! requires at least one path" if paths.empty?
|
|
112
|
+
|
|
113
|
+
with_lock do
|
|
114
|
+
leases = read_leases
|
|
115
|
+
lease = leases[lease_id]
|
|
116
|
+
raise Polyrun::Error, "unknown lease: #{lease_id}" unless lease
|
|
117
|
+
|
|
118
|
+
if lease["worker_id"].to_s != worker_id.to_s
|
|
119
|
+
raise Polyrun::Error, "lease worker mismatch"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
held = Array(lease["paths"]).map(&:to_s)
|
|
123
|
+
unknown = paths - held
|
|
124
|
+
raise Polyrun::Error, "ack_paths! paths not on lease: #{unknown.join(", ")}" unless unknown.empty?
|
|
125
|
+
|
|
126
|
+
write_intent!(
|
|
127
|
+
"op" => "ack_paths",
|
|
128
|
+
"lease_id" => lease_id,
|
|
129
|
+
"worker_id" => worker_id.to_s,
|
|
130
|
+
"paths" => paths
|
|
131
|
+
)
|
|
132
|
+
apply_ack_side_effects!(leases, lease_id, lease, paths, worker_id)
|
|
133
|
+
clear_intent!
|
|
134
|
+
end
|
|
135
|
+
true
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def heartbeat!(lease_id:, worker_id:)
|
|
139
|
+
with_lock do
|
|
140
|
+
leases = read_leases
|
|
141
|
+
lease = leases[lease_id]
|
|
142
|
+
raise Polyrun::Error, "unknown lease: #{lease_id}" unless lease
|
|
143
|
+
|
|
144
|
+
if lease["worker_id"].to_s != worker_id.to_s
|
|
145
|
+
raise Polyrun::Error, "lease worker mismatch"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
lease["heartbeat_at"] = Time.now.utc.iso8601
|
|
149
|
+
leases[lease_id] = lease
|
|
150
|
+
write_leases!(leases)
|
|
151
|
+
end
|
|
152
|
+
true
|
|
153
|
+
end
|
|
154
|
+
|
|
88
155
|
def status(detailed: false)
|
|
89
156
|
with_lock do
|
|
90
157
|
meta = load_meta!
|
|
@@ -111,13 +178,23 @@ module Polyrun
|
|
|
111
178
|
leases.each do |lease_id, lease|
|
|
112
179
|
if reclaim_lease?(lease, older_than: older_than, worker_id: worker_id)
|
|
113
180
|
paths = lease["paths"] || []
|
|
114
|
-
|
|
181
|
+
bumped = lease.merge("attempts" => Integer(lease["attempts"] || 0) + 1)
|
|
182
|
+
write_intent!(
|
|
183
|
+
"op" => "reclaim",
|
|
184
|
+
"lease_id" => lease_id,
|
|
185
|
+
"worker_id" => lease["worker_id"],
|
|
186
|
+
"paths" => paths,
|
|
187
|
+
"attempts" => bumped["attempts"]
|
|
188
|
+
)
|
|
189
|
+
quarantine_or_return_paths!(meta, paths, bumped)
|
|
115
190
|
reclaimed_paths += paths.size
|
|
116
191
|
append_ledger(
|
|
117
192
|
"RECLAIM" => lease_id,
|
|
118
193
|
"worker_id" => lease["worker_id"],
|
|
119
|
-
"paths" => paths
|
|
194
|
+
"paths" => paths,
|
|
195
|
+
"attempts" => bumped["attempts"]
|
|
120
196
|
)
|
|
197
|
+
clear_intent!
|
|
121
198
|
else
|
|
122
199
|
keep[lease_id] = lease
|
|
123
200
|
end
|
|
@@ -135,14 +212,28 @@ module Polyrun
|
|
|
135
212
|
lease = leases[lease_id]
|
|
136
213
|
return 0 unless lease
|
|
137
214
|
|
|
215
|
+
paths = lease["paths"] || []
|
|
216
|
+
bumped = lease.merge("attempts" => Integer(lease["attempts"] || 0) + 1)
|
|
217
|
+
write_intent!(
|
|
218
|
+
"op" => "reclaim_lease",
|
|
219
|
+
"lease_id" => lease_id,
|
|
220
|
+
"worker_id" => lease["worker_id"],
|
|
221
|
+
"paths" => paths,
|
|
222
|
+
"attempts" => bumped["attempts"]
|
|
223
|
+
)
|
|
138
224
|
leases.delete(lease_id)
|
|
139
225
|
write_leases!(leases)
|
|
140
226
|
meta = load_meta!
|
|
141
|
-
|
|
142
|
-
return_paths_to_pending!(meta, paths)
|
|
227
|
+
quarantine_or_return_paths!(meta, paths, bumped)
|
|
143
228
|
write_meta!(meta)
|
|
144
229
|
reclaimed = paths.size
|
|
145
|
-
append_ledger(
|
|
230
|
+
append_ledger(
|
|
231
|
+
"RECLAIM" => lease_id,
|
|
232
|
+
"worker_id" => lease["worker_id"],
|
|
233
|
+
"paths" => paths,
|
|
234
|
+
"attempts" => bumped["attempts"]
|
|
235
|
+
)
|
|
236
|
+
clear_intent!
|
|
146
237
|
end
|
|
147
238
|
reclaimed
|
|
148
239
|
end
|
|
@@ -154,8 +245,10 @@ module Polyrun
|
|
|
154
245
|
return false
|
|
155
246
|
end
|
|
156
247
|
if older_than
|
|
157
|
-
|
|
158
|
-
|
|
248
|
+
stamp = lease["heartbeat_at"].to_s
|
|
249
|
+
stamp = lease["claimed_at"].to_s if stamp.empty?
|
|
250
|
+
touched = Time.parse(stamp)
|
|
251
|
+
return (Time.now - touched) >= older_than
|
|
159
252
|
end
|
|
160
253
|
!!worker_id
|
|
161
254
|
rescue ArgumentError
|
|
@@ -165,13 +258,16 @@ module Polyrun
|
|
|
165
258
|
def lease_details(leases)
|
|
166
259
|
now = Time.now
|
|
167
260
|
leases.map do |lease_id, lease|
|
|
168
|
-
|
|
261
|
+
stamp = lease["heartbeat_at"].to_s
|
|
262
|
+
stamp = lease["claimed_at"].to_s if stamp.empty?
|
|
263
|
+
touched = Time.parse(stamp)
|
|
169
264
|
{
|
|
170
265
|
"lease_id" => lease_id,
|
|
171
266
|
"worker_id" => lease["worker_id"],
|
|
172
267
|
"paths_count" => (lease["paths"] || []).size,
|
|
173
268
|
"claimed_at" => lease["claimed_at"],
|
|
174
|
-
"
|
|
269
|
+
"heartbeat_at" => lease["heartbeat_at"],
|
|
270
|
+
"age_seconds" => (now - touched).round(1)
|
|
175
271
|
}
|
|
176
272
|
rescue ArgumentError
|
|
177
273
|
{
|
|
@@ -179,6 +275,7 @@ module Polyrun
|
|
|
179
275
|
"worker_id" => lease["worker_id"],
|
|
180
276
|
"paths_count" => (lease["paths"] || []).size,
|
|
181
277
|
"claimed_at" => lease["claimed_at"],
|
|
278
|
+
"heartbeat_at" => lease["heartbeat_at"],
|
|
182
279
|
"age_seconds" => nil
|
|
183
280
|
}
|
|
184
281
|
end
|
|
@@ -228,6 +325,7 @@ module Polyrun
|
|
|
228
325
|
FileUtils.mkdir_p(@root)
|
|
229
326
|
File.open(lock_path, File::CREAT | File::RDWR) do |f|
|
|
230
327
|
f.flock(File::LOCK_EX)
|
|
328
|
+
recover_intent!
|
|
231
329
|
yield
|
|
232
330
|
end
|
|
233
331
|
end
|
|
@@ -297,9 +395,13 @@ module Polyrun
|
|
|
297
395
|
File.open(ledger_path, "a") { |f| f.write(line) }
|
|
298
396
|
end
|
|
299
397
|
|
|
300
|
-
def atomic_write(path, body)
|
|
398
|
+
def atomic_write(path, body, fsync: false)
|
|
301
399
|
tmp = "#{path}.tmp.#{$$}"
|
|
302
|
-
File.
|
|
400
|
+
File.open(tmp, File::WRONLY | File::CREAT | File::TRUNC) do |io|
|
|
401
|
+
io.write(body)
|
|
402
|
+
io.flush
|
|
403
|
+
io.fsync if fsync
|
|
404
|
+
end
|
|
303
405
|
File.rename(tmp, path)
|
|
304
406
|
end
|
|
305
407
|
end
|
|
@@ -307,4 +409,5 @@ module Polyrun
|
|
|
307
409
|
end
|
|
308
410
|
|
|
309
411
|
require_relative "file_store_pending"
|
|
412
|
+
require_relative "file_store_intent"
|
|
310
413
|
# rubocop:enable Polyrun/FileLength, Metrics/ClassLength
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# rubocop:disable Metrics/ModuleLength -- claim/ack/reclaim intent recovery beside FileStore
|
|
2
|
+
module Polyrun
|
|
3
|
+
module Queue
|
|
4
|
+
class FileStore
|
|
5
|
+
INTENT_BASENAME = "intent.json"
|
|
6
|
+
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def intent_path
|
|
10
|
+
File.join(@root, INTENT_BASENAME)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def write_intent!(payload)
|
|
14
|
+
atomic_write(intent_path, JSON.generate(payload), fsync: true)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def clear_intent!
|
|
18
|
+
File.unlink(intent_path)
|
|
19
|
+
rescue Errno::ENOENT
|
|
20
|
+
nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def recover_intent!
|
|
24
|
+
return unless File.file?(intent_path)
|
|
25
|
+
|
|
26
|
+
intent = JSON.parse(File.read(intent_path))
|
|
27
|
+
case intent["op"].to_s
|
|
28
|
+
when "claim"
|
|
29
|
+
recover_claim_intent!(intent)
|
|
30
|
+
when "ack", "ack_paths"
|
|
31
|
+
recover_ack_intent!(intent)
|
|
32
|
+
when "reclaim", "reclaim_lease"
|
|
33
|
+
recover_reclaim_intent!(intent)
|
|
34
|
+
else
|
|
35
|
+
Polyrun::Log.warn "polyrun queue: dropping unknown intent op=#{intent["op"].inspect}"
|
|
36
|
+
end
|
|
37
|
+
clear_intent!
|
|
38
|
+
rescue JSON::ParserError, TypeError
|
|
39
|
+
Polyrun::Log.warn "polyrun queue: dropping corrupt intent at #{intent_path}"
|
|
40
|
+
clear_intent!
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def recover_claim_intent!(intent)
|
|
44
|
+
lease_id = intent["lease_id"].to_s
|
|
45
|
+
leases = read_leases
|
|
46
|
+
return if leases.key?(lease_id)
|
|
47
|
+
|
|
48
|
+
paths = Array(intent["paths"])
|
|
49
|
+
meta = load_meta!
|
|
50
|
+
return_paths_to_pending!(meta, paths)
|
|
51
|
+
write_meta!(meta)
|
|
52
|
+
append_ledger(
|
|
53
|
+
"RECOVER_CLAIM" => lease_id,
|
|
54
|
+
"worker_id" => intent["worker_id"],
|
|
55
|
+
"paths" => paths
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def recover_ack_intent!(intent)
|
|
60
|
+
lease_id = intent["lease_id"].to_s
|
|
61
|
+
leases = read_leases
|
|
62
|
+
lease = leases[lease_id]
|
|
63
|
+
return unless lease
|
|
64
|
+
|
|
65
|
+
paths = Array(intent["paths"])
|
|
66
|
+
paths = Array(lease["paths"]) if paths.empty?
|
|
67
|
+
apply_ack_side_effects!(leases, lease_id, lease, paths, intent["worker_id"])
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def recover_reclaim_intent!(intent)
|
|
71
|
+
lease_id = intent["lease_id"].to_s
|
|
72
|
+
leases = read_leases
|
|
73
|
+
lease = leases[lease_id]
|
|
74
|
+
return unless lease
|
|
75
|
+
|
|
76
|
+
paths = Array(intent["paths"])
|
|
77
|
+
paths = Array(lease["paths"]) if paths.empty?
|
|
78
|
+
bumped = lease.merge("attempts" => intent["attempts"] || lease["attempts"])
|
|
79
|
+
leases.delete(lease_id)
|
|
80
|
+
write_leases!(leases)
|
|
81
|
+
meta = load_meta!
|
|
82
|
+
quarantine_or_return_paths!(meta, paths, bumped)
|
|
83
|
+
write_meta!(meta)
|
|
84
|
+
append_ledger(
|
|
85
|
+
"RECOVER_RECLAIM" => lease_id,
|
|
86
|
+
"worker_id" => lease["worker_id"],
|
|
87
|
+
"paths" => paths,
|
|
88
|
+
"attempts" => bumped["attempts"]
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def apply_ack_side_effects!(leases, lease_id, lease, paths, worker_id)
|
|
93
|
+
append_done_lines!(paths)
|
|
94
|
+
clear_path_attempts!(paths)
|
|
95
|
+
meta = load_meta!
|
|
96
|
+
meta["done_count"] = Integer(meta["done_count"]) + paths.size
|
|
97
|
+
write_meta!(meta)
|
|
98
|
+
remaining = Array(lease["paths"]).map(&:to_s) - paths.map(&:to_s)
|
|
99
|
+
if remaining.empty?
|
|
100
|
+
leases.delete(lease_id)
|
|
101
|
+
else
|
|
102
|
+
lease["paths"] = remaining
|
|
103
|
+
leases[lease_id] = lease
|
|
104
|
+
end
|
|
105
|
+
write_leases!(leases)
|
|
106
|
+
append_ledger("ACK" => lease_id, "worker_id" => worker_id.to_s, "paths" => paths)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def quarantine_or_return_paths!(meta, paths, lease)
|
|
110
|
+
attempts_h = read_path_attempts
|
|
111
|
+
to_pending = []
|
|
112
|
+
poisoned = []
|
|
113
|
+
max_attempts = queue_max_attempts
|
|
114
|
+
Array(paths).each do |path|
|
|
115
|
+
key = path.to_s
|
|
116
|
+
n = Integer(attempts_h[key] || 0) + 1
|
|
117
|
+
attempts_h[key] = n
|
|
118
|
+
if max_attempts.positive? && n >= max_attempts
|
|
119
|
+
poisoned << [key, n]
|
|
120
|
+
else
|
|
121
|
+
to_pending << key
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
write_path_attempts!(attempts_h)
|
|
125
|
+
poisoned.each do |key, n|
|
|
126
|
+
append_failed_lines!(
|
|
127
|
+
[key],
|
|
128
|
+
"worker_id" => lease["worker_id"],
|
|
129
|
+
"attempts" => n
|
|
130
|
+
)
|
|
131
|
+
append_ledger("POISON" => lease["worker_id"], "attempts" => n, "paths" => [key])
|
|
132
|
+
end
|
|
133
|
+
return_paths_to_pending!(meta, to_pending)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def clear_path_attempts!(paths)
|
|
137
|
+
attempts_h = read_path_attempts
|
|
138
|
+
Array(paths).each { |p| attempts_h.delete(p.to_s) }
|
|
139
|
+
write_path_attempts!(attempts_h)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def queue_max_attempts
|
|
143
|
+
raw = ENV["POLYRUN_QUEUE_MAX_ATTEMPTS"].to_s.strip
|
|
144
|
+
return 3 if raw.empty?
|
|
145
|
+
|
|
146
|
+
n = Integer(raw, exception: false)
|
|
147
|
+
(n && n >= 1) ? n : 3
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def path_attempts_path
|
|
151
|
+
File.join(@root, "path_attempts.json")
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def read_path_attempts
|
|
155
|
+
return {} unless File.file?(path_attempts_path)
|
|
156
|
+
|
|
157
|
+
data = JSON.parse(File.read(path_attempts_path))
|
|
158
|
+
data.is_a?(Hash) ? data : {}
|
|
159
|
+
rescue JSON::ParserError
|
|
160
|
+
{}
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def write_path_attempts!(hash)
|
|
164
|
+
atomic_write(path_attempts_path, JSON.generate(hash))
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def failed_path
|
|
168
|
+
File.join(@root, "failed.jsonl")
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def append_failed_lines!(paths, lease)
|
|
172
|
+
return if paths.empty?
|
|
173
|
+
|
|
174
|
+
File.open(failed_path, "a") do |io|
|
|
175
|
+
paths.each do |p|
|
|
176
|
+
io.puts(JSON.generate(
|
|
177
|
+
"path" => p.to_s,
|
|
178
|
+
"worker_id" => lease["worker_id"],
|
|
179
|
+
"attempts" => Integer(lease["attempts"] || 0),
|
|
180
|
+
"at" => Time.now.utc.iso8601
|
|
181
|
+
))
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
# rubocop:enable Metrics/ModuleLength
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
module Polyrun
|
|
2
2
|
module Queue
|
|
3
3
|
# Claim / run / ack loop for +polyrun run-queue+ workers (forked or direct).
|
|
4
|
+
#
|
|
5
|
+
# Successful batches stay one command invocation. On failure, paths are re-run one at a time so
|
|
6
|
+
# already-passing paths can be acked without reclaiming them with a poison neighbor.
|
|
4
7
|
module WorkerLoop
|
|
5
8
|
module_function
|
|
6
9
|
|
|
@@ -12,18 +15,25 @@ module Polyrun
|
|
|
12
15
|
paths = claim["paths"] || []
|
|
13
16
|
break if paths.empty?
|
|
14
17
|
|
|
18
|
+
store.heartbeat!(lease_id: claim["lease_id"], worker_id: worker_id)
|
|
15
19
|
code = run_batch(cmd, paths)
|
|
16
20
|
if code == 0
|
|
17
21
|
store.ack!(lease_id: claim["lease_id"], worker_id: worker_id)
|
|
18
22
|
batches_ok += 1
|
|
19
|
-
|
|
20
|
-
store.reclaim_lease!(claim["lease_id"])
|
|
21
|
-
batches_fail += 1
|
|
22
|
-
return {ok: batches_ok, fail: batches_fail, exit_code: 1}
|
|
23
|
-
else
|
|
24
|
-
batches_fail += 1
|
|
25
|
-
return {ok: batches_ok, fail: batches_fail, exit_code: code.zero? ? 1 : code}
|
|
23
|
+
next
|
|
26
24
|
end
|
|
25
|
+
|
|
26
|
+
outcome = isolate_failed_batch!(
|
|
27
|
+
store: store,
|
|
28
|
+
worker_id: worker_id,
|
|
29
|
+
lease_id: claim["lease_id"],
|
|
30
|
+
paths: paths,
|
|
31
|
+
cmd: cmd,
|
|
32
|
+
on_failure: on_failure
|
|
33
|
+
)
|
|
34
|
+
batches_ok += outcome[:ok]
|
|
35
|
+
batches_fail += outcome[:fail]
|
|
36
|
+
return {ok: batches_ok, fail: batches_fail, exit_code: outcome[:exit_code]} if outcome[:stop]
|
|
27
37
|
end
|
|
28
38
|
{ok: batches_ok, fail: batches_fail, exit_code: 0}
|
|
29
39
|
rescue Polyrun::Error => e
|
|
@@ -34,6 +44,30 @@ module Polyrun
|
|
|
34
44
|
def run_batch(cmd, paths)
|
|
35
45
|
system(*cmd, *paths) ? 0 : ($?.exitstatus || 1)
|
|
36
46
|
end
|
|
47
|
+
|
|
48
|
+
def isolate_failed_batch!(store:, worker_id:, lease_id:, paths:, cmd:, on_failure:)
|
|
49
|
+
ok = 0
|
|
50
|
+
paths.each do |path|
|
|
51
|
+
store.heartbeat!(lease_id: lease_id, worker_id: worker_id)
|
|
52
|
+
code = run_batch(cmd, [path])
|
|
53
|
+
if code == 0
|
|
54
|
+
store.ack_paths!(lease_id: lease_id, worker_id: worker_id, paths: [path])
|
|
55
|
+
ok += 1
|
|
56
|
+
next
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
if on_failure.to_s == "requeue"
|
|
60
|
+
store.reclaim_lease!(lease_id)
|
|
61
|
+
return {ok: ok, fail: 1, exit_code: 1, stop: true}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
return {ok: ok, fail: 1, exit_code: code.zero? ? 1 : code, stop: true}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Every path passed on the isolating re-run; lease should already be empty.
|
|
68
|
+
{ok: ok.positive? ? 1 : 0, fail: 0, exit_code: 0, stop: false}
|
|
69
|
+
end
|
|
70
|
+
private_class_method :isolate_failed_batch!
|
|
37
71
|
end
|
|
38
72
|
end
|
|
39
73
|
end
|