Skip to content

Commit ca51626

Browse files
Add Falcon cluster service environment
1 parent b3758cd commit ca51626

5 files changed

Lines changed: 238 additions & 0 deletions

File tree

lib/falcon/environment/cluster.rb

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2026, by Samuel Williams.
5+
6+
require_relative "server"
7+
require_relative "../service/cluster"
8+
9+
module Falcon
10+
module Environment
11+
# Provides an environment for hosting a cluster of Falcon server workers, where each worker binds its own endpoint.
12+
module Cluster
13+
include Server
14+
15+
# The service class to use for the cluster.
16+
# @returns [Class]
17+
def service_class
18+
Service::Cluster
19+
end
20+
21+
# The host that this server will receive connections for.
22+
def url
23+
"http://[::]:0"
24+
end
25+
26+
# The endpoint bound by the current worker.
27+
# @returns [IO::Endpoint::BoundEndpoint | Nil]
28+
def bound_endpoint
29+
@bound_endpoint
30+
end
31+
32+
# Set the endpoint bound by the current worker.
33+
# @parameter bound_endpoint [IO::Endpoint::BoundEndpoint]
34+
def bound_endpoint=(bound_endpoint)
35+
@bound_endpoint = bound_endpoint
36+
end
37+
38+
# The first socket address bound by the current worker.
39+
# @returns [Addrinfo | Nil]
40+
def bound_address
41+
@bound_endpoint&.sockets&.first&.to_io&.local_address
42+
end
43+
44+
# The port bound by the current worker.
45+
# @returns [Integer | Nil]
46+
def bound_port
47+
bound_address&.ip_port
48+
end
49+
end
50+
end
51+
end

lib/falcon/service/cluster.rb

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2026, by Samuel Williams.
5+
6+
require "async/service/generic"
7+
8+
require_relative "server"
9+
10+
module Falcon
11+
# @namespace
12+
module Service
13+
# A managed service for running Falcon workers with independently bound endpoints.
14+
class Cluster < Server
15+
# Start the service without binding the endpoint in the parent process.
16+
def start
17+
preload!
18+
19+
Async::Service::Generic.instance_method(:start).bind_call(self)
20+
end
21+
22+
# Setup the service into the specified container.
23+
# @parameter container [Async::Container] The container to configure.
24+
def setup(container)
25+
container_options = @evaluator.container_options
26+
health_check_timeout = container_options[:health_check_timeout]
27+
28+
container.run(**container_options) do |instance|
29+
clock = Async::Clock.start
30+
31+
Async do
32+
evaluator = self.environment.evaluator
33+
server = nil
34+
35+
health_checker(instance, health_check_timeout) do
36+
if server
37+
instance.name = format_title(evaluator, server)
38+
end
39+
end
40+
41+
instance.status!("Preparing...")
42+
43+
bound_endpoint = evaluator.endpoint.bound
44+
evaluator.bound_endpoint = bound_endpoint
45+
46+
evaluator.prepare!(instance)
47+
emit_prepared(instance, clock)
48+
49+
instance.status!("Running...")
50+
server = run(instance, evaluator, bound_endpoint)
51+
instance.name = format_title(evaluator, server)
52+
emit_running(instance, clock)
53+
54+
instance.ready!
55+
end
56+
end
57+
end
58+
59+
# Run the service logic.
60+
#
61+
# @parameter instance [Object] The container instance.
62+
# @parameter evaluator [Environment::Evaluator] The environment evaluator.
63+
# @parameter bound_endpoint [IO::Endpoint] The endpoint bound by this worker.
64+
# @returns [Falcon::Server] The server instance.
65+
def run(instance, evaluator, bound_endpoint)
66+
server = evaluator.make_server(bound_endpoint)
67+
68+
Async do |task|
69+
server.run
70+
71+
task.children&.each(&:wait)
72+
end
73+
74+
server
75+
end
76+
end
77+
end
78+
end

releases.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## Unreleased
44

5+
- Add `Falcon::Environment::Cluster` and `Falcon::Service::Cluster` for running workers with independently bound endpoints.
56
- Move Falcon middleware trace providers to `traces/provider/falcon/middleware`.
67

78
## v0.55.5

test/falcon/environment/cluster.rb

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2026, by Samuel Williams.
5+
6+
require "falcon/environment/cluster"
7+
require "async/service/environment"
8+
9+
describe Falcon::Environment::Cluster do
10+
let(:evaluator) do
11+
Async::Service::Environment.build(subject, name: "localhost").evaluator
12+
end
13+
14+
it "provides default cluster settings" do
15+
expect(evaluator).to have_attributes(
16+
service_class: be == Falcon::Service::Cluster,
17+
url: be == "http://[::]:0",
18+
authority: be == "localhost",
19+
bound_endpoint: be == nil,
20+
bound_address: be == nil,
21+
bound_port: be == nil,
22+
)
23+
end
24+
end

test/falcon/service/cluster.rb

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2026, by Samuel Williams.
5+
6+
require "falcon/service/cluster"
7+
require "falcon/environment/cluster"
8+
require "async/container"
9+
require "async/service/environment"
10+
require "fileutils"
11+
require "net/http"
12+
require "protocol/http/middleware"
13+
14+
describe Falcon::Service::Cluster do
15+
let(:ports_path) {File.expand_path(".cluster/ports.txt", __dir__)}
16+
17+
let(:recorder) do
18+
path = ports_path
19+
20+
Module.new do
21+
def middleware
22+
Protocol::HTTP::Middleware::HelloWorld
23+
end
24+
25+
def container_options
26+
super.merge(restart: false)
27+
end
28+
29+
define_method(:prepare!) do |instance|
30+
super(instance)
31+
32+
File.open(path, "a") do |file|
33+
file.puts(bound_port)
34+
end
35+
end
36+
end
37+
end
38+
39+
let(:environment) do
40+
Async::Service::Environment.new(Falcon::Environment::Cluster).with(
41+
recorder,
42+
name: "hello",
43+
root: File.expand_path(".cluster/hello", __dir__),
44+
url: "http://127.0.0.1:0",
45+
count: 2,
46+
)
47+
end
48+
49+
let(:server) do
50+
subject.new(environment)
51+
end
52+
53+
before do
54+
FileUtils.rm_rf(File.dirname(ports_path))
55+
FileUtils.mkdir_p(File.dirname(ports_path))
56+
end
57+
58+
after do
59+
FileUtils.rm_rf(File.dirname(ports_path))
60+
end
61+
62+
it "binds a unique port for each worker before preparing the instance" do
63+
container = Async::Container.new
64+
65+
server.start
66+
server.setup(container)
67+
container.wait_until_ready
68+
69+
ports = File.readlines(ports_path, chomp: true).map(&:to_i)
70+
71+
expect(ports).to have_attributes(size: be == 2)
72+
expect(ports).to have_value(be > 0)
73+
expect(ports.uniq).to be == ports
74+
75+
ports.each do |port|
76+
response = Net::HTTP.get_response(URI("http://127.0.0.1:#{port}/"))
77+
78+
expect(response).to have_attributes(code: be == "200")
79+
end
80+
ensure
81+
server.stop
82+
container&.stop
83+
end
84+
end

0 commit comments

Comments
 (0)