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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5aad05a1deaf85505c91f0b6ebe11199fc918b6b3e3831efa5ddb753381cf824
4
- data.tar.gz: 760b88ec2cc3075f4bbfe152062bc8f1e24b71ce2c71984f9702c0cbdb7da81f
3
+ metadata.gz: ef260933f908739609343cb6b72e5fbbe75a01d0c93dd8fba5d1c4983e807f26
4
+ data.tar.gz: 22b576f3c302378c1ca2e8075893c3c4b9404329f6dacc22c281ad4b7c31f688
5
5
  SHA512:
6
- metadata.gz: fc050c6eff33d2fcc5f61629d46e88452912d3f798c49b49fdc53d21ca525403a575e89e13c9e34307cba312eab7307baebc80a187661a032592fb31eb797854
7
- data.tar.gz: a9ef2e674633abe4085b6e6e21eb74c3ec735b3ab7c4f6305f274172049e41058477b03e5a5e8105cc6b0afdeadcafc9babba07bbdd81855b54532585a9a946e
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 = @queue_keys.shuffle
18
- result = Cogworker.config.redis { |c| c.brpop(*keys, timeout: TIMEOUT) }
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
@@ -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! issued by another
9
- # process, e.g. a self-targeting WorkerKiller or the Web UI). Both the
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 to `History.max_entries`
10
- # independently on every write, oldest first.
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, max)
35
- write_and_trim(c, LIST_KEYS.fetch(status), raw, finished_at, max)
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, max)
66
+ def write_and_trim(conn, key, raw, score)
53
67
  conn.zadd(key, score, raw)
54
- conn.zremrangebyrank(key, 0, -(max + 1))
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. Reads the shared
60
- # "all" list once (rather than "success" and "failed" separately) since
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
- # Same caveat as any other read of this list: only entries still
66
- # within `History.max_entries` are counted, so a very busy queue's
67
- # oldest requested days may already have been trimmed away.
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
- since = Time.now.to_f - (days * 86_400)
70
- Cogworker.config.redis do |c|
71
- c.zrangebyscore(LIST_KEYS.fetch('all'), since, '+inf').each_with_object({}) do |raw, counts|
72
- entry = JSON.parse(raw)
73
- day = Time.at(entry['finished_at']).utc.strftime('%Y-%m-%d')
74
- bucket = (counts[day] ||= { 'success' => 0, 'failed' => 0 })
75
- bucket[entry['status']] += 1 if bucket.key?(entry['status'])
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
@@ -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
- # length-capped log meant for browsing/auditing past runs.
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
- DEFAULT_MAX_ENTRIES = 1000
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 entries are retained (oldest trimmed first), independently
14
- # for the "all", "success", and "failed" lists — read fresh on every
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 = 5000`.
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, max_entries: DEFAULT_MAX_ENTRIES)
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
@@ -25,6 +25,10 @@ module Cogworker
25
25
  @quiet = true
26
26
  end
27
27
 
28
+ def unquiet!
29
+ @quiet = false
30
+ end
31
+
28
32
  def quiet?
29
33
  @quiet
30
34
  end
@@ -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
@@ -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!`/`#stop!`
5
- # publish to that process's signal channel; the process (whether that's
6
- # this same process — e.g. WorkerKiller finding itself via ProcessSet — or
7
- # a genuinely remote one) is subscribed to it and reacts identically to a
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
@@ -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
 
@@ -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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cogworker
4
- VERSION = '0.1.0'
4
+ VERSION = '0.2.1'
5
5
  end