senotrusov-ruby-daemonic-threads 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+