tipi 0.31 → 0.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +29 -0
- data/Gemfile.lock +10 -4
- data/LICENSE +1 -1
- data/TODO.md +13 -47
- data/bin/tipi +13 -0
- data/df/agent.rb +63 -0
- data/df/etc_benchmark.rb +15 -0
- data/df/multi_agent_supervisor.rb +87 -0
- data/df/multi_client.rb +84 -0
- data/df/routing_benchmark.rb +60 -0
- data/df/sample_agent.rb +89 -0
- data/df/server.rb +54 -0
- data/df/sse_page.html +29 -0
- data/df/stress.rb +24 -0
- data/df/ws_page.html +38 -0
- data/examples/http_request_ws_server.rb +34 -0
- data/examples/http_server.rb +6 -6
- data/examples/http_server_forked.rb +4 -5
- data/examples/http_server_form.rb +23 -0
- data/examples/http_server_throttled_accept.rb +23 -0
- data/examples/http_unix_socket_server.rb +17 -0
- data/examples/http_ws_server.rb +10 -12
- data/examples/routing_server.rb +34 -0
- data/examples/websocket_client.rb +1 -2
- data/examples/websocket_demo.rb +4 -2
- data/examples/ws_page.html +1 -2
- data/lib/tipi.rb +7 -5
- data/lib/tipi/config_dsl.rb +153 -0
- data/lib/tipi/configuration.rb +1 -1
- data/lib/tipi/digital_fabric.rb +7 -0
- data/lib/tipi/digital_fabric/agent.rb +225 -0
- data/lib/tipi/digital_fabric/agent_proxy.rb +265 -0
- data/lib/tipi/digital_fabric/executive.rb +100 -0
- data/lib/tipi/digital_fabric/executive/index.html +69 -0
- data/lib/tipi/digital_fabric/protocol.rb +90 -0
- data/lib/tipi/digital_fabric/request_adapter.rb +48 -0
- data/lib/tipi/digital_fabric/service.rb +230 -0
- data/lib/tipi/http1_adapter.rb +59 -37
- data/lib/tipi/http2_adapter.rb +5 -3
- data/lib/tipi/http2_stream.rb +19 -7
- data/lib/tipi/rack_adapter.rb +11 -3
- data/lib/tipi/version.rb +1 -1
- data/lib/tipi/websocket.rb +33 -13
- data/test/helper.rb +1 -2
- data/test/test_http_server.rb +3 -2
- data/test/test_request.rb +108 -0
- data/tipi.gemspec +7 -3
- metadata +59 -7
- data/lib/tipi/request.rb +0 -118
@@ -0,0 +1,48 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative './protocol'
|
4
|
+
|
5
|
+
module DigitalFabric
|
6
|
+
class RequestAdapter
|
7
|
+
def initialize(agent, msg)
|
8
|
+
@agent = agent
|
9
|
+
@id = msg['id']
|
10
|
+
end
|
11
|
+
|
12
|
+
def protocol
|
13
|
+
'df'
|
14
|
+
end
|
15
|
+
|
16
|
+
def get_body_chunk
|
17
|
+
@agent.get_http_request_body(@id, 1)
|
18
|
+
end
|
19
|
+
|
20
|
+
def consume_request
|
21
|
+
@agent.get_http_request_body(@id, nil)
|
22
|
+
end
|
23
|
+
|
24
|
+
def respond(body, headers)
|
25
|
+
@agent.send_df_message(
|
26
|
+
Protocol.http_response(@id, body, headers, true)
|
27
|
+
)
|
28
|
+
end
|
29
|
+
|
30
|
+
def send_headers(headers, opts = {})
|
31
|
+
@agent.send_df_message(
|
32
|
+
Protocol.http_response(@id, nil, headers, false)
|
33
|
+
)
|
34
|
+
end
|
35
|
+
|
36
|
+
def send_chunk(body, done: )
|
37
|
+
@agent.send_df_message(
|
38
|
+
Protocol.http_response(@id, body, nil, done)
|
39
|
+
)
|
40
|
+
end
|
41
|
+
|
42
|
+
def finish
|
43
|
+
@agent.send_df_message(
|
44
|
+
Protocol.http_response(@id, nil, nil, true)
|
45
|
+
)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,230 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative './protocol'
|
4
|
+
require_relative './agent_proxy'
|
5
|
+
require 'securerandom'
|
6
|
+
|
7
|
+
module DigitalFabric
|
8
|
+
class Service
|
9
|
+
attr_reader :token
|
10
|
+
attr_reader :timer
|
11
|
+
|
12
|
+
def initialize(token: )
|
13
|
+
@token = token
|
14
|
+
@agents = {}
|
15
|
+
@routes = {}
|
16
|
+
@waiting_lists = {} # hash mapping routes to arrays of requests waiting for an agent to mount
|
17
|
+
@counters = {
|
18
|
+
connections: 0,
|
19
|
+
http_requests: 0,
|
20
|
+
errors: 0
|
21
|
+
}
|
22
|
+
@connection_count = 0
|
23
|
+
@http_latency_accumulator = 0
|
24
|
+
@http_latency_counter = 0
|
25
|
+
@last_counters = @counters.merge(stamp: Time.now.to_f - 1)
|
26
|
+
@fiber = Fiber.current
|
27
|
+
@timer = Polyphony::Timer.new(resolution: 1)
|
28
|
+
|
29
|
+
stats_updater = spin { @timer.every(10) { update_stats } }
|
30
|
+
@stats = {}
|
31
|
+
|
32
|
+
@current_request_count = 0
|
33
|
+
end
|
34
|
+
|
35
|
+
def update_stats
|
36
|
+
now = Time.now.to_f
|
37
|
+
elapsed = now - @last_counters[:stamp]
|
38
|
+
connections = @counters[:connections] - @last_counters[:connections]
|
39
|
+
http_requests = @counters[:http_requests] - @last_counters[:http_requests]
|
40
|
+
errors = @counters[:errors] - @last_counters[:errors]
|
41
|
+
@last_counters = @counters.merge(stamp: now)
|
42
|
+
|
43
|
+
average_latency = @http_latency_counter > 0 ?
|
44
|
+
@http_latency_accumulator / @http_latency_counter :
|
45
|
+
0
|
46
|
+
@http_latency_accumulator = 0
|
47
|
+
@http_latency_counter = 0
|
48
|
+
|
49
|
+
@stats = {
|
50
|
+
connection_rate: connections / elapsed,
|
51
|
+
http_request_rate: http_requests / elapsed,
|
52
|
+
error_rate: errors / elapsed,
|
53
|
+
average_latency: average_latency,
|
54
|
+
agent_count: @agents.size,
|
55
|
+
connection_count: @connection_count,
|
56
|
+
concurrent_requests: @current_request_count
|
57
|
+
}
|
58
|
+
end
|
59
|
+
|
60
|
+
def incr_connection_count
|
61
|
+
@connection_count += 1
|
62
|
+
end
|
63
|
+
|
64
|
+
def decr_connection_count
|
65
|
+
@connection_count -= 1
|
66
|
+
end
|
67
|
+
|
68
|
+
attr_reader :stats
|
69
|
+
|
70
|
+
def total_request_count
|
71
|
+
count = 0
|
72
|
+
@agents.keys.each do |agent|
|
73
|
+
if agent.respond_to?(:current_request_count)
|
74
|
+
count += agent.current_request_count
|
75
|
+
end
|
76
|
+
end
|
77
|
+
count
|
78
|
+
end
|
79
|
+
|
80
|
+
def record_latency_measurement(latency)
|
81
|
+
@http_latency_accumulator += latency
|
82
|
+
@http_latency_counter += 1
|
83
|
+
end
|
84
|
+
|
85
|
+
def http_request(req)
|
86
|
+
@current_request_count += 1
|
87
|
+
@counters[:http_requests] += 1
|
88
|
+
@counters[:connections] += 1 if req.headers[':first']
|
89
|
+
|
90
|
+
return upgrade_request(req) if req.upgrade_protocol
|
91
|
+
|
92
|
+
inject_request_headers(req)
|
93
|
+
agent = find_agent(req)
|
94
|
+
unless agent
|
95
|
+
return req.respond('pong') if req.query[:q] == 'ping'
|
96
|
+
|
97
|
+
@counters[:errors] += 1
|
98
|
+
return req.respond(nil, ':status' => Qeweney::Status::SERVICE_UNAVAILABLE)
|
99
|
+
end
|
100
|
+
|
101
|
+
agent.http_request(req)
|
102
|
+
rescue IOError, SystemCallError
|
103
|
+
@counters[:errors] += 1
|
104
|
+
rescue => e
|
105
|
+
@counters[:errors] += 1
|
106
|
+
p e
|
107
|
+
puts e.backtrace.join("\n")
|
108
|
+
req.respond(e.inspect, ':status' => Qeweney::Status::INTERNAL_SERVER_ERROR)
|
109
|
+
ensure
|
110
|
+
@current_request_count -= 1
|
111
|
+
req.adapter.conn.close if @shutdown
|
112
|
+
end
|
113
|
+
|
114
|
+
def inject_request_headers(req)
|
115
|
+
req.headers['x-request-id'] = SecureRandom.uuid
|
116
|
+
conn = req.adapter.conn
|
117
|
+
req.headers['x-forwarded-for'] = conn.peeraddr(false)[2]
|
118
|
+
req.headers['x-forwarded-proto'] = conn.is_a?(OpenSSL::SSL::SSLSocket) ? 'https' : 'http'
|
119
|
+
end
|
120
|
+
|
121
|
+
def upgrade_request(req)
|
122
|
+
case (protocol = req.upgrade_protocol)
|
123
|
+
when 'df'
|
124
|
+
df_upgrade(req)
|
125
|
+
else
|
126
|
+
agent = find_agent(req)
|
127
|
+
unless agent
|
128
|
+
@counters[:errors] += 1
|
129
|
+
return req.respond(nil, ':status' => Qeweney::Status::SERVICE_UNAVAILABLE)
|
130
|
+
end
|
131
|
+
|
132
|
+
agent.http_upgrade(req, protocol)
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
def df_upgrade(req)
|
137
|
+
if req.headers['df-token'] != @token
|
138
|
+
return req.respond(nil, ':status' => Qeweney::Status::FORBIDDEN)
|
139
|
+
end
|
140
|
+
|
141
|
+
req.adapter.conn << Protocol.df_upgrade_response
|
142
|
+
AgentProxy.new(self, req)
|
143
|
+
end
|
144
|
+
|
145
|
+
def mount(route, agent)
|
146
|
+
if route[:path]
|
147
|
+
route[:path_regexp] = path_regexp(route[:path])
|
148
|
+
end
|
149
|
+
@executive = agent if route[:executive]
|
150
|
+
@agents[agent] = route
|
151
|
+
@routing_changed = true
|
152
|
+
|
153
|
+
if (waiting = @waiting_lists[route])
|
154
|
+
waiting.each { |f| f.schedule(agent) }
|
155
|
+
@waiting_lists.delete(route)
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
def unmount(agent)
|
160
|
+
route = @agents[agent]
|
161
|
+
return unless route
|
162
|
+
|
163
|
+
@executive = nil if route[:executive]
|
164
|
+
@agents.delete(agent)
|
165
|
+
@routing_changed = true
|
166
|
+
|
167
|
+
@waiting_lists[route] ||= []
|
168
|
+
end
|
169
|
+
|
170
|
+
INVALID_HOST = 'INVALID_HOST'
|
171
|
+
|
172
|
+
def find_agent(req)
|
173
|
+
compile_agent_routes if @routing_changed
|
174
|
+
|
175
|
+
host = req.headers['host'] || INVALID_HOST
|
176
|
+
path = req.headers[':path']
|
177
|
+
|
178
|
+
route = @route_keys.find do |route|
|
179
|
+
(host == route[:host]) || (path =~ route[:path_regexp])
|
180
|
+
end
|
181
|
+
return @routes[route] if route
|
182
|
+
|
183
|
+
# search for a known route for an agent that recently unmounted
|
184
|
+
route, wait_list = @waiting_lists.find do |route, _|
|
185
|
+
(host == route[:host]) || (path =~ route[:path_regexp])
|
186
|
+
end
|
187
|
+
return wait_for_agent(wait_list) if route
|
188
|
+
|
189
|
+
nil
|
190
|
+
end
|
191
|
+
|
192
|
+
def compile_agent_routes
|
193
|
+
@routing_changed = false
|
194
|
+
|
195
|
+
@routes.clear
|
196
|
+
@agents.keys.reverse.each do |agent|
|
197
|
+
route = @agents[agent]
|
198
|
+
@routes[route] ||= agent
|
199
|
+
end
|
200
|
+
@route_keys = @routes.keys
|
201
|
+
end
|
202
|
+
|
203
|
+
def wait_for_agent(wait_list)
|
204
|
+
wait_list << Fiber.current
|
205
|
+
@timer.move_on_after(10) { suspend }
|
206
|
+
ensure
|
207
|
+
wait_list.delete(self)
|
208
|
+
end
|
209
|
+
|
210
|
+
def path_regexp(path)
|
211
|
+
/^#{path}/
|
212
|
+
end
|
213
|
+
|
214
|
+
def graceful_shutdown
|
215
|
+
@shutdown = true
|
216
|
+
@agents.keys.each do |agent|
|
217
|
+
if agent.respond_to?(:shutdown)
|
218
|
+
agent.shutdown
|
219
|
+
else
|
220
|
+
@agents.delete(agent)
|
221
|
+
end
|
222
|
+
end
|
223
|
+
move_on_after(60) do
|
224
|
+
while !@agents.empty?
|
225
|
+
sleep 0.25
|
226
|
+
end
|
227
|
+
end
|
228
|
+
end
|
229
|
+
end
|
230
|
+
end
|
data/lib/tipi/http1_adapter.rb
CHANGED
@@ -1,21 +1,24 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require 'http/parser'
|
4
|
-
require_relative './request'
|
5
4
|
require_relative './http2_adapter'
|
5
|
+
require 'qeweney/request'
|
6
6
|
|
7
7
|
module Tipi
|
8
8
|
# HTTP1 protocol implementation
|
9
9
|
class HTTP1Adapter
|
10
|
+
attr_reader :conn
|
11
|
+
|
10
12
|
# Initializes a protocol adapter instance
|
11
13
|
def initialize(conn, opts)
|
12
14
|
@conn = conn
|
13
15
|
@opts = opts
|
16
|
+
@first = true
|
14
17
|
@parser = ::HTTP::Parser.new(self)
|
15
18
|
end
|
16
19
|
|
17
20
|
def each(&block)
|
18
|
-
@conn.
|
21
|
+
@conn.recv_loop do |data|
|
19
22
|
return if handle_incoming_data(data, &block)
|
20
23
|
end
|
21
24
|
rescue SystemCallError, IOError
|
@@ -28,6 +31,10 @@ module Tipi
|
|
28
31
|
def handle_incoming_data(data, &block)
|
29
32
|
@parser << data
|
30
33
|
while (request = @requests_head)
|
34
|
+
if @first
|
35
|
+
request.headers[':first'] = true
|
36
|
+
@first = nil
|
37
|
+
end
|
31
38
|
return true if upgrade_connection(request.headers, &block)
|
32
39
|
|
33
40
|
@requests_head = request.__next__
|
@@ -65,14 +72,9 @@ module Tipi
|
|
65
72
|
# callback
|
66
73
|
def consume_request
|
67
74
|
request = @requests_head
|
68
|
-
|
69
|
-
data = @conn.readpartial(8192)
|
75
|
+
@conn.recv_loop do |data|
|
70
76
|
@parser << data
|
71
77
|
return if request.complete?
|
72
|
-
|
73
|
-
snooze
|
74
|
-
rescue EOFError
|
75
|
-
break
|
76
78
|
end
|
77
79
|
end
|
78
80
|
|
@@ -82,9 +84,23 @@ module Tipi
|
|
82
84
|
end
|
83
85
|
|
84
86
|
def on_headers_complete(headers)
|
87
|
+
headers = normalize_headers(headers)
|
85
88
|
headers[':path'] = @parser.request_url
|
86
|
-
headers[':method'] = @parser.http_method
|
87
|
-
queue_request(Request.new(headers, self))
|
89
|
+
headers[':method'] = @parser.http_method.downcase
|
90
|
+
queue_request(Qeweney::Request.new(headers, self))
|
91
|
+
end
|
92
|
+
|
93
|
+
def normalize_headers(headers)
|
94
|
+
headers.each_with_object({}) do |(k, v), h|
|
95
|
+
k = k.downcase
|
96
|
+
hk = h[k]
|
97
|
+
if hk
|
98
|
+
hk = h[k] = [hk] unless hk.is_a?(Array)
|
99
|
+
v.is_a?(Array) ? hk.concat(v) : hk << v
|
100
|
+
else
|
101
|
+
h[k] = v
|
102
|
+
end
|
103
|
+
end
|
88
104
|
end
|
89
105
|
|
90
106
|
def queue_request(request)
|
@@ -125,7 +141,7 @@ module Tipi
|
|
125
141
|
# @param headers [Hash] request headers
|
126
142
|
# @return [boolean] truthy if the connection has been upgraded
|
127
143
|
def upgrade_connection(headers, &block)
|
128
|
-
upgrade_protocol = headers['
|
144
|
+
upgrade_protocol = headers['upgrade']
|
129
145
|
return nil unless upgrade_protocol
|
130
146
|
|
131
147
|
upgrade_protocol = upgrade_protocol.downcase.to_sym
|
@@ -154,12 +170,15 @@ module Tipi
|
|
154
170
|
def http2_upgraded_headers(headers)
|
155
171
|
headers.merge(
|
156
172
|
':scheme' => 'http',
|
157
|
-
':authority' => headers['
|
173
|
+
':authority' => headers['host']
|
158
174
|
)
|
159
175
|
end
|
160
176
|
|
161
177
|
# response API
|
162
|
-
|
178
|
+
|
179
|
+
CRLF = "\r\n"
|
180
|
+
CRLF_ZERO_CRLF_CRLF = "\r\n0\r\n\r\n"
|
181
|
+
|
163
182
|
# Sends response including headers and body. Waits for the request to complete
|
164
183
|
# if not yet completed. The body is sent using chunked transfer encoding.
|
165
184
|
# @param body [String] response body
|
@@ -168,15 +187,15 @@ module Tipi
|
|
168
187
|
consume_request if @parsing
|
169
188
|
data = format_headers(headers, body)
|
170
189
|
if body
|
171
|
-
|
172
|
-
body
|
190
|
+
if @parser.http_minor == 0
|
191
|
+
data << body
|
173
192
|
else
|
174
|
-
|
193
|
+
data << body.bytesize.to_s(16) << CRLF << body << CRLF_ZERO_CRLF_CRLF
|
175
194
|
end
|
176
195
|
end
|
177
|
-
@conn
|
196
|
+
@conn.write(data.join)
|
178
197
|
end
|
179
|
-
|
198
|
+
|
180
199
|
DEFAULT_HEADERS_OPTS = {
|
181
200
|
empty_response: false,
|
182
201
|
consume_request: true
|
@@ -188,7 +207,8 @@ module Tipi
|
|
188
207
|
# @param empty_response [boolean] whether a response body will be sent
|
189
208
|
# @return [void]
|
190
209
|
def send_headers(headers, opts = DEFAULT_HEADERS_OPTS)
|
191
|
-
|
210
|
+
data = format_headers(headers, true)
|
211
|
+
@conn.write(data.join)
|
192
212
|
end
|
193
213
|
|
194
214
|
# Sends a response body chunk. If no headers were sent, default headers are
|
@@ -198,9 +218,10 @@ module Tipi
|
|
198
218
|
# @param done [boolean] whether the response is completed
|
199
219
|
# @return [void]
|
200
220
|
def send_chunk(chunk, done: false)
|
201
|
-
data =
|
221
|
+
data = []
|
222
|
+
data << "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n" if chunk
|
202
223
|
data << "0\r\n\r\n" if done
|
203
|
-
@conn
|
224
|
+
@conn.write(data.join)
|
204
225
|
end
|
205
226
|
|
206
227
|
# Finishes the response to the current request. If no headers were sent,
|
@@ -215,30 +236,23 @@ module Tipi
|
|
215
236
|
end
|
216
237
|
|
217
238
|
private
|
218
|
-
|
219
|
-
# Formats response headers. If empty_response is true(thy),
|
220
|
-
# status code will default to 204, otherwise to 200.
|
239
|
+
|
240
|
+
# Formats response headers into an array. If empty_response is true(thy),
|
241
|
+
# the response status code will default to 204, otherwise to 200.
|
221
242
|
# @param headers [Hash] response headers
|
222
243
|
# @param empty_response [boolean] whether a response body will be sent
|
223
244
|
# @return [String] formatted response headers
|
224
245
|
def format_headers(headers, body)
|
225
|
-
status = headers[':status']
|
226
|
-
|
227
|
-
|
246
|
+
status = headers[':status']
|
247
|
+
status ||= (body ? Qeweney::Status::OK : Qeweney::Status::NO_CONTENT)
|
248
|
+
lines = [format_status_line(body, status)]
|
228
249
|
headers.each do |k, v|
|
229
250
|
next if k =~ /^:/
|
230
251
|
|
231
|
-
|
232
|
-
end
|
233
|
-
data << "\r\n"
|
234
|
-
end
|
235
|
-
|
236
|
-
def format_header_lines(key, value)
|
237
|
-
if value.is_a?(Array)
|
238
|
-
value.inject(+'') { |data, item| data << "#{key}: #{item}\r\n" }
|
239
|
-
else
|
240
|
-
"#{key}: #{value}\r\n"
|
252
|
+
collect_header_lines(lines, k, v)
|
241
253
|
end
|
254
|
+
lines << CRLF
|
255
|
+
lines
|
242
256
|
end
|
243
257
|
|
244
258
|
def format_status_line(body, status)
|
@@ -264,5 +278,13 @@ module Tipi
|
|
264
278
|
+"HTTP/1.1 #{status}\r\nTransfer-Encoding: chunked\r\n"
|
265
279
|
end
|
266
280
|
end
|
281
|
+
|
282
|
+
def collect_header_lines(lines, key, value)
|
283
|
+
if value.is_a?(Array)
|
284
|
+
value.inject(lines) { |lines, item| data << "#{key}: #{item}\r\n" }
|
285
|
+
else
|
286
|
+
lines << "#{key}: #{value}\r\n"
|
287
|
+
end
|
288
|
+
end
|
267
289
|
end
|
268
290
|
end
|