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
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'cgi'
4
+ require 'digest/sha1'
4
5
  require 'json'
5
6
 
6
7
  module Cogworker
@@ -14,9 +15,9 @@ module Cogworker
14
15
  # retry timeline, last error, actions) in place of `Routes::Dead`'s
15
16
  # flat rows. (*`Routes::Queues`' own queue-level browsing survives
16
17
  # 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".)
18
+ # cross-queue, cross-status view; the two overlap a little on purpose:
19
+ # an operator wants both "what's in this queue" and "find this one job
20
+ # wherever it is".)
20
21
  #
21
22
  # `?status=`/`?q=`/`?selected=` are plain query params, read fresh on
22
23
  # every request and carried through the self-poll and every action's
@@ -63,37 +64,30 @@ module Cogworker
63
64
  app.post('/jobs/retrying/delete') do
64
65
  raw = params['raw']
65
66
  Cogworker.config.redis { |c| c.zrem(RedisKeys::RETRY, raw) }
66
- Cogworker::Attempts.clear(JSON.parse(raw)['jid'])
67
+ Cogworker::Attempts.clear(Jobs.parse_entry(raw)['jid'])
67
68
  Jobs.respond(self, params)
68
69
  end
69
70
 
70
- # Same "graduate back onto its queue" move `Routes::Dead#retry`/
71
- # `Cogworker::Scheduled#graduate` make — `zrem` winning (not
72
- # losing) gates it so two tabs retrying the same entry at once
73
- # can't both requeue it; the `redis` gem's `#zrem` returns a
74
- # **Boolean** for a single member, so this checks truthiness, not
75
- # `== 1` (`true == 1` is `false` in Ruby).
71
+ # Same "graduate back onto its queue" move `/jobs/dead/retry` below/
72
+ # `Cogworker::Scheduled#graduate` make (`JobUtil.claim_and_requeue`:
73
+ # validate, then claim + push atomically) — two tabs retrying the
74
+ # same entry at once can't both requeue it, and an entry that
75
+ # can't be requeued is left in place with a notice.
76
76
  app.post('/jobs/retrying/retry_now') do
77
77
  raw = params['raw']
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
84
- end
85
- Jobs.respond(self, params)
78
+ result = Cogworker.config.redis { |c| JobUtil.claim_and_requeue(c, RedisKeys::RETRY, raw) }
79
+ Jobs.respond(self, params, notice: result == :invalid ? 'not_requeueable' : nil)
86
80
  end
87
81
 
88
82
  app.post('/jobs/dead/delete') do
89
83
  raw = params['raw']
90
84
  Cogworker.config.redis { |c| c.zrem(RedisKeys::DEAD, raw) }
91
- Cogworker::Attempts.clear(JSON.parse(raw)['jid'])
85
+ Cogworker::Attempts.clear(Jobs.parse_entry(raw)['jid'])
92
86
  Jobs.respond(self, params)
93
87
  end
94
88
 
95
89
  app.post('/jobs/dead/delete_all') do
96
- jids = Cogworker.config.redis { |c| c.zrange(RedisKeys::DEAD, 0, -1) }.map { |raw| JSON.parse(raw)['jid'] }
90
+ jids = Cogworker.config.redis { |c| c.zrange(RedisKeys::DEAD, 0, -1) }.map { |raw| Jobs.parse_entry(raw)['jid'] }
97
91
  Cogworker.config.redis { |c| c.del(RedisKeys::DEAD) }
98
92
  jids.each { |jid| Cogworker::Attempts.clear(jid) }
99
93
  Jobs.respond(self, params)
@@ -101,14 +95,8 @@ module Cogworker
101
95
 
102
96
  app.post('/jobs/dead/retry') do
103
97
  raw = params['raw']
104
- 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
110
- end
111
- Jobs.respond(self, params)
98
+ result = Cogworker.config.redis { |c| JobUtil.claim_and_requeue(c, RedisKeys::DEAD, raw) }
99
+ Jobs.respond(self, params, notice: result == :invalid ? 'not_requeueable' : nil)
112
100
  end
113
101
 
114
102
  # Moves this one entry's score on its own ZSET — "run sooner" or
@@ -152,11 +140,16 @@ module Cogworker
152
140
  # #jobs-content in place, keeping the same filter/search/selection;
153
141
  # a plain form submission (no JS) falls back to a normal redirect
