cogworker 0.4.1 → 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: 680a06059bb5853e9d7843ff6d5313d7cd9431b222b846b496c9ca965d6b8844
4
- data.tar.gz: fd688cdc23fa56e86e6d4c50a7f319e98698d4c6166d8213359c38eb0f94aa71
3
+ metadata.gz: e908112cee4c6a9f98588b6cca08c292437d1713ec351ac6287e1f5ef605c452
4
+ data.tar.gz: 0c271ea997d7ddf51df5aa5a16b45cb08c0b3231f71dcdb253956160c589bde3
5
5
  SHA512:
6
- metadata.gz: fc5d3f1a358e0c826b2801ddd73f2b039b31b1e29616a38947da6df0cd85edfdf3b9b8c877e7123fc6283617f7f34317b9503c76db4eeb89a881677e335c5cfb
7
- data.tar.gz: 486aff800ab67a15ce9c815f659a70ee95abcfd28218e7c862329a26a0f3643dce3a3c24dc79c539531a9b29b50f745e81482e617ba908c7d80ea131fe7b6d60
6
+ metadata.gz: fb759fcff852bfa57315e4428250daeac0a0ae27771dc108e1879f3847616c073ce5d2fa8e079a77d73a51d7db3f18154d5cd6c7ea45c69b1282163cf817aa75
7
+ data.tar.gz: caea49e5286f9bf98e93c2931c2e8a77e18b9cce59e88c7ee7854f80bfe30b7b45649a148ed08c605ae87faeeb5ce87ae7f6fe23a523625db1728db1f215b120
@@ -68,7 +68,8 @@ module Cogworker
68
68
  # that's what gets pushed back — onto the end its queue is popped from,
69
69
  # so it runs next. Returns how many. One entry that can't be read is
70
70
  # logged and skipped, never allowed to cost the others their requeue.
71
- 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
72
73
  Cogworker.config.redis do |c|
73
74
  c.hvals(RedisKeys.workers(identity)).count do |raw|
74
75
  entry = JSON.parse(raw)
@@ -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,22 +117,35 @@ 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, redis_now(c))
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
+
136
149
  # Redis's clock, not this host's (so host clock skew doesn't matter to
137
150
  # orphan detection) — falling back to this host's where `TIME` isn't
138
151
  # allowed (some proxies/managed services): better a skewed stamp than a
@@ -145,12 +158,12 @@ module Cogworker
145
158
 
146
159
  # Keeps every in-flight `until_executed` periodic run's
147
160
  # `periodic:running:<pjid>` lock alive for as long as this process is —
148
- # see Periodic::RunningLock. Reads the same `cogworker:workers:<identity>`
149
- # Hash the Workers tab does, so it covers exactly the jobs actually
150
- # executing here right now.
151
- def touch_running_periodic_locks(conn, identity)
152
- conn.hvals(RedisKeys.workers(identity)).each do |raw|
153
- 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)
154
167
  next unless job.is_a?(Hash) && job['periodic_pjid']
155
168
 
156
169
  Periodic::RunningLock.touch(job['periodic_pjid'], job['jid'], Periodic::RunningLock.active_ttl, conn)
@@ -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
 
@@ -120,8 +132,14 @@ module Cogworker
120
132
  limit ? str[0, limit] : str
121
133
  end
122
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.
123
139
  def error_message(error)
124
140
  safe_string(error.message, ERROR_MESSAGE_LIMIT)
141
+ rescue Exception # rubocop:disable Lint/RescueException
142
+ "#<#{error.class}>"
125
143
  end
126
144
 
127
145
  UNPARSEABLE_CLASS = '(unparseable)'
@@ -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,14 +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: after the whole
16
+ # The first tick waits for the first orphan check that really
17
+ # completed (Scheduled#ready_for_ticker?): after the whole
17
18
  # fleet was down longer than a running `until_executed` job's lock
18
19
  # lives, the ticker would otherwise claim that entry's next slot before
