bluepill 0.1.2 → 0.1.4

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.
Files changed (43) hide show
  1. checksums.yaml +5 -5
  2. data/CHANGELOG.md +820 -0
  3. data/CONTRIBUTING.md +31 -0
  4. data/LICENSE +1 -1
  5. data/README.md +69 -30
  6. data/bin/bluepill +2 -2
  7. data/bluepill.gemspec +17 -6
  8. data/lib/bluepill/application.rb +311 -43
  9. data/lib/bluepill/condition_watch.rb +105 -10
  10. data/lib/bluepill/controller.rb +125 -14
  11. data/lib/bluepill/dsl/app_proxy.rb +103 -4
  12. data/lib/bluepill/dsl/process_factory.rb +185 -62
  13. data/lib/bluepill/dsl/process_proxy.rb +132 -14
  14. data/lib/bluepill/dsl.rb +27 -5
  15. data/lib/bluepill/group.rb +142 -9
  16. data/lib/bluepill/logger.rb +122 -7
  17. data/lib/bluepill/process.rb +582 -211
  18. data/lib/bluepill/process_commands.rb +262 -0
  19. data/lib/bluepill/process_conditions/always_true.rb +18 -4
  20. data/lib/bluepill/process_conditions/cpu_usage.rb +34 -0
  21. data/lib/bluepill/process_conditions/file_time.rb +40 -4
  22. data/lib/bluepill/process_conditions/http.rb +81 -23
  23. data/lib/bluepill/process_conditions/mem_usage.rb +64 -5
  24. data/lib/bluepill/process_conditions/process_condition.rb +45 -2
  25. data/lib/bluepill/process_conditions/running_time.rb +36 -1
  26. data/lib/bluepill/process_conditions/zombie_process.rb +26 -5
  27. data/lib/bluepill/process_conditions.rb +15 -1
  28. data/lib/bluepill/process_journal.rb +256 -104
  29. data/lib/bluepill/process_statistics.rb +33 -3
  30. data/lib/bluepill/socket.rb +63 -16
  31. data/lib/bluepill/system/commands.rb +286 -0
  32. data/lib/bluepill/system.rb +193 -229
  33. data/lib/bluepill/trigger.rb +111 -15
  34. data/lib/bluepill/triggers/flapping.rb +86 -5
  35. data/lib/bluepill/util/rotational_array.rb +25 -2
  36. data/lib/bluepill/version.rb +71 -19
  37. data/lib/bluepill.rb +25 -2
  38. data/sig/bluepill.rbs +926 -0
  39. data/sig/manifest.yaml +7 -0
  40. metadata +16 -35
  41. data/bin/sample_forking_server +0 -66
  42. data/lib/bluepill/application/client.rb +0 -6
  43. data/lib/bluepill/application/server.rb +0 -21
@@ -1,14 +1,49 @@
1
1
  module Bluepill
2
2
  module ProcessConditions
3
+ # Checks how long a process has been running, in seconds
4
+ #
5
+ # A config names it `checks :running_time`, with below: the longest the
6
+ # process may run (24.hours, say), to restart a process that has run for
7
+ # too long.
8
+ #
9
+ # @example
10
+ # process.checks :running_time, every: 10.minutes, below: 24.hours
11
+ #
12
+ # @api private
3
13
  class RunningTime < ProcessCondition
14
+ # Create the check
15
+ #
16
+ # @param [Hash{Symbol => Object}] options the check's options, with
17
+ # :below, the longest the process may run, in seconds
18
+ #
19
+ # @return [void]
20
+ #
21
+ # @api private
4
22
  def initialize(options = {})
23
+ super
5
24
  @below = options[:below]
6
25
  end
7
26
 
27
+ # Read how long the process has run from the tick's ps table
28
+ #
29
+ # @param [Integer, nil] pid the process's pid
30
+ # @param [Boolean] _include_children whether to include its children
31
+ #
32
+ # @return [Integer] the seconds since it started
33
+ #
34
+ # @api private
8
35
  def run(pid, _include_children)
