cogworker 0.3.0 → 0.4.1

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.
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'digest/sha1'
4
+ require 'json'
5
+
3
6
  module Cogworker
4
7
  # The default fetch (`config.fetch = :reliable`): every job is moved
5
8
  # atomically (`LMOVE`, Redis >= 6.2) from its queue onto this process's own
@@ -8,8 +11,8 @@ module Cogworker
8
11
  # retry/dead. A job is therefore never only in a processor's memory: if the
9
12
  # process dies mid-job (OOM, SIGKILL, a host going away), the job is still
10
13
  # 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
14
+ # `Scheduled` poller) puts it back on its queue once the dead process has
15
+ # gone `config.orphan_threshold` (default 5 min) without a heartbeat. The trade-off is at-least-once delivery: a job
13
16
  # that was partly done when its process died runs again from the start.
14
17
  #
15
18
  # `LMOVE` can only take from one list at a time, so there's no blocking
@@ -19,7 +22,12 @@ module Cogworker
19
22
  # round sleeps EMPTY_POLL_INTERVAL before the next. Weighting and
20
23
  # `Queue#pause!` behave exactly as in BasicFetch.
21
24
  class ReliableFetch < BasicFetch
22
- EMPTY_POLL_INTERVAL = 0.25 # seconds; worst-case pickup delay on an idle process
25
+ # The first pause after an empty round; each further empty round in a
26
+ # row doubles it, up to `config.fetch_idle_max_interval` (default 1s),
27
+ # and finding a job resets it. That cap is the worst-case pickup delay
28
+ # on an idle process — the trade-off against how often idle processors
29
+ # poll Redis (at 0.25s flat, 4 calls/s per thread).
30
+ EMPTY_POLL_INTERVAL = 0.25
23
31
  MIN_REDIS_VERSION = Gem::Version.new('6.2')
24
32
 
25
33
  # KEYS[1..n-1] = queue keys, in the order to try; KEYS[n] = in-progress list.
@@ -34,69 +42,199 @@ module Cogworker
34
42
  return false
35
43
  LUA
36
44
 
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
45
+ # (Run by hash, like every script here — see LuaScript.)
46
+ FETCH_SCRIPT_SHA = LuaScript.sha(FETCH_SCRIPT)
47
+
48
+ # Lua shared by every script here that puts a job back on its queue:
49
+ # onto the end its queue is popped from (so it runs next), onto
50
+ # `default_queue` if its own `queue` can't be read (the Processor then
51
+ # buries it as unparseable) — and a job from an `until_executed`
52
+ # periodic entry re-takes its running lock on the way
53
+ # (Periodic::RunningLock::RETAKE_FUNCTION), so the ticker can't start
54
+ # the next slot alongside the requeued run.
55
+ REQUEUE_JOB_FUNCTION = Periodic::RunningLock::RETAKE_FUNCTION + <<~LUA
56
+ local function requeue_job(job, queue_prefix, default_queue, lock_prefix, lock_ttl)
57
+ local queue = default_queue
58
+ local ok, decoded = pcall(cjson.decode, job)
59
+ if ok and type(decoded) == "table" then
60
+ if type(decoded["queue"]) == "string" and decoded["queue"] ~= "" then
61
+ queue = decoded["queue"]
62
+ end
63
+ local pjid, jid = decoded["periodic_pjid"], decoded["jid"]
64
+ if decoded["periodic_until_executed"] == true and type(pjid) == "string" and type(jid) == "string" then
65
+ retake_running_lock(lock_prefix .. pjid, jid, lock_ttl)
66
+ end
67
+ end
68
+ redis.call("RPUSH", queue_prefix .. queue, job)
69
+ end
70
+ LUA
71
+
72
+ # KEYS[1] = an in-progress list, KEYS[2] = its owner's presence key,
73
+ # KEYS[3] = LAST_BEAT; ARGV[1..4] = requeue_job's prefix/default
74
+ # queue/lock prefix/lock TTL, ARGV[5] = owner identity, ARGV[6] = orphan
75
+ # threshold, or "" to requeue unconditionally (this process's own list,
76
+ # on shutdown). Returns how many it moved, or -1 if the owner turned
77
+ # out to be alive. Oldest first, so recovered jobs keep their order. The
78
+ # "is the owner really dead?" check (see ReliableFetch.dead?) is made
79
+ # here, atomically with the requeue: checked separately, an owner that
80
+ # beat in between would lose jobs it is still running to a second run.
81
+ # Atomic per call, so two processes recovering one list can't both move
82
+ # a job.
83
+ REQUEUE_SCRIPT = REQUEUE_JOB_FUNCTION + <<~LUA
84
+ if ARGV[6] ~= "" then
85
+ if redis.call("EXISTS", KEYS[2]) == 1 then
86
+ return -1
87
+ end
88
+ local last = redis.call("HGET", KEYS[3], ARGV[5])
89
+ if last then
90
+ if redis.replicate_commands then redis.replicate_commands() end
91
+ local now = tonumber(redis.call("TIME")[1])
92
+ if now - tonumber(last) <= tonumber(ARGV[6]) then
93
+ return -1
94
+ end
95
+ end
96
+ end
44
97
  local moved = 0
