cogworker 0.2.1 → 0.3.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/lib/cogworker/basic_fetch.rb +37 -2
  3. data/lib/cogworker/cli.rb +11 -1
  4. data/lib/cogworker/client.rb +1 -1
  5. data/lib/cogworker/config.rb +26 -1
  6. data/lib/cogworker/heartbeat.rb +54 -6
  7. data/lib/cogworker/job.rb +9 -0
  8. data/lib/cogworker/job_util.rb +56 -0
  9. data/lib/cogworker/launcher.rb +20 -1
  10. data/lib/cogworker/manager.rb +43 -4
  11. data/lib/cogworker/owned_key.rb +40 -0
  12. data/lib/cogworker/periodic/claim.lua +9 -0
  13. data/lib/cogworker/periodic/release_middleware.rb +31 -13
  14. data/lib/cogworker/periodic/running_lock.rb +44 -0
  15. data/lib/cogworker/periodic/ticker.rb +39 -10
  16. data/lib/cogworker/process.rb +2 -2
  17. data/lib/cogworker/process_set.rb +1 -1
  18. data/lib/cogworker/processor.rb +116 -14
  19. data/lib/cogworker/queue.rb +4 -4
  20. data/lib/cogworker/redis_keys.rb +2 -0
  21. data/lib/cogworker/reliable_fetch.rb +137 -0
  22. data/lib/cogworker/scheduled.rb +37 -10
  23. data/lib/cogworker/stats.rb +1 -1
  24. data/lib/cogworker/status.rb +5 -1
  25. data/lib/cogworker/swarm.rb +64 -4
  26. data/lib/cogworker/testing/empty_queue_error.rb +9 -0
  27. data/lib/cogworker/testing.rb +27 -3
  28. data/lib/cogworker/unique_jobs/client_middleware.rb +13 -4
  29. data/lib/cogworker/unique_jobs/release_middleware.rb +4 -1
  30. data/lib/cogworker/version.rb +1 -1
  31. data/lib/cogworker/web/history_query.rb +181 -0
  32. data/lib/cogworker/web/layout.rb +3 -3
  33. data/lib/cogworker/web/routes/history.rb +111 -68
  34. data/lib/cogworker/web/routes/jobs.rb +17 -17
  35. data/lib/cogworker/web/routes/overview.rb +6 -12
  36. data/lib/cogworker/web/routes/schedules.rb +2 -2
  37. data/lib/cogworker/web.rb +21 -8
  38. data/lib/cogworker.rb +5 -1
  39. metadata +6 -1
@@ -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
@@ -56,6 +72,7 @@ module Cogworker
56
72
  ::Process.exit!(true)
57
73
  end
58
74
  @children[pid] = slot
75
+ @started_at[slot] = monotonic_now
59
76
  Cogworker.logger.info { "swarm: started child pid=#{pid} slot=#{slot}" }
60
77
  end
61
78
 
@@ -70,13 +87,16 @@ module Cogworker
70
87
  until @stopping && @children.empty?
71
88
  drain_signals
72
89
  reap_children
90
+ respawn_due_children
91
+ kill_stragglers
73
92
  sleep 0.2
74
93
  end
75
94
  end
76
95
 
77
96
  def drain_signals
78
97
  until @signal_queue.empty?
79
- case @signal_queue.pop
98
+ signal = @signal_queue.pop
99
+ case signal
80
100
  when :quiet then relay(Signals::QUIET)
81
101
  when :stop then initiate_stop
82
102
  when :restart
@@ -93,9 +113,25 @@ module Cogworker
93
113
 
94
114
  def initiate_stop
95
115
  @stopping = true
116
+ @stop_deadline = monotonic_now + GRACEFUL_STOP_TIMEOUT
117
+ @pending_respawns.clear
96
118
  relay(Signals::STOP)
97
119
  end
98
120
 
121
+ # Same bound as a phased restart's `wait_for_exit`: a child that hasn't
122
+ # exited GRACEFUL_STOP_TIMEOUT after the stop was relayed is
123
+ # force-killed (once), so one hung child can't keep the supervisor —
124
+ # and whatever is waiting on it — alive forever.
125
+ def kill_stragglers
126
+ return unless @stop_deadline && monotonic_now > @stop_deadline
127
+
128
+ @stop_deadline = nil
129
+ @children.each_key do |pid|
130
+ Cogworker.logger.warn { "swarm: child pid=#{pid} didn't stop within #{GRACEFUL_STOP_TIMEOUT}s, killing" }
131
+ safe_kill(pid, 'KILL')
132
+ end
133
+ end
134
+
99
135
  # Non-phased: signal every child at once; the normal reap/respawn path
100
136
  # below brings each one back as it exits, so there's a brief capacity
101
137
  # dip but no explicit special-casing needed. Phased: replace children
@@ -155,7 +191,31 @@ module Cogworker
155
191
  if @stopping
