cogworker 0.2.2 → 0.3.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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/lib/cogworker/basic_fetch.rb +37 -2
  3. data/lib/cogworker/cli.rb +11 -1
  4. data/lib/cogworker/client.rb +1 -1
  5. data/lib/cogworker/config.rb +26 -1
  6. data/lib/cogworker/heartbeat.rb +54 -6
  7. data/lib/cogworker/job.rb +9 -0
  8. data/lib/cogworker/job_util.rb +56 -0
  9. data/lib/cogworker/launcher.rb +20 -1
  10. data/lib/cogworker/manager.rb +43 -4
  11. data/lib/cogworker/owned_key.rb +40 -0
  12. data/lib/cogworker/periodic/claim.lua +9 -0
  13. data/lib/cogworker/periodic/release_middleware.rb +31 -13
  14. data/lib/cogworker/periodic/running_lock.rb +44 -0
  15. data/lib/cogworker/periodic/ticker.rb +39 -10
  16. data/lib/cogworker/process.rb +2 -2
  17. data/lib/cogworker/process_set.rb +1 -1
  18. data/lib/cogworker/processor.rb +116 -14
  19. data/lib/cogworker/queue.rb +4 -4
  20. data/lib/cogworker/redis_keys.rb +2 -0
  21. data/lib/cogworker/reliable_fetch.rb +137 -0
  22. data/lib/cogworker/scheduled.rb +37 -10
  23. data/lib/cogworker/stats.rb +1 -1
  24. data/lib/cogworker/status.rb +5 -1
  25. data/lib/cogworker/swarm.rb +64 -4
  26. data/lib/cogworker/testing/empty_queue_error.rb +9 -0
  27. data/lib/cogworker/testing.rb +27 -3
  28. data/lib/cogworker/unique_jobs/client_middleware.rb +13 -4
  29. data/lib/cogworker/unique_jobs/release_middleware.rb +4 -1
  30. data/lib/cogworker/version.rb +1 -1
  31. data/lib/cogworker/web/layout.rb +3 -3
  32. data/lib/cogworker/web/routes/jobs.rb +17 -17
  33. data/lib/cogworker/web/routes/overview.rb +6 -12
  34. data/lib/cogworker/web/routes/schedules.rb +2 -2
  35. data/lib/cogworker/web.rb +21 -8
  36. data/lib/cogworker.rb +5 -1
  37. metadata +5 -1
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'fugit'
4
4
  require 'json'
5
+ require 'securerandom'
5
6
 
6
7
  module Cogworker
7
8
  module Periodic
@@ -54,13 +55,19 @@ module Cogworker
54
55
  Cogworker.config.redis { |c| c.hset(RedisKeys::PERIODIC_SCHEDULE, *payloads.to_a.flatten) }
55
56
  end
56
57
 
58
+ # A failed tick is logged and retried next interval rather than ending
59
+ # the thread — a single Redis blip used to silently stop every
60
+ # periodic entry in this process for good. A slot whose claim raised
61
+ # isn't recorded in `@last_checked_slot`, so the next tick retries it.
57
62
  def run
58
63
  until @manager.stopping?
59
- tick unless @manager.quiet?
64
+ begin
65
+ tick unless @manager.quiet?
66
+ rescue StandardError => e
67
+ Cogworker.logger.error { "Periodic tick failed: #{e.class}: #{e.message}" }
68
+ end
60
69
  sleep(TICK_INTERVAL)
61
70
  end
62
- rescue StandardError => e
63
- Cogworker.logger.error { "Periodic ticker died: #{e.class}: #{e.message}" }
64
71
  end
65
72
 
66
73
  def tick
@@ -69,7 +76,8 @@ module Cogworker
69
76
  slot = cron_for(entry).previous_time(now).to_i
70
77
  next if @last_checked_slot[entry.pjid] == slot
71
78
 
72
- enqueue(entry, slot) if !disabled?(entry) && claim?(entry, slot)
79
+ jid = SecureRandom.hex(12)
80
+ enqueue(entry, slot, jid) if !disabled?(entry) && claim?(entry, slot, jid)
73
81
  @last_checked_slot[entry.pjid] = slot
74
82
  end
75
83
  end
@@ -93,14 +101,14 @@ module Cogworker
93
101
  @cron_cache[entry.pjid] ||= Fugit::Cron.parse(entry.cron)
94
102
  end
