prosody 0.1.1

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 (50) hide show
  1. checksums.yaml +7 -0
  2. data/.cargo/config.toml +2 -0
  3. data/.release-please-manifest.json +3 -0
  4. data/.rspec +3 -0
  5. data/.ruby-version +1 -0
  6. data/.standard.yml +9 -0
  7. data/.taplo.toml +6 -0
  8. data/ARCHITECTURE.md +591 -0
  9. data/CHANGELOG.md +92 -0
  10. data/Cargo.lock +3513 -0
  11. data/Cargo.toml +77 -0
  12. data/LICENSE +21 -0
  13. data/Makefile +36 -0
  14. data/README.md +946 -0
  15. data/Rakefile +26 -0
  16. data/ext/prosody/Cargo.toml +38 -0
  17. data/ext/prosody/extconf.rb +6 -0
  18. data/ext/prosody/src/admin.rs +171 -0
  19. data/ext/prosody/src/bridge/callback.rs +60 -0
  20. data/ext/prosody/src/bridge/mod.rs +332 -0
  21. data/ext/prosody/src/client/config.rs +819 -0
  22. data/ext/prosody/src/client/mod.rs +379 -0
  23. data/ext/prosody/src/gvl.rs +149 -0
  24. data/ext/prosody/src/handler/context.rs +436 -0
  25. data/ext/prosody/src/handler/message.rs +144 -0
  26. data/ext/prosody/src/handler/mod.rs +338 -0
  27. data/ext/prosody/src/handler/trigger.rs +93 -0
  28. data/ext/prosody/src/lib.rs +82 -0
  29. data/ext/prosody/src/logging.rs +353 -0
  30. data/ext/prosody/src/scheduler/cancellation.rs +67 -0
  31. data/ext/prosody/src/scheduler/handle.rs +50 -0
  32. data/ext/prosody/src/scheduler/mod.rs +169 -0
  33. data/ext/prosody/src/scheduler/processor.rs +166 -0
  34. data/ext/prosody/src/scheduler/result.rs +197 -0
  35. data/ext/prosody/src/tracing_util.rs +56 -0
  36. data/ext/prosody/src/util.rs +219 -0
  37. data/lib/prosody/configuration.rb +333 -0
  38. data/lib/prosody/handler.rb +177 -0
  39. data/lib/prosody/native_stubs.rb +417 -0
  40. data/lib/prosody/processor.rb +321 -0
  41. data/lib/prosody/sentry.rb +36 -0
  42. data/lib/prosody/version.rb +10 -0
  43. data/lib/prosody.rb +42 -0
  44. data/release-please-config.json +10 -0
  45. data/sig/configuration.rbs +252 -0
  46. data/sig/handler.rbs +79 -0
  47. data/sig/processor.rbs +100 -0
  48. data/sig/prosody.rbs +171 -0
  49. data/sig/version.rbs +9 -0
  50. metadata +193 -0
