From bfe5b1dcb66577ddca6a2e1dd79a64f518870f42 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 26 Jun 2026 15:06:34 +1200 Subject: [PATCH 01/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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 ```