cogworker 0.2.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/lib/cogworker/basic_fetch.rb +60 -2
  3. data/lib/cogworker/cli.rb +11 -1
  4. data/lib/cogworker/client.rb +1 -1
  5. data/lib/cogworker/config.rb +54 -1
  6. data/lib/cogworker/heartbeat.rb +79 -7
  7. data/lib/cogworker/job.rb +9 -0
  8. data/lib/cogworker/job_util.rb +61 -0
  9. data/lib/cogworker/launcher.rb +38 -1
  10. data/lib/cogworker/logging.rb +4 -0
  11. data/lib/cogworker/manager.rb +66 -4
  12. data/lib/cogworker/owned_key.rb +40 -0
  13. data/lib/cogworker/periodic/claim.lua +16 -4
  14. data/lib/cogworker/periodic/release_middleware.rb +31 -13
  15. data/lib/cogworker/periodic/running_lock.rb +69 -0
  16. data/lib/cogworker/periodic/ticker.rb +78 -13
  17. data/lib/cogworker/process.rb +2 -2
  18. data/lib/cogworker/process_set.rb +1 -1
  19. data/lib/cogworker/processor.rb +144 -14
  20. data/lib/cogworker/queue.rb +4 -4
  21. data/lib/cogworker/redis_keys.rb +9 -0
  22. data/lib/cogworker/reliable_fetch/unsupported_error.rb +11 -0
  23. data/lib/cogworker/reliable_fetch.rb +225 -0
  24. data/lib/cogworker/scheduled.rb +58 -10
  25. data/lib/cogworker/stats.rb +1 -1
  26. data/lib/cogworker/status.rb +5 -1
  27. data/lib/cogworker/swarm.rb +65 -4
  28. data/lib/cogworker/testing/empty_queue_error.rb +9 -0
  29. data/lib/cogworker/testing.rb +27 -3
  30. data/lib/cogworker/unique_jobs/client_middleware.rb +13 -4
  31. data/lib/cogworker/unique_jobs/release_middleware.rb +4 -1
  32. data/lib/cogworker/version.rb +1 -1
  33. data/lib/cogworker/web/layout.rb +3 -3
  34. data/lib/cogworker/web/optional_session.rb +25 -0
  35. data/lib/cogworker/web/routes/jobs.rb +72 -37
  36. data/lib/cogworker/web/routes/overview.rb +6 -12
  37. data/lib/cogworker/web/routes/schedules.rb +2 -2
  38. data/lib/cogworker/web.rb +36 -10
  39. data/lib/cogworker.rb +16 -1
  40. metadata +7 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: db7056e0bc7a73b9ed86025627dc3ab891e971f13797fb253ec2ea8df15362f9
4
- data.tar.gz: bd4b8a4f65b9538284a8d8682513fd868c1e8bfbee7b73709016ae7826ed38cb
3
+ metadata.gz: bfb90fe3a477f31658c58fd09a41e4404a0c09999b74614789bd343ee704fad2
4
+ data.tar.gz: 407aa20293538fa35c8219f88f21fd8b1c15150d96e77d2b495d017ef21f924e
5
5
  SHA512:
6
- metadata.gz: 9a9e6c877cf0d62f2cadece94b9ea07c5d9c3f0386ce914f0a0132825de5128da1a26494aa1ad4866f5ef02ce5f8f8d52e1f009fb93583a32f873652fa4407b6
7
- data.tar.gz: f06acea355f8898b7c0fdc3558c8ea76576e16f5070182cb30e4c883a87226b96ef3454594b4c8deab8e99a0862a2b951d71efb8f237d079205517fd4e076f51
6
+ metadata.gz: c7df3eba977499a45acca66e8d25fd87cfe4669b9098c1e99be798f587de2ddf76edc75adb01953e8fe311ac324d7e4eb44cce66a33be264efe4f266e67d8f3e
7
+ data.tar.gz: 7fa4d7ba18f7be8ae8e80bd7b260be8bd8e9387122a623802812abd166652859f48669bbb42c16f72f15df1104d855934f4cfdb6cf6aede2737d740540d27278
@@ -1,8 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
4
+
3
5
  module Cogworker
