cogworker 0.5.0 → 0.6.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.
@@ -28,6 +28,9 @@ module Cogworker
28
28
  # on an idle process — the trade-off against how often idle processors
29
29
  # poll Redis (at 0.25s flat, 4 calls/s per thread).
30
30
  EMPTY_POLL_INTERVAL = 0.25
31
+ # A queue counts as read if a live process stamped it (every heartbeat)
32
+ # this recently.
33
+ LIVE_QUEUE_WINDOW = 2 * Heartbeat::TTL
31
34
  ORPHANINGS_TTL = 7 * 24 * 60 * 60
32
35
  MIN_REDIS_VERSION = Gem::Version.new('6.2')
33
36
 
@@ -67,6 +70,7 @@ module Cogworker
67
70
  end
68
71
  end
69
72
  redis.call("RPUSH", queue_prefix .. queue, job)
73
+ return queue
70
74
  end
71
75
  LUA
72
76
 
@@ -78,8 +82,9 @@ module Cogworker
78
82
  # refused; ARGV[8] = orphan-counter key prefix, ARGV[9] = its TTL,
79
83
  # ARGV[10] = config.max_orphanings, ARGV[11..] = jobs to drop or keep
80
84
  # (see below); KEYS[4] = REPEAT_ORPHANS, KEYS[5] = cogworker:dead.
81
- # Returns {moved, <jobs filed straight into dead, raw>...}, or {-1} if
82
- # the owner turned out to be alive.
85
+ # Returns {moved, failed, n, <n unlisted queue names>..., <jobs filed
86
+ # straight into dead, raw>...}, or {-1, 0, 0} if the owner turned out to
87
+ # be alive. KEYS[6] = cogworker:queues, KEYS[7] = LIVE_QUEUES.
83
88
  # Oldest first, so recovered jobs keep their order. The "is the owner
84
89
  # really dead?" check is made here, atomically with the requeue:
85
90
  # checked separately, an owner that
@@ -89,7 +94,7 @@ module Cogworker
89
94
  REQUEUE_SCRIPT = REQUEUE_JOB_FUNCTION + <<~LUA
90
95
  if ARGV[6] ~= "" then
91
96
  if redis.call("EXISTS", KEYS[2]) == 1 then
92
- return {-1}
97
+ return {-1, 0, 0}
93
98
  end
94
99
  -- A stamp that isn't a number counts as no stamp (the presence key
95
100
  -- alone then decides); TIME refused (ACL, proxy) falls back to the
@@ -101,7 +106,7 @@ module Cogworker
101
106
  local ok, time = pcall(redis.call, "TIME")
102
107
  if ok then now = tonumber(time[1]) end
103
108
  if now - last <= tonumber(ARGV[6]) then
104
- return {-1}
109
+ return {-1, 0, 0}
105
110
  end
106
111
  end
107
112
  end
@@ -114,9 +119,15 @@ module Cogworker
114
119
  local mark, payload = string.sub(ARGV[i], 1, 2), string.sub(ARGV[i], 3)
115
120
  if mark == "d:" then finished[payload] = true else keep[payload] = true end
116
121
  end
122
+ if redis.replicate_commands then redis.replicate_commands() end
123
+ local clock = tonumber(ARGV[7])
124
+ local clock_ok, clock_reply = pcall(redis.call, "TIME")
125
+ if clock_ok then clock = tonumber(clock_reply[1]) end
117
126
  local moved = 0
118
127
  local kept = {}
119
128
  local buried = {} -- filed straight into dead, raw: the caller completes them
129
+ local failed = 0 -- couldn't be requeued (kept, at the back): the caller logs them
130
+ local unlisted = {} -- queues requeued onto that no live process reads
120
131
  -- One pass over the list as it stands. Each job is peeked, written
121
132
  -- where it goes, and only then taken off: a script stopped by an