45
98
  while true do
46
99
  local job = redis.call("LPOP", KEYS[1])
47
100
  if not job then
48
101
  return moved
49
102
  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)
103
+ requeue_job(job, ARGV[1], ARGV[2], ARGV[3], ARGV[4])
56
104
  moved = moved + 1
57
105
  end
58
106
  LUA
59
107
 
60
- # KEYS[1] = in-progress list, KEYS[2] = the job's queue; ARGV[1] = job.
61
- GIVE_BACK_SCRIPT = <<~LUA
108
+ # KEYS[1] = an in-progress list; ARGV[1] = one job on it, ARGV[2..5] =
109
+ # requeue_job's arguments. That one job, if still on the list, back on
110
+ # its queue: `give_back`, and `reconcile`'s strays.
111
+ REQUEUE_ONE_SCRIPT = REQUEUE_JOB_FUNCTION + <<~LUA
62
112
  if redis.call("LREM", KEYS[1], 1, ARGV[1]) == 1 then
63
- redis.call("RPUSH", KEYS[2], ARGV[1])
113
+ requeue_job(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5])
114
+ return 1
64
115
  end
116
+ return 0
117
+ LUA
118
+
119
+ # KEYS[1] = in-progress list, KEYS[2] = retry/dead ZSET; ARGV[1] = the
120
+ # job as fetched, ARGV[2] = score, ARGV[3] = payload to file. Off the
121
+ # in-progress list and into the ZSET in one step; a no-op if it's no
122
+ # longer in progress (already requeued by a shutdown).
123
+ INTERRUPT_SCRIPT = <<~LUA
124
+ if redis.call("LREM", KEYS[1], 1, ARGV[1]) == 1 then
125
+ redis.call("ZADD", KEYS[2], ARGV[2], ARGV[3])
126
+ return 1
127
+ end
128
+ return 0
65
129
  LUA
66
130
 
67
131
  class << self
132
+ # A connection error propagates: can't tell yet, so the caller
133
+ # (Manager#fetch_class) resolves again on its next use, instead of
134
+ # memoizing a guess made while Redis was unreachable. Anything else
135
+ # (INFO restricted, an odd version string) assumes a current server —
136
+ # the runtime fall-back (UnsupportedError) still catches a wrong guess.
68
137
  def supported?
69
138
  version = Cogworker.config.redis { |c| c.info('server')['redis_version'] }
70
139
  Gem::Version.new(version) >= MIN_REDIS_VERSION
140
+ rescue Redis::BaseConnectionError
141
+ raise
71
142
  rescue StandardError
72
- true # can't tell (Redis not reachable yet): assume a current server
143
+ true
73
144
  end
74
145
 
75
146
  # 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)
147
+ # queue; returns how many — or -1 if `if_dead_for:` is given and the
148
+ # owner turns out not to be dead (see REQUEUE_SCRIPT). Used without
149
+ # it for this process's own unfinished jobs on shutdown
150
+ # (`Manager#stop!`), with it for presumed-dead processes' lists.
151
+ def requeue_in_progress(identity, if_dead_for: nil)
79
152
  Cogworker.config.redis do |c|
