patient_http-solid_queue 1.0.0
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/ARCHITECTURE.md +119 -0
- data/CHANGELOG.md +16 -0
- data/MIT-LICENSE +20 -0
- data/README.md +598 -0
- data/VERSION +1 -0
- data/db/migrate/20260216000000_create_solid_queue_async_http_tables.rb +27 -0
- data/lib/patient_http/solid_queue/callback_job.rb +73 -0
- data/lib/patient_http/solid_queue/configuration.rb +142 -0
- data/lib/patient_http/solid_queue/context.rb +36 -0
- data/lib/patient_http/solid_queue/engine.rb +14 -0
- data/lib/patient_http/solid_queue/gc_lock.rb +14 -0
- data/lib/patient_http/solid_queue/inflight_request.rb +14 -0
- data/lib/patient_http/solid_queue/lifecycle_hooks.rb +21 -0
- data/lib/patient_http/solid_queue/process_registration.rb +14 -0
- data/lib/patient_http/solid_queue/processor_observer.rb +38 -0
- data/lib/patient_http/solid_queue/record.rb +10 -0
- data/lib/patient_http/solid_queue/request_executor.rb +78 -0
- data/lib/patient_http/solid_queue/request_job.rb +46 -0
- data/lib/patient_http/solid_queue/task_handler.rb +53 -0
- data/lib/patient_http/solid_queue/task_monitor.rb +260 -0
- data/lib/patient_http/solid_queue/task_monitor_thread.rb +133 -0
- data/lib/patient_http/solid_queue.rb +284 -0
- data/lib/patient_http-solid_queue.rb +3 -0
- data/lib/tasks/patient_http_solid_queue.rake +21 -0
- data/patient_http-solid_queue.gemspec +44 -0
- metadata +110 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PatientHttp
|
|
4
|
+
module SolidQueue
|
|
5
|
+
# Manages inflight request tracking in the database for crash recovery.
|
|
6
|
+
#
|
|
7
|
+
# This class maintains Active Record records for each in-flight request.
|
|
8
|
+
# It provides distributed locking for orphan detection and automatic
|
|
9
|
+
# re-enqueueing of requests interrupted by process crashes.
|
|
10
|
+
#
|
|
11
|
+
# Task ID format: "hostname:pid:hex/request-uuid"
|
|
12
|
+
# - hostname: sanitized hostname (colons and slashes replaced with dashes)
|
|
13
|
+
# - pid: process ID
|
|
14
|
+
# - hex: 8-character random hex for uniqueness
|
|
15
|
+
# - request-uuid: unique identifier for the request
|
|
16
|
+
class TaskMonitor
|
|
17
|
+
GC_LOCK_NAME = "gc"
|
|
18
|
+
|
|
19
|
+
# @return [Configuration] the configuration object
|
|
20
|
+
attr_reader :config
|
|
21
|
+
|
|
22
|
+
def initialize(config)
|
|
23
|
+
@config = config
|
|
24
|
+
hostname = ::Socket.gethostname.force_encoding("UTF-8").tr(":/", "-")
|
|
25
|
+
pid = ::Process.pid
|
|
26
|
+
@lock_identifier = "#{hostname}:#{pid}:#{SecureRandom.hex(8)}".freeze
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Register a request as inflight in the database.
|
|
30
|
+
#
|
|
31
|
+
# @param task [PatientHttp::RequestTask] the request task to register
|
|
32
|
+
# @return [void]
|
|
33
|
+
def register(task)
|
|
34
|
+
job_payload = task.task_handler.active_job_data.to_json
|
|
35
|
+
task_id = full_task_id(task.id)
|
|
36
|
+
now = Time.current
|
|
37
|
+
|
|
38
|
+
InflightRequest.create!(
|
|
39
|
+
task_id: task_id,
|
|
40
|
+
process_id: @lock_identifier,
|
|
41
|
+
job_payload: job_payload,
|
|
42
|
+
heartbeat_at: now,
|
|
43
|
+
created_at: now
|
|
44
|
+
)
|
|
45
|
+
rescue => e
|
|
46
|
+
@config.logger&.error("[PatientHttp::SolidQueue] Failed to register task #{task_id}: #{e.message}")
|
|
47
|
+
raise if PatientHttp.testing?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Unregister a request from the database (called when request completes).
|
|
51
|
+
#
|
|
52
|
+
# @param task [PatientHttp::RequestTask] the request task to unregister
|
|
53
|
+
# @return [void]
|
|
54
|
+
def unregister(task)
|
|
55
|
+
task_id = full_task_id(task.id)
|
|
56
|
+
InflightRequest.where(task_id: task_id).delete_all
|
|
57
|
+
rescue => e
|
|
58
|
+
@config.logger&.error("[PatientHttp::SolidQueue] Failed to unregister task #{task_id}: #{e.message}")
|
|
59
|
+
raise if PatientHttp.testing?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Update heartbeat timestamps for multiple requests in a single operation.
|
|
63
|
+
#
|
|
64
|
+
# @param task_ids [Array<String>] the request IDs to update
|
|
65
|
+
# @return [void]
|
|
66
|
+
def update_heartbeats(task_ids)
|
|
67
|
+
return if task_ids.empty?
|
|
68
|
+
|
|
69
|
+
full_ids = task_ids.map { |id| full_task_id(id) }
|
|
70
|
+
InflightRequest.where(task_id: full_ids).update_all(heartbeat_at: Time.current)
|
|
71
|
+
rescue => e
|
|
72
|
+
@config.logger&.error("[PatientHttp::SolidQueue] Failed to update heartbeats: #{e.message}")
|
|
73
|
+
raise if PatientHttp.testing?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Record or refresh this process's registration.
|
|
77
|
+
#
|
|
78
|
+
# @return [void]
|
|
79
|
+
def ping_process
|
|
80
|
+
ProcessRegistration.upsert(
|
|
81
|
+
{process_id: @lock_identifier, max_connections: @config.max_connections, last_seen_at: Time.current},
|
|
82
|
+
unique_by: :process_id
|
|
83
|
+
)
|
|
84
|
+
rescue => e
|
|
85
|
+
@config.logger&.error("[PatientHttp::SolidQueue] Failed to ping process: #{e.message}")
|
|
86
|
+
raise if PatientHttp.testing?
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Remove this process's registration.
|
|
90
|
+
#
|
|
91
|
+
# @return [void]
|
|
92
|
+
def remove_process
|
|
93
|
+
ProcessRegistration.where(process_id: @lock_identifier).delete_all
|
|
94
|
+
rescue => e
|
|
95
|
+
@config.logger&.error("[PatientHttp::SolidQueue] Failed to remove process: #{e.message}")
|
|
96
|
+
raise if PatientHttp.testing?
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Try to acquire the distributed garbage collection lock.
|
|
100
|
+
#
|
|
101
|
+
# Uses a single semaphore row and pessimistic locking to ensure only one
|
|
102
|
+
# process can claim the lock at a time.
|
|
103
|
+
# Returns false if another process holds a non-expired lock, or if GC was
|
|
104
|
+
# run recently (within heartbeat_interval).
|
|
105
|
+
#
|
|
106
|
+
# @return [Boolean] true if lock acquired, false otherwise
|
|
107
|
+
def acquire_gc_lock
|
|
108
|
+
now = Time.current
|
|
109
|
+
expires_at = now + gc_lock_ttl.seconds
|
|
110
|
+
acquired = false
|
|
111
|
+
|
|
112
|
+
ensure_gc_lock_row!
|
|
113
|
+
|
|
114
|
+
GcLock.transaction do
|
|
115
|
+
lock = GcLock.lock.find_by!(lock_name: GC_LOCK_NAME)
|
|
116
|
+
|
|
117
|
+
recent_gc = lock.last_gc_at && lock.last_gc_at > (now - @config.heartbeat_interval)
|
|
118
|
+
next if recent_gc
|
|
119
|
+
|
|
120
|
+
lock_held = lock.lock_holder.present? && lock.expires_at.present? && lock.expires_at > now
|
|
121
|
+
next if lock_held
|
|
122
|
+
|
|
123
|
+
lock.update!(
|
|
124
|
+
lock_holder: @lock_identifier,
|
|
125
|
+
acquired_at: now,
|
|
126
|
+
expires_at: expires_at
|
|
127
|
+
)
|
|
128
|
+
acquired = true
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
acquired
|
|
132
|
+
rescue => e
|
|
133
|
+
@config.logger&.error("[PatientHttp::SolidQueue] Failed to acquire GC lock: #{e.message}")
|
|
134
|
+
raise if PatientHttp.testing?
|
|
135
|
+
false
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Release the garbage collection lock if held by this process, and record last_gc_at.
|
|
139
|
+
#
|
|
140
|
+
# @return [void]
|
|
141
|
+
def release_gc_lock
|
|
142
|
+
GcLock.where(lock_name: GC_LOCK_NAME, lock_holder: @lock_identifier)
|
|
143
|
+
.update_all(last_gc_at: Time.current, lock_holder: nil, acquired_at: nil, expires_at: nil)
|
|
144
|
+
rescue => e
|
|
145
|
+
@config.logger&.error("[PatientHttp::SolidQueue] Failed to release GC lock: #{e.message}")
|
|
146
|
+
raise if PatientHttp.testing?
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Find and re-enqueue orphaned requests.
|
|
150
|
+
#
|
|
151
|
+
# @param orphan_threshold_seconds [Numeric] age threshold for considering a request orphaned
|
|
152
|
+
# @param logger [Logger] logger for output
|
|
153
|
+
# @return [Integer] number of orphaned requests re-enqueued
|
|
154
|
+
def cleanup_orphaned_requests(orphan_threshold_seconds, logger)
|
|
155
|
+
threshold = Time.current - orphan_threshold_seconds.seconds
|
|
156
|
+
|
|
157
|
+
prune_stale_process_registrations(threshold)
|
|
158
|
+
|
|
159
|
+
# Get process IDs with a recent heartbeat
|
|
160
|
+
active_process_ids = ProcessRegistration.where("last_seen_at >= ?", threshold).pluck(:process_id)
|
|
161
|
+
|
|
162
|
+
# Find stale requests from processes not in the active set
|
|
163
|
+
orphaned = InflightRequest
|
|
164
|
+
.where("heartbeat_at < ?", threshold)
|
|
165
|
+
.where.not(process_id: active_process_ids)
|
|
166
|
+
.to_a
|
|
167
|
+
|
|
168
|
+
return 0 if orphaned.empty?
|
|
169
|
+
|
|
170
|
+
reenqueued_count = 0
|
|
171
|
+
|
|
172
|
+
orphaned.each do |record|
|
|
173
|
+
reenqueued_count += 1 if reenqueue_orphaned_record(record, threshold, logger)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
reenqueued_count
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Build unique task ID for a request task that includes process identifier.
|
|
180
|
+
#
|
|
181
|
+
# @param task_id [String] the request task ID
|
|
182
|
+
# @return [String] the unique task ID
|
|
183
|
+
def full_task_id(task_id)
|
|
184
|
+
"#{@lock_identifier}/#{task_id}"
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Check if a task is registered in the inflight table.
|
|
188
|
+
#
|
|
189
|
+
# @param task [PatientHttp::RequestTask] the request task
|
|
190
|
+
# @return [Boolean]
|
|
191
|
+
# @api private
|
|
192
|
+
def registered?(task)
|
|
193
|
+
InflightRequest.where(task_id: full_task_id(task.id)).exists?
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Clear all records. Only allowed in test environment.
|
|
197
|
+
#
|
|
198
|
+
# @raise [RuntimeError] if called outside of test environment
|
|
199
|
+
# @return [void]
|
|
200
|
+
# @api private
|
|
201
|
+
def self.clear_all!
|
|
202
|
+
unless PatientHttp.testing?
|
|
203
|
+
raise "clear_all! is only allowed in test environment"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
InflightRequest.delete_all
|
|
207
|
+
ProcessRegistration.delete_all
|
|
208
|
+
GcLock.delete_all
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
private
|
|
212
|
+
|
|
213
|
+
def ensure_gc_lock_row!
|
|
214
|
+
GcLock.insert_all([{lock_name: GC_LOCK_NAME}], unique_by: :lock_name)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Re-enqueue a single orphaned record atomically.
|
|
218
|
+
#
|
|
219
|
+
# Uses a delete-by-exact-heartbeat to handle race conditions: if the
|
|
220
|
+
# heartbeat was updated between our read and the delete, the delete
|
|
221
|
+
# returns 0 rows and we skip re-enqueueing.
|
|
222
|
+
#
|
|
223
|
+
# @param record [InflightRequest] the orphaned record
|
|
224
|
+
# @param threshold [Float] heartbeat threshold (only records below this are orphaned)
|
|
225
|
+
# @param logger [Logger] logger for output
|
|
226
|
+
# @return [Boolean] true if successfully re-enqueued
|
|
227
|
+
def reenqueue_orphaned_record(record, threshold, logger)
|
|
228
|
+
# Atomically remove only if still orphaned (heartbeat unchanged)
|
|
229
|
+
deleted = InflightRequest
|
|
230
|
+
.where(task_id: record.task_id, heartbeat_at: record.heartbeat_at)
|
|
231
|
+
.where("heartbeat_at < ?", threshold)
|
|
232
|
+
.delete_all
|
|
233
|
+
|
|
234
|
+
return false if deleted == 0
|
|
235
|
+
|
|
236
|
+
job_data = JSON.parse(record.job_payload)
|
|
237
|
+
ActiveJob::Base.deserialize(job_data).tap { |j| j.executions = 0 }.enqueue
|
|
238
|
+
|
|
239
|
+
logger&.info(
|
|
240
|
+
"[PatientHttp::SolidQueue] Re-enqueued orphaned request #{record.task_id} to #{job_data["job_class"]}"
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
true
|
|
244
|
+
rescue => e
|
|
245
|
+
logger&.error(
|
|
246
|
+
"[PatientHttp::SolidQueue] Failed to re-enqueue orphaned request #{record.task_id}: #{e.class} - #{e.message}"
|
|
247
|
+
)
|
|
248
|
+
false
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def gc_lock_ttl
|
|
252
|
+
[@config.heartbeat_interval * 2, 120].max
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def prune_stale_process_registrations(threshold)
|
|
256
|
+
ProcessRegistration.where("last_seen_at < ?", threshold).delete_all
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PatientHttp
|
|
4
|
+
module SolidQueue
|
|
5
|
+
# Background thread that maintains heartbeats and performs garbage collection
|
|
6
|
+
# for in-flight HTTP requests.
|
|
7
|
+
class TaskMonitorThread
|
|
8
|
+
include PatientHttp::TimeHelper
|
|
9
|
+
|
|
10
|
+
# Maximum seconds to sleep between monitor thread checks.
|
|
11
|
+
MAX_MONITOR_SLEEP = 5.0
|
|
12
|
+
|
|
13
|
+
# @return [Configuration] the configuration object
|
|
14
|
+
attr_reader :config
|
|
15
|
+
|
|
16
|
+
# @return [TaskMonitor] the inflight request registry
|
|
17
|
+
attr_reader :task_monitor
|
|
18
|
+
|
|
19
|
+
# Initialize the monitor thread.
|
|
20
|
+
#
|
|
21
|
+
# @param config [Configuration] the configuration object
|
|
22
|
+
# @param task_monitor [TaskMonitor] the inflight request registry
|
|
23
|
+
# @param inflight_ids_callback [Proc] callback to get current inflight request IDs
|
|
24
|
+
def initialize(config, task_monitor, inflight_ids_callback)
|
|
25
|
+
@config = config
|
|
26
|
+
@task_monitor = task_monitor
|
|
27
|
+
@inflight_ids_callback = inflight_ids_callback
|
|
28
|
+
@thread = nil
|
|
29
|
+
@running = Concurrent::AtomicBoolean.new(false)
|
|
30
|
+
@stop_signal = Concurrent::Event.new
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Start the monitor thread.
|
|
34
|
+
#
|
|
35
|
+
# @return [void]
|
|
36
|
+
def start
|
|
37
|
+
return if @running.true?
|
|
38
|
+
@running.make_true
|
|
39
|
+
@stop_signal.reset
|
|
40
|
+
|
|
41
|
+
@task_monitor.ping_process
|
|
42
|
+
|
|
43
|
+
@thread = Thread.new do
|
|
44
|
+
run
|
|
45
|
+
rescue => e
|
|
46
|
+
@config.logger&.error("[PatientHttp::SolidQueue] Monitor error: #{e.message}\n#{e.backtrace.join("\n")}")
|
|
47
|
+
raise if PatientHttp.testing?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
@thread.name = "async-http-monitor"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Stop the monitor thread.
|
|
54
|
+
#
|
|
55
|
+
# @return [void]
|
|
56
|
+
def stop
|
|
57
|
+
@running.make_false
|
|
58
|
+
@stop_signal.set
|
|
59
|
+
@thread&.join(1)
|
|
60
|
+
@thread&.kill if @thread&.alive?
|
|
61
|
+
@thread = nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Check if monitor thread is running.
|
|
65
|
+
#
|
|
66
|
+
# @return [Boolean]
|
|
67
|
+
def running?
|
|
68
|
+
@running.true?
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def run
|
|
74
|
+
@config.logger&.info("[PatientHttp::SolidQueue] Monitor thread started")
|
|
75
|
+
|
|
76
|
+
last_heartbeat_update = monotonic_time - @config.heartbeat_interval
|
|
77
|
+
last_gc_attempt = monotonic_time - @config.heartbeat_interval
|
|
78
|
+
|
|
79
|
+
loop do
|
|
80
|
+
break unless @running.true?
|
|
81
|
+
|
|
82
|
+
current_time = monotonic_time
|
|
83
|
+
|
|
84
|
+
if current_time - last_heartbeat_update >= @config.heartbeat_interval
|
|
85
|
+
@task_monitor.ping_process
|
|
86
|
+
update_heartbeats
|
|
87
|
+
last_heartbeat_update = current_time
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
if current_time - last_gc_attempt >= @config.heartbeat_interval
|
|
91
|
+
attempt_garbage_collection
|
|
92
|
+
last_gc_attempt = current_time
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
wait_time = @config.heartbeat_interval / 2.0
|
|
96
|
+
wait_time = MAX_MONITOR_SLEEP if wait_time > MAX_MONITOR_SLEEP
|
|
97
|
+
@stop_signal.wait(wait_time)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
@config.logger&.info("[PatientHttp::SolidQueue] Monitor thread stopped")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def update_heartbeats
|
|
104
|
+
request_ids = @inflight_ids_callback.call
|
|
105
|
+
return if request_ids.empty?
|
|
106
|
+
|
|
107
|
+
@task_monitor.update_heartbeats(request_ids)
|
|
108
|
+
|
|
109
|
+
@config.logger&.debug("[PatientHttp::SolidQueue] Updated heartbeats for #{request_ids.size} inflight requests")
|
|
110
|
+
rescue => e
|
|
111
|
+
@config.logger&.error("[PatientHttp::SolidQueue] Failed to update heartbeats: #{e.class} - #{e.message}")
|
|
112
|
+
raise if PatientHttp.testing?
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def attempt_garbage_collection
|
|
116
|
+
return unless @task_monitor.acquire_gc_lock
|
|
117
|
+
|
|
118
|
+
begin
|
|
119
|
+
count = @task_monitor.cleanup_orphaned_requests(@config.orphan_threshold, @config.logger)
|
|
120
|
+
|
|
121
|
+
if count > 0
|
|
122
|
+
@config.logger&.info("[PatientHttp::SolidQueue] Garbage collection: re-enqueued #{count} orphaned requests")
|
|
123
|
+
end
|
|
124
|
+
ensure
|
|
125
|
+
@task_monitor.release_gc_lock
|
|
126
|
+
end
|
|
127
|
+
rescue => e
|
|
128
|
+
@config.logger&.error("[PatientHttp::SolidQueue] Garbage collection failed: #{e.class} - #{e.message}")
|
|
129
|
+
raise if PatientHttp.testing?
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "patient_http"
|
|
4
|
+
require "solid_queue"
|
|
5
|
+
|
|
6
|
+
# Main module for the Solid Queue Async HTTP gem.
|
|
7
|
+
#
|
|
8
|
+
# This gem provides a mechanism to offload long-running HTTP requests from Solid Queue workers
|
|
9
|
+
# to a dedicated async I/O processor running in the same process, freeing worker threads
|
|
10
|
+
# immediately while HTTP requests are in flight.
|
|
11
|
+
#
|
|
12
|
+
# == Usage
|
|
13
|
+
#
|
|
14
|
+
# request = PatientHttp::Request.new(:get, "https://api.example.com/users/123")
|
|
15
|
+
# PatientHttp::SolidQueue.execute(
|
|
16
|
+
# request,
|
|
17
|
+
# callback: MyCallback,
|
|
18
|
+
# callback_args: {user_id: 123}
|
|
19
|
+
# )
|
|
20
|
+
#
|
|
21
|
+
# Define a callback service class with +on_complete+ and +on_error+ methods:
|
|
22
|
+
#
|
|
23
|
+
# class MyCallback
|
|
24
|
+
# def on_complete(response)
|
|
25
|
+
# user_id = response.callback_args[:user_id]
|
|
26
|
+
# User.find(user_id).update!(data: response.json)
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
# def on_error(error)
|
|
30
|
+
# Rails.logger.error("Request failed: #{error.message}")
|
|
31
|
+
# end
|
|
32
|
+
# end
|
|
33
|
+
module PatientHttp
|
|
34
|
+
module SolidQueue
|
|
35
|
+
VERSION = File.read(File.join(__dir__, "../../VERSION")).strip
|
|
36
|
+
|
|
37
|
+
autoload :CallbackJob, File.join(__dir__, "solid_queue/callback_job")
|
|
38
|
+
autoload :Configuration, File.join(__dir__, "solid_queue/configuration")
|
|
39
|
+
autoload :Context, File.join(__dir__, "solid_queue/context")
|
|
40
|
+
autoload :GcLock, File.join(__dir__, "solid_queue/gc_lock")
|
|
41
|
+
autoload :InflightRequest, File.join(__dir__, "solid_queue/inflight_request")
|
|
42
|
+
autoload :ProcessorObserver, File.join(__dir__, "solid_queue/processor_observer")
|
|
43
|
+
autoload :ProcessRegistration, File.join(__dir__, "solid_queue/process_registration")
|
|
44
|
+
autoload :Record, File.join(__dir__, "solid_queue/record")
|
|
45
|
+
autoload :RequestExecutor, File.join(__dir__, "solid_queue/request_executor")
|
|
46
|
+
autoload :RequestJob, File.join(__dir__, "solid_queue/request_job")
|
|
47
|
+
autoload :LifecycleHooks, File.join(__dir__, "solid_queue/lifecycle_hooks")
|
|
48
|
+
autoload :TaskHandler, File.join(__dir__, "solid_queue/task_handler")
|
|
49
|
+
autoload :TaskMonitor, File.join(__dir__, "solid_queue/task_monitor")
|
|
50
|
+
autoload :TaskMonitorThread, File.join(__dir__, "solid_queue/task_monitor_thread")
|
|
51
|
+
|
|
52
|
+
@processor = nil
|
|
53
|
+
@configuration = nil
|
|
54
|
+
@after_completion_callbacks = []
|
|
55
|
+
@after_error_callbacks = []
|
|
56
|
+
@external_storage = nil
|
|
57
|
+
@request_handler = nil
|
|
58
|
+
|
|
59
|
+
class << self
|
|
60
|
+
attr_writer :configuration
|
|
61
|
+
|
|
62
|
+
# Configure the gem with a block.
|
|
63
|
+
#
|
|
64
|
+
# @yield [Configuration] the configuration object
|
|
65
|
+
# @return [Configuration]
|
|
66
|
+
def configure
|
|
67
|
+
configuration = Configuration.new
|
|
68
|
+
yield(configuration) if block_given?
|
|
69
|
+
@configuration = configuration
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Return the current configuration, initializing with defaults if necessary.
|
|
73
|
+
#
|
|
74
|
+
# @return [Configuration]
|
|
75
|
+
def configuration
|
|
76
|
+
@configuration ||= Configuration.new
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Reset configuration to defaults (useful for testing).
|
|
80
|
+
#
|
|
81
|
+
# @return [Configuration]
|
|
82
|
+
def reset_configuration!
|
|
83
|
+
@configuration = nil
|
|
84
|
+
configuration
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Add a callback to be executed after a successful request completion.
|
|
88
|
+
#
|
|
89
|
+
# @yield [response] block to execute after an HTTP request completes
|
|
90
|
+
# @yieldparam response [PatientHttp::Response] the HTTP response
|
|
91
|
+
def after_completion(&block)
|
|
92
|
+
@after_completion_callbacks << block
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Add a callback to be executed after a request error.
|
|
96
|
+
#
|
|
97
|
+
# @yield [error] block to execute after an HTTP request errors
|
|
98
|
+
# @yieldparam error [PatientHttp::Error] information about the error
|
|
99
|
+
def after_error(&block)
|
|
100
|
+
@after_error_callbacks << block
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Check if the processor is running.
|
|
104
|
+
#
|
|
105
|
+
# @return [Boolean]
|
|
106
|
+
def running?
|
|
107
|
+
!!@processor&.running?
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Check if the processor is draining (not accepting new requests).
|
|
111
|
+
#
|
|
112
|
+
# @return [Boolean]
|
|
113
|
+
def draining?
|
|
114
|
+
!!@processor&.draining?
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Check if the processor is stopping.
|
|
118
|
+
#
|
|
119
|
+
# @return [Boolean]
|
|
120
|
+
def stopping?
|
|
121
|
+
!!@processor&.stopping?
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Check if the processor is stopped.
|
|
125
|
+
#
|
|
126
|
+
# @return [Boolean]
|
|
127
|
+
def stopped?
|
|
128
|
+
@processor.nil? || @processor.stopped?
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Get an ExternalStorage instance for storing and fetching payloads.
|
|
132
|
+
#
|
|
133
|
+
# @return [PatientHttp::ExternalStorage]
|
|
134
|
+
# @api private
|
|
135
|
+
def external_storage
|
|
136
|
+
@external_storage ||= PatientHttp::ExternalStorage.new(configuration)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Execute an async HTTP request.
|
|
140
|
+
#
|
|
141
|
+
# @param request [PatientHttp::Request] the HTTP request to execute
|
|
142
|
+
# @param callback [Class, String] Callback service class with +on_complete+ and +on_error+
|
|
143
|
+
# instance methods, or its fully qualified class name.
|
|
144
|
+
# @param callback_args [#to_h, nil] Arguments to pass to callback
|
|
145
|
+
# @param raise_error_responses [Boolean] If true, treats non-2xx responses as errors
|
|
146
|
+
# @return [String] the request ID
|
|
147
|
+
def execute(request, callback:, callback_args: nil, raise_error_responses: false)
|
|
148
|
+
PatientHttp::CallbackValidator.validate!(callback)
|
|
149
|
+
callback_name = callback.is_a?(Class) ? callback.name : callback.to_s
|
|
150
|
+
callback_args = PatientHttp::CallbackValidator.validate_callback_args(callback_args)
|
|
151
|
+
request_id = SecureRandom.uuid
|
|
152
|
+
|
|
153
|
+
encrypted = encrypt(request.as_json)
|
|
154
|
+
|
|
155
|
+
data = if external_storage.enabled?
|
|
156
|
+
external_storage.store(encrypted, max_size: configuration.payload_store_threshold)
|
|
157
|
+
else
|
|
158
|
+
encrypted
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
RequestJob.perform_later(data, callback_name, raise_error_responses, callback_args, request_id)
|
|
162
|
+
|
|
163
|
+
request_id
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Start the processor.
|
|
167
|
+
#
|
|
168
|
+
# @return [void]
|
|
169
|
+
def start
|
|
170
|
+
return if running?
|
|
171
|
+
|
|
172
|
+
@processor = PatientHttp::Processor.new(configuration)
|
|
173
|
+
@processor.observe(ProcessorObserver.new(@processor))
|
|
174
|
+
@processor.start
|
|
175
|
+
|
|
176
|
+
@request_handler ||= lambda do |request:, callback:, raise_error_responses:, callback_args:|
|
|
177
|
+
execute(
|
|
178
|
+
request,
|
|
179
|
+
callback: callback,
|
|
180
|
+
raise_error_responses: raise_error_responses,
|
|
181
|
+
callback_args: callback_args
|
|
182
|
+
)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
PatientHttp.register_handler(@request_handler)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Signal the processor to drain (stop accepting new requests).
|
|
189
|
+
#
|
|
190
|
+
# @return [void]
|
|
191
|
+
def quiet
|
|
192
|
+
return unless running?
|
|
193
|
+
|
|
194
|
+
@processor.drain
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Stop the processor gracefully.
|
|
198
|
+
#
|
|
199
|
+
# @param timeout [Float, nil] maximum time to wait for in-flight requests to complete
|
|
200
|
+
# @return [void]
|
|
201
|
+
def stop(timeout: nil)
|
|
202
|
+
return unless @processor
|
|
203
|
+
|
|
204
|
+
if @request_handler
|
|
205
|
+
PatientHttp.unregister_handler(@request_handler)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
@processor.stop(timeout: timeout)
|
|
209
|
+
@processor = nil
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Reset all state (useful for testing).
|
|
213
|
+
#
|
|
214
|
+
# @return [void]
|
|
215
|
+
# @api private
|
|
216
|
+
def reset!
|
|
217
|
+
if @request_handler
|
|
218
|
+
PatientHttp.unregister_handler(@request_handler)
|
|
219
|
+
@request_handler = nil
|
|
220
|
+
end
|
|
221
|
+
@processor&.stop(timeout: 0)
|
|
222
|
+
@processor = nil
|
|
223
|
+
@configuration = nil
|
|
224
|
+
@external_storage = nil
|
|
225
|
+
@after_completion_callbacks = []
|
|
226
|
+
@after_error_callbacks = []
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Invoke the registered completion callbacks.
|
|
230
|
+
#
|
|
231
|
+
# @param response [PatientHttp::Response] the HTTP response
|
|
232
|
+
# @return [void]
|
|
233
|
+
# @api private
|
|
234
|
+
def invoke_completion_callbacks(response)
|
|
235
|
+
@after_completion_callbacks.each do |callback|
|
|
236
|
+
callback.call(response)
|
|
237
|
+
rescue => e
|
|
238
|
+
configuration.logger&.error("[PatientHttp::SolidQueue] after_completion callback error: #{e.class} - #{e.message}")
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Invoke the registered error callbacks.
|
|
243
|
+
#
|
|
244
|
+
# @param error [PatientHttp::Error] information about the error
|
|
245
|
+
# @return [void]
|
|
246
|
+
# @api private
|
|
247
|
+
def invoke_error_callbacks(error)
|
|
248
|
+
@after_error_callbacks.each do |callback|
|
|
249
|
+
callback.call(error)
|
|
250
|
+
rescue => e
|
|
251
|
+
configuration.logger&.error("[PatientHttp::SolidQueue] after_error callback error: #{e.class} - #{e.message}")
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Encrypt a value using the configured encryptor.
|
|
256
|
+
#
|
|
257
|
+
# @param value [Object] the value to encrypt
|
|
258
|
+
# @return [String] the encrypted value
|
|
259
|
+
def encrypt(value)
|
|
260
|
+
configuration.encryptor.encrypt(value)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Decrypt a value using the configured encryptor.
|
|
264
|
+
#
|
|
265
|
+
# @param value [String] the encrypted value to decrypt
|
|
266
|
+
# @return [Object] the decrypted value
|
|
267
|
+
def decrypt(value)
|
|
268
|
+
configuration.encryptor.decrypt(value)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Returns the processor instance.
|
|
272
|
+
#
|
|
273
|
+
# @return [PatientHttp::Processor, nil]
|
|
274
|
+
# @api private
|
|
275
|
+
attr_accessor :processor
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
if defined?(::Rails::Engine)
|
|
281
|
+
require_relative "solid_queue/engine"
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
PatientHttp::SolidQueue::LifecycleHooks.register
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
if Rake::Task.task_defined?("patient_http_solid_queue:install:migrations")
|
|
4
|
+
Rake::Task["patient_http_solid_queue:install:migrations"].clear
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
namespace :patient_http_solid_queue do
|
|
8
|
+
namespace :install do
|
|
9
|
+
desc "Copy migrations from patient_http_solid_queue to the queue database migration path"
|
|
10
|
+
task migrations: :"db:load_config" do
|
|
11
|
+
ENV["FROM"] = "patient_http_solid_queue"
|
|
12
|
+
ENV["DATABASE"] = "queue" if ENV["DATABASE"].nil? || ENV["DATABASE"].empty?
|
|
13
|
+
|
|
14
|
+
if Rake::Task.task_defined?("railties:install:migrations")
|
|
15
|
+
Rake::Task["railties:install:migrations"].invoke
|
|
16
|
+
else
|
|
17
|
+
Rake::Task["app:railties:install:migrations"].invoke
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|