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,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ class Process
5
+ class NotFoundError < RuntimeError
6
+ def initialize(id)
7
+ super("Process with id #{id} not found")
8
+ end
9
+ end
10
+
11
+ ATTRIBUTES = %i[id name pid hostname kind last_heartbeat_at supervisor_id].freeze
12
+ MEMORY_GRABBER = case RUBY_PLATFORM
13
+ when /linux/
14
+ ->(pid) {
15
+ IO.readlines("/proc/#{$$}/status").each do |line|
16
+ next unless line.start_with?("VmRSS:")
17
+ break line.split[1].to_i
18
+ end
19
+ }
20
+ when /darwin|bsd/
21
+ ->(pid) {
22
+ `ps -o pid,rss -p #{pid}`.lines.last.split.last.to_i
23
+ }
24
+ else
25
+ ->(pid) { 0 }
26
+ end
27
+
28
+ class << self
29
+ def register(**attributes)
30
+ attributes[:id] ||= SecureRandom.uuid
31
+ Lepus.instrument :register_process, **attributes do |payload|
32
+ new(**attributes).tap do |process|
33
+ ProcessRegistry.instance.add(process)
34
+ payload[:process_id] = process.id
35
+ end
36
+ rescue Exception => error # rubocop:disable Lint/RescueException
37
+ payload[:error] = error
38
+ raise
39
+ end
40
+ end
41
+
42
+ def prune(excluding: nil)
43
+ Lepus.instrument :prune_processes, size: 0 do |payload|
44
+ arr = prunable
45
+ arr.delete(excluding) if excluding
46
+ payload[:size] = arr.size
47
+
48
+ arr.each(&:prune)
49
+ end
50
+ end
51
+
52
+ def prunable
53
+ ProcessRegistry.instance.all.select do |process|
54
+ process.last_heartbeat_at && process.last_heartbeat_at < Time.now - Lepus.config.process_alive_threshold
55
+ end
56
+ end
57
+ end
58
+
59
+ attr_reader :attributes
60
+
61
+ def initialize(**attributes)
62
+ @attributes = attributes
63
+ @attributes[:id] ||= SecureRandom.uuid
64
+ end
65
+
66
+ ATTRIBUTES.each do |attribute|
67
+ define_method(attribute) { attributes[attribute] }
68
+ end
69
+
70
+ def last_heartbeat_at
71
+ attributes[:last_heartbeat_at]
72
+ end
73
+
74
+ def rss_memory
75
+ MEMORY_GRABBER.call(pid)
76
+ end
77
+
78
+ def heartbeat
79
+ now = Time.now
80
+ Lepus.instrument :heartbeat_process, process: self, rss_memory: 0, last_heartbeat_at: now do |payload|
81
+ ProcessRegistry.instance.find(id) # ensure process is still registered
82
+
83
+ update_attributes(last_heartbeat_at: now)
84
+ payload[:rss_memory] = rss_memory
85
+ rescue Exception => error # rubocop:disable Lint/RescueException
86
+ payload[:error] = error
87
+ raise
88
+ end
89
+ end
90
+
91
+ def update_attributes(new_attributes)
92
+ @attributes = @attributes.merge(new_attributes)
93
+ end
94
+
95
+ def destroy!
96
+ Lepus.instrument :destroy_process, process: self do |payload|
97
+ ProcessRegistry.instance.delete(self)
98
+ rescue Exception => error # rubocop:disable Lint/RescueException
99
+ payload[:error] = error
100
+ raise
101
+ end
102
+ end
103
+
104
+ def deregister(pruned: false)
105
+ Lepus.instrument :deregister_process, process: self, pruned: pruned do |payload|
106
+ destroy!
107
+
108
+ unless supervised? || pruned
109
+ supervisees.each(&:deregister)
110
+ end
111
+ rescue Exception => error # rubocop:disable Lint/RescueException
112
+ payload[:error] = error
113
+ raise
114
+ end
115
+ end
116
+
117
+ def prune
118
+ deregister(pruned: true)
119
+ end
120
+
121
+ def supervised?
122
+ !attributes[:supervisor_id].nil?
123
+ end
124
+
125
+ def eql?(other)
126
+ other.is_a?(self.class) && other.id == id && other.pid == pid
127
+ end
128
+ alias_method :==, :eql?
129
+
130
+ private
131
+
132
+ def supervisees
133
+ ProcessRegistry.instance.all.select { |process| process.supervisor_id == id }
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "singleton"
4
+
5
+ module Lepus
6
+ class ProcessRegistry
7
+ include Singleton
8
+
9
+ def initialize
10
+ @processes = ::Concurrent::Hash.new
11
+ end
12
+
13
+ def add(process)
14
+ @processes[process.id] = process
15
+ end
16
+
17
+ def delete(process)
18
+ @processes.delete(process.id)
19
+ end
20
+
21
+ def find(id)
22
+ @processes[id] || raise(Lepus::Process::NotFoundError.new(id))
23
+ end
24
+
25
+ def exists?(id)
26
+ @processes.key?(id)
27
+ end
28
+
29
+ def all
30
+ @processes.values
31
+ end
32
+
33
+ def clear
34
+ @processes.clear
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ module Processes
5
+ class Base
6
+ include Callbacks
7
+ include AppExecutor
8
+ include Registrable
9
+ include Interruptible
10
+ include Procline
11
+
12
+ attr_reader :name
13
+
14
+ def initialize(*)
15
+ @name = generate_name
16
+ @stopped = false
17
+ end
18
+
19
+ def kind
20
+ self.class.name.split("::").last
21
+ end
22
+
23
+ def hostname
24
+ @hostname ||= Socket.gethostname.force_encoding(Encoding::UTF_8)
25
+ end
26
+
27
+ def pid
28
+ @pid ||= ::Process.pid
29
+ end
30
+
31
+ def metadata
32
+ {}
33
+ end
34
+
35
+ def stop
36
+ @stopped = true
37
+ end
38
+
39
+ private
40
+
41
+ def generate_name
42
+ [kind.downcase, SecureRandom.hex(10)].join("-")
43
+ end
44
+
45
+ def stopped?
46
+ @stopped
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus::Processes
4
+ module Callbacks
5
+ def self.included(base)
6
+ base.extend(ClassMethods)
7
+ base.send :include, InstanceMethods
8
+ end
9
+
10
+ module InstanceMethods
11
+ def run_process_callbacks(name)
12
+ self.class.send(:"before_#{name}_callbacks").each do |method|
13
+ send(method)
14
+ end
15
+
16
+ result = yield if block_given?
17
+
18
+ self.class.send(:"after_#{name}_callbacks").each do |method|
19
+ send(method)
20
+ end
21
+
22
+ result
23
+ end
24
+ end
25
+
26
+ module ClassMethods
27
+ def inherited(base)
28
+ base.instance_variable_set(:@before_boot_callbacks, before_boot_callbacks.dup)
29
+ base.instance_variable_set(:@after_boot_callbacks, after_boot_callbacks.dup)
30
+ base.instance_variable_set(:@before_shutdown_callbacks, before_shutdown_callbacks.dup)
31
+ base.instance_variable_set(:@after_shutdown_callbacks, after_shutdown_callbacks.dup)
32
+ super
33
+ end
34
+
35
+ def before_boot(*methods)
36
+ @before_boot_callbacks ||= []
37
+ @before_boot_callbacks.concat methods
38
+ end
39
+
40
+ def after_boot(*methods)
41
+ @after_boot_callbacks ||= []
42
+ @after_boot_callbacks.concat methods
43
+ end
44
+
45
+ def before_shutdown(*methods)
46
+ @before_shutdown_callbacks ||= []
47
+ @before_shutdown_callbacks.concat methods
48
+ end
49
+
50
+ def after_shutdown(*methods)
51
+ @after_shutdown_callbacks ||= []
52
+ @after_shutdown_callbacks.concat methods
53
+ end
54
+
55
+ def before_boot_callbacks
56
+ @before_boot_callbacks || []
57
+ end
58
+
59
+ def after_boot_callbacks
60
+ @after_boot_callbacks || []
61
+ end
62
+
63
+ def before_shutdown_callbacks
64
+ @before_shutdown_callbacks || []
65
+ end
66
+
67
+ def after_shutdown_callbacks
68
+ @after_shutdown_callbacks || []
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus::Processes
4
+ class Consumer < Base
5
+ include Runnable
6
+
7
+ attr_reader :consumer_class
8
+
9
+ def initialize(class_name:, **options)
10
+ @consumer_class = class_name
11
+ @consumer_class = Lepus::Primitive::String.new(@consumer_class).constantize if @consumer_class.is_a?(String)
12
+
13
+ super(**options)
14
+ end
15
+
16
+ def metadata
17
+ super.merge(consumer_class: consumer_class.to_s)
18
+ end
19
+
20
+ def before_fork
21
+ return unless @consumer_class.respond_to?(:before_fork, true)
22
+
23
+ @consumer_class.send(:before_fork)
24
+ end
25
+
26
+ def after_fork
27
+ return unless @consumer_class.respond_to?(:after_fork, true)
28
+
29
+ @consumer_class.send(:after_fork)
30
+ end
31
+
32
+ private
33
+
34
+ SLEEP_INTERVAL = 5
35
+
36
+ def run
37
+ wrap_in_app_executor do
38
+ setup_consumer! # initialize bunny consumer within the #run method to ensure the process is running in the correct thread
39
+ end
40
+
41
+ loop do
42
+ break if shutting_down?
43
+
44
+ wrap_in_app_executor do
45
+ interruptible_sleep(SLEEP_INTERVAL)
46
+ end
47
+ end
48
+ ensure
49
+ Lepus.instrument(:shutdown_process, process: self) do
50
+ run_process_callbacks(:shutdown) { shutdown }
51
+ end
52
+ end
53
+
54
+ def shutdown
55
+ @subscriptions.to_a.each(&:cancel)
56
+ @channel&.close
57
+ @bunny&.close
58
+
59
+ super
60
+ end
61
+
62
+ def set_procline
63
+ procline consumer_class.name
64
+ end
65
+
66
+ def setup_consumer!
67
+ if consumer_class.config.nil?
68
+ raise Lepus::InvalidConsumerConfigError, "Consumer #{consumer_class.name} has no configuration"
69
+ end
70
+
71
+ @bunny = Thread.current[:lepus_bunny] || Lepus.config.create_connection
72
+ @channel = Thread.current[:lepus_channel] || begin
73
+ @bunny.create_channel(nil, 1, true).tap do |channel|
74
+ channel.prefetch(1) # @TODO make this configurable
75
+ channel.on_uncaught_exception { |error|
76
+ handle_thread_error(error)
77
+ }
78
+ end
79
+ end
80
+
81
+ @exchange = @channel.exchange(*consumer_class.config.exchange_args)
82
+ if (args = consumer_class.config.retry_queue_args)
83
+ @retry_queue = @channel.queue(*args)
84
+ end
85
+ if (args = consumer_class.config.error_queue_args)
86
+ @error_queue = @channel.queue(*args)
87
+ end
88
+
89
+ @subscriptions = Array.new((_threads = 1)) do |n| # may add multiple consumers in the future
90
+ main_queue = @channel.queue(*consumer_class.config.consumer_queue_args)
91
+ consumer_class.config.binds_args.each do |opts|
92
+ main_queue.bind(@exchange, **opts)
93
+ end
94
+
95
+ consumer_instance = consumer_class.new
96
+ consumer_wrapper = Lepus::ConsumerWrapper.new(
97
+ consumer_instance,
98
+ main_queue.channel,
99
+ main_queue,
100
+ "#{consumer_class.name}-#{n + 1}"
101
+ )
102
+ consumer_wrapper.on_delivery do |delivery_info, metadata, payload|
103
+ consumer_wrapper.process_delivery(delivery_info, metadata, payload)
104
+ end
105
+ main_queue.subscribe_with(consumer_wrapper)
106
+ end
107
+ rescue Bunny::TCPConnectionFailed, Bunny::PossibleAuthenticationFailureError
108
+ raise Lepus::ShutdownError
109
+ rescue Lepus::InvalidConsumerConfigError
110
+ raise Lepus::ShutdownError
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus::Processes
4
+ module Interruptible
5
+ def wake_up
6
+ interrupt
7
+ end
8
+
9
+ private
10
+
11
+ SELF_PIPE_BLOCK_SIZE = 11
12
+
13
+ def interrupt
14
+ self_pipe[:writer].write_nonblock(".")
15
+ rescue Errno::EAGAIN, Errno::EINTR
16
+ # Ignore writes that would block and retry
17
+ # if another signal arrived while writing
18
+ retry
19
+ end
20
+
21
+ def interruptible_sleep(time)
22
+ if time > 0 && self_pipe[:reader].wait_readable(time)
23
+ loop { self_pipe[:reader].read_nonblock(SELF_PIPE_BLOCK_SIZE) }
24
+ end
25
+ rescue Errno::EAGAIN, Errno::EINTR
26
+ end
27
+
28
+ # Self-pipe for signal-handling (http://cr.yp.to/docs/selfpipe.html)
29
+ def self_pipe
30
+ @self_pipe ||= create_self_pipe
31
+ end
32
+
33
+ def create_self_pipe
34
+ reader, writer = IO.pipe
35
+ {reader: reader, writer: writer}
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus::Processes
4
+ module Procline
5
+ # Sets the procline ($0)
6
+ # [lepus-supervisor: <string>]
7
+ def procline(string)
8
+ $0 = "[lepus-#{kind.downcase}: #{string}]"
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus::Processes
4
+ module Registrable
5
+ def self.included(base)
6
+ base.send :include, InstanceMethods
7
+ base.class_eval do
8
+ after_boot :register
9
+ after_boot :launch_heartbeat
10
+
11
+ before_shutdown :stop_heartbeat
12
+ after_shutdown :deregister
13
+ end
14
+ end
15
+
16
+ module InstanceMethods
17
+ def process_id
18
+ process&.id
19
+ end
20
+
21
+ private
22
+
23
+ attr_accessor :process
24
+
25
+ def register
26
+ @process = Lepus::Process.register(
27
+ kind: kind,
28
+ name: name,
29
+ pid: pid,
30
+ hostname: hostname,
31
+ supervisor_id: respond_to?(:supervisor) ? supervisor&.id : nil
32
+ )
33
+ end
34
+
35
+ def deregister
36
+ process&.deregister
37
+ end
38
+
39
+ def registered?
40
+ !!process
41
+ end
42
+
43
+ def launch_heartbeat
44
+ @heartbeat_task = ::Concurrent::TimerTask.new(execution_interval: Lepus.config.process_heartbeat_interval) do
45
+ wrap_in_app_executor { heartbeat }
46
+ end
47
+
48
+ @heartbeat_task.add_observer do |_time, _result, error|
49
+ handle_thread_error(error) if error
50
+ end
51
+
52
+ @heartbeat_task.execute
53
+ end
54
+
55
+ def stop_heartbeat
56
+ @heartbeat_task&.shutdown
57
+ end
58
+
59
+ def heartbeat
60
+ process.heartbeat
61
+ rescue Process::NotFoundError
62
+ self.process = nil
63
+ wake_up
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus::Processes
4
+ module Runnable
5
+ include Supervised
6
+
7
+ class InquiryMode
8
+ def initialize(mode)
9
+ @mode = mode.to_sym
10
+ end
11
+
12
+ %i[inline async fork].each do |value|
13
+ define_method(:"#{value}?") { @mode == value }
14
+ end
15
+ end
16
+
17
+ def start
18
+ boot
19
+
20
+ if running_async?
21
+ @thread = create_thread { run }
22
+ else
23
+ run
24
+ end
25
+ end
26
+
27
+ def stop
28
+ super
29
+
30
+ wake_up
31
+ @thread&.join
32
+ end
33
+
34
+ def mode=(mode)
35
+ @mode = InquiryMode.new(mode)
36
+ end
37
+
38
+ private
39
+
40
+ DEFAULT_MODE = :async
41
+
42
+ def mode
43
+ @mode ||= InquiryMode.new(DEFAULT_MODE)
44
+ end
45
+
46
+ def boot
47
+ Lepus.instrument(:start_process, process: self) do
48
+ run_process_callbacks(:boot) do
49
+ if running_as_fork?
50
+ register_signal_handlers
51
+ set_procline
52
+ end
53
+ end
54
+ end
55
+ end
56
+
57
+ def shutting_down?
58
+ stopped? || (running_as_fork? && supervisor_went_away?) || !registered? # || finished?
59
+ end
60
+
61
+ def run
62
+ raise NotImplementedError
63
+ end
64
+
65
+ # @TODO Add it to the inline mode
66
+ # def finished?
67
+ # running_inline? && all_work_completed?
68
+ # end
69
+
70
+ # def all_work_completed?
71
+ # false
72
+ # end
73
+
74
+ def shutdown
75
+ end
76
+
77
+ def set_procline
78
+ end
79
+
80
+ # def running_inline?
81
+ # mode.inline?
82
+ # end
83
+
84
+ def running_async?
85
+ mode.async?
86
+ end
87
+
88
+ def running_as_fork?
89
+ mode.fork?
90
+ end
91
+
92
+ def create_thread(&block)
93
+ Thread.new do
94
+ Thread.current.name = name
95
+ yield
96
+ rescue Exception => exception # rubocop:disable Lint/RescueException
97
+ handle_thread_error(exception)
98
+ raise
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus::Processes
4
+ module Supervised
5
+ def self.included(base)
6
+ base.send :include, InstanceMethods
7
+ base.class_eval do
8
+ attr_reader :supervisor
9
+ end
10
+ end
11
+
12
+ module InstanceMethods
13
+ def supervised_by(process)
14
+ @supervisor = process
15
+ end
16
+
17
+ private
18
+
19
+ def set_procline
20
+ procline "waiting"
21
+ end
22
+
23
+ def supervisor_went_away?
24
+ supervised? && supervisor.pid != ::Process.ppid
25
+ end
26
+
27
+ def supervised?
28
+ !!supervisor
29
+ end
30
+
31
+ def register_signal_handlers
32
+ %w[INT TERM].each do |signal|
33
+ trap(signal) do
34
+ stop
35
+ end
36
+ end
37
+
38
+ trap(:QUIT) do
39
+ exit!
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ module Processes
5
+ end
6
+ end