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
@@ -33,19 +33,35 @@ module Cogworker
33
33
  end
34
34
  end
35
35
 
36
- # Polled from the browser (see `grid_script`'s `refreshRows`) to
37
- # keep the grid live without a full-page/htmx fragment reload,
38
- # which would tear down and rebuild the AG Grid instance (and lose
39
- # its sort/filter/scroll state) on every tick.
36
+ # The grid's Infinite Row Model datasource (`grid_script`'s
37
+ # `getRows`) — one block of rows per call, sorted/filtered
38
+ # server-side by `Web::HistoryQuery`, so the page never ships
39
+ # every retained entry to the browser. Also what live updates
40
+ # re-hit (`refreshInfiniteCache`), for just the blocks on screen.
40
41
  app.get('/history/data') do
41
42
  status = History::STATUSES.include?(params['status']) ? params['status'] : 'all'
42
- jid = params['jid'].to_s
43
- entries, = Cogworker::History::Storage.page(status, 1, Cogworker::History.max_entries)
44
- entries = entries.select { |e| e['jid'] == jid } unless jid.empty?
45
- [200, { 'content-type' => 'application/json' }, [JSON.generate(entries)]]
43
+ query = Cogworker::Web::HistoryQuery.new(
44
+ status: status, jid: params['jid'].to_s,
45
+ sort: Routes::History.parse_json_param(params['sort'], []),
46
+ filters: Routes::History.parse_json_param(params['filter'], {})
47
+ )
48
+ start = params['start'].to_i
49
+ rows, total = query.fetch(start, params['end'].to_i - start)
50
+ [200, { 'content-type' => 'application/json' }, [JSON.generate('rows' => rows, 'total' => total)]]
46
51
  end
47
52
  end
48
53
 
54
+ # A malformed/missing `sort`/`filter` param just means "none" rather
55
+ # than a 500 — it's built by our own script, but still user input.
56
+ def parse_json_param(raw, fallback)
57
+ return fallback if raw.nil? || raw.empty?
58
+
59
+ parsed = JSON.parse(raw)
60
+ parsed.is_a?(fallback.class) ? parsed : fallback
61
+ rescue JSON::ParserError
62
+ fallback
63
+ end
64
+
49
65
  def ag_grid_head(script_name)
50
66
  js, grid_css, theme_css = AG_GRID_ASSETS.map { |rel| Layout.path(script_name, "assets/#{rel}") }
51
67
  <<~HTML
@@ -55,12 +71,11 @@ module Cogworker
55
71
  HTML
56
72
  end
57
73
 
58
- # Loads up to `Cogworker::History.max_entries` rows for the chosen
59
- # filter in one shot and hands them to AG Grid, which does its own
60
- # client-side sorting/per-column filtering/quick-search/pagination
61
- # from there — retention (`max_entries`) already bounds this to a
62
- # size AG Grid handles comfortably, so there's no need for the
63
- # gem's own server-side paging on top of it.
74
+ # Renders just the grid shell — rows are fetched block by block
75
+ # from `GET /history/data` (AG Grid's Infinite Row Model), with
76
+ # sorting, per-column filtering and pagination all done server-side
77
+ # (`Web::HistoryQuery`), so page weight no longer grows with
78
+ # `History.max_entries`.
64
79
  #
65
80
  # `jid` (optional — empty string means "not filtering") narrows this
66
81
  # down to one job's own run history across every retry attempt (a
@@ -69,12 +84,10 @@ module Cogworker
69
84
  # history, which only shows its *failures*, capped to the last few —
70
85
  # see `Routes::Jobs#attempts_section`, which links here).
71
86
  def render_content(script_name, status, jid = '')
72
- entries, = Cogworker::History::Storage.page(status, 1, Cogworker::History.max_entries)
73
- entries = entries.select { |e| e['jid'] == jid } unless jid.empty?
74
87
  <<~HTML
75
88
  <div style="display: flex; flex-direction: column; gap: 16px;">
76
89
  #{page_header(script_name, status, jid)}
77
- #{grid(entries, script_name, status, jid)}
90
+ #{grid(script_name, status, jid)}
78
91
  </div>
79
92
  HTML
80
93
  end
@@ -111,7 +124,7 @@ module Cogworker
111
124
  HTML