19
20
  # recovery had requeued the interrupted run (and re-taken its lock) —
20
21
  # two runs at once.
21
22
  @ticker = Periodic::Ticker.new(
22
23
  @manager, config.periodic_manager.entries, catch_up: config.periodic_catch_up,
23
- ready: -> { @scheduled.recovered_once? }
24
+ ready: -> { @scheduled.ready_for_ticker? }
24
25
  )
25
26
  @signal_queue = ::Queue.new
26
27
  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
@@ -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}" }
@@ -20,10 +20,14 @@ module Cogworker
20
20
  # Undoes a won claim whose push then failed, so the slot isn't lost:
21
21
  # puts `last_slot` back (only if it still is this slot — a later slot
22
22
  # may have been claimed since) and drops the per-slot NX lock.
23
- # KEYS[1] = last_slot, KEYS[2] = slot lock; ARGV[1] = slot, ARGV[2] =
24
- # previous last_slot ("" if none: then slot - 1, which still lets this
25
- # slot fire, unlike deleting the key — with catch_up off, a missing
26
- # last_slot would be re-primed to this very slot instead).
23
+ # KEYS[1] = last_slot, KEYS[2] = slot lock, KEYS[3] = running lock;
24
+ # ARGV[1] = slot, ARGV[2] = previous last_slot ("" if none: then
25
+ # slot - 1, which still lets this slot fire, unlike deleting the key —
26
+ # with catch_up off, a missing last_slot would be re-primed to this
27
+ # very slot instead), ARGV[3] = the jid the claim took the running lock
28
+ # under. Releasing that lock happens here too, in the same step: as a
29
+ # separate call made first, its failure (Redis down — the usual reason
30
+ # the push failed) skipped the rollback altogether.
27
31
  ROLLBACK_SCRIPT = <<~LUA
28
32
  if redis.call("GET", KEYS[1]) == ARGV[1] then
29
33
  if ARGV[2] == "" then
@@ -33,6 +37,12 @@ module Cogworker
33
37
  end
34
38
  end
35
39
  redis.call("DEL", KEYS[2])
40
+ -- Best-effort (an entry without until_executed has no lock; one of
41
+ -- the wrong type mustn't make an applied rollback count as failed
42
+ -- and be retried forever).
43
+ if redis.pcall("GET", KEYS[3]) == ARGV[3] then
44
+ redis.pcall("DEL", KEYS[3])
45
+ end
36
46
  return 1
37
47
  LUA
38
48
 
@@ -44,10 +54,19 @@ module Cogworker
44
54
  @last_checked_slot = {}
45
55
  @cron_cache = {}
46
56
  @pending_rollbacks = []
57
+ @failures = Hash.new(0) # pjid => consecutive failed ticks
58
+ @retry_at = {} # pjid => monotonic time it may be tried again
47
59
  end
48
60
 
61
+ # Publishing is only for restart survival and the Web UI: its failure
62
+ # (Redis down at boot, the key holding the wrong type) is logged, and
63
+ # must not take the process down with it.
49
64
  def start!
50
- publish_schedule!
65
+ begin
66
+ publish_schedule!
67
+ rescue StandardError => e
68
+ Cogworker.logger.error { "Periodic schedule not published: #{e.class}: #{e.message}" }
69
+ end
51
70
  @thread = Thread.new { run }
52
71
  end
53
72
 
@@ -96,53 +115,114 @@ module Cogworker
96
115
  retry_pending_rollbacks
97
116
  now = Time.now
98
117
  @entries.each do |entry|
99
- slot = cron_for(entry).previous_time(now).to_i
100
- next if @last_checked_slot[entry.pjid] == slot
118
+ next if backing_off?(entry.pjid)
101
119
 
