patient_http-solid_queue 1.0.1 → 1.1.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: 1c4ac3bbb362f31671cf29f2ceb4f848c691a810fe0ee5a75cdb688a8e3496b7
4
- data.tar.gz: 38743ad3ff5bf5a28d59b0b2de7f4d023d5d06deee198dcfc90d596682453976
3
+ metadata.gz: 2db0a06a8b31785cab9595f85351bef93277ba420f11c21b3858e24c9fbf7c18
4
+ data.tar.gz: 73facc946369816cf6e9384b14f5300420446c2bd4d93e91a2b10ad5f6620d64
5
5
  SHA512:
6
- metadata.gz: bbd0c898f04fca1cc69fb8e43d2af88f48003975a3d27cd91d89aacad9fb1da7fbbcb2299d51f81b8c04bbf892893982f4a63820ae47644f0492d165f231ae32
7
- data.tar.gz: c36e0b07297193eebe1b42c25890f51d7b6d0df33304f2e57a53e8c94605c662c8db97cb3bd5641b66c40fea2cd32aac7f76d5eee38e3e77c58f21850da875be
6
+ metadata.gz: 86859b38cba5d753001c093f32783905944852acc249a5c9dd7b52da6940dc8c23458e75fe257767f7e07355aec2be4788d27c4cdae62a87a7eba5eb79827728
7
+ data.tar.gz: 6d0444ccb2cfb9ee06e52423d84ab0aeca15bf8974a23b02a7c196ccfca99ef047ea28fddd7e864324a5428725fa7677168ca98b9f5b8ad179636250efa9876c
data/CHANGELOG.md CHANGED
@@ -4,6 +4,25 @@ 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.1.0
8
+
9
+ ### Changed
10
+
11
+ - `PatientHttp::SolidQueue.configure` now sets the built configuration as the `PatientHttp.default_configuration` so that secrets registered at the module level with `PatientHttp.register_secret` are applied to the configuration the processor runs with, regardless of boot order. This requires patient_http 1.3.0 or newer.
12
+
13
+ ## 1.0.2
14
+
15
+ ### Fixed
16
+
17
+ - Externally stored request payloads are no longer deleted as soon as the request is submitted to the processor. They are now retained until the request completes so that Active Job retries, shutdown re-enqueues, and crash recovery can still fetch them. Discarded `RequestJob` jobs clean up their stored payloads via an `after_discard` hook.
18
+ - `RequestJob` now declares `retry_on` for `PatientHttp::MaxCapacityError` and `PatientHttp::NotRunningError` with a polynomial backoff. Previously these errors (raised as normal backpressure when the processor is at max capacity) sent jobs straight to the failed jobs list because Active Job does not retry by default.
19
+ - `CallbackJob` no longer deletes externally stored payloads when the callback raises, so retries can fetch the payload and re-run the callback.
20
+ - Crash recovery no longer silently loses a request if the process crashes between removing the inflight record and re-enqueueing the job. The record is now claimed first and only deleted after the job has been enqueued, so a failed recovery attempt is retried by a later garbage collection pass.
21
+ - Fixed process registration and garbage collection on MySQL, where passing an explicit conflict target to `upsert`/`insert_all` raises an error. Crash recovery was silently disabled on MySQL.
22
+ - `PatientHttp::SolidQueue.configure` and `reset_configuration!` now reset the memoized external storage so it picks up the new configuration.
23
+ - Fixed race conditions that could create duplicate processors or task monitor threads from concurrent lifecycle calls; starting while the processor is draining no longer replaces it.
24
+ - The task monitor thread no longer holds a database connection from the pool while sleeping between heartbeats.
25
+
7
26
  ## 1.0.1
8
27
 
9
28
  ### Fixed
data/README.md CHANGED
@@ -94,6 +94,14 @@ The `response.callback_args` and `error.callback_args` provide access to the arg
94
94
 
95
95
  > [!IMPORTANT]
96
96
  > Do not re-raise errors in the `on_error` callback as a means to retry the request. That will just retry the error callback job. If you want to retry the original request, you can enqueue a new request from within `on_error`. Be careful with this approach, though, as it can lead to infinite retry loops if the error condition is not resolved.
