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 new file mode 100644 index 00000000..4fbe81e8 --- /dev/null +++ b/examples/cluster/client.rb @@ -0,0 +1,31 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "net/http" +require "uri" + +uri = URI(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 + 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 new file mode 100644 index 00000000..f309abf2 --- /dev/null +++ b/examples/cluster/falcon.rb @@ -0,0 +1,51 @@ +#!/usr/bin/env async-service +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +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 + 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 new file mode 100644 index 00000000..689d7eb7 --- /dev/null +++ b/examples/cluster/readme.md @@ -0,0 +1,29 @@ +# 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. + +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 + +Build and start Falcon and Envoy: + +```shell +$ docker compose up --build --detach +``` + +Run the client through Compose: + +```shell +$ docker compose run --rm client +Hello from worker 12! +Hello from worker 13! +``` + +The client waits for Envoy and confirms that requests reach both workers. + +Stop and remove the containers: + +```shell +$ docker compose down +``` 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 3b465060..c0c71ffa 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/environment/cluster.rb b/lib/falcon/environment/cluster.rb new file mode 100644 index 00000000..80fd802b --- /dev/null +++ b/lib/falcon/environment/cluster.rb @@ -0,0 +1,35 @@ +# 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 + + # 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/service/cluster.rb b/lib/falcon/service/cluster.rb new file mode 100644 index 00000000..c5f04b0e --- /dev/null +++ b/lib/falcon/service/cluster.rb @@ -0,0 +1,98 @@ +# 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 + # 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] + + 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 + end + end + end +end diff --git a/lib/falcon/service/server.rb b/lib/falcon/service/server.rb index c3061bc6..69c76d69 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,15 +43,16 @@ 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 diff --git a/releases.md b/releases.md index f77bd436..6608b913 100644 --- a/releases.md +++ b/releases.md @@ -1,5 +1,9 @@ # Releases +## 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`. diff --git a/test/falcon/environment/cluster.rb b/test/falcon/environment/cluster.rb new file mode 100644 index 00000000..386e0bf0 --- /dev/null +++ b/test/falcon/environment/cluster.rb @@ -0,0 +1,30 @@ +# 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", + ) + 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/service/cluster.rb b/test/falcon/service/cluster.rb new file mode 100644 index 00000000..841b5081 --- /dev/null +++ b/test/falcon/service/cluster.rb @@ -0,0 +1,118 @@ +# 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__)} + + 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 + + Module.new do + def middleware + Protocol::HTTP::Middleware::HelloWorld + end + + def container_options + super.merge(restart: false) + end + + define_method(:prepare_worker!) do |instance, listener:| + super(instance, listener: listener) + + File.open(path, "a") do |file| + listener.addresses.each do |address| + file.puts(address.ip_port) if address.ip? + end + 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, + health_check_timeout: 0.01, + ) + 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 + + sleep(0.01) + + container.stop + expect(container.failed?).to be_falsey + ensure + server.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