95
103
 
96
- def claim?(entry, slot)
104
+ def claim?(entry, slot, jid)
97
105
  return false if !@catch_up && priming_first_slot?(entry, slot)
98
106
 
99
107
  result = Cogworker.config.redis do |c|
100
108
  c.eval(CLAIM_SCRIPT,
101
109
  keys: [RedisKeys.periodic_running(entry.pjid), RedisKeys.periodic_last_slot(entry.pjid),
102
110
  RedisKeys.periodic_lock(entry.pjid, slot)],
103
- argv: [slot, entry.unique.to_s, LOCK_TTL])
111
+ argv: [slot, entry.unique.to_s, LOCK_TTL, jid, RunningLock.queued_ttl])
104
112
  end
105
113
  result == 1
106
114
  end
@@ -123,15 +131,36 @@ module Cogworker
123
131
  Cogworker.config.redis { |c| c.set(RedisKeys.periodic_last_slot(entry.pjid), slot, nx: true) }
124
132
  end
125
133
 
126
- def enqueue(entry, slot)
134
+ # The running lock (for `until_executed`) is already in place, set by
135
+ # the claim under this same `jid` — nothing to write after the push.
136
+ # That relies on the job keeping that jid: `JobUtil.normalize_item`
137
+ # only fills `jid` in when absent, but a custom client middleware that
138
+ # overwrites it would leave the lock owned by a jid no job carries —
139
+ # the entry then stays closed for `RunningLock.queued_ttl` (logged).
140
+ # Only undone here if the push didn't actually produce a job (raised,
141
+ # or a client middleware swallowed it), so a job that never existed
142
+ # can't hold the entry for the whole `RunningLock.queued_ttl`.
143
+ def enqueue(entry, slot, jid)
127
144
  job = {
128
145
  'class' => entry.class_name, 'args' => entry.args, 'retry' => entry.retry,
129
- 'periodic_pjid' => entry.pjid, 'periodic_slot' => slot
146
+ 'periodic_pjid' => entry.pjid, 'periodic_slot' => slot, 'jid' => jid
130
147
  }
131
- jid = Client.push(job)
148
+ pushed = begin
149
+ Client.push(job)
150
+ rescue StandardError
151
+ RunningLock.release(entry.pjid, jid) if entry.until_executed?
152
+ raise
153
+ end
132
154
  return unless entry.until_executed?
133
155
 
134
- Cogworker.config.redis { |c| c.set(RedisKeys.periodic_running(entry.pjid), jid) }
156
+ if pushed.nil?
157
+ RunningLock.release(entry.pjid, jid)
158
+ elsif pushed != jid
159
+ Cogworker.logger.warn do
160
+ "periodic #{entry.pjid}: a client middleware changed the job's jid (#{jid} -> #{pushed}); its running " \
161
+ "lock won't be released by the job and keeps the entry closed for up to #{RunningLock.queued_ttl}s"
162
+ end
163
+ end
135
164
  end
136
165
  end
137
166
  end
@@ -6,8 +6,8 @@ module Cogworker
6
6
  # that's this same process — e.g. WorkerKiller finding itself via
7
7
  # ProcessSet — or a genuinely remote one) is subscribed to it and reacts.
8
8
  # `#quiet!`/`#stop!` mirror a real `kill -TSTP`/`TERM`; `#resume!` has no
9
- # OS-signal equivalent (there's no `SIGCONT`-style un-quiet in real
10
- # Sidekiq-alike tooling) — it's Cogworker-specific, made possible by
9
+ # OS-signal equivalent (there's no `SIGCONT`-style un-quiet signal) —
10
+ # it's Cogworker-specific, made possible by
11
11
  # `Manager#quiet` being a plain in-memory flag rather than a one-way state
12
12
  # transition like `stopping?`.
13
13
  #
@@ -11,7 +11,7 @@ module Cogworker
11
11
  identities.each do |identity|
12
12
  info = Cogworker.config.redis { |c| c.hgetall(RedisKeys.process(identity)) }
13
13
  if info.empty?
14
- Cogworker.config.redis { |c| c.srem(RedisKeys::PROCESSES, identity) }
14
+ Cogworker.config.redis { |c| c.srem?(RedisKeys::PROCESSES, identity) }
15
15
  next
16
16
  end
17
17
 
@@ -7,43 +7,104 @@ module Cogworker
7
7
  # One thread of a Manager's pool: fetch -> register in-flight -> run the
