lepus 0.0.1.beta2 → 0.1.0

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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/linter.yml +21 -0
  3. data/.github/workflows/specs.yml +93 -13
  4. data/.gitignore +2 -0
  5. data/.rubocop.yml +10 -0
  6. data/.tool-versions +1 -1
  7. data/Gemfile +7 -0
  8. data/Gemfile.lock +36 -9
  9. data/Makefile +19 -0
  10. data/README.md +562 -7
  11. data/bin/setup +5 -2
  12. data/config.ru +14 -0
  13. data/docker-compose.yml +5 -3
  14. data/docs/README.md +80 -0
  15. data/docs/cli.md +108 -0
  16. data/docs/configuration.md +171 -0
  17. data/docs/consumers.md +168 -0
  18. data/docs/getting-started.md +136 -0
  19. data/docs/images/lepus-web.png +0 -0
  20. data/docs/middleware.md +240 -0
  21. data/docs/producers.md +173 -0
  22. data/docs/prometheus.md +112 -0
  23. data/docs/rails.md +161 -0
  24. data/docs/supervisor.md +112 -0
  25. data/docs/testing.md +141 -0
  26. data/docs/web.md +85 -0
  27. data/examples/grafana-dashboard.json +450 -0
  28. data/gemfiles/Gemfile.rails-5.2 +7 -0
  29. data/gemfiles/{rails52.gemfile.lock → Gemfile.rails-5.2.lock} +102 -69
  30. data/gemfiles/Gemfile.rails-6.1 +7 -0
  31. data/gemfiles/{rails61.gemfile.lock → Gemfile.rails-6.1.lock} +113 -79
  32. data/gemfiles/{rails52.gemfile → Gemfile.rails-7.2} +1 -1
  33. data/gemfiles/Gemfile.rails-7.2.lock +321 -0
  34. data/gemfiles/{rails61.gemfile → Gemfile.rails-8.0} +1 -1
  35. data/gemfiles/Gemfile.rails-8.0.lock +322 -0
  36. data/lepus.gemspec +7 -1
  37. data/lib/lepus/cli.rb +35 -4
  38. data/lib/lepus/configuration.rb +107 -0
  39. data/lib/lepus/connection_pool.rb +135 -0
  40. data/lib/lepus/consumer.rb +59 -41
  41. data/lib/lepus/consumers/config.rb +183 -0
  42. data/lib/lepus/consumers/handler.rb +56 -0
  43. data/lib/lepus/consumers/middleware_chain.rb +22 -0
  44. data/lib/lepus/consumers/middlewares/exception_logger.rb +27 -0
  45. data/lib/lepus/consumers/middlewares/honeybadger.rb +33 -0
  46. data/lib/lepus/consumers/middlewares/json.rb +37 -0
  47. data/lib/lepus/consumers/middlewares/max_retry.rb +83 -0
  48. data/lib/lepus/consumers/middlewares/unique.rb +65 -0
  49. data/lib/lepus/consumers/stats.rb +70 -0
  50. data/lib/lepus/consumers/stats_registry.rb +29 -0
  51. data/lib/lepus/consumers/worker.rb +141 -0
  52. data/lib/lepus/consumers/worker_factory.rb +124 -0
  53. data/lib/lepus/consumers.rb +6 -0
  54. data/lib/lepus/message/delivery_info.rb +72 -0
  55. data/lib/lepus/message/metadata.rb +99 -0
  56. data/lib/lepus/message.rb +88 -5
  57. data/lib/lepus/middleware_chain.rb +83 -0
  58. data/lib/lepus/primitive/hash.rb +29 -0
  59. data/lib/lepus/process.rb +24 -24
  60. data/lib/lepus/process_registry/backend.rb +49 -0
  61. data/lib/lepus/process_registry/file_backend.rb +108 -0
  62. data/lib/lepus/process_registry/message_builder.rb +72 -0
  63. data/lib/lepus/process_registry/rabbitmq_backend.rb +153 -0
  64. data/lib/lepus/process_registry.rb +56 -23
  65. data/lib/lepus/processes/base.rb +0 -5
  66. data/lib/lepus/processes/callbacks.rb +3 -0
  67. data/lib/lepus/processes/interruptible.rb +4 -8
  68. data/lib/lepus/processes/procline.rb +1 -1
  69. data/lib/lepus/processes/registrable.rb +1 -1
  70. data/lib/lepus/processes/runnable.rb +1 -1
  71. data/lib/lepus/processes.rb +15 -0
  72. data/lib/lepus/producer.rb +141 -30
  73. data/lib/lepus/producers/config.rb +46 -0
  74. data/lib/lepus/producers/definition.rb +48 -0
  75. data/lib/lepus/producers/hooks.rb +170 -0
  76. data/lib/lepus/producers/middleware_chain.rb +22 -0
  77. data/lib/lepus/producers/middlewares/correlation_id.rb +37 -0
  78. data/lib/lepus/producers/middlewares/header.rb +47 -0
  79. data/lib/lepus/producers/middlewares/instrumentation.rb +30 -0
  80. data/lib/lepus/producers/middlewares/json.rb +47 -0
  81. data/lib/lepus/producers/middlewares/unique.rb +67 -0
  82. data/lib/lepus/producers.rb +7 -0
  83. data/lib/lepus/prometheus/collector.rb +149 -0
  84. data/lib/lepus/prometheus/instrumentation.rb +168 -0
  85. data/lib/lepus/prometheus.rb +48 -0
  86. data/lib/lepus/publisher.rb +67 -0
  87. data/lib/lepus/supervisor/children_pipes.rb +25 -0
  88. data/lib/lepus/supervisor/lifecycle_hooks.rb +50 -0
  89. data/lib/lepus/supervisor/pidfiled.rb +1 -1
  90. data/lib/lepus/supervisor/registry_cleaner.rb +22 -0
  91. data/lib/lepus/supervisor.rb +129 -25
  92. data/lib/lepus/testing/exchange.rb +95 -0
  93. data/lib/lepus/testing/message_builder.rb +177 -0
  94. data/lib/lepus/testing/rspec_matchers.rb +258 -0
  95. data/lib/lepus/testing.rb +210 -0
  96. data/lib/lepus/unique.rb +18 -0
  97. data/lib/lepus/version.rb +1 -1
  98. data/lib/lepus/web/aggregator.rb +154 -0
  99. data/lib/lepus/web/api.rb +132 -0
  100. data/lib/lepus/web/app.rb +37 -0
  101. data/lib/lepus/web/management_api.rb +192 -0
  102. data/lib/lepus/web/respond_with.rb +28 -0
  103. data/lib/lepus/web.rb +238 -0
  104. data/lib/lepus.rb +39 -28
  105. data/test_offline.html +189 -0
  106. data/web/assets/css/styles.css +635 -0
  107. data/web/assets/js/app.js +6 -0
  108. data/web/assets/js/bootstrap.js +20 -0
  109. data/web/assets/js/controllers/connection_controller.js +44 -0
  110. data/web/assets/js/controllers/dashboard_controller.js +499 -0
  111. data/web/assets/js/controllers/queue_controller.js +17 -0
  112. data/web/assets/js/controllers/theme_controller.js +31 -0
  113. data/web/assets/js/offline-manager.js +233 -0
  114. data/web/assets/js/service-worker-manager.js +65 -0
  115. data/web/index.html +159 -0
  116. data/web/sw.js +144 -0
  117. metadata +177 -18
  118. data/lib/lepus/consumer_config.rb +0 -149
  119. data/lib/lepus/consumer_wrapper.rb +0 -46
  120. data/lib/lepus/lifecycle_hooks.rb +0 -49
  121. data/lib/lepus/middlewares/honeybadger.rb +0 -23
  122. data/lib/lepus/middlewares/json.rb +0 -35
  123. data/lib/lepus/middlewares/max_retry.rb +0 -57
  124. data/lib/lepus/processes/consumer.rb +0 -113
  125. data/lib/lepus/supervisor/config.rb +0 -45
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ class Message
5
+ # Internal data class representing delivery information.
6
+ # Provides the same interface as Bunny::DeliveryInfo (duck typing).
7
+ class DeliveryInfo
8
+ KNOWN_ATTRIBUTES = %i[delivery_tag redelivered exchange routing_key consumer_tag].freeze
9
+
10
+ attr_reader(*KNOWN_ATTRIBUTES)
11
+
12
+ def self.from_bunny(bunny_delivery_info)
13
+ new(
14
+ delivery_tag: bunny_delivery_info.delivery_tag,
15
+ redelivered: bunny_delivery_info.redelivered,
16
+ exchange: bunny_delivery_info.exchange,
17
+ routing_key: bunny_delivery_info.routing_key,
18
+ consumer_tag: bunny_delivery_info.consumer_tag
19
+ )
20
+ end
21
+
22
+ def initialize(**attrs)
23
+ @delivery_tag = attrs[:delivery_tag]
24
+ @redelivered = attrs.fetch(:redelivered, false)
25
+ @exchange = attrs[:exchange]
26
+ @routing_key = attrs[:routing_key]
27
+ @consumer_tag = attrs[:consumer_tag]
28
+ @extra_attributes = attrs.reject { |k, _| KNOWN_ATTRIBUTES.include?(k) }
29
+ end
30
+
31
+ def to_h
32
+ {
33
+ delivery_tag: delivery_tag,
34
+ redelivered: redelivered,
35
+ exchange: exchange,
36
+ routing_key: routing_key,
37
+ consumer_tag: consumer_tag
38
+ }.merge(@extra_attributes)
39
+ end
40
+
41
+ # Hash-style access to properties (compatible with Bunny::DeliveryInfo)
42
+ # @param key [Symbol, String] The property name
43
+ # @return [Object, nil] The property value
44
+ def [](key)
45
+ to_h[key.to_sym]
46
+ end
47
+
48
+ # Support dynamic attribute access for compatibility
49
+ def method_missing(method_name, *args)
50
+ return super if method_name.to_s.end_with?("=")
51
+ return super if args.any?
52
+
53
+ self[method_name]
54
+ end
55
+
56
+ def respond_to_missing?(method_name, include_private = false)
57
+ !method_name.to_s.end_with?("=") || super
58
+ end
59
+
60
+ def eql?(other)
61
+ return false unless other.is_a?(self.class)
62
+
63
+ delivery_tag == other.delivery_tag &&
64
+ redelivered == other.redelivered &&
65
+ exchange == other.exchange &&
66
+ routing_key == other.routing_key &&
67
+ consumer_tag == other.consumer_tag
68
+ end
69
+ alias_method :==, :eql?
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ class Message
5
+ # Internal data class representing message metadata/properties.
6
+ # Provides the same interface as Bunny::MessageProperties (duck typing).
7
+ class Metadata
8
+ KNOWN_ATTRIBUTES = %i[
9
+ content_type content_encoding headers delivery_mode priority
10
+ correlation_id reply_to expiration message_id timestamp
11
+ type user_id app_id cluster_id
12
+ ].freeze
13
+
14
+ attr_reader(*KNOWN_ATTRIBUTES)
15
+
16
+ def self.from_bunny(bunny_metadata)
17
+ new(
18
+ content_type: bunny_metadata.content_type,
19
+ content_encoding: bunny_metadata.content_encoding,
20
+ headers: bunny_metadata.headers,
21
+ delivery_mode: bunny_metadata.delivery_mode,
22
+ priority: bunny_metadata.priority,
23
+ correlation_id: bunny_metadata.correlation_id,
24
+ reply_to: bunny_metadata.reply_to,
25
+ expiration: bunny_metadata.expiration,
26
+ message_id: bunny_metadata.message_id,
27
+ timestamp: bunny_metadata.timestamp,
28
+ type: bunny_metadata.type,
29
+ user_id: bunny_metadata.user_id,
30
+ app_id: bunny_metadata.app_id,
31
+ cluster_id: bunny_metadata.cluster_id
32
+ )
33
+ end
34
+
35
+ def initialize(**attrs)
36
+ @content_type = attrs[:content_type]
37
+ @content_encoding = attrs[:content_encoding]
38
+ @headers = attrs[:headers]
39
+ @delivery_mode = attrs[:delivery_mode]
40
+ @priority = attrs[:priority]
41
+ @correlation_id = attrs[:correlation_id]
42
+ @reply_to = attrs[:reply_to]
43
+ @expiration = attrs[:expiration]
44
+ @message_id = attrs[:message_id]
45
+ @timestamp = attrs[:timestamp]
46
+ @type = attrs[:type]
47
+ @user_id = attrs[:user_id]
48
+ @app_id = attrs[:app_id]
49
+ @cluster_id = attrs[:cluster_id]
50
+ @extra_attributes = attrs.reject { |k, _| KNOWN_ATTRIBUTES.include?(k) }
51
+ end
52
+
53
+ def to_h
54
+ {
55
+ content_type: content_type,
56
+ content_encoding: content_encoding,
57
+ headers: headers,
58
+ delivery_mode: delivery_mode,
59
+ priority: priority,
60
+ correlation_id: correlation_id,
61
+ reply_to: reply_to,
62
+ expiration: expiration,
63
+ message_id: message_id,
64
+ timestamp: timestamp,
65
+ type: type,
66
+ user_id: user_id,
67
+ app_id: app_id,
68
+ cluster_id: cluster_id
69
+ }.merge(@extra_attributes)
70
+ end
71
+
72
+ # Hash-style access to properties (compatible with Bunny::MessageProperties)
73
+ # @param key [Symbol, String] The property name
74
+ # @return [Object, nil] The property value
75
+ def [](key)
76
+ to_h[key.to_sym]
77
+ end
78
+
79
+ # Support dynamic attribute access for compatibility
80
+ def method_missing(method_name, *args)
81
+ return super if method_name.to_s.end_with?("=")
82
+ return super if args.any?
83
+
84
+ self[method_name]
85
+ end
86
+
87
+ def respond_to_missing?(method_name, include_private = false)
88
+ !method_name.to_s.end_with?("=") || super
89
+ end
90
+
91
+ def eql?(other)
92
+ return false unless other.is_a?(self.class)
93
+
94
+ to_h == other.to_h
95
+ end
96
+ alias_method :==, :eql?
97
+ end
98
+ end
99
+ end
data/lib/lepus/message.rb CHANGED
@@ -2,20 +2,64 @@
2
2
 
