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,338 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
|
|
5
|
+
module LaravelCloud
|
|
6
|
+
module Queue
|
|
7
|
+
# Runs managed-queue jobs (`laravel-cloud queue:work`). Receives one message
|
|
8
|
+
# at a time — from the Cloud agent, or straight from SQS without one — and
|
|
9
|
+
# reports exactly one outcome per message:
|
|
10
|
+
#
|
|
11
|
+
# - success / discard → delete ("processed")
|
|
12
|
+
# - `retry_on` retrying → visibility reset ("released")
|
|
13
|
+
# - an exception, tries left → visibility reset ("released")
|
|
14
|
+
# - an exception, no tries → delete + failed job ("failed")
|
|
15
|
+
#
|
|
16
|
+
# `tries` / `backoff` are Laravel's `queue:work` options; the default of one
|
|
17
|
+
# try keeps Active Job's behaviour (an escaping exception is terminal).
|
|
18
|
+
# `tries` also caps deliveries, so a job that crashes the worker can't come
|
|
19
|
+
# back forever; a job's `retry_on` attempts raise its cap (RetryAttempts).
|
|
20
|
+
class Worker
|
|
21
|
+
# The message has been delivered more times than its job may be attempted.
|
|
22
|
+
class MaxAttemptsExceeded < LaravelCloud::Error; end
|
|
23
|
+
|
|
24
|
+
# Raised into the worker thread to abandon a long-poll or an overrunning
|
|
25
|
+
# job at shutdown. Not a StandardError, so `retry_on` and a job's own
|
|
26
|
+
# `rescue` don't treat it as a failure.
|
|
27
|
+
class Shutdown < Exception; end # rubocop:disable Lint/InheritException
|
|
28
|
+
|
|
29
|
+
SIGNALS = %w[TERM INT].freeze
|
|
30
|
+
|
|
31
|
+
# `queues` only applies without the agent, which is pinned to one queue.
|
|
32
|
+
# `shutdown_timeout` is how long a running job gets after a stop request
|
|
33
|
+
# before it's abandoned and released.
|
|
34
|
+
def initialize(queues: [], tries: 1, backoff: 0, max_jobs: nil, max_time: nil, shutdown_timeout: 15,
|
|
35
|
+
stop_when_empty: false, runtime: Queue.runtime, logger: nil)
|
|
36
|
+
@queues = queues
|
|
37
|
+
@tries = [tries.to_i, 1].max
|
|
38
|
+
@backoff = backoff.to_i.clamp(0, Dispatcher::MAX_VISIBILITY_SECONDS)
|
|
39
|
+
@max_jobs = max_jobs
|
|
40
|
+
@max_time = max_time
|
|
41
|
+
@shutdown_timeout = shutdown_timeout
|
|
42
|
+
@stop_when_empty = stop_when_empty
|
|
43
|
+
@runtime = runtime
|
|
44
|
+
@logger = logger || ActiveJob::Base.logger || Logger.new($stdout)
|
|
45
|
+
@stop_requested_at = nil
|
|
46
|
+
@polling = false
|
|
47
|
+
@executing = false
|
|
48
|
+
@processed = 0
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def run
|
|
52
|
+
@thread = Thread.current
|
|
53
|
+
@started_at = monotonic
|
|
54
|
+
previous_handlers = trap_signals
|
|
55
|
+
watchdog = start_watchdog
|
|
56
|
+
|
|
57
|
+
@logger.info("[LaravelCloud] Processing managed queue jobs #{source_description}")
|
|
58
|
+
|
|
59
|
+
until stopping? || limit_reached?
|
|
60
|
+
delivery = poll
|
|
61
|
+
|
|
62
|
+
if delivery.nil?
|
|
63
|
+
break if @stop_when_empty
|
|
64
|
+
|
|
65
|
+
next
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
process(delivery)
|
|
69
|
+
@processed += 1
|
|
70
|
+
end
|
|
71
|
+
rescue Shutdown
|
|
72
|
+
# Shut down mid-poll; nothing was taken.
|
|
73
|
+
ensure
|
|
74
|
+
watchdog&.kill
|
|
75
|
+
restore_signals(previous_handlers) if previous_handlers
|
|
76
|
+
@logger.info("[LaravelCloud] Stopped processing managed queue jobs")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Finish the current job, then exit. An idle long-poll (up to ~55s) is
|
|
80
|
+
# abandoned so shutdown isn't held up; a message received at that exact
|
|
81
|
+
# moment is redelivered by SQS after its visibility timeout.
|
|
82
|
+
def stop
|
|
83
|
+
@stop_requested_at ||= monotonic
|
|
84
|
+
@thread.raise(Shutdown) if @polling && @thread
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def stopping?
|
|
88
|
+
!@stop_requested_at.nil?
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def process(delivery)
|
|
92
|
+
job_data = begin
|
|
93
|
+
Message.decode(delivery.body, receive_count: delivery.receive_count)
|
|
94
|
+
rescue Message::Undecodable => e
|
|
95
|
+
# Drop the poison message so it isn't redelivered forever.
|
|
96
|
+
@logger.error("[LaravelCloud] Deleting undecodable managed queue message #{delivery.message_id}: #{e.message}")
|
|
97
|
+
acknowledge(delivery)
|
|
98
|
+
return
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
delivery.bind(job_data)
|
|
102
|
+
|
|
103
|
+
started_at = observer.now
|
|
104
|
+
observer.started(delivery.queue, started_at)
|
|
105
|
+
|
|
106
|
+
begin
|
|
107
|
+
job_class = JobClass.resolve(job_data["job_class"], allowed: @runtime.settings.allowed_job_classes)
|
|
108
|
+
enforce_max_attempts!(delivery, job_class)
|
|
109
|
+
execute(delivery, job_data)
|
|
110
|
+
rescue Shutdown
|
|
111
|
+
# Release now so another worker can take it immediately. As with a
|
|
112
|
+
# killed Laravel job, this counts as an attempt.
|
|
113
|
+
@logger.warn("[LaravelCloud] Shutdown timeout exceeded; releasing #{job_data['job_class']} (#{delivery.message_id}) for redelivery")
|
|
114
|
+
release(delivery, 0)
|
|
115
|
+
observer.released(delivery.queue, started_at, observer.now)
|
|
116
|
+
return
|
|
117
|
+
rescue StandardError => e
|
|
118
|
+
handle_failure(delivery, job_data, e, started_at)
|
|
119
|
+
return
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
if delivery.released?
|
|
123
|
+
release(delivery, delivery.release_delay)
|
|
124
|
+
observer.released(delivery.queue, started_at, observer.now)
|
|
125
|
+
else
|
|
126
|
+
acknowledge(delivery)
|
|
127
|
+
|
|
128
|
+
if delivery.retried?
|
|
129
|
+
observer.released(delivery.queue, started_at, observer.now)
|
|
130
|
+
else
|
|
131
|
+
observer.processed(delivery.queue, started_at, observer.now)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
def config
|
|
139
|
+
@runtime.config
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def observer
|
|
143
|
+
@runtime.observer
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# The watchdog only interrupts while @executing, so the interrupt lands in
|
|
147
|
+
# the job, not the outcome reporting. A job finishing at that same instant
|
|
148
|
+
# may run again; SQS is at-least-once anyway.
|
|
149
|
+
def execute(delivery, job_data)
|
|
150
|
+
@executing = true
|
|
151
|
+
Delivery.within(delivery) { ActiveJob::Base.execute(job_data) }
|
|
152
|
+
ensure
|
|
153
|
+
@executing = false
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def enforce_max_attempts!(delivery, job_class)
|
|
157
|
+
limit = [@tries, job_class.cloud_retry_attempts].max
|
|
158
|
+
|
|
159
|
+
return if delivery.receive_count <= limit
|
|
160
|
+
|
|
161
|
+
raise MaxAttemptsExceeded, "#{job_class.name} has been attempted too many times (#{delivery.receive_count} deliveries, limit #{limit})."
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def handle_failure(delivery, job_data, error, started_at)
|
|
165
|
+
if retryable?(error) && delivery.receive_count < @tries
|
|
166
|
+
release(delivery, @backoff)
|
|
167
|
+
observer.released(delivery.queue, started_at, observer.now)
|
|
168
|
+
return
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Terminal: delete it (the agent has no "failed" outcome) and report the
|
|
172
|
+
# failure through observability.
|
|
173
|
+
@logger.error("[LaravelCloud] #{job_data['job_class']} (#{delivery.message_id}) failed: #{error.class}: #{error.message}")
|
|
174
|
+
acknowledge(delivery)
|
|
175
|
+
observer.failed(delivery, error, started_at, observer.now)
|
|
176
|
+
notify_failure(error, job_data)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Whether another delivery could turn out differently.
|
|
180
|
+
def retryable?(error)
|
|
181
|
+
!error.is_a?(JobClass::Unrunnable) && !error.is_a?(MaxAttemptsExceeded)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def notify_failure(error, job_data)
|
|
185
|
+
@runtime.settings.on_failure&.call(error, job_data)
|
|
186
|
+
rescue StandardError => e
|
|
187
|
+
@logger.error("[LaravelCloud] The on_failure handler raised #{e.class}: #{e.message}")
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def poll
|
|
191
|
+
@polling = true
|
|
192
|
+
config.agent_available? ? receive_from_agent : receive_from_sqs
|
|
193
|
+
ensure
|
|
194
|
+
@polling = false
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def receive_from_agent
|
|
198
|
+
data = @runtime.agent.next_message
|
|
199
|
+
return nil if data.nil?
|
|
200
|
+
|
|
201
|
+
message_id = data["messageId"]
|
|
202
|
+
return nil unless message_id.is_a?(String) && !message_id.empty?
|
|
203
|
+
|
|
204
|
+
# The queue the message actually came from, for metrics.
|
|
205
|
+
queue_url = data["queueUrl"].is_a?(String) && !data["queueUrl"].empty? ? data["queueUrl"] : config.queue_url
|
|
206
|
+
attributes = data["attributes"].is_a?(Hash) ? data["attributes"] : {}
|
|
207
|
+
|
|
208
|
+
Delivery.new(
|
|
209
|
+
message_id: message_id,
|
|
210
|
+
receipt_handle: data["receiptHandle"].is_a?(String) ? data["receiptHandle"] : nil,
|
|
211
|
+
queue_url: queue_url,
|
|
212
|
+
queue: config.normalize_queue(queue_url),
|
|
213
|
+
body: data["body"].to_s,
|
|
214
|
+
receive_count: attributes["ApproximateReceiveCount"] || 1,
|
|
215
|
+
from_agent: true,
|
|
216
|
+
)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Polls each queue in turn and takes the first message found.
|
|
220
|
+
def receive_from_sqs
|
|
221
|
+
queue_names.each do |name|
|
|
222
|
+
queue_url = config.queue_url_for(name)
|
|
223
|
+
raise NotConfigured, "No Laravel Cloud managed queue is configured (LARAVEL_CLOUD_MANAGED_QUEUES_CONFIG is missing)." if queue_url.nil?
|
|
224
|
+
|
|
225
|
+
message = @runtime.sqs.receive_message(
|
|
226
|
+
queue_url: queue_url,
|
|
227
|
+
max_number_of_messages: 1,
|
|
228
|
+
wait_time_seconds: 20,
|
|
229
|
+
message_attribute_names: ["All"],
|
|
230
|
+
# The attempt counter (see Message.decode).
|
|
231
|
+
message_system_attribute_names: ["ApproximateReceiveCount"],
|
|
232
|
+
).messages.first
|
|
233
|
+
|
|
234
|
+
next if message.nil?
|
|
235
|
+
|
|
236
|
+
return Delivery.new(
|
|
237
|
+
message_id: message.message_id,
|
|
238
|
+
receipt_handle: message.receipt_handle,
|
|
239
|
+
queue_url: queue_url,
|
|
240
|
+
queue: config.normalize_queue(queue_url),
|
|
241
|
+
body: message.body.to_s,
|
|
242
|
+
receive_count: message.attributes&.fetch("ApproximateReceiveCount", nil) || 1,
|
|
243
|
+
from_agent: false,
|
|
244
|
+
)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
nil
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def queue_names
|
|
251
|
+
@queues.empty? ? [config.queue] : @queues
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def acknowledge(delivery)
|
|
255
|
+
if delivery.from_agent?
|
|
256
|
+
report(delivery, "processed")
|
|
257
|
+
elsif delivery.receipt_handle
|
|
258
|
+
@runtime.sqs.delete_message(queue_url: delivery.queue_url, receipt_handle: delivery.receipt_handle)
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Have SQS redeliver the original message after `delay` seconds.
|
|
263
|
+
def release(delivery, delay)
|
|
264
|
+
if delivery.from_agent?
|
|
265
|
+
report(delivery, "released", delay: delay)
|
|
266
|
+
elsif delivery.receipt_handle
|
|
267
|
+
@runtime.sqs.change_message_visibility(
|
|
268
|
+
queue_url: delivery.queue_url,
|
|
269
|
+
receipt_handle: delivery.receipt_handle,
|
|
270
|
+
visibility_timeout: delay,
|
|
271
|
+
)
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# An unavailable agent ends the worker (it only recovers on restart); a
|
|
276
|
+
# rejected outcome only concerns this message, so it's just logged.
|
|
277
|
+
def report(delivery, status, delay: nil)
|
|
278
|
+
@runtime.agent.report(delivery.message_id, delivery.receipt_handle, status, delay: delay)
|
|
279
|
+
rescue AgentClient::Rejected => e
|
|
280
|
+
@logger.error("[LaravelCloud] #{e.message} (message #{delivery.message_id}, outcome #{status})")
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def limit_reached?
|
|
284
|
+
if @max_jobs && @processed >= @max_jobs
|
|
285
|
+
@logger.info("[LaravelCloud] Processed #{@processed} jobs; stopping (max jobs)")
|
|
286
|
+
return true
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
if @max_time && monotonic - @started_at >= @max_time
|
|
290
|
+
@logger.info("[LaravelCloud] Ran for #{@max_time}s; stopping (max time)")
|
|
291
|
+
return true
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
false
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Enforces the shutdown timeout: signal handlers can't wait or lock, so
|
|
298
|
+
# they only note the stop time and this thread watches the clock.
|
|
299
|
+
def start_watchdog
|
|
300
|
+
return nil if @shutdown_timeout.nil?
|
|
301
|
+
|
|
302
|
+
Thread.new do
|
|
303
|
+
loop do
|
|
304
|
+
sleep 0.25
|
|
305
|
+
|
|
306
|
+
next unless stopping? && @executing && monotonic - @stop_requested_at >= @shutdown_timeout
|
|
307
|
+
|
|
308
|
+
@thread.raise(Shutdown)
|
|
309
|
+
break
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def monotonic
|
|
315
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def source_description
|
|
319
|
+
if config.agent_available?
|
|
320
|
+
"from the Laravel Cloud agent"
|
|
321
|
+
else
|
|
322
|
+
"directly from SQS (#{queue_names.join(', ')})"
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def trap_signals
|
|
327
|
+
SIGNALS.to_h { |signal| [signal, Signal.trap(signal) { stop }] }
|
|
328
|
+
rescue ArgumentError
|
|
329
|
+
# Not on the main thread (e.g. in tests).
|
|
330
|
+
nil
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def restore_signals(handlers)
|
|
334
|
+
handlers.each { |signal, handler| Signal.trap(signal, handler || "DEFAULT") }
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "aws-sdk-sqs"
|
|
4
|
+
|
|
5
|
+
module LaravelCloud
|
|
6
|
+
# Laravel Cloud Managed Queues for Active Job. Jobs are pushed straight to
|
|
7
|
+
# SQS, and received through the Cloud agent or, without one, from SQS.
|
|
8
|
+
module Queue
|
|
9
|
+
# LARAVEL_CLOUD_MANAGED_QUEUES_CONFIG is missing.
|
|
10
|
+
class NotConfigured < LaravelCloud::Error; end
|
|
11
|
+
|
|
12
|
+
# A dispatch the queue can't honour, raised rather than silently degraded.
|
|
13
|
+
class InvalidDispatch < LaravelCloud::Error; end
|
|
14
|
+
|
|
15
|
+
# What the adapter and worker share. Built lazily, so an app boots without
|
|
16
|
+
# a managed queue; injectable for tests.
|
|
17
|
+
class Runtime
|
|
18
|
+
attr_reader :settings
|
|
19
|
+
|
|
20
|
+
def initialize(config: nil, sqs: nil, agent: nil, events: nil, settings: Settings.new)
|
|
21
|
+
@config = config
|
|
22
|
+
@sqs = sqs
|
|
23
|
+
@agent = agent
|
|
24
|
+
@events = events
|
|
25
|
+
@settings = settings
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def config
|
|
29
|
+
@config ||= ManagedQueueConfig.from_env
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def sqs
|
|
33
|
+
@sqs ||= build_sqs_client
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def agent
|
|
37
|
+
@agent ||= AgentClient.new(config.agent_socket)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def observer
|
|
41
|
+
@observer ||= Observer.new(@events || LaravelCloud.events)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def dispatcher
|
|
45
|
+
@dispatcher ||= Dispatcher.new(self)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def build_sqs_client
|
|
51
|
+
args = { region: config.region || "us-east-1" }
|
|
52
|
+
|
|
53
|
+
# "ecs" means the container credential provider; otherwise the SDK's
|
|
54
|
+
# default chain.
|
|
55
|
+
args[:credentials] = Aws::ECSCredentials.new if config.ecs_credentials?
|
|
56
|
+
|
|
57
|
+
Aws::SQS::Client.new(**args)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
class << self
|
|
62
|
+
def runtime
|
|
63
|
+
@runtime ||= Runtime.new
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
attr_writer :runtime
|
|
67
|
+
|
|
68
|
+
def configure
|
|
69
|
+
yield runtime.settings
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
require_relative "queue/settings"
|
|
76
|
+
require_relative "queue/retry_attempts"
|
|
77
|
+
require_relative "queue/job_class"
|
|
78
|
+
require_relative "queue/managed_queue_config"
|
|
79
|
+
require_relative "queue/agent_client"
|
|
80
|
+
require_relative "queue/delivery"
|
|
81
|
+
require_relative "queue/message"
|
|
82
|
+
require_relative "queue/observer"
|
|
83
|
+
require_relative "queue/dispatcher"
|
|
84
|
+
require_relative "queue/message_attributes"
|
|
85
|
+
require_relative "queue/worker"
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LaravelCloud
|
|
4
|
+
module Websockets
|
|
5
|
+
# A server-side Pusher protocol connection, subscribed to private channels
|
|
6
|
+
# it signs itself. Calls on_failure once the connection is lost.
|
|
7
|
+
class Client
|
|
8
|
+
def initialize(logger: nil, &on_failure)
|
|
9
|
+
@logger = logger
|
|
10
|
+
@failure = on_failure
|
|
11
|
+
@monitor = Monitor.new
|
|
12
|
+
@changed = @monitor.new_cond
|
|
13
|
+
@subscriptions = {}
|
|
14
|
+
@joined = {}
|
|
15
|
+
@closed = false
|
|
16
|
+
@last_received = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
17
|
+
@executor = Concurrent::ThreadPoolExecutor.new(min_threads: 1, max_threads: 8, max_queue: 1024, fallback_policy: :abort)
|
|
18
|
+
@thread = Thread.new { run }
|
|
19
|
+
@thread.name = "laravel-cloud-rails-reverb"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def url
|
|
23
|
+
config = Websockets.settings
|
|
24
|
+
scheme = config[:scheme] == "https" ? "wss" : "ws"
|
|
25
|
+
"#{scheme}://#{config[:host]}:#{config[:port]}/app/#{URI.encode_www_form_component(config[:key])}?protocol=7&client=laravel-cloud-rails&version=1"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def write(bytes)
|
|
29
|
+
@socket.write(bytes)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def subscribe(channel, callback, success = nil)
|
|
33
|
+
@monitor.synchronize do
|
|
34
|
+
wait_until { @socket_id }
|
|
35
|
+
if !@subscriptions.key?(channel)
|
|
36
|
+
@subscriptions[channel] = []
|
|
37
|
+
send_event(event: "pusher:subscribe", data: { channel: channel, auth: Websockets.authorize(@socket_id, channel) })
|
|
38
|
+
end
|
|
39
|
+
@subscriptions[channel] << callback
|
|
40
|
+
wait_until { @joined[channel] }
|
|
41
|
+
end
|
|
42
|
+
success&.call
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def unsubscribe(channel, callback)
|
|
46
|
+
@monitor.synchronize do
|
|
47
|
+
callbacks = @subscriptions[channel]
|
|
48
|
+
return unless callbacks
|
|
49
|
+
callbacks.delete(callback)
|
|
50
|
+
if callbacks.empty?
|
|
51
|
+
@subscriptions.delete(channel)
|
|
52
|
+
@joined.delete(channel)
|
|
53
|
+
send_event(event: "pusher:unsubscribe", data: { channel: channel }) unless @closed
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def ping
|
|
59
|
+
@monitor.synchronize do
|
|
60
|
+
raise Unavailable, "Backend heartbeat timed out" if @closed || Process.clock_gettime(Process::CLOCK_MONOTONIC) - @last_received > 12
|
|
61
|
+
send_event(event: "pusher:ping", data: {})
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def close
|
|
66
|
+
@monitor.synchronize do
|
|
67
|
+
return if @closed
|
|
68
|
+
@closed = true
|
|
69
|
+
@changed.broadcast
|
|
70
|
+
@socket&.close
|
|
71
|
+
end
|
|
72
|
+
@executor.shutdown
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def wait_until
|
|
78
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5
|
|
79
|
+
until yield
|
|
80
|
+
raise Unavailable, "WebSocket backend connection closed" if @closed
|
|
81
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
82
|
+
raise Unavailable, "WebSocket backend subscription timed out" if remaining <= 0
|
|
83
|
+
@changed.wait(remaining)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def application_origin
|
|
88
|
+
uri = URI.parse(ENV["APP_URL"].to_s)
|
|
89
|
+
return unless uri.is_a?(URI::HTTP) && uri.host
|
|
90
|
+
port = uri.port unless uri.port == uri.default_port
|
|
91
|
+
URI::Generic.build(scheme: uri.scheme, host: uri.host, port: port).to_s
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def run
|
|
95
|
+
config = Websockets.settings
|
|
96
|
+
tcp = Socket.tcp(config[:host], config[:port], connect_timeout: 3)
|
|
97
|
+
@socket = tcp
|
|
98
|
+
if config[:scheme] == "https"
|
|
99
|
+
context = OpenSSL::SSL::SSLContext.new
|
|
100
|
+
context.set_params
|
|
101
|
+
tls = OpenSSL::SSL::SSLSocket.new(tcp, context)
|
|
102
|
+
tls.hostname = config[:host]
|
|
103
|
+
tls.sync_close = true
|
|
104
|
+
@socket = tls
|
|
105
|
+
Timeout.timeout(3, Unavailable, "Backend TLS handshake timed out") do
|
|
106
|
+
tls.connect
|
|
107
|
+
tls.post_connection_check(config[:host])
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
@driver = WebSocket::Driver.client(self, max_length: 1_000_000)
|
|
111
|
+
origin = application_origin
|
|
112
|
+
@driver.set_header("Origin", origin) if origin
|
|
113
|
+
@driver.on(:message) { |event| receive(JSON.parse(event.data)) }
|
|
114
|
+
@driver.on(:error) { |event| raise Unavailable, event.message }
|
|
115
|
+
@driver.on(:close) { raise Unavailable, "Backend WebSocket closed" }
|
|
116
|
+
@monitor.synchronize { @driver.start }
|
|
117
|
+
loop do
|
|
118
|
+
data = @socket.readpartial(16384)
|
|
119
|
+
@monitor.synchronize { @driver.parse(data) }
|
|
120
|
+
end
|
|
121
|
+
rescue StandardError => error
|
|
122
|
+
# Only our own messages are logged; they may quote the backend (e.g. "Origin not allowed").
|
|
123
|
+
detail = error.is_a?(Unavailable) ? error.message : error.class
|
|
124
|
+
@logger&.warn("laravel-cloud-rails: backend connection lost (#{detail})") unless @closed
|
|
125
|
+
ensure
|
|
126
|
+
close
|
|
127
|
+
@failure.call
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def send_event(event)
|
|
131
|
+
@driver.text(JSON.generate(event))
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def receive(frame)
|
|
135
|
+
@last_received = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
136
|
+
data = frame["data"].is_a?(String) ? JSON.parse(frame["data"]) : frame["data"]
|
|
137
|
+
case frame["event"]
|
|
138
|
+
when "pusher:connection_established"
|
|
139
|
+
@socket_id = data.fetch("socket_id")
|
|
140
|
+
@changed.broadcast
|
|
141
|
+
when "pusher_internal:subscription_succeeded"
|
|
142
|
+
@joined[frame.fetch("channel")] = true
|
|
143
|
+
@changed.broadcast
|
|
144
|
+
when "pusher:ping"
|
|
145
|
+
send_event(event: "pusher:pong", data: {})
|
|
146
|
+
when "pusher:error", "pusher:subscription_error"
|
|
147
|
+
raise Unavailable, "Backend error: #{data.is_a?(Hash) ? data["message"] : data}"
|
|
148
|
+
else
|
|
149
|
+
@subscriptions.fetch(frame["channel"], []).dup.each do |callback|
|
|
150
|
+
@executor.post { callback.call(frame["event"], data) }
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "digest"
|
|
6
|
+
require "json"
|
|
7
|
+
require "monitor"
|
|
8
|
+
require "socket"
|
|
9
|
+
require "concurrent"
|
|
10
|
+
require "websocket/driver"
|
|
11
|
+
|
|
12
|
+
module LaravelCloud
|
|
13
|
+
# Laravel Cloud managed WebSockets: a Pusher-compatible (Reverb) server whose
|
|
14
|
+
# connection settings Cloud injects as PUSHER_* variables.
|
|
15
|
+
module Websockets
|
|
16
|
+
class Error < LaravelCloud::Error; end
|
|
17
|
+
|
|
18
|
+
# The PUSHER_* variables are missing.
|
|
19
|
+
class NotConfigured < Error; end
|
|
20
|
+
|
|
21
|
+
# The server can't be reached, or refused the request or connection.
|
|
22
|
+
class Unavailable < Error; end
|
|
23
|
+
|
|
24
|
+
# An event too large for the server to accept.
|
|
25
|
+
class PayloadTooLarge < Error; end
|
|
26
|
+
|
|
27
|
+
VARIABLES = %w[PUSHER_APP_ID PUSHER_APP_KEY PUSHER_APP_SECRET PUSHER_HOST PUSHER_PORT PUSHER_SCHEME].freeze
|
|
28
|
+
|
|
29
|
+
def self.enabled?
|
|
30
|
+
VARIABLES.all? { |key| ENV[key] && !ENV[key].empty? }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.settings
|
|
34
|
+
raise NotConfigured, "Laravel Cloud WebSockets need #{VARIABLES.join(', ')}" unless enabled?
|
|
35
|
+
scheme = ENV.fetch("PUSHER_SCHEME")
|
|
36
|
+
raise Error, "PUSHER_SCHEME must be http or https" unless %w[http https].include?(scheme)
|
|
37
|
+
port = Integer(ENV.fetch("PUSHER_PORT"))
|
|
38
|
+
raise Error, "Invalid PUSHER_PORT" unless (1..65535).cover?(port)
|
|
39
|
+
{ host: ENV.fetch("PUSHER_HOST"), port: port, scheme: scheme, key: ENV.fetch("PUSHER_APP_KEY") }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Signs a socket's subscription to a private channel.
|
|
43
|
+
def self.authorize(socket_id, channel)
|
|
44
|
+
"#{ENV.fetch('PUSHER_APP_KEY')}:#{sign("#{socket_id}:#{channel}")}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.sign(value)
|
|
48
|
+
OpenSSL::HMAC.hexdigest("SHA256", ENV.fetch("PUSHER_APP_SECRET"), value)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Triggers an event through the server's HTTP API.
|
|
52
|
+
def self.publish(channel, event, data)
|
|
53
|
+
body = JSON.generate(name: event, channels: [channel], data: JSON.generate(data))
|
|
54
|
+
# Reverb refuses HTTP requests over 10 KB (max_request_size), headers
|
|
55
|
+
# included; the auth query and headers take roughly 350 bytes.
|
|
56
|
+
raise PayloadTooLarge, "Message exceeds the backend payload limit" if body.bytesize > 9500
|
|
57
|
+
path = "/apps/#{ENV.fetch('PUSHER_APP_ID')}/events"
|
|
58
|
+
parameters = { "auth_key" => ENV.fetch("PUSHER_APP_KEY"), "auth_timestamp" => Time.now.to_i.to_s, "auth_version" => "1.0", "body_md5" => Digest::MD5.hexdigest(body) }
|
|
59
|
+
query = URI.encode_www_form(parameters.sort)
|
|
60
|
+
config = settings
|
|
61
|
+
klass = config[:scheme] == "https" ? URI::HTTPS : URI::HTTP
|
|
62
|
+
uri = klass.build(host: config[:host], port: config[:port], path: path, query: "#{query}&auth_signature=#{sign("POST\n#{path}\n#{query}")}")
|
|
63
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 2, read_timeout: 3) do |http|
|
|
64
|
+
http.post(uri.request_uri, body, { "Content-Type" => "application/json" })
|
|
65
|
+
end
|
|
66
|
+
raise Unavailable, "WebSocket backend returned HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
67
|
+
true
|
|
68
|
+
rescue IOError, SystemCallError, Timeout::Error, OpenSSL::SSL::SSLError => error
|
|
69
|
+
raise Unavailable, "WebSocket backend unavailable (#{error.class})"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
require_relative "websockets/client"
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_job"
|
|
4
|
+
|
|
5
|
+
require_relative "laravel_cloud/version"
|
|
6
|
+
|
|
7
|
+
# Laravel Cloud features for Rails. This module holds what features share (the
|
|
8
|
+
# dashboard event socket); each feature lives in its own namespace, starting
|
|
9
|
+
# with Managed Queues (LaravelCloud::Queue).
|
|
10
|
+
module LaravelCloud
|
|
11
|
+
# Laravel's default too; LARAVEL_CLOUD_LOG_SOCKET overrides it.
|
|
12
|
+
DEFAULT_LOG_SOCKET = "unix:///tmp/cloud-init.sock"
|
|
13
|
+
|
|
14
|
+
class Error < StandardError; end
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
# The one dashboard event channel per process, shared by every feature.
|
|
18
|
+
def events
|
|
19
|
+
@events ||= Events.new(ENV.fetch("LARAVEL_CLOUD_LOG_SOCKET", DEFAULT_LOG_SOCKET))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
attr_writer :events
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
require_relative "laravel_cloud/events"
|
|
27
|
+
require_relative "laravel_cloud/queue"
|
|
28
|
+
require_relative "active_job/queue_adapters/cloud_adapter"
|
|
29
|
+
|
|
30
|
+
# Loaded up front so its Railtie can install the browser transport. Only for
|
|
31
|
+
# Rails apps that load Action Cable; the gem doesn't depend on it.
|
|
32
|
+
require_relative "action_cable/subscription_adapter/cloud" if defined?(Rails::Railtie) && defined?(ActionCable::Engine)
|