4
- # Pops jobs off Redis lists. Queue names repeated in the process's queue
5
- # list represent weight; since BRPOP itself scans its key list
6
+ # `config.fetch = :basic`: pops jobs off Redis lists with one blocking
7
+ # `BRPOP`. Once popped, a job exists only in the processor's memory (and
8
+ # its `cogworker:workers:<identity>` entry, which expires with the
9
+ # process), so a process that dies mid-job loses it — see ReliableFetch,
10
+ # the default, for the alternative.
11
+ #
12
+ # Queue names repeated in the process's queue list represent weight;
13
+ # since BRPOP itself scans its key list
6
14
  # strictly left-to-right, weighting is implemented by shuffling the
7
15
  # (already-expanded, so repeats survive) key list before every fetch cycle
8
16
  # — the more often a name appears, the more likely it lands first.
@@ -36,6 +44,43 @@ module Cogworker
36
44
  UnitOfWork.new(queue_key.delete_prefix(RedisKeys::QUEUE_PREFIX), raw_job)
37
45
  end
38
46
 
47
+ # Nothing to do: BRPOP already removed the job from Redis.
48
+ def acknowledge(_work); end
49
+
50
+ # Returns a fetched-but-unstarted job to the end of its queue that's
51
+ # popped next.
52
+ def give_back(work)
53
+ Cogworker.config.redis { |c| c.rpush(RedisKeys.queue(work.queue), work.raw_job) }
54
+ end
55
+
56
+ # Shutdown past the drain timeout (`Manager#stop!`): the only record of
57
+ # a still-running job is its `cogworker:workers:<identity>` entry, so
58
+ # that's what gets pushed back — onto the end its queue is popped from,
59
+ # so it runs next. Returns how many. One entry that can't be read is
60
+ # logged and skipped, never allowed to cost the others their requeue.
61
+ def self.requeue_in_progress(identity)
62
+ Cogworker.config.redis do |c|
63
+ c.hvals(RedisKeys.workers(identity)).count do |raw|
64
+ entry = JSON.parse(raw)
65
+ requeue_entry(c, entry.fetch('queue'), entry.fetch('payload'))
66
+ true
67
+ rescue StandardError => e
68
+ Cogworker.logger.error { "couldn't requeue in-flight entry #{raw.to_s[0, 200]}: #{e.class}: #{e.message}" }
69
+ false
70
+ end
71
+ end
72
+ end
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
+
39
84
  private
40
85
 
41
86
  def active_keys
@@ -46,5 +91,18 @@ module Cogworker
46
91
  end
47
92
 
48
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
49
107
  end
50
108
  end
data/lib/cogworker/cli.rb CHANGED
@@ -8,8 +8,15 @@ module Cogworker
8
8
  # `-r/--require <file>`, `-C/--config <file>`, `-L/--logfile <file>`,
9
9
  # `-q/--queue <name>[,<weight>]` (repeatable).
10
10
  class CLI
11
+ # Read (first set wins) for the default environment, and all written
12
+ # back with the resolved one — whichever of them the app's own boot
13
+ # code reads (Rails: RAILS_ENV, then RACK_ENV; Rack apps: RACK_ENV/
14
+ # APP_ENV) sees the same value `-e` asked for.
15
+ ENV_VARS = %w[APP_ENV RAILS_ENV RACK_ENV].freeze
16
+
11
17
  def self.parse(argv)
12
- options = { queues: [], require_path: nil, environment: ENV['APP_ENV'] || ENV['RACK_ENV'] || 'development' }
18
+ environment = ENV_VARS.filter_map { |k| ENV.fetch(k, nil) }.first || 'development'
19
+ options = { queues: [], require_path: nil, environment: environment }
13
20
 
14
21
  OptionParser.new do |o|
15
22
  o.on('-e ENV', '--environment ENV') { |v| options[:environment] = v }
@@ -34,11 +41,14 @@ module Cogworker
34
41
  # forked child with the exact same flags it was started with.
35
42
  def run(argv)
36
43
  options = self.class.parse(argv)
44
+ # Before the config file (its ERB may read these) and the app code.
45
+ ENV_VARS.each { |k| ENV[k] = options[:environment] }
37
46
  file_config = ConfigLoader.load(options[:config_path])
38
47
 
39
48
  Cogworker.server_process!
40
49
  Cogworker.config.concurrency = options[:concurrency] || file_config[:concurrency]&.to_i || Cogworker.config.concurrency
41
50
  Cogworker.config.queues = options[:queues].any? ? options[:queues] : (file_config[:queues] || Cogworker.config.queues)
51
+ Cogworker.config.fetch = file_config[:fetch] if file_config[:fetch]
42
52
 
43
53
  redirect_logfile(options[:logfile]) if options[:logfile]
44
54
  require_app_code(options[:require_path]) if options[:require_path]
