cogworker 0.2.2 → 0.3.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/lib/cogworker/basic_fetch.rb +37 -2
  3. data/lib/cogworker/cli.rb +11 -1
  4. data/lib/cogworker/client.rb +1 -1
  5. data/lib/cogworker/config.rb +26 -1
  6. data/lib/cogworker/heartbeat.rb +54 -6
  7. data/lib/cogworker/job.rb +9 -0
  8. data/lib/cogworker/job_util.rb +56 -0
  9. data/lib/cogworker/launcher.rb +20 -1
  10. data/lib/cogworker/manager.rb +43 -4
  11. data/lib/cogworker/owned_key.rb +40 -0
  12. data/lib/cogworker/periodic/claim.lua +9 -0
  13. data/lib/cogworker/periodic/release_middleware.rb +31 -13
  14. data/lib/cogworker/periodic/running_lock.rb +44 -0
  15. data/lib/cogworker/periodic/ticker.rb +39 -10
  16. data/lib/cogworker/process.rb +2 -2
  17. data/lib/cogworker/process_set.rb +1 -1
  18. data/lib/cogworker/processor.rb +116 -14
  19. data/lib/cogworker/queue.rb +4 -4
  20. data/lib/cogworker/redis_keys.rb +2 -0
  21. data/lib/cogworker/reliable_fetch.rb +137 -0
  22. data/lib/cogworker/scheduled.rb +37 -10
  23. data/lib/cogworker/stats.rb +1 -1
  24. data/lib/cogworker/status.rb +5 -1
  25. data/lib/cogworker/swarm.rb +64 -4
  26. data/lib/cogworker/testing/empty_queue_error.rb +9 -0
  27. data/lib/cogworker/testing.rb +27 -3
  28. data/lib/cogworker/unique_jobs/client_middleware.rb +13 -4
  29. data/lib/cogworker/unique_jobs/release_middleware.rb +4 -1
  30. data/lib/cogworker/version.rb +1 -1
  31. data/lib/cogworker/web/layout.rb +3 -3
  32. data/lib/cogworker/web/routes/jobs.rb +17 -17
  33. data/lib/cogworker/web/routes/overview.rb +6 -12
  34. data/lib/cogworker/web/routes/schedules.rb +2 -2
  35. data/lib/cogworker/web.rb +21 -8
  36. data/lib/cogworker.rb +5 -1
  37. metadata +5 -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: 13e1ad0fec77124674f55dc3e1f7213bd2a73834ebb474261ed82a337fc1bb3e
4
+ data.tar.gz: ed51fed2854035c83016b3180fa7872772e6f60bb2dd84e44089485a536f81e4
5
5
  SHA512:
