From bfe5b1dcb66577ddca6a2e1dd79a64f518870f42 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 26 Jun 2026 15:06:34 +1200 Subject: [PATCH 01/23] Add Falcon cluster service environment --- lib/falcon/environment/cluster.rb | 51 ++++++++++++++++++ lib/falcon/service/cluster.rb | 65 +++++++++++++++++++++++ lib/falcon/service/server.rb | 20 +++++-- releases.md | 1 + test/falcon/environment/cluster.rb | 24 +++++++++ test/falcon/service/cluster.rb | 84 ++++++++++++++++++++++++++++++ 6 files changed, 240 insertions(+), 5 deletions(-) create mode 100644 lib/falcon/environment/cluster.rb create mode 100644 lib/falcon/service/cluster.rb create mode 100644 test/falcon/environment/cluster.rb create mode 100644 test/falcon/service/cluster.rb diff --git a/lib/falcon/environment/cluster.rb b/lib/falcon/environment/cluster.rb new file mode 100644 index 00000000..862d0f1e --- /dev/null +++ b/lib/falcon/environment/cluster.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require_relative "server" +require_relative "../service/cluster" + +module Falcon + module Environment + # Provides an environment for hosting a cluster of Falcon server workers, where each worker binds its own endpoint. + module Cluster + include Server + + # The service class to use for the cluster. + # @returns [Class] + def service_class + Service::Cluster + end + + # The host that this server will receive connections for. + def url + "http://[::]:0" + end + + # The endpoint bound by the current worker. + # @returns [IO::Endpoint::BoundEndpoint | Nil] + def bound_endpoint + @bound_endpoint + end + + # Set the endpoint bound by the current worker. + # @parameter bound_endpoint [IO::Endpoint::BoundEndpoint] + def bound_endpoint=(bound_endpoint) + @bound_endpoint = bound_endpoint + end + + # The first socket address bound by the current worker. + # @returns [Addrinfo | Nil] + def bound_address + @bound_endpoint&.sockets&.first&.to_io&.local_address + end + + # The port bound by the current worker. + # @returns [Integer | Nil] + def bound_port + bound_address&.ip_port + end + end + end +end diff --git a/lib/falcon/service/cluster.rb b/lib/falcon/service/cluster.rb new file mode 100644 index 00000000..d09d8a2a --- /dev/null +++ b/lib/falcon/service/cluster.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require_relative "server" + +module Falcon + # @namespace + module Service + # A managed service for running Falcon workers with independently bound endpoints. + class Cluster < Server + # Cluster workers bind independently in their own process. + def bind_endpoint + end + + # Setup the service into the specified container. + # @parameter container [Async::Container] The container to configure. + def setup(container) + container_options = @evaluator.container_options + health_check_timeout = container_options[:health_check_timeout] + + container.run(**container_options) do |instance| + clock = Async::Clock.start + bound_endpoint = nil + + begin + Async do |task| + evaluator = self.environment.evaluator + server = nil + + health_checker(instance, health_check_timeout) do + if server + instance.name = format_title(evaluator, server) + end + end + + instance.status!("Preparing...") + + bound_endpoint = evaluator.endpoint.bound + evaluator.bound_endpoint = bound_endpoint + + evaluator.prepare!(instance) + emit_prepared(instance, clock) + + instance.status!("Running...") + server = run(instance, evaluator, bound_endpoint) + instance.name = format_title(evaluator, server) + emit_running(instance, clock) + + instance.ready! + + sleep + ensure + bound_endpoint&.close + task&.children&.each(&:stop) + end + ensure + bound_endpoint&.close + end + end + end + end + end +end diff --git a/lib/falcon/service/server.rb b/lib/falcon/service/server.rb index c3061bc6..f5e7f84b 100644 --- a/lib/falcon/service/server.rb +++ b/lib/falcon/service/server.rb @@ -21,8 +21,8 @@ def initialize(...) @bound_endpoint = nil end - # Prepare the bound endpoint for the server. - def start + # Bind the endpoint used by each server worker. + def bind_endpoint @endpoint = @evaluator.endpoint Sync do @@ -30,6 +30,11 @@ def start end Console.info(self){"Starting #{self.name} on #{@endpoint}"} + end + + # Prepare the bound endpoint for the server. + def start + bind_endpoint super end @@ -38,20 +43,25 @@ def start # # @parameter instance [Object] The container instance. # @parameter evaluator [Environment::Evaluator] The environment evaluator. + # @parameter bound_endpoint [IO::Endpoint] The endpoint bound by this worker. # @returns [Falcon::Server] The server instance. - def run(instance, evaluator) + def run(instance, evaluator, bound_endpoint = @bound_endpoint) if evaluator.respond_to?(:make_supervised_worker) Console.warn(self, "Async::Container::Supervisor is replaced by Async::Service::Supervisor, please update your service definition.") evaluator.make_supervised_worker(instance).run end - server = evaluator.make_server(@bound_endpoint) + server = evaluator.make_server(bound_endpoint) Async do |task| server.run - task.children&.each(&:wait) + begin + task.children&.each(&:wait) + rescue IOError + raise unless bound_endpoint.respond_to?(:sockets) && bound_endpoint.sockets.empty? + end end server diff --git a/releases.md b/releases.md index 7b76ef11..9267aa9c 100644 --- a/releases.md +++ b/releases.md @@ -2,6 +2,7 @@ ## Unreleased + - Add `Falcon::Environment::Cluster` and `Falcon::Service::Cluster` for running workers with independently bound endpoints. - Move Falcon middleware trace providers to `traces/provider/falcon/middleware`. ## v0.55.5 diff --git a/test/falcon/environment/cluster.rb b/test/falcon/environment/cluster.rb new file mode 100644 index 00000000..09a9fc4e --- /dev/null +++ b/test/falcon/environment/cluster.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "falcon/environment/cluster" +require "async/service/environment" + +describe Falcon::Environment::Cluster do + let(:evaluator) do + Async::Service::Environment.build(subject, name: "localhost").evaluator + end + + it "provides default cluster settings" do + expect(evaluator).to have_attributes( + service_class: be == Falcon::Service::Cluster, + url: be == "http://[::]:0", + authority: be == "localhost", + bound_endpoint: be == nil, + bound_address: be == nil, + bound_port: be == nil, + ) + end +end diff --git a/test/falcon/service/cluster.rb b/test/falcon/service/cluster.rb new file mode 100644 index 00000000..a7a75e7d --- /dev/null +++ b/test/falcon/service/cluster.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "falcon/service/cluster" +require "falcon/environment/cluster" +require "async/container" +require "async/service/environment" +require "fileutils" +require "net/http" +require "protocol/http/middleware" + +describe Falcon::Service::Cluster do + let(:ports_path) {File.expand_path(".cluster/ports.txt", __dir__)} + + let(:recorder) do + path = ports_path + + Module.new do + def middleware + Protocol::HTTP::Middleware::HelloWorld + end + + def container_options + super.merge(restart: false) + end + + define_method(:prepare!) do |instance| + super(instance) + + File.open(path, "a") do |file| + file.puts(bound_port) + end + end + end + end + + let(:environment) do + Async::Service::Environment.new(Falcon::Environment::Cluster).with( + recorder, + name: "hello", + root: File.expand_path(".cluster/hello", __dir__), + url: "http://127.0.0.1:0", + count: 2, + ) + end + + let(:server) do + subject.new(environment) + end + + before do + FileUtils.rm_rf(File.dirname(ports_path)) + FileUtils.mkdir_p(File.dirname(ports_path)) + end + + after do + FileUtils.rm_rf(File.dirname(ports_path)) + end + + it "binds a unique port for each worker before preparing the instance" do + container = Async::Container.new + + server.start + server.setup(container) + container.wait_until_ready + + ports = File.readlines(ports_path, chomp: true).map(&:to_i) + + expect(ports).to have_attributes(size: be == 2) + expect(ports).to have_value(be > 0) + expect(ports.uniq).to be == ports + + ports.each do |port| + response = Net::HTTP.get_response(URI("http://127.0.0.1:#{port}/")) + + expect(response).to have_attributes(code: be == "200") + end + ensure + server.stop + container&.stop + end +end From ed8d01f6ee38d4e093be331cdf1a90b0463b7257 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 20 Jul 2026 12:28:17 +1200 Subject: [PATCH 02/23] Fix cluster worker shutdown Assisted-By: devx/379cb815-bd96-4979-b667-b34037489b71 --- lib/falcon/service/cluster.rb | 19 ++++++++++++++----- lib/falcon/service/server.rb | 6 +----- test/falcon/service/cluster.rb | 5 ++++- test/falcon/service/server.rb | 27 +++++++++++++++++++++++++++ 4 files changed, 46 insertions(+), 11 deletions(-) diff --git a/lib/falcon/service/cluster.rb b/lib/falcon/service/cluster.rb index d09d8a2a..b211608f 100644 --- a/lib/falcon/service/cluster.rb +++ b/lib/falcon/service/cluster.rb @@ -24,11 +24,22 @@ def setup(container) clock = Async::Clock.start bound_endpoint = nil + # Route process signals through the reactor rather than an arbitrary request fiber. + signal_reader, signal_writer = IO.pipe + signal_handlers = [:INT, :TERM].to_h do |signal| + [signal, Signal.trap(signal){signal_writer.write_nonblock(".", exception: false)}] + end + begin Async do |task| evaluator = self.environment.evaluator server = nil + task.async(transient: true) do + signal_reader.read(1) + task.cancel + end + health_checker(instance, health_check_timeout) do if server instance.name = format_title(evaluator, server) @@ -49,13 +60,11 @@ def setup(container) emit_running(instance, clock) instance.ready! - - sleep - ensure - bound_endpoint&.close - task&.children&.each(&:stop) end ensure + signal_handlers.each{|signal, handler| Signal.trap(signal, handler)} + signal_reader.close + signal_writer.close bound_endpoint&.close end end diff --git a/lib/falcon/service/server.rb b/lib/falcon/service/server.rb index f5e7f84b..69c76d69 100644 --- a/lib/falcon/service/server.rb +++ b/lib/falcon/service/server.rb @@ -57,11 +57,7 @@ def run(instance, evaluator, bound_endpoint = @bound_endpoint) Async do |task| server.run - begin - task.children&.each(&:wait) - rescue IOError - raise unless bound_endpoint.respond_to?(:sockets) && bound_endpoint.sockets.empty? - end + task.children&.each(&:wait) end server diff --git a/test/falcon/service/cluster.rb b/test/falcon/service/cluster.rb index a7a75e7d..55732560 100644 --- a/test/falcon/service/cluster.rb +++ b/test/falcon/service/cluster.rb @@ -77,8 +77,11 @@ def container_options expect(response).to have_attributes(code: be == "200") end + + container.stop + expect(container.failed?).to be_falsey ensure server.stop - container&.stop + container&.stop unless container&.stopping? end end diff --git a/test/falcon/service/server.rb b/test/falcon/service/server.rb index c04f6aa8..aac1f15f 100644 --- a/test/falcon/service/server.rb +++ b/test/falcon/service/server.rb @@ -110,4 +110,31 @@ def server.run server.stop end end + + it "propagates server IO errors" do + evaluator = Object.new + bound_endpoint = Object.new + failing_server = Object.new + condition = Async::Condition.new + + failing_server.define_singleton_method(:run) do + Async do + condition.wait + raise IOError, "application failure" + end + end + + evaluator.define_singleton_method(:make_server) do |endpoint| + raise ArgumentError, "Unexpected endpoint!" unless endpoint.equal?(bound_endpoint) + failing_server + end + + expect do + Async do |task| + server.run(nil, evaluator, bound_endpoint) + condition.signal + task.children.each(&:wait) + end.wait + end.to raise_exception(IOError, message: be == "application failure") + end end From 82a23d42f2df085f377026f49af691dd59cb09b7 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Tue, 21 Jul 2026 12:50:17 +1200 Subject: [PATCH 03/23] Exercise cluster health checks Assisted-By: devx/379cb815-bd96-4979-b667-b34037489b71 --- test/falcon/service/cluster.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/falcon/service/cluster.rb b/test/falcon/service/cluster.rb index 55732560..ee12f222 100644 --- a/test/falcon/service/cluster.rb +++ b/test/falcon/service/cluster.rb @@ -43,6 +43,7 @@ def container_options root: File.expand_path(".cluster/hello", __dir__), url: "http://127.0.0.1:0", count: 2, + health_check_timeout: 0.01, ) end @@ -78,6 +79,8 @@ def container_options expect(response).to have_attributes(code: be == "200") end + sleep(0.01) + container.stop expect(container.failed?).to be_falsey ensure From 5e873517568a1debac90f60e0fcf6a14424929d3 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Tue, 21 Jul 2026 14:38:07 +1200 Subject: [PATCH 04/23] Use container signal handling for cluster workers Assisted-By: devx/379cb815-bd96-4979-b667-b34037489b71 --- lib/falcon/service/cluster.rb | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/lib/falcon/service/cluster.rb b/lib/falcon/service/cluster.rb index b211608f..ee876cb8 100644 --- a/lib/falcon/service/cluster.rb +++ b/lib/falcon/service/cluster.rb @@ -24,22 +24,11 @@ def setup(container) clock = Async::Clock.start bound_endpoint = nil - # Route process signals through the reactor rather than an arbitrary request fiber. - signal_reader, signal_writer = IO.pipe - signal_handlers = [:INT, :TERM].to_h do |signal| - [signal, Signal.trap(signal){signal_writer.write_nonblock(".", exception: false)}] - end - begin - Async do |task| + Async do evaluator = self.environment.evaluator server = nil - task.async(transient: true) do - signal_reader.read(1) - task.cancel - end - health_checker(instance, health_check_timeout) do if server instance.name = format_title(evaluator, server) @@ -62,9 +51,6 @@ def setup(container) instance.ready! end ensure - signal_handlers.each{|signal, handler| Signal.trap(signal, handler)} - signal_reader.close - signal_writer.close bound_endpoint&.close end end From 1da949f6d57a782753f18df4b32611e4b3de7e90 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 22 Jul 2026 17:59:25 +1200 Subject: [PATCH 05/23] Expose cluster worker bindings Assisted-By: devx/56c5beec-347d-4712-9cc7-ea033e4d2a8a --- lib/falcon/environment/cluster.rb | 28 ++++-------------- lib/falcon/service/cluster.rb | 41 ++++++++++++++++++++++++-- test/falcon/environment/cluster.rb | 12 ++++++-- test/falcon/service/cluster.rb | 47 ++++++++++++++++++++++++++++-- 4 files changed, 97 insertions(+), 31 deletions(-) diff --git a/lib/falcon/environment/cluster.rb b/lib/falcon/environment/cluster.rb index 862d0f1e..45dd4af6 100644 --- a/lib/falcon/environment/cluster.rb +++ b/lib/falcon/environment/cluster.rb @@ -23,28 +23,12 @@ def url "http://[::]:0" end - # The endpoint bound by the current worker. - # @returns [IO::Endpoint::BoundEndpoint | Nil] - def bound_endpoint - @bound_endpoint - end - - # Set the endpoint bound by the current worker. - # @parameter bound_endpoint [IO::Endpoint::BoundEndpoint] - def bound_endpoint=(bound_endpoint) - @bound_endpoint = bound_endpoint - end - - # The first socket address bound by the current worker. - # @returns [Addrinfo | Nil] - def bound_address - @bound_endpoint&.sockets&.first&.to_io&.local_address - end - - # The port bound by the current worker. - # @returns [Integer | Nil] - def bound_port - bound_address&.ip_port + # Prepare a cluster worker after its endpoint has been bound. + # + # @parameter instance [Object] The container instance. + # @parameter binding [Service::Cluster::Binding] The worker's bound endpoint and addresses. + def prepare_worker!(instance, binding) + prepare!(instance) end end end diff --git a/lib/falcon/service/cluster.rb b/lib/falcon/service/cluster.rb index ee876cb8..1a25f7a7 100644 --- a/lib/falcon/service/cluster.rb +++ b/lib/falcon/service/cluster.rb @@ -10,6 +10,41 @@ module Falcon module Service # A managed service for running Falcon workers with independently bound endpoints. class Cluster < Server + # An immutable association between a worker and its bound endpoint. + class Binding + # Initialize a worker binding. + # @parameter endpoint [IO::Endpoint::BoundEndpoint] The endpoint bound by the worker. + def initialize(endpoint:) + @endpoint = endpoint + @addresses = endpoint.sockets.map{|socket| socket.to_io.local_address}.freeze + freeze + end + + # @attribute [IO::Endpoint::BoundEndpoint] The endpoint bound by the worker. + attr_reader :endpoint + + # @attribute [Array(Addrinfo)] The addresses bound by the worker. + attr_reader :addresses + + # The first bound IP address, if present. + # @returns [String | Nil] The bound IP address. + def address + @addresses.find(&:ip?)&.ip_address + end + + # The first bound IP port, if present. + # @returns [Integer | Nil] The bound IP port. + def port + @addresses.find(&:ip?)&.ip_port + end + + # The first bound Unix socket path, if present. + # @returns [String | Nil] The bound Unix socket path. + def path + @addresses.find(&:unix?)&.unix_path + end + end + # Cluster workers bind independently in their own process. def bind_endpoint end @@ -38,13 +73,13 @@ def setup(container) instance.status!("Preparing...") bound_endpoint = evaluator.endpoint.bound - evaluator.bound_endpoint = bound_endpoint + binding = Binding.new(endpoint: bound_endpoint) - evaluator.prepare!(instance) + evaluator.prepare_worker!(instance, binding) emit_prepared(instance, clock) instance.status!("Running...") - server = run(instance, evaluator, bound_endpoint) + server = run(instance, evaluator, binding.endpoint) instance.name = format_title(evaluator, server) emit_running(instance, clock) diff --git a/test/falcon/environment/cluster.rb b/test/falcon/environment/cluster.rb index 09a9fc4e..ab3b3cda 100644 --- a/test/falcon/environment/cluster.rb +++ b/test/falcon/environment/cluster.rb @@ -16,9 +16,15 @@ service_class: be == Falcon::Service::Cluster, url: be == "http://[::]:0", authority: be == "localhost", - bound_endpoint: be == nil, - bound_address: be == nil, - bound_port: be == nil, ) end + + it "prepares workers using the existing preparation hook" do + instance = Object.new + binding = Object.new + + expect(evaluator).to receive(:prepare!).with(instance) + + evaluator.prepare_worker!(instance, binding) + end end diff --git a/test/falcon/service/cluster.rb b/test/falcon/service/cluster.rb index ee12f222..45afacef 100644 --- a/test/falcon/service/cluster.rb +++ b/test/falcon/service/cluster.rb @@ -14,6 +14,47 @@ describe Falcon::Service::Cluster do let(:ports_path) {File.expand_path(".cluster/ports.txt", __dir__)} + def make_binding(*addresses) + sockets = addresses.map do |address| + io = Struct.new(:local_address).new(address) + Struct.new(:to_io).new(io) + end + + endpoint = Struct.new(:sockets).new(sockets) + subject::Binding.new(endpoint: endpoint) + end + + it "captures all addresses from the bound endpoint" do + ip_address = Addrinfo.tcp("127.0.0.1", 9292) + unix_address = Addrinfo.unix("/tmp/falcon.sock") + binding = make_binding(ip_address, unix_address) + + expect(binding).to have_attributes( + addresses: be == [ip_address, unix_address], + address: be == "127.0.0.1", + port: be == 9292, + path: be == "/tmp/falcon.sock", + frozen?: be == true, + ) + expect(binding.addresses.frozen?).to be == true + end + + it "exposes an IP address and port without a Unix socket path" do + binding = make_binding(Addrinfo.tcp("127.0.0.1", 9292)) + + expect(binding.address).to be == "127.0.0.1" + expect(binding.port).to be == 9292 + expect(binding.path).to be == nil + end + + it "exposes a Unix socket path without an IP address and port" do + binding = make_binding(Addrinfo.unix("/tmp/falcon.sock")) + + expect(binding.address).to be == nil + expect(binding.port).to be == nil + expect(binding.path).to be == "/tmp/falcon.sock" + end + let(:recorder) do path = ports_path @@ -26,11 +67,11 @@ def container_options super.merge(restart: false) end - define_method(:prepare!) do |instance| - super(instance) + define_method(:prepare_worker!) do |instance, binding| + super(instance, binding) File.open(path, "a") do |file| - file.puts(bound_port) + file.puts(binding.port) end end end From 644332389cccccc5520480c7e298f924c08f7738 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 22 Jul 2026 18:21:08 +1200 Subject: [PATCH 06/23] Preserve all cluster binding addresses Assisted-By: devx/56c5beec-347d-4712-9cc7-ea033e4d2a8a --- lib/falcon/service/cluster.rb | 18 ------------------ test/falcon/service/cluster.rb | 23 +++-------------------- 2 files changed, 3 insertions(+), 38 deletions(-) diff --git a/lib/falcon/service/cluster.rb b/lib/falcon/service/cluster.rb index 1a25f7a7..964122ec 100644 --- a/lib/falcon/service/cluster.rb +++ b/lib/falcon/service/cluster.rb @@ -25,24 +25,6 @@ def initialize(endpoint:) # @attribute [Array(Addrinfo)] The addresses bound by the worker. attr_reader :addresses - - # The first bound IP address, if present. - # @returns [String | Nil] The bound IP address. - def address - @addresses.find(&:ip?)&.ip_address - end - - # The first bound IP port, if present. - # @returns [Integer | Nil] The bound IP port. - def port - @addresses.find(&:ip?)&.ip_port - end - - # The first bound Unix socket path, if present. - # @returns [String | Nil] The bound Unix socket path. - def path - @addresses.find(&:unix?)&.unix_path - end end # Cluster workers bind independently in their own process. diff --git a/test/falcon/service/cluster.rb b/test/falcon/service/cluster.rb index 45afacef..bf338f11 100644 --- a/test/falcon/service/cluster.rb +++ b/test/falcon/service/cluster.rb @@ -31,30 +31,11 @@ def make_binding(*addresses) expect(binding).to have_attributes( addresses: be == [ip_address, unix_address], - address: be == "127.0.0.1", - port: be == 9292, - path: be == "/tmp/falcon.sock", frozen?: be == true, ) expect(binding.addresses.frozen?).to be == true end - it "exposes an IP address and port without a Unix socket path" do - binding = make_binding(Addrinfo.tcp("127.0.0.1", 9292)) - - expect(binding.address).to be == "127.0.0.1" - expect(binding.port).to be == 9292 - expect(binding.path).to be == nil - end - - it "exposes a Unix socket path without an IP address and port" do - binding = make_binding(Addrinfo.unix("/tmp/falcon.sock")) - - expect(binding.address).to be == nil - expect(binding.port).to be == nil - expect(binding.path).to be == "/tmp/falcon.sock" - end - let(:recorder) do path = ports_path @@ -71,7 +52,9 @@ def container_options super(instance, binding) File.open(path, "a") do |file| - file.puts(binding.port) + binding.addresses.each do |address| + file.puts(address.ip_port) if address.ip? + end end end end From 93c7e579815985a8b21d7fd1bea4fa657893b363 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 22 Jul 2026 18:56:00 +1200 Subject: [PATCH 07/23] Add cluster Unix socket example Assisted-By: devx/56c5beec-347d-4712-9cc7-ea033e4d2a8a --- examples/cluster/client.rb | 35 +++++++++++++++++++++++++++++++++++ examples/cluster/falcon.rb | 36 ++++++++++++++++++++++++++++++++++++ examples/cluster/readme.md | 30 ++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+) create mode 100644 examples/cluster/client.rb create mode 100644 examples/cluster/falcon.rb create mode 100644 examples/cluster/readme.md diff --git a/examples/cluster/client.rb b/examples/cluster/client.rb new file mode 100644 index 00000000..e436e93f --- /dev/null +++ b/examples/cluster/client.rb @@ -0,0 +1,35 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "async/http/client" +require "async/http/endpoint" +require "io/endpoint/unix_endpoint" + +socket_directory = File.expand_path(ENV.fetch("SOCKET_DIRECTORY", "sockets"), __dir__) +socket_paths = Dir.glob(File.join(socket_directory, "*.ipc")).select{|path| File.socket?(path)} + +abort "No cluster sockets found in #{socket_directory}." if socket_paths.empty? + +Sync do + socket_paths.each do |socket_path| + transport = IO::Endpoint.unix(socket_path) + endpoint = Async::HTTP::Endpoint.parse( + "http://localhost", + transport, + protocol: Async::HTTP::Protocol::HTTP1 + ) + + Async::HTTP::Client.open(endpoint) do |client| + response = client.get("/") + + begin + puts "#{File.basename(socket_path)}: #{response.read}" + ensure + response.finish + end + end + end +end diff --git a/examples/cluster/falcon.rb b/examples/cluster/falcon.rb new file mode 100644 index 00000000..726607d7 --- /dev/null +++ b/examples/cluster/falcon.rb @@ -0,0 +1,36 @@ +#!/usr/bin/env async-service +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "fileutils" +require "io/endpoint/unix_endpoint" +require "protocol/http/middleware" + +require "falcon/environment/cluster" + +socket_directory = File.expand_path(ENV.fetch("SOCKET_DIRECTORY", "sockets"), __dir__) +FileUtils.mkdir_p(socket_directory) + +service "cluster" do + include Falcon::Environment::Cluster + + count 2 + + endpoint do + worker_id = "#{Process.pid}-#{Thread.current.object_id}" + socket_path = File.join(socket_directory, "#{worker_id}.ipc") + transport = IO::Endpoint.unix(socket_path) + + Async::HTTP::Endpoint.parse( + "http://localhost", + transport, + protocol: Async::HTTP::Protocol::HTTP1 + ) + end + + middleware do + Protocol::HTTP::Middleware::HelloWorld + end +end diff --git a/examples/cluster/readme.md b/examples/cluster/readme.md new file mode 100644 index 00000000..21be9dad --- /dev/null +++ b/examples/cluster/readme.md @@ -0,0 +1,30 @@ +# Cluster Unix Sockets + +This example shows how to run Falcon cluster workers on independently bound Unix domain sockets. This is useful when a local proxy or service supervisor discovers workers from a socket directory instead of assigning TCP ports. + +Each worker lazily constructs an `IO::Endpoint.unix` endpoint using its process ID and current thread object ID. Consequently, process, threaded, and hybrid workers never compete for the same socket path, and the socket directory reflects every independently bound worker. + +## Usage + +Start the two-worker cluster: + +```shell +$ bundle exec async-service ./falcon.rb +``` + +In another terminal, run the client: + +```shell +$ bundle exec ruby ./client.rb +37901-640.ipc: Hello World! +37902-640.ipc: Hello World! +``` + +Both commands use `./sockets` by default. Set `SOCKET_DIRECTORY` on both commands to use a different directory: + +```shell +$ SOCKET_DIRECTORY=/tmp/falcon-cluster bundle exec async-service ./falcon.rb +$ SOCKET_DIRECTORY=/tmp/falcon-cluster bundle exec ruby ./client.rb +``` + +Unix socket files can remain after a worker exits. Production service discovery should remove stale registrations when it observes the worker exit. From 921b033a606b13f5974e6378bd8c4360b171eaf8 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Thu, 23 Jul 2026 21:17:39 +1200 Subject: [PATCH 08/23] Describe cluster worker listeners Assisted-By: devx/56c5beec-347d-4712-9cc7-ea033e4d2a8a --- examples/cluster/readme.md | 2 ++ lib/falcon/environment/cluster.rb | 4 ++-- lib/falcon/service/cluster.rb | 37 +++++++++++++++++++++++------- test/falcon/environment/cluster.rb | 4 ++-- test/falcon/service/cluster.rb | 21 +++++++++-------- 5 files changed, 47 insertions(+), 21 deletions(-) diff --git a/examples/cluster/readme.md b/examples/cluster/readme.md index 21be9dad..9b6b9a3a 100644 --- a/examples/cluster/readme.md +++ b/examples/cluster/readme.md @@ -4,6 +4,8 @@ This example shows how to run Falcon cluster workers on independently bound Unix Each worker lazily constructs an `IO::Endpoint.unix` endpoint using its process ID and current thread object ID. Consequently, process, threaded, and hybrid workers never compete for the same socket path, and the socket directory reflects every independently bound worker. +After binding, Falcon describes each worker using a `Falcon::Service::Cluster::Listener`. The listener exposes its logical name, scheme, protocol, bound endpoint, and all concrete socket addresses. Service discovery integrations can use `prepare_worker!(instance, listener:)` to register exactly what the worker bound. + ## Usage Start the two-worker cluster: diff --git a/lib/falcon/environment/cluster.rb b/lib/falcon/environment/cluster.rb index 45dd4af6..80fd802b 100644 --- a/lib/falcon/environment/cluster.rb +++ b/lib/falcon/environment/cluster.rb @@ -26,8 +26,8 @@ def url # Prepare a cluster worker after its endpoint has been bound. # # @parameter instance [Object] The container instance. - # @parameter binding [Service::Cluster::Binding] The worker's bound endpoint and addresses. - def prepare_worker!(instance, binding) + # @parameter listener [Service::Cluster::Listener] The worker's bound listener. + def prepare_worker!(instance, listener:) prepare!(instance) end end diff --git a/lib/falcon/service/cluster.rb b/lib/falcon/service/cluster.rb index 964122ec..ff02f8fc 100644 --- a/lib/falcon/service/cluster.rb +++ b/lib/falcon/service/cluster.rb @@ -10,16 +10,31 @@ module Falcon module Service # A managed service for running Falcon workers with independently bound endpoints. class Cluster < Server - # An immutable association between a worker and its bound endpoint. - class Binding - # Initialize a worker binding. + # Describes a bound listener for a cluster worker. + class Listener + # Initialize a bound listener. + # @parameter name [String] The logical listener name. + # @parameter scheme [String] The application protocol scheme. + # @parameter protocol [Object] The application protocol implementation. # @parameter endpoint [IO::Endpoint::BoundEndpoint] The endpoint bound by the worker. - def initialize(endpoint:) + def initialize(name:, scheme:, protocol:, endpoint:) + @name = name + @scheme = scheme + @protocol = protocol @endpoint = endpoint @addresses = endpoint.sockets.map{|socket| socket.to_io.local_address}.freeze freeze end + # @attribute [String] The logical listener name. + attr_reader :name + + # @attribute [String] The application protocol scheme. + attr_reader :scheme + + # @attribute [Object] The application protocol implementation. + attr_reader :protocol + # @attribute [IO::Endpoint::BoundEndpoint] The endpoint bound by the worker. attr_reader :endpoint @@ -54,14 +69,20 @@ def setup(container) instance.status!("Preparing...") - bound_endpoint = evaluator.endpoint.bound - binding = Binding.new(endpoint: bound_endpoint) + endpoint = evaluator.endpoint + bound_endpoint = endpoint.bound + listener = Listener.new( + name: evaluator.name, + scheme: endpoint.scheme, + protocol: endpoint.protocol, + endpoint: bound_endpoint, + ) - evaluator.prepare_worker!(instance, binding) + evaluator.prepare_worker!(instance, listener: listener) emit_prepared(instance, clock) instance.status!("Running...") - server = run(instance, evaluator, binding.endpoint) + server = run(instance, evaluator, listener.endpoint) instance.name = format_title(evaluator, server) emit_running(instance, clock) diff --git a/test/falcon/environment/cluster.rb b/test/falcon/environment/cluster.rb index ab3b3cda..386e0bf0 100644 --- a/test/falcon/environment/cluster.rb +++ b/test/falcon/environment/cluster.rb @@ -21,10 +21,10 @@ it "prepares workers using the existing preparation hook" do instance = Object.new - binding = Object.new + listener = Object.new expect(evaluator).to receive(:prepare!).with(instance) - evaluator.prepare_worker!(instance, binding) + evaluator.prepare_worker!(instance, listener: listener) end end diff --git a/test/falcon/service/cluster.rb b/test/falcon/service/cluster.rb index bf338f11..d717e76a 100644 --- a/test/falcon/service/cluster.rb +++ b/test/falcon/service/cluster.rb @@ -14,26 +14,29 @@ describe Falcon::Service::Cluster do let(:ports_path) {File.expand_path(".cluster/ports.txt", __dir__)} - def make_binding(*addresses) + def make_listener(*addresses, name: "hello", scheme: "http", protocol: Async::HTTP::Protocol::HTTP1) sockets = addresses.map do |address| io = Struct.new(:local_address).new(address) Struct.new(:to_io).new(io) end endpoint = Struct.new(:sockets).new(sockets) - subject::Binding.new(endpoint: endpoint) + subject::Listener.new(name: name, scheme: scheme, protocol: protocol, endpoint: endpoint) end - it "captures all addresses from the bound endpoint" do + it "describes the bound listener" do ip_address = Addrinfo.tcp("127.0.0.1", 9292) unix_address = Addrinfo.unix("/tmp/falcon.sock") - binding = make_binding(ip_address, unix_address) + listener = make_listener(ip_address, unix_address) - expect(binding).to have_attributes( + expect(listener).to have_attributes( + name: be == "hello", + scheme: be == "http", + protocol: be == Async::HTTP::Protocol::HTTP1, addresses: be == [ip_address, unix_address], frozen?: be == true, ) - expect(binding.addresses.frozen?).to be == true + expect(listener.addresses.frozen?).to be == true end let(:recorder) do @@ -48,11 +51,11 @@ def container_options super.merge(restart: false) end - define_method(:prepare_worker!) do |instance, binding| - super(instance, binding) + define_method(:prepare_worker!) do |instance, listener:| + super(instance, listener: listener) File.open(path, "a") do |file| - binding.addresses.each do |address| + listener.addresses.each do |address| file.puts(address.ip_port) if address.ip? end end From 59526858283480a95d384aacf2d4e963fae0443f Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 24 Jul 2026 12:19:37 +1200 Subject: [PATCH 09/23] Expose cluster listener protocol names. --- examples/cluster/readme.md | 2 +- falcon.gemspec | 2 +- gems.rb | 2 +- lib/falcon/service/cluster.rb | 12 ++++++------ test/falcon/service/cluster.rb | 7 ++++--- 5 files changed, 13 insertions(+), 12 deletions(-) diff --git a/examples/cluster/readme.md b/examples/cluster/readme.md index 9b6b9a3a..1049dffb 100644 --- a/examples/cluster/readme.md +++ b/examples/cluster/readme.md @@ -4,7 +4,7 @@ This example shows how to run Falcon cluster workers on independently bound Unix Each worker lazily constructs an `IO::Endpoint.unix` endpoint using its process ID and current thread object ID. Consequently, process, threaded, and hybrid workers never compete for the same socket path, and the socket directory reflects every independently bound worker. -After binding, Falcon describes each worker using a `Falcon::Service::Cluster::Listener`. The listener exposes its logical name, scheme, protocol, bound endpoint, and all concrete socket addresses. Service discovery integrations can use `prepare_worker!(instance, listener:)` to register exactly what the worker bound. +After binding, Falcon describes each worker using a `Falcon::Service::Cluster::Listener`. The listener exposes its logical name, scheme, supported protocol names, bound endpoint, and all concrete socket addresses. Service discovery integrations can use `prepare_worker!(instance, listener:)` to register exactly what the worker bound. ## Usage diff --git a/falcon.gemspec b/falcon.gemspec index a19cce02..6713f3b9 100644 --- a/falcon.gemspec +++ b/falcon.gemspec @@ -28,7 +28,7 @@ Gem::Specification.new do |spec| spec.add_dependency "async" spec.add_dependency "async-container", "~> 0.20" - spec.add_dependency "async-http", "~> 0.75" + spec.add_dependency "async-http", "~> 0.97" spec.add_dependency "async-http-cache", "~> 0.4" spec.add_dependency "async-service", "~> 0.19" spec.add_dependency "async-utilization", "~> 0.3" diff --git a/gems.rb b/gems.rb index f5faafbe..0069b7e0 100644 --- a/gems.rb +++ b/gems.rb @@ -25,7 +25,7 @@ # gem "fiber-profiler" -gem "async-service-supervisor" +gem "async-service-supervisor", "~> 0.18" group :maintenance, optional: true do gem "bake-modernize" diff --git a/lib/falcon/service/cluster.rb b/lib/falcon/service/cluster.rb index ff02f8fc..c5f04b0e 100644 --- a/lib/falcon/service/cluster.rb +++ b/lib/falcon/service/cluster.rb @@ -15,12 +15,12 @@ class Listener # Initialize a bound listener. # @parameter name [String] The logical listener name. # @parameter scheme [String] The application protocol scheme. - # @parameter protocol [Object] The application protocol implementation. + # @parameter protocols [Array(String)] The supported application protocol names. # @parameter endpoint [IO::Endpoint::BoundEndpoint] The endpoint bound by the worker. - def initialize(name:, scheme:, protocol:, endpoint:) + def initialize(name:, scheme:, protocols:, endpoint:) @name = name @scheme = scheme - @protocol = protocol + @protocols = protocols.map(&:to_s).freeze @endpoint = endpoint @addresses = endpoint.sockets.map{|socket| socket.to_io.local_address}.freeze freeze @@ -32,8 +32,8 @@ def initialize(name:, scheme:, protocol:, endpoint:) # @attribute [String] The application protocol scheme. attr_reader :scheme - # @attribute [Object] The application protocol implementation. - attr_reader :protocol + # @attribute [Array(String)] The supported application protocol names. + attr_reader :protocols # @attribute [IO::Endpoint::BoundEndpoint] The endpoint bound by the worker. attr_reader :endpoint @@ -74,7 +74,7 @@ def setup(container) listener = Listener.new( name: evaluator.name, scheme: endpoint.scheme, - protocol: endpoint.protocol, + protocols: endpoint.protocol.names, endpoint: bound_endpoint, ) diff --git a/test/falcon/service/cluster.rb b/test/falcon/service/cluster.rb index d717e76a..841b5081 100644 --- a/test/falcon/service/cluster.rb +++ b/test/falcon/service/cluster.rb @@ -14,14 +14,14 @@ describe Falcon::Service::Cluster do let(:ports_path) {File.expand_path(".cluster/ports.txt", __dir__)} - def make_listener(*addresses, name: "hello", scheme: "http", protocol: Async::HTTP::Protocol::HTTP1) + def make_listener(*addresses, name: "hello", scheme: "http", protocols: ["http/1.1", "http/1.0"]) sockets = addresses.map do |address| io = Struct.new(:local_address).new(address) Struct.new(:to_io).new(io) end endpoint = Struct.new(:sockets).new(sockets) - subject::Listener.new(name: name, scheme: scheme, protocol: protocol, endpoint: endpoint) + subject::Listener.new(name: name, scheme: scheme, protocols: protocols, endpoint: endpoint) end it "describes the bound listener" do @@ -32,11 +32,12 @@ def make_listener(*addresses, name: "hello", scheme: "http", protocol: Async::HT expect(listener).to have_attributes( name: be == "hello", scheme: be == "http", - protocol: be == Async::HTTP::Protocol::HTTP1, + protocols: be == ["http/1.1", "http/1.0"], addresses: be == [ip_address, unix_address], frozen?: be == true, ) expect(listener.addresses.frozen?).to be == true + expect(listener.protocols.frozen?).to be == true end let(:recorder) do From 44bd5101bf3a08aaa9b32b6f9c13896ae2c071b8 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 24 Jul 2026 14:25:46 +1200 Subject: [PATCH 10/23] Move cluster change to unreleased section Assisted-By: devx/56c5beec-347d-4712-9cc7-ea033e4d2a8a --- releases.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/releases.md b/releases.md index 50a928f9..6608b913 100644 --- a/releases.md +++ b/releases.md @@ -1,8 +1,11 @@ # Releases -## v0.55.6 +## Unreleased - Add `Falcon::Environment::Cluster` and `Falcon::Service::Cluster` for running workers with independently bound endpoints. + +## v0.55.6 + - Move Falcon middleware trace providers to `traces/provider/falcon/middleware`. - Add `traces` to the test dependencies for Falcon middleware trace provider tests. From 269b1c348634863df481b0cb4e21dcdf30f36272 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 24 Jul 2026 14:28:29 +1200 Subject: [PATCH 11/23] Use ephemeral TCP ports in cluster example Assisted-By: devx/56c5beec-347d-4712-9cc7-ea033e4d2a8a --- examples/cluster/client.rb | 18 ++++++------------ examples/cluster/falcon.rb | 32 ++++++++++++++++++-------------- examples/cluster/readme.md | 22 ++++++++++------------ 3 files changed, 34 insertions(+), 38 deletions(-) diff --git a/examples/cluster/client.rb b/examples/cluster/client.rb index e436e93f..fde1eb71 100644 --- a/examples/cluster/client.rb +++ b/examples/cluster/client.rb @@ -6,27 +6,21 @@ require "async/http/client" require "async/http/endpoint" -require "io/endpoint/unix_endpoint" -socket_directory = File.expand_path(ENV.fetch("SOCKET_DIRECTORY", "sockets"), __dir__) -socket_paths = Dir.glob(File.join(socket_directory, "*.ipc")).select{|path| File.socket?(path)} +addresses_path = File.expand_path(ENV.fetch("ADDRESSES_PATH", "addresses.txt"), __dir__) +addresses = File.readlines(addresses_path, chomp: true) -abort "No cluster sockets found in #{socket_directory}." if socket_paths.empty? +abort "No cluster addresses found in #{addresses_path}." if addresses.empty? Sync do - socket_paths.each do |socket_path| - transport = IO::Endpoint.unix(socket_path) - endpoint = Async::HTTP::Endpoint.parse( - "http://localhost", - transport, - protocol: Async::HTTP::Protocol::HTTP1 - ) + addresses.each do |address| + endpoint = Async::HTTP::Endpoint.parse("http://#{address}") Async::HTTP::Client.open(endpoint) do |client| response = client.get("/") begin - puts "#{File.basename(socket_path)}: #{response.read}" + puts "#{address}: #{response.read}" ensure response.finish end diff --git a/examples/cluster/falcon.rb b/examples/cluster/falcon.rb index 726607d7..50057c92 100644 --- a/examples/cluster/falcon.rb +++ b/examples/cluster/falcon.rb @@ -4,30 +4,34 @@ # Released under the MIT License. # Copyright, 2026, by Samuel Williams. -require "fileutils" -require "io/endpoint/unix_endpoint" require "protocol/http/middleware" require "falcon/environment/cluster" -socket_directory = File.expand_path(ENV.fetch("SOCKET_DIRECTORY", "sockets"), __dir__) -FileUtils.mkdir_p(socket_directory) +addresses_path = File.expand_path(ENV.fetch("ADDRESSES_PATH", "addresses.txt"), __dir__) +File.write(addresses_path, "") + +record_addresses = Module.new do + define_method(:prepare_worker!) do |instance, listener:| + super(instance, listener: listener) + + File.open(addresses_path, "a") do |file| + file.flock(File::LOCK_EX) + listener.addresses.each do |address| + file.puts(address.inspect_sockaddr) if address.ip? + end + end + end +end service "cluster" do include Falcon::Environment::Cluster + include record_addresses count 2 - endpoint do - worker_id = "#{Process.pid}-#{Thread.current.object_id}" - socket_path = File.join(socket_directory, "#{worker_id}.ipc") - transport = IO::Endpoint.unix(socket_path) - - Async::HTTP::Endpoint.parse( - "http://localhost", - transport, - protocol: Async::HTTP::Protocol::HTTP1 - ) + def url + "http://localhost:0" end middleware do diff --git a/examples/cluster/readme.md b/examples/cluster/readme.md index 1049dffb..a375b7db 100644 --- a/examples/cluster/readme.md +++ b/examples/cluster/readme.md @@ -1,10 +1,8 @@ -# Cluster Unix Sockets +# Cluster TCP Endpoints -This example shows how to run Falcon cluster workers on independently bound Unix domain sockets. This is useful when a local proxy or service supervisor discovers workers from a socket directory instead of assigning TCP ports. +This example shows how to run Falcon cluster workers on independently bound TCP endpoints. Each worker binds to `localhost` with port `0`, allowing the operating system to assign an available port. -Each worker lazily constructs an `IO::Endpoint.unix` endpoint using its process ID and current thread object ID. Consequently, process, threaded, and hybrid workers never compete for the same socket path, and the socket directory reflects every independently bound worker. - -After binding, Falcon describes each worker using a `Falcon::Service::Cluster::Listener`. The listener exposes its logical name, scheme, supported protocol names, bound endpoint, and all concrete socket addresses. Service discovery integrations can use `prepare_worker!(instance, listener:)` to register exactly what the worker bound. +After binding, Falcon describes each worker using a `Falcon::Service::Cluster::Listener`. The listener exposes its logical name, scheme, supported protocol names, bound endpoint, and all concrete socket addresses. This example records those addresses in `addresses.txt`; service discovery integrations can instead use `prepare_worker!(instance, listener:)` to register them directly. ## Usage @@ -18,15 +16,15 @@ In another terminal, run the client: ```shell $ bundle exec ruby ./client.rb -37901-640.ipc: Hello World! -37902-640.ipc: Hello World! +[::]:53142: Hello World! +[::]:53143: Hello World! ``` -Both commands use `./sockets` by default. Set `SOCKET_DIRECTORY` on both commands to use a different directory: +The exact address family and ports are platform-dependent. + +Both commands use `./addresses.txt` by default. Set `ADDRESSES_PATH` on both commands to use a different file: ```shell -$ SOCKET_DIRECTORY=/tmp/falcon-cluster bundle exec async-service ./falcon.rb -$ SOCKET_DIRECTORY=/tmp/falcon-cluster bundle exec ruby ./client.rb +$ ADDRESSES_PATH=/tmp/falcon-cluster-addresses bundle exec async-service ./falcon.rb +$ ADDRESSES_PATH=/tmp/falcon-cluster-addresses bundle exec ruby ./client.rb ``` - -Unix socket files can remain after a worker exits. Production service discovery should remove stale registrations when it observes the worker exit. From e5ba8929461d5f38cd35c614e2f3ab5b0a4b81c2 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sat, 25 Jul 2026 11:01:00 +1200 Subject: [PATCH 12/23] Run cluster example behind Envoy Assisted-By: devx/56c5beec-347d-4712-9cc7-ea033e4d2a8a --- .dockerignore | 8 +++++ examples/cluster/Dockerfile | 12 +++++++ examples/cluster/client.rb | 36 ++++++++++--------- examples/cluster/compose.yaml | 32 +++++++++++++++++ examples/cluster/envoy.yaml | 68 +++++++++++++++++++++++++++++++++++ examples/cluster/falcon.rb | 51 +++++++++++++++----------- examples/cluster/gems.rb | 9 +++++ examples/cluster/readme.md | 25 +++++++------ 8 files changed, 191 insertions(+), 50 deletions(-) create mode 100644 .dockerignore create mode 100644 examples/cluster/Dockerfile create mode 100644 examples/cluster/compose.yaml create mode 100644 examples/cluster/envoy.yaml create mode 100644 examples/cluster/gems.rb diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..13049108 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +.context +.bundle +.covered.db +gems.locked +pkg +external +**/*.ipc diff --git a/examples/cluster/Dockerfile b/examples/cluster/Dockerfile new file mode 100644 index 00000000..4ea4e092 --- /dev/null +++ b/examples/cluster/Dockerfile @@ -0,0 +1,12 @@ +ARG RUBY_VERSION=4.0 +FROM ruby:${RUBY_VERSION} + +WORKDIR /code + +ENV BUNDLE_GEMFILE=/code/examples/cluster/gems.rb + +COPY . . + +RUN bundle install + +CMD ["bundle", "exec", "async-service", "examples/cluster/falcon.rb"] diff --git a/examples/cluster/client.rb b/examples/cluster/client.rb index fde1eb71..4fbe81e8 100644 --- a/examples/cluster/client.rb +++ b/examples/cluster/client.rb @@ -4,26 +4,28 @@ # Released under the MIT License. # Copyright, 2026, by Samuel Williams. -require "async/http/client" -require "async/http/endpoint" +require "net/http" +require "uri" -addresses_path = File.expand_path(ENV.fetch("ADDRESSES_PATH", "addresses.txt"), __dir__) -addresses = File.readlines(addresses_path, chomp: true) +uri = URI(ENV.fetch("ENVOY_URI", "http://127.0.0.1:10000")) +deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 20 +workers = {} -abort "No cluster addresses found in #{addresses_path}." if addresses.empty? - -Sync do - addresses.each do |address| - endpoint = Async::HTTP::Endpoint.parse("http://#{address}") +until workers.size == 2 || Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline + begin + response = Net::HTTP.get_response(uri) - Async::HTTP::Client.open(endpoint) do |client| - response = client.get("/") - - begin - puts "#{address}: #{response.read}" - ensure - response.finish - end + if response.is_a?(Net::HTTPSuccess) + worker_id = response["x-worker-id"] + workers[worker_id] ||= response.body end + rescue Errno::ECONNREFUSED, EOFError + # Envoy may still be connecting to the xDS control plane. end + + sleep(0.1) unless workers.size == 2 end + +abort "Envoy did not route requests to both workers." unless workers.size == 2 + +workers.each_value{|body| puts(body)} diff --git a/examples/cluster/compose.yaml b/examples/cluster/compose.yaml new file mode 100644 index 00000000..1f200704 --- /dev/null +++ b/examples/cluster/compose.yaml @@ -0,0 +1,32 @@ +services: + falcon: + image: cluster-falcon + build: + context: ../.. + dockerfile: examples/cluster/Dockerfile + environment: + CONSOLE_OUTPUT: XTerm + ports: + - "10000:10000" + + envoy: + image: envoyproxy/envoy:v1.32-latest + command: ["envoy", "-c", "/etc/envoy/envoy.yaml", "--log-level", "info"] + network_mode: "service:falcon" + volumes: + - ./envoy.yaml:/etc/envoy/envoy.yaml:ro + depends_on: + falcon: + condition: service_started + + client: + image: cluster-falcon + command: ["bundle", "exec", "ruby", "examples/cluster/client.rb"] + environment: + ENVOY_URI: http://127.0.0.1:10000 + network_mode: "service:falcon" + depends_on: + envoy: + condition: service_started + profiles: + - client diff --git a/examples/cluster/envoy.yaml b/examples/cluster/envoy.yaml new file mode 100644 index 00000000..fbdd9bb5 --- /dev/null +++ b/examples/cluster/envoy.yaml @@ -0,0 +1,68 @@ +node: + id: falcon-cluster-example + cluster: falcon-cluster-example + +dynamic_resources: + ads_config: + api_type: GRPC + transport_api_version: V3 + grpc_services: + - envoy_grpc: + cluster_name: xds_cluster + +static_resources: + listeners: + - name: listener_http + address: + socket_address: + address: 0.0.0.0 + port_value: 10000 + filter_chains: + - filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: ingress_http + route_config: + name: local_route + virtual_hosts: + - name: falcon + domains: ["*"] + routes: + - match: + prefix: "/" + route: + cluster: cluster + http_filters: + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + + clusters: + - name: cluster + connect_timeout: 1s + type: EDS + lb_policy: ROUND_ROBIN + eds_cluster_config: + service_name: cluster + eds_config: + ads: {} + resource_api_version: V3 + + - name: xds_cluster + connect_timeout: 1s + type: STATIC + load_assignment: + cluster_name: xds_cluster + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: 18000 + typed_extension_protocol_options: + envoy.extensions.upstreams.http.v3.HttpProtocolOptions: + "@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions + explicit_http_config: + http2_protocol_options: {} diff --git a/examples/cluster/falcon.rb b/examples/cluster/falcon.rb index 50057c92..f309abf2 100644 --- a/examples/cluster/falcon.rb +++ b/examples/cluster/falcon.rb @@ -4,29 +4,13 @@ # Released under the MIT License. # Copyright, 2026, by Samuel Williams. -require "protocol/http/middleware" - +require "async/service/supervisor" +require "async/service/supervisor/envoy" require "falcon/environment/cluster" -addresses_path = File.expand_path(ENV.fetch("ADDRESSES_PATH", "addresses.txt"), __dir__) -File.write(addresses_path, "") - -record_addresses = Module.new do - define_method(:prepare_worker!) do |instance, listener:| - super(instance, listener: listener) - - File.open(addresses_path, "a") do |file| - file.flock(File::LOCK_EX) - listener.addresses.each do |address| - file.puts(address.inspect_sockaddr) if address.ip? - end - end - end -end - service "cluster" do include Falcon::Environment::Cluster - include record_addresses + include Async::Service::Supervisor::Envoy::Supervised count 2 @@ -35,6 +19,33 @@ def url end middleware do - Protocol::HTTP::Middleware::HelloWorld + rack_application = proc do |_env| + worker_id = Process.pid.to_s + body = "Hello from worker #{worker_id}!\n" + + [ + 200, + { + "content-type" => "text/plain", + "content-length" => body.bytesize.to_s, + "x-worker-id" => worker_id, + }, + [body], + ] + end + + Falcon::Server.middleware(rack_application, cache: false) + end +end + +service "supervisor" do + include Async::Service::Supervisor::Environment + + monitors do + [ + Async::Service::Supervisor::Envoy::Monitor.new( + bind: "http://127.0.0.1:18000", + ), + ] end end diff --git a/examples/cluster/gems.rb b/examples/cluster/gems.rb new file mode 100644 index 00000000..e2390aa1 --- /dev/null +++ b/examples/cluster/gems.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +source "https://rubygems.org" + +gem "falcon", path: "../.." +gem "async-service-supervisor-envoy", "~> 0.0.1" diff --git a/examples/cluster/readme.md b/examples/cluster/readme.md index a375b7db..689d7eb7 100644 --- a/examples/cluster/readme.md +++ b/examples/cluster/readme.md @@ -1,30 +1,29 @@ -# Cluster TCP Endpoints +# Cluster with Envoy -This example shows how to run Falcon cluster workers on independently bound TCP endpoints. Each worker binds to `localhost` with port `0`, allowing the operating system to assign an available port. +This example runs a two-worker Falcon cluster behind Envoy. Each worker binds to `localhost` with port `0`, allowing the operating system to assign an available port. Falcon publishes the concrete worker addresses to Envoy through the supervisor's xDS control plane. -After binding, Falcon describes each worker using a `Falcon::Service::Cluster::Listener`. The listener exposes its logical name, scheme, supported protocol names, bound endpoint, and all concrete socket addresses. This example records those addresses in `addresses.txt`; service discovery integrations can instead use `prepare_worker!(instance, listener:)` to register them directly. +Docker Compose runs Falcon and Envoy in the same network namespace. This allows the workers to remain bound to loopback addresses while Envoy connects to their dynamically assigned ports. Envoy exposes a fixed HTTP listener on port 10000 and distributes requests across the workers. ## Usage -Start the two-worker cluster: +Build and start Falcon and Envoy: ```shell -$ bundle exec async-service ./falcon.rb +$ docker compose up --build --detach ``` -In another terminal, run the client: +Run the client through Compose: ```shell -$ bundle exec ruby ./client.rb -[::]:53142: Hello World! -[::]:53143: Hello World! +$ docker compose run --rm client +Hello from worker 12! +Hello from worker 13! ``` -The exact address family and ports are platform-dependent. +The client waits for Envoy and confirms that requests reach both workers. -Both commands use `./addresses.txt` by default. Set `ADDRESSES_PATH` on both commands to use a different file: +Stop and remove the containers: ```shell -$ ADDRESSES_PATH=/tmp/falcon-cluster-addresses bundle exec async-service ./falcon.rb -$ ADDRESSES_PATH=/tmp/falcon-cluster-addresses bundle exec ruby ./client.rb +$ docker compose down ``` From 4c9445bc85ced5557dbe19acc8354e512f925dcb Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 31 Jul 2026 15:24:49 +1200 Subject: [PATCH 13/23] Share listener lifecycle across Falcon services --- examples/cluster/readme.md | 36 +++++++++++++ lib/falcon.rb | 1 + lib/falcon/environment/cluster.rb | 7 --- lib/falcon/environment/server.rb | 8 +++ lib/falcon/listener.rb | 38 +++++++++++++ lib/falcon/service/cluster.rb | 88 ++++--------------------------- lib/falcon/service/server.rb | 75 +++++++++++++++++++++++--- readme.md | 9 ++-- releases.md | 1 + test/falcon/environment/server.rb | 9 ++++ test/falcon/listener.rb | 34 ++++++++++++ test/falcon/service/cluster.rb | 26 --------- test/falcon/service/server.rb | 19 ++++++- 13 files changed, 228 insertions(+), 123 deletions(-) create mode 100644 lib/falcon/listener.rb create mode 100644 test/falcon/listener.rb diff --git a/examples/cluster/readme.md b/examples/cluster/readme.md index 689d7eb7..769c4053 100644 --- a/examples/cluster/readme.md +++ b/examples/cluster/readme.md @@ -4,6 +4,42 @@ This example runs a two-worker Falcon cluster behind Envoy. Each worker binds to Docker Compose runs Falcon and Envoy in the same network namespace. This allows the workers to remain bound to loopback addresses while Envoy connects to their dynamically assigned ports. Envoy exposes a fixed HTTP listener on port 10000 and distributes requests across the workers. +## How It Works + +The `falcon` container runs both the Falcon cluster and its supervisor. When each worker starts: + +1. Falcon binds the worker to an available loopback port. +2. The worker registers its concrete address and supported protocols with the supervisor. +3. The supervisor's Envoy monitor publishes the current worker addresses as an Endpoint Discovery Service (EDS) resource. +4. Envoy receives the resource over its Aggregated Discovery Service (ADS) connection and uses those addresses for the `cluster` upstream. + +Requests arrive at Envoy's listener on port 10000. Envoy selects one of the discovered worker endpoints and forwards the request to it: + +```mermaid +flowchart LR + Client[Client] -->|HTTP on port 10000| Envoy + + subgraph Network[Shared network namespace] + Envoy[Envoy] + + subgraph Falcon[Falcon container] + Supervisor[Supervisor and xDS control plane] + Worker1[Falcon worker 1] + Worker2[Falcon worker 2] + end + + Worker1 -.->|Register endpoint| Supervisor + Worker2 -.->|Register endpoint| Supervisor + Supervisor -.->|EDS over ADS on port 18000| Envoy + Envoy -->|HTTP on dynamic port| Worker1 + Envoy -->|HTTP on dynamic port| Worker2 + end +``` + +The `envoy` service uses `network_mode: service:falcon`, so it shares the Falcon container's network namespace. Consequently, `127.0.0.1` and `localhost` refer to the same loopback interface for both processes. Without the shared namespace, Envoy could not connect to the workers' loopback addresses. + +If a worker exits, its supervisor connection closes and the monitor publishes an EDS update without that endpoint. Falcon restarts the worker, which binds a new available port and registers it; the monitor then publishes another update. Envoy receives both changes over its existing ADS stream, without polling or restarting. + ## Usage Build and start Falcon and Envoy: diff --git a/lib/falcon.rb b/lib/falcon.rb index 44ca3579..0b7b9e16 100644 --- a/lib/falcon.rb +++ b/lib/falcon.rb @@ -4,6 +4,7 @@ # Copyright, 2017-2026, by Samuel Williams. require_relative "falcon/version" +require_relative "falcon/listener" require_relative "falcon/server" require_relative "falcon/composite_server" diff --git a/lib/falcon/environment/cluster.rb b/lib/falcon/environment/cluster.rb index 80fd802b..d49d9898 100644 --- a/lib/falcon/environment/cluster.rb +++ b/lib/falcon/environment/cluster.rb @@ -23,13 +23,6 @@ def url "http://[::]:0" end - # Prepare a cluster worker after its endpoint has been bound. - # - # @parameter instance [Object] The container instance. - # @parameter listener [Service::Cluster::Listener] The worker's bound listener. - def prepare_worker!(instance, listener:) - prepare!(instance) - end end end end diff --git a/lib/falcon/environment/server.rb b/lib/falcon/environment/server.rb index 628a8393..c9284183 100644 --- a/lib/falcon/environment/server.rb +++ b/lib/falcon/environment/server.rb @@ -67,6 +67,14 @@ def client_endpoint ::Async::HTTP::Endpoint.parse(url) end + # Prepare a server worker after its listener has been bound. + # + # @parameter instance [Object] The container instance. + # @parameter listener [Falcon::Listener] The worker's bound listener. + def prepare_worker!(instance, listener:) + prepare!(instance) + end + # Make a server instance using the given endpoint. The endpoint may be a bound endpoint, so we take care to specify the protocol and scheme as per the original endpoint. # # @parameter endpoint [IO::Endpoint] The endpoint to bind to. diff --git a/lib/falcon/listener.rb b/lib/falcon/listener.rb new file mode 100644 index 00000000..71cb02e8 --- /dev/null +++ b/lib/falcon/listener.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +module Falcon + # Describes a bound listener for a Falcon server. + class Listener + # Initialize a bound listener. + # @parameter name [String] The logical listener name. + # @parameter scheme [String] The application protocol scheme. + # @parameter protocols [Array(String)] The supported application protocol names. + # @parameter endpoint [IO::Endpoint::BoundEndpoint] The bound endpoint. + def initialize(name:, scheme:, protocols:, endpoint:) + @name = name + @scheme = scheme + @protocols = protocols.map(&:to_s).freeze + @endpoint = endpoint + @addresses = endpoint.sockets.map{|socket| socket.to_io.local_address}.freeze + freeze + end + + # @attribute [String] The logical listener name. + attr_reader :name + + # @attribute [String] The application protocol scheme. + attr_reader :scheme + + # @attribute [Array(String)] The supported application protocol names. + attr_reader :protocols + + # @attribute [IO::Endpoint::BoundEndpoint] The bound endpoint. + attr_reader :endpoint + + # @attribute [Array(Addrinfo)] The bound addresses. + attr_reader :addresses + end +end diff --git a/lib/falcon/service/cluster.rb b/lib/falcon/service/cluster.rb index c5f04b0e..52dc4318 100644 --- a/lib/falcon/service/cluster.rb +++ b/lib/falcon/service/cluster.rb @@ -10,88 +10,22 @@ module Falcon module Service # A managed service for running Falcon workers with independently bound endpoints. class Cluster < Server - # Describes a bound listener for a cluster worker. - class Listener - # Initialize a bound listener. - # @parameter name [String] The logical listener name. - # @parameter scheme [String] The application protocol scheme. - # @parameter protocols [Array(String)] The supported application protocol names. - # @parameter endpoint [IO::Endpoint::BoundEndpoint] The endpoint bound by the worker. - def initialize(name:, scheme:, protocols:, endpoint:) - @name = name - @scheme = scheme - @protocols = protocols.map(&:to_s).freeze - @endpoint = endpoint - @addresses = endpoint.sockets.map{|socket| socket.to_io.local_address}.freeze - freeze - end - - # @attribute [String] The logical listener name. - attr_reader :name - - # @attribute [String] The application protocol scheme. - attr_reader :scheme - - # @attribute [Array(String)] The supported application protocol names. - attr_reader :protocols - - # @attribute [IO::Endpoint::BoundEndpoint] The endpoint bound by the worker. - attr_reader :endpoint - - # @attribute [Array(Addrinfo)] The addresses bound by the worker. - attr_reader :addresses - end - # Cluster workers bind independently in their own process. def bind_endpoint end - # Setup the service into the specified container. - # @parameter container [Async::Container] The container to configure. - def setup(container) - container_options = @evaluator.container_options - health_check_timeout = container_options[:health_check_timeout] + # Bind and yield a listener owned by a cluster worker. + # @parameter evaluator [Environment::Evaluator] The environment evaluator. + # @yields {|listener| ...} The listener owned by the worker. + # @parameter listener [Falcon::Listener] The bound listener. + def with_listener(evaluator) + endpoint = evaluator.endpoint + bound_endpoint = endpoint.bound + listener = make_listener(evaluator, endpoint, bound_endpoint) - container.run(**container_options) do |instance| - clock = Async::Clock.start - bound_endpoint = nil - - begin - Async do - evaluator = self.environment.evaluator - server = nil - - health_checker(instance, health_check_timeout) do - if server - instance.name = format_title(evaluator, server) - end - end - - instance.status!("Preparing...") - - endpoint = evaluator.endpoint - bound_endpoint = endpoint.bound - listener = Listener.new( - name: evaluator.name, - scheme: endpoint.scheme, - protocols: endpoint.protocol.names, - endpoint: bound_endpoint, - ) - - evaluator.prepare_worker!(instance, listener: listener) - emit_prepared(instance, clock) - - instance.status!("Running...") - server = run(instance, evaluator, listener.endpoint) - instance.name = format_title(evaluator, server) - emit_running(instance, clock) - - instance.ready! - end - ensure - bound_endpoint&.close - end - end + yield listener + ensure + bound_endpoint&.close end end end diff --git a/lib/falcon/service/server.rb b/lib/falcon/service/server.rb index 69c76d69..79488329 100644 --- a/lib/falcon/service/server.rb +++ b/lib/falcon/service/server.rb @@ -7,6 +7,7 @@ require "async/service/managed/service" require "async/http/endpoint" +require_relative "../listener" require_relative "../server" module Falcon @@ -18,7 +19,21 @@ class Server < Async::Service::Managed::Service def initialize(...) super - @bound_endpoint = nil + @listener = nil + end + + # Build a listener from a configured and bound endpoint. + # @parameter evaluator [Environment::Evaluator] The environment evaluator. + # @parameter endpoint [Async::HTTP::Endpoint] The configured endpoint. + # @parameter bound_endpoint [IO::Endpoint::BoundEndpoint] The bound endpoint. + # @returns [Falcon::Listener] The bound listener. + def make_listener(evaluator, endpoint, bound_endpoint) + Listener.new( + name: evaluator.name, + scheme: endpoint.scheme, + protocols: endpoint.protocol.names, + endpoint: bound_endpoint, + ) end # Bind the endpoint used by each server worker. @@ -26,7 +41,8 @@ def bind_endpoint @endpoint = @evaluator.endpoint Sync do - @bound_endpoint = @endpoint.bound + bound_endpoint = @endpoint.bound + @listener = make_listener(@evaluator, @endpoint, bound_endpoint) end Console.info(self){"Starting #{self.name} on #{@endpoint}"} @@ -39,20 +55,63 @@ def start super end + # Yield the listener used by a server worker. + # @parameter evaluator [Environment::Evaluator] The environment evaluator. + # @yields {|listener| ...} The listener used by the worker. + # @parameter listener [Falcon::Listener] The bound listener. + def with_listener(evaluator) + yield @listener + end + + # Setup the service into the specified container. + # @parameter container [Async::Container] The container to configure. + def setup(container) + container_options = @evaluator.container_options + health_check_timeout = container_options[:health_check_timeout] + + container.run(**container_options) do |instance| + clock = Async::Clock.start + evaluator = self.environment.evaluator + + with_listener(evaluator) do |listener| + Async do + server = nil + + health_checker(instance, health_check_timeout) do + if server + instance.name = format_title(evaluator, server) + end + end + + instance.status!("Preparing...") + evaluator.prepare_worker!(instance, listener: listener) + emit_prepared(instance, clock) + + instance.status!("Running...") + server = run(instance, evaluator, listener) + instance.name = format_title(evaluator, server) + emit_running(instance, clock) + + instance.ready! + end + end + end + end + # Run the service logic. # # @parameter instance [Object] The container instance. # @parameter evaluator [Environment::Evaluator] The environment evaluator. - # @parameter bound_endpoint [IO::Endpoint] The endpoint bound by this worker. + # @parameter listener [Falcon::Listener] The listener used by this worker. # @returns [Falcon::Server] The server instance. - def run(instance, evaluator, bound_endpoint = @bound_endpoint) + def run(instance, evaluator, listener = @listener) if evaluator.respond_to?(:make_supervised_worker) Console.warn(self, "Async::Container::Supervisor is replaced by Async::Service::Supervisor, please update your service definition.") evaluator.make_supervised_worker(instance).run end - server = evaluator.make_server(bound_endpoint) + server = evaluator.make_server(listener.endpoint) Async do |task| server.run @@ -75,9 +134,9 @@ def run(instance, evaluator, bound_endpoint = @bound_endpoint) # Close the bound endpoint. def stop(...) - if @bound_endpoint - @bound_endpoint.close - @bound_endpoint = nil + if @listener + @listener.endpoint.close + @listener = nil end @endpoint = nil diff --git a/readme.md b/readme.md index 239d9353..ad0f4dec 100644 --- a/readme.md +++ b/readme.md @@ -47,6 +47,11 @@ Please see the [project documentation](https://socketry.github.io/falcon/) for m Please see the [project releases](https://socketry.github.io/falcon/releases/index) for all releases. +### Unreleased + + - Add `Falcon::Environment::Cluster` and `Falcon::Service::Cluster` for running workers with independently bound endpoints. + - Add `Falcon::Listener` to describe bound listeners shared by regular server workers or owned by cluster workers. + ### v0.55.6 - Move Falcon middleware trace providers to `traces/provider/falcon/middleware`. @@ -87,10 +92,6 @@ Please see the [project releases](https://socketry.github.io/falcon/releases/ind - Introduce `Falcon::CompositeServer` for hosting multiple server instances in a single worker. -### v0.52.4 - - - Relax dependency on `async-container-supervisor` to allow `~> 0.6`. - ## Contributing We welcome contributions to this project. diff --git a/releases.md b/releases.md index 6608b913..d8941252 100644 --- a/releases.md +++ b/releases.md @@ -3,6 +3,7 @@ ## Unreleased - Add `Falcon::Environment::Cluster` and `Falcon::Service::Cluster` for running workers with independently bound endpoints. + - Add `Falcon::Listener` to describe bound listeners shared by regular server workers or owned by cluster workers. ## v0.55.6 diff --git a/test/falcon/environment/server.rb b/test/falcon/environment/server.rb index 07d7b9b1..7078f884 100644 --- a/test/falcon/environment/server.rb +++ b/test/falcon/environment/server.rb @@ -21,4 +21,13 @@ ) expect(evaluator.client_endpoint).to be_a(Async::HTTP::Endpoint) end + + it "prepares workers using the existing preparation hook" do + instance = Object.new + listener = Object.new + + expect(evaluator).to receive(:prepare!).with(instance) + + evaluator.prepare_worker!(instance, listener: listener) + end end diff --git a/test/falcon/listener.rb b/test/falcon/listener.rb new file mode 100644 index 00000000..7d9f654d --- /dev/null +++ b/test/falcon/listener.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "falcon/listener" + +describe Falcon::Listener do + def make_listener(*addresses, name: "hello", scheme: "http", protocols: ["http/1.1", "http/1.0"]) + sockets = addresses.map do |address| + io = Struct.new(:local_address).new(address) + Struct.new(:to_io).new(io) + end + + endpoint = Struct.new(:sockets).new(sockets) + subject.new(name: name, scheme: scheme, protocols: protocols, endpoint: endpoint) + end + + it "describes a bound listener" do + ip_address = Addrinfo.tcp("127.0.0.1", 9292) + unix_address = Addrinfo.unix("/tmp/falcon.sock") + listener = make_listener(ip_address, unix_address) + + expect(listener).to have_attributes( + name: be == "hello", + scheme: be == "http", + protocols: be == ["http/1.1", "http/1.0"], + addresses: be == [ip_address, unix_address], + frozen?: be == true, + ) + expect(listener.addresses.frozen?).to be == true + expect(listener.protocols.frozen?).to be == true + end +end diff --git a/test/falcon/service/cluster.rb b/test/falcon/service/cluster.rb index 841b5081..9fbad385 100644 --- a/test/falcon/service/cluster.rb +++ b/test/falcon/service/cluster.rb @@ -14,32 +14,6 @@ describe Falcon::Service::Cluster do let(:ports_path) {File.expand_path(".cluster/ports.txt", __dir__)} - def make_listener(*addresses, name: "hello", scheme: "http", protocols: ["http/1.1", "http/1.0"]) - sockets = addresses.map do |address| - io = Struct.new(:local_address).new(address) - Struct.new(:to_io).new(io) - end - - endpoint = Struct.new(:sockets).new(sockets) - subject::Listener.new(name: name, scheme: scheme, protocols: protocols, endpoint: endpoint) - end - - it "describes the bound listener" do - ip_address = Addrinfo.tcp("127.0.0.1", 9292) - unix_address = Addrinfo.unix("/tmp/falcon.sock") - listener = make_listener(ip_address, unix_address) - - expect(listener).to have_attributes( - name: be == "hello", - scheme: be == "http", - protocols: be == ["http/1.1", "http/1.0"], - addresses: be == [ip_address, unix_address], - frozen?: be == true, - ) - expect(listener.addresses.frozen?).to be == true - expect(listener.protocols.frozen?).to be == true - end - let(:recorder) do path = ports_path diff --git a/test/falcon/service/server.rb b/test/falcon/service/server.rb index aac1f15f..18d72f25 100644 --- a/test/falcon/service/server.rb +++ b/test/falcon/service/server.rb @@ -28,6 +28,22 @@ expect(server).to be_a subject end + it "binds a listener shared by its workers" do + server.start + + server.with_listener(environment.evaluator) do |listener| + expect(listener).to be_a(Falcon::Listener) + expect(listener).to have_attributes( + name: be == "hello", + scheme: be == "http", + protocols: be(:include?, "http/1.1"), + ) + expect(listener.addresses.first.ip?).to be == true + end + ensure + server.stop + end + it "can start and stop server" do container = Async::Container.new @@ -114,6 +130,7 @@ def server.run it "propagates server IO errors" do evaluator = Object.new bound_endpoint = Object.new + listener = Struct.new(:endpoint).new(bound_endpoint) failing_server = Object.new condition = Async::Condition.new @@ -131,7 +148,7 @@ def server.run expect do Async do |task| - server.run(nil, evaluator, bound_endpoint) + server.run(nil, evaluator, listener) condition.signal task.children.each(&:wait) end.wait From 5389c1930df748af16041fda5d1b16b8f4f4381a Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 31 Jul 2026 17:23:28 +1200 Subject: [PATCH 14/23] Use positional listener argument --- lib/falcon/environment/server.rb | 2 +- lib/falcon/service/server.rb | 2 +- test/falcon/environment/cluster.rb | 2 +- test/falcon/environment/server.rb | 2 +- test/falcon/service/cluster.rb | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/falcon/environment/server.rb b/lib/falcon/environment/server.rb index c9284183..a4640bdf 100644 --- a/lib/falcon/environment/server.rb +++ b/lib/falcon/environment/server.rb @@ -71,7 +71,7 @@ def client_endpoint # # @parameter instance [Object] The container instance. # @parameter listener [Falcon::Listener] The worker's bound listener. - def prepare_worker!(instance, listener:) + def prepare_worker!(instance, listener) prepare!(instance) end diff --git a/lib/falcon/service/server.rb b/lib/falcon/service/server.rb index 79488329..6aa0fd96 100644 --- a/lib/falcon/service/server.rb +++ b/lib/falcon/service/server.rb @@ -84,7 +84,7 @@ def setup(container) end instance.status!("Preparing...") - evaluator.prepare_worker!(instance, listener: listener) + evaluator.prepare_worker!(instance, listener) emit_prepared(instance, clock) instance.status!("Running...") diff --git a/test/falcon/environment/cluster.rb b/test/falcon/environment/cluster.rb index 386e0bf0..95afe789 100644 --- a/test/falcon/environment/cluster.rb +++ b/test/falcon/environment/cluster.rb @@ -25,6 +25,6 @@ expect(evaluator).to receive(:prepare!).with(instance) - evaluator.prepare_worker!(instance, listener: listener) + evaluator.prepare_worker!(instance, listener) end end diff --git a/test/falcon/environment/server.rb b/test/falcon/environment/server.rb index 7078f884..fb83fb4d 100644 --- a/test/falcon/environment/server.rb +++ b/test/falcon/environment/server.rb @@ -28,6 +28,6 @@ expect(evaluator).to receive(:prepare!).with(instance) - evaluator.prepare_worker!(instance, listener: listener) + evaluator.prepare_worker!(instance, listener) end end diff --git a/test/falcon/service/cluster.rb b/test/falcon/service/cluster.rb index 9fbad385..6fd5d482 100644 --- a/test/falcon/service/cluster.rb +++ b/test/falcon/service/cluster.rb @@ -26,8 +26,8 @@ def container_options super.merge(restart: false) end - define_method(:prepare_worker!) do |instance, listener:| - super(instance, listener: listener) + define_method(:prepare_worker!) do |instance, listener| + super(instance, listener) File.open(path, "a") do |file| listener.addresses.each do |address| From a8b444f07382862301f915419050aeec2529c441 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 31 Jul 2026 17:24:45 +1200 Subject: [PATCH 15/23] Close listeners directly --- lib/falcon/listener.rb | 5 +++++ lib/falcon/service/server.rb | 2 +- test/falcon/listener.rb | 16 +++++++++++++++- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/lib/falcon/listener.rb b/lib/falcon/listener.rb index 71cb02e8..12dfdc8a 100644 --- a/lib/falcon/listener.rb +++ b/lib/falcon/listener.rb @@ -34,5 +34,10 @@ def initialize(name:, scheme:, protocols:, endpoint:) # @attribute [Array(Addrinfo)] The bound addresses. attr_reader :addresses + + # Close the bound endpoint. + def close + @endpoint.close + end end end diff --git a/lib/falcon/service/server.rb b/lib/falcon/service/server.rb index 6aa0fd96..f9179c51 100644 --- a/lib/falcon/service/server.rb +++ b/lib/falcon/service/server.rb @@ -135,7 +135,7 @@ def run(instance, evaluator, listener = @listener) # Close the bound endpoint. def stop(...) if @listener - @listener.endpoint.close + @listener.close @listener = nil end diff --git a/test/falcon/listener.rb b/test/falcon/listener.rb index 7d9f654d..13bfdf55 100644 --- a/test/falcon/listener.rb +++ b/test/falcon/listener.rb @@ -12,7 +12,13 @@ def make_listener(*addresses, name: "hello", scheme: "http", protocols: ["http/1 Struct.new(:to_io).new(io) end - endpoint = Struct.new(:sockets).new(sockets) + endpoint = Struct.new(:sockets) do + attr_reader :closed + + def close + @closed = true + end + end.new(sockets) subject.new(name: name, scheme: scheme, protocols: protocols, endpoint: endpoint) end @@ -31,4 +37,12 @@ def make_listener(*addresses, name: "hello", scheme: "http", protocols: ["http/1 expect(listener.addresses.frozen?).to be == true expect(listener.protocols.frozen?).to be == true end + + it "closes the bound endpoint" do + listener = make_listener(Addrinfo.tcp("127.0.0.1", 9292)) + + listener.close + + expect(listener.endpoint.closed).to be == true + end end From 6000688ab1a692986933932dd991c3479d655f03 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 31 Jul 2026 20:24:52 +1200 Subject: [PATCH 16/23] Use latest supervisor Envoy integration --- examples/cluster/gems.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/cluster/gems.rb b/examples/cluster/gems.rb index e2390aa1..bec3663b 100644 --- a/examples/cluster/gems.rb +++ b/examples/cluster/gems.rb @@ -6,4 +6,4 @@ source "https://rubygems.org" gem "falcon", path: "../.." -gem "async-service-supervisor-envoy", "~> 0.0.1" +gem "async-service-supervisor-envoy", "~> 0.2.0" From 8c99f6871043a0f8eef3386c67cf4a3d9aecd490 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 31 Jul 2026 21:07:08 +1200 Subject: [PATCH 17/23] Use Async HTTP cluster client --- examples/cluster/client.rb | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/examples/cluster/client.rb b/examples/cluster/client.rb index 4fbe81e8..08e89b8f 100644 --- a/examples/cluster/client.rb +++ b/examples/cluster/client.rb @@ -4,26 +4,27 @@ # Released under the MIT License. # Copyright, 2026, by Samuel Williams. -require "net/http" -require "uri" +require "async/http/internet/instance" -uri = URI(ENV.fetch("ENVOY_URI", "http://127.0.0.1:10000")) +url = ENV.fetch("ENVOY_URI", "http://127.0.0.1:10000") deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 20 workers = {} -until workers.size == 2 || Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline - begin - response = Net::HTTP.get_response(uri) - - if response.is_a?(Net::HTTPSuccess) - worker_id = response["x-worker-id"] - workers[worker_id] ||= response.body +Sync do + until workers.size == 2 || Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline + begin + Async::HTTP::Internet.get(url) do |response| + if response.success? + worker_id = response.headers["x-worker-id"] + workers[worker_id] ||= response.read + end + end + rescue Errno::ECONNREFUSED, EOFError + # Envoy may still be connecting to the xDS control plane. end - rescue Errno::ECONNREFUSED, EOFError - # Envoy may still be connecting to the xDS control plane. + + sleep(0.1) unless workers.size == 2 end - - sleep(0.1) unless workers.size == 2 end abort "Envoy did not route requests to both workers." unless workers.size == 2 From de2a2023f9528ccdb6ed8c6d4e8dcfd0d81d14b0 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 31 Jul 2026 21:11:49 +1200 Subject: [PATCH 18/23] Document dynamic Envoy clusters Assisted-By: devx/56c5beec-347d-4712-9cc7-ea033e4d2a8a --- examples/cluster/readme.md | 42 +++-------------- guides/cluster-deployment/readme.md | 70 +++++++++++++++++++++++++++++ guides/links.yaml | 8 ++-- 3 files changed, 80 insertions(+), 40 deletions(-) create mode 100644 guides/cluster-deployment/readme.md diff --git a/examples/cluster/readme.md b/examples/cluster/readme.md index 769c4053..def1761a 100644 --- a/examples/cluster/readme.md +++ b/examples/cluster/readme.md @@ -1,48 +1,16 @@ # Cluster with Envoy -This example runs a two-worker Falcon cluster behind Envoy. Each worker binds to `localhost` with port `0`, allowing the operating system to assign an available port. Falcon publishes the concrete worker addresses to Envoy through the supervisor's xDS control plane. +This example runs a two-worker Falcon cluster behind Envoy using Docker Compose. Falcon publishes each worker's dynamically bound endpoint to Envoy through the supervisor's xDS control plane. -Docker Compose runs Falcon and Envoy in the same network namespace. This allows the workers to remain bound to loopback addresses while Envoy connects to their dynamically assigned ports. Envoy exposes a fixed HTTP listener on port 10000 and distributes requests across the workers. +See the [Dynamic Clusters with Envoy](../../guides/cluster-deployment/readme.md) guide for the architecture, endpoint registration lifecycle, and deployment considerations. -## How It Works +## Requirements -The `falcon` container runs both the Falcon cluster and its supervisor. When each worker starts: - -1. Falcon binds the worker to an available loopback port. -2. The worker registers its concrete address and supported protocols with the supervisor. -3. The supervisor's Envoy monitor publishes the current worker addresses as an Endpoint Discovery Service (EDS) resource. -4. Envoy receives the resource over its Aggregated Discovery Service (ADS) connection and uses those addresses for the `cluster` upstream. - -Requests arrive at Envoy's listener on port 10000. Envoy selects one of the discovered worker endpoints and forwards the request to it: - -```mermaid -flowchart LR - Client[Client] -->|HTTP on port 10000| Envoy - - subgraph Network[Shared network namespace] - Envoy[Envoy] - - subgraph Falcon[Falcon container] - Supervisor[Supervisor and xDS control plane] - Worker1[Falcon worker 1] - Worker2[Falcon worker 2] - end - - Worker1 -.->|Register endpoint| Supervisor - Worker2 -.->|Register endpoint| Supervisor - Supervisor -.->|EDS over ADS on port 18000| Envoy - Envoy -->|HTTP on dynamic port| Worker1 - Envoy -->|HTTP on dynamic port| Worker2 - end -``` - -The `envoy` service uses `network_mode: service:falcon`, so it shares the Falcon container's network namespace. Consequently, `127.0.0.1` and `localhost` refer to the same loopback interface for both processes. Without the shared namespace, Envoy could not connect to the workers' loopback addresses. - -If a worker exits, its supervisor connection closes and the monitor publishes an EDS update without that endpoint. Falcon restarts the worker, which binds a new available port and registers it; the monitor then publishes another update. Envoy receives both changes over its existing ADS stream, without polling or restarting. +- Docker with Compose support. ## Usage -Build and start Falcon and Envoy: +From this directory, build and start Falcon and Envoy: ```shell $ docker compose up --build --detach diff --git a/guides/cluster-deployment/readme.md b/guides/cluster-deployment/readme.md new file mode 100644 index 00000000..d83b63c2 --- /dev/null +++ b/guides/cluster-deployment/readme.md @@ -0,0 +1,70 @@ +# Dynamic Clusters with Envoy + +This guide explains how to run Falcon workers with independently bound endpoints and publish them dynamically to Envoy using xDS. + +## When to Use a Cluster + +A regular {ruby Falcon::Service::Server} binds one listener and shares it with every worker. This is the simplest design when Falcon accepts connections directly or sits behind a load balancer that targets one stable address. + +{ruby Falcon::Service::Cluster} instead gives each worker its own listener. Use it when an external load balancer needs to address, monitor, and remove workers individually. Because workers may bind ephemeral ports or Unix-domain sockets, the load balancer needs a dynamic source of viable endpoints rather than a static address list. + +| Service | Listener ownership | Upstream discovery | +| --- | --- | --- | +| `Falcon::Service::Server` | One listener shared by all workers | One stable address | +| `Falcon::Service::Cluster` | One listener per worker | Dynamic worker endpoints | + +## Architecture + +In the example, each cluster worker binds to `localhost` with port `0`, allowing the operating system to assign an available port. Falcon describes the bound resource with a {ruby Falcon::Listener}, including its name, scheme, supported protocols, and concrete addresses. + +The worker registers that listener with `async-service-supervisor-envoy`. The supervisor publishes the current workers through an xDS control plane, and Envoy uses Endpoint Discovery Service (EDS) updates to maintain the upstream cluster. + +Requests arrive at Envoy's stable listener. Envoy selects one of the discovered worker endpoints and forwards the request to it: + +```mermaid +flowchart LR + Client[Client] -->|HTTP on port 10000| Envoy + + subgraph Network[Shared network namespace] + Envoy[Envoy] + + subgraph Falcon[Falcon container] + Supervisor[Supervisor and xDS control plane] + Worker1[Falcon worker 1] + Worker2[Falcon worker 2] + end + + Worker1 -.->|Register endpoint| Supervisor + Worker2 -.->|Register endpoint| Supervisor + Supervisor -.->|EDS over ADS on port 18000| Envoy + Envoy -->|HTTP on dynamic port| Worker1 + Envoy -->|HTTP on dynamic port| Worker2 + end +``` + +## Worker Registration + +When each worker starts: + +1. Falcon binds the worker to an available loopback port. +2. The worker registers its concrete addresses and supported protocols with the supervisor. +3. The supervisor's Envoy monitor publishes the current worker endpoints as an EDS resource. +4. Envoy receives the resource over its Aggregated Discovery Service (ADS) connection and updates its upstream cluster. + +The listener preserves all addresses returned by the bound endpoint. This allows the same interface to describe IP sockets, Unix-domain sockets, and endpoints with additional addresses. + +## Worker Restarts + +If a worker exits, its supervisor connection closes and the monitor publishes an EDS update without that endpoint. Falcon restarts the worker, which binds a new available port and registers it. The monitor then publishes another update, and Envoy receives both changes over its existing ADS stream without polling or restarting. + +This lifecycle is important when ports are ephemeral or a directory may contain stale Unix-domain socket paths: consumers should use the supervisor's current endpoint state as the source of truth. + +## Network Topology + +The [cluster example](https://github.com/socketry/falcon/tree/main/examples/cluster) runs Falcon and Envoy in the same network namespace. Its Envoy service uses `network_mode: service:falcon`, so `127.0.0.1` and `localhost` refer to the same loopback interface for both processes. + +Without a shared network namespace, Envoy cannot connect to worker endpoints bound to Falcon's loopback interface. In a different deployment topology, bind workers to an interface that Envoy can reach and apply the appropriate network access controls. + +## Complete Example + +See the [cluster example](https://github.com/socketry/falcon/tree/main/examples/cluster) for a Docker Compose configuration containing Falcon, its supervisor and xDS control plane, Envoy, and an Async HTTP client that confirms requests reach both workers. diff --git a/guides/links.yaml b/guides/links.yaml index e5beacd7..07d8ce32 100644 --- a/guides/links.yaml +++ b/guides/links.yaml @@ -4,11 +4,13 @@ rails-integration: order: 2 deployment: order: 3 -performance-tuning: +cluster-deployment: order: 4 -websockets: +performance-tuning: order: 5 -interim-responses: +websockets: order: 6 +interim-responses: + order: 7 how-it-works: order: 10 From 11818dbb737754c01b9bbba90ad6b9d76fa7fc1d Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 31 Jul 2026 21:14:50 +1200 Subject: [PATCH 19/23] Clarify cluster example reference Assisted-By: devx/56c5beec-347d-4712-9cc7-ea033e4d2a8a --- guides/cluster-deployment/readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/guides/cluster-deployment/readme.md b/guides/cluster-deployment/readme.md index d83b63c2..7579eef5 100644 --- a/guides/cluster-deployment/readme.md +++ b/guides/cluster-deployment/readme.md @@ -15,7 +15,7 @@ A regular {ruby Falcon::Service::Server} binds one listener and shares it with e ## Architecture -In the example, each cluster worker binds to `localhost` with port `0`, allowing the operating system to assign an available port. Falcon describes the bound resource with a {ruby Falcon::Listener}, including its name, scheme, supported protocols, and concrete addresses. +In the accompanying [Docker Compose cluster example](https://github.com/socketry/falcon/tree/main/examples/cluster), each cluster worker binds to `localhost` with port `0`, allowing the operating system to assign an available port. Falcon describes the bound resource with a {ruby Falcon::Listener}, including its name, scheme, supported protocols, and concrete addresses. The worker registers that listener with `async-service-supervisor-envoy`. The supervisor publishes the current workers through an xDS control plane, and Envoy uses Endpoint Discovery Service (EDS) updates to maintain the upstream cluster. From 5a547cd7e18f173dc964768efdb5c98644e8087f Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 31 Jul 2026 21:16:03 +1200 Subject: [PATCH 20/23] Make cluster guide self-contained Assisted-By: devx/56c5beec-347d-4712-9cc7-ea033e4d2a8a --- guides/cluster-deployment/readme.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/guides/cluster-deployment/readme.md b/guides/cluster-deployment/readme.md index 7579eef5..8ff74050 100644 --- a/guides/cluster-deployment/readme.md +++ b/guides/cluster-deployment/readme.md @@ -15,7 +15,7 @@ A regular {ruby Falcon::Service::Server} binds one listener and shares it with e ## Architecture -In the accompanying [Docker Compose cluster example](https://github.com/socketry/falcon/tree/main/examples/cluster), each cluster worker binds to `localhost` with port `0`, allowing the operating system to assign an available port. Falcon describes the bound resource with a {ruby Falcon::Listener}, including its name, scheme, supported protocols, and concrete addresses. +Each cluster worker can bind to `localhost` with port `0`, allowing the operating system to assign an available port. Falcon describes the bound resource with a {ruby Falcon::Listener}, including its name, scheme, supported protocols, and concrete addresses. The worker registers that listener with `async-service-supervisor-envoy`. The supervisor publishes the current workers through an xDS control plane, and Envoy uses Endpoint Discovery Service (EDS) updates to maintain the upstream cluster. @@ -61,7 +61,7 @@ This lifecycle is important when ports are ephemeral or a directory may contain ## Network Topology -The [cluster example](https://github.com/socketry/falcon/tree/main/examples/cluster) runs Falcon and Envoy in the same network namespace. Its Envoy service uses `network_mode: service:falcon`, so `127.0.0.1` and `localhost` refer to the same loopback interface for both processes. +Falcon and Envoy can run in the same network namespace, allowing workers to bind to loopback addresses while remaining reachable by Envoy. With Docker Compose, `network_mode: service:falcon` gives the Envoy service access to Falcon's network namespace, so `127.0.0.1` and `localhost` refer to the same loopback interface for both processes. Without a shared network namespace, Envoy cannot connect to worker endpoints bound to Falcon's loopback interface. In a different deployment topology, bind workers to an interface that Envoy can reach and apply the appropriate network access controls. From 21f2d096075112d53a177f144e1a3dbf66c079e5 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 31 Jul 2026 21:16:46 +1200 Subject: [PATCH 21/23] Remove trailing whitespace Assisted-By: devx/56c5beec-347d-4712-9cc7-ea033e4d2a8a --- lib/falcon/environment/cluster.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/falcon/environment/cluster.rb b/lib/falcon/environment/cluster.rb index d49d9898..377bb932 100644 --- a/lib/falcon/environment/cluster.rb +++ b/lib/falcon/environment/cluster.rb @@ -22,7 +22,6 @@ def service_class def url "http://[::]:0" end - end end end From 92609000c18747b39869becb552f368a375d1ada Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 31 Jul 2026 21:18:25 +1200 Subject: [PATCH 22/23] Add inline cluster configuration Assisted-By: devx/56c5beec-347d-4712-9cc7-ea033e4d2a8a --- guides/cluster-deployment/readme.md | 134 +++++++++++++++++++++++++++- 1 file changed, 130 insertions(+), 4 deletions(-) diff --git a/guides/cluster-deployment/readme.md b/guides/cluster-deployment/readme.md index 8ff74050..e76d2167 100644 --- a/guides/cluster-deployment/readme.md +++ b/guides/cluster-deployment/readme.md @@ -42,6 +42,136 @@ flowchart LR end ``` +## Configuration + +Add the Envoy supervisor integration to your `gems.rb`: + +```ruby +gem "async-service-supervisor-envoy", "~> 0.2" +``` + +Define a Falcon cluster service and an accompanying supervisor in `falcon.rb`: + +```ruby +#!/usr/bin/env async-service +# frozen_string_literal: true + +require "async/service/supervisor" +require "async/service/supervisor/envoy" +require "falcon/environment/cluster" + +service "cluster" do + include Falcon::Environment::Cluster + include Async::Service::Supervisor::Envoy::Supervised + + count 2 + + def url + "http://localhost:0" + end + + middleware do + application = proc do |_env| + body = "Hello from worker #{Process.pid}!\n" + + [200, { + "content-type" => "text/plain", + "content-length" => body.bytesize.to_s, + }, [body]] + end + + Falcon::Server.middleware(application, cache: false) + end +end + +service "supervisor" do + include Async::Service::Supervisor::Environment + + monitors do + [ + Async::Service::Supervisor::Envoy::Monitor.new( + bind: "http://127.0.0.1:18000", + ), + ] + end +end +``` + +The Falcon service name becomes the listener name, so the corresponding Envoy EDS cluster uses `cluster` as its service name. Configure Envoy to receive aggregated discovery updates from the supervisor: + +```yaml +node: + id: falcon-cluster + cluster: falcon-cluster + +dynamic_resources: + ads_config: + api_type: GRPC + transport_api_version: V3 + grpc_services: + - envoy_grpc: + cluster_name: xds_cluster + +static_resources: + listeners: + - name: listener_http + address: + socket_address: + address: 0.0.0.0 + port_value: 10000 + filter_chains: + - filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: ingress_http + route_config: + name: local_route + virtual_hosts: + - name: falcon + domains: ["*"] + routes: + - match: + prefix: "/" + route: + cluster: cluster + http_filters: + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + + clusters: + - name: cluster + connect_timeout: 1s + type: EDS + lb_policy: ROUND_ROBIN + eds_cluster_config: + service_name: cluster + eds_config: + ads: {} + resource_api_version: V3 + + - name: xds_cluster + connect_timeout: 1s + type: STATIC + load_assignment: + cluster_name: xds_cluster + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: 18000 + typed_extension_protocol_options: + envoy.extensions.upstreams.http.v3.HttpProtocolOptions: + "@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions + explicit_http_config: + http2_protocol_options: {} +``` + +The `xds_cluster` connection uses HTTP/2 because ADS is served over gRPC. + ## Worker Registration When each worker starts: @@ -64,7 +194,3 @@ This lifecycle is important when ports are ephemeral or a directory may contain Falcon and Envoy can run in the same network namespace, allowing workers to bind to loopback addresses while remaining reachable by Envoy. With Docker Compose, `network_mode: service:falcon` gives the Envoy service access to Falcon's network namespace, so `127.0.0.1` and `localhost` refer to the same loopback interface for both processes. Without a shared network namespace, Envoy cannot connect to worker endpoints bound to Falcon's loopback interface. In a different deployment topology, bind workers to an interface that Envoy can reach and apply the appropriate network access controls. - -## Complete Example - -See the [cluster example](https://github.com/socketry/falcon/tree/main/examples/cluster) for a Docker Compose configuration containing Falcon, its supervisor and xDS control plane, Envoy, and an Async HTTP client that confirms requests reach both workers. From 3c17a37ba03824af8475f35c51ddea2fec60b339 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 31 Jul 2026 21:22:59 +1200 Subject: [PATCH 23/23] Isolate cluster Docker example. --- .dockerignore | 8 -------- examples/cluster/Dockerfile | 4 ++-- examples/cluster/compose.yaml | 5 ++--- examples/cluster/gems.rb | 2 +- guides/cluster-deployment/readme.md | 3 ++- 5 files changed, 7 insertions(+), 15 deletions(-) delete mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 13049108..00000000 --- a/.dockerignore +++ /dev/null @@ -1,8 +0,0 @@ -.git -.context -.bundle -.covered.db -gems.locked -pkg -external -**/*.ipc diff --git a/examples/cluster/Dockerfile b/examples/cluster/Dockerfile index 4ea4e092..047886d7 100644 --- a/examples/cluster/Dockerfile +++ b/examples/cluster/Dockerfile @@ -3,10 +3,10 @@ FROM ruby:${RUBY_VERSION} WORKDIR /code -ENV BUNDLE_GEMFILE=/code/examples/cluster/gems.rb +ENV BUNDLE_GEMFILE=/code/gems.rb COPY . . RUN bundle install -CMD ["bundle", "exec", "async-service", "examples/cluster/falcon.rb"] +CMD ["bundle", "exec", "async-service", "falcon.rb"] diff --git a/examples/cluster/compose.yaml b/examples/cluster/compose.yaml index 1f200704..8c1ee2bb 100644 --- a/examples/cluster/compose.yaml +++ b/examples/cluster/compose.yaml @@ -2,8 +2,7 @@ services: falcon: image: cluster-falcon build: - context: ../.. - dockerfile: examples/cluster/Dockerfile + context: . environment: CONSOLE_OUTPUT: XTerm ports: @@ -21,7 +20,7 @@ services: client: image: cluster-falcon - command: ["bundle", "exec", "ruby", "examples/cluster/client.rb"] + command: ["bundle", "exec", "ruby", "client.rb"] environment: ENVOY_URI: http://127.0.0.1:10000 network_mode: "service:falcon" diff --git a/examples/cluster/gems.rb b/examples/cluster/gems.rb index bec3663b..99856c34 100644 --- a/examples/cluster/gems.rb +++ b/examples/cluster/gems.rb @@ -5,5 +5,5 @@ source "https://rubygems.org" -gem "falcon", path: "../.." +gem "falcon", "~> 0.56.0" gem "async-service-supervisor-envoy", "~> 0.2.0" diff --git a/guides/cluster-deployment/readme.md b/guides/cluster-deployment/readme.md index e76d2167..d085aad8 100644 --- a/guides/cluster-deployment/readme.md +++ b/guides/cluster-deployment/readme.md @@ -44,9 +44,10 @@ flowchart LR ## Configuration -Add the Envoy supervisor integration to your `gems.rb`: +Add Falcon and the Envoy supervisor integration to your `gems.rb`: ```ruby +gem "falcon", "~> 0.56.0" gem "async-service-supervisor-envoy", "~> 0.2" ```