80
- c.eval(REQUEUE_SCRIPT, keys: [RedisKeys.in_progress(identity)], argv: [RedisKeys::QUEUE_PREFIX, 'default'])
153
+ LuaScript.run(c, REQUEUE_SCRIPT,
154
+ keys: [RedisKeys.in_progress(identity), RedisKeys.process(identity), RedisKeys::LAST_BEAT],
155
+ argv: [*requeue_args, identity, if_dead_for.to_s])
81
156
  end
82
157
  end
83
158
 
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
159
+ def requeue_args
160
+ [RedisKeys::QUEUE_PREFIX, 'default', RedisKeys.periodic_running(''), Periodic::RunningLock.queued_ttl]
161
+ end
162
+
163
+ # Requeues the in-progress list of every process that has died without
164
+ # a clean shutdown. "Dead" means its presence key is gone (no beat for
165
+ # Heartbeat::TTL) *and* `config.orphan_threshold` has passed since its
166
+ # last recorded beat (LAST_BEAT, on Redis's clock) — the presence key
167
+ # alone expires after a minute, too eagerly for a live process that
168
+ # merely couldn't beat for a while (a Redis outage or failover, a long
169
+ # GVL-holding call), whose running jobs would then run twice. With no
170
+ # recorded beat at all (lists from before LAST_BEAT existed), the
171
+ # presence key alone decides. Checked inside REQUEUE_SCRIPT, atomically
172
+ # with the requeue. A process always beats before its first fetch
173
+ # (`Heartbeat#start!`). Walks IN_PROGRESS_IDENTITIES (each
174
+ # reliable-fetch process registers itself there on every beat) rather
175
+ # than SCANning the keyspace; `scan: true` (once per reliable process,
176
+ # at boot) also picks up lists from before that set existed.
177
+ def recover_orphans(scan: false)
89
178
  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)) }
179
+ candidate_identities(scan: scan).sum do |identity|
180
+ next 0 if identity == own
94
181
 
95
- requeue_in_progress(identity).tap do |moved|
96
- next unless moved.positive?
182
+ moved = requeue_in_progress(identity, if_dead_for: Cogworker.config.orphan_threshold)
183
+ next 0 if moved.negative? # alive after all: keep its registration and last beat
97
184
 
98
- Cogworker.logger.warn { "requeued #{moved} job(s) left in progress by dead process #{identity}" }
185
+ forget_if_empty(identity)
186
+ Cogworker.logger.warn { "requeued #{moved} job(s) left in progress by dead process #{identity}" } if moved.positive?
187
+ moved
188
+ end
189
+ end
190
+
191
+ # This process's own in-progress entries that no processor thread is
192
+ # running (not in `cogworker:workers:<identity>`) — left behind when
193
+ # neither acknowledging nor handing a job back worked. Requeued once
194
+ # they've been seen stray on two calls in a row (the caller passes
195
+ # back what the previous call returned), which rules out a job caught
196
+ # in the instant between being fetched and registered. Returns this
197
+ # call's strays that weren't requeued yet.
198
+ def reconcile(identity, previous_strays = [])
199
+ Cogworker.config.redis do |c|
200
+ listed = c.lrange(RedisKeys.in_progress(identity), 0, -1)
201
+ running = c.hvals(RedisKeys.workers(identity)).filter_map { |raw| parse_hash(raw)&.dig('payload', 'jid') }
202
+ strays = listed.reject { |raw| running.include?(parse_hash(raw)&.fetch('jid', nil)) }
203
+ (strays & previous_strays).each do |raw|
204
+ next unless LuaScript.run(c, REQUEUE_ONE_SCRIPT, keys: [RedisKeys.in_progress(identity)],
205
+ argv: [raw, *requeue_args]) == 1
206
+
207
+ Cogworker.logger.warn { "requeued stray in-progress job jid=#{parse_hash(raw)&.fetch('jid', nil)}" }
99
208
  end
