cogworker 0.5.0 → 0.6.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.
@@ -43,16 +43,29 @@ module Cogworker
43
43
  end
44
44
  end
45
45
 
46
+ # The queue listing only feeds the Web UI/stats; failing to update it
47
+ # is logged, never raised — whatever the error, Redis being away
48
+ # included: the job is already queued by then, and raising would make
49
+ # the caller push it a second time (see raw_push).
50
+ def register_queue(conn, queue)
51
+ conn.sadd?(RedisKeys::QUEUES, queue)
52
+ rescue StandardError => e
53
+ RedisErrors.report("#{RedisKeys::QUEUES} not updated", e) { queue }
54
+ end
55
+
46
56
  def raw_push(job)
47
57
  Cogworker.config.redis do |conn|
48
58
  if job['at']
49
59
  conn.zadd(RedisKeys::SCHEDULE, job['at'].to_f, JSON.generate(job))
50
60
  else
51
61
  job['enqueued_at'] = Time.now.to_f
52
- conn.multi do |pipeline|
53
- pipeline.sadd?(RedisKeys::QUEUES, job['queue'])
54
- pipeline.lpush(RedisKeys.queue(job['queue']), JSON.generate(job))
55
- end
62
+ # The push is the one write that matters, and goes first, on its
63
+ # own. They used to share a MULTI — which doesn't roll back: with
64
+ # `cogworker:queues` unwritable the job was queued anyway but the
65
+ # whole push raised, so callers retried it (the cron ticker did,
66
+ # every tick) and queued it again and again.
67
+ conn.lpush(RedisKeys.queue(job['queue']), JSON.generate(job))
68
+ register_queue(conn, job['queue'])
56
69
  end
57
70
  end
58
71
  end
@@ -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, :max_orphanings
12
+ :orphan_threshold, :max_orphanings, :periodic_replace
13
13
  attr_accessor :concurrency, :queues, :periodic_catch_up, :unique_lock_ttl
14
14
 
15
15
  def initialize
@@ -61,7 +61,10 @@ module Cogworker
61
61
  end
62
62
 
63
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?
64
+ unless count.is_a?(Integer) && count.positive?
65
+ raise ArgumentError,
66
+ "max_orphanings must be a positive Integer, got #{count.inspect}"
67
+ end
65
68
 
66
69
  @max_orphanings = count
67
70
  end
@@ -130,7 +133,18 @@ module Cogworker
130
133
  # registers cron entries synchronously as it runs. No-op-safe: an app
131
134
  # that never calls this simply has an empty periodic_manager, and the
132
135
  # Ticker (started later, per process) has nothing to do.
133
- def periodic(&block)
136
+ #
137
+ # `replace: true` clears what earlier calls registered first, and makes
138
+ # the Ticker replace the whole published `periodic:schedule` rather than
139
+ # adding to it — so an entry deleted from (or changed in) the schedule
140
+ # file disappears from the Web UI instead of lingering there forever.
141
+ # Opt-in: with processes that register *different* schedules against one
142
+ # Redis, each would wipe the others' entries off the Schedules tab.
143
+ def periodic(replace: false, &block)
144
+ if replace
145
+ periodic_manager.clear!
146
+ @periodic_replace = true
147
+ end
134
148
  block&.call(periodic_manager)
135
149
  periodic_manager
136
150
  end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cogworker