122
133
  -- error keeps the writes it made before it (Redis doesn't roll them
@@ -170,7 +181,20 @@ module Cogworker
170
181
  end
171
182
  end
172
183
  end
173
- local written = parked or pcall(requeue_job, job, ARGV[1], ARGV[2], ARGV[3], ARGV[4])
184
+ local written, queue = parked, nil
185
+ if not parked then
186
+ written, queue = pcall(requeue_job, job, ARGV[1], ARGV[2], ARGV[3], ARGV[4])
187
+ end
188
+ -- A queue no live process has said it reads lately (KEYS[7]): tell
189
+ -- the caller, since nothing may be reading it — and list it
190
+ -- (KEYS[6]) so the Web UI shows it.
191
+ if written and queue then
192
+ local seen = tonumber(redis.pcall("ZSCORE", KEYS[7], queue) or "")
193
+ if not seen or seen < clock - #{LIVE_QUEUE_WINDOW} then
194
+ redis.pcall("SADD", KEYS[6], queue)
195
+ unlisted[queue] = true
196
+ end
197
+ end
174
198
  redis.call("LPOP", KEYS[1])
175
199
  if written then
176
200
  if counter then
@@ -180,13 +204,17 @@ module Cogworker
180
204
  moved = moved + 1
181
205
  else
182
206
  redis.call("RPUSH", KEYS[1], job)
207
+ failed = failed + 1
183
208
  end
184
209
  end
185
210
  end
186
211
  for _, job in ipairs(kept) do
187
212
  redis.call("RPUSH", KEYS[1], job)
188
213
  end
189
- local reply = {moved}
214
+ local names = {}
215
+ for name in pairs(unlisted) do table.insert(names, name) end
216
+ local reply = {moved, failed, #names}
217
+ for _, name in ipairs(names) do table.insert(reply, name) end
190
218
  for _, job in ipairs(buried) do table.insert(reply, job) end
191
219
  return reply
192
220
  LUA
@@ -227,6 +255,7 @@ module Cogworker
227
255
  end
228
256
  redis.call("ZADD", KEYS[2], ARGV[2], ARGV[3])
229
257
  redis.call("LREM", KEYS[1], 1, ARGV[1])
258
+ if KEYS[3] then redis.pcall("DEL", KEYS[3]) end -- it ran to the end: its orphan count is moot
230
259
  return 1
231
260
  LUA
232
261
 
@@ -242,7 +271,8 @@ module Cogworker
242
271
  return 1
243
272
  LUA
244
273
 
245
- # KEYS[1] = UNSETTLED; ARGV[1] = one record (its head), ARGV[2..4] =
274
+ # KEYS[1] = UNSETTLED, KEYS[2] = the job's orphan counter; ARGV[1] = one
275
+ # record (its head), ARGV[2..4] =
246
276
  # set, score, payload. Filed, then removed.
247
277
  FILE_UNSETTLED_SCRIPT = <<~LUA
248
278
  if redis.call("LINDEX", KEYS[1], 0) ~= ARGV[1] then
@@ -250,6 +280,19 @@ module Cogworker
250
280
  end
251
281
  redis.call("ZADD", ARGV[2], ARGV[3], ARGV[4])
252
282
  redis.call("LPOP", KEYS[1])
283
+ redis.pcall("DEL", KEYS[2]) -- it ran to the end: its orphan count is moot
284
+ return 1
285
+ LUA
286
+
287
+ # KEYS[1] = cogworker:dead; ARGV[1] = an entry, ARGV[2] = score,
288
+ # ARGV[3] = its replacement. Swapped in one step: two commands could
289
+ # leave both in dead (and Retry on each from the Web UI ran it twice).
290
+ SWAP_DEAD_ENTRY_SCRIPT = <<~LUA
291
+ if not redis.call("ZSCORE", KEYS[1], ARGV[1]) then
292
+ return 0
293
+ end
294
+ redis.call("ZADD", KEYS[1], ARGV[2], ARGV[3])
295
+ redis.call("ZREM", KEYS[1], ARGV[1])
253
296
  return 1
254
297
  LUA
255
298
 
@@ -260,6 +303,9 @@ module Cogworker
260
303
  return 1
261
304
  LUA
262
305
 
306
+ @pending_raw_burials = []
307
+ @raw_burials_mutex = Mutex.new
308
+
263
309
  class << self
264
310
  # A connection error propagates: can't tell yet, so the caller
265
311
  # (Manager#fetch_class) resolves again on its next use, instead of
@@ -269,9 +315,9 @@ module Cogworker
269
315
  def supported?
270
316
  version = Cogworker.config.redis { |c| c.info('server')['redis_version'] }
271
317
  Gem::Version.new(version) >= MIN_REDIS_VERSION
272
- rescue Redis::BaseConnectionError
273
- raise
274
- rescue StandardError
318
+ rescue StandardError => e
319
+ raise if RedisErrors.unavailable?(e)
320
+
275
321
  true
276
322
  end
277
323
 
@@ -285,15 +331,28 @@ module Cogworker
285
331
  # failed: dropped from it (which is their ack) rather than requeued,
286
332
  # in the same atomic step. `keep:` — jobs left on the list untouched.
287
333
  def requeue_in_progress(identity, if_dead_for: nil, except: [], keep: [])
288
- moved, *buried = Cogworker.config.redis do |c|
334
+ moved, failed, unlisted_count, *rest = Cogworker.config.redis do |c|
289
335
  LuaScript.run(c, REQUEUE_SCRIPT,
290
336
  keys: [RedisKeys.in_progress(identity), RedisKeys.process(identity), RedisKeys::LAST_BEAT,
291
- RedisKeys::REPEAT_ORPHANS, RedisKeys::DEAD],
337
+ RedisKeys::REPEAT_ORPHANS, RedisKeys::DEAD, RedisKeys::QUEUES, RedisKeys::LIVE_QUEUES],
292
338
  argv: [*requeue_args, identity, if_dead_for.to_s, Time.now.to_i,
293
339
  RedisKeys::ORPHANINGS_PREFIX, ORPHANINGS_TTL, Cogworker.config.max_orphanings,
294
340
  *except.map { |raw| "d:#{raw}" }, *keep.map { |raw| "k:#{raw}" }])
295
341
  end
296
- complete_raw_burials(buried)
342
+ unlisted = rest.shift(unlisted_count)
343
+ complete_raw_burials(rest)
344
+ unless unlisted.empty?
345
+ Cogworker.logger.warn do
346
+ "requeued job(s) from #{identity} onto queue(s) no live process reads: #{unlisted.join(', ')} " \
347
+ '— make sure some worker reads them'
348
+ end
349
+ end
350
+ if failed.positive?
351
+ Cogworker.logger.error do
352
+ "#{failed} job(s) left in progress by #{identity} couldn't be put back on their queue " \
353
+ '(a key of the wrong type?) — kept, retried on the next pass'
354
+ end
355
+ end
297
356
  moved
298
357
  end
299
358
 
@@ -330,12 +389,14 @@ module Cogworker
330
389
 
331
390
  recover_identity(identity)
332
391
  rescue StandardError => e
333
- report[:connection_lost] = true if e.is_a?(Redis::BaseConnectionError)
334
- Cogworker.logger.error { "orphan recovery skipped #{identity}: #{e.class}: #{e.message}" }
392
+ report[:connection_lost] = true if RedisErrors.unavailable?(e)
393
+ RedisErrors.report('Orphan recovery', e) { "skipped #{identity}" }
335
394
  0
336
395
  end
337
- isolated('burying repeat orphans', nil) { bury_repeat_orphans }
338
- isolated('filing unsettled jobs', nil) { file_unsettled }
396
+ isolated('burying repeat orphans', nil, report) { bury_repeat_orphans }
397
+ isolated('completing dead entries', nil, report) { retry_raw_burials }
398
+ isolated('filing unsettled jobs', nil, report) { file_unsettled }
399
+ RedisErrors.recovered('Orphan recovery') unless report[:connection_lost]
339
400
  moved
340
401
  end
341
402
 
@@ -364,7 +425,11 @@ module Cogworker
364
425
  return 0 if moved.negative? # alive after all: keep its registration and last beat
365
426
 
366
427
  isolated("forgetting #{identity}", nil) { forget_if_empty(identity) } # its jobs are already back
367
- Cogworker.logger.warn { "requeued #{moved} job(s) left in progress by dead process #{identity}" } if moved.positive?
428
+ if moved.positive?
429
+ Cogworker.logger.warn do
430
+ "requeued #{moved} job(s) left in progress by dead process #{identity}"
431
+ end
432
+ end
368
433
  moved
369
434
  end
370
435
 
@@ -413,12 +478,16 @@ module Cogworker
413
478
  LuaScript.run(c, ACK_SCRIPT, keys: [key, RedisKeys::ORPHANINGS_PREFIX + jid.to_s], argv: [raw])
414
479
  else
415
480
  set, score, payload = how
416
- LuaScript.run(c, INTERRUPT_SCRIPT, keys: [key, set], argv: [raw, score, payload])
481
+ filed = LuaScript.run(c, INTERRUPT_SCRIPT, keys: [key, set, orphanings_key(raw)],
482
+ argv: [raw, score, payload])
483
+ released_if_dead(c, set, payload) if filed == 1
417
484
  end
418
485
  end
419
486
  yield raw if block_given?
420
487
  rescue StandardError => e
421
- Cogworker.logger.error { "still couldn't settle jid=#{parse_hash(raw)&.fetch('jid', nil)}: #{e.class}: #{e.message}" }
488
+ Cogworker.logger.error do
489
+ "still couldn't settle jid=#{parse_hash(raw)&.fetch('jid', nil)}: #{e.class}: #{e.message}"
490
+ end
422
491
  end
423
492
  end
424
493
 
@@ -437,7 +506,9 @@ module Cogworker
437
506
  end
438
507
  true
439
508
  rescue StandardError => e
440
- Cogworker.logger.error { "couldn't hand over unsettled jid=#{parse_hash(raw)&.fetch('jid', nil)}: #{e.class}: #{e.message}" }
509
+ Cogworker.logger.error do
510
+ "couldn't hand over unsettled jid=#{parse_hash(raw)&.fetch('jid', nil)}: #{e.class}: #{e.message}"
511
+ end
441
512
  false
442
513
  end.keys
443
514
  end
@@ -449,10 +520,12 @@ module Cogworker
449
520
  Cogworker.config.redis do |c|
450
521
  c.lrange(RedisKeys::UNSETTLED, 0, -1).each do |record|
451
522
  entry = parse_hash(record)
452
- next c.lrem(RedisKeys::UNSETTLED, 1, record) unless entry # unreadable: nothing to file
523
+ next quarantine_record(c, record) unless valid_unsettled?(entry)
453
524
 
454
- LuaScript.run(c, FILE_UNSETTLED_SCRIPT, keys: [RedisKeys::UNSETTLED],
455
- argv: [record, entry['set'], entry['score'], entry['payload']])
525
+ filed = LuaScript.run(c, FILE_UNSETTLED_SCRIPT,
526
+ keys: [RedisKeys::UNSETTLED, orphanings_key(entry['payload'])],
527
+ argv: [record, entry['set'], entry['score'], entry['payload']])
528
+ released_if_dead(c, entry['set'], entry['payload']) if filed == 1
456
529
  end
457
530
  end
458
531
  end
@@ -460,35 +533,76 @@ module Cogworker
460
533
  # Jobs REQUEUE_SCRIPT had to file in dead raw (its parking list
461
534
  # unusable): swapped for a proper dead entry, and given the usual
462
535
  # terminal-failure side effects. Best-effort, per job.
536
+ # A swap that fails (Redis away) is kept and retried on the next pass
537
+ # (`recover_orphans`) — left alone, the raw entry stayed in dead without
538
+ # its error, its job's locks held and its failure uncounted.
463
539
  def complete_raw_burials(raws)
464
540
  raws.each do |raw|
465
541
  job = (parse_hash(raw) || {}).merge(repeat_orphan_error)
466
542
  Cogworker.config.redis do |c|
467
- c.zadd(RedisKeys::DEAD, Time.now.to_f, JSON.generate(job))
468
- c.zrem(RedisKeys::DEAD, raw)
469
- buried_as_dead(c, job)
543
+ swapped = LuaScript.run(c, SWAP_DEAD_ENTRY_SCRIPT, keys: [RedisKeys::DEAD],
544
+ argv: [raw, Time.now.to_f, JSON.generate(job)])
545
+ buried_as_dead(c, job) if swapped == 1
470
546
  end
547
+ @raw_burials_mutex.synchronize { @pending_raw_burials.delete(raw) }
471
548
  rescue StandardError => e
472
- Cogworker.logger.error { "completing the dead entry of a repeat orphan failed: #{e.class}: #{e.message}" }
549
+ @raw_burials_mutex.synchronize { @pending_raw_burials << raw unless @pending_raw_burials.include?(raw) }
550
+ RedisErrors.report('Orphan recovery', e) { "a repeat orphan's dead entry not completed (will retry)" }
473
551
  end
474
552
  end
475
553
 
554
+ def retry_raw_burials
555
+ complete_raw_burials(@raw_burials_mutex.synchronize { @pending_raw_burials.dup })
556
+ end
557
+
476
558
  def repeat_orphan_error
477
559
  { 'error_class' => 'Cogworker::ProcessDied', 'failed_at' => Time.now.to_f,
478
560
  'error_message' => "its process died while running it more than #{Cogworker.config.max_orphanings} times " \
479
561
  '(killed for memory? SIGKILL?) — not retried again' }
480
562
  end
481
563
 
564
+ FILEABLE_SETS = [RedisKeys::RETRY, RedisKeys::DEAD, RedisKeys::SCHEDULE].freeze
565
+
566
+ # `set` must be one of ours to file into — not any key a damaged record
567
+ # names (another type's, which would fail every pass at that record).
568
+ # Filing a failure in dead outside the middleware chain: release its
569
+ # locks, as the chain would for a terminal failure.
570
+ def released_if_dead(conn, set, payload)
571
+ job = parse_hash(payload)
572
+ JobUtil.release_terminal_locks(conn, job) if set == RedisKeys::DEAD && job
573
+ end
574
+
575
+ def valid_unsettled?(entry)
576
+ entry.is_a?(Hash) && FILEABLE_SETS.include?(entry['set']) && entry['score'].is_a?(Numeric) &&
577
+ entry['payload'].is_a?(String)
578
+ end
579
+
580
+ # A record that can't be filed (unreadable, fields missing) is moved
581
+ # out of the way — kept for a human, like KeyGuard's quarantine —
582
+ # instead of stopping every pass at it, and everything behind it.
583
+ def quarantine_record(conn, record)
584
+ target = "#{KeyGuard::QUARANTINE_PREFIX}#{RedisKeys::UNSETTLED}:records"
585
+ conn.rpush(target, record)
586
+ conn.expire(target, KeyGuard::QUARANTINE_TTL)
587
+ conn.lrem(RedisKeys::UNSETTLED, 1, record)
588
+ Cogworker.logger.error { "unusable #{RedisKeys::UNSETTLED} record moved to #{target}: #{record.to_s[0, 200]}" }
589
+ end
590
+
482
591
  # What any other terminal failure does on its way to dead: counted as
483
592
  # failed, and its `until_executed` locks released (so the entry / the
484
593
  # unique key isn't blocked until the locks' TTL). Best-effort.
594
+ # Each step on its own, locks first: one failing (a key of the wrong
595
+ # type) must not leave the others undone.
485
596
  def buried_as_dead(conn, job)
486
- conn.incr(RedisKeys::STATS_FAILED)
487
- OwnedKey.delete(RedisKeys.unique_lock(UniqueJobs.digest(job)), job['jid'], conn) if UniqueJobs.until_executed?(job)
488
- Periodic::RunningLock.release(job['periodic_pjid'], job['jid'], conn) if job['periodic_pjid']
489
- conn.del(RedisKeys::ORPHANINGS_PREFIX + job['jid'].to_s)
490
- rescue StandardError => e
491
- Cogworker.logger.error { "after burying jid=#{job['jid']}: #{e.class}: #{e.message}" }
597
+ JobUtil.release_terminal_locks(conn, job)
598
+ BestEffort.call('Orphan counter') { conn.del(RedisKeys::ORPHANINGS_PREFIX + job['jid'].to_s) }
599
+ BestEffort.call('Stats') { conn.incr(RedisKeys::STATS_FAILED) }
600
+ BestEffort.call('Throughput') { Throughput.record('failed') }
601
+ end
602
+
603
+ def orphanings_key(raw)
604
+ jid = parse_hash(raw)&.fetch('jid', nil) # (nil for an unreadable payload: an unused key then)
605
+ "#{RedisKeys::ORPHANINGS_PREFIX}#{jid}"
492
606
  end
493
607
 
494
608
  def parse_hash(raw)
@@ -519,8 +633,8 @@ module Cogworker
519
633
  def isolated(what, fallback, report = nil)
520
634
  yield
521
635
  rescue StandardError => e
522
- report[:connection_lost] = true if report && e.is_a?(Redis::BaseConnectionError)
523
- Cogworker.logger.error { "orphan recovery: #{what} failed: #{e.class}: #{e.message}" }
636
+ report[:connection_lost] = true if report && RedisErrors.unavailable?(e)
637
+ RedisErrors.report('Orphan recovery', e) { "#{what} failed" }
524
638
  fallback
525
639
  end
526
640
 
@@ -561,14 +675,15 @@ module Cogworker
561
675
  def give_back(work)
562
676
  Cogworker.config.redis do |c|
563
677
  LuaScript.run(c, REQUEUE_ONE_SCRIPT, keys: [RedisKeys.in_progress(Cogworker.identity)],
564
- argv: [work.raw_job, *self.class.requeue_args])
678
+ argv: [work.raw_job, *self.class.requeue_args])
565
679
  end
