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,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PatientHttp
|
|
4
|
+
module SolidQueue
|
|
5
|
+
# Active Job that invokes callback services for HTTP request results.
|
|
6
|
+
#
|
|
7
|
+
# Receives serialized Response or Error data and invokes the appropriate
|
|
8
|
+
# callback service method (+on_complete+ or +on_error+).
|
|
9
|
+
#
|
|
10
|
+
# @api private
|
|
11
|
+
class CallbackJob < ActiveJob::Base
|
|
12
|
+
# Clean up externally stored payloads when job exhausts all retries.
|
|
13
|
+
after_discard do |job, _exception|
|
|
14
|
+
data = job.arguments[0]
|
|
15
|
+
result_type = job.arguments[1]
|
|
16
|
+
|
|
17
|
+
begin
|
|
18
|
+
handler = PatientHttp::SolidQueue.configuration.on_retries_exhausted
|
|
19
|
+
if handler && result_type == "error"
|
|
20
|
+
actual_data = if PatientHttp::SolidQueue.external_storage.storage_ref?(data)
|
|
21
|
+
PatientHttp::SolidQueue.external_storage.fetch(data)
|
|
22
|
+
else
|
|
23
|
+
data
|
|
24
|
+
end
|
|
25
|
+
actual_data = PatientHttp::SolidQueue.decrypt(actual_data)
|
|
26
|
+
error = PatientHttp::Error.load(actual_data)
|
|
27
|
+
handler.call(error)
|
|
28
|
+
end
|
|
29
|
+
rescue => e
|
|
30
|
+
PatientHttp::SolidQueue.configuration.logger&.warn(
|
|
31
|
+
"[PatientHttp::SolidQueue] on_retries_exhausted handler failed: #{e.class.name} #{e.message}".strip
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
begin
|
|
36
|
+
PatientHttp::SolidQueue.external_storage.delete(data)
|
|
37
|
+
rescue => e
|
|
38
|
+
PatientHttp::SolidQueue.configuration.logger&.warn(
|
|
39
|
+
"[PatientHttp::SolidQueue] Failed to delete stored payload for dead job: #{e.class.name} #{e.message}".strip
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @param data [Hash] Response or Error data (possibly a storage reference)
|
|
45
|
+
# @param result_type [String] "response" or "error" indicating the type of result
|
|
46
|
+
# @param callback_service_name [String] Fully qualified callback service class name
|
|
47
|
+
def perform(data, result_type, callback_service_name)
|
|
48
|
+
callback_service_class = PatientHttp::ClassHelper.resolve_class_name(callback_service_name)
|
|
49
|
+
callback_service = callback_service_class.new
|
|
50
|
+
|
|
51
|
+
ref_data = PatientHttp::ExternalStorage.storage_ref?(data) ? data : nil
|
|
52
|
+
actual_data = ref_data ? PatientHttp::SolidQueue.external_storage.fetch(data) : data
|
|
53
|
+
actual_data = PatientHttp::SolidQueue.decrypt(actual_data)
|
|
54
|
+
|
|
55
|
+
begin
|
|
56
|
+
if result_type == "response"
|
|
57
|
+
response = PatientHttp::Response.load(actual_data)
|
|
58
|
+
PatientHttp::SolidQueue.invoke_completion_callbacks(response)
|
|
59
|
+
callback_service.on_complete(response)
|
|
60
|
+
elsif result_type == "error"
|
|
61
|
+
error = PatientHttp::Error.load(actual_data)
|
|
62
|
+
PatientHttp::SolidQueue.invoke_error_callbacks(error)
|
|
63
|
+
callback_service.on_error(error)
|
|
64
|
+
else
|
|
65
|
+
raise ArgumentError, "Unknown result_type: #{result_type}"
|
|
66
|
+
end
|
|
67
|
+
ensure
|
|
68
|
+
PatientHttp::SolidQueue.external_storage.delete(ref_data) if ref_data
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PatientHttp
|
|
4
|
+
module SolidQueue
|
|
5
|
+
# Configuration for the Solid Queue Async HTTP gem.
|
|
6
|
+
#
|
|
7
|
+
# Wraps PatientHttp::Configuration with Solid Queue-aware defaults and adds
|
|
8
|
+
# Solid Queue-specific options like queue name settings.
|
|
9
|
+
class Configuration < PatientHttp::Configuration
|
|
10
|
+
# Default threshold in bytes above which payloads are stored externally.
|
|
11
|
+
DEFAULT_PAYLOAD_STORE_THRESHOLD = 64 * 1024 # 64KB
|
|
12
|
+
|
|
13
|
+
# @return [Integer] Size threshold in bytes for external payload storage
|
|
14
|
+
attr_reader :payload_store_threshold
|
|
15
|
+
|
|
16
|
+
# @return [Numeric] Orphan detection threshold in seconds
|
|
17
|
+
attr_reader :orphan_threshold
|
|
18
|
+
|
|
19
|
+
# @return [Numeric] Heartbeat update interval in seconds
|
|
20
|
+
attr_reader :heartbeat_interval
|
|
21
|
+
|
|
22
|
+
# @return [String, nil] Queue name for RequestJob and CallbackJob
|
|
23
|
+
attr_reader :queue_name
|
|
24
|
+
|
|
25
|
+
# @return [#call, nil] Handler invoked when a CallbackWorker job exhausts all retries.
|
|
26
|
+
# @overload on_retries_exhausted
|
|
27
|
+
# Returns the current handler.
|
|
28
|
+
# @return [#call, nil]
|
|
29
|
+
# @overload on_retries_exhausted(&block)
|
|
30
|
+
# Sets a block as the handler.
|
|
31
|
+
# @yield [error] block to execute when retries are exhausted
|
|
32
|
+
# @yieldparam error [PatientHttp::Error] information about the error
|
|
33
|
+
def on_retries_exhausted(&block)
|
|
34
|
+
if block
|
|
35
|
+
@on_retries_exhausted = block
|
|
36
|
+
else
|
|
37
|
+
@on_retries_exhausted
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Buffer in seconds subtracted from SolidQueue.shutdown_timeout to derive
|
|
42
|
+
# the default shutdown_timeout for this gem's connection pool.
|
|
43
|
+
SHUTDOWN_TIMEOUT_BUFFER = 2
|
|
44
|
+
|
|
45
|
+
# @param heartbeat_interval [Numeric] Interval in seconds for heartbeat updates (default: 60)
|
|
46
|
+
# @param orphan_threshold [Numeric] Time in seconds to consider a job orphaned (default: 300)
|
|
47
|
+
# @param queue_name [String, nil] Optional queue name for RequestJob and CallbackJob (default: nil)
|
|
48
|
+
# @param payload_store_threshold [Integer] Size threshold in bytes for external payload storage (default: 64KB)
|
|
49
|
+
# @param on_retries_exhausted [#call, nil] Handler called when a CallbackWorker job exhausts retries
|
|
50
|
+
# @param pool_options [Hash] Additional options passed to the SolidQueue connection pool
|
|
51
|
+
def initialize(
|
|
52
|
+
heartbeat_interval: 60,
|
|
53
|
+
orphan_threshold: 300,
|
|
54
|
+
queue_name: nil,
|
|
55
|
+
payload_store_threshold: DEFAULT_PAYLOAD_STORE_THRESHOLD,
|
|
56
|
+
on_retries_exhausted: nil,
|
|
57
|
+
**pool_options
|
|
58
|
+
)
|
|
59
|
+
if ::SolidQueue.shutdown_timeout
|
|
60
|
+
pool_options[:shutdown_timeout] ||= [::SolidQueue.shutdown_timeout - SHUTDOWN_TIMEOUT_BUFFER, 1].max
|
|
61
|
+
end
|
|
62
|
+
pool_options[:user_agent] ||= "SolidQueue-AsyncHttp"
|
|
63
|
+
pool_options[:logger] ||= (defined?(SolidQueue.logger) ? SolidQueue.logger : nil)
|
|
64
|
+
|
|
65
|
+
super(**pool_options)
|
|
66
|
+
|
|
67
|
+
self.queue_name = queue_name
|
|
68
|
+
self.heartbeat_interval = heartbeat_interval
|
|
69
|
+
self.orphan_threshold = orphan_threshold
|
|
70
|
+
self.payload_store_threshold = payload_store_threshold || DEFAULT_PAYLOAD_STORE_THRESHOLD
|
|
71
|
+
self.on_retries_exhausted = on_retries_exhausted
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def payload_store_threshold=(value)
|
|
75
|
+
validate_positive_integer(:payload_store_threshold, value)
|
|
76
|
+
@payload_store_threshold = value
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def heartbeat_interval=(value)
|
|
80
|
+
raise ArgumentError, "heartbeat_interval must be positive, got: #{value.inspect}" unless value.positive?
|
|
81
|
+
@heartbeat_interval = value
|
|
82
|
+
validate_heartbeat_and_threshold
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def orphan_threshold=(value)
|
|
86
|
+
raise ArgumentError, "orphan_threshold must be positive, got: #{value.inspect}" unless value.positive?
|
|
87
|
+
@orphan_threshold = value
|
|
88
|
+
validate_heartbeat_and_threshold
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def queue_name=(name)
|
|
92
|
+
if name.nil?
|
|
93
|
+
@queue_name = nil
|
|
94
|
+
return
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
raise ArgumentError, "queue_name must be a String, got: #{name.class}" unless name.is_a?(String)
|
|
98
|
+
@queue_name = name
|
|
99
|
+
apply_queue_name(name)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Set the on_retries_exhausted handler.
|
|
103
|
+
#
|
|
104
|
+
# This handler is called when a CallbackWorker job exhausts all retries.
|
|
105
|
+
# It receives the same arguments as the on_error callback.
|
|
106
|
+
#
|
|
107
|
+
# @param value [#call, nil] A callable object or nil to clear the handler
|
|
108
|
+
# @raise [ArgumentError] If value is not callable and not nil
|
|
109
|
+
def on_retries_exhausted=(value)
|
|
110
|
+
if value && !value.respond_to?(:call)
|
|
111
|
+
raise ArgumentError.new("on_retries_exhausted must respond to #call, got: #{value.class}")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
@on_retries_exhausted = value
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# @return [Hash] configuration as a hash for logging/inspection
|
|
118
|
+
def to_h
|
|
119
|
+
super.merge(
|
|
120
|
+
"payload_store_threshold" => payload_store_threshold,
|
|
121
|
+
"heartbeat_interval" => heartbeat_interval,
|
|
122
|
+
"orphan_threshold" => orphan_threshold,
|
|
123
|
+
"queue_name" => queue_name,
|
|
124
|
+
"on_retries_exhausted" => on_retries_exhausted ? "defined" : nil
|
|
125
|
+
)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
def apply_queue_name(name)
|
|
131
|
+
PatientHttp::SolidQueue::RequestJob.queue_as(name)
|
|
132
|
+
PatientHttp::SolidQueue::CallbackJob.queue_as(name)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def validate_heartbeat_and_threshold
|
|
136
|
+
return unless @heartbeat_interval && @orphan_threshold
|
|
137
|
+
return unless @heartbeat_interval >= @orphan_threshold
|
|
138
|
+
raise ArgumentError, "heartbeat_interval (#{@heartbeat_interval}) must be less than orphan_threshold (#{@orphan_threshold})"
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PatientHttp
|
|
4
|
+
module SolidQueue
|
|
5
|
+
# Provides thread-safe context for Active Jobs.
|
|
6
|
+
#
|
|
7
|
+
# Manages the current Active Job context using a thread-id keyed hash,
|
|
8
|
+
# allowing async HTTP requests to access job information without it being
|
|
9
|
+
# passed explicitly. Only RequestJob needs this context for re-enqueueing jobs.
|
|
10
|
+
class Context
|
|
11
|
+
@jobs = Concurrent::Map.new
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
# Returns the current job data hash for the running thread.
|
|
15
|
+
#
|
|
16
|
+
# @return [Hash, nil]
|
|
17
|
+
def current_job
|
|
18
|
+
@jobs[Thread.current.object_id]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Set the current job context for the duration of a block.
|
|
22
|
+
#
|
|
23
|
+
# @param job_data [Hash] Active Job serialized hash
|
|
24
|
+
# @yield
|
|
25
|
+
def with_job(job_data)
|
|
26
|
+
thread_id = Thread.current.object_id
|
|
27
|
+
previous_job = @jobs[thread_id]
|
|
28
|
+
@jobs[thread_id] = job_data
|
|
29
|
+
yield
|
|
30
|
+
ensure
|
|
31
|
+
previous_job ? @jobs[thread_id] = previous_job : @jobs.delete(thread_id)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PatientHttp
|
|
4
|
+
module SolidQueue
|
|
5
|
+
# Rails Engine that makes gem migrations discoverable by the host application.
|
|
6
|
+
class Engine < ::Rails::Engine
|
|
7
|
+
engine_name "patient_http_solid_queue"
|
|
8
|
+
|
|
9
|
+
initializer "patient_http_solid_queue.migrations" do
|
|
10
|
+
config.paths["db/migrate"] << File.expand_path("../../../db/migrate", __dir__)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PatientHttp
|
|
4
|
+
module SolidQueue
|
|
5
|
+
# Active Record model for distributed garbage collection locking.
|
|
6
|
+
#
|
|
7
|
+
# Ensures only one process runs orphan detection at a time. The last_gc_at
|
|
8
|
+
# column records when GC was last successfully completed, allowing processes
|
|
9
|
+
# to skip GC attempts if another process ran GC recently.
|
|
10
|
+
class GcLock < Record
|
|
11
|
+
self.table_name = "patient_http_solid_queue_gc_locks"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PatientHttp
|
|
4
|
+
module SolidQueue
|
|
5
|
+
# Active Record model tracking inflight HTTP requests for crash recovery.
|
|
6
|
+
#
|
|
7
|
+
# Each record represents a single in-flight HTTP request. The heartbeat_at
|
|
8
|
+
# timestamp is updated periodically; stale records from dead processes are
|
|
9
|
+
# detected and re-enqueued by the GC mechanism.
|
|
10
|
+
class InflightRequest < Record
|
|
11
|
+
self.table_name = "patient_http_solid_queue_inflight_requests"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PatientHttp
|
|
4
|
+
module SolidQueue
|
|
5
|
+
# Registers lifecycle hooks with SolidQueue to start/stop the async HTTP processor.
|
|
6
|
+
class LifecycleHooks
|
|
7
|
+
@registered = false
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
def register
|
|
11
|
+
return if @registered
|
|
12
|
+
|
|
13
|
+
::SolidQueue.on_worker_start { PatientHttp::SolidQueue.start }
|
|
14
|
+
::SolidQueue.on_worker_stop { PatientHttp::SolidQueue.stop }
|
|
15
|
+
|
|
16
|
+
@registered = true
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PatientHttp
|
|
4
|
+
module SolidQueue
|
|
5
|
+
# Active Record model tracking registered async HTTP processor processes.
|
|
6
|
+
#
|
|
7
|
+
# Each record represents a running processor process. The last_seen_at
|
|
8
|
+
# timestamp is updated via heartbeats. Records for processes that are not
|
|
9
|
+
# in this table are considered orphaned during GC.
|
|
10
|
+
class ProcessRegistration < Record
|
|
11
|
+
self.table_name = "patient_http_solid_queue_processes"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PatientHttp
|
|
4
|
+
module SolidQueue
|
|
5
|
+
# Processor Observer that monitors for crashed processes in order to
|
|
6
|
+
# re-enqueue workers, and manages the TaskMonitor lifecycle.
|
|
7
|
+
class ProcessorObserver < PatientHttp::ProcessorObserver
|
|
8
|
+
attr_reader :task_monitor
|
|
9
|
+
|
|
10
|
+
def initialize(processor)
|
|
11
|
+
@processor = processor
|
|
12
|
+
@task_monitor = TaskMonitor.new(processor.config)
|
|
13
|
+
@monitor_thread = TaskMonitorThread.new(
|
|
14
|
+
processor.config,
|
|
15
|
+
@task_monitor,
|
|
16
|
+
-> { @processor.inflight_request_ids }
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def start
|
|
21
|
+
@monitor_thread.start
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def stop
|
|
25
|
+
@monitor_thread.stop
|
|
26
|
+
task_monitor.remove_process
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def request_start(request_task)
|
|
30
|
+
task_monitor.register(request_task)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def request_end(request_task)
|
|
34
|
+
task_monitor.unregister(request_task)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PatientHttp
|
|
4
|
+
module SolidQueue
|
|
5
|
+
# Helper methods for executing HTTP requests asynchronously.
|
|
6
|
+
class RequestExecutor
|
|
7
|
+
class << self
|
|
8
|
+
# Execute the request directly on the async processor.
|
|
9
|
+
#
|
|
10
|
+
# @param request [PatientHttp::Request] the HTTP request to execute
|
|
11
|
+
# @param callback [Class, String] Callback service class or its fully qualified class name
|
|
12
|
+
# @param active_job_data [Hash, nil] Active Job serialized hash with "job_class" and "arguments" keys
|
|
13
|
+
# @param synchronous [Boolean] If true, runs the request inline (for testing)
|
|
14
|
+
# @param callback_args [#to_h, nil] Arguments to pass to callback
|
|
15
|
+
# @param raise_error_responses [Boolean] If true, treats non-2xx responses as errors
|
|
16
|
+
# @param request_id [String, nil] Unique request ID for tracking
|
|
17
|
+
# @return [String] the request ID
|
|
18
|
+
# @api private
|
|
19
|
+
def execute(
|
|
20
|
+
request,
|
|
21
|
+
callback:,
|
|
22
|
+
active_job_data: nil,
|
|
23
|
+
synchronous: false,
|
|
24
|
+
callback_args: nil,
|
|
25
|
+
raise_error_responses: false,
|
|
26
|
+
request_id: nil
|
|
27
|
+
)
|
|
28
|
+
active_job_data = validate_active_job_data(active_job_data)
|
|
29
|
+
task_handler = TaskHandler.new(active_job_data)
|
|
30
|
+
config = PatientHttp::SolidQueue.configuration
|
|
31
|
+
|
|
32
|
+
task = PatientHttp::RequestTask.new(
|
|
33
|
+
request: request,
|
|
34
|
+
task_handler: task_handler,
|
|
35
|
+
callback: callback,
|
|
36
|
+
callback_args: callback_args,
|
|
37
|
+
raise_error_responses: raise_error_responses,
|
|
38
|
+
id: request_id,
|
|
39
|
+
default_max_redirects: config.max_redirects
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
if synchronous || async_disabled?
|
|
43
|
+
PatientHttp::SynchronousExecutor.new(
|
|
44
|
+
task,
|
|
45
|
+
config: config,
|
|
46
|
+
on_complete: ->(response) { PatientHttp::SolidQueue.invoke_completion_callbacks(response) },
|
|
47
|
+
on_error: ->(error) { PatientHttp::SolidQueue.invoke_error_callbacks(error) }
|
|
48
|
+
).call
|
|
49
|
+
return task.id
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
processor = PatientHttp::SolidQueue.processor
|
|
53
|
+
unless processor&.running?
|
|
54
|
+
raise PatientHttp::NotRunningError, "Cannot enqueue request: processor is not running"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
processor.enqueue(task)
|
|
58
|
+
task.id
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def validate_active_job_data(active_job_data)
|
|
64
|
+
active_job_data ||= PatientHttp::SolidQueue::Context.current_job
|
|
65
|
+
raise ArgumentError, "active_job_data is required" if active_job_data.nil?
|
|
66
|
+
raise ArgumentError, "active_job_data must be a Hash, got: #{active_job_data.class}" unless active_job_data.is_a?(Hash)
|
|
67
|
+
raise ArgumentError, "active_job_data must have 'job_class' key" unless active_job_data.key?("job_class")
|
|
68
|
+
raise ArgumentError, "active_job_data must have 'arguments' array" unless active_job_data["arguments"].is_a?(Array)
|
|
69
|
+
active_job_data
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def async_disabled?
|
|
73
|
+
PatientHttp.testing?
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PatientHttp
|
|
4
|
+
module SolidQueue
|
|
5
|
+
# Active Job that executes HTTP requests asynchronously.
|
|
6
|
+
#
|
|
7
|
+
# Enqueued when calling PatientHttp::SolidQueue.get, .post, etc.
|
|
8
|
+
# On completion, the specified callback service's on_complete or on_error is
|
|
9
|
+
# invoked via CallbackJob.
|
|
10
|
+
#
|
|
11
|
+
# @api private
|
|
12
|
+
class RequestJob < ActiveJob::Base
|
|
13
|
+
# Capture the Active Job serialized hash into Context so RequestExecutor can use it.
|
|
14
|
+
around_perform do |job, block|
|
|
15
|
+
PatientHttp::SolidQueue::Context.with_job(job.serialize) { block.call }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @param data [Hash] Request data (possibly a storage reference)
|
|
19
|
+
# @param callback_service_name [String] Fully qualified callback service class name
|
|
20
|
+
# @param raise_error_responses [Boolean, nil] Whether to treat non-2xx responses as errors
|
|
21
|
+
# @param callback_args [Hash, nil] Arguments to pass to the callback
|
|
22
|
+
# @param request_id [String, nil] Unique request ID for tracking
|
|
23
|
+
def perform(data, callback_service_name, raise_error_responses, callback_args, request_id)
|
|
24
|
+
ref_data = PatientHttp::ExternalStorage.storage_ref?(data) ? data : nil
|
|
25
|
+
actual_data = ref_data ? PatientHttp::SolidQueue.external_storage.fetch(data) : data
|
|
26
|
+
actual_data = PatientHttp::SolidQueue.decrypt(actual_data)
|
|
27
|
+
|
|
28
|
+
request = PatientHttp::Request.load(actual_data)
|
|
29
|
+
active_job_data = PatientHttp::SolidQueue::Context.current_job
|
|
30
|
+
|
|
31
|
+
begin
|
|
32
|
+
RequestExecutor.execute(
|
|
33
|
+
request,
|
|
34
|
+
callback: callback_service_name,
|
|
35
|
+
raise_error_responses: raise_error_responses,
|
|
36
|
+
callback_args: callback_args,
|
|
37
|
+
active_job_data: active_job_data,
|
|
38
|
+
request_id: request_id
|
|
39
|
+
)
|
|
40
|
+
ensure
|
|
41
|
+
PatientHttp::SolidQueue.external_storage.delete(ref_data) if ref_data
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PatientHttp
|
|
4
|
+
module SolidQueue
|
|
5
|
+
# Active Job implementation of TaskHandler.
|
|
6
|
+
#
|
|
7
|
+
# Handles task lifecycle operations using Active Job for job management:
|
|
8
|
+
# - Completion and error callbacks are triggered via CallbackJob
|
|
9
|
+
# - Large payloads are stored via ExternalStorage before enqueuing
|
|
10
|
+
# - Job retry uses ActiveJob::Base.deserialize
|
|
11
|
+
class TaskHandler < PatientHttp::TaskHandler
|
|
12
|
+
attr_reader :active_job_data
|
|
13
|
+
|
|
14
|
+
def initialize(active_job_data)
|
|
15
|
+
@active_job_data = active_job_data
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def on_complete(response, callback)
|
|
19
|
+
data = store_if_needed(response.as_json)
|
|
20
|
+
CallbackJob.perform_later(data, "response", callback)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def on_error(error, callback)
|
|
24
|
+
data = store_if_needed(error.as_json)
|
|
25
|
+
CallbackJob.perform_later(data, "error", callback)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def retry
|
|
29
|
+
ActiveJob::Base.deserialize(@active_job_data).tap { |j| j.executions = 0 }.enqueue
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def job_id
|
|
33
|
+
@active_job_data["job_id"]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def worker_class
|
|
37
|
+
PatientHttp::ClassHelper.resolve_class_name(@active_job_data["job_class"])
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def store_if_needed(data)
|
|
43
|
+
encrypted = PatientHttp::SolidQueue.encrypt(data)
|
|
44
|
+
external_storage = PatientHttp::SolidQueue.external_storage
|
|
45
|
+
if external_storage.enabled?
|
|
46
|
+
external_storage.store(encrypted, max_size: PatientHttp::SolidQueue.configuration.payload_store_threshold)
|
|
47
|
+
else
|
|
48
|
+
encrypted
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|