4
+ # Lock releases that failed (Redis away when a job finished) — kept here
5
+ # and retried on every heartbeat beat (Heartbeat#beat) until they go
6
+ # through, owner-checked like the original release. A failed release is
7
+ # best-effort so it can't fail the job, but left alone it held the lock
8
+ # until its TTL: an `until_executed` cron entry skipped slots for up to
9
+ # `RunningLock.active_ttl`, a unique job blocked its duplicates for up to
10
+ # `unique_lock_ttl`. In this process's memory only: one still pending when
11
+ # the process exits falls back to the lock's TTL.
12
+ module DeferredReleases
13
+ @mutex = Mutex.new
14
+ @pending = {} # lock key => owner jid
15
+
16
+ class << self
17
+ def add(key, owner)
18
+ @mutex.synchronize { @pending[key] = owner }
19
+ end
20
+
21
+ # Retries every pending release; stops at the first one Redis can't
22
+ # serve (no point hammering it), keeps whatever's left.
23
+ def retry_all(conn)
24
+ pending.each do |key, owner|
25
+ OwnedKey.delete(key, owner, conn) # (a no-op if it lapsed or changed hands meanwhile)
26
+ @mutex.synchronize { @pending.delete(key) if @pending[key] == owner }
27
+ rescue StandardError => e
28
+ raise if RedisErrors.unavailable?(e)
29
+
30
+ # Anything else (the key holding the wrong type, say) won't get
31
+ # better by retrying every 5s: dropped, logged once.
32
+ @mutex.synchronize { @pending.delete(key) if @pending[key] == owner }
33
+ Cogworker.logger.error { "deferred release of #{key} dropped: #{e.class}: #{e.message}" }
34
+ end
35
+ end
36
+
37
+ def pending
38
+ @mutex.synchronize { @pending.dup }
39
+ end
40
+
41
+ def reset!
42
+ @mutex.synchronize { @pending.clear }
43
+ end
44
+ end
45
+ end
46
+ end
@@ -99,9 +99,12 @@ module Cogworker
99
99
 
100
100
  def beat_safely
101
101
  beat
102
+ @beat_failed = false
103
+ RedisErrors.recovered('Heartbeat')
102
104
  true
103
105
  rescue StandardError => e
104
- Cogworker.logger.error { "Heartbeat failed: #{e.class}: #{e.message}" }
106
+ @beat_failed = true
107
+ RedisErrors.report('Heartbeat', e)
105
108
  false
106
109
  end
107
110
 
@@ -128,21 +131,43 @@ module Cogworker
128
131
  'busy', @manager.busy_count.to_s,
129
132
  'quiet', @manager.quiet?.to_s)
130
133
  c.expire(RedisKeys.process(identity), TTL)
131
- secondary('process registry') { c.sadd?(RedisKeys::PROCESSES, identity) }
132
- secondary('workers expiry') { c.expire(RedisKeys.workers(identity), TTL) }
133
- if reliable
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)) }
136
- end
134
+ write_registries(c, identity, reliable)
137
135
  touch_running_periodic_locks(c, identity)
136
+ # Not via #secondary: Redis failing here, after the presence key went
137
+ # through, doesn't make this beat a failed one.
138
+ BestEffort.call('Deferred lock releases') { DeferredReleases.retry_all(c) }
138
139
  end
139
140
  end
140
141
 
142
+ # Everything a beat writes besides the presence key — each on its own,
143
+ # best-effort (see #secondary).
144
+ def write_registries(conn, identity, reliable)
145
+ secondary('process registry') { conn.sadd?(RedisKeys::PROCESSES, identity) }
146
+ # The queues this process reads are listed even while empty: the Web
147
+ # UI shows them, and recovery can tell a queue nobody reads from one
148
+ # that's merely had nothing pushed yet.
149
+ secondary('queue registry') { conn.sadd(RedisKeys::QUEUES, @manager.queues.uniq) }
150
+ secondary('live queue registry') { mark_queues_live(conn) }
151
+ secondary('workers expiry') { conn.expire(RedisKeys.workers(identity), TTL) }
152
+ return unless reliable
153
+
154
+ secondary('in-progress registry') { conn.sadd?(RedisKeys::IN_PROGRESS_IDENTITIES, identity) }
155
+ secondary('last-beat stamp') { conn.hset(RedisKeys::LAST_BEAT, identity, redis_now(conn)) }
156
+ end
157
+
158
+ # Stamps this process's queues as read right now, and drops ones no
159
+ # process has stamped for a day (only ever a listing, never data).
160
+ def mark_queues_live(conn)
161
+ now = redis_now(conn)
162
+ conn.zadd(RedisKeys::LIVE_QUEUES, @manager.queues.uniq.map { |q| [now, q] })
163
+ conn.zremrangebyscore(RedisKeys::LIVE_QUEUES, '-inf', now - 86_400)
164
+ end
165
+
141
166
  def secondary(what)
142
167
  yield
143
- rescue Redis::BaseConnectionError
144
- raise # Redis itself is gone: the beat as a whole failed
145
168
  rescue StandardError => e
169
+ raise if RedisErrors.unavailable?(e) # Redis itself is away: the beat as a whole failed
170
+
146
171
  Cogworker.logger.error { "Heartbeat: #{what} not written: #{e.class}: #{e.message}" }