3
3
  module Lepus
4
4
  class Message
5
- attr_reader :delivery_info, :metadata, :payload
5
+ attr_reader :delivery_info, :metadata, :payload, :publish_options
6
+ attr_accessor :consumer_class
6
7
 
7
- def initialize(delivery_info, metadata, payload)
8
+ # Coerce raw Bunny objects into a Message with internal data classes.
9
+ # This decouples the Message from Bunny-specific objects.
10
+ # If the objects are already internal classes, they are used as-is.
11
+ #
12
+ # @param bunny_delivery_info [Bunny::DeliveryInfo, DeliveryInfo] The delivery info from Bunny or internal class
13
+ # @param bunny_metadata [Bunny::MessageProperties, Metadata] The metadata from Bunny or internal class
14
+ # @param payload [String] The raw message payload
15
+ # @return [Message]
16
+ def self.coerce(bunny_delivery_info, bunny_metadata, payload)
17
+ delivery_info = if bunny_delivery_info.is_a?(DeliveryInfo)
18
+ bunny_delivery_info
19
+ else
20
+ DeliveryInfo.from_bunny(bunny_delivery_info)
21
+ end
22
+
23
+ metadata = if bunny_metadata.is_a?(Metadata)
24
+ bunny_metadata
25
+ else
26
+ Metadata.from_bunny(bunny_metadata)
27
+ end
28
+
29
+ channel = bunny_delivery_info.respond_to?(:channel) ? bunny_delivery_info.channel : nil
30
+
31
+ new(delivery_info, metadata, payload, channel: channel)
32
+ end
33
+
34
+ def initialize(delivery_info, metadata, payload, channel: nil, publish_options: nil)
8
35
  @delivery_info = delivery_info