9
- System.running_time(pid)
36
+ # Like the other ps conditions, a process missing from ps reads as 0
37
+ System.running_time(pid).to_i
10
38
  end
11
39
 
40
+ # Return whether the process has run for less than the limit
41
+ #
42
+ # @param [Integer] value the seconds run read
43
+ #
44
+ # @return [Boolean] true when it's below the limit
45
+ #
46
+ # @api private
12
47
  def check(value)
13
48
  value < @below
14
49
  end
@@ -1,17 +1,38 @@
1
1
  module Bluepill
2
2
  module ProcessConditions
3
- # Process must have cache_actual_pid set to false to function correctly:
3
+ # Checks whether a process is a zombie, which ps shows as <defunct>
4
4
  #
5
- # process.checks :zombie_process, every: 5.seconds
6
- # process.cache_actual_pid = false
7
-
5
+ # A config names it `checks :zombie_process`. The process must have
6
+ # cache_actual_pid set to false for it to work correctly.
7
+ #
8
+ # @example
9
+ # process.checks :zombie_process, every: 5.seconds
10
+ # process.cache_actual_pid = false
11
+ #
12
+ # @api private
8
13
  class ZombieProcess < ProcessCondition
14
+ # Read the process's command line from the tick's ps table
15
+ #
16
+ # @param [Integer, nil] pid the process's pid
17
+ # @param [Boolean] _include_children whether to include its children
18
+ #
19
+ # @return [String, nil] the command line, or nil for a process ps
20
+ # doesn't list
21
+ #
22
+ # @api private
9
23
  def run(pid, _include_children)
10
24
  System.command(pid)
11
25
  end
12
26
 
27
+ # Return whether the process isn't a zombie
28
+ #
29
+ # @param [String, nil] value the command line run read
30
+ #
31
+ # @return [Boolean] true unless the command line says <defunct>
32
+ #
33
+ # @api private
13
34
  def check(value)
14
- (value =~ /\<defunct\>/).nil?
35
+ (value =~ /<defunct>/).nil?
15
36
  end
16
37
  end
17
38
  end
@@ -1,5 +1,19 @@
1
1
  module Bluepill
2
+ # The checks a process can run, named in a config by their underscored
3
+ # class name, such as `checks :mem_usage`
4
+ #
5
+ # @api private
2
6
  module ProcessConditions
7
+ # Return the check class a config names
8
+ #
9
+ # @param [Symbol, String] name the check's underscored name, such as
10
+ # :mem_usage
11
+ #
12
+ # @return [Class] the ProcessCondition subclass, such as MemUsage
13
+ #
14
+ # @raise [NameError] when no check has that name
15
+ #
16
+ # @api private
3
17
  def self.[](name)
4
18
  const_get(name.to_s.camelcase)
5
19
  end
@@ -7,6 +21,6 @@ module Bluepill
7
21
  end
8
22
 
9
23
  require 'bluepill/process_conditions/process_condition'
10
- Dir["#{File.dirname(__FILE__)}/process_conditions/*.rb"].each do |pc|
24
+ Dir["#{File.dirname(__FILE__)}/process_conditions/*.rb"].sort.each do |pc|
11
25
  require pc
12
26
  end
@@ -1,172 +1,314 @@
1
1
  require 'bluepill/system'
2
2
 
3
3
  module Bluepill
4
+ # Remember the pids and process groups of the processes bluepill starts
5
+ #
6
+ # Each process has a pid journal and a process group journal, files in the
7
+ # journal directory with one id a line. When bluepill starts again, it
8
+ # kills whatever an earlier run left in them.
9
+ #
10
+ # @api private
4
11
  module ProcessJournal
5
- module_function
6
-
7
12
  class << self
13
+ # Return the logger the journal writes to
14
+ #
15
+ # @return [Logger] the first logger that was set
16
+ #
17
+ # @api private
8
18
  attr_reader :logger
