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
data/Rakefile CHANGED
@@ -23,4 +23,14 @@ RSpec::Core::RakeTask.new(:spec)
23
23
 
24
24
  require "standard/rake"
25
25
 
26
- task default: %i[compile spec standard]
26
+ desc "Validate RBS signatures over the whole sig/ tree"
27
+ task :rbs do
28
+ sh "rbs -r logger -I sig validate"
29
+ end
30
+
31
+ desc "Type-check the Ruby implementation against its RBS signatures"
32
+ task :steep do
33
+ sh "steep check --with-expectations --severity-level=error"
34
+ end
35
+
36
+ task default: %i[compile spec standard rbs steep]
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prosody"
4
+ require "logger"
5
+
6
+ # Keyed-state example: per-key value/map/deque collections that survive across
7
+ # events. Definitions are declared once and reused for both registration (on the
8
+ # client) and binding (inside the handler). Every state op yields the fiber,
9
+ # never the thread.
10
+
11
+ # Definitions: declared once, reused for registration and binding. See
12
+ # keyed_state.rbs for payload and state types checked by Steep.
13
+ CART = Prosody.value("cart", ttl: 30 * 24 * 3600) # ValueState
14
+ TOTALS = Prosody.map("totals") # keys are always String
15
+ BACKLOG = Prosody.message_deque("backlog", capacity: 100) # bounded window of messages
16
+
17
+ class KeyedStateHandler < Prosody::EventHandler
18
+ def on_excise(context, message)
19
+ puts "Excise #{message.key}"
20
+ context.state(CART).clear
21
+ context.state(TOTALS).clear
22
+ context.state(BACKLOG).clear
23
+ nil
24
+ end
25
+
26
+ def initialize(logger:)
27
+ @logger = logger
28
+ end
29
+
30
+ def on_message(context, message)
31
+ payload = message.payload
32
+ cart = context.state(CART) # bound for this attempt only
33
+ current = cart.get || {"items" => []} # Hash, or nil when absent
34
+ cart.set(current.merge("items" => current["items"] + [payload["order_id"]]))
35
+
36
+ totals = context.state(TOTALS)
37
+ totals.set(message.key, payload["total"])
38
+ # Steep infers key as String and total as Integer from TOTALS's RBS type.
39
+ totals.each_pair { |key, total| @logger.info(format_total(key, total)) }
40
+
41
+ backlog = context.state(BACKLOG)
42
+ backlog.push(message) # stores the full Prosody::Message
43
+ oldest = backlog.get(0) # Message[order_event]?, preserving payload shape
44
+ @logger.info("oldest order: #{format_order_id(oldest.payload["order_id"])}") if oldest
45
+ end
46
+
47
+ def on_timer(_context, _timer)
48
+ end
49
+
50
+ private
51
+
52
+ def format_total(key, total)
53
+ "#{key}=#{total}"
54
+ end
55
+
56
+ def format_order_id(order_id)
57
+ order_id
58
+ end
59
+ end
60
+
61
+ if __FILE__ == $PROGRAM_NAME
62
+ client = Prosody::Client.new(
63
+ mock: true,
64
+ group_id: "keyed-state-example",
65
+ subscribed_topics: "orders",
66
+ state_collections: [CART, TOTALS, BACKLOG]
67
+ )
68
+ client.subscribe(KeyedStateHandler.new(logger: Logger.new($stdout)))
69
+ client.shutdown
70
+ end
@@ -0,0 +1,18 @@
1
+ type keyed_state_cart = { "items" => Array[String] }
2
+ type keyed_state_order_event = { "order_id" => String, "total" => Integer }
3
+
4
+ CART: Prosody::_ValueDefinition[keyed_state_cart]
5
+ TOTALS: Prosody::_MapDefinition[Integer]
6
+ BACKLOG: Prosody::_MessageDequeDefinition[keyed_state_order_event]
7
+
8
+ class KeyedStateHandler < Prosody::EventHandler[keyed_state_order_event]
9
+ @logger: Logger
10
+
11
+ def initialize: (logger: Logger) -> void
12
+ def on_message: (Prosody::Context context, Prosody::Message[keyed_state_order_event] message) -> void
13
+
14
+ private
15
+
16
+ def format_total: (String key, Integer total) -> String
17
+ def format_order_id: (String order_id) -> String
18
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prosody"
4
+
5
+ # The complete typed counterpart of the README burst-windowing example.
6
+ ACTIVITY_WINDOW = Prosody.value("activity-window")
7
+ PENDING_ACTIVITIES = Prosody.message_deque("pending-activities", capacity: 100)
8
+
9
+ class ActivityWindowHandler < Prosody::EventHandler
10
+ def on_excise(context, message)
11
+ puts "Excise #{message.key}"
12
+ context.state(PENDING_ACTIVITIES).clear
13
+ context.state(ACTIVITY_WINDOW).clear
14
+ context.clear_scheduled
15
+ nil
16
+ end
17
+
18
+ def on_message(context, message)
19
+ window = context.state(ACTIVITY_WINDOW)
20
+ pending = context.state(PENDING_ACTIVITIES)
21
+
22
+ if window.get
23
+ pending.push(message)
24
+ else
25
+ notify(message.key, [message])
26
+ window.set(true)
27
+ context.clear_and_schedule(Time.now + 5 * 60)
28
+ end
29
+ end
30
+
31
+ def on_timer(context, timer)
32
+ pending = context.state(PENDING_ACTIVITIES)
33
+ batch = pending.each.to_a
34
+ notify(timer.key, batch) unless batch.empty?
35
+ pending.clear
36
+ context.state(ACTIVITY_WINDOW).clear
37
+ end
38
+
39
+ private
40
+
41
+ def notify(user_id, activities)
42
+ puts "notify #{user_id}: #{activities.length} activities"
43
+ end
44
+ end
45
+
46
+ if __FILE__ == $PROGRAM_NAME
47
+ client = Prosody::Client.new(
48
+ mock: true,
49
+ group_id: "activity-window-example",
50
+ subscribed_topics: "activities",
51
+ state_collections: [ACTIVITY_WINDOW, PENDING_ACTIVITIES]
52
+ )
53
+ client.subscribe(ActivityWindowHandler.new)
54
+ client.shutdown
55
+ end
@@ -0,0 +1,16 @@
1
+ type activity_event = {
2
+ "actor" => String,
3
+ "action" => String
4
+ }
5
+
6
+ ACTIVITY_WINDOW: Prosody::_ValueDefinition[bool]
7
+ PENDING_ACTIVITIES: Prosody::_MessageDequeDefinition[activity_event]
8
+
9
+ class ActivityWindowHandler < Prosody::EventHandler[activity_event]
10
+ def on_message: (Prosody::Context context, Prosody::Message[activity_event] message) -> void
11
+ def on_timer: (Prosody::Context context, Prosody::Timer timer) -> void
12
+
13
+ private
14
+
15
+ def notify: (String user_id, Array[Prosody::Message[activity_event]] activities) -> void
16
+ end
@@ -11,6 +11,7 @@ crate-type = ["cdylib"]
11
11
  [dependencies]
