patient_http-solid_queue 1.1.0 → 1.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2db0a06a8b31785cab9595f85351bef93277ba420f11c21b3858e24c9fbf7c18
4
- data.tar.gz: 73facc946369816cf6e9384b14f5300420446c2bd4d93e91a2b10ad5f6620d64
3
+ metadata.gz: c5b556f667fd07c4e9dc41089d036b95fbe0c6ef9e820d5dfde5aea7f74fd76e
4
+ data.tar.gz: eb4583469ba5e2db21d19af63e5f6b601c2bff969e9c116e44087a1cea2717cb
5
5
  SHA512:
6
- metadata.gz: 86859b38cba5d753001c093f32783905944852acc249a5c9dd7b52da6940dc8c23458e75fe257767f7e07355aec2be4788d27c4cdae62a87a7eba5eb79827728
7
- data.tar.gz: 6d0444ccb2cfb9ee06e52423d84ab0aeca15bf8974a23b02a7c196ccfca99ef047ea28fddd7e864324a5428725fa7677168ca98b9f5b8ad179636250efa9876c
6
+ metadata.gz: a92d7a43aed5ed18160344e165c620dad2c2aae02184a1c4b3be41c59b3a41a5cc16c8628ff44e55c38e9fe0e4b5a24b8cde5aeb572db4792eca34de0c736eea
7
+ data.tar.gz: 77874d19a4dcbfbe0f6657b780d30a33a321eec9f6fad9cc5689a746bdfe1287bfdc28e33a64e4ca10610b19bdbcd0665dd515f8c264be440da21f51790f4d62
data/CHANGELOG.md CHANGED
@@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## 1.2.0
8
+
9
+ ### Added
10
+
11
+ - Named processors: declare profiles with `config.processor(:llm, max_connections: 200)` and route requests with `PatientHttp::SolidQueue.execute(request, processor: :llm)` or a `processor:` option on the request itself. Each profile runs as an independent processor with its own capacity, timeouts, and threads, so one workload class cannot starve another. The processor name is serialized into the job arguments, so retries and crash recovery keep their routing. A job that names an unconfigured processor raises `PatientHttp::UnknownProcessorError` and is retried, which makes new profile names safe to roll out gradually. Jobs from older gem versions run on the `:default` processor.
12
+ - Capacity fast path: requests are rejected with a cheap in-memory capacity check (`Processor#capacity_available?`) before the durable registry record is written, so a full processor rejects without a database insert and delete.
13
+ - The `completion_failed` processor event is handled by keeping the crash-recovery registry entry and releasing it from this process, so a request whose result could not be delivered is re-enqueued by the orphan collector on its next pass instead of being silently lost.
14
+ - A failure to write the crash-recovery registry entry raises `PatientHttp::SolidQueue::RegistrationError`, which `RequestJob` retries with backoff so a transient database issue does not fail the request outright.
15
+
16
+ ### Changed
17
+
18
+ - Requests are now registered in the crash-recovery registry when the processor accepts them (`request_enqueued`, on the job worker thread), instead of when they start processing (`request_start`, on the reactor thread). The registry insert no longer blocks the event loop, queued requests are covered by heartbeats, and a failure to write the record rejects the request and raises to the caller so a task is never accepted without a durable record. Rejected and re-enqueued tasks now remove their registry entries, closing a duplicate-delivery window after process restarts.
19
+ - With patient_http 1.5.0, result delivery (response decoding, callback job enqueueing, registry cleanup) runs on the processor's completion worker threads instead of the reactor thread. Size the database pool to cover `completion_threads` plus the monitor thread.
20
+ - TaskMonitor operations check out a database connection only for the duration of each operation, so gem-owned threads do not pin connections from the pool.
21
+ - The task monitor thread is now owned by the module and shared across all processors; `ProcessorObserver.new` takes a `task_monitor:` keyword argument.
22
+ - The `patient_http` dependency floor is now 1.5.0.
23
+
7
24
  ## 1.1.0
8
25
 
9
26
  ### Changed
