cogworker 0.3.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 13e1ad0fec77124674f55dc3e1f7213bd2a73834ebb474261ed82a337fc1bb3e
4
- data.tar.gz: ed51fed2854035c83016b3180fa7872772e6f60bb2dd84e44089485a536f81e4
3
+ metadata.gz: bfb90fe3a477f31658c58fd09a41e4404a0c09999b74614789bd343ee704fad2
4
+ data.tar.gz: 407aa20293538fa35c8219f88f21fd8b1c15150d96e77d2b495d017ef21f924e
5
5
  SHA512:
6
- metadata.gz: 0d78dd7a7c4bbdf8bfc8c8619467922e732879908562b1ac99fed71ce111a8a4925136e87a9c4e56cb5abed57867d76f06c5d929fcb822156d4fd0add7a182d9
7
- data.tar.gz: 265d890a8f7d65b180dcb9a0f00f0d2ba6daf16fa9044f7bdb94b88b2cbeced1f6d61f5e11c86d5b107d281bbbc47591b54cb3fc6820f7a7fecc4732dec451d2
6
+ metadata.gz: c7df3eba977499a45acca66e8d25fd87cfe4669b9098c1e99be798f587de2ddf76edc75adb01953e8fe311ac324d7e4eb44cce66a33be264efe4f266e67d8f3e
7
+ data.tar.gz: 7fa4d7ba18f7be8ae8e80bd7b260be8bd8e9387122a623802812abd166652859f48669bbb42c16f72f15df1104d855934f4cfdb6cf6aede2737d740540d27278
@@ -62,7 +62,7 @@ module Cogworker
62
62
  Cogworker.config.redis do |c|
63
63
  c.hvals(RedisKeys.workers(identity)).count do |raw|
64
64
  entry = JSON.parse(raw)
65
- c.rpush(RedisKeys.queue(entry.fetch('queue')), JSON.generate(entry.fetch('payload')))
65
+ requeue_entry(c, entry.fetch('queue'), entry.fetch('payload'))
66
66
  true
67
67
  rescue StandardError => e
68
68
  Cogworker.logger.error { "couldn't requeue in-flight entry #{raw.to_s[0, 200]}: #{e.class}: #{e.message}" }
@@ -71,6 +71,16 @@ module Cogworker
71
71
  end
72
72
  end
73
73
 
74
+ def self.requeue_entry(conn, queue, job)
75
+ queue_key = RedisKeys.queue(queue)
76
+ retake = job['periodic_until_executed'] && job['periodic_pjid'] && job['jid']
77
+ lock_key = retake ? RedisKeys.periodic_running(job['periodic_pjid']) : queue_key
78
+ conn.eval(REQUEUE_ENTRY_SCRIPT, keys: [queue_key, lock_key],
79
+ argv: [JSON.generate(job), retake ? '1' : '0', job['jid'].to_s,
80
+ Periodic::RunningLock.queued_ttl])
81
+ end
82
+ private_class_method :requeue_entry
83
+
74
84
  private
75
85
 
76
86
  def active_keys
@@ -81,5 +91,18 @@ module Cogworker
81
91
  end
82
92
 
83
93
  UnitOfWork = Struct.new(:queue, :raw_job)
94
+
95
+ # KEYS[1] = the queue, KEYS[2] = the job's periodic running lock (or the
96
+ # queue again when there's none to take); ARGV[1] = payload, ARGV[2] =
97
+ # "1" to re-take that lock, ARGV[3] = jid, ARGV[4] = lock TTL. Requeue
98
+ # and lock in one step: as two commands, a failure in between left the
99
+ # job queued without its lock (and miscounted).
100
+ REQUEUE_ENTRY_SCRIPT = Periodic::RunningLock::RETAKE_FUNCTION + <<~LUA
101
+ redis.call("RPUSH", KEYS[1], ARGV[1])
102
+ if ARGV[2] == "1" then
103
+ retake_running_lock(KEYS[2], ARGV[3], ARGV[4])
104
+ end
105
+ return 1
106
+ LUA
84
107
  end
85
108
  end
@@ -8,7 +8,8 @@ module Cogworker
8
8
  class Config
9
9
  FETCH_MODES = %i[reliable basic].freeze
10
10
 
11
- attr_reader :server_chain, :client_chain, :periodic_manager, :redis_options, :fetch
11
+ attr_reader :server_chain, :client_chain, :periodic_manager, :redis_options, :fetch, :fetch_idle_max_interval,
12
+ :orphan_threshold
12
13
  attr_accessor :concurrency, :queues, :periodic_catch_up, :unique_lock_ttl
13
14
 
14
15
  def initialize
@@ -39,9 +40,36 @@ module Cogworker
39
40
  # On an older Redis, `:reliable` falls back to `:basic` with a warning
40
41
  # (Manager#resolve_fetch_class).
41
42
  @fetch = :reliable
43
+ # Longest pause (seconds) ReliableFetch makes between polls of empty
44
+ # queues — the worst-case delay before an idle worker notices a new
45
+ # job. See ReliableFetch::EMPTY_POLL_INTERVAL.
46
+ @fetch_idle_max_interval = 1.0
47
+ # How long (seconds) a process must have gone without a heartbeat
48
+ # before ReliableFetch.recover_orphans treats it as dead and requeues
49
+ # its in-progress jobs. Higher: fewer false "dead" verdicts on a live
50
+ # process that merely couldn't beat for a while (a Redis outage or
51
+ # failover, a long GVL-holding call) — each of which runs its jobs
52
+ # twice; lower: a really crashed process's jobs come back sooner.
53
+ @orphan_threshold = 300
42
54
  register_default_middleware
