-
-
Notifications
You must be signed in to change notification settings - Fork 6
/
Copy pathcontroller.rb
223 lines (181 loc) · 5.66 KB
/
controller.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
# frozen_string_literal: true
# Released under the MIT License.
# Copyright, 2018-2022, by Samuel Williams.
require_relative 'error'
require_relative 'best'
require_relative 'statistics'
require_relative 'notify'
module Async
module Container
# 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.
class Controller
SIGHUP = Signal.list["HUP"]
SIGINT = Signal.list["INT"]
SIGTERM = Signal.list["TERM"]
SIGUSR1 = Signal.list["USR1"]
SIGUSR2 = Signal.list["USR2"]
# Initialize the controller.
# @parameter notify [Notify::Client] A client used for process readiness notifications.
def initialize(notify: Notify.open!)
@container = nil
if @notify = notify
@notify.status!("Initializing...")
end
@signals = {}
trap(SIGHUP) do
self.restart
end
end
# The state of the controller.
# @returns [String]
def state_string
if running?
"running"
else
"stopped"
end
end
# A human readable representation of the controller.
# @returns [String]
def to_s
"#{self.class} #{state_string}"
end
# Trap the specified signal.
# @parameters signal [Symbol] The signal to trap, e.g. `:INT`.
# @parameters block [Proc] The signal handler to invoke.
def trap(signal, &block)
@signals[signal] = block
end
# The current container being managed by the controller.
attr :container
# Create a container for the controller.
# Can be overridden by a sub-class.
# @returns [Generic] A specific container instance to use.
def create_container
Container.new
end
# Whether the controller has a running container.
# @returns [Boolean]
def running?
!!@container
end
# Wait for the underlying container to start.
def wait
@container&.wait
end
# Spawn container instances into the given container.
# Should be overridden by a sub-class.
# @parameter container [Generic] The container, generally from {#create_container}.
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
# Start the container unless it's already running.
def start
self.restart unless @container
end
# Stop the container if it's running.
# @parameter graceful [Boolean] Whether to give the children instances time to shut down or to kill them immediately.
def stop(graceful = true)
@container&.stop(graceful)
@container = nil
end
# Restart the container. A new container is created, and if successful, any old container is terminated gracefully.
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
@notify&.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!($!.to_s)
container.stop
raise SetupError, container
end
# Make this swap as atomic as possible:
old_container = @container
@container = container
Console.logger.debug(self, "Stopping old container...")
old_container&.stop
@notify&.ready!
rescue
# If we are leaving this function with an exception, try to kill the container:
container&.stop(false)
raise
end
# Reload the existing container. Children instances will be reloaded using `SIGHUP`.
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!")
raise SetupError, @container
else
@notify&.ready!
end
end
# Enter the controller run loop, trapping `SIGINT` and `SIGTERM`.
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
::Thread.current.raise(Interrupt)
end
terminate_action = Signal.trap(:TERM) do
::Thread.current.raise(Terminate)
end
hangup_action = Signal.trap(:HUP) do
::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(true)
rescue Terminate
self.stop(false)
ensure
self.stop(true)
# Restore the interrupt handler:
Signal.trap(:INT, interrupt_action)
Signal.trap(:TERM, terminate_action)
Signal.trap(:HUP, hangup_action)
end
end
end
end