9
36
  @metadata = metadata
10
37
  @payload = payload
38
+ @channel = channel
39
+ @publish_options = publish_options || {}
40
+ end
41
+
42
+ # Returns the channel associated with this message.
43
+ # Falls back to checking out a new channel from the producer connection pool if none is set.
44
+ # Note: The fallback channel is not memoized; each call will checkout a new channel.
45
+ #
46
+ # @return [Bunny::Channel, nil] The channel or nil if unavailable
47
+ def channel
48
+ return @channel if @channel
49
+
50
+ checkout_channel
11
51
  end
12
52
 
13
- def mutate(payload: nil, metadata: nil, delivery_info: nil)
53
+ def mutate(payload: nil, metadata: nil, delivery_info: nil, consumer_class: nil, channel: nil, publish_options: nil)
14
54
  self.class.new(
15
55
  delivery_info || @delivery_info,
16
56
  metadata || @metadata,
17
- payload || @payload
18
- )
57
+ payload || @payload,
58
+ channel: channel || @channel,
59
+ publish_options: publish_options || @publish_options
60
+ ).tap do |message|
61
+ message.consumer_class = consumer_class || @consumer_class
62
+ end
19
63
  end
20
64
 
21
65
  def to_h
@@ -26,6 +70,35 @@ module Lepus
26
70
  }
