cogworker 0.2.2 → 0.4.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/lib/cogworker/basic_fetch.rb +60 -2
  3. data/lib/cogworker/cli.rb +11 -1
  4. data/lib/cogworker/client.rb +1 -1
  5. data/lib/cogworker/config.rb +54 -1
  6. data/lib/cogworker/heartbeat.rb +79 -7
  7. data/lib/cogworker/job.rb +9 -0
  8. data/lib/cogworker/job_util.rb +61 -0
  9. data/lib/cogworker/launcher.rb +38 -1
  10. data/lib/cogworker/logging.rb +4 -0
  11. data/lib/cogworker/manager.rb +66 -4
  12. data/lib/cogworker/owned_key.rb +40 -0
  13. data/lib/cogworker/periodic/claim.lua +16 -4
  14. data/lib/cogworker/periodic/release_middleware.rb +31 -13
  15. data/lib/cogworker/periodic/running_lock.rb +69 -0
  16. data/lib/cogworker/periodic/ticker.rb +78 -13
  17. data/lib/cogworker/process.rb +2 -2
  18. data/lib/cogworker/process_set.rb +1 -1
  19. data/lib/cogworker/processor.rb +144 -14
  20. data/lib/cogworker/queue.rb +4 -4
  21. data/lib/cogworker/redis_keys.rb +9 -0
  22. data/lib/cogworker/reliable_fetch/unsupported_error.rb +11 -0
  23. data/lib/cogworker/reliable_fetch.rb +225 -0
  24. data/lib/cogworker/scheduled.rb +58 -10
  25. data/lib/cogworker/stats.rb +1 -1
  26. data/lib/cogworker/status.rb +5 -1
  27. data/lib/cogworker/swarm.rb +65 -4
  28. data/lib/cogworker/testing/empty_queue_error.rb +9 -0
  29. data/lib/cogworker/testing.rb +27 -3
  30. data/lib/cogworker/unique_jobs/client_middleware.rb +13 -4
  31. data/lib/cogworker/unique_jobs/release_middleware.rb +4 -1
  32. data/lib/cogworker/version.rb +1 -1
  33. data/lib/cogworker/web/layout.rb +3 -3
  34. data/lib/cogworker/web/optional_session.rb +25 -0
  35. data/lib/cogworker/web/routes/jobs.rb +72 -37
  36. data/lib/cogworker/web/routes/overview.rb +6 -12
  37. data/lib/cogworker/web/routes/schedules.rb +2 -2
  38. data/lib/cogworker/web.rb +36 -10
  39. data/lib/cogworker.rb +16 -1
  40. metadata +7 -1
@@ -4,17 +4,26 @@
4
4
  -- ARGV[1] = slot (epoch int)
5
5
  -- ARGV[2] = unique mode ("until_executed" or "")
6
6
  -- ARGV[3] = lock TTL (seconds)
7
+ -- ARGV[4] = jid the caller will push this slot's job under
8
+ -- ARGV[5] = running-lock TTL (seconds)
7
9
  --
8
- -- Returns 1 if this call won the claim for this slot (the caller should
9
- -- enqueue the job), 0 otherwise. Composes two guards: `last_slot` stops a
10
+ -- Returns {1, <previous last_slot, or "" if none>} if this call won the
11
+ -- claim for this slot (the caller should enqueue the job — and, if that
12
+ -- fails, roll the claim back with ROLLBACK_SCRIPT using that previous
13
+ -- value), 0 otherwise. Composes two guards: `last_slot` stops a
10
14
  -- process re-firing the same (or an earlier) slot on a later tick, and the
11
15
  -- NX lock breaks the narrow race where two processes both pass the
12
16
  -- `last_slot` check before either has written it back.
17
+ --
18
+ -- For `until_executed`, the running lock is written here, in the same
19
+ -- atomic step as the claim and before the job exists anywhere — see
20
+ -- Periodic::RunningLock for why it must never be written after the push.
13
21
  if ARGV[2] == "until_executed" and redis.call("GET", KEYS[1]) then
14
22
  return 0
15
23
  end
16
24
 
17
- local last = tonumber(redis.call("GET", KEYS[2]) or "0")
25
+ local previous = redis.call("GET", KEYS[2])
26
+ local last = tonumber(previous or "0")
18
27
  if tonumber(ARGV[1]) <= last then