43
55
  end
44
56
 
57
+ def orphan_threshold=(seconds)
58
+ unless seconds.is_a?(Numeric) && seconds.positive?
59
+ raise ArgumentError, "orphan_threshold must be a positive number of seconds, got #{seconds.inspect}"
60
+ end
61
+
62
+ @orphan_threshold = seconds
63
+ end
64
+
65
+ def fetch_idle_max_interval=(seconds)
66
+ unless seconds.is_a?(Numeric) && seconds.positive?
67
+ raise ArgumentError, "fetch_idle_max_interval must be a positive number of seconds, got #{seconds.inspect}"
68
+ end
69
+
70
+ @fetch_idle_max_interval = seconds
71
+ end
72
+
45
73
  def fetch=(mode)
46
74
  resolved = mode.to_s.to_sym
47
75
  unless FETCH_MODES.include?(resolved)
@@ -15,6 +15,7 @@ module Cogworker
15
15
  class Heartbeat
16
16
  INTERVAL = 5
17
17
  TTL = 60
18
+ STARTUP_RETRY_INTERVAL = 1
18
19
 
19
20
  def initialize(manager)
20
21
  @manager = manager
@@ -23,12 +24,21 @@ module Cogworker
23
24
  # The first beat is synchronous, before any processor fetches a job:
24
25
  # ReliableFetch.recover_orphans treats an in-progress list with no live
25
26
  # heartbeat behind it as a dead process's, so this process must be
26
- # visibly alive before its own list can have anything on it.
27
- def start!
27
+ # visibly alive before its own list can have anything on it. With Redis
28
+ # unreachable, it keeps retrying (every STARTUP_RETRY_INTERVAL) rather
29
+ # than letting the Manager start fetching with no presence key — until
30
+ # it succeeds (returns true) or `abort_if` says to give up (returns
31
+ # false, e.g. a stop signal arrived meanwhile; nothing is started then).
32
+ def start!(abort_if: -> { false })
28
33
  @stopping = false
29
- beat_safely
34
+ until beat_safely
35
+ return false if abort_if.call
36
+
37
+ sleep(STARTUP_RETRY_INTERVAL)
38
+ end
30
39
  @beat_thread = Thread.new { beat_loop }
31
40
  @signal_thread = Thread.new { subscribe_loop }
41
+ true
32
42
  end
33
43
 
34
44
  # Tears down both background threads, not just the Redis keys — leaving
@@ -67,6 +77,12 @@ module Cogworker
67
77
  Cogworker.config.redis do |c|
68
78
  c.del(RedisKeys.process(Cogworker.identity), RedisKeys.workers(Cogworker.identity))
69
79
  c.srem?(RedisKeys::PROCESSES, Cogworker.identity)
80
+ # Clean shutdown: Manager#stop! already requeued this process's
81
+ # in-progress list, so there's nothing left for recovery to find.
82
+ if c.llen(RedisKeys.in_progress(Cogworker.identity)).zero?
83
+ c.srem?(RedisKeys::IN_PROGRESS_IDENTITIES, Cogworker.identity)
84
+ c.hdel(RedisKeys::LAST_BEAT, Cogworker.identity)
85
+ end
70
86
  end
71
87
  end
72
88
 
@@ -83,12 +99,15 @@ module Cogworker
83
99
 
84
100
  def beat_safely
85
101
  beat
102
+ true
86
103
  rescue StandardError => e
87
104
  Cogworker.logger.error { "Heartbeat failed: #{e.class}: #{e.message}" }
105
+ false
88
106
  end
89
107
 
90
108
  def beat
91
109
  identity = Cogworker.identity
110
+ reliable = @manager.fetch_class == ReliableFetch
92
111
  info = {
93
112
  'hostname' => Cogworker.hostname,
94
113
  'pid' => ::Process.pid,
@@ -106,6 +125,10 @@ module Cogworker
106
125
  'quiet', @manager.quiet?.to_s)
107
126
  c.expire(RedisKeys.process(identity), TTL)
108
127
  c.expire(RedisKeys.workers(identity), TTL)
128
+ if reliable
129
+ c.sadd?(RedisKeys::IN_PROGRESS_IDENTITIES, identity)
130
+ c.hset(RedisKeys::LAST_BEAT, identity, c.time.first) # Redis's clock, not this host's
131
+ end
109
132
  touch_running_periodic_locks(c, identity)
110
133
  end
111
134
  end
@@ -120,7 +143,7 @@ module Cogworker
120
143
  job = JSON.parse(raw)['payload']
121
144
  next unless job.is_a?(Hash) && job['periodic_pjid']
122
145
 
123
- Periodic::RunningLock.touch(job['periodic_pjid'], job['jid'], Periodic::RunningLock::ACTIVE_TTL, conn)
146
+ Periodic::RunningLock.touch(job['periodic_pjid'], job['jid'], Periodic::RunningLock.active_ttl, conn)
124
147
  rescue StandardError => e
125
148
  # Per entry: one bad entry mustn't leave the others' locks to expire.
126
149
  Cogworker.logger.error { "couldn't refresh periodic lock for #{raw.to_s[0, 200]}: #{e.class}: #{e.message}" }
@@ -194,6 +217,7 @@ module Cogworker
194
217
  def remote_stop!
195
218
  @manager.stop!
196
219
  cleanup_presence!
220
+ Cogworker.flush_output!
197
221
  ::Process.exit!(true)
198
222
  end
199
223
  end
