patient_http 1.3.0 → 1.4.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/CHANGELOG.md +11 -0
- data/README.md +8 -0
- data/VERSION +1 -1
- data/lib/patient_http/processor.rb +112 -28
- data/lib/patient_http/processor_observer.rb +35 -0
- data/patient_http.gemspec +0 -2
- metadata +2 -16
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d0888d7e92f43f2031bf7f0eb5b0f4dd528ac7b79957b506f1df61825309990a
|
|
4
|
+
data.tar.gz: 87b5817b8205a0b605edb7cb41facf0f6b5690122ff63cb669dbcc6c6b090cd0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4bd3586a382436ac11872d10380afb28326497c9538383ae0e884ecaf901dd952df0202e9ed740047b6336c60960ebff20b83abcdc86a585c67e0915097dbe92
|
|
7
|
+
data.tar.gz: d86c67c48c1aa03d0aa47f4675626696ab9012b526add67a4d8ebf611aca40b8229e722bd2e07c2ef6de6caa6110a01507d3317f029668fa89a768d145fea299
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,17 @@ 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.4.0
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `ProcessorObserver` events for the full task pipeline: `request_enqueued` is sent when a task is announced to the processor, before the task is visible to the reactor, so observers can set up durable tracking (e.g. a crash-recovery registry entry) before `Processor#enqueue` returns or raises. `request_rejected` is sent when an announced task is not accepted (not running or at capacity). `request_requeued` is sent when an incomplete task is re-enqueued through its task handler, so observers can tear down tracking for tasks the job system owns again. Redirect tasks are announced with `request_enqueued` as well.
|
|
12
|
+
- `Processor#tracked_request_ids` returns the IDs of all tasks in the pipeline (queued, pending, and in-flight), so durable tracking can keep heartbeats alive for tasks that have not started yet.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- A task is now marked as started before the `request_start` notification is sent. Before this fix, a shutdown that snapshotted the task between the two steps re-enqueued the task without a `request_end` notification, which could leak observer tracking for the task and cause a duplicate execution through crash recovery.
|
|
17
|
+
|
|
7
18
|
## 1.3.0
|
|
8
19
|
|
|
9
20
|
### Added
|
data/README.md
CHANGED
|
@@ -744,6 +744,14 @@ end
|
|
|
744
744
|
processor.observe(MetricsObserver.new)
|
|
745
745
|
```
|
|
746
746
|
|
|
747
|
+
Observers can also track the full task pipeline:
|
|
748
|
+
|
|
749
|
+
- `request_enqueued(request_task)` is called when a task is announced to the processor (before the task is visible to the reactor). It is guaranteed to arrive before `request_start`, so observers can set up durable tracking (e.g. a crash-recovery registry entry) before `Processor#enqueue` returns or raises.
|
|
750
|
+
- `request_rejected(request_task)` is called when an announced task is not accepted (not running or at capacity), so observers can tear down anything they set up in `request_enqueued`.
|
|
751
|
+
- `request_requeued(request_task)` is called when an incomplete task is re-enqueued through its task handler (processor shutdown or reactor failure). The job system owns the request again once this is sent.
|
|
752
|
+
|
|
753
|
+
Use `Processor#tracked_request_ids` to get the IDs of all tasks in the pipeline (queued, pending, and in-flight), for example to keep heartbeats alive for tasks that have not started yet.
|
|
754
|
+
|
|
747
755
|
## Testing
|
|
748
756
|
|
|
749
757
|
Use `SynchronousExecutor` to execute requests synchronously in tests. This class can be used in place of the async processor for testing your request handling logic without needing to start the full async infrastructure.
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.
|
|
1
|
+
1.4.0
|
|
@@ -33,6 +33,10 @@ module PatientHttp
|
|
|
33
33
|
@reactor_generation = 0
|
|
34
34
|
@inflight_requests = Concurrent::Hash.new
|
|
35
35
|
@pending_tasks = Concurrent::Hash.new
|
|
36
|
+
# Tasks pushed onto @queue but not yet popped by the reactor. Kept in a
|
|
37
|
+
# hash because Thread::Queue cannot be enumerated; used to report all
|
|
38
|
+
# tracked task ids (e.g. for heartbeat updates on queued tasks).
|
|
39
|
+
@queued_tasks = Concurrent::Hash.new
|
|
36
40
|
@tasks_lock = Mutex.new
|
|
37
41
|
@idle_condition = ConditionVariable.new
|
|
38
42
|
@testing_callback = nil
|
|
@@ -76,14 +80,21 @@ module PatientHttp
|
|
|
76
80
|
# lock; re-enqueueing runs outside it. This is idempotent with stop()'s
|
|
77
81
|
# reenqueue_pending_requests: whichever runs second snapshots an empty
|
|
78
82
|
# set.
|
|
83
|
+
current_generation = false
|
|
79
84
|
orphaned_tasks = @tasks_lock.synchronize do
|
|
80
85
|
if @reactor_generation == generation
|
|
86
|
+
current_generation = true
|
|
81
87
|
drain_tracked_tasks_locked
|
|
82
88
|
else
|
|
83
89
|
[]
|
|
84
90
|
end
|
|
85
91
|
end
|
|
86
92
|
reenqueue_tasks(orphaned_tasks)
|
|
93
|
+
# Hand back tasks still sitting in the queue as well; a reactor that
|
|
94
|
+
# exits without a stop() call is the last owner of those tasks.
|
|
95
|
+
# stop() performs the same drain after reaping the reactor, and
|
|
96
|
+
# whichever drain runs second finds nothing left.
|
|
97
|
+
reenqueue_remaining_queue_items if current_generation
|
|
87
98
|
end
|
|
88
99
|
|
|
89
100
|
# The transition can fail if the reactor thread already failed and
|
|
@@ -198,22 +209,18 @@ module PatientHttp
|
|
|
198
209
|
# @raise [MaxCapacityError] if at max capacity
|
|
199
210
|
# @return [void]
|
|
200
211
|
def enqueue(task)
|
|
201
|
-
|
|
212
|
+
raise NotRunningError.new("Cannot enqueue request: processor is #{state}") unless running?
|
|
202
213
|
|
|
203
|
-
|
|
214
|
+
accepted = announce_and_enqueue(task) do
|
|
215
|
+
# The pre-check above is advisory; re-check the running state under
|
|
216
|
+
# the lock since observers were notified outside of it.
|
|
204
217
|
raise NotRunningError.new("Cannot enqueue request: processor is #{state}") unless running?
|
|
205
218
|
|
|
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
|
|
219
|
+
# Check capacity - the task is only accepted below max connections.
|
|
220
|
+
@queue.size + @pending_tasks.size + @inflight_requests.size < @config.max_connections
|
|
214
221
|
end
|
|
215
222
|
|
|
216
|
-
|
|
223
|
+
unless accepted
|
|
217
224
|
notify_observers { |observer| observer.capacity_exceeded }
|
|
218
225
|
raise MaxCapacityError.new("Cannot enqueue request: already at max capacity (#{@config.max_connections} connections)")
|
|
219
226
|
end
|
|
@@ -307,6 +314,17 @@ module PatientHttp
|
|
|
307
314
|
end
|
|
308
315
|
end
|
|
309
316
|
|
|
317
|
+
# Get the IDs of all tasks in the pipeline (queued, pending, and in-flight).
|
|
318
|
+
# Use this to keep durable tracking (e.g. heartbeats) alive for tasks the
|
|
319
|
+
# processor has accepted but not yet started.
|
|
320
|
+
#
|
|
321
|
+
# @return [Array<String>]
|
|
322
|
+
def tracked_request_ids
|
|
323
|
+
@tasks_lock.synchronize do
|
|
324
|
+
(@queued_tasks.keys + @pending_tasks.keys + @inflight_requests.keys).uniq
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
310
328
|
# Add an observer for processor events.
|
|
311
329
|
#
|
|
312
330
|
# @param observer [ProcessorObserver] the observer to add
|
|
@@ -393,6 +411,7 @@ module PatientHttp
|
|
|
393
411
|
|
|
394
412
|
# Track as pending immediately to avoid race condition with stop()
|
|
395
413
|
@tasks_lock.synchronize do
|
|
414
|
+
@queued_tasks.delete(request_task.id)
|
|
396
415
|
@pending_tasks[request_task.id] = request_task
|
|
397
416
|
end
|
|
398
417
|
|
|
@@ -473,12 +492,15 @@ module PatientHttp
|
|
|
473
492
|
|
|
474
493
|
@pending_tasks.delete(task.id)
|
|
475
494
|
@inflight_requests[task.id] = task
|
|
495
|
+
# Mark the task started in the same locked section that tracks it so
|
|
496
|
+
# a shutdown snapshot always sees a consistent started state. The
|
|
497
|
+
# shutdown re-enqueue path uses started? to pair request_end with
|
|
498
|
+
# request_start.
|
|
499
|
+
task.started!
|
|
476
500
|
end
|
|
477
501
|
|
|
478
502
|
notify_observers { |observer| observer.request_start(task) }
|
|
479
503
|
|
|
480
|
-
# Mark task as started
|
|
481
|
-
task.started!
|
|
482
504
|
claimed = false
|
|
483
505
|
|
|
484
506
|
begin
|
|
@@ -611,13 +633,9 @@ module PatientHttp
|
|
|
611
633
|
# from in-flight) and enqueue the redirect. If the claim fails the
|
|
612
634
|
# shutdown sequence already re-enqueued the original, so drop the redirect.
|
|
613
635
|
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
636
|
|
|
619
|
-
|
|
620
|
-
|
|
637
|
+
claimed = announce_and_enqueue(redirect_task) do
|
|
638
|
+
!@inflight_requests.delete(task.id).nil?
|
|
621
639
|
end
|
|
622
640
|
return false unless claimed
|
|
623
641
|
|
|
@@ -646,6 +664,47 @@ module PatientHttp
|
|
|
646
664
|
raise if PatientHttp.testing?
|
|
647
665
|
end
|
|
648
666
|
|
|
667
|
+
# Announce a task to observers and make it visible to the reactor. The
|
|
668
|
+
# task is announced before it can start, finish, or be re-enqueued, so
|
|
669
|
+
# observers can set up durable tracking first. Errors from the
|
|
670
|
+
# request_enqueued announcement propagate and reject the task, because a
|
|
671
|
+
# failed tracking setup must not let the task be accepted as if it were
|
|
672
|
+
# durable. The block runs while {@tasks_lock} is held and decides whether
|
|
673
|
+
# the task is accepted; when it returns false or raises, observers receive
|
|
674
|
+
# request_rejected so they can tear down anything they set up for the
|
|
675
|
+
# request_enqueued announcement. The rejection notification never replaces
|
|
676
|
+
# an exception that is already being raised.
|
|
677
|
+
#
|
|
678
|
+
# @param task [RequestTask] the request task to announce and enqueue
|
|
679
|
+
# @return [Boolean] true if the task was accepted
|
|
680
|
+
def announce_and_enqueue(task)
|
|
681
|
+
task.enqueued!
|
|
682
|
+
accepted = false
|
|
683
|
+
|
|
684
|
+
begin
|
|
685
|
+
notify_observers! { |observer| observer.request_enqueued(task) }
|
|
686
|
+
|
|
687
|
+
@tasks_lock.synchronize do
|
|
688
|
+
if yield
|
|
689
|
+
@queued_tasks[task.id] = task
|
|
690
|
+
@queue.push(task)
|
|
691
|
+
accepted = true
|
|
692
|
+
end
|
|
693
|
+
end
|
|
694
|
+
ensure
|
|
695
|
+
unless accepted
|
|
696
|
+
pending_error = $!
|
|
697
|
+
begin
|
|
698
|
+
notify_observers { |observer| observer.request_rejected(task) }
|
|
699
|
+
rescue
|
|
700
|
+
raise unless pending_error
|
|
701
|
+
end
|
|
702
|
+
end
|
|
703
|
+
end
|
|
704
|
+
|
|
705
|
+
accepted
|
|
706
|
+
end
|
|
707
|
+
|
|
649
708
|
# Notify all observers of an event. Observers are called outside of any
|
|
650
709
|
# internal lock so they can safely call back into the processor.
|
|
651
710
|
def notify_observers(&block)
|
|
@@ -655,6 +714,16 @@ module PatientHttp
|
|
|
655
714
|
end
|
|
656
715
|
end
|
|
657
716
|
|
|
717
|
+
# Notify all observers of an event and let observer errors propagate.
|
|
718
|
+
# Used for notifications the caller must be able to react to, such as
|
|
719
|
+
# durable tracking setup in request_enqueued.
|
|
720
|
+
def notify_observers!
|
|
721
|
+
observers = @tasks_lock.synchronize { @observers.dup }
|
|
722
|
+
observers.each do |observer|
|
|
723
|
+
yield(observer)
|
|
724
|
+
end
|
|
725
|
+
end
|
|
726
|
+
|
|
658
727
|
def notify_observer(observer)
|
|
659
728
|
yield(observer)
|
|
660
729
|
rescue => e
|
|
@@ -697,16 +766,27 @@ module PatientHttp
|
|
|
697
766
|
end
|
|
698
767
|
|
|
699
768
|
def reenqueue_remaining_queue_items
|
|
700
|
-
tasks_to_reenqueue =
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
769
|
+
tasks_to_reenqueue = @tasks_lock.synchronize do
|
|
770
|
+
tasks = []
|
|
771
|
+
|
|
772
|
+
# Drain remaining items from the queue (skip nil sentinels from stop)
|
|
773
|
+
until @queue.empty?
|
|
774
|
+
begin
|
|
775
|
+
task = @queue.pop(true)
|
|
776
|
+
tasks << task if task
|
|
777
|
+
rescue ThreadError
|
|
778
|
+
break
|
|
779
|
+
end
|
|
709
780
|
end
|
|
781
|
+
|
|
782
|
+
tasks.each { |task| @queued_tasks.delete(task.id) }
|
|
783
|
+
# The reactor has exited and no new tasks can be accepted, so any id
|
|
784
|
+
# still tracked as queued belongs to a task that left the queue
|
|
785
|
+
# without reaching pending or in-flight tracking. Reclaim those tasks
|
|
786
|
+
# as well so they are not tracked forever.
|
|
787
|
+
tasks.concat(@queued_tasks.values)
|
|
788
|
+
@queued_tasks.clear
|
|
789
|
+
tasks
|
|
710
790
|
end
|
|
711
791
|
|
|
712
792
|
reenqueue_tasks(tasks_to_reenqueue)
|
|
@@ -715,6 +795,10 @@ module PatientHttp
|
|
|
715
795
|
def reenqueue_tasks(tasks_to_reenqueue)
|
|
716
796
|
tasks_to_reenqueue.each do |task|
|
|
717
797
|
task.retry
|
|
798
|
+
# The task handler's job system owns the request again; let observers
|
|
799
|
+
# tear down any durable tracking for the task. Only sent after a
|
|
800
|
+
# successful retry so a failed retry leaves the tracking in place.
|
|
801
|
+
notify_observers { |observer| observer.request_requeued(task) }
|
|
718
802
|
# Only emit request_end for tasks that actually started, so observers
|
|
719
803
|
# that pair request_start/request_end (e.g. an in-flight gauge) stay
|
|
720
804
|
# balanced. Queued-but-never-started tasks emit neither.
|
|
@@ -24,6 +24,41 @@ module PatientHttp
|
|
|
24
24
|
def capacity_exceeded
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
# Called when a request task is handed to the processor, before the task is
|
|
28
|
+
# visible to the reactor. The notification is guaranteed to arrive before
|
|
29
|
+
# request_start for the task, so observers can set up durable tracking
|
|
30
|
+
# (e.g. a crash-recovery registry entry) with no risk that the task
|
|
31
|
+
# completes first. If the processor does not accept the task,
|
|
32
|
+
# request_rejected is sent afterward. Unlike other notifications, an error
|
|
33
|
+
# raised here propagates from Processor#enqueue and rejects the task, so a
|
|
34
|
+
# failed tracking setup does not let the task be accepted as if it were
|
|
35
|
+
# durable.
|
|
36
|
+
#
|
|
37
|
+
# @param request_task [RequestTask] the request task that was enqueued
|
|
38
|
+
# @return [void]
|
|
39
|
+
def request_enqueued(request_task)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Called when a request task announced with request_enqueued was not
|
|
43
|
+
# accepted by the processor (not running or at capacity). Observers should
|
|
44
|
+
# tear down anything they set up in request_enqueued; the caller owns the
|
|
45
|
+
# request again once this is sent.
|
|
46
|
+
#
|
|
47
|
+
# @param request_task [RequestTask] the request task that was rejected
|
|
48
|
+
# @return [void]
|
|
49
|
+
def request_rejected(request_task)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Called when an incomplete request task was re-enqueued through its task
|
|
53
|
+
# handler (processor shutdown or reactor failure). The task handler's job
|
|
54
|
+
# system owns the request again once this is sent, so observers should
|
|
55
|
+
# tear down any durable tracking for the task.
|
|
56
|
+
#
|
|
57
|
+
# @param request_task [RequestTask] the request task that was re-enqueued
|
|
58
|
+
# @return [void]
|
|
59
|
+
def request_requeued(request_task)
|
|
60
|
+
end
|
|
61
|
+
|
|
27
62
|
# Called when a request starts processing.
|
|
28
63
|
#
|
|
29
64
|
# @param request_task [RequestTask] the request task that started
|
data/patient_http.gemspec
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: patient_http
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Brian Durand
|
|
@@ -65,20 +65,6 @@ dependencies:
|
|
|
65
65
|
- - ">="
|
|
66
66
|
- !ruby/object:Gem::Version
|
|
67
67
|
version: '0'
|
|
68
|
-
- !ruby/object:Gem::Dependency
|
|
69
|
-
name: bundler
|
|
70
|
-
requirement: !ruby/object:Gem::Requirement
|
|
71
|
-
requirements:
|
|
72
|
-
- - ">="
|
|
73
|
-
- !ruby/object:Gem::Version
|
|
74
|
-
version: '0'
|
|
75
|
-
type: :development
|
|
76
|
-
prerelease: false
|
|
77
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
-
requirements:
|
|
79
|
-
- - ">="
|
|
80
|
-
- !ruby/object:Gem::Version
|
|
81
|
-
version: '0'
|
|
82
68
|
description: This gem provides a dedicated async HTTP processor that uses Ruby's Fiber
|
|
83
69
|
scheduler for non-blocking I/O. Application threads hand off HTTP requests to the
|
|
84
70
|
processor and return immediately. The processor handles hundreds of concurrent HTTP
|
|
@@ -159,7 +145,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
159
145
|
- !ruby/object:Gem::Version
|
|
160
146
|
version: '0'
|
|
161
147
|
requirements: []
|
|
162
|
-
rubygems_version:
|
|
148
|
+
rubygems_version: 3.6.9
|
|
163
149
|
specification_version: 4
|
|
164
150
|
summary: Generic async HTTP connection pool for Ruby applications using Fiber-based
|
|
165
151
|
concurrency
|