data/README.md CHANGED
@@ -230,6 +230,34 @@ PatientHttp.execute(request: request, callback: MyCallback, callback_args: {user
230
230
 
231
231
  See the [patient_http docs](https://github.com/bdurand/patient_http) for the full `Request` and `Response` API reference.
232
232
 
233
+ ### Named Processors
234
+
235
+ By default all requests share one processor and one `max_connections` cap. When one process serves workload classes with very different profiles (for example, large slow API calls and small fast webhook deliveries), a burst of one class can consume all of the capacity the other class needs. Named processor profiles isolate them:
236
+
237
+ ```ruby
238
+ PatientHttp::SolidQueue.configure do |config|
239
+ config.processor(:llm, max_connections: 200, request_timeout: 120)
240
+ config.processor(:webhooks, max_connections: 64, request_timeout: 10)
241
+ end
242
+ ```
243
+
244
+ Each profile runs as an independent processor in the process, with its own capacity, timeouts, and threads. Profile options override the top-level configuration; anything not overridden (secrets, preprocessors, payload stores, encryption, logger) is shared. The `:default` processor always exists; declare `config.processor(:default, ...)` to override its options.
245
+
246
+ Route a request to a processor in any of these ways:
247
+
248
+ ```ruby
249
+ # Explicit option on execute
250
+ PatientHttp::SolidQueue.execute(request, callback: MyCallback, processor: :llm)
251
+
252
+ # On the request itself (survives serialization, retries, and crash recovery)
253
+ request = PatientHttp::Request.new(:get, url, processor: :llm)
254
+
255
+ # Through a request template
256
+ template = PatientHttp::RequestTemplate.new(base_url: url, processor: :llm)
257
+ ```
258
+
259
+ The processor name is serialized into the job arguments, so Active Job retries and crash recovery keep their routing. A job that names a processor that is not configured in the executing process raises `PatientHttp::UnknownProcessorError` and is retried with backoff; this makes new profile names safe to roll out gradually. Jobs enqueued by older gem versions run on the `:default` processor.
260
+
233
261
  ### Using Request Templates
234
262
 
235
263
  For repeated requests to the same API, use `PatientHttp::RequestTemplate` to share configuration:
@@ -419,6 +447,16 @@ PatientHttp::SolidQueue.configure do |config|
419
447
  # Queue name for RequestJob and CallbackJob (default: nil, Active Job default)
420
448
  config.queue_name = "async_http"
421
449
 
450
+ # Number of threads that decode responses and deliver results (default: 2)
451
+ config.completion_threads = 2
452
+
453
+ # Maximum connections per host (default: nil, unlimited)
454
+ config.max_connections_per_host = 32
455
+
456
+ # Named processor profiles for workload isolation (see Named Processors)
457
+ config.processor(:llm, max_connections: 200, request_timeout: 120)
458
+ config.processor(:webhooks, max_connections: 64, request_timeout: 10)
459
+
422
460
  # Custom logger (defaults to SolidQueue.logger)
423
461
  config.logger = Rails.logger
424
462
 
@@ -440,6 +478,9 @@ See the [Configuration](lib/patient_http/solid_queue/configuration.rb) class for
440
478
  - `retries`: Number of times to retry a failed request before calling the error callback.
441
479
  - `max_response_size`: Set this to limit the maximum size of HTTP responses. This helps prevent excessive memory usage from unexpectedly large responses. Responses need to be serialized as Active Job arguments and very large responses may cause performance issues. If a response body is text content, it will be compressed to save space. However, binary content needs to be Base64 encoded which increases size by ~33%.
442
480
  - `payload_store_threshold`: Lower this if your queue backend struggles with large payloads; higher values avoid extra external storage reads/writes.
481
+ - `max_connections_per_host`: Bounds sockets per host. Verify the process file descriptor limit covers `max_connections` plus pooled idle host connections plus the application's own connections; raise the limit if needed.
482
+ - `completion_threads`: Number of threads that decode responses and deliver results (default 2). Increase when result callbacks do heavier work and completions back up behind them. Size the Active Record connection pool to cover these threads plus the task monitor thread in addition to the worker threads.
483
+ - `shutdown_timeout`: Must be below the process supervisor's termination window so the drain finishes before a hard kill. The default derives it from Solid Queue's own shutdown timeout; check any additional supervisor stop timeout as well.
443
484
  - `heartbeat_interval` and `orphan_threshold`: For high-churn workloads, keep `heartbeat_interval` as large as your recovery SLO allows (while still less than `orphan_threshold`) to reduce write/update pressure on monitoring tables. If Solid Queue uses PostgreSQL and request volume is high, tune autovacuum for the queue database tables because `inflight_requests` is intentionally insert/update/delete heavy.
444
485
 
445
486
  > [!IMPORTANT]
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.1.0
1
+ 1.2.0
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "delegate"
4
+
3
5
  module PatientHttp
4
6
  module SolidQueue
5
7
  # Configuration for the Solid Queue Async HTTP gem.
@@ -64,6 +66,7 @@ module PatientHttp
64
66
 
65
67
  super(**pool_options)
66
68
 
69
+ @processor_profiles = {default: {}}
67
70
  self.queue_name = queue_name
68
71
  self.heartbeat_interval = heartbeat_interval
69
72
  self.orphan_threshold = orphan_threshold
@@ -71,6 +74,64 @@ module PatientHttp
71
74
  self.on_retries_exhausted = on_retries_exhausted
72
75
  end
73
76
 
77
+ # Declare a named processor profile.
78
+ #
79
+ # Each profile becomes an independent processor with its own capacity,
80
+ # timeouts, and threads. Options are overrides applied on top of this
81
+ # configuration's HTTP pool options. Calling this with no options
82
+ # declares a profile that inherits every option. Requests select a
83
+ # processor with the +processor:+ option on
84
+ # +PatientHttp::SolidQueue.execute+ (or on the request itself). The
85
+ # +:default+ profile always exists; declaring it overrides options for
86
+ # the default processor.
87
+ #
88
+ # @example
89
+ # PatientHttp::SolidQueue.configure do |config|
90
+ # config.processor(:llm, max_connections: 200, request_timeout: 120)
91
+ # config.processor(:webhooks, max_connections: 64, request_timeout: 10)
92
+ # end
93
+ #
94
+ # @param name [Symbol, String] the processor name
95
+ # @param options [Hash] overrides for PatientHttp::Configuration options
96
+ # @return [Hash] the stored options for the profile
97
+ def processor(name, **options)
98
+ key = normalize_processor_name(name)
99
+ @processor_profiles[key] = normalize_profile_options!(options)
100
+ end
101
+
102
+ # Read back the options declared for a named processor profile.
103
+ #
104
+ # @param name [Symbol, String] the processor name
105
+ # @return [Hash, nil] the stored options, or nil if the profile is not declared
106
+ def processor_options(name)
107
+ @processor_profiles[normalize_processor_name(name)]
108
+ end
109
+
110
+ # All declared processor profiles. Always includes :default.
111
+ #
112
+ # @return [Hash{Symbol => Hash}] profile options by processor name
113
+ def processor_profiles
114
+ @processor_profiles.dup
115
+ end
116
+
117
+ # Build the effective configuration for a named processor. The default
118
+ # profile with no overrides is this configuration itself; other profiles
119
+ # get a view of this configuration with their overrides applied, so they
120
+ # share secrets, preprocessors, payload stores, and encryption.
121
+ #
122
+ # @param name [Symbol, String] the processor name
123
+ # @return [PatientHttp::Configuration] the configuration for the processor
124
+ # @raise [ArgumentError] if the profile is not declared
125
+ def processor_config(name)
126
+ key = normalize_processor_name(name)
127
+ profile = @processor_profiles[key]
128
+ raise ArgumentError.new("Unknown processor profile: #{name.inspect}") unless profile
129
+
130
+ return self if profile.empty?
131
+
132
+ ProfileConfiguration.new(self, profile)
133
+ end
134
+
74
135
  def payload_store_threshold=(value)
75
136
  validate_positive_integer(:payload_store_threshold, value)
76
137
  @payload_store_threshold = value
@@ -121,10 +182,24 @@ module PatientHttp
121
182
  "heartbeat_interval" => heartbeat_interval,
122
183
  "orphan_threshold" => orphan_threshold,
123
184
  "queue_name" => queue_name,
124
- "on_retries_exhausted" => on_retries_exhausted ? "defined" : nil
185
+ "on_retries_exhausted" => on_retries_exhausted ? "defined" : nil,
186
+ "processor_profiles" => processor_profiles.keys.map(&:to_s)
125
187
  )
126
188
  end
127
189
 
190
+ # View of a base configuration with a profile's option overrides applied.
191
+ # Everything not overridden (secrets, preprocessors, payload stores,
192
+ # encryption, logging) delegates to the base configuration, so all
193
+ # processors share those registries.
194
+ class ProfileConfiguration < SimpleDelegator
195
+ def initialize(base_configuration, overrides)
196
+ super(base_configuration)
197
+ overrides.each do |key, value|
198
+ define_singleton_method(key) { value }
199
+ end
200
+ end
201
+ end
202
+
128
203
  private
129
204
 
130
205
  def apply_queue_name(name)
@@ -132,6 +207,28 @@ module PatientHttp
132
207
  PatientHttp::SolidQueue::CallbackJob.queue_as(name)
133
208
  end
134
209
 
210
+ # Profile options must be valid PatientHttp::Configuration options. A
211
+ # throwaway configuration exercises each option's own validation and
212
+ # normalization, so the stored value is what the writer would have
213
+ # produced rather than the raw input.
214
+ def normalize_profile_options!(options)
215
+ return options if options.empty?
216
+
217
+ probe = PatientHttp::Configuration.new(**options)
218
+ options.to_h do |key, value|
219
+ [key, probe.respond_to?(key) ? probe.public_send(key) : value]
220
+ end
221
+ rescue ArgumentError => e
222
+ raise ArgumentError.new("Invalid processor profile options: #{e.message}")
223
+ end
224
+
225
+ def normalize_processor_name(name)
226
+ key = name.to_s
227
+ raise ArgumentError.new("processor name cannot be empty") if key.empty?
228
+
229
+ key.to_sym
230
+ end
231
+
135
232
  def validate_heartbeat_and_threshold
136
233
  return unless @heartbeat_interval && @orphan_threshold
137
234
  return unless @heartbeat_interval >= @orphan_threshold
@@ -2,37 +2,68 @@
2
2
 
3
3
  module PatientHttp
4
4
  module SolidQueue
5
- # Processor Observer that monitors for crashed processes in order to
6
- # re-enqueue workers, and manages the TaskMonitor lifecycle.
5
+ # Processor observer that maintains the crash-recovery registry for one
6
+ # processor. The task monitor is shared across all processors in the
7
+ # process; the module owns it and the monitor thread.
8
+ #
9
+ # Tasks are registered in the crash-recovery registry when the processor
10
+ # accepts them, before Processor#enqueue returns, so a request always has
11
+ # a durable record from the moment the caller hands it off. Registration
12
+ # runs on the caller's thread (a job worker thread), not the reactor
13
+ # thread. The entry is removed when the request completes or when an
14
+ # Active Job owns the request again (the task was rejected or
15
+ # re-enqueued). When result delivery fails (completion_failed), the entry
16
+ # is deliberately kept so the orphan collector re-enqueues the request
17
+ # instead of losing it.
7
18
  class ProcessorObserver < PatientHttp::ProcessorObserver
8
19
  attr_reader :task_monitor
9
20
 
10
- def initialize(processor)
21
+ def initialize(processor, task_monitor:)
11
22
  @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
- )
23
+ @task_monitor = task_monitor
24
+ @requeued_task_ids = Set.new
25
+ @requeued_mutex = Mutex.new
18
26
  end
19
27
 
20
- def start
21
- @monitor_thread.start
28
+ def request_enqueued(request_task)
29
+ task_monitor.register(request_task)
22
30
  end
23
31
 
24
- def stop
25
- @monitor_thread.stop
26
- task_monitor.remove_process
32
+ def request_rejected(request_task)
33
+ task_monitor.unregister(request_task)
27
34
  end
28
35
 
29
- def request_start(request_task)
30
- task_monitor.register(request_task)
36
+ def request_requeued(request_task)
37
+ task_monitor.unregister(request_task)
38
+ # The re-enqueue path fires request_end after request_requeued, but
39
+ # only for tasks that already started. Remember those tasks so that
40
+ # request_end does not unregister a second time. A task that never
41
+ # started gets no request_end, so remembering it would leak the id
42
+ # forever.
43
+ return unless request_task.started?
44
+
45
+ @requeued_mutex.synchronize { @requeued_task_ids << request_task.id }
31
46
  end
32
47
 
33
48
  def request_end(request_task)
49
+ requeued = @requeued_mutex.synchronize { @requeued_task_ids.delete?(request_task.id) }
50
+ return if requeued
51
+
34
52
  task_monitor.unregister(request_task)
35
53
  end
54
+
55
+ def completion_failed(request_task, error)
56
+ # Keep the crash-recovery registry entry, but hand it off to the orphan
57
+ # collector. Orphan collection ignores records that belong to a live
58
+ # process, so the entry has to be released for the request to be
59
+ # re-enqueued on the next pass rather than on the next process restart.
60
+ task_monitor.release(request_task)
61
+
62
+ PatientHttp::SolidQueue.configuration.logger&.error(
63
+ "[PatientHttp::SolidQueue] Result delivery failed for request #{request_task.id}; " \
64
+ "leaving crash-recovery record for re-enqueue: #{error.class} - #{error.message}"
65
+ )
66
+ end
36
67
  end
37
68
  end
38
69
  end
@@ -14,6 +14,8 @@ module PatientHttp
14
14
  # @param callback_args [#to_h, nil] Arguments to pass to callback
15
15
  # @param raise_error_responses [Boolean] If true, treats non-2xx responses as errors
16
16
  # @param request_id [String, nil] Unique request ID for tracking
17
+ # @param processor_name [Symbol, String, nil] Name of the processor profile to run
18
+ # the request on. Defaults to the request's own processor name or :default.
17
19
  # @return [String] the request ID
18
20
  # @api private
19
21
  def execute(
@@ -23,12 +25,19 @@ module PatientHttp
23
25
  synchronous: false,
24
26
  callback_args: nil,
25
27
  raise_error_responses: false,
26
- request_id: nil
28
+ request_id: nil,
29
+ processor_name: nil
27
30
  )
28
31
  active_job_data = validate_active_job_data(active_job_data)
29
32
  task_handler = TaskHandler.new(active_job_data)
30
33
  config = PatientHttp::SolidQueue.configuration
31
34
 
35
+ # Resolve the processor profile up front so the task is built with
36
+ # the options of the processor that will run it. An unknown name
37
+ # falls back to the base configuration here and is reported below.
38
+ name = (processor_name || request.processor || :default).to_sym
39
+ profile_config = config.processor_profiles.key?(name) ? config.processor_config(name) : config
40
+
32
41
  task = PatientHttp::RequestTask.new(
33
42
  request: request,
34
43
  task_handler: task_handler,
@@ -36,24 +45,43 @@ module PatientHttp
36
45
  callback_args: callback_args,
37
46
  raise_error_responses: raise_error_responses,
38
47
  id: request_id,
39
- default_max_redirects: config.max_redirects
48
+ default_max_redirects: profile_config.max_redirects
40
49
  )
41
50
 
42
51
  if synchronous || async_disabled?
43
52
  PatientHttp::SynchronousExecutor.new(
44
53
  task,
45
- config: config,
54
+ config: profile_config,
46
55
  on_complete: ->(response) { PatientHttp::SolidQueue.invoke_completion_callbacks(response) },
47
56
  on_error: ->(error) { PatientHttp::SolidQueue.invoke_error_callbacks(error) }
48
57
  ).call
49
58
  return task.id
50
59
  end
51
60
 
52
- processor = PatientHttp::SolidQueue.processor
61
+ # Look up the named processor. An unknown name raises so the job
62
+ # lands in Active Job's retry mechanism instead of being dropped;
63
+ # this covers rolling deploys where an old process has not
64
+ # configured a new profile yet.
65
+ processor = PatientHttp::SolidQueue.processor(name)
66
+ if processor.nil? && !config.processor_profiles.key?(name)
67
+ raise PatientHttp::UnknownProcessorError, "No processor profile configured for #{name.inspect}"
68
+ end
69
+
53
70
  unless processor&.running?
54
71
  raise PatientHttp::NotRunningError, "Cannot enqueue request: processor is not running"
55
72
  end
56
73
 
74
+ # Advisory capacity check before enqueueing. A real enqueue writes
75
+ # the durable registry record before the authoritative capacity
76
+ # check, so a full processor would pay a database insert and delete
77
+ # just to be rejected. This peek rejects for free; the race where
78
+ # capacity fills after the peek falls through to the normal
79
+ # rejection path.
80
+ unless processor.capacity_available?
81
+ raise PatientHttp::MaxCapacityError,
82
+ "Cannot enqueue request: processor #{name} is at max capacity (#{processor.config.max_connections} connections)"
83
+ end
84
+
57
85
  processor.enqueue(task)
58
86
  task.id
59
87
  end
@@ -13,7 +13,14 @@ module PatientHttp
13
13
  # Rejection due to backpressure is part of normal operation: retry until the
14
14
  # processor has capacity again. NotRunningError covers jobs that run during
15
15
  # the narrow window when the processor is draining or stopping.
16
- retry_on PatientHttp::MaxCapacityError, PatientHttp::NotRunningError, wait: :polynomially_longer, attempts: :unlimited
16
+ # UnknownProcessorError covers rolling deploys where a job names a processor
17
+ # profile that an old process has not configured yet.
18
+ retry_on PatientHttp::MaxCapacityError, PatientHttp::NotRunningError,
19
+ PatientHttp::UnknownProcessorError, wait: :polynomially_longer, attempts: :unlimited
20
+
21
+ # A registry write failure is usually a transient database issue, so give
22
+ # it a bounded number of retries before the job is marked failed.
23
+ retry_on PatientHttp::SolidQueue::RegistrationError, wait: :polynomially_longer, attempts: 10
17
24
 
18
25
  # Capture the Active Job serialized hash into Context so RequestExecutor can use it.
19
26
  around_perform do |job, block|
@@ -36,7 +43,9 @@ module PatientHttp
36
43
  # @param raise_error_responses [Boolean, nil] Whether to treat non-2xx responses as errors
37
44
  # @param callback_args [Hash, nil] Arguments to pass to the callback
38
45
  # @param request_id [String, nil] Unique request ID for tracking
39
- def perform(data, callback_service_name, raise_error_responses, callback_args, request_id)
46
+ # @param processor_name [String, nil] Name of the processor profile to run the request
47
+ # on; nil (jobs enqueued by older versions) runs on the default processor
48
+ def perform(data, callback_service_name, raise_error_responses, callback_args, request_id, processor_name = nil)
40
49
  actual_data = PatientHttp::ExternalStorage.storage_ref?(data) ? PatientHttp::SolidQueue.external_storage.fetch(data) : data
41
50
  actual_data = PatientHttp::SolidQueue.decrypt(actual_data)
42
51
 
@@ -53,7 +62,8 @@ module PatientHttp
53
62
  raise_error_responses: raise_error_responses,
54
63
  callback_args: callback_args,
55
64
  active_job_data: active_job_data,
56
- request_id: request_id
65
+ request_id: request_id,
66
+ processor_name: processor_name || "default"
57
67
  )
