cogworker 0.4.0 → 0.5.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: bfb90fe3a477f31658c58fd09a41e4404a0c09999b74614789bd343ee704fad2
4
- data.tar.gz: 407aa20293538fa35c8219f88f21fd8b1c15150d96e77d2b495d017ef21f924e
3
+ metadata.gz: e908112cee4c6a9f98588b6cca08c292437d1713ec351ac6287e1f5ef605c452
4
+ data.tar.gz: 0c271ea997d7ddf51df5aa5a16b45cb08c0b3231f71dcdb253956160c589bde3
5
5
  SHA512:
6
- metadata.gz: c7df3eba977499a45acca66e8d25fd87cfe4669b9098c1e99be798f587de2ddf76edc75adb01953e8fe311ac324d7e4eb44cce66a33be264efe4f266e67d8f3e
7
- data.tar.gz: 7fa4d7ba18f7be8ae8e80bd7b260be8bd8e9387122a623802812abd166652859f48669bbb42c16f72f15df1104d855934f4cfdb6cf6aede2737d740540d27278
6
+ metadata.gz: fb759fcff852bfa57315e4428250daeac0a0ae27771dc108e1879f3847616c073ce5d2fa8e079a77d73a51d7db3f18154d5cd6c7ea45c69b1282163cf817aa75
7
+ data.tar.gz: caea49e5286f9bf98e93c2931c2e8a77e18b9cce59e88c7ee7854f80bfe30b7b45649a148ed08c605ae87faeeb5ce87ae7f6fe23a523625db1728db1f215b120
@@ -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)
@@ -58,7 +68,8 @@ module Cogworker
58
68
  # that's what gets pushed back — onto the end its queue is popped from,
59
69
  # so it runs next. Returns how many. One entry that can't be read is
60
70
  # logged and skipped, never allowed to cost the others their requeue.
61
- def self.requeue_in_progress(identity)
71
+ # (`except:` is ReliableFetch's — there's no in-progress list here.)
72
+ def self.requeue_in_progress(identity, except: [], keep: []) # rubocop:disable Lint/UnusedMethodArgument
62
73
  Cogworker.config.redis do |c|
63
74
  c.hvals(RedisKeys.workers(identity)).count do |raw|
64
75
  entry = JSON.parse(raw)
@@ -75,14 +86,24 @@ module Cogworker
75
86
  queue_key = RedisKeys.queue(queue)
76
87
  retake = job['periodic_until_executed'] && job['periodic_pjid'] && job['jid']
77
88
  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,
89
+ LuaScript.run(conn, REQUEUE_ENTRY_SCRIPT, keys: [queue_key, lock_key],
90
+ argv: [JSON.generate(job), retake ? '1' : '0', job['jid'].to_s,
80
91
  Periodic::RunningLock.queued_ttl])
81
92
  end
82
93
  private_class_method :requeue_entry
83
94
 
84
95
  private
85
96
 
97
+ def interruptible_sleep(seconds)
98
+ deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + seconds
99
+ until @stopping.call
100
+ left = deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
101
+ break if left <= 0
102
+
103
+ sleep([left, 0.25].min)
104
+ end
105
+ end
106
+
86
107
  def active_keys
87
108
  paused = Cogworker.config.redis { |c| c.smembers(RedisKeys::PAUSED_QUEUES) }
88
109
  return @queue_keys if paused.empty?
@@ -9,7 +9,7 @@ module Cogworker
9
9
  FETCH_MODES = %i[reliable basic].freeze
10
10
 
11
11
  attr_reader :server_chain, :client_chain, :periodic_manager, :redis_options, :fetch, :fetch_idle_max_interval,
12
- :orphan_threshold
12
+ :orphan_threshold, :max_orphanings
13
13
  attr_accessor :concurrency, :queues, :periodic_catch_up, :unique_lock_ttl
14
14
 
15
15
  def initialize
@@ -51,9 +51,21 @@ module Cogworker
51
51
  # failover, a long GVL-holding call) — each of which runs its jobs
52
52
  # twice; lower: a really crashed process's jobs come back sooner.
53
53
  @orphan_threshold = 300
54
+ # How many times a job may be recovered from a process that died
55
+ # while running it before it goes to dead instead (ReliableFetch). The
56
+ # count can't single out the culprit: every job running in a process
57
+ # that crashed is counted, so keep this above the number of crashes an
58
+ # innocent job might plausibly sit through.
59
+ @max_orphanings = 3
54
60
  register_default_middleware
55
61
  end
56
62
 
