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,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Cogworker
|
|
6
|
+
# Wraps one job hash pulled off a queue/zset with the delegates TZ 3.4
|
|
7
|
+
# requires. `#klass` (not `#class`) deliberately — overriding Object#class
|
|
8
|
+
# would break is_a?/respond_to?, and the one real caller in this codebase
|
|
9
|
+
# only ever reads `.item['class']` off the hash directly, never a method
|
|
10
|
+
# on this wrapper.
|
|
11
|
+
class JobRecord
|
|
12
|
+
attr_reader :item, :value
|
|
13
|
+
|
|
14
|
+
def initialize(value)
|
|
15
|
+
@value = value
|
|
16
|
+
@item = JSON.parse(value)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def klass = item['class']
|
|
20
|
+
def args = item['args']
|
|
21
|
+
def queue = item['queue']
|
|
22
|
+
def jid = item['jid']
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module Cogworker
|
|
6
|
+
# Shared job-hash helpers: normalizing a client-supplied item, and the one
|
|
7
|
+
# correct `retry:`/`retry_count` accounting used by every caller that needs
|
|
8
|
+
# to know how many attempts a job gets or whether a failure is terminal.
|
|
9
|
+
module JobUtil
|
|
10
|
+
DEFAULT_MAX_RETRY_ATTEMPTS = 25
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# Normalizes a client-supplied job item (symbol or string keys mixed) into
|
|
15
|
+
# the canonical string-keyed job hash used everywhere from here on: client
|
|
16
|
+
# middleware, storage in Redis, server middleware, and the introspection
|
|
17
|
+
# API. Custom `cogworker_options` keys (e.g. `lock_run`) are preserved
|
|
18
|
+
# verbatim — normalization never allowlist-filters keys.
|
|
19
|
+
def normalize_item(item)
|
|
20
|
+
job = item.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
|
|
21
|
+
|
|
22
|
+
job['class'] = job['class'].to_s if job['class'].respond_to?(:to_s) && !job['class'].is_a?(String)
|
|
23
|
+
job['jid'] ||= SecureRandom.hex(12)
|
|
24
|
+
job['queue'] ||= 'default'
|
|
25
|
+
job['args'] ||= []
|
|
26
|
+
job['retry'] = true unless job.key?('retry')
|
|
27
|
+
job['created_at'] ||= Time.now.to_f
|
|
28
|
+
job
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# How many total attempts a job gets, per its `retry:`/`cogworker_options
|
|
32
|
+
# retry:` value: `false` -> none, `true`/absent -> the default cap, an
|
|
33
|
+
# integer -> that many. `job['retry']` round-trips through JSON, so this
|
|
34
|
+
# sees the literal `false`/`true`/Integer/nil, never a String — do NOT
|
|
35
|
+
# simplify this to `job['retry'].to_i`: `FalseClass`/`NilClass` don't
|
|
36
|
+
# define `#to_i` (`nil.to_i` happens to be `0` — coincidentally correct
|
|
37
|
+
# for absent — but `false.to_i` raises `NoMethodError` outright, which is
|
|
38
|
+
# exactly the bug this method exists to not have).
|
|
39
|
+
def max_retries(job)
|
|
40
|
+
case job['retry']
|
|
41
|
+
when false then 0
|
|
42
|
+
when true, nil then DEFAULT_MAX_RETRY_ATTEMPTS
|
|
43
|
+
else job['retry'].to_i
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Whether `job['retry_count']` (attempts already made, *before* this
|
|
48
|
+
# failure) has already reached this job's retry budget — i.e. this
|
|
49
|
+
# failed attempt is the last one it gets, whether or not `Processor` has
|
|
50
|
+
# incremented `retry_count` for it yet. Shared by every place that needs
|
|
51
|
+
# to know if a failure is final: `Processor#route_failure` (routes to
|
|
52
|
+
# retry vs dead), `Status::ServerMiddleware` ('failed' vs 'retrying'),
|
|
53
|
+
# `Periodic::ReleaseMiddleware` (releases the running-lock only on the
|
|
54
|
+
# terminal attempt) — previously three separate, subtly different copies
|
|
55
|
+
# of this same check, two of which had the `max_retries` bug above.
|
|
56
|
+
def terminal_failure?(job)
|
|
57
|
+
job['retry_count'].to_i >= max_retries(job)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cogworker
|
|
4
|
+
# Boots one OS process: the Manager thread pool, the Scheduled poller, the
|
|
5
|
+
# Heartbeat (+ remote quiet!/stop! subscriber), the periodic Ticker, and
|
|
6
|
+
# standard signal handling (TSTP -> quiet, TERM/INT -> stop). This is the
|
|
7
|
+
# method that must only ever run *after* a cogworkerswarm fork in a child,
|
|
8
|
+
# never in the swarm parent itself before forking — see Heartbeat's note
|
|
9
|
+
# on why.
|
|
10
|
+
class Launcher
|
|
11
|
+
def initialize(config = Cogworker.config)
|
|
12
|
+
@config = config
|
|
13
|
+
@manager = Manager.new(config)
|
|
14
|
+
@scheduled = Scheduled.new(@manager)
|
|
15
|
+
@heartbeat = Heartbeat.new(@manager)
|
|
16
|
+
@ticker = Periodic::Ticker.new(
|
|
17
|
+
@manager, config.periodic_manager.entries, catch_up: config.periodic_catch_up
|
|
18
|
+
)
|
|
19
|
+
@signal_queue = ::Queue.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def run
|
|
23
|
+
Cogworker.reset_identity!
|
|
24
|
+
install_signal_traps
|
|
25
|
+
@manager.start!
|
|
26
|
+
@scheduled.start!
|
|
27
|
+
@heartbeat.start!
|
|
28
|
+
@ticker.start!
|
|
29
|
+
log_startup_info
|
|
30
|
+
watch_signals
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def quiet!
|
|
34
|
+
@manager.quiet!
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def stop!
|
|
38
|
+
@manager.stop!
|
|
39
|
+
@scheduled.stop!
|
|
40
|
+
@ticker.stop!
|
|
41
|
+
@heartbeat.stop!
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# Logged once all components are up, so the operator's console shows a
|
|
47
|
+
# single summary (version/identity/pid/concurrency/queues/redis target)
|
|
48
|
+
# right at the point the process is actually ready to pull work, rather
|
|
49
|
+
# than scattered across each component's own start!.
|
|
50
|
+
def log_startup_info
|
|
51
|
+
Cogworker.logger.info do
|
|
52
|
+
"Cogworker #{Cogworker::VERSION} started, identity=#{Cogworker.identity} pid=#{::Process.pid} " \
|
|
53
|
+
"concurrency=#{@config.concurrency} queues=#{@config.queues.join(',')} redis=#{redis_target}"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Redacts a password/credential-bearing URL down to host/db so it's safe
|
|
58
|
+
# to print — this is a startup banner, not a debug dump of secrets.
|
|
59
|
+
def redis_target
|
|
60
|
+
options = @config.redis_options
|
|
61
|
+
if options[:url]
|
|
62
|
+
options[:url].sub(%r{//[^@/]+@}, '//')
|
|
63
|
+
else
|
|
64
|
+
"#{options[:host] || 'localhost'}:#{options[:port] || 6379}/#{options[:db] || 0}"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Signal handlers must do as little as possible; all they do here is
|
|
69
|
+
# push a symbol onto a Queue (safe from trap context) for the dedicated
|
|
70
|
+
# watcher thread (see #watch_signals) to act on.
|
|
71
|
+
def install_signal_traps
|
|
72
|
+
Signal.trap(Signals::QUIET) { @signal_queue << :quiet }
|
|
73
|
+
Signal.trap(Signals::STOP) { @signal_queue << :stop }
|
|
74
|
+
Signal.trap(Signals::INTERRUPT) { @signal_queue << :stop }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def watch_signals
|
|
78
|
+
loop do
|
|
79
|
+
case @signal_queue.pop
|
|
80
|
+
when :quiet
|
|
81
|
+
Cogworker.logger.info { "Received quiet signal, identity=#{Cogworker.identity}" }
|
|
82
|
+
quiet!
|
|
83
|
+
when :stop
|
|
84
|
+
Cogworker.logger.info { "Received stop signal, identity=#{Cogworker.identity}" }
|
|
85
|
+
stop!
|
|
86
|
+
break
|
|
87
|
+
else
|
|
88
|
+
Cogworker.logger.warn { "Received unknown signal, identity=#{Cogworker.identity}" }
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'logger'
|
|
4
|
+
|
|
5
|
+
module Cogworker
|
|
6
|
+
# Builds the default `Logger` (used unless `Cogworker.logger=` overrides
|
|
7
|
+
# it), with a compact one-line-per-entry format including pid.
|
|
8
|
+
module Logging
|
|
9
|
+
def self.default_logger(io = $stdout)
|
|
10
|
+
logger = ::Logger.new(io)
|
|
11
|
+
logger.level = ::Logger::INFO
|
|
12
|
+
logger.formatter = proc do |severity, time, _progname, msg|
|
|
13
|
+
"#{time.utc.iso8601} pid=#{::Process.pid} #{severity}: #{msg}\n"
|
|
14
|
+
end
|
|
15
|
+
logger
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cogworker
|
|
4
|
+
# Owns the `concurrency`-sized thread pool of Processors for one OS
|
|
5
|
+
# process, and the quiet/stop state they all poll.
|
|
6
|
+
class Manager
|
|
7
|
+
def initialize(config = Cogworker.config)
|
|
8
|
+
@config = config
|
|
9
|
+
@processors = Array.new(config.concurrency) { Processor.new(self) }
|
|
10
|
+
@quiet = false
|
|
11
|
+
@stopping = false
|
|
12
|
+
@busy_mutex = Mutex.new
|
|
13
|
+
@busy_count = 0
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def queues
|
|
17
|
+
@config.queues
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def start!
|
|
21
|
+
@processors.each(&:start!)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def quiet!
|
|
25
|
+
@quiet = true
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def quiet?
|
|
29
|
+
@quiet
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def stopping?
|
|
33
|
+
@stopping
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def busy_count
|
|
37
|
+
@busy_mutex.synchronize { @busy_count }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def processor_busy!
|
|
41
|
+
@busy_mutex.synchronize { @busy_count += 1 }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def processor_idle!
|
|
45
|
+
@busy_mutex.synchronize { @busy_count -= 1 }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Waits (up to `timeout` seconds) for in-flight jobs to finish, then
|
|
49
|
+
# returns. Does not force-kill lingering threads past the deadline — a
|
|
50
|
+
# thread stuck past the deadline is the caller's (Launcher's) problem to
|
|
51
|
+
# decide whether to hard-exit the process.
|
|
52
|
+
def stop!(timeout: 25)
|
|
53
|
+
@quiet = true
|
|
54
|
+
@stopping = true
|
|
55
|
+
deadline = monotonic_now + timeout
|
|
56
|
+
@processors.each do |p|
|
|
57
|
+
remaining = deadline - monotonic_now
|
|
58
|
+
p.thread&.join([remaining, 0].max)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
# Fully qualified: inside the Cogworker namespace, a bare `Process` would
|
|
65
|
+
# resolve to Cogworker::Process (the ProcessSet entry wrapper in api.rb),
|
|
66
|
+
# not the ::Process kernel module.
|
|
67
|
+
def monotonic_now
|
|
68
|
+
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cogworker
|
|
4
|
+
module Middleware
|
|
5
|
+
# One registered middleware: a class plus the args its initializer takes.
|
|
6
|
+
# A fresh instance is built on every #invoke (not cached/reused) — some
|
|
7
|
+
# existing middleware (e.g. a WorkerKiller with a class-level Mutex
|
|
8
|
+
# constant) is written expecting per-call instantiation.
|
|
9
|
+
Entry = Struct.new(:klass, :args) do
|
|
10
|
+
def build
|
|
11
|
+
klass.new(*args)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# An ordered list of middleware. #add appends, preserving registration
|
|
16
|
+
# order as call order. Works for both the server chain
|
|
17
|
+
# (call(worker, job, queue, &block)) and the client chain
|
|
18
|
+
# (call(worker_class, job, queue, redis_pool, &block)) — the chain itself
|
|
19
|
+
# is arity-agnostic, it just threads whatever args #invoke is given
|
|
20
|
+
# through every entry plus a continuation block.
|
|
21
|
+
class Chain
|
|
22
|
+
include Enumerable
|
|
23
|
+
|
|
24
|
+
def initialize
|
|
25
|
+
@entries = []
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def add(klass, *args)
|
|
29
|
+
remove(klass)
|
|
30
|
+
@entries << Entry.new(klass, args)
|
|
31
|
+
self
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def remove(klass)
|
|
35
|
+
@entries.delete_if { |e| e.klass == klass }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def each(&block)
|
|
39
|
+
@entries.each(&block)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def empty?
|
|
43
|
+
@entries.empty?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Invokes every entry in registration order, each wrapping the next,
|
|
47
|
+
# with `final_block` as the innermost call.
|
|
48
|
+
def invoke(*call_args, &final_block)
|
|
49
|
+
traverse(@entries.dup, call_args, &final_block)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def traverse(remaining, call_args, &final_block)
|
|
55
|
+
if remaining.empty?
|
|
56
|
+
final_block.call
|
|
57
|
+
else
|
|
58
|
+
entry = remaining.shift
|
|
59
|
+
entry.build.call(*call_args) { traverse(remaining, call_args, &final_block) }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
-- KEYS[1] = periodic:running:<pjid>
|
|
2
|
+
-- KEYS[2] = periodic:last_slot:<pjid>
|
|
3
|
+
-- KEYS[3] = periodic:lock:<pjid>:<slot>
|
|
4
|
+
-- ARGV[1] = slot (epoch int)
|
|
5
|
+
-- ARGV[2] = unique mode ("until_executed" or "")
|
|
6
|
+
-- ARGV[3] = lock TTL (seconds)
|
|
7
|
+
--
|
|
8
|
+
-- Returns 1 if this call won the claim for this slot (the caller should
|
|
9
|
+
-- enqueue the job), 0 otherwise. Composes two guards: `last_slot` stops a
|
|
10
|
+
-- process re-firing the same (or an earlier) slot on a later tick, and the
|
|
11
|
+
-- NX lock breaks the narrow race where two processes both pass the
|
|
12
|
+
-- `last_slot` check before either has written it back.
|
|
13
|
+
if ARGV[2] == "until_executed" and redis.call("GET", KEYS[1]) then
|
|
14
|
+
return 0
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
local last = tonumber(redis.call("GET", KEYS[2]) or "0")
|
|
18
|
+
if tonumber(ARGV[1]) <= last then
|
|
19
|
+
return 0
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
if not redis.call("SET", KEYS[3], "1", "NX", "EX", ARGV[3]) then
|
|
23
|
+
return 0
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
redis.call("SET", KEYS[2], ARGV[1])
|
|
27
|
+
return 1
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest/sha1'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module Cogworker
|
|
7
|
+
module Periodic
|
|
8
|
+
# One `mgr.register(...)` call. `pjid` is derived purely from the entry's
|
|
9
|
+
# own content (cron + class + args), so it is identical across every
|
|
10
|
+
# process/child that loads the same schedule, and stable across restarts
|
|
11
|
+
# as long as the schedule line itself doesn't change. Two registrations
|
|
12
|
+
# of the same class with different args (seen in the real schedule, e.g.
|
|
13
|
+
# MaterializedViewRefreshTopJob) therefore get distinct, stable pjids.
|
|
14
|
+
Entry = Struct.new(:cron, :class_name, :retry, :unique, :args, keyword_init: true) do
|
|
15
|
+
def pjid
|
|
16
|
+
Digest::SHA1.hexdigest("#{cron}|#{class_name}|#{args.to_json}")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def until_executed?
|
|
20
|
+
unique == :until_executed
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cogworker
|
|
4
|
+
module Periodic
|
|
5
|
+
# DSL object yielded to `config.periodic { |mgr| mgr.register(...) }`.
|
|
6
|
+
# Collects entries synchronously as the block runs; the actual cron
|
|
7
|
+
# ticking/claiming (Periodic::Ticker) starts later, from each process's
|
|
8
|
+
# post-fork startup hook, and reads the entries accumulated here.
|
|
9
|
+
class Manager
|
|
10
|
+
attr_reader :entries
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
@entries = []
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# `retry:` can't be read back as a bare local variable inside the
|
|
17
|
+
# method body (Ruby always lexes a bare `retry` as the retry-keyword,
|
|
18
|
+
# even when it's also a keyword-arg name), so options are captured via
|
|
19
|
+
# **opts instead of named keyword params.
|
|
20
|
+
def register(cron, class_name, **opts)
|
|
21
|
+
entry = Entry.new(cron: cron, class_name: class_name, retry: opts[:retry] || 0,
|
|
22
|
+
unique: opts[:unique], args: opts[:args] || [])
|
|
23
|
+
@entries << entry
|
|
24
|
+
entry
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cogworker
|
|
4
|
+
module Periodic
|
|
5
|
+
# Releases the `periodic:running:<pjid>` lock (used only for
|
|
6
|
+
# `unique: :until_executed` entries) on success, or on the terminal
|
|
7
|
+
# failed attempt. A no-op for any job that isn't periodic-scheduled
|
|
8
|
+
# (`job['periodic_pjid']` absent). Registered unconditionally in every
|
|
9
|
+
# Config, not gated on whether `config.periodic` is actually used, so it
|
|
10
|
+
# never becomes a hidden dependency of the (separate) job status layer.
|
|
11
|
+
class ReleaseMiddleware
|
|
12
|
+
def call(_worker, job, _queue)
|
|
13
|
+
yield
|
|
14
|
+
release(job) if job['periodic_pjid']
|
|
15
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
16
|
+
release(job) if job['periodic_pjid'] && JobUtil.terminal_failure?(job)
|
|
17
|
+
raise e
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def release(job)
|
|
23
|
+
Cogworker.config.redis { |c| c.del(RedisKeys.periodic_running(job['periodic_pjid'])) }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fugit'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module Cogworker
|
|
7
|
+
module Periodic
|
|
8
|
+
# One per process, started from the same post-fork boot path as
|
|
9
|
+
# Heartbeat/Scheduled (never before a swarm fork). Every TICK_INTERVAL,
|
|
10
|
+
# for each registered entry, computes the most recent cron slot and — if
|
|
11
|
+
# this process hasn't already handled that exact slot — attempts the
|
|
12
|
+
# atomic Lua claim; only the winner enqueues the job.
|
|
13
|
+
class Ticker
|
|
14
|
+
TICK_INTERVAL = 5
|
|
15
|
+
LOCK_TTL = TICK_INTERVAL * 4
|
|
16
|
+
|
|
17
|
+
CLAIM_SCRIPT = File.read(File.join(__dir__, 'claim.lua'))
|
|
18
|
+
|
|
19
|
+
def initialize(manager, entries, catch_up: true)
|
|
20
|
+
@manager = manager
|
|
21
|
+
@entries = entries
|
|
22
|
+
@catch_up = catch_up
|
|
23
|
+
@last_checked_slot = {}
|
|
24
|
+
@cron_cache = {}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def start!
|
|
28
|
+
publish_schedule!
|
|
29
|
+
@thread = Thread.new { run }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# See Scheduled#stop! for why this kills outright rather than joining:
|
|
33
|
+
# same non-daemon-thread-blocks-process-exit hazard, same up-to-5s
|
|
34
|
+
# (TICK_INTERVAL) sleep it would otherwise have to wake from first.
|
|
35
|
+
def stop!
|
|
36
|
+
@thread&.kill
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# Persisted for restart survival and Web UI visibility. Written from
|
|
42
|
+
# here (not at DSL-registration time in Config#periodic) so it never
|
|
43
|
+
# depends on `config.redis =` having already run — every process that
|
|
44
|
+
# boots re-writes the same idempotent entries regardless of ordering.
|
|
45
|
+
def publish_schedule!
|
|
46
|
+
return if @entries.empty?
|
|
47
|
+
|
|
48
|
+
payloads = @entries.each_with_object({}) do |entry, h|
|
|
49
|
+
h[entry.pjid] = JSON.generate(
|
|
50
|
+
'cron' => entry.cron, 'class' => entry.class_name, 'retry' => entry.retry,
|
|
51
|
+
'unique' => entry.unique, 'args' => entry.args
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
Cogworker.config.redis { |c| c.hset(RedisKeys::PERIODIC_SCHEDULE, *payloads.to_a.flatten) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def run
|
|
58
|
+
until @manager.stopping?
|
|
59
|
+
tick unless @manager.quiet?
|
|
60
|
+
sleep(TICK_INTERVAL)
|
|
61
|
+
end
|
|
62
|
+
rescue StandardError => e
|
|
63
|
+
Cogworker.logger.error { "Periodic ticker died: #{e.class}: #{e.message}" }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def tick
|
|
67
|
+
now = Time.now
|
|
68
|
+
@entries.each do |entry|
|
|
69
|
+
slot = cron_for(entry).previous_time(now).to_i
|
|
70
|
+
next if @last_checked_slot[entry.pjid] == slot
|
|
71
|
+
|
|
72
|
+
enqueue(entry, slot) if claim?(entry, slot)
|
|
73
|
+
@last_checked_slot[entry.pjid] = slot
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def cron_for(entry)
|
|
78
|
+
@cron_cache[entry.pjid] ||= Fugit::Cron.parse(entry.cron)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def claim?(entry, slot)
|
|
82
|
+
return false if !@catch_up && priming_first_slot?(entry, slot)
|
|
83
|
+
|
|
84
|
+
result = Cogworker.config.redis do |c|
|
|
85
|
+
c.eval(CLAIM_SCRIPT,
|
|
86
|
+
keys: [RedisKeys.periodic_running(entry.pjid), RedisKeys.periodic_last_slot(entry.pjid),
|
|
87
|
+
RedisKeys.periodic_lock(entry.pjid, slot)],
|
|
88
|
+
argv: [slot, entry.unique.to_s, LOCK_TTL])
|
|
89
|
+
end
|
|
90
|
+
result == 1
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# With catch-up disabled, an entry's very first tick — ever, across
|
|
94
|
+
# every process, since `periodic:last_slot:<pjid>` doesn't exist yet —
|
|
95
|
+
# must not fire the most-recently-due slot: that's exactly what makes
|
|
96
|
+
# every registered entry fire at once on a cold start against an
|
|
97
|
+
# empty/reset Redis (fresh deploy, Redis loss/restore). SETNX-ing the
|
|
98
|
+
# slot as the baseline (instead of enqueueing) fixes that without
|
|
99
|
+
# touching the claim script: it's a genuine "first tick" only when the
|
|
100
|
+
# key doesn't already exist, so racing sibling processes at boot agree
|
|
101
|
+
# on one winner (the rest see NX fail and skip too, same as any other
|
|
102
|
+
# tick), and an ordinary restart — where `last_slot` already persists
|
|
103
|
+
# from a prior run — falls through to the real claim below unchanged,
|
|
104
|
+
# still firing at most one catch-up run for whatever slot is due.
|
|
105
|
+
def priming_first_slot?(entry, slot)
|
|
106
|
+
return false unless @last_checked_slot[entry.pjid].nil?
|
|
107
|
+
|
|
108
|
+
Cogworker.config.redis { |c| c.set(RedisKeys.periodic_last_slot(entry.pjid), slot, nx: true) }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def enqueue(entry, slot)
|
|
112
|
+
job = {
|
|
113
|
+
'class' => entry.class_name, 'args' => entry.args, 'retry' => entry.retry,
|
|
114
|
+
'periodic_pjid' => entry.pjid, 'periodic_slot' => slot
|
|
115
|
+
}
|
|
116
|
+
jid = Client.push(job)
|
|
117
|
+
return unless entry.until_executed?
|
|
118
|
+
|
|
119
|
+
Cogworker.config.redis { |c| c.set(RedisKeys.periodic_running(entry.pjid), jid) }
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
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`.
|
|
9
|
+
#
|
|
10
|
+
# Named `Cogworker::Process`, not `::Process` — inside this namespace a
|
|
11
|
+
# bare `Process` resolves to this class, not the Kernel module, so any
|
|
12
|
+
# code in lib/cogworker/** that means the OS process must say `::Process`
|
|
13
|
+
# explicitly.
|
|
14
|
+
class Process
|
|
15
|
+
def initialize(hash)
|
|
16
|
+
@hash = hash
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def [](key) = @hash[key]
|
|
20
|
+
def identity = @hash['identity']
|
|
21
|
+
|
|
22
|
+
def quiet!
|
|
23
|
+
publish('quiet')
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def stop!
|
|
27
|
+
publish('stop')
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def publish(message)
|
|
33
|
+
Cogworker.config.redis { |c| c.publish(RedisKeys.signal(identity), message) }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Cogworker
|
|
6
|
+
class ProcessSet
|
|
7
|
+
include Enumerable
|
|
8
|
+
|
|
9
|
+
def each
|
|
10
|
+
identities = Cogworker.config.redis { |c| c.smembers(RedisKeys::PROCESSES) }
|
|
11
|
+
identities.each do |identity|
|
|
12
|
+
info = Cogworker.config.redis { |c| c.hgetall(RedisKeys.process(identity)) }
|
|
13
|
+
if info.empty?
|
|
14
|
+
Cogworker.config.redis { |c| c.srem(RedisKeys::PROCESSES, identity) }
|
|
15
|
+
next
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
parsed = JSON.parse(info['info'] || '{}').merge(
|
|
19
|
+
'identity' => identity, 'busy' => info['busy'].to_i, 'quiet' => info['quiet'] == 'true'
|
|
20
|
+
)
|
|
21
|
+
yield Process.new(parsed)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def size
|
|
26
|
+
count { true }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|