prosody 0.3.0 → 0.5.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/.cargo/config.toml +3 -0
  3. data/.release-please-manifest.json +1 -1
  4. data/AGENTS.md +395 -0
  5. data/ARCHITECTURE.md +14 -4
  6. data/CHANGELOG.md +28 -0
  7. data/CLAUDE.md +1 -0
  8. data/CONFIGURATION.md +167 -0
  9. data/Cargo.lock +1115 -645
  10. data/Cargo.toml +7 -6
  11. data/README.md +436 -146
  12. data/Rakefile +11 -1
  13. data/examples/keyed_state.rb +70 -0
  14. data/examples/keyed_state.rbs +18 -0
  15. data/examples/keyed_state_windowing.rb +55 -0
  16. data/examples/keyed_state_windowing.rbs +16 -0
  17. data/ext/prosody/Cargo.toml +1 -0
  18. data/ext/prosody/src/admin.rs +1 -5
  19. data/ext/prosody/src/bridge/mod.rs +17 -32
  20. data/ext/prosody/src/client/config.rs +501 -28
  21. data/ext/prosody/src/client/mod.rs +167 -74
  22. data/ext/prosody/src/client/request.rs +132 -0
  23. data/ext/prosody/src/client/support.rs +122 -0
  24. data/ext/prosody/src/handler/context.rs +150 -5
  25. data/ext/prosody/src/handler/message.rs +67 -0
  26. data/ext/prosody/src/handler/mod.rs +115 -85
  27. data/ext/prosody/src/handler/state/mod.rs +488 -0
  28. data/ext/prosody/src/handler/state/registration.rs +104 -0
  29. data/ext/prosody/src/handler/state/scan.rs +218 -0
  30. data/ext/prosody/src/lib.rs +15 -3
  31. data/ext/prosody/src/published.rs +273 -0
  32. data/ext/prosody/src/scheduler/mod.rs +2 -2
  33. data/ext/prosody/src/scheduler/processor.rs +2 -2
  34. data/ext/prosody/src/scheduler/result.rs +7 -4
  35. data/ext/prosody/src/util.rs +86 -5
  36. data/lib/prosody/configuration.rb +71 -11
  37. data/lib/prosody/handler.rb +65 -8
  38. data/lib/prosody/native_stubs.rb +550 -9
  39. data/lib/prosody/request.rb +45 -0
  40. data/lib/prosody/state.rb +816 -0
  41. data/lib/prosody/version.rb +1 -1
  42. data/lib/prosody.rb +6 -0
  43. data/release-please-config.json +4 -0
  44. data/sig/configuration.rbs +70 -11
  45. data/sig/handler.rbs +17 -5
  46. data/sig/processor.rbs +28 -12
  47. data/sig/prosody.rbs +53 -7
  48. data/sig/request.rbs +66 -0
  49. data/sig/sentry.rbs +6 -0
  50. data/sig/state.rbs +390 -0
  51. data/steep_expectations.yml +57 -0
  52. data/typecheck/payload_types.rb +54 -0
  53. data/typecheck/payload_types.rbs +22 -0
  54. data/typecheck_negative/payload_types.rb +20 -0
  55. data/typecheck_negative/payload_types.rbs +9 -0
  56. metadata +32 -9
@@ -6,13 +6,16 @@
6
6
 
7
7
  use crate::bridge::Bridge;
8
8
  use crate::logging::Logger;
9
- use crate::{BRIDGE, RUNTIME, TRACING_INIT};
10
- use magnus::value::BoxValue;
11
- use magnus::{Ruby, Value};
12
- use prosody::tracing::initialize_tracing;
9
+ use crate::{BRIDGE, ROOT_MOD, RUNTIME, TRACING_INIT};
10
+ use magnus::value::{BoxValue, ReprValue};
11
+ use magnus::{Error, Ruby, Value, function};
12
+ use prosody::tracing::{
13
+ TracingError, flush_telemetry as core_flush_telemetry, initialize_tracing,
14
+ shutdown_telemetry as core_shutdown_telemetry,
15
+ };
13
16
  use std::mem::{ManuallyDrop, forget};
14
17
  use tokio::runtime::{EnterGuard, Handle};
15
- use tracing::warn;
18
+ use tracing::{error, warn};
16
19
 
17
20
  /// Creates a static Ruby identifier (symbol) for efficient reuse.
18
21
  ///