63
+ def max_orphanings=(count)
64
+ raise ArgumentError, "max_orphanings must be a positive Integer, got #{count.inspect}" unless count.is_a?(Integer) && count.positive?
65
+
66
+ @max_orphanings = count
67
+ end
68
+
57
69
  def orphan_threshold=(seconds)
58
70
  unless seconds.is_a?(Numeric) && seconds.positive?
59
71
  raise ArgumentError, "orphan_threshold must be a positive number of seconds, got #{seconds.inspect}"
@@ -117,30 +117,53 @@ module Cogworker
117
117
  'rss_kb' => current_rss_kb
118
118
  }
119
119
 
120
+ # The presence key is what makes this process alive to everyone else
121
+ # (and what Heartbeat#start! waits for) — the one write a beat can't
122
+ # do without. Everything else is written on its own, best-effort: one
123
+ # shared key of the wrong type used to fail every beat of every
124
+ # process, so no new process could start anywhere in the fleet.
120
125
  Cogworker.config.redis do |c|
121
- c.sadd?(RedisKeys::PROCESSES, identity)
122
126
  c.hset(RedisKeys.process(identity),
123
127
  'info', JSON.generate(info),
124
128
  'busy', @manager.busy_count.to_s,
125
129
  'quiet', @manager.quiet?.to_s)
126
130
  c.expire(RedisKeys.process(identity), TTL)
127
- c.expire(RedisKeys.workers(identity), TTL)
131
+ secondary('process registry') { c.sadd?(RedisKeys::PROCESSES, identity) }
132
+ secondary('workers expiry') { c.expire(RedisKeys.workers(identity), TTL) }
128
133
  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
134
+ secondary('in-progress registry') { c.sadd?(RedisKeys::IN_PROGRESS_IDENTITIES, identity) }
135
+ secondary('last-beat stamp') { c.hset(RedisKeys::LAST_BEAT, identity, redis_now(c)) }
131
136
  end
132
137
  touch_running_periodic_locks(c, identity)
133
138
  end
134
139
  end
135
140
 
141
+ def secondary(what)
142
+ yield
143
+ rescue Redis::BaseConnectionError
144
+ raise # Redis itself is gone: the beat as a whole failed
145
+ rescue StandardError => e
146
+ Cogworker.logger.error { "Heartbeat: #{what} not written: #{e.class}: #{e.message}" }
147
+ end
148
+
149
+ # Redis's clock, not this host's (so host clock skew doesn't matter to
150
+ # orphan detection) — falling back to this host's where `TIME` isn't
151
+ # allowed (some proxies/managed services): better a skewed stamp than a
152
+ # process that can never complete its first beat.
153
+ def redis_now(conn)
154
+ conn.time.first
155
+ rescue Redis::CommandError
156
+ Time.now.to_i
157
+ end
158
+
136
159
  # Keeps every in-flight `until_executed` periodic run's
137
160
  # `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']
161
+ # see Periodic::RunningLock. From the Manager's own in-memory record of
162
+ # what's running here, not `cogworker:workers` in Redis: a failover that
163
+ # lost that Hash would otherwise let a long run's lock lapse mid-run.
164
+ def touch_running_periodic_locks(conn, _identity)
165
+ @manager.running_jobs.each do |raw|
166
+ job = JSON.parse(raw)
144
167
  next unless job.is_a?(Hash) && job['periodic_pjid']
145
168
 
146
169
  Periodic::RunningLock.touch(job['periodic_pjid'], job['jid'], Periodic::RunningLock.active_ttl, conn)
@@ -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)
@@ -37,11 +37,18 @@ module Cogworker
37
37
  # define `#to_i` (`nil.to_i` happens to be `0` — coincidentally correct
38
38
  # for absent — but `false.to_i` raises `NoMethodError` outright, which is
39
39
  # exactly the bug this method exists to not have).
40
+ #
41
+ # Anything else (a Hash, a non-numeric String…) means no retries — the
42
+ # cautious reading for a job with side effects — rather than raising
43
+ # from the middle of recording its failure.
40
44
  def max_retries(job)
41
- case job['retry']
45
+ case (value = job['retry'])
42
46
  when false then 0
43
47
  when true, nil then DEFAULT_MAX_RETRY_ATTEMPTS
44
- else job['retry'].to_i
48
+ when Integer then value
49
+ when Float then value.to_i
50
+ when String then Integer(value, exception: false) || 0
51
+ else 0
45
52
  end
46
53
  end
47
54
 
@@ -75,12 +82,17 @@ module Cogworker
75
82
  # commands, a crash or dropped connection between the ZREM and the
76
83
  # LPUSH lost the job. The new payload is built in Ruby, not re-encoded