58
68
  end
59
69
  end
@@ -19,8 +19,13 @@ module PatientHttp
19
19
  # @return [Configuration] the configuration object
20
20
  attr_reader :config
21
21
 
22
- def initialize(config)
22
+ # @param config [Configuration] the configuration object
23
+ # @param max_connections [#call, nil] callable returning the process's total
24
+ # configured max connections; defaults to the configuration's value. With
25
+ # named processors the module passes a sum across all processors.
26
+ def initialize(config, max_connections: nil)
23
27
  @config = config
28
+ @max_connections_source = max_connections || -> { config.max_connections }
24
29
  hostname = ::Socket.gethostname.force_encoding("UTF-8").tr(":/", "-")
25
30
  pid = ::Process.pid
26
31
  @lock_identifier = "#{hostname}:#{pid}:#{SecureRandom.hex(8)}".freeze
@@ -28,23 +33,32 @@ module PatientHttp
28
33
 
29
34
  # Register a request as inflight in the database.
30
35
  #
36
+ # Runs on the caller's thread via the request_enqueued observer event.
37
+ # Errors propagate so a task is never accepted without a durable record:
38
+ # the processor rejects the task and the enqueue raises to the caller.
39
+ # They are wrapped in RegistrationError so the job retries instead of
40
+ # failing outright, since the usual cause is a transient database issue.
41
+ #
31
42
  # @param task [PatientHttp::RequestTask] the request task to register
