cogworker 0.1.0 → 0.2.1

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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/lib/cogworker/attempts.rb +47 -0
  3. data/lib/cogworker/basic_fetch.rb +25 -2
  4. data/lib/cogworker/heartbeat.rb +4 -2
  5. data/lib/cogworker/history/storage.rb +58 -22
  6. data/lib/cogworker/history.rb +57 -7
  7. data/lib/cogworker/manager.rb +4 -0
  8. data/lib/cogworker/periodic/ticker.rb +16 -1
  9. data/lib/cogworker/process.rb +13 -5
  10. data/lib/cogworker/processor.rb +4 -0
  11. data/lib/cogworker/queue.rb +17 -0
  12. data/lib/cogworker/redis_keys.rb +5 -0
  13. data/lib/cogworker/throughput.rb +51 -0
  14. data/lib/cogworker/version.rb +1 -1
  15. data/lib/cogworker/web/assets/nocturne/fonts/inter-latin.woff2 +0 -0
  16. data/lib/cogworker/web/assets/nocturne/styles.css +582 -0
  17. data/lib/cogworker/web/assets/phosphor/Phosphor.woff2 +0 -0
  18. data/lib/cogworker/web/assets/phosphor/style.css +4627 -0
  19. data/lib/cogworker/web/layout.rb +232 -141
  20. data/lib/cogworker/web/routes/history.rb +86 -32
  21. data/lib/cogworker/web/routes/jobs.rb +469 -0
  22. data/lib/cogworker/web/routes/overview.rb +746 -0
  23. data/lib/cogworker/web/routes/schedules.rb +197 -0
  24. data/lib/cogworker/web/routes/workers.rb +227 -0
  25. data/lib/cogworker/web.rb +4 -3
  26. metadata +11 -9
  27. data/lib/cogworker/web/assets/tailwind.css +0 -1
  28. data/lib/cogworker/web/routes/busy.rb +0 -99
  29. data/lib/cogworker/web/routes/dead.rb +0 -93
  30. data/lib/cogworker/web/routes/periodic.rb +0 -67
  31. data/lib/cogworker/web/routes/queues.rb +0 -101
  32. data/lib/cogworker/web/routes/retries.rb +0 -89
  33. data/lib/cogworker/web/routes/scheduled.rb +0 -49
  34. data/lib/cogworker/web/routes/stats.rb +0 -298