12
12
  atomic-take.workspace = true
13
13
  bumpalo = { workspace = true, features = ["collections"] }
14
+ crossbeam-channel.workspace = true
14
15
  educe.workspace = true
15
16
  futures.workspace = true
16
17
  magnus = { workspace = true, default-features = false }
@@ -6,8 +6,7 @@
6
6
  use crate::bridge::Bridge;
7
7
  use crate::util::ensure_runtime_context;
8
8
  use crate::{ROOT_MOD, id};
9
- use magnus::value::ReprValue;
10
- use magnus::{Error, Module, Object, Ruby, Value, function, method};
9
+ use magnus::{Error, Module, Object, Ruby, function, method};
11
10
  use prosody::admin::{AdminConfiguration, ProsodyAdminClient, TopicConfiguration};
12
11
  use std::sync::Arc;
13
12
  use tracing::Span;
@@ -164,8 +163,5 @@ pub fn init(ruby: &Ruby) -> Result<(), Error> {
164
163
  method!(AdminClient::delete_topic, 1),
165
164
  )?;
166
165
 
167
- // Make the admin client class private
168
- let _: Value = module.funcall(id!(ruby, "private_constant"), (class_id,))?;
169
-
170
166
  Ok(())
171
167
  }
@@ -9,15 +9,12 @@ use crate::bridge::callback::AsyncCallback;
9
9
  use crate::gvl::{GvlError, without_gvl};
10
10
  use crate::{ROOT_MOD, RUNTIME, id};
11
11
  use atomic_take::AtomicTake;
12
+ use crossbeam_channel::{Receiver, Sender, TryRecvError, TrySendError, bounded, select, unbounded};
12
13
  use educe::Educe;
13
- use futures::executor::block_on;
14
14
  use magnus::value::{Lazy, ReprValue};
15
15
  use magnus::{Error, Module, RClass, Ruby, Value};
16
16
  use std::any::Any;
17
17
  use thiserror::Error;
18
- use tokio::select;
19
- use tokio::sync::mpsc::error::SendError;
20
- use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel};
21
18
  use tokio::sync::oneshot;