102
- jid = SecureRandom.hex(12)
103
- previous = claim(entry, slot, jid) unless disabled?(entry)
104
- enqueue(entry, slot, jid, previous) if previous
105
- @last_checked_slot[entry.pjid] = slot
120
+ tick_entry(entry, now)
121
+ @failures.delete(entry.pjid)
122
+ rescue StandardError => e
123
+ # Per entry: one entry failing (its keys of the wrong type, its
124
+ # push failing) used to skip every entry after it, on every tick.
125
+ # Its slot isn't recorded as checked, so it's retried — after an
126
+ # exponential pause (`back_off`), not on every 5s tick, which made a
127
+ # deterministically failing entry hammer Redis and the log. Not for
128
+ # a lost connection, though: that's Redis being away, not the
129
+ # entry failing — backing off every entry on it left cron idle for
130
+ # minutes after Redis came back.
131
+ delay = e.is_a?(Redis::BaseConnectionError) ? TICK_INTERVAL : back_off(entry.pjid)
132
+ Cogworker.logger.error { "Periodic tick failed for #{entry.pjid} (next try in #{delay}s): #{e.class}: #{e.message}" }
106
133
  end
107
134
  end
108
135
 
109
- # Web UI "Disable" (`Routes::Schedules`) — skips the claim/enqueue
110
- # step entirely, short-circuiting before `claim` even runs, so
111
- # neither `periodic:last_slot:<pjid>` nor the per-slot lock advance
112
- # while disabled: the Web UI's own "LastRun" column keeps showing the
113
- # last time it *actually* ran, not a due-but-skipped slot, and
114
- # re-enabling doesn't trigger a catch-up burst for every slot that
115
- # was silently skipped in between. `@last_checked_slot` (this one
116
- # Ticker instance's own in-memory dedup, unrelated to the Redis-
117
- # persisted last_slot) still advances either way, exactly as it
118
- # already did before this entry ever had a disabled state — it only
119
- # stops this same tick loop from re-evaluating the same slot twice.
136
+ BACKOFF_MAX = 300
137
+
138
+ def backing_off?(key)
139
+ (at = @retry_at[key]) && ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) < at
140
+ end
141
+
142
+ # 5s, 10s, 20s … up to BACKOFF_MAX; reset by the next success.
143
+ def back_off(key)
144
+ @failures[key] += 1
145
+ delay = [TICK_INTERVAL * (2**(@failures[key] - 1)), BACKOFF_MAX].min
146
+ @retry_at[key] = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + delay
147
+ delay
148
+ end
149
+
150
+ def tick_entry(entry, now)
151
+ slot = cron_for(entry).previous_time(now).to_i
152
+ return if @last_checked_slot[entry.pjid] == slot
153
+
154
+ jid = SecureRandom.hex(12)
155
+ previous = claim(entry, slot, jid) unless disabled?(entry)
156
+ enqueue(entry, slot, jid, previous) if previous
157
+ @last_checked_slot[entry.pjid] = slot
158
+ end
159
+
120
160
  # The usual reason a push fails — Redis unreachable — makes this fail
121
161
  # too; it's then kept and retried at the start of every tick until it
122
162
  # goes through (before that tick looks at the slot again), rather than
123
- # just logged and the slot lost. (A push that timed out *after* Redis
124
- # had applied it is the one case this turns into a second run of the
125
- # slot: indistinguishable from one that never arrived.)
126
- def rollback_claim(entry, slot, previous_slot)
163
+ # just logged and the slot lost. Returns true, or the error. (A push
164
+ # that timed out *after* Redis had applied it is the one case this
165
+ # turns into a second run of the slot: indistinguishable from one that
166
+ # never arrived.) Pending
167
+ # rollbacks live in this process's memory only: one still pending when
168
+ # the process restarts is lost — that slot is skipped, and an
169
+ # `until_executed` entry stays locked until its lock's TTL.
170
+ def rollback_claim(entry, slot, previous_slot, jid)
127
171
  Cogworker.config.redis do |c|