@@ -64,37 +64,42 @@ module Cogworker
64
64
  # `queue` may be nil).
65
65
  def requeueable(raw)
66
66
  job = JSON.parse(raw)
67
- job.is_a?(Hash) && job['queue'].is_a?(String) ? job : nil
67
+ job.is_a?(Hash) && job['queue'].is_a?(String) && !job['queue'].empty? ? job : nil
68
68
  rescue JSON::ParserError, TypeError
69
69
  nil
70
70
  end
71
71
 
72
- # Validate, then claim, then requeue — in that order, so an entry that
73
- # can't be requeued is never `zrem`'d and lost (it stays where it is for
74
- # the caller to deal with). Returns `:requeued`, `:gone` (someone else
75
- # won the `zrem` first) or `:invalid`. The one implementation behind
76
- # `Scheduled#graduate` and every Web UI retry action.
72
+ # KEYS[1] = the ZSET, KEYS[2] = cogworker:queues, KEYS[3] = the queue;
73
+ # ARGV[1] = the entry as stored, ARGV[2] = queue name, ARGV[3] = the
74
+ # payload to push. Claim and push in one atomic step: as separate
75
+ # commands, a crash or dropped connection between the ZREM and the
76
+ # LPUSH lost the job. The new payload is built in Ruby, not re-encoded
77
+ # by Lua's cjson (which would round large integers in args).
78
+ CLAIM_AND_REQUEUE_SCRIPT = <<~LUA
79
+ if redis.call("ZREM", KEYS[1], ARGV[1]) == 0 then
80
+ return 0
81
+ end
82
+ redis.call("SADD", KEYS[2], ARGV[2])
83
+ redis.call("LPUSH", KEYS[3], ARGV[3])
84
+ return 1
85
+ LUA
86
+
87
+ # Validate, then (atomically) claim and requeue — an entry that can't be
88
+ # requeued is never removed and lost (it stays where it is for the
89
+ # caller to deal with). Returns `:requeued`, `:gone` (someone else
90
+ # claimed it first) or `:invalid`. The one implementation behind
91
+ # `Scheduled#graduate` and every Web UI retry action. Re-stamps
92
+ # `enqueued_at`: queue latency is measured from it, and a retried job
93
+ # still carried its original push time (a scheduled one, none at all).
77
94
  def claim_and_requeue(conn, set, raw)
78
95
  job = requeueable(raw)
79
96
  return :invalid unless job
80
- return :gone unless conn.zrem(set, raw)
81
97
 
82
- requeue(conn, job)
83
- :requeued
84
- end
85
-
86
- # Puts a job taken off `cogworker:schedule`/`retry`/`dead` (the caller
87
- # has already won its `zrem`) back onto its queue — the one shared
88
- # implementation behind `Scheduled#graduate` and the Web UI's retry
89
- # actions. Re-stamps `enqueued_at`: queue latency is measured from it,
90
- # and a retried job still carried its original push time (a scheduled
91
- # one, none at all), inflating the queue's latency by its whole history.
92
- def requeue(conn, job)
93
98
  job['enqueued_at'] = Time.now.to_f
94
- conn.multi do |tx|
95
- tx.sadd?(RedisKeys::QUEUES, job['queue'])
96
- tx.lpush(RedisKeys.queue(job['queue']), JSON.generate(job))
97
- end
99
+ won = conn.eval(CLAIM_AND_REQUEUE_SCRIPT,
100
+ keys: [set, RedisKeys::QUEUES, RedisKeys.queue(job['queue'])],
101
+ argv: [raw, job['queue'], JSON.generate(job)])
102
+ won == 1 ? :requeued : :gone
98
103
  end
99
104
 
100
105
  UNPARSEABLE_CLASS = '(unparseable)'
@@ -23,7 +23,8 @@ module Cogworker
23
23
  Cogworker.reset_identity!
24
24
  install_signal_traps
25
25
  resolve_periodic_classes
26
- @heartbeat.start! # first: see Heartbeat#start!
26
+ return unless start_heartbeat
27
+
27
28
  @manager.start!
28
29
  @scheduled.start!
29
30
  @ticker.start!
@@ -44,6 +45,23 @@ module Cogworker
44
45
 
45
46
  private
46
47
 
48
+ # First, and blocking until it succeeds: see Heartbeat#start!. A stop
49
+ # signal arriving while Redis is still unreachable ends the wait (and
50
+ # the process) without ever starting anything; a quiet one is applied
51
+ # and the wait goes on.
52
+ def start_heartbeat
53
+ until @heartbeat.start!(abort_if: -> { !@signal_queue.empty? })
54
+ case @signal_queue.pop
55
+ when :stop
56
+ Cogworker.logger.info { 'Received stop signal before startup completed' }
57
+ stop!
58
+ return false
59
+ when :quiet then quiet!
60
+ end
61
+ end
62
+ true
63
+ end
64
+
47
65
  # Resolves every periodic entry's class here, on the main thread, before
48
66
  # any processor thread exists. `Processor#build_worker` otherwise does
49
67
  # the first `const_get` lazily on whichever processor thread picks the
@@ -7,6 +7,10 @@ module Cogworker
7
7
  # it), with a compact one-line-per-entry format including pid.
8
8
  module Logging
9
9
  def self.default_logger(io = $stdout)
10
+ # Unbuffered: processes here end via `Process.exit!` (swarm children,
11
+ # a remote stop), which skips Ruby's IO flush — with stdout a pipe
12
+ # (docker, systemd) the last lines logged before exit were lost.
13
+ io.sync = true if io.respond_to?(:sync=)
10
14
  logger = ::Logger.new(io)