22
19
  use tracing::{Instrument, Span, debug, error, warn};
23
20
 
@@ -63,7 +60,7 @@ pub struct Bridge {
63
60
  /// Channel sender for submitting functions to be executed in the Ruby
64
61
  /// context.
65
62
  #[educe(Debug(ignore))]
66
- tx: UnboundedSender<RubyFunction>,
63
+ tx: Sender<RubyFunction>,
67
64
  }
68
65
 
69
66
  impl Bridge {
@@ -83,12 +80,12 @@ impl Bridge {
83
80
  ///
84
81
  /// A new `Bridge` instance.
85
82
  pub fn new(ruby: &Ruby) -> Self {
86
- let (tx, mut rx) = unbounded_channel();
83
+ let (tx, rx) = unbounded();
87
84
 
88
85
  // Create a dedicated Ruby thread to process functions
89
86
  ruby.thread_create_from_fn(move |ruby| {
90
87
  loop {
91
- let Err(error) = poll(&mut rx, ruby) else {
88
+ let Err(error) = poll(&rx, ruby) else {
92
89
  continue;
93
90
  };
94
91
 
@@ -208,8 +205,8 @@ impl Bridge {
208
205
  /// Unlike [`run`](Self::run), this method does not await a result and can
209
206
  /// be called from synchronous contexts such as `Drop` implementations.
210
207
  /// Returns `Err` only if the bridge receiver has been dropped (shutdown).
211
- pub(crate) fn send(&self, function: RubyFunction) -> Result<(), SendError<RubyFunction>> {
212
- self.tx.send(function)
208
+ pub(crate) fn send(&self, function: RubyFunction) -> Result<(), BridgeError> {
209
+ self.tx.send(function).map_err(|_| BridgeError::Shutdown)
213
210
  }
214
211
  }
215
212
 
@@ -233,32 +230,24 @@ impl Bridge {
233
230
  ///
234
231
  /// Returns a `BridgeError` if there was an issue with polling or executing
235
232
  /// commands.
236
- fn poll(rx: &mut UnboundedReceiver<RubyFunction>, ruby: &Ruby) -> Result<(), BridgeError> {
237
- // Set up cancellation channel
238
- let (cancel_tx, cancel_rx) = oneshot::channel();
239
- let mut maybe_cancel_tx = Some(cancel_tx);
233
+ fn poll(rx: &Receiver<RubyFunction>, ruby: &Ruby) -> Result<(), BridgeError> {
234
+ let (cancel_tx, cancel_rx) = bounded(1);
240
235
 
241
236
  // Function to be executed without the GVL (Global VM Lock)
242
237
  let poll_fn = || {
243
- // Wait for either a command or cancellation
244
- let maybe_command = block_on(async {
245
- select! {
246
- _ = cancel_rx => Err(BridgeError::Cancelled),
247
- result = rx.recv() => Ok(result),
248
- }
249
- })?;
250
-
251
- let first_command = maybe_command.ok_or(BridgeError::Shutdown)?;
238
+ let first_command = select! {
239
+ recv(cancel_rx) -> _ => return Err(BridgeError::Cancelled),
240
+ recv(rx) -> result => result.map_err(|_| BridgeError::Shutdown)?,
241
+ };
252
242
 
253
243
  // Batch up to POLL_BATCH_SIZE commands
254
244
  let mut commands = Vec::with_capacity(POLL_BATCH_SIZE);
255
245
  commands.push(first_command);
256
246
 
257
247
  while commands.len() < POLL_BATCH_SIZE {
258
- if let Ok(command) = rx.try_recv() {
259
- commands.push(command);
260
- } else {
261
- break;
248
+ match rx.try_recv() {
249
+ Ok(command) => commands.push(command),
250
+ Err(TryRecvError::Empty | TryRecvError::Disconnected) => break,
262
251
  }
263
252
  }
264
253
 
@@ -266,12 +255,8 @@ fn poll(rx: &mut UnboundedReceiver<RubyFunction>, ruby: &Ruby) -> Result<(), Bri
266
255
  };
267
256
 
268
257
  // Function to cancel polling if needed
269
- let cancel_fn = || {
270
- let Some(cancel_tx) = maybe_cancel_tx.take() else {
271
- return;
272
- };
273
-
274
- if cancel_tx.send(()).is_err() {
258
+ let cancel_fn = move || {
259
+ if matches!(cancel_tx.try_send(()), Err(TrySendError::Disconnected(()))) {
275
260
  warn!("Failed to cancel poll operation");
276
261
  }
277
262
  };