19
28
  return 0
20
29
  end
@@ -24,4 +33,7 @@ if not redis.call("SET", KEYS[3], "1", "NX", "EX", ARGV[3]) then
24
33
  end
25
34
 
26
35
  redis.call("SET", KEYS[2], ARGV[1])
27
- return 1
36
+ if ARGV[2] == "until_executed" then
37
+ redis.call("SET", KEYS[1], ARGV[4], "EX", ARGV[5])
38
+ end
39
+ return {1, previous or ""}
@@ -2,25 +2,43 @@
2
2
 
3
3
  module Cogworker
4
4
  module Periodic
5
- # Releases the `periodic:running:<pjid>` lock (used only for
6
- # `unique: :until_executed` entries) on success, or on the terminal
7
- # failed attempt. A no-op for any job that isn't periodic-scheduled
8
- # (`job['periodic_pjid']` absent). Registered unconditionally in every
9
- # Config, not gated on whether `config.periodic` is actually used, so it
10
- # never becomes a hidden dependency of the (separate) job status layer.
5
+ # Drives the server side of the `periodic:running:<pjid>` lock (used
6
+ # only for `unique: :until_executed` entries; see RunningLock for the
7
+ # whole lifecycle): shortens it to `RunningLock.active_ttl` when the run
8
+ # starts, and releases it on success or on the terminal failed attempt
9
+ # (a failure that will be retried is `Processor#route_failure`'s to
10
+ # extend — only it knows the retry delay).
11
+ # A no-op for any job that isn't periodic-scheduled (`job['periodic_pjid']`
12
+ # absent) — and every call is a no-op on a lock owned by another jid, so
13
+ # it's harmless for a non-`until_executed` entry too. Registered
14
+ # unconditionally in every Config, not gated on whether
15
+ # `config.periodic` is actually used, so it never becomes a hidden
16
+ # dependency of the (separate) job status layer.
11
17
  class ReleaseMiddleware
12
18
  def call(_worker, job, _queue)
13
- yield
14
- release(job) if job['periodic_pjid']
15
- rescue Exception => e # rubocop:disable Lint/RescueException
16
- release(job) if job['periodic_pjid'] && JobUtil.terminal_failure?(job)
17
- raise e
19
+ pjid = job['periodic_pjid']
20
+ return yield unless pjid
21
+
22
+ shorten_lock(pjid, job['jid'])
23
+ begin
24
+ yield
25
+ rescue Exception => e # rubocop:disable Lint/RescueException
26
+ RunningLock.release(pjid, job['jid']) if JobUtil.terminal_failure?(job)
27
+ raise e
28
+ end
29
+ RunningLock.release(pjid, job['jid'])
18
30
  end
19
31
 
20
32
  private
21
33
 
22
- def release(job)
23
- Cogworker.config.redis { |c| c.del(RedisKeys.periodic_running(job['periodic_pjid'])) }
34
+ # Best-effort: failing here must not stop the job from running (it
35
+ # would be routed to retry/dead as if it had failed). If it does fail,
36
+ # the lock just keeps its longer queued TTL until this process's next
37
+ # heartbeat shortens it — within Heartbeat::INTERVAL.
38
+ def shorten_lock(pjid, jid)
39
+ RunningLock.touch(pjid, jid, RunningLock.active_ttl)
40
+ rescue StandardError => e
41
+ Cogworker.logger.warn { "couldn't shorten periodic lock for #{pjid}: #{e.class}: #{e.message}" }
24
42
  end
25
43
  end
26
44
  end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cogworker