27
71
  end
28
72
 
73
+ # Converts metadata and delivery_info into a hash suitable for Publisher.publish.
74
+ # Used by producer middlewares to pass options to the publisher.
75
+ #
76
+ # @return [Hash] Options hash compatible with Publisher.publish
77
+ def to_publish_options
78
+ opts = @publish_options.dup
79
+
80
+ if delivery_info
81
+ opts[:routing_key] = delivery_info.routing_key if delivery_info.routing_key
82
+ end
83
+
84
+ if metadata
85
+ opts[:headers] = metadata.headers if metadata.headers
86
+ opts[:content_type] = metadata.content_type if metadata.content_type
87
+ opts[:content_encoding] = metadata.content_encoding if metadata.content_encoding
88
+ opts[:correlation_id] = metadata.correlation_id if metadata.correlation_id
89
+ opts[:reply_to] = metadata.reply_to if metadata.reply_to
90
+ opts[:expiration] = metadata.expiration if metadata.expiration
91
+ opts[:message_id] = metadata.message_id if metadata.message_id
92
+ opts[:timestamp] = metadata.timestamp if metadata.timestamp
93
+ opts[:type] = metadata.type if metadata.type
94
+ opts[:app_id] = metadata.app_id if metadata.app_id
95
+ opts[:priority] = metadata.priority if metadata.priority
96
+ opts[:delivery_mode] = metadata.delivery_mode if metadata.delivery_mode
97
+ end
98
+
99
+ opts
100
+ end
101
+
29
102
  def eql?(other)
