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.
- checksums.yaml +4 -4
- data/LICENSE.txt +21 -0
- data/README.md +608 -0
- data/lib/cogworker/basic_fetch.rb +2 -2
- data/lib/cogworker/best_effort.rb +23 -0
- data/lib/cogworker/cli.rb +3 -2
- data/lib/cogworker/client.rb +17 -4
- data/lib/cogworker/config.rb +17 -3
- data/lib/cogworker/deferred_releases.rb +46 -0
- data/lib/cogworker/heartbeat.rb +56 -12
- data/lib/cogworker/history/middleware.rb +9 -5
- data/lib/cogworker/job_util.rb +17 -0
- data/lib/cogworker/key_guard.rb +36 -1
- data/lib/cogworker/launcher.rb +4 -0
- data/lib/cogworker/lua_script.rb +3 -3
- data/lib/cogworker/manager.rb +19 -1
- data/lib/cogworker/periodic/claim.lua +5 -2
- data/lib/cogworker/periodic/manager.rb +5 -0
- data/lib/cogworker/periodic/release_middleware.rb +13 -2
- data/lib/cogworker/periodic/running_lock.rb +12 -0
- data/lib/cogworker/periodic/ticker.rb +59 -18
- data/lib/cogworker/process_set.rb +3 -0
- data/lib/cogworker/processor.rb +33 -9
- data/lib/cogworker/redis_errors.rb +93 -0
- data/lib/cogworker/redis_keys.rb +4 -0
- data/lib/cogworker/reliable_fetch.rb +155 -40
- data/lib/cogworker/scheduled.rb +15 -8
- data/lib/cogworker/status/server_middleware.rb +13 -7
- data/lib/cogworker/swarm.rb +2 -0
- data/lib/cogworker/unique_jobs/release_middleware.rb +16 -5
- data/lib/cogworker/version.rb +1 -1
- data/lib/cogworker/web/layout.rb +3 -1
- data/lib/cogworker/web/routes/jobs.rb +4 -4
- data/lib/cogworker/web/routes/overview.rb +4 -2
- data/lib/cogworker/work_set.rb +3 -0
- data/lib/cogworker.rb +1 -1
- metadata +6 -1
|
@@ -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,
|
|
82
|
-
# the owner turned out to
|
|
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
|
|
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
|
|
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
|
|
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
|
|
273
|
-
raise
|
|
274
|
-
|
|
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, *
|
|
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
|
-
|
|
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
|
|
334
|
-
|
|
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('
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
523
|
+
next quarantine_record(c, record) unless valid_unsettled?(entry)
|
|
453
524
|
|
|
454
|
-
LuaScript.run(c, FILE_UNSETTLED_SCRIPT,
|
|
455
|
-
|
|
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
|
-
|
|
468
|
-
|
|
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
|
-
|
|
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
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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 &&
|
|
523
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
696
|
+
RedisKeys::ORPHANINGS_PREFIX + jid.to_s],
|
|
582
697
|
argv: [work.raw_job])
|
|
583
698
|
end
|
|
584
699
|
end
|
data/lib/cogworker/scheduled.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
data/lib/cogworker/swarm.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
data/lib/cogworker/version.rb
CHANGED
data/lib/cogworker/web/layout.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;">
|
data/lib/cogworker/work_set.rb
CHANGED
|
@@ -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
|
|