@@ -217,3 +220,81 @@ pub fn ensure_runtime_context(ruby: &Ruby) -> Option<EnterGuard<'static>> {
217
220
 
218
221
  guard
219
222
  }
223
+
224
+ /// Exports buffered telemetry (spans and metrics) without shutting the
225
+ /// export pipeline down. Safe to call even if tracing was never initialized.
226
+ ///
227
+ /// Use for a mid-run flush — e.g. one of several clients in a process
228
+ /// shutting down while others keep running. [`shutdown_telemetry`] is
229
+ /// registered automatically to run once at process exit, so most
230
+ /// applications only need this for manual, mid-run flushes.
231
+ ///
232
+ /// Blocks the calling thread until the export completes.
233
+ ///
234
+ /// # Errors
235
+ ///
236
+ /// Returns a `RuntimeError` if the span or metric exporter fails to flush.
237
+ pub fn flush_telemetry(ruby: &Ruby) -> Result<(), Error> {
238
+ core_flush_telemetry().map_err(|error| tracing_error(ruby, &error))
239
+ }
240
+
241
+ /// Flushes buffered telemetry and shuts the export pipeline down. Safe to
242
+ /// call even if tracing was never initialized.
243
+ ///
244
+ /// Registered to run once via `Kernel#at_exit` (see
245
+ /// [`register_shutdown_at_exit`]), so applications get it for free; call it
246
+ /// directly only for tests or other cases that need shutdown before the
247
+ /// process actually exits.
248
+ ///
249
+ /// Blocks the calling thread until the final export completes.
250
+ ///
251
+ /// # Errors
252
+ ///
253
+ /// Returns a `RuntimeError` if the span or metric pipeline fails to shut
254
+ /// down.
255
+ pub fn shutdown_telemetry(ruby: &Ruby) -> Result<(), Error> {
256
+ core_shutdown_telemetry().map_err(|error| tracing_error(ruby, &error))
257
+ }
258
+
259
+ /// Registers [`shutdown_telemetry`] to run once via `Kernel#at_exit`, so
260
+ /// short-lived processes don't lose the tail of telemetry buffered since the
261
+ /// last periodic export.
262
+ ///
263
+ /// Failures are logged rather than raised: exceptions from an `at_exit`
264
+ /// block are easy to miss and shouldn't prevent the process from exiting.
265
+ ///
266
+ /// # Errors
267
+ ///
268
+ /// Returns a `Magnus::Error` if registering the `at_exit` hook fails.
269
+ fn register_shutdown_at_exit(ruby: &Ruby) -> Result<(), Error> {
270
+ let _: Value = ruby
271
+ .module_kernel()
272
+ .block_call("at_exit", (), |_ruby, _args, _block| {
273
+ if let Err(error) = core_shutdown_telemetry() {
274
+ error!("failed to shut down telemetry at exit: {error:#}");
275
+ }
276
+ })?;
277
+
278
+ Ok(())
279
+ }
280
+
281
+ /// Converts a [`TracingError`] into the `Magnus::Error` shape used across the
282
+ /// extension's Ruby-facing functions.
283
+ fn tracing_error(ruby: &Ruby, error: &TracingError) -> Error {
284
+ Error::new(ruby.exception_runtime_error(), error.to_string())
285
+ }
286
+
287
+ /// Initializes this module's Ruby-visible surface: the `flush_telemetry` and
288
+ /// `shutdown_telemetry` module functions, plus the `at_exit` hook that runs
289
+ /// shutdown automatically.
290
+ ///
291
+ /// # Errors
292
+ ///
293
+ /// Returns a `Magnus::Error` if function or hook registration fails.
294
+ pub fn init(ruby: &Ruby) -> Result<(), Error> {
295
+ let module = ruby.get_inner(&ROOT_MOD);
296
+ module.define_module_function("flush_telemetry", function!(flush_telemetry, 0))?;
297
+ module.define_module_function("shutdown_telemetry", function!(shutdown_telemetry, 0))?;
298
+
299
+ register_shutdown_at_exit(ruby)
300
+ }
@@ -131,7 +131,7 @@ module Prosody
131
131
  config_param :max_retry_delay, converter: ->(v) { duration_converter(v) }
132
132
 
133
133
  # Global shared cache capacity across all partitions for message deduplication.