209
+ strays - previous_strays
210
+ end
211
+ end
212
+
213
+ def parse_hash(raw)
214
+ value = JSON.parse(raw)
215
+ value.is_a?(Hash) ? value : nil
216
+ rescue JSON::ParserError
217
+ nil
218
+ end
219
+
220
+ def candidate_identities(scan:)
221
+ Cogworker.config.redis do |c|
222
+ identities = c.smembers(RedisKeys::IN_PROGRESS_IDENTITIES)
223
+ next identities unless scan
224
+
225
+ scanned = c.scan_each(match: "#{RedisKeys::IN_PROGRESS_PREFIX}*").map do |key|
226
+ key.delete_prefix(RedisKeys::IN_PROGRESS_PREFIX)
227
+ end
228
+ identities | scanned
229
+ end
230
+ end
231
+
232
+ def forget_if_empty(identity)
233
+ Cogworker.config.redis do |c|
234
+ next unless c.llen(RedisKeys.in_progress(identity)).zero?
235
+
236
+ c.srem?(RedisKeys::IN_PROGRESS_IDENTITIES, identity)
237
+ c.hdel(RedisKeys::LAST_BEAT, identity)
100
238
  end
101
239
  end
102
240
  end
@@ -104,18 +242,20 @@ module Cogworker
104
242
  def retrieve_work
105
243
  keys = active_keys
106
244
  if keys.empty?
107
- sleep(TIMEOUT)
245
+ interruptible_sleep(TIMEOUT)
108
246
  return nil
109
247
  end
110
248
 
111
- result = Cogworker.config.redis do |c|
112
- c.eval(FETCH_SCRIPT, keys: keys.shuffle.uniq + [RedisKeys.in_progress(Cogworker.identity)])
113
- end
249
+ distinct = keys.uniq
250
+ return blocking_fetch(distinct.first) if distinct.size == 1
251
+
252
+ result = run_fetch_script(keys.shuffle.uniq + [RedisKeys.in_progress(Cogworker.identity)])
114
253
  unless result
115
- sleep(EMPTY_POLL_INTERVAL)
254
+ idle_sleep
116
255
  return nil
117
256
  end
118
257
 
258
+ @idle_interval = nil
119
259
  queue_key, raw_job = result
120
260
  UnitOfWork.new(queue_key.delete_prefix(RedisKeys::QUEUE_PREFIX), raw_job)
121
261
  end
@@ -125,13 +265,52 @@ module Cogworker
125
265
  # no longer in progress (already requeued by `requeue_in_progress`).
126
266
  def give_back(work)
127
267
  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])
268
+ LuaScript.run(c, REQUEUE_ONE_SCRIPT, keys: [RedisKeys.in_progress(Cogworker.identity)],
269
+ argv: [work.raw_job, *self.class.requeue_args])
270
+ end
271
+ end
272
+
273
+ def interrupt(work, set, score, payload)
274
+ Cogworker.config.redis do |c|
275
+ LuaScript.run(c, INTERRUPT_SCRIPT, keys: [RedisKeys.in_progress(Cogworker.identity), set],
276
+ argv: [work.raw_job, score, payload])
130
277
  end
131
278
  end
132
279
 
133
280
  def acknowledge(work)
134
281
  Cogworker.config.redis { |c| c.lrem(RedisKeys.in_progress(Cogworker.identity), 1, work.raw_job) }
135
282
  end
283
+
284
+ private
285
+
286
+ # With a single (unpaused) queue there's no weighting to honor, so no
287
+ # need to poll: a blocking BLMOVE waits on the server and returns the
288
+ # moment a job arrives, for up to TIMEOUT — same as BasicFetch's BRPOP.
289
+ def blocking_fetch(queue_key)
290
+ raw_job = Cogworker.config.redis do |c|
291
+ c.blmove(queue_key, RedisKeys.in_progress(Cogworker.identity), 'RIGHT', 'LEFT', timeout: TIMEOUT)
292
+ end
293
+ raw_job && UnitOfWork.new(queue_key.delete_prefix(RedisKeys::QUEUE_PREFIX), raw_job)
294
+ rescue Redis::CommandError => e
295
+ raise unless e.message.match?(/unknown (redis )?command/i)
296
+
297
+ raise UnsupportedError, e.message
298
+ end
299
+
300
+ def run_fetch_script(keys)
301
+ Cogworker.config.redis { |c| LuaScript.run(c, FETCH_SCRIPT, keys: keys) }
302
+ rescue Redis::CommandError => e
303
+ raise unless e.message.match?(/unknown (redis )?command/i)
304
+
305
+ raise UnsupportedError, e.message
306
+ end
307
+
308
+ # Jittered (75–100% of the interval) so a process's threads, all idle
309
+ # at once, don't keep polling in lockstep.
310
+ def idle_sleep
311
+ max = Cogworker.config.fetch_idle_max_interval
312
+ @idle_interval = @idle_interval ? [@idle_interval * 2, max].min : [EMPTY_POLL_INTERVAL, max].min
313
+ interruptible_sleep(@idle_interval * (0.75 + (rand * 0.25)))
314
+ end
136
315
  end