8
8
  # server middleware chain around perform -> stats/retry -> deregister.
9
9
  class Processor
10
+ ERROR_BACKOFF = 1 # seconds
11
+ ACK_ATTEMPTS = 3
12
+
10
13
  attr_reader :thread, :tid
11
14
 
12
15
  def initialize(manager)
13
16
  @manager = manager
17
+ @busy = false
14
18
  @tid = SecureRandom.hex(6)
15
- @fetcher = BasicFetch.new(manager.queues)
19
+ @fetcher = manager.fetch_class.new(manager.queues)
16
20
  end
17
21
 
18
22
  def start!
19
23
  @thread = Thread.new { run }
20
24
  end
21
25
 
26
+ # True while executing a job (as opposed to fetching or idle) — what
27
+ # `Manager#stop!` uses to tell a thread that will exit on its own within
28
+ # one fetch timeout from one stuck in a long job.
29
+ def busy?
30
+ @busy
31
+ end
32
+
22
33
  private
23
34
 
35
+ # An error escaping one iteration (a Redis blip in `retrieve_work` or
36
+ # the in-flight bookkeeping) is logged and the loop carries on after
37
+ # ERROR_BACKOFF — it used to end the thread for good, with nothing ever
38
+ # restarting it, so the process silently lost capacity one slot at a
39
+ # time.
24
40
  def run
25
41
  until @manager.stopping?
26
- if @manager.quiet?
27
- sleep(0.5)
28
- next
42
+ begin
43
+ process_one
44
+ rescue StandardError => e
45
+ Cogworker.logger.error { "Processor error: #{e.class}: #{e.message}" }
46
+ sleep(ERROR_BACKOFF)
29
47
  end
48
+ end
49
+ end
30
50
 
31
- work = @fetcher.retrieve_work
32
- next unless work
51
+ def process_one
52
+ if @manager.quiet?
53
+ sleep(0.5)
54
+ return
55
+ end
33
56
 
34
- @manager.processor_busy!
35
- begin
36
- execute(work)
37
- ensure
38
- @manager.processor_idle!
57
+ work = @fetcher.retrieve_work
58
+ return unless work
59
+
60
+ # Fetched after shutdown began (e.g. still blocked in BRPOP when
61
+ # `Manager#stop!` requeued unfinished work, which it then popped right
62
+ # back): hand it back rather than start it, since the process is about
63
+ # to exit underneath it.
64
+ return @fetcher.give_back(work) if @manager.stopping?
65
+
66
+ @manager.processor_busy!
67
+ @busy = true
68
+ begin
69
+ execute(work)
70
+ ensure
71
+ @busy = false
72
+ @manager.processor_idle!
73
+ acknowledge(work)
74
+ end
75
+ end
76
+
77
+ # A failed ack leaves an already-finished job on this process's own
78
+ # in-progress list, where `Manager#stop!` would later requeue it — a
79
+ # silent second run. Retried briefly, then logged with the jid so that
80
+ # duplicate can at least be traced.
81
+ def acknowledge(work)
82
+ attempts = 0
83
+ begin
84
+ attempts += 1
85
+ @fetcher.acknowledge(work)
86
+ rescue StandardError => e
87
+ if attempts < ACK_ATTEMPTS
88
+ sleep(0.1 * attempts)
89
+ retry
90
+ end
91
+ Cogworker.logger.error do
92
+ "couldn't acknowledge finished job jid=#{jid_of(work)} after #{attempts} attempts " \
93
+ "(it may run again on shutdown): #{e.class}: #{e.message}"
39
94
  end
40
95
  end
41
- rescue StandardError => e
42
- Cogworker.logger.error { "Processor thread died: #{e.class}: #{e.message}" }
96
+ end
97
+
98
+ def jid_of(work)
99
+ JSON.parse(work.raw_job)['jid']
100
+ rescue StandardError
101
+ '?'
43
102
  end
44
103
 
45
104
  def execute(work)
46
- job = JSON.parse(work.raw_job)
105
+ job = parse_job(work)
106
+ return unless job
107
+
47
108
  register_in_workers(work.queue, job)
48
109
  Cogworker.logger.info { "start: #{job['class']} jid=#{job['jid']}" }
49
110
 
@@ -89,6 +150,30 @@ module Cogworker
89
150
  deregister_from_workers
