cogworker 0.1.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 +7 -0
- data/exe/cogworker +6 -0
- data/exe/cogworkerswarm +6 -0
- data/lib/cogworker/basic_fetch.rb +27 -0
- data/lib/cogworker/cli.rb +64 -0
- data/lib/cogworker/client.rb +61 -0
- data/lib/cogworker/component.rb +19 -0
- data/lib/cogworker/config.rb +84 -0
- data/lib/cogworker/config_loader.rb +22 -0
- data/lib/cogworker/heartbeat.rb +150 -0
- data/lib/cogworker/history/middleware.rb +18 -0
- data/lib/cogworker/history/storage.rb +81 -0
- data/lib/cogworker/history.rb +33 -0
- data/lib/cogworker/job.rb +75 -0
- data/lib/cogworker/job_record.rb +24 -0
- data/lib/cogworker/job_util.rb +60 -0
- data/lib/cogworker/launcher.rb +93 -0
- data/lib/cogworker/logging.rb +18 -0
- data/lib/cogworker/manager.rb +71 -0
- data/lib/cogworker/middleware/chain.rb +64 -0
- data/lib/cogworker/periodic/claim.lua +27 -0
- data/lib/cogworker/periodic/entry.rb +24 -0
- data/lib/cogworker/periodic/manager.rb +28 -0
- data/lib/cogworker/periodic/release_middleware.rb +27 -0
- data/lib/cogworker/periodic/ticker.rb +123 -0
- data/lib/cogworker/process.rb +36 -0
- data/lib/cogworker/process_set.rb +29 -0
- data/lib/cogworker/processor.rb +129 -0
- data/lib/cogworker/prometheus/exporter.rb +62 -0
- data/lib/cogworker/queue.rb +60 -0
- data/lib/cogworker/redis_connection.rb +37 -0
- data/lib/cogworker/redis_keys.rb +32 -0
- data/lib/cogworker/scheduled.rb +67 -0
- data/lib/cogworker/signals.rb +15 -0
- data/lib/cogworker/stats.rb +47 -0
- data/lib/cogworker/status/client_middleware.rb +19 -0
- data/lib/cogworker/status/server_middleware.rb +31 -0
- data/lib/cogworker/status/storage.rb +30 -0
- data/lib/cogworker/status/worker.rb +27 -0
- data/lib/cogworker/status.rb +40 -0
- data/lib/cogworker/swarm.rb +169 -0
- data/lib/cogworker/testing.rb +109 -0
- data/lib/cogworker/unique_jobs/client_middleware.rb +31 -0
- data/lib/cogworker/unique_jobs/release_middleware.rb +30 -0
- data/lib/cogworker/unique_jobs.rb +32 -0
- data/lib/cogworker/version.rb +5 -0
- data/lib/cogworker/web/action.rb +63 -0
- data/lib/cogworker/web/application.rb +62 -0
- data/lib/cogworker/web/assets/ag-grid/ag-grid-community.min.js +1 -0
- data/lib/cogworker/web/assets/ag-grid/ag-grid.min.css +7 -0
- data/lib/cogworker/web/assets/ag-grid/ag-theme-alpine.min.css +2 -0
- data/lib/cogworker/web/assets/chart.umd.min.js +13 -0
- data/lib/cogworker/web/assets/htmx.min.js +1 -0
- data/lib/cogworker/web/assets/tailwind.css +1 -0
- data/lib/cogworker/web/layout.rb +352 -0
- data/lib/cogworker/web/router.rb +27 -0
- data/lib/cogworker/web/routes/busy.rb +99 -0
- data/lib/cogworker/web/routes/dead.rb +93 -0
- data/lib/cogworker/web/routes/history.rb +226 -0
- data/lib/cogworker/web/routes/periodic.rb +67 -0
- data/lib/cogworker/web/routes/queues.rb +101 -0
- data/lib/cogworker/web/routes/retries.rb +89 -0
- data/lib/cogworker/web/routes/save_session.rb +21 -0
- data/lib/cogworker/web/routes/scheduled.rb +49 -0
- data/lib/cogworker/web/routes/stats.rb +298 -0
- data/lib/cogworker/web/views.rb +25 -0
- data/lib/cogworker/web.rb +257 -0
- data/lib/cogworker/work.rb +19 -0
- data/lib/cogworker/work_set.rb +23 -0
- data/lib/cogworker/worker.rb +5 -0
- data/lib/cogworker/workers.rb +8 -0
- data/lib/cogworker.rb +114 -0
- metadata +300 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
|
|
6
|
+
module Cogworker
|
|
7
|
+
# One thread of a Manager's pool: fetch -> register in-flight -> run the
|
|
8
|
+
# server middleware chain around perform -> stats/retry -> deregister.
|
|
9
|
+
class Processor
|
|
10
|
+
attr_reader :thread, :tid
|
|
11
|
+
|
|
12
|
+
def initialize(manager)
|
|
13
|
+
@manager = manager
|
|
14
|
+
@tid = SecureRandom.hex(6)
|
|
15
|
+
@fetcher = BasicFetch.new(manager.queues)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def start!
|
|
19
|
+
@thread = Thread.new { run }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def run
|
|
25
|
+
until @manager.stopping?
|
|
26
|
+
if @manager.quiet?
|
|
27
|
+
sleep(0.5)
|
|
28
|
+
next
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
work = @fetcher.retrieve_work
|
|
32
|
+
next unless work
|
|
33
|
+
|
|
34
|
+
@manager.processor_busy!
|
|
35
|
+
begin
|
|
36
|
+
execute(work)
|
|
37
|
+
ensure
|
|
38
|
+
@manager.processor_idle!
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
rescue StandardError => e
|
|
42
|
+
Cogworker.logger.error { "Processor thread died: #{e.class}: #{e.message}" }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def execute(work)
|
|
46
|
+
job = JSON.parse(work.raw_job)
|
|
47
|
+
register_in_workers(work.queue, job)
|
|
48
|
+
Cogworker.logger.info { "start: #{job['class']} jid=#{job['jid']}" }
|
|
49
|
+
|
|
50
|
+
begin
|
|
51
|
+
# `build_worker` itself can raise (most commonly `NameError`, e.g. a
|
|
52
|
+
# class the caller registered/enqueued but never actually defined —
|
|
53
|
+
# deliberately still routed through the *same* middleware chain
|
|
54
|
+
# below rather than caught here directly: `History::Middleware`/
|
|
55
|
+
# `Status::ServerMiddleware` (and any custom server middleware)
|
|
56
|
+
# only ever see a job by wrapping this `chain.invoke` call, so a
|
|
57
|
+
# resolution failure caught out here, before `invoke` ever runs,
|
|
58
|
+
# would never reach them — this job would fail/retry/die with no
|
|
59
|
+
# History entry and no status update at all, silently. Catching it
|
|
60
|
+
# here and re-raising it as the chain's own "final block" gives
|
|
61
|
+
# every middleware the same crack at observing this failure as any
|
|
62
|
+
# perform-time one, with `worker` as `nil` in that case (every
|
|
63
|
+
# built-in server middleware ignores the `worker` arg entirely
|
|
64
|
+
# already; a custom one that needs a real instance simply can't act
|
|
65
|
+
# on this failure either way — there's no worker to act on).
|
|
66
|
+
resolution_error = nil
|
|
67
|
+
worker = begin
|
|
68
|
+
build_worker(job)
|
|
69
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
70
|
+
resolution_error = e
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
Cogworker.config.server_chain.invoke(worker, job, work.queue) do
|
|
75
|
+
raise resolution_error if resolution_error
|
|
76
|
+
|
|
77
|
+
worker.perform(*job['args'])
|
|
78
|
+
end
|
|
79
|
+
Cogworker.config.redis { |c| c.incr(RedisKeys::STATS_PROCESSED) }
|
|
80
|
+
Cogworker.logger.info { "done: #{job['class']} jid=#{job['jid']}" }
|
|
81
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
82
|
+
Cogworker.config.redis { |c| c.incr(RedisKeys::STATS_FAILED) }
|
|
83
|
+
route_failure(job, e)
|
|
84
|
+
Cogworker.logger.warn { "fail: #{job['class']} jid=#{job['jid']}: #{e.class}: #{e&.message}" }
|
|
85
|
+
end
|
|
86
|
+
ensure
|
|
87
|
+
deregister_from_workers
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def build_worker(job)
|
|
91
|
+
klass = Object.const_get(job['class'])
|
|
92
|
+
worker = klass.new
|
|
93
|
+
worker.jid = job['jid'] if worker.respond_to?(:jid=)
|
|
94
|
+
worker
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def route_failure(job, error)
|
|
98
|
+
job['error_class'] = error.class.name
|
|
99
|
+
job['error_message'] = error.message.to_s[0, 10_000]
|
|
100
|
+
job['failed_at'] ||= Time.now.to_f
|
|
101
|
+
|
|
102
|
+
max_retries = JobUtil.max_retries(job)
|
|
103
|
+
new_count = job['retry_count'].to_i + 1
|
|
104
|
+
job['retry_count'] = new_count
|
|
105
|
+
|
|
106
|
+
if new_count <= max_retries
|
|
107
|
+
delay = retry_delay(new_count)
|
|
108
|
+
Cogworker.config.redis { |c| c.zadd(RedisKeys::RETRY, Time.now.to_f + delay, JSON.generate(job)) }
|
|
109
|
+
else
|
|
110
|
+
Cogworker.config.redis { |c| c.zadd(RedisKeys::DEAD, Time.now.to_f, JSON.generate(job)) }
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Grows with the retry count, with jitter to avoid a thundering herd of
|
|
115
|
+
# retries all landing on the same second.
|
|
116
|
+
def retry_delay(count)
|
|
117
|
+
(count**4) + 15 + (rand(30) * (count + 1))
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def register_in_workers(queue, job)
|
|
121
|
+
payload = JSON.generate('queue' => queue, 'payload' => job, 'run_at' => Time.now.to_i)
|
|
122
|
+
Cogworker.config.redis { |c| c.hset(RedisKeys.workers(Cogworker.identity), tid, payload) }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def deregister_from_workers
|
|
126
|
+
Cogworker.config.redis { |c| c.hdel(RedisKeys.workers(Cogworker.identity), tid) }
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cogworker
|
|
4
|
+
module Prometheus
|
|
5
|
+
# Own minimal exporter, built entirely on the introspection API — no
|
|
6
|
+
# dependency on any third-party Prometheus gem's internal hooks. Mounted
|
|
7
|
+
# exactly the way any other Web UI extension is: `Web.register(...)`
|
|
8
|
+
# adds its `/metrics` route through the normal extension mechanism, not
|
|
9
|
+
# a special-cased reserved path in the Web core.
|
|
10
|
+
module Exporter
|
|
11
|
+
def self.registered(app)
|
|
12
|
+
app.get('/metrics') do
|
|
13
|
+
# Mounted unconditionally by Web.load_routes! (see there); gated
|
|
14
|
+
# here, at request time, rather than by leaving the route
|
|
15
|
+
# unregistered, so Cogworker::Web.prometheus_exporter_enabled can
|
|
16
|
+
# be set any time before a request arrives, same as time_format/
|
|
17
|
+
# live_update_interval — and so a disabled /metrics still 404s
|
|
18
|
+
# for a reason visible right here, not a mysteriously-never-
|
|
19
|
+
# mounted route.
|
|
20
|
+
unless Cogworker::Web.prometheus_exporter_enabled
|
|
21
|
+
next [404, { 'content-type' => 'text/plain' }, ['Not Found']]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
[200, { 'content-type' => 'text/plain; version=0.0.4' }, [Exporter.render]]
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.render
|
|
29
|
+
stats = Stats.new
|
|
30
|
+
lines = []
|
|
31
|
+
lines << '# TYPE cogworker_processed_total counter'
|
|
32
|
+
lines << "cogworker_processed_total #{stats.processed}"
|
|
33
|
+
lines << '# TYPE cogworker_failed_total counter'
|
|
34
|
+
lines << "cogworker_failed_total #{stats.failed}"
|
|
35
|
+
lines << '# TYPE cogworker_retry_size gauge'
|
|
36
|
+
lines << "cogworker_retry_size #{stats.retry_size}"
|
|
37
|
+
lines << '# TYPE cogworker_scheduled_size gauge'
|
|
38
|
+
lines << "cogworker_scheduled_size #{stats.scheduled_size}"
|
|
39
|
+
lines << '# TYPE cogworker_dead_size gauge'
|
|
40
|
+
lines << "cogworker_dead_size #{stats.dead_size}"
|
|
41
|
+
|
|
42
|
+
lines << '# TYPE cogworker_queue_size gauge'
|
|
43
|
+
queue_names.each { |q| lines << %(cogworker_queue_size{queue="#{q}"} #{Queue.new(q).size}) }
|
|
44
|
+
lines << '# TYPE cogworker_queue_latency_seconds gauge'
|
|
45
|
+
queue_names.each do |q|
|
|
46
|
+
lines << %(cogworker_queue_latency_seconds{queue="#{q}"} #{Queue.new(q).latency.round(3)})
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
lines << '# TYPE cogworker_busy_workers gauge'
|
|
50
|
+
lines << "cogworker_busy_workers #{ProcessSet.new.sum { |p| p['busy'].to_i }}"
|
|
51
|
+
|
|
52
|
+
"#{lines.join("\n")}\n"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.queue_names
|
|
56
|
+
Cogworker.config.redis { |c| c.smembers(RedisKeys::QUEUES) }.sort
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
Cogworker::Web.register(Cogworker::Prometheus::Exporter, name: 'prometheus_exporter', tab: nil, index: nil)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Cogworker
|
|
6
|
+
# One named queue: size/latency plus enumeration of its JobRecords, oldest
|
|
7
|
+
# (soon-to-be-popped) entries last, newest first.
|
|
8
|
+
class Queue
|
|
9
|
+
include Enumerable
|
|
10
|
+
|
|
11
|
+
def initialize(name = 'default')
|
|
12
|
+
@name = name
|
|
13
|
+
@key = RedisKeys.queue(name)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
attr_reader :name
|
|
17
|
+
|
|
18
|
+
def size
|
|
19
|
+
Cogworker.config.redis { |c| c.llen(@key) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# FIFO: LPUSH on push, so index 0 is the newest and -1 the oldest
|
|
23
|
+
# (next to be popped) — latency is measured off that tail entry.
|
|
24
|
+
def latency
|
|
25
|
+
oldest = Cogworker.config.redis { |c| c.lindex(@key, -1) }
|
|
26
|
+
return 0 unless oldest
|
|
27
|
+
|
|
28
|
+
job = JSON.parse(oldest)
|
|
29
|
+
enqueued_at = job['enqueued_at'] || job['created_at']
|
|
30
|
+
return 0 unless enqueued_at
|
|
31
|
+
|
|
32
|
+
[Time.now.to_f - enqueued_at, 0].max
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Removes every occurrence of this exact raw job entry (matched by full
|
|
36
|
+
# JSON string, same "raw" identity `Routes::Dead`/`Routes::Retries`
|
|
37
|
+
# already key their own delete/retry actions off).
|
|
38
|
+
def delete(raw)
|
|
39
|
+
Cogworker.config.redis { |c| c.lrem(@key, 0, raw) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def clear
|
|
43
|
+
Cogworker.config.redis { |c| c.del(@key) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def each
|
|
47
|
+
page = 0
|
|
48
|
+
per = 50
|
|
49
|
+
loop do
|
|
50
|
+
entries = Cogworker.config.redis { |c| c.lrange(@key, page * per, (page * per) + per - 1) }
|
|
51
|
+
break if entries.empty?
|
|
52
|
+
|
|
53
|
+
entries.each { |raw| yield JobRecord.new(raw) }
|
|
54
|
+
break if entries.size < per
|
|
55
|
+
|
|
56
|
+
page += 1
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'redis'
|
|
4
|
+
require 'connection_pool'
|
|
5
|
+
|
|
6
|
+
module Cogworker
|
|
7
|
+
# Builds a fork-safe ConnectionPool of redis-rb clients from either a
|
|
8
|
+
# `url:` or separate `host:`/`port:`/`password:`/`db:` keys. redis-rb
|
|
9
|
+
# connects lazily on first command, so building the pool before a
|
|
10
|
+
# cogworkerswarm fork is safe as long as nothing issues a command yet.
|
|
11
|
+
module RedisConnection
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def create(options = {})
|
|
15
|
+
options = options.dup
|
|
16
|
+
size = options.delete(:size) || options.delete(:concurrency) || 5
|
|
17
|
+
client_opts = client_options(options)
|
|
18
|
+
|
|
19
|
+
ConnectionPool.new(size: size, timeout: options[:pool_timeout] || 5) do
|
|
20
|
+
::Redis.new(client_opts)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def client_options(options)
|
|
25
|
+
if options[:url]
|
|
26
|
+
{ url: options[:url] }
|
|
27
|
+
else
|
|
28
|
+
{
|
|
29
|
+
host: options[:host] || 'localhost',
|
|
30
|
+
port: options[:port] || 6379,
|
|
31
|
+
password: options[:password],
|
|
32
|
+
db: (options[:db] || 0).to_i
|
|
33
|
+
}.compact
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cogworker
|
|
4
|
+
# Every Redis key name/namespace this gem reads or writes, in one place —
|
|
5
|
+
# the client (push), the server (processor/scheduler/heartbeat/periodic
|
|
6
|
+
# ticker), and the Web UI all read and write the exact same keys through
|
|
7
|
+
# these constants/methods rather than retyping the literal, so a typo
|
|
8
|
+
# can't silently create a second, disconnected copy of a set/list/hash
|
|
9
|
+
# that nothing else reads.
|
|
10
|
+
module RedisKeys
|
|
11
|
+
QUEUE_PREFIX = 'cogworker:queue:'
|
|
12
|
+
QUEUES = 'cogworker:queues'
|
|
13
|
+
SCHEDULE = 'cogworker:schedule'
|
|
14
|
+
RETRY = 'cogworker:retry'
|
|
15
|
+
DEAD = 'cogworker:dead'
|
|
16
|
+
PROCESSES = 'cogworker:processes'
|
|
17
|
+
STATS_PROCESSED = 'cogworker:stats:processed'
|
|
18
|
+
STATS_FAILED = 'cogworker:stats:failed'
|
|
19
|
+
PERIODIC_SCHEDULE = 'periodic:schedule'
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
def queue(name) = "#{QUEUE_PREFIX}#{name}"
|
|
24
|
+
def process(identity) = "cogworker:process:#{identity}"
|
|
25
|
+
def workers(identity) = "cogworker:workers:#{identity}"
|
|
26
|
+
def signal(identity) = "cogworker:signal:#{identity}"
|
|
27
|
+
def periodic_running(pjid) = "periodic:running:#{pjid}"
|
|
28
|
+
def periodic_last_slot(pjid) = "periodic:last_slot:#{pjid}"
|
|
29
|
+
def periodic_lock(pjid, slot) = "periodic:lock:#{pjid}:#{slot}"
|
|
30
|
+
def unique_lock(digest) = "cogworker:unique:#{digest}"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Cogworker
|
|
6
|
+
# Background poller: moves due jobs from the `cogworker:schedule` and
|
|
7
|
+
# `cogworker:retry` ZSETs into their target queue. Safe with many processes
|
|
8
|
+
# polling concurrently: `ZREM` returning 1 (vs 0) is the atomic "I won the
|
|
9
|
+
# race to graduate this job" signal — the same pattern the periodic
|
|
10
|
+
# scheduler's Lua claim later builds on for its own exactly-once guarantee.
|
|
11
|
+
class Scheduled
|
|
12
|
+
SETS = [RedisKeys::SCHEDULE, RedisKeys::RETRY].freeze
|
|
13
|
+
POLL_INTERVAL = 5
|
|
14
|
+
|
|
15
|
+
def initialize(manager)
|
|
16
|
+
@manager = manager
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def start!
|
|
20
|
+
@thread = Thread.new { run }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Killed outright, not gracefully joined: unlike a Processor (which may
|
|
24
|
+
# be mid-job), this thread only ever holds the GVL briefly between one
|
|
25
|
+
# POLL_INTERVAL sleep and the next, so there's nothing to drain. Without
|
|
26
|
+
# this, the thread would only notice `@manager.stopping?` after waking
|
|
27
|
+
# from its own up-to-5s sleep — and since it's a non-daemon thread, the
|
|
28
|
+
# OS process can't actually exit until then, which a caller blocked on a
|
|
29
|
+
# plain (no-timeout) `Process.waitpid` for this process — Swarm's
|
|
30
|
+
# phased restart — would otherwise just sit stuck behind.
|
|
31
|
+
def stop!
|
|
32
|
+
@thread&.kill
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def run
|
|
38
|
+
until @manager.stopping?
|
|
39
|
+
enqueue_due_jobs unless @manager.quiet?
|
|
40
|
+
sleep(POLL_INTERVAL)
|
|
41
|
+
end
|
|
42
|
+
rescue StandardError => e
|
|
43
|
+
Cogworker.logger.error { "Scheduled poller died: #{e.class}: #{e.message}" }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def enqueue_due_jobs
|
|
47
|
+
now = Time.now.to_f
|
|
48
|
+
SETS.each do |set|
|
|
49
|
+
candidates = Cogworker.config.redis { |c| c.zrangebyscore(set, '-inf', now, limit: [0, 50]) }
|
|
50
|
+
candidates.each { |raw| graduate(set, raw) }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def graduate(set, raw)
|
|
55
|
+
Cogworker.config.redis do |c|
|
|
56
|
+
won = c.zrem(set, raw)
|
|
57
|
+
next unless won
|
|
58
|
+
|
|
59
|
+
job = JSON.parse(raw)
|
|
60
|
+
c.multi do |pipeline|
|
|
61
|
+
pipeline.sadd(RedisKeys::QUEUES, job['queue'])
|
|
62
|
+
pipeline.lpush(RedisKeys.queue(job['queue']), raw)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cogworker
|
|
4
|
+
# OS signal names used for process control. `Launcher` (one process) and
|
|
5
|
+
# `Swarm` (the multi-process supervisor) both trap the same quiet/stop
|
|
6
|
+
# pair identically, and `Swarm` relays them on to its children plus its
|
|
7
|
+
# own phased-restart trigger — named here once so the two trap sites and
|
|
8
|
+
# every relay/kill call site can't drift apart.
|
|
9
|
+
module Signals
|
|
10
|
+
QUIET = 'TSTP'
|
|
11
|
+
STOP = 'TERM'
|
|
12
|
+
INTERRUPT = 'INT'
|
|
13
|
+
RESTART = 'USR2'
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cogworker
|
|
4
|
+
# Aggregate counters. Cheap: a handful of GET/ZCARD/LLEN calls, no keyspace
|
|
5
|
+
# scanning.
|
|
6
|
+
class Stats
|
|
7
|
+
def enqueued
|
|
8
|
+
queue_names.sum { |q| Cogworker.config.redis { |c| c.llen(RedisKeys.queue(q)) } }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def processed
|
|
12
|
+
(Cogworker.config.redis { |c| c.get(RedisKeys::STATS_PROCESSED) } || 0).to_i
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def failed
|
|
16
|
+
(Cogworker.config.redis { |c| c.get(RedisKeys::STATS_FAILED) } || 0).to_i
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def retry_size
|
|
20
|
+
Cogworker.config.redis { |c| c.zcard(RedisKeys::RETRY) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def scheduled_size
|
|
24
|
+
Cogworker.config.redis { |c| c.zcard(RedisKeys::SCHEDULE) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def dead_size
|
|
28
|
+
Cogworker.config.redis { |c| c.zcard(RedisKeys::DEAD) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The raw `INFO` reply as a flat Hash (server/clients/memory/stats/...
|
|
32
|
+
# sections all merged together, same as the `redis` gem always returns
|
|
33
|
+
# it) — the Web UI's Stats tab picks a handful of fields (version,
|
|
34
|
+
# uptime, connected clients, memory usage) back out of this itself,
|
|
35
|
+
# rather than this class pre-selecting/renaming them, so a future
|
|
36
|
+
# consumer isn't limited to whatever subset this method chose.
|
|
37
|
+
def redis_info
|
|
38
|
+
Cogworker.config.redis(&:info)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def queue_names
|
|
44
|
+
Cogworker.config.redis { |c| c.smembers(RedisKeys::QUEUES) }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cogworker
|
|
4
|
+
module Status
|
|
5
|
+
# Client middleware: writes the initial 'queued' status entry right
|
|
6
|
+
# after a job is successfully pushed.
|
|
7
|
+
class ClientMiddleware
|
|
8
|
+
def initialize(expiration)
|
|
9
|
+
@expiration = expiration
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call(_worker_class, job, queue, _redis_pool = nil)
|
|
13
|
+
yield
|
|
14
|
+
Storage.write(job['jid'], @expiration, 'status' => 'queued', 'update_time' => Time.now.to_f,
|
|
15
|
+
'class' => job['class'], 'queue' => queue)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cogworker
|
|
4
|
+
module Status
|
|
5
|
+
# Server middleware: writes 'working' before perform, then
|
|
6
|
+
# 'complete'/'failed'/'retrying' after, per `JobUtil.terminal_failure?`.
|
|
7
|
+
class ServerMiddleware
|
|
8
|
+
def initialize(expiration)
|
|
9
|
+
@expiration = expiration
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call(_worker, job, queue)
|
|
13
|
+
write(job, queue, 'working')
|
|
14
|
+
yield
|
|
15
|
+
write(job, queue, 'complete')
|
|
16
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
17
|
+
write(job, queue, JobUtil.terminal_failure?(job) ? 'failed' : 'retrying',
|
|
18
|
+
error_class: e.class.name, error_message: e.message.to_s[0, 10_000])
|
|
19
|
+
raise e
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def write(job, queue, status, error_class: nil, error_message: nil)
|
|
25
|
+
Storage.write(job['jid'], @expiration, 'status' => status, 'update_time' => Time.now.to_f,
|
|
26
|
+
'class' => job['class'], 'queue' => queue,
|
|
27
|
+
'error_class' => error_class, 'error_message' => error_message)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cogworker
|
|
4
|
+
module Status
|
|
5
|
+
# Shared read/write for `status:<jid>` — a Hash with `status`,
|
|
6
|
+
# `update_time`, `class`, `queue`, and optional `error_class`/
|
|
7
|
+
# `error_message`/`pct`/`message`. TTL is refreshed on every write so a
|
|
8
|
+
# long-running job's status doesn't expire mid-flight relative to its
|
|
9
|
+
# own last update.
|
|
10
|
+
module Storage
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def write(jid, expiration, fields)
|
|
14
|
+
cleaned = fields.compact.transform_values(&:to_s)
|
|
15
|
+
return if cleaned.empty?
|
|
16
|
+
|
|
17
|
+
key = "status:#{jid}"
|
|
18
|
+
Cogworker.config.redis do |c|
|
|
19
|
+
c.hset(key, *cleaned.to_a.flatten)
|
|
20
|
+
c.expire(key, expiration.to_i)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def read(jid)
|
|
25
|
+
hash = Cogworker.config.redis { |c| c.hgetall("status:#{jid}") }
|
|
26
|
+
hash.empty? ? nil : hash
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cogworker
|
|
4
|
+
module Status
|
|
5
|
+
# Include-only mixin (`include Cogworker::Status::Worker` alongside
|
|
6
|
+
# `Cogworker::Worker`). Safe to include and never call anything on — the
|
|
7
|
+
# #at/#store helpers below are optional, for a job that wants to report
|
|
8
|
+
# progress mid-perform; nothing about the required queued/working/
|
|
9
|
+
# complete/failed/retrying lifecycle depends on a job ever calling them.
|
|
10
|
+
module Worker
|
|
11
|
+
def at(pct, message = nil)
|
|
12
|
+
Storage.write(jid, expiration, 'status' => 'working', 'update_time' => Time.now.to_f,
|
|
13
|
+
'pct' => pct, 'message' => message)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def store(fields)
|
|
17
|
+
Storage.write(jid, expiration, fields)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def expiration
|
|
23
|
+
Status.default_expiration || Status::DEFAULT_EXPIRATION
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cogworker
|
|
4
|
+
# Job status/progress tracking: `status:<jid>` in Redis, TTL = the
|
|
5
|
+
# `expiration:` passed to configure_*_middleware. Real usage reads a wider
|
|
6
|
+
# status vocabulary than the base queued/working/complete/failed set —
|
|
7
|
+
# `retrying` (a failed attempt with retries left) is produced here too.
|
|
8
|
+
# `stopped`/`interrupted` are valid values this schema supports (an
|
|
9
|
+
# external reconciler could write them for a job whose owning process
|
|
10
|
+
# vanished mid-flight) but nothing in this gem produces them automatically
|
|
11
|
+
# yet — a crashed process losing its in-flight job's status is the same
|
|
12
|
+
# known limitation as losing the job itself (see Processor/BasicFetch).
|
|
13
|
+
module Status
|
|
14
|
+
DEFAULT_EXPIRATION = 30 * 60 # seconds
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
attr_accessor :default_expiration
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def configure_client_middleware(config, expiration: DEFAULT_EXPIRATION)
|
|
23
|
+
self.default_expiration ||= expiration.to_i
|
|
24
|
+
config.client_middleware { |chain| chain.add(ClientMiddleware, expiration.to_i) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def configure_server_middleware(config, expiration: DEFAULT_EXPIRATION)
|
|
28
|
+
self.default_expiration ||= expiration.to_i
|
|
29
|
+
config.server_middleware { |chain| chain.add(ServerMiddleware, expiration.to_i) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def status(jid)
|
|
33
|
+
Storage.read(jid)&.fetch('status', nil)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def get(jid)
|
|
37
|
+
Storage.read(jid)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|