4
+ module Periodic
5
+ # `periodic:running:<pjid>` — the "an earlier run of this entry hasn't
6
+ # finished yet" lock behind `unique: :until_executed` periodic entries.
7
+ #
8
+ # Lifecycle (every write keyed on the owning jid, so a stray older run
9
+ # can never shorten, extend or release a newer run's lock):
10
+ # - set by `claim.lua`, atomically with the slot claim itself, *before*
11
+ # the job is pushed — setting it after the push let a fast job finish
12
+ # (and release a lock that didn't exist yet) before the lock was
13
+ # written, wedging the entry forever. TTL = `queued_ttl`, covering
14
+ # however long the job may wait in its queue.
15
+ # - shortened to `active_ttl` once a processor starts it, then kept alive
16
+ # by that process's Heartbeat every beat — so a run whose process dies
17
+ # (OOM, SIGKILL, shutdown past the drain timeout) can't hold the entry
18
+ # forever. `active_ttl` deliberately outlives the time it takes for
19
+ # that dead process's job to be recovered (`config.orphan_threshold`
20
+ # plus one orphan check), so the lock is still there, still owned by
21
+ # that job, when the job is requeued — at which point `retake` (or ReliableFetch's
22
+ # REQUEUE_SCRIPT) extends it again for the wait in the queue. A shorter
23
+ # TTL let the lock lapse first: the ticker would enqueue the next slot
24
+ # while the recovered run was still to come, i.e. two runs at once.
25
+ # - extended on a non-terminal failure to cover the retry backoff plus
26
+ # `queued_ttl` (`Processor#extend_locks_for_retry` — the job now waits
27
+ # in `cogworker:retry`, not on a process).
28
+ # - deleted on success or on the terminal failure.
29
+ module RunningLock
30
+ # Lua, prepended to every script that puts a job back on its queue
31
+ # (ReliableFetch::REQUEUE_SCRIPT, BasicFetch::REQUEUE_ENTRY_SCRIPT), so
32
+ # the requeue and the lock move together in one atomic step. Extends
33
+ # the lock if still that job's, takes it if it lapsed, leaves it if a
34
+ # newer run holds it.
35
+ RETAKE_FUNCTION = <<~LUA
36
+ local function retake_running_lock(lock, jid, ttl)
37
+ local owner = redis.call("GET", lock)
38
+ if not owner then
39
+ redis.call("SET", lock, jid, "EX", ttl)
40
+ elseif owner == jid then
41
+ redis.call("EXPIRE", lock, ttl)
42
+ end
43
+ end
44
+ LUA
45
+
46
+ module_function
47
+
48
+ # Whole seconds: it goes straight into `SET ... EX` in claim.lua, which
49
+ # rejects a non-integer (a fractional `unique_lock_ttl` used to make
50
+ # every claim — and so every tick — fail).
51
+ # A method, not a constant: it follows `config.orphan_threshold`.
52
+ def active_ttl
53
+ (Cogworker.config.orphan_threshold + Scheduled::ORPHAN_CHECK_INTERVAL + 60).to_f.ceil
54
+ end
55
+
56
+ def queued_ttl
57
+ Cogworker.config.unique_lock_ttl.to_f.ceil
58
+ end
59
+
60
+ def touch(pjid, jid, ttl, conn = nil)
61
+ OwnedKey.expire(RedisKeys.periodic_running(pjid), jid, ttl, conn)
62
+ end
63
+
64
+ def release(pjid, jid, conn = nil)
65
+ OwnedKey.delete(RedisKeys.periodic_running(pjid), jid, conn)
66
+ end
67
+ end
68
+ end
69
+ end
@@ -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
@@ -16,6 +17,25 @@ module Cogworker
16
17
 
17
18
  CLAIM_SCRIPT = File.read(File.join(__dir__, 'claim.lua'))
18
19
 
20
+ # Undoes a won claim whose push then failed, so the slot isn't lost:
21
+ # puts `last_slot` back (only if it still is this slot — a later slot
22
+ # may have been claimed since) and drops the per-slot NX lock.
23
+ # KEYS[1] = last_slot, KEYS[2] = slot lock; ARGV[1] = slot, ARGV[2] =
24
+ # previous last_slot ("" if none: then slot - 1, which still lets this
25
+ # slot fire, unlike deleting the key — with catch_up off, a missing
26
+ # last_slot would be re-primed to this very slot instead).
27
+ ROLLBACK_SCRIPT = <<~LUA
28
+ if redis.call("GET", KEYS[1]) == ARGV[1] then
29
+ if ARGV[2] == "" then
30
+ redis.call("SET", KEYS[1], tostring(tonumber(ARGV[1]) - 1))
31
+ else
32
+ redis.call("SET", KEYS[1], ARGV[2])
33
+ end
34
+ end
35
+ redis.call("DEL", KEYS[2])
36
+ return 1
37
+ LUA
38
+
19
39
  def initialize(manager, entries, catch_up: true)
20
40
  @manager = manager
21
41
  @entries = entries
@@ -54,13 +74,20 @@ module Cogworker
54
74
  Cogworker.config.redis { |c| c.hset(RedisKeys::PERIODIC_SCHEDULE, *payloads.to_a.flatten) }