19
+
20
+ # Return the directory the journals are in
21
+ #
22
+ # @return [String] the journals directory under bluepill's base dir
23
+ #
24
+ # @api private
9
25
  attr_reader :journal_base_dir
10
26
 
27
+ # Set the logger the journal writes to, unless it already has one
28
+ #
29
+ # Only the first logger is kept.
30
+ #
31
+ # @param [Logger] new_logger the logger to use
32
+ #
33
+ # @return [Logger] the logger
34
+ #
35
+ # @api private
11
36
  def logger=(new_logger)
12
37
  @logger ||= new_logger
13
38
  end
14
39
 
40
+ # Set the journal directory from bluepill's base dir, and create it
41
+ #
42
+ # Only the first base dir is kept. The directory is made world-writable
43
+ # so processes that run as other users can lock their journals.
44
+ #
45
+ # @param [String] base_dir bluepill's base directory
46
+ #
47
+ # @return [void]
48
+ #
49
+ # @api private
15
50
  def base_dir=(base_dir)
16
51
  @journal_base_dir ||= File.join(base_dir, 'journals')
17
52
  FileUtils.mkdir_p(@journal_base_dir) unless File.exist?(@journal_base_dir)
18
- FileUtils.chmod(0777, @journal_base_dir)
53
+ FileUtils.chmod(0o777, @journal_base_dir)
19
54
  end
20
55
  end
21
56
 
22
- def skip_pid?(pid)
57
+ # Return whether a pid can't be journaled or signaled
58
+ #
59
+ # A pid of 1 or less would reach init, or a whole process group.
60
+ #
61
+ # @param [Integer, nil] pid the pid to check
62
+ #
63
+ # @return [Boolean] true unless pid is an Integer above 1
64
+ #
65
+ # @api private
66
+ def self.skip_pid?(pid)
23
67
  !pid.is_a?(Integer) || pid <= 1
24
68
  end
25
69
 
26
- def skip_pgid?(pgid)
70
+ # Return whether a process group id can't be journaled or signaled
71
+ #
72
+ # @param [Integer, nil] pgid the process group id to check
73
+ #
74
+ # @return [Boolean] true unless pgid is an Integer above 1
75
+ #
76
+ # @api private
77
+ def self.skip_pgid?(pgid)
27
78
  !pgid.is_a?(Integer) || pgid <= 1
28
79
  end
29
80
 
30
- # atomic operation on POSIX filesystems, since
31
- # f.flock(File::LOCK_SH) is not available on all platforms
32
- def acquire_atomic_fs_lock(name)
81
+ # Run a block while holding a lock on a journal
82
+ #
83
+ # The lock is a directory, since making one is atomic on POSIX
84
+ # filesystems and f.flock(File::LOCK_SH) is not available on all
85
+ # platforms. It waits up to 10 seconds for another process's lock.
86
+ #
87
+ # @param [String] name the journal's file name
88
+ #
89
+ # @yield while holding the lock
90
+ # @yieldreturn [Object] what the method returns
91
+ #
92
+ # @return [Object] what the block returns
93
+ #
94
+ # @raise [RuntimeError] if the lock is still held after 10 tries
95
+ #
96
+ # @api private
97
+ def self.acquire_atomic_fs_lock(name)
98
+ lock = "#{name}.lock"
33
99
  times = 0
34
- name += '.lock'
35
- Dir.mkdir name, 0700
36
- logger.debug("Acquired lock #{name}")
37
- yield
38
- rescue Errno::EEXIST
39
- times += 1
40
- logger.debug("Waiting for lock #{name}")
41
- sleep 1
42
- if times < 10
43
- retry
44
- else
45
- logger.info("Timeout waiting for lock #{name}")
46
- raise "Timeout waiting for lock #{name}"
100
+ begin
101
+ Dir.mkdir lock, 0o700
102
+ rescue Errno::EEXIST
103
+ times += 1
104
+ if times < 10
105
+ logger.debug("Waiting for lock #{lock}")
106
+ sleep 1
107
+ retry
108
+ end
109
+ logger.info("Timeout waiting for lock #{lock}")
110
+ raise "Timeout waiting for lock #{lock}"
111
+ end
112
+ logger.debug("Acquired lock #{lock}")
113
+ begin
114
+ yield
115
+ ensure
116
+ clear_atomic_fs_lock(lock)
47
117
  end