147
172
  end
148
173
 
@@ -161,12 +186,30 @@ module Cogworker
161
186
  # see Periodic::RunningLock. From the Manager's own in-memory record of
162
187
  # what's running here, not `cogworker:workers` in Redis: a failover that
163
188
  # lost that Hash would otherwise let a long run's lock lapse mid-run.
189
+ # (One narrow race remains: a release landing between this beat's
190
+ # snapshot of `performing_jobs` and its Lua call, on the first beat
191
+ # after a failed one, can be undone — the lock then lapses on
192
+ # `active_ttl`. Not worth a cross-thread lock on every beat.)
193
+ # Only jobs still inside `perform` (`performing_jobs`): once it returns,
194
+ # Periodic::ReleaseMiddleware has released the lock, and refreshing it
195
+ # again would bring it back. A missing lock is only *re-taken* on the
196
+ # first beat after a failed one — i.e. after Redis was away, possibly
197
+ # long enough for the lock to lapse; otherwise a missing lock means it
198
+ # was released, and stays released.
164
199
  def touch_running_periodic_locks(conn, _identity)
165
- @manager.running_jobs.each do |raw|
200
+ recovering = @beat_failed
201
+ @manager.performing_jobs.each do |raw|
166
202
  job = JSON.parse(raw)
167
203
  next unless job.is_a?(Hash) && job['periodic_pjid']
168
204
 
169
- Periodic::RunningLock.touch(job['periodic_pjid'], job['jid'], Periodic::RunningLock.active_ttl, conn)
205
+ if recovering && job['periodic_until_executed']
206
+ # Re-taken, not just extended: after Redis was away longer than
207
+ # `active_ttl`, the lock is gone while the run is still going, and
208
+ # the ticker could start the next slot alongside it.
209
+ Periodic::RunningLock.retake(job['periodic_pjid'], job['jid'], Periodic::RunningLock.active_ttl, conn)
210
+ else
211
+ Periodic::RunningLock.touch(job['periodic_pjid'], job['jid'], Periodic::RunningLock.active_ttl, conn)
212
+ end
170
213
  rescue StandardError => e
171
214
  # Per entry: one bad entry mustn't leave the others' locks to expire.
172
215
  Cogworker.logger.error { "couldn't refresh periodic lock for #{raw.to_s[0, 200]}: #{e.class}: #{e.message}" }
@@ -199,6 +242,7 @@ module Cogworker
199
242
  def subscribe_loop
200
243
  @subscribe_client = ::Redis.new(RedisConnection.client_options(Cogworker.config.redis_options))
201
244
  @subscribe_client.subscribe(RedisKeys.signal(Cogworker.identity)) do |on|
245
+ on.subscribe { |*| RedisErrors.recovered('Signal subscriber') }
202
246
  on.message do |_channel, message|
203
247
  dispatch(message)
204
248
  end
@@ -206,7 +250,7 @@ module Cogworker
206
250
  rescue StandardError => e
207
251
  return if @stopping
208
252
 
209
- Cogworker.logger.error { "Signal subscriber died: #{e.class}: #{e.message}" }
253
+ RedisErrors.report('Signal subscriber', e)
210
254
  sleep(1)
211
255
  retry
212
256
  end
@@ -5,13 +5,17 @@ module Cogworker
5
5
  # Server middleware: times every run and records it via Storage,
6
6
  # success or failure, regardless of what other middleware/worker code does.
7
7
  class Middleware
8
+ # Recording is best-effort (BestEffort): only the job's own exception
9
+ # is re-raised, never one from writing its history.
8
10
  def call(_worker, job, queue)
9
11
  started_at = Time.now.to_f
10
- yield
11
- Storage.record(job, queue, started_at, Time.now.to_f, 'success')
12
- rescue Exception => e # rubocop:disable Lint/RescueException
13
- Storage.record(job, queue, started_at, Time.now.to_f, 'failed', error: e)
14
- raise e
12
+ begin
13
+ yield
14
+ rescue Exception => e # rubocop:disable Lint/RescueException
15
+ BestEffort.call('History') { Storage.record(job, queue, started_at, Time.now.to_f, 'failed', error: e) }
16
+ raise e
17
+ end
18
+ BestEffort.call('History') { Storage.record(job, queue, started_at, Time.now.to_f, 'success') }
15
19
  end
