cogworker 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 13e1ad0fec77124674f55dc3e1f7213bd2a73834ebb474261ed82a337fc1bb3e
4
- data.tar.gz: ed51fed2854035c83016b3180fa7872772e6f60bb2dd84e44089485a536f81e4
3
+ metadata.gz: 680a06059bb5853e9d7843ff6d5313d7cd9431b222b846b496c9ca965d6b8844
4
+ data.tar.gz: fd688cdc23fa56e86e6d4c50a7f319e98698d4c6166d8213359c38eb0f94aa71
5
5
  SHA512:
6
- metadata.gz: 0d78dd7a7c4bbdf8bfc8c8619467922e732879908562b1ac99fed71ce111a8a4925136e87a9c4e56cb5abed57867d76f06c5d929fcb822156d4fd0add7a182d9
7
- data.tar.gz: 265d890a8f7d65b180dcb9a0f00f0d2ba6daf16fa9044f7bdb94b88b2cbeced1f6d61f5e11c86d5b107d281bbbc47591b54cb3fc6820f7a7fecc4732dec451d2
6
+ metadata.gz: fc5d3f1a358e0c826b2801ddd73f2b039b31b1e29616a38947da6df0cd85edfdf3b9b8c877e7123fc6283617f7f34317b9503c76db4eeb89a881677e335c5cfb
7
+ data.tar.gz: 486aff800ab67a15ce9c815f659a70ee95abcfd28218e7c862329a26a0f3643dce3a3c24dc79c539531a9b29b50f745e81482e617ba908c7d80ea131fe7b6d60
@@ -24,7 +24,7 @@ module Cogworker
24
24
  def record(jid, attempt:, error:, outcome:)
25
25
  entry = JSON.generate(
26
26
  'attempt' => attempt, 'failed_at' => Time.now.to_f, 'outcome' => outcome,
27
- 'error_class' => error.class.name, 'error_message' => error.message.to_s[0, 10_000]
27
+ 'error_class' => error.class.name, 'error_message' => JobUtil.error_message(error)
28
28
  )
29
29
  key = RedisKeys.job_attempts(jid)
30
30
  Cogworker.config.redis do |c|
@@ -17,8 +17,12 @@ module Cogworker
17
17
  class BasicFetch
18
18
  TIMEOUT = 2 # seconds; also how often a stopped/quieted processor notices and exits its fetch loop.
19
19
 
20
- def initialize(queues)
20
+ # `stopping:` lets a fetcher cut its own waits short once shutdown has
21
+ # begun (Processor passes the Manager's `stopping?`), so a long idle
22
+ # pause can't hold up `Manager#stop!`.
23
+ def initialize(queues, stopping: -> { false })
21
24
  @queue_keys = Array(queues).map { |q| RedisKeys.queue(q) }
25
+ @stopping = stopping
22
26
  end
23
27
 
24
28
  # Re-reads `cogworker:paused_queues` on every single fetch (not once at
@@ -33,7 +37,7 @@ module Cogworker
33
37
  def retrieve_work
34
38
  keys = active_keys
35
39
  if keys.empty?
36
- sleep(TIMEOUT)
40
+ interruptible_sleep(TIMEOUT)
37
41
  return nil
38
42
  end
39
43
 
@@ -47,6 +51,12 @@ module Cogworker
47
51
  # Nothing to do: BRPOP already removed the job from Redis.
48
52
  def acknowledge(_work); end
49
53
 
54
+ # `Processor#interrupt`: the job ran, recording its failure failed —
55
+ # file it under `set` (retry/dead) at `score` instead.
56
+ def interrupt(_work, set, score, payload)
57
+ Cogworker.config.redis { |c| c.zadd(set, score, payload) }
58
+ end
59
+
50
60
  # Returns a fetched-but-unstarted job to the end of its queue that's
51
61
  # popped next.
52
62
  def give_back(work)
@@ -62,7 +72,7 @@ module Cogworker
62
72
  Cogworker.config.redis do |c|
63
73
  c.hvals(RedisKeys.workers(identity)).count do |raw|
64
74
  entry = JSON.parse(raw)
65
- c.rpush(RedisKeys.queue(entry.fetch('queue')), JSON.generate(entry.fetch('payload')))
75
+ requeue_entry(c, entry.fetch('queue'), entry.fetch('payload'))
66
76
  true
67
77
  rescue StandardError => e
68
78
  Cogworker.logger.error { "couldn't requeue in-flight entry #{raw.to_s[0, 200]}: #{e.class}: #{e.message}" }
@@ -71,8 +81,28 @@ module Cogworker
71
81
  end
72
82
  end
73
83
 
84
+ def self.requeue_entry(conn, queue, job)
85
+ queue_key = RedisKeys.queue(queue)
86
+ retake = job['periodic_until_executed'] && job['periodic_pjid'] && job['jid']
87
+ lock_key = retake ? RedisKeys.periodic_running(job['periodic_pjid']) : queue_key
88
+ LuaScript.run(conn, REQUEUE_ENTRY_SCRIPT, keys: [queue_key, lock_key],
89
+ argv: [JSON.generate(job), retake ? '1' : '0', job['jid'].to_s,
90
+ Periodic::RunningLock.queued_ttl])
91
+ end
92
+ private_class_method :requeue_entry
93
+
74
94
  private
