Async::ContainerSourceAsyncContainerController

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