@@ -50,7 +50,7 @@ module Cogworker
50
50
  else
51
51
  job['enqueued_at'] = Time.now.to_f
52
52
  conn.multi do |pipeline|
53
- pipeline.sadd(RedisKeys::QUEUES, job['queue'])
53
+ pipeline.sadd?(RedisKeys::QUEUES, job['queue'])
54
54
  pipeline.lpush(RedisKeys.queue(job['queue']), JSON.generate(job))
55
55
  end
56
56
  end
@@ -6,7 +6,10 @@ module Cogworker
6
6
  # `configure_client` calls, so every block sees and extends the same
7
7
  # server/client middleware chains.
8
8
  class Config
9
- attr_reader :server_chain, :client_chain, :periodic_manager, :redis_options
9
+ FETCH_MODES = %i[reliable basic].freeze
10
+
11
+ attr_reader :server_chain, :client_chain, :periodic_manager, :redis_options, :fetch, :fetch_idle_max_interval,
12
+ :orphan_threshold
10
13
  attr_accessor :concurrency, :queues, :periodic_catch_up, :unique_lock_ttl
11
14
 
12
15
  def initialize
@@ -31,11 +34,61 @@ module Cogworker
31
34
  # set higher/lower to match how long a unique job might legitimately
32
35
  # run plus however long its retries can take.
33
36
  @unique_lock_ttl = 24 * 60 * 60
37
+ # How processors take jobs off their queues — `:reliable` (ReliableFetch,
38
+ # needs Redis >= 6.2; a job survives its process dying) or `:basic`
39
+ # (BasicFetch, one blocking BRPOP; a job is lost if its process dies).
40
+ # On an older Redis, `:reliable` falls back to `:basic` with a warning
41
+ # (Manager#resolve_fetch_class).
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
34
54
  register_default_middleware
35
55
  end
36
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
+
73
+ def fetch=(mode)
74
+ resolved = mode.to_s.to_sym
75
+ unless FETCH_MODES.include?(resolved)
76
+ raise ArgumentError, "fetch must be one of #{FETCH_MODES.join(', ')}, got #{mode.inspect}"
77
+ end
78
+
79
+ @fetch = resolved
80
+ end
81
+
82
+ # Also drops an already-built pool: `redis_pool` is memoized on first
83
+ # use, so without this a `redis =` after anything had touched Redis
84
+ # (an init file configuring twice, a test switching databases) was
85
+ # silently ignored. The old pool's connections are closed as they're
86
+ # checked back in, so a caller still holding one finishes normally.
37
87
  def redis=(options)
38
88
  @redis_options = options
89
+ old_pool = @redis_pool
90
+ @redis_pool = nil
91
+ old_pool&.shutdown(&:close)
39
92
  end
40
93
 
41
94
  def redis_pool
@@ -15,25 +15,47 @@ 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
21
22
  end
22
23
 
23
- def start!
24
+ # The first beat is synchronous, before any processor fetches a job:
25
+ # ReliableFetch.recover_orphans treats an in-progress list with no live
26
+ # heartbeat behind it as a dead process's, so this process must be
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 })
24
33
  @stopping = false
34
+ until beat_safely
35
+ return false if abort_if.call
36
+
37
+ sleep(STARTUP_RETRY_INTERVAL)
38
+ end
25
39
  @beat_thread = Thread.new { beat_loop }
26
40
  @signal_thread = Thread.new { subscribe_loop }
41
+ true
27
42
  end
28
43
 
29
44
  # Tears down both background threads, not just the Redis keys — leaving
30
45
  # them running would leak a thread (and a dedicated pub/sub connection)
31
46
  # per Heartbeat instance for the remaining life of the process.
47
+ #
48
+ # The subscriber thread is killed *before* its connection is closed, and
49
+ # never via `unsubscribe`: on redis-rb 4.x, `unsubscribe` from another
50
+ # thread waits on the client's monitor, which the subscriber thread
51
+ # holds for as long as it sits in its blocking read — so it hung
52
+ # forever, and with it every `TERM`. Killing the thread releases the
53
+ # monitor; closing the now-idle connection afterwards is safe on both
54
+ # 4.x and 5.x.
32
55
  def stop!
33
56
  @stopping = true
34
57
  @beat_thread&.kill
35
- @subscribe_client&.unsubscribe
36
- @signal_thread&.kill
58
+ stop_subscriber!
37
59
  cleanup_presence!
38
60
  rescue StandardError
39
61
  nil
@@ -41,24 +63,51 @@ module Cogworker
41
63
 
