prosody 0.4.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 (51) 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 +2 -2
  6. data/CHANGELOG.md +15 -0
  7. data/CLAUDE.md +1 -0
  8. data/CONFIGURATION.md +167 -0
  9. data/Cargo.lock +660 -326
  10. data/Cargo.toml +2 -1
  11. data/README.md +290 -191
  12. data/examples/keyed_state.rb +15 -3
  13. data/examples/keyed_state_windowing.rb +9 -1
  14. data/ext/prosody/Cargo.toml +2 -1
  15. data/ext/prosody/src/admin.rs +1 -5
  16. data/ext/prosody/src/bridge/mod.rs +17 -32
  17. data/ext/prosody/src/client/config.rs +194 -89
  18. data/ext/prosody/src/client/mod.rs +167 -74
  19. data/ext/prosody/src/client/request.rs +132 -0
  20. data/ext/prosody/src/client/support.rs +122 -0
  21. data/ext/prosody/src/handler/context.rs +24 -20
  22. data/ext/prosody/src/handler/message.rs +50 -0
  23. data/ext/prosody/src/handler/mod.rs +112 -84
  24. data/ext/prosody/src/handler/state/mod.rs +488 -0
  25. data/ext/prosody/src/handler/state/registration.rs +104 -0
  26. data/ext/prosody/src/handler/state/scan.rs +218 -0
  27. data/ext/prosody/src/lib.rs +15 -3
  28. data/ext/prosody/src/published.rs +273 -0
  29. data/ext/prosody/src/scheduler/mod.rs +2 -2
  30. data/ext/prosody/src/scheduler/processor.rs +2 -2
  31. data/ext/prosody/src/scheduler/result.rs +7 -4
  32. data/ext/prosody/src/util.rs +86 -5
  33. data/lib/prosody/configuration.rb +49 -15
  34. data/lib/prosody/handler.rb +63 -10
  35. data/lib/prosody/native_stubs.rb +197 -31
  36. data/lib/prosody/request.rb +45 -0
  37. data/lib/prosody/state.rb +164 -41
  38. data/lib/prosody/version.rb +1 -1
  39. data/lib/prosody.rb +1 -0
  40. data/sig/configuration.rbs +51 -15
  41. data/sig/handler.rbs +12 -4
  42. data/sig/prosody.rbs +43 -2
  43. data/sig/request.rbs +66 -0
  44. data/sig/state.rbs +165 -47
  45. data/steep_expectations.yml +10 -0
  46. data/typecheck/payload_types.rb +14 -3
  47. data/typecheck/payload_types.rbs +4 -2
  48. data/typecheck_negative/payload_types.rb +4 -0
  49. data/typecheck_negative/payload_types.rbs +1 -0
  50. metadata +12 -2
  51. data/ext/prosody/src/handler/state.rs +0 -1035
@@ -6,8 +6,8 @@
6
6
 
7
7
  use crate::bridge::Bridge;
8
8
  use crate::handler::state::{
9
- DequeStateVariant, MapStateVariant, NativeDequeState, NativeMapState, NativeValueState,
10
- ValueStateVariant, state_error,
9
+ NativeJsonDequeState, NativeJsonMapState, NativeJsonValueState, NativeMessageDequeState,
10
+ NativeMessageMapState, NativeMessageValueState, state_error,
11
11
  };
12
12
  use crate::tracing_util::extract_opentelemetry_context;
13
13
  use crate::{ROOT_MOD, id};
