lepus 0.0.1.beta2

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 (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