30
103
  other.is_a?(self.class) &&
31
104
  delivery_info == other.delivery_info &&
@@ -33,5 +106,15 @@ module Lepus
33
106
  payload == other.payload
34
107
  end
35
108
  alias_method :==, :eql?
109
+
110
+ private
111
+
112
+ def checkout_channel
113
+ Lepus.config.producer_config.with_connection do |connection|
114
+ connection.create_channel
115
+ end
116
+ rescue
117
+ nil
118
+ end
36
119
  end
37
120
  end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ # Manages middleware registration and execution for producers.
5
+ # Middlewares can modify the message (payload, headers, routing_key, etc.)
6
+ # before it is published to RabbitMQ.
7
+ class MiddlewareChain
8
+ attr_reader :middlewares
9
+
10
+ def initialize
11
+ @middlewares = []
12
+ end
13
+
14
+ # Registers a middleware to the chain.
15
+ #
16
+ # @param middleware [Symbol, String, Class<Lepus::Middleware>] The middleware to register.
17
+ # Can be a symbol/string (auto-loaded from producers/middlewares/) or a class.
18
+ # @param opts [Hash] Options passed to the middleware constructor.
19
+ # @return [self]
20
+ def use(middleware, opts = {})
21
+ instance = resolve_middleware(middleware, opts)
22
+ @middlewares << instance
23
+ self
24
+ end
25
+
26
+ # Executes the middleware chain with the given message.
27
+ # The final action (publishing) is called after all middlewares have processed the message.
28
+ #
29
+ # @param message [Lepus::Message] The message to process.
30
+ # @yield [message] Block called as the final action with the processed message.
31
+ # @return [Object] The result of the final action.
32
+ def execute(message, &final_action)
33
+ chain = @middlewares.reduce(final_action) do |next_middleware, middleware|
34
+ ->(msg) { middleware.call(msg, next_middleware) }
35
+ end
36
+ chain.call(message)
37
+ end
38
+
39
+ # Creates a combined chain from multiple chains.
40
+ # Used to merge global and per-producer middleware chains.
41
+ #
42
+ # @param chains [Array<MiddlewareChain>] The chains to combine.
43
+ # @return [MiddlewareChain] A new chain containing all middlewares.
44
+ def self.combine(*chains)
45
+ combined = new
46
+ chains.each do |chain|
47
+ chain.middlewares.each { |m| combined.middlewares << m }
48
+ end
49
+ combined
50
+ end
51
+
52
+ # Returns true if the chain has no middlewares.
53
+ #
54
+ # @return [Boolean]
55
+ def empty?
56
+ @middlewares.empty?
57
+ end
58
+
59
+ # Returns the number of middlewares in the chain.
60
+ #
61
+ # @return [Integer]
62
+ def size
63
+ @middlewares.size
64
+ end
65
+
66
+ private
67
+
68
+ def resolve_middleware(middleware, opts)
69
+ case middleware
70
+ when Symbol, String
71
+ load_middleware(middleware, opts)
72
+ when Class
73
+ middleware.new(**opts)
74
+ else
75
+ raise ArgumentError, "Middleware must be a Symbol, String, or Class, got #{middleware.class}"
76
+ end
77
+ end
78
+
79
+ def load_middleware(name, opts)
80
+ raise NotImplementedError, "Subclass must implement #load_middleware"
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus::Primitive
4
+ class Hash < ::Hash
5
+ def initialize(*args)
6
+ args.each do |arg|
7
+ arg.each do |key, value|
8
+ self[key] = value
9
+ end
10
+ end
11
+ end
12
+
13
+ def deep_symbolize_keys
14
+ each_with_object(self.class.new) do |(key, value), result|
15
+ sym_key = key.is_a?(::String) ? key.to_sym : key
16
+ sym_value = if value.is_a?(::Hash)
17
+ self.class.new(value).deep_symbolize_keys
18
+ elsif value.is_a?(::Array)
19
+ value.map do |item|
20
+ item.is_a?(::Hash) ? self.class.new(item).deep_symbolize_keys : item
21
+ end
22
+ else
23
+ value
24
+ end
25
+ result[sym_key] = sym_value
26
+ end
27
+ end
28
+ end
29
+ end
data/lib/lepus/process.rb CHANGED
@@ -9,28 +9,13 @@ module Lepus
9
9
  end
