patient_http-sidekiq 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: 5d2f470219dae6e479974adecec97231a42918686c415b3d601c1803274cdb06
4
- data.tar.gz: b8af3ece14b9c58032153ee2870c7122ae30b460894aad31bbd018f879e79da3
3
+ metadata.gz: f244dd52326602169e67b8471527b939c3e451fd11dcbcfe424305d5ad0929dd
4
+ data.tar.gz: 28b502c3aca097c48e364195e338e475d67f4f584209797054e2cc8ed75dda90
5
5
  SHA512:
6
- metadata.gz: 9f78b1ade928fc188d3e48247fe6a6a2a3891470b820b24e912737cdea8ad8ce1bbb3b0324e3dc0ab7417cf0c5142db9045b3ee24064e7d4d54de45ea191b126
7
- data.tar.gz: 8050fbbbaa96eb45217ef7df4c1fb554f3b79fe8440156ed09e31e42d290fdc5cf849ebf20be2eb03dcf4b35a72b827d740e41606412cd1a987ea60ce1333b87
6
+ metadata.gz: 12bb283a157d40689ffadfb5fdb58b74260374017409f99185a650f989d54da2ff858bbccac294fd6fe7095c0150cc024a0e15649280d0d1aaf5da04d2211b12
7
+ data.tar.gz: a27fbd9f52d110b05bb4177dd675642dde873c3a519078ea8c95f295c96f8be499e8fff119e79a8c0f446d1c6d608e537c374dceda993d98bcdf2081ef9e0a00
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
+ - Crash recovery now recovers requests from crashed processes. Previously a crashed process's ID remained in the process registry indefinitely, permanently excluding its in-flight requests from orphan detection unless the Sidekiq Web UI happened to prune it.
12
+ - Fixed calls to nonexistent `Sidekiq.testing?` in error handlers, which would raise `NoMethodError` and permanently kill the task monitor thread on the first transient Redis error.
13
+ - 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 Sidekiq retries, shutdown re-enqueues, and crash recovery can still fetch them. Dead `RequestWorker` jobs clean up their stored payloads when retries are exhausted.
14
+ - `CallbackWorker` no longer deletes externally stored payloads when the callback raises, so Sidekiq retries can fetch the payload and re-run the callback.
15
+ - Fixed dead job payload cleanup in `CallbackWorker`, which silently failed by calling a nonexistent method.
16
+ - `PatientHttp::Sidekiq.configure` and `reset_configuration!` now reset the memoized external storage so it picks up the new configuration.
17
+ - Heartbeat updates now refresh the TTL on the inflight tracking keys so they cannot expire while long-running requests are still in flight.
18
+ - 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.
19
+
20
+ ## 1.0.1
21
+
22
+ ### Fixed
23
+
24
+ - Ensure `PatientHttp::Sidekiq` is automatically registered as the HTTP processor on a process not running the Sidekiq server. This gem will now auto register itself on a client process either by calling `PatientHttp::Sidekiq.configure` or `PatientHttp::Sidekiq.register_handler` directly.
25
+
7
26
  ## 1.0.0
8
27
 
9
28
  ### Added
data/README.md CHANGED
@@ -46,7 +46,17 @@ The async processor runs in a dedicated thread within your Sidekiq process, usin
46
46
 
47
47
  ## Quick Start
48
48
 
49
- ### 1. Create a Callback Service
49
+ ### 1. Configure The Gem
50
+
51
+ Configure the gem in an initializer (see the Configuration section below for all available options):
52
+
53
+ ```ruby
54
+ PatientHttp::Sidekiq.configure do |config|
55
+ config.max_connections = 256
56
+ end
57
+ ```
58
+
59
+ ### 2. Create a Callback Service
50
60
 
51
61
  Define a callback service class with `on_complete` and `on_error` methods:
52
62
 
@@ -69,7 +79,7 @@ class FetchDataCallback
69
79
  end
70
80
  ```
71
81
 
72
- ### 2. Make HTTP Requests
82
+ ### 3. Make HTTP Requests
73
83
 
74
84
  Make HTTP requests from anywhere in your code using `PatientHttp`:
75
85
 
@@ -82,7 +92,7 @@ PatientHttp.get(
82
92
  )
83
93
  ```
84
94
 
85
- ### 3. That's It!
95
+ ### 4. That's It!
86
96
 