55
75
  end
56
76
 
77
+ # A failed tick is logged and retried next interval rather than ending
78
+ # the thread — a single Redis blip used to silently stop every
79
+ # periodic entry in this process for good. A slot whose claim or push
80
+ # raised isn't recorded in `@last_checked_slot` (and a failed push
81
+ # rolls its claim back — see #enqueue), so the next tick retries it.
57
82
  def run
58
83
  until @manager.stopping?
59
- tick unless @manager.quiet?
84
+ begin
85
+ tick unless @manager.quiet?
86
+ rescue StandardError => e
87
+ Cogworker.logger.error { "Periodic tick failed: #{e.class}: #{e.message}" }
88
+ end
60
89
  sleep(TICK_INTERVAL)
61
90
  end
62
- rescue StandardError => e
63
- Cogworker.logger.error { "Periodic ticker died: #{e.class}: #{e.message}" }
64
91
  end
65
92
 
66
93
  def tick
@@ -69,13 +96,15 @@ module Cogworker
69
96
  slot = cron_for(entry).previous_time(now).to_i
70
97
  next if @last_checked_slot[entry.pjid] == slot
71
98
 
72
- enqueue(entry, slot) if !disabled?(entry) && claim?(entry, slot)
99
+ jid = SecureRandom.hex(12)
100
+ previous = claim(entry, slot, jid) unless disabled?(entry)
101
+ enqueue(entry, slot, jid, previous) if previous
73
102
  @last_checked_slot[entry.pjid] = slot
74
103
  end
75
104
  end
76
105
 
77
106
  # Web UI "Disable" (`Routes::Schedules`) — skips the claim/enqueue
78
- # step entirely, short-circuiting before `claim?` even runs, so
107
+ # step entirely, short-circuiting before `claim` even runs, so
79
108
  # neither `periodic:last_slot:<pjid>` nor the per-slot lock advance
80
109
  # while disabled: the Web UI's own "LastRun" column keeps showing the
81
110
  # last time it *actually* ran, not a due-but-skipped slot, and
@@ -85,6 +114,15 @@ module Cogworker
85
114
  # persisted last_slot) still advances either way, exactly as it
86
115
  # already did before this entry ever had a disabled state — it only
87
116
  # stops this same tick loop from re-evaluating the same slot twice.
117
+ def rollback_claim(entry, slot, previous_slot)
118
+ Cogworker.config.redis do |c|
119
+ keys = [RedisKeys.periodic_last_slot(entry.pjid), RedisKeys.periodic_lock(entry.pjid, slot)]
120
+ c.eval(ROLLBACK_SCRIPT, keys: keys, argv: [slot, previous_slot])
121
+ end
122
+ rescue StandardError => e
123
+ Cogworker.logger.error { "periodic #{entry.pjid}: couldn't roll back slot #{slot}: #{e.class}: #{e.message}" }
124
+ end
125
+
88
126
  def disabled?(entry)
89
127
  Cogworker.config.redis { |c| c.sismember(RedisKeys::PERIODIC_DISABLED, entry.pjid) }
90
128
  end
@@ -93,16 +131,18 @@ module Cogworker
93
131
  @cron_cache[entry.pjid] ||= Fugit::Cron.parse(entry.cron)
94
132
  end
95
133
 
96
- def claim?(entry, slot)
97
- return false if !@catch_up && priming_first_slot?(entry, slot)
134
+ # nil if this process didn't win the slot; otherwise the previous
135
+ # `last_slot` value ("" if none), for #rollback_claim.
136
+ def claim(entry, slot, jid)
137
+ return nil if !@catch_up && priming_first_slot?(entry, slot)
98
138
 
99
139
  result = Cogworker.config.redis do |c|
100
140
  c.eval(CLAIM_SCRIPT,
101
141
  keys: [RedisKeys.periodic_running(entry.pjid), RedisKeys.periodic_last_slot(entry.pjid),
102
142
  RedisKeys.periodic_lock(entry.pjid, slot)],
103
- argv: [slot, entry.unique.to_s, LOCK_TTL])
143
+ argv: [slot, entry.unique.to_s, LOCK_TTL, jid, RunningLock.queued_ttl])
104
144
  end