566
680
  end
567
681
 
568
682
  def interrupt(work, set, score, payload)
569
683
  Cogworker.config.redis do |c|
570
- LuaScript.run(c, INTERRUPT_SCRIPT, keys: [RedisKeys.in_progress(Cogworker.identity), set],
571
- argv: [work.raw_job, score, payload])
684
+ LuaScript.run(c, INTERRUPT_SCRIPT, keys: [RedisKeys.in_progress(Cogworker.identity), set,
685
+ self.class.orphanings_key(work.raw_job)],
686
+ argv: [work.raw_job, score, payload])
572
687
  end
573
688
  end
574
689
 
@@ -578,7 +693,7 @@ module Cogworker
578
693
  jid = self.class.parse_hash(work.raw_job)&.fetch('jid', nil)
579
694
  Cogworker.config.redis do |c|
580
695
  LuaScript.run(c, ACK_SCRIPT, keys: [RedisKeys.in_progress(Cogworker.identity),
581
- RedisKeys::ORPHANINGS_PREFIX + jid.to_s],
696
+ RedisKeys::ORPHANINGS_PREFIX + jid.to_s],
582
697
  argv: [work.raw_job])
583
698
  end
584
699
  end
@@ -97,16 +97,22 @@ module Cogworker
97
97
  # one of the same oldest 50 picked again on every poll, and enough of