43
+ # @raise [RegistrationError] if the record cannot be written
32
44
  # @return [void]
33
45
  def register(task)
34
46
  job_payload = task.task_handler.active_job_data.to_json
35
47
  task_id = full_task_id(task.id)
36
48
  now = Time.current
37
49
 
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
- )
50
+ with_connection do
51
+ InflightRequest.create!(
52
+ task_id: task_id,
53
+ process_id: @lock_identifier,
54
+ job_payload: job_payload,
55
+ heartbeat_at: now,
56
+ created_at: now
57
+ )
58
+ end
45
59
  rescue => e
46
- @config.logger&.error("[PatientHttp::SolidQueue] Failed to register task #{task_id}: #{e.message}")
47
- raise if PatientHttp.testing?
60
+ @config.logger&.error("[PatientHttp::SolidQueue] Failed to register task #{task_id}: #{e.class} - #{e.message}")
61
+ raise RegistrationError.new("Failed to register task #{task_id}: #{e.class} - #{e.message}")
48
62
  end
49
63
 
50
64
  # Unregister a request from the database (called when request completes).
@@ -53,12 +67,36 @@ module PatientHttp
53
67
  # @return [void]
54
68
  def unregister(task)
55
69
  task_id = full_task_id(task.id)
56
- InflightRequest.where(task_id: task_id).delete_all
70
+ with_connection do
71
+ InflightRequest.where(task_id: task_id).delete_all
72
+ end
57
73
  rescue => e
