class Controller
Manages the life-cycle of one or more containers in order to support a persistent system. e.g. a web server, job server or some other long running system.
Definitions
def initialize(notify: Notify.open!, container_class: Container, graceful_stop: true)
Initialize the controller.
Signature
-
parameter
notify
Notify::Client
A client used for process readiness notifications.
Implementation
def initialize(notify: Notify.open!, container_class: Container, graceful_stop: true)
@container = nil
@container_class = container_class
if @notify = notify
@notify.status!("Initializing...")
end
@signals = {}
trap(SIGHUP) do
self.restart
end
@graceful_stop = graceful_stop
end
def state_string
The state of the controller.
Signature
-
returns
String
Implementation
def state_string
if running?
"running"
else
"stopped"
end
end
def to_s
A human readable representation of the controller.
Signature
-
returns
String
Implementation
def to_s
"#{self.class} #{state_string}"
end
def trap(signal, &block)
Trap the specified signal.
Implementation
def trap(signal, &block)
@signals[signal] = block
end
attr :container
The current container being managed by the controller.
def create_container
Create a container for the controller. Can be overridden by a sub-class.
Signature
-
returns
Generic
A specific container instance to use.
Implementation
def create_container
@container_class.new
end
def running?
Whether the controller has a running container.
Signature
-
returns
Boolean
Implementation
def running?
!!@container
end
def wait
Wait for the underlying container to start.
Implementation
def wait
@container&.wait
end
def setup(container)
Spawn container instances into the given container. Should be overridden by a sub-class.
Signature
-
parameter
container
Generic
The container, generally from
#create_container
.
Implementation
def setup(container)
# Don't do this, otherwise calling super is risky for sub-classes:
# raise NotImplementedError, "Container setup is must be implemented in derived class!"
end
def start
Start the container unless it's already running.
Implementation
def start
self.restart unless @container
end
def stop(graceful = @graceful_stop)
Stop the container if it's running.
Signature
-
parameter
graceful
Boolean
Whether to give the children instances time to shut down or to kill them immediately.
Implementation
def stop(graceful = @graceful_stop)
@container&.stop(graceful)
@container = nil
end
def restart
Restart the container. A new container is created, and if successful, any old container is terminated gracefully. This is equivalent to a blue-green deployment.
Implementation
def restart
if @container
@notify&.restarting!
Console.logger.debug(self) {"Restarting container..."}
else
Console.logger.debug(self) {"Starting container..."}
end
container = self.create_container
begin
self.setup(container)
rescue => error
@notify&.error!(error.to_s)
raise SetupError, container
end
# Wait for all child processes to enter the ready state.
Console.logger.debug(self, "Waiting for startup...")
container.wait_until_ready
Console.logger.debug(self, "Finished startup.")
if container.failed?
@notify&.error!("Container failed to start!")
container.stop(false)
raise SetupError, container
end
# The following swap should be atomic:
old_container = @container
@container = container
container = nil
if old_container
Console.logger.debug(self, "Stopping old container...")
old_container&.stop(@graceful_stop)
end
@notify&.ready!
ensure
# If we are leaving this function with an exception, try to kill the container:
container&.stop(false)
end
def reload
Reload the existing container. Children instances will be reloaded using SIGHUP
.
Implementation
def reload
@notify&.reloading!
Console.logger.info(self) {"Reloading container: #{@container}..."}
begin
self.setup(@container)
rescue
raise SetupError, container
end
# Wait for all child processes to enter the ready state.
Console.logger.debug(self, "Waiting for startup...")
@container.wait_until_ready
Console.logger.debug(self, "Finished startup.")
if @container.failed?
@notify.error!("Container failed to reload!")
raise SetupError, @container
else
@notify&.ready!
end
end
def run
Enter the controller run loop, trapping SIGINT
and SIGTERM
.
Implementation
def run
# I thought this was the default... but it doesn't always raise an exception unless you do this explicitly.
# We use `Thread.current.raise(...)` so that exceptions are filtered through `Thread.handle_interrupt` correctly.
interrupt_action = Signal.trap(:INT) do
# $stderr.puts "Received INT signal, terminating...", caller
::Thread.current.raise(Interrupt)
end
terminate_action = Signal.trap(:TERM) do
# $stderr.puts "Received TERM signal, terminating...", caller
::Thread.current.raise(Terminate)
end
hangup_action = Signal.trap(:HUP) do
# $stderr.puts "Received HUP signal, restarting...", caller
::Thread.current.raise(Hangup)
end
self.start
while @container&.running?
begin
@container.wait
rescue SignalException => exception
if handler = @signals[exception.signo]
begin
handler.call
rescue SetupError => error
Console.logger.error(self) {error}
end
else
raise
end
end
end
rescue Interrupt
self.stop
rescue Terminate
self.stop(false)
ensure
self.stop(false)
# Restore the interrupt handler:
Signal.trap(:INT, interrupt_action)
Signal.trap(:TERM, terminate_action)
Signal.trap(:HUP, hangup_action)
end