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,167 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
if (globalThis.WebSocket.__laravelCloudBridge) return;
|
|
3
|
+
const config = globalThis.__laravelCloudWebsockets;
|
|
4
|
+
const NativeWebSocket = globalThis.WebSocket;
|
|
5
|
+
const endpoint = '/__laravel_cloud/cable';
|
|
6
|
+
const protocol = 'actioncable-v1-json';
|
|
7
|
+
|
|
8
|
+
class CableSocket extends EventTarget {
|
|
9
|
+
constructor(url) {
|
|
10
|
+
super();
|
|
11
|
+
this.url = new URL(url, location.href).href;
|
|
12
|
+
this.protocol = protocol;
|
|
13
|
+
this.extensions = '';
|
|
14
|
+
this.readyState = 0;
|
|
15
|
+
this.bufferedAmount = 0;
|
|
16
|
+
this.binaryType = 'blob';
|
|
17
|
+
this.sequence = 0;
|
|
18
|
+
this.joined = new Map();
|
|
19
|
+
this.queue = Promise.resolve();
|
|
20
|
+
this.abort = new AbortController();
|
|
21
|
+
this.onPageHide = () => this.close();
|
|
22
|
+
globalThis.addEventListener("pagehide", this.onPageHide, { once: true });
|
|
23
|
+
this.transport = new NativeWebSocket(
|
|
24
|
+
`${config.scheme === 'https' ? 'wss' : 'ws'}://${config.host}:${config.port}/app/${encodeURIComponent(config.key)}?protocol=7&client=laravel-cloud-rails&version=1`
|
|
25
|
+
);
|
|
26
|
+
this.transport.addEventListener('message', event => {
|
|
27
|
+
try {
|
|
28
|
+
this.receive(JSON.parse(event.data)).catch(error => this.fail(error));
|
|
29
|
+
} catch (error) {
|
|
30
|
+
this.fail(error);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
this.transport.addEventListener('close', event => this.finish(event.code, event.reason, event.wasClean));
|
|
34
|
+
this.transport.addEventListener('error', () => this.fail(new Error('WebSocket backend unavailable')));
|
|
35
|
+
this.deadline = setTimeout(() => this.fail(new Error('Connection timed out')), 12000);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async request(command, keepalive = false) {
|
|
39
|
+
const response = await fetch(endpoint, {
|
|
40
|
+
method: 'POST',
|
|
41
|
+
credentials: 'same-origin',
|
|
42
|
+
headers: { 'Content-Type': 'application/json' },
|
|
43
|
+
body: JSON.stringify({ ...command, token: this.token, request_id: crypto.randomUUID() }),
|
|
44
|
+
cache: 'no-store',
|
|
45
|
+
keepalive,
|
|
46
|
+
signal: keepalive ? AbortSignal.timeout(5000) : AbortSignal.any([this.abort.signal, AbortSignal.timeout(10000)]),
|
|
47
|
+
});
|
|
48
|
+
const result = await response.json();
|
|
49
|
+
if (!response.ok) {
|
|
50
|
+
const error = new Error(result.error || `Channel request failed: HTTP ${response.status}`);
|
|
51
|
+
error.terminal = [400, 401, 403, 404, 413, 415].includes(response.status);
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async receive(event) {
|
|
58
|
+
const data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
|
|
59
|
+
if (event.event === 'pusher:connection_established') {
|
|
60
|
+
const url = new URL(this.url);
|
|
61
|
+
const result = await this.request({ command: 'connect', socket_id: data.socket_id, url: url.pathname + url.search });
|
|
62
|
+
this.token = result.token;
|
|
63
|
+
if (this.readyState > 1) {
|
|
64
|
+
this.release();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const pending = result.channels.map(channel => new Promise(resolve => {
|
|
68
|
+
this.joined.set(channel.name, resolve);
|
|
69
|
+
this.transport.send(JSON.stringify({ event: 'pusher:subscribe', data: { channel: channel.name, auth: channel.auth } }));
|
|
70
|
+
}));
|
|
71
|
+
await Promise.all(pending);
|
|
72
|
+
if (this.readyState > 1) return;
|
|
73
|
+
clearTimeout(this.deadline);
|
|
74
|
+
this.readyState = 1;
|
|
75
|
+
this.emit(new Event('open'));
|
|
76
|
+
await this.request({ command: 'ready' });
|
|
77
|
+
this.lease = setInterval(() => this.send(JSON.stringify({ command: 'lease' })), 30000);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (event.event === 'pusher_internal:subscription_succeeded') {
|
|
81
|
+
this.joined.get(event.channel)?.();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (event.event === 'pusher:ping') {
|
|
85
|
+
this.transport.send(JSON.stringify({ event: 'pusher:pong', data: {} }));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (event.event === 'pusher:error' || event.event === 'pusher:subscription_error') {
|
|
89
|
+
throw new Error('WebSocket subscription failed');
|
|
90
|
+
}
|
|
91
|
+
if (event.event === 'cloud-heartbeat' && this.readyState === 1) {
|
|
92
|
+
this.message({ type: 'ping', message: data.time });
|
|
93
|
+
}
|
|
94
|
+
if (event.event === 'cloud-frame' && this.readyState === 1) {
|
|
95
|
+
if (data.sequence <= this.sequence) return;
|
|
96
|
+
if (data.sequence !== this.sequence + 1) throw new Error('Connection stream interrupted');
|
|
97
|
+
this.sequence = data.sequence;
|
|
98
|
+
this.message(data.frame);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
send(raw) {
|
|
103
|
+
if (this.readyState !== 1) throw new DOMException('WebSocket is not open', 'InvalidStateError');
|
|
104
|
+
const command = JSON.parse(raw);
|
|
105
|
+
this.queue = this.queue.then(() => this.request(command)).catch(error => this.fail(error));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
message(frame) {
|
|
109
|
+
this.emit(new MessageEvent('message', { data: JSON.stringify(frame) }));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
emit(event) {
|
|
113
|
+
this[`on${event.type}`]?.(event);
|
|
114
|
+
this.dispatchEvent(event);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
fail(error) {
|
|
118
|
+
if (this.readyState > 1) return;
|
|
119
|
+
this.emit(new Event('error'));
|
|
120
|
+
// Like Rails' own rejection: stops the client's reconnect loop, even before open.
|
|
121
|
+
if (error.terminal) this.message({ type: 'disconnect', reason: error.message, reconnect: false });
|
|
122
|
+
this.finish(error.terminal ? 1008 : 1006, error.message, false);
|
|
123
|
+
this.transport.close();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// A keepalive request survives page unload. sendBeacon can't be used:
|
|
127
|
+
// Chrome refuses beacons with a JSON content type.
|
|
128
|
+
release() {
|
|
129
|
+
if (this.token) this.request({ command: 'disconnect' }, true).catch(() => {});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
close(code = 1000, reason = '') {
|
|
133
|
+
if (this.readyState > 1) return;
|
|
134
|
+
this.readyState = 2;
|
|
135
|
+
this.release();
|
|
136
|
+
this.transport.close();
|
|
137
|
+
this.finish(code, reason, true);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
finish(code = 1006, reason = '', wasClean = false) {
|
|
141
|
+
if (this.readyState === 3) return;
|
|
142
|
+
if (this.readyState < 2) this.release();
|
|
143
|
+
this.readyState = 3;
|
|
144
|
+
clearTimeout(this.deadline);
|
|
145
|
+
clearInterval(this.lease);
|
|
146
|
+
globalThis.removeEventListener("pagehide", this.onPageHide);
|
|
147
|
+
this.abort.abort();
|
|
148
|
+
this.emit(new CloseEvent('close', { code, reason, wasClean }));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function CloudWebSocket(url, protocols = []) {
|
|
153
|
+
if (!new.target) throw new TypeError('WebSocket constructor requires new');
|
|
154
|
+
const names = Array.isArray(protocols) ? protocols : [protocols];
|
|
155
|
+
if (names.includes(protocol)) return new CableSocket(url);
|
|
156
|
+
return new NativeWebSocket(url, protocols);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
for (const [name, value] of Object.entries({ CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 })) {
|
|
160
|
+
Object.defineProperty(CloudWebSocket, name, { value, enumerable: true });
|
|
161
|
+
Object.defineProperty(CableSocket.prototype, name, { value });
|
|
162
|
+
}
|
|
163
|
+
Object.defineProperty(CloudWebSocket, Symbol.hasInstance, { value: value => value instanceof NativeWebSocket || value instanceof CableSocket });
|
|
164
|
+
CloudWebSocket.__laravelCloudBridge = true;
|
|
165
|
+
CloudWebSocket.prototype = NativeWebSocket.prototype;
|
|
166
|
+
globalThis.WebSocket = CloudWebSocket;
|
|
167
|
+
})();
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "action_cable"
|
|
4
|
+
require "action_cable/subscription_adapter/base"
|
|
5
|
+
require "action_cable/subscription_adapter/channel_prefix"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
require "erb"
|
|
8
|
+
require_relative "../../laravel_cloud/websockets"
|
|
9
|
+
|
|
10
|
+
module ActionCable
|
|
11
|
+
module SubscriptionAdapter
|
|
12
|
+
# `adapter: cloud` in config/cable.yml. Browsers hold their socket to Cloud
|
|
13
|
+
# WebSockets; channel commands reach Rails over same-origin HTTP
|
|
14
|
+
# (Middleware) and run against a virtual Action Cable connection
|
|
15
|
+
# (Session), whose frames go back through Cloud WebSockets.
|
|
16
|
+
#
|
|
17
|
+
# Broadcasting only publishes over HTTP, so processes that never subscribe
|
|
18
|
+
# (jobs, consoles) don't open a connection.
|
|
19
|
+
class Cloud < Base
|
|
20
|
+
prepend ChannelPrefix
|
|
21
|
+
|
|
22
|
+
Websockets = ::LaravelCloud::Websockets
|
|
23
|
+
|
|
24
|
+
# A malformed command from the browser transport.
|
|
25
|
+
class InvalidCommand < Websockets::Error; end
|
|
26
|
+
|
|
27
|
+
def self.stream_channel(stream)
|
|
28
|
+
"private-roc-stream-#{Digest::SHA256.hexdigest(stream)}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The owner cookie binds connection tokens to the browser that opened them.
|
|
32
|
+
def self.owner(request)
|
|
33
|
+
value = Rack::Utils.parse_cookies_header(request.get_header("HTTP_COOKIE").to_s)["__laravel_cloud_owner"]
|
|
34
|
+
return unless value.is_a?(String) && value.ascii_only? && value.match?(/\A[0-9a-f]{48}\.[0-9a-f]{64}\z/)
|
|
35
|
+
nonce, signature = value.split(".")
|
|
36
|
+
nonce if ActiveSupport::SecurityUtils.secure_compare(Websockets.sign("roc-owner:#{nonce}"), signature)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.owner_cookie
|
|
40
|
+
nonce = SecureRandom.hex(24)
|
|
41
|
+
"#{nonce}.#{Websockets.sign("roc-owner:#{nonce}")}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.runtime
|
|
45
|
+
@runtime_lock ||= Mutex.new
|
|
46
|
+
@runtime_lock.synchronize do
|
|
47
|
+
if !@runtime || @runtime.pid != Process.pid || !@runtime.alive?
|
|
48
|
+
@runtime = Runtime.new
|
|
49
|
+
end
|
|
50
|
+
@runtime
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def initialize(server)
|
|
55
|
+
Websockets.settings
|
|
56
|
+
super
|
|
57
|
+
@bindings = {}
|
|
58
|
+
@bindings_lock = Mutex.new
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def broadcast(channel, payload)
|
|
62
|
+
Websockets.publish(self.class.stream_channel(channel), "cloud-broadcast", payload)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def subscribe(channel, callback, success_callback = nil)
|
|
66
|
+
runtime = self.class.runtime
|
|
67
|
+
@bindings_lock.synchronize { @bindings[[channel, callback]] = runtime }
|
|
68
|
+
runtime.subscribe(channel, callback, success_callback)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def unsubscribe(channel, callback)
|
|
72
|
+
runtime = @bindings_lock.synchronize { @bindings.delete([channel, callback]) }
|
|
73
|
+
runtime&.unsubscribe(channel, callback)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def shutdown
|
|
77
|
+
runtimes = @bindings_lock.synchronize { @bindings.values.uniq }
|
|
78
|
+
runtimes.each(&:stop)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
require_relative "cloud/runtime"
|
|
85
|
+
require_relative "cloud/middleware"
|
|
86
|
+
require_relative "cloud/railtie" if defined?(Rails::Railtie)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveJob
|
|
4
|
+
module QueueAdapters
|
|
5
|
+
# `config.active_job.queue_adapter = :cloud`. SQS has no priority, so
|
|
6
|
+
# `priority` is ignored.
|
|
7
|
+
class CloudAdapter < (defined?(AbstractAdapter) ? AbstractAdapter : Object)
|
|
8
|
+
def initialize(runtime: nil)
|
|
9
|
+
super()
|
|
10
|
+
@runtime = runtime
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def enqueue(job)
|
|
14
|
+
dispatcher.dispatch(job, delay: 0)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def enqueue_at(job, timestamp)
|
|
18
|
+
dispatcher.dispatch(job, delay: timestamp - Time.now.to_f)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def enqueue_all(jobs)
|
|
22
|
+
dispatcher.dispatch_all(jobs)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
# Resolved per call, so the app boots without a managed queue.
|
|
28
|
+
def dispatcher
|
|
29
|
+
(@runtime || LaravelCloud::Queue.runtime).dispatcher
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module LaravelCloud
|
|
6
|
+
# `laravel-cloud queue:work [options]`, the queue worker Laravel Cloud runs.
|
|
7
|
+
# Boots the app from config/environment.rb, so run it from the app root.
|
|
8
|
+
class CLI
|
|
9
|
+
def initialize(argv, out: $stdout, err: $stderr, boot: true)
|
|
10
|
+
@argv = argv.dup
|
|
11
|
+
@out = out
|
|
12
|
+
@err = err
|
|
13
|
+
@boot = boot
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def run
|
|
17
|
+
command = @argv.shift
|
|
18
|
+
|
|
19
|
+
case command
|
|
20
|
+
when "queue:work" then work
|
|
21
|
+
when "-v", "--version"
|
|
22
|
+
@out.puts("laravel-cloud #{VERSION}")
|
|
23
|
+
0
|
|
24
|
+
else
|
|
25
|
+
@err.puts(command.nil? || %w[-h --help].include?(command) ? usage : "Unknown command #{command.inspect}.\n\n#{usage}")
|
|
26
|
+
command.nil? || %w[-h --help].include?(command) ? 0 : 1
|
|
27
|
+
end
|
|
28
|
+
rescue OptionParser::ParseError => e
|
|
29
|
+
@err.puts("#{e.message}\n\n#{usage}")
|
|
30
|
+
1
|
|
31
|
+
rescue LaravelCloud::Error => e
|
|
32
|
+
# Expected failures exit non-zero without a backtrace; on Cloud that
|
|
33
|
+
# restarts the container.
|
|
34
|
+
@err.puts("[LaravelCloud] #{e.message}")
|
|
35
|
+
1
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def work
|
|
41
|
+
options = { queues: [] }
|
|
42
|
+
|
|
43
|
+
parser = OptionParser.new do |opts|
|
|
44
|
+
opts.banner = "Usage: laravel-cloud queue:work [options]"
|
|
45
|
+
opts.on("-q", "--queue NAME", "Queue(s) to poll without the Cloud agent, in order; repeat or comma-separate") do |value|
|
|
46
|
+
options[:queues].concat(value.split(",").map(&:strip).reject(&:empty?))
|
|
47
|
+
end
|
|
48
|
+
opts.on("--tries N", Integer, "Times a job that raises is attempted in all (default 1)") { |value| options[:tries] = value }
|
|
49
|
+
opts.on("--backoff SECONDS", Integer, "Wait before re-attempting a job that raised (default 0)") { |value| options[:backoff] = value }
|
|
50
|
+
opts.on("--max-jobs N", Integer, "Stop after processing N jobs") { |value| options[:max_jobs] = value }
|
|
51
|
+
opts.on("--max-time SECONDS", Integer, "Stop after running for this long") { |value| options[:max_time] = value }
|
|
52
|
+
opts.on("--shutdown-timeout SECONDS", Integer, "Time a running job gets to finish on shutdown (default 15)") do |value|
|
|
53
|
+
options[:shutdown_timeout] = value
|
|
54
|
+
end
|
|
55
|
+
opts.on("--stop-when-empty", "Exit once the queues are drained") { options[:stop_when_empty] = true }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
parser.parse!(@argv)
|
|
59
|
+
boot_app
|
|
60
|
+
|
|
61
|
+
Queue::Worker.new(**options).run
|
|
62
|
+
0
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def boot_app
|
|
67
|
+
return unless @boot
|
|
68
|
+
|
|
69
|
+
environment = File.expand_path("config/environment.rb")
|
|
70
|
+
|
|
71
|
+
unless File.exist?(environment)
|
|
72
|
+
raise LaravelCloud::Error, "No Rails application found: run laravel-cloud from the application root (#{environment} is missing)."
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
require environment
|
|
76
|
+
|
|
77
|
+
# Booting this way skips eager loading, which a long-lived worker wants.
|
|
78
|
+
Rails.application.eager_load! if Rails.application.config.eager_load
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def usage
|
|
82
|
+
<<~USAGE
|
|
83
|
+
Usage: laravel-cloud <command> [options]
|
|
84
|
+
|
|
85
|
+
Commands:
|
|
86
|
+
queue:work Process Laravel Cloud managed queue jobs (--help for options)
|
|
87
|
+
USAGE
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "socket"
|
|
5
|
+
|
|
6
|
+
module LaravelCloud
|
|
7
|
+
# Writes dashboard events to the Cloud log socket as newline-delimited JSON.
|
|
8
|
+
# A port of Laravel's Cloud\Events. Failures are swallowed: observability
|
|
9
|
+
# must never take down a job or a request.
|
|
10
|
+
class Events
|
|
11
|
+
TIMEOUT = 2
|
|
12
|
+
|
|
13
|
+
# e.g. "unix:///tmp/cloud-init.sock", or a bare path.
|
|
14
|
+
def initialize(address)
|
|
15
|
+
@path = address.to_s.delete_prefix("unix://")
|
|
16
|
+
@socket = nil
|
|
17
|
+
|
|
18
|
+
# Request threads all enqueue, so lock to keep lines from interleaving.
|
|
19
|
+
@mutex = Mutex.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def emit(payload)
|
|
23
|
+
emit_many([payload])
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def emit_many(payloads)
|
|
27
|
+
return if payloads.empty?
|
|
28
|
+
|
|
29
|
+
line = payloads.map { |payload| JSON.generate(scrub(payload)) }.join("\n") << "\n"
|
|
30
|
+
|
|
31
|
+
@mutex.synchronize do
|
|
32
|
+
ensure_connected
|
|
33
|
+
write(line)
|
|
34
|
+
end
|
|
35
|
+
rescue StandardError
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
# Like Laravel's JSON_INVALID_UTF8_SUBSTITUTE: bad bytes become U+FFFD.
|
|
41
|
+
# Ruby's JSON would raise instead, silently dropping the event.
|
|
42
|
+
def scrub(value)
|
|
43
|
+
case value
|
|
44
|
+
when String then value.valid_encoding? && value.encoding == Encoding::UTF_8 ? value : value.dup.force_encoding(Encoding::UTF_8).scrub("\uFFFD")
|
|
45
|
+
when Hash then value.to_h { |key, item| [scrub(key), scrub(item)] }
|
|
46
|
+
when Array then value.map { |item| scrub(item) }
|
|
47
|
+
else value
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def write(line)
|
|
52
|
+
@socket.write(line)
|
|
53
|
+
rescue StandardError
|
|
54
|
+
# The stream may hold a half-written line; reconnect on the next emit.
|
|
55
|
+
disconnect
|
|
56
|
+
raise
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def ensure_connected
|
|
60
|
+
connect unless connected?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def connect
|
|
64
|
+
socket = Socket.unix(@path)
|
|
65
|
+
socket.timeout = TIMEOUT
|
|
66
|
+
@socket = socket
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# A non-blocking read tells a closed peer (EOF) from a healthy idle socket
|
|
70
|
+
# (nothing to read). The collector never writes back.
|
|
71
|
+
def connected?
|
|
72
|
+
return false if @socket.nil? || @socket.closed?
|
|
73
|
+
|
|
74
|
+
if @socket.read_nonblock(1, exception: false).nil?
|
|
75
|
+
disconnect
|
|
76
|
+
return false
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
true
|
|
80
|
+
rescue StandardError
|
|
81
|
+
disconnect
|
|
82
|
+
false
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def disconnect
|
|
86
|
+
@socket&.close
|
|
87
|
+
rescue StandardError
|
|
88
|
+
# Already gone.
|
|
89
|
+
ensure
|
|
90
|
+
@socket = nil
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "socket"
|
|
6
|
+
|
|
7
|
+
module LaravelCloud
|
|
8
|
+
module Queue
|
|
9
|
+
# Talks to the Cloud agent over its unix socket. The agent long-polls SQS,
|
|
10
|
+
# hands messages over on GET /next, and on POST /result performs the
|
|
11
|
+
# delete or visibility change itself.
|
|
12
|
+
class AgentClient
|
|
13
|
+
# The agent is unreachable or erroring. It only recovers on restart, so
|
|
14
|
+
# the worker exits; SQS redelivers anything in flight.
|
|
15
|
+
class Unavailable < LaravelCloud::Error; end
|
|
16
|
+
|
|
17
|
+
# The agent refused one outcome (HTTP 4xx); the worker logs it and moves on.
|
|
18
|
+
class Rejected < LaravelCloud::Error; end
|
|
19
|
+
|
|
20
|
+
# Longer than the agent's ~55s poll, so a poll never times out early.
|
|
21
|
+
NEXT_TIMEOUT = 65
|
|
22
|
+
RESULT_TIMEOUT = 10
|
|
23
|
+
RESULT_ATTEMPTS = 3
|
|
24
|
+
|
|
25
|
+
# The agent hasn't seen the request yet, so these are safe to retry.
|
|
26
|
+
CONNECT_ERRORS = [Errno::ENOENT, Errno::ECONNREFUSED].freeze
|
|
27
|
+
|
|
28
|
+
# The agent may already have acted, so these aren't retried.
|
|
29
|
+
TRANSPORT_ERRORS = [
|
|
30
|
+
Errno::ECONNRESET, Errno::EPIPE, EOFError, IOError,
|
|
31
|
+
Net::ReadTimeout, Net::WriteTimeout, Net::OpenTimeout, Net::HTTPBadResponse,
|
|
32
|
+
].freeze
|
|
33
|
+
|
|
34
|
+
def initialize(socket_path)
|
|
35
|
+
@socket_path = socket_path
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The next message (messageId, receiptHandle, body, attributes, queueUrl),
|
|
39
|
+
# or nil when there's none.
|
|
40
|
+
def next_message
|
|
41
|
+
response = request(Net::HTTP::Get.new("/next"), timeout: NEXT_TIMEOUT)
|
|
42
|
+
|
|
43
|
+
return nil if response.code == "204"
|
|
44
|
+
|
|
45
|
+
raise Unavailable, "The Laravel Cloud agent returned HTTP #{response.code} from GET /next." unless response.code == "200"
|
|
46
|
+
|
|
47
|
+
data = JSON.parse(response.body.to_s)
|
|
48
|
+
raise Unavailable, "The Laravel Cloud agent returned a non-object body from GET /next." unless data.is_a?(Hash)
|
|
49
|
+
|
|
50
|
+
data
|
|
51
|
+
rescue JSON::ParserError
|
|
52
|
+
raise Unavailable, "The Laravel Cloud agent returned a malformed body from GET /next."
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# "processed" deletes the message; "released" redelivers it after `delay`
|
|
56
|
+
# seconds. There's no "failed" outcome — failures are reported as
|
|
57
|
+
# "processed" and surfaced through observability.
|
|
58
|
+
def report(message_id, receipt_handle, status, delay: nil)
|
|
59
|
+
payload = {
|
|
60
|
+
"messageId" => message_id,
|
|
61
|
+
"receiptHandle" => receipt_handle,
|
|
62
|
+
"status" => status,
|
|
63
|
+
"delay" => delay,
|
|
64
|
+
}.compact
|
|
65
|
+
|
|
66
|
+
attempts = 0
|
|
67
|
+
|
|
68
|
+
begin
|
|
69
|
+
attempts += 1
|
|
70
|
+
|
|
71
|
+
post = Net::HTTP::Post.new("/result", "Content-Type" => "application/json")
|
|
72
|
+
post.body = JSON.generate(payload)
|
|
73
|
+
|
|
74
|
+
response = request(post, timeout: RESULT_TIMEOUT, retry_connect: attempts < RESULT_ATTEMPTS)
|
|
75
|
+
rescue RetryableConnectionError
|
|
76
|
+
sleep 0.1
|
|
77
|
+
retry
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
code = response.code.to_i
|
|
81
|
+
|
|
82
|
+
raise Unavailable, "The Laravel Cloud agent returned HTTP #{code} from POST /result." if code >= 500
|
|
83
|
+
raise Rejected, "The Laravel Cloud agent rejected the result with HTTP #{code}." if code >= 400
|
|
84
|
+
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
class RetryableConnectionError < StandardError; end
|
|
91
|
+
private_constant :RetryableConnectionError
|
|
92
|
+
|
|
93
|
+
def request(request, timeout:, retry_connect: false)
|
|
94
|
+
http = UnixHTTP.new("localhost")
|
|
95
|
+
http.socket_path = @socket_path
|
|
96
|
+
http.open_timeout = timeout
|
|
97
|
+
http.read_timeout = timeout
|
|
98
|
+
http.write_timeout = timeout
|
|
99
|
+
# Never re-issue a long-poll; #report retries on its own terms.
|
|
100
|
+
http.max_retries = 0
|
|
101
|
+
|
|
102
|
+
http.start { |connection| connection.request(request) }
|
|
103
|
+
rescue *CONNECT_ERRORS => e
|
|
104
|
+
raise RetryableConnectionError if retry_connect
|
|
105
|
+
|
|
106
|
+
raise Unavailable, "The Laravel Cloud agent runtime socket is unreachable (#{e.class}: #{e.message})."
|
|
107
|
+
rescue *TRANSPORT_ERRORS => e
|
|
108
|
+
raise Unavailable, "The Laravel Cloud agent request failed (#{e.class}: #{e.message})."
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Net::HTTP over a unix socket instead of TCP.
|
|
112
|
+
class UnixHTTP < Net::HTTP
|
|
113
|
+
attr_accessor :socket_path
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def connect
|
|
118
|
+
socket = begin
|
|
119
|
+
UNIXSocket.new(socket_path)
|
|
120
|
+
rescue ArgumentError => e
|
|
121
|
+
# e.g. a path over the OS's ~104-byte limit: not worth retrying.
|
|
122
|
+
raise Unavailable, "The Laravel Cloud agent socket path #{socket_path.inspect} is unusable: #{e.message}"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
@socket = Net::BufferedIO.new(
|
|
126
|
+
socket,
|
|
127
|
+
read_timeout: @read_timeout,
|
|
128
|
+
write_timeout: @write_timeout,
|
|
129
|
+
continue_timeout: @continue_timeout,
|
|
130
|
+
debug_output: @debug_output,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
on_connect
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
private_constant :UnixHTTP
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/isolated_execution_state"
|
|
4
|
+
|
|
5
|
+
module LaravelCloud
|
|
6
|
+
module Queue
|
|
7
|
+
# A received message and its outcome so far.
|
|
8
|
+
#
|
|
9
|
+
# The worker makes it *current* while the job runs. `retry_on` re-enqueues
|
|
10
|
+
# a dup of the job with the same job_id, so the adapter can recognise the
|
|
11
|
+
# retry and record a release here instead of pushing a duplicate — the
|
|
12
|
+
# equivalent of Laravel's `$job->release($delay)`.
|
|
13
|
+
class Delivery
|
|
14
|
+
STATE_KEY = :laravel_cloud_queue_delivery
|
|
15
|
+
|
|
16
|
+
attr_reader :message_id, :receipt_handle, :queue_url, :queue, :body, :receive_count, :job_id, :job_queue_name,
|
|
17
|
+
:release_delay
|
|
18
|
+
|
|
19
|
+
def self.within(delivery)
|
|
20
|
+
previous = current
|
|
21
|
+
ActiveSupport::IsolatedExecutionState[STATE_KEY] = delivery
|
|
22
|
+
yield
|
|
23
|
+
ensure
|
|
24
|
+
ActiveSupport::IsolatedExecutionState[STATE_KEY] = previous
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.current
|
|
28
|
+
ActiveSupport::IsolatedExecutionState[STATE_KEY]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# `receive_count` is SQS's ApproximateReceiveCount.
|
|
32
|
+
def initialize(message_id:, receipt_handle:, queue_url:, queue:, body:, receive_count:, from_agent:)
|
|
33
|
+
@message_id = message_id
|
|
34
|
+
@receipt_handle = receipt_handle
|
|
35
|
+
@queue_url = queue_url
|
|
36
|
+
@queue = queue
|
|
37
|
+
@body = body
|
|
38
|
+
@receive_count = [receive_count.to_i, 1].max
|
|
39
|
+
@from_agent = from_agent
|
|
40
|
+
@job_id = nil
|
|
41
|
+
@job_queue_name = nil
|
|
42
|
+
@release_delay = nil
|
|
43
|
+
@superseded = false
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def from_agent?
|
|
47
|
+
@from_agent
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def bind(job_data)
|
|
51
|
+
@job_id = job_data["job_id"]
|
|
52
|
+
@job_queue_name = job_data["queue_name"]
|
|
53
|
+
self
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Whether enqueueing `job` is a retry of the running job.
|
|
57
|
+
def retry_of?(job)
|
|
58
|
+
!job_id.nil? && job.job_id == job_id
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def release!(delay)
|
|
62
|
+
@release_delay = delay
|
|
63
|
+
@superseded = false
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The retry was pushed to another queue, so delete the original.
|
|
67
|
+
def supersede!
|
|
68
|
+
@release_delay = nil
|
|
69
|
+
@superseded = true
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def released?
|
|
73
|
+
!@release_delay.nil?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def retried?
|
|
77
|
+
released? || @superseded
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|