laravel-cloud-rails 0.1.1

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,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module LaravelCloud
6
+ module Queue
7
+ # Pushes jobs to SQS for the Active Job adapter.
8
+ class Dispatcher
9
+ # SQS limits: per-message delay, visibility timeout (a retry's backoff),
10
+ # and SendMessageBatch size.
11
+ MAX_DELAY_SECONDS = 900
12
+ MAX_VISIBILITY_SECONDS = 43_200
13
+ MAX_BATCH_MESSAGES = 10
14
+ MAX_BATCH_BYTES = 1_048_576
15
+
16
+ def initialize(runtime)
17
+ @runtime = runtime
18
+ end
19
+
20
+ # `delay` is in seconds (a Float, from `wait:` / `wait_until:`).
21
+ def dispatch(job, delay: 0)
22
+ delivery = Delivery.current
23
+
24
+ if delivery&.retry_of?(job)
25
+ retry_job(job, delay, delivery)
26
+ else
27
+ push(job, queue_for(job), delay)
28
+ end
29
+ end
30
+
31
+ # `ActiveJob.perform_all_later`. Every job is validated before anything is
32
+ # sent, so an invalid one raises with nothing half-sent; a job SQS rejects
33
+ # gets an `enqueue_error`. Returns the number enqueued.
34
+ def dispatch_all(jobs)
35
+ entries = jobs.map do |job|
36
+ queue = queue_for(job)
37
+ [job, queue, message_for(job, queue, delay_for(job))]
38
+ end
39
+
40
+ entries.group_by { |_, queue, _| queue }.sum do |queue, queue_entries|
41
+ queue_url = require_queue_url(queue)
42
+
43
+ batches(queue_entries).sum { |batch| send_batch(queue, queue_url, batch) }
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ def config
50
+ @runtime.config
51
+ end
52
+
53
+ # A `retry_on` re-enqueue of the running job. Rather than pushing a copy,
54
+ # the original message is released for SQS to redeliver after the backoff:
55
+ # no duplicate, backoffs up to 12 hours, and it works on FIFO queues.
56
+ def retry_job(job, delay, delivery)
57
+ # Round up: truncating a sub-second backoff to 0 would make a hot retry
58
+ # loop. Long polynomial backoffs are clamped to the 12-hour ceiling.
59
+ seconds = delay.to_f.ceil.clamp(0, MAX_VISIBILITY_SECONDS)
60
+
61
+ # Recorded first, so a failed cross-queue push below still retries here.
62
+ delivery.release!(seconds)
63
+ job.provider_job_id = delivery.message_id
64
+
65
+ # `retry_on ..., queue:` moves the job, which a release can't do: push
66
+ # it there as a new message and delete the original. Comparing with the
67
+ # queue name it arrived with means only an explicit move gets here.
68
+ return if job.queue_name.to_s == delivery.job_queue_name.to_s
69
+
70
+ push(job, queue_for(job), delay)
71
+ delivery.supersede!
72
+ end
73
+
74
+ def push(job, queue, delay)
75
+ result = send_message(queue, message_for(job, queue, delay))
76
+
77
+ job.provider_job_id = result.message_id
78
+ end
79
+
80
+ def send_message(queue, message)
81
+ queue_url = require_queue_url(queue)
82
+
83
+ result = begin
84
+ @runtime.sqs.send_message(message.merge(queue_url: queue_url))
85
+ rescue StandardError => e
86
+ raise ActiveJob::EnqueueError, "Failed to push to the Laravel Cloud managed queue \"#{queue}\": #{e.message}"
87
+ end
88
+
89
+ @runtime.observer.queued(queue)
90
+ result
91
+ end
92
+
93
+ def send_batch(queue, queue_url, batch)
94
+ by_id = batch.each_with_index.to_h { |(job, _, _), index| [index.to_s, job] }
95
+ messages = batch.each_with_index.map { |(_, _, message), index| message.merge(id: index.to_s) }
96
+
97
+ result = begin
98
+ @runtime.sqs.send_message_batch(queue_url: queue_url, entries: messages)
99
+ rescue StandardError => e
100
+ error = ActiveJob::EnqueueError.new("Failed to push to the Laravel Cloud managed queue \"#{queue}\": #{e.message}")
101
+ by_id.each_value { |job| reject(job, error) }
102
+ return 0
103
+ end
104
+
105
+ result.successful.each do |entry|
106
+ job = by_id.fetch(entry.id)
107
+ job.provider_job_id = entry.message_id
108
+ job.successfully_enqueued = true
109
+ @runtime.observer.queued(queue)
110
+ end
111
+
112
+ result.failed.each do |entry|
113
+ reject(by_id.fetch(entry.id), ActiveJob::EnqueueError.new(
114
+ "The Laravel Cloud managed queue \"#{queue}\" rejected the job: #{entry.code} #{entry.message}",
115
+ ))
116
+ end
117
+
118
+ result.successful.size
119
+ end
120
+
121
+ def reject(job, error)
122
+ job.successfully_enqueued = false
123
+ job.enqueue_error = error
124
+ end
125
+
126
+ # Split one queue's messages by SendMessageBatch's limits.
127
+ def batches(entries)
128
+ batches = []
129
+ bytes = 0
130
+
131
+ entries.each do |entry|
132
+ size = entry.last[:message_body].bytesize
133
+
134
+ if batches.empty? || batches.last.size >= MAX_BATCH_MESSAGES || bytes + size > MAX_BATCH_BYTES
135
+ batches << []
136
+ bytes = 0
137
+ end
138
+
139
+ batches.last << entry
140
+ bytes += size
141
+ end
142
+
143
+ batches
144
+ end
145
+
146
+ def message_for(job, queue, delay)
147
+ group = attribute(job, :cloud_message_group_id)
148
+ deduplication = attribute(job, :cloud_message_deduplication_id)
149
+
150
+ { message_body: Message.encode(job) }.merge(attributes_for(queue, delay, group: group, deduplication: deduplication))
151
+ end
152
+
153
+ # FIFO queues need a group and deduplication ID and reject delays.
154
+ # Anything a queue can't honour raises rather than being dropped.
155
+ def attributes_for(queue, delay, group:, deduplication:)
156
+ if ManagedQueueConfig.fifo?(queue)
157
+ if delay.to_f.positive?
158
+ raise InvalidDispatch, "The FIFO queue \"#{queue}\" does not support a per-message delay; " \
159
+ "remove the wait or dispatch to a standard queue."
160
+ end
161
+
162
+ # Laravel's SqsQueue defaults: one group per queue, unique dedupe IDs.
163
+ { message_group_id: group || queue, message_deduplication_id: deduplication || SecureRandom.uuid }
164
+ else
165
+ # Deduplication is FIFO-only; it would silently do nothing here.
166
+ unless deduplication.nil?
167
+ raise InvalidDispatch, "A message deduplication ID was set for the non-FIFO queue \"#{queue}\"; " \
168
+ "deduplication requires a \".fifo\" queue."
169
+ end
170
+
171
+ attributes = {}
172
+
173
+ # On a standard queue a group is a fair-queues tenant key.
174
+ attributes[:message_group_id] = group unless group.nil?
175
+
176
+ if delay.to_f.positive?
177
+ seconds = delay.to_f.ceil
178
+
179
+ # Raise rather than quietly run the job early.
180
+ if seconds > MAX_DELAY_SECONDS
181
+ raise InvalidDispatch, "A delay of #{seconds} seconds exceeds the SQS maximum of #{MAX_DELAY_SECONDS} " \
182
+ "seconds (15 minutes); SQS cannot delay a message beyond 15 minutes."
183
+ end
184
+
185
+ attributes[:delay_seconds] = seconds
186
+ end
187
+
188
+ attributes
189
+ end
190
+ end
191
+
192
+ def delay_for(job)
193
+ job.scheduled_at ? job.scheduled_at.to_f - Time.now.to_f : 0
194
+ end
195
+
196
+ def require_queue_url(queue)
197
+ config.queue_url_for(queue) ||
198
+ raise(NotConfigured, "No Laravel Cloud managed queue is configured (LARAVEL_CLOUD_MANAGED_QUEUES_CONFIG is missing).")
199
+ end
200
+
201
+ def attribute(job, name)
202
+ job.respond_to?(name) ? job.public_send(name) : nil
203
+ end
204
+
205
+ # Active Job's default queue maps to the environment's default managed
206
+ # queue; any other name is the managed queue of that name.
207
+ def queue_for(job)
208
+ name = job.queue_name.to_s
209
+
210
+ name == job.class.queue_name_from_part(job.class.default_queue_name) ? config.queue : name
211
+ end
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LaravelCloud
4
+ module Queue
5
+ # Validates the job class a message names before constantizing it.
6
+ module JobClass
7
+ NAME_PATTERN = /\A[A-Z]\w*(::[A-Z]\w*)*\z/
8
+
9
+ # Can never run here, so not retried.
10
+ class Unrunnable < LaravelCloud::Error; end
11
+
12
+ # Not defined here, e.g. mid-deploy. Retried, as another worker may have it.
13
+ class NotDefined < LaravelCloud::Error; end
14
+
15
+ def self.resolve(name, allowed: nil)
16
+ name = name.to_s
17
+
18
+ raise Unrunnable, "#{name.inspect} is not a valid job class name." unless NAME_PATTERN.match?(name)
19
+ raise Unrunnable, "#{name} is not in the allowed job classes." if allowed && !allowed.include?(name)
20
+
21
+ klass = begin
22
+ Object.const_get(name)
23
+ rescue NameError
24
+ raise NotDefined, "The job class #{name} is not defined."
25
+ end
26
+
27
+ raise Unrunnable, "#{name} is not an Active Job class." unless klass.is_a?(Class) && klass < ActiveJob::Base
28
+
29
+ klass
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module LaravelCloud
6
+ module Queue
7
+ # Parses LARAVEL_CLOUD_MANAGED_QUEUES_CONFIG, which Laravel Cloud injects:
8
+ #
9
+ # {
10
+ # "driver": "cloud",
11
+ # "queue": "default",
12
+ # "connection": {
13
+ # "driver": "sqs",
14
+ # "prefix": "https://sqs.us-east-2.amazonaws.com/036991040854",
15
+ # "suffix": "-env-a21e326b-...",
16
+ # "queue": "default",
17
+ # "region": "us-east-2",
18
+ # "credentials": "ecs"
19
+ # },
20
+ # "agent": {
21
+ # "enabled": true,
22
+ # "socket": "/tmp/cloud-agent.sock"
23
+ # }
24
+ # }
25
+ class ManagedQueueConfig
26
+ DEFAULT_AGENT_SOCKET = "/tmp/cloud-agent.sock"
27
+ FIFO_SUFFIX = ".fifo"
28
+
29
+ attr_reader :queue_url, :region, :credentials, :queue, :agent_socket, :prefix, :suffix
30
+
31
+ def self.from_env(env = ENV)
32
+ from_json(env["LARAVEL_CLOUD_MANAGED_QUEUES_CONFIG"], agent_socket: env["LARAVEL_CLOUD_AGENT_SOCKET"])
33
+ end
34
+
35
+ # Missing or malformed config gives an unconfigured result rather than an
36
+ # error, so the app still boots.
37
+ def self.from_json(json, agent_socket: nil)
38
+ data = parse(json)
39
+ connection = data["connection"].is_a?(Hash) ? data["connection"] : {}
40
+
41
+ agent = data["agent"].is_a?(Hash) ? data["agent"] : {}
42
+
43
+ # Agent socket: the config's, then LARAVEL_CLOUD_AGENT_SOCKET, then the default.
44
+ socket = presence(agent["socket"]) || presence(agent_socket) || DEFAULT_AGENT_SOCKET
45
+
46
+ new(
47
+ prefix: connection["prefix"].to_s.sub(%r{/+\z}, ""),
48
+ suffix: connection["suffix"].to_s,
49
+ queue: (connection["queue"] || data["queue"] || "default").to_s,
50
+ region: connection["region"]&.to_s,
51
+ credentials: (connection["credentials"] || "default").to_s,
52
+ agent_socket: socket,
53
+ agent_enabled: agent["enabled"] == true,
54
+ )
55
+ end
56
+
57
+ def self.parse(json)
58
+ return {} if json.nil? || json.empty?
59
+
60
+ decoded = JSON.parse(json)
61
+ decoded.is_a?(Hash) ? decoded : {}
62
+ rescue JSON::ParserError
63
+ {}
64
+ end
65
+ private_class_method :parse
66
+
67
+ def self.presence(value)
68
+ value.is_a?(String) && !value.empty? ? value : nil
69
+ end
70
+ private_class_method :presence
71
+
72
+ # ".fifo" is the only marker SQS and Laravel use for FIFO queues.
73
+ def self.fifo?(queue)
74
+ queue.to_s.end_with?(FIFO_SUFFIX)
75
+ end
76
+
77
+ def initialize(prefix:, suffix:, queue:, region:, credentials:, agent_socket:, agent_enabled:)
78
+ @prefix = prefix
79
+ @suffix = suffix
80
+ @queue = queue
81
+ @region = region
82
+ @credentials = credentials
83
+ @agent_socket = agent_socket
84
+ @agent_enabled = agent_enabled
85
+ @queue_url = queue_url_for(queue)
86
+ end
87
+
88
+ # The SQS URL for a queue name, as Laravel's SqsQueue::suffixQueue builds
89
+ # it: {prefix}/{queue}{suffix}, with the suffix before ".fifo" on FIFO
90
+ # queues ({prefix}/{base}{suffix}.fifo). Nil when unconfigured.
91
+ def queue_url_for(queue)
92
+ return nil if prefix.empty?
93
+
94
+ if self.class.fifo?(queue)
95
+ "#{prefix}/#{queue.delete_suffix(FIFO_SUFFIX)}#{suffix}#{FIFO_SUFFIX}"
96
+ else
97
+ "#{prefix}/#{queue}#{suffix}"
98
+ end
99
+ end
100
+
101
+ # The queue name for an SQS URL — the inverse of #queue_url_for, and the
102
+ # name the dashboard groups metrics by (Laravel's Cloud\Queue::normalizeQueue).
103
+ def normalize_queue(queue)
104
+ queue = queue.to_s
105
+ queue = queue.delete_prefix("#{prefix}/") unless prefix.empty?
106
+
107
+ return queue if suffix.empty?
108
+
109
+ if self.class.fifo?(queue)
110
+ base = queue.delete_suffix(FIFO_SUFFIX)
111
+ base.end_with?(suffix) ? "#{base.delete_suffix(suffix)}#{FIFO_SUFFIX}" : queue
112
+ else
113
+ queue.delete_suffix(suffix)
114
+ end
115
+ end
116
+
117
+ def configured?
118
+ !queue_url.nil?
119
+ end
120
+
121
+ # Whether to receive through the Cloud agent. Trusts the config's flag
122
+ # rather than probing for the socket, which may appear or vanish.
123
+ def agent_available?
124
+ @agent_enabled
125
+ end
126
+
127
+ def ecs_credentials?
128
+ credentials == "ecs"
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module LaravelCloud
6
+ module Queue
7
+ # The message body: Active Job's serialized job plus the "uuid" and
8
+ # "displayName" keys of Laravel's payload, which the dashboard names
9
+ # failed jobs by. Decoding only reads "data".
10
+ #
11
+ # { "uuid": "<job_id>", "displayName": "ProcessReportJob", "data": { ...job.serialize... } }
12
+ module Message
13
+ class Undecodable < LaravelCloud::Error; end
14
+
15
+ def self.encode(job)
16
+ JSON.generate(
17
+ "uuid" => job.job_id,
18
+ "displayName" => job.class.name,
19
+ "data" => job.serialize,
20
+ )
21
+ end
22
+
23
+ # Decode a body into job data, with attempt counters taken from SQS.
24
+ #
25
+ # Retries release the original message (see Delivery), so its stored
26
+ # counters never change and `retry_on` would retry forever. Instead,
27
+ # delivery N has N-1 prior executions — per message, like Laravel's
28
+ # SqsJob::attempts(), so a freshly pushed message (a dashboard retry, a
29
+ # queue move) starts again at 1. `exception_executions` is cleared, which
30
+ # makes `retry_on` count all deliveries rather than per exception.
31
+ def self.decode(body, receive_count: 1)
32
+ payload = JSON.parse(body.to_s)
33
+ data = payload["data"] if payload.is_a?(Hash)
34
+
35
+ unless data.is_a?(Hash) && data["job_class"].is_a?(String)
36
+ raise Undecodable, "The managed queue message body is not in the expected format."
37
+ end
38
+
39
+ data.merge(
40
+ "executions" => [receive_count.to_i, 1].max - 1,
41
+ "exception_executions" => nil,
42
+ )
43
+ rescue JSON::ParserError
44
+ raise Undecodable, "The managed queue message body is not valid JSON."
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module LaravelCloud
6
+ module Queue
7
+ # Message group and deduplication IDs for FIFO and fair queues, declared on
8
+ # the job class (a value, or a block evaluated on the job):
9
+ #
10
+ # class ProcessOrderJob < ApplicationJob
11
+ # include LaravelCloud::Queue::MessageAttributes
12
+ #
13
+ # queue_as "orders.fifo"
14
+ #
15
+ # message_group_id { "customer-#{arguments.first.customer_id}" }
16
+ # message_deduplication_id { "order-#{arguments.first.id}" }
17
+ # end
18
+ #
19
+ # On a FIFO queue the group is the ordering scope; on a standard queue it's
20
+ # a fair-queues tenant key. Deduplication is FIFO-only.
21
+ module MessageAttributes
22
+ extend ActiveSupport::Concern
23
+
24
+ included do
25
+ class_attribute :cloud_message_group_id_resolver, instance_writer: false
26
+ class_attribute :cloud_message_deduplication_id_resolver, instance_writer: false
27
+ end
28
+
29
+ class_methods do
30
+ def message_group_id(value = nil, &block)
31
+ self.cloud_message_group_id_resolver = block || value
32
+ end
33
+
34
+ def message_deduplication_id(value = nil, &block)
35
+ self.cloud_message_deduplication_id_resolver = block || value
36
+ end
37
+ end
38
+
39
+ def cloud_message_group_id
40
+ resolve_cloud_attribute(cloud_message_group_id_resolver)
41
+ end
42
+
43
+ def cloud_message_deduplication_id
44
+ resolve_cloud_attribute(cloud_message_deduplication_id_resolver)
45
+ end
46
+
47
+ private
48
+
49
+ def resolve_cloud_attribute(resolver)
50
+ value = resolver.is_a?(Proc) ? instance_exec(&resolver) : resolver
51
+ value&.to_s
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+
6
+ module LaravelCloud
7
+ module Queue
8
+ # Emits the dashboard's queue events, in the same format as Laravel's
9
+ # Cloud\Queue and Cloud\FailedJobProvider:
10
+ #
11
+ # - "queued" when a job is pushed onto a managed queue
12
+ # - "started" when a worker picks a job up
13
+ # - "processed" when the job succeeds
14
+ # - "released" when the job fails but will be retried
15
+ # - "failed" when the job fails terminally
16
+ # - "failed_job" the payload + exception for a terminal failure
17
+ class Observer
18
+ # Laravel caps the one-line exception preview at 1001 characters.
19
+ MAX_EXCEPTION_PREVIEW_CHARS = 1001
20
+
21
+ def initialize(events)
22
+ @events = events
23
+ end
24
+
25
+ def queued(queue)
26
+ emit_queue_event("queued", queue, now)
27
+ end
28
+
29
+ def started(queue, at)
30
+ emit_queue_event("started", queue, at)
31
+ end
32
+
33
+ def processed(queue, started_at, finished_at)
34
+ emit_queue_event("processed", queue, finished_at, "duration_ms" => duration_ms(started_at, finished_at))
35
+ end
36
+
37
+ def released(queue, started_at, finished_at)
38
+ emit_queue_event("released", queue, finished_at, "duration_ms" => duration_ms(started_at, finished_at))
39
+ end
40
+
41
+ # Matches FailedJobProvider::log exactly — the same fields, and the
42
+ # failed job before the "failed" event. The dashboard needs every field.
43
+ def failed(delivery, error, started_at, finished_at)
44
+ @events.emit(
45
+ "_cloud_event" => "failed_job",
46
+ "id" => uuid7(finished_at),
47
+ "queue" => delivery.queue,
48
+ "started_at" => format_time(started_at),
49
+ "attempts" => delivery.receive_count,
50
+ # Whole and unchanged: Cloud pushes it back as-is to retry the job.
51
+ "payload" => delivery.body.to_s,
52
+ "exception_preview" => preview(error),
53
+ "job_name" => job_name(delivery.body),
54
+ "exception" => describe(error),
55
+ )
56
+
57
+ emit_queue_event("failed", delivery.queue, finished_at, "duration_ms" => duration_ms(started_at, finished_at))
58
+ end
59
+
60
+ def now
61
+ Time.now.utc
62
+ end
63
+
64
+ private
65
+
66
+ def emit_queue_event(type, queue, at, extra = {})
67
+ @events.emit({
68
+ "_cloud_event" => "queue",
69
+ "timestamp" => format_time(at),
70
+ "type" => type,
71
+ "queue" => queue,
72
+ }.merge(extra))
73
+ end
74
+
75
+ # A UUIDv7 for `time`, like Laravel's Str::uuid7($timestamp). It must be
76
+ # v7: Cloud derives the failure time from it (UUIDv7ToDateTime).
77
+ def uuid7(time)
78
+ milliseconds = (time.to_r * 1000).to_i
79
+ bytes = [milliseconds >> 16, milliseconds & 0xFFFF].pack("Nn") + SecureRandom.random_bytes(10)
80
+
81
+ bytes.setbyte(6, (bytes.getbyte(6) & 0x0F) | 0x70)
82
+ bytes.setbyte(8, (bytes.getbyte(8) & 0x3F) | 0x80)
83
+
84
+ hex = bytes.unpack1("H*")
85
+ [hex[0, 8], hex[8, 4], hex[12, 4], hex[16, 4], hex[20, 12]].join("-")
86
+ end
87
+
88
+ def format_time(time)
89
+ time.utc.strftime("%Y-%m-%d %H:%M:%S.%6N")
90
+ end
91
+
92
+ def duration_ms(started_at, finished_at)
93
+ [((finished_at - started_at) * 1000).round, 0].max
94
+ end
95
+
96
+ # "Class: message in file:line", as Laravel previews it.
97
+ def preview(error)
98
+ location = error.backtrace_locations&.first
99
+ where = location ? " in #{utf8(location.absolute_path || location.path)}:#{location.lineno}" : ""
100
+ message = utf8(error.message)
101
+
102
+ line = message.empty? ? "#{error.class}#{where}" : "#{error.class}: #{message}#{where}"
103
+ line[0, MAX_EXCEPTION_PREVIEW_CHARS]
104
+ end
105
+
106
+ def job_name(body)
107
+ payload = JSON.parse(body.to_s)
108
+ payload.is_a?(Hash) ? payload["displayName"].to_s : ""
109
+ rescue JSON::ParserError
110
+ ""
111
+ end
112
+
113
+ # "Class: message" then the backtrace, like PHP's (string) $exception.
114
+ def describe(error)
115
+ ["#{error.class}: #{utf8(error.message)}", *Array(error.backtrace).map { |line| utf8(line) }].join("\n")
116
+ end
117
+
118
+ # Exceptions can carry any bytes, and mixing encodings would raise here —
119
+ # after the message is deleted, losing the failed job. Invalid bytes
120
+ # become U+FFFD, like PHP's JSON_INVALID_UTF8_SUBSTITUTE.
121
+ def utf8(value)
122
+ value.to_s.dup.force_encoding(Encoding::UTF_8).scrub("\uFFFD")
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LaravelCloud
4
+ module Queue
5
+ # Records the highest `retry_on` `attempts:` on a job class, so the
6
+ # worker's delivery cap never cuts a retry short: retries reuse the
7
+ # message, so each one is another delivery. Active Job doesn't expose this,
8
+ # so `retry_on` is wrapped; its arguments pass through untouched.
9
+ module RetryAttempts
10
+ DEFAULT_ATTEMPTS = 5 # Active Job's own default
11
+
12
+ def retry_on(*exceptions, attempts: DEFAULT_ATTEMPTS, **options, &block)
13
+ limit = attempts == :unlimited ? Float::INFINITY : attempts.to_i
14
+ self.cloud_retry_attempts = [cloud_retry_attempts, limit].max
15
+
16
+ super
17
+ end
18
+ end
19
+
20
+ ActiveSupport.on_load(:active_job) do
21
+ # 0 without `retry_on`; Infinity for `attempts: :unlimited`.
22
+ class_attribute :cloud_retry_attempts, instance_accessor: false, default: 0
23
+
24
+ ActiveJob::Exceptions::ClassMethods.prepend(RetryAttempts)
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LaravelCloud
4
+ module Queue
5
+ # Optional settings, set from an initializer:
6
+ #
7
+ # LaravelCloud::Queue.configure do |queue|
8
+ # queue.allowed_job_classes = %w[ProcessReportJob SendInvoiceJob]
9
+ # queue.on_failure = ->(error, job_data) { Rails.error.report(error) }
10
+ # end
11
+ class Settings
12
+ # Job class names the worker may run (nil: any Active Job). Checked before
13
+ # constantizing, so other names never trigger autoloading.
14
+ attr_reader :allowed_job_classes
15
+
16
+ # Called with (error, job_data) when a job fails for good. Errors it
17
+ # raises are logged and ignored.
18
+ attr_accessor :on_failure
19
+
20
+ def initialize
21
+ @allowed_job_classes = nil
22
+ @on_failure = nil
23
+ end
24
+
25
+ def allowed_job_classes=(classes)
26
+ @allowed_job_classes = classes.nil? ? nil : Array(classes).map(&:to_s)
27
+ end
28
+ end
29
+ end
30
+ end