98
98
  # them would starve everything behind them for good.
99
99
  def defer(set, raw, error)
100
- Cogworker.logger.error { "Graduating an entry of #{set} failed (deferred #{DEFER_DELAY}s): #{error.class}: #{error.message}" }
100
+ Cogworker.logger.error do
101
+ "Graduating an entry of #{set} failed (deferred #{DEFER_DELAY}s): #{error.class}: #{error.message}"
102
+ end
101
103
  Cogworker.config.redis { |c| c.zadd(set, Time.now.to_f + DEFER_DELAY, raw, xx: true) }
102
104
  rescue StandardError => e
103
105
  Cogworker.logger.error { "Deferring it failed too: #{e.class}: #{e.message}" }
104
106
  end
105
107
 
108
+ # A block returning :skipped did nothing (not due yet): that proves
109
+ # nothing about Redis, so it doesn't end an outage report either.
106
110
  def guarded(what)
107
- yield
111
+ result = yield
112
+ RedisErrors.recovered(what) unless result == :skipped
113
+ result
108
114
  rescue StandardError => e
109
- Cogworker.logger.error { "#{what} failed: #{e.class}: #{e.message}" }
115
+ RedisErrors.report(what, e)
110
116
  end
111
117
 
112
118
  # The next check is scheduled before this one runs: one that keeps
