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 +4 -4
- data/CHANGELOG.md +17 -0
- data/README.md +41 -0
- data/VERSION +1 -1
- data/lib/patient_http/solid_queue/configuration.rb +98 -1
- data/lib/patient_http/solid_queue/processor_observer.rb +47 -16
- data/lib/patient_http/solid_queue/request_executor.rb +32 -4
- data/lib/patient_http/solid_queue/request_job.rb +13 -3
- data/lib/patient_http/solid_queue/task_monitor.rb +79 -18
- data/lib/patient_http/solid_queue/task_monitor_thread.rb +6 -5
- data/lib/patient_http/solid_queue.rb +111 -26
- data/patient_http-solid_queue.gemspec +1 -3
- metadata +3 -17
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c5b556f667fd07c4e9dc41089d036b95fbe0c6ef9e820d5dfde5aea7f74fd76e
|
|
4
|
+
data.tar.gz: eb4583469ba5e2db21d19af63e5f6b601c2bff969e9c116e44087a1cea2717cb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
+
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
|
|
6
|
-
#
|
|
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 =
|
|
13
|
-
@
|
|
14
|
-
|
|
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
|
|
21
|
-
|
|
28
|
+
def request_enqueued(request_task)
|
|
29
|
+
task_monitor.register(request_task)
|
|
22
30
|
end
|
|
23
31
|
|
|
24
|
-
def
|
|
25
|
-
|
|
26
|
-
task_monitor.remove_process
|
|
32
|
+
def request_rejected(request_task)
|
|
33
|
+
task_monitor.unregister(request_task)
|
|
27
34
|
end
|
|
28
35
|
|
|
29
|
-
def
|
|
30
|
-
task_monitor.
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
24
|
-
|
|
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
|
-
@
|
|
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 = @
|
|
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}
|
|
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
|
-
@
|
|
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
|
|
119
|
+
# Check if any processor is running.
|
|
113
120
|
#
|
|
114
121
|
# @return [Boolean]
|
|
115
122
|
def running?
|
|
116
|
-
|
|
123
|
+
@processors.values.any?(&:running?)
|
|
117
124
|
end
|
|
118
125
|
|
|
119
|
-
# Check if
|
|
126
|
+
# Check if any processor is draining (not accepting new requests).
|
|
120
127
|
#
|
|
121
128
|
# @return [Boolean]
|
|
122
129
|
def draining?
|
|
123
|
-
|
|
130
|
+
@processors.values.any?(&:draining?)
|
|
124
131
|
end
|
|
125
132
|
|
|
126
|
-
# Check if
|
|
133
|
+
# Check if any processor is stopping.
|
|
127
134
|
#
|
|
128
135
|
# @return [Boolean]
|
|
129
136
|
def stopping?
|
|
130
|
-
|
|
137
|
+
@processors.values.any?(&:stopping?)
|
|
131
138
|
end
|
|
132
139
|
|
|
133
|
-
# Check if
|
|
140
|
+
# Check if all processors are stopped or none have been started.
|
|
134
141
|
#
|
|
135
142
|
# @return [Boolean]
|
|
136
143
|
def stopped?
|
|
137
|
-
@
|
|
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
|
-
|
|
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
|
|
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 @
|
|
199
|
+
return if @processors.any? && !@processors.values.all?(&:stopped?)
|
|
181
200
|
|
|
182
|
-
@
|
|
183
|
-
|
|
184
|
-
|
|
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
|
|
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
|
-
@
|
|
236
|
+
@processors.each_value(&:drain)
|
|
198
237
|
end
|
|
199
238
|
end
|
|
200
239
|
|
|
201
|
-
# Stop
|
|
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
|
|
250
|
+
return if @processors.empty?
|
|
212
251
|
|
|
213
|
-
|
|
214
|
-
@
|
|
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
|
-
|
|
228
|
-
@
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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: []
|