75
95
 
96
+ def interruptible_sleep(seconds)
97
+ deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + seconds
98
+ until @stopping.call
99
+ left = deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
100
+ break if left <= 0
101
+
102
+ sleep([left, 0.25].min)
103
+ end
104
+ end
105
+
76
106
  def active_keys
77
107
  paused = Cogworker.config.redis { |c| c.smembers(RedisKeys::PAUSED_QUEUES) }
78
108
  return @queue_keys if paused.empty?
@@ -81,5 +111,18 @@ module Cogworker
81
111
  end
82
112
 
83
113
  UnitOfWork = Struct.new(:queue, :raw_job)
114
+
115
+ # KEYS[1] = the queue, KEYS[2] = the job's periodic running lock (or the
116
+ # queue again when there's none to take); ARGV[1] = payload, ARGV[2] =
117
+ # "1" to re-take that lock, ARGV[3] = jid, ARGV[4] = lock TTL. Requeue
118
+ # and lock in one step: as two commands, a failure in between left the
119
+ # job queued without its lock (and miscounted).
120
+ REQUEUE_ENTRY_SCRIPT = Periodic::RunningLock::RETAKE_FUNCTION + <<~LUA
121
+ redis.call("RPUSH", KEYS[1], ARGV[1])
122
+ if ARGV[2] == "1" then
123
+ retake_running_lock(KEYS[2], ARGV[3], ARGV[4])
124
+ end
125
+ return 1
126
+ LUA
84
127
  end
85
128
  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,10 +125,24 @@ 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, redis_now(c))
131
+ end
109
132
  touch_running_periodic_locks(c, identity)
110
133
  end
111
134
  end
112
135
 
136
+ # Redis's clock, not this host's (so host clock skew doesn't matter to
137
+ # orphan detection) — falling back to this host's where `TIME` isn't
138
+ # allowed (some proxies/managed services): better a skewed stamp than a
139
+ # process that can never complete its first beat.
140
+ def redis_now(conn)
141
+ conn.time.first
142
+ rescue Redis::CommandError
143
+ Time.now.to_i
144
+ end
145
+
113
146
  # Keeps every in-flight `until_executed` periodic run's
114
147
  # `periodic:running:<pjid>` lock alive for as long as this process is —
115
148
  # see Periodic::RunningLock. Reads the same `cogworker:workers:<identity>`
@@ -120,7 +153,7 @@ module Cogworker
120
153
  job = JSON.parse(raw)['payload']
121
154
  next unless job.is_a?(Hash) && job['periodic_pjid']
122
155
 
123
- Periodic::RunningLock.touch(job['periodic_pjid'], job['jid'], Periodic::RunningLock::ACTIVE_TTL, conn)
156
+ Periodic::RunningLock.touch(job['periodic_pjid'], job['jid'], Periodic::RunningLock.active_ttl, conn)
124
157
  rescue StandardError => e
125
158
  # Per entry: one bad entry mustn't leave the others' locks to expire.
126
159
  Cogworker.logger.error { "couldn't refresh periodic lock for #{raw.to_s[0, 200]}: #{e.class}: #{e.message}" }
@@ -194,6 +227,7 @@ module Cogworker
194
227
  def remote_stop!
195
228
  @manager.stop!
196
229
  cleanup_presence!
230
+ Cogworker.flush_output!
197
231
  ::Process.exit!(true)
198
232
  end
199
233
  end
@@ -30,8 +30,8 @@ module Cogworker
30
30
  }
31
31
  if error
32
32
  entry['error_class'] = error.class.name
33
- entry['error_message'] = error.message.to_s[0, 10_000]
34
- entry['backtrace'] = (error.backtrace || []).first(200)
33
+ entry['error_message'] = JobUtil.error_message(error)
34
+ entry['backtrace'] = (error.backtrace || []).first(200).map { |line| JobUtil.safe_string(line) }
35
35
  end
36
36
 
37
37
  raw = JSON.generate(entry)
