fantail 0.0.1
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 +7 -0
- checksums.yaml.gz.sig +0 -0
- data/lib/fantail/backend.rb +159 -0
- data/lib/fantail/control.rb +23 -0
- data/lib/fantail/endpoint.rb +88 -0
- data/lib/fantail/monitor.rb +107 -0
- data/lib/fantail/proxy.rb +74 -0
- data/lib/fantail/registry.rb +140 -0
- data/lib/fantail/response_body.rb +30 -0
- data/lib/fantail/server.rb +47 -0
- data/lib/fantail/version.rb +8 -0
- data/lib/fantail.rb +18 -0
- data/license.md +21 -0
- data/readme.md +55 -0
- data/releases.md +5 -0
- data.tar.gz.sig +0 -0
- metadata +112 -0
- metadata.gz.sig +5 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: b1f870f878fe0737149ef9b28549ceeba613a51fc39c70a243a84667713b73d4
|
|
4
|
+
data.tar.gz: 114fd5d72a6f53af337967440fdabd26c8f726a0654edaf80835a2b96faa4666
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 734e0a70510bfdd3ee9e9bf602d0ecb557f8202cde970c7b4aeb4e4900f23ff7fc6bc8f2a3c8a675b1366bb3d99ad6772618b6043ca737edf50ef37e7482e3e4
|
|
7
|
+
data.tar.gz: 9e5cff59c2dd10739f677a102c12ef83cb3e007b175be6158e239e59abdf7ffe0b3460258149862564ebf6c09575467aaa92b2168581cdcba2cdf28bc77ba942
|
checksums.yaml.gz.sig
ADDED
|
Binary file
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2026, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
module Fantail
|
|
7
|
+
# Represents a live backend client and its admission state.
|
|
8
|
+
class Backend
|
|
9
|
+
# Initialize a backend.
|
|
10
|
+
# @parameter endpoint [Endpoint] The endpoint served by this backend.
|
|
11
|
+
# @parameter client [Interface(:call, :close)] The HTTP client for the endpoint.
|
|
12
|
+
# @parameter exchange_limit [Integer] The maximum number of outstanding response exchanges.
|
|
13
|
+
# @yields {|backend| ...} Invoked when the backend can accept another request.
|
|
14
|
+
def initialize(endpoint, client, exchange_limit:, &available)
|
|
15
|
+
raise ArgumentError, "Exchange limit must be positive!" unless exchange_limit.positive?
|
|
16
|
+
|
|
17
|
+
@endpoint = endpoint
|
|
18
|
+
@client = client
|
|
19
|
+
@exchange_limit = exchange_limit
|
|
20
|
+
@available = available
|
|
21
|
+
|
|
22
|
+
@guard = Thread::Mutex.new
|
|
23
|
+
@active = true
|
|
24
|
+
@processing = false
|
|
25
|
+
@exchanges = 0
|
|
26
|
+
@queued = false
|
|
27
|
+
@closed = false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @attribute [Endpoint] The endpoint served by this backend.
|
|
31
|
+
attr :endpoint
|
|
32
|
+
|
|
33
|
+
# @attribute [String] The stable backend name.
|
|
34
|
+
def name
|
|
35
|
+
@endpoint.name
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @attribute [Integer] The maximum number of outstanding response exchanges.
|
|
39
|
+
attr :exchange_limit
|
|
40
|
+
|
|
41
|
+
# Start advertising this backend as available.
|
|
42
|
+
def start
|
|
43
|
+
notify_available
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Reserve the processing slot and one response exchange.
|
|
47
|
+
# @returns [Boolean] Whether the backend was successfully reserved.
|
|
48
|
+
def reserve
|
|
49
|
+
@guard.synchronize do
|
|
50
|
+
@queued = false
|
|
51
|
+
|
|
52
|
+
if @active && !@processing && @exchanges < @exchange_limit
|
|
53
|
+
@processing = true
|
|
54
|
+
@exchanges += 1
|
|
55
|
+
return true
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
return false
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Send a request to the backend.
|
|
63
|
+
# @parameter request [Protocol::HTTP::Request] The upstream request.
|
|
64
|
+
# @returns [Protocol::HTTP::Response] The upstream response.
|
|
65
|
+
def call(request)
|
|
66
|
+
@client.call(request)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Release the request-processing slot after response headers arrive.
|
|
70
|
+
def processed
|
|
71
|
+
@guard.synchronize do
|
|
72
|
+
raise RuntimeError, "Backend is not processing a request!" unless @processing
|
|
73
|
+
@processing = false
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
notify_available
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Release both reservations when a request fails before response headers.
|
|
80
|
+
def failed
|
|
81
|
+
close = @guard.synchronize do
|
|
82
|
+
raise RuntimeError, "Backend is not processing a request!" unless @processing
|
|
83
|
+
@processing = false
|
|
84
|
+
@exchanges -= 1
|
|
85
|
+
should_close?
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
notify_available
|
|
89
|
+
close_client if close
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Release an outstanding response exchange.
|
|
93
|
+
def release
|
|
94
|
+
close = @guard.synchronize do
|
|
95
|
+
raise RuntimeError, "Backend has no outstanding exchange!" unless @exchanges.positive?
|
|
96
|
+
@exchanges -= 1
|
|
97
|
+
should_close?
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
notify_available
|
|
101
|
+
close_client if close
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Retire this backend without interrupting outstanding responses.
|
|
105
|
+
def retire
|
|
106
|
+
close = @guard.synchronize do
|
|
107
|
+
@active = false
|
|
108
|
+
should_close?
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
close_client if close
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# @returns [Boolean] Whether this backend still accepts new requests.
|
|
115
|
+
def active?
|
|
116
|
+
@guard.synchronize{@active}
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# @returns [Integer] The number of outstanding response exchanges.
|
|
120
|
+
def exchanges
|
|
121
|
+
@guard.synchronize{@exchanges}
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# @returns [Boolean] Whether a request is waiting for response headers.
|
|
125
|
+
def processing?
|
|
126
|
+
@guard.synchronize{@processing}
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
protected
|
|
130
|
+
|
|
131
|
+
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
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def should_close?
|
|
145
|
+
!@active && !@processing && @exchanges.zero? && !@closed
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def close_client
|
|
149
|
+
close = @guard.synchronize do
|
|
150
|
+
unless @closed
|
|
151
|
+
@closed = true
|
|
152
|
+
true
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
@client.close if close
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2026, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
require "async/bus/server"
|
|
7
|
+
|
|
8
|
+
module Fantail
|
|
9
|
+
# Exposes a registry over async-bus for endpoint publication.
|
|
10
|
+
class Control < Async::Bus::Server
|
|
11
|
+
# Initialize a control server.
|
|
12
|
+
# @parameter endpoint [IO::Endpoint] The endpoint used for registry updates.
|
|
13
|
+
# @parameter registry [Registry] The registry to expose.
|
|
14
|
+
def initialize(endpoint, registry)
|
|
15
|
+
super(endpoint)
|
|
16
|
+
@registry = registry
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
protected def connected!(connection)
|
|
20
|
+
connection.bind(:registry, @registry)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2026, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
require "async/http/client"
|
|
7
|
+
require "async/http/endpoint"
|
|
8
|
+
require "async/http/protocol/http1"
|
|
9
|
+
require "async/http/protocol/http2"
|
|
10
|
+
|
|
11
|
+
module Fantail
|
|
12
|
+
# Represents a named HTTP backend endpoint.
|
|
13
|
+
class Endpoint
|
|
14
|
+
# Coerce an endpoint or endpoint description into an endpoint.
|
|
15
|
+
# @parameter description [Endpoint | Hash] The endpoint or serialized endpoint description.
|
|
16
|
+
# @returns [Endpoint] The coerced endpoint.
|
|
17
|
+
def self.coerce(description)
|
|
18
|
+
return description if description.is_a?(self)
|
|
19
|
+
|
|
20
|
+
name = description[:name] || description["name"]
|
|
21
|
+
url = description[:url] || description["url"]
|
|
22
|
+
protocol = description[:protocol] || description["protocol"]
|
|
23
|
+
|
|
24
|
+
self.new(name, url, protocol: protocol)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Initialize a named backend endpoint.
|
|
28
|
+
# @parameter name [String] The stable endpoint identity.
|
|
29
|
+
# @parameter url [String] The absolute backend URL.
|
|
30
|
+
# @parameter protocol [String | Nil] The upstream HTTP protocol name.
|
|
31
|
+
def initialize(name, url, protocol: nil)
|
|
32
|
+
raise ArgumentError, "Endpoint name is required!" unless name
|
|
33
|
+
raise ArgumentError, "Endpoint URL is required!" unless url
|
|
34
|
+
|
|
35
|
+
@name = name.to_s
|
|
36
|
+
@url = url.to_s
|
|
37
|
+
@protocol = protocol&.to_s
|
|
38
|
+
|
|
39
|
+
freeze
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @attribute [String] The stable endpoint identity.
|
|
43
|
+
attr :name
|
|
44
|
+
|
|
45
|
+
# @attribute [String] The absolute backend URL.
|
|
46
|
+
attr :url
|
|
47
|
+
|
|
48
|
+
# @attribute [String | Nil] The upstream HTTP protocol name.
|
|
49
|
+
attr :protocol
|
|
50
|
+
|
|
51
|
+
# Build an HTTP client for this endpoint.
|
|
52
|
+
# @parameter exchange_limit [Integer] The maximum number of outstanding response exchanges.
|
|
53
|
+
# @returns [Async::HTTP::Client] A client connected to this endpoint.
|
|
54
|
+
def make_client(exchange_limit:)
|
|
55
|
+
endpoint = Async::HTTP::Endpoint.parse(@url, protocol: protocol_module)
|
|
56
|
+
|
|
57
|
+
Async::HTTP::Client.new(endpoint, limit: exchange_limit, retries: 0)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Convert this endpoint into a transport-safe description.
|
|
61
|
+
# @returns [Hash] A serialized endpoint description.
|
|
62
|
+
def to_h
|
|
63
|
+
{
|
|
64
|
+
"name" => @name,
|
|
65
|
+
"url" => @url,
|
|
66
|
+
"protocol" => @protocol,
|
|
67
|
+
}.compact
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Compare endpoints by their serialized configuration.
|
|
71
|
+
def ==(other)
|
|
72
|
+
other.is_a?(Endpoint) && other.to_h == self.to_h
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
protected
|
|
76
|
+
|
|
77
|
+
def protocol_module
|
|
78
|
+
case @protocol
|
|
79
|
+
when nil, "http/1.0", "http/1.1"
|
|
80
|
+
Async::HTTP::Protocol::HTTP1
|
|
81
|
+
when "h2", "http/2"
|
|
82
|
+
Async::HTTP::Protocol::HTTP2
|
|
83
|
+
else
|
|
84
|
+
raise ArgumentError, "Unsupported HTTP protocol: #{@protocol.inspect}"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2026, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
require "async/bus/client"
|
|
7
|
+
require "async/queue"
|
|
8
|
+
|
|
9
|
+
require_relative "endpoint"
|
|
10
|
+
|
|
11
|
+
module Fantail
|
|
12
|
+
# Publishes a desired endpoint set and subsequent deltas to a Fantail registry.
|
|
13
|
+
class Monitor < Async::Bus::Client
|
|
14
|
+
# Initialize an endpoint monitor.
|
|
15
|
+
# @parameter endpoint [IO::Endpoint] The Fantail control endpoint.
|
|
16
|
+
def initialize(endpoint)
|
|
17
|
+
super(endpoint)
|
|
18
|
+
|
|
19
|
+
@guard = Thread::Mutex.new
|
|
20
|
+
@endpoints = {}
|
|
21
|
+
@revision = 0
|
|
22
|
+
@changes = Async::Queue.new
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Replace the desired endpoint set without blocking on the control connection.
|
|
26
|
+
# @parameter descriptions [Array(Endpoint | Hash)] The complete desired endpoint set.
|
|
27
|
+
# @returns [Integer] The local revision number.
|
|
28
|
+
def replace(descriptions)
|
|
29
|
+
endpoints = descriptions.map{|description| Endpoint.coerce(description).to_h}
|
|
30
|
+
|
|
31
|
+
revision = @guard.synchronize do
|
|
32
|
+
@endpoints = endpoints.to_h{|description| [description.fetch("name"), description]}
|
|
33
|
+
@revision += 1
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
@changes.enqueue([revision, :replace, endpoints])
|
|
37
|
+
return revision
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Add or replace a desired endpoint without blocking on the control connection.
|
|
41
|
+
# @parameter description [Endpoint | Hash] The endpoint to publish.
|
|
42
|
+
# @returns [Integer] The local revision number.
|
|
43
|
+
def upsert(description)
|
|
44
|
+
endpoint = Endpoint.coerce(description).to_h
|
|
45
|
+
|
|
46
|
+
revision = @guard.synchronize do
|
|
47
|
+
@endpoints[endpoint.fetch("name")] = endpoint
|
|
48
|
+
@revision += 1
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
@changes.enqueue([revision, :update, [endpoint], []])
|
|
52
|
+
return revision
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Remove a desired endpoint without blocking on the control connection.
|
|
56
|
+
# @parameter name [String] The endpoint identity to remove.
|
|
57
|
+
# @returns [Integer] The local revision number.
|
|
58
|
+
def remove(name)
|
|
59
|
+
name = name.to_s
|
|
60
|
+
|
|
61
|
+
revision = @guard.synchronize do
|
|
62
|
+
@endpoints.delete(name)
|
|
63
|
+
@revision += 1
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
@changes.enqueue([revision, :update, [], [name]])
|
|
67
|
+
return revision
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @returns [Array(Hash)] A snapshot of the desired endpoints.
|
|
71
|
+
def endpoints
|
|
72
|
+
@guard.synchronize{@endpoints.values.map(&:dup)}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Run the persistent publisher, resynchronizing completely after each reconnect.
|
|
76
|
+
# @parameter options [Hash] Options forwarded to Async::Bus::Client#run.
|
|
77
|
+
# @returns [Async::Task] The publisher task.
|
|
78
|
+
def run(**options)
|
|
79
|
+
super(**options) do |connection|
|
|
80
|
+
synchronize(connection[:registry])
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
protected
|
|
85
|
+
|
|
86
|
+
def synchronize(registry)
|
|
87
|
+
revision, endpoints = snapshot
|
|
88
|
+
registry.replace(endpoints)
|
|
89
|
+
|
|
90
|
+
publish_changes(registry, revision)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def snapshot
|
|
94
|
+
@guard.synchronize{[@revision, @endpoints.values.map(&:dup)]}
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def publish_changes(registry, revision)
|
|
98
|
+
while change = @changes.dequeue
|
|
99
|
+
change_revision, operation, *arguments = change
|
|
100
|
+
next if change_revision <= revision
|
|
101
|
+
|
|
102
|
+
registry.public_send(operation, *arguments)
|
|
103
|
+
revision = change_revision
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2026, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
require "protocol/http/request"
|
|
7
|
+
require "protocol/http/response"
|
|
8
|
+
|
|
9
|
+
require_relative "response_body"
|
|
10
|
+
|
|
11
|
+
module Fantail
|
|
12
|
+
# Routes HTTP requests through the registry's global admission queue.
|
|
13
|
+
class Proxy
|
|
14
|
+
# Initialize an HTTP proxy.
|
|
15
|
+
# @parameter registry [Registry] The backend registry.
|
|
16
|
+
def initialize(registry)
|
|
17
|
+
@registry = registry
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Route a request to the next available backend.
|
|
21
|
+
# @parameter request [Protocol::HTTP::Request] The downstream request.
|
|
22
|
+
# @returns [Protocol::HTTP::Response] The upstream or generated response.
|
|
23
|
+
def call(request)
|
|
24
|
+
unless backend = @registry.acquire
|
|
25
|
+
return Protocol::HTTP::Response[503, {"content-type" => "text/plain"}, ["No backends available.\n"]]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
reservation = :processing
|
|
29
|
+
upstream_request = build_request(request)
|
|
30
|
+
response = backend.call(upstream_request)
|
|
31
|
+
backend.processed
|
|
32
|
+
reservation = :exchange
|
|
33
|
+
|
|
34
|
+
if body = response.body
|
|
35
|
+
response.body = ResponseBody.new(body){backend.release}
|
|
36
|
+
else
|
|
37
|
+
backend.release
|
|
38
|
+
end
|
|
39
|
+
reservation = nil
|
|
40
|
+
|
|
41
|
+
return response
|
|
42
|
+
rescue => error
|
|
43
|
+
case reservation
|
|
44
|
+
when :processing
|
|
45
|
+
backend.failed
|
|
46
|
+
when :exchange
|
|
47
|
+
backend.release
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
return Protocol::HTTP::Response[502, {"content-type" => "text/plain"}, ["Bad Gateway: #{error.class}\n"]]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
protected
|
|
54
|
+
|
|
55
|
+
def build_request(request)
|
|
56
|
+
upstream_request = Protocol::HTTP::Request.new(
|
|
57
|
+
nil,
|
|
58
|
+
nil,
|
|
59
|
+
request.method,
|
|
60
|
+
request.path,
|
|
61
|
+
nil,
|
|
62
|
+
request.headers,
|
|
63
|
+
request.body,
|
|
64
|
+
request.protocol,
|
|
65
|
+
request.interim_response,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Transfer ownership of the request body to the upstream request:
|
|
69
|
+
request.body = nil
|
|
70
|
+
|
|
71
|
+
return upstream_request
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2026, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
require "async/bus/controller"
|
|
7
|
+
require "async/queue"
|
|
8
|
+
|
|
9
|
+
require_relative "endpoint"
|
|
10
|
+
require_relative "backend"
|
|
11
|
+
|
|
12
|
+
module Fantail
|
|
13
|
+
# Maintains live backends and a global queue of available processing slots.
|
|
14
|
+
class Registry < Async::Bus::Controller
|
|
15
|
+
WAKE = Object.new.freeze
|
|
16
|
+
|
|
17
|
+
# Initialize an endpoint registry.
|
|
18
|
+
# @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)
|
|
21
|
+
@exchange_limit = exchange_limit
|
|
22
|
+
@backend_factory = backend_factory || self.method(:make_backend)
|
|
23
|
+
|
|
24
|
+
@guard = Thread::Mutex.new
|
|
25
|
+
@backends = {}
|
|
26
|
+
@available = Async::Queue.new
|
|
27
|
+
@closed = false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Replace the complete endpoint set.
|
|
31
|
+
# @parameter descriptions [Array(Endpoint | Hash)] The desired endpoints.
|
|
32
|
+
# @returns [Integer] The resulting endpoint count.
|
|
33
|
+
def replace(descriptions)
|
|
34
|
+
endpoints = descriptions.map{|description| Endpoint.coerce(description)}
|
|
35
|
+
names = endpoints.map(&:name)
|
|
36
|
+
|
|
37
|
+
current_names = @guard.synchronize{@backends.keys}
|
|
38
|
+
self.update(endpoints, current_names - names)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Apply endpoint additions, replacements, and removals.
|
|
42
|
+
# @parameter upserted [Array(Endpoint | Hash)] Endpoints to add or replace.
|
|
43
|
+
# @parameter removed [Array(String)] Endpoint names to remove.
|
|
44
|
+
# @returns [Integer] The resulting endpoint count.
|
|
45
|
+
def update(upserted, removed)
|
|
46
|
+
retired = []
|
|
47
|
+
started = []
|
|
48
|
+
|
|
49
|
+
@guard.synchronize do
|
|
50
|
+
raise IOError, "Registry is closed!" if @closed
|
|
51
|
+
|
|
52
|
+
removed.each do |name|
|
|
53
|
+
if backend = @backends.delete(name.to_s)
|
|
54
|
+
retired << backend
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
upserted.each do |description|
|
|
59
|
+
endpoint = Endpoint.coerce(description)
|
|
60
|
+
current = @backends[endpoint.name]
|
|
61
|
+
|
|
62
|
+
next if current&.endpoint == endpoint
|
|
63
|
+
|
|
64
|
+
retired << current if current
|
|
65
|
+
backend = @backend_factory.call(endpoint, @exchange_limit, self.method(:offer))
|
|
66
|
+
@backends[endpoint.name] = backend
|
|
67
|
+
started << backend
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
retired.each(&:retire)
|
|
72
|
+
started.each(&:start)
|
|
73
|
+
@available.enqueue(WAKE) unless retired.empty?
|
|
74
|
+
|
|
75
|
+
self.size
|
|
76
|
+
end
|
|
77
|
+
|
|
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
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# @returns [Integer] The number of active endpoints.
|
|
93
|
+
def size
|
|
94
|
+
@guard.synchronize{@backends.size}
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# @returns [Boolean] Whether no active endpoints are registered.
|
|
98
|
+
def empty?
|
|
99
|
+
self.size.zero?
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# @returns [Array(String)] The active endpoint names.
|
|
103
|
+
def names
|
|
104
|
+
@guard.synchronize{@backends.keys.sort}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Find an active backend by name.
|
|
108
|
+
# @parameter name [String] The endpoint name.
|
|
109
|
+
# @returns [Backend | Nil] The active backend.
|
|
110
|
+
def [](name)
|
|
111
|
+
@guard.synchronize{@backends[name.to_s]}
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Close the registry and retire all backends.
|
|
115
|
+
def close
|
|
116
|
+
backends = @guard.synchronize do
|
|
117
|
+
next [] if @closed
|
|
118
|
+
|
|
119
|
+
@closed = true
|
|
120
|
+
@backends.values.tap{@backends = {}}
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
@available.close
|
|
124
|
+
backends.each(&:retire)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
protected
|
|
128
|
+
|
|
129
|
+
def offer(backend)
|
|
130
|
+
@available.enqueue(backend)
|
|
131
|
+
rescue Async::Queue::ClosedError
|
|
132
|
+
# The registry is already shutting down:
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def make_backend(endpoint, exchange_limit, available)
|
|
136
|
+
client = endpoint.make_client(exchange_limit: exchange_limit)
|
|
137
|
+
Backend.new(endpoint, client, exchange_limit: exchange_limit, &available)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2026, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
require "protocol/http/body/wrapper"
|
|
7
|
+
|
|
8
|
+
module Fantail
|
|
9
|
+
# Wraps an upstream response body and releases its backend exchange when closed.
|
|
10
|
+
class ResponseBody < Protocol::HTTP::Body::Wrapper
|
|
11
|
+
# Initialize a response body wrapper.
|
|
12
|
+
# @parameter body [Protocol::HTTP::Body::Readable] The upstream response body.
|
|
13
|
+
# @yields Invoked exactly once when the response body closes.
|
|
14
|
+
def initialize(body, &release)
|
|
15
|
+
super(body)
|
|
16
|
+
@release = release
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Close the upstream body and release its backend exchange.
|
|
20
|
+
# @parameter error [Exception | Nil] The error which caused the body to close.
|
|
21
|
+
def close(error = nil)
|
|
22
|
+
super
|
|
23
|
+
ensure
|
|
24
|
+
if release = @release
|
|
25
|
+
@release = nil
|
|
26
|
+
release.call
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2026, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
require "async/http/server"
|
|
7
|
+
|
|
8
|
+
require_relative "registry"
|
|
9
|
+
require_relative "proxy"
|
|
10
|
+
require_relative "control"
|
|
11
|
+
|
|
12
|
+
module Fantail
|
|
13
|
+
# Runs the HTTP load balancer and endpoint-control server together.
|
|
14
|
+
class Server
|
|
15
|
+
# Initialize a Fantail server.
|
|
16
|
+
# @parameter endpoint [Async::HTTP::Endpoint] The downstream HTTP endpoint.
|
|
17
|
+
# @parameter control_endpoint [IO::Endpoint] The async-bus control endpoint.
|
|
18
|
+
# @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)
|
|
22
|
+
@http_server = Async::HTTP::Server.new(@proxy, endpoint)
|
|
23
|
+
@control_server = Control.new(control_endpoint, @registry)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @attribute [Registry] The server's endpoint registry.
|
|
27
|
+
attr :registry
|
|
28
|
+
|
|
29
|
+
# Run the HTTP and control servers.
|
|
30
|
+
# @parameter parent [Interface(:async)] The parent task.
|
|
31
|
+
# @returns [Async::Task] The server task.
|
|
32
|
+
def run(parent: Async::Task.current)
|
|
33
|
+
parent.async do |task|
|
|
34
|
+
task.async do
|
|
35
|
+
@control_server.accept
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
@http_server.run.wait
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Close the endpoint registry.
|
|
43
|
+
def close
|
|
44
|
+
@registry.close
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
data/lib/fantail.rb
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2026, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
require_relative "fantail/version"
|
|
7
|
+
require_relative "fantail/endpoint"
|
|
8
|
+
require_relative "fantail/backend"
|
|
9
|
+
require_relative "fantail/response_body"
|
|
10
|
+
require_relative "fantail/registry"
|
|
11
|
+
require_relative "fantail/proxy"
|
|
12
|
+
require_relative "fantail/control"
|
|
13
|
+
require_relative "fantail/monitor"
|
|
14
|
+
require_relative "fantail/server"
|
|
15
|
+
|
|
16
|
+
# Provides worker-aware HTTP load balancing with a global admission queue.
|
|
17
|
+
module Fantail
|
|
18
|
+
end
|
data/license.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# MIT License
|
|
2
|
+
|
|
3
|
+
Copyright, 2026, by Samuel Williams.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/readme.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Fantail
|
|
2
|
+
|
|
3
|
+
Worker-aware HTTP load balancing with a global admission queue.
|
|
4
|
+
|
|
5
|
+
[](https://github.com/socketry/fantail/actions?workflow=Test)
|
|
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.
|
|
8
|
+
|
|
9
|
+
## Usage
|
|
10
|
+
|
|
11
|
+
Please see the [project documentation](https://socketry.github.io/fantail/) for more details.
|
|
12
|
+
|
|
13
|
+
- [Getting Started](https://socketry.github.io/fantail/guides/getting-started/index) - This guide explains how to run Fantail and publish HTTP worker endpoints.
|
|
14
|
+
|
|
15
|
+
## Releases
|
|
16
|
+
|
|
17
|
+
Please see the [project releases](https://socketry.github.io/fantail/releases/index) for all releases.
|
|
18
|
+
|
|
19
|
+
### v0.0.1
|
|
20
|
+
|
|
21
|
+
- Initial implementation.
|
|
22
|
+
|
|
23
|
+
## Contributing
|
|
24
|
+
|
|
25
|
+
We welcome contributions to this project.
|
|
26
|
+
|
|
27
|
+
1. Fork the repository.
|
|
28
|
+
2. Create your feature branch (`git checkout -b my-new-feature`).
|
|
29
|
+
3. Commit your changes (`git commit -am 'Add some feature.'`).
|
|
30
|
+
4. Push to the branch (`git push origin my-new-feature`).
|
|
31
|
+
5. Create a new pull request.
|
|
32
|
+
|
|
33
|
+
### Running Tests
|
|
34
|
+
|
|
35
|
+
To run the test suite:
|
|
36
|
+
|
|
37
|
+
``` bash
|
|
38
|
+
$ bundle exec sus
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Making Releases
|
|
42
|
+
|
|
43
|
+
To make a new release:
|
|
44
|
+
|
|
45
|
+
``` bash
|
|
46
|
+
$ bundle exec bake gem:release:patch # or minor or major
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Developer Certificate of Origin
|
|
50
|
+
|
|
51
|
+
In order to protect users of this project, we require all contributors to comply with the [Developer Certificate of Origin](https://developercertificate.org/). This ensures that all contributions are properly licensed and attributed.
|
|
52
|
+
|
|
53
|
+
### Community Guidelines
|
|
54
|
+
|
|
55
|
+
This project is best served by a collaborative and respectful environment. Treat each other professionally, respect differing viewpoints, and engage constructively. Harassment, discrimination, or harmful behavior is not tolerated. Communicate clearly, listen actively, and support one another. If any issues arise, please inform the project maintainers.
|
data/releases.md
ADDED
data.tar.gz.sig
ADDED
|
Binary file
|
metadata
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: fantail
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.0.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Samuel Williams
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain:
|
|
10
|
+
- |
|
|
11
|
+
-----BEGIN CERTIFICATE-----
|
|
12
|
+
MIIE2DCCA0CgAwIBAgIBATANBgkqhkiG9w0BAQsFADBhMRgwFgYDVQQDDA9zYW11
|
|
13
|
+
ZWwud2lsbGlhbXMxHTAbBgoJkiaJk/IsZAEZFg1vcmlvbnRyYW5zZmVyMRIwEAYK
|
|
14
|
+
CZImiZPyLGQBGRYCY28xEjAQBgoJkiaJk/IsZAEZFgJuejAeFw0yMjA4MDYwNDUz
|
|
15
|
+
MjRaFw0zMjA4MDMwNDUzMjRaMGExGDAWBgNVBAMMD3NhbXVlbC53aWxsaWFtczEd
|
|
16
|
+
MBsGCgmSJomT8ixkARkWDW9yaW9udHJhbnNmZXIxEjAQBgoJkiaJk/IsZAEZFgJj
|
|
17
|
+
bzESMBAGCgmSJomT8ixkARkWAm56MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIB
|
|
18
|
+
igKCAYEAomvSopQXQ24+9DBB6I6jxRI2auu3VVb4nOjmmHq7XWM4u3HL+pni63X2
|
|
19
|
+
9qZdoq9xt7H+RPbwL28LDpDNflYQXoOhoVhQ37Pjn9YDjl8/4/9xa9+NUpl9XDIW
|
|
20
|
+
sGkaOY0eqsQm1pEWkHJr3zn/fxoKPZPfaJOglovdxf7dgsHz67Xgd/ka+Wo1YqoE
|
|
21
|
+
e5AUKRwUuvaUaumAKgPH+4E4oiLXI4T1Ff5Q7xxv6yXvHuYtlMHhYfgNn8iiW8WN
|
|
22
|
+
XibYXPNP7NtieSQqwR/xM6IRSoyXKuS+ZNGDPUUGk8RoiV/xvVN4LrVm9upSc0ss
|
|
23
|
+
RZ6qwOQmXCo/lLcDUxJAgG95cPw//sI00tZan75VgsGzSWAOdjQpFM0l4dxvKwHn
|
|
24
|
+
tUeT3ZsAgt0JnGqNm2Bkz81kG4A2hSyFZTFA8vZGhp+hz+8Q573tAR89y9YJBdYM
|
|
25
|
+
zp0FM4zwMNEUwgfRzv1tEVVUEXmoFCyhzonUUw4nE4CFu/sE3ffhjKcXcY//qiSW
|
|
26
|
+
xm4erY3XAgMBAAGjgZowgZcwCQYDVR0TBAIwADALBgNVHQ8EBAMCBLAwHQYDVR0O
|
|
27
|
+
BBYEFO9t7XWuFf2SKLmuijgqR4sGDlRsMC4GA1UdEQQnMCWBI3NhbXVlbC53aWxs
|
|
28
|
+
aWFtc0BvcmlvbnRyYW5zZmVyLmNvLm56MC4GA1UdEgQnMCWBI3NhbXVlbC53aWxs
|
|
29
|
+
aWFtc0BvcmlvbnRyYW5zZmVyLmNvLm56MA0GCSqGSIb3DQEBCwUAA4IBgQB5sxkE
|
|
30
|
+
cBsSYwK6fYpM+hA5B5yZY2+L0Z+27jF1pWGgbhPH8/FjjBLVn+VFok3CDpRqwXCl
|
|
31
|
+
xCO40JEkKdznNy2avOMra6PFiQyOE74kCtv7P+Fdc+FhgqI5lMon6tt9rNeXmnW/
|
|
32
|
+
c1NaMRdxy999hmRGzUSFjozcCwxpy/LwabxtdXwXgSay4mQ32EDjqR1TixS1+smp
|
|
33
|
+
8C/NCWgpIfzpHGJsjvmH2wAfKtTTqB9CVKLCWEnCHyCaRVuKkrKjqhYCdmMBqCws
|
|
34
|
+
JkxfQWC+jBVeG9ZtPhQgZpfhvh+6hMhraUYRQ6XGyvBqEUe+yo6DKIT3MtGE2+CP
|
|
35
|
+
eX9i9ZWBydWb8/rvmwmX2kkcBbX0hZS1rcR593hGc61JR6lvkGYQ2MYskBveyaxt
|
|
36
|
+
Q2K9NVun/S785AP05vKkXZEFYxqG6EW012U4oLcFl5MySFajYXRYbuUpH6AY+HP8
|
|
37
|
+
voD0MPg1DssDLKwXyt1eKD/+Fq0bFWhwVM/1XiAXL7lyYUyOq24KHgQ2Csg=
|
|
38
|
+
-----END CERTIFICATE-----
|
|
39
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
40
|
+
dependencies:
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: async-bus
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '0.3'
|
|
48
|
+
type: :runtime
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '0.3'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: async-http
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '0.99'
|
|
62
|
+
type: :runtime
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '0.99'
|
|
69
|
+
executables: []
|
|
70
|
+
extensions: []
|
|
71
|
+
extra_rdoc_files: []
|
|
72
|
+
files:
|
|
73
|
+
- lib/fantail.rb
|
|
74
|
+
- lib/fantail/backend.rb
|
|
75
|
+
- lib/fantail/control.rb
|
|
76
|
+
- lib/fantail/endpoint.rb
|
|
77
|
+
- lib/fantail/monitor.rb
|
|
78
|
+
- lib/fantail/proxy.rb
|
|
79
|
+
- lib/fantail/registry.rb
|
|
80
|
+
- lib/fantail/response_body.rb
|
|
81
|
+
- lib/fantail/server.rb
|
|
82
|
+
- lib/fantail/version.rb
|
|
83
|
+
- license.md
|
|
84
|
+
- readme.md
|
|
85
|
+
- releases.md
|
|
86
|
+
homepage: https://github.com/socketry/fantail
|
|
87
|
+
licenses:
|
|
88
|
+
- MIT
|
|
89
|
+
metadata:
|
|
90
|
+
bug_tracker_uri: https://github.com/socketry/fantail/issues
|
|
91
|
+
changelog_uri: https://github.com/socketry/fantail/blob/main/releases.md
|
|
92
|
+
documentation_uri: https://socketry.github.io/fantail/
|
|
93
|
+
funding_uri: https://github.com/sponsors/ioquatix/
|
|
94
|
+
source_code_uri: https://github.com/socketry/fantail.git
|
|
95
|
+
rdoc_options: []
|
|
96
|
+
require_paths:
|
|
97
|
+
- lib
|
|
98
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
99
|
+
requirements:
|
|
100
|
+
- - ">="
|
|
101
|
+
- !ruby/object:Gem::Version
|
|
102
|
+
version: '3.3'
|
|
103
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
104
|
+
requirements:
|
|
105
|
+
- - ">="
|
|
106
|
+
- !ruby/object:Gem::Version
|
|
107
|
+
version: '0'
|
|
108
|
+
requirements: []
|
|
109
|
+
rubygems_version: 4.0.10
|
|
110
|
+
specification_version: 4
|
|
111
|
+
summary: Worker-aware HTTP load balancing with a global admission queue.
|
|
112
|
+
test_files: []
|
metadata.gz.sig
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
8�Nċ����*�Pb��\��y,����<���_��I�g8<4v7rz˅z�dX@\kK�4��$��P��NBi�J�^ľ������*lD���|8fI��g��83�؉o�5a�f���9�3�9p���[ĜP ?<�g2])��m��Ă�����ϫj$�!e6/|Gq�C��d���K��{C�����]Z������{�[�n�T
|
|
2
|
+
+k/�3z[
|
|
3
|
+
���/��P��
|
|
4
|
+
I�y������1-�a�oo�UC�l�������A���C���T�O\FhL���VP����xo��{��׆��K�ie����D��L� �%�;���6\
|
|
5
|
+
���Ku�'�A��w�Y�k[�����Pʻl�n�3�X����41�p����l
|