42
64
  private
43
65
 
66
+ def stop_subscriber!
67
+ if @signal_thread && @signal_thread != Thread.current
68
+ @signal_thread.kill
69
+ @signal_thread.join(1)
70
+ end
71
+ @subscribe_client&.close
72
+ rescue StandardError
73
+ nil
74
+ end
75
+
44
76
  def cleanup_presence!
45
77
  Cogworker.config.redis do |c|
46
78
  c.del(RedisKeys.process(Cogworker.identity), RedisKeys.workers(Cogworker.identity))
47
- c.srem(RedisKeys::PROCESSES, Cogworker.identity)
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
48
86
  end
49
87
  end
50
88
 
89
+ # A failed beat (Redis blip, failover) is logged and retried on the next
90
+ # interval, never allowed to end the loop: a dead heartbeat thread
91
+ # meant this process silently vanished from ProcessSet/the Workers tab
92
+ # once its TTL ran out, while still running jobs.
51
93
  def beat_loop
52
94
  loop do
53
- beat
54
95
  sleep(INTERVAL)
96
+ beat_safely
55
97
  end
98
+ end
99
+
100
+ def beat_safely
101
+ beat
102
+ true
56
103
  rescue StandardError => e
57
- Cogworker.logger.error { "Heartbeat died: #{e.class}: #{e.message}" }
104
+ Cogworker.logger.error { "Heartbeat failed: #{e.class}: #{e.message}" }
105
+ false
58
106
  end
59
107
 
60
108
  def beat
61
109
  identity = Cogworker.identity
110
+ reliable = @manager.fetch_class == ReliableFetch
62
111
  info = {
63
112
  'hostname' => Cogworker.hostname,
64
113
  'pid' => ::Process.pid,
@@ -69,13 +118,35 @@ module Cogworker
69
118
  }
70
119
 
71
120
  Cogworker.config.redis do |c|
72
- c.sadd(RedisKeys::PROCESSES, identity)
121
+ c.sadd?(RedisKeys::PROCESSES, identity)
73
122
  c.hset(RedisKeys.process(identity),
74
123
  'info', JSON.generate(info),
75
124
  'busy', @manager.busy_count.to_s,
76
125
  'quiet', @manager.quiet?.to_s)
77
126
  c.expire(RedisKeys.process(identity), TTL)
78
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
132
+ touch_running_periodic_locks(c, identity)
133
+ end
134
+ end
135
+
136
+ # Keeps every in-flight `until_executed` periodic run's
137
+ # `periodic:running:<pjid>` lock alive for as long as this process is —
138
+ # see Periodic::RunningLock. Reads the same `cogworker:workers:<identity>`
139
+ # Hash the Workers tab does, so it covers exactly the jobs actually
140
+ # executing here right now.
141
+ def touch_running_periodic_locks(conn, identity)
142
+ conn.hvals(RedisKeys.workers(identity)).each do |raw|
143
+ job = JSON.parse(raw)['payload']
144
+ next unless job.is_a?(Hash) && job['periodic_pjid']
145
+
146
+ Periodic::RunningLock.touch(job['periodic_pjid'], job['jid'], Periodic::RunningLock.active_ttl, conn)
147
+ rescue StandardError => e
148
+ # Per entry: one bad entry mustn't leave the others' locks to expire.
149
+ Cogworker.logger.error { "couldn't refresh periodic lock for #{raw.to_s[0, 200]}: #{e.class}: #{e.message}" }
79
150
  end
80
151
  end
81
152
 
@@ -146,6 +217,7 @@ module Cogworker
146
217
  def remote_stop!
147
218
  @manager.stop!
148
219
  cleanup_presence!
220
+ Cogworker.flush_output!
149
221
  ::Process.exit!(true)
150
222
  end
151
223
  end
data/lib/cogworker/job.rb CHANGED
@@ -50,6 +50,15 @@ module Cogworker
50
50
  Testing.jobs_for(name).clear
51
51
  end
52
52
 
53
+ # Run this class's recorded fake-mode jobs — see `Testing.perform_one`.
54
+ def perform_one
55
+ Testing.perform_one(name)
56
+ end
57
+
58
+ def drain
59
+ Testing.drain(name)
60
+ end
61
+
53
62
  private
54
63
 
55
64
  def job_payload(extra)
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
3
4
  require 'securerandom'
4
5
 
5
6
  module Cogworker
@@ -56,5 +57,65 @@ module Cogworker
56
57
  def terminal_failure?(job)