58
74
  @config.logger&.error("[PatientHttp::SolidQueue] Failed to unregister task #{task_id}: #{e.message}")
59
75
  raise if PatientHttp.testing?
60
76
  end
61
77
 
78
+ # Release a request from this process so the orphan collector re-enqueues
79
+ # it on its next pass. Used when a result could not be delivered: the
80
+ # request is no longer tracked here, so its record must not keep looking
81
+ # like it belongs to a live process. Orphan collection skips records
82
+ # whose process is still registered, which would otherwise strand the
83
+ # request until this process exits.
84
+ #
85
+ # @param task [PatientHttp::RequestTask] the request task to release
86
+ # @return [void]
87
+ def release(task)
88
+ task_id = full_task_id(task.id)
89
+ with_connection do
90
+ InflightRequest.where(task_id: task_id).update_all(
91
+ process_id: released_process_id,
92
+ heartbeat_at: Time.at(0).utc
93
+ )
94
+ end
95
+ rescue => e
96
+ @config.logger&.error("[PatientHttp::SolidQueue] Failed to release task #{task_id}: #{e.message}")
97
+ raise if PatientHttp.testing?
98
+ end
99
+
62
100
  # Update heartbeat timestamps for multiple requests in a single operation.
63
101
  #
64
102
  # @param task_ids [Array<String>] the request IDs to update