77
84
  # by Lua's cjson (which would round large integers in args).
85
+ # Present? Push, then remove — never remove first: a script stopped by
86
+ # an error keeps the writes made before it, so a ZREM followed by a
87
+ # failing LPUSH (the queue key holding the wrong type) lost the entry.
88
+ # The `cogworker:queues` listing is best-effort.
78
89
  CLAIM_AND_REQUEUE_SCRIPT = <<~LUA
79
- if redis.call("ZREM", KEYS[1], ARGV[1]) == 0 then
90
+ if not redis.call("ZSCORE", KEYS[1], ARGV[1]) then
80
91
  return 0
81
92
  end
82
- redis.call("SADD", KEYS[2], ARGV[2])
83
93
  redis.call("LPUSH", KEYS[3], ARGV[3])
94
+ redis.call("ZREM", KEYS[1], ARGV[1])
95
+ redis.pcall("SADD", KEYS[2], ARGV[2])
84
96
  return 1
85
97
  LUA
86
98
 
@@ -96,12 +108,40 @@ module Cogworker
96
108
  return :invalid unless job
97
109
 
98
110
  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)])
111
+ won = LuaScript.run(conn, CLAIM_AND_REQUEUE_SCRIPT,
112
+ keys: [set, RedisKeys::QUEUES, RedisKeys.queue(job['queue'])],
113
+ argv: [raw, job['queue'], JSON.generate(job)])
102
114
  won == 1 ? :requeued : :gone
103
115
  end
104
116
 
117
+ ERROR_MESSAGE_LIMIT = 10_000
118
+
119
+ # Valid UTF-8, whatever came in — every string this gem stores from
120
+ # outside its control (an exception's message, a raw payload) goes
121
+ # through here before `JSON.generate`, which raises on invalid UTF-8.
122
+ # An unchecked `raise "bad \xff".b` used to make every attempt to record
123
+ # the failure itself raise, deterministically. Strings in another
124
+ # (valid) encoding are transcoded; binary/broken ones are scrubbed.
125
+ def safe_string(value, limit = nil)
126
+ str = value.to_s
127
+ str = if [Encoding::UTF_8, Encoding::BINARY, Encoding::US_ASCII].include?(str.encoding)
128
+ str.dup.force_encoding(Encoding::UTF_8).scrub
129
+ else
130
+ str.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
131
+ end
132
+ limit ? str[0, limit] : str
133
+ end
134
+
135
+ # Never raises: an exception whose own `#message` raises (a broken
136
+ # custom exception class) used to escape from the middle of recording
137
+ # its failure — and, the job then counting as not-yet-run, it was handed
138
+ # back and re-run about once a second, forever.
139
+ def error_message(error)
140
+ safe_string(error.message, ERROR_MESSAGE_LIMIT)
141
+ rescue Exception # rubocop:disable Lint/RescueException
142
+ "#<#{error.class}>"
143
+ end
144
+
105
145
  UNPARSEABLE_CLASS = '(unparseable)'
106
146
 
107
147
  # A payload that isn't a JSON object can't be run, retried or even
@@ -113,8 +153,8 @@ module Cogworker
113
153
  {
114
154
  'class' => UNPARSEABLE_CLASS, 'args' => [], 'queue' => queue, 'jid' => SecureRandom.hex(12),
115
155
  '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]
156
+ 'error_class' => error.class.name, 'error_message' => error_message(error),
157
+ 'raw_payload' => safe_string(raw, 100_000)
118
158
  }
119
159
  end
120
160
  end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Cogworker