@@ -116,7 +122,7 @@ module Cogworker
116
122
  # The one-off SCAN only counts as done once it has succeeded.
117
123
  def recover_orphans_if_due
118
124
  now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
119
- return if @next_orphan_check && now < @next_orphan_check
125
+ return :skipped if @next_orphan_check && now < @next_orphan_check
120
126
 
121
127
  @next_orphan_check = now + ORPHAN_CHECK_INTERVAL
122
128
  reliable = @manager.fetch_class == ReliableFetch
@@ -125,8 +131,8 @@ module Cogworker
125
131
  # not a minute later — Redis coming back is when it matters most.
126
132
  begin
127
133
  ReliableFetch.recover_orphans(scan: reliable && !@scanned, report: report)
128
- rescue Redis::BaseConnectionError
129
- @next_orphan_check = now + POLL_INTERVAL
134
+ rescue StandardError => e
135
+ @next_orphan_check = now + POLL_INTERVAL if RedisErrors.unavailable?(e)
130
136
  raise
131
137
  end
132
138
  @next_orphan_check = now + POLL_INTERVAL if report[:connection_lost]
@@ -138,6 +144,7 @@ module Cogworker
138
144
 
139
145
  @scanned = true if report[:scan]
140
146
  @recovered_once = true if report[:registry] || report[:scan]
