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 +4 -4
- data/CHANGELOG.md +13 -0
- data/VERSION +1 -1
- data/lib/patient_http/sidekiq/callback_worker.rb +16 -15
- data/lib/patient_http/sidekiq/request_worker.rb +25 -15
- data/lib/patient_http/sidekiq/stats.rb +1 -1
- data/lib/patient_http/sidekiq/task_handler.rb +20 -0
- data/lib/patient_http/sidekiq/task_monitor.rb +49 -6
- data/lib/patient_http/sidekiq/task_monitor_thread.rb +4 -5
- data/lib/patient_http/sidekiq.rb +28 -14
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f244dd52326602169e67b8471527b939c3e451fd11dcbcfe424305d5ad0929dd
|
|
4
|
+
data.tar.gz: 28b502c3aca097c48e364195e338e475d67f4f584209797054e2cc8ed75dda90
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.0.2
|
|
@@ -53,7 +53,7 @@ module PatientHttp
|
|
|
53
53
|
end
|
|
54
54
|
|
|
55
55
|
begin
|
|
56
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
@@ -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
|
-
|
|
405
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
149
|
+
raise if PatientHttp.testing?
|
|
151
150
|
end
|
|
152
151
|
end
|
|
153
152
|
end
|
data/lib/patient_http/sidekiq.rb
CHANGED
|
@@ -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
|
-
|
|
260
|
+
@lifecycle_mutex.synchronize do
|
|
261
|
+
return if @processor && !@processor.stopped?
|
|
255
262
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
278
|
+
@lifecycle_mutex.synchronize do
|
|
279
|
+
return unless running?
|
|
271
280
|
|
|
272
|
-
|
|
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
|
-
|
|
294
|
+
@lifecycle_mutex.synchronize do
|
|
295
|
+
return unless @processor
|
|
285
296
|
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
@
|
|
296
|
-
|
|
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 = []
|