@@ -0,0 +1,333 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosody
4
+ # Configuration class for the Prosody messaging client.
5
+ #
6
+ # This class provides a flexible configuration system for the Prosody client,
7
+ # with strong typing, validation, and convenient default values. It handles
8
+ # various parameter types including strings, arrays, durations, integers,
9
+ # and special case configurations like operation mode and health probe settings.
10
+ #
11
+ # @example Basic usage
12
+ # config = Prosody::Configuration.new(
13
+ # bootstrap_servers: "kafka:9092",
14
+ # group_id: "my-consumer-group"
15
+ # )
16
+ #
17
+ # @example With block syntax
18
+ # config = Prosody::Configuration.new do |c|
19
+ # c.bootstrap_servers = ["kafka1:9092", "kafka2:9092"]
20
+ # c.group_id = "my-consumer-group"
21
+ # c.max_concurrency = 10
22
+ # end
23
+ class Configuration
24
+ # Defines a configuration parameter with type conversion and optional default value.
25
+ #
26
+ # This DSL helper creates getter and setter methods for a configuration parameter,
27
+ # with the setter applying type conversion. The getter returns the default value
28
+ # when the parameter hasn't been set.
29
+ #
30
+ # @param name [Symbol, String] The parameter name
31
+ # @param converter [Proc] A lambda that converts and validates the input value
32
+ # @param default [Object, nil] The default value returned when parameter is unset
33
+ # @return [void]
34
+ def self.config_param(name, converter: ->(v) { v }, default: nil)
35
+ name_sym = name.to_sym
36
+ define_method(name) { @config.key?(name_sym) ? @config[name_sym] : default }
37
+ define_method(:"#{name}=") { |value| @config[name_sym] = value.nil? ? nil : converter.call(value) }
38
+ end
39
+
40
+ # Converts a duration value to float seconds.
41
+ #
42
+ # Handles ActiveSupport::Duration objects (if available) and numeric values,
43
+ # converting them to floating-point seconds.
44
+ #
45
+ # @param v [Numeric, ActiveSupport::Duration] The duration value to convert
46
+ # @return [Float] The duration in seconds as a float
47
+ # @raise [ArgumentError] If the input is not a valid duration type
48
+ def self.duration_converter(v)
49
+ if defined?(ActiveSupport::Duration) && v.is_a?(ActiveSupport::Duration)
50
+ v.to_f
51
+ elsif v.is_a?(Numeric)
52
+ v.to_f
53
+ else
54
+ raise ArgumentError, "Invalid type for duration: #{v.inspect}"
55
+ end
56
+ end
57
+
58
+ # Initializes a new Configuration instance.
59
+ #
60
+ # @param kwargs [Hash] Initial configuration parameters as keyword arguments
61
+ # @yield [self] Yields self for block-based configuration
62
+ # @return [Configuration] The new configuration instance
63
+ def initialize(kwargs = {})
64
+ @config = {}
65
+ kwargs.each { |k, v| public_send(:"#{k}=", v) }
66
+ yield self if block_given?
67
+ end
68
+
69
+ # List of Kafka bootstrap server addresses.
70
+ # Accepts a string (single server) or array of strings (multiple servers).
71
+ config_param :bootstrap_servers,
72
+ converter: lambda { |v|
73
+ if v.is_a?(String)
74
+ [v.to_s]
75
+ elsif v.respond_to?(:to_a)
76
+ v.to_a.map(&:to_s)
77
+ else
78
+ raise ArgumentError, "Invalid type for bootstrap_servers"
79
+ end
80
+ }
81
+
82
+ # List of Kafka topics to subscribe to.
83
+ # Accepts a string (single topic) or array of strings (multiple topics).
84
+ config_param :subscribed_topics,
85
+ converter: lambda { |v|
86
+ if v.is_a?(String)
87
+ [v.to_s]
88
+ elsif v.respond_to?(:to_a)
89
+ v.to_a.map(&:to_s)
90
+ else
91
+ raise ArgumentError, "Invalid type for subscribed_topics"
92
+ end
93
+ }
94
+
95
+ # List of event types the consumer is allowed to process.
96
+ # Accepts a string (single event type) or array of strings (multiple event types).
97
+ config_param :allowed_events,
98
+ converter: lambda { |v|
99
+ if v.is_a?(String)
100
+ [v.to_s]
101
+ elsif v.respond_to?(:to_a)
102
+ v.to_a.map(&:to_s)
103
+ else
104
+ raise ArgumentError, "Invalid type for allowed_events"
105
+ end
106
+ }
107
+
108
+ # Whether to use mock mode (for testing).
109
+ # When enabled, creates a mock Kafka implementation instead of connecting to real servers.
110
+ config_param :mock, converter: ->(v) { v.nil? ? nil : !!v }
111
+
112
+ # Maximum time to wait for a send operation to complete (in seconds).
113
+ config_param :send_timeout, converter: ->(v) { duration_converter(v) }
114
+
115
+ # Threshold in seconds after which a stalled consumer is detected.
116
+ config_param :stall_threshold, converter: ->(v) { duration_converter(v) }
117
+
118
+ # Shutdown budget; handlers run freely until cancellation fires near the end of the timeout.
119
+ config_param :shutdown_timeout, converter: ->(v) { duration_converter(v) }
120
+
121
+ # Interval between Kafka poll operations (in seconds).
122
+ config_param :poll_interval, converter: ->(v) { duration_converter(v) }
123
+
124
+ # Interval between offset commit operations (in seconds).
125
+ config_param :commit_interval, converter: ->(v) { duration_converter(v) }
126
+
127
+ # Base delay for retry operations (in seconds).
128
+ config_param :retry_base, converter: ->(v) { duration_converter(v) }
129
+
130
+ # Maximum delay between retries (in seconds).
131
+ config_param :max_retry_delay, converter: ->(v) { duration_converter(v) }
132
+
133
+ # Global shared cache capacity across all partitions for message deduplication.
134
+ # Default 8192. Set to 0 to disable deduplication entirely.
135
+ config_param :idempotence_cache_size, converter: ->(v) { Integer(v) }
136
+
137
+ # Version string for cache-busting deduplication hashes. Changing this
138
+ # invalidates all previously recorded dedup entries.
139
+ config_param :idempotence_version, converter: lambda(&:to_s)
140
+
141
+ # TTL for deduplication records in Cassandra.
142
+ config_param :idempotence_ttl, converter: ->(v) { duration_converter(v) }
143
+
144
+ # Maximum number of concurrent message processing tasks.
145
+ config_param :max_concurrency, converter: ->(v) { Integer(v) }
146
+
147
+ # Maximum number of messages to process before committing offsets.
148
+ config_param :max_uncommitted, converter: ->(v) { Integer(v) }
149
+
150
+ # Maximum number of retry attempts.
151
+ config_param :max_retries, converter: ->(v) { Integer(v) }
152
+
153
+ # Kafka consumer group ID.
154
+ config_param :group_id, converter: lambda(&:to_s)
155
+
156
+ # Identifier for the system producing messages.
157
+ config_param :source_system, converter: lambda(&:to_s)
158
+
159
+ # Topic to send failed messages to.
160
+ config_param :failure_topic, converter: lambda(&:to_s)
161
+
162
+ # List of Cassandra contact nodes (hostnames or IPs).
163
+ # Accepts a string (single node) or array of strings (multiple nodes).
164
+ config_param :cassandra_nodes,
165
+ converter: lambda { |v|
166
+ if v.is_a?(String)
167
+ [v.to_s]
168
+ elsif v.respond_to?(:to_a)
169
+ v.to_a.map(&:to_s)
170
+ else
171
+ raise ArgumentError, "Invalid type for cassandra_nodes"
172
+ end
173
+ }
174
+
175
+ # Keyspace to use for storing timer data in Cassandra.
176
+ config_param :cassandra_keyspace, converter: lambda(&:to_s)
177
+
178
+ # Preferred datacenter for Cassandra query routing.
179
+ config_param :cassandra_datacenter, converter: lambda(&:to_s)
180
+
181
+ # Preferred rack identifier for Cassandra topology-aware routing.
182
+ config_param :cassandra_rack, converter: lambda(&:to_s)
183
+
184
+ # Username for authenticating with Cassandra.
185
+ config_param :cassandra_user, converter: lambda(&:to_s)
186
+
187
+ # Password for authenticating with Cassandra.
188
+ config_param :cassandra_password, converter: lambda(&:to_s)
189
+
190
+ # Retention period for failed/unprocessed timer data in Cassandra.
191
+ # Accepts duration objects or numeric values (in seconds).
192
+ config_param :cassandra_retention, converter: ->(v) { duration_converter(v) }
193
+
194
+ # Timer slab partitioning duration in seconds.
195
+ # Controls how timers are grouped for storage and retrieval.
196
+ config_param :slab_size, converter: ->(v) { duration_converter(v) }
197
+
198
+ # Scheduler configuration
199
+ #
200
+ # Target proportion of execution time for failure/retry task processing (0.0 to 1.0).
201
+ # Controls bandwidth allocation between Normal and Failure task classes.
202
+ config_param :scheduler_failure_weight, converter: ->(v) { Float(v) }
203
+
204
+ # Wait duration at which urgency boost reaches maximum intensity (in seconds).
205
+ config_param :scheduler_max_wait, converter: ->(v) { duration_converter(v) }
206
+
207
+ # Maximum urgency boost (in seconds of virtual time) for waiting tasks.
208
+ config_param :scheduler_wait_weight, converter: ->(v) { Float(v) }
209
+
210
+ # Cache capacity for tracking per-key virtual time in the scheduler.
211
+ config_param :scheduler_cache_size, converter: ->(v) { Integer(v) }
212
+
213
+ # Monopolization configuration
214
+ #
215
+ # Whether monopolization detection is enabled.
216
+ config_param :monopolization_enabled, converter: ->(v) { v.nil? ? nil : !!v }
217
+
218
+ # Threshold for monopolization detection (0.0 to 1.0).
219
+ config_param :monopolization_threshold, converter: ->(v) { Float(v) }
220
+
221
+ # Rolling window duration (in seconds) for monopolization detection.
222
+ config_param :monopolization_window, converter: ->(v) { duration_converter(v) }
223
+
224
+ # Cache size for tracking key execution intervals.
225
+ config_param :monopolization_cache_size, converter: ->(v) { Integer(v) }
226
+
227
+ # Defer configuration
228
+ #
229
+ # Whether deferral is enabled for new messages.
230
+ config_param :defer_enabled, converter: ->(v) { v.nil? ? nil : !!v }
231
+
232
+ # Base exponential backoff delay for deferred retries (in seconds).
233
+ config_param :defer_base, converter: ->(v) { duration_converter(v) }
234
+
235
+ # Maximum delay between deferred retries (in seconds).
236
+ config_param :defer_max_delay, converter: ->(v) { duration_converter(v) }
237
+
238
+ # Failure rate threshold for enabling deferral (0.0 to 1.0).
239
+ config_param :defer_failure_threshold, converter: ->(v) { Float(v) }
240
+
241
+ # Sliding window duration (in seconds) for failure rate tracking.
242
+ config_param :defer_failure_window, converter: ->(v) { duration_converter(v) }
243
+
244
+ # Cache size for defer middleware.
245
+ config_param :defer_cache_size, converter: ->(v) { Integer(v) }
246
+
247
+ # Timeout for Kafka seek operations (in seconds).
248
+ config_param :defer_seek_timeout, converter: ->(v) { duration_converter(v) }
249
+
250
+ # Messages to read sequentially before seeking.
251
+ config_param :defer_discard_threshold, converter: ->(v) { Integer(v) }
252
+
253
+ # Timeout configuration
254
+ #
255
+ # Fixed timeout duration for handler execution (in seconds).
256
+ # If unset, defaults to 80% of stall_threshold.
257
+ config_param :timeout, converter: ->(v) { duration_converter(v) }
258
+
259
+ # Telemetry emitter configuration
260
+ #
261
+ # Kafka topic to produce internal telemetry events to.
262
+ # Overrides the PROSODY_TELEMETRY_TOPIC environment variable.
263
+ config_param :telemetry_topic, converter: lambda(&:to_s)
264
+
265
+ # Whether the telemetry emitter is enabled.
266
+ # Overrides the PROSODY_TELEMETRY_ENABLED environment variable.
267
+ config_param :telemetry_enabled, converter: ->(v) { v.nil? ? nil : !!v }
268
+
269
+ # Span linking for message execution spans.
270
+ # Controls how the receive span connects to the OTel context from the Kafka producer.
271
+ # Accepted values: "child" (child-of relationship) or "follows_from".
272
+ # Overrides the PROSODY_MESSAGE_SPANS environment variable. Default: "child".
273
+ config_param :message_spans, converter: ->(v) { v.to_s }
274
+
275
+ # Span linking for timer execution spans.
276
+ # Controls how timer spans connect to the OTel context stored at schedule time.
277
+ # Accepted values: "child" (child-of relationship) or "follows_from".
278
+ # Overrides the PROSODY_TIMER_SPANS environment variable. Default: "follows_from".
279
+ config_param :timer_spans, converter: ->(v) { v.to_s }
280
+
281
+ # Operation mode of the client.
282
+ #
283
+ # Valid values:
284
+ # - "pipeline" or :pipeline - Prioritizes throughput and ordering
285
+ # - "low_latency" or :low_latency - Prioritizes latency over throughput
286
+ # - "best_effort" or :best_effort - Makes best effort tradeoffs
287
+ config_param :mode,
288
+ converter: lambda { |v|
289
+ return nil if v.nil?
290
+
291
+ s = v.to_s.downcase
292
+ case s
293
+ when "pipeline"
294
+ :pipeline
295
+ when "low_latency"
296
+ :low_latency
297
+ when "best_effort"
298
+ :best_effort
299
+ else
300
+ raise ArgumentError, "Invalid mode: #{v}"
301
+ end
302
+ }
303
+
304
+ # Configuration for the health probe port.
305
+ #
306
+ # Accepts:
307
+ # - nil (uses default configuration)
308
+ # - false or :disabled (explicitly disables the probe port)
309
+ # - Port number (0-65535) to use for the health probe
310
+ config_param :probe_port,
311
+ converter: lambda { |v|
312
+ if v.nil?
313
+ nil
314
+ elsif v == false
315
+ false
316
+ elsif (v.is_a?(Symbol) || v.is_a?(String)) && v.to_sym == :disabled
317
+ false
318
+ else
319
+ port = Integer(v)
320
+ raise ArgumentError, "Invalid port number" unless port.between?(0, 65_535)
321
+
322
+ port
323
+ end
324
+ }
325
+
326
+ # Returns a Ruby hash with only non-nil values, suitable for Rust deserialization.
327
+ #
328
+ # @return [Hash] Configuration hash with all nil values removed
329
+ def to_hash
330
+ @config.dup.compact
331
+ end
332
+ end
333
+ end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosody
4
+ # Base error class for all Prosody-specific exceptions.
5
+ #
6
+ # Specific error types may extend this class to provide more detailed
7
+ # error information and classification.
8
+ class Error < StandardError; end
9
+
10
+ # --------------------------------------------------------------------------
11
+ # 1) Base error classes with a `#permanent?` contract
12
+ # --------------------------------------------------------------------------
13
+
14
+ # Abstract base for all errors raised by EventHandler methods.
15
+ # Subclasses **must** implement `#permanent?` to indicate retry behavior.
16
+ #
17
+ # @abstract
18
+ class EventHandlerError < Error
19
+ # Indicates whether this error is permanent (no retry) or
20
+ # transient (retryable).
21
+ #
22
+ # @return [Boolean] true if permanent (no retry), false if transient (retryable)
23
+ # @raise [NotImplementedError] if not overridden by subclass
24
+ def permanent?
25
+ raise NotImplementedError, "#{self.class} must implement #permanent?"
26
+ end
27
+ end
28
+
29
+ # Error indicating that the failure is temporary and can be retried.
30
+ #
31
+ # @see EventHandlerError#permanent?
32
+ class TransientError < EventHandlerError
33
+ # @return [false] indicates this error is retryable
34
+ def permanent? = false
35
+ end
36
+
37
+ # Error indicating that the failure is permanent and should not be retried.
38
+ #
39
+ # @see EventHandlerError#permanent?
40
+ class PermanentError < EventHandlerError
41
+ # @return [true] indicates this error is not retryable
42
+ def permanent? = true
43
+ end
44
+
45
+ # --------------------------------------------------------------------------
46
+ # 2) Mixin that provides "decorators" for wrapping methods
47
+ # --------------------------------------------------------------------------
48
+
49
+ # Mixin providing class-level methods to wrap instance methods so that
50
+ # specified exceptions are re-wrapped as PermanentError or TransientError.
51
+ #
52
+ # @example
53
+ # class MyHandler < Prosody::EventHandler
54
+ # extend Prosody::ErrorClassification
55
+ #
56
+ # # Treat TypeError as permanent (no retry)
57
+ # permanent :on_message, TypeError
58
+ #
59
+ # # Treat JSON::ParserError as transient (retryable)
60
+ # transient :on_message, JSON::ParserError
61
+ #
62
+ # def on_message(context, message)
63
+ # # Process the message...
64
+ # end
65
+ # end
66
+ module ErrorClassification
67
+ # Wraps the given instance method so that specified exception types
68
+ # are caught and re-raised as Prosody::PermanentError.
69
+ #
70
+ # @param [Symbol] method_name the name of the method to wrap
71
+ # @param [Class<Exception>] exception_classes one or more Exception subclasses to catch
72
+ # @return [void]
73
+ # @raise [ArgumentError] if no exception classes given
74
+ # @raise [NameError] if method_name is not defined on this class or its ancestors
75
+ def permanent(method_name, *exception_classes)
76
+ wrap_errors(method_name, exception_classes, PermanentError)
77
+ end
78
+
79
+ # Wraps the given instance method so that specified exception types
80
+ # are caught and re-raised as Prosody::TransientError.
81
+ #
82
+ # @param [Symbol] method_name the name of the method to wrap
83
+ # @param [Class<Exception>] exception_classes one or more Exception subclasses to catch
84
+ # @return [void]
85
+ # @raise [ArgumentError] if no exception classes given
86
+ # @raise [NameError] if method_name is not defined on this class or its ancestors
87
+ def transient(method_name, *exception_classes)
88
+ wrap_errors(method_name, exception_classes, TransientError)
89
+ end
90
+
91
+ private
92
+
93
+ # Core implementation: prepends a module that defines the same method name,
94
+ # rescuing the specified exceptions and re-raising them as the given error_class.
95
+ #
96
+ # @param [Symbol] method_name the method to wrap
97
+ # @param [Array<Class<Exception>>] exception_classes exceptions to catch
98
+ # @param [Class<EventHandlerError>] error_class the error class to wrap caught exceptions in
99
+ # @return [void]
100
+ def wrap_errors(method_name, exception_classes, error_class)
101
+ # Must specify at least one exception class
102
+ if exception_classes.empty?
103
+ raise ArgumentError, "At least one exception class must be provided"
104
+ end
105
+
106
+ # Ensure the method exists (in this class or its ancestors)
107
+ unless method_defined?(method_name)
108
+ raise NameError, "Method `#{method_name}` is not defined"
109
+ end
110
+
111
+ # Build a prepended wrapper module
112
+ wrapper = Module.new do
113
+ define_method(method_name) do |*args, &block|
114
+ super(*args, &block)
115
+ rescue *exception_classes => e
116
+ # The new exception's #cause will be set automatically
117
+ raise error_class.new(e.message)
118
+ end
119
+ end
120
+
121
+ prepend wrapper
122
+ end
123
+ end
124
+
125
+ # --------------------------------------------------------------------------
126
+ # 3) Base EventHandler that users will subclass
127
+ # --------------------------------------------------------------------------
128
+
129
+ # Abstract base class for handling incoming messages and timers from Prosody.
130
+ # Subclasses **must** implement `#on_message` to process received messages.
131
+ # Subclasses **may** implement `#on_timer` to process timer events.
132
+ # They may also use `permanent` or `transient` decorators to control retry logic.
133
+ #
134
+ # @example
135
+ # class MyHandler < Prosody::EventHandler
136
+ # extend Prosody::ErrorClassification
137
+ #
138
+ # permanent :on_message, ArgumentError
139
+ # transient :on_message, RuntimeError
140
+ # permanent :on_timer, ArgumentError
141
+ # transient :on_timer, RuntimeError
142
+ #
143
+ # def on_message(context, message)
144
+ # # Process message...
145
+ # end
146
+ #
147
+ # def on_timer(context, trigger)
148
+ # # Process timer event...
149
+ # end
150
+ # end
151
+ class EventHandler
152
+ extend ErrorClassification
153
+
154
+ # Process a single message received from Prosody.
155
+ # This method must be implemented by subclasses to define
156
+ # custom message handling logic.
157
+ #
158
+ # @param [Context] context the message context
159
+ # @param [Message] message the message payload
160
+ # @raise [NotImplementedError] if not overridden by subclass
161
+ # @return [void]
162
+ def on_message(context, message)
163
+ raise NotImplementedError, "Subclasses must implement #on_message"
164
+ end
165
+
166
+ # Process a timer event when it fires.
167
+ # This method must be implemented by subclasses to handle
168
+ # scheduled timer events if they can fire.
169
+ #
170
+ # @param [Context] context the event context
171
+ # @param [Timer] timer the timer containing key, time, and span
172
+ # @return [void]
173
+ def on_timer(context, timer)
174
+ raise NotImplementedError, "Subclasses must implement #on_timer"
175
+ end
176
+ end
177
+ end