154
142
  # to that same URL.
155
- def respond(action, params)
143
+ # `notice:` is a key into NOTICES (never free text — it's echoed into
144
+ # the page). It shows once: the redirect carries it as `?notice=`,
145
+ # but `query_string` (and so every poll URL) leaves it out.
146
+ def respond(action, params, notice: nil)
156
147
  if action.hx_request?
157
- render_content(action.request.script_name, params)
148
+ render_content(action.request.script_name, params.merge('notice' => notice))
158
149
  else
159
- action.redirect(Layout.path(action.request.script_name, "jobs#{query_string(params)}"))
150
+ location = "jobs#{query_string(params)}"
151
+ location += "#{location.include?('?') ? '&' : '?'}notice=#{CGI.escape(notice)}" if notice
152
+ action.redirect(Layout.path(action.request.script_name, location))
160
153
  end
161
154
  end
162
155
 
@@ -171,6 +164,19 @@ module Cogworker
171
164
  parts.empty? ? '' : "?#{parts.join('&')}"
172
165
  end
173
166
 
167
+ NOTICES = {
168
+ 'not_requeueable' => "This entry can't be put back on a queue — it's unreadable or has no queue " \
169
+ 'of its own. Delete it instead.'
170
+ }.freeze
171
+
172
+ def notice_banner(params)
173
+ text = NOTICES[params['notice'].to_s]
174
+ return '' unless text
175
+
176
+ style = 'padding: 10px 14px; border-left: 3px solid var(--color-danger);'
177
+ %(<div class="card" role="status" style="#{style}">#{Layout.h(text)}</div>)
178
+ end
179
+
174
180
  def render_content(script_name, params)
175
181
  status = STATUSES.include?(params['status']) ? params['status'] : 'All'
176
182
  query = params['q'].to_s
@@ -183,6 +189,7 @@ module Cogworker
183
189
 
184
190
  <<~HTML
185
191
  <div style="display: flex; flex-direction: column; gap: 16px;">
192
+ #{notice_banner(params)}
186
193
  #{page_header(script_name, status, query, filtered.size, rows.size)}
187
194
  <div style="display: grid; grid-template-columns: minmax(0, 1fr) #{selected ? 'minmax(320px, 400px)' : '0px'}; gap: 16px; align-items: start;">
188
195
  #{jobs_table(filtered, script_name, params)}
@@ -254,15 +261,25 @@ module Cogworker
254
261
  when :scheduled
255
262
  delete_button('scheduled', script_name, row, params)
256
263
  when :retrying
257
- retry_now_button('retrying', script_name, row, params) + delete_button('retrying', script_name, row,
258
- params)
264
+ retry_html = requeueable?(row) ? retry_now_button('retrying', script_name, row, params) : ''
265
+ retry_html + delete_button('retrying', script_name, row, params)
259
266
  when :dead
260
- retry_button('dead', script_name, row, params) + delete_button('dead', script_name, row, params)
267
+ retry_html = requeueable?(row) ? retry_button('dead', script_name, row, params) : ''
268
+ retry_html + delete_button('dead', script_name, row, params)
261
269
  else
262
270
  ''
263
271
  end
264
272
  end
265
273
 
274
+ # No Retry for an entry that can't go back on a queue (no queue of
275
+ # its own) or that would only fail straight back into Dead (an
276
+ # unparseable payload's wrapper) — Delete is all that makes sense.
277
+ # From the row's already-parsed fields — this runs for every row on
278
+ # every poll, so no re-parsing `raw` here.
279
+ def requeueable?(row)
280
+ row[:klass] != JobUtil::UNPARSEABLE_CLASS && row[:queue].is_a?(String) && !row[:queue].empty?
281
+ end
282
+
266
283
  def delete_button(bucket, script_name, row, params, extra: {})
267
284
  action = Layout.path(script_name, "jobs/#{bucket}/delete")
268
285
  form_with_extra(action, 'raw', row[:raw], 'delete', params, variant: :danger, icon: 'trash', extra: extra)
@@ -430,10 +447,28 @@ module Cogworker
430
447
  end
431
448
  end
432
449
 
450
+ # A ZSET entry that isn't a job Hash (corrupt, or written by
451
+ # something else) shows up as an `(unparseable)` row — Delete is
452
+ # offered, Retry isn't (`requeueable?`) — instead of a JSON::ParserError
453
+ # taking the whole tab down.
454
+ def parse_entry(raw)
455
+ job = JSON.parse(raw)
456
+ return job if job.is_a?(Hash)
457
+
458
+ unparseable_entry(raw)
459
+ rescue JSON::ParserError
460
+ unparseable_entry(raw)
461
+ end
462
+
463
+ def unparseable_entry(raw)
464
+ { 'class' => JobUtil::UNPARSEABLE_CLASS, 'jid' => "unparseable-#{Digest::SHA1.hexdigest(raw.to_s)[0, 12]}",
465
+ 'args' => [], 'error_message' => raw.to_s[0, 200] }
466
+ end
467
+
433
468
  def scheduled_rows
434
469
  entries = Cogworker.config.redis { |c| c.zrange(RedisKeys::SCHEDULE, 0, -1, withscores: true) }
435
470
  entries.map do |raw, score|
436
- job = JSON.parse(raw)
471
+ job = parse_entry(raw)
437
472
  { jid: job['jid'], klass: job['class'], queue: job['queue'], status: 'Scheduled', attempt: '—',
438
473
  next_action: score, at: job['created_at'], args: job['args'], error_class: nil, error_message: nil,
439
474
  raw: raw, source: :scheduled }
@@ -443,7 +478,7 @@ module Cogworker
443
478
  def retrying_rows
444
479
  entries = Cogworker.config.redis { |c| c.zrange(RedisKeys::RETRY, 0, -1, withscores: true) }
445
480
  entries.map do |raw, score|
446
- job = JSON.parse(raw)
481
+ job = parse_entry(raw)
447
482
  { jid: job['jid'], klass: job['class'], queue: job['queue'], status: 'Retrying',
448
483
  attempt: "#{job['retry_count'].to_i} of #{JobUtil.max_retries(job)}", next_action: score,
449
484
  at: job['failed_at'] || job['created_at'], args: job['args'], error_class: job['error_class'],
@@ -454,7 +489,7 @@ module Cogworker
454
489
  def dead_rows
455
490
  entries = Cogworker.config.redis { |c| c.zrevrange(RedisKeys::DEAD, 0, -1, withscores: true) }
456
491
  entries.map do |raw, score|
457
- job = JSON.parse(raw)
492
+ job = parse_entry(raw)
458
493
  { jid: job['jid'], klass: job['class'], queue: job['queue'], status: 'Dead',
459
494
  attempt: "#{job['retry_count'].to_i} of #{JobUtil.max_retries(job)}", next_action: nil, at: score,
460
495
  args: job['args'], error_class: job['error_class'], error_message: job['error_message'], raw: raw,
@@ -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
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'rack'
4
- require 'rack/session/cookie'
5
4
  require 'rack/static'
6
5
  require 'securerandom'
7
6
 
@@ -20,7 +19,7 @@ module Cogworker
20
19
  # Every built-in tab lives under here; touching each constant once
21
20
  # forces Zeitwerk to load that file (and run its
22
21
  # `Cogworker::Web.register(...)` bottom-of-file side effect) — nothing
23
- # else ever references `Routes::Queues` etc. by name, so without this
22
+ # else ever references `Routes::Overview` etc. by name, so without this
24
23
  # they would simply never load.
25
24
  BUILT_IN_ROUTE_NAMES = %i[Overview Jobs Schedules Workers History SaveSession].freeze
26
25
  DEFAULT_TIME_FORMAT = '%Y-%m-%d %H:%M:%S'
@@ -94,8 +93,32 @@ module Cogworker
94
93
  @tabs ||= {}
95
94
  end
96
95
 
96
+ # The built-in session cookie's signing secret. The gem itself never
97
+ # reads the session — it's there for an auth middleware added via
98
+ # `.use` — but a random per-process default means each Puma worker
99
+ # signs with its own key, so that middleware's session doesn't survive
100
+ # a request landing on a different worker. Set it explicitly
101
+ # (`Cogworker::Web.session_secret = ...`, at least 64 characters) or
102
+ # via `COGWORKER_SESSION_SECRET` for any multi-process deployment.
97
103
  def session_secret
98
- @session_secret ||= SecureRandom.hex(32)
104
+ @session_secret ||= ENV.fetch('COGWORKER_SESSION_SECRET') { SecureRandom.hex(32) }
105
+ end
106
+
107
+ # `false` drops the built-in session cookie entirely (it's already
108
+ # skipped automatically whenever the host app provides a session —
109
+ # see OptionalSession). Default true.
110
+ def builtin_session
111
+ @builtin_session.nil? || @builtin_session
112
+ end
113
+
114
+ def builtin_session=(enabled)
115
+ @builtin_session = enabled
116
+ @app = nil
117
+ end
118
+
119
+ def session_secret=(secret)
120
+ @session_secret = secret
121
+ @app = nil
99
122
  end
100
123
 
101
124
  # How every `Layout.time_tag` timestamp is rendered — a Ruby
@@ -132,10 +155,11 @@ module Cogworker
132
155
  end
133
156
 
134
157
  # 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
158
+ # global live-update toggle is on — the htmx-polled Overview/Jobs/
159
+ # Schedules/Workers tabs (`Layout.poll_div`'s `hx-trigger="every Ns
160
+ # [...]"`), History's AG Grid poll and Overview's Chart.js polls all
161
+ # share this one value, so there's a single knob rather than one per
162
+ # tab. Set this from your
139
163
  # own init file/config.ru, e.g. `Cogworker::Web.live_update_interval = 10`.
140
164
  def live_update_interval
141
165
  @live_update_interval ||= DEFAULT_LIVE_UPDATE_INTERVAL
@@ -234,13 +258,15 @@ module Cogworker
234
258
 
235
259
  def build_app
236
260
  builder = Rack::Builder.new
237
- builder.use(Rack::Session::Cookie, secret: session_secret, key: 'cogworker.session')
261
+ if builtin_session
262
+ builder.use(Cogworker::Web::OptionalSession, secret: session_secret, key: 'cogworker.session')
263
+ end
238
264
  # No `cache_control:` (no `immutable`/long `max-age`): these files
239
265
  # are plain, unfingerprinted paths that *do* change — every time
240
- # this gem's Tailwind bundle gets rebuilt, or on any gem upgrade —
266
+ # a vendored stylesheet/script gets edited, or on any gem upgrade —
241
267
  # and `immutable` previously told browsers to keep serving a stale
242
268
  # 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
269
+ # revalidation at all (caught by hand: a real edit to a vendored stylesheet
244
270
  # didn't show up in an already-open tab until a hard reload).
245
271
  # `Rack::Static`/`Rack::Files` already sends `Last-Modified` and
246
272
  # 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)
@@ -80,12 +83,24 @@ module Cogworker
80
83
  !!@server_process
81
84
  end
82
85
 
86
+ # Called right before every `Process.exit!` (which, unlike `exit`,
87
+ # flushes nothing): a custom `Cogworker.logger` writing to a buffered
88
+ # stdout/stderr would otherwise lose its last lines.
89
+ def flush_output!
90
+ [$stdout, $stderr].each do |io|
91
+ io.flush
92
+ rescue StandardError
93
+ nil
94
+ end
95
+ end
96
+
83
97
  def server_process!
84
98
  @server_process = true
85
99
  end
86
100
 
87
101
  # 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.
102
+ # simply never coerces job args, so there is nothing to toggle — see
103
+ # README "Job arguments".
89
104
  def strict_args!(_value = true)
90
105
  true
91
106
  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.4.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,8 @@ 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
242
+ - lib/cogworker/reliable_fetch/unsupported_error.rb
239
243
  - lib/cogworker/scheduled.rb
240
244
  - lib/cogworker/signals.rb
241
245
  - lib/cogworker/stats.rb
@@ -246,6 +250,7 @@ files:
246
250
  - lib/cogworker/status/worker.rb
247
251
  - lib/cogworker/swarm.rb
248
252
  - lib/cogworker/testing.rb
253
+ - lib/cogworker/testing/empty_queue_error.rb
249
254
  - lib/cogworker/throughput.rb
250
255
  - lib/cogworker/unique_jobs.rb
251
256
  - lib/cogworker/unique_jobs/client_middleware.rb
@@ -265,6 +270,7 @@ files:
265
270
  - lib/cogworker/web/assets/phosphor/style.css
266
271
  - lib/cogworker/web/history_query.rb
267
272
  - lib/cogworker/web/layout.rb
273
+ - lib/cogworker/web/optional_session.rb
268
274
  - lib/cogworker/web/router.rb
269
275
  - lib/cogworker/web/routes/history.rb
270
276
  - lib/cogworker/web/routes/jobs.rb