@@ -67,7 +105,9 @@ module PatientHttp
67
105
  return if task_ids.empty?
68
106
 
69
107
  full_ids = task_ids.map { |id| full_task_id(id) }
70
- InflightRequest.where(task_id: full_ids).update_all(heartbeat_at: Time.current)
108
+ with_connection do
109
+ InflightRequest.where(task_id: full_ids).update_all(heartbeat_at: Time.current)
110
+ end
71
111
  rescue => e
72
112
  @config.logger&.error("[PatientHttp::SolidQueue] Failed to update heartbeats: #{e.message}")
73
113
  raise if PatientHttp.testing?
@@ -77,10 +117,14 @@ module PatientHttp
77
117
  #
78
118
  # @return [void]
79
119
  def ping_process
80
- ProcessRegistration.upsert(
81
- {process_id: @lock_identifier, max_connections: @config.max_connections, last_seen_at: Time.current},
82
- unique_by: upsert_unique_by(:process_id)
83
- )
120
+ max_connections = @max_connections_source.call
121
+
122
+ with_connection do
123
+ ProcessRegistration.upsert(
124
+ {process_id: @lock_identifier, max_connections: max_connections, last_seen_at: Time.current},
125
+ unique_by: upsert_unique_by(:process_id)
126
+ )
127
+ end
84
128
  rescue => e
85
129
  @config.logger&.error("[PatientHttp::SolidQueue] Failed to ping process: #{e.message}")
86
130
  raise if PatientHttp.testing?
@@ -90,7 +134,9 @@ module PatientHttp
90
134
  #
91
135
  # @return [void]
92
136
  def remove_process
93
- ProcessRegistration.where(process_id: @lock_identifier).delete_all
137
+ with_connection do
138
+ ProcessRegistration.where(process_id: @lock_identifier).delete_all
139
+ end
94
140
  rescue => e
95
141
  @config.logger&.error("[PatientHttp::SolidQueue] Failed to remove process: #{e.message}")
96
142
  raise if PatientHttp.testing?
@@ -190,7 +236,9 @@ module PatientHttp
190
236
  # @return [Boolean]
191
237
  # @api private
192
238
  def registered?(task)
193
- InflightRequest.where(task_id: full_task_id(task.id)).exists?
239
+ with_connection do
240
+ InflightRequest.where(task_id: full_task_id(task.id)).exists?
241
+ end
194
242
  end
195
243
 
196
244
  # Clear all records. Only allowed in test environment.
@@ -210,6 +258,19 @@ module PatientHttp
210
258
 
211
259
  private
212
260
 
261
+ # Check out a database connection only for the duration of the work so
262
+ # gem-owned threads (completion workers, the monitor thread) do not pin
263
+ # connections from the pool between operations.
264
+ def with_connection(&block)
265
+ Record.connection_pool.with_connection(&block)
266
+ end
267
+
268
+ # Process identifier stamped on released records. It is never registered
269
+ # in the process table, so orphan collection always considers it dead.
270
+ def released_process_id
271
+ "#{@lock_identifier}:released"
272
+ end
273
+
213
274
  def ensure_gc_lock_row!
214
275
  GcLock.insert_all([{lock_name: GC_LOCK_NAME}])
215
276
  end
@@ -20,11 +20,12 @@ module PatientHttp
20
20
  #
21
21
  # @param config [Configuration] the configuration object
22
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)
23
+ # @param tracked_ids_callback [Proc] callback to get the IDs of all requests the
24
+ # processors are tracking (queued, pending, and in-flight)
25
+ def initialize(config, task_monitor, tracked_ids_callback)
25
26
  @config = config
26
27
  @task_monitor = task_monitor
27
- @inflight_ids_callback = inflight_ids_callback
28
+ @tracked_ids_callback = tracked_ids_callback
28
29
  @thread = nil
29
30
  @running = Concurrent::AtomicBoolean.new(false)
30
31
  @stop_signal = Concurrent::Event.new
@@ -112,12 +113,12 @@ module PatientHttp
112
113
  end
113
114
 
114
115
  def update_heartbeats
115
- request_ids = @inflight_ids_callback.call
116
+ request_ids = @tracked_ids_callback.call
116
117
  return if request_ids.empty?
117
118
 
118
119
  @task_monitor.update_heartbeats(request_ids)
119
120
 
120
- @config.logger&.debug("[PatientHttp::SolidQueue] Updated heartbeats for #{request_ids.size} inflight requests")
121
+ @config.logger&.debug("[PatientHttp::SolidQueue] Updated heartbeats for #{request_ids.size} tracked requests")
121
122
  rescue => e
122
123
  @config.logger&.error("[PatientHttp::SolidQueue] Failed to update heartbeats: #{e.class} - #{e.message}")
123
124
  raise if PatientHttp.testing?
@@ -34,6 +34,11 @@ module PatientHttp
34
34
  module SolidQueue
35
35
  VERSION = File.read(File.join(__dir__, "../../VERSION")).strip
