zeridion-flare 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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +175 -0
- data/lib/zeridion_flare/client.rb +298 -0
- data/lib/zeridion_flare/errors.rb +34 -0
- data/lib/zeridion_flare/version.rb +7 -0
- data/lib/zeridion_flare/webhook.rb +74 -0
- data/lib/zeridion_flare/worker/cancellation_token.rb +89 -0
- data/lib/zeridion_flare/worker/configuration.rb +97 -0
- data/lib/zeridion_flare/worker/errors.rb +44 -0
- data/lib/zeridion_flare/worker/executor.rb +98 -0
- data/lib/zeridion_flare/worker/heartbeat.rb +123 -0
- data/lib/zeridion_flare/worker/identity.rb +68 -0
- data/lib/zeridion_flare/worker/job.rb +85 -0
- data/lib/zeridion_flare/worker/job_context.rb +91 -0
- data/lib/zeridion_flare/worker/job_definition.rb +58 -0
- data/lib/zeridion_flare/worker/pool.rb +118 -0
- data/lib/zeridion_flare/worker/progress_slot.rb +43 -0
- data/lib/zeridion_flare/worker/recurring_job.rb +81 -0
- data/lib/zeridion_flare/worker/recurring_schedule.rb +51 -0
- data/lib/zeridion_flare/worker/registry.rb +100 -0
- data/lib/zeridion_flare/worker/runner.rb +313 -0
- data/lib/zeridion_flare/worker/worker.rb +211 -0
- data/lib/zeridion_flare/worker.rb +39 -0
- data/lib/zeridion_flare.rb +14 -0
- metadata +103 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Zeridion
|
|
6
|
+
module Flare
|
|
7
|
+
# Context handed to every job's #perform during execution. Constructed by
|
|
8
|
+
# the worker runtime — never built by application code.
|
|
9
|
+
#
|
|
10
|
+
# Lives at Zeridion::Flare::JobContext (not under ::Worker) to mirror the
|
|
11
|
+
# top-level JobContext in the reference SDK, so a `ctx` parameter reads the
|
|
12
|
+
# same across languages.
|
|
13
|
+
class JobContext
|
|
14
|
+
# @return [String] unique id of this job execution.
|
|
15
|
+
attr_reader :job_id
|
|
16
|
+
|
|
17
|
+
# @return [String] the routing job_type for this execution.
|
|
18
|
+
attr_reader :job_type
|
|
19
|
+
|
|
20
|
+
# @return [Integer] 1-based attempt counter.
|
|
21
|
+
attr_reader :attempt
|
|
22
|
+
|
|
23
|
+
# @return [Integer] max attempts configured for this job.
|
|
24
|
+
attr_reader :max_attempts
|
|
25
|
+
|
|
26
|
+
# @return [Time, nil] when the job was originally enqueued (offset-aware).
|
|
27
|
+
# nil if the server sent an unparseable timestamp — parsing never raises
|
|
28
|
+
# into dispatch.
|
|
29
|
+
attr_reader :enqueued_at
|
|
30
|
+
|
|
31
|
+
# @return [Logger] logger pre-bound with this job's fields so handler logs
|
|
32
|
+
# auto-correlate.
|
|
33
|
+
attr_reader :logger
|
|
34
|
+
|
|
35
|
+
# @return [Worker::CancellationToken] the cooperative cancellation handle.
|
|
36
|
+
attr_reader :cancellation
|
|
37
|
+
|
|
38
|
+
def initialize(job_id:, job_type:, attempt:, max_attempts:, enqueued_at:, logger:, cancellation:,
|
|
39
|
+
progress_slot:)
|
|
40
|
+
@job_id = job_id
|
|
41
|
+
@job_type = job_type
|
|
42
|
+
@attempt = attempt
|
|
43
|
+
@max_attempts = max_attempts
|
|
44
|
+
@enqueued_at = enqueued_at
|
|
45
|
+
@logger = logger
|
|
46
|
+
@cancellation = cancellation
|
|
47
|
+
@progress_slot = progress_slot
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @return [Boolean] true once cancellation has been requested (server
|
|
51
|
+
# "cancel", host shutdown, or timeout). Check this in long handlers.
|
|
52
|
+
def cancelled?
|
|
53
|
+
@cancellation.cancelled?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Raise Worker::JobCancelledError if cancellation has been requested.
|
|
57
|
+
def check_cancellation!
|
|
58
|
+
@cancellation.check!
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Report progress (0.0–1.0) for the dashboard. Clamp-only last-write-wins:
|
|
62
|
+
# the latest value rides the next heartbeat. nil / NaN / <= 0 are dropped
|
|
63
|
+
# (no beat carries them); > 1 is clamped to 1.0. Never raises.
|
|
64
|
+
#
|
|
65
|
+
# @param fraction [Numeric]
|
|
66
|
+
# @return [void]
|
|
67
|
+
def report_progress(fraction)
|
|
68
|
+
@progress_slot.set(fraction)
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Parse a server RFC-3339 timestamp defensively. Accepts both `Z` and a
|
|
73
|
+
# numeric offset. Returns nil (never raises) on a malformed value so a bad
|
|
74
|
+
# timestamp can't kill dispatch.
|
|
75
|
+
#
|
|
76
|
+
# @param raw [String, nil]
|
|
77
|
+
# @return [Time, nil]
|
|
78
|
+
def self.parse_timestamp(raw)
|
|
79
|
+
return nil if raw.nil? || raw.to_s.empty?
|
|
80
|
+
|
|
81
|
+
Time.iso8601(raw.to_s)
|
|
82
|
+
rescue ArgumentError, TypeError
|
|
83
|
+
begin
|
|
84
|
+
Time.parse(raw.to_s)
|
|
85
|
+
rescue ArgumentError, TypeError
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zeridion
|
|
4
|
+
module Flare
|
|
5
|
+
module Worker
|
|
6
|
+
# Resolved, immutable metadata for one registered job class — the Ruby
|
|
7
|
+
# analog of the reference SDK's JobTypeInfo. Built by the Registry from a
|
|
8
|
+
# job class's `flare_options`. `recurring?` distinguishes the two #perform
|
|
9
|
+
# arities (payload vs payloadless).
|
|
10
|
+
class JobDefinition
|
|
11
|
+
attr_reader :job_type, :job_class, :queue, :max_attempts, :timeout_seconds,
|
|
12
|
+
:cron, :timezone, :payload_struct
|
|
13
|
+
|
|
14
|
+
def initialize(job_type:, job_class:, queue:, max_attempts:, timeout_seconds:,
|
|
15
|
+
cron: nil, timezone: nil, payload_struct: nil)
|
|
16
|
+
@job_type = job_type
|
|
17
|
+
@job_class = job_class
|
|
18
|
+
@queue = queue
|
|
19
|
+
@max_attempts = max_attempts
|
|
20
|
+
@timeout_seconds = timeout_seconds
|
|
21
|
+
@cron = cron
|
|
22
|
+
@timezone = timezone
|
|
23
|
+
@payload_struct = payload_struct
|
|
24
|
+
freeze
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def recurring?
|
|
28
|
+
!@cron.nil?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Build from a class that included Zeridion::Flare::Job.
|
|
32
|
+
def self.from_payload_class(klass)
|
|
33
|
+
new(
|
|
34
|
+
job_type: klass.flare_job_type,
|
|
35
|
+
job_class: klass,
|
|
36
|
+
queue: klass.flare_queue,
|
|
37
|
+
max_attempts: klass.flare_max_attempts,
|
|
38
|
+
timeout_seconds: klass.flare_timeout,
|
|
39
|
+
payload_struct: klass.flare_payload_struct,
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Build from a class that included Zeridion::Flare::RecurringJob.
|
|
44
|
+
def self.from_recurring_class(klass)
|
|
45
|
+
new(
|
|
46
|
+
job_type: klass.flare_job_type,
|
|
47
|
+
job_class: klass,
|
|
48
|
+
queue: klass.flare_queue,
|
|
49
|
+
max_attempts: klass.flare_max_attempts,
|
|
50
|
+
timeout_seconds: klass.flare_timeout,
|
|
51
|
+
cron: klass.flare_cron,
|
|
52
|
+
timezone: klass.flare_timezone,
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zeridion
|
|
4
|
+
module Flare
|
|
5
|
+
module Worker
|
|
6
|
+
# Bounded-concurrency job pool: `concurrency` long-lived worker threads
|
|
7
|
+
# draining a work queue, gated by a permit SizedQueue so the runtime can
|
|
8
|
+
# read free slots in O(1) for the poll `capacity`.
|
|
9
|
+
#
|
|
10
|
+
# Contract: the caller MUST #acquire a permit BEFORE #submit-ting a job,
|
|
11
|
+
# and the pool releases that permit when the job's block returns (success
|
|
12
|
+
# or error). This keeps `free_slots` honest: it never schedules more than
|
|
13
|
+
# `concurrency` jobs, and `capacity` reflects true free slots.
|
|
14
|
+
class Pool
|
|
15
|
+
def initialize(concurrency:)
|
|
16
|
+
@concurrency = concurrency
|
|
17
|
+
@work = Thread::Queue.new
|
|
18
|
+
# Pre-fill the permit queue: `concurrency` available slots.
|
|
19
|
+
@permits = Thread::SizedQueue.new(concurrency)
|
|
20
|
+
concurrency.times { @permits.push(:permit) }
|
|
21
|
+
@threads = []
|
|
22
|
+
@started = false
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Spawn the worker threads. Idempotent.
|
|
26
|
+
def start
|
|
27
|
+
return self if @started
|
|
28
|
+
|
|
29
|
+
@started = true
|
|
30
|
+
@concurrency.times do |i|
|
|
31
|
+
@threads << Thread.new do
|
|
32
|
+
if Thread.current.respond_to?(:name=)
|
|
33
|
+
Thread.current.name = "flare-job-#{i}"
|
|
34
|
+
end
|
|
35
|
+
loop do
|
|
36
|
+
task = @work.pop
|
|
37
|
+
break if task == :__stop__
|
|
38
|
+
|
|
39
|
+
begin
|
|
40
|
+
task.call
|
|
41
|
+
ensure
|
|
42
|
+
release
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
self
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @return [Integer] current free slots (O(1)).
|
|
51
|
+
def free_slots
|
|
52
|
+
@permits.size
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Try to reserve a slot without blocking. Returns true if a permit was
|
|
56
|
+
# taken (the caller must then #submit and the pool will release it), or
|
|
57
|
+
# false if the pool is saturated.
|
|
58
|
+
def try_acquire
|
|
59
|
+
@permits.pop(true)
|
|
60
|
+
true
|
|
61
|
+
rescue ThreadError
|
|
62
|
+
false
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Enqueue a job block onto a worker thread. A permit must already be
|
|
66
|
+
# held (via #try_acquire). The pool releases the permit after the block
|
|
67
|
+
# runs.
|
|
68
|
+
def submit(&block)
|
|
69
|
+
@work.push(block)
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @return [Integer] number of jobs currently executing.
|
|
74
|
+
def in_flight
|
|
75
|
+
@concurrency - free_slots
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Drain: stop accepting new work and wait up to `timeout` seconds for
|
|
79
|
+
# in-flight jobs to finish, then tear down the threads. Returns true if
|
|
80
|
+
# all threads exited within the timeout, false if some were still
|
|
81
|
+
# running (the caller decides what to do — we do NOT kill them, since a
|
|
82
|
+
# Thread#kill mid-Net::HTTP can corrupt the socket).
|
|
83
|
+
def shutdown(timeout:)
|
|
84
|
+
deadline = monotonic_now + timeout.to_f
|
|
85
|
+
# Tell each thread to exit once it has finished its current job and
|
|
86
|
+
# drained any queued work.
|
|
87
|
+
@concurrency.times { @work.push(:__stop__) }
|
|
88
|
+
|
|
89
|
+
all_joined = true
|
|
90
|
+
@threads.each do |t|
|
|
91
|
+
remaining = deadline - monotonic_now
|
|
92
|
+
remaining = 0 if remaining.negative?
|
|
93
|
+
all_joined = false if t.join(remaining).nil?
|
|
94
|
+
end
|
|
95
|
+
all_joined
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Are all worker threads dead? Used by tests to assert clean teardown.
|
|
99
|
+
def all_threads_dead?
|
|
100
|
+
@threads.none?(&:alive?)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def release
|
|
106
|
+
@permits.push(:permit)
|
|
107
|
+
rescue ThreadError
|
|
108
|
+
# Permit queue closed during shutdown — nothing to release.
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def monotonic_now
|
|
113
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zeridion
|
|
4
|
+
module Flare
|
|
5
|
+
module Worker
|
|
6
|
+
# Holds the latest progress value for one in-flight job. Clamp-only,
|
|
7
|
+
# last-write-wins (decision D3): the server enforces monotonicity, so the
|
|
8
|
+
# client never adds its own "non-decreasing" guard. Values that are nil,
|
|
9
|
+
# NaN, <= 0, or otherwise out of range are dropped — #get then returns nil
|
|
10
|
+
# and the heartbeat omits the `progress` key entirely.
|
|
11
|
+
#
|
|
12
|
+
# A value > 1 is clamped to 1.0 (the protocol says clamp the high end but
|
|
13
|
+
# treat <= 0 / NaN as unset). Mutex-guarded so a free-threaded Ruby
|
|
14
|
+
# (3.13+ `--mjit`/`--yjit` no-GVL builds) sees a consistent value across
|
|
15
|
+
# the handler thread (which writes) and the heartbeat thread (which reads).
|
|
16
|
+
class ProgressSlot
|
|
17
|
+
def initialize
|
|
18
|
+
@mutex = Mutex.new
|
|
19
|
+
@value = nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Record a progress value. nil / NaN / <= 0 → drop (slot unchanged).
|
|
23
|
+
# > 1 → clamp to 1.0. Otherwise store as-is.
|
|
24
|
+
def set(value)
|
|
25
|
+
return if value.nil?
|
|
26
|
+
return unless value.is_a?(Numeric)
|
|
27
|
+
|
|
28
|
+
f = value.to_f
|
|
29
|
+
return if f.nan?
|
|
30
|
+
return if f <= 0.0
|
|
31
|
+
|
|
32
|
+
f = 1.0 if f > 1.0
|
|
33
|
+
@mutex.synchronize { @value = f }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [Float, nil] the latest clamped progress, or nil if never set.
|
|
37
|
+
def get
|
|
38
|
+
@mutex.synchronize { @value }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zeridion
|
|
4
|
+
module Flare
|
|
5
|
+
# Mixin marking a class as a payloadless recurring (cron) job:
|
|
6
|
+
#
|
|
7
|
+
# class NightlyCleanup
|
|
8
|
+
# include Zeridion::Flare::RecurringJob
|
|
9
|
+
# flare_options cron: "0 3 * * *", queue: "maintenance", timezone: "UTC"
|
|
10
|
+
#
|
|
11
|
+
# def perform(ctx)
|
|
12
|
+
# return if ctx.cancelled?
|
|
13
|
+
# PurgeExpired.run
|
|
14
|
+
# end
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# The server runs the schedule and enqueues an execution per tick; the worker
|
|
18
|
+
# announces the schedule (including `timezone`) at register time and then
|
|
19
|
+
# dispatches the resulting jobs to `#perform(ctx)` — note the single-arg
|
|
20
|
+
# signature (no payload).
|
|
21
|
+
#
|
|
22
|
+
# `cron` is required (a recurring job with no schedule never fires). job_type
|
|
23
|
+
# defaults to the class FQN.
|
|
24
|
+
module RecurringJob
|
|
25
|
+
def self.included(base)
|
|
26
|
+
base.extend(ClassMacros)
|
|
27
|
+
base.include(InstanceShape)
|
|
28
|
+
Worker::Registry.register_recurring(base)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
module ClassMacros
|
|
32
|
+
# @param cron [String] required cron expression (e.g. "0 3 * * *")
|
|
33
|
+
# @param job_type [String, nil] explicit routing type (default: class FQN)
|
|
34
|
+
# @param queue [String]
|
|
35
|
+
# @param timezone [String] IANA tz for the cron (default "UTC")
|
|
36
|
+
# @param max_attempts [Integer]
|
|
37
|
+
# @param timeout [Integer] seconds
|
|
38
|
+
def flare_options(cron: nil, job_type: nil, queue: nil, timezone: nil, max_attempts: nil, timeout: nil)
|
|
39
|
+
@flare_cron = cron unless cron.nil?
|
|
40
|
+
@flare_job_type = job_type unless job_type.nil?
|
|
41
|
+
@flare_queue = queue unless queue.nil?
|
|
42
|
+
@flare_timezone = timezone unless timezone.nil?
|
|
43
|
+
@flare_max_attempts = max_attempts unless max_attempts.nil?
|
|
44
|
+
@flare_timeout = timeout unless timeout.nil?
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def flare_cron
|
|
49
|
+
@flare_cron
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def flare_job_type
|
|
53
|
+
@flare_job_type || name ||
|
|
54
|
+
raise(ArgumentError, "anonymous recurring job class needs an explicit flare_options(job_type:)")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def flare_queue
|
|
58
|
+
@flare_queue || "default"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def flare_timezone
|
|
62
|
+
@flare_timezone || "UTC"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def flare_max_attempts
|
|
66
|
+
@flare_max_attempts || 3
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def flare_timeout
|
|
70
|
+
@flare_timeout || 1800
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
module InstanceShape
|
|
75
|
+
def perform(_ctx)
|
|
76
|
+
raise NotImplementedError, "#{self.class.name} must implement #perform(ctx)"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zeridion
|
|
4
|
+
module Flare
|
|
5
|
+
module Worker
|
|
6
|
+
# One cron schedule announced at register time. This is the single typed
|
|
7
|
+
# wire DTO in the worker — everything else stays loose string-keyed Hashes.
|
|
8
|
+
#
|
|
9
|
+
# The full 6-field shape from the frozen protocol, including `timezone`
|
|
10
|
+
# (absence ⇒ server-default TZ, which runs non-UTC crons at the wrong
|
|
11
|
+
# local time). `#to_wire` drops nil fields — Ruby's JSON.generate emits
|
|
12
|
+
# `null` for nil values, and the server treats a present-but-null field
|
|
13
|
+
# differently from an omitted one, so compaction is load-bearing.
|
|
14
|
+
RecurringSchedule = Data.define(
|
|
15
|
+
:job_type,
|
|
16
|
+
:cron_expression,
|
|
17
|
+
:queue,
|
|
18
|
+
:timezone,
|
|
19
|
+
:max_attempts,
|
|
20
|
+
:timeout_seconds,
|
|
21
|
+
) do
|
|
22
|
+
# Build from a JobDefinition. queue/timezone/max_attempts/timeout_seconds
|
|
23
|
+
# are always populated from the definition's resolved options, so they
|
|
24
|
+
# are sent; only genuinely-absent optionals would be nil.
|
|
25
|
+
def self.from_definition(definition)
|
|
26
|
+
new(
|
|
27
|
+
job_type: definition.job_type,
|
|
28
|
+
cron_expression: definition.cron,
|
|
29
|
+
queue: definition.queue,
|
|
30
|
+
timezone: definition.timezone,
|
|
31
|
+
max_attempts: definition.max_attempts,
|
|
32
|
+
timeout_seconds: definition.timeout_seconds,
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Snake-case Hash with nil fields removed (so JSON.generate never emits
|
|
37
|
+
# `"queue": null` etc.).
|
|
38
|
+
def to_wire
|
|
39
|
+
{
|
|
40
|
+
"job_type" => job_type,
|
|
41
|
+
"cron_expression" => cron_expression,
|
|
42
|
+
"queue" => queue,
|
|
43
|
+
"timezone" => timezone,
|
|
44
|
+
"max_attempts" => max_attempts,
|
|
45
|
+
"timeout_seconds" => timeout_seconds,
|
|
46
|
+
}.compact
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zeridion
|
|
4
|
+
module Flare
|
|
5
|
+
module Worker
|
|
6
|
+
# The job registry. Two roles:
|
|
7
|
+
#
|
|
8
|
+
# 1. A process-global accumulator (Registry.register_payload /
|
|
9
|
+
# register_recurring, called from the Job / RecurringJob `included`
|
|
10
|
+
# hooks) that records every job class as it is defined — no
|
|
11
|
+
# ObjectSpace scan.
|
|
12
|
+
#
|
|
13
|
+
# 2. A per-Worker lookup instance (Registry.new(definitions)) that maps
|
|
14
|
+
# job_type → JobDefinition and exposes the distinct queues / job_types
|
|
15
|
+
# for poll + register.
|
|
16
|
+
#
|
|
17
|
+
# Duplicate job_type at registration raises DuplicateJobTypeError so a
|
|
18
|
+
# routing collision surfaces at boot, not at dispatch.
|
|
19
|
+
class Registry
|
|
20
|
+
# ── Process-global accumulator ──────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
@global_classes = []
|
|
23
|
+
@global_mutex = Mutex.new
|
|
24
|
+
|
|
25
|
+
class << self
|
|
26
|
+
def register_payload(klass)
|
|
27
|
+
@global_mutex.synchronize { @global_classes << [:payload, klass] }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def register_recurring(klass)
|
|
31
|
+
@global_mutex.synchronize { @global_classes << [:recurring, klass] }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Snapshot every class declared so far, resolved to JobDefinitions,
|
|
35
|
+
# and de-duplicated by job_type (raising on collision). Returns a
|
|
36
|
+
# Registry instance the Worker uses for the rest of its life.
|
|
37
|
+
def from_declared
|
|
38
|
+
snapshot = @global_mutex.synchronize { @global_classes.dup }
|
|
39
|
+
definitions = snapshot.map do |(kind, klass)|
|
|
40
|
+
case kind
|
|
41
|
+
when :payload then JobDefinition.from_payload_class(klass)
|
|
42
|
+
when :recurring then JobDefinition.from_recurring_class(klass)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
new(definitions)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Test seam: forget all globally-declared classes. Not part of the
|
|
49
|
+
# public API — used only so unit tests don't bleed registrations.
|
|
50
|
+
def reset_declared!
|
|
51
|
+
@global_mutex.synchronize { @global_classes = [] }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# ── Per-worker instance ─────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
def initialize(definitions)
|
|
58
|
+
@by_type = {}
|
|
59
|
+
definitions.each do |definition|
|
|
60
|
+
if @by_type.key?(definition.job_type)
|
|
61
|
+
raise DuplicateJobTypeError, definition.job_type
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
@by_type[definition.job_type] = definition
|
|
65
|
+
end
|
|
66
|
+
@by_type.freeze
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @return [JobDefinition, nil]
|
|
70
|
+
def lookup(job_type)
|
|
71
|
+
@by_type[job_type]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @return [Array<JobDefinition>]
|
|
75
|
+
def all
|
|
76
|
+
@by_type.values
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def empty?
|
|
80
|
+
@by_type.empty?
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Distinct served queues across every registered job.
|
|
84
|
+
def queues
|
|
85
|
+
@by_type.values.map(&:queue).uniq
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Every registered job_type (what poll restricts to).
|
|
89
|
+
def job_types
|
|
90
|
+
@by_type.values.map(&:job_type)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The recurring schedules to announce at register time.
|
|
94
|
+
def recurring_schedules
|
|
95
|
+
@by_type.values.select(&:recurring?).map { |d| RecurringSchedule.from_definition(d) }
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|