10
10
 
11
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
12
 
28
13
  class << self
29
14
  def register(**attributes)
30
15
  attributes[:id] ||= SecureRandom.uuid
31
16
  Lepus.instrument :register_process, **attributes do |payload|
32
17
  new(**attributes).tap do |process|
33
- ProcessRegistry.instance.add(process)
18
+ ProcessRegistry.add(process)
34
19
  payload[:process_id] = process.id
35
20
  end
36
21
  rescue Exception => error # rubocop:disable Lint/RescueException
@@ -50,10 +35,16 @@ module Lepus
50
35
  end
51
36
 
52
37
  def prunable
53
- ProcessRegistry.instance.all.select do |process|
38
+ ProcessRegistry.all.select do |process|
54
39
  process.last_heartbeat_at && process.last_heartbeat_at < Time.now - Lepus.config.process_alive_threshold
55
40
  end
56
41
  end
42
+
43
+ def coerce(raw)
44
+ attrs = raw.transform_keys(&:to_sym)
45
+ attrs[:last_heartbeat_at] = Time.iso8601(attrs[:last_heartbeat_at]) if attrs[:last_heartbeat_at]
46
+ new(**attrs)
47
+ end
57
48
  end
58
49
 
59
50
  attr_reader :attributes
@@ -63,6 +54,14 @@ module Lepus
63
54
  @attributes[:id] ||= SecureRandom.uuid