6
+ # Finds this gem's Redis keys holding the wrong type of value — which
7
+ # only a manual write or data corruption produces, but which then breaks
8
+ # every command on that key, deterministically: a queue that can't be
9
+ # pushed to, a `dead` set nothing can be filed in, a registry that fails
10
+ # every beat — and moves each such key out of the way, to
11
+ # `cogworker:quarantine:<key>:<time>:<random>` (kept QUARANTINE_TTL for a human to
12
+ # look at), with an error in the log. Everything then carries on with a
13
+ # fresh, empty key of the right type. Run by every process's Scheduled
14
+ # poller at boot and every CHECK_INTERVAL.
15
+ module KeyGuard
16
+ CHECK_INTERVAL = 60
17
+ QUARANTINE_TTL = 30 * 24 * 60 * 60
18
+ QUARANTINE_PREFIX = 'cogworker:quarantine:'
19
+
20
+ FIXED = {
21
+ RedisKeys::SCHEDULE => 'zset', RedisKeys::RETRY => 'zset', RedisKeys::DEAD => 'zset',
22
+ RedisKeys::QUEUES => 'set', RedisKeys::PROCESSES => 'set', RedisKeys::PAUSED_QUEUES => 'set',
23
+ RedisKeys::IN_PROGRESS_IDENTITIES => 'set', RedisKeys::LAST_BEAT => 'hash',
24
+ RedisKeys::REPEAT_ORPHANS => 'list', RedisKeys::UNSETTLED => 'list', RedisKeys::PERIODIC_SCHEDULE => 'hash',
25
+ RedisKeys::PERIODIC_DISABLED => 'set', RedisKeys::STATS_PROCESSED => 'string',
26
+ RedisKeys::STATS_FAILED => 'string'
27
+ }.freeze
28
+
29
+ # KEYS[1] = the key, KEYS[2] = its quarantine name; ARGV[1] = expected
30
+ # type, ARGV[2] = quarantine TTL. Re-checked here, atomically with the
31
+ # move, so a key fixed (or recreated) in between is left alone.
32
+ QUARANTINE_SCRIPT = <<~LUA
33
+ local actual = redis.call("TYPE", KEYS[1])["ok"]
34
+ if actual == "none" or actual == ARGV[1] then
35
+ return 0
36
+ end
37
+ redis.call("RENAME", KEYS[1], KEYS[2])
38
+ redis.call("EXPIRE", KEYS[2], ARGV[2])
39
+ return 1
40
+ LUA
41
+
42
+ module_function
43
+
44
+ # Returns the keys quarantined.
45
+ def check(queues: Cogworker.config.queues)
46
+ expected = expected_types(queues)
47
+ actual = Cogworker.config.redis do |c|
48
+ c.pipelined { |p| expected.each_key { |key| p.type(key) } }
49
+ end
50
+ expected.keys.zip(actual).filter_map do |key, type|
51
+ next if type == 'none' || type == expected[key]
52
+
53
+ key if quarantine(key, expected[key], type)
54
+ end
55
+ end
56
+
57
+ # The fixed keys, plus the per-queue and per-process ones currently
58
+ # listed in them (only where the listing itself is readable).
59
+ def expected_types(queues)
60
+ types = FIXED.dup
61
+ queue_names = Array(queues) | readable_members(RedisKeys::QUEUES)
62
+ queue_names.each { |q| types[RedisKeys.queue(q)] = 'list' }
63
+ readable_members(RedisKeys::IN_PROGRESS_IDENTITIES).each { |id| types[RedisKeys.in_progress(id)] = 'list' }
64
+ readable_members(RedisKeys::PROCESSES).each do |id|
65
+ types[RedisKeys.process(id)] = 'hash'
66
+ types[RedisKeys.workers(id)] = 'hash'
67
+ end
68
+ types
69
+ end
70
+
71
+ def readable_members(set)
72
+ Cogworker.config.redis { |c| c.smembers(set) }
73
+ rescue Redis::CommandError
74
+ [] # itself of the wrong type: quarantined this round, listed again next
75
+ end
76
+
77
+ def quarantine(key, expected, actual)
78
+ # Unique per move: a second quarantine of the same key within the same
79
+ # second must not overwrite (RENAME) the first one's copy.
80
+ target = "#{QUARANTINE_PREFIX}#{key}:#{Time.now.to_i}:#{SecureRandom.hex(4)}"
81
+ moved = Cogworker.config.redis do |c|
82
+ LuaScript.run(c, QUARANTINE_SCRIPT, keys: [key, target], argv: [expected, QUARANTINE_TTL])
83
+ end
84
+ return false unless moved == 1
85
+
86
+ Cogworker.logger.error do
87
+ "#{key} held a #{actual}, not a #{expected}: moved to #{target} (kept #{QUARANTINE_TTL / 86_400} days)"
88
+ end
89
+ true
90
+ end
91
+ end
92
+ end
@@ -13,8 +13,15 @@ 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 that really
17
+ # completed (Scheduled#ready_for_ticker?): after the whole
18
+ # fleet was down longer than a running `until_executed` job's lock
19
+ # lives, the ticker would otherwise claim that entry's next slot before
20
+ # recovery had requeued the interrupted run (and re-taken its lock) —
21
+ # two runs at once.
16
22
  @ticker = Periodic::Ticker.new(
17
- @manager, config.periodic_manager.entries, catch_up: config.periodic_catch_up
23
+ @manager, config.periodic_manager.entries, catch_up: config.periodic_catch_up,
24
+ ready: -> { @scheduled.ready_for_ticker? }
18
25
  )
19
26
  @signal_queue = ::Queue.new
20
27
  end
@@ -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
@@ -7,6 +7,16 @@ module Cogworker
7
7
  def initialize(config = Cogworker.config)