16
20
  end
17
21
  end
@@ -142,6 +142,23 @@ module Cogworker
142
142
  "#<#{error.class}>"
143
143
  end
144
144
 
145
+ # A job on its way to dead outside the usual path (no server middleware
146
+ # around it to do this): releases its `until_executed` locks, owner-
147
+ # checked, each on its own — so the entry / the unique key isn't blocked
148
+ # until the locks' TTL. Best-effort.
149
+ def release_terminal_locks(conn, job)
150
+ keys = []
151
+ keys << RedisKeys.unique_lock(UniqueJobs.digest(job)) if UniqueJobs.until_executed?(job)
152
+ keys << RedisKeys.periodic_running(job['periodic_pjid']) if job['periodic_pjid']
153
+ keys.each do |key|
154
+ released = BestEffort.call('Lock release') do
155
+ OwnedKey.delete(key, job['jid'], conn)
156
+ true
157
+ end
158
+ DeferredReleases.add(key, job['jid']) unless released
159
+ end
160
+ end
161
+
145
162
  UNPARSEABLE_CLASS = '(unparseable)'
146
163
 
147
164
  # A payload that isn't a JSON object can't be run, retried or even
@@ -22,6 +22,7 @@ module Cogworker
22
22
  RedisKeys::QUEUES => 'set', RedisKeys::PROCESSES => 'set', RedisKeys::PAUSED_QUEUES => 'set',
23
23
  RedisKeys::IN_PROGRESS_IDENTITIES => 'set', RedisKeys::LAST_BEAT => 'hash',
24
24
  RedisKeys::REPEAT_ORPHANS => 'list', RedisKeys::UNSETTLED => 'list', RedisKeys::PERIODIC_SCHEDULE => 'hash',
25
+ RedisKeys::LIVE_QUEUES => 'zset',
25
26
  RedisKeys::PERIODIC_DISABLED => 'set', RedisKeys::STATS_PROCESSED => 'string',
26
27
  RedisKeys::STATS_FAILED => 'string'
27
28
  }.freeze
@@ -65,9 +66,41 @@ module Cogworker
65
66
  types[RedisKeys.process(id)] = 'hash'
66
67
  types[RedisKeys.workers(id)] = 'hash'
67
68
  end
69
+ readable_fields(RedisKeys::PERIODIC_SCHEDULE).each do |pjid|
70
+ types[RedisKeys.periodic_running(pjid)] = 'string'
71
+ types[RedisKeys.periodic_last_slot(pjid)] = 'string'
72
+ end
68
73
  types
69
74
  end
70
75
 
76
+ def readable_fields(hash)
77
+ Cogworker.config.redis { |c| c.hkeys(hash) }
78
+ rescue Redis::CommandError
79
+ []
80
+ end
81
+
82
+ MAX_COPIES = 5
83
+
84
+ # Keeps the newest MAX_COPIES quarantined copies of one key: something
85
+ # that keeps writing the wrong type would otherwise leave one more every
86
+ # CHECK_INTERVAL, for QUARANTINE_TTL. (A SCAN, but only on the rare
87
+ # occasion a key actually gets quarantined.)
88
+ def prune_copies(key)
89
+ prefix = "#{QUARANTINE_PREFIX}#{key}:"
90
+ exact = /\A#{Regexp.escape(prefix)}(\d+):\h{8}\z/ # this key's copies only — not `<key>:<more>:...`'s
91
+ Cogworker.config.redis do |c|
92
+ copies = c.scan_each(match: "#{glob_escape(prefix)}*").select { |name| name.match?(exact) }
93
+ stale = copies.sort_by { |name| name[exact, 1].to_i }.reverse.drop(MAX_COPIES)
94
+ c.del(*stale) unless stale.empty?
95
+ end
96
+ rescue StandardError => e
97
+ Cogworker.logger.error { "pruning quarantined copies of #{key} failed: #{e.class}: #{e.message}" }
98
+ end
99
+
100
+ def glob_escape(text)
101
+ text.gsub(/[*?\[\]\\]/) { |ch| "\\#{ch}" }
102
+ end
103
+
71
104
  def readable_members(set)
72
105
  Cogworker.config.redis { |c| c.smembers(set) }