156
192
  Cogworker.logger.info { "swarm: child pid=#{pid} slot=#{slot} exited" }
157
193
  else
158
- Cogworker.logger.warn { "swarm: child pid=#{pid} slot=#{slot} exited unexpectedly, respawning" }
194
+ schedule_respawn(pid, slot)
195
+ end
196
+ end
197
+
198
+ def schedule_respawn(pid, slot)
199
+ uptime = monotonic_now - @started_at.fetch(slot, monotonic_now)
200
+ @crash_streak[slot] = uptime < HEALTHY_UPTIME ? @crash_streak[slot] + 1 : 0
201
+ delay = respawn_delay(@crash_streak[slot])
202
+ Cogworker.logger.warn do
203
+ "swarm: child pid=#{pid} slot=#{slot} exited unexpectedly after #{uptime.round(1)}s, " \
204
+ "respawning in #{delay}s"
205
+ end
206
+ @pending_respawns[slot] = monotonic_now + delay
207
+ end
208
+
209
+ def respawn_delay(streak)
210
+ return 0 if streak <= 1
211
+
212
+ [RESPAWN_BASE_DELAY * (2**(streak - 2)), RESPAWN_MAX_DELAY].min
213
+ end
214
+
215
+ def respawn_due_children
216
+ now = monotonic_now
217
+ @pending_respawns.select { |_slot, at| at <= now }.each_key do |slot|
218
+ @pending_respawns.delete(slot)
159
219
  fork_child(slot)
160
220
  end
161
221
  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.1'