64
55
  end
65
56
 
57
+ def to_h
58
+ attributes.dup.tap do |hash|
59
+ if hash[:last_heartbeat_at]
60
+ hash[:last_heartbeat_at] = hash[:last_heartbeat_at].iso8601(6)
61
+ end
62
+ end
63
+ end
64
+
66
65
  ATTRIBUTES.each do |attribute|
67
66
  define_method(attribute) { attributes[attribute] }
68
67
  end
@@ -72,15 +71,15 @@ module Lepus
72
71
  end
73
72
 
74
73
  def rss_memory
75
- MEMORY_GRABBER.call(pid)
74
+ Processes::MEMORY_GRABBER.call(pid)
76
75
  end
77
76
 
78
- def heartbeat
77
+ def heartbeat(metrics: {})
79
78
  now = Time.now
80
79
  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
80
+ ProcessRegistry.find(id) # ensure process is still registered
82
81
 
83
- update_attributes(last_heartbeat_at: now)
82
+ update_attributes(last_heartbeat_at: now, metrics: metrics)
84
83
  payload[:rss_memory] = rss_memory
85
84
  rescue Exception => error # rubocop:disable Lint/RescueException
86
85
  payload[:error] = error
@@ -88,13 +87,14 @@ module Lepus
88
87
  end
89
88
  end
90
89
 
91
- def update_attributes(new_attributes)
90
+ def update_attributes(metrics: {}, **new_attributes)
92
91
  @attributes = @attributes.merge(new_attributes)
92
+ ProcessRegistry.update(self, metrics: metrics)
93
93
  end
94
94
 
95
95
  def destroy!
96
96
  Lepus.instrument :destroy_process, process: self do |payload|
97
- ProcessRegistry.instance.delete(self)
97
+ ProcessRegistry.delete(self)
98
98
  rescue Exception => error # rubocop:disable Lint/RescueException
99
99
  payload[:error] = error
100
100
  raise
@@ -130,7 +130,7 @@ module Lepus
130
130
  private
131
131
 
132
132
  def supervisees
133
- ProcessRegistry.instance.all.select { |process| process.supervisor_id == id }
133
+ ProcessRegistry.all.select { |process| process.supervisor_id == id }
134
134
  end
135
135
  end
136
136
  end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ class ProcessRegistry