36
36
 
37
+ # Raised when the crash-recovery registry entry for a request cannot be
38
+ # written. The request is rejected rather than accepted without a durable
39
+ # record, and the job retries.
40
+ class RegistrationError < StandardError; end
41
+
37
42
  autoload :CallbackJob, File.join(__dir__, "solid_queue/callback_job")
38
43
  autoload :Configuration, File.join(__dir__, "solid_queue/configuration")
39
44
  autoload :Context, File.join(__dir__, "solid_queue/context")
@@ -49,13 +54,15 @@ module PatientHttp
49
54
  autoload :TaskMonitor, File.join(__dir__, "solid_queue/task_monitor")
50
55
  autoload :TaskMonitorThread, File.join(__dir__, "solid_queue/task_monitor_thread")
51
56
 
52
- @processor = nil
57
+ @processors = {}
53
58
  @configuration = nil
54
59
  @after_completion_callbacks = []
55
60
  @after_error_callbacks = []
56
61
  @external_storage = nil
57
62
  @request_handler = nil
58
63
  @lifecycle_mutex = Mutex.new
64
+ @task_monitor = nil
65
+ @monitor_thread = nil
59
66
 
60
67
  class << self
61
68
  attr_writer :configuration
@@ -109,32 +116,32 @@ module PatientHttp
109
116
  @after_error_callbacks << block
110
117
  end
111
118
 
112
- # Check if the processor is running.
119
+ # Check if any processor is running.
113
120
  #
114
121
  # @return [Boolean]
115
122
  def running?
116
- !!@processor&.running?
123
+ @processors.values.any?(&:running?)
117
124
  end
118
125
 
119
- # Check if the processor is draining (not accepting new requests).
126
+ # Check if any processor is draining (not accepting new requests).
120
127
  #
121
128
  # @return [Boolean]
122
129
  def draining?
123
- !!@processor&.draining?
130
+ @processors.values.any?(&:draining?)
124
131
  end
125
132
 
126
- # Check if the processor is stopping.
133
+ # Check if any processor is stopping.
127
134
  #
128
135
  # @return [Boolean]
129
136
  def stopping?
130
- !!@processor&.stopping?
137
+ @processors.values.any?(&:stopping?)
131
138
  end
132
139
 
133
- # Check if the processor is stopped.
140
+ # Check if all processors are stopped or none have been started.
134
141
  #
135
142
  # @return [Boolean]
136
143
  def stopped?
137
- @processor.nil? || @processor.stopped?
144
+ @processors.values.all?(&:stopped?)
138
145
  end
139
146
 
140
147
  # Get an ExternalStorage instance for storing and fetching payloads.
@@ -152,12 +159,23 @@ module PatientHttp
152
159
  # instance methods, or its fully qualified class name.
153
160
  # @param callback_args [#to_h, nil] Arguments to pass to callback
154
161
  # @param raise_error_responses [Boolean] If true, treats non-2xx responses as errors
162
+ # @param processor [Symbol, String, nil] Name of the processor profile that should
163
+ # execute the request. Defaults to the request's own processor name or :default.
155
164
  # @return [String] the request ID
156
- def execute(request, callback:, callback_args: nil, raise_error_responses: false)
165
+ # @raise [PatientHttp::UnknownProcessorError] if the processor profile is not configured
166
+ def execute(request, callback:, callback_args: nil, raise_error_responses: false, processor: nil)
157
167
  PatientHttp::CallbackValidator.validate!(callback)
158
168
  callback_name = callback.is_a?(Class) ? callback.name : callback.to_s
159
169
  callback_args = PatientHttp::CallbackValidator.validate_callback_args(callback_args)
160
170
  request_id = SecureRandom.uuid
171
+ processor_name = (processor || request.processor || :default).to_s
172
+
173
+ # Catch a misspelled profile name at the call site. A job that names an
174
+ # unconfigured profile is retried instead, which covers rolling deploys
175
+ # where the executing process is older than the enqueueing one.
176
+ unless configuration.processor_profiles.key?(processor_name.to_sym)
177
+ raise PatientHttp::UnknownProcessorError.new("No processor profile configured for #{processor_name.inspect}")
178
+ end
161
179
 
162
180
  encrypted = encrypt(request.as_json)
163
181
 
@@ -167,38 +185,59 @@ module PatientHttp
167
185
  encrypted
168
186
  end
169
187
 
170
- RequestJob.perform_later(data, callback_name, raise_error_responses, callback_args, request_id)
188
+ RequestJob.perform_later(data, callback_name, raise_error_responses, callback_args, request_id, processor_name)
171
189
 
172
190
  request_id
173
191
  end
174
192
 
175
- # Start the processor.
193
+ # Start a processor for each configured processor profile, along with
194
+ # the shared crash-recovery monitor.
176
195
  #
177
196
  # @return [void]
178
197
  def start
179
198
  @lifecycle_mutex.synchronize do
180
- return if @processor && !@processor.stopped?
199
+ return if @processors.any? && !@processors.values.all?(&:stopped?)
181
200
 