11
15
  logger.level = ::Logger::INFO
12
16
  logger.formatter = proc do |severity, time, _progname, msg|
@@ -4,11 +4,9 @@ module Cogworker
4
4
  # Owns the `concurrency`-sized thread pool of Processors for one OS
5
5
  # process, and the quiet/stop state they all poll.
6
6
  class Manager
7
- attr_reader :fetch_class
8
-
9
7
  def initialize(config = Cogworker.config)
10
8
  @config = config
11
- @fetch_class = resolve_fetch_class
9
+ @fetch_mutex = Mutex.new
12
10
  @processors = Array.new(config.concurrency) { Processor.new(self) }
13
11
  @quiet = false
14
12
  @stopping = false
@@ -20,6 +18,31 @@ module Cogworker
20
18
  @config.queues
21
19
  end
22
20
 
21
+ # Resolved on first use — from a processor thread's first fetch, after
22
+ # the Heartbeat's first beat proved Redis reachable — not in the
23
+ # constructor: that needed Redis just to build a Manager, and when
24
+ # Redis was unreachable at that moment, the version check couldn't run
25
+ # at all (see ReliableFetch.supported?).
26
+ def fetch_class
27
+ @fetch_mutex.synchronize { @fetch_class ||= resolve_fetch_class }
28
+ end
29
+
30
+ # The server turned out not to support ReliableFetch after all
31
+ # (ReliableFetch::UnsupportedError on a real fetch): every processor
32
+ # switches to BasicFetch from its next fetch on. Safe, since no job can
33
+ # be on the in-progress list — no LMOVE ever succeeded.
34
+ def fall_back_to_basic_fetch!(error)
35
+ @fetch_mutex.synchronize do
36
+ next if @fetch_class == BasicFetch
37
+
38
+ Cogworker.logger.warn do
39
+ "config.fetch = :reliable isn't supported by this Redis (#{error.message[0, 120]}); " \
40
+ 'falling back to :basic (a job is lost if its process dies mid-job)'
41
+ end
42
+ @fetch_class = BasicFetch
43
+ end
44
+ end
45
+
23
46
  def start!
24
47
  @processors.each(&:start!)
25
48
  end
@@ -87,7 +110,7 @@ module Cogworker
87
110
  end
88
111
 
89
112
  def requeue_unfinished
90
- moved = @fetch_class.requeue_in_progress(Cogworker.identity)
113
+ moved = fetch_class.requeue_in_progress(Cogworker.identity)
91
114
  Cogworker.logger.warn { "requeued #{moved} job(s) unfinished at shutdown" } if moved.positive?
92
115
  rescue StandardError => e
93
116
  Cogworker.logger.error { "Requeueing unfinished jobs failed: #{e.class}: #{e.message}" }
@@ -7,8 +7,10 @@
7
7
  -- ARGV[4] = jid the caller will push this slot's job under
8
8
  -- ARGV[5] = running-lock TTL (seconds)
9
9
  --