97
+
98
+ Callback jobs are not retried by default. If you want failed callbacks to be retried before being discarded, configure `retry_on` in an initializer:
99
+
100
+ ```ruby
101
+ PatientHttp::SolidQueue::CallbackJob.retry_on StandardError, wait: :polynomially_longer, attempts: 5
102
+ ```
103
+
104
+ Note that Active Job runs `after_discard` hooks for any unhandled exception, not just when configured retries are exhausted. Without `retry_on`, the first callback failure will trigger the `on_retries_exhausted` handler and delete any externally stored payload, even though the failed job can still be retried manually from Mission Control (such retries will fail if the payload was stored externally).
97
105
  >
98
106
  > Also note that the error callback is only called when an exception occurs during the HTTP request (timeout, connection failure, etc). HTTP error status codes (4xx, 5xx) do not trigger the error callback by default. Instead, they are treated as completed requests and passed to the `on_complete` callback. See the "Handling HTTP Error Responses" section below for how to treat HTTP errors as exceptions.
99
107
 
@@ -436,7 +444,7 @@ See the [Configuration](lib/patient_http/solid_queue/configuration.rb) class for
436
444
 
437
445
  > [!IMPORTANT]
438
446
  >
439
- > One difference between using this gem and making synchronous HTTP requests from a Solid Queue job is that if `max_connections` is reached due to slow asynchronous requests, new requests will trigger an error on the Active Job. The Active Job retry mechanism will handle re-enqueuing the job.
447
+ > One difference between using this gem and making synchronous HTTP requests from a Solid Queue job is that if `max_connections` is reached due to slow asynchronous requests, new requests will trigger an error on the Active Job. The job declares `retry_on` for this error with a polynomial backoff, so it will automatically be retried until the processor has capacity again.
440
448
  >
441
449
  > In contrast, slow synchronous HTTP requests will fill up the worker pool and block new jobs from being dequeued until a worker thread becomes free.
442
450
  >
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.0.1
1
+ 1.1.0
@@ -52,21 +52,22 @@ module PatientHttp
52
52
  actual_data = ref_data ? PatientHttp::SolidQueue.external_storage.fetch(data) : data
53
53
  actual_data = PatientHttp::SolidQueue.decrypt(actual_data)
54
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
55
+ if result_type == "response"
56
+ response = PatientHttp::Response.load(actual_data)
57
+ PatientHttp::SolidQueue.invoke_completion_callbacks(response)
58
+ callback_service.on_complete(response)
59
+ elsif result_type == "error"
60
+ error = PatientHttp::Error.load(actual_data)
61
+ PatientHttp::SolidQueue.invoke_error_callbacks(error)
62
+ callback_service.on_error(error)
63
+ else
64
+ raise ArgumentError, "Unknown result_type: #{result_type}"
69
65
  end
66
+
67
+ # Only delete the stored payload after the callback succeeds so that
68
+ # retries can still fetch it. Discarded jobs are cleaned up by the
69
+ # after_discard hook.
70
+ PatientHttp::SolidQueue.external_storage.delete(ref_data) if ref_data
70
71
  end
71
72
  end
72
73
  end
@@ -10,36 +10,51 @@ module PatientHttp
10
10
  #
11
11
  # @api private
12
12
  class RequestJob < ActiveJob::Base
13
+ # Rejection due to backpressure is part of normal operation: retry until the
14
+ # processor has capacity again. NotRunningError covers jobs that run during
15
+ # the narrow window when the processor is draining or stopping.
16
+ retry_on PatientHttp::MaxCapacityError, PatientHttp::NotRunningError, wait: :polynomially_longer, attempts: :unlimited
17
+
13
18
  # Capture the Active Job serialized hash into Context so RequestExecutor can use it.
14
19
  around_perform do |job, block|
15
20
  PatientHttp::SolidQueue::Context.with_job(job.serialize) { block.call }
16
21
  end
17
22
 
23
+ # Clean up the externally stored request payload when the job is discarded.
24
+ # The payload is normally deleted by TaskHandler when the request completes,
25
+ # so this only fires for requests that never made it that far.
26
+ after_discard do |job, _exception|
27
+ PatientHttp::SolidQueue.external_storage.delete(job.arguments[0])
28
+ rescue => e
29
+ PatientHttp::SolidQueue.configuration.logger&.warn(
30
+ "[PatientHttp::SolidQueue] Failed to delete stored payload for dead job: #{e.class.name} #{e.message}".strip
31
+ )
32
+ end
33
+
18
34
  # @param data [Hash] Request data (possibly a storage reference)
19
35
  # @param callback_service_name [String] Fully qualified callback service class name