48
- ensure
49
- clear_atomic_fs_lock(name)
50
118
  end
51
119
 
52
- def clear_all_atomic_fs_locks
120
+ # Remove the journal locks in the current directory
121
+ #
122
+ # @return [void]
123
+ #
124
+ # @api private
125
+ def self.clear_all_atomic_fs_locks
53
126
  Dir['.*.lock'].each do |f|
54
127
  System.delete_if_exists(f) if File.directory?(f)
55
128
  end
56
129
  end
57
130
 
58
- def pid_journal_filename(journal_name)
59
- File.join(@journal_base_dir, ".bluepill_pids_journal.#{journal_name}")
131
+ # Return the path of a process's pid journal
132
+ #
133
+ # @param [String] journal_name the process's name
134
+ #
135
+ # @return [String] the journal's path
136
+ #
137
+ # @api private
138
+ def self.pid_journal_filename(journal_name)
139
+ File.join(journal_base_dir, ".bluepill_pids_journal.#{journal_name}")
60
140
  end
61
141
 
62
- def pgid_journal_filename(journal_name)
63
- File.join(@journal_base_dir, ".bluepill_pgids_journal.#{journal_name}")
142
+ # Return the path of a process's process group journal
143
+ #
144
+ # @param [String] journal_name the process's name
145
+ #
146
+ # @return [String] the journal's path
147
+ #
148
+ # @api private
149
+ def self.pgid_journal_filename(journal_name)
150
+ File.join(journal_base_dir, ".bluepill_pgids_journal.#{journal_name}")
64
151
  end
65
152
 
66
- def pid_journal(filename)
153
+ # Return the valid pids in a pid journal
154
+ #
155
+ # @param [String] filename the journal's path
156
+ #
157
+ # @return [Array<Integer>] the pids, or none if there's no journal
158
+ #
159
+ # @api private
160
+ def self.pid_journal(filename)
67
161
  logger.debug("pid journal file: #{filename}")
68
- result = File.open(filename, 'r').readlines.collect(&:to_i).reject { |pid| skip_pid?(pid) }
162
+ result = File.readlines(filename).collect(&:to_i).reject { |pid| skip_pid?(pid) }
69
163
  logger.debug("pid journal = #{result.join(' ')}")
70
164
  result
71
165
  rescue Errno::ENOENT
72
166
  []
73
167
  end
74
168
 
75
- def pgid_journal(filename)
169
+ # Return the valid process group ids in a process group journal
170
+ #
171
+ # @param [String] filename the journal's path
172
+ #
173
+ # @return [Array<Integer>] the process group ids, or none if there's no
174
+ # journal
175
+ #
176
+ # @api private
177
+ def self.pgid_journal(filename)
76
178
  logger.debug("pgid journal file: #{filename}")
77
- result = File.open(filename, 'r').readlines.collect(&:to_i).reject { |pgid| skip_pgid?(pgid) }
179
+ result = File.readlines(filename).collect(&:to_i).reject { |pgid| skip_pgid?(pgid) }
78
180
  logger.debug("pgid journal = #{result.join(' ')}")
79
181
  result
80
182
  rescue Errno::ENOENT
81
183
  []
82
184
  end
83
185
 
84
- def clear_atomic_fs_lock(name)
186
+ # Release a journal lock, if it's there
187
+ #
188
+ # @param [String] name the lock's directory
189
+ #
190
+ # @return [void]
191
+ #
192
+ # @api private
193
+ def self.clear_atomic_fs_lock(name)
85
194
  return unless File.directory?(name)
