milvaion-worker 1.0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 690ac8cdb6818d2ee65b491eee4415b102d57b8ad48a90b03655f68cde194b90
4
+ data.tar.gz: 66e2b832f777e3dec76371caedcbbb9cedcdf25e854cacd52c815836b8af32e5
5
+ SHA512:
6
+ metadata.gz: 793e35c5741b0f1be61f4154dd3796bf2554ba229c9ea0baafc85b2b5d54b9a3198dfa8c4fd7bebe19bd7439a6e82e4e0218ff6dbc4b95cae592a94c7854e67f
7
+ data.tar.gz: aeaccc8157b3a95d6237a1db1561a5b9c61f1e2f52bc635a761f27889a7e860f4d15a1056b676ded1c6ce6828266531be92ba2a0a98d436c68d2f08f0b4346c6
data/README.md ADDED
@@ -0,0 +1,190 @@
1
+ # Milvaion Ruby Worker SDK
2
+
3
+ Run [Milvaion](https://github.com/Milvasoft/milvaion) jobs in Ruby. A Ruby worker shows up in the dashboard like a .NET worker. The scheduler dispatches jobs to it, and the dashboard shows its live logs, status, retries, cancellation and timeouts.
4
+
5
+ ```bash
6
+ gem install milvaion-worker
7
+ ```
8
+
9
+ Or add it to your Gemfile:
10
+
11
+ ```ruby
12
+ gem "milvaion-worker", "~> 1.0"
13
+ ```
14
+
15
+ It requires Ruby 3.1 or later, and connects to the same RabbitMQ and Redis instances as your Milvaion API. It uses [bunny](https://github.com/ruby-amqp/bunny) for RabbitMQ and [redis-rb](https://github.com/redis/redis-rb) for Redis.
16
+
17
+ ## Quick start
18
+
19
+ ```ruby
20
+ require "milvaion-worker"
21
+
22
+ worker = Milvaion::Worker.new(
23
+ worker_id: "report-worker",
24
+ rabbitmq_url: "amqp://guest:guest@localhost:5672/",
25
+ redis_url: "redis://localhost:6379"
26
+ )
27
+
28
+ worker.job("BuildReportJob", max_retries: 3, execution_timeout: 600) do |ctx|
29
+ data = ctx.data || {}
30
+ raise Milvaion::PermanentJobError, 'Job data needs "month".' unless data["month"] # no retries, straight to the DLQ
31
+
32
+ ctx.log("Building the report for #{data['month']}")
33
+
34
+ rows = fetch_rows(data["month"])
35
+
36
+ rows.each_slice(500) do |batch|
37
+ ctx.raise_if_aborted # stop early if the job was cancelled or timed out
38
+ write_report(batch)
39
+ end
40
+
41
+ { rows: rows.size } # shown as the occurrence result
42
+ end
43
+
44
+ worker.run # blocks; SIGINT/SIGTERM shut down gracefully
45
+ ```
46
+
47
+ Then create a job in the dashboard: pick the worker `report-worker` and the job `BuildReportJob`, and set a schedule.
48
+
49
+ To run the worker inside your own process, call `worker.start` and `worker.stop` instead of `run`.
50
+
51
+ ## Jobs
52
+
53
+ There are two ways to register a job. Either way, names are unique within a worker and matched case-insensitively.
54
+
55
+ **With a block** (or any object that responds to `call(ctx)`):
56
+
57
+ ```ruby
58
+ worker.job("SendEmailJob", max_retries: 5) { |ctx| Mailer.deliver(ctx.data) }
59
+ worker.job("CleanupJob", Cleanup.method(:run))
60
+ ```
61
+
62
+ **With a class.** Subclass `Milvaion::Job` and implement `perform(ctx)`. Each execution gets a new instance. The job name defaults to the class name without its namespace.
63
+
64
+ ```ruby
65
+ class SendEmailJob < Milvaion::Job
66
+ job_options max_retries: 5, data_schema: { type: "object", properties: { to: { type: "string" } } }
67
+
68
+ def perform(ctx)
69
+ ctx.log("Sending to #{ctx.data['to']}")
70
+ { sent: true }
71
+ end
72
+ end
73
+
74
+ worker.job(SendEmailJob)
75
+ worker.job(SendEmailJob, max_parallel_jobs: 2) # options here override job_options
76
+ ```
77
+
78
+ Use `job_name "OtherName"` in the class to register it under a different name.
79
+
80
+ The handler receives a `Milvaion::JobContext`:
81
+
82
+ | Member | Description |
83
+ |---|---|
84
+ | `ctx.data` | Parsed job data with string keys, or `nil`. Data that is not valid JSON fails the job permanently. |
85
+ | `ctx.raw_data` | Job data as the raw JSON string. |
86
+ | `ctx.occurrence_id` | Id of this execution. It stays the same across retries and redeliveries, so you can use it as an idempotency key. |
87
+ | `ctx.retry_count` | `0` on the first attempt, then `1`, `2` and so on. |
88
+ | `ctx.aborted?`, `ctx.abort_reason`, `ctx.raise_if_aborted` | Whether the job was cancelled, timed out or interrupted by shutdown. `abort_reason` is `:cancelled`, `:timed_out`, `:shutdown` or `nil`. |
89
+ | `ctx.sleep(seconds)` | Like `Kernel#sleep`, but wakes up and raises `Milvaion::JobAborted` as soon as the job is aborted. |
90
+ | `ctx.log` / `info` / `debug` / `warn(message, data = nil)` | Write to the occurrence log in the dashboard. |
91
+ | `ctx.error(message, error = nil, data = nil)` | Write an error entry, with the exception's type and stack trace when given. |
92
+ | `ctx.job` | The scheduled job as the API sent it, a Hash with string keys. |
93
+
94
+ How the handler ends decides the outcome:
95
+
96
+ | The handler... | Outcome |
97
+ |---|---|
98
+ | returns a value | `Completed`. A string is stored as-is; anything else is stored as JSON. `nil` stores `Job {name} completed successfully`. |
99
+ | raises | Retried with exponential backoff. After `max_retries`, the occurrence is `Failed` and the job goes to the dead letter queue. |
100
+ | raises `Milvaion::PermanentJobError` | `Failed` without retrying, and the job goes to the dead letter queue. |
101
+ | runs past its timeout | `TimedOut` |
102
+ | is cancelled from the dashboard | `Cancelled` |
103
+ | is still running when the worker stops | The job goes back to the queue and runs again. |
104
+
105
+ ### Cancellation and timeouts in Ruby threads
106
+
107
+ Jobs run on a thread pool, so blocking code does not stall other jobs. Ruby has no safe way to stop a thread from outside: `Thread#raise` and `Thread#kill` can interrupt a job in the middle of an `ensure` block or while it holds a lock. So cancellation and timeouts are **cooperative** by default:
108
+
109
+ - When a job is cancelled or times out, the worker reports the occurrence as `Cancelled` or `TimedOut` and settles the message right away. It does not wait for the handler.
110
+ - The handler's thread keeps running until it returns. Until then it still uses CPU, memory and connections, and it is not counted in the worker's capacity any more.
111
+ - To stop early, check `ctx.aborted?` or call `ctx.raise_if_aborted` in long loops, and use `ctx.sleep` instead of `sleep`.
112
+
113
+ If a job cannot check the context, for example because it spends its time in one long blocking call, set `interrupt_on_abort: true` on the job. The worker then raises `Milvaion::JobAborted` inside the handler's thread when the job is aborted. This is the same mechanism as Ruby's `Timeout` module, with the same risks, so only use it for code that is safe to interrupt at any point.
114
+
115
+ ### Job options
116
+
117
+ | Option | Default | Description |
118
+ |---|---|---|
119
+ | `max_parallel_jobs` | min(10, worker `max_parallel_jobs`) | Jobs of this type that may run at once on this instance |
120
+ | `execution_timeout` | worker default (3600 s) | Seconds. `0` disables the timeout. A timeout set on the job in the dashboard takes precedence. |
121
+ | `max_retries` | 3 | Retries after the first failure |
122
+ | `base_retry_delay` | 5 | Retry *n* waits `base × 2^(n-1)` seconds |
123
+ | `data_schema` | none | JSON Schema of the job data, as a Hash or a JSON string. The dashboard builds the job data form from it. |
124
+ | `result_schema` | none | JSON Schema of the result |
125
+ | `routing_pattern` | `{worker_id}.{name without "Job"}.*` | RabbitMQ routing pattern |
126
+ | `consumer_id` | `{name without "Job"}-consumer` | Shown in the dashboard |
127
+ | `interrupt_on_abort` | `false` | Raise `JobAborted` inside the handler thread on cancel or timeout (see above) |
128
+
129
+ ## Worker options
130
+
131
+ Pass these to `Milvaion::Worker.new(...)` or `Milvaion::WorkerOptions.new(...)`:
132
+
133
+ | Option | Default | Description |
134
+ |---|---|---|
135
+ | `worker_id` | required | Identifies the worker application. Use the same value on every replica. |
136
+ | `rabbitmq_url` | `amqp://guest:guest@localhost:5672/` | |
137
+ | `queue_type` | `classic` | Must match the API's `MilvaionConfig:RabbitMQ:QueueType` |
138
+ | `redis_url` | `redis://localhost:6379` | Redis only carries cancellation requests. `nil` disables them. |
139
+ | `redis_key_prefix` | `Milvaion:JobScheduler:` | Must match the API's `MilvaionConfig:Redis:KeyPrefix` |
140
+ | `max_parallel_jobs` | CPU count × 2 | Size of the job thread pool, reported as this instance's capacity |
141
+ | `execution_timeout` | 3600 | Default job timeout in seconds |
142
+ | `heartbeat_interval` | 30 | |
143
+ | `job_heartbeat_interval` | 60 | Keeps long jobs from being flagged as zombies |
144
+ | `shutdown_timeout` | 30 | How long `stop` lets running jobs finish |
145
+ | `startup_timeout` | 30 | How long `start` waits for RabbitMQ and Redis |
146
+ | `version` | `1.0.0` | Shown in the dashboard |
147
+ | `instance_id` | `{worker_id}-{8 hex}` | Must be unique per process |
148
+ | `logger` | a `Logger` on `$stdout` at INFO | |
149
+
150
+ For containers, `Milvaion::Worker.new(Milvaion::WorkerOptions.from_env)` reads the following variables. Keyword arguments to `from_env` take precedence.
151
+
152
+ - `MILVAION_WORKER_ID`
153
+ - `MILVAION_RABBITMQ_URL`
154
+ - `MILVAION_RABBITMQ_QUEUE_TYPE`
155
+ - `MILVAION_REDIS_URL` (`false` disables Redis)
156
+ - `MILVAION_REDIS_KEY_PREFIX`
157
+ - `MILVAION_MAX_PARALLEL_JOBS`
158
+ - `MILVAION_EXECUTION_TIMEOUT_SECONDS`
159
+ - `MILVAION_HEARTBEAT_INTERVAL_SECONDS`
160
+
161
+ ## Reliability
162
+
163
+ - **Reconnects.** If RabbitMQ goes away, bunny's automatic recovery restores the connection with its queues and consumers, and the worker registers again. The Redis listener reconnects and subscribes again.
164
+ - **At-least-once delivery.** A message is acknowledged only after its outcome has been reported, so a crash makes the job run again. Make handlers idempotent; `ctx.occurrence_id` helps with that.
165
+ - **Graceful shutdown.** `stop` does the following in order:
166
+ 1. Stops taking new jobs, and returns jobs that were waiting for a free thread to the queue.
167
+ 2. Waits up to `shutdown_timeout` for running jobs to finish.
168
+ 3. Returns the jobs that are still running to the queue.
169
+ 4. Flushes logs and tells the API the instance has left.
170
+
171
+ ## How it works
172
+
173
+ The SDK implements the language-neutral [worker protocol](https://github.com/Milvasoft/milvaion/blob/master/docs/githubdocs/WORKER-PROTOCOL.md): the RabbitMQ queues and message formats the .NET SDK uses.
174
+
175
+ ## Development
176
+
177
+ ```bash
178
+ bundle install
179
+ bundle exec rspec
180
+ bundle exec ruby examples/basic_worker.rb # configure with MILVAION_* variables
181
+ gem build milvaion-worker.gemspec
182
+ ```
183
+
184
+ The integration specs run a worker against a real RabbitMQ and Redis. They are skipped unless `MILVAION_TEST_RABBITMQ_URL` is set:
185
+
186
+ ```bash
187
+ docker run -d --name rabbit -p 5672:5672 rabbitmq:4
188
+ docker run -d --name redis -p 6379:6379 redis:7
189
+ MILVAION_TEST_RABBITMQ_URL=amqp://guest:guest@localhost:5672 MILVAION_TEST_REDIS_URL=redis://localhost:6379 bundle exec rspec
190
+ ```
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "redis"
5
+
6
+ module Milvaion
7
+ # Receives the cancellation requests the API publishes on Redis, on a thread of its own.
8
+ # If the connection drops, it reconnects and subscribes again.
9
+ class CancellationListener
10
+ # A subscription that sees no message for this long is renewed, which also detects a dead connection.
11
+ IDLE_RESUBSCRIBE_SECONDS = 60
12
+ RECONNECT_DELAY_SECONDS = 2
13
+
14
+ def initialize(url, channel, registry, logger, redis_factory: nil)
15
+ @url = url
16
+ @channel = channel
17
+ @registry = registry
18
+ @logger = logger
19
+ @redis_factory = redis_factory || ->(redis_url) { Redis.new(url: redis_url, connect_timeout: 5, reconnect_attempts: 0) }
20
+ @mutex = Mutex.new
21
+ @client = nil
22
+ @thread = nil
23
+ @stopped = false
24
+ end
25
+
26
+ # Checks that Redis is reachable, then starts listening. Raises if it is not.
27
+ def start(timeout)
28
+ deadline = monotonic + timeout
29
+
30
+ begin
31
+ probe = @redis_factory.call(@url)
32
+ probe.ping
33
+ probe.close
34
+ rescue StandardError => e
35
+ raise ConnectionError, "Could not connect to Redis within #{timeout}s: #{e.message}" if monotonic + RECONNECT_DELAY_SECONDS > deadline
36
+
37
+ @logger.warn("Could not connect to Redis: #{e.message}")
38
+ Kernel.sleep(RECONNECT_DELAY_SECONDS)
39
+ retry
40
+ end
41
+
42
+ ready = Queue.new
43
+
44
+ @thread = Thread.new do
45
+ Thread.current.name = "milvaion-cancel"
46
+ listen(ready)
47
+ end
48
+
49
+ # Wait for the first subscription so a cancellation sent right after start is not missed.
50
+ Thread.new { Kernel.sleep(timeout) && ready << :timeout }
51
+ @logger.warn("Subscribing to #{@channel} is taking long; continuing.") if ready.pop == :timeout
52
+
53
+ @logger.info("Listening for cancellations on #{@channel}")
54
+ self
55
+ end
56
+
57
+ def stop
58
+ @mutex.synchronize do
59
+ @stopped = true
60
+ client = @client
61
+ @client = nil
62
+
63
+ begin
64
+ client&.close
65
+ rescue StandardError
66
+ nil
67
+ end
68
+ end
69
+
70
+ @thread&.kill
71
+ @thread&.join(2)
72
+ @thread = nil
73
+ end
74
+
75
+ # Handles one message from the channel. Public for tests.
76
+ def on_message(payload)
77
+ request = JSON.parse(payload.to_s)
78
+ return unless request.is_a?(Hash)
79
+
80
+ occurrence_id = request["CorrelationId"] || request["OccurrenceId"]
81
+ reason = request["Reason"]
82
+
83
+ return unless occurrence_id && @registry.cancel(occurrence_id, reason)
84
+
85
+ @logger.info("Cancelling occurrence #{occurrence_id}: #{reason.to_s.empty? ? 'no reason given' : reason}")
86
+ rescue JSON::ParserError
87
+ @logger.debug("Ignored a cancellation message that is not JSON.")
88
+ end
89
+
90
+ private
91
+
92
+ def listen(ready)
93
+ failed = false
94
+
95
+ until stopped?
96
+ begin
97
+ client = @redis_factory.call(@url)
98
+ @mutex.synchronize { @client = client }
99
+ break if stopped?
100
+
101
+ client.subscribe_with_timeout(IDLE_RESUBSCRIBE_SECONDS, @channel) do |on|
102
+ on.subscribe do
103
+ ready << :ok
104
+ @logger.info("Reconnected to Redis.") if failed
105
+ failed = false
106
+ end
107
+
108
+ on.message { |_channel, payload| on_message(payload) }
109
+ end
110
+ rescue Redis::TimeoutError
111
+ # No message for a while: renew the subscription.
112
+ rescue StandardError => e
113
+ break if stopped?
114
+
115
+ @logger.warn("Redis cancellation listener error: #{e.message}. Reconnecting...") unless failed
116
+ failed = true
117
+ Kernel.sleep(RECONNECT_DELAY_SECONDS)
118
+ ensure
119
+ begin
120
+ client&.close
121
+ rescue StandardError
122
+ nil
123
+ end
124
+ end
125
+ end
126
+ end
127
+
128
+ def stopped?
129
+ @mutex.synchronize { @stopped }
130
+ end
131
+
132
+ def monotonic
133
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Milvaion
6
+ # Abort state of one execution. Safe to read and wait on from any thread.
7
+ class AbortSignal
8
+ attr_reader :reason, :message
9
+
10
+ def initialize
11
+ @mutex = Mutex.new
12
+ @changed = ConditionVariable.new
13
+ @reason = nil
14
+ @message = ""
15
+ @listeners = []
16
+ end
17
+
18
+ def aborted?
19
+ !@reason.nil?
20
+ end
21
+
22
+ # Sets the reason once; later calls return false.
23
+ def trigger(reason, message)
24
+ listeners = @mutex.synchronize do
25
+ return false unless @reason.nil?
26
+
27
+ @reason = reason
28
+ @message = message
29
+ @changed.broadcast
30
+ @listeners.dup
31
+ end
32
+
33
+ listeners.each(&:call)
34
+ true
35
+ end
36
+
37
+ # Calls the block once when the signal is triggered, right away if it already was.
38
+ def on_abort(&listener)
39
+ @mutex.synchronize do
40
+ if @reason.nil?
41
+ @listeners << listener
42
+ return
43
+ end
44
+ end
45
+
46
+ listener.call
47
+ end
48
+
49
+ # Sleeps up to seconds, waking early on abort. Returns true if aborted.
50
+ def wait(seconds)
51
+ deadline = monotonic + seconds.to_f
52
+
53
+ @mutex.synchronize do
54
+ while @reason.nil?
55
+ remaining = deadline - monotonic
56
+ break if remaining <= 0
57
+
58
+ @changed.wait(@mutex, remaining)
59
+ end
60
+ end
61
+
62
+ aborted?
63
+ end
64
+
65
+ private
66
+
67
+ def monotonic
68
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
69
+ end
70
+ end
71
+
72
+ # What a job handler receives.
73
+ class JobContext
74
+ # Id of this execution. Stable across retries and redeliveries, so it works as an idempotency key.
75
+ attr_reader :occurrence_id
76
+ # 0 on the first attempt, then 1, 2, ...
77
+ attr_reader :retry_count
78
+ # Instance id of this worker process.
79
+ attr_reader :instance_id
80
+ # The scheduled job as the API sent it, a Hash with string keys.
81
+ attr_reader :job
82
+
83
+ def initialize(occurrence_id:, job:, retry_count:, instance_id:, abort:, sink:)
84
+ @occurrence_id = occurrence_id
85
+ @job = job
86
+ @retry_count = retry_count
87
+ @instance_id = instance_id
88
+ @abort = abort
89
+ @sink = sink
90
+ @data_mutex = Mutex.new
91
+ @parsed = false
92
+ @data = nil
93
+ end
94
+
95
+ def job_id
96
+ job["id"]
97
+ end
98
+
99
+ def job_name
100
+ job["jobNameInWorker"]
101
+ end
102
+
103
+ # Job data as the raw JSON string, or nil.
104
+ def raw_data
105
+ job["jobData"]
106
+ end
107
+
108
+ # Parsed job data (string keys), or nil when the job has none. Invalid JSON fails the job permanently.
109
+ def data
110
+ @data_mutex.synchronize do
111
+ unless @parsed
112
+ raw = raw_data.to_s.strip
113
+
114
+ begin
115
+ @data = raw.empty? ? nil : JSON.parse(raw)
116
+ rescue JSON::ParserError
117
+ raise PermanentJobError, "Job data is not valid JSON."
118
+ end
119
+
120
+ @parsed = true
121
+ end
122
+ end
123
+
124
+ @data
125
+ end
126
+
127
+ # True once the job has been cancelled, has timed out or the worker is shutting down.
128
+ # Ruby threads cannot be stopped safely from outside, so check this in long loops.
129
+ def aborted?
130
+ @abort.aborted?
131
+ end
132
+
133
+ # :cancelled, :timed_out, :shutdown or nil.
134
+ def abort_reason
135
+ @abort.reason
136
+ end
137
+
138
+ # Raises JobAborted if the job has been aborted.
139
+ def raise_if_aborted
140
+ raise JobAborted.new(@abort.reason, @abort.message) if @abort.aborted?
141
+ end
142
+
143
+ # Sleeps up to seconds, and raises JobAborted as soon as the job is aborted. Use it instead of Kernel#sleep.
144
+ def sleep(seconds)
145
+ @abort.wait(seconds)
146
+ raise_if_aborted
147
+ end
148
+
149
+ # Writes an Information entry to the occurrence log.
150
+ def log(message, data = nil)
151
+ write("Information", message, data)
152
+ end
153
+ alias info log
154
+
155
+ def debug(message, data = nil)
156
+ write("Debug", message, data)
157
+ end
158
+
159
+ def warn(message, data = nil)
160
+ write("Warning", message, data)
161
+ end
162
+
163
+ # Writes an Error entry, with the details of error when given.
164
+ def error(message, error = nil, data = nil)
165
+ details = (data || {}).dup
166
+ exception_type = nil
167
+
168
+ if error
169
+ exception_type = Milvaion.error_type_name(error)
170
+ details["ExceptionType"] = exception_type
171
+ details["StackTrace"] = Milvaion.format_error(error)
172
+ details["InnerException"] = error.cause.message if error.cause
173
+ end
174
+
175
+ write("Error", message, details, "UserDefined", exception_type)
176
+ end
177
+
178
+ private
179
+
180
+ def write(level, message, data, category = "UserCode", exception_type = nil)
181
+ @sink.call({
182
+ "timestamp" => Milvaion.utc_now,
183
+ "level" => level,
184
+ "message" => message.to_s,
185
+ "data" => data,
186
+ "category" => category,
187
+ "exceptionType" => exception_type
188
+ })
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Milvaion
4
+ # Base class of the errors this SDK raises.
5
+ class Error < StandardError; end
6
+
7
+ # A failure that retrying cannot fix, such as invalid job data.
8
+ # A job that raises it is not retried and goes straight to the dead letter queue.
9
+ class PermanentJobError < Error; end
10
+
11
+ # RabbitMQ or Redis could not be reached, or a message was not confirmed by the broker.
12
+ class ConnectionError < Error; end
13
+
14
+ # Why a running job was aborted.
15
+ module AbortReason
16
+ # Cancelled from the dashboard.
17
+ CANCELLED = :cancelled
18
+ # Ran past its execution timeout.
19
+ TIMED_OUT = :timed_out
20
+ # The worker is shutting down; the job goes back to the queue.
21
+ SHUTDOWN = :shutdown
22
+ end
23
+
24
+ # Raised by JobContext#raise_if_aborted and JobContext#sleep once the job has been aborted.
25
+ class JobAborted < Error
26
+ attr_reader :reason
27
+
28
+ def initialize(reason, message = nil)
29
+ super(message || "Job was aborted (#{reason}).")
30
+ @reason = reason
31
+ end
32
+ end
33
+
34
+ # Renders an exception the way the .NET SDK does, so the dashboard shows both alike.
35
+ def self.format_error(error)
36
+ formatted = +"Type: #{error_type_name(error)}\nMessage: #{error.message}\nStackTrace: #{(error.backtrace || []).join("\n")}\n"
37
+ formatted << "\nInner Exception:\n" << format_error(error.cause) if error.cause
38
+
39
+ formatted
40
+ end
41
+
42
+ def self.error_type_name(error)
43
+ error.class.name || error.class.to_s
44
+ end
45
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Milvaion
4
+ # Base class for class-based jobs. Each execution gets a new instance.
5
+ #
6
+ # class SendEmailJob < Milvaion::Job
7
+ # job_options max_retries: 5, data_schema: { type: "object" }
8
+ #
9
+ # def perform(ctx)
10
+ # ctx.log("Sending to #{ctx.data['to']}")
11
+ # { sent: true }
12
+ # end
13
+ # end
14
+ #
15
+ # worker.job(SendEmailJob)
16
+ class Job
17
+ class << self
18
+ # The name the dashboard dispatches the job by. Defaults to the class name without its namespace.
19
+ def job_name(name = nil)
20
+ @job_name = name.to_s unless name.nil?
21
+ @job_name || self.name.to_s.split("::").last
22
+ end
23
+
24
+ # Job options (see JobOptions). Merged with those of the parent class.
25
+ def job_options(**options)
26
+ @job_options = own_job_options.merge(options) unless options.empty?
27
+ inherited = superclass.respond_to?(:job_options) ? superclass.job_options : {}
28
+ inherited.merge(own_job_options)
29
+ end
30
+
31
+ private
32
+
33
+ def own_job_options
34
+ @job_options || {}
35
+ end
36
+ end
37
+
38
+ # Runs the job. The return value is stored as the occurrence result.
39
+ def perform(_ctx)
40
+ raise NotImplementedError, "#{self.class} must implement perform(ctx)"
41
+ end
42
+ end
43
+ end