182
- @processor = PatientHttp::Processor.new(configuration)
183
- @processor.observe(ProcessorObserver.new(@processor))
184
- @processor.start
201
+ @task_monitor ||= TaskMonitor.new(
202
+ configuration,
203
+ max_connections: -> { @processors.values.sum { |p| p.config.max_connections } }
204
+ )
205
+
206
+ @processors = {}
207
+ configuration.processor_profiles.each_key do |name|
208
+ processor = PatientHttp::Processor.new(configuration.processor_config(name), name: name)
209
+ processor.observe(ProcessorObserver.new(processor, task_monitor: @task_monitor))
210
+ @processors[name] = processor
211
+ end
212
+ @processors.each_value(&:start)
213
+
214
+ # A previous run can leave a monitor thread behind if the processors
215
+ # stopped without going through #stop.
216
+ @monitor_thread&.stop
217
+
218
+ @monitor_thread = TaskMonitorThread.new(
219
+ configuration,
220
+ @task_monitor,
221
+ -> { @processors.values.flat_map(&:tracked_request_ids) }
222
+ )
223
+ @monitor_thread.start
185
224
  end
186
225
 
187
226
  register_handler
188
227
  end
189
228
 
190
- # Signal the processor to drain (stop accepting new requests).
229
+ # Signal all processors to drain (stop accepting new requests).
191
230
  #
192
231
  # @return [void]
193
232
  def quiet
194
233
  @lifecycle_mutex.synchronize do
195
234
  return unless running?
196
235
 
197
- @processor.drain
236
+ @processors.each_value(&:drain)
198
237
  end
199
238
  end
200
239
 
201
- # Stop the processor gracefully.
240
+ # Stop all processors gracefully.
202
241
  #
203
242
  # @param timeout [Float, nil] maximum time to wait for in-flight requests to complete
204
243
  # @return [void]
@@ -208,10 +247,11 @@ module PatientHttp
208
247
  end
209
248
 
210
249
  @lifecycle_mutex.synchronize do
211
- return unless @processor
250
+ return if @processors.empty?
212
251
 
213
- @processor.stop(timeout: timeout)
214
- @processor = nil
252
+ stop_processors(timeout: timeout)
253
+ @processors = {}
254
+ shutdown_shared_services
215
255
  end
216
256
  end
217
257
 
@@ -224,8 +264,9 @@ module PatientHttp
224
264
  PatientHttp.unregister_handler(@request_handler)
225
265
  end
226
266
  @lifecycle_mutex.synchronize do
227
- @processor&.stop(timeout: 0)
228
- @processor = nil
267
+ stop_processors(timeout: 0)
268
+ @processors = {}
269
+ shutdown_shared_services
229
270
  end
230
271
  @configuration = nil
231
272
  @external_storage = nil
@@ -292,11 +333,55 @@ module PatientHttp
292
333
  configuration.encryptor.decrypt(value)
293
334
  end
294
335
 
295
- # Returns the processor instance.
336
+ # Returns a processor instance by name (internal accessor).
296
337
  #
338
+ # @param name [Symbol, String] the processor name
297
339
  # @return [PatientHttp::Processor, nil]
298
340
  # @api private
299
- attr_accessor :processor
341
+ def processor(name = :default)
342
+ @processors[name.to_sym]
343
+ end
344
+
345
+ # Set the default processor (internal, for testing).
346
+ #
347
+ # @param value [PatientHttp::Processor, nil]
348
+ # @api private
349
+ def processor=(value)
350
+ if value.nil?
351
+ @processors.delete(:default)
352
+ else
353
+ @processors[:default] = value
354
+ end
355
+ end
356
+
357
+ private
358
+
359
+ # Stop every processor, draining them at the same time so the timeout
360
+ # bounds the whole shutdown instead of each processor in turn.
361
+ def stop_processors(timeout:)
362
+ processors = @processors.values
363
+ return if processors.empty?
364
+
365
+ if processors.one?
366
+ processors.first.stop(timeout: timeout)
367
+ else
368
+ processors.map { |processor| Thread.new { processor.stop(timeout: timeout) } }.each(&:join)
369
+ end
370
+ end
371
+
372
+ # Stop the shared monitor thread and remove this process from the
373
+ # registry. Called with the lifecycle mutex held after all processors
374
+ # have stopped.
375
+ def shutdown_shared_services
376
+ @monitor_thread&.stop
377
+ @monitor_thread = nil
378
+ begin
379
+ @task_monitor&.remove_process
380
+ rescue => e
381
+ configuration.logger&.error("[PatientHttp::SolidQueue] Failed to remove process registration: #{e.inspect}")
382
+ end
383
+ @task_monitor = nil
384
+ end
300
385
  end
301
386
  end
302
387
  end
@@ -37,8 +37,6 @@ Gem::Specification.new do |spec|
37
37
 
38
38
  spec.required_ruby_version = ">= 3.2"
39
39
 
40
- spec.add_dependency "patient_http", ">= 1.3.0"
40
+ spec.add_dependency "patient_http", ">= 1.5.0"
41
41
  spec.add_dependency "solid_queue", ">= 1.0.0"
42
-
43
- spec.add_development_dependency "bundler"
44
42
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: patient_http-solid_queue
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Durand
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: 1.3.0
18
+ version: 1.5.0
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: 1.3.0
25
+ version: 1.5.0
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: solid_queue
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -37,20 +37,6 @@ dependencies:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
39
  version: 1.0.0
40
- - !ruby/object:Gem::Dependency
41
- name: bundler
42
- requirement: !ruby/object:Gem::Requirement
43
- requirements:
44
- - - ">="
45
- - !ruby/object:Gem::Version
46
- version: '0'
47
- type: :development
48
- prerelease: false
49
- version_requirements: !ruby/object:Gem::Requirement
50
- requirements:
51
- - - ">="
52
- - !ruby/object:Gem::Version
53
- version: '0'
54
40
  email:
55
41
  - bbdurand@gmail.com
56
42
  executables: []