195
+
86
196
  Dir.rmdir(name)
87
197
  logger.debug("Cleared lock #{name}")
88
198
  end
89
199
 
90
- def kill_all_from_all_journals
91
- pids = Dir['.bluepill_pids_journal.*'].collect { |p| p.sub(/^\.bluepill_pids_journal\./, '') }
92
- pids.reject! { |p| p =~ /\.lock$/ }
200
+ # Kill what every journal in the current directory lists
201
+ #
202
+ # @return [void]
203
+ #
204
+ # @api private
205
+ def self.kill_all_from_all_journals
206
+ pids = Dir['.bluepill_pids_journal.*'].collect { |p| p.sub(/\A\.bluepill_pids_journal\./, '') }
207
+ pids.reject! { |p| p.end_with?('.lock') }
93
208
  pids.each do |journal_name|
94
209
  kill_all_from_journal(journal_name)
95
210
  end
96
211
  end
97
212
 
98
- def kill_all_from_journal(journal_name)
213
+ # Kill the processes and process groups that a process's journals list
214
+ #
215
+ # @param [String] journal_name the process's name
216
+ #
217
+ # @return [void]
218
+ #
219
+ # @api private
220
+ def self.kill_all_from_journal(journal_name)
99
221
  kill_all_pids_from_journal(journal_name)
100
222
  kill_all_pgids_from_journal(journal_name)
101
223
  end
102
224
 
103
- def kill_all_pgids_from_journal(journal_name)
225
+ # Kill the process groups that a process's journal lists
226
+ #
227
+ # @param [String] journal_name the process's name
228
+ #
229
+ # @return [void]
230
+ #
231
+ # @api private
232
+ def self.kill_all_pgids_from_journal(journal_name)
104
233
  filename = pgid_journal_filename(journal_name)
105
- j = pgid_journal(filename)
106
- if j.length > 0
107
- acquire_atomic_fs_lock(filename) do
108
- j.each do |pgid|
109
- begin
110
- ::Process.kill('TERM', -pgid)
111
- logger.info("Termed old process group #{pgid}")
112
- rescue Errno::ESRCH
113
- logger.debug("Unable to term missing process group #{pgid}")
114
- end
115
- end
116
-
117
- if j.count { |pgid| System.pid_alive?(pgid) } > 1
118
- sleep(1)
119
- j.each do |pgid|
120
- begin
121
- ::Process.kill('KILL', -pgid)
122
- logger.info("Killed old process group #{pgid}")
123
- rescue Errno::ESRCH
124
- logger.debug("Unable to kill missing process group #{pgid}")
125
- end
126
- end
127
- end
128
- System.delete_if_exists(filename) # reset journal
129
- logger.debug('Journal cleanup completed')
130
- end
131
- else
234
+ kill_all_from_journal_file(filename, pgid_journal(filename), 'process group', -1)
235
+ end
236
+
237
+ # Kill the processes that a process's journal lists
238
+ #
239
+ # @param [String] journal_name the process's name
240
+ #
241
+ # @return [void]
242
+ #
243
+ # @api private
244
+ def self.kill_all_pids_from_journal(journal_name)
245
+ filename = pid_journal_filename(journal_name)
246
+ kill_all_from_journal_file(filename, pid_journal(filename), 'process', 1)
247
+ end
248
+
249
+ # Kill the ids a journal lists, and then delete the journal
250
+ #
251
+ # Sends TERM, waits a second for any survivors, then sends KILL to those
252
+ # still alive. A negative sign targets process groups instead of pids.
253
+ #
254
+ # @param [String] filename the journal's path
255
+ # @param [Array<Integer>] ids the pids or process group ids in it
256
+ # @param [String] label what the ids are, for the log
257
+ # @param [Integer] sign 1 for pids, or -1 for process groups
258
+ #
259
+ # @return [void]
260
+ #
261
+ # @api private
262
+ def self.kill_all_from_journal_file(filename, ids, label, sign)
263
+ if ids.empty?
132
264
  logger.debug('No previous process journal - Skipping cleanup')
