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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ba7400987a1a1483e88f8c0e3c3fb3579544a02f5f3bdb6da7752f5864792fcb
4
- data.tar.gz: cc1ab11246151f2b9843e81c730653e922a34db6f4a57600267637fbe72d7f49
3
+ metadata.gz: d0888d7e92f43f2031bf7f0eb5b0f4dd528ac7b79957b506f1df61825309990a
4
+ data.tar.gz: 87b5817b8205a0b605edb7cb41facf0f6b5690122ff63cb669dbcc6c6b090cd0
5
5
  SHA512:
6
- metadata.gz: e021b76930cc0c1d6c0741723d2db084947f04b8daee1564bc1b04d56d24399443dc6567fe153a99795d5c322810ab56f9cd218f1b8ddafb3a127785352f0bb7
7
- data.tar.gz: 5d920b85e033e9fbfcd551ca2f7702d260da8ddbea04f105173e591c07fab7b66df2c6d6a85303463fa6faa6d2c8e6357296c317e6ccec8e7599dbbd97b1f842
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.3.0
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
- at_capacity = false
212
+ raise NotRunningError.new("Cannot enqueue request: processor is #{state}") unless running?
202
213
 
203
- @tasks_lock.synchronize do
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 - raise error if at max connections
207
- total = @queue.size + @pending_tasks.size + @inflight_requests.size
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
- if at_capacity
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
- @queue.push(redirect_task)
620
- true
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
- # Drain remaining items from the queue (skip nil sentinels from stop)
703
- until @queue.empty?
704
- begin
705
- task = @queue.pop(true)
706
- tasks_to_reenqueue << task if task
707
- rescue ThreadError
708
- break
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
@@ -43,6 +43,4 @@ Gem::Specification.new do |spec|
43
43
  spec.add_dependency "async-http", "~> 0.60"
44
44
  spec.add_dependency "concurrent-ruby", "~> 1.2"
45
45
  spec.add_dependency "logger"
46
-
47
- spec.add_development_dependency "bundler"
48
46
  end
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.3.0
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: 4.0.3
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