tipi 0.33 → 0.37.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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -0
  3. data/Gemfile.lock +10 -4
  4. data/LICENSE +1 -1
  5. data/TODO.md +11 -47
  6. data/df/agent.rb +63 -0
  7. data/df/etc_benchmark.rb +15 -0
  8. data/df/multi_agent_supervisor.rb +87 -0
  9. data/df/multi_client.rb +84 -0
  10. data/df/routing_benchmark.rb +60 -0
  11. data/df/sample_agent.rb +89 -0
  12. data/df/server.rb +54 -0
  13. data/df/sse_page.html +29 -0
  14. data/df/stress.rb +24 -0
  15. data/df/ws_page.html +38 -0
  16. data/e +0 -0
  17. data/examples/http_request_ws_server.rb +35 -0
  18. data/examples/http_server.rb +6 -6
  19. data/examples/http_server_form.rb +23 -0
  20. data/examples/http_unix_socket_server.rb +17 -0
  21. data/examples/http_ws_server.rb +10 -12
  22. data/examples/routing_server.rb +34 -0
  23. data/examples/ws_page.html +1 -2
  24. data/lib/tipi.rb +5 -1
  25. data/lib/tipi/digital_fabric.rb +7 -0
  26. data/lib/tipi/digital_fabric/agent.rb +225 -0
  27. data/lib/tipi/digital_fabric/agent_proxy.rb +265 -0
  28. data/lib/tipi/digital_fabric/executive.rb +100 -0
  29. data/lib/tipi/digital_fabric/executive/index.html +69 -0
  30. data/lib/tipi/digital_fabric/protocol.rb +90 -0
  31. data/lib/tipi/digital_fabric/request_adapter.rb +48 -0
  32. data/lib/tipi/digital_fabric/service.rb +230 -0
  33. data/lib/tipi/http1_adapter.rb +50 -14
  34. data/lib/tipi/http2_adapter.rb +4 -2
  35. data/lib/tipi/http2_stream.rb +20 -8
  36. data/lib/tipi/rack_adapter.rb +1 -1
  37. data/lib/tipi/version.rb +1 -1
  38. data/lib/tipi/websocket.rb +33 -29
  39. data/test/helper.rb +1 -2
  40. data/test/test_http_server.rb +10 -12
  41. data/test/test_request.rb +108 -0
  42. data/tipi.gemspec +7 -3
  43. metadata +57 -6
  44. data/lib/tipi/request.rb +0 -118
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tipi/digital_fabric'
4
+ require 'json'
5
+
6
+ module DigitalFabric
7
+ # agent for managing DF service
8
+ class Executive
9
+ INDEX_HTML = IO.read(File.join(__dir__, 'executive/index.html'))
10
+
11
+ attr_reader :last_service_stats
12
+
13
+ def initialize(service, route = { path: '/executive' })
14
+ @service = service
15
+ route[:executive] = true
16
+ @service.mount(route, self)
17
+ @current_request_count = 0
18
+ @updater = spin_loop(interval: 10) { update_service_stats }
19
+ update_service_stats
20
+ end
21
+
22
+ def current_request_count
23
+ @current_request_count
24
+ end
25
+
26
+ def http_request(req)
27
+ @current_request_count += 1
28
+ case req.path
29
+ when '/'
30
+ req.respond(INDEX_HTML, 'Content-Type' => 'text/html')
31
+ when '/stats'
32
+ message = last_service_stats
33
+ req.respond(message.to_json, { 'Content-Type' => 'text.json' })
34
+ when '/stream/stats'
35
+ stream_stats(req)
36
+ else
37
+ req.respond('Invalid path', { ':status' => Qeweney::Status::NOT_FOUND })
38
+ end
39
+ ensure
40
+ @current_request_count -= 1
41
+ end
42
+
43
+ def stream_stats(req)
44
+ req.send_headers({ 'Content-Type' => 'text/event-stream' })
45
+
46
+ @service.timer.every(10) do
47
+ message = last_service_stats
48
+ req.send_chunk(format_sse_event(message.to_json))
49
+ end
50
+ rescue IOError, SystemCallError
51
+ # ignore
52
+ ensure
53
+ req.send_chunk("retry: 0\n\n", true) rescue nil
54
+ end
55
+
56
+ def format_sse_event(data)
57
+ "data: #{data}\n\n"
58
+ end
59
+
60
+ def update_service_stats
61
+ @last_service_stats = {
62
+ service: @service.stats,
63
+ machine: machine_stats
64
+ }
65
+ end
66
+
67
+ TOP_CPU_REGEXP = /%Cpu(.+)/.freeze
68
+ TOP_CPU_IDLE_REGEXP = /([\d\.]+) id/.freeze
69
+ TOP_MEM_REGEXP = /MiB Mem(.+)/.freeze
70
+ TOP_MEM_FREE_REGEXP = /([\d\.]+) free/.freeze
71
+ LOADAVG_REGEXP = /^([\d\.]+)/.freeze
72
+
73
+ def machine_stats
74
+ top = `top -bn1 | head -n4`
75
+ unless top =~ TOP_CPU_REGEXP && Regexp.last_match(1) =~ TOP_CPU_IDLE_REGEXP
76
+ p top =~ TOP_CPU_REGEXP
77
+ p Regexp.last_match(1)
78
+ p Regexp.last_match(1) =~ TOP_CPU_IDLE_REGEXP
79
+ raise 'Invalid output from top (cpu)'
80
+ end
81
+ cpu_utilization = 100 - Regexp.last_match(1).to_i
82
+
83
+ unless top =~ TOP_MEM_REGEXP && Regexp.last_match(1) =~ TOP_MEM_FREE_REGEXP
84
+ raise 'Invalid output from top (mem)'
85
+ end
86
+
87
+ mem_free = Regexp.last_match(1).to_f
88
+
89
+ stats = `cat /proc/loadavg`
90
+ raise 'Invalid output from /proc/loadavg' unless stats =~ LOADAVG_REGEXP
91
+ load_avg = Regexp.last_match(1).to_f
92
+
93
+ {
94
+ mem_free: mem_free,
95
+ cpu_utilization: cpu_utilization,
96
+ load_avg: load_avg
97
+ }
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,69 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <title>Digital Fabric Executive</title>
5
+ <style>
6
+ .hidden { display: none }
7
+ </style>
8
+ </head>
9
+ <body>
10
+ <h1>Digital Fabric Executive</h1>
11
+ <script>
12
+ function updateStats(update) {
13
+ for (let k in update.service) {
14
+ let v = update.service[k];
15
+ let e = document.querySelector('#' + k);
16
+ if (e) e.innerText = v;
17
+ }
18
+ for (let k in update.machine) {
19
+ let v = update.machine[k];
20
+ let e = document.querySelector('#' + k);
21
+ if (e) e.innerText = v;
22
+ }
23
+ }
24
+
25
+ function connect() {
26
+ console.log("connecting...");
27
+ window.eventSource = new EventSource("/stream/stats");
28
+
29
+ window.eventSource.onopen = function(e) {
30
+ console.log("connected");
31
+ document.querySelector('#status').innerText = 'connected';
32
+ document.querySelector('#stats').className = '';
33
+ return false;
34
+ }
35
+
36
+ window.eventSource.onmessage = function(e) {
37
+ console.log("message", e.data);
38
+ updateStats(JSON.parse(e.data));
39
+ }
40
+
41
+ window.eventSource.onerror = function(e) {
42
+ console.log("error", e);
43
+ document.querySelector('#status').innerText = 'disconnected';
44
+ document.querySelector('#stats').className = 'hidden';
45
+ window.eventSource.close();
46
+ window.eventSource = null;
47
+ setTimeout(connect, 5000);
48
+ }
49
+ };
50
+
51
+ window.onload = connect;
52
+ </script>
53
+ <h2 id="status"></h2>
54
+ <div id="stats" class="hidden">
55
+ <h2>Service</h2>
56
+ <p>Request rate: <span id="http_request_rate"></span></p>
57
+ <p>Error rate: <span id="error_rate"></span></p>
58
+ <p>Average Latency: <span id="average_latency"></span>s</p>
59
+ <p>Connected agents: <span id="agent_count"></span></p>
60
+ <p>Connected clients: <span id="connection_count"></span></p>
61
+ <p>Concurrent requests: <span id="concurrent_requests"></span></p>
62
+
63
+ <h2>Machine</h2>
64
+ <p>CPU utilization: <span id="cpu_utilization"></span>%</p>
65
+ <p>Free memory: <span id="mem_free"></span>MB</p>
66
+ <p>Load average: <span id="load_avg"></span></p>
67
+ </div>
68
+ </body>
69
+ </html>
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DigitalFabric
4
+ module Protocol
5
+ PING = 'ping'
6
+ SHUTDOWN = 'shutdown'
7
+
8
+ HTTP_REQUEST = 'http_request'
9
+ HTTP_RESPONSE = 'http_response'
10
+ HTTP_UPGRADE = 'http_upgrade'
11
+ HTTP_GET_REQUEST_BODY = 'http_get_request_body'
12
+ HTTP_REQUEST_BODY = 'http_request_body'
13
+
14
+ CONN_DATA = 'conn_data'
15
+ CONN_CLOSE = 'conn_close'
16
+
17
+ WS_REQUEST = 'ws_request'
18
+ WS_RESPONSE = 'ws_response'
19
+ WS_DATA = 'ws_data'
20
+ WS_CLOSE = 'ws_close'
21
+
22
+ SEND_TIMEOUT = 15
23
+ RECV_TIMEOUT = SEND_TIMEOUT + 5
24
+
25
+ class << self
26
+ def ping
27
+ { kind: PING }
28
+ end
29
+
30
+ def shutdown
31
+ { kind: SHUTDOWN }
32
+ end
33
+
34
+ DF_UPGRADE_RESPONSE = <<~HTTP.gsub("\n", "\r\n")
35
+ HTTP/1.1 101 Switching Protocols
36
+ Upgrade: df
37
+ Connection: Upgrade
38
+
39
+ HTTP
40
+
41
+ def df_upgrade_response
42
+ DF_UPGRADE_RESPONSE
43
+ end
44
+
45
+ def http_request(id, req)
46
+ { kind: HTTP_REQUEST, id: id, headers: req.headers, body: req.next_chunk, complete: req.complete? }
47
+ end
48
+
49
+ def http_response(id, body, headers, complete)
50
+ { kind: HTTP_RESPONSE, id: id, body: body, headers: headers, complete: complete }
51
+ end
52
+
53
+ def http_upgrade(id, headers)
54
+ { kind: HTTP_UPGRADE, id: id }
55
+ end
56
+
57
+ def http_get_request_body(id, limit = nil)
58
+ { kind: HTTP_GET_REQUEST_BODY, id: id, limit: limit }
59
+ end
60
+
61
+ def http_request_body(id, body, complete)
62
+ { kind: HTTP_REQUEST_BODY, id: id, body: body, complete: complete }
63
+ end
64
+
65
+ def connection_data(id, data)
66
+ { kind: CONN_DATA, id: id, data: data }
67
+ end
68
+
69
+ def connection_close(id)
70
+ { kind: CONN_CLOSE, id: id }
71
+ end
72
+
73
+ def ws_request(id, headers)
74
+ { kind: WS_REQUEST, id: id, headers: headers }
75
+ end
76
+
77
+ def ws_response(id, headers)
78
+ { kind: WS_RESPONSE, id: id, headers: headers }
79
+ end
80
+
81
+ def ws_data(id, data)
82
+ { id: id, kind: WS_DATA, data: data }
83
+ end
84
+
85
+ def ws_close(id)
86
+ { id: id, kind: WS_CLOSE }
87
+ end
88
+ end
89
+ end
90
+ end
@@ -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