lepus 0.0.1.beta2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/specs.yml +44 -0
  3. data/.gitignore +12 -0
  4. data/.rspec +1 -0
  5. data/.rubocop.yml +35 -0
  6. data/.tool-versions +1 -0
  7. data/CHANGELOG.md +10 -0
  8. data/Gemfile +6 -0
  9. data/Gemfile.lock +120 -0
  10. data/LICENSE.txt +21 -0
  11. data/README.md +213 -0
  12. data/Rakefile +4 -0
  13. data/bin/console +9 -0
  14. data/bin/setup +7 -0
  15. data/docker-compose.yml +8 -0
  16. data/exec/lepus +9 -0
  17. data/gemfiles/rails52.gemfile +5 -0
  18. data/gemfiles/rails52.gemfile.lock +242 -0
  19. data/gemfiles/rails61.gemfile +5 -0
  20. data/gemfiles/rails61.gemfile.lock +260 -0
  21. data/lepus.gemspec +53 -0
  22. data/lib/lepus/app_executor.rb +19 -0
  23. data/lib/lepus/cli.rb +27 -0
  24. data/lib/lepus/configuration.rb +90 -0
  25. data/lib/lepus/consumer.rb +177 -0
  26. data/lib/lepus/consumer_config.rb +149 -0
  27. data/lib/lepus/consumer_wrapper.rb +46 -0
  28. data/lib/lepus/lifecycle_hooks.rb +49 -0
  29. data/lib/lepus/message.rb +37 -0
  30. data/lib/lepus/middleware.rb +18 -0
  31. data/lib/lepus/middlewares/honeybadger.rb +23 -0
  32. data/lib/lepus/middlewares/json.rb +35 -0
  33. data/lib/lepus/middlewares/max_retry.rb +57 -0
  34. data/lib/lepus/primitive/string.rb +55 -0
  35. data/lib/lepus/process.rb +136 -0
  36. data/lib/lepus/process_registry.rb +37 -0
  37. data/lib/lepus/processes/base.rb +50 -0
  38. data/lib/lepus/processes/callbacks.rb +72 -0
  39. data/lib/lepus/processes/consumer.rb +113 -0
  40. data/lib/lepus/processes/interruptible.rb +38 -0
  41. data/lib/lepus/processes/procline.rb +11 -0
  42. data/lib/lepus/processes/registrable.rb +67 -0
  43. data/lib/lepus/processes/runnable.rb +102 -0
  44. data/lib/lepus/processes/supervised.rb +44 -0
  45. data/lib/lepus/processes.rb +6 -0
  46. data/lib/lepus/producer.rb +42 -0
  47. data/lib/lepus/rails/log_subscriber.rb +120 -0
  48. data/lib/lepus/rails/railtie.rb +31 -0
  49. data/lib/lepus/rails.rb +7 -0
  50. data/lib/lepus/supervisor/config.rb +45 -0
  51. data/lib/lepus/supervisor/maintenance.rb +35 -0
  52. data/lib/lepus/supervisor/pidfile.rb +61 -0
  53. data/lib/lepus/supervisor/pidfiled.rb +29 -0
  54. data/lib/lepus/supervisor/signals.rb +71 -0
  55. data/lib/lepus/supervisor.rb +204 -0
  56. data/lib/lepus/timer.rb +29 -0
  57. data/lib/lepus/version.rb +5 -0
  58. data/lib/lepus.rb +95 -0
  59. data/lib/puma/plugin/lepus.rb +74 -0
  60. metadata +290 -0
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ class Producer
5
+ DEFAULT_EXCHANGE_OPTIONS = {
6
+ type: :topic,
7
+ durable: true,
8
+ auto_delete: false
9
+ }.freeze
10
+
11
+ DEFAULT_PUBLISH_OPTIONS = {
12
+ expiration: 7 * (60 * 60 * 24)
13
+ }.freeze
14
+
15
+ def initialize(exchange_name, **options)
16
+ @exchange_name = exchange_name
17
+ @exchange_options = DEFAULT_EXCHANGE_OPTIONS.merge(options)
18
+ end
19
+
20
+ def publish(message, **options)
21
+ payload = if message.is_a?(String)
22
+ options[:content_type] ||= "text/plain"
23
+ message
24
+ else
25
+ options[:content_type] ||= "application/json"
26
+ MultiJson.dump(message)
27
+ end
28
+
29
+ bunny.with_channel do |channel|
30
+ exchange = channel.exchange(@exchange_name, @exchange_options)
31
+ exchange.publish(
32
+ payload,
33
+ DEFAULT_PUBLISH_OPTIONS.merge(options)
34
+ )
35
+ end
36
+ end
37
+
38
+ def bunny
39
+ Thread.current[:lepus_bunny] ||= Lepus.config.create_connection(suffix: "producer")
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Lepus::LogSubscriber < ActiveSupport::LogSubscriber
4
+ def start_process(event)
5
+ process = event.payload[:process]
6
+
7
+ attributes = {
8
+ pid: process.pid,
9
+ hostname: process.hostname,
10
+ process_id: process.process_id,
11
+ name: process.name
12
+ }.merge(process.metadata)
13
+
14
+ info formatted_event(event, action: "Started #{process.kind}", **attributes)
15
+ end
16
+
17
+ def shutdown_process(event)
18
+ process = event.payload[:process]
19
+
20
+ attributes = {
21
+ pid: process.pid,
22
+ hostname: process.hostname,
23
+ process_id: process.process_id,
24
+ name: process.name
25
+ }.merge(process.metadata)
26
+
27
+ info formatted_event(event, action: "Shutdown #{process.kind}", **attributes)
28
+ end
29
+
30
+ def register_process(event)
31
+ process_kind = event.payload[:kind]
32
+ attributes = event.payload.slice(:pid, :hostname, :process_id, :name)
33
+
34
+ if (error = event.payload[:error])
35
+ warn formatted_event(event, action: "Error registering #{process_kind}", **attributes.merge(error: formatted_error(error)))
36
+ else
37
+ debug formatted_event(event, action: "Register #{process_kind}", **attributes)
38
+ end
39
+ end
40
+
41
+ def deregister_process(event)
42
+ process = event.payload[:process]
43
+
44
+ attributes = {
45
+ process_id: process.id,
46
+ pid: process.pid,
47
+ hostname: process.hostname,
48
+ name: process.name,
49
+ last_heartbeat_at: process.last_heartbeat_at&.strftime("%Y-%m-%d %H:%M:%S"),
50
+ claimed_size: event.payload[:claimed_size],
51
+ pruned: event.payload[:pruned]
52
+ }
53
+
54
+ if (error = event.payload[:error])
55
+ warn formatted_event(event, action: "Error deregistering #{process.kind}", **attributes.merge(error: formatted_error(error)))
56
+ else
57
+ debug formatted_event(event, action: "Deregister #{process.kind}", **attributes)
58
+ end
59
+ end
60
+
61
+ def prune_processes(event)
62
+ debug formatted_event(event, action: "Prune dead processes", **event.payload.slice(:size))
63
+ end
64
+
65
+ def thread_error(event)
66
+ error formatted_event(event, action: "Error in thread", error: formatted_error(event.payload[:error]))
67
+ end
68
+
69
+ def graceful_termination(event)
70
+ attributes = event.payload.slice(:process_id, :supervisor_pid, :supervised_processes)
71
+
72
+ if event.payload[:shutdown_timeout_exceeded]
73
+ warn formatted_event(event, action: "Supervisor wasn't terminated gracefully - shutdown timeout exceeded", **attributes)
74
+ else
75
+ info formatted_event(event, action: "Supervisor terminated gracefully", **attributes)
76
+ end
77
+ end
78
+
79
+ def immediate_termination(event)
80
+ info formatted_event(event, action: "Supervisor terminated immediately", **event.payload.slice(:process_id, :supervisor_pid, :supervised_processes))
81
+ end
82
+
83
+ def unhandled_signal_error(event)
84
+ error formatted_event(event, action: "Received unhandled signal", **event.payload.slice(:signal))
85
+ end
86
+
87
+ def replace_fork(event)
88
+ status = event.payload[:status]
89
+ attributes = event.payload.slice(:pid).merge \
90
+ status: status.exitstatus || "no exit status set",
91
+ pid_from_status: status.pid,
92
+ signaled: status.signaled?,
93
+ stopsig: status.stopsig,
94
+ termsig: status.termsig
95
+
96
+ if (replaced_fork = event.payload[:fork])
97
+ info formatted_event(event, action: "Replaced terminated #{replaced_fork.kind}", **attributes.merge(hostname: replaced_fork.hostname, name: replaced_fork.name))
98
+ else
99
+ warn formatted_event(event, action: "Tried to replace forked process but it had already died", **attributes)
100
+ end
101
+ end
102
+
103
+ private
104
+
105
+ def formatted_event(event, action:, **attributes)
106
+ "Lepus-#{Lepus::VERSION} #{action} (#{event.duration.round(1)}ms) #{formatted_attributes(**attributes)}"
107
+ end
108
+
109
+ def formatted_attributes(**attributes)
110
+ attributes.map { |attr, value| "#{attr}: #{value.inspect}" }.join(", ")
111
+ end
112
+
113
+ def formatted_error(error)
114
+ [error.class, error.message].compact.join(" ")
115
+ end
116
+
117
+ def logger
118
+ Lepus.logger
119
+ end
120
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ class Railtie < ::Rails::Railtie
5
+ config.lepus = ActiveSupport::OrderedOptions.new
6
+ config.lepus.app_executor = nil
7
+ config.lepus.on_thread_error = nil
8
+
9
+ initializer "lepus.app_executor", before: :run_prepare_callbacks do |app|
10
+ config.lepus.app_executor ||= app.executor
11
+ if ::Rails.respond_to?(:error) && config.lepus.on_thread_error.nil?
12
+ config.lepus.on_thread_error = ->(exception) { ::Rails.error.report(exception, handled: false) }
13
+ elsif config.lepus.on_thread_error.nil?
14
+ config.lepus.on_thread_error = ->(exception) { Lepus.logger.error(exception) }
15
+ end
16
+
17
+ Lepus.config.app_executor = config.lepus.app_executor
18
+ Lepus.config.on_thread_error = config.lepus.on_thread_error
19
+ end
20
+
21
+ initializer "lepus.logger" do
22
+ ActiveSupport.on_load(:lepus) do
23
+ self.logger = ::Rails.logger if logger == Lepus::DEFAULT_LOGGER
24
+ end
25
+
26
+ Lepus::LogSubscriber.attach_to :lepus
27
+ end
28
+ end
29
+
30
+ ActiveSupport.run_load_hooks(:lepus, self)
31
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+ require "active_support/log_subscriber"
5
+
6
+ require_relative "rails/log_subscriber"
7
+ require_relative "rails/railtie"
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "logger"
5
+
6
+ module Lepus
7
+ class Supervisor < Processes::Base
8
+ class Config
9
+ class ProcessStruct < Struct.new(:process_class, :attributes)
10
+ def instantiate
11
+ process_class.new(**attributes)
12
+ end
13
+ end
14
+
15
+ attr_accessor :pidfile, :require_file
16
+
17
+ def initialize(require_file: nil, pidfile: "tmp/pids/lepus.pid", **kwargs)
18
+ @pidfile = pidfile
19
+ @require_file = require_file
20
+ self.consumers = kwargs[:consumers] if kwargs.key?(:consumers)
21
+ end
22
+
23
+ def configured_processes
24
+ consumer_processes
25
+ end
26
+
27
+ def consumers=(vals)
28
+ @consumer_processes = nil
29
+ @consumers = Array(vals).map(&:to_s)
30
+ end
31
+
32
+ def consumers
33
+ @consumers ||= Lepus::Consumer.descendants.reject(&:abstract_class?).map(&:name).compact
34
+ end
35
+
36
+ protected
37
+
38
+ def consumer_processes
39
+ @consumer_processes ||= consumers.map do |class_name|
40
+ ProcessStruct.new(Lepus::Processes::Consumer, {class_name: class_name})
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,35 @@
1
+ module Lepus
2
+ class Supervisor < Processes::Base
3
+ module Maintenance
4
+ def self.included(base)
5
+ base.send :include, InstanceMethods
6
+ end
7
+
8
+ module InstanceMethods
9
+ private
10
+
11
+ def launch_maintenance_task
12
+ @maintenance_task = ::Concurrent::TimerTask.new(run_now: true, execution_interval: Lepus.config.process_alive_threshold) do
13
+ prune_dead_processes
14
+ end
15
+
16
+ @maintenance_task.add_observer do |_, _, error|
17
+ handle_thread_error(error) if error
18
+ end
19
+
20
+ @maintenance_task.execute
21
+ end
22
+
23
+ def stop_maintenance_task
24
+ @maintenance_task&.shutdown
25
+ end
26
+
27
+ def prune_dead_processes
28
+ wrap_in_app_executor do
29
+ Lepus::Process.prune(excluding: process)
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ class Supervisor < Processes::Base
5
+ class Pidfile
6
+ def initialize(path)
7
+ @path = path
8
+ @pid = ::Process.pid
9
+ end
10
+
11
+ def setup
12
+ check_status
13
+ write_file
14
+ set_at_exit_hook
15
+ end
16
+
17
+ def delete
18
+ delete_file
19
+ end
20
+
21
+ private
22
+
23
+ attr_reader :path, :pid
24
+
25
+ def check_status
26
+ if ::File.exist?(path)
27
+ existing_pid = ::File.read(path).strip.to_i
28
+ existing_pid > 0 && ::Process.kill(0, existing_pid)
29
+
30
+ already_running!
31
+ else
32
+ FileUtils.mkdir_p File.dirname(path)
33
+ end
34
+ rescue Errno::ESRCH
35
+ # Process is dead, ignore, just delete the file
36
+ delete
37
+ rescue Errno::EPERM
38
+ already_running!
39
+ end
40
+
41
+ def write_file
42
+ ::File.open(path, ::File::CREAT | ::File::EXCL | ::File::WRONLY) { |file| file.write(pid.to_s) }
43
+ rescue Errno::EEXIST
44
+ check_status
45
+ retry
46
+ end
47
+
48
+ def set_at_exit_hook
49
+ at_exit { delete if ::Process.pid == pid }
50
+ end
51
+
52
+ def delete_file
53
+ ::File.delete(path) if ::File.exist?(path)
54
+ end
55
+
56
+ def already_running!
57
+ abort "A supervisor is already running. Check #{path}"
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ class Supervisor < Processes::Base
5
+ module Pidfiled
6
+ def self.included(base)
7
+ base.send :include, InstanceMethods
8
+ base.class_eval do
9
+ before_boot :setup_pidfile
10
+ after_shutdown :delete_pidfile
11
+ end
12
+ end
13
+
14
+ module InstanceMethods
15
+ private
16
+
17
+ def setup_pidfile
18
+ if (path = configuration.pidfile)
19
+ @pidfile = Pidfile.new(path).tap(&:setup)
20
+ end
21
+ end
22
+
23
+ def delete_pidfile
24
+ @pidfile&.delete
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ class Supervisor < Processes::Base
5
+ module Signals
6
+ def self.included(base)
7
+ base.send :include, InstanceMethods
8
+ base.class_eval do
9
+ before_boot :register_signal_handlers
10
+ after_shutdown :restore_default_signal_handlers
11
+ end
12
+ end
13
+
14
+ module InstanceMethods
15
+ private
16
+
17
+ SIGNALS = %i[QUIT INT TERM]
18
+
19
+ def register_signal_handlers
20
+ SIGNALS.each do |signal|
21
+ trap(signal) do
22
+ signal_queue << signal
23
+ interrupt
24
+ end
25
+ end
26
+ end
27
+
28
+ def restore_default_signal_handlers
29
+ SIGNALS.each do |signal|
30
+ trap(signal, :DEFAULT)
31
+ end
32
+ end
33
+
34
+ def process_signal_queue
35
+ while (signal = signal_queue.shift)
36
+ handle_signal(signal)
37
+ end
38
+ end
39
+
40
+ def handle_signal(signal)
41
+ case signal
42
+ when :TERM, :INT
43
+ stop
44
+ terminate_gracefully
45
+ when :QUIT
46
+ stop
47
+ terminate_immediately
48
+ else
49
+ Lepus.instrument :unhandled_signal_error, signal: signal
50
+ end
51
+ end
52
+
53
+ def signal_processes(pids, signal)
54
+ pids.each do |pid|
55
+ signal_process pid, signal
56
+ end
57
+ end
58
+
59
+ def signal_process(pid, signal)
60
+ ::Process.kill signal, pid
61
+ rescue Errno::ESRCH
62
+ # Ignore, process died before
63
+ end
64
+
65
+ def signal_queue
66
+ @signal_queue ||= []
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ class Supervisor < Processes::Base
5
+ include LifecycleHooks
6
+ include Maintenance
7
+ include Signals
8
+ include Pidfiled
9
+
10
+ class << self
11
+ def start(**options)
12
+ # Lepus.config.supervisor = true
13
+ config = Config.new(**options)
14
+ new(config).tap(&:start)
15
+ end
16
+ end
17
+
18
+ def initialize(configuration)
19
+ @configuration = configuration
20
+ @forks = {}
21
+ @configured_processes = {}
22
+ ProcessRegistry.instance # Ensure the registry is initialized
23
+ end
24
+
25
+ def start
26
+ boot
27
+
28
+ run_start_hooks
29
+
30
+ start_processes
31
+ launch_maintenance_task
32
+
33
+ supervise
34
+ end
35
+
36
+ def stop
37
+ super
38
+ run_stop_hooks
39
+ end
40
+
41
+ private
42
+
43
+ attr_reader :configuration, :forks, :configured_processes
44
+
45
+ def boot
46
+ Lepus.instrument(:start_process, process: self) do
47
+ if configuration.require_file
48
+ Kernel.require configuration.require_file
49
+ else
50
+ begin
51
+ require "rails"
52
+ require_relative "rails"
53
+ require File.expand_path("config/environment", Dir.pwd)
54
+ rescue LoadError
55
+ # Rails not found
56
+ end
57
+ end
58
+
59
+ setup_consumers
60
+ check_bunny_connection
61
+
62
+ run_process_callbacks(:boot) do
63
+ sync_std_streams
64
+ end
65
+ end
66
+ end
67
+
68
+ def setup_consumers
69
+ Lepus.eager_load_consumers!
70
+
71
+ if configuration.consumers.empty?
72
+ abort "No consumers found. Exiting..."
73
+ end
74
+ end
75
+
76
+ def check_bunny_connection
77
+ temp_bunny = Lepus.config.create_connection(suffix: "(boot-check)")
78
+ temp_bunny.close
79
+ end
80
+
81
+ def start_processes
82
+ configuration.configured_processes.each do |configured_process|
83
+ start_process(configured_process)
84
+ end
85
+ end
86
+
87
+ def supervise
88
+ loop do
89
+ break if stopped?
90
+
91
+ set_procline
92
+ process_signal_queue
93
+
94
+ unless stopped?
95
+ reap_and_replace_terminated_forks
96
+ interruptible_sleep(1)
97
+ end
98
+ end
99
+ ensure
100
+ shutdown
101
+ end
102
+
103
+ def start_process(configured_process)
104
+ process_instance = configured_process.instantiate.tap do |instance|
105
+ instance.supervised_by process
106
+ instance.mode = :fork
107
+ end
108
+
109
+ process_instance.before_fork
110
+ pid = fork do
111
+ process_instance.after_fork
112
+ process_instance.start
113
+ end
114
+
115
+ configured_processes[pid] = configured_process
116
+ forks[pid] = process_instance
117
+ end
118
+
119
+ def set_procline
120
+ procline "supervising #{supervised_processes.join(", ")}"
121
+ end
122
+
123
+ def terminate_gracefully
124
+ Lepus.instrument(:graceful_termination, process_id: process_id, supervisor_pid: ::Process.pid, supervised_processes: supervised_processes) do |payload|
125
+ term_forks
126
+
127
+ shutdown_timeout = 5
128
+ puts "\nWaiting up to #{shutdown_timeout} seconds for processes to terminate gracefully..."
129
+ Timer.wait_until(shutdown_timeout, -> { all_forks_terminated? }) do
130
+ reap_terminated_forks
131
+ end
132
+
133
+ unless all_forks_terminated?
134
+ payload[:shutdown_timeout_exceeded] = true
135
+ terminate_immediately
136
+ end
137
+ end
138
+ end
139
+
140
+ def terminate_immediately
141
+ Lepus.instrument(:immediate_termination, process_id: process_id, supervisor_pid: ::Process.pid, supervised_processes: supervised_processes) do
142
+ quit_forks
143
+ end
144
+ end
145
+
146
+ def shutdown
147
+ Lepus.instrument(:shutdown_process, process: self) do
148
+ run_process_callbacks(:shutdown) do
149
+ stop_maintenance_task
150
+ end
151
+ end
152
+ end
153
+
154
+ def sync_std_streams
155
+ $stdout.sync = $stderr.sync = true
156
+ end
157
+
158
+ def supervised_processes
159
+ forks.keys
160
+ end
161
+
162
+ def term_forks
163
+ signal_processes(forks.keys, :TERM)
164
+ end
165
+
166
+ def quit_forks
167
+ signal_processes(forks.keys, :QUIT)
168
+ end
169
+
170
+ def reap_and_replace_terminated_forks
171
+ loop do
172
+ pid, status = ::Process.waitpid2(-1, ::Process::WNOHANG)
173
+ break unless pid
174
+
175
+ replace_fork(pid, status)
176
+ end
177
+ end
178
+
179
+ def reap_terminated_forks
180
+ loop do
181
+ pid, _ = ::Process.waitpid2(-1, ::Process::WNOHANG)
182
+ break unless pid
183
+
184
+ configured_processes.delete(pid)
185
+ end
186
+ rescue SystemCallError
187
+ # All children already reaped
188
+ end
189
+
190
+ def replace_fork(pid, status)
191
+ Lepus.instrument(:replace_fork, supervisor_pid: ::Process.pid, pid: pid, status: status) do |payload|
192
+ if (terminated_fork = forks.delete(pid))
193
+ payload[:fork] = terminated_fork
194
+
195
+ start_process(configured_processes.delete(pid))
196
+ end
197
+ end
198
+ end
199
+
200
+ def all_forks_terminated?
201
+ forks.empty?
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ module Timer
5
+ extend self
6
+
7
+ def wait_until(timeout, condition, &block)
8
+ if timeout > 0
9
+ deadline = monotonic_time_now + timeout
10
+
11
+ while monotonic_time_now < deadline && !condition.call
12
+ sleep 0.1
13
+ yield
14
+ end
15
+ else
16
+ until condition.call
17
+ sleep 0.5
18
+ yield
19
+ end
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def monotonic_time_now
26
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ VERSION = "0.0.1.beta2"
5
+ end