@@ -334,13 +334,13 @@ impl Context {
334
334
  /// Returns a permanent state error if the name is unregistered or its
335
335
  /// registered identity mismatches.
336
336
  #[allow(clippy::needless_pass_by_value, reason = "Magnus method argument type")]
337
- fn value_state(ruby: &Ruby, this: &Self, name: String) -> Result<NativeValueState, Error> {
337
+ fn value_state(ruby: &Ruby, this: &Self, name: String) -> Result<NativeJsonValueState, Error> {
338
338
  let handle = this
339
339
  .inner
340
340
  .value_state(&name)
341
341
  .map_err(|error| state_error(ruby, &error))?;
342
- Ok(NativeValueState::new(
343
- ValueStateVariant::Json(Arc::from(handle)),
342
+ Ok(NativeJsonValueState::new(
343
+ Arc::from(handle),
344
344
  this.bridge.clone(),
345
345
  Arc::clone(&this.propagator),
346
346
  ))
@@ -352,13 +352,13 @@ impl Context {
352
352
  ///
353
353
  /// See [`value_state`](Self::value_state).
354
354
  #[allow(clippy::needless_pass_by_value, reason = "Magnus method argument type")]
355
- fn map_state(ruby: &Ruby, this: &Self, name: String) -> Result<NativeMapState, Error> {
355
+ fn map_state(ruby: &Ruby, this: &Self, name: String) -> Result<NativeJsonMapState, Error> {
356
356
  let handle = this
357
357
  .inner
358
358
  .map_state(&name)
359
359
  .map_err(|error| state_error(ruby, &error))?;
360
- Ok(NativeMapState::new(
361
- MapStateVariant::Json(Arc::from(handle)),
360
+ Ok(NativeJsonMapState::new(
361
+ Arc::from(handle),
362
362
  this.bridge.clone(),
363
363
  Arc::clone(&this.propagator),
364
364
  ))
@@ -370,13 +370,13 @@ impl Context {
370
370
  ///
371
371
  /// See [`value_state`](Self::value_state).
372
372
  #[allow(clippy::needless_pass_by_value, reason = "Magnus method argument type")]
373
- fn deque_state(ruby: &Ruby, this: &Self, name: String) -> Result<NativeDequeState, Error> {
373
+ fn deque_state(ruby: &Ruby, this: &Self, name: String) -> Result<NativeJsonDequeState, Error> {
374
374
  let handle = this
375
375
  .inner
376
376
  .deque_state(&name)
377
377
  .map_err(|error| state_error(ruby, &error))?;
378
- Ok(NativeDequeState::new(
379
- DequeStateVariant::Json(Arc::from(handle)),
378
+ Ok(NativeJsonDequeState::new(
379
+ Arc::from(handle),
380
380
  this.bridge.clone(),
381
381
  Arc::clone(&this.propagator),
382
382
  ))
@@ -392,13 +392,13 @@ impl Context {
392
392
  ruby: &Ruby,
393
393
  this: &Self,
394
394
  name: String,
395
- ) -> Result<NativeValueState, Error> {
395
+ ) -> Result<NativeMessageValueState, Error> {
396
396
  let handle = this
397
397
  .inner
398
398
  .message_value_state(&name)
399
399
  .map_err(|error| state_error(ruby, &error))?;
400
- Ok(NativeValueState::new(
401
- ValueStateVariant::Message(Arc::from(handle)),
400
+ Ok(NativeMessageValueState::new(
401
+ Arc::from(handle),
402
402
  this.bridge.clone(),
403
403
  Arc::clone(&this.propagator),
404
404
  ))
@@ -410,13 +410,17 @@ impl Context {
410
410
  ///
411
411
  /// See [`value_state`](Self::value_state).
412
412
  #[allow(clippy::needless_pass_by_value, reason = "Magnus method argument type")]
413
- fn message_map_state(ruby: &Ruby, this: &Self, name: String) -> Result<NativeMapState, Error> {
413
+ fn message_map_state(
414
+ ruby: &Ruby,
415
+ this: &Self,
416
+ name: String,
417
+ ) -> Result<NativeMessageMapState, Error> {
414
418
  let handle = this
415
419
  .inner
416
420
  .message_map_state(&name)
417
421
  .map_err(|error| state_error(ruby, &error))?;
418
- Ok(NativeMapState::new(
419
- MapStateVariant::Message(Arc::from(handle)),
422
+ Ok(NativeMessageMapState::new(
423
+ Arc::from(handle),
420
424
  this.bridge.clone(),
421
425
  Arc::clone(&this.propagator),
422
426
  ))
@@ -432,13 +436,13 @@ impl Context {
432
436
  ruby: &Ruby,
433
437
  this: &Self,
434
438
  name: String,
435
- ) -> Result<NativeDequeState, Error> {
439
+ ) -> Result<NativeMessageDequeState, Error> {
436
440
  let handle = this
437
441
  .inner
438
442
  .message_deque_state(&name)
439
443
  .map_err(|error| state_error(ruby, &error))?;
440
- Ok(NativeDequeState::new(
441
- DequeStateVariant::Message(Arc::from(handle)),
444
+ Ok(NativeMessageDequeState::new(
445
+ Arc::from(handle),
442
446
  this.bridge.clone(),
443
447
  Arc::clone(&this.propagator),
444
448
  ))
@@ -24,6 +24,15 @@ pub struct Message {
24
24
  inner: ConsumerMessage<serde_json::Value>,
25
25
  }
26
26
 
27
+ /// Ruby wrapper for an excise record.
28
+ #[derive(Educe, Clone)]
29
+ #[educe(Debug)]
30
+ #[magnus::wrap(class = "Prosody::ExciseMessage", frozen_shareable)]
31
+ pub struct ExciseMessage {
32
+ #[educe(Debug(ignore))]
33
+ inner: ConsumerMessage<()>,
34
+ }
35
+
27
36
  impl Message {
28
37
  /// Returns the topic name this message was published to.
29
38
  ///
@@ -122,6 +131,34 @@ impl Message {
122
131
  }
123
132
  }
124
133
 
134
+ impl ExciseMessage {
135
+ fn topic(&self) -> &'static str {
136
+ self.inner.topic().as_ref()
137
+ }
138
+
139
+ fn partition(&self) -> i32 {
140
+ self.inner.partition()
141
+ }
142
+
143
+ fn offset(&self) -> i64 {
144
+ self.inner.offset()
145
+ }
146
+
147
+ fn key(&self) -> &str {
148
+ self.inner.key()
149
+ }
150
+
151
+ fn timestamp(ruby: &Ruby, this: &Self) -> Result<Value, Error> {
152
+ let epoch_micros = this.inner.timestamp().timestamp_micros();
153
+ ruby.module_kernel()
154
+ .const_get::<_, RClass>(id!(ruby, "Time"))?
155
+ .funcall(
156
+ id!(ruby, "at"),
157
+ (epoch_micros, ruby.to_symbol("microsecond")),
158
+ )
159
+ }
160
+ }
161
+
125
162
  impl From<ConsumerMessage<serde_json::Value>> for Message {
126
163
  /// Creates a new Message wrapper from a Prosody `ConsumerMessage`.
127
164
  ///
@@ -133,6 +170,12 @@ impl From<ConsumerMessage<serde_json::Value>> for Message {
133
170
  }
134
171
  }
135
172
 
173
+ impl From<ConsumerMessage<()>> for ExciseMessage {
174
+ fn from(inner: ConsumerMessage<()>) -> Self {
175
+ Self { inner }
176
+ }
177
+ }
178
+
136
179
  /// Initializes the `Prosody::Message` Ruby class and defines its methods.
137
180
  ///
138
181
  /// # Arguments
@@ -157,5 +200,12 @@ pub fn init(ruby: &Ruby) -> Result<(), Error> {
157
200
  class.define_method(id!(ruby, "timestamp"), method!(Message::timestamp, 0))?;
158
201
  class.define_method(id!(ruby, "payload"), method!(Message::payload, 0))?;
159
202
 
203
+ let class = module.define_class(id!(ruby, "ExciseMessage"), ruby.class_object())?;
204
+ class.define_method(id!(ruby, "topic"), method!(ExciseMessage::topic, 0))?;
205
+ class.define_method(id!(ruby, "partition"), method!(ExciseMessage::partition, 0))?;
206
+ class.define_method(id!(ruby, "offset"), method!(ExciseMessage::offset, 0))?;
207
+ class.define_method(id!(ruby, "key"), method!(ExciseMessage::key, 0))?;
208
+ class.define_method(id!(ruby, "timestamp"), method!(ExciseMessage::timestamp, 0))?;
209
+
160
210
  Ok(())
161
211
  }
@@ -11,7 +11,7 @@
11
11
 
12
12
  use crate::bridge::{Bridge, BridgeError};
13
13
  use crate::handler::context::Context;
14
- use crate::handler::message::Message;
14
+ use crate::handler::message::{ExciseMessage, Message};
15
15
  use crate::handler::trigger::Timer;
16
16
  use crate::id;
17
17
  use crate::scheduler::result::ProcessingError;
@@ -19,7 +19,7 @@ use crate::scheduler::{Scheduler, SchedulerError};
19
19
  use crate::util::ThreadSafeValue;
20
20
  use futures::pin_mut;
21
21
  use magnus::value::ReprValue;
22
- use magnus::{Error, Ruby, Value};
22
+ use magnus::{Error, IntoValue, Ruby, Value};
23
23
  use opentelemetry::propagation::TextMapCompositePropagator;
24
24
  use opentelemetry::trace::Status;
25
25
  use prosody::consumer::event_context::EventContext;
@@ -27,13 +27,14 @@ use prosody::consumer::message::ConsumerMessage;
27
27
  use prosody::consumer::middleware::FallibleHandler;
28
28
  use prosody::consumer::{DemandType, Keyed};
29
29
  use prosody::error::{ClassifyError, ErrorCategory};
30
+ use prosody::high_level::{ClientHandler, JsonCodecs};
30
31
  use prosody::propagator::new_propagator;
31
32
  use prosody::timers::{TimerType, Trigger as ProsodyTrigger};
32
33
  use std::collections::HashMap;
33
34
  use std::sync::Arc;
34
35
  use thiserror::Error;
35
36
  use tokio::select;
36
- use tracing::{Instrument, info_span};
37
+ use tracing::{Instrument, Span, info_span};
37
38
  use tracing_opentelemetry::OpenTelemetrySpanExt;
38
39
 
39
40
  mod context;
@@ -41,6 +42,11 @@ mod message;
41
42
  mod state;
42
43
  mod trigger;
43
44
 
45
+ pub(crate) use state::{
46
+ NativeJsonDequeScan, NativeJsonMapScan, NativeMapKeyScan, parse_direction,
47
+ published_deque_scan, published_map_key_scan, published_map_scan,
48
+ };
49
+
44
50
  /// A handler that bridges between Kafka messages and Ruby message processing
45
51
  /// code.
46
52
  ///
@@ -84,11 +90,78 @@ impl RubyHandler {
84
90
  propagator: Arc::new(new_propagator()),
85
91
  })
86
92
  }
93
+
94
+ async fn handle_record<C, P, M>(
95
+ &self,
96
+ context: C,
97
+ message: ConsumerMessage<P>,
98
+ method: &'static str,
99
+ event_type: &'static str,
100
+ span: Span,
101
+ ) -> Result<serde_json::Value, RubyHandlerError>
102
+ where
103
+ C: EventContext<Payload = serde_json::Value>,
104
+ M: From<ConsumerMessage<P>> + IntoValue + Send + 'static,
105
+ P: Send + Sync + 'static,
106
+ {
107
+ let cancel_future = context.clone().on_cancel();
108
+ let handler = self.handler.clone();
109
+ let task_id = format!(
110
+ "{}/{}:{}",
111
+ message.topic(),
112
+ message.partition(),
113
+ message.offset()
114
+ );
115
+ let event_context = HashMap::from([
116
+ ("event_type".into(), event_type.into()),
117
+ ("topic".into(), message.topic().to_string()),
118
+ ("partition".into(), message.partition().to_string()),
119
+ ("key".into(), message.key().to_string()),
120
+ ("offset".into(), message.offset().to_string()),
121
+ ]);
122
+ let context = Context::new(
123
+ context.boxed(),
124
+ self.bridge.clone(),
125
+ self.propagator.clone(),
126
+ );
127
+ let response_requested = message.response_requested();
128
+ let message = M::from(message);
129
+ let cloned_span = span.clone();
130
+
131
+ async move {
132
+ let task_handle = self
133
+ .scheduler
134
+ .schedule(task_id, &cloned_span, event_context, move |ruby| {
135
+ let result = handler.get(ruby).funcall(method, (context, message))?;
136
+ Ok(if response_requested {
137
+ result
138
+ } else {
139
+ ruby.qnil().as_value()
140
+ })
141
+ })
142
+ .await?;
143
+ let result_future = task_handle.result.receive();
144
+ pin_mut!(result_future);
145
+ let result = select! {
146
+ result = &mut result_future => {
147
+ result.inspect_err(|error| cloned_span.set_status(Status::error(error.to_string())))?
148
+ }
149
+ () = cancel_future => {
150
+ task_handle.cancellation_token.cancel(&self.bridge).await
151
+ .inspect_err(|error| cloned_span.set_status(Status::error(error.to_string())))?;
152
+ result_future.await?
153
+ }
154
+ };
155
+ Ok(result)
156
+ }
157
+ .instrument(span)
158
+ .await
159
+ }
87
160
  }
88
161
 
89
162
  impl FallibleHandler for RubyHandler {
90
163
  type Error = RubyHandlerError;
91
- type Output = ();
164
+ type Output = serde_json::Value;
92
165
  type Payload = serde_json::Value;
93
166
 
94
167
  /// Processes a Kafka message by dispatching it to the Ruby handler.
@@ -116,12 +189,10 @@ impl FallibleHandler for RubyHandler {
116
189
  context: C,
117
190
  message: ConsumerMessage<Self::Payload>,
118
191
  _demand_type: DemandType,
119
- ) -> Result<(), Self::Error>
192
+ ) -> Result<Self::Output, Self::Error>
120
193
  where
121
194
  C: EventContext<Payload = Self::Payload>,
122
195
  {
123
- // Create a new span for the on_message operation as a child of the message's
124
- // span
125
196
  let span = info_span!(
126
197
  parent: message.span(),
127
198
  "on_message",
@@ -130,75 +201,29 @@ impl FallibleHandler for RubyHandler {
130
201
  offset = message.offset(),
131
202
  key = %message.key()
132
203
  );
204
+ self.handle_record::<_, _, Message>(context, message, "on_message", "message", span)
205
+ .await
206
+ }
133
207
 
134
- // Get a future that completes when cancellation is signaled
135
- let cloned_context = context.clone();
136
- let cancel_future = cloned_context.on_cancel();
137
-
138
- // Clone the handler reference for use in the closure
139
- let handler = self.handler.clone();
140
-
141
- // Create a unique task ID for this message
142
- let task_id = format!(
143
- "{}/{}:{}",
144
- message.topic(),
145
- message.partition(),
146
- message.offset()
147
- );
148
-
149
- let event_context = HashMap::from([
150
- ("event_type".into(), "message".into()),
151
- ("topic".into(), message.topic().to_string()),
152
- ("partition".into(), message.partition().to_string()),
153
- ("key".into(), message.key().to_string()),
154
- ("offset".into(), message.offset().to_string()),
155
- ]);
156
-
157
- // Convert the Kafka message and context to Ruby-compatible types
158
- let context = Context::new(
159
- context.boxed(),
160
- self.bridge.clone(),
161
- self.propagator.clone(),
208
+ async fn on_excise<C>(
209
+ &self,
210
+ context: C,
211
+ message: ConsumerMessage<()>,
212
+ _demand_type: DemandType,
213
+ ) -> Result<Self::Output, Self::Error>
214
+ where
215
+ C: EventContext<Payload = Self::Payload>,
216
+ {
217
+ let span = info_span!(
218
+ parent: message.span(),
219
+ "on_excise",
220
+ topic = %message.topic(),
221
+ partition = message.partition(),
222
+ offset = message.offset(),
223
+ key = %message.key()
162
224
  );
163
- let message: Message = message.into();
164
-
165
- // Execute the entire message handling operation within the span
166
- let cloned_span = span.clone();
167
- async move {
168
- // Schedule the task to run in Ruby
169
- let task_handle = self
170
- .scheduler
171
- .schedule(task_id, &cloned_span, event_context, move |ruby| {
172
- let _: Value = handler
173
- .get(ruby)
174
- .funcall(id!(ruby, "on_message"), (context, message))?;
175
-
176
- Ok(())
177
- })
178
- .await?;
179
-
180
- // Get the future that will complete when the task is done
181
- let result_future = task_handle.result.receive();
182
- pin_mut!(result_future);
183
-
184
- // Wait for either task completion or shutdown signal
185
- select! {
186
- result = &mut result_future => {
187
- result.inspect_err(|e| cloned_span.set_status(Status::error(e.to_string())))?;
188
- }
189
- () = cancel_future => {
190
- // A cancel() failure is a genuine bridge error; mark the span.
191
- // The subsequent result_future error is expected cancellation, not a handler bug.
192
- task_handle.cancellation_token.cancel(&self.bridge).await
193
- .inspect_err(|e| cloned_span.set_status(Status::error(e.to_string())))?;
194
- result_future.await?;
195
- }
196
- }
197
-
198
- Ok(())
199
- }
200
- .instrument(span)
201
- .await
225
+ self.handle_record::<_, _, ExciseMessage>(context, message, "on_excise", "excise", span)
226
+ .await
202
227
  }
203
228
 
204
229
  async fn on_timer<C>(
@@ -206,13 +231,13 @@ impl FallibleHandler for RubyHandler {
206
231
  context: C,
207
232
  trigger: ProsodyTrigger,
208
233
  _demand_type: DemandType,
209
- ) -> Result<(), Self::Error>
234
+ ) -> Result<Self::Output, Self::Error>
210
235
  where
211
236
  C: EventContext<Payload = Self::Payload>,
212
237
  {
213
238
  // Only process application timers; internal timers are handled by middleware
214
239
  if trigger.timer_type != TimerType::Application {
215
- return Ok(());
240
+ return Ok(serde_json::Value::Null);
216
241
  }
217
242
 
218
243
  // Create a new span for the on_timer operation as a child of the trigger's span
@@ -257,8 +282,7 @@ impl FallibleHandler for RubyHandler {
257
282
  let _: Value = handler
258
283
  .get(ruby)
259
284
  .funcall(id!(ruby, "on_timer"), (context, timer))?;
260
-
261
- Ok(())
285
+ Ok(ruby.qnil().as_value())
262
286
  })
263
287
  .await?;
264
288
 
@@ -267,20 +291,20 @@ impl FallibleHandler for RubyHandler {
267
291
  pin_mut!(result_future);
268
292
 
269
293
  // Wait for either task completion or shutdown signal
270
- select! {
294
+ let result = select! {
271
295
  result = &mut result_future => {
272
- result.inspect_err(|e| cloned_span.set_status(Status::error(e.to_string())))?;
296
+ result.inspect_err(|e| cloned_span.set_status(Status::error(e.to_string())))?
273
297
  }
274
298
  () = cancel_future => {
275
299
  // A cancel() failure is a genuine bridge error; mark the span.
276
300
  // The subsequent result_future error is expected cancellation, not a handler bug.
277
301
  task_handle.cancellation_token.cancel(&self.bridge).await
278
302
  .inspect_err(|e| cloned_span.set_status(Status::error(e.to_string())))?;
279
- result_future.await?;
303
+ result_future.await?
280
304
  }
281
- }
305
+ };
282
306
 
283
- Ok(())
307
+ Ok(result)
284
308
  }
285
309
  .instrument(span)
286
310
  .await
@@ -295,6 +319,10 @@ impl FallibleHandler for RubyHandler {
295
319
  }
296
320
  }
297
321
 
322
+ impl ClientHandler for RubyHandler {
323
+ type Codecs = JsonCodecs;
324
+ }
325
+
298
326
  impl ClassifyError for RubyHandlerError {
299
327
  /// Categorizes errors for proper retry handling in the Kafka consumer.
300
328
  ///
@@ -341,7 +369,7 @@ pub enum RubyHandlerError {
341
369
  pub fn init(ruby: &Ruby) -> Result<(), Error> {
342
370
  context::init(ruby)?;
343
371
  message::init(ruby)?;
344
- state::init(ruby)?;
372
+ state::register(ruby)?;
345
373
  trigger::init(ruby)?;
346
374
 
347
375
  Ok(())