128
- keys = [RedisKeys.periodic_last_slot(entry.pjid), RedisKeys.periodic_lock(entry.pjid, slot)]
129
- LuaScript.run(c, ROLLBACK_SCRIPT, keys: keys, argv: [slot, previous_slot])
172
+ keys = [RedisKeys.periodic_last_slot(entry.pjid), RedisKeys.periodic_lock(entry.pjid, slot),
173
+ RedisKeys.periodic_running(entry.pjid)]
174
+ LuaScript.run(c, ROLLBACK_SCRIPT, keys: keys, argv: [slot, previous_slot, jid])
130
175
  end
131
176
  true
132
177
  rescue StandardError => e
133
178
  Cogworker.logger.error { "periodic #{entry.pjid}: couldn't roll back slot #{slot} (will retry): #{e.class}: #{e.message}" }
134
- @pending_rollbacks << [entry, slot, previous_slot]
135
- false
179
+ @pending_rollbacks << [entry, slot, previous_slot, jid]
180
+ e # (truthy, but not `true`: the caller tells a lost connection from a failing rollback)
136
181
  end
137
182
 
183
+ # With the same backoff as a failing entry (keyed by the rollback): a
184
+ # rollback that keeps failing is retried less and less often, but
185
+ # always before that entry's slot is looked at again.
138
186
  def retry_pending_rollbacks
139
187
  pending = @pending_rollbacks
140
188
  @pending_rollbacks = []
141
- pending.each { |args| rollback_claim(*args) }
189
+ pending.each do |args|
190
+ key = "rollback:#{args[0].pjid}:#{args[1]}"
191
+ next @pending_rollbacks << args if backing_off?(key)
192
+
193
+ result = rollback_claim(*args)
194
+ if result == true
195
+ @failures.delete(key)
196
+ @retry_at.delete(key)
197
+ elsif !result.is_a?(Redis::BaseConnectionError)
198
+ back_off(key) # only a rollback failing on its own backs off; see #tick
199
+ end
200
+ end
142
201
  end
143
202
 
203
+ # Web UI "Disable" (`Routes::Schedules`) — skips the claim/enqueue
204
+ # step entirely, short-circuiting before `claim` even runs, so
205
+ # neither `periodic:last_slot:<pjid>` nor the per-slot lock advance
206
+ # while disabled: the Web UI's own "LastRun" column keeps showing the
207
+ # last time it *actually* ran, not a due-but-skipped slot, and
208
+ # re-enabling doesn't trigger a catch-up burst for every slot that
209
+ # was silently skipped in between. `@last_checked_slot` (this one
210
+ # Ticker instance's own in-memory dedup, unrelated to the Redis-
211
+ # persisted last_slot) still advances either way, exactly as it
212
+ # already did before this entry ever had a disabled state — it only
213
+ # stops this same tick loop from re-evaluating the same slot twice.
214
+ #
215
+ # A disabled-set that can't be read (the key holding the wrong type)
216
+ # counts as "not disabled", logged: it used to fail every entry's tick,
217
+ # stopping all cron. Losing a Disable is the lesser evil. A dropped
218
+ # connection still raises (the tick is then retried as a whole).
144
219
  def disabled?(entry)
145
220
  Cogworker.config.redis { |c| c.sismember(RedisKeys::PERIODIC_DISABLED, entry.pjid) }
221
+ rescue Redis::BaseConnectionError
222
+ raise
223
+ rescue StandardError => e
224
+ Cogworker.logger.error { "Periodic: can't read #{RedisKeys::PERIODIC_DISABLED} (#{e.class}: #{e.message}); treating #{entry.pjid} as enabled" }
225
+ false
146
226
  end
147
227
 
148
228
  def cron_for(entry)
@@ -201,8 +281,7 @@ module Cogworker
201
281
  pushed = begin
202
282
  Client.push(job)
203
283
  rescue StandardError
204
- RunningLock.release(entry.pjid, jid) if entry.until_executed?
205
- rollback_claim(entry, slot, previous_slot)
284
+ rollback_claim(entry, slot, previous_slot, jid)
206
285
  raise
207
286
  end
208
287
  return unless entry.until_executed?