265
+ return
266
+ end
267
+
268
+ acquire_atomic_fs_lock(filename) do
269
+ signal_journaled('TERM', ids, label, sign)
270
+ survivors = ids.select { |id| System.pid_alive?(sign * id) }
271
+ unless survivors.empty?
272
+ sleep(1)
273
+ survivors = survivors.select { |id| System.pid_alive?(sign * id) }
274
+ signal_journaled('KILL', survivors, label, sign)
275
+ end
276
+ System.delete_if_exists(filename) # reset journal
277
+ logger.debug('Journal cleanup completed')
133
278
  end
134
279
  end
135
280
 
136
- def kill_all_pids_from_journal(journal_name)
137
- filename = pid_journal_filename(journal_name)
138
- j = pid_journal(filename)
139
- if j.length > 0
140
- acquire_atomic_fs_lock(filename) do
141
- j.each do |pid|
142
- begin
143
- ::Process.kill('TERM', pid)
144
- logger.info("Termed old process #{pid}")
145
- rescue Errno::ESRCH
146
- logger.debug("Unable to term missing process #{pid}")
147
- end
148
- end
149
-
150
- if j.count { |pid| System.pid_alive?(pid) } > 1
151
- sleep(1)
152
- j.each do |pid|
153
- begin
154
- ::Process.kill('KILL', pid)
155
- logger.info("Killed old process #{pid}")
156
- rescue Errno::ESRCH
157
- logger.debug("Unable to kill missing process #{pid}")
158
- end
159
- end
160
- end
161
- System.delete_if_exists(filename) # reset journal
162
- logger.debug('Journal cleanup completed')
281
+ # Send a signal to each journaled id, and log what happened
282
+ #
283
+ # @param [String] signal TERM or KILL
284
+ # @param [Array<Integer>] ids the pids or process group ids
285
+ # @param [String] label what the ids are, for the log
286
+ # @param [Integer] sign 1 for pids, or -1 for process groups
287
+ #
288
+ # @return [void]
289
+ #
290
+ # @api private
291
+ def self.signal_journaled(signal, ids, label, sign)
292
+ past, verb = signal.eql?('TERM') ? %w[Termed term] : %w[Killed kill]
293
+ ids.each do |id|
294
+ begin
295
+ ::Process.kill(signal, sign * id)
296
+ logger.info("#{past} old #{label} #{id}")
297
+ rescue Errno::ESRCH
298
+ logger.debug("Unable to #{verb} missing #{label} #{id}")
163
299
  end
164
- else
165
- logger.debug('No previous process journal - Skipping cleanup')
166
300
  end
167
301
  end
168
302
 
169
- def append_pgid_to_journal(journal_name, pgid)
303
+ # Add a process group id to a process's journal, unless it's there
304
+ #
305
+ # @param [String] journal_name the process's name
306
+ # @param [Integer, nil] pgid the process group id
307
+ #
308
+ # @return [void]
309
+ #
310
+ # @api private
311
+ def self.append_pgid_to_journal(journal_name, pgid)
170
312
  if skip_pgid?(pgid)
171
313
  logger.debug("Skipping invalid pgid #{pgid} (class #{pgid.class})")
172
314
  return
@@ -178,22 +320,32 @@ module Bluepill
178
320
  logger.debug("Skipping duplicate pgid #{pgid} already in journal #{journal_name}")
179
321
  else
180
322
  logger.debug("Saving pgid #{pgid} to process journal #{journal_name}")
181
- File.open(filename, 'a+', 0600) { |f| f.puts(pgid) }
323
+ File.open(filename, 'a+', 0o600) { |f| f.puts(pgid) }
182
324
  logger.info("Saved pgid #{pgid} to journal #{journal_name}")
183
- logger.debug("Journal now = #{File.open(filename, 'r').read}")
325
+ logger.debug("Journal now = #{File.read(filename)}")
184
326
  end
