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,313 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zeridion
|
|
4
|
+
module Flare
|
|
5
|
+
module Worker
|
|
6
|
+
# The poll loop. Runs register-once → poll → dispatch → ack, and bounds
|
|
7
|
+
# the poll `capacity` to true free slots clamped 1–50. Owns no threads of
|
|
8
|
+
# its own beyond the per-job heartbeat pumps it starts; the bounded job
|
|
9
|
+
# threads live in the Pool. Mirrors the reference SDK's poll-loop service.
|
|
10
|
+
class Runner
|
|
11
|
+
CAPACITY_MIN = 1
|
|
12
|
+
CAPACITY_MAX = 50
|
|
13
|
+
|
|
14
|
+
# @param worker_id [String]
|
|
15
|
+
# @param config [Configuration]
|
|
16
|
+
# @param registry [Registry]
|
|
17
|
+
# @param executor [Executor]
|
|
18
|
+
# @param pool [Pool]
|
|
19
|
+
# @param poll_client [Object] strict client; worker owns it (long read-timeout, no poll-timeout retry)
|
|
20
|
+
# @param heartbeat_client [Object] strict client for heartbeats
|
|
21
|
+
# @param ack_client [Object] strict client for acks (<= 1 retry)
|
|
22
|
+
# @param register_client [Object] strict client for register
|
|
23
|
+
# @param stop_token [CancellationToken] flips on host shutdown / #stop
|
|
24
|
+
# @param logger [Logger, nil]
|
|
25
|
+
def initialize(worker_id:, config:, registry:, executor:, pool:,
|
|
26
|
+
poll_client:, heartbeat_client:, ack_client:, register_client:,
|
|
27
|
+
stop_token:, logger: nil)
|
|
28
|
+
@worker_id = worker_id
|
|
29
|
+
@config = config
|
|
30
|
+
@registry = registry
|
|
31
|
+
@executor = executor
|
|
32
|
+
@pool = pool
|
|
33
|
+
@poll_client = poll_client
|
|
34
|
+
@heartbeat_client = heartbeat_client
|
|
35
|
+
@ack_client = ack_client
|
|
36
|
+
@register_client = register_client
|
|
37
|
+
@stop = stop_token
|
|
38
|
+
@logger = logger
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Run the loop on the CURRENT thread until the stop token trips. The
|
|
42
|
+
# caller (Worker) decides whether that is the main thread or a spawned
|
|
43
|
+
# poll thread.
|
|
44
|
+
def run
|
|
45
|
+
if @registry.empty?
|
|
46
|
+
@logger&.warn("worker_starting no job types discovered — worker will not poll")
|
|
47
|
+
return
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
queues = poll_queues
|
|
51
|
+
job_types = @registry.job_types
|
|
52
|
+
|
|
53
|
+
@logger&.info(
|
|
54
|
+
"worker_starting worker_id=#{@worker_id} job_type_count=#{job_types.length} " \
|
|
55
|
+
"queues=#{queues.join(',')}",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
register(queues, job_types)
|
|
59
|
+
poll_loop(queues, job_types)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def poll_queues
|
|
65
|
+
@config.explicit_queues? ? @config.queues : @registry.queues
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def register(queues, job_types)
|
|
69
|
+
body = {
|
|
70
|
+
"worker_id" => @worker_id,
|
|
71
|
+
"queues" => queues,
|
|
72
|
+
"job_types" => job_types,
|
|
73
|
+
}
|
|
74
|
+
hostname = @config.hostname || Identity.default_hostname
|
|
75
|
+
body["hostname"] = hostname unless hostname.to_s.empty?
|
|
76
|
+
body["sdk_version"] = ::Zeridion::Flare::VERSION
|
|
77
|
+
|
|
78
|
+
schedules = @registry.recurring_schedules.map(&:to_wire)
|
|
79
|
+
# OMIT recurring_schedules entirely when empty (never send []).
|
|
80
|
+
body["recurring_schedules"] = schedules unless schedules.empty?
|
|
81
|
+
|
|
82
|
+
# Best-effort: a strict client raises, the worker swallows (D4).
|
|
83
|
+
# Timeout::Error is included because Net::ReadTimeout < Timeout::Error
|
|
84
|
+
# (NOT IOError/SystemCallError) — a cold-start register read-timeout
|
|
85
|
+
# must not escape and kill the poll thread before polling begins.
|
|
86
|
+
begin
|
|
87
|
+
@register_client.register_worker(body)
|
|
88
|
+
@logger&.info("worker_registered worker_id=#{@worker_id}")
|
|
89
|
+
rescue ::Zeridion::Flare::FlareError, IOError, SystemCallError, Timeout::Error => e
|
|
90
|
+
@logger&.warn("registration_failed worker_id=#{@worker_id} error=#{e.class}: #{e.message}")
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def poll_loop(queues, job_types)
|
|
95
|
+
until @stop.cancelled?
|
|
96
|
+
capacity = current_capacity
|
|
97
|
+
if capacity.nil?
|
|
98
|
+
# Saturated: no free slots. Idle the poll interval and re-check.
|
|
99
|
+
@stop.wait(@config.poll_interval_s)
|
|
100
|
+
next
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
jobs = poll_once(queues, job_types, capacity)
|
|
104
|
+
next if jobs.nil? # an error / rate-limit already slept
|
|
105
|
+
|
|
106
|
+
if jobs.empty?
|
|
107
|
+
@stop.wait(@config.poll_interval_s)
|
|
108
|
+
next
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
@logger&.debug("polled_jobs count=#{jobs.length}")
|
|
112
|
+
dispatch(jobs)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
@logger&.info("worker_stopped worker_id=#{@worker_id}")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# @return [Integer, nil] the clamped capacity to poll with, or nil when
|
|
119
|
+
# the pool is fully saturated (poll should be skipped this cycle).
|
|
120
|
+
def current_capacity
|
|
121
|
+
free = @pool.free_slots
|
|
122
|
+
return nil if free <= 0
|
|
123
|
+
|
|
124
|
+
free.clamp(CAPACITY_MIN, CAPACITY_MAX)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# @return [Array<Hash>, nil] polled jobs, or nil if the cycle slept due
|
|
128
|
+
# to an error / rate-limit (caller should `next`).
|
|
129
|
+
def poll_once(queues, job_types, capacity)
|
|
130
|
+
body = {
|
|
131
|
+
"worker_id" => @worker_id,
|
|
132
|
+
"queues" => queues,
|
|
133
|
+
"capacity" => capacity,
|
|
134
|
+
"job_types" => job_types,
|
|
135
|
+
}
|
|
136
|
+
response = @poll_client.poll_workers(body)
|
|
137
|
+
jobs = response.is_a?(Hash) ? response["jobs"] : nil
|
|
138
|
+
jobs.is_a?(Array) ? jobs : []
|
|
139
|
+
rescue ::Zeridion::Flare::RateLimitError => e
|
|
140
|
+
sleep_for_rate_limit(e)
|
|
141
|
+
nil
|
|
142
|
+
rescue ::Zeridion::Flare::AuthError => e
|
|
143
|
+
# 401 is fatal-fast on poll: stop spinning (rotation = restart).
|
|
144
|
+
@logger&.error("poll_failed_auth worker_id=#{@worker_id} error=#{e.message} — stopping poll")
|
|
145
|
+
@stop.cancel!
|
|
146
|
+
nil
|
|
147
|
+
rescue StandardError => e
|
|
148
|
+
# Transient / network / 5xx / a long-poll read timeout: log and retry
|
|
149
|
+
# next cycle (the next poll IS the retry — no poll-timeout retry).
|
|
150
|
+
@logger&.error("poll_failed error=#{e.class}: #{e.message} retry_after=#{@config.poll_interval_s}s")
|
|
151
|
+
@stop.wait(@config.poll_interval_s)
|
|
152
|
+
nil
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def dispatch(jobs)
|
|
156
|
+
jobs.each do |item|
|
|
157
|
+
break if @stop.cancelled?
|
|
158
|
+
# Reserve a slot; stop if we somehow lost the race (shouldn't, since
|
|
159
|
+
# capacity gated the poll, but the server may over-return).
|
|
160
|
+
break unless @pool.try_acquire
|
|
161
|
+
|
|
162
|
+
@pool.submit { process_job(item) }
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# The full per-job lifecycle: heartbeat pump (immediate beat) → execute
|
|
167
|
+
# → stop pump → ack. Runs on a pool thread.
|
|
168
|
+
def process_job(item)
|
|
169
|
+
job_id = item["id"]
|
|
170
|
+
start_monotonic = monotonic_now
|
|
171
|
+
|
|
172
|
+
cancellation = CancellationToken.new
|
|
173
|
+
# If the host is already shutting down, wire shutdown → this job's
|
|
174
|
+
# cancellation so a freshly-claimed job unwinds cooperatively.
|
|
175
|
+
cancellation.cancel! if @stop.cancelled?
|
|
176
|
+
|
|
177
|
+
progress_slot = ProgressSlot.new
|
|
178
|
+
context = build_context(item, cancellation, progress_slot)
|
|
179
|
+
|
|
180
|
+
@logger&.info(
|
|
181
|
+
"job_executing job_id=#{job_id} job_type=#{item['job_type']} " \
|
|
182
|
+
"attempt=#{item['attempt']} max_attempts=#{item['max_attempts']}",
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
hb_stop = CancellationToken.new
|
|
186
|
+
pump = Heartbeat.new(
|
|
187
|
+
client: @heartbeat_client,
|
|
188
|
+
worker_id: @worker_id,
|
|
189
|
+
job_id: job_id,
|
|
190
|
+
timeout_seconds: job_timeout(item),
|
|
191
|
+
progress_slot: progress_slot,
|
|
192
|
+
cancellation: cancellation,
|
|
193
|
+
stop: hb_stop,
|
|
194
|
+
heartbeat_min_ms: @config.heartbeat_min_ms,
|
|
195
|
+
logger: @logger,
|
|
196
|
+
).start
|
|
197
|
+
|
|
198
|
+
# Local per-job timeout watchdog: trips the cooperative token (D6 — no
|
|
199
|
+
# thread-kill). A non-yielding handler can't be force-stopped; this
|
|
200
|
+
# bounds well-behaved handlers and lets the rest land on the reaper.
|
|
201
|
+
watchdog = start_timeout_watchdog(item, cancellation)
|
|
202
|
+
|
|
203
|
+
outcome =
|
|
204
|
+
begin
|
|
205
|
+
@executor.execute(item, context)
|
|
206
|
+
ensure
|
|
207
|
+
watchdog&.cancel!
|
|
208
|
+
pump.stop_and_join(timeout: @config.rpc_timeout_s + 1)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
duration_ms = ((monotonic_now - start_monotonic) * 1000).round
|
|
212
|
+
log_outcome(job_id, outcome, duration_ms)
|
|
213
|
+
ack(job_id, outcome, duration_ms)
|
|
214
|
+
rescue StandardError => e
|
|
215
|
+
@logger&.error("job_unexpected_error job_id=#{item['id']} error=#{e.class}: #{e.message}")
|
|
216
|
+
ack(item["id"], Executor::Outcome.new(succeeded: false,
|
|
217
|
+
error: { "type" => e.class.name, "message" => e.message }), 0)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def build_context(item, cancellation, progress_slot)
|
|
221
|
+
JobContext.new(
|
|
222
|
+
job_id: item["id"],
|
|
223
|
+
job_type: item["job_type"],
|
|
224
|
+
attempt: item["attempt"],
|
|
225
|
+
max_attempts: item["max_attempts"],
|
|
226
|
+
enqueued_at: JobContext.parse_timestamp(item["enqueued_at"]),
|
|
227
|
+
logger: @logger,
|
|
228
|
+
cancellation: cancellation,
|
|
229
|
+
progress_slot: progress_slot,
|
|
230
|
+
)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def job_timeout(item)
|
|
234
|
+
t = item["timeout_seconds"].to_i
|
|
235
|
+
t.positive? ? t : @config.default_timeout_s
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Spawn a watchdog thread that trips the cooperative cancellation token
|
|
239
|
+
# once the job's timeout elapses (unless cancelled first). Returns a
|
|
240
|
+
# CancellationToken used to cancel the watchdog when the job finishes.
|
|
241
|
+
def start_timeout_watchdog(item, cancellation)
|
|
242
|
+
timeout = job_timeout(item)
|
|
243
|
+
stop_watch = CancellationToken.new
|
|
244
|
+
Thread.new do
|
|
245
|
+
# Wake on either the timeout OR the job finishing (stop_watch).
|
|
246
|
+
woke_to_stop = stop_watch.wait(timeout)
|
|
247
|
+
cancellation.cancel! unless woke_to_stop
|
|
248
|
+
end
|
|
249
|
+
stop_watch
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def log_outcome(job_id, outcome, duration_ms)
|
|
253
|
+
if outcome.succeeded?
|
|
254
|
+
@logger&.info("job_succeeded job_id=#{job_id} duration_ms=#{duration_ms}")
|
|
255
|
+
else
|
|
256
|
+
@logger&.warn("job_failed job_id=#{job_id} error_message=#{outcome.error&.fetch('message', 'unknown')}")
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Ack the terminal outcome. Status is "succeeded"/"failed" only (D1 — a
|
|
261
|
+
# cancelled/timed-out job acks "failed", never "cancelled"). At most one
|
|
262
|
+
# retry (D5): rely on the server's at-least-once delivery rather than
|
|
263
|
+
# amplifying with client backoff.
|
|
264
|
+
def ack(job_id, outcome, duration_ms)
|
|
265
|
+
status = outcome.succeeded? ? "succeeded" : "failed"
|
|
266
|
+
body = {
|
|
267
|
+
"job_id" => job_id,
|
|
268
|
+
"worker_id" => @worker_id,
|
|
269
|
+
"status" => status,
|
|
270
|
+
"duration_ms" => duration_ms,
|
|
271
|
+
}
|
|
272
|
+
body["error"] = outcome.error if !outcome.succeeded? && outcome.error && !outcome.error.empty?
|
|
273
|
+
|
|
274
|
+
attempts = 0
|
|
275
|
+
begin
|
|
276
|
+
@ack_client.ack_worker(body)
|
|
277
|
+
rescue ::Zeridion::Flare::FlareError, IOError, SystemCallError, Timeout::Error => e
|
|
278
|
+
attempts += 1
|
|
279
|
+
if attempts <= 1
|
|
280
|
+
retry
|
|
281
|
+
end
|
|
282
|
+
@logger&.error("ack_failed job_id=#{job_id} status=#{status} error=#{e.class}: #{e.message}")
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# On 429: sleep = Retry-After (delta) if present, else
|
|
287
|
+
# max(0, X-RateLimit-Reset(epoch) − now), clamped to the poll read
|
|
288
|
+
# timeout. Keep heartbeating in-flight jobs (their pumps run on their own
|
|
289
|
+
# threads/clients, untouched by a poll back-off).
|
|
290
|
+
def sleep_for_rate_limit(error)
|
|
291
|
+
seconds = error.retry_after
|
|
292
|
+
# RateLimitError#retry_after carries X-RateLimit-Reset (an epoch). If
|
|
293
|
+
# it looks like an absolute epoch (large), convert to a delta.
|
|
294
|
+
delay =
|
|
295
|
+
if seconds.nil?
|
|
296
|
+
@config.poll_interval_s
|
|
297
|
+
elsif seconds > 1_000_000_000 # epoch seconds, not a delta
|
|
298
|
+
[seconds - Time.now.to_i, 0].max
|
|
299
|
+
else
|
|
300
|
+
[seconds, 0].max
|
|
301
|
+
end
|
|
302
|
+
delay = delay.clamp(0, @config.poll_read_timeout_s)
|
|
303
|
+
@logger&.warn("poll_rate_limited sleep_s=#{delay}")
|
|
304
|
+
@stop.wait(delay)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def monotonic_now
|
|
308
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
end
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zeridion
|
|
4
|
+
module Flare
|
|
5
|
+
module Worker
|
|
6
|
+
# The background-worker runtime — the Ruby analog of the reference SDK's
|
|
7
|
+
# hosted worker service. One bootstrap object:
|
|
8
|
+
#
|
|
9
|
+
# require "zeridion_flare/worker"
|
|
10
|
+
#
|
|
11
|
+
# class SendWelcomeEmail
|
|
12
|
+
# include Zeridion::Flare::Job
|
|
13
|
+
# flare_options queue: "email", max_attempts: 5, timeout: 120
|
|
14
|
+
# def perform(payload, ctx) = Mailer.welcome(payload["email"]).deliver_now
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# worker = Zeridion::Flare::Worker.new(concurrency: 5)
|
|
18
|
+
# worker.run # blocks; SIGTERM/SIGINT → graceful drain
|
|
19
|
+
#
|
|
20
|
+
# Lifecycle:
|
|
21
|
+
# #start — spawn the poll loop on a background thread, return immediately.
|
|
22
|
+
# #run — #start, install SIGTERM/SIGINT handlers, then block until a
|
|
23
|
+
# signal arrives, then #stop. (Signals are wired ONLY inside #run.)
|
|
24
|
+
# #stop — idempotent; stop polling and drain in-flight jobs on the
|
|
25
|
+
# CURRENT thread within the shutdown grace, then join everything.
|
|
26
|
+
#
|
|
27
|
+
# The worker OWNS its HTTP clients (decision D5): a poll client with a read
|
|
28
|
+
# timeout > 30 s and no poll-timeout retry, a single-shot heartbeat client,
|
|
29
|
+
# an ack client with at most one retry, and a register client. All four
|
|
30
|
+
# reuse the existing Client transport — the worker does NOT reinvent HTTP.
|
|
31
|
+
class Worker
|
|
32
|
+
# @param api_key [String, nil] falls back to FLARE_API_KEY.
|
|
33
|
+
# @param base_url [String, nil] default https://api.zeridion.com.
|
|
34
|
+
# @param registry [Registry, nil] explicit registry; default = every
|
|
35
|
+
# class that included Zeridion::Flare::Job / RecurringJob so far.
|
|
36
|
+
# @param transport [Object, nil] HTTP transport (for tests / DI); passed
|
|
37
|
+
# through to every Client the worker builds.
|
|
38
|
+
# @param max_response_bytes [Integer] body cap forwarded to each Client.
|
|
39
|
+
# @param config_options see Configuration#initialize (queues:,
|
|
40
|
+
# concurrency:, poll_interval_s:, default_timeout_s:,
|
|
41
|
+
# default_max_attempts:, shutdown_grace_s:, poll_read_timeout_s:,
|
|
42
|
+
# rpc_timeout_s:, hostname:, logger:, heartbeat_min_ms:).
|
|
43
|
+
def initialize(api_key: nil, base_url: nil, registry: nil, transport: nil,
|
|
44
|
+
max_response_bytes: 10 * 1024 * 1024, **config_options)
|
|
45
|
+
@config = Configuration.new(**config_options)
|
|
46
|
+
@registry = registry || Registry.from_declared
|
|
47
|
+
@worker_id = Identity.generate(hostname: @config.hostname)
|
|
48
|
+
@logger = @config.logger
|
|
49
|
+
@stop = CancellationToken.new
|
|
50
|
+
@lifecycle_mutex = Mutex.new
|
|
51
|
+
@started = false
|
|
52
|
+
@stopped = false
|
|
53
|
+
@poll_thread = nil
|
|
54
|
+
@run_latch = CancellationToken.new
|
|
55
|
+
|
|
56
|
+
base_url ||= ::Zeridion::Flare::Client::DEFAULT_BASE_URL
|
|
57
|
+
@poll_client = build_poll_client(api_key, base_url, transport, max_response_bytes)
|
|
58
|
+
@heartbeat_client = build_rpc_client(api_key, base_url, transport, max_response_bytes, max_retries: 0)
|
|
59
|
+
@ack_client = build_rpc_client(api_key, base_url, transport, max_response_bytes, max_retries: 1)
|
|
60
|
+
@register_client = build_rpc_client(api_key, base_url, transport, max_response_bytes, max_retries: 0)
|
|
61
|
+
|
|
62
|
+
@pool = Pool.new(concurrency: @config.concurrency)
|
|
63
|
+
@executor = Executor.new(registry: @registry, logger: @logger)
|
|
64
|
+
@runner = build_runner
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @return [String] this worker's id (wrk_{host}_{pid}_{rand}).
|
|
68
|
+
attr_reader :worker_id
|
|
69
|
+
|
|
70
|
+
# @return [Configuration]
|
|
71
|
+
attr_reader :config
|
|
72
|
+
|
|
73
|
+
# Start the pool + poll loop on a background thread. Non-blocking.
|
|
74
|
+
# Idempotent. Returns self.
|
|
75
|
+
def start
|
|
76
|
+
@lifecycle_mutex.synchronize do
|
|
77
|
+
return self if @started
|
|
78
|
+
|
|
79
|
+
@started = true
|
|
80
|
+
@pool.start
|
|
81
|
+
@poll_thread = Thread.new do
|
|
82
|
+
if Thread.current.respond_to?(:name=)
|
|
83
|
+
Thread.current.name = "flare-poll"
|
|
84
|
+
end
|
|
85
|
+
@runner.run
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
self
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Start, install SIGTERM/SIGINT handlers, and block until a stop signal
|
|
92
|
+
# (or another thread calls #stop). On wake, drain and return. Must run on
|
|
93
|
+
# the main thread (Signal.trap is process-global).
|
|
94
|
+
def run
|
|
95
|
+
start
|
|
96
|
+
install_signal_handlers
|
|
97
|
+
# Block the calling thread until stop is requested.
|
|
98
|
+
@run_latch.wait(Float::INFINITY)
|
|
99
|
+
stop
|
|
100
|
+
ensure
|
|
101
|
+
uninstall_signal_handlers
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Idempotent graceful shutdown: stop polling, then drain in-flight jobs
|
|
105
|
+
# on the CURRENT thread within shutdown_grace_s, then join the poll
|
|
106
|
+
# thread + pool. Safe to call from a signal context or another thread.
|
|
107
|
+
def stop
|
|
108
|
+
@lifecycle_mutex.synchronize do
|
|
109
|
+
return if @stopped
|
|
110
|
+
|
|
111
|
+
@stopped = true
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# 1. Flip the stop token → poll loop stops claiming new jobs and
|
|
115
|
+
# cooperatively cancels freshly-claimed ones.
|
|
116
|
+
@stop.cancel!
|
|
117
|
+
# 2. Wake #run if it is blocking.
|
|
118
|
+
@run_latch.cancel!
|
|
119
|
+
# 3. Let the poll loop notice and exit.
|
|
120
|
+
@poll_thread&.join(@config.poll_read_timeout_s + 5)
|
|
121
|
+
# 4. Drain in-flight jobs (each acks itself) within the grace window.
|
|
122
|
+
@logger&.info("shutdown_waiting in_flight=#{@pool.in_flight}") if @pool.in_flight.positive?
|
|
123
|
+
all_done = @pool.shutdown(timeout: @config.shutdown_grace_s)
|
|
124
|
+
@logger&.warn("shutdown_grace_exceeded — some jobs still running") unless all_done
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# @return [Boolean] true once #stop has completed (test convenience).
|
|
129
|
+
def stopped?
|
|
130
|
+
@lifecycle_mutex.synchronize { @stopped }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def build_runner
|
|
136
|
+
Runner.new(
|
|
137
|
+
worker_id: @worker_id,
|
|
138
|
+
config: @config,
|
|
139
|
+
registry: @registry,
|
|
140
|
+
executor: @executor,
|
|
141
|
+
pool: @pool,
|
|
142
|
+
poll_client: @poll_client,
|
|
143
|
+
heartbeat_client: @heartbeat_client,
|
|
144
|
+
ack_client: @ack_client,
|
|
145
|
+
register_client: @register_client,
|
|
146
|
+
stop_token: @stop,
|
|
147
|
+
logger: @logger,
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Poll client: long read timeout (> 30 s long-poll), retries disabled so
|
|
152
|
+
# a long-poll read timeout is never retried inside the client (the next
|
|
153
|
+
# loop poll is the retry).
|
|
154
|
+
def build_poll_client(api_key, base_url, transport, max_response_bytes)
|
|
155
|
+
::Zeridion::Flare::Client.new(
|
|
156
|
+
api_key: api_key,
|
|
157
|
+
base_url: base_url,
|
|
158
|
+
max_retries: 0,
|
|
159
|
+
timeout_seconds: @config.poll_read_timeout_s,
|
|
160
|
+
max_response_bytes: max_response_bytes,
|
|
161
|
+
transport: transport,
|
|
162
|
+
)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Heartbeat / ack / register client: short read timeout, retry budget per
|
|
166
|
+
# caller (heartbeat 0, ack 1, register 0).
|
|
167
|
+
def build_rpc_client(api_key, base_url, transport, max_response_bytes, max_retries:)
|
|
168
|
+
::Zeridion::Flare::Client.new(
|
|
169
|
+
api_key: api_key,
|
|
170
|
+
base_url: base_url,
|
|
171
|
+
max_retries: max_retries,
|
|
172
|
+
timeout_seconds: @config.rpc_timeout_s,
|
|
173
|
+
max_response_bytes: max_response_bytes,
|
|
174
|
+
transport: transport,
|
|
175
|
+
)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def install_signal_handlers
|
|
179
|
+
@previous_handlers = {}
|
|
180
|
+
%w[TERM INT].each do |sig|
|
|
181
|
+
@previous_handlers[sig] = Signal.trap(sig) do
|
|
182
|
+
# A trap handler MUST NOT acquire a Mutex (Ruby raises
|
|
183
|
+
# "can't be called from trap context" — both CancellationTokens use
|
|
184
|
+
# one internally). Hand the actual wake to a fresh thread, which
|
|
185
|
+
# runs OUTSIDE the trap context and may safely lock. The real drain
|
|
186
|
+
# then runs on the main thread once #run unblocks.
|
|
187
|
+
Thread.new do
|
|
188
|
+
@stop.cancel!
|
|
189
|
+
@run_latch.cancel!
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
rescue ArgumentError
|
|
193
|
+
# Signal not supported on this platform — skip it.
|
|
194
|
+
nil
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def uninstall_signal_handlers
|
|
199
|
+
return unless @previous_handlers
|
|
200
|
+
|
|
201
|
+
@previous_handlers.each do |sig, handler|
|
|
202
|
+
Signal.trap(sig, handler) if handler
|
|
203
|
+
rescue ArgumentError
|
|
204
|
+
nil
|
|
205
|
+
end
|
|
206
|
+
@previous_handlers = nil
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Opt-in background-worker runtime for the Zeridion Flare Ruby SDK.
|
|
4
|
+
#
|
|
5
|
+
# require "zeridion_flare/worker"
|
|
6
|
+
#
|
|
7
|
+
# This pulls in the full worker surface on top of the thin client:
|
|
8
|
+
#
|
|
9
|
+
# * Zeridion::Flare::Job — mixin + `flare_options` macro for payload jobs
|
|
10
|
+
# * Zeridion::Flare::RecurringJob — mixin for payloadless cron jobs
|
|
11
|
+
# * Zeridion::Flare::Worker — the runtime (register → poll → dispatch →
|
|
12
|
+
# heartbeat → ack → drain)
|
|
13
|
+
# * Zeridion::Flare::Worker::Configuration
|
|
14
|
+
# * Zeridion::Flare::JobContext — per-execution context (cancellation, progress)
|
|
15
|
+
#
|
|
16
|
+
# The runtime is opt-in by import so thin-client bundles stay lean. It reads the
|
|
17
|
+
# single client version constant and reuses the existing HTTP transport for
|
|
18
|
+
# auth / retry / body-cap — it does NOT reinvent HTTP.
|
|
19
|
+
#
|
|
20
|
+
# Requires Ruby 3.2+ (uses Data.define).
|
|
21
|
+
|
|
22
|
+
require_relative "../zeridion_flare"
|
|
23
|
+
|
|
24
|
+
require_relative "worker/errors"
|
|
25
|
+
require_relative "worker/recurring_schedule"
|
|
26
|
+
require_relative "worker/progress_slot"
|
|
27
|
+
require_relative "worker/cancellation_token"
|
|
28
|
+
require_relative "worker/job_context"
|
|
29
|
+
require_relative "worker/job"
|
|
30
|
+
require_relative "worker/recurring_job"
|
|
31
|
+
require_relative "worker/registry"
|
|
32
|
+
require_relative "worker/job_definition"
|
|
33
|
+
require_relative "worker/identity"
|
|
34
|
+
require_relative "worker/configuration"
|
|
35
|
+
require_relative "worker/executor"
|
|
36
|
+
require_relative "worker/heartbeat"
|
|
37
|
+
require_relative "worker/pool"
|
|
38
|
+
require_relative "worker/runner"
|
|
39
|
+
require_relative "worker/worker"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "zeridion_flare/version"
|
|
4
|
+
require_relative "zeridion_flare/errors"
|
|
5
|
+
require_relative "zeridion_flare/webhook"
|
|
6
|
+
require_relative "zeridion_flare/client"
|
|
7
|
+
|
|
8
|
+
# Top-level convenience: the module name is `Zeridion::Flare`. Most consumers
|
|
9
|
+
# will write `client = Zeridion::Flare::Client.new(api_key: "...")` or alias
|
|
10
|
+
# the module as needed.
|
|
11
|
+
module Zeridion
|
|
12
|
+
module Flare
|
|
13
|
+
end
|
|
14
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: zeridion-flare
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.2.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Zeridion
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-07-20 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: minitest
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '5.20'
|
|
20
|
+
type: :development
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '5.20'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: rake
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '13.0'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '13.0'
|
|
41
|
+
description: Synchronous Ruby client for the Zeridion Flare API (background jobs,
|
|
42
|
+
recurring schedules, workers, webhook verification) plus an opt-in background-worker
|
|
43
|
+
runtime (require "zeridion_flare/worker") that polls, dispatches to your job classes,
|
|
44
|
+
heartbeats with progress, honors server cancellation, and drains gracefully on shutdown.
|
|
45
|
+
Zero runtime dependencies — uses Ruby stdlib Net::HTTP only.
|
|
46
|
+
email:
|
|
47
|
+
- support@zeridion.com
|
|
48
|
+
executables: []
|
|
49
|
+
extensions: []
|
|
50
|
+
extra_rdoc_files: []
|
|
51
|
+
files:
|
|
52
|
+
- LICENSE.txt
|
|
53
|
+
- README.md
|
|
54
|
+
- lib/zeridion_flare.rb
|
|
55
|
+
- lib/zeridion_flare/client.rb
|
|
56
|
+
- lib/zeridion_flare/errors.rb
|
|
57
|
+
- lib/zeridion_flare/version.rb
|
|
58
|
+
- lib/zeridion_flare/webhook.rb
|
|
59
|
+
- lib/zeridion_flare/worker.rb
|
|
60
|
+
- lib/zeridion_flare/worker/cancellation_token.rb
|
|
61
|
+
- lib/zeridion_flare/worker/configuration.rb
|
|
62
|
+
- lib/zeridion_flare/worker/errors.rb
|
|
63
|
+
- lib/zeridion_flare/worker/executor.rb
|
|
64
|
+
- lib/zeridion_flare/worker/heartbeat.rb
|
|
65
|
+
- lib/zeridion_flare/worker/identity.rb
|
|
66
|
+
- lib/zeridion_flare/worker/job.rb
|
|
67
|
+
- lib/zeridion_flare/worker/job_context.rb
|
|
68
|
+
- lib/zeridion_flare/worker/job_definition.rb
|
|
69
|
+
- lib/zeridion_flare/worker/pool.rb
|
|
70
|
+
- lib/zeridion_flare/worker/progress_slot.rb
|
|
71
|
+
- lib/zeridion_flare/worker/recurring_job.rb
|
|
72
|
+
- lib/zeridion_flare/worker/recurring_schedule.rb
|
|
73
|
+
- lib/zeridion_flare/worker/registry.rb
|
|
74
|
+
- lib/zeridion_flare/worker/runner.rb
|
|
75
|
+
- lib/zeridion_flare/worker/worker.rb
|
|
76
|
+
homepage: https://docs.zeridion.com/flare
|
|
77
|
+
licenses:
|
|
78
|
+
- MIT
|
|
79
|
+
metadata:
|
|
80
|
+
homepage_uri: https://docs.zeridion.com/flare
|
|
81
|
+
source_code_uri: https://github.com/zeridion/zeridion
|
|
82
|
+
documentation_uri: https://docs.zeridion.com/flare
|
|
83
|
+
rubygems_mfa_required: 'true'
|
|
84
|
+
post_install_message:
|
|
85
|
+
rdoc_options: []
|
|
86
|
+
require_paths:
|
|
87
|
+
- lib
|
|
88
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
89
|
+
requirements:
|
|
90
|
+
- - ">="
|
|
91
|
+
- !ruby/object:Gem::Version
|
|
92
|
+
version: 3.2.0
|
|
93
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
94
|
+
requirements:
|
|
95
|
+
- - ">="
|
|
96
|
+
- !ruby/object:Gem::Version
|
|
97
|
+
version: '0'
|
|
98
|
+
requirements: []
|
|
99
|
+
rubygems_version: 3.5.22
|
|
100
|
+
signing_key:
|
|
101
|
+
specification_version: 4
|
|
102
|
+
summary: Official Ruby SDK for Zeridion Flare — managed background jobs + worker runtime.
|
|
103
|
+
test_files: []
|