cogworker 0.2.2 → 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 (37) 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/layout.rb +3 -3
  32. data/lib/cogworker/web/routes/jobs.rb +17 -17
  33. data/lib/cogworker/web/routes/overview.rb +6 -12
  34. data/lib/cogworker/web/routes/schedules.rb +2 -2
  35. data/lib/cogworker/web.rb +21 -8
  36. data/lib/cogworker.rb +5 -1
  37. metadata +5 -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.2'
4
+ VERSION = '0.3.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
@@ -14,9 +14,9 @@ module Cogworker
14
14
  # retry timeline, last error, actions) in place of `Routes::Dead`'s
15
15
  # flat rows. (*`Routes::Queues`' own queue-level browsing survives
16
16
  # separately, as `Routes::Overview` layout B — this tab is the
17
- # cross-queue, cross-status view; the two overlap a little on purpose,
18
- # the same way a real Sidekiq-style admin wants both "what's in this
19
- # queue" and "find this one job wherever it is".)
17
+ # cross-queue, cross-status view; the two overlap a little on purpose:
18
+ # an operator wants both "what's in this queue" and "find this one job
19
+ # wherever it is".)
20
20
  #
21
21
  # `?status=`/`?q=`/`?selected=` are plain query params, read fresh on
22
22
  # every request and carried through the self-poll and every action's
@@ -67,7 +67,7 @@ module Cogworker
67
67
  Jobs.respond(self, params)
68
68
  end
69
69
 
70
- # Same "graduate back onto its queue" move `Routes::Dead#retry`/
70
+ # Same "graduate back onto its queue" move `/jobs/dead/retry` below/
71
71
  # `Cogworker::Scheduled#graduate` make — `zrem` winning (not
72
72
  # losing) gates it so two tabs retrying the same entry at once
73
73
  # can't both requeue it; the `redis` gem's `#zrem` returns a
@@ -76,11 +76,7 @@ module Cogworker
76
76
  app.post('/jobs/retrying/retry_now') do
77
77
  raw = params['raw']
78
78
  Cogworker.config.redis do |c|
79
- if c.zrem(RedisKeys::RETRY, raw)
80
- job = JSON.parse(raw)
81
- c.sadd(RedisKeys::QUEUES, job['queue'])
82
- c.lpush(RedisKeys.queue(job['queue']), raw)
83
- end
79
+ JobUtil.claim_and_requeue(c, RedisKeys::RETRY, raw)
84
80
  end
85
81
  Jobs.respond(self, params)
86
82
  end
@@ -102,11 +98,7 @@ module Cogworker
102
98
  app.post('/jobs/dead/retry') do
103
99
  raw = params['raw']
104
100
  Cogworker.config.redis do |c|
105
- if c.zrem(RedisKeys::DEAD, raw)
106
- job = JSON.parse(raw)
107
- c.sadd(RedisKeys::QUEUES, job['queue'])
108
- c.lpush(RedisKeys.queue(job['queue']), raw)
109
- end
101
+ JobUtil.claim_and_requeue(c, RedisKeys::DEAD, raw)
110
102
  end
111
103
  Jobs.respond(self, params)
112
104
  end
@@ -254,15 +246,23 @@ module Cogworker
254
246
  when :scheduled
255
247
  delete_button('scheduled', script_name, row, params)
256
248
  when :retrying
257
- retry_now_button('retrying', script_name, row, params) + delete_button('retrying', script_name, row,
258
- params)
249
+ retry_html = requeueable?(row) ? retry_now_button('retrying', script_name, row, params) : ''
250
+ retry_html + delete_button('retrying', script_name, row, params)
259
251
  when :dead
260
- retry_button('dead', script_name, row, params) + delete_button('dead', script_name, row, params)
252
+ retry_html = requeueable?(row) ? retry_button('dead', script_name, row, params) : ''
253
+ retry_html + delete_button('dead', script_name, row, params)
261
254
  else
262
255
  ''
263
256
  end
264
257
  end
265
258
 
259
+ # No Retry for an entry that can't go back on a queue (no queue of
260
+ # its own) or that would only fail straight back into Dead (an
261
+ # unparseable payload's wrapper) — Delete is all that makes sense.
262
+ def requeueable?(row)
263
+ row[:klass] != JobUtil::UNPARSEABLE_CLASS && !JobUtil.requeueable(row[:raw].to_s).nil?
264
+ end
265
+
266
266
  def delete_button(bucket, script_name, row, params, extra: {})
267
267
  action = Layout.path(script_name, "jobs/#{bucket}/delete")
268
268
  form_with_extra(action, 'raw', row[:raw], 'delete', params, variant: :danger, icon: 'trash', extra: extra)
@@ -125,8 +125,8 @@ module Cogworker
125
125
  end