8
8
  @config = config
9
9
  @fetch_mutex = Mutex.new
10
+ # This process's own record of its jobs (raw payloads, as fetched):
11
+ # running right now (a count — two copies of one payload can run at
12
+ # once), and finished but not yet settled in Redis — `:ack` (the ack
13
+ # failed) or `[set, score, payload]` (the job ran and failed, and
14
+ # filing it in retry/dead failed too). In memory on purpose:
15
+ # ReliableFetch.reconcile trusts it over anything in Redis, which may
16
+ # be what's failing (or what lost its last writes in a failover).
17
+ @jobs_mutex = Mutex.new
18
+ @running_jobs = Hash.new(0)
19
+ @pending_settlements = {}
10
20
  @processors = Array.new(config.concurrency) { Processor.new(self) }
11
21
  @quiet = false
12
22
  @stopping = false
@@ -18,11 +28,11 @@ module Cogworker
18
28
  @config.queues
19
29
  end
20
30
 
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?).
31
+ # Resolved on first use, not in the constructor (that needed Redis
32
+ # just to build a Manager). First use is normally the Heartbeat's first
33
+ # beat; if Redis can't be reached then, ReliableFetch.supported? raises
34
+ # and nothing is memoized — the beat fails and is retried, and this
35
+ # resolves again next time — rather than memoizing a guess.
26
36
  def fetch_class
27
37
  @fetch_mutex.synchronize { @fetch_class ||= resolve_fetch_class }
28
38
  end
@@ -63,6 +73,34 @@ module Cogworker
63
73
  @stopping
64
74
  end
65
75
 
76
+ def job_started(raw)
77
+ @jobs_mutex.synchronize { @running_jobs[raw] += 1 }
78
+ end
79
+
80
+ def job_finished(raw)
81
+ @jobs_mutex.synchronize do
82
+ @running_jobs[raw] -= 1
83
+ @running_jobs.delete(raw) unless @running_jobs[raw].positive?
84
+ end
85
+ end
86
+
87
+ def running_jobs
88
+ @jobs_mutex.synchronize { @running_jobs.keys }
89
+ end
90
+
91
+ # `how`: :ack, or [set, score, payload] — see ReliableFetch.settle.
92
+ def settle_later(raw, how)
93
+ @jobs_mutex.synchronize { @pending_settlements[raw] = how }
94
+ end
95
+
96
+ def settled(raw)
97
+ @jobs_mutex.synchronize { @pending_settlements.delete(raw) }
98
+ end
99
+
100
+ def pending_settlements
101
+ @jobs_mutex.synchronize { @pending_settlements.dup }
102
+ end
103
+
66
104
  def busy_count
67
105
  @busy_mutex.synchronize { @busy_count }
68
106
  end
@@ -109,8 +147,27 @@ module Cogworker
109
147
  end
110
148
  end
111
149
 
150
+ # Jobs that finished but aren't settled in Redis yet are still on the
151
+ # in-progress list. One more try at settling them first; then `except:`
152
+ # drops the ones only waiting for their ack (which is what dropping
153
+ # them is), in the same atomic step as requeueing the rest — a separate
154
+ # ack pass that failed used to leave them to be requeued and run again.
155
+ # Ones that couldn't be filed in retry/dead even now are handed over to
156
+ # UNSETTLED for any live process to file later (ReliableFetch.park_unsettled)
157
+ # — never requeued: they already ran. Any that can't even be handed
158
+ # over are left on the list (`keep:`): orphan recovery requeues them
159
+ # once this process is gone, at most `max_orphanings` times.
112
160
  def requeue_unfinished
113
- moved = fetch_class.requeue_in_progress(Cogworker.identity)
161
+ keep = []
162
+ if fetch_class == ReliableFetch
163
+ ReliableFetch.settle_pending(Cogworker.identity, pending_settlements) { |raw| settled(raw) }
164
+ unfiled = pending_settlements.reject { |_raw, how| how == :ack }
165
+ keep = ReliableFetch.park_unsettled(Cogworker.identity, unfiled)
166
+ (unfiled.keys - keep).each { |raw| settled(raw) }
167
+ end
168
+ to_ack = pending_settlements.select { |_raw, how| how == :ack }.keys
169
+ moved = fetch_class.requeue_in_progress(Cogworker.identity, except: to_ack, keep: keep)
170
+ to_ack.each { |raw| settled(raw) }
114
171
  Cogworker.logger.warn { "requeued #{moved} job(s) unfinished at shutdown" } if moved.positive?
115
172
  rescue StandardError => e
116
173
  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