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.
- 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/history/storage.rb +58 -22
- data/lib/cogworker/history.rb +57 -7
- 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 +5 -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: ef260933f908739609343cb6b72e5fbbe75a01d0c93dd8fba5d1c4983e807f26
|
|
4
|
+
data.tar.gz: 22b576f3c302378c1ca2e8075893c3c4b9404329f6dacc22c281ad4b7c31f688
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a48c5530445af6a80846ef5313202f88a3638ecdb33614e7f86baba87c33c4e214add29febf257e025cdf1921ad76bc35140cd91828101aa556a4fb0a461e662
|
|
7
|
+
data.tar.gz: f6b2cdaef9cf94adb3823d29cffca99d50697a7553dc6178357fb6cf435ba8fb82320be291e1b04bd5075ab05ad2cf86cb5b2ed67a4f6420e20283360ec399f0
|
|
@@ -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
|
|
@@ -6,8 +6,14 @@ module Cogworker
|
|
|
6
6
|
module History
|
|
7
7
|
# Three parallel ZSETs (score = finished_at), so any of the three
|
|
8
8
|
# filters (all/success/failed) is a plain `ZREVRANGE` — no per-request
|
|
9
|
-
# `ZUNIONSTORE`/scan needed. Each is trimmed
|
|
10
|
-
#
|
|
9
|
+
# `ZUNIONSTORE`/scan needed. Each is trimmed independently on every
|
|
10
|
+
# write, oldest first, by age (`History.retention_days`, the primary,
|
|
11
|
+
# expected limit) and then by count (`History.max_entries`, a safety
|
|
12
|
+
# ceiling only — see both constants' own comments in `history.rb`) —
|
|
13
|
+
# see `write_and_trim`. Alongside those, `record` also maintains one
|
|
14
|
+
# small per-UTC-day Hash counter (`daily_counts` reads these back) —
|
|
15
|
+
# kept deliberately separate from, and unaffected by, either trim above
|
|
16
|
+
# (see `daily_counts`' own comment).
|
|
11
17
|
module Storage
|
|
12
18
|
LIST_KEYS = {
|
|
13
19
|
'all' => 'cogworker:history:all',
|
|
@@ -29,13 +35,21 @@ module Cogworker
|
|
|
29
35
|
end
|
|
30
36
|
|
|
31
37
|
raw = JSON.generate(entry)
|
|
32
|
-
max = Cogworker::History.max_entries
|
|
33
38
|
Cogworker.config.redis do |c|
|
|
34
|
-
write_and_trim(c, LIST_KEYS.fetch('all'), raw, finished_at
|
|
35
|
-
write_and_trim(c, LIST_KEYS.fetch(status), raw, finished_at
|
|
39
|
+
write_and_trim(c, LIST_KEYS.fetch('all'), raw, finished_at)
|
|
40
|
+
write_and_trim(c, LIST_KEYS.fetch(status), raw, finished_at)
|
|
41
|
+
# Independent of the two trims above — see `daily_counts`' own
|
|
42
|
+
# comment for why "Runs per day" can't just read the `all` list.
|
|
43
|
+
bump_daily_count(c, finished_at, status)
|
|
36
44
|
end
|
|
37
45
|
end
|
|
38
46
|
|
|
47
|
+
def bump_daily_count(conn, finished_at, status)
|
|
48
|
+
daily_key = RedisKeys.history_daily_bucket(Time.at(finished_at).utc.strftime('%Y-%m-%d'))
|
|
49
|
+
conn.hincrby(daily_key, status, 1)
|
|
50
|
+
conn.expire(daily_key, Cogworker::History.daily_stats_retention_days * 86_400)
|
|
51
|
+
end
|
|
52
|
+
|
|
39
53
|
# Newest-first page of `count`/`status` entries: `[entries, total]`.
|
|
40
54
|
def page(status, page_number, per_page)
|
|
41
55
|
key = LIST_KEYS.fetch(status, LIST_KEYS.fetch('all'))
|
|
@@ -49,33 +63,55 @@ module Cogworker
|
|
|
49
63
|
end
|
|
50
64
|
end
|
|
51
65
|
|
|
52
|
-
def write_and_trim(conn, key, raw, score
|
|
66
|
+
def write_and_trim(conn, key, raw, score)
|
|
53
67
|
conn.zadd(key, score, raw)
|
|
54
|
-
|
|
68
|
+
# Age first — the primary, expected trim in normal operation: gone
|
|
69
|
+
# once older than `History.retention_days`, regardless of how few
|
|
70
|
+
# entries that leaves. Count second, as a safety ceiling only — see
|
|
71
|
+
# `History::DEFAULT_MAX_ENTRIES`'s own comment for why both run on
|
|
72
|
+
# every write rather than just one. Both read fresh from
|
|
73
|
+
# `Cogworker::History` here (rather than being passed in) so a
|
|
74
|
+
# config change takes effect on the very next write, same as
|
|
75
|
+
# `max_entries` always has.
|
|
76
|
+
cutoff = Time.now.to_f - (Cogworker::History.retention_days * 86_400)
|
|
77
|
+
conn.zremrangebyscore(key, '-inf', cutoff)
|
|
78
|
+
conn.zremrangebyrank(key, 0, -(Cogworker::History.max_entries + 1))
|
|
55
79
|
end
|
|
56
80
|
|
|
57
81
|
# Success/failed counts per UTC calendar day, for the last `days` days
|
|
58
82
|
# (today included) — `{ 'YYYY-MM-DD' => { 'success' => n, 'failed' => n } }`,
|
|
59
|
-
# a day with no entries simply absent from the Hash.
|
|
60
|
-
#
|
|
61
|
-
# every entry already carries its own `status`. Bucketing uses UTC,
|
|
62
|
-
# not the viewer's browser timezone (unlike `Layout.time_tag`
|
|
83
|
+
# a day with no entries simply absent from the Hash. Bucketing uses
|
|
84
|
+
# UTC, not the viewer's browser timezone (unlike `Layout.time_tag`
|
|
63
85
|
# elsewhere) — this is a server-rendered daily aggregate, not a single
|
|
64
86
|
# instant, so there's no one "browser day" to convert into.
|
|
65
|
-
#
|
|
66
|
-
#
|
|
67
|
-
#
|
|
87
|
+
#
|
|
88
|
+
# Reads `RedisKeys.history_daily_bucket(day)` (one small Hash per day,
|
|
89
|
+
# `record` above keeps them updated) rather than scanning the `all`
|
|
90
|
+
# list and bucketing entries by hand — deliberately: even now that the
|
|
91
|
+
# `all`/`success`/`failed` lists are trimmed by age (`History.
|
|
92
|
+
# retention_days`) rather than purely by count, that's still a
|
|
93
|
+
# *different*, independently configured window than the daily
|
|
94
|
+
# buckets' own (`History.daily_stats_retention_days`, TTL-based, same
|
|
95
|
+
# idea as `Cogworker::Throughput`'s hourly buckets) — a "6 months"
|
|
96
|
+
# chart request would find nothing if it depended on `all` still
|
|
97
|
+
# holding 6 months of entries, since `retention_days` defaults to
|
|
98
|
+
# far less than that. The daily buckets are sized (and meant) to
|
|
99
|
+
# outlive the raw per-entry lists, so "Runs per day" stays accurate
|
|
100
|
+
# regardless of how short `retention_days` is configured.
|
|
68
101
|
def daily_counts(days)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
end
|
|
102
|
+
now = Time.now.utc
|
|
103
|
+
day_strings = (days - 1).downto(0).map { |offset| (now - (offset * 86_400)).strftime('%Y-%m-%d') }
|
|
104
|
+
raw = Cogworker.config.redis { |c| c.pipelined { |pipe| day_strings.each { |d| daily_hmget(pipe, d) } } }
|
|
105
|
+
day_strings.zip(raw).each_with_object({}) do |(day, (success, failed)), counts|
|
|
106
|
+
next if success.nil? && failed.nil?
|
|
107
|
+
|
|
108
|
+
counts[day] = { 'success' => success.to_i, 'failed' => failed.to_i }
|
|
77
109
|
end
|
|
78
110
|
end
|
|
111
|
+
|
|
112
|
+
def daily_hmget(pipe, day)
|
|
113
|
+
pipe.hmget(RedisKeys.history_daily_bucket(day), 'success', 'failed')
|
|
114
|
+
end
|
|
79
115
|
end
|
|
80
116
|
end
|
|
81
117
|
end
|
data/lib/cogworker/history.rb
CHANGED
|
@@ -4,29 +4,79 @@ module Cogworker
|
|
|
4
4
|
# Execution history: every job run (success and failure alike) is recorded
|
|
5
5
|
# with its full args, timing, and — on failure — error class/message/
|
|
6
6
|
# backtrace. Distinct from the Status layer (`Cogworker::Status`), which
|
|
7
|
-
# tracks *current* per-jid state with a TTL; History is an append-only
|
|
8
|
-
#
|
|
7
|
+
# tracks *current* per-jid state with a TTL; History is an append-only log
|
|
8
|
+
# meant for browsing/auditing past runs, trimmed by age first.
|
|
9
9
|
module History
|
|
10
|
-
|
|
10
|
+
# The primary, expected trim for the "all"/"success"/"failed" lists —
|
|
11
|
+
# anything older than this many days is gone on the next write,
|
|
12
|
+
# regardless of how few (or many) entries that leaves. See `Storage.
|
|
13
|
+
# write_and_trim`.
|
|
14
|
+
DEFAULT_RETENTION_DAYS = 30
|
|
15
|
+
# A safety ceiling, not the everyday trim mechanism — `retention_days`
|
|
16
|
+
# above is. Each entry in the lists it bounds carries its full args and,
|
|
17
|
+
# on failure, up to 200 backtrace lines, so a burst of traffic under a
|
|
18
|
+
# purely time-based trim could otherwise grow Redis memory unboundedly
|
|
19
|
+
# until those entries finally age out; this caps that. Sized well above
|
|
20
|
+
# what `retention_days`' default window is expected to hold in normal
|
|
21
|
+
# operation specifically so it stays a rare safety net rather than the
|
|
22
|
+
# thing actually doing the trimming day to day — if it's the one
|
|
23
|
+
# regularly cutting entries short of `retention_days`, raise it (or
|
|
24
|
+
# lower `retention_days`) to match this queue's real volume.
|
|
25
|
+
DEFAULT_MAX_ENTRIES = 50_000
|
|
26
|
+
# "Runs per day" (`Storage.daily_counts`) reads a separate, per-UTC-day
|
|
27
|
+
# counter (`RedisKeys.history_daily_bucket`) rather than the capped
|
|
28
|
+
# `all`/`success`/`failed` lists above, specifically so those lists'
|
|
29
|
+
# own trim (age- or count-based) doesn't also blow away the daily
|
|
30
|
+
# chart's older days — see `Storage`'s own comment. Each day's counter
|
|
31
|
+
# self-expires via `EXPIRE`, the same TTL-based cleanup `Cogworker::
|
|
32
|
+
# Throughput` already uses for its hourly buckets, rather than a
|
|
33
|
+
# count-based trim: 400 days comfortably covers the widest period the
|
|
34
|
+
# built-in chart offers (6 months / 182 days) with real margin, so a
|
|
35
|
+
# viewer opening that period on day 1 of a new retention window still
|
|
36
|
+
# sees its oldest days.
|
|
37
|
+
DEFAULT_DAILY_STATS_RETENTION_DAYS = 400
|
|
11
38
|
|
|
12
39
|
class << self
|
|
13
|
-
# How many
|
|
14
|
-
#
|
|
40
|
+
# How many days an entry survives in the "all"/"success"/"failed"
|
|
41
|
+
# lists before `write_and_trim` removes it — read fresh on every
|
|
15
42
|
# write, so changing it takes effect immediately, no need to rebuild
|
|
16
43
|
# the middleware chain. Configurable via
|
|
44
|
+
# `configure_server_middleware(config, retention_days: N)`, or
|
|
45
|
+
# directly: `Cogworker::History.retention_days = 90`.
|
|
46
|
+
attr_writer :retention_days
|
|
47
|
+
# The count-based safety ceiling alongside `retention_days` above —
|
|
48
|
+
# see `DEFAULT_MAX_ENTRIES`. Configurable via
|
|
17
49
|
# `configure_server_middleware(config, max_entries: N)`, or directly:
|
|
18
|
-
# `Cogworker::History.max_entries =
|
|
50
|
+
# `Cogworker::History.max_entries = 50_000`.
|
|
19
51
|
attr_writer :max_entries
|
|
52
|
+
# How many days a "Runs per day" daily counter survives before
|
|
53
|
+
# self-expiring — independent of `retention_days`/`max_entries` above
|
|
54
|
+
# (see `DEFAULT_DAILY_STATS_RETENTION_DAYS`). Configurable via
|
|
55
|
+
# `configure_server_middleware(config, daily_stats_retention_days: N)`,
|
|
56
|
+
# or directly: `Cogworker::History.daily_stats_retention_days = 800`.
|
|
57
|
+
attr_writer :daily_stats_retention_days
|
|
58
|
+
|
|
59
|
+
def retention_days
|
|
60
|
+
@retention_days ||= DEFAULT_RETENTION_DAYS
|
|
61
|
+
end
|
|
20
62
|
|
|
21
63
|
def max_entries
|
|
22
64
|
@max_entries ||= DEFAULT_MAX_ENTRIES
|
|
23
65
|
end
|
|
66
|
+
|
|
67
|
+
def daily_stats_retention_days
|
|
68
|
+
@daily_stats_retention_days ||= DEFAULT_DAILY_STATS_RETENTION_DAYS
|
|
69
|
+
end
|
|
24
70
|
end
|
|
25
71
|
|
|
26
72
|
module_function
|
|
27
73
|
|
|
28
|
-
def configure_server_middleware(config,
|
|
74
|
+
def configure_server_middleware(config, retention_days: DEFAULT_RETENTION_DAYS,
|
|
75
|
+
max_entries: DEFAULT_MAX_ENTRIES,
|
|
76
|
+
daily_stats_retention_days: DEFAULT_DAILY_STATS_RETENTION_DAYS)
|
|
77
|
+
self.retention_days = retention_days
|
|
29
78
|
self.max_entries = max_entries
|
|
79
|
+
self.daily_stats_retention_days = daily_stats_retention_days
|
|
30
80
|
config.server_middleware { |chain| chain.add(Middleware) }
|
|
31
81
|
end
|
|
32
82
|
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,7 @@ 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}"
|
|
35
|
+
def history_daily_bucket(day) = "cogworker:history:daily:#{day}"
|
|
31
36
|
end
|
|
32
37
|
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
|