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,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,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Bundler requires a gem by its name, so `gem "laravel-cloud-rails"` lands here.
4
+ require_relative "laravel_cloud"
@@ -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