patient_http 1.4.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 +27 -0
- data/README.md +13 -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 +284 -94
- data/lib/patient_http/processor_observer.rb +36 -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
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e193efd74abdf58c93ef6d65796c881bf08042264da0da0db34ee030ec52d08a
|
|
4
|
+
data.tar.gz: cb61f5268f51d6959b04e096420419f3454852096b5e027d67f79fa2f9789be8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 56896f881827163fffaebfc8c745e47d5abdbf3c11da1727d6801164b1ed1e4ac276ac4a9e288d8d8d9cc544aab2e1a67d28c8cd5cff312429d243b49658107f
|
|
7
|
+
data.tar.gz: 4e2a4c555e44ed3c0ee6997d74ca0dfa444e7ad4567a9b58386e3bbd442ce8a82d94558e7606aa8bab2749a9930221e0c2609ffced97922a47cf7f1c4261023a
|
data/ARCHITECTURE.md
CHANGED
|
@@ -54,7 +54,16 @@ The `TaskHandler` abstract class defines how the processor communicates results
|
|
|
54
54
|
- **on_error(error, callback)**: Called when an HTTP request fails. Your implementation should enqueue the error for handling.
|
|
55
55
|
- **retry**: Called when the processor shuts down with in-flight requests. Your implementation should re-enqueue the original job.
|
|
56
56
|
|
|
57
|
-
> **Important:** TaskHandler callbacks run on the processor's reactor thread. They
|
|
57
|
+
> **Important:** TaskHandler callbacks run on the processor's completion worker threads (see `completion_threads`), not the reactor thread, so they no longer block the event loop. They can still slow the processor down by two routes, so keep them fast -- typically just enqueuing a message for another system to pick up:
|
|
58
|
+
>
|
|
59
|
+
> - Callbacks compete with the reactor thread for the GVL, so heavy CPU work in a callback can still add latency to in-flight requests.
|
|
60
|
+
> - A task stays in the capacity count until its result is delivered, so callbacks that back up consume request capacity and eventually make `enqueue` raise `MaxCapacityError`.
|
|
61
|
+
>
|
|
62
|
+
> **Callbacks must be thread-safe.** With the default `completion_threads` of 2, results are delivered concurrently, so two callbacks can run at the same time and in an order unrelated to the order the requests completed. Guard any state a handler shares between calls. Set `completion_threads: 1` to serialize delivery on a single worker thread.
|
|
63
|
+
>
|
|
64
|
+
> **Callbacks must be idempotent.** A failed delivery is retried `completion_retries` times (default 2), and each retry calls the callback again, so a callback that raises after enqueuing its message enqueues it more than once. Set `completion_retries: 0` if the callback cannot be made idempotent.
|
|
65
|
+
>
|
|
66
|
+
> If a callback keeps raising after the configured retries, the processor sends `completion_failed` to observers and does NOT send `request_end`, so durable tracking survives for external recovery.
|
|
58
67
|
|
|
59
68
|
Example:
|
|
60
69
|
```ruby
|
|
@@ -230,10 +239,12 @@ erDiagram
|
|
|
230
239
|
|
|
231
240
|
## Process Model
|
|
232
241
|
|
|
233
|
-
Each
|
|
234
|
-
-
|
|
235
|
-
- **One** async HTTP processor thread
|
|
242
|
+
Each `Processor` instance runs:
|
|
243
|
+
- **One** async HTTP processor (reactor) thread
|
|
236
244
|
- **One** fiber reactor within the processor thread
|
|
245
|
+
- A small pool of completion worker threads (`completion_threads`, default 2) that decode responses and deliver results
|
|
246
|
+
|
|
247
|
+
A process usually runs one processor, but `Processor` is fully instance-based: a process can run several named processors (`Processor.new(config, name: :llm)`), each with its own capacity, timeouts, and threads. Requests carry an optional `processor` name (serialized with the request) that integrations use for routing.
|
|
237
248
|
|
|
238
249
|
```
|
|
239
250
|
┌─────────────────────────────────────────────────────────────┐
|
|
@@ -266,9 +277,10 @@ Each application process can run:
|
|
|
266
277
|
The processor uses Ruby's Fiber scheduler (`async` gem) for non-blocking I/O:
|
|
267
278
|
|
|
268
279
|
1. **Application threads** remain free while HTTP requests execute
|
|
269
|
-
2. **Fiber reactor** multiplexes hundreds of HTTP connections
|
|
270
|
-
3. **Connection pooling** and HTTP/2 reuse connections efficiently
|
|
271
|
-
4. **
|
|
280
|
+
2. **Fiber reactor** multiplexes hundreds of HTTP connections and performs only socket I/O and light bookkeeping
|
|
281
|
+
3. **Connection pooling** and HTTP/2 reuse connections efficiently; `max_connections_per_host` bounds sockets per host
|
|
282
|
+
4. **Completion worker threads** decode response bodies (join, inflate, charset), build responses, and execute TaskHandler callbacks and `request_end` observers off the reactor. Decoding is CPU-bound and has no fiber yield point, so running it inline on the reactor would stop every other in-flight request until it finished. On a worker thread the Ruby scheduler can preempt it, and Zlib releases the GVL for part of the inflate
|
|
283
|
+
5. **Delivery is concurrent, not serialized.** Callbacks and completion-time observers run on any of the `completion_threads` workers, so they must be thread-safe. Completions are also bounded: a task stays in the capacity count until a worker claims its result, so the completion backlog can never exceed `max_connections`
|
|
272
284
|
|
|
273
285
|
## State Management
|
|
274
286
|
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,33 @@ 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.5.0
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Completion executor: finished results are now delivered on a small pool of worker threads (`Configuration#completion_threads`, default 2, minimum 1) instead of the reactor thread. Response decoding, payload encoding, task-handler callbacks, and `request_end` observers all run on these threads, so the reactor only performs socket I/O and light bookkeeping.
|
|
12
|
+
- `Configuration#completion_retries` (default 2): delivery of a finished result is retried with a short backoff before the failure is reported. A retry calls `on_complete`/`on_error` again, so handlers must be idempotent; set `completion_retries: 0` to report the first failure without retrying.
|
|
13
|
+
- `ProcessorObserver#completion_failed(request_task, error)`: sent when a result could not be delivered after all retries. `request_end` is NOT sent in this case, so durable tracking (crash-recovery records) stays in place and the request can be recovered by an external process instead of being silently lost. This replaces the previous behavior where a delivery failure was logged, swallowed, and the tracking was torn down.
|
|
14
|
+
- `Configuration#max_connections_per_host` (default nil = unlimited): bounds the number of connections each host's HTTP client may open, which bounds file descriptor usage.
|
|
15
|
+
- `Processor#remaining_capacity` and `Processor#capacity_available?`: cheap, advisory capacity checks with no observer notifications, so integrations can reject work before paying registration costs.
|
|
16
|
+
- Named processors: `Processor.new(config, name:)` names a processor (used in its thread names), and `Request` accepts a `processor:` option that survives serialization (`as_json` / `load`), so integrations can route requests to one of several processors in the same process. `RequestTemplate`, `RequestHelper`, and `PatientHttp.request` pass the option through. `PatientHttp::UnknownProcessorError` is defined for handlers to raise for unrecognized names. Serialized output for requests without a processor name is unchanged.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- Compressed response bodies (gzip/deflate) are now inflated on a completion worker thread instead of the reactor thread. The size limit still applies to the inflated bytes, so gzip-bomb protection is unchanged. The `content-encoding` header is removed from the response after decoding, as before.
|
|
21
|
+
- Requests send `accept-encoding: gzip` by default, but a request that sets the header keeps its own value. Set `accept-encoding: identity` on a request to opt out of compression, or name another encoding to receive the body still encoded. Previously the header was set unconditionally.
|
|
22
|
+
- Inline and synchronous execution decodes response bodies through the same reader as the async path instead of a `Protocol::HTTP::AcceptEncoding` middleware. The middleware overwrote the request's `accept-encoding` header, so a request opting out of compression was previously honored only when it ran on the processor.
|
|
23
|
+
- A `content-encoding` header naming more than one encoding is now decoded from the outermost encoding inward, and `identity` is recognized. Previously only a header holding exactly one supported name was decoded, so a value such as `gzip, identity` delivered the body still compressed.
|
|
24
|
+
- A response body carrying an encoding the reader cannot decode is delivered unchanged with a `content-encoding` header naming only the encodings still applied, and the condition is now logged as a warning. Such a body keeps its binary encoding, because the Content-Type charset does not describe encoded bytes.
|
|
25
|
+
- `ProcessorObserver` hooks no longer all run on the reactor thread; see the class documentation for the thread each hook runs on. `request_end` and `request_error` for completed requests now run on completion worker threads.
|
|
26
|
+
- **Breaking for callbacks:** `TaskHandler` callbacks and completion-time observer hooks must now be thread-safe. The reactor thread previously serialized them; they now run concurrently on `completion_threads` workers, in an order unrelated to the order the requests completed. Set `completion_threads: 1` to restore serialized delivery.
|
|
27
|
+
- On shutdown, results that were already handed off for delivery are delivered before remaining tasks are re-enqueued.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- A `deflate` response body carrying a zlib header, which is the format RFC 9110 specifies for that encoding, failed to inflate with `Zlib::DataError: invalid stored block lengths`. Both the zlib and the raw deflate wire formats are now supported.
|
|
32
|
+
- A response body that a text content type claims is text, but that does not hold text, is now stored as a binary payload instead of as text. Such a body could not be serialized, so `JSON.generate` raised `JSON::GeneratorError` and the result could never be delivered. This happened for a body still carrying a content encoding the reader cannot decode (for example `br`), and for text holding an invalid byte sequence.
|
|
33
|
+
|
|
7
34
|
## 1.4.0
|
|
8
35
|
|
|
9
36
|
### Added
|
data/README.md
CHANGED
|
@@ -34,8 +34,8 @@ class MyTaskHandler < PatientHttp::TaskHandler
|
|
|
34
34
|
|
|
35
35
|
def on_complete(response, callback)
|
|
36
36
|
# Enqueue a message for your application to process the response.
|
|
37
|
-
# Keep this lightweight --
|
|
38
|
-
#
|
|
37
|
+
# Keep this lightweight and thread-safe -- it runs on a completion
|
|
38
|
+
# worker thread, concurrently with other completions.
|
|
39
39
|
MyJobSystem.enqueue(callback, :on_complete, response.as_json)
|
|
40
40
|
end
|
|
41
41
|
|
|
@@ -51,7 +51,11 @@ class MyTaskHandler < PatientHttp::TaskHandler
|
|
|
51
51
|
end
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
-
> **Important:** TaskHandler callbacks run on the processor's reactor thread.
|
|
54
|
+
> **Important:** TaskHandler callbacks run on the processor's completion worker threads (see `completion_threads`), not the reactor thread, so they no longer block the event loop. Keep them lightweight anyway -- typically just enqueuing a message for another system to pick up. Heavy callbacks compete with the reactor for the GVL, and because a task stays in the capacity count until its result is delivered, callbacks that back up consume request capacity.
|
|
55
|
+
>
|
|
56
|
+
> Callbacks must be thread-safe. Results are delivered concurrently on `completion_threads` workers (default 2), so two callbacks can run at the same time and in an order unrelated to the order the requests completed. Set `completion_threads: 1` to serialize delivery.
|
|
57
|
+
>
|
|
58
|
+
> Callbacks must also be idempotent. A callback that raises is retried `completion_retries` times (default 2), so one that raises after enqueuing its message enqueues it again. Set `completion_retries: 0` if that is not acceptable.
|
|
55
59
|
|
|
56
60
|
### 2. Create and Enqueue Requests
|
|
57
61
|
|
|
@@ -685,9 +689,14 @@ config.register_secret(:api_token, ENV["MY_API_TOKEN"])
|
|
|
685
689
|
### Tuning Tips
|
|
686
690
|
|
|
687
691
|
- **max_connections**: Each connection uses memory and file descriptors. A tuned system can handle thousands.
|
|
692
|
+
- **max_connections_per_host**: Bounds sockets per host (default unlimited). Set a value such as 32 for high-concurrency deployments so one host cannot consume every file descriptor. Verify the process file descriptor limit covers `max_connections` plus pooled idle host connections plus the application's own connections.
|
|
688
693
|
- **request_timeout**: Set based on expected API response times. AI/LLM APIs may need minutes.
|
|
689
694
|
- **connection_pool_size**: Increase for applications calling many different API hosts.
|
|
690
|
-
- **max_response_size**: Keeps memory usage bounded. Large responses may need external payload storage.
|
|
695
|
+
- **max_response_size**: Keeps memory usage bounded. Large responses may need external payload storage. The limit applies to the inflated bytes of compressed responses.
|
|
696
|
+
- **Response compression**: Requests ask for `gzip` by default and the body is inflated on a completion worker thread. Set `accept-encoding` on a request to change this: `identity` skips compression, and any other encoding is delivered still encoded with its `content-encoding` header kept so you can decode it yourself.
|
|
697
|
+
- **completion_threads**: Number of threads that decode responses and deliver results (default 2). Increase when callbacks do heavier work (serialization, encryption) and completions back up behind them. Any value above 1 delivers results concurrently, so `TaskHandler` callbacks and completion-time observers must be thread-safe. Use 1 to serialize delivery.
|
|
698
|
+
- **completion_retries**: Delivery retries before a result is reported through `completion_failed` (default 2). A retry calls `on_complete`/`on_error` again, so a handler that raises *after* enqueuing its message delivers that message twice. Make handlers idempotent, or set `completion_retries: 0` to report the first failure without retrying.
|
|
699
|
+
- **shutdown_timeout**: Set below the process supervisor's termination window so the drain (including handed-off completions) finishes before a hard kill.
|
|
691
700
|
|
|
692
701
|
## Processor Lifecycle
|
|
693
702
|
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.
|
|
1
|
+
1.5.0
|
data/lib/patient_http/client.rb
CHANGED
|
@@ -9,7 +9,8 @@ module PatientHttp
|
|
|
9
9
|
connection_timeout: config.connection_timeout,
|
|
10
10
|
proxy_url: config.proxy_url,
|
|
11
11
|
retries: config.retries,
|
|
12
|
-
protocol: config.protocol
|
|
12
|
+
protocol: config.protocol,
|
|
13
|
+
connection_limit: config.max_connections_per_host
|
|
13
14
|
)
|
|
14
15
|
@response_reader = ResponseReader.new(@processor)
|
|
15
16
|
@request_preparer = RequestPreparer.new(config)
|
|
@@ -17,6 +18,10 @@ module PatientHttp
|
|
|
17
18
|
|
|
18
19
|
# Make an asynchronous HTTP request.
|
|
19
20
|
#
|
|
21
|
+
# The returned body is the array of raw (possibly compressed) body chunks;
|
|
22
|
+
# use {#decode_response} to produce the final body string. Splitting the
|
|
23
|
+
# decode out keeps CPU-bound work off the reactor thread.
|
|
24
|
+
#
|
|
20
25
|
# @param request [Request] the request to make
|
|
21
26
|
# @param request_id [String] unique request identifier
|
|
22
27
|
# @return [Hash] the response data with keys for :status, :headers, and :body
|
|
@@ -35,7 +40,7 @@ module PatientHttp
|
|
|
35
40
|
# Note: headers that appear multiple times (e.g. set-cookie) are
|
|
36
41
|
# flattened to a single joined string value.
|
|
37
42
|
headers_hash = async_response.headers.to_h.transform_values(&:to_s)
|
|
38
|
-
body = @response_reader.
|
|
43
|
+
body = @response_reader.read_raw_body(async_response, headers_hash)
|
|
39
44
|
|
|
40
45
|
{
|
|
41
46
|
status: async_response.status,
|
|
@@ -54,6 +59,26 @@ module PatientHttp
|
|
|
54
59
|
end
|
|
55
60
|
end
|
|
56
61
|
|
|
62
|
+
# Decode raw response data into deliverable response data.
|
|
63
|
+
#
|
|
64
|
+
# Joins and inflates the raw body chunks, applies the charset, and rewrites
|
|
65
|
+
# the content-encoding header to name only the encodings still applied to
|
|
66
|
+
# the body. The header is removed when nothing is left, and kept when the
|
|
67
|
+
# server used an encoding the reader cannot decode, so the delivered
|
|
68
|
+
# response always describes the body it carries. This is CPU-bound work
|
|
69
|
+
# intended to run on a completion worker thread.
|
|
70
|
+
#
|
|
71
|
+
# @param response_data [Hash] raw response data from {#make_request}
|
|
72
|
+
# @return [Hash] response data with the decoded body string
|
|
73
|
+
# @raise [ResponseTooLargeError] if the inflated body exceeds max_response_size
|
|
74
|
+
def decode_response(response_data)
|
|
75
|
+
headers = response_data[:headers]
|
|
76
|
+
body = @response_reader.decode_body(response_data[:body], headers)
|
|
77
|
+
headers = ResponseReader.rewrite_content_encoding(headers)
|
|
78
|
+
|
|
79
|
+
response_data.merge(headers: headers, body: body)
|
|
80
|
+
end
|
|
81
|
+
|
|
57
82
|
# Close all clients and release resources.
|
|
58
83
|
#
|
|
59
84
|
# @return [void]
|
|
@@ -15,7 +15,7 @@ module PatientHttp
|
|
|
15
15
|
http2: Async::HTTP::Protocol::HTTP2
|
|
16
16
|
}.freeze
|
|
17
17
|
|
|
18
|
-
def initialize(max_size:, connection_timeout: nil, proxy_url: nil, retries: 3, protocol: nil)
|
|
18
|
+
def initialize(max_size:, connection_timeout: nil, proxy_url: nil, retries: 3, protocol: nil, connection_limit: nil)
|
|
19
19
|
if protocol && !PROTOCOLS.include?(protocol)
|
|
20
20
|
raise ArgumentError.new("protocol must be one of #{PROTOCOLS.keys.inspect}, got: #{protocol.inspect}")
|
|
21
21
|
end
|
|
@@ -26,16 +26,17 @@ module PatientHttp
|
|
|
26
26
|
@proxy_url = proxy_url
|
|
27
27
|
@retries = retries
|
|
28
28
|
@protocol = protocol
|
|
29
|
+
@connection_limit = connection_limit
|
|
29
30
|
@mutex = Mutex.new
|
|
30
31
|
@proxy_client = nil
|
|
31
32
|
end
|
|
32
33
|
|
|
33
|
-
attr_reader :max_size, :connection_timeout, :proxy_url, :retries, :protocol
|
|
34
|
+
attr_reader :max_size, :connection_timeout, :proxy_url, :retries, :protocol, :connection_limit
|
|
34
35
|
|
|
35
36
|
# Get or create a client for the given endpoint.
|
|
36
37
|
#
|
|
37
38
|
# @param endpoint [Async::HTTP::Endpoint] the target endpoint
|
|
38
|
-
# @return [
|
|
39
|
+
# @return [Async::HTTP::Client] the client for the endpoint's host
|
|
39
40
|
def client_for(endpoint)
|
|
40
41
|
key = host_key(endpoint)
|
|
41
42
|
|
|
@@ -154,13 +155,15 @@ module PatientHttp
|
|
|
154
155
|
end
|
|
155
156
|
|
|
156
157
|
def make_client(endpoint)
|
|
157
|
-
|
|
158
|
-
|
|
158
|
+
# Response bodies are decoded by ResponseReader on a completion worker
|
|
159
|
+
# thread instead of a Protocol::HTTP::AcceptEncoding wrapper, so the
|
|
160
|
+
# reactor thread never pays for inflating compressed bodies.
|
|
161
|
+
@proxy_url ? make_proxied_client(endpoint) : make_direct_client(endpoint)
|
|
159
162
|
end
|
|
160
163
|
|
|
161
164
|
def make_direct_client(endpoint)
|
|
162
165
|
configured_endpoint = configure_endpoint(endpoint)
|
|
163
|
-
Async::HTTP::Client.new(configured_endpoint, retries: @retries)
|
|
166
|
+
Async::HTTP::Client.new(configured_endpoint, retries: @retries, **client_options)
|
|
164
167
|
end
|
|
165
168
|
|
|
166
169
|
def make_proxied_client(endpoint)
|
|
@@ -170,7 +173,11 @@ module PatientHttp
|
|
|
170
173
|
configured_endpoint = configure_endpoint(endpoint)
|
|
171
174
|
|
|
172
175
|
proxy = @proxy_client.proxy(configured_endpoint)
|
|
173
|
-
Async::HTTP::Client.new(proxy.wrap_endpoint(configured_endpoint), retries: @retries)
|
|
176
|
+
Async::HTTP::Client.new(proxy.wrap_endpoint(configured_endpoint), retries: @retries, **client_options)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def client_options
|
|
180
|
+
@connection_limit ? {limit: @connection_limit} : {}
|
|
174
181
|
end
|
|
175
182
|
|
|
176
183
|
def create_proxy_client
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PatientHttp
|
|
4
|
+
# Fixed pool of worker threads that deliver completed request results.
|
|
5
|
+
#
|
|
6
|
+
# The processor's reactor thread hands each finished HTTP exchange to this
|
|
7
|
+
# pool so response decoding, serialization, and callback delivery never
|
|
8
|
+
# block the event loop. Jobs are arbitrary callables consumed from a single
|
|
9
|
+
# queue.
|
|
10
|
+
#
|
|
11
|
+
# @api private
|
|
12
|
+
class CompletionExecutor
|
|
13
|
+
# Initialize the executor and start its worker threads.
|
|
14
|
+
#
|
|
15
|
+
# @param threads [Integer] number of worker threads
|
|
16
|
+
# @param logger [Logger, nil] logger for unexpected job errors
|
|
17
|
+
# @param thread_name_prefix [String] prefix for worker thread names
|
|
18
|
+
# @param on_finished [#call, nil] invoked after each job completes, outside
|
|
19
|
+
# any executor lock, so the owner can re-check idle conditions
|
|
20
|
+
def initialize(threads:, logger: nil, thread_name_prefix: "patient-http-completion", on_finished: nil)
|
|
21
|
+
@queue = Thread::Queue.new
|
|
22
|
+
@logger = logger
|
|
23
|
+
@on_finished = on_finished
|
|
24
|
+
@mutex = Mutex.new
|
|
25
|
+
# Jobs enqueued but not yet fully executed. Tracked separately from the
|
|
26
|
+
# queue size so a job that has been popped but is still running keeps
|
|
27
|
+
# the executor non-idle.
|
|
28
|
+
@outstanding = 0
|
|
29
|
+
@threads = Array.new(threads) do |index|
|
|
30
|
+
Thread.new do
|
|
31
|
+
Thread.current.name = "#{thread_name_prefix}-#{index + 1}"
|
|
32
|
+
run_worker
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Enqueue a job for execution.
|
|
38
|
+
#
|
|
39
|
+
# @param job [#call] the job to run
|
|
40
|
+
# @raise [ClosedQueueError] if the executor has been shut down
|
|
41
|
+
# @return [void]
|
|
42
|
+
def enqueue(job)
|
|
43
|
+
@mutex.synchronize { @outstanding += 1 }
|
|
44
|
+
begin
|
|
45
|
+
@queue.push(job)
|
|
46
|
+
rescue ClosedQueueError
|
|
47
|
+
@mutex.synchronize { @outstanding -= 1 }
|
|
48
|
+
raise
|
|
49
|
+
end
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Check whether the executor has no queued or running jobs.
|
|
54
|
+
#
|
|
55
|
+
# @return [Boolean]
|
|
56
|
+
def idle?
|
|
57
|
+
@mutex.synchronize { @outstanding == 0 }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Check whether the given thread is one of this executor's workers.
|
|
61
|
+
#
|
|
62
|
+
# @param thread [Thread] the thread to check
|
|
63
|
+
# @return [Boolean]
|
|
64
|
+
def worker_thread?(thread = Thread.current)
|
|
65
|
+
@threads.include?(thread)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Shut down the executor: close the queue so workers drain remaining jobs
|
|
69
|
+
# and exit, then join them within the timeout. Workers still alive after
|
|
70
|
+
# the deadline are killed; their tasks remain durably tracked and are
|
|
71
|
+
# recovered by the owner's re-enqueue logic.
|
|
72
|
+
#
|
|
73
|
+
# Safe to call more than once and from a worker thread itself (the
|
|
74
|
+
# current thread is never joined or killed).
|
|
75
|
+
#
|
|
76
|
+
# @param timeout [Numeric] seconds to wait for workers to drain
|
|
77
|
+
# @return [void]
|
|
78
|
+
def shutdown(timeout: 5)
|
|
79
|
+
@queue.close
|
|
80
|
+
|
|
81
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
82
|
+
@threads.each do |thread|
|
|
83
|
+
next if thread.equal?(Thread.current)
|
|
84
|
+
|
|
85
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
86
|
+
thread.join(remaining.positive? ? remaining : 0)
|
|
87
|
+
if thread.alive?
|
|
88
|
+
thread.kill
|
|
89
|
+
thread.join(1)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
discard_undrained_jobs
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
# Drop jobs left in the closed queue by workers that were killed at the
|
|
100
|
+
# shutdown deadline. Those jobs can never run, so they must stop counting
|
|
101
|
+
# against the outstanding total or the executor would never report itself
|
|
102
|
+
# idle again.
|
|
103
|
+
#
|
|
104
|
+
# @return [void]
|
|
105
|
+
def discard_undrained_jobs
|
|
106
|
+
discarded = 0
|
|
107
|
+
|
|
108
|
+
loop do
|
|
109
|
+
break unless @queue.pop(true)
|
|
110
|
+
discarded += 1
|
|
111
|
+
rescue ThreadError
|
|
112
|
+
break
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
@mutex.synchronize { @outstanding -= discarded } if discarded > 0
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def run_worker
|
|
120
|
+
while (job = @queue.pop)
|
|
121
|
+
begin
|
|
122
|
+
job.call
|
|
123
|
+
rescue => e
|
|
124
|
+
@logger&.error(
|
|
125
|
+
"[PatientHttp] Completion worker error: #{e.class} - #{e.message}\n#{e.backtrace&.join("\n")}"
|
|
126
|
+
)
|
|
127
|
+
warn("#{e.inspect}\n#{e.backtrace&.join("\n")}") if PatientHttp.testing?
|
|
128
|
+
ensure
|
|
129
|
+
@mutex.synchronize { @outstanding -= 1 }
|
|
130
|
+
begin
|
|
131
|
+
@on_finished&.call
|
|
132
|
+
rescue => e
|
|
133
|
+
@logger&.error("[PatientHttp] Completion executor callback error: #{e.inspect}")
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -15,6 +15,16 @@ module PatientHttp
|
|
|
15
15
|
# @return [Integer] Maximum number of concurrent connections
|
|
16
16
|
attr_reader :max_connections
|
|
17
17
|
|
|
18
|
+
# @return [Integer, nil] Maximum number of connections per host (nil for unlimited)
|
|
19
|
+
attr_reader :max_connections_per_host
|
|
20
|
+
|
|
21
|
+
# @return [Integer] Number of threads that deliver completed results
|
|
22
|
+
attr_reader :completion_threads
|
|
23
|
+
|
|
24
|
+
# @return [Integer] Number of retries when delivering a completed result fails.
|
|
25
|
+
# A retry calls the task handler again, so handlers must be idempotent.
|
|
26
|
+
attr_reader :completion_retries
|
|
27
|
+
|
|
18
28
|
# @return [Numeric] Default request timeout in seconds
|
|
19
29
|
attr_reader :request_timeout
|
|
20
30
|
|
|
@@ -68,6 +78,12 @@ module PatientHttp
|
|
|
68
78
|
# @param proxy_url [String, nil] HTTP/HTTPS proxy URL (supports authentication)
|
|
69
79
|
# @param retries [Integer] Number of retries for failed requests
|
|
70
80
|
# @param protocol [Symbol, nil] HTTP protocol to use (:http1 or :http2); nil to negotiate
|
|
81
|
+
# @param max_connections_per_host [Integer, nil] Maximum number of connections per host (nil for unlimited)
|
|
82
|
+
# @param completion_threads [Integer] Number of threads that deliver completed results
|
|
83
|
+
# @param completion_retries [Integer] Number of retries when delivering a completed result fails.
|
|
84
|
+
# A retry calls TaskHandler#on_complete or #on_error again, so a handler that raises after
|
|
85
|
+
# its side effect delivers the callback more than once unless it is idempotent. Set to 0 to
|
|
86
|
+
# report the first failure without retrying.
|
|
71
87
|
def initialize(
|
|
72
88
|
max_connections: 256,
|
|
73
89
|
request_timeout: 60,
|
|
@@ -82,7 +98,10 @@ module PatientHttp
|
|
|
82
98
|
proxy_url: nil,
|
|
83
99
|
retries: 3,
|
|
84
100
|
protocol: nil,
|
|
85
|
-
encryption_key: nil
|
|
101
|
+
encryption_key: nil,
|
|
102
|
+
max_connections_per_host: nil,
|
|
103
|
+
completion_threads: 2,
|
|
104
|
+
completion_retries: 2
|
|
86
105
|
)
|
|
87
106
|
@mutex = Mutex.new
|
|
88
107
|
|
|
@@ -113,6 +132,9 @@ module PatientHttp
|
|
|
113
132
|
self.retries = retries
|
|
114
133
|
self.protocol = protocol
|
|
115
134
|
self.encryption_key = encryption_key
|
|
135
|
+
self.max_connections_per_host = max_connections_per_host
|
|
136
|
+
self.completion_threads = completion_threads
|
|
137
|
+
self.completion_retries = completion_retries
|
|
116
138
|
end
|
|
117
139
|
|
|
118
140
|
# Get the logger to use to report pool events. Default is to log errors to STDERR.
|
|
@@ -124,6 +146,26 @@ module PatientHttp
|
|
|
124
146
|
@max_connections = value
|
|
125
147
|
end
|
|
126
148
|
|
|
149
|
+
def max_connections_per_host=(value)
|
|
150
|
+
if value.nil?
|
|
151
|
+
@max_connections_per_host = nil
|
|
152
|
+
return
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
validate_positive_integer(:max_connections_per_host, value)
|
|
156
|
+
@max_connections_per_host = value
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def completion_threads=(value)
|
|
160
|
+
validate_positive_integer(:completion_threads, value)
|
|
161
|
+
@completion_threads = value
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def completion_retries=(value)
|
|
165
|
+
validate_non_negative_integer(:completion_retries, value)
|
|
166
|
+
@completion_retries = value
|
|
167
|
+
end
|
|
168
|
+
|
|
127
169
|
def request_timeout=(value)
|
|
128
170
|
validate_positive(:request_timeout, value)
|
|
129
171
|
@request_timeout = value
|
|
@@ -388,6 +430,9 @@ module PatientHttp
|
|
|
388
430
|
"proxy_url" => proxy_url,
|
|
389
431
|
"retries" => retries,
|
|
390
432
|
"protocol" => protocol,
|
|
433
|
+
"max_connections_per_host" => max_connections_per_host,
|
|
434
|
+
"completion_threads" => completion_threads,
|
|
435
|
+
"completion_retries" => completion_retries,
|
|
391
436
|
"payload_stores" => payload_stores.keys,
|
|
392
437
|
"default_payload_store" => default_payload_store_name,
|
|
393
438
|
"secrets" => @mutex.synchronize { @secrets.keys },
|
data/lib/patient_http/payload.rb
CHANGED
|
@@ -29,7 +29,9 @@ module PatientHttp
|
|
|
29
29
|
# Encodes a value based on its MIME type.
|
|
30
30
|
#
|
|
31
31
|
# For text-based content types, applies gzip compression if beneficial.
|
|
32
|
-
# For binary content, uses Base64 encoding.
|
|
32
|
+
# For binary content, uses Base64 encoding. A value that a text MIME type
|
|
33
|
+
# claims is text but that does not hold text is encoded as binary as
|
|
34
|
+
# well, because the serialized form must survive JSON encoding.
|
|
33
35
|
#
|
|
34
36
|
# @param value [String] the value to encode
|
|
35
37
|
# @param mimetype [String, nil] the MIME type of the content
|
|
@@ -38,21 +40,11 @@ module PatientHttp
|
|
|
38
40
|
return nil if value.nil?
|
|
39
41
|
|
|
40
42
|
if is_text_mimetype?(mimetype)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if value.bytesize < 4096
|
|
44
|
-
[:text, value, value.encoding.name]
|
|
45
|
-
else
|
|
46
|
-
gzipped = Zlib.gzip(value)
|
|
47
|
-
if gzipped.bytesize < value.bytesize
|
|
48
|
-
[:gzipped, [gzipped].pack("m0"), value.encoding.name]
|
|
49
|
-
else
|
|
50
|
-
[:text, value, value.encoding.name]
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
else
|
|
54
|
-
[:binary, [value].pack("m0"), Encoding::BINARY.name]
|
|
43
|
+
text = text_value(value, charset(mimetype))
|
|
44
|
+
return encode_text(text) if text?(text)
|
|
55
45
|
end
|
|
46
|
+
|
|
47
|
+
[:binary, [value].pack("m0"), Encoding::BINARY.name]
|
|
56
48
|
end
|
|
57
49
|
|
|
58
50
|
# Decodes an encoded value based on its encoding type.
|
|
@@ -78,6 +70,36 @@ module PatientHttp
|
|
|
78
70
|
|
|
79
71
|
private
|
|
80
72
|
|
|
73
|
+
# Encode a text value, compressing it when that makes it smaller.
|
|
74
|
+
#
|
|
75
|
+
# @param value [String] the text to encode
|
|
76
|
+
# @return [Array(Symbol, String, String)] [encoding, encoded_value, charset]
|
|
77
|
+
def encode_text(value)
|
|
78
|
+
return [:text, value, value.encoding.name] if value.bytesize < 4096
|
|
79
|
+
|
|
80
|
+
gzipped = Zlib.gzip(value)
|
|
81
|
+
if gzipped.bytesize < value.bytesize
|
|
82
|
+
[:gzipped, [gzipped].pack("m0"), value.encoding.name]
|
|
83
|
+
else
|
|
84
|
+
[:text, value, value.encoding.name]
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Whether a value can be serialized as text. JSON encoding converts a
|
|
89
|
+
# string to UTF-8, so the value must either be valid text in its own
|
|
90
|
+
# encoding or hold bytes that are already valid UTF-8. A body still
|
|
91
|
+
# carrying a content encoding the reader could not decode holds neither,
|
|
92
|
+
# even though its MIME type names a text type.
|
|
93
|
+
#
|
|
94
|
+
# @param value [String] the value to check
|
|
95
|
+
# @return [Boolean]
|
|
96
|
+
def text?(value)
|
|
97
|
+
return value.valid_encoding? unless value.encoding == Encoding::BINARY
|
|
98
|
+
return true if value.ascii_only?
|
|
99
|
+
|
|
100
|
+
value.dup.force_encoding(Encoding::UTF_8).valid_encoding?
|
|
101
|
+
end
|
|
102
|
+
|
|
81
103
|
def is_text_mimetype?(mimetype)
|
|
82
104
|
mimetype&.match?(/\Atext\/|application\/(?:json|xml|javascript)/)
|
|
83
105
|
end
|