cogworker 0.2.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/lib/cogworker/basic_fetch.rb +60 -2
  3. data/lib/cogworker/cli.rb +11 -1
  4. data/lib/cogworker/client.rb +1 -1
  5. data/lib/cogworker/config.rb +54 -1
  6. data/lib/cogworker/heartbeat.rb +79 -7
  7. data/lib/cogworker/job.rb +9 -0
  8. data/lib/cogworker/job_util.rb +61 -0
  9. data/lib/cogworker/launcher.rb +38 -1
  10. data/lib/cogworker/logging.rb +4 -0
  11. data/lib/cogworker/manager.rb +66 -4
  12. data/lib/cogworker/owned_key.rb +40 -0
  13. data/lib/cogworker/periodic/claim.lua +16 -4
  14. data/lib/cogworker/periodic/release_middleware.rb +31 -13
  15. data/lib/cogworker/periodic/running_lock.rb +69 -0
  16. data/lib/cogworker/periodic/ticker.rb +78 -13
  17. data/lib/cogworker/process.rb +2 -2
  18. data/lib/cogworker/process_set.rb +1 -1
  19. data/lib/cogworker/processor.rb +144 -14
  20. data/lib/cogworker/queue.rb +4 -4
  21. data/lib/cogworker/redis_keys.rb +9 -0
  22. data/lib/cogworker/reliable_fetch/unsupported_error.rb +11 -0
  23. data/lib/cogworker/reliable_fetch.rb +225 -0
  24. data/lib/cogworker/scheduled.rb +58 -10
  25. data/lib/cogworker/stats.rb +1 -1
  26. data/lib/cogworker/status.rb +5 -1
  27. data/lib/cogworker/swarm.rb +65 -4
  28. data/lib/cogworker/testing/empty_queue_error.rb +9 -0
  29. data/lib/cogworker/testing.rb +27 -3
  30. data/lib/cogworker/unique_jobs/client_middleware.rb +13 -4
  31. data/lib/cogworker/unique_jobs/release_middleware.rb +4 -1
  32. data/lib/cogworker/version.rb +1 -1
  33. data/lib/cogworker/web/layout.rb +3 -3
  34. data/lib/cogworker/web/optional_session.rb +25 -0
  35. data/lib/cogworker/web/routes/jobs.rb +72 -37
  36. data/lib/cogworker/web/routes/overview.rb +6 -12
  37. data/lib/cogworker/web/routes/schedules.rb +2 -2
  38. data/lib/cogworker/web.rb +36 -10
  39. data/lib/cogworker.rb +16 -1
  40. metadata +7 -1
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest/sha1'
4
+
5
+ module Cogworker
6
+ # The default fetch (`config.fetch = :reliable`): every job is moved
7
+ # atomically (`LMOVE`, Redis >= 6.2) from its queue onto this process's own
8
+ # `cogworker:inprogress:<identity>` list, and only removed from there
9
+ # (`acknowledge`) once it has finished — succeeded, or been routed to
10
+ # retry/dead. A job is therefore never only in a processor's memory: if the
11
+ # process dies mid-job (OOM, SIGKILL, a host going away), the job is still
12
+ # on that list, and `recover_orphans` (run by every live process's
13
+ # `Scheduled` poller) puts it back on its queue once the dead process has
14
+ # gone `config.orphan_threshold` (default 5 min) without a heartbeat. The trade-off is at-least-once delivery: a job
15
+ # that was partly done when its process died runs again from the start.
16
+ #
17
+ # `LMOVE` can only take from one list at a time, so there's no blocking
18
+ # wait across several weighted queues the way `BasicFetch`'s single
19
+ # `BRPOP` has: instead, one round trip (FETCH_SCRIPT) tries the queues in
20
+ # weighted-shuffled order and takes the first job it finds, and an empty
21
+ # round sleeps EMPTY_POLL_INTERVAL before the next. Weighting and
22
+ # `Queue#pause!` behave exactly as in BasicFetch.
23
+ class ReliableFetch < BasicFetch
24
+ # The first pause after an empty round; each further empty round in a
25
+ # row doubles it, up to `config.fetch_idle_max_interval` (default 1s),
26
+ # and finding a job resets it. That cap is the worst-case pickup delay
27
+ # on an idle process — the trade-off against how often idle processors
28
+ # poll Redis (at 0.25s flat, 4 calls/s per thread).
29
+ EMPTY_POLL_INTERVAL = 0.25
30
+ MIN_REDIS_VERSION = Gem::Version.new('6.2')
31
+
32
+ # KEYS[1..n-1] = queue keys, in the order to try; KEYS[n] = in-progress list.
33
+ FETCH_SCRIPT = <<~LUA
34
+ local dest = KEYS[#KEYS]
35
+ for i = 1, #KEYS - 1 do
36
+ local job = redis.call("LMOVE", KEYS[i], dest, "RIGHT", "LEFT")
37
+ if job then
38
+ return {KEYS[i], job}
39
+ end
40
+ end
41
+ return false
42
+ LUA
43
+
44
+ # The hot path runs this by hash (EVALSHA), not by shipping the whole
45
+ # script text on every poll; see #run_fetch_script.
46
+ FETCH_SCRIPT_SHA = Digest::SHA1.hexdigest(FETCH_SCRIPT)
47
+
48
+ # KEYS[1] = an in-progress list; ARGV[1] = queue key prefix; ARGV[2] =
49
+ # queue for a payload whose own `queue` can't be read (the Processor
50
+ # then buries it as unparseable); ARGV[3] = periodic running-lock key
51
+ # prefix, ARGV[4] = its queued TTL. Oldest first, each onto the end its
52
+ # queue is popped from, so recovered jobs run next, in their original
53
+ # order. Atomic per call: two processes recovering the same list can't
54
+ # both move one job. A job from an `until_executed` periodic entry
55
+ # re-takes its running lock on the way (same rule as
56
+ # Periodic::RunningLock::RETAKE_FUNCTION), so the ticker can't start
57
+ # the next slot alongside the recovered run.
58
+ REQUEUE_SCRIPT = Periodic::RunningLock::RETAKE_FUNCTION + <<~LUA
59
+ local moved = 0
60
+ while true do
61
+ local job = redis.call("LPOP", KEYS[1])
62
+ if not job then
63
+ return moved
64
+ end
65
+ local queue = ARGV[2]
66
+ local ok, decoded = pcall(cjson.decode, job)
67
+ if ok and type(decoded) == "table" then
68
+ if type(decoded["queue"]) == "string" then
69
+ queue = decoded["queue"]
70
+ end
71
+ local pjid, jid = decoded["periodic_pjid"], decoded["jid"]
72
+ if decoded["periodic_until_executed"] == true and type(pjid) == "string" and type(jid) == "string" then
73
+ retake_running_lock(ARGV[3] .. pjid, jid, ARGV[4])
74
+ end
75
+ end
76
+ redis.call("RPUSH", ARGV[1] .. queue, job)
77
+ moved = moved + 1
78
+ end
79
+ LUA
80
+
81
+ # KEYS[1] = in-progress list, KEYS[2] = the job's queue; ARGV[1] = job.
82
+ GIVE_BACK_SCRIPT = <<~LUA
83
+ if redis.call("LREM", KEYS[1], 1, ARGV[1]) == 1 then
84
+ redis.call("RPUSH", KEYS[2], ARGV[1])
85
+ end
86
+ LUA
87
+
88
+ class << self
89
+ def supported?
90
+ version = Cogworker.config.redis { |c| c.info('server')['redis_version'] }
91
+ Gem::Version.new(version) >= MIN_REDIS_VERSION
92
+ rescue StandardError
93
+ true # can't tell (Redis not reachable yet): assume a current server
94
+ end
95
+
96
+ # Puts every job still on `identity`'s in-progress list back on its
97
+ # queue; returns how many. Used for this process's own unfinished jobs
98
+ # on shutdown (`Manager#stop!`) and for dead processes' lists.
99
+ def requeue_in_progress(identity)
100
+ Cogworker.config.redis do |c|
101
+ c.eval(REQUEUE_SCRIPT, keys: [RedisKeys.in_progress(identity)],
102
+ argv: [RedisKeys::QUEUE_PREFIX, 'default', RedisKeys.periodic_running(''),
103
+ Periodic::RunningLock.queued_ttl])
104
+ end
105
+ end
106
+
107
+ # Requeues the in-progress list of every process that has gone
108
+ # `config.orphan_threshold` without a heartbeat — i.e. that died
109
+ # without a clean shutdown (see #dead?). A process always
110
+ # writes its heartbeat before its first fetch (`Heartbeat#start!`), so
111
+ # a live process's list is never mistaken for an orphan. Walks
112
+ # IN_PROGRESS_IDENTITIES (each reliable-fetch process registers itself
113
+ # there on every beat) rather than SCANning the keyspace; `scan: true`
114
+ # (once per process, at boot) also picks up lists from before that
115
+ # set existed.
116
+ def recover_orphans(scan: false)
117
+ own = Cogworker.identity
118
+ candidate_identities(scan: scan).sum do |identity|
119
+ next 0 if identity == own || !dead?(identity)
120
+
121
+ requeue_in_progress(identity).tap do |moved|
122
+ forget_if_empty(identity)
123
+ next unless moved.positive?
124
+
125
+ Cogworker.logger.warn { "requeued #{moved} job(s) left in progress by dead process #{identity}" }
126
+ end
127
+ end
128
+ end
129
+
130
+ # Its presence key gone (no beat for Heartbeat::TTL) is not enough on
131
+ # its own: a live process that just couldn't beat for a minute (a
132
+ # Redis outage or failover, a long GVL-holding call) would lose its
133
+ # in-progress jobs to a second, parallel run. It also has to have gone
134
+ # `orphan_threshold` since its last recorded beat — compared on
135
+ # Redis's clock on both sides, so host clock skew doesn't matter. No
136
+ # recorded beat at all (a list from before LAST_BEAT existed) falls
137
+ # back to the presence key alone.
138
+ def dead?(identity)
139
+ Cogworker.config.redis do |c|
140
+ next false if c.exists?(RedisKeys.process(identity))
141
+
142
+ last = c.hget(RedisKeys::LAST_BEAT, identity)
143
+ last.nil? || c.time.first - last.to_f > Cogworker.config.orphan_threshold
144
+ end
145
+ end
146
+
147
+ def candidate_identities(scan:)
148
+ Cogworker.config.redis do |c|
149
+ identities = c.smembers(RedisKeys::IN_PROGRESS_IDENTITIES)
150
+ next identities unless scan
151
+
152
+ scanned = c.scan_each(match: "#{RedisKeys::IN_PROGRESS_PREFIX}*").map do |key|
153
+ key.delete_prefix(RedisKeys::IN_PROGRESS_PREFIX)
154
+ end
155
+ identities | scanned
156
+ end
157
+ end
158
+
159
+ def forget_if_empty(identity)
160
+ Cogworker.config.redis do |c|
161
+ next unless c.llen(RedisKeys.in_progress(identity)).zero?
162
+
163
+ c.srem?(RedisKeys::IN_PROGRESS_IDENTITIES, identity)
164
+ c.hdel(RedisKeys::LAST_BEAT, identity)
165
+ end
166
+ end
167
+ end
168
+
169
+ def retrieve_work
170
+ keys = active_keys
171
+ if keys.empty?
172
+ sleep(TIMEOUT)
173
+ return nil
174
+ end
175
+
176
+ result = run_fetch_script(keys.shuffle.uniq + [RedisKeys.in_progress(Cogworker.identity)])
177
+ unless result
178
+ idle_sleep
179
+ return nil
180
+ end
181
+
182
+ @idle_interval = nil
183
+ queue_key, raw_job = result
184
+ UnitOfWork.new(queue_key.delete_prefix(RedisKeys::QUEUE_PREFIX), raw_job)
185
+ end
186
+
187
+ # Atomically moves a fetched-but-unstarted job off the in-progress list
188
+ # and back to the end of its queue that's popped next. A no-op if it's
189
+ # no longer in progress (already requeued by `requeue_in_progress`).
190
+ def give_back(work)
191
+ Cogworker.config.redis do |c|
192
+ c.eval(GIVE_BACK_SCRIPT, keys: [RedisKeys.in_progress(Cogworker.identity), RedisKeys.queue(work.queue)],
193
+ argv: [work.raw_job])
194
+ end
195
+ end
196
+
197
+ def acknowledge(work)
198
+ Cogworker.config.redis { |c| c.lrem(RedisKeys.in_progress(Cogworker.identity), 1, work.raw_job) }
199
+ end
200
+
201
+ private
202
+
203
+ def run_fetch_script(keys)
204
+ Cogworker.config.redis do |c|
205
+ c.evalsha(FETCH_SCRIPT_SHA, keys: keys)
206
+ rescue Redis::CommandError => e
207
+ raise unless e.message.start_with?('NOSCRIPT')
208
+
209
+ c.eval(FETCH_SCRIPT, keys: keys) # loads it into the script cache for next time
210
+ end
211
+ rescue Redis::CommandError => e
212
+ raise unless e.message.match?(/unknown (redis )?command/i)
213
+
214
+ raise UnsupportedError, e.message
215
+ end
216
+
217
+ # Jittered (75–100% of the interval) so a process's threads, all idle
218
+ # at once, don't keep polling in lockstep.
219
+ def idle_sleep
220
+ max = Cogworker.config.fetch_idle_max_interval
221
+ @idle_interval = @idle_interval ? [@idle_interval * 2, max].min : [EMPTY_POLL_INTERVAL, max].min
222
+ sleep(@idle_interval * (0.75 + (rand * 0.25)))
223
+ end
224
+ end
225
+ end
@@ -11,6 +11,29 @@ module Cogworker
11
11
  class Scheduled
12
12
  SETS = [RedisKeys::SCHEDULE, RedisKeys::RETRY].freeze
13
13
  POLL_INTERVAL = 5
14
+ # How often this poller also looks for jobs left in progress by a dead
15
+ # process (ReliableFetch.recover_orphans) — the first time right away,
16
+ # at boot. Every live process does it, whatever its own fetch mode: in
17
+ # a mixed fleet, or after a runtime fall-back to `:basic`, the lists of
18
+ # crashed reliable processes still need someone to requeue them, and
19
+ # with nothing registered it's a single SMEMBERS. Only the one-off
20
+ # keyspace SCAN (for lists from before IN_PROGRESS_IDENTITIES existed)
21
+ # is limited to processes actually using ReliableFetch. The requeue is
22
+ # atomic, so any number of processes doing it at once is safe.
23
+ ORPHAN_CHECK_INTERVAL = 60
24
+
25
+ # KEYS[1] = the ZSET, KEYS[2] = cogworker:dead, KEYS[3] = stats:failed;
26
+ # ARGV[1] = the entry, ARGV[2] = its dead-set score, ARGV[3] = the
27
+ # wrapper to store. Same claim-and-move-atomically shape as
28
+ # JobUtil::CLAIM_AND_REQUEUE_SCRIPT.
29
+ BURY_SCRIPT = <<~LUA
30
+ if redis.call("ZREM", KEYS[1], ARGV[1]) == 0 then
31
+ return 0
32
+ end
33
+ redis.call("ZADD", KEYS[2], ARGV[2], ARGV[3])
34
+ redis.call("INCR", KEYS[3])
35
+ return 1
36
+ LUA
14
37
 
15
38
  def initialize(manager)
16
39
  @manager = manager
@@ -34,13 +57,28 @@ module Cogworker
34
57
 
35
58
  private
36
59
 
60
+ # A failed poll is logged and retried next interval rather than ending
61
+ # the thread — a single Redis blip used to stop every scheduled/retry
62
+ # job in this process from ever reaching its queue again.
37
63
  def run
38
64
  until @manager.stopping?
39
- enqueue_due_jobs unless @manager.quiet?
65
+ begin
66
+ recover_orphans_if_due
67
+ enqueue_due_jobs unless @manager.quiet?
68
+ rescue StandardError => e
69
+ Cogworker.logger.error { "Scheduled poll failed: #{e.class}: #{e.message}" }
70
+ end
40
71
  sleep(POLL_INTERVAL)
41
72
  end
42
- rescue StandardError => e
43
- Cogworker.logger.error { "Scheduled poller died: #{e.class}: #{e.message}" }
73
+ end
74
+
75
+ def recover_orphans_if_due
76
+ now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
77
+ return if @next_orphan_check && now < @next_orphan_check
78
+
79
+ scan = @next_orphan_check.nil? && @manager.fetch_class == ReliableFetch
80
+ @next_orphan_check = now + ORPHAN_CHECK_INTERVAL
81
+ ReliableFetch.recover_orphans(scan: scan)
44
82
  end
45
83
 
46
84
  def enqueue_due_jobs
@@ -51,17 +89,27 @@ module Cogworker
51
89
  end
52
90
  end
53
91
 
92
+ # An entry that can't be requeued (not JSON, no queue) is buried in
93
+ # `dead` instead: left in place it would sit first by score, failing
94
+ # every poll and starving everything behind it; raising after the
95
+ # `zrem` used to lose it silently (and cut the rest of this poll short).
54
96
  def graduate(set, raw)
55
97
  Cogworker.config.redis do |c|
56
- won = c.zrem(set, raw)
57
- next unless won
98
+ next unless JobUtil.claim_and_requeue(c, set, raw) == :invalid
58
99
 
59
- job = JSON.parse(raw)
60
- c.multi do |pipeline|
61
- pipeline.sadd(RedisKeys::QUEUES, job['queue'])
62
- pipeline.lpush(RedisKeys.queue(job['queue']), raw)
63
- end
100
+ bury(c, set, raw)
64
101
  end
65
102
  end
103
+
104
+ def bury(conn, set, raw)
105
+ error = JSON::ParserError.new("not a job (JSON object with a queue) in #{set}")
106
+ job = JobUtil.unparseable_job(raw, queue: nil, error: error)
107
+ buried = conn.eval(BURY_SCRIPT, keys: [set, RedisKeys::DEAD, RedisKeys::STATS_FAILED],
108
+ argv: [raw, job['failed_at'], JSON.generate(job)])
109
+ return unless buried == 1
110
+
111
+ Throughput.record('failed') # counted like Processor#bury_unparseable
112
+ Cogworker.logger.error { "unparseable entry in #{set} moved to dead: #{raw.to_s[0, 200]}" }
113
+ end
66
114
  end
67
115
  end
@@ -30,7 +30,7 @@ module Cogworker
30
30
 
31
31
  # The raw `INFO` reply as a flat Hash (server/clients/memory/stats/...
32
32
  # sections all merged together, same as the `redis` gem always returns
33
- # it) — the Web UI's Stats tab picks a handful of fields (version,
33
+ # it) — the Web UI's Overview tab picks a handful of fields (version,
34
34
  # uptime, connected clients, memory usage) back out of this itself,
35
35
  # rather than this class pre-selecting/renaming them, so a future
36
36
  # consumer isn't limited to whatever subset this method chose.
@@ -29,8 +29,12 @@ module Cogworker
29
29
  config.server_middleware { |chain| chain.add(ServerMiddleware, expiration.to_i) }
30
30
  end
31
31
 
32
+ # A Symbol (`:queued`, `:working`, `:retrying`, `:complete`, `:failed`,
33
+ # or `:stopped`/`:interrupted` if something external wrote those), or
34
+ # `nil` for an unknown/expired jid. `get` below stays the raw stored
35
+ # Hash, string values and all.
32
36
  def status(jid)
33
- Storage.read(jid)&.fetch('status', nil)
37
+ Storage.read(jid)&.fetch('status', nil)&.to_sym
34
38
  end
35
39
 
36
40
  def get(jid)
@@ -20,14 +20,30 @@ module Cogworker
20
20
  # that never exits (a hung Redis call outliving even Manager#stop!'s
21
21
  # own 25s join, or anything else) must not be able to wedge the whole
22
22
  # restart cycle, and the supervisor's entire signal-handling loop with
23
- # it, forever.
24
- GRACEFUL_STOP_TIMEOUT = 20
23
+ # it, forever. Kept well above a child's own worst-case clean shutdown
24
+ # (Manager#stop!'s 25s drain + up to BasicFetch::TIMEOUT + 1 waiting out
25
+ # fetching threads + ~1s for Heartbeat#stop!) so a child is never killed
26
+ # mid-`requeue_unfinished` — for `:basic`, that would lose the jobs.
27
+ GRACEFUL_STOP_TIMEOUT = 35
28
+
29
+ # Respawn backoff for a child that exits unexpectedly: none for the
30
+ # first crash after a healthy run, then doubling from
31
+ # RESPAWN_BASE_DELAY up to RESPAWN_MAX_DELAY while the slot keeps dying
32
+ # within HEALTHY_UPTIME of starting. Without it, a child crashing during
33
+ # boot (bad deploy, Redis unreachable, a typo in the init file) made the
34
+ # supervisor fork/crash in a tight loop forever.
35
+ HEALTHY_UPTIME = 30
36
+ RESPAWN_BASE_DELAY = 1
37
+ RESPAWN_MAX_DELAY = 60
25
38
 
26
39
  def initialize(argv, count: ENV.fetch('COGWORKER_COUNT', 1).to_i, phased: ENV['PHASED_RESTART'] == 'true')
27
40
  @argv = argv
28
41
  @count = [count, 1].max
29
42
  @phased = phased
30
43
  @children = {} # pid => slot index
44
+ @started_at = {} # slot => monotonic time of its current child's fork
45
+ @crash_streak = Hash.new(0) # slot => consecutive quick crashes
46
+ @pending_respawns = {} # slot => monotonic time it may be forked again
31
47
  @signal_queue = ::Queue.new
32
48
  @stopping = false
33
49
  end
@@ -53,9 +69,11 @@ module Cogworker
53
69
  # indefinitely, so this OS process would never actually go away —
54
70
  # and `Swarm#initiate_restart`'s own `Process.waitpid(pid)` for this
55
71
  # exact child would then block forever right along with it.
72
+ Cogworker.flush_output!
56
73
  ::Process.exit!(true)
57
74
  end
58
75
  @children[pid] = slot
76
+ @started_at[slot] = monotonic_now
59
77
  Cogworker.logger.info { "swarm: started child pid=#{pid} slot=#{slot}" }
60
78
  end
61
79
 
@@ -70,13 +88,16 @@ module Cogworker
70
88
  until @stopping && @children.empty?
71
89
  drain_signals
72
90
  reap_children
91
+ respawn_due_children
92
+ kill_stragglers
73
93
  sleep 0.2
74
94
  end
75
95
  end
76
96
 
77
97
  def drain_signals
78
98
  until @signal_queue.empty?
79
- case @signal_queue.pop
99
+ signal = @signal_queue.pop
100
+ case signal
80
101
  when :quiet then relay(Signals::QUIET)
81
102
  when :stop then initiate_stop
82
103
  when :restart
@@ -93,9 +114,25 @@ module Cogworker
93
114
 
94
115
  def initiate_stop
95
116
  @stopping = true
117
+ @stop_deadline = monotonic_now + GRACEFUL_STOP_TIMEOUT
118
+ @pending_respawns.clear
96
119
  relay(Signals::STOP)
97
120
  end
98
121
 
122
+ # Same bound as a phased restart's `wait_for_exit`: a child that hasn't
123
+ # exited GRACEFUL_STOP_TIMEOUT after the stop was relayed is
124
+ # force-killed (once), so one hung child can't keep the supervisor —
125
+ # and whatever is waiting on it — alive forever.
126
+ def kill_stragglers
127
+ return unless @stop_deadline && monotonic_now > @stop_deadline
128
+
129
+ @stop_deadline = nil
130
+ @children.each_key do |pid|
131
+ Cogworker.logger.warn { "swarm: child pid=#{pid} didn't stop within #{GRACEFUL_STOP_TIMEOUT}s, killing" }
132
+ safe_kill(pid, 'KILL')
133
+ end
134
+ end
135
+
99
136
  # Non-phased: signal every child at once; the normal reap/respawn path
100
137
  # below brings each one back as it exits, so there's a brief capacity
101
138
  # dip but no explicit special-casing needed. Phased: replace children
@@ -155,7 +192,31 @@ module Cogworker
155
192
  if @stopping
156
193
  Cogworker.logger.info { "swarm: child pid=#{pid} slot=#{slot} exited" }
157
194
  else
158
- Cogworker.logger.warn { "swarm: child pid=#{pid} slot=#{slot} exited unexpectedly, respawning" }
195
+ schedule_respawn(pid, slot)
196
+ end
197
+ end
198
+
199
+ def schedule_respawn(pid, slot)
200
+ uptime = monotonic_now - @started_at.fetch(slot, monotonic_now)
201
+ @crash_streak[slot] = uptime < HEALTHY_UPTIME ? @crash_streak[slot] + 1 : 0
202
+ delay = respawn_delay(@crash_streak[slot])
203
+ Cogworker.logger.warn do
204
+ "swarm: child pid=#{pid} slot=#{slot} exited unexpectedly after #{uptime.round(1)}s, " \
205
+ "respawning in #{delay}s"
206
+ end
207
+ @pending_respawns[slot] = monotonic_now + delay
208
+ end
209
+
210
+ def respawn_delay(streak)
211
+ return 0 if streak <= 1
212
+
213
+ [RESPAWN_BASE_DELAY * (2**(streak - 2)), RESPAWN_MAX_DELAY].min
214
+ end
215
+
216
+ def respawn_due_children
217
+ now = monotonic_now
218
+ @pending_respawns.select { |_slot, at| at <= now }.each_key do |slot|
219
+ @pending_respawns.delete(slot)
159
220
  fork_child(slot)
160
221
  end
161
222
  end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cogworker
4
+ module Testing
5
+ # Raised by `Testing.perform_one`/`SomeJob.perform_one` when fake mode
6
+ # has nothing recorded for that class.
7
+ class EmptyQueueError < StandardError; end
8
+ end
9
+ end
@@ -14,8 +14,9 @@ module Cogworker
14
14
  # but the normalized job hash is appended to `jobs_for(klass_name)`
15
15
  # instead of touching Redis — nothing is scheduled or executed. Use
16
16
  # `SomeJob.jobs` (added to `Job::ClassMethods`) to assert what got
17
- # pushed, `SomeJob.clear`/`Testing.clear_jobs!` to reset between
18
- # examples.
17
+ # pushed, `SomeJob.perform_one`/`SomeJob.drain`/`Testing.drain_all` to
18
+ # then actually run them, `SomeJob.clear`/`Testing.clear_jobs!` to reset
19
+ # between examples.
19
20
  # - `:inline` — the job runs synchronously, right here in the calling
20
21
  # thread, through the real server middleware chain
21
22
  # (`Cogworker.config.server_chain`) — same as `Processor#execute`, minus
@@ -75,8 +76,31 @@ module Cogworker
75
76
  registry.clear
76
77
  end
77
78
 
79
+ # Fake mode's "now run them": `perform_one` takes the oldest recorded
80
+ # job for a class off its fake queue and runs it via `perform_inline`
81
+ # (real server middleware chain, errors propagate); `drain` repeats
82
+ # until that class's queue is empty, `drain_all` until every class's
83
+ # is — both also running whatever those jobs push in turn. Also
84
+ # reachable as `SomeJob.perform_one`/`SomeJob.drain`.
85
+ def perform_one(klass_name)
86
+ job = jobs_for(klass_name).shift
87
+ raise EmptyQueueError, "no #{klass_name} jobs recorded" unless job
88
+
89
+ perform_inline(job)
90
+ end
91
+
92
+ def drain(klass_name)
93
+ perform_one(klass_name) until jobs_for(klass_name).empty?
94
+ end
95
+
96
+ def drain_all
97
+ while (klass_name = registry.find { |_name, jobs| jobs.any? }&.first)
98
+ drain(klass_name)
99
+ end
100
+ end
101
+
78
102
  # Runs one job synchronously through the real server middleware chain.
79
- # Only ever called from `Client.push` while `inline?`.
103
+ # Called from `Client.push` while `inline?`, and by `perform_one`.
80
104
  def perform_inline(job)
81
105
  klass = Object.const_get(job['class'])
82
106
  worker = klass.new
@@ -9,15 +9,14 @@ module Cogworker
9
9
  # continue down the chain to the real push. A job that loses the claim
10
10
  # (another copy is already enqueued/scheduled/running) never reaches
11
11
  # `dispatch` at all — it sets `job['unique_skipped']` instead, which
12
- # `Client.push` reads to return `nil` rather than a jid, the same
13
- # "nothing was actually pushed" signal `sidekiq-unique-jobs` gives.
12
+ # `Client.push` reads to return `nil` rather than a jid — the
13
+ # "nothing was actually pushed" signal to the caller.
14
14
  class ClientMiddleware
15
15
  def call(_worker_class, job, _queue, redis_pool = Cogworker.config.redis_pool)
16
16
  return yield unless UniqueJobs.until_executed?(job)
17
17
 
18
18
  acquired = redis_pool.with do |c|
19
- c.set(RedisKeys.unique_lock(UniqueJobs.digest(job)), job['jid'],
20
- nx: true, ex: Cogworker.config.unique_lock_ttl)
19
+ c.set(RedisKeys.unique_lock(UniqueJobs.digest(job)), job['jid'], nx: true, ex: lock_ttl(job))
21
20
  end
22
21
 
23
22
  if acquired
@@ -26,6 +25,16 @@ module Cogworker
26
25
  job['unique_skipped'] = true
27
26
  end
28
27
  end
28
+
29
+ private
30
+
31
+ # Counted from when the job is due, not from now: a `perform_in`/
32
+ # `perform_at` further out than `unique_lock_ttl` used to lose its
33
+ # lock before it even ran, letting a duplicate in.
34
+ def lock_ttl(job)
35
+ wait = job['at'] ? [job['at'].to_f - Time.now.to_f, 0].max : 0
36
+ (Cogworker.config.unique_lock_ttl + wait).ceil
37
+ end
29
38
  end
30
39
  end
31
40
  end
@@ -22,8 +22,11 @@ module Cogworker
22
22
 
23
23
  private
24
24
 
25
+ # Owner-checked: if this job's lock already expired and passed to a
26
+ # newer copy, this must not release *that* one's lock (which would let
27
+ # a third copy in while the newer one is still running).
25
28
  def release(job)
26
- Cogworker.config.redis { |c| c.del(RedisKeys.unique_lock(UniqueJobs.digest(job))) }
29
+ OwnedKey.delete(RedisKeys.unique_lock(UniqueJobs.digest(job)), job['jid'])
27
30
  end
28
31
  end
29
32
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cogworker
4
- VERSION = '0.2.2'
4
+ VERSION = '0.4.0'
5
5
  end
@@ -178,8 +178,8 @@ module Cogworker
178
178
  end
179
179
 
180
180
  # Cluster-wide "quiet every process" (`Routes::Workers`'s own `POST
181
- # /workers/pause_all`) — the mock's "Pause intake" button. Unlike real
182
- # Sidekiq OSS (where quiet is a one-way trip back to a fresh process),
181
+ # /workers/pause_all`) — the mock's "Pause intake" button. Quiet isn't
182
+ # a one-way trip that only a fresh process can undo:
183
183
  # `Manager#quiet` here is a plain in-memory flag, so `resume_intake_
184
184
  # button` below is a genuine, symmetric undo rather than a full
185
185
  # process restart.
@@ -237,7 +237,7 @@ module Cogworker
237
237
  # each one's visible text to the *browser's local* time, in
238
238
  # `Web.time_format`. Registered on `htmx:load`, which htmx fires once
239
239
  # for the initial page **and** again after every fragment swap — so
240
- # newly-swapped-in timestamps (an auto-refreshed Busy/Scheduled/Dead
240
+ # newly-swapped-in timestamps (an auto-refreshed Workers/Jobs/Schedules
241
241
  # tab) get the same treatment without a full reload.
242
242
  #
243
243
  # `window.cogworkerFormatTime` is the same formatter exposed globally
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rack/session/cookie'
4
+
5
+ module Cogworker
6
+ class Web
7
+ # The Web UI's own session cookie, applied only when nothing upstream
8
+ # already provides `env['rack.session']`. Mounted inside an app that has
9
+ # its own session (Rails, or any `Rack::Session::*` in front), an
10
+ # unconditional `Rack::Session::Cookie` here replaced that session with
11
+ # a copy for everything inside the Web UI — so an auth middleware added
12
+ # via `Web.use` read and wrote the copy, never the host's real session,
13
+ # and every response carried a second `cogworker.session` cookie.
14
+ class OptionalSession
15
+ def initialize(app, **options)
16
+ @app = app
17
+ @with_session = Rack::Session::Cookie.new(app, **options)
18
+ end
19
+
20
+ def call(env)
21
+ env['rack.session'] ? @app.call(env) : @with_session.call(env)
22
+ end
23
+ end
24
+ end
25
+ end