@@ -1,93 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Cogworker
4
- class Web
5
- module Routes
6
- # Lists jobs that exhausted their retries, with delete and retry
7
- # (requeue) actions.
8
- module Dead
9
- CONTENT_ID = 'dead-content'
10
-
11
- module_function
12
-
13
- def registered(app)
14
- app.get('/dead') do
15
- content = Dead.render_content(request.script_name)
16
- if hx_request?
17
- content
18
- else
19
- Layout.wrap('Dead', Layout.poll_div(CONTENT_ID, request.script_name, 'dead', content),
20
- script_name: request.script_name)
21
- end
22
- end
23
-
24
- app.post('/dead/delete') do
25
- Cogworker.config.redis { |c| c.zrem(RedisKeys::DEAD, params['raw']) }
26
- Dead.respond(self)
27
- end
28
-
29
- app.post('/dead/delete_all') do
30
- Cogworker.config.redis { |c| c.del(RedisKeys::DEAD) }
31
- Dead.respond(self)
32
- end
33
-
34
- # Puts the job back on its original queue for one more attempt —
35
- # same "graduate out of this ZSET, back onto cogworker:queue:<q>"
36
- # move `Routes::Retries#retry_now` does for `cogworker:retry`,
37
- # and `Cogworker::Scheduled#graduate` does for both `cogworker:
38
- # schedule`/`cogworker:retry`. `zrem` winning (not losing) is what
39
- # actually gates the requeue: without it, two browser tabs (or a
40
- # slow double-click) retrying the same entry at once could both
41
- # see it and each push a copy. NOTE: for a single (non-Array)
42
- # member, the `redis` gem's `#zrem` returns a **Boolean**, not the
43
- # raw `1`/`0` integer reply — `== 1` would always be false here
44
- # (`true == 1` is `false` in Ruby); check truthiness instead, the
45
- # same way `Cogworker::Scheduled#graduate`'s `next unless won` does.
46
- app.post('/dead/retry') do
47
- raw = params['raw']
48
- Cogworker.config.redis do |c|
49
- if c.zrem(RedisKeys::DEAD, raw)
50
- job = JSON.parse(raw)
51
- c.sadd(RedisKeys::QUEUES, job['queue'])
52
- c.lpush(RedisKeys.queue(job['queue']), raw)
53
- end
54
- end
55
- Dead.respond(self)
56
- end
57
- end
58
-
59
- # After an action, htmx gets the refreshed fragment swapped into
60
- # #dead-content in place; a plain form submission (no JS) falls back
61
- # to a normal redirect back to the full page.
62
- def respond(action)
63
- if action.hx_request?
64
- render_content(action.request.script_name)
65
- else
66
- action.redirect(Layout.path(action.request.script_name, 'dead'))
67
- end
68
- end
69
-
70
- def render_content(script_name)
71
- # Newest DiedAt first — `zrevrange` (`cogworker:dead`'s score is
72
- # the epoch it died at), not `zrange`.
73
- entries = Cogworker.config.redis { |c| c.zrevrange(RedisKeys::DEAD, 0, -1, withscores: true) }
74
- delete_path = Layout.path(script_name, 'dead/delete')
75
- retry_path = Layout.path(script_name, 'dead/retry')
76
- rows = entries.map do |raw, score|
77
- job = JSON.parse(raw)
78
- [job['jid'], Layout.h(job['class']), Layout.time_tag(score),
79
- Layout.form_button(retry_path, 'raw', raw, 'retry', hx_target: "##{CONTENT_ID}", variant: :primary),
80
- Layout.form_button(delete_path, 'raw', raw, 'delete', hx_target: "##{CONTENT_ID}", variant: :danger)]
81
- end
82
- delete_all_path = Layout.path(script_name, 'dead/delete_all')
83
- delete_all_button = Layout.action_button(delete_all_path, 'delete all', hx_target: "##{CONTENT_ID}",
84
- variant: :danger)
85
- %(<div class="mb-3 flex justify-end">#{delete_all_button}</div>) +
86
- Layout.table(%w[JID Class DiedAt Retry Delete], rows, empty_message: 'No dead jobs.')
87
- end
88
- end
89
- end
90
- end
91
- end
92
-
93
- Cogworker::Web.register(Cogworker::Web::Routes::Dead)
@@ -1,67 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'fugit'
4
- require 'json'
5
-
6
- module Cogworker
7
- class Web
8
- module Routes
9
- # Read-only: lists every registered `config.periodic { |mgr|
10
- # mgr.register(...) }` entry and when it last/next fires. Sourced
11
- # entirely from Redis (`periodic:schedule`/`periodic:last_slot:<pjid>`),
12
- # not from any in-process `Periodic::Manager` — those are only
13
- # published once an actual worker process's `Periodic::Ticker` has
14
- # booted (see `Ticker#publish_schedule!`), so a Web-UI-only process
15
- # (no worker ever started) shows the empty state here, same as Busy
16
- # shows no processes with no worker running.
17
- module Periodic
18
- CONTENT_ID = 'periodic-content'
19
-
20
- module_function
21
-
22
- def registered(app)
23
- app.get('/periodic') do
24
- content = Routes::Periodic.render_content
25
- if hx_request?
26
- content
27
- else
28
- Layout.wrap('Periodic', Layout.poll_div(CONTENT_ID, request.script_name, 'periodic', content),
29
- script_name: request.script_name)
30
- end
31
- end
32
- end
33
-
34
- def render_content
35
- schedule = Cogworker.config.redis { |c| c.hgetall(RedisKeys::PERIODIC_SCHEDULE) }
36
- rows = schedule.map { |pjid, raw| row_for(pjid, JSON.parse(raw)) }
37
- Layout.table(%w[Class Cron Args NextRun LastRun Unique], rows,
38
- empty_message: 'Nothing registered yet (no worker process has booted the periodic ' \
39
- 'scheduler — periodic jobs are published to Redis by ' \
40
- 'Periodic::Ticker on worker startup, not by the Web UI process).')
41
- end
42
-
43
- def row_for(pjid, entry)
44
- [Layout.h(entry['class']), Layout.h(entry['cron']), Layout.h(entry['args'].to_json),
45
- next_run_tag(entry['cron']), last_run_tag(pjid), unique_cell(entry['unique'])]
46
- end
47
-
48
- def next_run_tag(cron)
49
- Layout.time_tag(Fugit::Cron.parse(cron)&.next_time(Time.now)&.to_t)
50
- rescue StandardError
51
- Layout.h('invalid cron')
52
- end
53
-
54
- def last_run_tag(pjid)
55
- last_slot = Cogworker.config.redis { |c| c.get(RedisKeys.periodic_last_slot(pjid)) }
56
- last_slot ? Layout.time_tag(last_slot.to_f) : %(<span class="text-gray-400 italic">never</span>)
57
- end
58
-
59
- def unique_cell(unique)
60
- unique.nil? || unique.empty? ? '' : Layout.badge(unique, variant: :warning)
61
- end
62
- end
63
- end
64
- end
65
- end
66
-
67
- Cogworker::Web.register(Cogworker::Web::Routes::Periodic)
@@ -1,101 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Cogworker
4
- class Web
5
- module Routes
6
- # Lists every known queue with its size/latency, and a per-queue drill
7
- # down page listing that queue's pending jobs.
8
- module Queues
9
- CONTENT_ID = 'queues-content'
10
- QUEUE_CONTENT_ID = 'queue-content'
11
-
12
- module_function
13
-
14
- def registered(app)
15
- app.get('/queues') do
16
- content = Queues.render_content(request.script_name)
17
- if hx_request?
18
- content
19
- else
20
- Layout.wrap('Queues', Layout.poll_div(CONTENT_ID, request.script_name, 'queues', content),
21
- script_name: request.script_name)
22
- end
23
- end
24
-
25
- app.get('/queues/:name') do
26
- name = url_params('name')
27
- content = Queues.render_queue_content(name, request.script_name)
28
- if hx_request?
29
- content
30
- else
31
- Layout.wrap("Queue: #{Layout.h(name)}",
32
- Layout.poll_div(QUEUE_CONTENT_ID, request.script_name, "queues/#{name}", content),
33
- script_name: request.script_name)
34
- end
35
- end
36
-
37
- # `raw` is the exact JSON string the job entry was pushed with —
38
- # same "raw" identity `Routes::Dead`/`Routes::Retries` already key
39
- # their own per-row delete off — so `Queue#delete` can `LREM` it
40
- # back out of the list.
41
- app.post('/queues/:name/delete') do
42
- name = url_params('name')
43
- Cogworker::Queue.new(name).delete(params['raw'])
44
- Queues.respond(self, name)
45
- end
46
-
47
- app.post('/queues/:name/delete_all') do
48
- name = url_params('name')
49
- Cogworker::Queue.new(name).clear
50
- Queues.respond(self, name)
51
- end
52
- end
53
-
54
- # After an action, htmx gets the refreshed fragment swapped into
55
- # #queue-content in place; a plain form submission (no JS) falls
56
- # back to a normal redirect back to the queue's own page.
57
- def respond(action, name)
58
- if action.hx_request?
59
- render_queue_content(name, action.request.script_name)
60
- else
61
- action.redirect(Layout.path(action.request.script_name, "queues/#{name}"))
62
- end
63
- end
64
-
65
- def render_content(script_name)
66
- names = Cogworker.config.redis { |c| c.smembers(RedisKeys::QUEUES) }.sort
67
- rows = names.map do |name|
68
- q = Cogworker::Queue.new(name)
69
- link = Layout.path(script_name, "queues/#{Layout.h(name)}")
70
- [%(<a class="text-indigo-600 dark:text-indigo-400 hover:underline font-medium" href="#{link}">#{Layout.h(name)}</a>),
71
- q.size, q.latency.round(2)]
72
- end
73
- Layout.table(%w[Name Size Latency], rows, empty_message: 'No queues yet — push a job to create one.')
74
- end
75
-
76
- def render_queue_content(name, script_name)
77
- delete_path = Layout.path(script_name, "queues/#{name}/delete")
78
- rows = Cogworker::Queue.new(name).map do |r|
79
- [r.jid, Layout.h(r.klass), Layout.h(r.args.to_s),
80
- Layout.form_button(delete_path, 'raw', r.value, 'delete', hx_target: "##{QUEUE_CONTENT_ID}",
81
- variant: :danger)]
82
- end
83
- delete_all_button(name, script_name, rows.empty?) +
84
- Layout.table(%w[JID Class Args Delete], rows, empty_message: 'This queue is empty.')
85
- end
86
-
87
- # Nothing to bulk-delete once the queue is already empty.
88
- def delete_all_button(name, script_name, empty)
89
- return '' if empty
90
-
91
- delete_all_path = Layout.path(script_name, "queues/#{name}/delete_all")
92
- button = Layout.action_button(delete_all_path, 'delete all', hx_target: "##{QUEUE_CONTENT_ID}",
93
- variant: :danger)
94
- %(<div class="mb-3 flex justify-end">#{button}</div>)
95
- end
96
- end
97
- end
98
- end
99
- end
100
-
101
- Cogworker::Web.register(Cogworker::Web::Routes::Queues)
@@ -1,89 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Cogworker
4
- class Web
5
- module Routes
6
- # Lists jobs currently scheduled for retry, with delete and
7
- # retry-now (skip the backoff) actions.
8
- module Retries
9
- CONTENT_ID = 'retries-content'
10
-
11
- module_function
12
-
13
- def registered(app)
14
- app.get('/retries') do
15
- content = Retries.render_content(request.script_name)
16
- if hx_request?
17
- content
18
- else
19
- Layout.wrap('Retries', Layout.poll_div(CONTENT_ID, request.script_name, 'retries', content),
20
- script_name: request.script_name)
21
- end
22
- end
23
-
24
- app.post('/retries/delete') do
25
- Cogworker.config.redis { |c| c.zrem(RedisKeys::RETRY, params['raw']) }
26
- Retries.respond(self)
27
- end
28
-
29
- app.post('/retries/retry_now') do
30
- raw = params['raw']
31
- Cogworker.config.redis do |c|
32
- # For a single (non-Array) member, the `redis` gem's `#zrem`
33
- # returns a **Boolean**, not the raw `1`/`0` integer reply —
34
- # `== 1` is always false here (`true == 1` is `false` in
35
- # Ruby); check truthiness instead, matching
36
- # `Cogworker::Scheduled#graduate`'s `next unless won`.
37
- if c.zrem(RedisKeys::RETRY, raw)
38
- job = JSON.parse(raw)
39
- c.sadd(RedisKeys::QUEUES, job['queue'])
40
- c.lpush(RedisKeys.queue(job['queue']), raw)
41
- end
42
- end
43
- Retries.respond(self)
44
- end
45
- end
46
-
47
- # After an action, htmx gets the refreshed fragment swapped into
48
- # #retries-content in place; a plain form submission (no JS) falls
49
- # back to a normal redirect back to the full page.
50
- def respond(action)
51
- if action.hx_request?
52
- render_content(action.request.script_name)
53
- else
54
- action.redirect(Layout.path(action.request.script_name, 'retries'))
55
- end
56
- end
57
-
58
- def render_content(script_name)
59
- entries = Cogworker.config.redis { |c| c.zrange(RedisKeys::RETRY, 0, -1) }
60
- rows = entries.map do |raw|
61
- job = JSON.parse(raw)
62
- [job['jid'], Layout.h(job['class']), error_cell(job), delete_form(script_name, raw),
63
- retry_now_form(script_name, raw)]
64
- end
65
- Layout.table(%w[JID Class Error Delete RetryNow], rows, empty_message: 'No retries pending.')
66
- end
67
-
68
- def error_cell(job)
69
- <<~HTML
70
- <div class="font-mono text-xs text-red-700 dark:text-red-400">#{Layout.h(job['error_class'])}</div>
71
- <div class="text-xs text-gray-500 dark:text-gray-400 truncate max-w-xs">#{Layout.h(job['error_message'])}</div>
72
- HTML
73
- end
74
-
75
- def delete_form(script_name, raw)
76
- action = Layout.path(script_name, 'retries/delete')
77
- Layout.form_button(action, 'raw', raw, 'delete', hx_target: "##{CONTENT_ID}", variant: :danger)
78
- end
79
-
80
- def retry_now_form(script_name, raw)
81
- action = Layout.path(script_name, 'retries/retry_now')
82
- Layout.form_button(action, 'raw', raw, 'retry now', hx_target: "##{CONTENT_ID}", variant: :primary)
83
- end
84
- end
85
- end
86
- end
87
- end
88
-
89
- Cogworker::Web.register(Cogworker::Web::Routes::Retries)
@@ -1,49 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Cogworker
4
- class Web
5
- module Routes
6
- # Lists jobs pushed with `perform_in`/`perform_at`, still waiting for
7
- # their run-at time, with a delete action.
8
- module Scheduled
9
- CONTENT_ID = 'scheduled-content'
10
-
11
- module_function
12
-
13
- def registered(app)
14
- app.get('/scheduled') do
15
- content = Scheduled.render_content(request.script_name)
16
- if hx_request?
17
- content
18
- else
19
- Layout.wrap('Scheduled', Layout.poll_div(CONTENT_ID, request.script_name, 'scheduled', content),
20
- script_name: request.script_name)
21
- end
22
- end
23
-
24
- app.post('/scheduled/delete') do
25
- Cogworker.config.redis { |c| c.zrem(RedisKeys::SCHEDULE, params['raw']) }
26
- if hx_request?
27
- Scheduled.render_content(request.script_name)
28
- else
29
- redirect Layout.path(request.script_name, 'scheduled')
30
- end
31
- end
32
- end
33
-
34
- def render_content(script_name)
35
- entries = Cogworker.config.redis { |c| c.zrange(RedisKeys::SCHEDULE, 0, -1, withscores: true) }
36
- delete_path = Layout.path(script_name, 'scheduled/delete')
37
- rows = entries.map do |raw, score|
38
- job = JSON.parse(raw)
39
- [job['jid'], Layout.h(job['class']), Layout.time_tag(score),
40
- Layout.form_button(delete_path, 'raw', raw, 'delete', hx_target: "##{CONTENT_ID}", variant: :danger)]
41
- end
42
- Layout.table(%w[JID Class RunAt Delete], rows, empty_message: 'Nothing scheduled.')
43
- end
44
- end
45
- end
46
- end
47
- end
48
-
49
- Cogworker::Web.register(Cogworker::Web::Routes::Scheduled)