10
- -- Returns 1 if this call won the claim for this slot (the caller should
11
- -- 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
12
14
  -- process re-firing the same (or an earlier) slot on a later tick, and the
13
15
  -- NX lock breaks the narrow race where two processes both pass the
14
16
  -- `last_slot` check before either has written it back.
@@ -20,7 +22,8 @@ if ARGV[2] == "until_executed" and redis.call("GET", KEYS[1]) then
20
22
  return 0
21
23
  end
22
24
 
23
- 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")
24
27
  if tonumber(ARGV[1]) <= last then
25
28
  return 0
26
29
  end
@@ -33,4 +36,4 @@ redis.call("SET", KEYS[2], ARGV[1])
33
36
  if ARGV[2] == "until_executed" then
34
37
  redis.call("SET", KEYS[1], ARGV[4], "EX", ARGV[5])
35
38
  end
36
- return 1
39
+ return {1, previous or ""}
@@ -4,7 +4,7 @@ module Cogworker
4
4
  module Periodic
5
5
  # Drives the server side of the `periodic:running:<pjid>` lock (used
6
6
  # only for `unique: :until_executed` entries; see RunningLock for the
7
- # whole lifecycle): shortens it to RunningLock::ACTIVE_TTL when the run
7
+ # whole lifecycle): shortens it to `RunningLock.active_ttl` when the run
8
8
  # starts, and releases it on success or on the terminal failed attempt
9
9
  # (a failure that will be retried is `Processor#route_failure`'s to
10
10
  # extend — only it knows the retry delay).
@@ -36,7 +36,7 @@ module Cogworker
36
36
  # the lock just keeps its longer queued TTL until this process's next
37
37
  # heartbeat shortens it — within Heartbeat::INTERVAL.
38
38
  def shorten_lock(pjid, jid)
39
- RunningLock.touch(pjid, jid, RunningLock::ACTIVE_TTL)
39
+ RunningLock.touch(pjid, jid, RunningLock.active_ttl)
40
40
  rescue StandardError => e
41
41
  Cogworker.logger.warn { "couldn't shorten periodic lock for #{pjid}: #{e.class}: #{e.message}" }
42
42
  end
@@ -12,22 +12,47 @@ module Cogworker
12
12
  # (and release a lock that didn't exist yet) before the lock was
13
13
  # written, wedging the entry forever. TTL = `queued_ttl`, covering
14
14
  # however long the job may wait in its queue.
15
- # - shortened to ACTIVE_TTL once a processor starts it, then kept alive
15
+ # - shortened to `active_ttl` once a processor starts it, then kept alive
16
16
  # by that process's Heartbeat every beat — so a run whose process dies
17
- # (OOM, SIGKILL, shutdown past the drain timeout) frees the entry
18
- # within ACTIVE_TTL instead of never.
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.
19
25
  # - extended on a non-terminal failure to cover the retry backoff plus
20
26
  # `queued_ttl` (`Processor#extend_locks_for_retry` — the job now waits
21
27
  # in `cogworker:retry`, not on a process).
22
28
  # - deleted on success or on the terminal failure.
23
29
  module RunningLock
24
- ACTIVE_TTL = 60
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
25
45
 
26
46
  module_function
27
47
 
28
48
  # Whole seconds: it goes straight into `SET ... EX` in claim.lua, which
29
49
  # rejects a non-integer (a fractional `unique_lock_ttl` used to make
30
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
+
31
56
  def queued_ttl
32
57
  Cogworker.config.unique_lock_ttl.to_f.ceil
33
58
  end
@@ -17,6 +17,25 @@ module Cogworker
17
17
 
18
18
  CLAIM_SCRIPT = File.read(File.join(__dir__, 'claim.lua'))
19
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
+
20
39
  def initialize(manager, entries, catch_up: true)
21
40
  @manager = manager
22
41
  @entries = entries
@@ -57,8 +76,9 @@ module Cogworker
57
76
 
58
77
  # A failed tick is logged and retried next interval rather than ending
59
78
  # 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.
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.
62
82
  def run
63
83
  until @manager.stopping?
64
84
  begin
@@ -77,13 +97,14 @@ module Cogworker
77
97
  next if @last_checked_slot[entry.pjid] == slot
78
98
 
79
99
  jid = SecureRandom.hex(12)
80
- enqueue(entry, slot, jid) if !disabled?(entry) && claim?(entry, slot, jid)
100
+ previous = claim(entry, slot, jid) unless disabled?(entry)
101
+ enqueue(entry, slot, jid, previous) if previous
81
102
  @last_checked_slot[entry.pjid] = slot
82
103
  end
83
104
  end
84
105
 
85
106
  # Web UI "Disable" (`Routes::Schedules`) — skips the claim/enqueue
86
- # step entirely, short-circuiting before `claim?` even runs, so
107
+ # step entirely, short-circuiting before `claim` even runs, so
87
108
  # neither `periodic:last_slot:<pjid>` nor the per-slot lock advance
88
109
  # while disabled: the Web UI's own "LastRun" column keeps showing the
89
110
  # last time it *actually* ran, not a due-but-skipped slot, and
@@ -93,6 +114,15 @@ module Cogworker
93
114
  # persisted last_slot) still advances either way, exactly as it
94
115
  # already did before this entry ever had a disabled state — it only
95
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
+
96
126
  def disabled?(entry)
97
127
  Cogworker.config.redis { |c| c.sismember(RedisKeys::PERIODIC_DISABLED, entry.pjid) }
98
128
  end
@@ -101,8 +131,10 @@ module Cogworker
101
131
  @cron_cache[entry.pjid] ||= Fugit::Cron.parse(entry.cron)
102
132
  end
103
133
 
104
- def claim?(entry, slot, jid)
105
- 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)
106
138
 
107
139
  result = Cogworker.config.redis do |c|
108
140
  c.eval(CLAIM_SCRIPT,
@@ -110,7 +142,7 @@ module Cogworker
110
142
  RedisKeys.periodic_lock(entry.pjid, slot)],
111
143
  argv: [slot, entry.unique.to_s, LOCK_TTL, jid, RunningLock.queued_ttl])
112
144
  end
113
- result == 1
145
+ result.is_a?(Array) ? result[1].to_s : nil
114
146
  end
115
147
 
116
148
  # With catch-up disabled, an entry's very first tick — ever, across
@@ -140,15 +172,19 @@ module Cogworker
140
172
  # Only undone here if the push didn't actually produce a job (raised,
141
173
  # or a client middleware swallowed it), so a job that never existed
142
174
  # can't hold the entry for the whole `RunningLock.queued_ttl`.
143
- def enqueue(entry, slot, jid)
175
+ def enqueue(entry, slot, jid, previous_slot)
144
176
  job = {
145
177
  'class' => entry.class_name, 'args' => entry.args, 'retry' => entry.retry,
146
178
  'periodic_pjid' => entry.pjid, 'periodic_slot' => slot, 'jid' => jid
147
179
  }
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?
148
183
  pushed = begin
149
184
  Client.push(job)
150
185
  rescue StandardError
151
186
  RunningLock.release(entry.pjid, jid) if entry.until_executed?
187
+ rollback_claim(entry, slot, previous_slot)
152
188
  raise
153
189
  end
154
190
  return unless entry.until_executed?
@@ -16,7 +16,6 @@ module Cogworker
16
16
  @manager = manager
17
17
  @busy = false
18
18
  @tid = SecureRandom.hex(6)
19
- @fetcher = manager.fetch_class.new(manager.queues)
20
19
  end
21
20
 
22
21
  def start!
@@ -54,23 +53,46 @@ module Cogworker
54
53
  return
55
54
  end
56
55
 
57
- work = @fetcher.retrieve_work
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
58
63
  return unless work
59
64
 
60
65
  # Fetched after shutdown began (e.g. still blocked in BRPOP when
61
66
  # `Manager#stop!` requeued unfinished work, which it then popped right
62
67
  # back): hand it back rather than start it, since the process is about
63
68
  # to exit underneath it.
