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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -0
  3. data/Gemfile.lock +10 -4
  4. data/LICENSE +1 -1
  5. data/TODO.md +13 -47
  6. data/bin/tipi +13 -0
  7. data/df/agent.rb +63 -0
  8. data/df/etc_benchmark.rb +15 -0
  9. data/df/multi_agent_supervisor.rb +87 -0
  10. data/df/multi_client.rb +84 -0
  11. data/df/routing_benchmark.rb +60 -0
  12. data/df/sample_agent.rb +89 -0
  13. data/df/server.rb +54 -0
  14. data/df/sse_page.html +29 -0
  15. data/df/stress.rb +24 -0
  16. data/df/ws_page.html +38 -0
  17. data/examples/http_request_ws_server.rb +34 -0
  18. data/examples/http_server.rb +6 -6
  19. data/examples/http_server_forked.rb +4 -5
  20. data/examples/http_server_form.rb +23 -0
  21. data/examples/http_server_throttled_accept.rb +23 -0
  22. data/examples/http_unix_socket_server.rb +17 -0
  23. data/examples/http_ws_server.rb +10 -12
  24. data/examples/routing_server.rb +34 -0
  25. data/examples/websocket_client.rb +1 -2
  26. data/examples/websocket_demo.rb +4 -2
  27. data/examples/ws_page.html +1 -2
  28. data/lib/tipi.rb +7 -5
  29. data/lib/tipi/config_dsl.rb +153 -0
  30. data/lib/tipi/configuration.rb +1 -1
  31. data/lib/tipi/digital_fabric.rb +7 -0
  32. data/lib/tipi/digital_fabric/agent.rb +225 -0
  33. data/lib/tipi/digital_fabric/agent_proxy.rb +265 -0
  34. data/lib/tipi/digital_fabric/executive.rb +100 -0
  35. data/lib/tipi/digital_fabric/executive/index.html +69 -0
  36. data/lib/tipi/digital_fabric/protocol.rb +90 -0
  37. data/lib/tipi/digital_fabric/request_adapter.rb +48 -0
  38. data/lib/tipi/digital_fabric/service.rb +230 -0
  39. data/lib/tipi/http1_adapter.rb +59 -37
  40. data/lib/tipi/http2_adapter.rb +5 -3
  41. data/lib/tipi/http2_stream.rb +19 -7
  42. data/lib/tipi/rack_adapter.rb +11 -3
  43. data/lib/tipi/version.rb +1 -1
  44. data/lib/tipi/websocket.rb +33 -13
  45. data/test/helper.rb +1 -2
  46. data/test/test_http_server.rb +3 -2
  47. data/test/test_request.rb +108 -0
  48. data/tipi.gemspec +7 -3
  49. metadata +59 -7
  50. 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
@@ -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.read_loop do |data|
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
- loop do
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['Upgrade']
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['Host']
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
- data << if @parser.http_minor == 0
172
- body
190
+ if @parser.http_minor == 0
191
+ data << body
173
192
  else
174
- "#{body.bytesize.to_s(16)}\r\n#{body}\r\n0\r\n\r\n"
193
+ data << body.bytesize.to_s(16) << CRLF << body << CRLF_ZERO_CRLF_CRLF
175
194
  end
176
195
  end
177
- @conn << data
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
- @conn << format_headers(headers, !opts[:empty_response])
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 = +"#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n"
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 << data
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), the response
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'] || (body ? 200 : 204)
226
- data = format_status_line(body, status)
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
- data << format_header_lines(k, v)
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