5
+ # Abstract backend interface for process registry storage.
6
+ # Implementations must provide all methods defined here.
7
+ module Backend
8
+ def start
9
+ raise NotImplementedError, "#{self.class}#start must be implemented"
10
+ end
11
+
12
+ def stop
13
+ raise NotImplementedError, "#{self.class}#stop must be implemented"
14
+ end
15
+
16
+ def add(process)
17
+ raise NotImplementedError, "#{self.class}#add must be implemented"
18
+ end
19
+
20
+ def update(process, metrics: {})
21
+ add(process, metrics: metrics)
22
+ end
23
+
24
+ def delete(process)
25
+ raise NotImplementedError, "#{self.class}#delete must be implemented"
26
+ end
27
+
28
+ def find(id)
29
+ raise NotImplementedError, "#{self.class}#find must be implemented"
30
+ end
31
+
32
+ def exists?(id)
33
+ raise NotImplementedError, "#{self.class}#exists? must be implemented"
34
+ end
35
+
36
+ def all
37
+ raise NotImplementedError, "#{self.class}#all must be implemented"
38
+ end
39
+
40
+ def count
41
+ raise NotImplementedError, "#{self.class}#count must be implemented"
42
+ end
43
+
44
+ def clear
45
+ raise NotImplementedError, "#{self.class}#clear must be implemented"
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "tmpdir"
5
+ require "base64"
6
+
7
+ module Lepus
8
+ class ProcessRegistry
9
+ # File-based backend for process registry storage.
10
+ # Stores process data in a file using Marshal serialization.
11
+ # This is the default backend for apps not using the web dashboard.
12
+ class FileBackend
13
+ include Backend
14
+
15
+ attr_reader :path
16
+
17
+ def initialize(path: nil)
18
+ @path = path
19
+ end
20
+
21
+ def start
22
+ @path ||= Pathname.new(Dir.tmpdir).join("lepus_process_registry.store")
23
+ end
24
+
25
+ def stop
26
+ path.delete if path&.exist?
27
+ end
28
+
29
+ def add(process, metrics: {})
30
+ transaction do |data|
31
+ data[process.id] = process.to_h
32
+ end
33
+ end
34
+
35
+ def delete(process)
36
+ transaction do |data|
37
+ data.delete(process.id)
38
+ end
39
+ end
40
+
41
+ def find(id)
42
+ raw = read.fetch(id) { raise(Lepus::Process::NotFoundError.new(id)) }
43
+ Lepus::Process.coerce(raw)
44
+ end
45
+
46
+ def exists?(id)
47
+ read.key?(id)
48
+ end
49
+
50
+ def all
51
+ read.keys.map { |id| find(id) }
52
+ end
53
+
54
+ def count
55
+ return 0 unless path
56
+
57
+ read.size
58
+ end
59
+
60
+ def clear
61
+ return unless path
62
+
63
+ write({})
64
+ end
65
+
66
+ private
67
+
68
+ def transaction
69
+ data = read
70
+ yield data
71
+ write(data)
72
+ end
73
+
74
+ def read
75
+ with_lock(File::LOCK_SH) do |f|
76
+ if f.size.zero?
77
+ {}
78
+ else
79
+ encoded = f.read
80
+ Marshal.load(Base64.strict_decode64(encoded))
81
+ end
82
+ end
83
+ end
84
+
85
+ def write(data)
86
+ with_lock(File::LOCK_EX) do |f|
87
+ f.rewind
88
+ f.truncate(0)
89
+ encoded = Base64.strict_encode64(Marshal.dump(data))
90
+ f.write(encoded)
91
+ f.flush
92
+ end
93
+ end
94
+
95
+ def with_lock(lock_type)
96
+ unless path
97
+ raise "ProcessRegistry not started. Call Lepus::ProcessRegistry.start first."
98
+ end
99
+ File.open(path, File::RDWR | File::CREAT | File::BINARY, 0o644) do |f|
100
+ f.flock(lock_type)
101
+ result = yield f
102
+ f.flock(File::LOCK_UN)
103
+ result
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end