4
+ VERSION = '0.3.0'
5
5
  end
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Cogworker
6
+ class Web
7
+ # Server side of the History tab's AG Grid (Infinite Row Model): one
8
+ # block of rows at a time — `[rows, total]` for `offset`/`limit` —
9
+ # instead of handing the browser every retained entry up front.
10
+ #
11
+ # `sort`/`filters` arrive in AG Grid's own `sortModel`/`filterModel`
12
+ # shapes (`[{ 'colId' => ..., 'sort' => 'asc'|'desc' }]` /
13
+ # `{ colId => { 'filterType' => 'text'|'number', 'type' => ..., ... } }`,
14
+ # optionally combined via `operator`/`conditions`), and `COLUMNS` below
15
+ # computes each column's value the same way `Routes::History#grid_script`
16
+ # displays it, so a server-side filter matches what the viewer sees.
17
+ #
18
+ # Two paths:
19
+ # - no filters, sorted by Finished (the default): a plain `ZREVRANGE`/
20
+ # `ZRANGE` of just the requested slice, total from `ZCARD` — the
21
+ # everyday case stays O(limit), whatever `History.max_entries` is.
22
+ # - anything else (a column filter, `?jid=`, sorting by another column):
23
+ # a scan of the whole status ZSET in chunks, filtered/sorted in Ruby,
24
+ # then sliced. Bounded by retention (`History.retention_days`/
25
+ # `max_entries`), and only the requested slice ever leaves the server.
26
+ class HistoryQuery
27
+ SCAN_CHUNK = 1000
28
+ MAX_LIMIT = 1000
29
+ DEFAULT_SORT = 'finished_at'
30
+
31
+ COLUMNS = {
32
+ 'finished_at' => ->(e) { e['finished_at'].to_f },
33
+ 'class' => ->(e) { e['class'].to_s },
34
+ 'queue' => ->(e) { e['queue'].to_s },
35
+ 'jid' => ->(e) { e['jid'].to_s },
36
+ 'args' => ->(e) { JSON.generate(e['args']) },
37
+ 'status' => ->(e) { e['status'].to_s },
38
+ 'duration' => ->(e) { ((e['finished_at'].to_f - e['started_at'].to_f) * 1000).round },
39
+ 'error' => ->(e) { e['error_class'] ? "#{e['error_class']}: #{e['error_message']}" : '' }
40
+ }.freeze
41
+
42
+ def initialize(status:, jid: '', sort: [], filters: {})
43
+ @key = Cogworker::History::Storage::LIST_KEYS.fetch(status, Cogworker::History::Storage::LIST_KEYS.fetch('all'))
44
+ @jid = jid.to_s
45
+ @sort = Array(sort).find { |s| s.is_a?(Hash) && COLUMNS.key?(s['colId']) }
46
+ @filters = filters.is_a?(Hash) ? filters.select { |col, _| COLUMNS.key?(col) } : {}
47
+ end
48
+
49
+ def fetch(offset, limit)
50
+ offset = [offset.to_i, 0].max
51
+ limit = limit.to_i.clamp(0, MAX_LIMIT)
52
+ fast_path? ? fetch_slice(offset, limit) : fetch_scanned(offset, limit)
53
+ end
54
+
55
+ private
56
+
57
+ def sort_col
58
+ @sort ? @sort['colId'] : DEFAULT_SORT
59
+ end
60
+
61
+ def ascending?
62
+ @sort && @sort['sort'] == 'asc'
63
+ end
64
+
65
+ def fast_path?
66
+ @jid.empty? && @filters.empty? && sort_col == DEFAULT_SORT
67
+ end
68
+
69
+ def fetch_slice(offset, limit)
70
+ return [[], Cogworker.config.redis { |c| c.zcard(@key) }] if limit.zero?
71
+
72
+ stop = offset + limit - 1
73
+ total, raw = Cogworker.config.redis do |c|
74
+ [c.zcard(@key), ascending? ? c.zrange(@key, offset, stop) : c.zrevrange(@key, offset, stop)]
75
+ end
76
+ [raw.map { |r| JSON.parse(r) }, total]
77
+ end
78
+
79
+ def fetch_scanned(offset, limit)
80
+ matched = scan.select { |entry| matches?(entry) }
81
+ [sort(matched)[offset, limit] || [], matched.size]
82
+ end
83
+
84
+ # Ties stay newest-first (the scan's own order) either direction —
85
+ # the index tiebreaker also makes Ruby's unstable `sort_by` stable.
86
+ def sort(entries)
87
+ getter = COLUMNS.fetch(sort_col)
88
+ keyed = entries.each_with_index.map { |entry, i| [getter.call(entry), i, entry] }
89
+ if ascending?
90
+ keyed.sort_by { |value, i, _| [value, i] }.map(&:last)
91
+ else
92
+ keyed.sort_by { |value, i, _| [value, -i] }.reverse.map(&:last)
93
+ end
94
+ end
95
+
96
+ def scan
97
+ needle = @jid.empty? ? nil : JSON.generate('jid' => @jid)[1..-2]
98
+ entries = []
99
+ Cogworker.config.redis do |c|
100
+ start = 0
101
+ loop do
102
+ chunk = c.zrevrange(@key, start, start + SCAN_CHUNK - 1)
103
+ chunk.each do |raw|
104
+ next if needle && !raw.include?(needle) # cheap pre-check before parsing
105
+
106
+ entry = JSON.parse(raw)
107
+ entries << entry if @jid.empty? || entry['jid'] == @jid
108
+ end
109
+ break if chunk.size < SCAN_CHUNK
110
+
111
+ start += SCAN_CHUNK
112
+ end
113
+ end
114
+ entries
115
+ end
116
+
117
+ def matches?(entry)
118
+ @filters.all? { |col, model| model_matches?(model, COLUMNS.fetch(col).call(entry)) }
119
+ end
120
+
121
+ def model_matches?(model, value)
122
+ return true unless model.is_a?(Hash)
123
+
124
+ if model['conditions'].is_a?(Array)
125
+ results = model['conditions'].map { |cond| condition_matches?(model['filterType'], cond, value) }
126
+ model['operator'] == 'OR' ? results.any? : results.all?
127
+ else
128
+ condition_matches?(model['filterType'], model, value)
129
+ end
130
+ end
131
+
132
+ def condition_matches?(filter_type, cond, value)
133
+ return true unless cond.is_a?(Hash)
134
+
135
+ case cond['filterType'] || filter_type
136
+ when 'number' then number_matches?(cond, value)
137
+ else text_matches?(cond, value.to_s)
138
+ end
139
+ end
140
+
141
+ # AG Grid's default text filter semantics: case-insensitive.
142
+ def text_matches?(cond, value)
143
+ value = value.downcase
144
+ needle = cond['filter'].to_s.downcase
145
+ case cond['type']
146
+ when 'contains' then value.include?(needle)
147
+ when 'notContains' then !value.include?(needle)
148
+ when 'equals' then value == needle
149
+ when 'notEqual' then value != needle
150
+ when 'startsWith' then value.start_with?(needle)
151
+ when 'endsWith' then value.end_with?(needle)
152
+ when 'blank' then value.empty?
153
+ when 'notBlank' then !value.empty?
154
+ else true
155
+ end
156
+ end
157
+
158
+ # AG Grid's default number filter semantics (`inRange` exclusive).
159
+ def number_matches?(cond, value)
160
+ return value.nil? if cond['type'] == 'blank'
161
+ return !value.nil? if cond['type'] == 'notBlank'
162
+
163
+ target = Float(cond['filter'], exception: false)
164
+ return true if target.nil?
165
+
166
+ case cond['type']
167
+ when 'equals' then value == target
168
+ when 'notEqual' then value != target
169
+ when 'lessThan' then value < target
170
+ when 'lessThanOrEqual' then value <= target
171
+ when 'greaterThan' then value > target
172
+ when 'greaterThanOrEqual' then value >= target
173
+ when 'inRange'
174
+ to = Float(cond['filterTo'], exception: false)
175
+ to.nil? || (value > target && value < to)
176
+ else true
177
+ end
178
+ end
179
+ end
180
+ end
181
+ 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