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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b1f870f878fe0737149ef9b28549ceeba613a51fc39c70a243a84667713b73d4
4
- data.tar.gz: 114fd5d72a6f53af337967440fdabd26c8f726a0654edaf80835a2b96faa4666
3
+ metadata.gz: e47d5decebc437bc838df2937ec0947a28f1b0f37f84035a744880ef95cbc77c
4
+ data.tar.gz: 860980fe620dc80a4c48635aa6f39e8e328b44509a093222db417f982bc735bf
5
5
  SHA512:
6
- metadata.gz: 734e0a70510bfdd3ee9e9bf602d0ecb557f8202cde970c7b4aeb4e4900f23ff7fc6bc8f2a3c8a675b1366bb3d99ad6772618b6043ca737edf50ef37e7482e3e4
7
- data.tar.gz: 9e5cff59c2dd10739f677a102c12ef83cb3e007b175be6158e239e59abdf7ffe0b3460258149862564ebf6c09575467aaa92b2168581cdcba2cdf28bc77ba942
6
+ metadata.gz: e881c4c6ad57a8a455e1e71ae894528f5401eaefae8594f74230d8d97c068fbc5dee0b05fa873d661d02f59d90435535b33d6abb0171ab482aa54f7fb12de7ca
7
+ data.tar.gz: 1d26ab270de4ca7b585dec05d44e7b09f300865392a4c981038062cc43735d2b5f0db1e10e20ec29d511fd57f9da8c27ba55f124a8234d4c8876018aa0150779
checksums.yaml.gz.sig CHANGED
Binary file
@@ -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 = false
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
- @queued = false
51
-
52
- if @active && !@processing && @exchanges < @exchange_limit
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
- raise RuntimeError, "Backend is not processing a request!" unless @processing
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
- raise RuntimeError, "Backend is not processing a request!" unless @processing
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
- notify = @guard.synchronize do
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 && !@processing && @exchanges.zero? && !@closed
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 registry's global admission queue.
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
- def initialize(registry)
17
- @registry = registry
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 backend = @registry.acquire
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
- backend.processed
38
+ backend_reservation.processed
32
39
  reservation = :exchange
33
40
 
34
41
  if body = response.body
35
- response.body = ResponseBody.new(body){backend.release}
42
+ response.body = ResponseBody.new(body){backend_reservation.release}
36
43
  else
37
- backend.release
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
- backend.failed
52
+ backend_reservation.failed
46
53
  when :exchange
47
- backend.release
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
@@ -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 a global queue of available processing slots.
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 backend_factory [Proc | Nil] An optional backend construction strategy.
20
- def initialize(exchange_limit: 8, backend_factory: nil)
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 = Async::Queue.new
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
- @available.enqueue(WAKE) unless retired.empty?
72
+ notify_available unless retired.empty?
74
73
 
75
74
  self.size
76
75
  end
77
76
 
78
- # Acquire the next backend with processing capacity.
79
- # @returns [Backend | Nil] An admitted backend, or nil if no endpoints exist.
80
- def acquire
81
- loop do
82
- return nil if self.empty?
83
-
84
- candidate = @available.dequeue
85
- return nil unless candidate
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(backend)
130
- @available.enqueue(backend)
131
- rescue Async::Queue::ClosedError
132
- # The registry is already shutting down:
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
@@ -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
- def initialize(endpoint, control_endpoint, exchange_limit: 8)
20
- @registry = Registry.new(exchange_limit: exchange_limit)
21
- @proxy = Proxy.new(@registry)
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
- # Close the endpoint registry.
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
@@ -4,5 +4,5 @@
4
4
  # Copyright, 2026, by Samuel Williams.
5
5
 
6
6
  module Fantail
7
- VERSION = "0.0.1"
7
+ VERSION = "0.1.0"
8
8
  end
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
  [![Development Status](https://github.com/socketry/fantail/workflows/Test/badge.svg)](https://github.com/socketry/fantail/actions?workflow=Test)
6
6
 
7
- Fantail routes each request to a worker which is ready to process it. It 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.
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
@@ -1,5 +1,9 @@
1
1
  # Releases
2
2
 
3
+ ## v0.1.0
4
+
5
+ - Add configurable request queues, worker affinity policies, central permit scheduling, and load shedding.
6
+
3
7
  ## v0.0.1
4
8
 
5
9
  - Initial implementation.
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.1
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