64
- return @fetcher.give_back(work) if @manager.stopping?
69
+ return fetcher.give_back(work) if @manager.stopping?
65
70
 
66
71
  @manager.processor_busy!
67
72
  @busy = true
73
+ finished = false
68
74
  begin
69
75
  execute(work)
76
+ finished = true
70
77
  ensure
71
78
  @busy = false
72
79
  @manager.processor_idle!
73
- acknowledge(work)
80
+ finished ? acknowledge(work) : give_back(work)
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)
93
+ rescue StandardError => e
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}"
74
96
  end
75
97
  end
76
98
 
@@ -82,7 +104,7 @@ module Cogworker
82
104
  attempts = 0
83
105
  begin
84
106
  attempts += 1
85
- @fetcher.acknowledge(work)
107
+ fetcher.acknowledge(work)
86
108
  rescue StandardError => e
87
109
  if attempts < ACK_ATTEMPTS
88
110
  sleep(0.1 * attempts)
@@ -174,6 +196,12 @@ module Cogworker
174
196
  Cogworker.logger.error { "unparseable job on queue #{work.queue} moved to dead: #{error.message}" }
175
197
  end
176
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
+
177
205
  def build_worker(job)
178
206
  klass = Object.const_get(job['class'])
179
207
  worker = klass.new
@@ -10,6 +10,13 @@ module Cogworker
10
10
  module RedisKeys
11
11
  QUEUE_PREFIX = 'cogworker:queue:'
12
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'
13
20
  QUEUES = 'cogworker:queues'
14
21
  SCHEDULE = 'cogworker:schedule'
15
22
  RETRY = 'cogworker:retry'
@@ -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
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'digest/sha1'
4
+
3
5
  module Cogworker
4
6
  # The default fetch (`config.fetch = :reliable`): every job is moved
5
7
  # atomically (`LMOVE`, Redis >= 6.2) from its queue onto this process's own
@@ -8,8 +10,8 @@ module Cogworker
8
10
  # retry/dead. A job is therefore never only in a processor's memory: if the
9
11
  # process dies mid-job (OOM, SIGKILL, a host going away), the job is still
10
12
  # 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
+ # `Scheduled` poller) puts it back on its queue once the dead process has
14
+ # gone `config.orphan_threshold` (default 5 min) without a heartbeat. The trade-off is at-least-once delivery: a job
13
15
  # that was partly done when its process died runs again from the start.
14
16
  #
15
17
  # `LMOVE` can only take from one list at a time, so there's no blocking
@@ -19,7 +21,12 @@ module Cogworker
19
21
  # round sleeps EMPTY_POLL_INTERVAL before the next. Weighting and
20
22
  # `Queue#pause!` behave exactly as in BasicFetch.
21
23
  class ReliableFetch < BasicFetch
22
- EMPTY_POLL_INTERVAL = 0.25 # seconds; worst-case pickup delay on an idle process
24
+ # The first pause after an empty round; each further empty round in a
25
+ # row doubles it, up to `config.fetch_idle_max_interval` (default 1s),
26
+ # and finding a job resets it. That cap is the worst-case pickup delay
27
+ # on an idle process — the trade-off against how often idle processors
28
+ # poll Redis (at 0.25s flat, 4 calls/s per thread).
29
+ EMPTY_POLL_INTERVAL = 0.25
23
30
  MIN_REDIS_VERSION = Gem::Version.new('6.2')
24
31
 
25
32
  # KEYS[1..n-1] = queue keys, in the order to try; KEYS[n] = in-progress list.
@@ -34,13 +41,21 @@ module Cogworker
34
41
  return false
35
42
  LUA
36
43
 
44
+ # The hot path runs this by hash (EVALSHA), not by shipping the whole
45
+ # script text on every poll; see #run_fetch_script.
46
+ FETCH_SCRIPT_SHA = Digest::SHA1.hexdigest(FETCH_SCRIPT)
47
+
37
48
  # KEYS[1] = an in-progress list; ARGV[1] = queue key prefix; ARGV[2] =
38
49
  # 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
50
+ # then buries it as unparseable); ARGV[3] = periodic running-lock key
51
+ # prefix, ARGV[4] = its queued TTL. Oldest first, each onto the end its
40
52
  # queue is popped from, so recovered jobs run next, in their original
41
53
  # order. Atomic per call: two processes recovering the same list can't
42
- # both move one job.
43
- REQUEUE_SCRIPT = <<~LUA
54
+ # both move one job. A job from an `until_executed` periodic entry
55
+ # re-takes its running lock on the way (same rule as
56
+ # Periodic::RunningLock::RETAKE_FUNCTION), so the ticker can't start
57
+ # the next slot alongside the recovered run.
58
+ REQUEUE_SCRIPT = Periodic::RunningLock::RETAKE_FUNCTION + <<~LUA
44
59
  local moved = 0
45
60
  while true do
46
61
  local job = redis.call("LPOP", KEYS[1])
@@ -49,8 +64,14 @@ module Cogworker
49
64
  end
50
65
  local queue = ARGV[2]
51
66
  local ok, decoded = pcall(cjson.decode, job)
52
- if ok and type(decoded) == "table" and type(decoded["queue"]) == "string" then
53
- queue = decoded["queue"]
67
+ if ok and type(decoded) == "table" then
68
+ if type(decoded["queue"]) == "string" then
69
+ queue = decoded["queue"]
70
+ end
71
+ local pjid, jid = decoded["periodic_pjid"], decoded["jid"]
72
+ if decoded["periodic_until_executed"] == true and type(pjid) == "string" and type(jid) == "string" then
73
+ retake_running_lock(ARGV[3] .. pjid, jid, ARGV[4])
74
+ end
54
75
  end
