From a7297e60e71caaad583b9c16b20a034d235859e0 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 31 Jul 2026 15:25:04 +1200 Subject: [PATCH 01/14] Deduplicate shared listener endpoints Assisted-By: devx/56c5beec-347d-4712-9cc7-ea033e4d2a8a --- context/getting-started.md | 8 +++-- guides/getting-started/readme.md | 8 +++-- lib/async/service/supervisor/envoy/monitor.rb | 4 +-- .../service/supervisor/envoy/supervised.rb | 2 +- readme.md | 4 +++ releases.md | 4 +++ .../async/service/supervisor/envoy/monitor.rb | 30 +++++++++++++++++++ 7 files changed, 51 insertions(+), 9 deletions(-) diff --git a/context/getting-started.md b/context/getting-started.md index 35e94f4..4d5fbd1 100644 --- a/context/getting-started.md +++ b/context/getting-started.md @@ -41,16 +41,18 @@ state = { Workers without endpoint state are ignored by the Envoy monitor. -Falcon cluster workers can register their concrete post-bind listener automatically: +Falcon server workers can register their concrete bound listener automatically: ``` ruby service "application" do - include Falcon::Environment::Cluster + include Falcon::Environment::Server include Async::Service::Supervisor::Envoy::Supervised end ``` -Falcon describes its bound server resource as a listener. The integration converts that listener into Envoy upstream endpoint state, including its name, scheme, supported protocols, and every concrete IP or Unix socket address. Addresses belonging to one listener remain grouped as one Envoy load-balancer endpoint. +`Falcon::Environment::Server` reports one listener shared by all of its workers, while `Falcon::Environment::Cluster` reports the listener bound independently by each worker. Falcon describes either bound server resource as a listener. The integration converts that listener into Envoy upstream endpoint state, including its name, scheme, supported protocols, and every concrete IP or Unix socket address. + +Addresses belonging to one listener remain grouped as one Envoy load-balancer endpoint. When several workers report the same shared listener, the monitor publishes it once and keeps it available while any reporting worker is healthy. ## Monitor Usage diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md index 35e94f4..4d5fbd1 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -41,16 +41,18 @@ state = { Workers without endpoint state are ignored by the Envoy monitor. -Falcon cluster workers can register their concrete post-bind listener automatically: +Falcon server workers can register their concrete bound listener automatically: ``` ruby service "application" do - include Falcon::Environment::Cluster + include Falcon::Environment::Server include Async::Service::Supervisor::Envoy::Supervised end ``` -Falcon describes its bound server resource as a listener. The integration converts that listener into Envoy upstream endpoint state, including its name, scheme, supported protocols, and every concrete IP or Unix socket address. Addresses belonging to one listener remain grouped as one Envoy load-balancer endpoint. +`Falcon::Environment::Server` reports one listener shared by all of its workers, while `Falcon::Environment::Cluster` reports the listener bound independently by each worker. Falcon describes either bound server resource as a listener. The integration converts that listener into Envoy upstream endpoint state, including its name, scheme, supported protocols, and every concrete IP or Unix socket address. + +Addresses belonging to one listener remain grouped as one Envoy load-balancer endpoint. When several workers report the same shared listener, the monitor publishes it once and keeps it available while any reporting worker is healthy. ## Monitor Usage diff --git a/lib/async/service/supervisor/envoy/monitor.rb b/lib/async/service/supervisor/envoy/monitor.rb index fed8cc0..7420fa1 100644 --- a/lib/async/service/supervisor/envoy/monitor.rb +++ b/lib/async/service/supervisor/envoy/monitor.rb @@ -146,8 +146,8 @@ def build_records_by_cluster def build_clusters(records_by_cluster = build_records_by_cluster) records_by_cluster.transform_values do |records| - records.map do |record| - {addresses: record[:endpoint].addresses, healthy: record[:healthy]} + records.group_by{|record| record[:endpoint].addresses}.map do |addresses, records| + {addresses: addresses, healthy: records.any?{|record| record[:healthy]}} end end end diff --git a/lib/async/service/supervisor/envoy/supervised.rb b/lib/async/service/supervisor/envoy/supervised.rb index e1f6693..a7443c6 100644 --- a/lib/async/service/supervisor/envoy/supervised.rb +++ b/lib/async/service/supervisor/envoy/supervised.rb @@ -9,7 +9,7 @@ module Async module Service module Supervisor module Envoy - # Registers post-bind cluster listeners with the supervisor. + # Registers bound Falcon listeners with the supervisor. module Supervised include Async::Service::Supervisor::Supervised diff --git a/readme.md b/readme.md index 2c1d20e..be79fc7 100644 --- a/readme.md +++ b/readme.md @@ -24,6 +24,10 @@ Please see the [project documentation](https://socketry.github.io/async-service- Please see the [project releases](https://socketry.github.io/async-service-supervisor-envoy/releases/index) for all releases. +### Unreleased + + - Deduplicate shared listener endpoints reported by multiple supervised workers. + ### v0.0.1 - Register concrete Falcon cluster listeners as Envoy upstream endpoint state after binding. diff --git a/releases.md b/releases.md index 9bd3b6b..320be51 100644 --- a/releases.md +++ b/releases.md @@ -1,5 +1,9 @@ # Releases +## Unreleased + + - Deduplicate shared listener endpoints reported by multiple supervised workers. + ## v0.0.1 - Register concrete Falcon cluster listeners as Envoy upstream endpoint state after binding. diff --git a/test/async/service/supervisor/envoy/monitor.rb b/test/async/service/supervisor/envoy/monitor.rb index f5e3f00..3f8c742 100644 --- a/test/async/service/supervisor/envoy/monitor.rb +++ b/test/async/service/supervisor/envoy/monitor.rb @@ -100,6 +100,36 @@ def endpoint_assignment(cluster) expect(assignment.endpoints.first.lb_endpoints).to be(:empty?) end + it "publishes a shared listener once while any worker remains" do + delegate = Class.new(Async::Service::Supervisor::Envoy::Delegate) do + def healthy?(supervisor_controller, endpoint) + supervisor_controller.state[:healthy] + end + end.new + + monitor = subject.new(delegate: delegate) + endpoint = {name: "myservice", scheme: "http", protocols: ["h2"], addresses: [{address: "127.0.0.1", port: 50051}]} + first = Controller.new(1, {endpoint: endpoint, healthy: false}) + second = Controller.new(2, {endpoint: endpoint, healthy: true}) + + monitor.register(first) + monitor.register(second) + + expect(monitor.as_json[:clusters]["myservice"]).to be == [ + {addresses: [{address: "127.0.0.1", port: 50051}], healthy: true} + ] + + monitor.remove(second) + + expect(monitor.as_json[:clusters]["myservice"]).to be == [ + {addresses: [{address: "127.0.0.1", port: 50051}], healthy: false} + ] + + monitor.remove(first) + + expect(monitor.as_json[:clusters]["myservice"]).to be == nil + end + it "groups workers by service name" do monitor.register(Controller.new(1, { endpoint: {name: "service-a", scheme: "http", protocols: ["h2"], addresses: [{address: "127.0.0.1", port: 50051}]} From 23a657166377b2674429d5e7207e605d297a2504 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 31 Jul 2026 15:48:02 +1200 Subject: [PATCH 02/14] Model shared endpoint groups --- .../service/supervisor/envoy/endpoint.rb | 31 +++++-- .../supervisor/envoy/endpoint_group.rb | 63 +++++++++++++++ lib/async/service/supervisor/envoy/monitor.rb | 11 ++- releases.md | 2 +- .../service/supervisor/envoy/endpoint.rb | 80 +++++++++++++++++++ .../supervisor/envoy/endpoint_group.rb | 54 +++++++++++++ .../async/service/supervisor/envoy/monitor.rb | 42 ---------- 7 files changed, 233 insertions(+), 50 deletions(-) create mode 100644 lib/async/service/supervisor/envoy/endpoint_group.rb create mode 100644 test/async/service/supervisor/envoy/endpoint.rb create mode 100644 test/async/service/supervisor/envoy/endpoint_group.rb diff --git a/lib/async/service/supervisor/envoy/endpoint.rb b/lib/async/service/supervisor/envoy/endpoint.rb index accf1b1..e08174d 100644 --- a/lib/async/service/supervisor/envoy/endpoint.rb +++ b/lib/async/service/supervisor/envoy/endpoint.rb @@ -7,7 +7,7 @@ module Async module Service module Supervisor module Envoy - # Represents one upstream endpoint published to Envoy EDS. + # An immutable upstream endpoint published to Envoy EDS. class Endpoint # Wrap serialized endpoint state. # @parameter value [Endpoint | Hash] The value to wrap. @@ -30,9 +30,9 @@ def self.normalize_address(value) if path = value[:path] raise ArgumentError, "A Unix endpoint cannot specify an IP address or port!" if value[:address] || value[:port] - {path: path.to_s}.freeze + {path: path.to_s.dup.freeze}.freeze elsif value[:address] && value[:port] - {address: value[:address].to_s, port: Integer(value[:port])}.freeze + {address: value[:address].to_s.dup.freeze, port: Integer(value[:port])}.freeze else raise ArgumentError, "An endpoint address requires either path, or address and port: #{value.inspect}" end @@ -44,12 +44,15 @@ def self.normalize_address(value) # @parameter protocols [Array(String)] The supported upstream HTTP protocol names. # @parameter addresses [Array(Hash)] The grouped concrete addresses. def initialize(name:, scheme:, protocols:, addresses:) - @name = name.to_s + @name = name.to_s.dup.freeze @scheme = scheme.to_sym - @protocols = protocols.map(&:to_s).uniq.freeze + @protocols = protocols.map{|protocol| protocol.to_s.dup.freeze}.uniq.freeze raise ArgumentError, "An endpoint requires at least one protocol!" if @protocols.empty? @addresses = addresses.map{|value| self.class.normalize_address(value)}.freeze raise ArgumentError, "An endpoint requires at least one address!" if @addresses.empty? + + @hash = [self.class, @name, @scheme, @protocols, @addresses].hash + freeze end # @attribute [String] The upstream cluster name. @@ -64,6 +67,24 @@ def initialize(name:, scheme:, protocols:, addresses:) # @attribute [Array(Hash)] The grouped concrete addresses. attr :addresses + # Compare this endpoint with another endpoint by value. + # @parameter other [Object] The object to compare. + # @returns [Boolean] Whether both endpoints have identical values. + def ==(other) + other.instance_of?(self.class) && + @name == other.name && + @scheme == other.scheme && + @protocols == other.protocols && + @addresses == other.addresses + end + + alias eql? == + + # Compute the value hash used when grouping endpoints. + # @returns [Integer] The endpoint value hash. + def hash + @hash + end end end end diff --git a/lib/async/service/supervisor/envoy/endpoint_group.rb b/lib/async/service/supervisor/envoy/endpoint_group.rb new file mode 100644 index 0000000..1a9dc73 --- /dev/null +++ b/lib/async/service/supervisor/envoy/endpoint_group.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +module Async + module Service + module Supervisor + module Envoy + # Groups the workers reporting a single upstream endpoint. + class EndpointGroup + # Initialize an endpoint group. + # @parameter endpoint [Endpoint] The endpoint shared by the workers. + def initialize(endpoint) + @endpoint = endpoint + @workers = {} + end + + # @attribute [Endpoint] The endpoint shared by the workers. + attr :endpoint + + # Add or update a worker report. + # @parameter worker [SupervisorController] The worker reporting the endpoint. + # @parameter healthy [Boolean] Whether the worker considers the endpoint healthy. + def add(worker, healthy:) + @workers[worker.id] = healthy + end + + # Remove a worker report. + # @parameter worker [SupervisorController] The worker to remove. + # @returns [Boolean | Nil] The removed health value, if present. + def remove(worker) + @workers.delete(worker.id) + end + + # Count the workers reporting this endpoint. + # @returns [Integer] The number of reporting workers. + def size + @workers.size + end + + # Determine whether any workers report this endpoint. + # @returns [Boolean] Whether the group has no workers. + def empty? + @workers.empty? + end + + # Determine the aggregate endpoint health. + # @returns [Boolean] Whether any reporting worker is healthy. + def healthy? + @workers.each_value.any? + end + + # Convert the group to an xDS endpoint description. + # @returns [Hash] The endpoint addresses and aggregate health. + def as_json + {addresses: @endpoint.addresses, healthy: healthy?} + end + end + end + end + end +end diff --git a/lib/async/service/supervisor/envoy/monitor.rb b/lib/async/service/supervisor/envoy/monitor.rb index 7420fa1..0d26f4f 100644 --- a/lib/async/service/supervisor/envoy/monitor.rb +++ b/lib/async/service/supervisor/envoy/monitor.rb @@ -10,6 +10,7 @@ require_relative "delegate" require_relative "endpoint" +require_relative "endpoint_group" module Async module Service @@ -108,6 +109,7 @@ def build_record(supervisor_controller, endpoint) { cluster: cluster.to_s, endpoint: endpoint, + worker: supervisor_controller, healthy: @delegate.healthy?(supervisor_controller, endpoint), } end @@ -146,9 +148,14 @@ def build_records_by_cluster def build_clusters(records_by_cluster = build_records_by_cluster) records_by_cluster.transform_values do |records| - records.group_by{|record| record[:endpoint].addresses}.map do |addresses, records| - {addresses: addresses, healthy: records.any?{|record| record[:healthy]}} + groups = {} + + records.each do |record| + group = groups[record[:endpoint]] ||= EndpointGroup.new(record[:endpoint]) + group.add(record[:worker], healthy: record[:healthy]) end + + groups.each_value.map(&:as_json) end end diff --git a/releases.md b/releases.md index 320be51..6191fc2 100644 --- a/releases.md +++ b/releases.md @@ -2,7 +2,7 @@ ## Unreleased - - Deduplicate shared listener endpoints reported by multiple supervised workers. + - Deduplicate immutable endpoint values reported by multiple supervised workers and aggregate their health. ## v0.0.1 diff --git a/test/async/service/supervisor/envoy/endpoint.rb b/test/async/service/supervisor/envoy/endpoint.rb new file mode 100644 index 0000000..948ee91 --- /dev/null +++ b/test/async/service/supervisor/envoy/endpoint.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "async/service/supervisor/envoy/endpoint" + +describe Async::Service::Supervisor::Envoy::Endpoint do + let(:attributes) do + { + name: "api", + scheme: "http", + protocols: ["h2"], + addresses: [{path: "/tmp/api.ipc"}], + } + end + + it "wraps endpoint values" do + endpoint = subject.wrap(attributes) + + expect(endpoint.name).to be == "api" + expect(endpoint.scheme).to be == :http + expect(endpoint.protocols).to be == ["h2"] + expect(endpoint.addresses).to be == [{path: "/tmp/api.ipc"}] + end + + it "returns endpoint instances unchanged" do + endpoint = subject.new(**attributes) + + expect(subject.wrap(endpoint)).to be_equal(endpoint) + end + + it "has immutable value semantics" do + endpoint = subject.new(**attributes) + equivalent = subject.new(**attributes) + + expect(endpoint).to be == equivalent + expect(endpoint.eql?(equivalent)).to be == true + expect(endpoint.hash).to be == equivalent.hash + expect({endpoint => true}[equivalent]).to be == true + + expect(endpoint.frozen?).to be == true + expect(endpoint.name.frozen?).to be == true + expect(endpoint.protocols.frozen?).to be == true + expect(endpoint.protocols.first.frozen?).to be == true + expect(endpoint.addresses.frozen?).to be == true + expect(endpoint.addresses.first.frozen?).to be == true + expect(endpoint.addresses.first[:path].frozen?).to be == true + end + + it "preserves address order in endpoint identity" do + addresses = [ + {address: "127.0.0.1", port: 9292}, + {path: "/tmp/api.ipc"}, + ] + + endpoint = subject.new(**attributes, addresses: addresses) + reordered = subject.new(**attributes, addresses: addresses.reverse) + + expect(endpoint).not.to be == reordered + end + + it "rejects invalid endpoint objects" do + expect do + subject.wrap(Object.new) + end.to raise_exception(ArgumentError) + end + + it "rejects endpoints without protocols" do + expect do + subject.new(**attributes, protocols: []) + end.to raise_exception(ArgumentError) + end + + it "rejects invalid endpoint addresses" do + expect do + subject.new(**attributes, addresses: [{}]) + end.to raise_exception(ArgumentError) + end +end diff --git a/test/async/service/supervisor/envoy/endpoint_group.rb b/test/async/service/supervisor/envoy/endpoint_group.rb new file mode 100644 index 0000000..e546b62 --- /dev/null +++ b/test/async/service/supervisor/envoy/endpoint_group.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "async/service/supervisor/envoy/endpoint_group" +require "async/service/supervisor/envoy/endpoint" + +describe Async::Service::Supervisor::Envoy::EndpointGroup do + Worker = Struct.new(:id) + + let(:endpoint) do + Async::Service::Supervisor::Envoy::Endpoint.new( + name: "api", + scheme: "http", + protocols: ["h2"], + addresses: [{path: "/tmp/api.ipc"}], + ) + end + + let(:group) {subject.new(endpoint)} + + it "tracks workers precisely and aggregates health" do + first = Worker.new(1) + second = Worker.new(2) + + group.add(first, healthy: false) + group.add(second, healthy: true) + + expect(group.size).to be == 2 + expect(group.healthy?).to be == true + expect(group.as_json).to be == { + addresses: [{path: "/tmp/api.ipc"}], + healthy: true, + } + + group.remove(second) + + expect(group.size).to be == 1 + expect(group.healthy?).to be == false + + group.remove(first) + + expect(group.empty?).to be == true + end + + it "updates an existing worker report by worker ID" do + group.add(Worker.new(1), healthy: false) + group.add(Worker.new(1), healthy: true) + + expect(group.size).to be == 1 + expect(group.healthy?).to be == true + end +end diff --git a/test/async/service/supervisor/envoy/monitor.rb b/test/async/service/supervisor/envoy/monitor.rb index 3f8c742..62e3efe 100644 --- a/test/async/service/supervisor/envoy/monitor.rb +++ b/test/async/service/supervisor/envoy/monitor.rb @@ -275,48 +275,6 @@ def healthy?(supervisor_controller, endpoint) } end - it "wraps endpoint values" do - endpoint = Async::Service::Supervisor::Envoy::Endpoint.wrap( - {name: "api", scheme: "http", protocols: ["h2"], addresses: [{path: "/tmp/api.ipc"}]} - ) - - expect(endpoint.name).to be == "api" - expect(endpoint.scheme).to be == :http - expect(endpoint.protocols).to be == ["h2"] - expect(endpoint.protocols.frozen?).to be == true - expect(endpoint.addresses).to be == [{path: "/tmp/api.ipc"}] - end - - it "returns endpoint instances unchanged" do - endpoint = Async::Service::Supervisor::Envoy::Endpoint.new( - name: "api", scheme: "http", protocols: ["h2"], addresses: [{path: "/tmp/api.ipc"}] - ) - - expect(Async::Service::Supervisor::Envoy::Endpoint.wrap(endpoint)).to be == endpoint - end - - it "rejects invalid endpoint objects" do - expect do - Async::Service::Supervisor::Envoy::Endpoint.wrap(Object.new) - end.to raise_exception(ArgumentError) - end - - it "rejects endpoints without protocols" do - expect do - Async::Service::Supervisor::Envoy::Endpoint.new( - name: "api", scheme: "http", protocols: [], addresses: [{path: "/tmp/api.ipc"}] - ) - end.to raise_exception(ArgumentError) - end - - it "rejects invalid endpoint addresses" do - expect do - Async::Service::Supervisor::Envoy::Endpoint.new( - name: "api", scheme: "http", protocols: ["h2"], addresses: [{}] - ) - end.to raise_exception(ArgumentError) - end - it "selects the preferred common endpoint protocol" do monitor.register(Controller.new(1, { endpoint: {name: "myservice", scheme: "http", protocols: ["h2", "http/1.1"], addresses: [{path: "/tmp/one.ipc"}]} From 0b08c9399edecc4fc03e79a99b426b9fcf315226 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 31 Jul 2026 16:20:57 +1200 Subject: [PATCH 03/14] Build immutable Envoy endpoints --- .../service/supervisor/envoy/endpoint.rb | 28 +++++++++++++++---- .../service/supervisor/envoy/endpoint.rb | 14 +++++----- .../supervisor/envoy/endpoint_group.rb | 2 +- .../async/service/supervisor/envoy/monitor.rb | 2 +- 4 files changed, 31 insertions(+), 15 deletions(-) diff --git a/lib/async/service/supervisor/envoy/endpoint.rb b/lib/async/service/supervisor/envoy/endpoint.rb index e08174d..135d860 100644 --- a/lib/async/service/supervisor/envoy/endpoint.rb +++ b/lib/async/service/supervisor/envoy/endpoint.rb @@ -9,6 +9,21 @@ module Supervisor module Envoy # An immutable upstream endpoint published to Envoy EDS. class Endpoint + # Build an immutable endpoint. + # @parameter name [String] The upstream cluster name. + # @parameter scheme [String | Symbol] The upstream application scheme. + # @parameter protocols [Array(String)] The supported upstream HTTP protocol names. + # @parameter addresses [Array(Hash)] The grouped concrete addresses. + # @returns [Endpoint] The immutable endpoint. + def self.build(name:, scheme:, protocols:, addresses:) + new( + name: name.to_s.dup.freeze, + scheme: scheme.to_sym, + protocols: protocols.map{|protocol| protocol.to_s.dup.freeze}.uniq.freeze, + addresses: addresses.map{|value| normalize_address(value)}.freeze, + ).tap(&:freeze) + end + # Wrap serialized endpoint state. # @parameter value [Endpoint | Hash] The value to wrap. # @returns [Endpoint] The endpoint value. @@ -17,7 +32,7 @@ def self.wrap(value) when self value when Hash - new(**value) + build(**value) else raise ArgumentError, "Invalid Envoy endpoint: #{value.inspect}" end @@ -44,15 +59,14 @@ def self.normalize_address(value) # @parameter protocols [Array(String)] The supported upstream HTTP protocol names. # @parameter addresses [Array(Hash)] The grouped concrete addresses. def initialize(name:, scheme:, protocols:, addresses:) - @name = name.to_s.dup.freeze - @scheme = scheme.to_sym - @protocols = protocols.map{|protocol| protocol.to_s.dup.freeze}.uniq.freeze + @name = name + @scheme = scheme + @protocols = protocols raise ArgumentError, "An endpoint requires at least one protocol!" if @protocols.empty? - @addresses = addresses.map{|value| self.class.normalize_address(value)}.freeze + @addresses = addresses raise ArgumentError, "An endpoint requires at least one address!" if @addresses.empty? @hash = [self.class, @name, @scheme, @protocols, @addresses].hash - freeze end # @attribute [String] The upstream cluster name. @@ -85,6 +99,8 @@ def ==(other) def hash @hash end + + private_class_method :new end end end diff --git a/test/async/service/supervisor/envoy/endpoint.rb b/test/async/service/supervisor/envoy/endpoint.rb index 948ee91..18d1ef7 100644 --- a/test/async/service/supervisor/envoy/endpoint.rb +++ b/test/async/service/supervisor/envoy/endpoint.rb @@ -25,14 +25,14 @@ end it "returns endpoint instances unchanged" do - endpoint = subject.new(**attributes) + endpoint = subject.build(**attributes) expect(subject.wrap(endpoint)).to be_equal(endpoint) end it "has immutable value semantics" do - endpoint = subject.new(**attributes) - equivalent = subject.new(**attributes) + endpoint = subject.build(**attributes) + equivalent = subject.build(**attributes) expect(endpoint).to be == equivalent expect(endpoint.eql?(equivalent)).to be == true @@ -54,8 +54,8 @@ {path: "/tmp/api.ipc"}, ] - endpoint = subject.new(**attributes, addresses: addresses) - reordered = subject.new(**attributes, addresses: addresses.reverse) + endpoint = subject.build(**attributes, addresses: addresses) + reordered = subject.build(**attributes, addresses: addresses.reverse) expect(endpoint).not.to be == reordered end @@ -68,13 +68,13 @@ it "rejects endpoints without protocols" do expect do - subject.new(**attributes, protocols: []) + subject.build(**attributes, protocols: []) end.to raise_exception(ArgumentError) end it "rejects invalid endpoint addresses" do expect do - subject.new(**attributes, addresses: [{}]) + subject.build(**attributes, addresses: [{}]) end.to raise_exception(ArgumentError) end end diff --git a/test/async/service/supervisor/envoy/endpoint_group.rb b/test/async/service/supervisor/envoy/endpoint_group.rb index e546b62..7c3744a 100644 --- a/test/async/service/supervisor/envoy/endpoint_group.rb +++ b/test/async/service/supervisor/envoy/endpoint_group.rb @@ -10,7 +10,7 @@ Worker = Struct.new(:id) let(:endpoint) do - Async::Service::Supervisor::Envoy::Endpoint.new( + Async::Service::Supervisor::Envoy::Endpoint.build( name: "api", scheme: "http", protocols: ["h2"], diff --git a/test/async/service/supervisor/envoy/monitor.rb b/test/async/service/supervisor/envoy/monitor.rb index 62e3efe..62716d1 100644 --- a/test/async/service/supervisor/envoy/monitor.rb +++ b/test/async/service/supervisor/envoy/monitor.rb @@ -241,7 +241,7 @@ def healthy?(supervisor_controller, endpoint) delegate = Class.new(Async::Service::Supervisor::Envoy::Delegate) do def endpoint_list(supervisor_controller) [ - Async::Service::Supervisor::Envoy::Endpoint.new( + Async::Service::Supervisor::Envoy::Endpoint.build( name: "ignored", scheme: "http", protocols: ["http/1.1"], From 6846eeda7d24b0920c538e939ce93413a7c48c11 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 31 Jul 2026 16:22:25 +1200 Subject: [PATCH 04/14] Keep Endpoint constructor public --- lib/async/service/supervisor/envoy/endpoint.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/async/service/supervisor/envoy/endpoint.rb b/lib/async/service/supervisor/envoy/endpoint.rb index 135d860..dc4607f 100644 --- a/lib/async/service/supervisor/envoy/endpoint.rb +++ b/lib/async/service/supervisor/envoy/endpoint.rb @@ -99,8 +99,6 @@ def ==(other) def hash @hash end - - private_class_method :new end end end From 31fed88011f56d5b812464e578264bccf3cc5199 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 31 Jul 2026 16:27:56 +1200 Subject: [PATCH 05/14] Cache endpoint hash when frozen --- lib/async/service/supervisor/envoy/endpoint.rb | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/async/service/supervisor/envoy/endpoint.rb b/lib/async/service/supervisor/envoy/endpoint.rb index dc4607f..00b385d 100644 --- a/lib/async/service/supervisor/envoy/endpoint.rb +++ b/lib/async/service/supervisor/envoy/endpoint.rb @@ -65,8 +65,6 @@ def initialize(name:, scheme:, protocols:, addresses:) raise ArgumentError, "An endpoint requires at least one protocol!" if @protocols.empty? @addresses = addresses raise ArgumentError, "An endpoint requires at least one address!" if @addresses.empty? - - @hash = [self.class, @name, @scheme, @protocols, @addresses].hash end # @attribute [String] The upstream cluster name. @@ -81,6 +79,12 @@ def initialize(name:, scheme:, protocols:, addresses:) # @attribute [Array(Hash)] The grouped concrete addresses. attr :addresses + # Freeze this endpoint and cache its value hash. + def freeze + @hash = hash unless frozen? + super + end + # Compare this endpoint with another endpoint by value. # @parameter other [Object] The object to compare. # @returns [Boolean] Whether both endpoints have identical values. @@ -97,7 +101,7 @@ def ==(other) # Compute the value hash used when grouping endpoints. # @returns [Integer] The endpoint value hash. def hash - @hash + @hash || [self.class, @name, @scheme, @protocols, @addresses].hash end end end From db8324f7120365a8465825f0c7febb65616646d8 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 31 Jul 2026 16:32:41 +1200 Subject: [PATCH 06/14] Initialize endpoint hash cache --- lib/async/service/supervisor/envoy/endpoint.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/async/service/supervisor/envoy/endpoint.rb b/lib/async/service/supervisor/envoy/endpoint.rb index 00b385d..3d06fcb 100644 --- a/lib/async/service/supervisor/envoy/endpoint.rb +++ b/lib/async/service/supervisor/envoy/endpoint.rb @@ -65,6 +65,8 @@ def initialize(name:, scheme:, protocols:, addresses:) raise ArgumentError, "An endpoint requires at least one protocol!" if @protocols.empty? @addresses = addresses raise ArgumentError, "An endpoint requires at least one address!" if @addresses.empty? + + @hash = nil end # @attribute [String] The upstream cluster name. From fdd3cb8ac368cbc40272aed38d34a027e104ec63 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 31 Jul 2026 16:39:48 +1200 Subject: [PATCH 07/14] Use positional Endpoint initialization --- lib/async/service/supervisor/envoy/endpoint.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/async/service/supervisor/envoy/endpoint.rb b/lib/async/service/supervisor/envoy/endpoint.rb index 3d06fcb..883600b 100644 --- a/lib/async/service/supervisor/envoy/endpoint.rb +++ b/lib/async/service/supervisor/envoy/endpoint.rb @@ -17,10 +17,10 @@ class Endpoint # @returns [Endpoint] The immutable endpoint. def self.build(name:, scheme:, protocols:, addresses:) new( - name: name.to_s.dup.freeze, - scheme: scheme.to_sym, - protocols: protocols.map{|protocol| protocol.to_s.dup.freeze}.uniq.freeze, - addresses: addresses.map{|value| normalize_address(value)}.freeze, + name.to_s.dup.freeze, + scheme.to_sym, + protocols.map{|protocol| protocol.to_s.dup.freeze}.uniq.freeze, + addresses.map{|value| normalize_address(value)}.freeze, ).tap(&:freeze) end @@ -58,7 +58,7 @@ def self.normalize_address(value) # @parameter scheme [String | Symbol] The upstream application scheme. # @parameter protocols [Array(String)] The supported upstream HTTP protocol names. # @parameter addresses [Array(Hash)] The grouped concrete addresses. - def initialize(name:, scheme:, protocols:, addresses:) + def initialize(name, scheme, protocols, addresses) @name = name @scheme = scheme @protocols = protocols From 61acd3564ad4c70712b7322b90fb466eac45b395 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 31 Jul 2026 16:40:30 +1200 Subject: [PATCH 08/14] Simplify endpoint string normalization --- lib/async/service/supervisor/envoy/endpoint.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/async/service/supervisor/envoy/endpoint.rb b/lib/async/service/supervisor/envoy/endpoint.rb index 883600b..3ae1f42 100644 --- a/lib/async/service/supervisor/envoy/endpoint.rb +++ b/lib/async/service/supervisor/envoy/endpoint.rb @@ -17,9 +17,9 @@ class Endpoint # @returns [Endpoint] The immutable endpoint. def self.build(name:, scheme:, protocols:, addresses:) new( - name.to_s.dup.freeze, + name.to_s.freeze, scheme.to_sym, - protocols.map{|protocol| protocol.to_s.dup.freeze}.uniq.freeze, + protocols.map{|protocol| protocol.to_s.freeze}.uniq.freeze, addresses.map{|value| normalize_address(value)}.freeze, ).tap(&:freeze) end @@ -45,9 +45,9 @@ def self.normalize_address(value) if path = value[:path] raise ArgumentError, "A Unix endpoint cannot specify an IP address or port!" if value[:address] || value[:port] - {path: path.to_s.dup.freeze}.freeze + {path: path.to_s.freeze}.freeze elsif value[:address] && value[:port] - {address: value[:address].to_s.dup.freeze, port: Integer(value[:port])}.freeze + {address: value[:address].to_s.freeze, port: Integer(value[:port])}.freeze else raise ArgumentError, "An endpoint address requires either path, or address and port: #{value.inspect}" end From 92aa951b61271e6a357a6343fbcc84c293123719 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 31 Jul 2026 16:43:00 +1200 Subject: [PATCH 09/14] Finalize endpoint values when frozen --- lib/async/service/supervisor/envoy/endpoint.rb | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/lib/async/service/supervisor/envoy/endpoint.rb b/lib/async/service/supervisor/envoy/endpoint.rb index 3ae1f42..ddd9a9e 100644 --- a/lib/async/service/supervisor/envoy/endpoint.rb +++ b/lib/async/service/supervisor/envoy/endpoint.rb @@ -16,12 +16,7 @@ class Endpoint # @parameter addresses [Array(Hash)] The grouped concrete addresses. # @returns [Endpoint] The immutable endpoint. def self.build(name:, scheme:, protocols:, addresses:) - new( - name.to_s.freeze, - scheme.to_sym, - protocols.map{|protocol| protocol.to_s.freeze}.uniq.freeze, - addresses.map{|value| normalize_address(value)}.freeze, - ).tap(&:freeze) + new(name, scheme, protocols, addresses).tap(&:freeze) end # Wrap serialized endpoint state. @@ -83,7 +78,14 @@ def initialize(name, scheme, protocols, addresses) # Freeze this endpoint and cache its value hash. def freeze - @hash = hash unless frozen? + unless frozen? + @name = @name.to_s.freeze + @scheme = @scheme.to_sym + @protocols = @protocols.map{|protocol| protocol.to_s.freeze}.uniq.freeze + @addresses = @addresses.map{|value| self.class.normalize_address(value)}.freeze + @hash = hash + end + super end From 6956020dd543cf04cb345132707c7940fff84635 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 31 Jul 2026 16:44:17 +1200 Subject: [PATCH 10/14] Separate endpoint normalization and freezing --- .../service/supervisor/envoy/endpoint.rb | 29 ++++++++++++------- .../service/supervisor/envoy/endpoint.rb | 1 + 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/lib/async/service/supervisor/envoy/endpoint.rb b/lib/async/service/supervisor/envoy/endpoint.rb index ddd9a9e..fae5597 100644 --- a/lib/async/service/supervisor/envoy/endpoint.rb +++ b/lib/async/service/supervisor/envoy/endpoint.rb @@ -16,7 +16,12 @@ class Endpoint # @parameter addresses [Array(Hash)] The grouped concrete addresses. # @returns [Endpoint] The immutable endpoint. def self.build(name:, scheme:, protocols:, addresses:) - new(name, scheme, protocols, addresses).tap(&:freeze) + new( + name.to_s, + scheme.to_sym, + protocols.map(&:to_s).uniq, + addresses.map{|value| normalize_address(value)}, + ).tap(&:freeze) end # Wrap serialized endpoint state. @@ -40,9 +45,9 @@ def self.normalize_address(value) if path = value[:path] raise ArgumentError, "A Unix endpoint cannot specify an IP address or port!" if value[:address] || value[:port] - {path: path.to_s.freeze}.freeze + {path: path.to_s} elsif value[:address] && value[:port] - {address: value[:address].to_s.freeze, port: Integer(value[:port])}.freeze + {address: value[:address].to_s, port: Integer(value[:port])} else raise ArgumentError, "An endpoint address requires either path, or address and port: #{value.inspect}" end @@ -50,7 +55,7 @@ def self.normalize_address(value) # Initialize an endpoint. # @parameter name [String] The upstream cluster name. - # @parameter scheme [String | Symbol] The upstream application scheme. + # @parameter scheme [Symbol] The upstream application scheme. # @parameter protocols [Array(String)] The supported upstream HTTP protocol names. # @parameter addresses [Array(Hash)] The grouped concrete addresses. def initialize(name, scheme, protocols, addresses) @@ -78,13 +83,15 @@ def initialize(name, scheme, protocols, addresses) # Freeze this endpoint and cache its value hash. def freeze - unless frozen? - @name = @name.to_s.freeze - @scheme = @scheme.to_sym - @protocols = @protocols.map{|protocol| protocol.to_s.freeze}.uniq.freeze - @addresses = @addresses.map{|value| self.class.normalize_address(value)}.freeze - @hash = hash - end + return self if frozen? + + @name.freeze + @protocols.each(&:freeze).freeze + @addresses.each do |address| + address.each_value(&:freeze) + address.freeze + end.freeze + @hash = hash super end diff --git a/test/async/service/supervisor/envoy/endpoint.rb b/test/async/service/supervisor/envoy/endpoint.rb index 18d1ef7..c842aaa 100644 --- a/test/async/service/supervisor/envoy/endpoint.rb +++ b/test/async/service/supervisor/envoy/endpoint.rb @@ -46,6 +46,7 @@ expect(endpoint.addresses.frozen?).to be == true expect(endpoint.addresses.first.frozen?).to be == true expect(endpoint.addresses.first[:path].frozen?).to be == true + expect(endpoint.freeze).to be_equal(endpoint) end it "preserves address order in endpoint identity" do From 380f812e8a303cb41e962d8e6aa9f48de2d64d87 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 31 Jul 2026 16:47:15 +1200 Subject: [PATCH 11/14] Make endpoint hash dispatch explicit --- lib/async/service/supervisor/envoy/endpoint.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/async/service/supervisor/envoy/endpoint.rb b/lib/async/service/supervisor/envoy/endpoint.rb index fae5597..729389b 100644 --- a/lib/async/service/supervisor/envoy/endpoint.rb +++ b/lib/async/service/supervisor/envoy/endpoint.rb @@ -91,7 +91,7 @@ def freeze address.each_value(&:freeze) address.freeze end.freeze - @hash = hash + @hash = self.hash super end From 49f1c361e6649b8f6cecbcf66d495a90f9ccfb7d Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 31 Jul 2026 16:51:03 +1200 Subject: [PATCH 12/14] Define strict endpoint equality with eql --- lib/async/service/supervisor/envoy/endpoint.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/async/service/supervisor/envoy/endpoint.rb b/lib/async/service/supervisor/envoy/endpoint.rb index 729389b..7efffe5 100644 --- a/lib/async/service/supervisor/envoy/endpoint.rb +++ b/lib/async/service/supervisor/envoy/endpoint.rb @@ -99,7 +99,7 @@ def freeze # Compare this endpoint with another endpoint by value. # @parameter other [Object] The object to compare. # @returns [Boolean] Whether both endpoints have identical values. - def ==(other) + def eql?(other) other.instance_of?(self.class) && @name == other.name && @scheme == other.scheme && @@ -107,7 +107,7 @@ def ==(other) @addresses == other.addresses end - alias eql? == + alias == eql? # Compute the value hash used when grouping endpoints. # @returns [Integer] The endpoint value hash. From 98139dd703e3eefa6d74141c4227afc879392b07 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 31 Jul 2026 16:57:34 +1200 Subject: [PATCH 13/14] Clarify endpoint documentation --- lib/async/service/supervisor/envoy/endpoint.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/async/service/supervisor/envoy/endpoint.rb b/lib/async/service/supervisor/envoy/endpoint.rb index 7efffe5..63090e6 100644 --- a/lib/async/service/supervisor/envoy/endpoint.rb +++ b/lib/async/service/supervisor/envoy/endpoint.rb @@ -7,7 +7,7 @@ module Async module Service module Supervisor module Envoy - # An immutable upstream endpoint published to Envoy EDS. + # Represents an upstream endpoint published to Envoy EDS. class Endpoint # Build an immutable endpoint. # @parameter name [String] The upstream cluster name. From 5c572101bdcf3a72727709b9d157471948fa15fa Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 31 Jul 2026 16:59:58 +1200 Subject: [PATCH 14/14] Use endpoint address contract directly --- lib/async/service/supervisor/envoy/endpoint.rb | 17 +---------------- test/async/service/supervisor/envoy/endpoint.rb | 6 ------ 2 files changed, 1 insertion(+), 22 deletions(-) diff --git a/lib/async/service/supervisor/envoy/endpoint.rb b/lib/async/service/supervisor/envoy/endpoint.rb index 63090e6..dc6f51f 100644 --- a/lib/async/service/supervisor/envoy/endpoint.rb +++ b/lib/async/service/supervisor/envoy/endpoint.rb @@ -20,7 +20,7 @@ def self.build(name:, scheme:, protocols:, addresses:) name.to_s, scheme.to_sym, protocols.map(&:to_s).uniq, - addresses.map{|value| normalize_address(value)}, + addresses, ).tap(&:freeze) end @@ -38,21 +38,6 @@ def self.wrap(value) end end - # Normalize a concrete endpoint address. - # @parameter value [Hash] The address value. - # @returns [Hash] A normalized IP or Unix address. - def self.normalize_address(value) - if path = value[:path] - raise ArgumentError, "A Unix endpoint cannot specify an IP address or port!" if value[:address] || value[:port] - - {path: path.to_s} - elsif value[:address] && value[:port] - {address: value[:address].to_s, port: Integer(value[:port])} - else - raise ArgumentError, "An endpoint address requires either path, or address and port: #{value.inspect}" - end - end - # Initialize an endpoint. # @parameter name [String] The upstream cluster name. # @parameter scheme [Symbol] The upstream application scheme. diff --git a/test/async/service/supervisor/envoy/endpoint.rb b/test/async/service/supervisor/envoy/endpoint.rb index c842aaa..06d3898 100644 --- a/test/async/service/supervisor/envoy/endpoint.rb +++ b/test/async/service/supervisor/envoy/endpoint.rb @@ -72,10 +72,4 @@ subject.build(**attributes, protocols: []) end.to raise_exception(ArgumentError) end - - it "rejects invalid endpoint addresses" do - expect do - subject.build(**attributes, addresses: [{}]) - end.to raise_exception(ArgumentError) - end end