monkrb 0.16.0 → 0.18.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,117 @@
1
+ module Monk
2
+ # The class an app's jobs inherit from:
3
+ #
4
+ # class SendReceipt < Monk::Job
5
+ # queue "mailers" # optional; "default" otherwise
6
+ # priority 10 # optional; lower runs sooner, 0 otherwise
7
+ # max_attempts 3 # optional; 5 otherwise
8
+ # timeout 30 # optional; seconds a run may take, no limit otherwise
9
+ # never_retry KeyError # optional; errors that fail the job at once
10
+ #
11
+ # def self.perform(order_id)
12
+ # ...
13
+ # end
14
+ # end
15
+ #
16
+ # A class, not a block: a Class is always Ractor-shareable, so #perform
17
+ # is callable from every worker Ractor without the make_shareable rules
18
+ # a Proc has to meet (docs/design/ractor.md). Settings are inherited, so
19
+ # an app can put shared ones on its own base class.
20
+ class Job
21
+ DEFAULT_QUEUE = "default".freeze
22
+ DEFAULT_PRIORITY = 0
23
+ DEFAULT_MAX_ATTEMPTS = 5
24
+
25
+ # monk_jobs.priority and max_attempts are SMALLINT columns.
26
+ PRIORITY_RANGE = -32_768..32_767
27
+ MAX_ATTEMPTS_RANGE = 1..32_767
28
+
29
+ class << self
30
+ def inherited(subclass)
31
+ super
32
+ Monk::Jobs.record(subclass)
33
+ end
34
+
35
+ def queue(name = nil)
36
+ return @queue || inherited_setting(:queue, DEFAULT_QUEUE) if name.nil?
37
+
38
+ unless name.is_a?(String) && !name.empty?
39
+ raise ArgumentError, "#{self.name || "a job"}'s queue must be a non-empty String, got #{name.inspect}"
40
+ end
41
+
42
+ # Frozen, so a worker Ractor can read it back off the class.
43
+ @queue = name.dup.freeze
44
+ end
45
+
46
+ def priority(value = nil)
47
+ return @priority || inherited_setting(:priority, DEFAULT_PRIORITY) if value.nil?
48
+
49
+ @priority = checked_integer(:priority, value, PRIORITY_RANGE)
50
+ end
51
+
52
+ def max_attempts(value = nil)
53
+ return @max_attempts || inherited_setting(:max_attempts, DEFAULT_MAX_ATTEMPTS) if value.nil?
54
+
55
+ @max_attempts = checked_integer(:max_attempts, value, MAX_ATTEMPTS_RANGE)
56
+ end
57
+
58
+ # Seconds one run of the job may take before the worker gives up on
59
+ # it: the run fails (and is retried like any other failure) and the
60
+ # worker resets its database connection, since an interrupted query
61
+ # keeps running on the server otherwise. nil, the default, means no
62
+ # limit.
63
+ def timeout(seconds = nil)
64
+ return @timeout || (equal?(Monk::Job) ? nil : superclass.timeout) if seconds.nil?
65
+
66
+ unless seconds.is_a?(Numeric) && seconds.positive?
67
+ raise ArgumentError,
68
+ "#{name || "a job"}'s timeout must be a positive number of seconds, got #{seconds.inspect}"
69
+ end
70
+
71
+ @timeout = seconds
72
+ end
73
+
74
+ # Errors that retrying can't fix: a job that raises one of these (or
75
+ # a subclass of one, matched the way rescue matches) fails at once
76
+ # instead of spending its remaining attempts. Adds to the parent
77
+ # class's list rather than replacing it, so an app's own base job
78
+ # class can declare errors for every job. With no arguments, returns
79
+ # the whole list -- frozen, so a worker Ractor can read it.
80
+ def never_retry(*error_classes)
81
+ inherited = equal?(Monk::Job) ? [] : superclass.never_retry
82
+ unless error_classes.empty?
83
+ error_classes.each do |error_class|
84
+ next if error_class.is_a?(Class) && error_class <= Exception
85
+
86
+ raise ArgumentError,
87
+ "#{name || "a job"}'s never_retry takes exception classes, got #{error_class.inspect}"
88
+ end
89
+ @never_retry = ((@never_retry || []) + error_classes).uniq.freeze
90
+ end
91
+
92
+ (inherited + (@never_retry || [])).uniq.freeze
93
+ end
94
+
95
+ # SendReceipt.enqueue(order_id, wait: 60) -- see Monk::Jobs.enqueue.
96
+ def enqueue(*, **)
97
+ Monk::Jobs.enqueue(self, *, **)
98
+ end
99
+
100
+ def perform(*)
101
+ raise NotImplementedError, "#{name || "this job"} must define self.perform"
102
+ end
103
+
104
+ private
105
+
106
+ def inherited_setting(setting, default)
107
+ equal?(Monk::Job) ? default : superclass.public_send(setting)
108
+ end
109
+
110
+ def checked_integer(setting, value, range)
111
+ return value if value.is_a?(Integer) && range.cover?(value)
112
+
113
+ raise ArgumentError, "#{name || "a job"}'s #{setting} must be an Integer in #{range}, got #{value.inspect}"
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,239 @@
1
+ require "socket"
2
+ require_relative "../jobs"
3
+ require_relative "worker"
4
+
5
+ module Monk
6
+ module Jobs
7
+ # A job process (bin/jobs): the supervisor in the calling Ractor and N
8
+ # worker Ractors (docs/history/plan-jobs.md Phase 6). Opt-in on its own,
9
+ # require "monk/jobs/runtime" -- the web process only enqueues.
10
+ #
11
+ # Monk::Jobs::Runtime.new(queues: %w[mailers default], workers: 5).run
12
+ #
13
+ # run blocks until TERM or INT (or #stop): workers finish the job in
14
+ # hand, up to shutdown_timeout seconds, and whatever is still running
15
+ # then is released for another process.
16
+ class Runtime
17
+ DEFAULTS = {
18
+ queues: [Job::DEFAULT_QUEUE], workers: 5, poll_interval: 1.0, tick_interval: 1.0,
19
+ heartbeat_interval: 15, process_timeout: 120, shutdown_timeout: 25,
20
+ }.freeze
21
+
22
+ # queues: claimed in this order, so earlier ones go first.
23
+ # poll_interval: seconds an idle worker waits before claiming again.
24
+ # tick_interval: seconds between the supervisor's rounds of staging
25
+ # due jobs (so also how late a scheduled job can start).
26
+ # heartbeat_interval: seconds between this process's heartbeats, and
27
+ # between its looks for dead processes to prune.
28
+ # process_timeout: seconds of silence after which another process's
29
+ # running jobs are released.
30
+ # shutdown_timeout: seconds a stop waits for jobs in flight.
31
+ def initialize(**settings)
32
+ unknown = settings.keys - DEFAULTS.keys
33
+ raise ArgumentError, "unknown Monk::Jobs::Runtime setting(s): #{unknown.join(", ")}" if unknown.any?
34
+
35
+ settings = DEFAULTS.merge(settings)
36
+ @queues = checked_queues(settings[:queues])
37
+ @worker_count = settings[:workers]
38
+ unless positive_integer?(@worker_count)
39
+ raise ArgumentError, "workers must be a positive Integer, got #{@worker_count.inspect}"
40
+ end
41
+
42
+ %i[poll_interval tick_interval heartbeat_interval process_timeout shutdown_timeout].each do |name|
43
+ value = settings[name]
44
+ unless value.is_a?(Numeric) && value.positive?
45
+ raise ArgumentError, "#{name} must be a positive number of seconds, got #{value.inspect}"
46
+ end
47
+
48
+ instance_variable_set(:"@#{name}", value)
49
+ end
50
+ @stopping = false
51
+ end
52
+
53
+ def run(trap_signals: true)
54
+ Monk.freeze!
55
+ @adapter = Monk::Jobs.adapter
56
+ @hostname = Socket.gethostname
57
+ @pid = Process.pid
58
+ @process_id = @adapter.register_process(hostname: @hostname, pid: @pid)
59
+ trap_signals! if trap_signals
60
+
61
+ @monitors = {} # monitor port => [worker index, its Ractor]
62
+ @controls = {} # worker index => its control port
63
+ @holding = {} # worker index => id of the job it's running
64
+ @to_release = [] # ids of jobs dead workers held, not yet released
65
+ @holding_port = Ractor::Port.new
66
+ @worker_count.times { |index| spawn_worker(index) }
67
+ Monk::Log.info(
68
+ "Monk::Jobs process #{@process_id} started: #{@worker_count} workers on #{@queues.join(", ")} (pid #{@pid})",
69
+ )
70
+
71
+ ticker, timer = start_ticker
72
+ supervise(ticker)
73
+ shut_down(ticker)
74
+ ensure
75
+ timer&.kill
76
+ ticker&.close
77
+ @holding_port&.close
78
+ end
79
+
80
+ # Asks run to stop; safe from a signal handler or another thread.
81
+ def stop
82
+ @stopping = true
83
+ end
84
+
85
+ private
86
+
87
+ def supervise(ticker)
88
+ last_heartbeat = monotonic
89
+ dead = []
90
+ until @stopping
91
+ port, message = Ractor.select(ticker, @holding_port, *@monitors.keys)
92
+ if port.equal?(ticker)
93
+ last_heartbeat = tick(last_heartbeat)
94
+ # Respawned on the next tick rather than at once, so a worker
95
+ # that dies straight away (the database is down) can't spin.
96
+ dead.each { |index| spawn_worker(index) }
97
+ dead.clear
98
+ elsif port.equal?(@holding_port)
99
+ index, id = message
100
+ id ? @holding[index] = id : @holding.delete(index)
101
+ else
102
+ dead << worker_exited(port)
103
+ end
104
+ end
105
+ end
106
+
107
+ # A database error here (Postgres restarting, a dropped connection)
108
+ # mustn't end the process: it's logged, this Ractor's connection is
109
+ # reset, and the next tick tries again.
110
+ def tick(last_heartbeat)
111
+ release_dead_workers_jobs
112
+ @adapter.stage_due
113
+ return last_heartbeat if monotonic - last_heartbeat < @heartbeat_interval
114
+
115
+ beat
116
+ monotonic
117
+ rescue StandardError => e
118
+ Monk::Log.error("Monk::Jobs supervisor: #{Monk::Jobs.describe_error(e)}; reconnecting")
119
+ reconnect
120
+ last_heartbeat
121
+ end
122
+
123
+ # A job stays on the list until its release has gone through (or found
124
+ # the job no longer held), so a database error just retries it next tick.
125
+ def release_dead_workers_jobs
126
+ until @to_release.empty?
127
+ @adapter.release(@to_release.first, @process_id)
128
+ @to_release.shift
129
+ end
130
+ end
131
+
132
+ def reconnect
133
+ @adapter.reset_connection
134
+ rescue StandardError => e
135
+ Monk::Log.error("Monk::Jobs supervisor: can't reconnect yet (#{e.class}: #{e.message})")
136
+ end
137
+
138
+ def beat
139
+ @adapter.heartbeat(@process_id, hostname: @hostname, pid: @pid)
140
+ released = @adapter.prune(@process_timeout)
141
+ return unless released.positive?
142
+
143
+ Monk::Log.warn("Monk::Jobs: released #{released} job(s) of processes silent for #{@process_timeout}s")
144
+ end
145
+
146
+ def spawn_worker(index)
147
+ hello = Ractor::Port.new
148
+ args = [@adapter, @process_id, @queues, @poll_interval, hello, @holding_port, index]
149
+ ractor = Ractor.new(*args, name: "monk-jobs-worker-#{index}") { |*worker_args| Monk::Jobs::Worker.run(*worker_args) }
150
+ @controls[index] = hello.receive
151
+ monitor = Ractor::Port.new
152
+ ractor.monitor(monitor)
153
+ @monitors[monitor] = [index, ractor]
154
+ ensure
155
+ hello&.close
156
+ end
157
+
158
+ # One port per worker: Ractor#monitor's message is a bare :exited or
159
+ # :aborted that doesn't say which Ractor (Phase 0.4).
160
+ def worker_exited(port)
161
+ index, ractor = @monitors.delete(port)
162
+ @controls.delete(index)
163
+ held = @holding.delete(index)
164
+ @to_release << held if held
165
+ begin
166
+ ractor.value
167
+ Monk::Log.warn("Monk::Jobs: worker #{index} exited; starting a new one")
168
+ rescue Ractor::RemoteError => e
169
+ Monk::Log.error("Monk::Jobs: worker #{index} died; starting a new one. #{Monk::Jobs.describe_error(e.cause)}")
170
+ end
171
+ index
172
+ end
173
+
174
+ def shut_down(ticker)
175
+ Monk::Log.info(
176
+ "Monk::Jobs process #{@process_id} stopping: waiting up to #{@shutdown_timeout}s for jobs in flight",
177
+ )
178
+ @controls.each_value do |control|
179
+ control << :stop
180
+ rescue Ractor::ClosedError
181
+ nil
182
+ end
183
+
184
+ deadline = monotonic + @shutdown_timeout
185
+ until @monitors.empty? || monotonic >= deadline
186
+ port, = Ractor.select(ticker, *@monitors.keys)
187
+ @monitors.delete(port) unless port.equal?(ticker)
188
+ end
189
+ unless @monitors.empty?
190
+ Monk::Log.warn(
191
+ "Monk::Jobs: #{@monitors.size} worker(s) still busy after #{@shutdown_timeout}s; releasing their jobs",
192
+ )
193
+ end
194
+
195
+ begin
196
+ @adapter.deregister(@process_id)
197
+ rescue StandardError => e
198
+ # Its jobs are released by another process's pruning instead.
199
+ Monk::Log.error("Monk::Jobs process #{@process_id} couldn't deregister: #{e.class}: #{e.message}")
200
+ end
201
+ Monk::Log.info("Monk::Jobs process #{@process_id} stopped")
202
+ end
203
+
204
+ def start_ticker
205
+ ticker = Ractor::Port.new
206
+ interval = @tick_interval
207
+ timer = Thread.new do
208
+ loop do
209
+ sleep interval
210
+ ticker << :tick
211
+ end
212
+ rescue Ractor::ClosedError
213
+ nil
214
+ end
215
+ [ticker, timer]
216
+ end
217
+
218
+ def trap_signals!
219
+ %w[TERM INT].each { |signal| Signal.trap(signal) { stop } }
220
+ end
221
+
222
+ def checked_queues(queues)
223
+ unless queues.is_a?(Array) && queues.any? && queues.all? { |q| q.is_a?(String) && !q.empty? }
224
+ raise ArgumentError, "queues must be a non-empty Array of queue names, got #{queues.inspect}"
225
+ end
226
+
227
+ Ractor.make_shareable(queues.map(&:dup))
228
+ end
229
+
230
+ def positive_integer?(value)
231
+ value.is_a?(Integer) && value.positive?
232
+ end
233
+
234
+ def monotonic
235
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
236
+ end
237
+ end
238
+ end
239
+ end
@@ -0,0 +1,94 @@
1
+ require "timeout"
2
+
3
+ module Monk
4
+ module Jobs
5
+ # One worker Ractor's loop (docs/history/plan-jobs.md Phase 6): claim
6
+ # from the queues in order, run the job, finish or fail it, and wait
7
+ # poll_interval when there's nothing to claim. Plain module methods,
8
+ # not blocks, so they're callable from any Ractor.
9
+ module Worker
10
+ # Failures that retrying can't fix.
11
+ NEVER_RETRY = [UnknownJobError, NotImplementedError].freeze
12
+
13
+ # The body of a worker Ractor, until the supervisor sends :stop on the
14
+ # control port handed back through `hello`. A stop is only noticed
15
+ # between jobs, never in the middle of one. `holding` tells the
16
+ # supervisor which job this worker has, [index, id] after a claim and
17
+ # [index, nil] once it's finished or failed, so a job whose worker
18
+ # dies in between can be released.
19
+ def self.run(adapter, process_id, queues, poll_interval, hello, holding, index)
20
+ # The supervisor logs why a worker ended, backtrace included; don't
21
+ # also dump it to stderr.
22
+ Thread.current.report_on_exception = false
23
+ control = Ractor::Port.new
24
+ hello << control
25
+ wake = Thread::Queue.new
26
+ stopping = false
27
+ Thread.new do
28
+ control.receive
29
+ stopping = true
30
+ wake << :stop
31
+ end
32
+
33
+ until stopping
34
+ claim = next_claim(adapter, queues, process_id)
35
+ next wake.pop(timeout: poll_interval) unless claim
36
+
37
+ holding << [index, claim.id]
38
+ perform(adapter, process_id, claim)
39
+ holding << [index, nil]
40
+ end
41
+ end
42
+
43
+ def self.next_claim(adapter, queues, process_id)
44
+ queues.each do |queue|
45
+ claim = adapter.claim(queue, process_id)
46
+ return claim if claim
47
+ end
48
+ nil
49
+ end
50
+
51
+ # Every exception fails the job, not only StandardErrors: one that
52
+ # escaped would leave the job running inside a live process, which
53
+ # pruning never touches. A non-StandardError is re-raised afterwards,
54
+ # ending this Ractor so the supervisor starts a fresh one.
55
+ def self.perform(adapter, process_id, claim)
56
+ job = nil
57
+ error = begin
58
+ job = Monk::Jobs.lookup(claim.job_class)
59
+ run_job(job, claim.args)
60
+ nil
61
+ rescue Exception => e # rubocop:disable Lint/RescueException
62
+ e
63
+ end
64
+ return adapter.finish(claim.id, process_id) unless error
65
+
66
+ record_failure(adapter, process_id, claim, error, job)
67
+ raise error unless error.is_a?(StandardError) || error.is_a?(NotImplementedError)
68
+ end
69
+
70
+ def self.run_job(job, args)
71
+ return job.perform(*args) unless job.timeout
72
+
73
+ Timeout.timeout(job.timeout) { job.perform(*args) }
74
+ end
75
+
76
+ # job is nil when its class couldn't be found (UnknownJobError).
77
+ def self.record_failure(adapter, process_id, claim, error, job)
78
+ # Phase 0.3: the interrupted query is still running on the server,
79
+ # and this connection's next query would wait for it.
80
+ adapter.reset_connection if error.is_a?(Timeout::Error)
81
+
82
+ never_retry = NEVER_RETRY + (job ? job.never_retry : [])
83
+ retry_in = never_retry.any? { |klass| error.is_a?(klass) } ? nil : Monk::Jobs.backoff(claim.attempts)
84
+ outcome = adapter.fail(claim.id, process_id, error: Monk::Jobs.describe_error(error), retry_in: retry_in)
85
+ outcomes = { scheduled: "retrying in #{retry_in}s", failed: "failed for good" }
86
+ what_next = outcomes.fetch(outcome, "no longer held")
87
+ Monk::Log.error(
88
+ "Monk::Jobs: #{claim.job_class} (job #{claim.id}, attempt #{claim.attempts}) failed with " \
89
+ "#{error.class}: #{error.message} -- #{what_next}",
90
+ )
91
+ end
92
+ end
93
+ end
94
+ end
data/lib/monk/jobs.rb ADDED
@@ -0,0 +1,229 @@
1
+ # The whole framework, as monk/live does: jobs use Monk.freeze!, Monk::Log
2
+ # and Monk.env, and a job process (bin/jobs) may load nothing else first.
3
+ require_relative "../monk"
4
+ require_relative "jobs/errors"
5
+ require_relative "jobs/args"
6
+ require "json"
7
+
8
+ module Monk
9
+ # Background jobs on Postgres. Opt-in: require "monk/jobs" explicitly --
10
+ # `require "monk"` alone does not load this. Design:
11
+ # docs/adr/0013-jobs-narrow-state-table-plus-payloads.md; plan:
12
+ # docs/history/plan-jobs.md.
13
+ module Jobs
14
+ # last_error keeps the first lines of a failure, not all of it.
15
+ MAX_ERROR_LENGTH = 4_000
16
+ BACKTRACE_LINES = 10
17
+
18
+ # The process id drain! claims jobs as: never a monk_processes id, which
19
+ # starts at 1.
20
+ DRAIN_PROCESS_ID = 0
21
+
22
+ class << self
23
+ # Typically, in config/jobs.rb:
24
+ #
25
+ # Monk::Jobs.configure(db_name: :primary)
26
+ #
27
+ # db_name: is a database registered with Monk::Persistence::Pg. The
28
+ # app's own database by default, so a job can be enqueued inside the
29
+ # app's own transaction; a separate one isolates the queue from the
30
+ # app's long transactions (docs/adr/0013-jobs-narrow-state-table-plus-payloads.md).
31
+ # Tests use the app's test database, the same way: enqueue, drain!,
32
+ # and clear! between tests.
33
+ def configure(db_name:)
34
+ require_relative "jobs/adapters/pg"
35
+ @adapter = Adapters::Pg.new(db_name: db_name)
36
+ end
37
+
38
+ # The configured adapter. Shareable (it freezes itself), so a worker
39
+ # Ractor reads it straight off this module.
40
+ def adapter
41
+ @adapter || raise(NotConfiguredError, "call Monk::Jobs.configure(db_name:) before enqueueing or running jobs")
42
+ end
43
+
44
+ # Stores a job to run later; returns its id. args are passed to the
45
+ # job's self.perform as they are, and must be plain JSON values
46
+ # (Args.check!). Options:
47
+ #
48
+ # wait: 60 run no sooner than 60 seconds from now
49
+ # at: Time run no sooner than then
50
+ # conn: a PG::Connection enqueue on it, inside its transaction, so
51
+ # the job commits or rolls back with the
52
+ # app's own writes
53
+ #
54
+ # SendReceipt.enqueue(order_id) is the same call.
55
+ def enqueue(job_class, *args, wait: nil, at: nil, conn: nil)
56
+ check_enqueueable!(job_class)
57
+ Args.check!(args)
58
+ check_schedule!(wait, at)
59
+
60
+ adapter.enqueue(
61
+ job_class: job_class.name, queue: job_class.queue, priority: job_class.priority,
62
+ max_attempts: job_class.max_attempts, args: JSON.generate(args), wait: wait, at: at, conn: conn,
63
+ )
64
+ end
65
+
66
+ # Called by Monk::Job.inherited, in the main Ractor as the app's job
67
+ # files load. Only recorded here: names are resolved when the
68
+ # registry is frozen, so a class that gets its constant name after
69
+ # being created (Foo = Class.new(Monk::Job)) is still found.
70
+ def record(job_class)
71
+ classes << job_class
72
+ end
73
+
74
+ # The Monk::Job subclass a monk_job_payloads.job_class names. Only
75
+ # ever answers with a recorded job class -- never Object.const_get
76
+ # on a String read from the database. Reads nothing but the frozen
77
+ # registry, so it works the same from any Ractor.
78
+ def lookup(name)
79
+ registry = @registry
80
+ if registry.nil?
81
+ raise NotFrozenError,
82
+ "Monk::Jobs.lookup(#{name.inspect}) before the job registry is frozen -- call Monk.boot(app) " \
83
+ "(or Monk.freeze!) in the main Ractor first, after the app's job classes are loaded"
84
+ end
85
+
86
+ registry.fetch(name) do
87
+ raise UnknownJobError, "no Monk::Job subclass named #{name.inspect} is loaded in this process"
88
+ end
89
+ end
90
+
91
+ # Called from Monk.freeze! via Monk.freeze_hooks. Rebuilds the map
92
+ # from every named job class recorded so far, so freezing twice (two
93
+ # apps, or a test suite) is harmless. Anonymous classes are skipped:
94
+ # without a name a job can never be found again once enqueued.
95
+ def freeze_registry!
96
+ @registry = Ractor.make_shareable(classes.filter_map { |job| [job.name, job] if job.name }.to_h)
97
+ end
98
+
99
+ # A failed job back to available, with its attempts reset; true if it
100
+ # was failed. Failed jobs stay in monk_jobs until retried or discarded.
101
+ def retry_failed(id)
102
+ adapter.retry_failed(id)
103
+ end
104
+
105
+ # Deletes a failed job; true if it was failed.
106
+ def discard_failed(id)
107
+ adapter.discard_failed(id)
108
+ end
109
+
110
+ # Seconds to wait before retrying a job that has failed `attempts`
111
+ # times: 16, 31, 96, 271, 640, ... (Sidekiq's curve, without its
112
+ # random jitter).
113
+ def backoff(attempts)
114
+ (attempts**4) + 15
115
+ end
116
+
117
+ # What goes in last_error: the class and message, then the first
118
+ # BACKTRACE_LINES of the backtrace, capped at MAX_ERROR_LENGTH.
119
+ def describe_error(error)
120
+ lines = ["#{error.class}: #{error.message}", *Array(error.backtrace).first(BACKTRACE_LINES)]
121
+ lines.join("\n")[0, MAX_ERROR_LENGTH]
122
+ end
123
+
124
+ # Runs every job that's due, one after another, until none is left --
125
+ # including jobs those jobs enqueue -- and returns how many ran. For
126
+ # an app's tests: enqueue, drain!, then assert on what the jobs did.
127
+ # Nothing is retried: the first job that raises is marked failed and
128
+ # its error re-raised here.
129
+ #
130
+ # Each job runs in a throwaway Ractor, as it would in bin/jobs, so a
131
+ # job that only works in the main Ractor (a gem with module-level
132
+ # state, a non-shareable constant) fails in the test too.
133
+ # in_ractor: false runs jobs in the calling Ractor instead, for a test
134
+ # that needs to see a job's side effects there.
135
+ #
136
+ # Calls Monk.freeze! first, as bin/jobs does, so jobs can read the
137
+ # app's frozen configuration from their Ractor.
138
+ def drain!(in_ractor: true)
139
+ Monk.freeze!
140
+ queues = classes.map(&:queue) | [Job::DEFAULT_QUEUE]
141
+ ran = 0
142
+ loop do
143
+ nil while adapter.stage_due.positive?
144
+ claim = queues.lazy.filter_map { |queue| adapter.claim(queue, DRAIN_PROCESS_ID) }.first
145
+ break unless claim
146
+
147
+ run_drained(claim, in_ractor)
148
+ ran += 1
149
+ end
150
+ ran
151
+ end
152
+
153
+ # Empties the queue: every job, failed ones included, and every job
154
+ # process row. For an app's tests, between one test and the next --
155
+ # and so it refuses to run unless MONK_ENV is test.
156
+ def clear!
157
+ unless Monk.env.test?
158
+ raise ClearOutsideTestsError,
159
+ "Monk::Jobs.clear! empties the whole queue, so it only runs with MONK_ENV=test (this is #{Monk.env})"
160
+ end
161
+
162
+ adapter.clear!
163
+ end
164
+
165
+ # Test-only: unfreezes the registry and forgets the configuration.
166
+ # Recorded classes stay recorded, the same way a Ruby class can't be
167
+ # undefined.
168
+ def reset!
169
+ @registry = nil
170
+ @adapter = nil
171
+ end
172
+
173
+ private
174
+
175
+ def run_drained(claim, in_ractor)
176
+ begin
177
+ job = lookup(claim.job_class)
178
+ in_ractor ? perform_in_ractor(job, claim.args) : job.perform(*claim.args)
179
+ # NotImplementedError too, a job class with no self.perform: it's a
180
+ # ScriptError, not a StandardError, and would otherwise leave the job
181
+ # running.
182
+ rescue StandardError, NotImplementedError => e
183
+ adapter.fail(claim.id, DRAIN_PROCESS_ID, error: describe_error(e), retry_in: nil)
184
+ raise
185
+ end
186
+ adapter.finish(claim.id, DRAIN_PROCESS_ID)
187
+ end
188
+
189
+ def perform_in_ractor(job, args)
190
+ Ractor.new(job, args) do |job, args|
191
+ # The error is re-raised in the caller; don't also print it here.
192
+ Thread.current.report_on_exception = false
193
+ job.perform(*args)
194
+ nil
195
+ end.value
196
+ rescue Ractor::RemoteError => e
197
+ # The job's own error, as if it had run here. cause: nil, or Ruby
198
+ # would chain the RemoteError onto it -- whose cause it already is.
199
+ raise e.cause, cause: nil
200
+ end
201
+
202
+ def check_enqueueable!(job_class)
203
+ unless job_class.is_a?(Class) && job_class < Monk::Job
204
+ raise ArgumentError, "Monk::Jobs.enqueue needs a Monk::Job subclass, got #{job_class.inspect}"
205
+ end
206
+ return if job_class.name
207
+
208
+ raise ArgumentError,
209
+ "#{job_class.inspect} has no name, so a worker could never find it again -- assign it to a constant"
210
+ end
211
+
212
+ def check_schedule!(wait, at)
213
+ raise ArgumentError, "pass wait: or at:, not both" if wait && at
214
+ if wait && !(wait.is_a?(Numeric) && wait >= 0)
215
+ raise ArgumentError, "wait: must be a number of seconds, zero or more, got #{wait.inspect}"
216
+ end
217
+ raise ArgumentError, "at: must be a Time, got #{at.inspect}" if at && !at.is_a?(Time)
218
+ end
219
+
220
+ def classes
221
+ @classes ||= []
222
+ end
223
+ end
224
+
225
+ Monk.freeze_hooks << self
226
+ end
227
+ end
228
+
229
+ require_relative "jobs/job"