137
316
  end
@@ -13,10 +13,28 @@ module Cogworker
13
13
  POLL_INTERVAL = 5
14
14
  # How often this poller also looks for jobs left in progress by a dead
15
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.
16
+ # at boot. Every live process does it, whatever its own fetch mode: in
17
+ # a mixed fleet, or after a runtime fall-back to `:basic`, the lists of
18
+ # crashed reliable processes still need someone to requeue them, and
19
+ # with nothing registered it's a single SMEMBERS. Only the one-off
20
+ # keyspace SCAN (for lists from before IN_PROGRESS_IDENTITIES existed)
21
+ # is limited to processes actually using ReliableFetch. The requeue is
22
+ # atomic, so any number of processes doing it at once is safe.
18
23
  ORPHAN_CHECK_INTERVAL = 60
19
24
 
25
+ # KEYS[1] = the ZSET, KEYS[2] = cogworker:dead, KEYS[3] = stats:failed;
26
+ # ARGV[1] = the entry, ARGV[2] = its dead-set score, ARGV[3] = the
27
+ # wrapper to store. Same claim-and-move-atomically shape as
28
+ # JobUtil::CLAIM_AND_REQUEUE_SCRIPT.
29
+ BURY_SCRIPT = <<~LUA
30
+ if redis.call("ZREM", KEYS[1], ARGV[1]) == 0 then
31
+ return 0
32
+ end
33
+ redis.call("ZADD", KEYS[2], ARGV[2], ARGV[3])
34
+ redis.call("INCR", KEYS[3])
35
+ return 1
36
+ LUA
37
+
20
38
  def initialize(manager)
21
39
  @manager = manager
22
40
  end
@@ -37,6 +55,12 @@ module Cogworker
37
55
  @thread&.kill
38
56
  end
39
57
 
58
+ # True once the first orphan check has completed — what the periodic
59
+ # Ticker waits for before its first tick (see Launcher#initialize).
60
+ def recovered_once?
61
+ @recovered_once == true
62
+ end
63
+
40
64
  private
41
65
 
42
66
  # A failed poll is logged and retried next interval rather than ending
@@ -54,12 +78,17 @@ module Cogworker
54
78
  end
55
79
  end
56
80
 
81
+ # Next check scheduled only once this one has completed: a failed one
82
+ # (Redis down) is retried on the next poll rather than a minute later.
57
83
  def recover_orphans_if_due
58
84
  now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
59
85
  return if @next_orphan_check && now < @next_orphan_check
60
86
 
87
+ reliable = @manager.fetch_class == ReliableFetch
88
+ ReliableFetch.recover_orphans(scan: !@recovered_once && reliable)
89
+ @strays = ReliableFetch.reconcile(Cogworker.identity, @strays || []) if reliable
90
+ @recovered_once = true
61
91
  @next_orphan_check = now + ORPHAN_CHECK_INTERVAL
62
- ReliableFetch.recover_orphans
63
92
  end
64
93
 
65
94
  def enqueue_due_jobs
@@ -78,16 +107,18 @@ module Cogworker
78
107
  Cogworker.config.redis do |c|
79
108
  next unless JobUtil.claim_and_requeue(c, set, raw) == :invalid
80
109
 
81
- bury(c, set, raw) if c.zrem(set, raw)
110
+ bury(c, set, raw)
82
111
  end
83
112
  end
84
113
 
85
114
  def bury(conn, set, raw)
86
115
  error = JSON::ParserError.new("not a job (JSON object with a queue) in #{set}")
87
116
  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')