73
106
  rescue Redis::CommandError
@@ -77,12 +110,14 @@ module Cogworker
77
110
  def quarantine(key, expected, actual)
78
111
  # Unique per move: a second quarantine of the same key within the same
79
112
  # second must not overwrite (RENAME) the first one's copy.
80
- target = "#{QUARANTINE_PREFIX}#{key}:#{Time.now.to_i}:#{SecureRandom.hex(4)}"
113
+ # Milliseconds, so copies made within one second still sort by age.
114
+ target = "#{QUARANTINE_PREFIX}#{key}:#{(Time.now.to_f * 1000).to_i}:#{SecureRandom.hex(4)}"
81
115
  moved = Cogworker.config.redis do |c|
82
116
  LuaScript.run(c, QUARANTINE_SCRIPT, keys: [key, target], argv: [expected, QUARANTINE_TTL])
83
117
  end
84
118
  return false unless moved == 1
85
119
 
120
+ prune_copies(key)
86
121
  Cogworker.logger.error do
87
122
  "#{key} held a #{actual}, not a #{expected}: moved to #{target} (kept #{QUARANTINE_TTL / 86_400} days)"
88
123
  end
@@ -21,6 +21,7 @@ module Cogworker
21
21
  # two runs at once.
22
22
  @ticker = Periodic::Ticker.new(
23
23
  @manager, config.periodic_manager.entries, catch_up: config.periodic_catch_up,
24
+ replace: config.periodic_replace,
24
25
  ready: -> { @scheduled.ready_for_ticker? }
25
26
  )
26
27
  @signal_queue = ::Queue.new
@@ -45,6 +46,9 @@ module Cogworker
45
46
 
46
47
  def stop!
47
48
  @manager.stop!
49
+ # Last chance for releases deferred by a recent outage — they live in
50
+ # this process's memory and would otherwise wait out their locks' TTL.
51
+ BestEffort.call('Deferred lock releases') { @config.redis { |c| DeferredReleases.retry_all(c) } }
48
52
  @scheduled.stop!
49
53
  @ticker.stop!
50
54
  @heartbeat.stop!
@@ -9,8 +9,8 @@ module Cogworker
9
9
  # restarted or failed-over server, SCRIPT FLUSH). Every script in this
10
10
  # gem goes through here.
11
11
  module LuaScript
12
- SHAS = {} # source => sha1; memoized, the source strings are frozen constants
13
- SHAS_MUTEX = Mutex.new
12
+ @shas = {} # source => sha1; memoized, the sources are frozen constants
13
+ @shas_mutex = Mutex.new
14
14
 
15
15
  module_function
16
16
 
@@ -24,7 +24,7 @@ module Cogworker
24
24
  end
25
25
 
26
26
  def sha(source)
27
- SHAS_MUTEX.synchronize { SHAS[source] ||= Digest::SHA1.hexdigest(source) }
27
+ @shas_mutex.synchronize { @shas[source] ||= Digest::SHA1.hexdigest(source) }
28
28
  end
29
29
  end
30
30
  end
@@ -16,6 +16,7 @@ module Cogworker
16
16
  # be what's failing (or what lost its last writes in a failover).
17
17
  @jobs_mutex = Mutex.new
18
18
  @running_jobs = Hash.new(0)
19
+ @performing_jobs = Hash.new(0) # the subset still inside `perform`
19
20
  @pending_settlements = {}
20
21
  @processors = Array.new(config.concurrency) { Processor.new(self) }
21
22
  @quiet = false
@@ -74,7 +75,24 @@ module Cogworker
74
75
  end
75
76
 
76
77
  def job_started(raw)
77
- @jobs_mutex.synchronize { @running_jobs[raw] += 1 }
78
+ @jobs_mutex.synchronize do
79
+ @running_jobs[raw] += 1
80
+ @performing_jobs[raw] += 1
81
+ end
82
+ end
83
+
84
+ # `perform` (with its middleware) has returned: the job is still this
85
+ # process's until settled, but its locks are no longer its to keep
86
+ # alive — Heartbeat stops refreshing them (see `performing_jobs`).
87
+ def job_performed(raw)
88
+ @jobs_mutex.synchronize do
89
+ @performing_jobs[raw] -= 1
90
+ @performing_jobs.delete(raw) unless @performing_jobs[raw].positive?
91
+ end
92
+ end
93
+
94
+ def performing_jobs
95
+ @jobs_mutex.synchronize { @performing_jobs.keys }
78
96
  end