@@ -64,37 +64,64 @@ 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
98
+ job['enqueued_at'] = Time.now.to_f
99
+ won = LuaScript.run(conn, 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
84
103
  end
85
104
 
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
105
+ ERROR_MESSAGE_LIMIT = 10_000
106
+
107
+ # Valid UTF-8, whatever came in — every string this gem stores from
108
+ # outside its control (an exception's message, a raw payload) goes
109
+ # through here before `JSON.generate`, which raises on invalid UTF-8.
110
+ # An unchecked `raise "bad \xff".b` used to make every attempt to record
111
+ # the failure itself raise, deterministically. Strings in another
112
+ # (valid) encoding are transcoded; binary/broken ones are scrubbed.
113
+ def safe_string(value, limit = nil)
114
+ str = value.to_s
115
+ str = if [Encoding::UTF_8, Encoding::BINARY, Encoding::US_ASCII].include?(str.encoding)
116
+ str.dup.force_encoding(Encoding::UTF_8).scrub
117
+ else
118
+ str.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
119
+ end
120
+ limit ? str[0, limit] : str
121
+ end
122
+
123
+ def error_message(error)
124
+ safe_string(error.message, ERROR_MESSAGE_LIMIT)
98
125
  end
99
126
 
100
127
  UNPARSEABLE_CLASS = '(unparseable)'
@@ -108,8 +135,8 @@ module Cogworker
108
135
  {
109
136
  'class' => UNPARSEABLE_CLASS, 'args' => [], 'queue' => queue, 'jid' => SecureRandom.hex(12),
110
137
  '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]
138
+ 'error_class' => error.class.name, 'error_message' => error_message(error),
139
+ 'raw_payload' => safe_string(raw, 100_000)
113
140
  }
114
141
  end
115
142
  end
@@ -13,8 +13,14 @@ module Cogworker
13
13
  @manager = Manager.new(config)
14
14
  @scheduled = Scheduled.new(@manager)
15
15
  @heartbeat = Heartbeat.new(@manager)
16
+ # The first tick waits for the first orphan check: after the whole
17
+ # fleet was down longer than a running `until_executed` job's lock
18
+ # lives, the ticker would otherwise claim that entry's next slot before
19
+ # recovery had requeued the interrupted run (and re-taken its lock) —
20
+ # two runs at once.
16
21
  @ticker = Periodic::Ticker.new(
17
- @manager, config.periodic_manager.entries, catch_up: config.periodic_catch_up
22
+ @manager, config.periodic_manager.entries, catch_up: config.periodic_catch_up,
23
+ ready: -> { @scheduled.recovered_once? }
18
24
  )
19
25
  @signal_queue = ::Queue.new
20
26
  end
@@ -23,7 +29,8 @@ module Cogworker
23
29
  Cogworker.reset_identity!
24
30
  install_signal_traps
25
31
  resolve_periodic_classes
26
- @heartbeat.start! # first: see Heartbeat#start!
32
+ return unless start_heartbeat
33
+
27
34
  @manager.start!
28
35
  @scheduled.start!
29
36
  @ticker.start!
@@ -44,6 +51,23 @@ module Cogworker
44
51
 
45
52
  private
46
53
 
54
+ # First, and blocking until it succeeds: see Heartbeat#start!. A stop
55
+ # signal arriving while Redis is still unreachable ends the wait (and
56
+ # the process) without ever starting anything; a quiet one is applied
57
+ # and the wait goes on.
58
+ def start_heartbeat
59
+ until @heartbeat.start!(abort_if: -> { !@signal_queue.empty? })
60
+ case @signal_queue.pop
61
+ when :stop
62
+ Cogworker.logger.info { 'Received stop signal before startup completed' }
63
+ stop!
64
+ return false
65
+ when :quiet then quiet!
66
+ end
67
+ end
68
+ true
69
+ end
70
+
47
71
  # Resolves every periodic entry's class here, on the main thread, before
48
72
  # any processor thread exists. `Processor#build_worker` otherwise does
49
73
  # 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|
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest/sha1'
4
+
5
+ module Cogworker
6
+ # Runs a Lua script by its SHA1 (EVALSHA) instead of shipping the whole
7
+ # source on every call, falling back to EVAL — which also loads it into
8
+ # the server's script cache for next time — on NOSCRIPT (first use, a
9
+ # restarted or failed-over server, SCRIPT FLUSH). Every script in this
10
+ # gem goes through here.
11
+ module LuaScript
12
+ SHAS = {} # source => sha1; memoized, the source strings are frozen constants
13
+ SHAS_MUTEX = Mutex.new
14
+
15
+ module_function
16
+
17
+ def run(conn, source, keys: [], argv: [])
18
+ begin
19
+ return conn.evalsha(sha(source), keys: keys, argv: argv)
20
+ rescue Redis::CommandError => e
21
+ raise unless e.message.start_with?('NOSCRIPT')
22
+ end
23
+ conn.eval(source, keys: keys, argv: argv)
24
+ end
25
+
26
+ def sha(source)
27
+ SHAS_MUTEX.synchronize { SHAS[source] ||= Digest::SHA1.hexdigest(source) }
28
+ end
29
+ end
30
+ end
@@ -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, not in the constructor (that needed Redis
22
+ # just to build a Manager). First use is normally the Heartbeat's first
23
+ # beat; if Redis can't be reached then, ReliableFetch.supported? raises
24
+ # and nothing is memoized — the beat fails and is retried, and this
25
+ # resolves again next time — rather than memoizing a guess.
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}" }
@@ -32,9 +32,9 @@ module Cogworker
32
32
  end
33
33
 
34
34
  def run(conn, script, key, argv)
35
- return conn.eval(script, keys: [key], argv: argv) if conn
35
+ return LuaScript.run(conn, script, keys: [key], argv: argv) if conn
36
36
 
37
- Cogworker.config.redis { |c| c.eval(script, keys: [key], argv: argv) }
37
+ Cogworker.config.redis { |c| LuaScript.run(c, script, keys: [key], argv: argv) }
38
38
  end
39
39
  end
40
40
  end
@@ -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