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.
- checksums.yaml +4 -4
- data/.cargo/config.toml +3 -0
- data/.release-please-manifest.json +1 -1
- data/AGENTS.md +395 -0
- data/ARCHITECTURE.md +14 -4
- data/CHANGELOG.md +28 -0
- data/CLAUDE.md +1 -0
- data/CONFIGURATION.md +167 -0
- data/Cargo.lock +1115 -645
- data/Cargo.toml +7 -6
- data/README.md +436 -146
- data/Rakefile +11 -1
- data/examples/keyed_state.rb +70 -0
- data/examples/keyed_state.rbs +18 -0
- data/examples/keyed_state_windowing.rb +55 -0
- data/examples/keyed_state_windowing.rbs +16 -0
- data/ext/prosody/Cargo.toml +1 -0
- data/ext/prosody/src/admin.rs +1 -5
- data/ext/prosody/src/bridge/mod.rs +17 -32
- data/ext/prosody/src/client/config.rs +501 -28
- data/ext/prosody/src/client/mod.rs +167 -74
- data/ext/prosody/src/client/request.rs +132 -0
- data/ext/prosody/src/client/support.rs +122 -0
- data/ext/prosody/src/handler/context.rs +150 -5
- data/ext/prosody/src/handler/message.rs +67 -0
- data/ext/prosody/src/handler/mod.rs +115 -85
- data/ext/prosody/src/handler/state/mod.rs +488 -0
- data/ext/prosody/src/handler/state/registration.rs +104 -0
- data/ext/prosody/src/handler/state/scan.rs +218 -0
- data/ext/prosody/src/lib.rs +15 -3
- data/ext/prosody/src/published.rs +273 -0
- data/ext/prosody/src/scheduler/mod.rs +2 -2
- data/ext/prosody/src/scheduler/processor.rs +2 -2
- data/ext/prosody/src/scheduler/result.rs +7 -4
- data/ext/prosody/src/util.rs +86 -5
- data/lib/prosody/configuration.rb +71 -11
- data/lib/prosody/handler.rb +65 -8
- data/lib/prosody/native_stubs.rb +550 -9
- data/lib/prosody/request.rb +45 -0
- data/lib/prosody/state.rb +816 -0
- data/lib/prosody/version.rb +1 -1
- data/lib/prosody.rb +6 -0
- data/release-please-config.json +4 -0
- data/sig/configuration.rbs +70 -11
- data/sig/handler.rbs +17 -5
- data/sig/processor.rbs +28 -12
- data/sig/prosody.rbs +53 -7
- data/sig/request.rbs +66 -0
- data/sig/sentry.rbs +6 -0
- data/sig/state.rbs +390 -0
- data/steep_expectations.yml +57 -0
- data/typecheck/payload_types.rb +54 -0
- data/typecheck/payload_types.rbs +22 -0
- data/typecheck_negative/payload_types.rb +20 -0
- data/typecheck_negative/payload_types.rbs +9 -0
- 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
|
-
|
|
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
|
data/ext/prosody/Cargo.toml
CHANGED
|
@@ -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 }
|
data/ext/prosody/src/admin.rs
CHANGED
|
@@ -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::
|
|
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:
|
|
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,
|
|
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(&
|
|
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<(),
|
|
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: &
|
|
237
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
259
|
-
commands.push(command)
|
|
260
|
-
|
|
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
|
-
|
|
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
|
};
|