55
76
  redis.call("RPUSH", ARGV[1] .. queue, job)
56
77
  moved = moved + 1
@@ -77,28 +98,72 @@ module Cogworker
77
98
  # on shutdown (`Manager#stop!`) and for dead processes' lists.
78
99
  def requeue_in_progress(identity)
79
100
  Cogworker.config.redis do |c|
80
- c.eval(REQUEUE_SCRIPT, keys: [RedisKeys.in_progress(identity)], argv: [RedisKeys::QUEUE_PREFIX, 'default'])
101
+ c.eval(REQUEUE_SCRIPT, keys: [RedisKeys.in_progress(identity)],
102
+ argv: [RedisKeys::QUEUE_PREFIX, 'default', RedisKeys.periodic_running(''),
103
+ Periodic::RunningLock.queued_ttl])
81
104
  end
82
105
  end
83
106
 
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
107
+ # Requeues the in-progress list of every process that has gone
108
+ # `config.orphan_threshold` without a heartbeat — i.e. that died
109
+ # without a clean shutdown (see #dead?). A process always
86
110
  # 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
111
+ # a live process's list is never mistaken for an orphan. Walks
112
+ # IN_PROGRESS_IDENTITIES (each reliable-fetch process registers itself
113
+ # there on every beat) rather than SCANning the keyspace; `scan: true`
114
+ # (once per process, at boot) also picks up lists from before that
115
+ # set existed.
116
+ def recover_orphans(scan: false)
89
117
  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)) }
118
+ candidate_identities(scan: scan).sum do |identity|
119
+ next 0 if identity == own || !dead?(identity)
94
120
 
95
121
  requeue_in_progress(identity).tap do |moved|
122
+ forget_if_empty(identity)
96
123
  next unless moved.positive?
97
124
 
98
125
  Cogworker.logger.warn { "requeued #{moved} job(s) left in progress by dead process #{identity}" }
99
126
  end
100
127
  end
101
128
  end
129
+
130
+ # Its presence key gone (no beat for Heartbeat::TTL) is not enough on
131
+ # its own: a live process that just couldn't beat for a minute (a
132
+ # Redis outage or failover, a long GVL-holding call) would lose its
133
+ # in-progress jobs to a second, parallel run. It also has to have gone
134
+ # `orphan_threshold` since its last recorded beat — compared on
135
+ # Redis's clock on both sides, so host clock skew doesn't matter. No
136
+ # recorded beat at all (a list from before LAST_BEAT existed) falls
137
+ # back to the presence key alone.
138
+ def dead?(identity)
139
+ Cogworker.config.redis do |c|
140
+ next false if c.exists?(RedisKeys.process(identity))
141
+
142
+ last = c.hget(RedisKeys::LAST_BEAT, identity)
143
+ last.nil? || c.time.first - last.to_f > Cogworker.config.orphan_threshold
144
+ end
145
+ end
146
+
147
+ def candidate_identities(scan:)
148
+ Cogworker.config.redis do |c|
149
+ identities = c.smembers(RedisKeys::IN_PROGRESS_IDENTITIES)
150
+ next identities unless scan
151
+
152
+ scanned = c.scan_each(match: "#{RedisKeys::IN_PROGRESS_PREFIX}*").map do |key|
153
+ key.delete_prefix(RedisKeys::IN_PROGRESS_PREFIX)
154
+ end
155
+ identities | scanned
156
+ end
157
+ end
158
+
159
+ def forget_if_empty(identity)
160
+ Cogworker.config.redis do |c|
161
+ next unless c.llen(RedisKeys.in_progress(identity)).zero?
162
+
163
+ c.srem?(RedisKeys::IN_PROGRESS_IDENTITIES, identity)
164
+ c.hdel(RedisKeys::LAST_BEAT, identity)
165
+ end
166
+ end
102
167
  end
103
168
 
104
169
  def retrieve_work
@@ -108,14 +173,13 @@ module Cogworker
108
173
  return nil
109
174
  end
110
175
 
111
- result = Cogworker.config.redis do |c|
112
- c.eval(FETCH_SCRIPT, keys: keys.shuffle.uniq + [RedisKeys.in_progress(Cogworker.identity)])
113
- end
176
+ result = run_fetch_script(keys.shuffle.uniq + [RedisKeys.in_progress(Cogworker.identity)])
114
177
  unless result
115
- sleep(EMPTY_POLL_INTERVAL)
178
+ idle_sleep
116
179
  return nil
117
180
  end
118
181
 
182
+ @idle_interval = nil
119
183
  queue_key, raw_job = result
120
184
  UnitOfWork.new(queue_key.delete_prefix(RedisKeys::QUEUE_PREFIX), raw_job)
121
185
  end
@@ -133,5 +197,29 @@ module Cogworker
133
197
  def acknowledge(work)
134
198
  Cogworker.config.redis { |c| c.lrem(RedisKeys.in_progress(Cogworker.identity), 1, work.raw_job) }
135
199
  end