105
- result == 1
145
+ result.is_a?(Array) ? result[1].to_s : nil
106
146
  end
107
147
 
108
148
  # With catch-up disabled, an entry's very first tick — ever, across
@@ -123,15 +163,40 @@ module Cogworker
123
163
  Cogworker.config.redis { |c| c.set(RedisKeys.periodic_last_slot(entry.pjid), slot, nx: true) }
124
164
  end
125
165
 
126
- def enqueue(entry, slot)
166
+ # The running lock (for `until_executed`) is already in place, set by
167
+ # the claim under this same `jid` — nothing to write after the push.
168
+ # That relies on the job keeping that jid: `JobUtil.normalize_item`
169
+ # only fills `jid` in when absent, but a custom client middleware that
170
+ # overwrites it would leave the lock owned by a jid no job carries —
171
+ # the entry then stays closed for `RunningLock.queued_ttl` (logged).
172
+ # Only undone here if the push didn't actually produce a job (raised,
173
+ # or a client middleware swallowed it), so a job that never existed
174
+ # can't hold the entry for the whole `RunningLock.queued_ttl`.
175
+ def enqueue(entry, slot, jid, previous_slot)
127
176
  job = {
128
177
  'class' => entry.class_name, 'args' => entry.args, 'retry' => entry.retry,
129
- 'periodic_pjid' => entry.pjid, 'periodic_slot' => slot
178
+ 'periodic_pjid' => entry.pjid, 'periodic_slot' => slot, 'jid' => jid
130
179
  }
131
- jid = Client.push(job)
180
+ # Lets a requeue after a crash/shutdown re-take this run's lock
181
+ # (Periodic::RunningLock::RETAKE_FUNCTION, in both fetches' requeue scripts).
182
+ job['periodic_until_executed'] = true if entry.until_executed?
183
+ pushed = begin
184
+ Client.push(job)
185
+ rescue StandardError
186
+ RunningLock.release(entry.pjid, jid) if entry.until_executed?
187
+ rollback_claim(entry, slot, previous_slot)
188
+ raise
189
+ end
132
190
  return unless entry.until_executed?
133
191
 
134
- Cogworker.config.redis { |c| c.set(RedisKeys.periodic_running(entry.pjid), jid) }
192
+ if pushed.nil?
193
+ RunningLock.release(entry.pjid, jid)
194
+ elsif pushed != jid
195
+ Cogworker.logger.warn do
196
+ "periodic #{entry.pjid}: a client middleware changed the job's jid (#{jid} -> #{pushed}); its running " \
197
+ "lock won't be released by the job and keeps the entry closed for up to #{RunningLock.queued_ttl}s"
198
+ end
199
+ end
135
200
  end
136
201
  end
137
202
  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,126 @@ 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)
16
19
  end
17
20
 
18
21
  def start!
19
22
  @thread = Thread.new { run }
20
23
  end
21
24
 
25
+ # True while executing a job (as opposed to fetching or idle) — what
26
+ # `Manager#stop!` uses to tell a thread that will exit on its own within
27
+ # one fetch timeout from one stuck in a long job.
28
+ def busy?
29
+ @busy
30
+ end
31
+
22
32
  private
23
33
 
34
+ # An error escaping one iteration (a Redis blip in `retrieve_work` or
35
+ # the in-flight bookkeeping) is logged and the loop carries on after
36
+ # ERROR_BACKOFF — it used to end the thread for good, with nothing ever
37
+ # restarting it, so the process silently lost capacity one slot at a
38
+ # time.
24
39
  def run
25
40
  until @manager.stopping?
26
- if @manager.quiet?
27
- sleep(0.5)
28
- next
41
+ begin
42
+ process_one
43
+ rescue StandardError => e
44
+ Cogworker.logger.error { "Processor error: #{e.class}: #{e.message}" }
45
+ sleep(ERROR_BACKOFF)
29
46
  end
47
+ end
48
+ end
30
49
 
31
- work = @fetcher.retrieve_work
32
- next unless work
50
+ def process_one
51
+ if @manager.quiet?
52
+ sleep(0.5)
53
+ return
54
+ end
33
55
 