79
97
 
80
98
  def job_finished(raw)
@@ -10,7 +10,7 @@
10
10
  -- Returns {1, <previous last_slot, or "" if none>} if this call won the
11
11
  -- claim for this slot (the caller should enqueue the job — and, if that
12
12
  -- fails, roll the claim back with ROLLBACK_SCRIPT using that previous
13
- -- value), 0 otherwise. Composes two guards: `last_slot` stops a
13
+ -- value), 2 if blocked by the entry's running lock, 0 otherwise. Composes two guards: `last_slot` stops a
14
14
  -- process re-firing the same (or an earlier) slot on a later tick, and the
15
15
  -- NX lock breaks the narrow race where two processes both pass the
16
16
  -- `last_slot` check before either has written it back.
@@ -18,8 +18,11 @@
18
18
  -- For `until_executed`, the running lock is written here, in the same
19
19
  -- atomic step as the claim and before the job exists anywhere — see
20
20
  -- Periodic::RunningLock for why it must never be written after the push.
21
+ -- Blocked by a run still holding the entry's lock: a distinct 2, so the
22
+ -- caller can retry the slot briefly — the lock may only be waiting for a
23
+ -- release delayed by an outage (see Ticker::BLOCKED_GRACE).
21
24
  if ARGV[2] == "until_executed" and redis.call("GET", KEYS[1]) then
22
- return 0
25
+ return 2
23
26
  end
24
27
 
25
28
  local previous = redis.call("GET", KEYS[2])
@@ -23,6 +23,11 @@ module Cogworker
23
23
  @entries << entry
24
24
  entry
25
25
  end
26
+
27
+ # In place: Launcher hands this same `entries` array to the Ticker.
28
+ def clear!
29
+ @entries.clear
30
+ end
26
31
  end
27
32
  end
28
33
  end
@@ -23,14 +23,25 @@ module Cogworker
23
23
  begin
24
24
  yield
25
25
  rescue Exception => e # rubocop:disable Lint/RescueException
26
- RunningLock.release(pjid, job['jid']) if JobUtil.terminal_failure?(job)
26
+ release(pjid, job['jid']) if JobUtil.terminal_failure?(job)
27
27
  raise e
28
28
  end
29
- RunningLock.release(pjid, job['jid'])
29
+ release(pjid, job['jid'])
30
30
  end
31
31
 
32
32
  private
33
33
 
34
+ # Best-effort — a release that fails (Redis away) never turns the
35
+ # finished run into a failure — and retried later (DeferredReleases)
36
+ # rather than left to the lock's TTL.
37
+ def release(pjid, jid)
38
+ released = BestEffort.call('Periodic lock') do
39
+ RunningLock.release(pjid, jid)
40
+ true
41
+ end
42
+ DeferredReleases.add(RedisKeys.periodic_running(pjid), jid) unless released
43
+ end
44
+
34
45
  # Best-effort: failing here must not stop the job from running (it
35
46
  # would be routed to retry/dead as if it had failed). If it does fail,
36
47
  # the lock just keeps its longer queued TTL until this process's next
@@ -61,6 +61,18 @@ module Cogworker
61
61
  OwnedKey.expire(RedisKeys.periodic_running(pjid), jid, ttl, conn)
62
62
  end
63
63
 
64
+ RETAKE_SCRIPT = RETAKE_FUNCTION + <<~LUA
65
+ retake_running_lock(KEYS[1], ARGV[1], ARGV[2])
66
+ return 1
67
+ LUA
68
+
69
+ # Extends the lock if still `jid`'s, takes it if it's gone, leaves it
70
+ # if another run holds it — for a run still going whose lock may have
71
+ # lapsed (Redis away longer than `active_ttl`).
72
+ def retake(pjid, jid, ttl, conn)
73
+ LuaScript.run(conn, RETAKE_SCRIPT, keys: [RedisKeys.periodic_running(pjid)], argv: [jid, ttl.to_f.ceil])
74
+ end
75
+
64
76
  def release(pjid, jid, conn = nil)
65
77
  OwnedKey.delete(RedisKeys.periodic_running(pjid), jid, conn)
66
78
  end