200
+
201
+ private
202
+
203
+ def run_fetch_script(keys)
204
+ Cogworker.config.redis do |c|
205
+ c.evalsha(FETCH_SCRIPT_SHA, keys: keys)
206
+ rescue Redis::CommandError => e
207
+ raise unless e.message.start_with?('NOSCRIPT')
208
+
209
+ c.eval(FETCH_SCRIPT, keys: keys) # loads it into the script cache for next time
210
+ end
211
+ rescue Redis::CommandError => e
212
+ raise unless e.message.match?(/unknown (redis )?command/i)
213
+
214
+ raise UnsupportedError, e.message
215
+ end
216
+
217
+ # Jittered (75–100% of the interval) so a process's threads, all idle
218
+ # at once, don't keep polling in lockstep.
219
+ def idle_sleep
220
+ max = Cogworker.config.fetch_idle_max_interval
221
+ @idle_interval = @idle_interval ? [@idle_interval * 2, max].min : [EMPTY_POLL_INTERVAL, max].min
222
+ sleep(@idle_interval * (0.75 + (rand * 0.25)))
223
+ end
136
224
  end
137
225
  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
@@ -58,8 +76,9 @@ module Cogworker
58
76
  now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
59
77
  return if @next_orphan_check && now < @next_orphan_check
60
78
 
79
+ scan = @next_orphan_check.nil? && @manager.fetch_class == ReliableFetch
61
80
  @next_orphan_check = now + ORPHAN_CHECK_INTERVAL
62
- ReliableFetch.recover_orphans
81
+ ReliableFetch.recover_orphans(scan: scan)
63
82
  end
64
83
 
65
84
  def enqueue_due_jobs
@@ -78,16 +97,18 @@ module Cogworker
78
97
  Cogworker.config.redis do |c|
79
98
  next unless JobUtil.claim_and_requeue(c, set, raw) == :invalid
80
99
 
81
- bury(c, set, raw) if c.zrem(set, raw)
100
+ bury(c, set, raw)
82
101
  end
83
102
  end
84
103
 
85
104
  def bury(conn, set, raw)
86
105
  error = JSON::ParserError.new("not a job (JSON object with a queue) in #{set}")
87
106
  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')
107
+ buried = conn.eval(BURY_SCRIPT, keys: [set, RedisKeys::DEAD, RedisKeys::STATS_FAILED],
108
+ argv: [raw, job['failed_at'], JSON.generate(job)])
109
+ return unless buried == 1
110
+
111
+ Throughput.record('failed') # counted like Processor#bury_unparseable
91
112
  Cogworker.logger.error { "unparseable entry in #{set} moved to dead: #{raw.to_s[0, 200]}" }
92
113
  end
93
114
  end
@@ -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.0'
5
5
  end
@@ -0,0 +1,25 @@
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
+ end
24
+ end
25
+ 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,
data/lib/cogworker/web.rb CHANGED
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'rack'
4
- require 'rack/session/cookie'
5
4
  require 'rack/static'
6
5
  require 'securerandom'
7
6
 
@@ -105,6 +104,18 @@ module Cogworker
105
104
  @session_secret ||= ENV.fetch('COGWORKER_SESSION_SECRET') { SecureRandom.hex(32) }
106
105
  end
107
106
 
107
+ # `false` drops the built-in session cookie entirely (it's already
108
+ # skipped automatically whenever the host app provides a session —
109
+ # see OptionalSession). Default true.
110
+ def builtin_session
111
+ @builtin_session.nil? || @builtin_session
112
+ end
113
+
114
+ def builtin_session=(enabled)
115
+ @builtin_session = enabled
116
+ @app = nil
117
+ end
118
+
108
119
  def session_secret=(secret)
109
120
  @session_secret = secret
110
121
  @app = nil
@@ -247,7 +258,9 @@ module Cogworker
247
258
 
248
259
  def build_app
249
260
  builder = Rack::Builder.new
250
- builder.use(Rack::Session::Cookie, secret: session_secret, key: 'cogworker.session')
261
+ if builtin_session
262
+ builder.use(Cogworker::Web::OptionalSession, secret: session_secret, key: 'cogworker.session')
263
+ end
251
264
  # No `cache_control:` (no `immutable`/long `max-age`): these files
252
265
  # are plain, unfingerprinted paths that *do* change — every time
253
266
  # a vendored stylesheet/script gets edited, or on any gem upgrade —
data/lib/cogworker.rb CHANGED
@@ -83,6 +83,17 @@ module Cogworker
83
83
  !!@server_process
84
84
  end
85
85
 
86
+ # Called right before every `Process.exit!` (which, unlike `exit`,
87
+ # flushes nothing): a custom `Cogworker.logger` writing to a buffered
88
+ # stdout/stderr would otherwise lose its last lines.
89
+ def flush_output!
90
+ [$stdout, $stderr].each do |io|
91
+ io.flush
92
+ rescue StandardError
93
+ nil
94
+ end
95
+ end
96
+
86
97
  def server_process!
87
98
  @server_process = true
88
99
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cogworker
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - vdonec
@@ -239,6 +239,7 @@ files:
239
239
  - lib/cogworker/redis_connection.rb
240
240
  - lib/cogworker/redis_keys.rb
241
241
  - lib/cogworker/reliable_fetch.rb
242
+ - lib/cogworker/reliable_fetch/unsupported_error.rb
242
243
  - lib/cogworker/scheduled.rb
243
244
  - lib/cogworker/signals.rb
244
245
  - lib/cogworker/stats.rb
@@ -269,6 +270,7 @@ files:
269
270
  - lib/cogworker/web/assets/phosphor/style.css
270
271
  - lib/cogworker/web/history_query.rb
271
272
  - lib/cogworker/web/layout.rb
273
+ - lib/cogworker/web/optional_session.rb
272
274
  - lib/cogworker/web/router.rb
273
275
  - lib/cogworker/web/routes/history.rb
274
276
  - lib/cogworker/web/routes/jobs.rb