Getting Started

This guide explains how to run Fantail and publish HTTP worker endpoints.

Installation

Add the gem to your project:

$ bundle add fantail

Server

Fantail exposes an HTTP endpoint for downstream traffic and an async-bus endpoint for worker registration:

require "async"
require "async/http/endpoint"
require "fantail"
require "io/endpoint"

Sync do
	configuration = Fantail::Configuration.load("config/fantail.rb")
	
	server = Fantail::Server.new(
		Async::HTTP::Endpoint.parse("http://0.0.0.0:9292"),
		IO::Endpoint.tcp("0.0.0.0", 9293),
		configuration: configuration,
	)
	
	server.run.wait
end

Endpoint Publication

A monitor keeps the desired endpoint set locally. Registration callbacks only update this local state and enqueue a change; they do not wait for the control connection.

require "async"
require "fantail"
require "io/endpoint"

Sync do
	monitor = Fantail::Monitor.new(IO::Endpoint.tcp("fantail", 9293))
	monitor.replace([
		Fantail::Endpoint.new("worker-1", "http://127.0.0.1:9301"),
		Fantail::Endpoint.new("worker-2", "http://127.0.0.1:9302"),
	])
	
	monitor.run.wait
end

After connecting, the monitor performs a complete replacement. It then publishes additions, replacements, and removals as deltas. A reconnect always begins with another complete replacement so missed deltas cannot leave the registry stale.

Admission Semantics

Each backend has a configurable number of request-processing permits and response exchanges. A processing permit is released as soon as upstream response headers arrive. The exchange remains reserved until the response body closes.

This allows a worker to begin another request while an earlier response streams, without allowing an unbounded number of streaming responses to accumulate.

The scheduler owns all permits. Request queues can decide which workers are eligible and express a soft preference between them, but cannot reserve capacity independently. If the preferred worker is unavailable, the scheduler remains work-conserving and uses another eligible worker.

Request Queues

Fantail configuration is trusted application Ruby evaluated using a scoped configuration builder:

# config/fantail.rb
queue :liquid do
	match{|request| request.path.start_with?("/render")}
	balance :spread
	depth_limit 500
	wait_limit 0.25
	shed status: 429, retry_after: 1
end

queue :grpc do
	match do |request|
		request.headers["content-type"]&.start_with?("application/grpc")
	end
	
	balance :pack, affinity: :grpc
end

default_queue :liquid
pending_limit 1_000
permit_limit 1

Configuration can be split into files relative to the file being evaluated using load_file "queues.rb".

Matchers are evaluated in definition order, followed by the default queue. Across queues, the oldest eligible head request is dispatched first. If that request has no eligible worker, another queue can use the available permit.

The built-in :spread policy prefers the least-active worker. The :pack policy prefers a worker already processing the specified affinity, while remaining bounded by its permits. An application can supply a policy object implementing select(backends, queue:, request:), and can restrict hard eligibility with queue.eligible.

Load Shedding

depth_limit bounds requests actually waiting in a queue; immediately dispatchable requests do not count against it. pending_limit provides a global bound across all queues. wait_limit bounds actual queue residence time in seconds. Rejected requests use the response configured by shed, which defaults to HTTP 429.

Applications can add an admission policy with either a block or an object implementing admit?(request, queue:, pending:):

queue :default do
	admit do |request, queue:, pending:|
		pending < application_limit_for(queue.name)
	end
end