147
+ nil
141
148
  end
142
149
 
143
150
  # See KeyGuard. First thing on the first poll, then every
@@ -145,7 +152,7 @@ module Cogworker
145
152
  # would break them is already out of the way.
146
153
  def check_keys_if_due
147
154
  now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
148
- return if @next_key_check && now < @next_key_check
155
+ return :skipped if @next_key_check && now < @next_key_check
149
156
 
150
157
  @next_key_check = now + KeyGuard::CHECK_INTERVAL
151
158
  KeyGuard.check(queues: @manager.queues)
@@ -191,7 +198,7 @@ module Cogworker
191
198
  error = JSON::ParserError.new("not a job (JSON object with a queue) in #{set}")
192
199
  job = JobUtil.unparseable_job(raw, queue: nil, error: error)
193
200
  buried = LuaScript.run(conn, BURY_SCRIPT, keys: [set, RedisKeys::DEAD, RedisKeys::STATS_FAILED],
194
- argv: [raw, job['failed_at'], JSON.generate(job)])
201
+ argv: [raw, job['failed_at'], JSON.generate(job)])
195
202
  return unless buried == 1
196
203
 
197
204
  Throughput.record('failed') # counted like Processor#bury_unparseable
@@ -9,14 +9,20 @@ module Cogworker
9
9
  @expiration = expiration
