cogworker 0.1.0 → 0.2.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/attempts.rb +47 -0
- data/lib/cogworker/basic_fetch.rb +25 -2
- data/lib/cogworker/heartbeat.rb +4 -2
- data/lib/cogworker/manager.rb +4 -0
- data/lib/cogworker/periodic/ticker.rb +16 -1
- data/lib/cogworker/process.rb +13 -5
- data/lib/cogworker/processor.rb +4 -0
- data/lib/cogworker/queue.rb +17 -0
- data/lib/cogworker/redis_keys.rb +4 -0
- data/lib/cogworker/throughput.rb +51 -0
- data/lib/cogworker/version.rb +1 -1
- data/lib/cogworker/web/assets/nocturne/fonts/inter-latin.woff2 +0 -0
- data/lib/cogworker/web/assets/nocturne/styles.css +582 -0
- data/lib/cogworker/web/assets/phosphor/Phosphor.woff2 +0 -0
- data/lib/cogworker/web/assets/phosphor/style.css +4627 -0
- data/lib/cogworker/web/layout.rb +232 -141
- data/lib/cogworker/web/routes/history.rb +86 -32
- data/lib/cogworker/web/routes/jobs.rb +469 -0
- data/lib/cogworker/web/routes/overview.rb +746 -0
- data/lib/cogworker/web/routes/schedules.rb +197 -0
- data/lib/cogworker/web/routes/workers.rb +227 -0
- data/lib/cogworker/web.rb +4 -3
- metadata +11 -9
- data/lib/cogworker/web/assets/tailwind.css +0 -1
- data/lib/cogworker/web/routes/busy.rb +0 -99
- data/lib/cogworker/web/routes/dead.rb +0 -93
- data/lib/cogworker/web/routes/periodic.rb +0 -67
- data/lib/cogworker/web/routes/queues.rb +0 -101
- data/lib/cogworker/web/routes/retries.rb +0 -89
- data/lib/cogworker/web/routes/scheduled.rb +0 -49
- data/lib/cogworker/web/routes/stats.rb +0 -298
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1eb0a27dc233c6009a4a78103adb88ddc76aa813dd53e1f5905e419f028f4d11
|
|
4
|
+
data.tar.gz: 511c286faf085250e00bf5fb7ce4f45bfaac44731a60d1a1c92779095e758147
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1663b0475259d7bd30b5dd5718dbccc38bd53e1b4494a6574d4159229943f9a789754549171bee406134def631524733f4bb9e01e901ff552f3ad778f15f5599
|
|
7
|
+
data.tar.gz: 8a32c328155045a7c19049bba52478dbd2b806b6cf875af3d83cf7a05dfc63d1eda59ae36d66dcd19a4d5494ca8613b13a62620bddbf13fd57d08f6d297927d3
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Cogworker
|
|
6
|
+
# Per-job append-only log of failed attempts (attempt number, when, error
|
|
7
|
+
# class/message, and whether that failure routed the job to retry or to
|
|
8
|
+
# dead) — what `Processor#route_failure` appends to on every failure, and
|
|
9
|
+
# what the Web UI's Jobs tab reads to render a retry timeline in a job's
|
|
10
|
+
# detail panel.
|
|
11
|
+
#
|
|
12
|
+
# Distinct from `Cogworker::History`: History is every *completed* run
|
|
13
|
+
# (success or failure) across every job, capped and trimmed independently
|
|
14
|
+
# of any one job's fate. This is the *in-progress* failure trail for one
|
|
15
|
+
# still-retrying-or-dead job, keyed by JID, growing one entry per attempt
|
|
16
|
+
# until that job is deleted from Retries/Dead (`.clear`) or the key
|
|
17
|
+
# expires on its own (`TTL_SECONDS`) if nothing ever does.
|
|
18
|
+
module Attempts
|
|
19
|
+
MAX_ENTRIES = 25
|
|
20
|
+
TTL_SECONDS = 30 * 24 * 60 * 60 # 30 days — bounds an abandoned dead entry's log even if nothing ever calls `.clear`
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
def record(jid, attempt:, error:, outcome:)
|
|
25
|
+
entry = JSON.generate(
|
|
26
|
+
'attempt' => attempt, 'failed_at' => Time.now.to_f, 'outcome' => outcome,
|
|
27
|
+
'error_class' => error.class.name, 'error_message' => error.message.to_s[0, 10_000]
|
|
28
|
+
)
|
|
29
|
+
key = RedisKeys.job_attempts(jid)
|
|
30
|
+
Cogworker.config.redis do |c|
|
|
31
|
+
c.rpush(key, entry)
|
|
32
|
+
c.ltrim(key, -MAX_ENTRIES, -1)
|
|
33
|
+
c.expire(key, TTL_SECONDS)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Oldest attempt first — the order a timeline reads top-to-bottom.
|
|
38
|
+
def for(jid)
|
|
39
|
+
raw = Cogworker.config.redis { |c| c.lrange(RedisKeys.job_attempts(jid), 0, -1) }
|
|
40
|
+
raw.map { |r| JSON.parse(r) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def clear(jid)
|
|
44
|
+
Cogworker.config.redis { |c| c.del(RedisKeys.job_attempts(jid)) }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -13,15 +13,38 @@ module Cogworker
|
|
|
13
13
|
@queue_keys = Array(queues).map { |q| RedisKeys.queue(q) }
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
# Re-reads `cogworker:paused_queues` on every single fetch (not once at
|
|
17
|
+
# `initialize`, and not cached) — a `Queue#pause!`/`#resume!` from the
|
|
18
|
+
# Web UI takes effect on this processor's very next cycle, not just for
|
|
19
|
+
# ones started after the toggle. If every one of this processor's
|
|
20
|
+
# queues is currently paused, there's nothing left to `BRPOP` at all
|
|
21
|
+
# (an empty key list is invalid) — sleep out one `TIMEOUT` instead of
|
|
22
|
+
# returning immediately, same pause `BRPOP` itself would have caused,
|
|
23
|
+
# so `Processor#run`'s loop doesn't spin hot polling Redis in a tight
|
|
24
|
+
# loop with no rate limit.
|
|
16
25
|
def retrieve_work
|
|
17
|
-
keys =
|
|
18
|
-
|
|
26
|
+
keys = active_keys
|
|
27
|
+
if keys.empty?
|
|
28
|
+
sleep(TIMEOUT)
|
|
29
|
+
return nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
result = Cogworker.config.redis { |c| c.brpop(*keys.shuffle, timeout: TIMEOUT) }
|
|
19
33
|
return nil unless result
|
|
20
34
|
|
|
21
35
|
queue_key, raw_job = result
|
|
22
36
|
UnitOfWork.new(queue_key.delete_prefix(RedisKeys::QUEUE_PREFIX), raw_job)
|
|
23
37
|
end
|
|
24
38
|
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def active_keys
|
|
42
|
+
paused = Cogworker.config.redis { |c| c.smembers(RedisKeys::PAUSED_QUEUES) }
|
|
43
|
+
return @queue_keys if paused.empty?
|
|
44
|
+
|
|
45
|
+
@queue_keys.reject { |k| paused.include?(k.delete_prefix(RedisKeys::QUEUE_PREFIX)) }
|
|
46
|
+
end
|
|
47
|
+
|
|
25
48
|
UnitOfWork = Struct.new(:queue, :raw_job)
|
|
26
49
|
end
|
|
27
50
|
end
|
data/lib/cogworker/heartbeat.rb
CHANGED
|
@@ -5,8 +5,9 @@ require 'redis'
|
|
|
5
5
|
|
|
6
6
|
module Cogworker
|
|
7
7
|
# Publishes this process's presence (for ProcessSet) and listens for
|
|
8
|
-
# remote quiet!/stop! requests (for Process#quiet!/#stop!
|
|
9
|
-
# process, e.g. a self-targeting WorkerKiller or the Web
|
|
8
|
+
# remote quiet!/resume!/stop! requests (for Process#quiet!/#resume!/#stop!
|
|
9
|
+
# issued by another process, e.g. a self-targeting WorkerKiller or the Web
|
|
10
|
+
# UI). Both the
|
|
10
11
|
# heartbeat loop and the pub/sub subscriber must only start *after* a
|
|
11
12
|
# cogworkerswarm fork, never before, in the parent — otherwise the thread
|
|
12
13
|
# simply doesn't exist in the child, and a lock that thread held could
|
|
@@ -119,6 +120,7 @@ module Cogworker
|
|
|
119
120
|
def dispatch(message)
|
|
120
121
|
case message
|
|
121
122
|
when 'quiet' then @manager.quiet!
|
|
123
|
+
when 'resume' then @manager.unquiet!
|
|
122
124
|
when 'stop' then remote_stop!
|
|
123
125
|
else Cogworker.logger.warn { "Unknown signal message: #{message}" }
|
|
124
126
|
end
|
data/lib/cogworker/manager.rb
CHANGED
|
@@ -69,11 +69,26 @@ module Cogworker
|
|
|
69
69
|
slot = cron_for(entry).previous_time(now).to_i
|
|
70
70
|
next if @last_checked_slot[entry.pjid] == slot
|
|
71
71
|
|
|
72
|
-
enqueue(entry, slot) if claim?(entry, slot)
|
|
72
|
+
enqueue(entry, slot) if !disabled?(entry) && claim?(entry, slot)
|
|
73
73
|
@last_checked_slot[entry.pjid] = slot
|
|
74
74
|
end
|
|
75
75
|
end
|
|
76
76
|
|
|
77
|
+
# Web UI "Disable" (`Routes::Schedules`) — skips the claim/enqueue
|
|
78
|
+
# step entirely, short-circuiting before `claim?` even runs, so
|
|
79
|
+
# neither `periodic:last_slot:<pjid>` nor the per-slot lock advance
|
|
80
|
+
# while disabled: the Web UI's own "LastRun" column keeps showing the
|
|
81
|
+
# last time it *actually* ran, not a due-but-skipped slot, and
|
|
82
|
+
# re-enabling doesn't trigger a catch-up burst for every slot that
|
|
83
|
+
# was silently skipped in between. `@last_checked_slot` (this one
|
|
84
|
+
# Ticker instance's own in-memory dedup, unrelated to the Redis-
|
|
85
|
+
# persisted last_slot) still advances either way, exactly as it
|
|
86
|
+
# already did before this entry ever had a disabled state — it only
|
|
87
|
+
# stops this same tick loop from re-evaluating the same slot twice.
|
|
88
|
+
def disabled?(entry)
|
|
89
|
+
Cogworker.config.redis { |c| c.sismember(RedisKeys::PERIODIC_DISABLED, entry.pjid) }
|
|
90
|
+
end
|
|
91
|
+
|
|
77
92
|
def cron_for(entry)
|
|
78
93
|
@cron_cache[entry.pjid] ||= Fugit::Cron.parse(entry.cron)
|
|
79
94
|
end
|
data/lib/cogworker/process.rb
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Cogworker
|
|
4
|
-
# One live worker process, looked up by heartbeat key. `#quiet!`/`#
|
|
5
|
-
# publish to that process's signal channel; the process (whether
|
|
6
|
-
# this same process — e.g. WorkerKiller finding itself via
|
|
7
|
-
# a genuinely remote one) is subscribed to it and reacts
|
|
8
|
-
# real `kill -TSTP`/`TERM
|
|
4
|
+
# One live worker process, looked up by heartbeat key. `#quiet!`/`#resume!`/
|
|
5
|
+
# `#stop!` publish to that process's signal channel; the process (whether
|
|
6
|
+
# that's this same process — e.g. WorkerKiller finding itself via
|
|
7
|
+
# ProcessSet — or a genuinely remote one) is subscribed to it and reacts.
|
|
8
|
+
# `#quiet!`/`#stop!` mirror a real `kill -TSTP`/`TERM`; `#resume!` has no
|
|
9
|
+
# OS-signal equivalent (there's no `SIGCONT`-style un-quiet in real
|
|
10
|
+
# Sidekiq-alike tooling) — it's Cogworker-specific, made possible by
|
|
11
|
+
# `Manager#quiet` being a plain in-memory flag rather than a one-way state
|
|
12
|
+
# transition like `stopping?`.
|
|
9
13
|
#
|
|
10
14
|
# Named `Cogworker::Process`, not `::Process` — inside this namespace a
|
|
11
15
|
# bare `Process` resolves to this class, not the Kernel module, so any
|
|
@@ -23,6 +27,10 @@ module Cogworker
|
|
|
23
27
|
publish('quiet')
|
|
24
28
|
end
|
|
25
29
|
|
|
30
|
+
def resume!
|
|
31
|
+
publish('resume')
|
|
32
|
+
end
|
|
33
|
+
|
|
26
34
|
def stop!
|
|
27
35
|
publish('stop')
|
|
28
36
|
end
|
data/lib/cogworker/processor.rb
CHANGED
|
@@ -77,9 +77,11 @@ module Cogworker
|
|
|
77
77
|
worker.perform(*job['args'])
|
|
78
78
|
end
|
|
79
79
|
Cogworker.config.redis { |c| c.incr(RedisKeys::STATS_PROCESSED) }
|
|
80
|
+
Throughput.record('processed')
|
|
80
81
|
Cogworker.logger.info { "done: #{job['class']} jid=#{job['jid']}" }
|
|
81
82
|
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
82
83
|
Cogworker.config.redis { |c| c.incr(RedisKeys::STATS_FAILED) }
|
|
84
|
+
Throughput.record('failed')
|
|
83
85
|
route_failure(job, e)
|
|
84
86
|
Cogworker.logger.warn { "fail: #{job['class']} jid=#{job['jid']}: #{e.class}: #{e&.message}" }
|
|
85
87
|
end
|
|
@@ -106,8 +108,10 @@ module Cogworker
|
|
|
106
108
|
if new_count <= max_retries
|
|
107
109
|
delay = retry_delay(new_count)
|
|
108
110
|
Cogworker.config.redis { |c| c.zadd(RedisKeys::RETRY, Time.now.to_f + delay, JSON.generate(job)) }
|
|
111
|
+
Attempts.record(job['jid'], attempt: new_count, error: error, outcome: 'retrying')
|
|
109
112
|
else
|
|
110
113
|
Cogworker.config.redis { |c| c.zadd(RedisKeys::DEAD, Time.now.to_f, JSON.generate(job)) }
|
|
114
|
+
Attempts.record(job['jid'], attempt: new_count, error: error, outcome: 'dead')
|
|
111
115
|
end
|
|
112
116
|
end
|
|
113
117
|
|
data/lib/cogworker/queue.rb
CHANGED
|
@@ -43,6 +43,23 @@ module Cogworker
|
|
|
43
43
|
Cogworker.config.redis { |c| c.del(@key) }
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
+
# Paused queues are skipped by every processor's own weighted fetch
|
|
47
|
+
# (`BasicFetch#retrieve_work`, re-read fresh every fetch cycle, not
|
|
48
|
+
# cached at process start) — jobs already sitting in this queue's list
|
|
49
|
+
# are untouched and keep arriving via `Client.push`, they just stop
|
|
50
|
+
# being picked up until `resume!`.
|
|
51
|
+
def pause!
|
|
52
|
+
Cogworker.config.redis { |c| c.sadd(RedisKeys::PAUSED_QUEUES, name) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def resume!
|
|
56
|
+
Cogworker.config.redis { |c| c.srem(RedisKeys::PAUSED_QUEUES, name) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def paused?
|
|
60
|
+
Cogworker.config.redis { |c| c.sismember(RedisKeys::PAUSED_QUEUES, name) }
|
|
61
|
+
end
|
|
62
|
+
|
|
46
63
|
def each
|
|
47
64
|
page = 0
|
|
48
65
|
per = 50
|
data/lib/cogworker/redis_keys.rb
CHANGED
|
@@ -14,13 +14,16 @@ module Cogworker
|
|
|
14
14
|
RETRY = 'cogworker:retry'
|
|
15
15
|
DEAD = 'cogworker:dead'
|
|
16
16
|
PROCESSES = 'cogworker:processes'
|
|
17
|
+
PAUSED_QUEUES = 'cogworker:paused_queues'
|
|
17
18
|
STATS_PROCESSED = 'cogworker:stats:processed'
|
|
18
19
|
STATS_FAILED = 'cogworker:stats:failed'
|
|
19
20
|
PERIODIC_SCHEDULE = 'periodic:schedule'
|
|
21
|
+
PERIODIC_DISABLED = 'periodic:disabled'
|
|
20
22
|
|
|
21
23
|
module_function
|
|
22
24
|
|
|
23
25
|
def queue(name) = "#{QUEUE_PREFIX}#{name}"
|
|
26
|
+
def job_attempts(jid) = "cogworker:job_attempts:#{jid}"
|
|
24
27
|
def process(identity) = "cogworker:process:#{identity}"
|
|
25
28
|
def workers(identity) = "cogworker:workers:#{identity}"
|
|
26
29
|
def signal(identity) = "cogworker:signal:#{identity}"
|
|
@@ -28,5 +31,6 @@ module Cogworker
|
|
|
28
31
|
def periodic_last_slot(pjid) = "periodic:last_slot:#{pjid}"
|
|
29
32
|
def periodic_lock(pjid, slot) = "periodic:lock:#{pjid}:#{slot}"
|
|
30
33
|
def unique_lock(digest) = "cogworker:unique:#{digest}"
|
|
34
|
+
def throughput_bucket(hour) = "cogworker:throughput:#{hour}"
|
|
31
35
|
end
|
|
32
36
|
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cogworker
|
|
4
|
+
# Per-hour processed/failed counters, feeding Overview's own "Throughput"
|
|
5
|
+
# chart (the "Relay" concept mock's own sparkline — dropped in the
|
|
6
|
+
# initial nocturne migration for lack of real data; this is that data).
|
|
7
|
+
# Distinct from `Cogworker::Stats`' plain running totals (`cogworker:
|
|
8
|
+
# stats:processed`/`:failed`, incremented forever, no time dimension) and
|
|
9
|
+
# from `Cogworker::History` (a capped log of individual runs, one entry
|
|
10
|
+
# per job) — this is neither a total nor a per-job record, just how many
|
|
11
|
+
# finished in each one-hour bucket, bounded to the last `WINDOW_HOURS` by
|
|
12
|
+
# each bucket's own TTL rather than any trim/cleanup pass.
|
|
13
|
+
#
|
|
14
|
+
# Hour buckets, not minute ones: the mock's own "24h" sparkline is 24
|
|
15
|
+
# sample points (one per hour), not 1440 — matching that resolution
|
|
16
|
+
# keeps `.series` a 24-key read (cheap enough to poll every few seconds)
|
|
17
|
+
# instead of 1440.
|
|
18
|
+
module Throughput
|
|
19
|
+
WINDOW_HOURS = 24
|
|
20
|
+
BUCKET_TTL = (WINDOW_HOURS + 1) * 3600 # a bit of slack past the window itself
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
def record(outcome, at: Time.now)
|
|
25
|
+
key = RedisKeys.throughput_bucket(bucket_for(at))
|
|
26
|
+
Cogworker.config.redis do |c|
|
|
27
|
+
c.hincrby(key, outcome, 1)
|
|
28
|
+
c.expire(key, BUCKET_TTL)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# One entry per hour, oldest first, covering exactly the last `hours`
|
|
33
|
+
# hours ending now — including hours with no activity at all (a real
|
|
34
|
+
# "0 processed" reads as an honest gap, not a jump the chart papers
|
|
35
|
+
# over by skipping it).
|
|
36
|
+
def series(hours: WINDOW_HOURS, now: Time.now)
|
|
37
|
+
end_bucket = bucket_for(now)
|
|
38
|
+
buckets = ((end_bucket - hours + 1)..end_bucket).to_a
|
|
39
|
+
raw = Cogworker.config.redis do |c|
|
|
40
|
+
c.pipelined { |pipe| buckets.each { |b| pipe.hmget(RedisKeys.throughput_bucket(b), 'processed', 'failed') } }
|
|
41
|
+
end
|
|
42
|
+
buckets.zip(raw).map do |bucket, (processed, failed)|
|
|
43
|
+
{ 'time' => Time.at(bucket * 3600).utc, 'processed' => processed.to_i, 'failed' => failed.to_i }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def bucket_for(time)
|
|
48
|
+
time.to_i / 3600
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
data/lib/cogworker/version.rb
CHANGED
|
Binary file
|