34
- @manager.processor_busy!
35
- begin
36
- execute(work)
37
- ensure
38
- @manager.processor_idle!
39
- end
56
+ work = begin
57
+ fetcher.retrieve_work
58
+ rescue ReliableFetch::UnsupportedError => e
59
+ @manager.fall_back_to_basic_fetch!(e)
60
+ @fetcher = nil
61
+ return
62
+ end
63
+ return unless work
64
+
65
+ # Fetched after shutdown began (e.g. still blocked in BRPOP when
66
+ # `Manager#stop!` requeued unfinished work, which it then popped right
67
+ # back): hand it back rather than start it, since the process is about
68
+ # to exit underneath it.
69
+ return fetcher.give_back(work) if @manager.stopping?
70
+
71
+ @manager.processor_busy!
72
+ @busy = true
73
+ finished = false
74
+ begin
75
+ execute(work)
76
+ finished = true
77
+ ensure
78
+ @busy = false
79
+ @manager.processor_idle!
80
+ finished ? acknowledge(work) : give_back(work)
40
81
  end
82
+ end
83
+
84
+ # `execute` raising means bookkeeping failed (Redis, mid-way through
85
+ # registering the job or routing its failure to retry/dead) — not the
86
+ # job itself, whose own errors `execute` always handles. Acknowledging
87
+ # here would drop a job that never made it to retry/dead; instead it
88
+ # goes straight back on its queue (it may run again: at-least-once). If
89
+ # even that fails, it simply stays on the in-progress list, which
90
+ # `Manager#stop!`/orphan recovery requeue later.
91
+ def give_back(work)
92
+ fetcher.give_back(work)
41
93
  rescue StandardError => e
42
- Cogworker.logger.error { "Processor thread died: #{e.class}: #{e.message}" }
94
+ Cogworker.logger.error do
95
+ "couldn't hand back jid=#{jid_of(work)} after a failed run (left in progress): #{e.class}: #{e.message}"
96
+ end
97
+ end
98
+
99
+ # A failed ack leaves an already-finished job on this process's own
100
+ # in-progress list, where `Manager#stop!` would later requeue it — a
101
+ # silent second run. Retried briefly, then logged with the jid so that
102
+ # duplicate can at least be traced.
103
+ def acknowledge(work)
104
+ attempts = 0
105
+ begin
106
+ attempts += 1
107
+ fetcher.acknowledge(work)
108
+ rescue StandardError => e
109
+ if attempts < ACK_ATTEMPTS
110
+ sleep(0.1 * attempts)
111
+ retry
112
+ end
113
+ Cogworker.logger.error do
114
+ "couldn't acknowledge finished job jid=#{jid_of(work)} after #{attempts} attempts " \
115
+ "(it may run again on shutdown): #{e.class}: #{e.message}"
116
+ end
117
+ end
118
+ end
119
+
120
+ def jid_of(work)
121
+ JSON.parse(work.raw_job)['jid']
122
+ rescue StandardError
123
+ '?'
43
124
  end
44
125
 
45
126
  def execute(work)
46
- job = JSON.parse(work.raw_job)
127
+ job = parse_job(work)
128
+ return unless job
129
+
47
130
  register_in_workers(work.queue, job)
48
131
  Cogworker.logger.info { "start: #{job['class']} jid=#{job['jid']}" }
49
132
 
@@ -89,6 +172,36 @@ module Cogworker
89
172
  deregister_from_workers
90
173
  end
91
174
 
175
+ # A payload that isn't a JSON object goes straight to `dead`
176
+ # (`JobUtil.unparseable_job`), and `nil` tells `execute` to skip it.
177
+ # Before this, `JSON.parse` raised outside any rescue: the job was lost
178
+ # and the processor thread died with it.
179
+ def parse_job(work)
180
+ job = JSON.parse(work.raw_job)
181
+ raise JSON::ParserError, "expected a JSON object, got #{job.class}" unless job.is_a?(Hash)
182
+
183
+ job
184
+ rescue JSON::ParserError => e
185
+ bury_unparseable(work, e)
186
+ nil
187
+ end
188
+
189
+ def bury_unparseable(work, error)
190
+ job = JobUtil.unparseable_job(work.raw_job, queue: work.queue, error: error)
191
+ Cogworker.config.redis do |c|
192
+ c.zadd(RedisKeys::DEAD, job['failed_at'], JSON.generate(job))
193
+ c.incr(RedisKeys::STATS_FAILED)
194
+ end
195
+ Throughput.record('failed')
196
+ Cogworker.logger.error { "unparseable job on queue #{work.queue} moved to dead: #{error.message}" }
197
+ end
198
+
199
+ # Built on first use, not in the constructor (see Manager#fetch_class),
200
+ # and rebuilt after a fall-back to BasicFetch.
201
+ def fetcher
202
+ @fetcher ||= @manager.fetch_class.new(@manager.queues)
203
+ end
204
+
92
205
  def build_worker(job)