117
+ buried = LuaScript.run(conn, BURY_SCRIPT, keys: [set, RedisKeys::DEAD, RedisKeys::STATS_FAILED],
118
+ argv: [raw, job['failed_at'], JSON.generate(job)])
119
+ return unless buried == 1
120
+
121
+ Throughput.record('failed') # counted like Processor#bury_unparseable
91
122
  Cogworker.logger.error { "unparseable entry in #{set} moved to dead: #{raw.to_s[0, 200]}" }
92
123
  end
93
124
  end
@@ -15,7 +15,7 @@ module Cogworker
15
15
  write(job, queue, 'complete')
16
16
  rescue Exception => e # rubocop:disable Lint/RescueException
17
17
  write(job, queue, JobUtil.terminal_failure?(job) ? 'failed' : 'retrying',
18
- error_class: e.class.name, error_message: e.message.to_s[0, 10_000])
18
+ error_class: e.class.name, error_message: JobUtil.error_message(e))
19
19
  raise e
20
20
  end
21
21
 
@@ -69,6 +69,7 @@ module Cogworker
69
69
  # indefinitely, so this OS process would never actually go away —
70
70
  # and `Swarm#initiate_restart`'s own `Process.waitpid(pid)` for this
71
71
  # exact child would then block forever right along with it.
72
+ Cogworker.flush_output!
72
73
  ::Process.exit!(true)
73
74
  end
74
75
  @children[pid] = slot
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cogworker
4
- VERSION = '0.3.0'
4
+ VERSION = '0.4.1'
5
5
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rack/session/cookie'
4
+
5
+ module Cogworker
6
+ class Web
7
+ # The Web UI's own session cookie, applied only when nothing upstream
8
+ # already provides `env['rack.session']`. Mounted inside an app that has
9
+ # its own session (Rails, or any `Rack::Session::*` in front), an
10
+ # unconditional `Rack::Session::Cookie` here replaced that session with
11
+ # a copy for everything inside the Web UI — so an auth middleware added
12
+ # via `Web.use` read and wrote the copy, never the host's real session,
13
+ # and every response carried a second `cogworker.session` cookie.
14
+ class OptionalSession
15
+ def initialize(app, **options)
16
+ @app = app
17
+ @with_session = Rack::Session::Cookie.new(app, **options)
18
+ end
19
+
20
+ def call(env)
21
+ env['rack.session'] ? @app.call(env) : @with_session.call(env)
22
+ end
23
+
24
+ # A session middleware added via `Web.use` sits *after* this one in
25
+ # the stack, so it can't be detected per request the way an upstream
26
+ # one is — `Web.build_app` checks for one up front instead.
27
+ def self.session_middleware?(middleware)
28
+ middleware.is_a?(Class) && middleware <= Rack::Session::Abstract::Persisted
29
+ end
30
+ end
31
+ end
32
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'cgi'
4
+ require 'digest/sha1'
4
5
  require 'json'
5
6
 
6
7
  module Cogworker
@@ -63,33 +64,30 @@ module Cogworker
63
64
  app.post('/jobs/retrying/delete') do
64
65
  raw = params['raw']
65
66
  Cogworker.config.redis { |c| c.zrem(RedisKeys::RETRY, raw) }
66
- Cogworker::Attempts.clear(JSON.parse(raw)['jid'])
67
+ Cogworker::Attempts.clear(Jobs.parse_entry(raw)['jid'])
67
68
  Jobs.respond(self, params)
68
69
  end
69
70
 
70
71
  # Same "graduate back onto its queue" move `/jobs/dead/retry` below/
71
- # `Cogworker::Scheduled#graduate` make — `zrem` winning (not
72
- # losing) gates it so two tabs retrying the same entry at once
73
- # can't both requeue it; the `redis` gem's `#zrem` returns a
74
- # **Boolean** for a single member, so this checks truthiness, not
75
- # `== 1` (`true == 1` is `false` in Ruby).
72
+ # `Cogworker::Scheduled#graduate` make (`JobUtil.claim_and_requeue`:
73
+ # validate, then claim + push atomically) — two tabs retrying the
74
+ # same entry at once can't both requeue it, and an entry that
75
+ # can't be requeued is left in place with a notice.
76
76
  app.post('/jobs/retrying/retry_now') do
77
77
  raw = params['raw']