20
36
  # @param raise_error_responses [Boolean, nil] Whether to treat non-2xx responses as errors
21
37
  # @param callback_args [Hash, nil] Arguments to pass to the callback
22
38
  # @param request_id [String, nil] Unique request ID for tracking
23
39
  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
40
+ actual_data = PatientHttp::ExternalStorage.storage_ref?(data) ? PatientHttp::SolidQueue.external_storage.fetch(data) : data
26
41
  actual_data = PatientHttp::SolidQueue.decrypt(actual_data)
27
42
 
28
43
  request = PatientHttp::Request.load(actual_data)
29
44
  active_job_data = PatientHttp::SolidQueue::Context.current_job
30
45
 
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
46
+ # The stored payload must not be deleted here: this job data is re-enqueued
47
+ # for Active Job retries (e.g. MaxCapacityError), processor shutdown retries,
48
+ # and crash recovery, all of which need to fetch the payload again.
49
+ # TaskHandler deletes it when the request completes.
50
+ RequestExecutor.execute(
51
+ request,
52
+ callback: callback_service_name,
53
+ raise_error_responses: raise_error_responses,
54
+ callback_args: callback_args,
55
+ active_job_data: active_job_data,
56
+ request_id: request_id
57
+ )
43
58
  end
44
59
  end
45
60
  end
@@ -18,11 +18,13 @@ module PatientHttp
18
18
  def on_complete(response, callback)
19
19
  data = store_if_needed(response.as_json)
20
20
  CallbackJob.perform_later(data, "response", callback)
21
+ delete_stored_request_payload
21
22
  end
22
23
 
23
24
  def on_error(error, callback)
24
25
  data = store_if_needed(error.as_json)
25
26
  CallbackJob.perform_later(data, "error", callback)
27
+ delete_stored_request_payload
26
28
  end
27
29
 
28
30
  def retry
@@ -39,6 +41,24 @@ module PatientHttp
39
41
 
40
42
  private
41
43
 
44
+ # Delete the externally stored request payload once the request has
45
+ # completed. Until then the payload must remain fetchable because the
46
+ # Active Job data referencing it can be re-enqueued by Active Job retries,
47
+ # processor shutdown retries, and crash recovery. Only applies to
48
+ # RequestJob jobs; other job types own their own arguments.
49
+ def delete_stored_request_payload
50
+ return unless @active_job_data["job_class"] == RequestJob.name
51
+
52
+ data = @active_job_data["arguments"]&.first
53
+ return unless PatientHttp::ExternalStorage.storage_ref?(data)
54
+
55
+ PatientHttp::SolidQueue.external_storage.delete(data)
56
+ rescue => e
57
+ PatientHttp::SolidQueue.configuration.logger&.warn(
58
+ "[PatientHttp::SolidQueue] Failed to delete stored request payload: #{e.class.name} #{e.message}".strip
59
+ )
60
+ end
61
+
42
62
  def store_if_needed(data)
43
63
  encrypted = PatientHttp::SolidQueue.encrypt(data)
44
64
  external_storage = PatientHttp::SolidQueue.external_storage
@@ -79,7 +79,7 @@ module PatientHttp
79
79
  def ping_process
80
80
  ProcessRegistration.upsert(
81
81
  {process_id: @lock_identifier, max_connections: @config.max_connections, last_seen_at: Time.current},
82
- unique_by: :process_id
82
+ unique_by: upsert_unique_by(:process_id)
83
83
  )
84
84
  rescue => e
85
85
  @config.logger&.error("[PatientHttp::SolidQueue] Failed to ping process: #{e.message}")
@@ -211,31 +211,47 @@ module PatientHttp
211
211
  private
212
212
 
213
213
  def ensure_gc_lock_row!
214
- GcLock.insert_all([{lock_name: GC_LOCK_NAME}], unique_by: :lock_name)
214
+ GcLock.insert_all([{lock_name: GC_LOCK_NAME}])
215
215
  end
216
216
 
217
- # Re-enqueue a single orphaned record atomically.
217
+ # MySQL does not support an explicit conflict target for upserts; it always
218
+ # resolves conflicts via the table's unique indexes (ON DUPLICATE KEY UPDATE).
219
+ # Adapters with conflict targets (PostgreSQL, SQLite) require one to be given.
218
220
  #