126
126
 
127
127
  # `raw` is the exact JSON string the job entry was pushed with —
128
- # same "raw" identity `Routes::Dead`/`Routes::Retries` already key
129
- # their own per-row delete off — so `Queue#delete` can `LREM` it
128
+ # same "raw" identity `Routes::Jobs`' dead/retrying actions already
129
+ # key their own per-row delete off — so `Queue#delete` can `LREM` it
130
130
  # back out of the list.
131
131
  app.post('/overview/:name/delete') do
132
132
  name = url_params('name')
@@ -167,15 +167,9 @@ module Cogworker
167
167
  def retry_all(name)
168
168
  entries = Cogworker.config.redis { |c| c.zrange(RedisKeys::RETRY, 0, -1) }
169
169
  entries.each do |raw|
170
- job = JSON.parse(raw)
171
- next unless job['queue'] == name
170
+ next unless JobUtil.requeueable(raw)&.fetch('queue') == name
172
171
 
173
- Cogworker.config.redis do |c|
174
- if c.zrem(RedisKeys::RETRY, raw)
175
- c.sadd(RedisKeys::QUEUES, job['queue'])
176
- c.lpush(RedisKeys.queue(job['queue']), raw)
177
- end
178
- end
172
+ Cogworker.config.redis { |c| JobUtil.claim_and_requeue(c, RedisKeys::RETRY, raw) }
179
173
  end
180
174
  end
181
175
 
@@ -447,8 +441,8 @@ module Cogworker
447
441
  # `processed`/`failed`. Shared between the initial render
448
442
  # (`throughput_section`, baked into the page) and `/overview/
449
443
  # throughput_data` (what the chart's own poll re-fetches from then
450
- # on), so the two can never drift apart — same pattern `Routes::
451
- # Stats#chart_data_payload` already uses for its own chart.
444
+ # on), so the two can never drift apart — same pattern
445
+ # `runs_per_day_payload` above uses for the Runs-per-day chart.
452
446
  def throughput_payload
453
447
  series = Cogworker::Throughput.series