112
125
  end
113
126
 
114
- def grid(entries, script_name, status, jid)
127
+ def grid(script_name, status, jid)
115
128
  <<~HTML
116
129
  <div id="history-grid" class="ag-theme-alpine" style="height: 70vh; width: 100%;"></div>
117
130
 
@@ -123,11 +136,11 @@ module Cogworker
123
136
  <pre id="history-backtrace-content" class="mono" style="margin: 0; font-size: 12px; line-height: 1.6; white-space: pre-wrap; max-height: 60vh; overflow-y: auto; background: var(--color-bg); border: 1px solid var(--color-divider); border-radius: var(--radius-sm); padding: var(--space-3);"></pre>
124
137
  </dialog>
125
138
 
126
- #{grid_script(entries, script_name, status, jid)}
139
+ #{grid_script(script_name, status, jid)}
127
140
  HTML
128
141
  end
129
142
 
130
- def grid_script(entries, script_name, status, jid)
143
+ def grid_script(script_name, status, jid)
131
144
  query = "status=#{status}"
132
145
  query += "&jid=#{CGI.escape(jid)}" unless jid.empty?
133
146
  data_url = Layout.path(script_name, "history/data?#{query}")
@@ -135,25 +148,18 @@ module Cogworker
135
148
  <script>
136
149
  (function () {
137
150
  var dataUrl = #{Layout.json_for_script(data_url)};
138
- var rowData = #{Layout.json_for_script(entries)};
139
- var backtraces = {};
140
151
  // The dialog leads with the error itself (class + message)
141
152
  // and only then the backtrace — the grid's own Error column
142
153
  // can be too narrow/truncated to read the full message, and
143
154
  // clicking through to "just the backtrace" without it loses
144
- // the one thing you're usually trying to look up.
145
- function indexBacktraces() {
146
- backtraces = {};
147
- rowData.forEach(function (row) {
148
- if (!row.backtrace) return;
149
- var header = (row.error_class || 'Error') + ': ' + (row.error_message || '');
150
- backtraces[row.jid] = header + '\\n\\n' + row.backtrace.join('\\n');
151
- });
152
- }
153
- indexBacktraces();
154
-
155
- window.cogworkerShowHistoryBacktrace = function (jid) {
156
- document.getElementById('history-backtrace-content').textContent = backtraces[jid] || '(no backtrace)';
155
+ // the one thing you're usually trying to look up. Takes the
156
+ // row itself (not a jid lookup): every retry attempt of one
157
+ // job shares its jid but has its own backtrace.
158
+ window.cogworkerShowHistoryBacktrace = function (row) {
159
+ var text = row && row.backtrace
160
+ ? (row.error_class || 'Error') + ': ' + (row.error_message || '') + '\\n\\n' + row.backtrace.join('\\n')
161
+ : '(no backtrace)';
162
+ document.getElementById('history-backtrace-content').textContent = text;
157
163
  document.getElementById('history-backtrace-dialog').showModal();
158
164
  };
159
165
 
@@ -167,7 +173,10 @@ module Cogworker
167
173
  // it builds from a plain string/DOM node.
168
174
  var cssVar = function (name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); };
169
175
 
176
+ // Infinite Row Model: a row whose block is still loading has
177
+ // no `data` yet — every getter/renderer below guards for it.
170
178
  function statusCellRenderer(p) {
179
+ if (!p.value) return '';
171
180
  var ok = p.value === 'success';
172
181
  var bg = cssVar(ok ? '--color-success-800' : '--color-danger-800');
173
182
  var fg = cssVar(ok ? '--color-success-100' : '--color-danger-100');
@@ -185,18 +194,19 @@ module Cogworker
185
194
  }
186
195
 
187
196
  function errorValueGetter(p) {
197
+ if (!p.data) return '';
188
198
  return p.data.error_class ? (p.data.error_class + ': ' + p.data.error_message) : '';
189
199
  }
190
200
 
191
201
  function errorCellRenderer(p) {
192
- if (!p.data.backtrace) return p.value || '';
202
+ if (!p.data || !p.data.backtrace) return p.value || '';
193
203
  var span = document.createElement('span');
194
204
  span.textContent = p.value;
195
205
  span.style.color = cssVar('--color-danger-300');
196
206
  span.style.textDecoration = 'underline';
197
207
  span.style.cursor = 'pointer';
198
208
  span.title = 'Click to view backtrace';
199
- span.addEventListener('click', function () { window.cogworkerShowHistoryBacktrace(p.data.jid); });
209
+ span.addEventListener('click', function () { window.cogworkerShowHistoryBacktrace(p.data); });
200
210
  return span;
201
211
  }
202
212
 
@@ -218,33 +228,73 @@ module Cogworker
218
228
  return parts.join(' ');
219
229
  }
220
230
 
231
+ // Sorting/filtering run server-side (`Web::HistoryQuery`,
232
+ // keyed by each column's `colId`), so every `colId` here
233
+ // must match one of its `COLUMNS` — and compute the same
234
+ // value there as `valueGetter`/`valueFormatter` show here.
235
+ // Status has no column filter: the All/Success/Failed
236
+ // switch above already picks which ZSET is read at all.
237
+ var textFilter = { filter: 'agTextColumnFilter', filterParams: { buttons: ['reset'], debounceMs: 400 } };
221
238
  var columnDefs = [
222
- { field: 'finished_at', headerName: 'Finished', sort: 'desc', minWidth: 170,
223
- valueFormatter: function (p) { return window.cogworkerFormatTime(new Date(p.value * 1000)); } },
224
- { field: 'class', headerName: 'Class' },
225
- { field: 'queue', headerName: 'Queue' },
226
- { field: 'jid', headerName: 'JID', minWidth: 160 },
227
- { field: 'args', headerName: 'Args', minWidth: 200,
228
- valueFormatter: function (p) { return JSON.stringify(p.value); } },
229
- { field: 'status', headerName: 'Status', cellRenderer: statusCellRenderer, maxWidth: 120 },
230
- { headerName: 'Duration', maxWidth: 160,
231
- // `valueGetter` stays a plain millisecond Integer — AG
232
- // Grid sorts/filters on that raw value, `valueFormatter`
233
- // only changes what's *displayed*, so numeric sort order
234
- // ("23ms" before "1m 5s") stays correct regardless of
235
- // formatting.
236
- valueGetter: function (p) { return Math.round((p.data.finished_at - p.data.started_at) * 1000); },
237
- valueFormatter: function (p) { return formatDuration(p.value); } },
238
- { headerName: 'Error', minWidth: 260, valueGetter: errorValueGetter, cellRenderer: errorCellRenderer }
239
+ { colId: 'finished_at', field: 'finished_at', headerName: 'Finished', sort: 'desc', minWidth: 170, filter: false,
240
+ valueFormatter: function (p) { return p.value == null ? '' : window.cogworkerFormatTime(new Date(p.value * 1000)); } },
241
+ Object.assign({ colId: 'class', field: 'class', headerName: 'Class' }, textFilter),
242
+ Object.assign({ colId: 'queue', field: 'queue', headerName: 'Queue' }, textFilter),
243
+ Object.assign({ colId: 'jid', field: 'jid', headerName: 'JID', minWidth: 160 }, textFilter),
244
+ Object.assign({ colId: 'args', field: 'args', headerName: 'Args', minWidth: 200,
245
+ valueFormatter: function (p) { return p.data ? JSON.stringify(p.value) : ''; } }, textFilter),
246
+ { colId: 'status', field: 'status', headerName: 'Status', cellRenderer: statusCellRenderer, maxWidth: 120, filter: false },
247
+ { colId: 'duration', headerName: 'Duration', maxWidth: 160,
248
+ filter: 'agNumberColumnFilter', filterParams: { buttons: ['reset'], debounceMs: 400 },
249
+ // `valueGetter` stays a plain millisecond Integer (the
250
+ // same unit the number filter compares against server-
251
+ // side), `valueFormatter` only changes what's displayed.
252
+ valueGetter: function (p) { return p.data ? Math.round((p.data.finished_at - p.data.started_at) * 1000) : null; },
253
+ valueFormatter: function (p) { return p.value == null ? '' : formatDuration(p.value); } },
254
+ Object.assign({ colId: 'error', headerName: 'Error', minWidth: 260, valueGetter: errorValueGetter,
255
+ cellRenderer: errorCellRenderer }, textFilter)
239
256
  ];
240
257
 
258
+ function blockUrl(params) {
259
+ return dataUrl +
260
+ '&start=' + params.startRow + '&end=' + params.endRow +
261
+ '&sort=' + encodeURIComponent(JSON.stringify(params.sortModel || [])) +
262
+ '&filter=' + encodeURIComponent(JSON.stringify(params.filterModel || {}));
263
+ }
264
+
265
+ var datasource = {
266
+ getRows: function (params) {
267
+ fetch(blockUrl(params), { headers: { 'Accept': 'application/json' } })
268
+ .then(function (r) { if (!r.ok) throw new Error(r.status); return r.json(); })
269
+ .then(function (data) { params.successCallback(data.rows, data.total); })
270
+ .catch(function () { params.failCallback(); });
271
+ }
272
+ };
273
+
241
274
  var gridApi = agGrid.createGrid(document.getElementById('history-grid'), {
242
275
  columnDefs: columnDefs,
243
- rowData: rowData,
244
- defaultColDef: { sortable: true, filter: true, resizable: true, flex: 1 },
276
+ rowModelType: 'infinite',
277
+ datasource: datasource,
278
+ // One block == one page, and only that one block cached:
279
+ // `refreshInfiniteCache()` (the live poll below) re-fetches
280
+ // *every* cached block, so a bigger cache meant one extra
281
+ // request per page visited on every tick. Paging back to an
282
+ // earlier page costs a fresh request instead — fine, and
283
+ // it's fresher anyway.
284
+ cacheBlockSize: #{Cogworker::Web.history_per_page},
285
+ maxBlocksInCache: 1,
286
+ defaultColDef: { sortable: true, resizable: true, flex: 1 },
245
287
  pagination: true,
246
288
  paginationPageSize: #{Cogworker::Web.history_per_page},
247
- paginationPageSizeSelector: [10, 25, 50, 100]
289
+ paginationPageSizeSelector: [10, 25, 50, 100],
290
+ // Keeps block size == page size when the viewer picks a
291
+ // different page size — otherwise one page would span
292
+ // several blocks, more than the 1-block cache holds.
293
+ onPaginationChanged: function (e) {
294
+ if (!e.newPageSize) return;
295
+ var size = e.api.paginationGetPageSize();
296
+ if (e.api.getGridOption('cacheBlockSize') !== size) e.api.setGridOption('cacheBlockSize', size);
297
+ }
248
298
  });
249
299
 
250
300
  if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
@@ -253,19 +303,12 @@ module Cogworker
253
303
 
254
304
  // AG Grid isn't htmx-swapped (unlike Workers/Stats/Overview), so
255
305
  // it needs its own poll — gated by the same global toggle —
256
- // that replaces just `rowData` in place rather than
257
- // reloading the fragment and tearing the grid instance down.
306
+ // that re-fetches just the cached blocks in place (current
307
+ // sort/filter/page kept) rather than reloading the fragment
308
+ // and tearing the grid instance down.
258
309
  function refreshRows() {
259
310
  if (!window.cogworkerLiveUpdate) return;
260
- fetch(dataUrl, { headers: { 'Accept': 'application/json' } })
261
- .then(function (r) { return r.ok ? r.json() : null; })
262
- .then(function (data) {
263
- if (!data) return;
264
- rowData = data;
265
- indexBacktraces();
266
- gridApi.setGridOption('rowData', rowData);
267
- })
268
- .catch(function () {});
311
+ gridApi.refreshInfiniteCache();
269
312
  }
270
313
  setInterval(refreshRows, #{Cogworker::Web.live_update_interval * 1000});
271
314
  })();
@@ -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.1
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
@@ -263,6 +267,7 @@ files:
263
267
  - lib/cogworker/web/assets/nocturne/styles.css
264
268
  - lib/cogworker/web/assets/phosphor/Phosphor.woff2
265
269
  - lib/cogworker/web/assets/phosphor/style.css
270
+ - lib/cogworker/web/history_query.rb
266
271
  - lib/cogworker/web/layout.rb
267
272
  - lib/cogworker/web/router.rb
268
273
  - lib/cogworker/web/routes/history.rb