87
97
  The request will be enqueued as a Sidekiq job and passed to a [PatientHttp](https://github.com/bdurand/patient_http) processor to execute asynchronously. When the HTTP request completes, your callback's `on_complete` method is executed in another Sidekiq job.
88
98
 
@@ -451,6 +461,9 @@ end
451
461
 
452
462
  See the [Configuration](lib/patient_http/sidekiq/configuration.rb) class for all available options.
453
463
 
464
+ > [!NOTE]
465
+ > You **must** call `PatientHttp::Sidekiq.configure` or `PatientHttp::Sidekiq.register_handler` in order to register the request handler and start processing requests. If you do not call either method, you will get errors making HTTP requests with `PatientHttp.execute`.
466
+
454
467
  ### Tuning Tips
455
468
 
456
469
  - `max_connections`: Adjust this based on your system's resources. Each connection uses memory and file descriptors. A tuned system with sufficient resources can handle thousands of concurrent connections.
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.0.0
1
+ 1.0.2
@@ -53,7 +53,7 @@ module PatientHttp
53
53
  end
54
54
 
55
55
  begin
56
- ExternalStorage.delete(data)
56
+ Sidekiq.external_storage.delete(data)
57
57
  rescue => e
58
58
  PatientHttp::Sidekiq.configuration.logger&.warn(
59
59
  "[PatientHttp::Sidekiq] Failed to delete stored payload for dead job: #{e.class.name} #{e.message}".strip
@@ -75,21 +75,22 @@ module PatientHttp
75
75
  actual_data = ref_data ? Sidekiq.external_storage.fetch(data) : data
76
76
  actual_data = Sidekiq.decrypt(actual_data)
77
77
 
78
- begin
79
- if result_type == "response"
80
- response = PatientHttp::Response.load(actual_data)
81
- PatientHttp::Sidekiq.invoke_completion_callbacks(response)
82
- callback_service.on_complete(response)
83
- elsif result_type == "error"
84
- error = PatientHttp::Error.load(actual_data)
85
- PatientHttp::Sidekiq.invoke_error_callbacks(error)
86
- callback_service.on_error(error)
87
- else
88
- raise ArgumentError, "Unknown result_type: #{result_type}"
89
- end
90
- ensure
91
- Sidekiq.external_storage.delete(ref_data) if ref_data
78
+ if result_type == "response"
79
+ response = PatientHttp::Response.load(actual_data)
80
+ PatientHttp::Sidekiq.invoke_completion_callbacks(response)
81
+ callback_service.on_complete(response)
82
+ elsif result_type == "error"
83
+ error = PatientHttp::Error.load(actual_data)
84
+ PatientHttp::Sidekiq.invoke_error_callbacks(error)
85
+ callback_service.on_error(error)
86
+ else
87
+ raise ArgumentError, "Unknown result_type: #{result_type}"
92
88
  end
89
+
90
+ # Only delete the stored payload after the callback succeeds so that
91
+ # Sidekiq retries can still fetch it. Failed jobs are cleaned up by
92
+ # the sidekiq_retries_exhausted hook.
93
+ Sidekiq.external_storage.delete(ref_data) if ref_data
93
94
  end
94
95
  end
95
96
  end
@@ -15,6 +15,17 @@ module PatientHttp
15
15
  class RequestWorker
16
16
  include ::Sidekiq::Job
17
17
 
18
+ # Clean up the externally stored request payload when the job exhausts all
19
+ # retries. The payload is normally deleted by TaskHandler when the request
20
+ # completes, so this only fires for requests that never made it that far.
21
+ sidekiq_retries_exhausted do |job, _exception|
22
+ Sidekiq.external_storage.delete(job["args"][0])
23
+ rescue => e
24
+ PatientHttp::Sidekiq.configuration.logger&.warn(
25
+ "[PatientHttp::Sidekiq] Failed to delete stored payload for dead job: #{e.class.name} #{e.message}".strip
26
+ )
27
+ end
28
+
18
29
  # Perform the HTTP request.
19
30
  #
20
31
  # @param data [Hash] Request data (possibly a storage reference) with keys:
@@ -26,31 +37,30 @@ module PatientHttp
26
37
  # - "max_redirects" [Integer, nil] Maximum redirects to follow
27
38
  # @param callback_service_name [String] Fully qualified callback service class name
28
39
  # @param raise_error_responses [Boolean, nil] Whether to treat non-2xx responses as errors;
29
- # defaults to the global config if nil
40
+ # nil is treated as false
30
41
  # @param callback_args [Hash, nil] Arguments to pass to the callback
31
42
  # @param request_id [String, nil] Unique request ID for tracking
32
43
  # @return [void]
33
44
  def perform(data, callback_service_name, raise_error_responses, callback_args, request_id)
34
45
  # Fetch from external storage if needed
35
- ref_data = PatientHttp::ExternalStorage.storage_ref?(data) ? data : nil
36
- actual_data = ref_data ? Sidekiq.external_storage.fetch(data) : data
46
+ actual_data = PatientHttp::ExternalStorage.storage_ref?(data) ? Sidekiq.external_storage.fetch(data) : data
37
47
  actual_data = Sidekiq.decrypt(actual_data)
38
48
 
39
49
  request = PatientHttp::Request.load(actual_data)
40
50
  sidekiq_job = Sidekiq::Context.current_job
41
51
 
42
- begin
43
- RequestExecutor.execute(
44
- request,
45
- callback: callback_service_name,
46
- raise_error_responses: raise_error_responses,
47
- callback_args: callback_args,
48
- sidekiq_job: sidekiq_job,
49
- request_id: request_id
50
- )
51
- ensure
52
- Sidekiq.external_storage.delete(ref_data) if ref_data
53
- end
52
+ # The stored payload must not be deleted here: this job hash is re-pushed
53
+ # for Sidekiq retries (e.g. MaxCapacityError), processor shutdown retries,
54
+ # and crash recovery, all of which need to fetch the payload again.
55
+ # TaskHandler deletes it when the request completes.
56
+ RequestExecutor.execute(
57
+ request,
58
+ callback: callback_service_name,
59
+ raise_error_responses: raise_error_responses,
60
+ callback_args: callback_args,
61
+ sidekiq_job: sidekiq_job,
62
+ request_id: request_id
63
+ )
54
64
  end
55
65
  end
56
66
  end
@@ -112,7 +112,7 @@ module PatientHttp
112
112
 
113
113
  def handle_error(error)
114
114
  @config&.logger&.error("[PatientHttp::Sidekiq] Stats error: #{error.inspect}")
115
- raise error if ::Sidekiq.testing?
115
+ raise error if PatientHttp.testing?
116
116
  end
117
117
  end
118
118
  end
@@ -29,6 +29,7 @@ module PatientHttp
29
29
  def on_complete(response, callback)
30
30
  data = store_if_needed(response.as_json)
31
31
  CallbackWorker.perform_async(data, "response", callback)
32
+ delete_stored_request_payload
32
33
  end
33
34
 
34
35
  # Trigger the error callback with the error.
@@ -42,6 +43,7 @@ module PatientHttp
42
43
  def on_error(error, callback)
43
44
  data = store_if_needed(error.as_json)
44
45
  CallbackWorker.perform_async(data, "error", callback)
46
+ delete_stored_request_payload
45
47
  end
46
48
 
47
49
  # Re-enqueue the original Sidekiq job for retry.
@@ -67,6 +69,24 @@ module PatientHttp
67
69
 
68
70
  private
69
71
 
72
+ # Delete the externally stored request payload once the request has
73
+ # completed. Until then the payload must remain fetchable because the
74
+ # Sidekiq job hash referencing it can be re-pushed by Sidekiq retries,
75
+ # processor shutdown retries, and crash recovery. Only applies to
76
+ # RequestWorker jobs; other job types own their own arguments.
77
+ def delete_stored_request_payload
78
+ return unless @sidekiq_job["class"] == RequestWorker.name
79
+
80
+ data = @sidekiq_job["args"]&.first
81
+ return unless PatientHttp::ExternalStorage.storage_ref?(data)
82
+
83
+ PatientHttp::Sidekiq.external_storage.delete(data)
84
+ rescue => e
85
+ PatientHttp::Sidekiq.configuration.logger&.warn(
86
+ "[PatientHttp::Sidekiq] Failed to delete stored request payload: #{e.class.name} #{e.message}".strip
87
+ )
88
+ end
89
+
70
90
  def store_if_needed(data)
71
91
  encrypted = Sidekiq.encrypt(data)
72
92
  external_storage = PatientHttp::Sidekiq.external_storage
@@ -226,6 +226,11 @@ module PatientHttp
226
226
  task_ids.each do |task_id|
227
227
  pipeline.call("ZADD", INFLIGHT_INDEX_KEY, "XX", timestamp_ms, full_task_id(task_id))
228
228
  end
229
+ # Keep the inflight keys alive while requests are still in flight;
230
+ # otherwise they only get their TTL refreshed when new requests
231
+ # are registered.
232
+ pipeline.call("EXPIRE", INFLIGHT_INDEX_KEY, inflight_ttl)
233
+ pipeline.call("EXPIRE", INFLIGHT_JOBS_KEY, inflight_ttl)
229
234
  end
230
235
  end
231
236
  end
@@ -401,10 +406,8 @@ module PatientHttp
401
406
  orphaned_request_ids_by_process = all_orphaned_request_ids.group_by do |request_id|
402
407
  request_id.split("/", 2).first
403
408
  end
404
- all_process_ids = ::Sidekiq.redis do |redis|
405
- redis.smembers(PROCESS_SET_KEY)
406
- end
407
- orphaned_request_ids = orphaned_request_ids_by_process.except(*all_process_ids).values.flatten
409
+ live_process_ids = prune_stale_processes(orphaned_request_ids_by_process.keys)
410
+ orphaned_request_ids = orphaned_request_ids_by_process.except(*live_process_ids).values.flatten
408
411
 
409
412
  return [] if orphaned_request_ids.empty?
410
413
 
@@ -416,6 +419,42 @@ module PatientHttp
416
419
  orphaned_request_ids.zip(job_payloads).reject { |_id, payload| payload.nil? }
417
420
  end
418
421
 
422
+ # Determine which of the given process IDs belong to live processes,
423
+ # removing dead ones from the process set.
424
+ #
425
+ # Membership in the process set alone doesn't prove liveness: a crashed
426
+ # process never removes itself from the set. A process is only considered
427
+ # live if its max_connections key (refreshed on every heartbeat with a
428
+ # short TTL) still exists. Stale members are removed from the set so
429
+ # their inflight requests can be recovered.
430
+ #
431
+ # @param process_ids [Array<String>] candidate process IDs
432
+ #
433
+ # @return [Array<String>] the subset of process IDs that are live
434
+ def prune_stale_processes(process_ids)
435
+ registered_ids = ::Sidekiq.redis do |redis|
436
+ redis.smembers(PROCESS_SET_KEY)
437
+ end
438
+ candidates = process_ids & registered_ids
439
+ return [] if candidates.empty?
440
+
441
+ max_connection_values = ::Sidekiq.redis do |redis|
442
+ redis.mget(*candidates.map { |process_id| max_connections_key_for(process_id) })
443
+ end
444
+
445
+ stale_process_ids, live_process_ids = candidates.zip(max_connection_values)
446
+ .partition { |_process_id, max_conn| max_conn.nil? }
447
+ .map { |pairs| pairs.map(&:first) }
448
+
449
+ unless stale_process_ids.empty?
450
+ ::Sidekiq.redis do |redis|
451
+ redis.srem(PROCESS_SET_KEY, stale_process_ids)
452
+ end
453
+ end
454
+
455
+ live_process_ids
456
+ end
457
+
419
458
  # Re-enqueue all orphaned jobs.
420
459
  #
421
460
  # @param orphaned_requests [Array<Array(String, String)>] array of [request_id, job_payload] pairs
@@ -503,7 +542,7 @@ module PatientHttp
503
542
  # @return [Integer] TTL in seconds
504
543
  def inflight_ttl
505
544
  # Set to 3x the orphan threshold, with a minimum of 1 hour
506
- [config.orphan_threshold * 3, 3600].max
545
+ [config.orphan_threshold * 3, 3600].max.round
507
546
  end
508
547
 
509
548
  # Calculate the TTL for the garbage collection lock.
@@ -535,7 +574,11 @@ module PatientHttp
535
574
  end
536
575
 
537
576
  def max_connections_key
538
- "#{PROCESS_SET_KEY}:#{@lock_identifier}:max_connections"
577
+ max_connections_key_for(@lock_identifier)
578
+ end
579
+
580
+ def max_connections_key_for(process_id)
581
+ "#{PROCESS_SET_KEY}:#{process_id}:max_connections"
539
582
  end
540
583
  end
541
584
  end
@@ -35,8 +35,7 @@ module PatientHttp
35
35
  #
36
36
  # @return [void]
37
37
  def start
38
- return if @running.true?
39
- @running.make_true
38
+ return unless @running.make_true
40
39
  @stop_signal.reset
41
40
 
42
41
  @task_monitor.ping_process
@@ -46,7 +45,7 @@ module PatientHttp
46
45
  rescue => e
47
46
  # Log error but don't crash
48
47
  @config.logger&.error("[PatientHttp::Sidekiq] Monitor error: #{e.message}\n#{e.backtrace.join("\n")}")
49
- raise if ::Sidekiq.testing?
48
+ raise if PatientHttp.testing?
50
49
  end
51
50
 
52
51
  @thread.name = "patient-http-monitor"
@@ -120,7 +119,7 @@ module PatientHttp
120
119
  @config.logger&.debug("[PatientHttp::Sidekiq] Updated heartbeats for #{request_ids.size} inflight requests")
121
120
  rescue => e
122
121
  @config.logger&.error("[PatientHttp::Sidekiq] Failed to update heartbeats: #{e.class} - #{e.message}")
123
- raise if ::Sidekiq.testing?
122
+ raise if PatientHttp.testing?
124
123
  end
125
124
 
126
125
  # Attempt to acquire GC lock and clean up orphaned requests.
@@ -147,7 +146,7 @@ module PatientHttp
147
146
  end
148
147
  rescue => e
149
148
  @config.logger&.error("[PatientHttp::Sidekiq] Garbage collection failed: #{e.class} - #{e.message}")
150
- raise if ::Sidekiq.testing?
149
+ raise if PatientHttp.testing?
151
150
  end
152
151
  end
153
152
  end
@@ -87,6 +87,7 @@ module PatientHttp
87
87
  @after_error_callbacks = []
88
88
  @external_storage = nil
89
89
  @request_handler = nil
90
+ @lifecycle_mutex = Mutex.new
90
91
 
91
92
  class << self
92
93
  attr_writer :configuration
@@ -98,6 +99,9 @@ module PatientHttp
98
99
  configuration = Configuration.new
99
100
  yield(configuration) if block_given?
100
101
  @configuration = configuration
102
+ @external_storage = nil
103
+ register_handler
104
+ configuration
101
105
  end
102
106
 
103
107
  # Ensure configuration is initialized
@@ -110,6 +114,7 @@ module PatientHttp
110
114
  # @return [Configuration]
111
115
  def reset_configuration!
112
116
  @configuration = nil
117
+ @external_storage = nil
113
118
  configuration
114
119
  end
115
120
 
@@ -231,19 +236,11 @@ module PatientHttp
231
236
  request_id
232
237
  end
233
238
 
234
- # Start the processor
239
+ # Register Sidekiq as the request handler for processing HTTP requests. This is called
240
+ # automatically when the processor starts or you call PatientHttp::Sidekiq.configure.
235
241
  #
236
242
  # @return [void]
237
- def start
238
- return if running?
239
-
240
- @processor = PatientHttp::Processor.new(configuration)
241
- @processor.observe(ProcessorObserver.new(@processor))
242
- configuration.observers.each do |observer|
243
- @processor.observe(observer)
244
- end
245
- @processor.start
246
-
243
+ def register_handler
247
244
  @request_handler ||= lambda do |request:, callback:, raise_error_responses:, callback_args:|
248
245
  execute(
249
246
  request,
@@ -256,13 +253,33 @@ module PatientHttp
256
253
  PatientHttp.register_handler(@request_handler)
257
254
  end
258
255
 
256
+ # Start the processor
257
+ #
258
+ # @return [void]
259
+ def start
260
+ @lifecycle_mutex.synchronize do
261
+ return if @processor && !@processor.stopped?
262
+
263
+ @processor = PatientHttp::Processor.new(configuration)
264
+ @processor.observe(ProcessorObserver.new(@processor))
265
+ configuration.observers.each do |observer|
266
+ @processor.observe(observer)
267
+ end
268
+ @processor.start
269
+ end
270
+
271
+ register_handler
272
+ end
273
+
259
274
  # Signal the processor to drain (stop accepting new requests)
260
275
  #
261
276
  # @return [void]
262
277
  def quiet
263
- return unless running?
278
+ @lifecycle_mutex.synchronize do
279
+ return unless running?
264
280
 
265
- @processor.drain
281
+ @processor.drain
282
+ end
266
283
  end
267
284
 
268
285
  # Stop the processor gracefully
@@ -274,10 +291,12 @@ module PatientHttp
274
291
  PatientHttp.unregister_handler(@request_handler)
275
292
  end
276
293
 
277
- return unless @processor
294
+ @lifecycle_mutex.synchronize do
295
+ return unless @processor
278
296
 
279
- @processor.stop(timeout: timeout)
280
- @processor = nil
297
+ @processor.stop(timeout: timeout)
298
+ @processor = nil
299
+ end
281
300
  end
282
301
 
283
302
  # Reset all state (useful for testing)
@@ -285,8 +304,10 @@ module PatientHttp
285
304
  # @return [void]
286
305
  # @api private
287
306
  def reset!
288
- @processor&.stop(timeout: 0)
289
- @processor = nil
307
+ @lifecycle_mutex.synchronize do
308
+ @processor&.stop(timeout: 0)
309
+ @processor = nil
310
+ end
290
311
  @configuration = nil
291
312
  @external_storage = nil
292
313
  @after_completion_callbacks = []
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: patient_http-sidekiq
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