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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5aad05a1deaf85505c91f0b6ebe11199fc918b6b3e3831efa5ddb753381cf824
4
- data.tar.gz: 760b88ec2cc3075f4bbfe152062bc8f1e24b71ce2c71984f9702c0cbdb7da81f
3
+ metadata.gz: 1eb0a27dc233c6009a4a78103adb88ddc76aa813dd53e1f5905e419f028f4d11
4
+ data.tar.gz: 511c286faf085250e00bf5fb7ce4f45bfaac44731a60d1a1c92779095e758147
5
5
  SHA512:
6
- metadata.gz: fc050c6eff33d2fcc5f61629d46e88452912d3f798c49b49fdc53d21ca525403a575e89e13c9e34307cba312eab7307baebc80a187661a032592fb31eb797854
7
- data.tar.gz: a9ef2e674633abe4085b6e6e21eb74c3ec735b3ab7c4f6305f274172049e41058477b03e5a5e8105cc6b0afdeadcafc9babba07bbdd81855b54532585a9a946e
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 = @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
@@ -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,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
@@ -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.0'
5
5
  end