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.
- checksums.yaml +7 -0
- data/LICENSE.md +21 -0
- data/README.md +234 -0
- data/exe/laravel-cloud +7 -0
- data/lib/action_cable/subscription_adapter/cloud/middleware.rb +129 -0
- data/lib/action_cable/subscription_adapter/cloud/railtie.rb +15 -0
- data/lib/action_cable/subscription_adapter/cloud/runtime.rb +255 -0
- data/lib/action_cable/subscription_adapter/cloud/transport.js +167 -0
- data/lib/action_cable/subscription_adapter/cloud.rb +86 -0
- data/lib/active_job/queue_adapters/cloud_adapter.rb +33 -0
- data/lib/laravel-cloud-rails.rb +4 -0
- data/lib/laravel_cloud/cli.rb +90 -0
- data/lib/laravel_cloud/events.rb +93 -0
- data/lib/laravel_cloud/queue/agent_client.rb +139 -0
- data/lib/laravel_cloud/queue/delivery.rb +81 -0
- data/lib/laravel_cloud/queue/dispatcher.rb +214 -0
- data/lib/laravel_cloud/queue/job_class.rb +33 -0
- data/lib/laravel_cloud/queue/managed_queue_config.rb +132 -0
- data/lib/laravel_cloud/queue/message.rb +48 -0
- data/lib/laravel_cloud/queue/message_attributes.rb +55 -0
- data/lib/laravel_cloud/queue/observer.rb +126 -0
- data/lib/laravel_cloud/queue/retry_attempts.rb +27 -0
- data/lib/laravel_cloud/queue/settings.rb +30 -0
- data/lib/laravel_cloud/queue/worker.rb +338 -0
- data/lib/laravel_cloud/queue.rb +85 -0
- data/lib/laravel_cloud/version.rb +6 -0
- data/lib/laravel_cloud/websockets/client.rb +156 -0
- data/lib/laravel_cloud/websockets.rb +74 -0
- data/lib/laravel_cloud.rb +32 -0
- metadata +96 -0
|
@@ -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
|