134
- # Default 8192. Set to 0 to disable deduplication entirely.
134
+ # Must be at least 1. Default: 8192.
135
135
  config_param :idempotence_cache_size, converter: ->(v) { Integer(v) }
136
136
 
137
137
  # Version string for cache-busting deduplication hashes. Changing this
@@ -156,6 +156,21 @@ module Prosody
156
156
  # Identifier for the system producing messages.
157
157
  config_param :source_system, converter: lambda(&:to_s)
158
158
 
159
+ # Address for the peer listener.
160
+ config_param :peer_bind_address, converter: lambda(&:to_s)
161
+
162
+ # gRPC connect URI that other clients use for this client.
163
+ config_param :peer_advertised_connect, converter: lambda(&:to_s)
164
+
165
+ # Network name used to identify direct routes.
166
+ config_param :peer_network_name, converter: lambda(&:to_s)
167
+
168
+ # Maximum number of peer channels and registrations in each cache.
169
+ config_param :peer_cache_capacity, converter: ->(v) { Integer(v) }
170
+
171
+ # Duration of each peer registration lease.
172
+ config_param :peer_registration_ttl, converter: ->(v) { duration_converter(v) }
173
+
159
174
  # Topic to send failed messages to.
160
175
  config_param :failure_topic, converter: lambda(&:to_s)
161
176
 
@@ -172,7 +187,7 @@ module Prosody
172
187
  end
173
188
  }
174
189
 
175
- # Keyspace to use for storing timer data in Cassandra.
190
+ # Keyspace used for persistent Prosody data in Cassandra.
176
191
  config_param :cassandra_keyspace, converter: lambda(&:to_s)
177
192
 
178
193
  # Preferred datacenter for Cassandra query routing.
@@ -187,7 +202,7 @@ module Prosody
187
202
  # Password for authenticating with Cassandra.
188
203
  config_param :cassandra_password, converter: lambda(&:to_s)
189
204
 
190
- # Retention period for failed/unprocessed timer data in Cassandra.
205
+ # Retention period for persistent timer and deferral data in Cassandra.
191
206
  # Accepts duration objects or numeric values (in seconds).
192
207
  config_param :cassandra_retention, converter: ->(v) { duration_converter(v) }
193
208
 
@@ -235,23 +250,28 @@ module Prosody
235
250
  # Maximum delay between deferred retries (in seconds).
236
251
  config_param :defer_max_delay, converter: ->(v) { duration_converter(v) }
237
252
 
238
- # Failure rate threshold for enabling deferral (0.0 to 1.0).
253
+ # Failure rate threshold for disabling deferral (0.0 to 1.0).
239
254
  config_param :defer_failure_threshold, converter: ->(v) { Float(v) }
240
255
 
241
256
  # Sliding window duration (in seconds) for failure rate tracking.
242
257
  config_param :defer_failure_window, converter: ->(v) { duration_converter(v) }
243
258
 
244
- # Cache size for defer middleware.
245
- config_param :defer_cache_size, converter: ->(v) { Integer(v) }
246
-
247
259
  # Maximum deferred store cache entries per Cassandra defer store. Env: PROSODY_DEFER_STORE_CACHE_SIZE
248
260
  config_param :defer_store_cache_size, converter: ->(v) { Integer(v) }
249
261
 
250
- # Timeout for Kafka seek operations (in seconds).
251
- config_param :defer_seek_timeout, converter: ->(v) { duration_converter(v) }
262
+ # Kafka message loader configuration
263
+ #
264
+ # Maximum messages retained by the shared Kafka loader.
265
+ # Env: PROSODY_LOADER_CACHE_SIZE. Default: 1024.
266
+ config_param :loader_cache_size, converter: ->(v) { Integer(v) }
252
267
 
253
- # Messages to read sequentially before seeking.
254
- config_param :defer_discard_threshold, converter: ->(v) { Integer(v) }
268
+ # Timeout for Kafka loader seek operations (in seconds).
269
+ # Env: PROSODY_LOADER_SEEK_TIMEOUT. Default: 30 seconds.
270
+ config_param :loader_seek_timeout, converter: ->(v) { duration_converter(v) }
271
+
272
+ # Sequential-read distance before the loader seeks.
273
+ # Env: PROSODY_LOADER_DISCARD_THRESHOLD. Default: 100.
274
+ config_param :loader_discard_threshold, converter: ->(v) { Integer(v) }
255
275
 
256
276
  # Timeout configuration
257
277
  #