78
- Cogworker.config.redis do |c|
79
- JobUtil.claim_and_requeue(c, RedisKeys::RETRY, raw)
80
- end
81
- Jobs.respond(self, params)
78
+ result = Cogworker.config.redis { |c| JobUtil.claim_and_requeue(c, RedisKeys::RETRY, raw) }
79
+ Jobs.respond(self, params, notice: result == :invalid ? 'not_requeueable' : nil)
82
80
  end
83
81
 
84
82
  app.post('/jobs/dead/delete') do
85
83
  raw = params['raw']
86
84
  Cogworker.config.redis { |c| c.zrem(RedisKeys::DEAD, raw) }
87
- Cogworker::Attempts.clear(JSON.parse(raw)['jid'])
85
+ Cogworker::Attempts.clear(Jobs.parse_entry(raw)['jid'])
88
86
  Jobs.respond(self, params)
89
87
  end
90
88
 
91
89
  app.post('/jobs/dead/delete_all') do
92
- jids = Cogworker.config.redis { |c| c.zrange(RedisKeys::DEAD, 0, -1) }.map { |raw| JSON.parse(raw)['jid'] }
90
+ jids = Cogworker.config.redis { |c| c.zrange(RedisKeys::DEAD, 0, -1) }.map { |raw| Jobs.parse_entry(raw)['jid'] }
93
91
  Cogworker.config.redis { |c| c.del(RedisKeys::DEAD) }
94
92
  jids.each { |jid| Cogworker::Attempts.clear(jid) }
95
93
  Jobs.respond(self, params)
@@ -97,10 +95,8 @@ module Cogworker
97
95
 
98
96
  app.post('/jobs/dead/retry') do
99
97
  raw = params['raw']
100
- Cogworker.config.redis do |c|
101
- JobUtil.claim_and_requeue(c, RedisKeys::DEAD, raw)
102
- end
103
- Jobs.respond(self, params)
98
+ result = Cogworker.config.redis { |c| JobUtil.claim_and_requeue(c, RedisKeys::DEAD, raw) }
99
+ Jobs.respond(self, params, notice: result == :invalid ? 'not_requeueable' : nil)
104
100
  end
105
101
 
106
102
  # Moves this one entry's score on its own ZSET — "run sooner" or
@@ -144,11 +140,16 @@ module Cogworker
144
140
  # #jobs-content in place, keeping the same filter/search/selection;
145
141
  # a plain form submission (no JS) falls back to a normal redirect
146
142
  # to that same URL.
147
- def respond(action, params)
143
+ # `notice:` is a key into NOTICES (never free text — it's echoed into
144
+ # the page). It shows once: the redirect carries it as `?notice=`,
145
+ # but `query_string` (and so every poll URL) leaves it out.
146
+ def respond(action, params, notice: nil)
148
147
  if action.hx_request?
149
- render_content(action.request.script_name, params)
148
+ render_content(action.request.script_name, params.merge('notice' => notice))
150
149
  else
151
- action.redirect(Layout.path(action.request.script_name, "jobs#{query_string(params)}"))
150
+ location = "jobs#{query_string(params)}"
151
+ location += "#{location.include?('?') ? '&' : '?'}notice=#{CGI.escape(notice)}" if notice
152
+ action.redirect(Layout.path(action.request.script_name, location))
152
153
  end
153
154
  end
154
155
 
@@ -163,6 +164,19 @@ module Cogworker
163
164
  parts.empty? ? '' : "?#{parts.join('&')}"
164
165
  end
165
166
 