219
- # Uses a delete-by-exact-heartbeat to handle race conditions: if the
220
- # heartbeat was updated between our read and the delete, the delete
221
- # returns 0 rows and we skip re-enqueueing.
221
+ # @param column [Symbol] the unique column to use as the conflict target
222
+ # @return [Symbol, nil] the column, or nil when the adapter forbids a target
223
+ def upsert_unique_by(column)
224
+ Record.connection.supports_insert_conflict_target? ? column : nil
225
+ end
226
+
227
+ # Re-enqueue a single orphaned record.
228
+ #
229
+ # Uses a claim-by-exact-heartbeat update to handle race conditions: if the
230
+ # heartbeat was updated between our read and the claim, the update returns
231
+ # 0 rows and we skip re-enqueueing. Claiming (refreshing the heartbeat)
232
+ # before enqueueing means a crash mid-recovery leaves the record behind to
233
+ # go stale and be retried by a later GC pass, instead of losing the request.
234
+ # The record is only deleted after the job has been enqueued.
222
235
  #
223
236
  # @param record [InflightRequest] the orphaned record
224
237
  # @param threshold [Float] heartbeat threshold (only records below this are orphaned)
225
238
  # @param logger [Logger] logger for output
226
239
  # @return [Boolean] true if successfully re-enqueued
227
240
  def reenqueue_orphaned_record(record, threshold, logger)
228
- # Atomically remove only if still orphaned (heartbeat unchanged)
229
- deleted = InflightRequest
241
+ # Atomically claim only if still orphaned (heartbeat unchanged). The dead
242
+ # process_id is left in place so a failed recovery becomes orphaned again.
243
+ claimed = InflightRequest
230
244
  .where(task_id: record.task_id, heartbeat_at: record.heartbeat_at)
231
245
  .where("heartbeat_at < ?", threshold)
232
- .delete_all
246
+ .update_all(heartbeat_at: Time.current)
233
247
 
234
- return false if deleted == 0
248
+ return false if claimed == 0
235
249
 
236
250
  job_data = JSON.parse(record.job_payload)
237
251
  ActiveJob::Base.deserialize(job_data).tap { |j| j.executions = 0 }.enqueue
238
252
 
253
+ InflightRequest.where(task_id: record.task_id).delete_all
254
+
239
255
  logger&.info(
240
256
  "[PatientHttp::SolidQueue] Re-enqueued orphaned request #{record.task_id} to #{job_data["job_class"]}"
241
257
  )
@@ -34,11 +34,10 @@ module PatientHttp
34
34
  #
35
35
  # @return [void]
36
36
  def start
37
- return if @running.true?
38
- @running.make_true
37
+ return unless @running.make_true
39
38
  @stop_signal.reset
40
39
 
41
- @task_monitor.ping_process
40
+ with_connection { @task_monitor.ping_process }
42
41
 
43
42
  @thread = Thread.new do
44
43
  run
@@ -80,16 +79,24 @@ module PatientHttp
80
79
  break unless @running.true?
81
80
 
82
81
  current_time = monotonic_time
83
-
84
- if current_time - last_heartbeat_update >= @config.heartbeat_interval
85
- @task_monitor.ping_process
86
- update_heartbeats
87
- last_heartbeat_update = current_time
88
- end
89
-
90
- if current_time - last_gc_attempt >= @config.heartbeat_interval
91
- attempt_garbage_collection
92
- last_gc_attempt = current_time
82
+ update_heartbeats_due = current_time - last_heartbeat_update >= @config.heartbeat_interval
83
+ gc_attempt_due = current_time - last_gc_attempt >= @config.heartbeat_interval
84
+
85
+ if update_heartbeats_due || gc_attempt_due
86
+ # Check out a database connection only for the duration of the work
87
+ # so the thread does not hold a slot from the pool while it sleeps.
88
+ with_connection do
89
+ if update_heartbeats_due
90
+ @task_monitor.ping_process
91
+ update_heartbeats
92
+ last_heartbeat_update = current_time
93
+ end
94
+
95
+ if gc_attempt_due
96
+ attempt_garbage_collection
97
+ last_gc_attempt = current_time
98
+ end
99
+ end
93
100
  end
94
101
 
95
102
  wait_time = @config.heartbeat_interval / 2.0
@@ -100,6 +107,10 @@ module PatientHttp
100
107
  @config.logger&.info("[PatientHttp::SolidQueue] Monitor thread stopped")
101
108
  end
102
109
 
110
+ def with_connection(&block)
111
+ Record.connection_pool.with_connection(&block)
112
+ end
113
+
103
114
  def update_heartbeats
