fantail 0.0.1 → 0.1.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
- checksums.yaml.gz.sig +0 -0
- data/lib/fantail/backend.rb +39 -25
- data/lib/fantail/balance.rb +60 -0
- data/lib/fantail/configuration.rb +155 -0
- data/lib/fantail/proxy.rb +16 -9
- data/lib/fantail/queue.rb +148 -0
- data/lib/fantail/registry.rb +25 -28
- data/lib/fantail/scheduler.rb +221 -0
- data/lib/fantail/server.rb +12 -4
- data/lib/fantail/version.rb +1 -1
- data/lib/fantail.rb +4 -0
- data/readme.md +5 -1
- data/releases.md +4 -0
- data.tar.gz.sig +0 -0
- metadata +5 -1
- metadata.gz.sig +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e47d5decebc437bc838df2937ec0947a28f1b0f37f84035a744880ef95cbc77c
|
|
4
|
+
data.tar.gz: 860980fe620dc80a4c48635aa6f39e8e328b44509a093222db417f982bc735bf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e881c4c6ad57a8a455e1e71ae894528f5401eaefae8594f74230d8d97c068fbc5dee0b05fa873d661d02f59d90435535b33d6abb0171ab482aa54f7fb12de7ca
|
|
7
|
+
data.tar.gz: 1d26ab270de4ca7b585dec05d44e7b09f300865392a4c981038062cc43735d2b5f0db1e10e20ec29d511fd57f9da8c27ba55f124a8234d4c8876018aa0150779
|
checksums.yaml.gz.sig
CHANGED
|
Binary file
|
data/lib/fantail/backend.rb
CHANGED
|
@@ -10,20 +10,23 @@ module Fantail
|
|
|
10
10
|
# @parameter endpoint [Endpoint] The endpoint served by this backend.
|
|
11
11
|
# @parameter client [Interface(:call, :close)] The HTTP client for the endpoint.
|
|
12
12
|
# @parameter exchange_limit [Integer] The maximum number of outstanding response exchanges.
|
|
13
|
+
# @parameter permit_limit [Integer] The maximum number of concurrent processing permits.
|
|
13
14
|
# @yields {|backend| ...} Invoked when the backend can accept another request.
|
|
14
|
-
def initialize(endpoint, client, exchange_limit:, &available)
|
|
15
|
+
def initialize(endpoint, client, exchange_limit:, permit_limit: 1, &available)
|
|
15
16
|
raise ArgumentError, "Exchange limit must be positive!" unless exchange_limit.positive?
|
|
17
|
+
raise ArgumentError, "Permit limit must be positive!" unless permit_limit.positive?
|
|
16
18
|
|
|
17
19
|
@endpoint = endpoint
|
|
18
20
|
@client = client
|
|
19
21
|
@exchange_limit = exchange_limit
|
|
22
|
+
@permit_limit = permit_limit
|
|
20
23
|
@available = available
|
|
21
24
|
|
|
22
25
|
@guard = Thread::Mutex.new
|
|
23
26
|
@active = true
|
|
24
|
-
@processing =
|
|
27
|
+
@processing = 0
|
|
28
|
+
@processing_by_queue = Hash.new(0)
|
|
25
29
|
@exchanges = 0
|
|
26
|
-
@queued = false
|
|
27
30
|
@closed = false
|
|
28
31
|
end
|
|
29
32
|
|
|
@@ -45,12 +48,11 @@ module Fantail
|
|
|
45
48
|
|
|
46
49
|
# Reserve the processing slot and one response exchange.
|
|
47
50
|
# @returns [Boolean] Whether the backend was successfully reserved.
|
|
48
|
-
def reserve
|
|
51
|
+
def reserve(queue_name = :default)
|
|
49
52
|
@guard.synchronize do
|
|
50
|
-
@
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
@processing = true
|
|
53
|
+
if @active && @processing < @permit_limit && @exchanges < @exchange_limit
|
|
54
|
+
@processing += 1
|
|
55
|
+
@processing_by_queue[queue_name] += 1
|
|
54
56
|
@exchanges += 1
|
|
55
57
|
return true
|
|
56
58
|
end
|
|
@@ -67,20 +69,18 @@ module Fantail
|
|
|
67
69
|
end
|
|
68
70
|
|
|
69
71
|
# Release the request-processing slot after response headers arrive.
|
|
70
|
-
def processed
|
|
72
|
+
def processed(queue_name = :default)
|
|
71
73
|
@guard.synchronize do
|
|
72
|
-
|
|
73
|
-
@processing = false
|
|
74
|
+
release_processing(queue_name)
|
|
74
75
|
end
|
|
75
76
|
|
|
76
77
|
notify_available
|
|
77
78
|
end
|
|
78
79
|
|
|
79
80
|
# Release both reservations when a request fails before response headers.
|
|
80
|
-
def failed
|
|
81
|
+
def failed(queue_name = :default)
|
|
81
82
|
close = @guard.synchronize do
|
|
82
|
-
|
|
83
|
-
@processing = false
|
|
83
|
+
release_processing(queue_name)
|
|
84
84
|
@exchanges -= 1
|
|
85
85
|
should_close?
|
|
86
86
|
end
|
|
@@ -123,26 +123,40 @@ module Fantail
|
|
|
123
123
|
|
|
124
124
|
# @returns [Boolean] Whether a request is waiting for response headers.
|
|
125
125
|
def processing?
|
|
126
|
+
@guard.synchronize{@processing.positive?}
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# @returns [Integer] The number of active processing permits.
|
|
130
|
+
def processing
|
|
126
131
|
@guard.synchronize{@processing}
|
|
127
132
|
end
|
|
128
133
|
|
|
134
|
+
# @returns [Integer] The number of active permits for the given queue affinity.
|
|
135
|
+
def processing_for(queue_name)
|
|
136
|
+
@guard.synchronize{@processing_by_queue[queue_name]}
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# @returns [Boolean] Whether another request can be admitted.
|
|
140
|
+
def available?
|
|
141
|
+
@guard.synchronize{@active && @processing < @permit_limit && @exchanges < @exchange_limit}
|
|
142
|
+
end
|
|
143
|
+
|
|
129
144
|
protected
|
|
130
145
|
|
|
131
146
|
def notify_available
|
|
132
|
-
|
|
133
|
-
if @active && !@processing && @exchanges < @exchange_limit && !@queued
|
|
134
|
-
@queued = true
|
|
135
|
-
true
|
|
136
|
-
else
|
|
137
|
-
false
|
|
138
|
-
end
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
@available.call(self) if notify
|
|
147
|
+
@available.call(self) if available?
|
|
142
148
|
end
|
|
143
149
|
|
|
144
150
|
def should_close?
|
|
145
|
-
!@active &&
|
|
151
|
+
!@active && @processing.zero? && @exchanges.zero? && !@closed
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def release_processing(queue_name)
|
|
155
|
+
raise RuntimeError, "Backend is not processing a request!" unless @processing.positive?
|
|
156
|
+
raise RuntimeError, "Backend is not processing queue #{queue_name.inspect}!" unless @processing_by_queue[queue_name].positive?
|
|
157
|
+
|
|
158
|
+
@processing -= 1
|
|
159
|
+
@processing_by_queue[queue_name] -= 1
|
|
146
160
|
end
|
|
147
161
|
|
|
148
162
|
def close_client
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2026, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
module Fantail
|
|
7
|
+
# Built-in backend selection policies.
|
|
8
|
+
module Balance
|
|
9
|
+
# Prefer the backend with the fewest active requests.
|
|
10
|
+
class Spread
|
|
11
|
+
# Select the least-active backend, using its name for deterministic ties.
|
|
12
|
+
# @parameter backends [Array(Backend)] Eligible backends with available permits.
|
|
13
|
+
# @parameter queue [Queue] The request queue being scheduled.
|
|
14
|
+
# @parameter request [Protocol::HTTP::Request] The pending request.
|
|
15
|
+
# @returns [Backend | Nil] The preferred backend.
|
|
16
|
+
def select(backends, queue:, request:)
|
|
17
|
+
backends.min_by{|backend| [backend.processing, backend.name]}
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Prefer a backend which is already processing the same class of work.
|
|
22
|
+
class Pack
|
|
23
|
+
# @parameter affinity [Symbol | Nil] The queue affinity to pack, or the current queue by default.
|
|
24
|
+
def initialize(affinity: nil)
|
|
25
|
+
@affinity = affinity
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Select the backend with the most active work for the affinity.
|
|
29
|
+
# @parameter backends [Array(Backend)] Eligible backends with available permits.
|
|
30
|
+
# @parameter queue [Queue] The request queue being scheduled.
|
|
31
|
+
# @parameter request [Protocol::HTTP::Request] The pending request.
|
|
32
|
+
# @returns [Backend | Nil] The preferred backend.
|
|
33
|
+
def select(backends, queue:, request:)
|
|
34
|
+
affinity = @affinity || queue.name
|
|
35
|
+
backends.min_by do |backend|
|
|
36
|
+
[-backend.processing_for(affinity), backend.processing, backend.name]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Resolve a built-in policy name or validate an application policy object.
|
|
42
|
+
# @parameter policy [Symbol | #select] The policy name or object.
|
|
43
|
+
# @parameter options [Hash] Options for a built-in policy.
|
|
44
|
+
# @returns [#select] The resolved balance policy.
|
|
45
|
+
def self.coerce(policy, **options)
|
|
46
|
+
case policy
|
|
47
|
+
when :spread
|
|
48
|
+
Spread.new(**options)
|
|
49
|
+
when :pack
|
|
50
|
+
Pack.new(**options)
|
|
51
|
+
else
|
|
52
|
+
unless policy.respond_to?(:select)
|
|
53
|
+
raise ArgumentError, "Balance policy must respond to #select!"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
policy
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2026, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
require_relative "queue"
|
|
7
|
+
|
|
8
|
+
module Fantail
|
|
9
|
+
# Immutable request queue and admission configuration.
|
|
10
|
+
class Configuration
|
|
11
|
+
# Builds configurations using the application DSL.
|
|
12
|
+
class Builder
|
|
13
|
+
# @parameter root [String] The root for relative configuration files.
|
|
14
|
+
def initialize(root = Dir.pwd)
|
|
15
|
+
@root = File.expand_path(root)
|
|
16
|
+
@queues = {}
|
|
17
|
+
@default_queue_name = nil
|
|
18
|
+
@pending_limit = nil
|
|
19
|
+
@permit_limit = 1
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @attribute [String] The root for relative configuration files.
|
|
23
|
+
attr :root
|
|
24
|
+
|
|
25
|
+
# Evaluate a trusted configuration file using this builder.
|
|
26
|
+
# @parameter path [String] The relative or absolute configuration path.
|
|
27
|
+
def load_file(path)
|
|
28
|
+
realpath = File.realpath(File.expand_path(path, @root))
|
|
29
|
+
root = @root
|
|
30
|
+
@root = File.dirname(realpath)
|
|
31
|
+
instance_eval(File.read(realpath), realpath)
|
|
32
|
+
ensure
|
|
33
|
+
@root = root if root
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Define a named request queue. Matchers are evaluated in definition order.
|
|
37
|
+
# @parameter name [Symbol | String] The stable queue name.
|
|
38
|
+
# @yields {|queue| ...} The queue builder, or evaluates the block as its DSL.
|
|
39
|
+
# @returns [Queue] The configured queue.
|
|
40
|
+
def queue(name, &block)
|
|
41
|
+
name = name.to_sym
|
|
42
|
+
raise ArgumentError, "Queue #{name.inspect} is already defined!" if @queues.key?(name)
|
|
43
|
+
|
|
44
|
+
builder = Queue::Builder.new(name)
|
|
45
|
+
if block
|
|
46
|
+
if block.arity.zero?
|
|
47
|
+
builder.instance_eval(&block)
|
|
48
|
+
else
|
|
49
|
+
block.call(builder)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
@queues[name] = builder.build
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Select the fallback queue for unmatched requests.
|
|
57
|
+
# @parameter name [Symbol | String] A defined queue name.
|
|
58
|
+
def default_queue(name)
|
|
59
|
+
@default_queue_name = name.to_sym
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Set the global pending request limit.
|
|
63
|
+
# @parameter value [Integer] The maximum number of pending requests.
|
|
64
|
+
def pending_limit(value)
|
|
65
|
+
value = Integer(value)
|
|
66
|
+
raise ArgumentError, "Pending limit must not be negative!" if value.negative?
|
|
67
|
+
@pending_limit = value
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Set the number of processing permits provided by each worker.
|
|
71
|
+
# @parameter value [Integer] The number of permits per worker.
|
|
72
|
+
def permit_limit(value)
|
|
73
|
+
value = Integer(value)
|
|
74
|
+
raise ArgumentError, "Permit limit must be positive!" unless value.positive?
|
|
75
|
+
@permit_limit = value
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Validate and build the immutable configuration.
|
|
79
|
+
# @returns [Configuration] The configured request queues.
|
|
80
|
+
def build
|
|
81
|
+
raise ArgumentError, "At least one queue must be defined!" if @queues.empty?
|
|
82
|
+
default_queue_name = @default_queue_name || @queues.keys.first
|
|
83
|
+
raise ArgumentError, "Default queue #{default_queue_name.inspect} is not defined!" unless @queues.key?(default_queue_name)
|
|
84
|
+
|
|
85
|
+
Configuration.new(
|
|
86
|
+
queues: @queues.dup.freeze,
|
|
87
|
+
default_queue_name: default_queue_name,
|
|
88
|
+
pending_limit: @pending_limit,
|
|
89
|
+
permit_limit: @permit_limit,
|
|
90
|
+
).freeze
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Build a configuration using a scoped builder.
|
|
95
|
+
# @parameter root [String] The root for relative configuration files.
|
|
96
|
+
# @yields {|builder| ...} The configuration builder.
|
|
97
|
+
# @returns [Configuration] The immutable configuration.
|
|
98
|
+
def self.build(root: Dir.pwd, &block)
|
|
99
|
+
builder = Builder.new(root)
|
|
100
|
+
|
|
101
|
+
if block
|
|
102
|
+
if block.arity.zero?
|
|
103
|
+
builder.instance_eval(&block)
|
|
104
|
+
else
|
|
105
|
+
block.call(builder)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
builder.build
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# @returns [Configuration] A single-queue, single-permit configuration.
|
|
113
|
+
def self.default
|
|
114
|
+
@default ||= build do
|
|
115
|
+
queue(:default)
|
|
116
|
+
default_queue(:default)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Load and build trusted application configuration files.
|
|
121
|
+
# @parameter paths [String | Array(String)] The configuration files to load.
|
|
122
|
+
# @returns [Configuration] The immutable configuration.
|
|
123
|
+
def self.load(paths)
|
|
124
|
+
builder = Builder.new
|
|
125
|
+
Array(paths).each{|path| builder.load_file(path)}
|
|
126
|
+
builder.build
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# @parameter queues [Hash(Symbol, Queue)] The configured queues.
|
|
130
|
+
# @parameter default_queue_name [Symbol] The fallback queue name.
|
|
131
|
+
# @parameter pending_limit [Integer | Nil] The global pending request limit.
|
|
132
|
+
# @parameter permit_limit [Integer] The processing permits per worker.
|
|
133
|
+
def initialize(queues:, default_queue_name:, pending_limit:, permit_limit:)
|
|
134
|
+
@queues = queues
|
|
135
|
+
@default_queue_name = default_queue_name
|
|
136
|
+
@pending_limit = pending_limit
|
|
137
|
+
@permit_limit = permit_limit
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
attr :queues
|
|
141
|
+
attr :default_queue_name
|
|
142
|
+
attr :pending_limit
|
|
143
|
+
attr :permit_limit
|
|
144
|
+
|
|
145
|
+
# Classify a request using matchers in definition order.
|
|
146
|
+
# @returns [Queue] The matching or default queue.
|
|
147
|
+
def classify(request)
|
|
148
|
+
@queues.each_value do |queue|
|
|
149
|
+
return queue if queue.match?(request)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
@queues.fetch(@default_queue_name)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
data/lib/fantail/proxy.rb
CHANGED
|
@@ -7,34 +7,41 @@ require "protocol/http/request"
|
|
|
7
7
|
require "protocol/http/response"
|
|
8
8
|
|
|
9
9
|
require_relative "response_body"
|
|
10
|
+
require_relative "scheduler"
|
|
10
11
|
|
|
11
12
|
module Fantail
|
|
12
|
-
# Routes HTTP requests through the
|
|
13
|
+
# Routes HTTP requests through the configured admission queues.
|
|
13
14
|
class Proxy
|
|
14
15
|
# Initialize an HTTP proxy.
|
|
15
16
|
# @parameter registry [Registry] The backend registry.
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
# @parameter configuration [Configuration] Request classification and scheduling policy.
|
|
18
|
+
def initialize(registry, configuration: Configuration.default)
|
|
19
|
+
@scheduler = Scheduler.new(registry, configuration)
|
|
18
20
|
end
|
|
19
21
|
|
|
22
|
+
attr :scheduler
|
|
23
|
+
|
|
20
24
|
# Route a request to the next available backend.
|
|
21
25
|
# @parameter request [Protocol::HTTP::Request] The downstream request.
|
|
22
26
|
# @returns [Protocol::HTTP::Response] The upstream or generated response.
|
|
23
27
|
def call(request)
|
|
24
|
-
unless
|
|
28
|
+
unless backend_reservation = @scheduler.acquire(request)
|
|
25
29
|
return Protocol::HTTP::Response[503, {"content-type" => "text/plain"}, ["No backends available.\n"]]
|
|
26
30
|
end
|
|
27
31
|
|
|
32
|
+
return backend_reservation.response if backend_reservation.is_a?(Scheduler::Rejection)
|
|
33
|
+
|
|
28
34
|
reservation = :processing
|
|
35
|
+
backend = backend_reservation.backend
|
|
29
36
|
upstream_request = build_request(request)
|
|
30
37
|
response = backend.call(upstream_request)
|
|
31
|
-
|
|
38
|
+
backend_reservation.processed
|
|
32
39
|
reservation = :exchange
|
|
33
40
|
|
|
34
41
|
if body = response.body
|
|
35
|
-
response.body = ResponseBody.new(body){
|
|
42
|
+
response.body = ResponseBody.new(body){backend_reservation.release}
|
|
36
43
|
else
|
|
37
|
-
|
|
44
|
+
backend_reservation.release
|
|
38
45
|
end
|
|
39
46
|
reservation = nil
|
|
40
47
|
|
|
@@ -42,9 +49,9 @@ module Fantail
|
|
|
42
49
|
rescue => error
|
|
43
50
|
case reservation
|
|
44
51
|
when :processing
|
|
45
|
-
|
|
52
|
+
backend_reservation.failed
|
|
46
53
|
when :exchange
|
|
47
|
-
|
|
54
|
+
backend_reservation.release
|
|
48
55
|
end
|
|
49
56
|
|
|
50
57
|
return Protocol::HTTP::Response[502, {"content-type" => "text/plain"}, ["Bad Gateway: #{error.class}\n"]]
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2026, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
require_relative "balance"
|
|
7
|
+
|
|
8
|
+
module Fantail
|
|
9
|
+
# Immutable policy for one class of requests.
|
|
10
|
+
class Queue
|
|
11
|
+
# Builds a queue policy using the configuration DSL.
|
|
12
|
+
class Builder
|
|
13
|
+
# @parameter name [Symbol | String] The stable queue name.
|
|
14
|
+
def initialize(name)
|
|
15
|
+
@name = name
|
|
16
|
+
@matcher = nil
|
|
17
|
+
@eligibility = nil
|
|
18
|
+
@admission = nil
|
|
19
|
+
@balance_policy = Balance::Spread.new
|
|
20
|
+
@depth_limit = nil
|
|
21
|
+
@wait_limit = nil
|
|
22
|
+
@shed_status = 429
|
|
23
|
+
@shed_headers = {}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Set the request classifier for this queue.
|
|
27
|
+
# @yields {|request| ...} Whether a request belongs to this queue.
|
|
28
|
+
def match(&block)
|
|
29
|
+
raise ArgumentError, "A matcher block is required!" unless block
|
|
30
|
+
@matcher = block
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Restrict the backends which may serve this queue.
|
|
34
|
+
# @yields {|backend, request| ...} Whether the backend is eligible.
|
|
35
|
+
def eligible(&block)
|
|
36
|
+
raise ArgumentError, "An eligibility block is required!" unless block
|
|
37
|
+
@eligibility = block
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Set an application admission policy.
|
|
41
|
+
# @parameter policy [#admit? | #call | Nil] The admission policy object.
|
|
42
|
+
# @yields {|request, queue:, pending:| ...} Whether the request can wait.
|
|
43
|
+
def admit(policy = nil, &block)
|
|
44
|
+
@admission = policy || block
|
|
45
|
+
raise ArgumentError, "An admission policy is required!" unless @admission
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Set the soft backend balance policy.
|
|
49
|
+
# @parameter policy [Symbol | #select] A built-in name or application policy.
|
|
50
|
+
# @parameter options [Hash] Options for a built-in policy.
|
|
51
|
+
def balance(policy, **options)
|
|
52
|
+
@balance_policy = Balance.coerce(policy, **options)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Set the maximum number of requests waiting in this queue.
|
|
56
|
+
# @parameter value [Integer] The maximum queue depth.
|
|
57
|
+
def depth_limit(value)
|
|
58
|
+
value = Integer(value)
|
|
59
|
+
raise ArgumentError, "Depth limit must not be negative!" if value.negative?
|
|
60
|
+
@depth_limit = value
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Set the maximum time a request may wait for a permit.
|
|
64
|
+
# @parameter value [Numeric] The maximum wait in seconds.
|
|
65
|
+
def wait_limit(value)
|
|
66
|
+
value = Float(value)
|
|
67
|
+
raise ArgumentError, "Wait limit must be positive!" unless value.positive?
|
|
68
|
+
@wait_limit = value
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Configure the response used when admission is rejected.
|
|
72
|
+
# @parameter status [Integer] The HTTP response status.
|
|
73
|
+
# @parameter retry_after [Numeric | String | Nil] An optional Retry-After value.
|
|
74
|
+
# @parameter headers [Hash] Additional response headers.
|
|
75
|
+
def shed(status: 429, retry_after: nil, headers: {})
|
|
76
|
+
@shed_status = Integer(status)
|
|
77
|
+
@shed_headers = headers.transform_keys(&:to_s)
|
|
78
|
+
@shed_headers["retry-after"] = retry_after.to_s if retry_after
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Build the immutable queue policy.
|
|
82
|
+
# @returns [Queue] The configured queue.
|
|
83
|
+
def build
|
|
84
|
+
Queue.new(
|
|
85
|
+
@name,
|
|
86
|
+
matcher: @matcher,
|
|
87
|
+
eligibility: @eligibility,
|
|
88
|
+
admission: @admission,
|
|
89
|
+
balance_policy: @balance_policy,
|
|
90
|
+
depth_limit: @depth_limit,
|
|
91
|
+
wait_limit: @wait_limit,
|
|
92
|
+
shed_status: @shed_status,
|
|
93
|
+
shed_headers: @shed_headers.dup.freeze,
|
|
94
|
+
).freeze
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# @parameter name [Symbol | String] The stable queue name.
|
|
99
|
+
# @parameter matcher [Proc | Nil] The request classifier.
|
|
100
|
+
# @parameter eligibility [Proc | Nil] The backend eligibility policy.
|
|
101
|
+
# @parameter admission [#admit? | #call | Nil] The queue admission policy.
|
|
102
|
+
# @parameter balance_policy [#select] The backend balance policy.
|
|
103
|
+
# @parameter depth_limit [Integer | Nil] The maximum queue depth.
|
|
104
|
+
# @parameter wait_limit [Float | Nil] The maximum queue wait.
|
|
105
|
+
# @parameter shed_status [Integer] The rejection response status.
|
|
106
|
+
# @parameter shed_headers [Hash] The rejection response headers.
|
|
107
|
+
def initialize(name, matcher:, eligibility:, admission:, balance_policy:, depth_limit:, wait_limit:, shed_status:, shed_headers:)
|
|
108
|
+
@name = name.to_sym
|
|
109
|
+
@matcher = matcher
|
|
110
|
+
@eligibility = eligibility
|
|
111
|
+
@admission = admission
|
|
112
|
+
@balance_policy = balance_policy
|
|
113
|
+
@depth_limit = depth_limit
|
|
114
|
+
@wait_limit = wait_limit
|
|
115
|
+
@shed_status = shed_status
|
|
116
|
+
@shed_headers = shed_headers
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
attr :name
|
|
120
|
+
attr :balance_policy
|
|
121
|
+
attr :depth_limit
|
|
122
|
+
attr :wait_limit
|
|
123
|
+
attr :shed_status
|
|
124
|
+
attr :shed_headers
|
|
125
|
+
|
|
126
|
+
# @parameter request [Protocol::HTTP::Request] The request to classify.
|
|
127
|
+
# @returns [Boolean | Nil] Whether the request matches this queue.
|
|
128
|
+
def match?(request)
|
|
129
|
+
@matcher&.call(request)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# @returns [Boolean] Whether a backend may serve the request.
|
|
133
|
+
def eligible?(backend, request)
|
|
134
|
+
!@eligibility || @eligibility.call(backend, request)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# @returns [Boolean] Whether a request may enter the pending queue.
|
|
138
|
+
def admit?(request, pending:)
|
|
139
|
+
return true unless @admission
|
|
140
|
+
|
|
141
|
+
if @admission.respond_to?(:admit?)
|
|
142
|
+
@admission.admit?(request, queue: self, pending: pending)
|
|
143
|
+
else
|
|
144
|
+
@admission.call(request, queue: self, pending: pending)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
data/lib/fantail/registry.rb
CHANGED
|
@@ -4,26 +4,25 @@
|
|
|
4
4
|
# Copyright, 2026, by Samuel Williams.
|
|
5
5
|
|
|
6
6
|
require "async/bus/controller"
|
|
7
|
-
require "async/queue"
|
|
8
7
|
|
|
9
8
|
require_relative "endpoint"
|
|
10
9
|
require_relative "backend"
|
|
11
10
|
|
|
12
11
|
module Fantail
|
|
13
|
-
# Maintains live backends and
|
|
12
|
+
# Maintains live backends and notifies the scheduler when capacity changes.
|
|
14
13
|
class Registry < Async::Bus::Controller
|
|
15
|
-
WAKE = Object.new.freeze
|
|
16
|
-
|
|
17
14
|
# Initialize an endpoint registry.
|
|
18
15
|
# @parameter exchange_limit [Integer] The maximum outstanding responses per backend.
|
|
19
|
-
# @parameter
|
|
20
|
-
|
|
16
|
+
# @parameter permit_limit [Integer] The maximum active processing permits per backend.
|
|
17
|
+
# @parameter backend_factory [#call(endpoint, exchange_limit, permit_limit, available) | Nil] An optional backend construction strategy.
|
|
18
|
+
def initialize(exchange_limit: 8, permit_limit: 1, backend_factory: nil)
|
|
21
19
|
@exchange_limit = exchange_limit
|
|
20
|
+
@permit_limit = permit_limit
|
|
22
21
|
@backend_factory = backend_factory || self.method(:make_backend)
|
|
23
22
|
|
|
24
23
|
@guard = Thread::Mutex.new
|
|
25
24
|
@backends = {}
|
|
26
|
-
@available =
|
|
25
|
+
@available = nil
|
|
27
26
|
@closed = false
|
|
28
27
|
end
|
|
29
28
|
|
|
@@ -62,7 +61,7 @@ module Fantail
|
|
|
62
61
|
next if current&.endpoint == endpoint
|
|
63
62
|
|
|
64
63
|
retired << current if current
|
|
65
|
-
backend = @backend_factory.call(endpoint, @exchange_limit, self.method(:offer))
|
|
64
|
+
backend = @backend_factory.call(endpoint, @exchange_limit, @permit_limit, self.method(:offer))
|
|
66
65
|
@backends[endpoint.name] = backend
|
|
67
66
|
started << backend
|
|
68
67
|
end
|
|
@@ -70,23 +69,19 @@ module Fantail
|
|
|
70
69
|
|
|
71
70
|
retired.each(&:retire)
|
|
72
71
|
started.each(&:start)
|
|
73
|
-
|
|
72
|
+
notify_available unless retired.empty?
|
|
74
73
|
|
|
75
74
|
self.size
|
|
76
75
|
end
|
|
77
76
|
|
|
78
|
-
#
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
next if candidate.equal?(WAKE)
|
|
87
|
-
|
|
88
|
-
return candidate if candidate.reserve
|
|
89
|
-
end
|
|
77
|
+
# @returns [Array(Backend)] A snapshot of active backends.
|
|
78
|
+
def backends
|
|
79
|
+
@guard.synchronize{@backends.values.dup}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Register the central scheduler capacity callback.
|
|
83
|
+
def on_available(&block)
|
|
84
|
+
@guard.synchronize{@available = block}
|
|
90
85
|
end
|
|
91
86
|
|
|
92
87
|
# @returns [Integer] The number of active endpoints.
|
|
@@ -120,21 +115,23 @@ module Fantail
|
|
|
120
115
|
@backends.values.tap{@backends = {}}
|
|
121
116
|
end
|
|
122
117
|
|
|
123
|
-
@available.close
|
|
124
118
|
backends.each(&:retire)
|
|
125
119
|
end
|
|
126
120
|
|
|
127
121
|
protected
|
|
128
122
|
|
|
129
|
-
def offer(
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
123
|
+
def offer(_backend)
|
|
124
|
+
notify_available
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def notify_available
|
|
128
|
+
available = @guard.synchronize{@available}
|
|
129
|
+
available&.call
|
|
133
130
|
end
|
|
134
131
|
|
|
135
|
-
def make_backend(endpoint, exchange_limit, available)
|
|
132
|
+
def make_backend(endpoint, exchange_limit, permit_limit, available)
|
|
136
133
|
client = endpoint.make_client(exchange_limit: exchange_limit)
|
|
137
|
-
Backend.new(endpoint, client, exchange_limit: exchange_limit, &available)
|
|
134
|
+
Backend.new(endpoint, client, exchange_limit: exchange_limit, permit_limit: permit_limit, &available)
|
|
138
135
|
end
|
|
139
136
|
end
|
|
140
137
|
end
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2026, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
require "async/queue"
|
|
7
|
+
require "protocol/http/response"
|
|
8
|
+
|
|
9
|
+
require_relative "configuration"
|
|
10
|
+
|
|
11
|
+
module Fantail
|
|
12
|
+
# Matches pending requests to concrete backend permits.
|
|
13
|
+
class Scheduler
|
|
14
|
+
# A reserved processing permit and response exchange.
|
|
15
|
+
class Reservation
|
|
16
|
+
# @parameter backend [Backend] The reserved backend.
|
|
17
|
+
# @parameter queue_name [Symbol] The queue consuming the permit.
|
|
18
|
+
def initialize(backend, queue_name)
|
|
19
|
+
@backend = backend
|
|
20
|
+
@queue_name = queue_name
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
attr :backend
|
|
24
|
+
|
|
25
|
+
# Release the processing permit after response headers arrive.
|
|
26
|
+
def processed
|
|
27
|
+
@backend.processed(@queue_name)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Release the processing permit and exchange after an upstream failure.
|
|
31
|
+
def failed
|
|
32
|
+
@backend.failed(@queue_name)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Release the response exchange after its body closes.
|
|
36
|
+
def release
|
|
37
|
+
@backend.release
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# A queue admission rejection.
|
|
42
|
+
class Rejection
|
|
43
|
+
# @parameter queue [Queue] The queue which rejected admission.
|
|
44
|
+
def initialize(queue)
|
|
45
|
+
@queue = queue
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @returns [Protocol::HTTP::Response] The configured shedding response.
|
|
49
|
+
def response
|
|
50
|
+
headers = {"content-type" => "text/plain"}.merge(@queue.shed_headers)
|
|
51
|
+
Protocol::HTTP::Response[@queue.shed_status, headers, ["Request queue is full.\n"]]
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
Entry = Struct.new(:request, :queue, :enqueued_at, :result, :pending, :assignment)
|
|
56
|
+
|
|
57
|
+
# @parameter registry [Registry] The available backend registry.
|
|
58
|
+
# @parameter configuration [Configuration] Request and scheduling policy.
|
|
59
|
+
def initialize(registry, configuration = Configuration.default)
|
|
60
|
+
@registry = registry
|
|
61
|
+
@configuration = configuration
|
|
62
|
+
@guard = Thread::Mutex.new
|
|
63
|
+
@pending = configuration.queues.to_h{|name, queue| [name, []]}
|
|
64
|
+
@pending_count = 0
|
|
65
|
+
@closed = false
|
|
66
|
+
|
|
67
|
+
@registry.on_available{schedule}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Admit a request, wait for a matching permit, or return a rejection.
|
|
71
|
+
def acquire(request)
|
|
72
|
+
queue = @configuration.classify(request)
|
|
73
|
+
entry = nil
|
|
74
|
+
result = @guard.synchronize do
|
|
75
|
+
if @closed
|
|
76
|
+
nil
|
|
77
|
+
elsif reservation = reserve(queue, request)
|
|
78
|
+
reservation
|
|
79
|
+
elsif @registry.empty?
|
|
80
|
+
nil
|
|
81
|
+
elsif reject?(queue, request)
|
|
82
|
+
Rejection.new(queue)
|
|
83
|
+
else
|
|
84
|
+
entry = Entry.new(request, queue, now, Async::Queue.new, true, nil)
|
|
85
|
+
@pending.fetch(queue.name) << entry
|
|
86
|
+
@pending_count += 1
|
|
87
|
+
schedule_locked
|
|
88
|
+
entry.assignment
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
return result unless entry
|
|
93
|
+
|
|
94
|
+
if assignment = entry.assignment
|
|
95
|
+
entry = nil
|
|
96
|
+
return assignment
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
if wait_limit = queue.wait_limit
|
|
100
|
+
remaining = wait_limit - (now - entry.enqueued_at)
|
|
101
|
+
result = entry.result.dequeue(timeout: remaining) if remaining.positive?
|
|
102
|
+
if result
|
|
103
|
+
entry = nil
|
|
104
|
+
return result
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
result = cancel(entry)
|
|
108
|
+
entry = nil
|
|
109
|
+
return result
|
|
110
|
+
else
|
|
111
|
+
result = entry.result.dequeue
|
|
112
|
+
entry = nil
|
|
113
|
+
return result
|
|
114
|
+
end
|
|
115
|
+
ensure
|
|
116
|
+
if entry && assignment = cancel(entry, rejection: false)
|
|
117
|
+
# The request was assigned concurrently but its waiting task was
|
|
118
|
+
# interrupted before receiving the reservation. No upstream request
|
|
119
|
+
# was started, so release both the permit and response exchange.
|
|
120
|
+
assignment.failed
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Try to dispatch pending requests after capacity changes.
|
|
125
|
+
def schedule
|
|
126
|
+
@guard.synchronize{schedule_locked unless @closed}
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Stop accepting requests and wake all tasks waiting for a permit.
|
|
130
|
+
def close
|
|
131
|
+
entries = @guard.synchronize do
|
|
132
|
+
return if @closed
|
|
133
|
+
|
|
134
|
+
@closed = true
|
|
135
|
+
entries = @pending.values.flatten(1)
|
|
136
|
+
@pending.each_value(&:clear)
|
|
137
|
+
@pending_count = 0
|
|
138
|
+
entries.each{|entry| entry.pending = false}
|
|
139
|
+
entries
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
entries.each{|entry| entry.result.close}
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# @parameter queue_name [Symbol | String | Nil] An optional queue to inspect.
|
|
146
|
+
# @returns [Integer] The number of requests waiting for a permit.
|
|
147
|
+
def pending_count(queue_name = nil)
|
|
148
|
+
@guard.synchronize do
|
|
149
|
+
if queue_name
|
|
150
|
+
@pending.fetch(queue_name.to_sym).size
|
|
151
|
+
else
|
|
152
|
+
@pending_count
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
protected
|
|
158
|
+
|
|
159
|
+
def now
|
|
160
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def reject?(queue, request)
|
|
164
|
+
return true if @configuration.pending_limit && @pending_count >= @configuration.pending_limit
|
|
165
|
+
pending = @pending.fetch(queue.name).size
|
|
166
|
+
return true if queue.depth_limit && pending >= queue.depth_limit
|
|
167
|
+
return true unless queue.admit?(request, pending: pending)
|
|
168
|
+
|
|
169
|
+
false
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def cancel(entry, rejection: true)
|
|
173
|
+
@guard.synchronize do
|
|
174
|
+
return entry.assignment unless entry.pending
|
|
175
|
+
|
|
176
|
+
@pending.fetch(entry.queue.name).delete(entry)
|
|
177
|
+
entry.pending = false
|
|
178
|
+
@pending_count -= 1
|
|
179
|
+
Rejection.new(entry.queue) if rejection
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def schedule_locked
|
|
184
|
+
loop do
|
|
185
|
+
entries = @pending.each_value.filter_map(&:first).sort_by(&:enqueued_at)
|
|
186
|
+
matched = false
|
|
187
|
+
|
|
188
|
+
entries.each do |entry|
|
|
189
|
+
if reservation = reserve(entry.queue, entry.request)
|
|
190
|
+
@pending.fetch(entry.queue.name).shift
|
|
191
|
+
@pending_count -= 1
|
|
192
|
+
entry.pending = false
|
|
193
|
+
entry.assignment = reservation
|
|
194
|
+
entry.result.enqueue(reservation)
|
|
195
|
+
matched = true
|
|
196
|
+
break
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
break unless matched
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def reserve(queue, request)
|
|
205
|
+
backends = @registry.backends.select do |backend|
|
|
206
|
+
backend.available? && queue.eligible?(backend, request)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
until backends.empty?
|
|
210
|
+
backend = queue.balance_policy.select(backends, queue: queue, request: request)
|
|
211
|
+
return nil unless backend
|
|
212
|
+
raise ArgumentError, "Balance policy selected an ineligible backend!" unless backends.include?(backend)
|
|
213
|
+
|
|
214
|
+
return Reservation.new(backend, queue.name) if backend.reserve(queue.name)
|
|
215
|
+
backends.delete(backend)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
nil
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
data/lib/fantail/server.rb
CHANGED
|
@@ -8,6 +8,7 @@ require "async/http/server"
|
|
|
8
8
|
require_relative "registry"
|
|
9
9
|
require_relative "proxy"
|
|
10
10
|
require_relative "control"
|
|
11
|
+
require_relative "configuration"
|
|
11
12
|
|
|
12
13
|
module Fantail
|
|
13
14
|
# Runs the HTTP load balancer and endpoint-control server together.
|
|
@@ -16,9 +17,10 @@ module Fantail
|
|
|
16
17
|
# @parameter endpoint [Async::HTTP::Endpoint] The downstream HTTP endpoint.
|
|
17
18
|
# @parameter control_endpoint [IO::Endpoint] The async-bus control endpoint.
|
|
18
19
|
# @parameter exchange_limit [Integer] The maximum outstanding responses per backend.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
@
|
|
20
|
+
# @parameter configuration [Configuration] Request classification and scheduling policy.
|
|
21
|
+
def initialize(endpoint, control_endpoint, exchange_limit: 8, configuration: Configuration.default)
|
|
22
|
+
@registry = Registry.new(exchange_limit: exchange_limit, permit_limit: configuration.permit_limit)
|
|
23
|
+
@proxy = Proxy.new(@registry, configuration: configuration)
|
|
22
24
|
@http_server = Async::HTTP::Server.new(@proxy, endpoint)
|
|
23
25
|
@control_server = Control.new(control_endpoint, @registry)
|
|
24
26
|
end
|
|
@@ -26,6 +28,11 @@ module Fantail
|
|
|
26
28
|
# @attribute [Registry] The server's endpoint registry.
|
|
27
29
|
attr :registry
|
|
28
30
|
|
|
31
|
+
# @attribute [Scheduler] The central request scheduler.
|
|
32
|
+
def scheduler
|
|
33
|
+
@proxy.scheduler
|
|
34
|
+
end
|
|
35
|
+
|
|
29
36
|
# Run the HTTP and control servers.
|
|
30
37
|
# @parameter parent [Interface(:async)] The parent task.
|
|
31
38
|
# @returns [Async::Task] The server task.
|
|
@@ -39,8 +46,9 @@ module Fantail
|
|
|
39
46
|
end
|
|
40
47
|
end
|
|
41
48
|
|
|
42
|
-
#
|
|
49
|
+
# Stop pending requests and close the endpoint registry.
|
|
43
50
|
def close
|
|
51
|
+
@proxy.scheduler.close
|
|
44
52
|
@registry.close
|
|
45
53
|
end
|
|
46
54
|
end
|
data/lib/fantail/version.rb
CHANGED
data/lib/fantail.rb
CHANGED
|
@@ -4,10 +4,14 @@
|
|
|
4
4
|
# Copyright, 2026, by Samuel Williams.
|
|
5
5
|
|
|
6
6
|
require_relative "fantail/version"
|
|
7
|
+
require_relative "fantail/balance"
|
|
8
|
+
require_relative "fantail/queue"
|
|
9
|
+
require_relative "fantail/configuration"
|
|
7
10
|
require_relative "fantail/endpoint"
|
|
8
11
|
require_relative "fantail/backend"
|
|
9
12
|
require_relative "fantail/response_body"
|
|
10
13
|
require_relative "fantail/registry"
|
|
14
|
+
require_relative "fantail/scheduler"
|
|
11
15
|
require_relative "fantail/proxy"
|
|
12
16
|
require_relative "fantail/control"
|
|
13
17
|
require_relative "fantail/monitor"
|
data/readme.md
CHANGED
|
@@ -4,7 +4,7 @@ Worker-aware HTTP load balancing with a global admission queue.
|
|
|
4
4
|
|
|
5
5
|
[](https://github.com/socketry/fantail/actions?workflow=Test)
|
|
6
6
|
|
|
7
|
-
Fantail routes each request to a worker which is ready to process it.
|
|
7
|
+
Fantail routes each request to a worker which is ready to process it. Configurable request queues can express worker affinity and load-shedding policy while a central scheduler remains responsible for matching requests to worker permits. Fantail separates the short-lived request-processing reservation from the potentially longer response exchange, so another request can begin after response headers arrive while the previous response body is still streaming.
|
|
8
8
|
|
|
9
9
|
## Usage
|
|
10
10
|
|
|
@@ -16,6 +16,10 @@ Please see the [project documentation](https://socketry.github.io/fantail/) for
|
|
|
16
16
|
|
|
17
17
|
Please see the [project releases](https://socketry.github.io/fantail/releases/index) for all releases.
|
|
18
18
|
|
|
19
|
+
### v0.1.0
|
|
20
|
+
|
|
21
|
+
- Add configurable request queues, worker affinity policies, central permit scheduling, and load shedding.
|
|
22
|
+
|
|
19
23
|
### v0.0.1
|
|
20
24
|
|
|
21
25
|
- Initial implementation.
|
data/releases.md
CHANGED
data.tar.gz.sig
CHANGED
|
Binary file
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: fantail
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0
|
|
4
|
+
version: 0.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Samuel Williams
|
|
@@ -72,12 +72,16 @@ extra_rdoc_files: []
|
|
|
72
72
|
files:
|
|
73
73
|
- lib/fantail.rb
|
|
74
74
|
- lib/fantail/backend.rb
|
|
75
|
+
- lib/fantail/balance.rb
|
|
76
|
+
- lib/fantail/configuration.rb
|
|
75
77
|
- lib/fantail/control.rb
|
|
76
78
|
- lib/fantail/endpoint.rb
|
|
77
79
|
- lib/fantail/monitor.rb
|
|
78
80
|
- lib/fantail/proxy.rb
|
|
81
|
+
- lib/fantail/queue.rb
|
|
79
82
|
- lib/fantail/registry.rb
|
|
80
83
|
- lib/fantail/response_body.rb
|
|
84
|
+
- lib/fantail/scheduler.rb
|
|
81
85
|
- lib/fantail/server.rb
|
|
82
86
|
- lib/fantail/version.rb
|
|
83
87
|
- license.md
|
metadata.gz.sig
CHANGED
|
Binary file
|