patient_http-sidekiq 1.0.1 → 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: bcb233e85930e0c27c9d950b3fee8621cb78d72534f128e2d13bb5072da6a95b
4
- data.tar.gz: 86f1b667bb854573f4123d1d35895aea0d6159a21f8026c1313877a61ae93b7a
3
+ metadata.gz: f244dd52326602169e67b8471527b939c3e451fd11dcbcfe424305d5ad0929dd
4
+ data.tar.gz: 28b502c3aca097c48e364195e338e475d67f4f584209797054e2cc8ed75dda90
5
5
  SHA512:
6
- metadata.gz: 69fc0eece8eb9075ee02ee3a8ef5422017f08c7bb06206b8309cb0174f5c28943ac489c0b9b58004524b301007f2e1ed6355e2dcd0c5a0e4d855eaf027e6d1cf
7
- data.tar.gz: 2a24b218cd8a33687b141a0ed1a05e08e00e01da7d3fce9fe4b94bdb560c39a5d9d931caa8f3116ad63b03dc94089cac30a73e00ba34ac31443903ca115c25b2
6
+ metadata.gz: 12bb283a157d40689ffadfb5fdb58b74260374017409f99185a650f989d54da2ff858bbccac294fd6fe7095c0150cc024a0e15649280d0d1aaf5da04d2211b12
7
+ data.tar.gz: a27fbd9f52d110b05bb4177dd675642dde873c3a519078ea8c95f295c96f8be499e8fff119e79a8c0f446d1c6d608e537c374dceda993d98bcdf2081ef9e0a00
data/CHANGELOG.md CHANGED
@@ -4,6 +4,19 @@ 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
+
7
20
  ## 1.0.1
8
21
 
9
22
  ### Fixed
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.0.1
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
@@ -97,8 +98,10 @@ module PatientHttp
97
98
  def configure
98
99
  configuration = Configuration.new
99
100
  yield(configuration) if block_given?
100
- register_handler
101
101
  @configuration = configuration
102
+ @external_storage = nil
103
+ register_handler
104
+ configuration
102
105
  end
103
106
 
104
107
  # Ensure configuration is initialized
@@ -111,6 +114,7 @@ module PatientHttp
111
114
  # @return [Configuration]
112
115
  def reset_configuration!
113
116
  @configuration = nil
117
+ @external_storage = nil
114
118
  configuration
115
119
  end
116
120
 
@@ -234,6 +238,8 @@ module PatientHttp
234
238
 
235
239
  # Register Sidekiq as the request handler for processing HTTP requests. This is called
236
240
  # automatically when the processor starts or you call PatientHttp::Sidekiq.configure.
241
+ #
242
+ # @return [void]
237
243
  def register_handler
238
244
  @request_handler ||= lambda do |request:, callback:, raise_error_responses:, callback_args:|
239
245
  execute(
@@ -251,14 +257,16 @@ module PatientHttp
251
257
  #
252
258
  # @return [void]
253
259
  def start
254
- return if running?
260
+ @lifecycle_mutex.synchronize do
261
+ return if @processor && !@processor.stopped?
255
262
 
256
- @processor = PatientHttp::Processor.new(configuration)
257
- @processor.observe(ProcessorObserver.new(@processor))
258
- configuration.observers.each do |observer|
259
- @processor.observe(observer)
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
260
269
  end
261
- @processor.start
262
270
 
263
271
  register_handler
264
272
  end
@@ -267,9 +275,11 @@ module PatientHttp
267
275
  #
268
276
  # @return [void]
269
277
  def quiet
270
- return unless running?
278
+ @lifecycle_mutex.synchronize do
279
+ return unless running?
271
280
 
272
- @processor.drain
281
+ @processor.drain
282
+ end
273
283
  end
274
284
 
275
285
  # Stop the processor gracefully
@@ -281,10 +291,12 @@ module PatientHttp
281
291
  PatientHttp.unregister_handler(@request_handler)
282
292
  end
283
293
 
284
- return unless @processor
294
+ @lifecycle_mutex.synchronize do
295
+ return unless @processor
285
296
 
286
- @processor.stop(timeout: timeout)
287
- @processor = nil
297
+ @processor.stop(timeout: timeout)
298
+ @processor = nil
299
+ end
288
300
  end
289
301
 
290
302
  # Reset all state (useful for testing)
@@ -292,8 +304,10 @@ module PatientHttp
292
304
  # @return [void]
293
305
  # @api private
294
306
  def reset!
295
- @processor&.stop(timeout: 0)
296
- @processor = nil
307
+ @lifecycle_mutex.synchronize do
308
+ @processor&.stop(timeout: 0)
309
+ @processor = nil
310
+ end
297
311
  @configuration = nil
298
312
  @external_storage = nil
299
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.1
4
+ version: 1.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Durand