patient_http-solid_queue 1.0.0 → 1.0.2

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: a3dff2ec8890085fac33d90459fae8756ba79fbf357579ac1cacb71a49f7e8fe
4
- data.tar.gz: 8664f9bad61dc7db22fd7e3908ea705b094703863e9d13598f0f787e91e6d1f9
3
+ metadata.gz: 4b17f1e1d5c477d8ca2dac603c0521dd7a842469d24db38dadb5f24858443dca
4
+ data.tar.gz: 25d38a530f6ec873e5b224462d0335fa99912e6c1c36cad46f2c072ff9a5162a
5
5
  SHA512:
6
- metadata.gz: 106fc849d06ab190c8d691cf4f99066bd7fcc6c3cc0be20da1a95a43fbc73e4ff7cb1517a555bd008c4a55113394096985eb58835c04a579be098b371f2fdb14
7
- data.tar.gz: 502cd86c858693f6c70edc88a97e2cae08e9a6f85712dc7f2fb06d5066124ae679351e7b685fa4fba4a4c0da97020f096c3673ca451aa768b329d2c7b01d61a3
6
+ metadata.gz: f1d0021667606b1b527eb4b1013fb418c5e9593d89d8daba3102415305f3a337d9258fd9c4387474c0f913b421619ceeeead1d59495fdf5d814635e1df2760b0
7
+ data.tar.gz: b27c9cfa86a5265061b3510bd168f7780b61bab702efc621d2214dac72c4bc79efb58c3c814c01a6ad71b7a7d986b85697d73e608ccd3bdfa8834a692f9f4555
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.0.2
8
+
9
+ ### Fixed
10
+
11
+ - 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.
12
+ - `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.
13
+ - `CallbackJob` no longer deletes externally stored payloads when the callback raises, so retries can fetch the payload and re-run the callback.
14
+ - 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.
15
+ - 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.
16
+ - `PatientHttp::SolidQueue.configure` and `reset_configuration!` now reset the memoized external storage so it picks up the new configuration.
17
+ - 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.
18
+ - The task monitor thread no longer holds a database connection from the pool while sleeping between heartbeats.
19
+
20
+ ## 1.0.1
21
+
22
+ ### Fixed
23
+
24
+ - Ensure `PatientHttp::SolidQueue` is automatically registered as the HTTP processor on a process not running the SolidQueue server. This gem will now auto register itself on a client process either by calling `PatientHttp::SolidQueue.configure` or `PatientHttp::SolidQueue.register_handler` directly.
25
+
7
26
  ## 1.0.0
8
27
 
9
28
  ### Added
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.0
1
+ 1.0.2
@@ -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,6 +55,7 @@ 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
@@ -67,6 +68,9 @@ module PatientHttp
67
68
  configuration = Configuration.new
68
69
  yield(configuration) if block_given?
69
70
  @configuration = configuration
71
+ @external_storage = nil
72
+ register_handler
73
+ configuration
70
74
  end
71
75
 
72
76
  # Return the current configuration, initializing with defaults if necessary.
@@ -81,6 +85,7 @@ module PatientHttp
81
85
  # @return [Configuration]
82
86
  def reset_configuration!
83
87
  @configuration = nil
88
+ @external_storage = nil
84
89
  configuration
85
90
  end
86
91
 
@@ -167,31 +172,26 @@ module PatientHttp
167
172
  #
168
173
  # @return [void]
169
174
  def start
170
- return if running?
171
-
172
- @processor = PatientHttp::Processor.new(configuration)
173
- @processor.observe(ProcessorObserver.new(@processor))
174
- @processor.start
175
+ @lifecycle_mutex.synchronize do
176
+ return if @processor && !@processor.stopped?
175
177
 
176
- @request_handler ||= lambda do |request:, callback:, raise_error_responses:, callback_args:|
177
- execute(
178
- request,
179
- callback: callback,
180
- raise_error_responses: raise_error_responses,
181
- callback_args: callback_args
182
- )
178
+ @processor = PatientHttp::Processor.new(configuration)
179
+ @processor.observe(ProcessorObserver.new(@processor))
180
+ @processor.start
183
181
  end
184
182
 
185
- PatientHttp.register_handler(@request_handler)
183
+ register_handler
186
184
  end
187
185
 
188
186
  # Signal the processor to drain (stop accepting new requests).
189
187
  #
190
188
  # @return [void]
191
189
  def quiet
192
- return unless running?
190
+ @lifecycle_mutex.synchronize do
191
+ return unless running?
193
192
 
194
- @processor.drain
193
+ @processor.drain
194
+ end
195
195
  end
196
196
 
197
197
  # Stop the processor gracefully.
@@ -199,14 +199,16 @@ module PatientHttp
199
199
  # @param timeout [Float, nil] maximum time to wait for in-flight requests to complete
200
200
  # @return [void]
201
201
  def stop(timeout: nil)
202
- return unless @processor
203
-
204
202
  if @request_handler
205
203
  PatientHttp.unregister_handler(@request_handler)
206
204
  end
207
205
 
208
- @processor.stop(timeout: timeout)
209
- @processor = nil
206
+ @lifecycle_mutex.synchronize do
207
+ return unless @processor
208
+
209
+ @processor.stop(timeout: timeout)
210
+ @processor = nil
211
+ end
210
212
  end
211
213
 
212
214
  # Reset all state (useful for testing).
@@ -216,16 +218,34 @@ module PatientHttp
216
218
  def reset!
217
219
  if @request_handler
218
220
  PatientHttp.unregister_handler(@request_handler)
219
- @request_handler = nil
220
221
  end
221
- @processor&.stop(timeout: 0)
222
- @processor = nil
222
+ @lifecycle_mutex.synchronize do
223
+ @processor&.stop(timeout: 0)
224
+ @processor = nil
225
+ end
223
226
  @configuration = nil
224
227
  @external_storage = nil
225
228
  @after_completion_callbacks = []
226
229
  @after_error_callbacks = []
227
230
  end
228
231
 
232
+ # Register SolidQueue as the request handler for processing HTTP requests. This is called
233
+ # automatically when the processor starts or you call PatientHttp::SolidQueue.configure.
234
+ #
235
+ # @return [void]
236
+ def register_handler
237
+ @request_handler ||= lambda do |request:, callback:, raise_error_responses:, callback_args:|
238
+ execute(
239
+ request,
240
+ callback: callback,
241
+ raise_error_responses: raise_error_responses,
242
+ callback_args: callback_args
243
+ )
244
+ end
245
+
246
+ PatientHttp.register_handler(@request_handler)
247
+ end
248
+
229
249
  # Invoke the registered completion callbacks.
230
250
  #
231
251
  # @param response [PatientHttp::Response] the HTTP response
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.0
4
+ version: 1.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Durand