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.
- checksums.yaml +4 -4
- data/lib/cogworker/basic_fetch.rb +60 -2
- data/lib/cogworker/cli.rb +11 -1
- data/lib/cogworker/client.rb +1 -1
- data/lib/cogworker/config.rb +54 -1
- data/lib/cogworker/heartbeat.rb +79 -7
- data/lib/cogworker/job.rb +9 -0
- data/lib/cogworker/job_util.rb +61 -0
- data/lib/cogworker/launcher.rb +38 -1
- data/lib/cogworker/logging.rb +4 -0
- data/lib/cogworker/manager.rb +66 -4
- data/lib/cogworker/owned_key.rb +40 -0
- data/lib/cogworker/periodic/claim.lua +16 -4
- data/lib/cogworker/periodic/release_middleware.rb +31 -13
- data/lib/cogworker/periodic/running_lock.rb +69 -0
- data/lib/cogworker/periodic/ticker.rb +78 -13
- data/lib/cogworker/process.rb +2 -2
- data/lib/cogworker/process_set.rb +1 -1
- data/lib/cogworker/processor.rb +144 -14
- data/lib/cogworker/queue.rb +4 -4
- data/lib/cogworker/redis_keys.rb +9 -0
- data/lib/cogworker/reliable_fetch/unsupported_error.rb +11 -0
- data/lib/cogworker/reliable_fetch.rb +225 -0
- data/lib/cogworker/scheduled.rb +58 -10
- data/lib/cogworker/stats.rb +1 -1
- data/lib/cogworker/status.rb +5 -1
- data/lib/cogworker/swarm.rb +65 -4
- data/lib/cogworker/testing/empty_queue_error.rb +9 -0
- data/lib/cogworker/testing.rb +27 -3
- data/lib/cogworker/unique_jobs/client_middleware.rb +13 -4
- data/lib/cogworker/unique_jobs/release_middleware.rb +4 -1
- data/lib/cogworker/version.rb +1 -1
- data/lib/cogworker/web/layout.rb +3 -3
- data/lib/cogworker/web/optional_session.rb +25 -0
- data/lib/cogworker/web/routes/jobs.rb +72 -37
- data/lib/cogworker/web/routes/overview.rb +6 -12
- data/lib/cogworker/web/routes/schedules.rb +2 -2
- data/lib/cogworker/web.rb +36 -10
- data/lib/cogworker.rb +16 -1
- 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
|
|
9
|
-
--
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
6
|
-
# `unique: :until_executed` entries
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
97
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/cogworker/process.rb
CHANGED
|
@@ -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
|
|
10
|
-
#
|
|
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
|
|
data/lib/cogworker/processor.rb
CHANGED
|
@@ -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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
32
|
-
|
|
50
|
+
def process_one
|
|
51
|
+
if @manager.quiet?
|
|
52
|
+
sleep(0.5)
|
|
53
|
+
return
|
|
54
|
+
end
|
|
33
55
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
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 =
|
|
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)
|
data/lib/cogworker/queue.rb
CHANGED
|
@@ -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::
|
|
37
|
-
# already key their own delete/retry
|
|
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?
|
data/lib/cogworker/redis_keys.rb
CHANGED
|
@@ -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
|