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.
- checksums.yaml +4 -4
- data/lib/cogworker/basic_fetch.rb +37 -2
- data/lib/cogworker/cli.rb +11 -1
- data/lib/cogworker/client.rb +1 -1
- data/lib/cogworker/config.rb +26 -1
- data/lib/cogworker/heartbeat.rb +54 -6
- data/lib/cogworker/job.rb +9 -0
- data/lib/cogworker/job_util.rb +56 -0
- data/lib/cogworker/launcher.rb +20 -1
- data/lib/cogworker/manager.rb +43 -4
- data/lib/cogworker/owned_key.rb +40 -0
- data/lib/cogworker/periodic/claim.lua +9 -0
- data/lib/cogworker/periodic/release_middleware.rb +31 -13
- data/lib/cogworker/periodic/running_lock.rb +44 -0
- data/lib/cogworker/periodic/ticker.rb +39 -10
- data/lib/cogworker/process.rb +2 -2
- data/lib/cogworker/process_set.rb +1 -1
- data/lib/cogworker/processor.rb +116 -14
- data/lib/cogworker/queue.rb +4 -4
- data/lib/cogworker/redis_keys.rb +2 -0
- data/lib/cogworker/reliable_fetch.rb +137 -0
- data/lib/cogworker/scheduled.rb +37 -10
- data/lib/cogworker/stats.rb +1 -1
- data/lib/cogworker/status.rb +5 -1
- data/lib/cogworker/swarm.rb +64 -4
- data/lib/cogworker/testing/empty_queue_error.rb +9 -0
- data/lib/cogworker/testing.rb +27 -3
- data/lib/cogworker/unique_jobs/client_middleware.rb +13 -4
- data/lib/cogworker/unique_jobs/release_middleware.rb +4 -1
- data/lib/cogworker/version.rb +1 -1
- data/lib/cogworker/web/history_query.rb +181 -0
- data/lib/cogworker/web/layout.rb +3 -3
- data/lib/cogworker/web/routes/history.rb +111 -68
- data/lib/cogworker/web/routes/jobs.rb +17 -17
- data/lib/cogworker/web/routes/overview.rb +6 -12
- data/lib/cogworker/web/routes/schedules.rb +2 -2
- data/lib/cogworker/web.rb +21 -8
- data/lib/cogworker.rb +5 -1
- metadata +6 -1
|
@@ -33,19 +33,35 @@ module Cogworker
|
|
|
33
33
|
end
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
#
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
#
|
|
62
|
-
#
|
|
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(
|
|
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(
|
|
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(
|
|
139
|
+
#{grid_script(script_name, status, jid)}
|
|
127
140
|
HTML
|
|
128
141
|
end
|
|
129
142
|
|
|
130
|
-
def grid_script(
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
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
|
-
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
244
|
-
|
|
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
|
|
257
|
-
//
|
|
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
|
-
|
|
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
|
-
#
|
|
19
|
-
#
|
|
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 `
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
258
|
-
|
|
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
|
-
|
|
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::
|
|
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
|
-
|
|
171
|
-
next unless job['queue'] == name
|
|
170
|
+
next unless JobUtil.requeueable(raw)&.fetch('queue') == name
|
|
172
171
|
|
|
173
|
-
Cogworker.config.redis
|
|
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
|
|
451
|
-
#
|
|
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::
|
|
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 —
|
|
136
|
-
#
|
|
137
|
-
# History's
|
|
138
|
-
# so there's a single knob rather than one per
|
|
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
|
-
#
|
|
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
|
|
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.
|
|
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
|