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 +7 -0
- data/README.md +190 -0
- data/lib/milvaion/cancellation_listener.rb +136 -0
- data/lib/milvaion/context.rb +191 -0
- data/lib/milvaion/errors.rb +45 -0
- data/lib/milvaion/job.rb +43 -0
- data/lib/milvaion/log_buffer.rb +120 -0
- data/lib/milvaion/metrics.rb +115 -0
- data/lib/milvaion/options.rb +205 -0
- data/lib/milvaion/processor.rb +558 -0
- data/lib/milvaion/protocol.rb +45 -0
- data/lib/milvaion/thread_pool.rb +85 -0
- data/lib/milvaion/worker/version.rb +8 -0
- data/lib/milvaion/worker.rb +504 -0
- data/lib/milvaion-worker.rb +3 -0
- data/lib/milvaion.rb +3 -0
- data/milvaion-worker.gemspec +29 -0
- metadata +118 -0
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
|
data/lib/milvaion/job.rb
ADDED
|
@@ -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
|