@@ -281,6 +301,46 @@ module Prosody
281
301
  # Overrides the PROSODY_TIMER_SPANS environment variable. Default: "follows_from".
282
302
  config_param :timer_spans, converter: ->(v) { v.to_s }
283
303
 
304
+ # Keyed-state collections to register before subscribe.
305
+ #
306
+ # Accepts an array of StateDefinition objects (from Prosody.value/map/deque
307
+ # and their message_* siblings) or already-serialized registration hashes.
308
+ # Duplicate names within the set are rejected by the native layer.
309
+ config_param :state_collections,
310
+ converter: lambda { |v|
311
+ list = v.is_a?(Hash) ? [v] : Array(v)
312
+ list.map { |d| d.respond_to?(:to_state_config) ? d.to_state_config : d }
313
+ }
314
+
315
+ # Subsystem under which published JSON collections are advertised.
316
+ # Uses PROSODY_SUBSYSTEM when omitted. Published collections require it.
317
+ config_param :subsystem, converter: lambda(&:to_s)
318
+
319
+ # Disk workspace for the local keyed-state cache. Each live client
320
+ # needs its own directory. Falls back to the
321
+ # PROSODY_STATE_CACHE_DIR environment variable. Must not be an empty string.
322
+ config_param :state_cache_dir, converter: lambda(&:to_s)
323
+
324
+ # Capacity of the owning keyed-state cache, such as "64 MiB". Uses
325
+ # PROSODY_STATE_OWNED_CACHE_SIZE when omitted. Otherwise, the engine
326
+ # selects its default.
327
+ config_param :state_owned_cache_size, converter: lambda(&:to_s)
328
+
329
+ # Capacity of the published-state read-through cache, such as "1 MiB".
330
+ # Uses PROSODY_STATE_READ_CACHE_SIZE when omitted. It then uses the owned
331
+ # cache size when set, or 1 MiB when both sizes are unset.
332
+ config_param :state_read_cache_size, converter: lambda(&:to_s)
333
+
334
+ # Default published-read cache policy in seconds, or false to bypass it.
335
+ # Uses PROSODY_STATE_READ_CACHE_TTL when omitted, then 5 seconds.
336
+ config_param :state_read_cache,
337
+ converter: ->(v) { (v == true || v == false) ? v : Float(v) }
338
+
339
+ # Delay in whole seconds between staging a provisional cell and the
340
+ # keyed-state recovery sweep. Every registered TTL must strictly exceed this.
341
+ # Must be a whole number of seconds >= 1 (validated natively).
342
+ config_param :state_recovery_delay, converter: ->(v) { duration_converter(v) }
343
+
284
344
  # Operation mode of the client.
285
345
  #
286
346
  # Valid values:
@@ -69,7 +69,7 @@ module Prosody
69
69
  #
70
70
  # @param [Symbol] method_name the name of the method to wrap
71
71
  # @param [Class<Exception>] exception_classes one or more Exception subclasses to catch
72
- # @return [void]
72
+ # @return [Prosody::json_value] The wrapped method result
73
73
  # @raise [ArgumentError] if no exception classes given
74
74
  # @raise [NameError] if method_name is not defined on this class or its ancestors
75
75
  def permanent(method_name, *exception_classes)
@@ -81,7 +81,7 @@ module Prosody
81
81
  #
82
82
  # @param [Symbol] method_name the name of the method to wrap
83
83
  # @param [Class<Exception>] exception_classes one or more Exception subclasses to catch
84
- # @return [void]
84
+ # @return [Prosody::json_value] The wrapped method result
85
85
  # @raise [ArgumentError] if no exception classes given
86
86
  # @raise [NameError] if method_name is not defined on this class or its ancestors
87
87
  def transient(method_name, *exception_classes)
@@ -96,7 +96,7 @@ module Prosody
96
96
  # @param [Symbol] method_name the method to wrap
97
97
  # @param [Array<Class<Exception>>] exception_classes exceptions to catch
98
98
  # @param [Class<EventHandlerError>] error_class the error class to wrap caught exceptions in
99
- # @return [void]
99
+ # @return [Prosody::json_value] the message response
100
100
  def wrap_errors(method_name, exception_classes, error_class)
101
101
  # Must specify at least one exception class
102
102
  if exception_classes.empty?
@@ -114,9 +114,10 @@ module Prosody
114
114
  super(*args, &block)