454
448
  {
@@ -52,12 +52,12 @@ module Cogworker
52
52
  end
53
53
 
54
54
  app.post('/schedules/:pjid/disable') do
55
- Cogworker.config.redis { |c| c.sadd(RedisKeys::PERIODIC_DISABLED, url_params('pjid')) }
55
+ Cogworker.config.redis { |c| c.sadd?(RedisKeys::PERIODIC_DISABLED, url_params('pjid')) }
56
56
  Routes::Schedules.respond(self)
57
57
  end
58
58
 
59
59
  app.post('/schedules/:pjid/enable') do
60
- Cogworker.config.redis { |c| c.srem(RedisKeys::PERIODIC_DISABLED, url_params('pjid')) }
60
+ Cogworker.config.redis { |c| c.srem?(RedisKeys::PERIODIC_DISABLED, url_params('pjid')) }
61
61
  Routes::Schedules.respond(self)
62
62
  end
63
63
  end
data/lib/cogworker/web.rb CHANGED
@@ -20,7 +20,7 @@ module Cogworker
20
20
  # Every built-in tab lives under here; touching each constant once
21
21
  # forces Zeitwerk to load that file (and run its
22
22
  # `Cogworker::Web.register(...)` bottom-of-file side effect) — nothing
23
- # else ever references `Routes::Queues` etc. by name, so without this
23
+ # else ever references `Routes::Overview` etc. by name, so without this
24
24
  # they would simply never load.
25
25
  BUILT_IN_ROUTE_NAMES = %i[Overview Jobs Schedules Workers History SaveSession].freeze
26
26
  DEFAULT_TIME_FORMAT = '%Y-%m-%d %H:%M:%S'
@@ -94,8 +94,20 @@ module Cogworker
94
94
  @tabs ||= {}
95
95
  end
96
96
 
97
+ # The built-in session cookie's signing secret. The gem itself never
98
+ # reads the session — it's there for an auth middleware added via
99
+ # `.use` — but a random per-process default means each Puma worker
100
+ # signs with its own key, so that middleware's session doesn't survive
101
+ # a request landing on a different worker. Set it explicitly
102
+ # (`Cogworker::Web.session_secret = ...`, at least 64 characters) or
103
+ # via `COGWORKER_SESSION_SECRET` for any multi-process deployment.
97
104
  def session_secret
98
- @session_secret ||= SecureRandom.hex(32)
105
+ @session_secret ||= ENV.fetch('COGWORKER_SESSION_SECRET') { SecureRandom.hex(32) }
106
+ end
107
+
108
+ def session_secret=(secret)
109
+ @session_secret = secret
110
+ @app = nil
99
111
  end
100
112
 
101
113
  # How every `Layout.time_tag` timestamp is rendered — a Ruby
@@ -132,10 +144,11 @@ module Cogworker
132
144
  end
133
145
 
134
146
  # How often (in seconds) every auto-refreshing tab polls while the
135
- # global live-update toggle is on — both the htmx-polled Busy/Stats/
136
- # Queues tabs (`Layout.poll_div`'s `hx-trigger="every Ns [...]"`) and
137
- # History's own AG Grid `refreshRows()` JS poll share this one value,
138
- # so there's a single knob rather than one per tab. Set this from your
147
+ # global live-update toggle is on — the htmx-polled Overview/Jobs/
148
+ # Schedules/Workers tabs (`Layout.poll_div`'s `hx-trigger="every Ns
149
+ # [...]"`), History's AG Grid poll and Overview's Chart.js polls all
150
+ # share this one value, so there's a single knob rather than one per
151
+ # tab. Set this from your
139
152
  # own init file/config.ru, e.g. `Cogworker::Web.live_update_interval = 10`.
140
153
  def live_update_interval
141
154
  @live_update_interval ||= DEFAULT_LIVE_UPDATE_INTERVAL
@@ -237,10 +250,10 @@ module Cogworker
237
250
  builder.use(Rack::Session::Cookie, secret: session_secret, key: 'cogworker.session')
238
251
  # No `cache_control:` (no `immutable`/long `max-age`): these files
239
252
  # are plain, unfingerprinted paths that *do* change — every time
240
- # this gem's Tailwind bundle gets rebuilt, or on any gem upgrade —
253
+ # a vendored stylesheet/script gets edited, or on any gem upgrade —
241
254
  # and `immutable` previously told browsers to keep serving a stale
242
255
  # cached copy under the old URL for up to a year with no
243
- # revalidation at all (caught by hand: a real edit to tailwind.css
256
+ # revalidation at all (caught by hand: a real edit to a vendored stylesheet
244
257
  # didn't show up in an already-open tab until a hard reload).
245
258
  # `Rack::Static`/`Rack::Files` already sends `Last-Modified` and
246
259
  # honors conditional GETs by default, which is all that's needed
data/lib/cogworker.rb CHANGED
@@ -2,6 +2,9 @@
2
2
 
3
3
  require 'socket'
4
4
  require 'securerandom'
5
+ # `Time#iso8601` (Logging, Web::Layout.time_tag) only moved into core in
6
+ # Ruby 3.4; on 3.1-3.3 it needs the stdlib extension loaded explicitly.
7
+ require 'time'
5
8
  require 'zeitwerk'
6
9
 
7
10
  # Redis-backed background job processing: worker DSL, periodic (cron)
@@ -85,7 +88,8 @@ module Cogworker
85
88
  end
86
89
 
87
90
  # No-op: exists so calling code doesn't need to change, but this gem
88
- # simply never coerces job args, so there is nothing to toggle.
91
+ # simply never coerces job args, so there is nothing to toggle — see
92
+ # README "Job arguments".
89
93
  def strict_args!(_value = true)
90
94
  true
91
95
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cogworker
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - vdonec
@@ -224,10 +224,12 @@ files:
224
224
  - lib/cogworker/logging.rb
225
225
  - lib/cogworker/manager.rb
226
226
  - lib/cogworker/middleware/chain.rb
227
+ - lib/cogworker/owned_key.rb
227
228
  - lib/cogworker/periodic/claim.lua
228
229
  - lib/cogworker/periodic/entry.rb
229
230
  - lib/cogworker/periodic/manager.rb
230
231
  - lib/cogworker/periodic/release_middleware.rb
232
+ - lib/cogworker/periodic/running_lock.rb
231
233
  - lib/cogworker/periodic/ticker.rb
232
234
  - lib/cogworker/process.rb
233
235
  - lib/cogworker/process_set.rb
@@ -236,6 +238,7 @@ files:
236
238
  - lib/cogworker/queue.rb
237
239
  - lib/cogworker/redis_connection.rb
238
240
  - lib/cogworker/redis_keys.rb
241
+ - lib/cogworker/reliable_fetch.rb
239
242
  - lib/cogworker/scheduled.rb
240
243
  - lib/cogworker/signals.rb
241
244
  - lib/cogworker/stats.rb
@@ -246,6 +249,7 @@ files:
246
249
  - lib/cogworker/status/worker.rb
247
250
  - lib/cogworker/swarm.rb
248
251
  - lib/cogworker/testing.rb
252
+ - lib/cogworker/testing/empty_queue_error.rb
249
253
  - lib/cogworker/throughput.rb
250
254
  - lib/cogworker/unique_jobs.rb
251
255
  - lib/cogworker/unique_jobs/client_middleware.rb