90
151
  end
91
152
 
153
+ # A payload that isn't a JSON object goes straight to `dead`
154
+ # (`JobUtil.unparseable_job`), and `nil` tells `execute` to skip it.
155
+ # Before this, `JSON.parse` raised outside any rescue: the job was lost
156
+ # and the processor thread died with it.
157
+ def parse_job(work)
158
+ job = JSON.parse(work.raw_job)
159
+ raise JSON::ParserError, "expected a JSON object, got #{job.class}" unless job.is_a?(Hash)
160
+
161
+ job
162
+ rescue JSON::ParserError => e
163
+ bury_unparseable(work, e)
164
+ nil
165
+ end
166
+
167
+ def bury_unparseable(work, error)
168
+ job = JobUtil.unparseable_job(work.raw_job, queue: work.queue, error: error)
169
+ Cogworker.config.redis do |c|
170
+ c.zadd(RedisKeys::DEAD, job['failed_at'], JSON.generate(job))
171
+ c.incr(RedisKeys::STATS_FAILED)
172
+ end
173
+ Throughput.record('failed')
174
+ Cogworker.logger.error { "unparseable job on queue #{work.queue} moved to dead: #{error.message}" }
175
+ end
176
+
92
177
  def build_worker(job)
93
178
  klass = Object.const_get(job['class'])
94
179
  worker = klass.new
@@ -108,6 +193,7 @@ module Cogworker
108
193
  if new_count <= max_retries
109
194
  delay = retry_delay(new_count)
110
195
  Cogworker.config.redis { |c| c.zadd(RedisKeys::RETRY, Time.now.to_f + delay, JSON.generate(job)) }
196
+ extend_locks_for_retry(job, delay)
111
197
  Attempts.record(job['jid'], attempt: new_count, error: error, outcome: 'retrying')
112
198
  else
113
199
  Cogworker.config.redis { |c| c.zadd(RedisKeys::DEAD, Time.now.to_f, JSON.generate(job)) }
@@ -115,6 +201,22 @@ module Cogworker
115
201
  end
116
202
  end
117
203
 
204
+ # A job waiting out its retry backoff still holds its `until_executed`
205
+ # lock(s) — extended here to cover that whole wait plus the usual
206
+ # `unique_lock_ttl`, since a fixed TTL counted from the original push
207
+ # ran out during a long retry series (the delay grows as `count**4`)
208
+ # and let a duplicate in. Owner-checked, so a lock that has since
209
+ # passed to another jid is left alone.
210
+ def extend_locks_for_retry(job, delay)
211
+ ttl = delay + Cogworker.config.unique_lock_ttl
212
+ Cogworker.config.redis do |c|
213
+ if UniqueJobs.until_executed?(job)
214
+ OwnedKey.expire(RedisKeys.unique_lock(UniqueJobs.digest(job)), job['jid'], ttl, c)
215
+ end
216
+ Periodic::RunningLock.touch(job['periodic_pjid'], job['jid'], ttl, c) if job['periodic_pjid']
217
+ end
218
+ end
219
+
118
220
  # Grows with the retry count, with jitter to avoid a thundering herd of
119
221
  # retries all landing on the same second.
120
222
  def retry_delay(count)
@@ -33,8 +33,8 @@ module Cogworker
33
33
  end
34
34
 
35
35
  # Removes every occurrence of this exact raw job entry (matched by full
36
- # JSON string, same "raw" identity `Routes::Dead`/`Routes::Retries`
37
- # already key their own delete/retry actions off).
36
+ # JSON string, same "raw" identity `Routes::Jobs`' dead/retrying
37
+ # actions already key their own delete/retry off).
38
38
  def delete(raw)
39
39
  Cogworker.config.redis { |c| c.lrem(@key, 0, raw) }
40
40
  end
@@ -49,11 +49,11 @@ module Cogworker
49
49
  # are untouched and keep arriving via `Client.push`, they just stop
50
50
  # being picked up until `resume!`.
51
51
  def pause!
52
- Cogworker.config.redis { |c| c.sadd(RedisKeys::PAUSED_QUEUES, name) }
52
+ Cogworker.config.redis { |c| c.sadd?(RedisKeys::PAUSED_QUEUES, name) }
53
53
  end
54
54
 
55
55
  def resume!
