patient_http 1.3.0 → 1.5.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 +4 -4
- data/ARCHITECTURE.md +19 -7
- data/CHANGELOG.md +38 -0
- data/README.md +21 -4
- data/VERSION +1 -1
- data/lib/patient_http/client.rb +27 -2
- data/lib/patient_http/client_pool.rb +14 -7
- data/lib/patient_http/completion_executor.rb +139 -0
- data/lib/patient_http/configuration.rb +46 -1
- data/lib/patient_http/payload.rb +37 -15
- data/lib/patient_http/processor.rb +387 -113
- data/lib/patient_http/processor_observer.rb +71 -3
- data/lib/patient_http/request.rb +23 -2
- data/lib/patient_http/request_helper.rb +15 -6
- data/lib/patient_http/request_preparer.rb +7 -0
- data/lib/patient_http/request_task.rb +2 -1
- data/lib/patient_http/request_template.rb +8 -3
- data/lib/patient_http/response_reader.rb +225 -17
- data/lib/patient_http/synchronous_executor.rb +16 -33
- data/lib/patient_http.rb +13 -2
- data/patient_http.gemspec +0 -2
- metadata +3 -16
|
@@ -9,9 +9,22 @@ module PatientHttp
|
|
|
9
9
|
# Timing constants for the reactor loop
|
|
10
10
|
DEQUEUE_TIMEOUT = 1.0 # Seconds to wait when dequeueing requests
|
|
11
11
|
|
|
12
|
+
# Base delay between attempts when delivering a completed result fails.
|
|
13
|
+
# The delay grows linearly with each attempt.
|
|
14
|
+
COMPLETION_RETRY_DELAY = 0.5
|
|
15
|
+
|
|
16
|
+
# Seconds allowed for the completion executor to drain during shutdown.
|
|
17
|
+
# The reactor's teardown and stop() share this budget so the reactor can
|
|
18
|
+
# never spend longer draining than stop() is willing to wait for it.
|
|
19
|
+
COMPLETION_SHUTDOWN_TIMEOUT = 5
|
|
20
|
+
|
|
12
21
|
# @return [Configuration] the configuration object for the processor
|
|
13
22
|
attr_reader :config
|
|
14
23
|
|
|
24
|
+
# @return [String] the processor's name; used in thread names so multiple
|
|
25
|
+
# named processors in one process are distinguishable
|
|
26
|
+
attr_reader :name
|
|
27
|
+
|
|
15
28
|
# Callback to invoke after each request. Only available in testing mode.
|
|
16
29
|
# @api private
|
|
17
30
|
attr_accessor :testing_callback
|
|
@@ -19,9 +32,12 @@ module PatientHttp
|
|
|
19
32
|
# Initialize the processor.
|
|
20
33
|
#
|
|
21
34
|
# @param config [Configuration] the configuration object
|
|
35
|
+
# @param name [String, Symbol] optional name to distinguish this processor
|
|
36
|
+
# when a process runs more than one
|
|
22
37
|
# @return [void]
|
|
23
|
-
def initialize(config)
|
|
38
|
+
def initialize(config, name: "default")
|
|
24
39
|
@config = config
|
|
40
|
+
@name = name.to_s
|
|
25
41
|
@lifecycle = LifecycleManager.new
|
|
26
42
|
@queue = Thread::Queue.new
|
|
27
43
|
@reactor_thread = nil
|
|
@@ -33,11 +49,16 @@ module PatientHttp
|
|
|
33
49
|
@reactor_generation = 0
|
|
34
50
|
@inflight_requests = Concurrent::Hash.new
|
|
35
51
|
@pending_tasks = Concurrent::Hash.new
|
|
52
|
+
# Tasks pushed onto @queue but not yet popped by the reactor. Kept in a
|
|
53
|
+
# hash because Thread::Queue cannot be enumerated; used to report all
|
|
54
|
+
# tracked task ids (e.g. for heartbeat updates on queued tasks).
|
|
55
|
+
@queued_tasks = Concurrent::Hash.new
|
|
36
56
|
@tasks_lock = Mutex.new
|
|
37
57
|
@idle_condition = ConditionVariable.new
|
|
38
58
|
@testing_callback = nil
|
|
39
59
|
@http_client = Client.new(self)
|
|
40
60
|
@observers = []
|
|
61
|
+
@completion_executor = nil
|
|
41
62
|
end
|
|
42
63
|
|
|
43
64
|
# Start the processor.
|
|
@@ -57,8 +78,20 @@ module PatientHttp
|
|
|
57
78
|
@reactor_generation += 1
|
|
58
79
|
end
|
|
59
80
|
|
|
81
|
+
# The completion executor delivers finished results on its own worker
|
|
82
|
+
# threads so the reactor thread never blocks on response decoding,
|
|
83
|
+
# serialization, or callback delivery. A new executor is created for
|
|
84
|
+
# each run, like the reactor thread.
|
|
85
|
+
executor = CompletionExecutor.new(
|
|
86
|
+
threads: @config.completion_threads,
|
|
87
|
+
logger: @config.logger,
|
|
88
|
+
thread_name_prefix: thread_name("patient-http-completion"),
|
|
89
|
+
on_finished: -> { signal_idle }
|
|
90
|
+
)
|
|
91
|
+
@tasks_lock.synchronize { @completion_executor = executor }
|
|
92
|
+
|
|
60
93
|
@reactor_thread = Thread.new do
|
|
61
|
-
Thread.current.name = "patient-http-processor"
|
|
94
|
+
Thread.current.name = thread_name("patient-http-processor")
|
|
62
95
|
run_reactor
|
|
63
96
|
rescue => e
|
|
64
97
|
@config.logger&.error("[PatientHttp] Processor error: #{e.message}\n#{e.backtrace.join("\n")}")
|
|
@@ -76,14 +109,33 @@ module PatientHttp
|
|
|
76
109
|
# lock; re-enqueueing runs outside it. This is idempotent with stop()'s
|
|
77
110
|
# reenqueue_pending_requests: whichever runs second snapshots an empty
|
|
78
111
|
# set.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
112
|
+
current_generation = @tasks_lock.synchronize { @reactor_generation == generation }
|
|
113
|
+
if current_generation
|
|
114
|
+
begin
|
|
115
|
+
# Drain the completion executor before stealing tracked tasks so
|
|
116
|
+
# results already handed off are delivered rather than retried.
|
|
117
|
+
# Tasks whose completion job never ran stay in in-flight tracking
|
|
118
|
+
# and are re-enqueued below. The drain is bounded so it cannot
|
|
119
|
+
# outlast stop()'s own shutdown budget, and it runs in its own
|
|
120
|
+
# block so the re-enqueue still happens if a stop() that gave up
|
|
121
|
+
# waiting kills this thread mid-drain.
|
|
122
|
+
executor.shutdown(timeout: COMPLETION_SHUTDOWN_TIMEOUT)
|
|
123
|
+
ensure
|
|
124
|
+
orphaned_tasks = @tasks_lock.synchronize do
|
|
125
|
+
if @reactor_generation == generation
|
|
126
|
+
drain_tracked_tasks_locked
|
|
127
|
+
else
|
|
128
|
+
[]
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
reenqueue_tasks(orphaned_tasks)
|
|
132
|
+
# Hand back tasks still sitting in the queue as well; a reactor
|
|
133
|
+
# that exits without a stop() call is the last owner of those
|
|
134
|
+
# tasks. stop() performs the same drain after reaping the
|
|
135
|
+
# reactor, and whichever drain runs second finds nothing left.
|
|
136
|
+
reenqueue_remaining_queue_items
|
|
84
137
|
end
|
|
85
138
|
end
|
|
86
|
-
reenqueue_tasks(orphaned_tasks)
|
|
87
139
|
end
|
|
88
140
|
|
|
89
141
|
# The transition can fail if the reactor thread already failed and
|
|
@@ -126,14 +178,15 @@ module PatientHttp
|
|
|
126
178
|
# Interrupt the reactor's queue wait by pushing a sentinel value
|
|
127
179
|
@queue.push(nil)
|
|
128
180
|
|
|
129
|
-
# Wait for in-flight and pending requests to complete
|
|
181
|
+
# Wait for in-flight and pending requests to complete, including
|
|
182
|
+
# results still being delivered by the completion executor.
|
|
130
183
|
# Queue items are not checked here — they will be re-enqueued by
|
|
131
184
|
# reenqueue_remaining_queue_items after the reactor thread exits.
|
|
132
185
|
if timeout > 0
|
|
133
186
|
deadline = monotonic_time + timeout
|
|
134
187
|
@tasks_lock.synchronize do
|
|
135
188
|
loop do
|
|
136
|
-
break if @pending_tasks.empty? && @inflight_requests.empty?
|
|
189
|
+
break if @pending_tasks.empty? && @inflight_requests.empty? && completion_executor_settled?
|
|
137
190
|
remaining = deadline - monotonic_time
|
|
138
191
|
break if remaining <= 0
|
|
139
192
|
@idle_condition.wait(@tasks_lock, remaining)
|
|
@@ -149,6 +202,10 @@ module PatientHttp
|
|
|
149
202
|
# exits on its own once the callback returns (its loop sees the stopped
|
|
150
203
|
# state) and its ensure block performs the same cleanup.
|
|
151
204
|
if reactor && !reactor.equal?(Thread.current)
|
|
205
|
+
# The join stays short: the reactor's teardown drains the completion
|
|
206
|
+
# executor, which can join this very thread when stop was called from
|
|
207
|
+
# a completion callback. Killing the reactor breaks that standoff and
|
|
208
|
+
# its teardown still re-enqueues from an ensure block.
|
|
152
209
|
reactor.join(1) if reactor.alive?
|
|
153
210
|
if reactor.alive?
|
|
154
211
|
reactor.kill
|
|
@@ -161,6 +218,12 @@ module PatientHttp
|
|
|
161
218
|
@reactor_thread = nil if @reactor_thread.equal?(reactor)
|
|
162
219
|
end
|
|
163
220
|
|
|
221
|
+
# Shut down the completion executor. The reactor's own teardown
|
|
222
|
+
# normally drains it already; this pass reaps any worker that is still
|
|
223
|
+
# stuck past the deadline. Remaining queued jobs belong to tasks that
|
|
224
|
+
# were re-enqueued above, so they no-op when their claim fails.
|
|
225
|
+
@completion_executor&.shutdown(timeout: COMPLETION_SHUTDOWN_TIMEOUT)
|
|
226
|
+
|
|
164
227
|
# Run a second pass now that the reactor has exited to catch any task
|
|
165
228
|
# that slipped into pending/in-flight tracking after the first snapshot
|
|
166
229
|
# (a task can be popped from the queue but not yet tracked when the
|
|
@@ -198,22 +261,18 @@ module PatientHttp
|
|
|
198
261
|
# @raise [MaxCapacityError] if at max capacity
|
|
199
262
|
# @return [void]
|
|
200
263
|
def enqueue(task)
|
|
201
|
-
|
|
264
|
+
raise NotRunningError.new("Cannot enqueue request: processor is #{state}") unless running?
|
|
202
265
|
|
|
203
|
-
|
|
266
|
+
accepted = announce_and_enqueue(task) do
|
|
267
|
+
# The pre-check above is advisory; re-check the running state under
|
|
268
|
+
# the lock since observers were notified outside of it.
|
|
204
269
|
raise NotRunningError.new("Cannot enqueue request: processor is #{state}") unless running?
|
|
205
270
|
|
|
206
|
-
# Check capacity -
|
|
207
|
-
|
|
208
|
-
if total >= @config.max_connections
|
|
209
|
-
at_capacity = true
|
|
210
|
-
else
|
|
211
|
-
task.enqueued!
|
|
212
|
-
@queue.push(task)
|
|
213
|
-
end
|
|
271
|
+
# Check capacity - the task is only accepted below max connections.
|
|
272
|
+
@queue.size + @pending_tasks.size + @inflight_requests.size < @config.max_connections
|
|
214
273
|
end
|
|
215
274
|
|
|
216
|
-
|
|
275
|
+
unless accepted
|
|
217
276
|
notify_observers { |observer| observer.capacity_exceeded }
|
|
218
277
|
raise MaxCapacityError.new("Cannot enqueue request: already at max capacity (#{@config.max_connections} connections)")
|
|
219
278
|
end
|
|
@@ -268,13 +327,39 @@ module PatientHttp
|
|
|
268
327
|
@lifecycle.stopping?
|
|
269
328
|
end
|
|
270
329
|
|
|
271
|
-
# Check if processor is idle (no queued or in-flight requests
|
|
330
|
+
# Check if processor is idle (no queued or in-flight requests, and no
|
|
331
|
+
# results still being delivered by the completion executor).
|
|
272
332
|
#
|
|
273
333
|
# @return [Boolean]
|
|
274
334
|
def idle?
|
|
275
|
-
@
|
|
335
|
+
executor = @completion_executor
|
|
336
|
+
tracking_empty = @tasks_lock.synchronize do
|
|
276
337
|
@queue.empty? && @pending_tasks.empty? && @inflight_requests.empty?
|
|
277
338
|
end
|
|
339
|
+
|
|
340
|
+
tracking_empty && (executor.nil? || executor.idle?)
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# Check how many more requests the processor can accept before reaching
|
|
344
|
+
# max capacity. This is an advisory value: the authoritative check happens
|
|
345
|
+
# inside {#enqueue}, so a concurrent enqueue can still hit
|
|
346
|
+
# {MaxCapacityError}. It performs no observer notifications and no durable
|
|
347
|
+
# registration, so it is cheap to call before paying enqueue costs.
|
|
348
|
+
#
|
|
349
|
+
# @return [Integer] remaining capacity (never negative)
|
|
350
|
+
def remaining_capacity
|
|
351
|
+
@tasks_lock.synchronize do
|
|
352
|
+
remaining = @config.max_connections - (@queue.size + @pending_tasks.size + @inflight_requests.size)
|
|
353
|
+
(remaining > 0) ? remaining : 0
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Check if the processor can accept at least one more request. Advisory
|
|
358
|
+
# only; see {#remaining_capacity}.
|
|
359
|
+
#
|
|
360
|
+
# @return [Boolean]
|
|
361
|
+
def capacity_available?
|
|
362
|
+
remaining_capacity > 0
|
|
278
363
|
end
|
|
279
364
|
|
|
280
365
|
# Get the number of in-flight requests (actively executing HTTP calls).
|
|
@@ -307,6 +392,17 @@ module PatientHttp
|
|
|
307
392
|
end
|
|
308
393
|
end
|
|
309
394
|
|
|
395
|
+
# Get the IDs of all tasks in the pipeline (queued, pending, and in-flight).
|
|
396
|
+
# Use this to keep durable tracking (e.g. heartbeats) alive for tasks the
|
|
397
|
+
# processor has accepted but not yet started.
|
|
398
|
+
#
|
|
399
|
+
# @return [Array<String>]
|
|
400
|
+
def tracked_request_ids
|
|
401
|
+
@tasks_lock.synchronize do
|
|
402
|
+
(@queued_tasks.keys + @pending_tasks.keys + @inflight_requests.keys).uniq
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
|
|
310
406
|
# Add an observer for processor events.
|
|
311
407
|
#
|
|
312
408
|
# @param observer [ProcessorObserver] the observer to add
|
|
@@ -373,6 +469,16 @@ module PatientHttp
|
|
|
373
469
|
|
|
374
470
|
private
|
|
375
471
|
|
|
472
|
+
# Build a thread name for this processor. The default processor keeps the
|
|
473
|
+
# bare prefix; named processors append their name so multiple processors
|
|
474
|
+
# in one process are distinguishable.
|
|
475
|
+
#
|
|
476
|
+
# @param prefix [String] the base thread name
|
|
477
|
+
# @return [String]
|
|
478
|
+
def thread_name(prefix)
|
|
479
|
+
(@name == "default") ? prefix : "#{prefix}-#{@name}"
|
|
480
|
+
end
|
|
481
|
+
|
|
376
482
|
# Run the async reactor loop.
|
|
377
483
|
#
|
|
378
484
|
# @return [void]
|
|
@@ -393,6 +499,7 @@ module PatientHttp
|
|
|
393
499
|
|
|
394
500
|
# Track as pending immediately to avoid race condition with stop()
|
|
395
501
|
@tasks_lock.synchronize do
|
|
502
|
+
@queued_tasks.delete(request_task.id)
|
|
396
503
|
@pending_tasks[request_task.id] = request_task
|
|
397
504
|
end
|
|
398
505
|
|
|
@@ -473,14 +580,15 @@ module PatientHttp
|
|
|
473
580
|
|
|
474
581
|
@pending_tasks.delete(task.id)
|
|
475
582
|
@inflight_requests[task.id] = task
|
|
583
|
+
# Mark the task started in the same locked section that tracks it so
|
|
584
|
+
# a shutdown snapshot always sees a consistent started state. The
|
|
585
|
+
# shutdown re-enqueue path uses started? to pair request_end with
|
|
586
|
+
# request_start.
|
|
587
|
+
task.started!
|
|
476
588
|
end
|
|
477
589
|
|
|
478
590
|
notify_observers { |observer| observer.request_start(task) }
|
|
479
591
|
|
|
480
|
-
# Mark task as started
|
|
481
|
-
task.started!
|
|
482
|
-
claimed = false
|
|
483
|
-
|
|
484
592
|
begin
|
|
485
593
|
response_data = @http_client.make_request(task.request, task.id)
|
|
486
594
|
|
|
@@ -488,24 +596,14 @@ module PatientHttp
|
|
|
488
596
|
# shutdown sequence has re-enqueued the task; discard the response.
|
|
489
597
|
return if stopped?
|
|
490
598
|
|
|
491
|
-
# Check for redirect handling. handle_redirect claims the task itself
|
|
492
|
-
# (atomically with enqueueing the redirect) and returns whether this
|
|
493
|
-
# caller owns delivery, so the ensure block can finish the task.
|
|
494
599
|
if should_follow_redirect?(task, response_data)
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
if task.raise_error_responses && !response.success?
|
|
503
|
-
http_error = HttpError.new(response)
|
|
504
|
-
notify_observers { |observer| observer.request_error(http_error) }
|
|
505
|
-
handle_error(task, http_error)
|
|
506
|
-
else
|
|
507
|
-
handle_completion(task, response)
|
|
508
|
-
end
|
|
600
|
+
handle_redirect(task, response_data)
|
|
601
|
+
else
|
|
602
|
+
# Hand the result to the completion executor without claiming the
|
|
603
|
+
# task. The task stays in in-flight tracking until a completion
|
|
604
|
+
# worker claims it, so the shutdown re-enqueue protocol covers
|
|
605
|
+
# results that are queued but not yet delivered.
|
|
606
|
+
dispatch_completion(task, response_data: response_data)
|
|
509
607
|
end
|
|
510
608
|
rescue ResponseReader::ReadAbortedError
|
|
511
609
|
# The processor stopped past its shutdown deadline while the response
|
|
@@ -513,24 +611,103 @@ module PatientHttp
|
|
|
513
611
|
# there is nothing to deliver.
|
|
514
612
|
nil
|
|
515
613
|
rescue => e
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
614
|
+
dispatch_completion(task, error: e)
|
|
615
|
+
end
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
# Hand off a finished HTTP exchange to the completion executor.
|
|
619
|
+
#
|
|
620
|
+
# @param task [RequestTask] the request task
|
|
621
|
+
# @param response_data [Hash, nil] raw response data on success
|
|
622
|
+
# @param error [Exception, nil] the error on failure
|
|
623
|
+
# @return [void]
|
|
624
|
+
def dispatch_completion(task, response_data: nil, error: nil)
|
|
625
|
+
executor = @completion_executor
|
|
626
|
+
executor.enqueue(-> { run_completion(task, response_data: response_data, error: error) })
|
|
627
|
+
rescue ClosedQueueError
|
|
628
|
+
# The executor is already shut down. The task is still tracked, so the
|
|
629
|
+
# shutdown sequence re-enqueues it.
|
|
630
|
+
nil
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
# Deliver a finished result on a completion worker thread: decode the
|
|
634
|
+
# response, claim the task, run the result callbacks, and notify
|
|
635
|
+
# observers. When delivery fails after all retries, request_end is NOT
|
|
636
|
+
# fired so durable tracking (crash-recovery records) stays in place and
|
|
637
|
+
# the request can be recovered instead of silently lost.
|
|
638
|
+
#
|
|
639
|
+
# @param task [RequestTask] the request task
|
|
640
|
+
# @param response_data [Hash, nil] raw response data on success
|
|
641
|
+
# @param error [Exception, nil] the error on failure
|
|
642
|
+
# @return [void]
|
|
643
|
+
def run_completion(task, response_data: nil, error: nil)
|
|
644
|
+
response = nil
|
|
645
|
+
|
|
646
|
+
if error.nil?
|
|
647
|
+
begin
|
|
648
|
+
response = task.build_response(**@http_client.decode_response(response_data))
|
|
649
|
+
if task.raise_error_responses && !response.success?
|
|
650
|
+
error = HttpError.new(response)
|
|
651
|
+
end
|
|
652
|
+
rescue => e
|
|
653
|
+
error = e
|
|
654
|
+
end
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
# A claim failure means the shutdown sequence already re-enqueued the
|
|
658
|
+
# task; the result must not be delivered.
|
|
659
|
+
return unless claim_task(task)
|
|
660
|
+
|
|
661
|
+
failure = nil
|
|
662
|
+
begin
|
|
663
|
+
if error
|
|
664
|
+
notify_observers { |observer| observer.request_error(error) }
|
|
665
|
+
failure = handle_error(task, error)
|
|
666
|
+
else
|
|
667
|
+
failure = handle_completion(task, response)
|
|
668
|
+
end
|
|
669
|
+
|
|
670
|
+
if failure.nil?
|
|
671
|
+
finish_task(task)
|
|
672
|
+
else
|
|
673
|
+
notify_observers { |observer| observer.completion_failed(task, failure) }
|
|
527
674
|
end
|
|
528
675
|
ensure
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
676
|
+
@testing_callback&.call(task) if PatientHttp.testing?
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
raise failure if failure && PatientHttp.testing?
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
# Run a delivery block with bounded retries. Returns nil when the block
|
|
683
|
+
# succeeds or the final exception when all attempts fail. Retries back off
|
|
684
|
+
# linearly; sleeping is safe here because delivery runs on a completion
|
|
685
|
+
# worker thread, not the reactor.
|
|
686
|
+
#
|
|
687
|
+
# The block calls into the task handler, so a retry calls the handler
|
|
688
|
+
# again. A handler that raises after its side effect therefore repeats that
|
|
689
|
+
# side effect; handlers must be idempotent, or completion_retries must be
|
|
690
|
+
# set to zero.
|
|
691
|
+
#
|
|
692
|
+
# @param task [RequestTask] the request task (for log context)
|
|
693
|
+
# @return [Exception, nil] the final failure or nil on success
|
|
694
|
+
def deliver_with_retries(task)
|
|
695
|
+
attempts = 0
|
|
696
|
+
|
|
697
|
+
begin
|
|
698
|
+
yield
|
|
699
|
+
nil
|
|
700
|
+
rescue => e
|
|
701
|
+
attempts += 1
|
|
702
|
+
if attempts <= @config.completion_retries
|
|
703
|
+
@config.logger&.warn(
|
|
704
|
+
"[PatientHttp] Retrying result delivery for request #{task.id} " \
|
|
705
|
+
"(attempt #{attempts + 1}): #{e.class} - #{e.message}"
|
|
706
|
+
)
|
|
707
|
+
sleep(COMPLETION_RETRY_DELAY * attempts) unless PatientHttp.testing?
|
|
708
|
+
retry
|
|
709
|
+
end
|
|
710
|
+
e
|
|
534
711
|
end
|
|
535
712
|
end
|
|
536
713
|
|
|
@@ -554,12 +731,33 @@ module PatientHttp
|
|
|
554
731
|
# @param task [RequestTask] the request task
|
|
555
732
|
# @return [void]
|
|
556
733
|
def finish_task(task)
|
|
734
|
+
signal_idle
|
|
735
|
+
notify_observers { |observer| observer.request_end(task) }
|
|
736
|
+
end
|
|
737
|
+
|
|
738
|
+
# Broadcast the idle condition when the pipeline is empty. Called after a
|
|
739
|
+
# claimed task finishes and by the completion executor after each job, so
|
|
740
|
+
# stop() waiters wake once the last delivery completes.
|
|
741
|
+
#
|
|
742
|
+
# @return [void]
|
|
743
|
+
def signal_idle
|
|
744
|
+
executor = @completion_executor
|
|
557
745
|
@tasks_lock.synchronize do
|
|
558
|
-
if @pending_tasks.empty? && @inflight_requests.empty?
|
|
746
|
+
if @pending_tasks.empty? && @inflight_requests.empty? && (executor.nil? || executor.idle?)
|
|
559
747
|
@idle_condition.broadcast
|
|
560
748
|
end
|
|
561
749
|
end
|
|
562
|
-
|
|
750
|
+
end
|
|
751
|
+
|
|
752
|
+
# Check whether stop() should keep waiting on the completion executor.
|
|
753
|
+
# When stop is called from a completion worker itself (via a result
|
|
754
|
+
# callback), its own in-progress job would never settle, so it is treated
|
|
755
|
+
# as settled to avoid waiting out the full timeout.
|
|
756
|
+
#
|
|
757
|
+
# @return [Boolean]
|
|
758
|
+
def completion_executor_settled?
|
|
759
|
+
executor = @completion_executor
|
|
760
|
+
executor.nil? || executor.worker_thread? || executor.idle?
|
|
563
761
|
end
|
|
564
762
|
|
|
565
763
|
# Handle successful response. The caller must have claimed the task via
|
|
@@ -567,32 +765,36 @@ module PatientHttp
|
|
|
567
765
|
#
|
|
568
766
|
# @param task [RequestTask] the request task
|
|
569
767
|
# @param response [Response] the response object
|
|
570
|
-
# @return [
|
|
768
|
+
# @return [Exception, nil] the delivery failure or nil on success
|
|
571
769
|
def handle_completion(task, response)
|
|
572
|
-
task.completed!(response)
|
|
770
|
+
failure = deliver_with_retries(task) { task.completed!(response) }
|
|
573
771
|
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
772
|
+
if failure
|
|
773
|
+
@config.logger&.error(
|
|
774
|
+
"[PatientHttp] Failed to enqueue completion callback for request #{task.id}: " \
|
|
775
|
+
"#{failure.class} - #{failure.message}"
|
|
776
|
+
)
|
|
777
|
+
else
|
|
778
|
+
@config.logger&.debug(
|
|
779
|
+
"[PatientHttp] Request #{task.id} succeeded with status #{response.status}, " \
|
|
780
|
+
"enqueued callback #{task.callback}"
|
|
781
|
+
)
|
|
782
|
+
end
|
|
783
|
+
|
|
784
|
+
failure
|
|
583
785
|
end
|
|
584
786
|
|
|
585
|
-
# Handle a redirect response.
|
|
787
|
+
# Handle a redirect response on the reactor thread.
|
|
586
788
|
#
|
|
587
|
-
#
|
|
588
|
-
#
|
|
589
|
-
#
|
|
590
|
-
#
|
|
591
|
-
#
|
|
789
|
+
# Redirect errors are handed to the completion executor for delivery.
|
|
790
|
+
# When following a redirect, the original task is removed from in-flight
|
|
791
|
+
# tracking and the redirect task is pushed onto the queue within a single
|
|
792
|
+
# {@tasks_lock} section, so a concurrent {#idle?} never observes a moment
|
|
793
|
+
# where neither is tracked.
|
|
592
794
|
#
|
|
593
795
|
# @param task [RequestTask] the request task
|
|
594
796
|
# @param response_data [Hash] the response data with status, headers, body
|
|
595
|
-
# @return [
|
|
797
|
+
# @return [void]
|
|
596
798
|
def handle_redirect(task, response_data)
|
|
597
799
|
status = response_data[:status]
|
|
598
800
|
location = response_data[:headers]["location"]
|
|
@@ -600,30 +802,32 @@ module PatientHttp
|
|
|
600
802
|
# Check for redirect errors
|
|
601
803
|
error = check_redirect_error(task, response_data)
|
|
602
804
|
if error
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
notify_observers { |observer| observer.request_error(error) }
|
|
606
|
-
handle_error(task, error)
|
|
607
|
-
return true
|
|
805
|
+
dispatch_completion(task, error: error)
|
|
806
|
+
return
|
|
608
807
|
end
|
|
609
808
|
|
|
610
809
|
# Create the redirect task, then atomically claim the original (remove it
|
|
611
810
|
# from in-flight) and enqueue the redirect. If the claim fails the
|
|
612
811
|
# shutdown sequence already re-enqueued the original, so drop the redirect.
|
|
613
812
|
redirect_task = task.redirect_task(location: location, status: status)
|
|
614
|
-
redirect_task.enqueued!
|
|
615
|
-
|
|
616
|
-
claimed = @tasks_lock.synchronize do
|
|
617
|
-
next false if @inflight_requests.delete(task.id).nil?
|
|
618
813
|
|
|
619
|
-
|
|
620
|
-
|
|
814
|
+
begin
|
|
815
|
+
claimed = announce_and_enqueue(redirect_task) do
|
|
816
|
+
!@inflight_requests.delete(task.id).nil?
|
|
817
|
+
end
|
|
818
|
+
rescue => e
|
|
819
|
+
# The redirect could not be registered (e.g. durable tracking setup
|
|
820
|
+
# failed). Deliver the failure as the original task's result.
|
|
821
|
+
dispatch_completion(task, error: e)
|
|
822
|
+
return
|
|
621
823
|
end
|
|
622
|
-
return
|
|
824
|
+
return unless claimed
|
|
623
825
|
|
|
624
826
|
redirect_url = resolve_redirect_url(task.request.url, location)
|
|
625
827
|
@config.logger&.debug("[PatientHttp] Request #{task.id} redirected (#{status}) to #{redirect_url}")
|
|
626
|
-
|
|
828
|
+
|
|
829
|
+
finish_task(task)
|
|
830
|
+
@testing_callback&.call(task) if PatientHttp.testing?
|
|
627
831
|
end
|
|
628
832
|
|
|
629
833
|
# Handle error response. The caller must have claimed the task via
|
|
@@ -631,19 +835,64 @@ module PatientHttp
|
|
|
631
835
|
#
|
|
632
836
|
# @param task [RequestTask] the request task
|
|
633
837
|
# @param exception [Exception] the exception
|
|
634
|
-
# @return [
|
|
838
|
+
# @return [Exception, nil] the delivery failure or nil on success
|
|
635
839
|
def handle_error(task, exception)
|
|
636
|
-
task.error!(exception)
|
|
840
|
+
failure = deliver_with_retries(task) { task.error!(exception) }
|
|
637
841
|
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
842
|
+
if failure
|
|
843
|
+
@config.logger&.error(
|
|
844
|
+
"[PatientHttp] Failed to enqueue error worker for request #{task.id}: " \
|
|
845
|
+
"#{failure.class} - #{failure.message}"
|
|
846
|
+
)
|
|
847
|
+
else
|
|
848
|
+
@config.logger&.warn(
|
|
849
|
+
"[PatientHttp] Request #{task.id} failed with #{exception.class.name}: #{exception.message}, " \
|
|
850
|
+
"enqueued callback #{task.callback}\n#{exception.backtrace&.join("\n")}"
|
|
851
|
+
)
|
|
852
|
+
end
|
|
853
|
+
|
|
854
|
+
failure
|
|
855
|
+
end
|
|
856
|
+
|
|
857
|
+
# Announce a task to observers and make it visible to the reactor. The
|
|
858
|
+
# task is announced before it can start, finish, or be re-enqueued, so
|
|
859
|
+
# observers can set up durable tracking first. Errors from the
|
|
860
|
+
# request_enqueued announcement propagate and reject the task, because a
|
|
861
|
+
# failed tracking setup must not let the task be accepted as if it were
|
|
862
|
+
# durable. The block runs while {@tasks_lock} is held and decides whether
|
|
863
|
+
# the task is accepted; when it returns false or raises, observers receive
|
|
864
|
+
# request_rejected so they can tear down anything they set up for the
|
|
865
|
+
# request_enqueued announcement. The rejection notification never replaces
|
|
866
|
+
# an exception that is already being raised.
|
|
867
|
+
#
|
|
868
|
+
# @param task [RequestTask] the request task to announce and enqueue
|
|
869
|
+
# @return [Boolean] true if the task was accepted
|
|
870
|
+
def announce_and_enqueue(task)
|
|
871
|
+
task.enqueued!
|
|
872
|
+
accepted = false
|
|
873
|
+
|
|
874
|
+
begin
|
|
875
|
+
notify_observers! { |observer| observer.request_enqueued(task) }
|
|
876
|
+
|
|
877
|
+
@tasks_lock.synchronize do
|
|
878
|
+
if yield
|
|
879
|
+
@queued_tasks[task.id] = task
|
|
880
|
+
@queue.push(task)
|
|
881
|
+
accepted = true
|
|
882
|
+
end
|
|
883
|
+
end
|
|
884
|
+
ensure
|
|
885
|
+
unless accepted
|
|
886
|
+
pending_error = $!
|
|
887
|
+
begin
|
|
888
|
+
notify_observers { |observer| observer.request_rejected(task) }
|
|
889
|
+
rescue
|
|
890
|
+
raise unless pending_error
|
|
891
|
+
end
|
|
892
|
+
end
|
|
893
|
+
end
|
|
894
|
+
|
|
895
|
+
accepted
|
|
647
896
|
end
|
|
648
897
|
|
|
649
898
|
# Notify all observers of an event. Observers are called outside of any
|
|
@@ -655,6 +904,16 @@ module PatientHttp
|
|
|
655
904
|
end
|
|
656
905
|
end
|
|
657
906
|
|
|
907
|
+
# Notify all observers of an event and let observer errors propagate.
|
|
908
|
+
# Used for notifications the caller must be able to react to, such as
|
|
909
|
+
# durable tracking setup in request_enqueued.
|
|
910
|
+
def notify_observers!
|
|
911
|
+
observers = @tasks_lock.synchronize { @observers.dup }
|
|
912
|
+
observers.each do |observer|
|
|
913
|
+
yield(observer)
|
|
914
|
+
end
|
|
915
|
+
end
|
|
916
|
+
|
|
658
917
|
def notify_observer(observer)
|
|
659
918
|
yield(observer)
|
|
660
919
|
rescue => e
|
|
@@ -697,16 +956,27 @@ module PatientHttp
|
|
|
697
956
|
end
|
|
698
957
|
|
|
699
958
|
def reenqueue_remaining_queue_items
|
|
700
|
-
tasks_to_reenqueue =
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
959
|
+
tasks_to_reenqueue = @tasks_lock.synchronize do
|
|
960
|
+
tasks = []
|
|
961
|
+
|
|
962
|
+
# Drain remaining items from the queue (skip nil sentinels from stop)
|
|
963
|
+
until @queue.empty?
|
|
964
|
+
begin
|
|
965
|
+
task = @queue.pop(true)
|
|
966
|
+
tasks << task if task
|
|
967
|
+
rescue ThreadError
|
|
968
|
+
break
|
|
969
|
+
end
|
|
709
970
|
end
|
|
971
|
+
|
|
972
|
+
tasks.each { |task| @queued_tasks.delete(task.id) }
|
|
973
|
+
# The reactor has exited and no new tasks can be accepted, so any id
|
|
974
|
+
# still tracked as queued belongs to a task that left the queue
|
|
975
|
+
# without reaching pending or in-flight tracking. Reclaim those tasks
|
|
976
|
+
# as well so they are not tracked forever.
|
|
977
|
+
tasks.concat(@queued_tasks.values)
|
|
978
|
+
@queued_tasks.clear
|
|
979
|
+
tasks
|
|
710
980
|
end
|
|
711
981
|
|
|
712
982
|
reenqueue_tasks(tasks_to_reenqueue)
|
|
@@ -715,6 +985,10 @@ module PatientHttp
|
|
|
715
985
|
def reenqueue_tasks(tasks_to_reenqueue)
|
|
716
986
|
tasks_to_reenqueue.each do |task|
|
|
717
987
|
task.retry
|
|
988
|
+
# The task handler's job system owns the request again; let observers
|
|
989
|
+
# tear down any durable tracking for the task. Only sent after a
|
|
990
|
+
# successful retry so a failed retry leaves the tracking in place.
|
|
991
|
+
notify_observers { |observer| observer.request_requeued(task) }
|
|
718
992
|
# Only emit request_end for tasks that actually started, so observers
|
|
719
993
|
# that pair request_start/request_end (e.g. an in-flight gauge) stay
|
|
720
994
|
# balanced. Queued-but-never-started tasks emit neither.
|