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.
@@ -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,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PatientHttp
4
+ module SolidQueue
5
+ # Base Active Record class for patient_http-solid_queue models.
6
+ class Record < ::SolidQueue::Record
7
+ self.abstract_class = true
8
+ end
9
+ end
10
+ 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