57
58
  job['retry_count'].to_i >= max_retries(job)
58
59
  end
60
+
61
+ # The job Hash a raw `schedule`/`retry`/`dead` entry would requeue as —
62
+ # or `nil` if it can't be: not JSON, not an object, or no String
63
+ # `queue` to push it onto (e.g. an `unparseable_job` wrapper, whose
64
+ # `queue` may be nil).
65
+ def requeueable(raw)
66
+ job = JSON.parse(raw)
67
+ job.is_a?(Hash) && job['queue'].is_a?(String) && !job['queue'].empty? ? job : nil
68
+ rescue JSON::ParserError, TypeError
69
+ nil
70
+ end
71
+
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).
94
+ def claim_and_requeue(conn, set, raw)
95
+ job = requeueable(raw)
96
+ return :invalid unless job
97
+
98
+ job['enqueued_at'] = Time.now.to_f
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
103
+ end
104
+
105
+ UNPARSEABLE_CLASS = '(unparseable)'
106
+
107
+ # A payload that isn't a JSON object can't be run, retried or even
108
+ # attributed to a class. Wrapped into a regular job Hash (the Web UI
109
+ # parses every Dead entry as one) that keeps the original bytes under
110
+ # `raw_payload`, for whoever finds one to bury it in `dead`.
111
+ def unparseable_job(raw, queue:, error:)
112
+ now = Time.now.to_f
113
+ {
114
+ 'class' => UNPARSEABLE_CLASS, 'args' => [], 'queue' => queue, 'jid' => SecureRandom.hex(12),
115
+ 'retry' => false, 'retry_count' => 0, 'enqueued_at' => now, 'failed_at' => now,
116
+ 'error_class' => error.class.name, 'error_message' => error.message.to_s[0, 10_000],
117
+ 'raw_payload' => raw.to_s[0, 100_000]
118
+ }
119
+ end
59
120
  end
60
121
  end
@@ -22,9 +22,11 @@ module Cogworker
22
22
  def run
23
23
  Cogworker.reset_identity!
24
24
  install_signal_traps
25
+ resolve_periodic_classes
26
+ return unless start_heartbeat
27
+
25
28
  @manager.start!
26
29
  @scheduled.start!
27
- @heartbeat.start!
28
30
  @ticker.start!
29
31
  log_startup_info
30
32
  watch_signals
@@ -43,6 +45,41 @@ module Cogworker
43
45
 
44
46
  private
45
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
+
65
+ # Resolves every periodic entry's class here, on the main thread, before
66
+ # any processor thread exists. `Processor#build_worker` otherwise does
67
+ # the first `const_get` lazily on whichever processor thread picks the
68
+ # job up — and with an app autoloader that isn't thread-safe, two
69
+ # threads racing to autoload the same class (or its base class) can see
70
+ # it half-defined (`undefined method 'perform'`). A class that can't be
71
+ # resolved is only logged: every run of that entry will fail the same
72
+ # way (and show up in Dead/History), but it shouldn't keep the rest of
73
+ # the process from booting. Regular job classes aren't known up front —
74
+ # eager-load the app in the init file for those (see README).
75
+ def resolve_periodic_classes
76
+ @config.periodic_manager.entries.map(&:class_name).uniq.each do |name|
77
+ Object.const_get(name)
78
+ rescue NameError => e
79
+ Cogworker.logger.warn { "periodic job class #{name} can't be resolved: #{e.message}" }
80
+ end
81
+ end
82
+
46
83
  # Logged once all components are up, so the operator's console shows a
47
84
  # single summary (version/identity/pid/concurrency/queues/redis target)
48
85
  # right at the point the process is actually ready to pull work, rather
@@ -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|
@@ -6,6 +6,7 @@ module Cogworker
6
6
  class Manager
7
7
  def initialize(config = Cogworker.config)
8
8
  @config = config
9
+ @fetch_mutex = Mutex.new
9
10
  @processors = Array.new(config.concurrency) { Processor.new(self) }
10
11
  @quiet = false
11
12
  @stopping = false
@@ -17,6 +18,31 @@ module Cogworker
17
18
  @config.queues
18
19
  end
19
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
+
20
46
  def start!
21
47
  @processors.each(&:start!)
22
48
  end
@@ -49,10 +75,13 @@ module Cogworker
49
75
  @busy_mutex.synchronize { @busy_count -= 1 }
50
76
  end
51
77
 