185
327
  end
186
328
  end
187
329
 
188
- def append_pid_to_journal(journal_name, pid)
189
- begin
190
- append_pgid_to_journal(journal_name, ::Process.getpgid(pid))
191
- rescue Errno::ESRCH
192
- end
330
+ # Add a pid, and its process group id, to a process's journals
331
+ #
332
+ # @param [String] journal_name the process's name
333
+ # @param [Integer, nil] pid the pid
334
+ #
335
+ # @return [void]
336
+ #
337
+ # @api private
338
+ def self.append_pid_to_journal(journal_name, pid)
193
339
  if skip_pid?(pid)
194
340
  logger.debug("Skipping invalid pid #{pid} (class #{pid.class})")
195
341
  return
196
342
  end
343
+ # skip_pid? made sure it's an Integer
344
+ valid_pid = pid #: Integer
345
+ begin
346
+ append_pgid_to_journal(journal_name, ::Process.getpgid(valid_pid))
347
+ rescue Errno::ESRCH
348
+ end
197
349
 
198
350
  filename = pid_journal_filename(journal_name)
199
351
  acquire_atomic_fs_lock(filename) do
@@ -201,9 +353,9 @@ module Bluepill
201
353
  logger.debug("Skipping duplicate pid #{pid} already in journal #{journal_name}")
202
354
  else
203
355
  logger.debug("Saving pid #{pid} to process journal #{journal_name}")
204
- File.open(filename, 'a+', 0600) { |f| f.puts(pid) }
356
+ File.open(filename, 'a+', 0o600) { |f| f.puts(pid) }
205
357
  logger.info("Saved pid #{pid} to journal #{journal_name}")
206
- logger.debug("Journal now = #{File.open(filename, 'r').read}")
358
+ logger.debug("Journal now = #{File.read(filename)}")
207
359
  end
208
360
  end
209
361
  end
@@ -1,21 +1,51 @@
1
1
  module Bluepill
2
+ # Keeps a process's last few events, which bluepill status shows
3
+ #
4
+ # @api private
2
5
  class ProcessStatistics
3
- STRFTIME = '%m/%d/%Y %H:%I:%S'.freeze
6
+ # How status shows each event's time
7
+ STRFTIME = '%m/%d/%Y %H:%M:%S'.freeze
8
+
9
+ # How many events are kept
4
10
  EVENTS_TO_PERSIST = 10
5
11
 
12
+ # Return the recorded events, newest last
13
+ #
14
+ # @return [Util::RotationalArray<Array(Symbol, String, nil, Time)>] each
15
+ # event, its reason and its time
16
+ #
17
+ # @api private
6
18
  attr_reader :events
7
19
 
8
- # possibly persist this data.
20
+ # Create an empty event history
21
+ #
22
+ # @return [void]
23
+ #
24
+ # @api private
9
25
  def initialize
10
26
  @events = Util::RotationalArray.new(EVENTS_TO_PERSIST)
11
27
  end
12
28
 
29
+ # Add an event to the history, with the time it happened
30
+ #
31
+ # @param [Symbol, String] event the event, such as :restart
32
+ # @param [String, nil] reason why it happened
33
+ #
34
+ # @return [Util::RotationalArray<Array(Symbol, String, nil, Time)>] the
35
+ # history, newest last
36
+ #
37
+ # @api private
13
38
  def record_event(event, reason)
14
39
  events.push([event, reason, Time.now])
15
40
  end
16
41
 
42
+ # Return the history as text, newest first, for bluepill status
43
+ #
44
+ # @return [String]
45
+ #
46
+ # @api private
17
47
  def to_s
18
- str = events.reverse.collect do |(event, reason, time)|
48
+ str = events.reverse.collect do |event, reason, time|
19
49
  " #{event} at #{time.strftime(STRFTIME)} - #{reason || 'unspecified'}"
20
50
  end.join("\n")
21
51