93
206
  klass = Object.const_get(job['class'])
94
207
  worker = klass.new
@@ -108,6 +221,7 @@ module Cogworker
108
221
  if new_count <= max_retries
109
222
  delay = retry_delay(new_count)
110
223
  Cogworker.config.redis { |c| c.zadd(RedisKeys::RETRY, Time.now.to_f + delay, JSON.generate(job)) }
224
+ extend_locks_for_retry(job, delay)
111
225
  Attempts.record(job['jid'], attempt: new_count, error: error, outcome: 'retrying')
112
226
  else
113
227
  Cogworker.config.redis { |c| c.zadd(RedisKeys::DEAD, Time.now.to_f, JSON.generate(job)) }
@@ -115,6 +229,22 @@ module Cogworker
115
229
  end
116
230
  end
117
231
 
232
+ # A job waiting out its retry backoff still holds its `until_executed`
233
+ # lock(s) — extended here to cover that whole wait plus the usual
234
+ # `unique_lock_ttl`, since a fixed TTL counted from the original push
235
+ # ran out during a long retry series (the delay grows as `count**4`)
236
+ # and let a duplicate in. Owner-checked, so a lock that has since
237
+ # passed to another jid is left alone.
238
+ def extend_locks_for_retry(job, delay)
239
+ ttl = delay + Cogworker.config.unique_lock_ttl
240
+ Cogworker.config.redis do |c|
241
+ if UniqueJobs.until_executed?(job)
242
+ OwnedKey.expire(RedisKeys.unique_lock(UniqueJobs.digest(job)), job['jid'], ttl, c)
243
+ end
244
+ Periodic::RunningLock.touch(job['periodic_pjid'], job['jid'], ttl, c) if job['periodic_pjid']
245
+ end
246
+ end
247
+
118
248
  # Grows with the retry count, with jitter to avoid a thundering herd of
119
249
  # retries all landing on the same second.
120
250
  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,14 @@ module Cogworker
9
9
  # that nothing else reads.
10
10
  module RedisKeys
11
11
  QUEUE_PREFIX = 'cogworker:queue:'
12
+ IN_PROGRESS_PREFIX = 'cogworker:inprogress:'
13
+ # Every identity that has (or may have) an in-progress list — what
14
+ # orphan recovery walks instead of SCANning the whole keyspace.
15
+ IN_PROGRESS_IDENTITIES = 'cogworker:inprogress_identities'
16
+ # identity => Redis server time (epoch seconds) of its last heartbeat.
17
+ # No TTL, unlike `process(identity)`: it's what tells "dead for a
18
+ # while" apart from "missed a few beats" (Config#orphan_threshold).
19
+ LAST_BEAT = 'cogworker:last_beat'
12
20
  QUEUES = 'cogworker:queues'
13
21
  SCHEDULE = 'cogworker:schedule'
14
22
  RETRY = 'cogworker:retry'
@@ -26,6 +34,7 @@ module Cogworker
26
34
  def job_attempts(jid) = "cogworker:job_attempts:#{jid}"
27
35
  def process(identity) = "cogworker:process:#{identity}"
28
36
  def workers(identity) = "cogworker:workers:#{identity}"
37
+ def in_progress(identity) = "#{IN_PROGRESS_PREFIX}#{identity}"
29
38
  def signal(identity) = "cogworker:signal:#{identity}"
30
39
  def periodic_running(pjid) = "periodic:running:#{pjid}"
31
40
  def periodic_last_slot(pjid) = "periodic:last_slot:#{pjid}"
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cogworker
4
+ class ReliableFetch
5
+ # Raised by ReliableFetch#retrieve_work when the server turns out not to
6
+ # know LMOVE (Redis < 6.2) even though the version check at boot let
7
+ # `:reliable` through (e.g. Redis wasn't reachable then). The Processor
8
+ # reacts by switching the whole Manager over to BasicFetch.
9
+ class UnsupportedError < StandardError; end
10
+ end
11
+ end