6
- metadata.gz: 9a9e6c877cf0d62f2cadece94b9ea07c5d9c3f0386ce914f0a0132825de5128da1a26494aa1ad4866f5ef02ce5f8f8d52e1f009fb93583a32f873652fa4407b6
7
- data.tar.gz: f06acea355f8898b7c0fdc3558c8ea76576e16f5070182cb30e4c883a87226b96ef3454594b4c8deab8e99a0862a2b951d71efb8f237d079205517fd4e076f51
6
+ metadata.gz: 0d78dd7a7c4bbdf8bfc8c8619467922e732879908562b1ac99fed71ce111a8a4925136e87a9c4e56cb5abed57867d76f06c5d929fcb822156d4fd0add7a182d9
7
+ data.tar.gz: 265d890a8f7d65b180dcb9a0f00f0d2ba6daf16fa9044f7bdb94b88b2cbeced1f6d61f5e11c86d5b107d281bbbc47591b54cb3fc6820f7a7fecc4732dec451d2
@@ -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,33 @@ 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
+ c.rpush(RedisKeys.queue(entry.fetch('queue')), JSON.generate(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
+
39
74
  private
40
75
 
41
76
  def active_keys
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,9 @@ 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
10
12
  attr_accessor :concurrency, :queues, :periodic_catch_up, :unique_lock_ttl
11
13
 
12
14
  def initialize
@@ -31,11 +33,34 @@ module Cogworker
31
33
  # set higher/lower to match how long a unique job might legitimately
32
34
  # run plus however long its retries can take.
33
35
  @unique_lock_ttl = 24 * 60 * 60
36
+ # How processors take jobs off their queues — `:reliable` (ReliableFetch,
37
+ # needs Redis >= 6.2; a job survives its process dying) or `:basic`
38
+ # (BasicFetch, one blocking BRPOP; a job is lost if its process dies).
39
+ # On an older Redis, `:reliable` falls back to `:basic` with a warning
40
+ # (Manager#resolve_fetch_class).
41
+ @fetch = :reliable
34
42
  register_default_middleware
35
43
  end
36
44
 
45
+ def fetch=(mode)
46
+ resolved = mode.to_s.to_sym
47
+ unless FETCH_MODES.include?(resolved)
48
+ raise ArgumentError, "fetch must be one of #{FETCH_MODES.join(', ')}, got #{mode.inspect}"
49
+ end
50
+
51
+ @fetch = resolved
52
+ end
53
+
54
+ # Also drops an already-built pool: `redis_pool` is memoized on first
55
+ # use, so without this a `redis =` after anything had touched Redis
56
+ # (an init file configuring twice, a test switching databases) was
57
+ # silently ignored. The old pool's connections are closed as they're
58
+ # checked back in, so a caller still holding one finishes normally.
37
59
  def redis=(options)
38
60
  @redis_options = options
61
+ old_pool = @redis_pool
62
+ @redis_pool = nil
63
+ old_pool&.shutdown(&:close)
39
64
  end
40
65
 
41
66
  def redis_pool
@@ -20,8 +20,13 @@ module Cogworker
20
20
  @manager = manager
21
21
  end
22
22
 
23
+ # The first beat is synchronous, before any processor fetches a job:
24
+ # ReliableFetch.recover_orphans treats an in-progress list with no live
25
+ # 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.
23
27
  def start!
24
28
  @stopping = false
29
+ beat_safely
25
30
  @beat_thread = Thread.new { beat_loop }
26
31
  @signal_thread = Thread.new { subscribe_loop }
27
32
  end
@@ -29,11 +34,18 @@ module Cogworker
29
34
  # Tears down both background threads, not just the Redis keys — leaving
30
35
  # them running would leak a thread (and a dedicated pub/sub connection)
31
36
  # per Heartbeat instance for the remaining life of the process.
37
+ #
38
+ # The subscriber thread is killed *before* its connection is closed, and
39
+ # never via `unsubscribe`: on redis-rb 4.x, `unsubscribe` from another
40
+ # thread waits on the client's monitor, which the subscriber thread
41
+ # holds for as long as it sits in its blocking read — so it hung
42
+ # forever, and with it every `TERM`. Killing the thread releases the
43
+ # monitor; closing the now-idle connection afterwards is safe on both
44
+ # 4.x and 5.x.
32
45
  def stop!
33
46
  @stopping = true
34
47
  @beat_thread&.kill
35
- @subscribe_client&.unsubscribe
36
- @signal_thread&.kill
48
+ stop_subscriber!
37
49
  cleanup_presence!
38
50
  rescue StandardError
39
51
  nil
@@ -41,20 +53,38 @@ module Cogworker
41
53
 
42
54
  private
43
55
 
56
+ def stop_subscriber!
57
+ if @signal_thread && @signal_thread != Thread.current
58
+ @signal_thread.kill
59
+ @signal_thread.join(1)
60
+ end
61
+ @subscribe_client&.close
62
+ rescue StandardError
63
+ nil
64
+ end
65
+
44
66
  def cleanup_presence!
45
67
  Cogworker.config.redis do |c|
46
68
  c.del(RedisKeys.process(Cogworker.identity), RedisKeys.workers(Cogworker.identity))
47
- c.srem(RedisKeys::PROCESSES, Cogworker.identity)
69
+ c.srem?(RedisKeys::PROCESSES, Cogworker.identity)
48
70
  end
49
71
  end
50
72
 
73
+ # A failed beat (Redis blip, failover) is logged and retried on the next
74
+ # interval, never allowed to end the loop: a dead heartbeat thread
75
+ # meant this process silently vanished from ProcessSet/the Workers tab
76
+ # once its TTL ran out, while still running jobs.
51
77
  def beat_loop
52
78
  loop do
53
- beat
54
79
  sleep(INTERVAL)
80
+ beat_safely
55
81
  end
82
+ end
83
+
84
+ def beat_safely
85
+ beat
56
86
  rescue StandardError => e
57
- Cogworker.logger.error { "Heartbeat died: #{e.class}: #{e.message}" }
87
+ Cogworker.logger.error { "Heartbeat failed: #{e.class}: #{e.message}" }
58
88
  end
59
89
 
60
90
  def beat
@@ -69,13 +99,31 @@ module Cogworker
69
99
  }
70
100
 
71
101
  Cogworker.config.redis do |c|
72
- c.sadd(RedisKeys::PROCESSES, identity)
102
+ c.sadd?(RedisKeys::PROCESSES, identity)
73
103
  c.hset(RedisKeys.process(identity),
74
104
  'info', JSON.generate(info),
75
105
  'busy', @manager.busy_count.to_s,
76
106
  'quiet', @manager.quiet?.to_s)
77
107
  c.expire(RedisKeys.process(identity), TTL)
78
108
  c.expire(RedisKeys.workers(identity), TTL)
109
+ touch_running_periodic_locks(c, identity)
110
+ end
111
+ end
112
+
113
+ # Keeps every in-flight `until_executed` periodic run's
114
+ # `periodic:running:<pjid>` lock alive for as long as this process is —
115
+ # see Periodic::RunningLock. Reads the same `cogworker:workers:<identity>`
116
+ # Hash the Workers tab does, so it covers exactly the jobs actually
117
+ # executing here right now.
118
+ def touch_running_periodic_locks(conn, identity)
119
+ conn.hvals(RedisKeys.workers(identity)).each do |raw|
120
+ job = JSON.parse(raw)['payload']
121
+ next unless job.is_a?(Hash) && job['periodic_pjid']
122
+
123
+ Periodic::RunningLock.touch(job['periodic_pjid'], job['jid'], Periodic::RunningLock::ACTIVE_TTL, conn)
124
+ rescue StandardError => e
125
+ # Per entry: one bad entry mustn't leave the others' locks to expire.
126
+ Cogworker.logger.error { "couldn't refresh periodic lock for #{raw.to_s[0, 200]}: #{e.class}: #{e.message}" }
79
127
  end
80
128
  end
81
129
 
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,60 @@ 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 : nil
68
+ rescue JSON::ParserError, TypeError
69
+ nil
70
+ end
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.
77
+ def claim_and_requeue(conn, set, raw)
78
+ job = requeueable(raw)
79
+ return :invalid unless job
80
+ return :gone unless conn.zrem(set, raw)
81
+
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
+ 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
98
+ end
99
+
100
+ UNPARSEABLE_CLASS = '(unparseable)'
101
+
102
+ # A payload that isn't a JSON object can't be run, retried or even
103
+ # attributed to a class. Wrapped into a regular job Hash (the Web UI
104
+ # parses every Dead entry as one) that keeps the original bytes under
105
+ # `raw_payload`, for whoever finds one to bury it in `dead`.
106
+ def unparseable_job(raw, queue:, error:)
107
+ now = Time.now.to_f
108
+ {
109
+ 'class' => UNPARSEABLE_CLASS, 'args' => [], 'queue' => queue, 'jid' => SecureRandom.hex(12),
110
+ 'retry' => false, 'retry_count' => 0, 'enqueued_at' => now, 'failed_at' => now,
111
+ 'error_class' => error.class.name, 'error_message' => error.message.to_s[0, 10_000],
112
+ 'raw_payload' => raw.to_s[0, 100_000]
113
+ }
114
+ end
59
115
  end
60
116
  end
@@ -22,9 +22,10 @@ module Cogworker
22
22
  def run
23
23
  Cogworker.reset_identity!
24
24
  install_signal_traps
25
+ resolve_periodic_classes
26
+ @heartbeat.start! # first: see Heartbeat#start!
25
27
  @manager.start!
26
28
  @scheduled.start!
27
- @heartbeat.start!
28
29
  @ticker.start!
29
30
  log_startup_info
30
31
  watch_signals
@@ -43,6 +44,24 @@ module Cogworker
43
44
 
44
45
  private
45
46
 
47
+ # Resolves every periodic entry's class here, on the main thread, before
48
+ # any processor thread exists. `Processor#build_worker` otherwise does
49
+ # the first `const_get` lazily on whichever processor thread picks the
50
+ # job up — and with an app autoloader that isn't thread-safe, two
51
+ # threads racing to autoload the same class (or its base class) can see
52
+ # it half-defined (`undefined method 'perform'`). A class that can't be
53
+ # resolved is only logged: every run of that entry will fail the same
54
+ # way (and show up in Dead/History), but it shouldn't keep the rest of
55
+ # the process from booting. Regular job classes aren't known up front —
56
+ # eager-load the app in the init file for those (see README).
57
+ def resolve_periodic_classes
58
+ @config.periodic_manager.entries.map(&:class_name).uniq.each do |name|
59
+ Object.const_get(name)
60
+ rescue NameError => e
61
+ Cogworker.logger.warn { "periodic job class #{name} can't be resolved: #{e.message}" }
62
+ end
63
+ end
64
+
46
65
  # Logged once all components are up, so the operator's console shows a
47
66
  # single summary (version/identity/pid/concurrency/queues/redis target)
48
67
  # right at the point the process is actually ready to pull work, rather
@@ -4,8 +4,11 @@ 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
+
7
9
  def initialize(config = Cogworker.config)
8
10
  @config = config
11
+ @fetch_class = resolve_fetch_class
9
12
  @processors = Array.new(config.concurrency) { Processor.new(self) }
10
13
  @quiet = false
11
14
  @stopping = false
@@ -49,10 +52,13 @@ module Cogworker
49
52
  @busy_mutex.synchronize { @busy_count -= 1 }
50
53
  end
51
54
 
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.
55
+ # Waits (up to `timeout` seconds) for in-flight jobs to finish, then puts
56
+ # any job still unfinished back on its queue and returns. Does not
57
+ # force-kill lingering threads past the deadline — a thread stuck past
58
+ # the deadline is the caller's (Launcher's) problem to decide whether to
59
+ # hard-exit the process; its job is already safely requeued by then,
60
+ # rather than lost with the process (it may therefore run twice, if the
61
+ # thread does still finish it).
56
62
  def stop!(timeout: 25)
57
63
  @quiet = true
58
64
  @stopping = true
@@ -61,10 +67,43 @@ module Cogworker
61
67
  remaining = deadline - monotonic_now
62
68
  p.thread&.join([remaining, 0].max)
63
69
  end
70
+ wait_for_fetching_processors
71
+ requeue_unfinished
64
72
  end
65
73
 
66
74
  private
67
75
 
76
+ # A processor that isn't running a job is at most one fetch timeout away
77
+ # from noticing `stopping?` and exiting — but until it does, it can
78
+ # still pop a job (BRPOP blocks for up to BasicFetch::TIMEOUT), and one
79
+ # popped after `requeue_unfinished` had already run would be lost with
80
+ # the process. Waiting those out first means the only threads left
81
+ # alive are ones stuck inside a job, which never fetch again.
82
+ def wait_for_fetching_processors
83
+ deadline = monotonic_now + BasicFetch::TIMEOUT + 1
84
+ @processors.reject(&:busy?).each do |p|
85
+ p.thread&.join([deadline - monotonic_now, 0].max)
86
+ end
87
+ end
88
+
89
+ def requeue_unfinished
90
+ moved = @fetch_class.requeue_in_progress(Cogworker.identity)
91
+ Cogworker.logger.warn { "requeued #{moved} job(s) unfinished at shutdown" } if moved.positive?
92
+ rescue StandardError => e
93
+ Cogworker.logger.error { "Requeueing unfinished jobs failed: #{e.class}: #{e.message}" }
94
+ end
95
+
96
+ def resolve_fetch_class
97
+ return BasicFetch if @config.fetch == :basic
98
+ return ReliableFetch if ReliableFetch.supported?
99
+
100
+ Cogworker.logger.warn do
101
+ "config.fetch = :reliable needs Redis >= #{ReliableFetch::MIN_REDIS_VERSION}; falling back to :basic " \
102
+ '(a job is lost if its process dies mid-job)'
103
+ end
104
+ BasicFetch
105
+ end
106
+
68
107
  # Fully qualified: inside the Cogworker namespace, a bare `Process` would
69
108
  # resolve to Cogworker::Process (the ProcessSet entry wrapper in api.rb),
70
109
  # 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
@@ -4,12 +4,18 @@
4
4
  -- ARGV[1] = slot (epoch int)
5
5
  -- ARGV[2] = unique mode ("until_executed" or "")
6
6
  -- ARGV[3] = lock TTL (seconds)
7
+ -- ARGV[4] = jid the caller will push this slot's job under
8
+ -- ARGV[5] = running-lock TTL (seconds)
7
9
  --
8
10
  -- Returns 1 if this call won the claim for this slot (the caller should
9
11
  -- enqueue the job), 0 otherwise. Composes two guards: `last_slot` stops a
10
12
  -- process re-firing the same (or an earlier) slot on a later tick, and the
11
13
  -- NX lock breaks the narrow race where two processes both pass the
12
14
  -- `last_slot` check before either has written it back.
15
+ --
16
+ -- For `until_executed`, the running lock is written here, in the same
17
+ -- atomic step as the claim and before the job exists anywhere — see
18
+ -- Periodic::RunningLock for why it must never be written after the push.
13
19
  if ARGV[2] == "until_executed" and redis.call("GET", KEYS[1]) then
14
20
  return 0
15
21
  end
@@ -24,4 +30,7 @@ if not redis.call("SET", KEYS[3], "1", "NX", "EX", ARGV[3]) then
24
30
  end
25
31
 
26
32
  redis.call("SET", KEYS[2], ARGV[1])
33
+ if ARGV[2] == "until_executed" then
34
+ redis.call("SET", KEYS[1], ARGV[4], "EX", ARGV[5])
35
+ end
27
36
  return 1
@@ -2,25 +2,43 @@
2
2
 
3
3
  module Cogworker
4
4
  module Periodic
5
- # Releases the `periodic:running:<pjid>` lock (used only for
6
- # `unique: :until_executed` entries) on success, or on the terminal
7
- # failed attempt. A no-op for any job that isn't periodic-scheduled
8
- # (`job['periodic_pjid']` absent). Registered unconditionally in every
9
- # Config, not gated on whether `config.periodic` is actually used, so it
10
- # never becomes a hidden dependency of the (separate) job status layer.
5
+ # Drives the server side of the `periodic:running:<pjid>` lock (used
6
+ # only for `unique: :until_executed` entries; see RunningLock for the
7
+ # whole lifecycle): shortens it to RunningLock::ACTIVE_TTL when the run
8
+ # starts, and releases it on success or on the terminal failed attempt
9
+ # (a failure that will be retried is `Processor#route_failure`'s to
10
+ # extend — only it knows the retry delay).
11
+ # A no-op for any job that isn't periodic-scheduled (`job['periodic_pjid']`
12
+ # absent) — and every call is a no-op on a lock owned by another jid, so
13
+ # it's harmless for a non-`until_executed` entry too. Registered
14
+ # unconditionally in every Config, not gated on whether
15
+ # `config.periodic` is actually used, so it never becomes a hidden
16
+ # dependency of the (separate) job status layer.
11
17
  class ReleaseMiddleware
12
18
  def call(_worker, job, _queue)
13
- yield
14
- release(job) if job['periodic_pjid']
15
- rescue Exception => e # rubocop:disable Lint/RescueException
16
- release(job) if job['periodic_pjid'] && JobUtil.terminal_failure?(job)
17
- raise e
19
+ pjid = job['periodic_pjid']
20
+ return yield unless pjid
21
+
22
+ shorten_lock(pjid, job['jid'])
23
+ begin
24
+ yield
25
+ rescue Exception => e # rubocop:disable Lint/RescueException
26
+ RunningLock.release(pjid, job['jid']) if JobUtil.terminal_failure?(job)
27
+ raise e
28
+ end
29
+ RunningLock.release(pjid, job['jid'])
18
30
  end
19
31
 
20
32
  private
21
33
 
22
- def release(job)
23
- Cogworker.config.redis { |c| c.del(RedisKeys.periodic_running(job['periodic_pjid'])) }
34
+ # Best-effort: failing here must not stop the job from running (it
35
+ # would be routed to retry/dead as if it had failed). If it does fail,
36
+ # the lock just keeps its longer queued TTL until this process's next
37
+ # heartbeat shortens it — within Heartbeat::INTERVAL.
38
+ def shorten_lock(pjid, jid)
39
+ RunningLock.touch(pjid, jid, RunningLock::ACTIVE_TTL)
40
+ rescue StandardError => e
41
+ Cogworker.logger.warn { "couldn't shorten periodic lock for #{pjid}: #{e.class}: #{e.message}" }
24
42
  end
25
43
  end
26
44
  end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cogworker
4
+ module Periodic
5
+ # `periodic:running:<pjid>` — the "an earlier run of this entry hasn't
6
+ # finished yet" lock behind `unique: :until_executed` periodic entries.
7
+ #
8
+ # Lifecycle (every write keyed on the owning jid, so a stray older run
9
+ # can never shorten, extend or release a newer run's lock):
10
+ # - set by `claim.lua`, atomically with the slot claim itself, *before*
11
+ # the job is pushed — setting it after the push let a fast job finish
12
+ # (and release a lock that didn't exist yet) before the lock was
13
+ # written, wedging the entry forever. TTL = `queued_ttl`, covering
14
+ # however long the job may wait in its queue.
15
+ # - shortened to ACTIVE_TTL once a processor starts it, then kept alive
16
+ # by that process's Heartbeat every beat — so a run whose process dies
17
+ # (OOM, SIGKILL, shutdown past the drain timeout) frees the entry
18
+ # within ACTIVE_TTL instead of never.
19
+ # - extended on a non-terminal failure to cover the retry backoff plus
20
+ # `queued_ttl` (`Processor#extend_locks_for_retry` — the job now waits
21
+ # in `cogworker:retry`, not on a process).
22
+ # - deleted on success or on the terminal failure.
23
+ module RunningLock
24
+ ACTIVE_TTL = 60
25
+
26
+ module_function
27
+
28
+ # Whole seconds: it goes straight into `SET ... EX` in claim.lua, which
29
+ # rejects a non-integer (a fractional `unique_lock_ttl` used to make
30
+ # every claim — and so every tick — fail).
31
+ def queued_ttl
32
+ Cogworker.config.unique_lock_ttl.to_f.ceil
33
+ end
34
+
35
+ def touch(pjid, jid, ttl, conn = nil)
36
+ OwnedKey.expire(RedisKeys.periodic_running(pjid), jid, ttl, conn)
37
+ end
38
+
39
+ def release(pjid, jid, conn = nil)
40
+ OwnedKey.delete(RedisKeys.periodic_running(pjid), jid, conn)
41
+ end
42
+ end
43
+ end
44
+ end