senotrusov-ruby-daemonic-threads 1.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,37 @@
1
+
2
+ # Copyright 2009 Stanislav Senotrusov <senotrusov@gmail.com>
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+
17
+ # To reproduce race condition, start daemon and do concurrent HTTP get for some resource
18
+ # ab -c 100 -n 1000 http://127.0.0.1:4000/daemon/foo_resources/7.xml
19
+
20
+ # Rails default to lazy load this, so it gaves random exceptions in multithreaded env
21
+ ActiveSupport::TimeWithZone
22
+
23
+ # @@loaded_zones hash must be mutexed
24
+ class TZInfo::Timezone
25
+ @@loaded_zones_mutex = Mutex.new
26
+
27
+ class << self
28
+ alias_method :unsafe_get, :get
29
+
30
+ def get(identifier)
31
+ @@loaded_zones_mutex.synchronize do
32
+ unsafe_get(identifier)
33
+ end
34
+ end
35
+ end
36
+ end
37
+
@@ -0,0 +1,53 @@
1
+
2
+ # Copyright 2009 Stanislav Senotrusov <senotrusov@gmail.com>
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+
17
+ class DaemonicThreads::Process
18
+
19
+ def initialize(controller)
20
+ @controller = controller
21
+ @name = controller.name
22
+
23
+ @config = DaemonicThreads::Config.new(RAILS_ROOT + '/config/daemons.yml')
24
+ @http = DaemonicThreads::HTTP::Server.new(self)
25
+ @queues = DaemonicThreads::Queues.new(self)
26
+ @daemons = DaemonicThreads::Daemons.new(self)
27
+ end
28
+
29
+ attr_reader :controller, :name, :config, :http, :queues, :daemons
30
+
31
+ def start
32
+ @queues.restore
33
+
34
+ @http.start
35
+ @daemons.start
36
+ end
37
+
38
+ def join
39
+ @http.join
40
+ @daemons.join
41
+ end
42
+
43
+ def stop
44
+ @http.stop
45
+ @daemons.stop
46
+ end
47
+
48
+ def before_exit
49
+ @queues.store
50
+ end
51
+
52
+ end
53
+
@@ -0,0 +1,194 @@
1
+
2
+ # Copyright 2009 Stanislav Senotrusov <senotrusov@gmail.com>
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+
17
+ # Демон - экземпляр некоторого класса, запущенный в процессе.
18
+ # В одном процессе параллельно могут быть запущено произвольное число демонов.
19
+ #
20
+ # Исключение из initialize() останавливает весь процесс, потому что непонятно - как и что там запустилось и будут ли теперь работать корректно start и join.
21
+ #
22
+ # Как правило, initialize() не переопределяется, а для запуска тредов, поддемонов, сетевых подключение используется initialize_daemon().
23
+ #
24
+ # initialize_daemon() более толерантен к ошибкам.
25
+ # Исключение из группы IPSocket::SOCKET_EXEPTIONS полученное из initialize_daemon() перестартовывает демон.
26
+ # Тем не менее, остальные исключения, полученные из initialize_daemon() останавливают весь процесс
27
+ #
28
+ # Исключения, полученные из тредов, перестартовывает демон.
29
+ #
30
+ # После отработки initialize в произвольной последовательности вызываются join() и stop().
31
+ # join() и stop() вызывается только один раз.
32
+ # Исключение в stop() останавливает весь процесс, потому что непонятно - всему ли была доведена команда остановиться.
33
+ # Исключение в join() останавливает весь процесс, потому что непонятно - всё ли остановилось.
34
+ #
35
+ # Таким образом, реализуется несколько боязливая стратегия работы - если что-то идёт не так, процесс останавливается.
36
+ # Рестарт демонов происходит только когда ошибка понятна, и она, скорее всего, не приводит к фатальным последствиям.
37
+ # Такой ошибкой является сбой сети.
38
+ # Если в ваших демонах есть другие понятные вам ошибки - ловити их сами или декларируйте в RESTART_ON.
39
+ #
40
+ # Если тред запустит самостоятельно демона, то он может получить из этого демона exception.
41
+ # Если этот exception не относится к категории тех, которые решаются перезапуском демона, то весь процесс останавливается.
42
+ # На самом деле, когда вы получаете такой exception из spawn_daemon @runner.process.controller.stop уже выполнен. Вот-вот всё закроется.
43
+
44
+
45
+ # Вывод inspect очень большой, потому что он долго ходит по перекрёсным ссылкам.
46
+ # Сюрпризом будет то, что обычный exception no method error, message которого выполняет internaly some kind of inspect,
47
+ # может выполнятся секунду-другую, если exception случился паралельно.
48
+
49
+
50
+ module DaemonicThreads::Prototype
51
+ RESTART_ON = IPSocket::SOCKET_EXEPTIONS + [DaemonicThreads::MustTerminatedState] # TODO: inheritable
52
+
53
+ def initialize(name, runner, parent = nil)
54
+ @name = name
55
+ @runner = runner
56
+ @config = runner.config
57
+ @process = runner.process
58
+ @logger = Rails.logger
59
+ @parent = parent
60
+
61
+ @config["queues"].each do |queue_handler, queue_name|
62
+ instance_variable_set("@#{queue_handler}", @process.queues[queue_name])
63
+ end if @config["queues"]
64
+
65
+ @threads = ThreadGroup.new
66
+ @daemons = []
67
+ @creatures_mutex = Mutex.new
68
+ @stop_condition = ConditionVariable.new
69
+ @must_terminate = false
70
+
71
+ initialize_http if respond_to?(:initialize_http)
72
+ end
73
+
74
+ attr_reader :logger
75
+
76
+ def join
77
+ @creatures_mutex.synchronize do
78
+ @stop_condition.wait(@creatures_mutex) unless @must_terminate
79
+ end
80
+
81
+ deinitialize_http if respond_to?(:deinitialize_http)
82
+
83
+ @daemons.each {|daemon| daemon.join }
84
+
85
+ @threads.list.each {|thread| thread.join }
86
+ end
87
+
88
+
89
+ def stop
90
+ @creatures_mutex.synchronize do
91
+ @must_terminate = true
92
+ @stop_condition.signal
93
+ end
94
+
95
+ @daemons.each {|daemon| daemon.stop }
96
+
97
+ @threads.list.each do |thread|
98
+ @config["queues"].each do |queue_handler, queue_name|
99
+ instance_variable_get("@#{queue_handler}").release_blocked thread
100
+ end
101
+ end if @config["queues"]
102
+ end
103
+
104
+
105
+ def perform_initialize_daemon(*args)
106
+ initialize_daemon(*args) if respond_to? :initialize_daemon
107
+ end
108
+
109
+
110
+ # Можно запускать из initialize_daemon или из любого треда
111
+ def spawn_daemon name, klass, *args
112
+ @creatures_mutex.synchronize do
113
+ raise(DaemonicThreads::MustTerminatedState, "Unable to spawn new daemons after stop() is called") if @must_terminate
114
+
115
+ # Мы не ловим никаких exceptions, потому что они поймаются или panic_on_exception (тред или http-запрос) или runner-ом (initialize, initialize_daemon).
116
+ # Полагаться на себя тот должен, кто spawn_daemon вызвал из треда, запущенного без помощи spawn_thread, а значит без должной обработки ошибок.
117
+
118
+ @daemons.push(daemon = klass.new(name, @runner, self))
119
+ daemon.perform_initialize_daemon(*args)
120
+ return daemon
121
+ end
122
+ end
123
+
124
+
125
+ # Можно запускать из initialize_daemon или из любого треда
126
+ def spawn_thread(thread_name, *args)
127
+ @creatures_mutex.synchronize do
128
+ raise(DaemonicThreads::MustTerminatedState, "Unable to spawn new threads after stop() is called") if @must_terminate
129
+
130
+ thread_title = "#{self.class}#thread:`#{thread_name}' @name:`#{@name}'"
131
+
132
+ @threads.add(thread = Thread.new { execute_thread(thread_name, thread_title, args) } )
133
+
134
+ return thread
135
+ end
136
+ end
137
+
138
+ def execute_thread(thread_name, thread_title, args)
139
+
140
+ panic_on_exception(thread_title) do
141
+ Thread.current[:title] = thread_title
142
+ Thread.current[:started_at] = Time.now
143
+
144
+ if block_given?
145
+ yield(*args)
146
+ elsif respond_to?(thread_name)
147
+ __send__(thread_name, *args)
148
+ else
149
+ raise("Thread block was not given or method `#{thread_name}' not found. Don't know what to do.")
150
+ end
151
+ end
152
+
153
+ ensure
154
+ panic_on_exception("#{thread_title} -- Release ActiveRecord connection to pool") { ActiveRecord::Base.clear_active_connections! }
155
+ end
156
+
157
+
158
+ def panic_on_exception(title = nil, handler = nil)
159
+ yield
160
+ rescue *(RESTART_ON) => exception
161
+ begin
162
+ exception.log!(@logger, :warn, title, (@process.controller.env == "production" ? :inspect : :inspect_with_backtrace))
163
+ handler.call(exception) if handler
164
+ @runner.restart_daemon
165
+ rescue Exception => handler_exception
166
+ begin
167
+ handler_exception.log!(@logger, :fatal, title)
168
+ ensure
169
+ @process.controller.stop
170
+ end
171
+ end
172
+ rescue Exception => exception
173
+ begin
174
+ exception.log!(@logger, :fatal, title)
175
+ handler.call(exception) if handler
176
+ rescue Exception => handler_exception
177
+ handler_exception.log!(@logger, :fatal, title)
178
+ ensure
179
+ @process.controller.stop
180
+ end
181
+ end
182
+
183
+
184
+ def must_terminate?
185
+ @creatures_mutex.synchronize { @must_terminate }
186
+ end
187
+
188
+
189
+ def log severity, message = nil
190
+ @logger.__send__(severity, "#{self.class}##{caller.first.match(/`(.*)'/)[1]} -- #{block_given? ? yield : message}" )
191
+ end
192
+
193
+ end
194
+
@@ -0,0 +1,72 @@
1
+
2
+ # Copyright 2009 Stanislav Senotrusov <senotrusov@gmail.com>
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+
17
+ # NOTE:
18
+ # It is not a quarantied delivery queues.
19
+ # It rely on normal process startup/shutdown sequence.
20
+ # So, if you get sigfault all data will be lost.
21
+ # On the other hand, queues tends to be quite quickly, since all done in memory
22
+
23
+ class DaemonicThreads::Queues
24
+
25
+ DEFAULT_STORAGE_DIR = Rails.root + 'tmp' + 'queues'
26
+
27
+ def initialize(process)
28
+ @queues = {}
29
+ @storage_dir = DEFAULT_STORAGE_DIR
30
+ @queue_names = process.config.queue_names
31
+
32
+ raise("Queues storage directory #{@storage_dir} is not available!") unless storage_available?
33
+ end
34
+
35
+ def restore
36
+ @queue_names.each do |name|
37
+ if File.exists?(filename = "#{@storage_dir}/#{name}")
38
+ @queues[name] = SmartQueue.new(File.read(filename))
39
+ File.unlink(filename)
40
+ else
41
+ @queues[name] = SmartQueue.new
42
+ end
43
+ end
44
+ end
45
+
46
+ def store
47
+ @queues.each do |name, queue|
48
+ File.write("#{@storage_dir}/#{name}", queue.to_storage)
49
+ end
50
+ end
51
+
52
+ def [] name
53
+ @queues[name]
54
+ end
55
+
56
+ private
57
+
58
+ def storage_available?
59
+ if File.directory?(@storage_dir) && File.writable?(@storage_dir)
60
+ return true
61
+ else
62
+ begin
63
+ Dir.mkdir(@storage_dir)
64
+ return true
65
+ rescue SystemCallError
66
+ return false
67
+ end
68
+ end
69
+ end
70
+
71
+ end
72
+
@@ -0,0 +1,134 @@
1
+
2
+ # Copyright 2009 Stanislav Senotrusov <senotrusov@gmail.com>
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+
17
+ class DaemonicThreads::Runner
18
+ RESTART_DELAY = 15
19
+
20
+ def initialize(name, config, process)
21
+ @name = name
22
+ @config = config
23
+ @process = process
24
+ @logger = Rails.logger
25
+
26
+ @mutex = Mutex.new
27
+ @delay = ConditionVariable.new
28
+ @must_terminate = false
29
+ @restarting = false
30
+ end
31
+
32
+ attr_reader :name, :config, :process
33
+
34
+ def start
35
+ @watchdog_thread = Thread.new_with_exception_handling(lambda { @process.controller.stop }, @logger, :fatal, "#{self.class}#watchdog @name:`#{@name}'") { watchdog }
36
+ end
37
+
38
+ def join
39
+ @watchdog_thread.join if @watchdog_thread
40
+ end
41
+
42
+ def stop
43
+ restart_process_on_exception do
44
+ @mutex.synchronize do
45
+ @must_terminate = true
46
+ @delay.signal
47
+
48
+ return if @restarting
49
+
50
+ @daemon.stop if @daemon
51
+ end
52
+ end
53
+ end
54
+
55
+ #
56
+ # Нельзя вызывать restart_daemon(), из initialize(), потому что перестартовывать ещё нечего.
57
+ # Такой вызов может произойти, если в initialize() запускаются треды, которые при ошибке вызывают restart_daemon.
58
+ # Запускайте новые треды в методе initialize_daemon(). Он выполняется после завершения initialize()
59
+ #
60
+ # Нельзя вызывать restart_daemon() после того, как отработал join().
61
+ # Таким действием можно непреднамеренно перезапустить новое, только что созданное приложение.
62
+ # Пауза между завершением старого и запуском нового приложения (RESTART_DELAY), несколько снижает вероятность такой ошибки.
63
+ # Польностью ошибку исключить можно, только если в join() делать join для всех запущенных тредов.
64
+ # Это делается автоматически, если использовать методы spawn_daemon и spawn_thread.
65
+ #
66
+ def restart_daemon
67
+ restart_process_on_exception do
68
+ @mutex.synchronize do
69
+ return if @must_terminate || @restarting
70
+
71
+ @restarting = true
72
+
73
+ unless @daemon
74
+ raise "#{self.class}#restart_daemon @name:`#{@name} -- Called restart_daemon(), but @daemon does not exists! This may occur when you somehow call restart_daemon() from initialize() and then raises then exception from initialize(). Or you spawn threads in initialize() instead of in spawn_threads() and one thread ends with exception before initialize() fully completes. Or it may be both cases."
75
+ end
76
+
77
+ @daemon.stop
78
+ end
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ def watchdog
85
+ loop do
86
+ @mutex.synchronize do
87
+ return if @must_terminate
88
+ @restarting = false
89
+
90
+ @logger.info "#{self.class}#watchdog @name:`#{@name}' -- Starting..."
91
+ @daemon = @config["class-constantized"].new(@name, self)
92
+ end
93
+
94
+ begin
95
+ @daemon.perform_initialize_daemon
96
+ rescue *(@config["class-constantized"]::RESTART_ON) => exception
97
+ exception.log! @logger, :warn, "#{self.class}#watchdog @name:`#{@name}' -- Restarting daemon because of exception", (@process.controller.env == "production" ? :inspect : :inspect_with_backtrace)
98
+ restart_daemon
99
+ end
100
+
101
+ @daemon.join
102
+
103
+ delay
104
+ end
105
+ end
106
+
107
+ def delay
108
+ @mutex.synchronize do
109
+ unless @must_terminate
110
+ @logger.info "#{self.class}#delay @name:`#{@name}' -- Catch termination, restarting in #{RESTART_DELAY} seconds..."
111
+
112
+ Thread.new_with_exception_handling(lambda { @process.controller.stop }, @logger, :fatal, "#{self.class}#delay @name:`#{@name}'") do
113
+ # Если кто-то пошлёт сигнал во время сна, ожидающий получит два сигнала по пробуждении. В этом конкретном случае это не ппроблема, потому как этот кто-то - это стоп, и после него получать сигнал будет некому
114
+ sleep RESTART_DELAY
115
+ @delay.signal
116
+ end
117
+
118
+ @delay.wait(@mutex)
119
+ end
120
+ end
121
+ end
122
+
123
+ def restart_process_on_exception
124
+ yield
125
+ rescue Exception => exception
126
+ begin
127
+ exception.log!(@logger, :fatal, "#{self.class}##{caller.first.match(/`(.*)'/)[1]} @name:`#{@name}' -- Stopping process because of exception")
128
+ ensure
129
+ @process.controller.stop
130
+ end
131
+ end
132
+
133
+ end
134
+