56
- Cogworker.config.redis { |c| c.srem(RedisKeys::PAUSED_QUEUES, name) }
56
+ Cogworker.config.redis { |c| c.srem?(RedisKeys::PAUSED_QUEUES, name) }
57
57
  end
58
58
 
59
59
  def paused?
@@ -9,6 +9,7 @@ module Cogworker
9
9
  # that nothing else reads.
10
10
  module RedisKeys
11
11
  QUEUE_PREFIX = 'cogworker:queue:'
12
+ IN_PROGRESS_PREFIX = 'cogworker:inprogress:'
12
13
  QUEUES = 'cogworker:queues'
13
14
  SCHEDULE = 'cogworker:schedule'
14
15
  RETRY = 'cogworker:retry'
@@ -26,6 +27,7 @@ module Cogworker
26
27
  def job_attempts(jid) = "cogworker:job_attempts:#{jid}"
27
28
  def process(identity) = "cogworker:process:#{identity}"
28
29
  def workers(identity) = "cogworker:workers:#{identity}"
30
+ def in_progress(identity) = "#{IN_PROGRESS_PREFIX}#{identity}"
29
31
  def signal(identity) = "cogworker:signal:#{identity}"
30
32
  def periodic_running(pjid) = "periodic:running:#{pjid}"