10
10
  end
11
11
 
12
+ # Every status write is best-effort (BestEffort) — before `perform`
13
+ # as much as after: one failing used to count as the job failing.
12
14
  def call(_worker, job, queue)
13
- write(job, queue, 'working')
14
- yield
15
- write(job, queue, 'complete')
16
- rescue Exception => e # rubocop:disable Lint/RescueException
17
- write(job, queue, JobUtil.terminal_failure?(job) ? 'failed' : 'retrying',
18
- error_class: e.class.name, error_message: JobUtil.error_message(e))
19
- raise e
15
+ BestEffort.call('Status') { write(job, queue, 'working') }
16
+ begin
17
+ yield
18
+ rescue Exception => e # rubocop:disable Lint/RescueException
19
+ BestEffort.call('Status') do
20
+ write(job, queue, JobUtil.terminal_failure?(job) ? 'failed' : 'retrying',
21
+ error_class: e.class.name, error_message: JobUtil.error_message(e))
22
+ end
23
+ raise e
24
+ end
25
+ BestEffort.call('Status') { write(job, queue, 'complete') }
20
26
  end
21
27
 
22
28
  private
@@ -59,6 +59,8 @@ module Cogworker
59
59
  def fork_child(slot)
60
60
  pid = ::Process.fork do
61
61
  Cogworker.reset_identity!
62
+ RedisErrors.reset! # the parent's outage state isn't this child's
63
+ DeferredReleases.reset! # nor are its pending releases
62
64
  CLI.new.run(@argv.dup)
63
65
  # `exit!`, not plain `exit`: `Launcher#run` only returns once
64
66
  # `Manager#stop!` has had its bounded (default 25s) chance to join
@@ -12,12 +12,16 @@ module Cogworker
12
12
  # `unique: :until_executed`. Registered unconditionally in every
13
13
  # `Config`, same as `Periodic::ReleaseMiddleware`.
14
14
  class ReleaseMiddleware
15
+ # Releasing is best-effort (BestEffort): a failed release leaves the
16
+ # lock to its TTL, and only the job's own exception is re-raised.
15
17
  def call(_worker, job, _queue)
16
- yield
18
+ begin
19
+ yield
20
+ rescue Exception => e # rubocop:disable Lint/RescueException
21
+ release(job) if UniqueJobs.until_executed?(job) && JobUtil.terminal_failure?(job)
22
+ raise e
23
+ end
17
24
  release(job) if UniqueJobs.until_executed?(job)
18
- rescue Exception => e # rubocop:disable Lint/RescueException
19
- release(job) if UniqueJobs.until_executed?(job) && JobUtil.terminal_failure?(job)
20
- raise e
21
25
  end
22
26
 
23
27
  private
@@ -25,8 +29,15 @@ module Cogworker
25
29
  # Owner-checked: if this job's lock already expired and passed to a
26
30
  # newer copy, this must not release *that* one's lock (which would let
27
31
  # a third copy in while the newer one is still running).
32
+ # Owner-checked; retried later (DeferredReleases) if it fails, rather
33
+ # than leaving duplicates blocked for the lock's whole TTL.
28
34
  def release(job)
29
- OwnedKey.delete(RedisKeys.unique_lock(UniqueJobs.digest(job)), job['jid'])
35
+ key = RedisKeys.unique_lock(UniqueJobs.digest(job))
36
+ released = BestEffort.call('Unique lock') do
37
+ OwnedKey.delete(key, job['jid'])
38
+ true
39
+ end
40
+ DeferredReleases.add(key, job['jid']) unless released
30
41
  end
31
42
  end
32
43
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cogworker
4
- VERSION = '0.5.0'
4
+ VERSION = '0.6.0'
5
5
  end
@@ -380,7 +380,9 @@ module Cogworker
380
380
  # Redis cards), where the default wrapper would just nest one bordered
381
381
  # box inside another.
382
382
  def table(headers, rows, empty_message: 'Nothing here.', wrapped: true)