104
115
  request_ids = @inflight_ids_callback.call
105
116
  return if request_ids.empty?
@@ -55,19 +55,26 @@ module PatientHttp
55
55
  @after_error_callbacks = []
56
56
  @external_storage = nil
57
57
  @request_handler = nil
58
+ @lifecycle_mutex = Mutex.new
58
59
 
59
60
  class << self
60
61
  attr_writer :configuration
61
62
 
62
- # Configure the gem with a block.
63
+ # Configure the gem with a block. The built configuration is also set as the
64
+ # `PatientHttp.default_configuration` so that secrets registered at the module
65
+ # level with `PatientHttp.register_secret` are applied to the configuration the
66
+ # processor runs with, regardless of boot order.
63
67
  #
64
68
  # @yield [Configuration] the configuration object
65
69
  # @return [Configuration]
66
70
  def configure
67
71
  configuration = Configuration.new
68
72
  yield(configuration) if block_given?
69
- register_handler
70
73
  @configuration = configuration
74
+ @external_storage = nil
75
+ register_handler
76
+ PatientHttp.default_configuration = configuration
77
+ configuration
71
78
  end
72
79
 
73
80
  # Return the current configuration, initializing with defaults if necessary.
@@ -82,6 +89,7 @@ module PatientHttp
82
89
  # @return [Configuration]
83
90
  def reset_configuration!
84
91
  @configuration = nil
92
+ @external_storage = nil
85
93
  configuration
86
94
  end
87
95
 
@@ -168,11 +176,13 @@ module PatientHttp
168
176
  #
169
177
  # @return [void]
170
178
  def start
171
- return if running?
179
+ @lifecycle_mutex.synchronize do
180
+ return if @processor && !@processor.stopped?
172
181
 
173
- @processor = PatientHttp::Processor.new(configuration)
174
- @processor.observe(ProcessorObserver.new(@processor))
175
- @processor.start
182
+ @processor = PatientHttp::Processor.new(configuration)
183
+ @processor.observe(ProcessorObserver.new(@processor))
184
+ @processor.start
185
+ end
176
186
 
177
187
  register_handler
178
188
  end
@@ -181,9 +191,11 @@ module PatientHttp
181
191
  #
182
192
  # @return [void]
183
193
  def quiet
184
- return unless running?
194
+ @lifecycle_mutex.synchronize do
195
+ return unless running?
185
196
 
186
- @processor.drain
197
+ @processor.drain
198
+ end
187
199
  end
188
200
 
189
201
  # Stop the processor gracefully.
@@ -191,14 +203,16 @@ module PatientHttp
191
203
  # @param timeout [Float, nil] maximum time to wait for in-flight requests to complete
192
204
  # @return [void]
193
205
  def stop(timeout: nil)
194
- return unless @processor
195
-
196
206
  if @request_handler
197
207
  PatientHttp.unregister_handler(@request_handler)
198
208
  end
199
209
 
200
- @processor.stop(timeout: timeout)
201
- @processor = nil
210
+ @lifecycle_mutex.synchronize do
211
+ return unless @processor
212
+
213
+ @processor.stop(timeout: timeout)
214
+ @processor = nil
215
+ end
202
216
  end
203
217
 
204
218
  # Reset all state (useful for testing).
@@ -209,8 +223,10 @@ module PatientHttp
209
223
  if @request_handler
210
224
  PatientHttp.unregister_handler(@request_handler)
211
225
  end
212
- @processor&.stop(timeout: 0)
213
- @processor = nil
226
+ @lifecycle_mutex.synchronize do
227
+ @processor&.stop(timeout: 0)
228
+ @processor = nil
229
+ end
214
230
  @configuration = nil
215
231
  @external_storage = nil
216
232
  @after_completion_callbacks = []
@@ -37,7 +37,7 @@ Gem::Specification.new do |spec|
37
37
 
38
38
  spec.required_ruby_version = ">= 3.2"
39
39
 
40
- spec.add_dependency "patient_http"
40
+ spec.add_dependency "patient_http", ">= 1.3.0"
41
41
  spec.add_dependency "solid_queue", ">= 1.0.0"
42
42
 
43
43
  spec.add_development_dependency "bundler"
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.0.1
4
+ version: 1.1.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: '0'
18
+ version: 1.3.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: '0'
25
+ version: 1.3.0
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: solid_queue
28
28
  requirement: !ruby/object:Gem::Requirement