31
33
  def periodic_last_slot(pjid) = "periodic:last_slot:#{pjid}"
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cogworker
4
+ # The default fetch (`config.fetch = :reliable`): every job is moved
5
+ # atomically (`LMOVE`, Redis >= 6.2) from its queue onto this process's own
6
+ # `cogworker:inprogress:<identity>` list, and only removed from there
7
+ # (`acknowledge`) once it has finished — succeeded, or been routed to
8
+ # retry/dead. A job is therefore never only in a processor's memory: if the
9
+ # process dies mid-job (OOM, SIGKILL, a host going away), the job is still
10
+ # on that list, and `recover_orphans` (run by every live process's
11
+ # `Scheduled` poller) puts it back on its queue once the dead process's
12
+ # heartbeat has expired. The trade-off is at-least-once delivery: a job
13
+ # that was partly done when its process died runs again from the start.
14
+ #
15
+ # `LMOVE` can only take from one list at a time, so there's no blocking
16
+ # wait across several weighted queues the way `BasicFetch`'s single
17
+ # `BRPOP` has: instead, one round trip (FETCH_SCRIPT) tries the queues in
18
+ # weighted-shuffled order and takes the first job it finds, and an empty
19
+ # round sleeps EMPTY_POLL_INTERVAL before the next. Weighting and
20
+ # `Queue#pause!` behave exactly as in BasicFetch.
21
+ class ReliableFetch < BasicFetch
22
+ EMPTY_POLL_INTERVAL = 0.25 # seconds; worst-case pickup delay on an idle process
23
+ MIN_REDIS_VERSION = Gem::Version.new('6.2')
24
+
25
+ # KEYS[1..n-1] = queue keys, in the order to try; KEYS[n] = in-progress list.
26
+ FETCH_SCRIPT = <<~LUA
27
+ local dest = KEYS[#KEYS]
28
+ for i = 1, #KEYS - 1 do
29
+ local job = redis.call("LMOVE", KEYS[i], dest, "RIGHT", "LEFT")
30
+ if job then
31
+ return {KEYS[i], job}
32
+ end
33
+ end
34
+ return false
35
+ LUA
36
+
37
+ # KEYS[1] = an in-progress list; ARGV[1] = queue key prefix; ARGV[2] =
38
+ # queue for a payload whose own `queue` can't be read (the Processor
39
+ # then buries it as unparseable). Oldest first, each onto the end its
40
+ # queue is popped from, so recovered jobs run next, in their original
41
+ # order. Atomic per call: two processes recovering the same list can't
42
+ # both move one job.
43
+ REQUEUE_SCRIPT = <<~LUA
44
+ local moved = 0
45
+ while true do
46
+ local job = redis.call("LPOP", KEYS[1])
47
+ if not job then
48
+ return moved
49
+ end
50
+ local queue = ARGV[2]
51
+ local ok, decoded = pcall(cjson.decode, job)
52
+ if ok and type(decoded) == "table" and type(decoded["queue"]) == "string" then
53
+ queue = decoded["queue"]
54
+ end
55
+ redis.call("RPUSH", ARGV[1] .. queue, job)
56
+ moved = moved + 1
57
+ end
58
+ LUA
59
+
60
+ # KEYS[1] = in-progress list, KEYS[2] = the job's queue; ARGV[1] = job.
61
+ GIVE_BACK_SCRIPT = <<~LUA
62
+ if redis.call("LREM", KEYS[1], 1, ARGV[1]) == 1 then
63
+ redis.call("RPUSH", KEYS[2], ARGV[1])
64
+ end
65
+ LUA
66
+
67
+ class << self
68
+ def supported?
69
+ version = Cogworker.config.redis { |c| c.info('server')['redis_version'] }
70
+ Gem::Version.new(version) >= MIN_REDIS_VERSION
71
+ rescue StandardError
72
+ true # can't tell (Redis not reachable yet): assume a current server
73
+ end
74
+
75
+ # Puts every job still on `identity`'s in-progress list back on its
76
+ # queue; returns how many. Used for this process's own unfinished jobs
77
+ # on shutdown (`Manager#stop!`) and for dead processes' lists.
78
+ def requeue_in_progress(identity)
79
+ Cogworker.config.redis do |c|
80
+ c.eval(REQUEUE_SCRIPT, keys: [RedisKeys.in_progress(identity)], argv: [RedisKeys::QUEUE_PREFIX, 'default'])
81
+ end
82
+ end
83
+
84
+ # Requeues the in-progress list of every process whose heartbeat key
85
+ # is gone — i.e. that died without a clean shutdown. A process always
86
+ # writes its heartbeat before its first fetch (`Heartbeat#start!`), so
87
+ # a live process's list is never mistaken for an orphan.
88
+ def recover_orphans
89
+ own = Cogworker.identity
90
+ keys = Cogworker.config.redis { |c| c.scan_each(match: "#{RedisKeys::IN_PROGRESS_PREFIX}*").to_a }
91
+ keys.sum do |key|
92
+ identity = key.delete_prefix(RedisKeys::IN_PROGRESS_PREFIX)
93
+ next 0 if identity == own || Cogworker.config.redis { |c| c.exists?(RedisKeys.process(identity)) }
94
+
95
+ requeue_in_progress(identity).tap do |moved|
96
+ next unless moved.positive?
97
+
98
+ Cogworker.logger.warn { "requeued #{moved} job(s) left in progress by dead process #{identity}" }
99
+ end
100
+ end
101
+ end
102
+ end
103
+
104
+ def retrieve_work
105
+ keys = active_keys
106
+ if keys.empty?
107
+ sleep(TIMEOUT)
108
+ return nil
109
+ end
110
+
111
+ result = Cogworker.config.redis do |c|
112
+ c.eval(FETCH_SCRIPT, keys: keys.shuffle.uniq + [RedisKeys.in_progress(Cogworker.identity)])
113
+ end
114
+ unless result
115
+ sleep(EMPTY_POLL_INTERVAL)
116
+ return nil
117
+ end
118
+
119
+ queue_key, raw_job = result
120
+ UnitOfWork.new(queue_key.delete_prefix(RedisKeys::QUEUE_PREFIX), raw_job)
121
+ end
122
+
123
+ # Atomically moves a fetched-but-unstarted job off the in-progress list
124
+ # and back to the end of its queue that's popped next. A no-op if it's
125
+ # no longer in progress (already requeued by `requeue_in_progress`).
126
+ def give_back(work)
127
+ Cogworker.config.redis do |c|
128
+ c.eval(GIVE_BACK_SCRIPT, keys: [RedisKeys.in_progress(Cogworker.identity), RedisKeys.queue(work.queue)],
129
+ argv: [work.raw_job])
130
+ end
131
+ end
132
+
133
+ def acknowledge(work)
134
+ Cogworker.config.redis { |c| c.lrem(RedisKeys.in_progress(Cogworker.identity), 1, work.raw_job) }
135
+ end
136
+ end
137
+ end
@@ -11,6 +11,11 @@ module Cogworker
11
11
  class Scheduled
12
12
  SETS = [RedisKeys::SCHEDULE, RedisKeys::RETRY].freeze
13
13
  POLL_INTERVAL = 5
14
+ # How often this poller also looks for jobs left in progress by a dead
15
+ # process (ReliableFetch.recover_orphans) — the first time right away,
16
+ # at boot. Every live process does it; the requeue is atomic, so any
17
+ # number of them doing it at once is safe.
18
+ ORPHAN_CHECK_INTERVAL = 60
14
19
 
15
20
  def initialize(manager)
16
21
  @manager = manager
@@ -34,13 +39,27 @@ module Cogworker
34
39
 
35
40
  private
36
41
 
42
+ # A failed poll is logged and retried next interval rather than ending
43
+ # the thread — a single Redis blip used to stop every scheduled/retry
44
+ # job in this process from ever reaching its queue again.
37
45
  def run
38
46
  until @manager.stopping?
39
- enqueue_due_jobs unless @manager.quiet?
47
+ begin
48
+ recover_orphans_if_due
49
+ enqueue_due_jobs unless @manager.quiet?
50
+ rescue StandardError => e
51
+ Cogworker.logger.error { "Scheduled poll failed: #{e.class}: #{e.message}" }
52
+ end
40
53
  sleep(POLL_INTERVAL)
41
54
  end
42
- rescue StandardError => e
43
- Cogworker.logger.error { "Scheduled poller died: #{e.class}: #{e.message}" }
55
+ end
56
+
57
+ def recover_orphans_if_due
58
+ now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
59
+ return if @next_orphan_check && now < @next_orphan_check
60
+
61
+ @next_orphan_check = now + ORPHAN_CHECK_INTERVAL
62
+ ReliableFetch.recover_orphans
44
63
  end
45
64
 
46
65
  def enqueue_due_jobs
@@ -51,17 +70,25 @@ module Cogworker
51
70
  end
52
71
  end
53
72
 
73
+ # An entry that can't be requeued (not JSON, no queue) is buried in
74
+ # `dead` instead: left in place it would sit first by score, failing
75
+ # every poll and starving everything behind it; raising after the
76
+ # `zrem` used to lose it silently (and cut the rest of this poll short).
54
77
  def graduate(set, raw)
55
78
  Cogworker.config.redis do |c|
56
- won = c.zrem(set, raw)
57
- next unless won
79
+ next unless JobUtil.claim_and_requeue(c, set, raw) == :invalid
58
80
 
59
- job = JSON.parse(raw)
60
- c.multi do |pipeline|
61
- pipeline.sadd(RedisKeys::QUEUES, job['queue'])
62
- pipeline.lpush(RedisKeys.queue(job['queue']), raw)
63
- end
81
+ bury(c, set, raw) if c.zrem(set, raw)
64
82
  end
65
83
  end
84
+
85
+ def bury(conn, set, raw)
86
+ error = JSON::ParserError.new("not a job (JSON object with a queue) in #{set}")
87
+ job = JobUtil.unparseable_job(raw, queue: nil, error: error)
88
+ conn.zadd(RedisKeys::DEAD, job['failed_at'], JSON.generate(job))
89
+ conn.incr(RedisKeys::STATS_FAILED) # counted like Processor#bury_unparseable
90
+ Throughput.record('failed')
91
+ Cogworker.logger.error { "unparseable entry in #{set} moved to dead: #{raw.to_s[0, 200]}" }
92
+ end
66
93
  end
67
94
  end
@@ -30,7 +30,7 @@ module Cogworker
30
30
 
31
31
  # The raw `INFO` reply as a flat Hash (server/clients/memory/stats/...
32
32
  # sections all merged together, same as the `redis` gem always returns
33
- # it) — the Web UI's Stats tab picks a handful of fields (version,
33
+ # it) — the Web UI's Overview tab picks a handful of fields (version,
34
34
  # uptime, connected clients, memory usage) back out of this itself,
35
35
  # rather than this class pre-selecting/renaming them, so a future
36
36
  # consumer isn't limited to whatever subset this method chose.
@@ -29,8 +29,12 @@ module Cogworker
29
29
  config.server_middleware { |chain| chain.add(ServerMiddleware, expiration.to_i) }
30
30
  end
31
31
 
32
+ # A Symbol (`:queued`, `:working`, `:retrying`, `:complete`, `:failed`,
33
+ # or `:stopped`/`:interrupted` if something external wrote those), or
34
+ # `nil` for an unknown/expired jid. `get` below stays the raw stored
35
+ # Hash, string values and all.
32
36
  def status(jid)
33
- Storage.read(jid)&.fetch('status', nil)
37
+ Storage.read(jid)&.fetch('status', nil)&.to_sym
34
38
  end
35
39
 
36
40
  def get(jid)