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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -0
  3. data/README.md +4 -1
  4. data/docs/SETUP_PROFILE.md +1 -0
  5. data/lib/polyrun/cli/database_commands.rb +21 -7
  6. data/lib/polyrun/cli/default_run.rb +28 -6
  7. data/lib/polyrun/cli/help.rb +4 -2
  8. data/lib/polyrun/cli/helpers.rb +41 -6
  9. data/lib/polyrun/cli/plan_command.rb +10 -29
  10. data/lib/polyrun/cli/run_queue_command.rb +10 -1
  11. data/lib/polyrun/cli/run_shards_command.rb +2 -8
  12. data/lib/polyrun/cli/run_shards_parallel_children.rb +52 -39
  13. data/lib/polyrun/cli/run_shards_parallel_wait.rb +2 -2
  14. data/lib/polyrun/cli/run_shards_plan_boot_phases.rb +3 -0
  15. data/lib/polyrun/cli/run_shards_planning.rb +16 -29
  16. data/lib/polyrun/cli/run_shards_run.rb +1 -1
  17. data/lib/polyrun/cli/run_shards_worker_interrupt.rb +16 -14
  18. data/lib/polyrun/cli/start_bootstrap.rb +51 -22
  19. data/lib/polyrun/cli.rb +8 -1
  20. data/lib/polyrun/config.rb +9 -4
  21. data/lib/polyrun/coverage/example_diff.rb +6 -7
  22. data/lib/polyrun/coverage/merge/formatters_html.rb +20 -2
  23. data/lib/polyrun/coverage/merge.rb +9 -3
  24. data/lib/polyrun/coverage/merge_fragment_meta.rb +6 -1
  25. data/lib/polyrun/coverage/rails.rb +1 -1
  26. data/lib/polyrun/database/clone_shards.rb +28 -5
  27. data/lib/polyrun/database/provision.rb +15 -9
  28. data/lib/polyrun/database/url_builder/connection/infer.rb +4 -0
  29. data/lib/polyrun/database/url_builder.rb +31 -3
  30. data/lib/polyrun/partition/paths.rb +23 -4
  31. data/lib/polyrun/partition/plan.rb +6 -24
  32. data/lib/polyrun/partition/suite.rb +116 -0
  33. data/lib/polyrun/partition/timing_diagnostics.rb +2 -3
  34. data/lib/polyrun/queue/file_store.rb +133 -30
  35. data/lib/polyrun/queue/file_store_intent.rb +188 -0
  36. data/lib/polyrun/queue/worker_loop.rb +41 -7
  37. data/lib/polyrun/spec_quality/config.rb +1 -1
  38. data/lib/polyrun/spec_quality/profile.rb +0 -1
  39. data/lib/polyrun/spec_quality/report.rb +16 -7
  40. data/lib/polyrun/templates/rails_prepare.polyrun.yml +3 -1
  41. data/lib/polyrun/version.rb +1 -1
  42. data/lib/polyrun/worker_output.rb +30 -13
  43. data/lib/polyrun.rb +1 -0
  44. 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 = SecureRandom.uuid
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
- leases = read_leases
48
- leases[lease_id] = {
49
- "worker_id" => worker_id.to_s,
50
- "paths" => batch,
51
- "claimed_at" => Time.now.utc.iso8601
52
- }
53
- write_meta!(meta)
54
- write_leases!(leases)
55
- append_ledger(
56
- "CLAIM" => lease_id,
57
- "worker_id" => worker_id.to_s,
58
- "paths" => batch,
59
- "pending_remaining" => meta["pending_count"]
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
- return_paths_to_pending!(meta, paths)
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
- paths = lease["paths"] || []
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("RECLAIM" => lease_id, "worker_id" => lease["worker_id"], "paths" => paths)
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
- claimed = Time.parse(lease["claimed_at"].to_s)
158
- return (Time.now - claimed) >= older_than
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
- claimed = Time.parse(lease["claimed_at"].to_s)
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
- "age_seconds" => (now - claimed).round(1)
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.write(tmp, body)
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
- elsif on_failure.to_s == "requeue"
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
@@ -53,7 +53,7 @@ module Polyrun
53
53
  path = File.expand_path(path)
54
54
  return {} unless File.file?(path)
55
55
 
56
- data = YAML.load_file(path)
56
+ data = YAML.safe_load_file(path, permitted_classes: [Symbol], aliases: true)
57
57
  data.is_a?(Hash) ? data : {}
58
58
  end
59
59
 
@@ -41,7 +41,6 @@ module Polyrun
41
41
 
42
42
  delta = numeric(a) - numeric(b)
43
43
  out[k] = delta if delta.positive? || k.start_with?("cpu")
44
- out[k] = delta
45
44
  end
46
45
  out
47
46
  end