115
115
  rescue *exception_classes => e
116
116
  # The new exception's #cause will be set automatically
117
- raise error_class.new(e.message)
117
+ Kernel.raise error_class.new(e.message)
118
118
  end
119
119
  end
120
+ wrapper.instance_variable_set(:@prosody_error_wrapper, true)
120
121
 
121
122
  prepend wrapper
122
123
  end
@@ -127,8 +128,10 @@ module Prosody
127
128
  # --------------------------------------------------------------------------
128
129
 
129
130
  # 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.
131
+ # The RBS type parameters describe the message payload and handler response.
132
+ # Both parameters default to +Prosody::json_value+. Declare narrower types in
133
+ # your application's RBS to check payload access and handler responses.
134
+ # Subclasses must implement `#on_message`, `#on_excise`, and `#on_timer`.
132
135
  # They may also use `permanent` or `transient` decorators to control retry logic.
133
136
  #
134
137
  # @example
@@ -144,6 +147,10 @@ module Prosody
144
147
  # # Process message...
145
148
  # end
146
149
  #
150
+ # def on_excise(context, message)
151
+ # # Process excise record...
152
+ # end
153
+ #
147
154
  # def on_timer(context, trigger)
148
155
  # # Process timer event...
149
156
  # end
@@ -151,18 +158,68 @@ module Prosody
151
158
  class EventHandler
152
159
  extend ErrorClassification
153
160
 
161
+ HANDLER_METHODS = %i[on_message on_excise on_timer].freeze
162
+
163
+ def self.validate_handler!(handler)
164
+ owners = handler.class.ancestors.take_while { |owner| owner != self }
165
+
166
+ HANDLER_METHODS.each do |name|
167
+ validate_method!(handler, owners, name)
168
+ end
169
+ end
170
+
171
+ def self.validate_method!(handler, owners, name)
172
+ implemented = owners.any? do |owner|
173
+ !owner.instance_variable_get(:@prosody_error_wrapper) &&
174
+ (owner.instance_methods(false) + owner.private_instance_methods(false)).include?(name)
175
+ end
176
+ raise ArgumentError, "handler must implement ##{name}" unless implemented
177
+ method = handler.method(name)
178
+ while method.owner.instance_variable_get(:@prosody_error_wrapper)
179
+ method = method.super_method
180
+ raise ArgumentError, "handler must implement ##{name}" unless method
181
+ end
182
+ return if accepts_two_parameters?(method)
183
+
184
+ raise ArgumentError, "handler ##{name} must accept two parameters"
185
+ end
186
+ private_class_method :validate_method!
187
+
188
+ def self.accepts_two_parameters?(method)
189
+ parameters = method.parameters
190
+ required = parameters.count { |kind, _| kind == :req }
191
+ positional = parameters.count do |parameter|
192
+ parameter.first == :req || parameter.first == :opt
193
+ end
194
+ has_rest = parameters.any? { |kind, _| kind == :rest }
195
+ has_required_keyword = parameters.any? { |kind, _| kind == :keyreq }
196
+
197
+ !has_required_keyword && required <= 2 && (has_rest || positional >= 2)
198
+ end
199
+ private_class_method :accepts_two_parameters?
200
+
154
201
  # Process a single message received from Prosody.
155
202
  # This method must be implemented by subclasses to define
156
203
  # custom message handling logic.
157
204
  #
158
205
  # @param [Context] context the message context
159
- # @param [Message] message the message payload
206
+ # @param [Message<Payload>] message the message and its typed JSON payload
160
207
  # @raise [NotImplementedError] if not overridden by subclass
161
- # @return [void]
208
+ # @return [Prosody::json_value] the message response
162
209
  def on_message(context, message)
163
210
  raise NotImplementedError, "Subclasses must implement #on_message"
164
211
  end
165
212
 
213
+ # Process an excise record for a key.
214
+ #
215
+ # @param [Context] context the event context
216
+ # @param [ExciseMessage] message the excise record metadata
217
+ # @raise [NotImplementedError] if not overridden by a subclass
218
+ # @return [Prosody::json_value] the excise response
219
+ def on_excise(context, message)
220
+ raise NotImplementedError, "Subclasses must implement #on_excise"
221
+ end
222
+
166
223
  # Process a timer event when it fires.
167
224
  # This method must be implemented by subclasses to handle
168
225
  # scheduled timer events if they can fire.