383
- return %(<p class="text-muted" style="font-size: 13px; font-style: italic;">#{h(empty_message)}</p>) if rows.empty?
383
+ if rows.empty?
384
+ return %(<p class="text-muted" style="font-size: 13px; font-style: italic;">#{h(empty_message)}</p>)
385
+ end
384
386
 
385
387
  head = headers.map { |c| %(<th>#{h(c)}</th>) }.join
386
388
  body = rows.map do |row|
@@ -288,13 +288,13 @@ module Cogworker
288
288
  def retry_now_button(bucket, script_name, row, params)
289
289
  action = Layout.path(script_name, "jobs/#{bucket}/retry_now")
290
290
  form_with_extra(action, 'raw', row[:raw], 'retry now', params, variant: :primary, icon: 'arrow-clockwise',
291
- extra: {})
291
+ extra: {})
292
292
  end
293
293
 
294
294
  def retry_button(bucket, script_name, row, params)
295
295
  action = Layout.path(script_name, "jobs/#{bucket}/retry")
296
296
  form_with_extra(action, 'raw', row[:raw], 'retry', params, variant: :primary, icon: 'arrow-clockwise',
297
- extra: {})
297
+ extra: {})
298
298
  end
299
299
 
300
300
  # `Layout.form_button` only carries one hidden field. Every action
@@ -304,9 +304,9 @@ module Cogworker
304
304
  # since it isn't in the URL a plain form submits to) plus, for an
305
305
  # enqueued-job delete, `queue` (`Queue#delete` isn't keyed off the
306
306
  # raw payload alone the way the ZSETs are).
307
- def form_with_extra(action, hidden_name, hidden_value, label, params, variant:, icon: nil, extra:)
307
+ def form_with_extra(action, hidden_name, hidden_value, label, params, variant:, extra:, icon: nil)
308
308
  hidden = extra.merge('status' => params['status'].to_s, 'q' => params['q'].to_s,
309
- 'selected' => params['selected'].to_s)
309
+ 'selected' => params['selected'].to_s)
310
310
  hidden_inputs = hidden.map { |k, v| %(<input type="hidden" name="#{k}" value="#{Layout.h(v)}">) }.join
311
311
  classes = "btn #{Layout::BUTTON_VARIANTS.fetch(variant)}"
312
312
  <<~HTML
@@ -613,7 +613,9 @@ module Cogworker
613
613
  def render_layout_b(script_name, selected_name)
614
614
  names = queue_names
615
615
  selected_name = names.first if (selected_name.nil? || selected_name.empty?) && !names.empty?
616
- return %(<p class="text-muted">No queues yet — push a job to create one.</p>) if selected_name.nil? || selected_name.empty?
616
+ if selected_name.nil? || selected_name.empty?
617
+ return %(<p class="text-muted">No queues yet — push a job to create one.</p>)
618
+ end
617
619
 
618
620
  <<~HTML
619
621
  <div style="display: grid; grid-template-columns: minmax(240px, 320px) minmax(0, 1fr); gap: 16px; align-items: start;">
@@ -676,7 +678,7 @@ module Cogworker
676
678
  end.join
677
679
  paused_tag = q.paused? ? " #{Layout.badge('paused', variant: :warning)}" : ''
678
680
  header_actions = queue_pause_button(q, script_name, 'b') + retry_all_button(name, script_name,
679
- retrying_count)
681
+ retrying_count)
680
682
  <<~HTML
681
683
  <section style="display: flex; flex-direction: column; gap: 16px; min-width: 0;">
682
684
  <div style="background: var(--color-surface); border-radius: var(--radius-md); box-shadow: var(--shadow-sm); padding: 18px;">
@@ -3,6 +3,9 @@
3
3
  require 'json'
4
4
 
5
5
  module Cogworker
6
+ # Every job running right now, across all live processes: yields
7
+ # `[identity, thread id, Cogworker::Work]` from each process's
8
+ # `cogworker:workers:<identity>` Hash (only for processes ProcessSet knows).
6
9
  class WorkSet
7
10
  include Enumerable
8
11
 
data/lib/cogworker.rb CHANGED
@@ -101,7 +101,7 @@ module Cogworker
101
101
  # No-op: exists so calling code doesn't need to change, but this gem
102
102
  # simply never coerces job args, so there is nothing to toggle — see
103
103
  # README "Job arguments".
104
- def strict_args!(_value = true)
104
+ def strict_args!(_value = true) # rubocop:disable Style/OptionalBooleanParameter -- kept positional for calling-code compatibility
105
105
  true
106
106
  end
107
107