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.
Files changed (73) hide show
  1. checksums.yaml +7 -0
  2. data/exe/cogworker +6 -0
  3. data/exe/cogworkerswarm +6 -0
  4. data/lib/cogworker/basic_fetch.rb +27 -0
  5. data/lib/cogworker/cli.rb +64 -0
  6. data/lib/cogworker/client.rb +61 -0
  7. data/lib/cogworker/component.rb +19 -0
  8. data/lib/cogworker/config.rb +84 -0
  9. data/lib/cogworker/config_loader.rb +22 -0
  10. data/lib/cogworker/heartbeat.rb +150 -0
  11. data/lib/cogworker/history/middleware.rb +18 -0
  12. data/lib/cogworker/history/storage.rb +81 -0
  13. data/lib/cogworker/history.rb +33 -0
  14. data/lib/cogworker/job.rb +75 -0
  15. data/lib/cogworker/job_record.rb +24 -0
  16. data/lib/cogworker/job_util.rb +60 -0
  17. data/lib/cogworker/launcher.rb +93 -0
  18. data/lib/cogworker/logging.rb +18 -0
  19. data/lib/cogworker/manager.rb +71 -0
  20. data/lib/cogworker/middleware/chain.rb +64 -0
  21. data/lib/cogworker/periodic/claim.lua +27 -0
  22. data/lib/cogworker/periodic/entry.rb +24 -0
  23. data/lib/cogworker/periodic/manager.rb +28 -0
  24. data/lib/cogworker/periodic/release_middleware.rb +27 -0
  25. data/lib/cogworker/periodic/ticker.rb +123 -0
  26. data/lib/cogworker/process.rb +36 -0
  27. data/lib/cogworker/process_set.rb +29 -0
  28. data/lib/cogworker/processor.rb +129 -0
  29. data/lib/cogworker/prometheus/exporter.rb +62 -0
  30. data/lib/cogworker/queue.rb +60 -0
  31. data/lib/cogworker/redis_connection.rb +37 -0
  32. data/lib/cogworker/redis_keys.rb +32 -0
  33. data/lib/cogworker/scheduled.rb +67 -0
  34. data/lib/cogworker/signals.rb +15 -0
  35. data/lib/cogworker/stats.rb +47 -0
  36. data/lib/cogworker/status/client_middleware.rb +19 -0
  37. data/lib/cogworker/status/server_middleware.rb +31 -0
  38. data/lib/cogworker/status/storage.rb +30 -0
  39. data/lib/cogworker/status/worker.rb +27 -0
  40. data/lib/cogworker/status.rb +40 -0
  41. data/lib/cogworker/swarm.rb +169 -0
  42. data/lib/cogworker/testing.rb +109 -0
  43. data/lib/cogworker/unique_jobs/client_middleware.rb +31 -0
  44. data/lib/cogworker/unique_jobs/release_middleware.rb +30 -0
  45. data/lib/cogworker/unique_jobs.rb +32 -0
  46. data/lib/cogworker/version.rb +5 -0
  47. data/lib/cogworker/web/action.rb +63 -0
  48. data/lib/cogworker/web/application.rb +62 -0
  49. data/lib/cogworker/web/assets/ag-grid/ag-grid-community.min.js +1 -0
  50. data/lib/cogworker/web/assets/ag-grid/ag-grid.min.css +7 -0
  51. data/lib/cogworker/web/assets/ag-grid/ag-theme-alpine.min.css +2 -0
  52. data/lib/cogworker/web/assets/chart.umd.min.js +13 -0
  53. data/lib/cogworker/web/assets/htmx.min.js +1 -0
  54. data/lib/cogworker/web/assets/tailwind.css +1 -0
  55. data/lib/cogworker/web/layout.rb +352 -0
  56. data/lib/cogworker/web/router.rb +27 -0
  57. data/lib/cogworker/web/routes/busy.rb +99 -0
  58. data/lib/cogworker/web/routes/dead.rb +93 -0
  59. data/lib/cogworker/web/routes/history.rb +226 -0
  60. data/lib/cogworker/web/routes/periodic.rb +67 -0
  61. data/lib/cogworker/web/routes/queues.rb +101 -0
  62. data/lib/cogworker/web/routes/retries.rb +89 -0
  63. data/lib/cogworker/web/routes/save_session.rb +21 -0
  64. data/lib/cogworker/web/routes/scheduled.rb +49 -0
  65. data/lib/cogworker/web/routes/stats.rb +298 -0
  66. data/lib/cogworker/web/views.rb +25 -0
  67. data/lib/cogworker/web.rb +257 -0
  68. data/lib/cogworker/work.rb +19 -0
  69. data/lib/cogworker/work_set.rb +23 -0
  70. data/lib/cogworker/worker.rb +5 -0
  71. data/lib/cogworker/workers.rb +8 -0
  72. data/lib/cogworker.rb +114 -0
  73. 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