async-service-supervisor-envoy 0.2.0 → 0.3.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: 50a07c8f84453334cdae213344ab37e44e8b4ec170fb0f8db3bb75b0385883c1
4
- data.tar.gz: 897302227c2b0b8d760dac2156f6e4ba11f3d4b325dc4fd57bb3dbb0aa6972f9
3
+ metadata.gz: 15171bea49a1422dc4ef04dc7f04f904dcc28e050e97365d905c15348e19877d
4
+ data.tar.gz: 9ccfbff50bda3608571165db4dc9b12f46668cb1b84063d8515ef0f7f3059157
5
5
  SHA512:
6
- metadata.gz: dfdb7eaa42d25f280a75a31c7aa8b45ab9497f0e7380ffca09b49e060639713076807cea5a9e1265b36c65931002f6b456963309fa41605cf8d309c459abee04
7
- data.tar.gz: 5e52d5bd10c4f3a53b85de3962dd60bbaee07f86a9113524e87230854a740a7d306632c69e3ba46018e8368fe4635fe5dfecbdf19cfc3a903548289c695a471e
6
+ metadata.gz: e8ea8643b3ae68cdb1e318e2dbae4a3b51d8c3609b7e6c1f73713828c7c76aa9539a586f810e2a91371a39b766da44471fee0b66cb22d9dcdc66d3fce7f91bfd
7
+ data.tar.gz: 593ec4b29fd5b582b8727fcf18552263bc286666348de95bb43dcd772e283413c28a2fdc6801d6759791266efb5253ea4f29d494aa78ea3affe922b64fd70f94
checksums.yaml.gz.sig CHANGED
Binary file
@@ -94,3 +94,34 @@ Async::Service::Supervisor::Envoy::Monitor.new(
94
94
  ```
95
95
 
96
96
  Disconnected workers are removed from EDS. Registered workers that fail the delegate health check remain in EDS with an unhealthy endpoint status.
97
+
98
+ ## Load-aware Balancing
99
+
100
+ Enable out-of-band ORCA reporting to let Envoy weight independently bound workers using their current processor utilization and request throughput:
101
+
102
+ ``` ruby
103
+ utilization_monitor = Async::Service::Supervisor::UtilizationMonitor.new(interval: 1)
104
+
105
+ [
106
+ utilization_monitor,
107
+ Async::Service::Supervisor::Envoy::Monitor.new(
108
+ bind: "http://0.0.0.0:18000",
109
+ orca: true,
110
+ utilization_monitor: utilization_monitor,
111
+ interval: 1
112
+ )
113
+ ]
114
+ ```
115
+
116
+ The supervisor utilization monitor manages each worker's shared-memory allocation and registration. The Envoy monitor samples it through `sample_by_worker`, combines each worker's `requests_total` counter with processor usage from `process-metrics`, and serves the resulting ORCA reports from the same HTTP/2 endpoint as ADS. It also configures each discovered cluster to use Envoy's client-side weighted-round-robin policy.
117
+
118
+ The first sample establishes a baseline. Subsequent reports contain normalized `cpu_utilization` and `rps_fractional` values for each worker. Reports are removed immediately when a worker disconnects.
119
+
120
+ Out-of-band ORCA requires:
121
+
122
+ - Envoy 1.39 or later.
123
+ - A fixed TCP port for the monitor's `bind` address.
124
+ - A supervisor utilization monitor registered alongside the Envoy monitor.
125
+ - Independently addressable TCP worker endpoints. Unix sockets and endpoints shared by several workers cannot provide distinct per-worker ORCA identities.
126
+
127
+ The monitor address and worker addresses must be reachable from Envoy. In a sidecar deployment, binding the monitor to a fixed port in the shared network namespace satisfies this requirement.
@@ -5,12 +5,17 @@
5
5
 
6
6
  require "async/http/endpoint"
7
7
  require "async/service/supervisor/monitor"
8
+ require "async/service/supervisor/utilization_monitor"
9
+ require "async/grpc/xds/client_side_weighted_round_robin"
8
10
  require "async/grpc/xds/control_plane"
9
11
  require "async/grpc/xds/server"
12
+ require "process/metrics"
13
+ require "xds/data/orca/v3/orca_load_report_pb"
10
14
 
11
15
  require_relative "delegate"
12
16
  require_relative "endpoint"
13
17
  require_relative "endpoint_group"
18
+ require_relative "orca_service"
14
19
 
15
20
  module Async
16
21
  module Service
@@ -23,21 +28,45 @@ module Async
23
28
  # @parameter bind [String | Nil] The optional address for the xDS control plane server.
24
29
  # @parameter delegate [Delegate] The delegate used to map supervisor state into Envoy endpoints.
25
30
  # @parameter control_plane [Async::GRPC::XDS::ControlPlane] The xDS control plane to update.
31
+ # @parameter orca [Boolean] Whether to collect and serve per-worker ORCA load reports.
32
+ # @parameter processor [Process::Metrics::Processor | Nil] The optional process CPU sampler.
33
+ # @parameter utilization_monitor [Async::Service::Supervisor::UtilizationMonitor | Nil] The per-worker utilization monitor used for ORCA reporting.
34
+ # @parameter interval [Numeric] The endpoint reconciliation and ORCA reporting interval in seconds.
26
35
  def initialize(
27
36
  bind: nil,
28
37
  delegate: Delegate.new,
29
38
  control_plane: Async::GRPC::XDS::ControlPlane.new,
39
+ orca: false,
40
+ processor: nil,
41
+ utilization_monitor: nil,
42
+ interval: 1,
30
43
  **options
31
44
  )
32
- super(**options)
45
+ super(interval: interval, **options)
33
46
 
34
47
  @bind = bind
35
48
  @delegate = delegate
36
49
  @control_plane = control_plane
50
+ @interval = interval
51
+ @orca = orca
37
52
  @controllers = {}
38
53
  @published_clusters = {}
39
54
  @server_task = nil
40
55
  @mutex = Mutex.new
56
+
57
+ if @orca
58
+ raise ArgumentError, "ORCA reporting requires a TCP bind address!" unless @bind
59
+ raise ArgumentError, "ORCA reporting requires a utilization monitor!" unless utilization_monitor
60
+
61
+ @orca_port = server_endpoint.url.port
62
+ raise ArgumentError, "ORCA reporting requires a fixed TCP port!" unless @orca_port&.positive?
63
+
64
+ @processor = processor || Process::Metrics::Processor.new
65
+ @utilization_monitor = utilization_monitor
66
+ @request_totals = {}
67
+ @load_reports = {}
68
+ @authorities = {}
69
+ end
41
70
  end
42
71
 
43
72
  # @attribute [Async::GRPC::XDS::ControlPlane] The xDS control plane receiving cluster and endpoint updates.
@@ -52,6 +81,7 @@ module Async
52
81
  def register(supervisor_controller)
53
82
  @mutex.synchronize do
54
83
  @controllers[supervisor_controller.id] = supervisor_controller
84
+ @authorities[worker_hostname(supervisor_controller)] = supervisor_controller.id if @orca
55
85
  reconcile
56
86
  end
57
87
  end
@@ -62,6 +92,12 @@ module Async
62
92
  def remove(supervisor_controller)
63
93
  @mutex.synchronize do
64
94
  @controllers.delete(supervisor_controller.id)
95
+ if @orca
96
+ hostname = worker_hostname(supervisor_controller)
97
+ @authorities.delete(hostname)
98
+ @load_reports.delete(hostname)
99
+ @request_totals.delete(supervisor_controller.id)
100
+ end
65
101
  reconcile
66
102
  end
67
103
  end
@@ -74,8 +110,9 @@ module Async
74
110
 
75
111
  if @bind
76
112
  @server_task = parent.async do
77
- endpoint = Async::HTTP::Endpoint.parse(@bind, protocol: Async::HTTP::Protocol::HTTP2)
78
- Async::GRPC::XDS::Server.new(@control_plane).run(endpoint)
113
+ server = Async::GRPC::XDS::Server.new(@control_plane)
114
+ server.dispatcher.register(ORCAService.new(self, minimum_interval: @interval)) if @orca
115
+ server.run(server_endpoint)
79
116
  end
80
117
  end
81
118
 
@@ -92,9 +129,29 @@ module Async
92
129
  end
93
130
  end
94
131
 
132
+ # Determine whether an ORCA worker authority is currently registered.
133
+ # @parameter authority [String] The gRPC request authority.
134
+ # @returns [Boolean] Whether the authority identifies a live worker.
135
+ def worker?(authority)
136
+ return false unless @orca
137
+
138
+ @mutex.synchronize{@authorities.key?(authority)}
139
+ end
140
+
141
+ # Get the latest ORCA report for a worker authority.
142
+ # @parameter authority [String] The gRPC request authority.
143
+ # @returns [Xds::Data::Orca::V3::OrcaLoadReport | Nil] The latest valid report, if available.
144
+ def load_report(authority)
145
+ return unless @orca
146
+
147
+ @mutex.synchronize{@load_reports[authority]}
148
+ end
149
+
95
150
  # Refresh endpoint health and publish updated EDS state.
96
151
  # @returns [void]
97
152
  def run_once
153
+ sample_load_reports if @orca
154
+
98
155
  @mutex.synchronize do
99
156
  reconcile
100
157
  end
@@ -102,6 +159,14 @@ module Async
102
159
 
103
160
  private
104
161
 
162
+ def server_endpoint
163
+ @server_endpoint ||= Async::HTTP::Endpoint.parse(@bind, protocol: Async::HTTP::Protocol::HTTP2)
164
+ end
165
+
166
+ def worker_hostname(supervisor_controller)
167
+ "worker-#{supervisor_controller.id}"
168
+ end
169
+
105
170
  def build_record(supervisor_controller, endpoint)
106
171
  cluster = @delegate.cluster(supervisor_controller, endpoint)
107
172
  return unless cluster
@@ -148,14 +213,24 @@ module Async
148
213
 
149
214
  def build_clusters(records_by_cluster = build_records_by_cluster)
150
215
  records_by_cluster.transform_values do |records|
151
- groups = {}
152
-
153
- records.each do |record|
154
- group = groups[record[:endpoint]] ||= EndpointGroup.new(record[:endpoint])
155
- group.add(record[:worker], healthy: record[:healthy])
216
+ if @orca
217
+ records.map do |record|
218
+ {
219
+ addresses: record[:endpoint].addresses,
220
+ healthy: record[:healthy],
221
+ hostname: worker_hostname(record[:worker]),
222
+ }
223
+ end
224
+ else
225
+ groups = {}
226
+
227
+ records.each do |record|
228
+ group = groups[record[:endpoint]] ||= EndpointGroup.new(record[:endpoint])
229
+ group.add(record[:worker], healthy: record[:healthy])
230
+ end
231
+
232
+ groups.each_value.map(&:as_json)
156
233
  end
157
-
158
- groups.each_value.map(&:as_json)
159
234
  end
160
235
  end
161
236
 
@@ -168,7 +243,71 @@ module Async
168
243
  raise ArgumentError, "Envoy cluster contains no common protocols: #{protocols.inspect}" if common_protocols.empty?
169
244
  raise ArgumentError, "HTTPS upstream endpoints are not yet supported!" if schemes.first == :https
170
245
 
171
- {protocol: envoy_protocol(common_protocols)}
246
+ configuration = {protocol: envoy_protocol(common_protocols)}
247
+
248
+ if @orca
249
+ if records.any?{|record| record[:endpoint].addresses.any?{|address| address[:path]}}
250
+ raise ArgumentError, "Out-of-band ORCA reporting requires IP endpoints!"
251
+ end
252
+
253
+ configuration[:load_balancing_policy] = Async::GRPC::XDS::ClientSideWeightedRoundRobin.build(
254
+ @orca_port,
255
+ reporting_period: @interval
256
+ )
257
+ end
258
+
259
+ configuration
260
+ end
261
+
262
+ def sample_load_reports
263
+ controllers = @mutex.synchronize{@controllers.dup}
264
+ workers = @utilization_monitor.sample_by_worker
265
+ process_ids = controllers.each_value.filter_map(&:process_id)
266
+ processor_samples = @processor.sample(process_ids)
267
+ request_totals = {}
268
+
269
+ workers.each do |worker_id, worker|
270
+ requests_total = worker[:utilization][:requests_total]
271
+ if requests_total.is_a?(Numeric) && requests_total.finite?
272
+ request_totals[worker_id] = requests_total
273
+ end
274
+ end
275
+
276
+ @mutex.synchronize do
277
+ controllers.each do |worker_id, controller|
278
+ next unless @controllers[worker_id].equal?(controller)
279
+
280
+ hostname = worker_hostname(controller)
281
+ processor_sample = processor_samples[controller.process_id]
282
+ requests_total = request_totals[worker_id]
283
+ previous_requests_total = @request_totals[worker_id]
284
+
285
+ if processor_sample && requests_total && previous_requests_total && requests_total >= previous_requests_total
286
+ rps = (requests_total - previous_requests_total).fdiv(processor_sample.duration)
287
+ cpu = processor_sample.utilization
288
+
289
+ if rps.finite? && cpu.finite?
290
+ @load_reports[hostname] = Xds::Data::Orca::V3::OrcaLoadReport.new(
291
+ cpu_utilization: cpu,
292
+ rps_fractional: rps,
293
+ named_metrics: {"orca.heartbeat" => 0.0},
294
+ )
295
+ next
296
+ end
297
+ end
298
+
299
+ @load_reports.delete(hostname)
300
+ end
301
+
302
+ @request_totals = request_totals
303
+
304
+ Console.debug(self, "Sampled ORCA load reports.",
305
+ workers: workers.keys,
306
+ processes: processor_samples.keys,
307
+ requests: request_totals,
308
+ reports: @load_reports.keys,
309
+ )
310
+ end
172
311
  end
173
312
 
174
313
  def envoy_protocol(protocols)
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "async/grpc/service"
7
+ require "protocol/http/body/writable"
8
+ require "xds/service/orca/v3/open_rca_service"
9
+
10
+ module Async
11
+ module Service
12
+ module Supervisor
13
+ module Envoy
14
+ # Streams per-worker out-of-band ORCA load reports to Envoy.
15
+ class ORCAService < Async::GRPC::Service
16
+ SERVICE_NAME = "xds.service.orca.v3.OpenRcaService"
17
+ EMPTY_REPORT = Xds::Data::Orca::V3::OrcaLoadReport.new(
18
+ named_metrics: {"orca.heartbeat" => 0.0}
19
+ )
20
+
21
+ # Initialize the ORCA service.
22
+ # @parameter monitor [Monitor] The monitor providing worker load reports.
23
+ # @parameter minimum_interval [Numeric] The minimum reporting interval in seconds.
24
+ def initialize(monitor, minimum_interval: 1)
25
+ super(Xds::Service::Orca::V3::OpenRcaService, SERVICE_NAME)
26
+
27
+ @monitor = monitor
28
+ @minimum_interval = minimum_interval
29
+ end
30
+
31
+ # Stream current load reports for the worker named by the request authority.
32
+ # @parameter input [Interface(:read)] The ORCA request stream.
33
+ # @parameter output [Interface(:write)] The ORCA report stream.
34
+ # @parameter call [Protocol::GRPC::Call] The gRPC call context.
35
+ # @asynchronous
36
+ def stream_core_metrics(input, output, call)
37
+ request = input.read
38
+ return unless request
39
+
40
+ authority = call.request.authority
41
+ interval = [duration(request.report_interval), @minimum_interval].max
42
+
43
+ while @monitor.worker?(authority)
44
+ output.write(@monitor.load_report(authority) || EMPTY_REPORT)
45
+
46
+ sleep(interval)
47
+ end
48
+ rescue Protocol::HTTP::Body::Writable::Closed
49
+ # The client closed the reporting stream.
50
+ end
51
+
52
+ private
53
+
54
+ def duration(value)
55
+ return 0 unless value
56
+
57
+ value.seconds + value.nanos.fdiv(1_000_000_000)
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -11,7 +11,7 @@ module Async
11
11
  module Supervisor
12
12
  # @namespace
13
13
  module Envoy
14
- VERSION = "0.2.0"
14
+ VERSION = "0.3.0"
15
15
  end
16
16
  end
17
17
  end
@@ -7,4 +7,5 @@ require_relative "envoy/version"
7
7
  require_relative "envoy/delegate"
8
8
  require_relative "envoy/endpoint"
9
9
  require_relative "envoy/monitor"
10
+ require_relative "envoy/orca_service"
10
11
  require_relative "envoy/supervised"
data/readme.md CHANGED
@@ -13,6 +13,7 @@ Provides an Envoy xDS monitor for `async-service-supervisor`.
13
13
  - **Multiple clusters** - Groups workers by `state[:name]` by default.
14
14
  - **Endpoint contract** - Converts concrete post-bind worker listeners into Envoy upstream endpoints, including grouped IP or Unix socket addresses.
15
15
  - **Delegate mapping** - Uses a delegate object for endpoint selection, cluster grouping, and health without active probing.
16
+ - **Load-aware balancing** - Optionally reports per-worker CPU utilization and request throughput to Envoy using out-of-band ORCA.
16
17
 
17
18
  ## Usage
18
19
 
@@ -24,6 +25,10 @@ Please see the [project documentation](https://socketry.github.io/async-service-
24
25
 
25
26
  Please see the [project releases](https://socketry.github.io/async-service-supervisor-envoy/releases/index) for all releases.
26
27
 
28
+ ### v0.3.0
29
+
30
+ - Add supervisor-driven out-of-band ORCA load reporting for independently addressable workers.
31
+
27
32
  ### v0.2.0
28
33
 
29
34
  - Accept the required Falcon listener as a positional worker preparation argument.
data/releases.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Releases
2
2
 
3
+ ## v0.3.0
4
+
5
+ - Add supervisor-driven out-of-band ORCA load reporting for independently addressable workers.
6
+
3
7
  ## v0.2.0
4
8
 
5
9
  - Accept the required Falcon listener as a positional worker preparation argument.
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: async-service-supervisor-envoy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Williams
@@ -58,14 +58,14 @@ dependencies:
58
58
  requirements:
59
59
  - - "~>"
60
60
  - !ruby/object:Gem::Version
61
- version: '0.1'
61
+ version: '0.2'
62
62
  type: :runtime
63
63
  prerelease: false
64
64
  version_requirements: !ruby/object:Gem::Requirement
65
65
  requirements:
66
66
  - - "~>"
67
67
  - !ruby/object:Gem::Version
68
- version: '0.1'
68
+ version: '0.2'
69
69
  - !ruby/object:Gem::Dependency
70
70
  name: async-http
71
71
  requirement: !ruby/object:Gem::Requirement
@@ -86,14 +86,28 @@ dependencies:
86
86
  requirements:
87
87
  - - "~>"
88
88
  - !ruby/object:Gem::Version
89
- version: '0.18'
89
+ version: '0.20'
90
90
  type: :runtime
91
91
  prerelease: false
92
92
  version_requirements: !ruby/object:Gem::Requirement
93
93
  requirements:
94
94
  - - "~>"
95
95
  - !ruby/object:Gem::Version
96
- version: '0.18'
96
+ version: '0.20'
97
+ - !ruby/object:Gem::Dependency
98
+ name: process-metrics
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '0.12'
104
+ type: :runtime
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '0.12'
97
111
  executables: []
98
112
  extensions: []
99
113
  extra_rdoc_files: []
@@ -106,6 +120,7 @@ files:
106
120
  - lib/async/service/supervisor/envoy/endpoint.rb
107
121
  - lib/async/service/supervisor/envoy/endpoint_group.rb
108
122
  - lib/async/service/supervisor/envoy/monitor.rb
123
+ - lib/async/service/supervisor/envoy/orca_service.rb
109
124
  - lib/async/service/supervisor/envoy/supervised.rb
110
125
  - lib/async/service/supervisor/envoy/version.rb
111
126
  - license.md
metadata.gz.sig CHANGED
Binary file