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.
@@ -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