167
+ NOTICES = {
168
+ 'not_requeueable' => "This entry can't be put back on a queue — it's unreadable or has no queue " \
169
+ 'of its own. Delete it instead.'
170
+ }.freeze
171
+
172
+ def notice_banner(params)
173
+ text = NOTICES[params['notice'].to_s]
174
+ return '' unless text
175
+
176
+ style = 'padding: 10px 14px; border-left: 3px solid var(--color-danger);'
177
+ %(<div class="card" role="status" style="#{style}">#{Layout.h(text)}</div>)
178
+ end
179
+
166
180
  def render_content(script_name, params)
167
181
  status = STATUSES.include?(params['status']) ? params['status'] : 'All'
168
182
  query = params['q'].to_s
@@ -175,6 +189,7 @@ module Cogworker
175
189
 
176
190
  <<~HTML
177
191
  <div style="display: flex; flex-direction: column; gap: 16px;">
192
+ #{notice_banner(params)}
178
193
  #{page_header(script_name, status, query, filtered.size, rows.size)}
179
194
  <div style="display: grid; grid-template-columns: minmax(0, 1fr) #{selected ? 'minmax(320px, 400px)' : '0px'}; gap: 16px; align-items: start;">
180
195
  #{jobs_table(filtered, script_name, params)}
@@ -259,8 +274,10 @@ module Cogworker
259
274
  # No Retry for an entry that can't go back on a queue (no queue of
260
275
  # its own) or that would only fail straight back into Dead (an
261
276
  # unparseable payload's wrapper) — Delete is all that makes sense.
277
+ # From the row's already-parsed fields — this runs for every row on
278
+ # every poll, so no re-parsing `raw` here.
262
279
  def requeueable?(row)
263
- row[:klass] != JobUtil::UNPARSEABLE_CLASS && !JobUtil.requeueable(row[:raw].to_s).nil?
280
+ row[:klass] != JobUtil::UNPARSEABLE_CLASS && row[:queue].is_a?(String) && !row[:queue].empty?
264
281
  end
265
282
 
266
283
  def delete_button(bucket, script_name, row, params, extra: {})
@@ -430,10 +447,28 @@ module Cogworker
430
447
  end
431
448
  end
432
449
 
450
+ # A ZSET entry that isn't a job Hash (corrupt, or written by
451
+ # something else) shows up as an `(unparseable)` row — Delete is
452
+ # offered, Retry isn't (`requeueable?`) — instead of a JSON::ParserError
453
+ # taking the whole tab down.
454
+ def parse_entry(raw)
455
+ job = JSON.parse(raw)
456
+ return job if job.is_a?(Hash)
457
+
458
+ unparseable_entry(raw)
459
+ rescue JSON::ParserError
460
+ unparseable_entry(raw)
461
+ end
462
+
463
+ def unparseable_entry(raw)
464
+ { 'class' => JobUtil::UNPARSEABLE_CLASS, 'jid' => "unparseable-#{Digest::SHA1.hexdigest(raw.to_s)[0, 12]}",
465
+ 'args' => [], 'error_message' => raw.to_s[0, 200] }
466
+ end
467
+
433
468
  def scheduled_rows
434
469
  entries = Cogworker.config.redis { |c| c.zrange(RedisKeys::SCHEDULE, 0, -1, withscores: true) }
435
470
  entries.map do |raw, score|
436
- job = JSON.parse(raw)
471
+ job = parse_entry(raw)
437
472
  { jid: job['jid'], klass: job['class'], queue: job['queue'], status: 'Scheduled', attempt: '—',
438
473
  next_action: score, at: job['created_at'], args: job['args'], error_class: nil, error_message: nil,
439
474
  raw: raw, source: :scheduled }
@@ -443,7 +478,7 @@ module Cogworker
443
478
  def retrying_rows
444
479
  entries = Cogworker.config.redis { |c| c.zrange(RedisKeys::RETRY, 0, -1, withscores: true) }
445
480
  entries.map do |raw, score|
446
- job = JSON.parse(raw)
481
+ job = parse_entry(raw)
447
482
  { jid: job['jid'], klass: job['class'], queue: job['queue'], status: 'Retrying',
448
483
  attempt: "#{job['retry_count'].to_i} of #{JobUtil.max_retries(job)}", next_action: score,
449
484
  at: job['failed_at'] || job['created_at'], args: job['args'], error_class: job['error_class'],
@@ -454,7 +489,7 @@ module Cogworker
454
489
  def dead_rows
455
490
  entries = Cogworker.config.redis { |c| c.zrevrange(RedisKeys::DEAD, 0, -1, withscores: true) }
456
491
  entries.map do |raw, score|
457
- job = JSON.parse(raw)
492
+ job = parse_entry(raw)
458
493
  { jid: job['jid'], klass: job['class'], queue: job['queue'], status: 'Dead',
459
494
  attempt: "#{job['retry_count'].to_i} of #{JobUtil.max_retries(job)}", next_action: nil, at: score,
460
495
  args: job['args'], error_class: job['error_class'], error_message: job['error_message'], raw: raw,