52
- # Waits (up to `timeout` seconds) for in-flight jobs to finish, then
53
- # returns. Does not force-kill lingering threads past the deadline — a
54
- # thread stuck past the deadline is the caller's (Launcher's) problem to
55
- # decide whether to hard-exit the process.
78
+ # Waits (up to `timeout` seconds) for in-flight jobs to finish, then puts
79
+ # any job still unfinished back on its queue and returns. Does not
80
+ # force-kill lingering threads past the deadline — a thread stuck past
81
+ # the deadline is the caller's (Launcher's) problem to decide whether to
82
+ # hard-exit the process; its job is already safely requeued by then,
83
+ # rather than lost with the process (it may therefore run twice, if the
84
+ # thread does still finish it).
56
85
  def stop!(timeout: 25)
57
86
  @quiet = true
58
87
  @stopping = true
@@ -61,10 +90,43 @@ module Cogworker
61
90
  remaining = deadline - monotonic_now
62
91
  p.thread&.join([remaining, 0].max)
63
92
  end
93
+ wait_for_fetching_processors
94
+ requeue_unfinished
64
95
  end
65
96
 
66
97
  private
67
98
 
99
+ # A processor that isn't running a job is at most one fetch timeout away
100
+ # from noticing `stopping?` and exiting — but until it does, it can
101
+ # still pop a job (BRPOP blocks for up to BasicFetch::TIMEOUT), and one
102
+ # popped after `requeue_unfinished` had already run would be lost with
103
+ # the process. Waiting those out first means the only threads left
104
+ # alive are ones stuck inside a job, which never fetch again.
105
+ def wait_for_fetching_processors
106
+ deadline = monotonic_now + BasicFetch::TIMEOUT + 1
107
+ @processors.reject(&:busy?).each do |p|
108
+ p.thread&.join([deadline - monotonic_now, 0].max)
109
+ end
110
+ end
111
+
112
+ def requeue_unfinished
113
+ moved = fetch_class.requeue_in_progress(Cogworker.identity)
114
+ Cogworker.logger.warn { "requeued #{moved} job(s) unfinished at shutdown" } if moved.positive?
115
+ rescue StandardError => e
116
+ Cogworker.logger.error { "Requeueing unfinished jobs failed: #{e.class}: #{e.message}" }
117
+ end
118
+
119
+ def resolve_fetch_class
120
+ return BasicFetch if @config.fetch == :basic
121
+ return ReliableFetch if ReliableFetch.supported?
122
+
123
+ Cogworker.logger.warn do
124
+ "config.fetch = :reliable needs Redis >= #{ReliableFetch::MIN_REDIS_VERSION}; falling back to :basic " \
125
+ '(a job is lost if its process dies mid-job)'
126
+ end
127
+ BasicFetch
128
+ end
129
+
68
130
  # Fully qualified: inside the Cogworker namespace, a bare `Process` would
69
131
  # resolve to Cogworker::Process (the ProcessSet entry wrapper in api.rb),
70
132
  # not the ::Process kernel module.
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cogworker
4
+ # Compare-and-act on a lock key whose value is its owner's jid
5
+ # (`periodic:running:<pjid>`, `cogworker:unique:<digest>`): the EXPIRE/DEL
6
+ # only happens if the key still holds that exact jid, atomically — so a
7
+ # stray older run can never shorten, extend or release a lock that has
8
+ # since passed to a newer one.
9
+ module OwnedKey
10
+ EXPIRE_IF_OWNED = <<~LUA
11
+ if redis.call("GET", KEYS[1]) == ARGV[1] then
12
+ return redis.call("EXPIRE", KEYS[1], ARGV[2])
13
+ end
14
+ return 0
15
+ LUA
16
+
17
+ DELETE_IF_OWNED = <<~LUA
18
+ if redis.call("GET", KEYS[1]) == ARGV[1] then
19
+ return redis.call("DEL", KEYS[1])
20
+ end
21
+ return 0
22
+ LUA
23
+
24
+ module_function
25
+
26
+ def expire(key, owner, ttl, conn = nil)
27
+ run(conn, EXPIRE_IF_OWNED, key, [owner, ttl.ceil])
28
+ end
29
+
30
+ def delete(key, owner, conn = nil)
31
+ run(conn, DELETE_IF_OWNED, key, [owner])
32
+ end
33
+
34
+ def run(conn, script, key, argv)
35
+ return conn.eval(script, keys: [key], argv: argv) if conn
36
+
37
+ Cogworker.config.redis { |c| c.eval(script, keys: [key], argv: argv) }
38
+ end
39
+ end
40
+ end