refactor: move lapin code into revolt-broker crate

This commit is contained in:
izzy
2025-06-28 17:23:44 +01:00
parent 85989a2138
commit 9d576d2430
15 changed files with 409 additions and 234 deletions

16
Cargo.lock generated
View File

@@ -6573,6 +6573,7 @@ dependencies = [
"querystring", "querystring",
"rand 0.6.5", "rand 0.6.5",
"redis-kiss", "redis-kiss",
"revolt-broker",
"revolt-config", "revolt-config",
"revolt-database", "revolt-database",
"revolt-models", "revolt-models",
@@ -6586,6 +6587,19 @@ dependencies = [
"ulid 0.5.0", "ulid 0.5.0",
] ]
[[package]]
name = "revolt-broker"
version = "0.8.8"
dependencies = [
"async-std",
"lapin",
"log",
"rand 0.9.1",
"revolt-config",
"rmp-serde",
"serde",
]
[[package]] [[package]]
name = "revolt-config" name = "revolt-config"
version = "0.8.8" version = "0.8.8"
@@ -6645,6 +6659,7 @@ dependencies = [
"rand 0.8.5", "rand 0.8.5",
"redis-kiss", "redis-kiss",
"regex", "regex",
"revolt-broker",
"revolt-config", "revolt-config",
"revolt-models", "revolt-models",
"revolt-parser", "revolt-parser",
@@ -6694,6 +6709,7 @@ dependencies = [
"redis-kiss", "redis-kiss",
"regex", "regex",
"reqwest 0.11.27", "reqwest 0.11.27",
"revolt-broker",
"revolt-config", "revolt-config",
"revolt-database", "revolt-database",
"revolt-models", "revolt-models",

View File

@@ -39,6 +39,7 @@ async-std = { version = "1.8.0", features = [
# core # core
authifier = { version = "1.0.15" } authifier = { version = "1.0.15" }
revolt-result = { path = "../core/result" } revolt-result = { path = "../core/result" }
revolt-broker = { path = "../core/broker" }
revolt-models = { path = "../core/models" } revolt-models = { path = "../core/models" }
revolt-config = { path = "../core/config" } revolt-config = { path = "../core/config" }
revolt-database = { path = "../core/database" } revolt-database = { path = "../core/database" }

View File

@@ -1,20 +1,10 @@
use std::collections::HashMap;
use async_channel::Receiver; use async_channel::Receiver;
use async_std::{net::TcpStream, sync::Mutex}; use async_std::{net::TcpStream, sync::Mutex};
use async_tungstenite::WebSocketStream; use async_tungstenite::WebSocketStream;
use authifier::AuthifierEvent; use authifier::AuthifierEvent;
use futures::{pin_mut, select, stream::SplitSink, FutureExt, SinkExt, StreamExt}; use futures::{pin_mut, select, stream::SplitSink, FutureExt, SinkExt};
use lapin::{ use revolt_broker::event_stream;
options::BasicAckOptions, use revolt_database::{events::client::EventV1, Database};
types::{AMQPValue, FieldArray, FieldTable, LongLongInt},
};
use rand::Rng;
use revolt_config::report_internal_error;
use revolt_database::{
events::client::{get_event_stream_channel, EventV1},
Database,
};
use sentry::Level; use sentry::Level;
use crate::{ use crate::{
@@ -22,8 +12,6 @@ use crate::{
events::state::{State, SubscriptionStateChange}, events::state::{State, SubscriptionStateChange},
}; };
static QUEUE_NAME: &str = "revolt.events";
/// Event subscriber loop /// Event subscriber loop
pub async fn client_subscriber( pub async fn client_subscriber(
write: &Mutex<SplitSink<WebSocketStream<TcpStream>, async_tungstenite::tungstenite::Message>>, write: &Mutex<SplitSink<WebSocketStream<TcpStream>, async_tungstenite::tungstenite::Message>>,
@@ -33,43 +21,17 @@ pub async fn client_subscriber(
db: &'static Database, db: &'static Database,
state: &mut State, state: &mut State,
) { ) {
// * --- CHANNEL CREATE --- let mut consumer = event_stream::Consumer::new().await;
let channel = get_event_stream_channel().await; consumer.set_topics(state.subscribed.read().await.clone());
// * --- CHANNEL END ---
let mut offset: Option<LongLongInt> = None;
let mut cancel = false; let mut cancel = false;
loop { loop {
// Build arguments for consumer // Reload consumer if subscriptions change
let mut args: FieldTable = Default::default(); if !matches!(state.apply_state().await, SubscriptionStateChange::None) {
consumer.set_topics(state.subscribed.read().await.clone());
// Configure stream filter to select topics we are listening for
{
let mut filter: FieldArray = Default::default();
for topic in state.subscribed.read().await.iter() {
filter.push(AMQPValue::LongString(topic.as_str().into()));
} }
args.insert("x-stream-filter".into(), AMQPValue::FieldArray(filter));
}
// Set stream offset if applicable
if let Some(offset) = offset {
args.insert("x-stream-offset".into(), AMQPValue::LongLongInt(offset));
}
// Create the consumer
let tag: String = rand::thread_rng()
.sample_iter::<char, _>(&rand::distributions::Standard)
.take(32)
.collect();
let mut consumer = channel
.basic_consume(QUEUE_NAME, &tag, Default::default(), args)
.await
.unwrap();
// Read incoming events // Read incoming events
loop { loop {
let reloaded = reloaded.recv().fuse(); let reloaded = reloaded.recv().fuse();
@@ -85,43 +47,8 @@ pub async fn client_subscriber(
cancel = true; cancel = true;
break; break;
} }
delivery = delivery => { event = delivery => {
let delivery = delivery.expect("error in consumer").expect("error in consumer"); if let Some(mut event) = event {
// Acknowledgement is required
delivery.ack(BasicAckOptions::default()).await.expect("ack");
// Parse the delivery headers
let headers: HashMap<String, AMQPValue> = delivery
.properties
.headers()
.as_ref()
.map(|table| {
table
.into_iter()
.map(|(k, v)| (k.to_string(), v.clone()))
.collect()
})
.unwrap_or_default();
// Client-side topic filtering (broker uses Bloom filter so may have false-positives)
let filter_value = headers
.get("x-stream-filter-value")
.expect("`x-stream-filter-value` not present in message!");
if state
.subscribed
.read()
.await
.contains(
&filter_value
.as_long_string()
.expect("`string`")
.to_string()
) {
// Deserialise the data
let mut event: EventV1 = rmp_serde::from_slice(&delivery.data).expect("`data`");
// Handle the event // Handle the event
if let EventV1::Auth(auth) = &event { if let EventV1::Auth(auth) = &event {
if let AuthifierEvent::DeleteSession { session_id, .. } = auth { if let AuthifierEvent::DeleteSession { session_id, .. } = auth {
@@ -165,39 +92,21 @@ pub async fn client_subscriber(
cancel = true; cancel = true;
break; break;
} }
}
// Keep track of the current offset break;
let stream_offset = headers } else {
.get("x-stream-offset") cancel = true;
.expect("`x-stream-offset` not present in message!");
offset = Some(stream_offset.as_long_long_int().unwrap() + 1);
// Reload consumer if subscriptions change
if !matches!(state.apply_state().await, SubscriptionStateChange::None) {
break; break;
} }
} }
} }
} }
// Close the consumer
if let Err(err) = channel.basic_cancel(&tag, Default::default()).await {
eprintln!("Failed to close consumer! {:?}", err);
} else {
// Read the consumer to the end
while let Some(delivery) = consumer.next().await {
let delivery = delivery.expect("error in consumer");
delivery.ack(BasicAckOptions::default()).await.expect("ack");
}
}
// Break out if cancelled // Break out if cancelled
if cancel { if cancel {
break; break;
} }
} }
report_internal_error!(channel.close(0, "closing channel").await).ok(); consumer.dispose().await;
} }

View File

@@ -0,0 +1,25 @@
[package]
name = "revolt-broker"
version = "0.8.8"
edition = "2024"
license = "AGPL-3.0-or-later"
authors = ["Paul Makles <me@insrt.uk>"]
description = "Revolt Backend: Event Broker"
[dependencies]
# Utility
log = "0.4"
rand = "0.9.1"
# RabbitMQ/AMQP client
lapin = "3.0.0"
# Async runtime
async-std = { version = "1.8.0" }
# Serialisation
serde = "1"
rmp-serde = "1.3.0"
# Core
revolt-config = { version = "0.8.8", path = "../config" }

View File

@@ -0,0 +1,160 @@
use std::{
collections::{HashMap, HashSet},
sync::Arc,
};
use async_std::stream::StreamExt;
use lapin::{
Channel,
options::BasicAckOptions,
types::{AMQPValue, FieldArray, FieldTable, LongLongInt},
};
use rand::Rng;
use revolt_config::config;
use serde::de::DeserializeOwned;
use crate::event_stream::get_channel;
pub struct Consumer {
channel: Arc<Channel>,
tag: String,
topics: HashSet<String>,
topics_changed: bool,
consumer: Option<lapin::Consumer>,
offset: Option<LongLongInt>,
}
impl Consumer {
/// Create a new event stream consumer
pub async fn new() -> Consumer {
Consumer {
channel: get_channel().await,
tag: rand::rng()
.sample_iter::<char, _>(&rand::distr::StandardUniform)
.take(32)
.collect(),
topics: HashSet::new(),
topics_changed: false,
consumer: None,
offset: None,
}
}
/// Update the set of topics
pub fn set_topics(&mut self, topics: HashSet<String>) {
self.topics = topics;
self.topics_changed = true;
}
/// Get the current consumer
pub async fn ensure_consumer(&mut self) {
if self.topics_changed {
self.consumer = None;
}
if self.consumer.is_none() {
let config = config().await;
// Build arguments for consumer
let mut args: FieldTable = Default::default();
// Configure stream filter to select topics we are listening for
{
let mut filter: FieldArray = Default::default();
for topic in &self.topics {
filter.push(AMQPValue::LongString(topic.as_str().into()));
}
args.insert("x-stream-filter".into(), AMQPValue::FieldArray(filter));
}
// Set stream offset if applicable
if let Some(offset) = self.offset {
args.insert("x-stream-offset".into(), AMQPValue::LongLongInt(offset));
}
// Create the consumer
self.consumer = Some(
self.channel
.basic_consume(
&config.rabbit.event_stream.queue,
&self.tag,
Default::default(),
args,
)
.await
.unwrap(),
);
}
}
/// Close the active consumer
pub async fn dispose(&mut self) {
// Close the channel -- don't do this actually
// capture_internal_error!(self.channel.close(0, "closing channel").await);
// Close the consumer
if let Err(err) = self
.channel
.basic_cancel(&self.tag, Default::default())
.await
{
eprintln!("Failed to close consumer! {:?}", err);
}
// is this necessary?
// else {
// Read the consumer to the end
// while let Some(delivery) = consumer.next().await {
// let delivery = delivery.expect("error in consumer");
// delivery.ack(BasicAckOptions::default()).await.expect("ack");
// }
// }
}
/// Get the next item
pub async fn next<T: DeserializeOwned>(&mut self) -> Option<T> {
self.ensure_consumer().await;
let consumer = self.consumer.as_mut().unwrap();
while let Some(Ok(delivery)) = consumer.next().await {
// Acknowledgement is required
delivery.ack(BasicAckOptions::default()).await.expect("ack");
// Parse the delivery headers
let headers: HashMap<String, AMQPValue> = delivery
.properties
.headers()
.as_ref()
.map(|table| {
table
.into_iter()
.map(|(k, v)| (k.to_string(), v.clone()))
.collect()
})
.unwrap_or_default();
// Keep track of the current offset
let stream_offset = headers
.get("x-stream-offset")
.expect("`x-stream-offset` not present in message!");
self.offset = Some(stream_offset.as_long_long_int().unwrap() + 1);
// Client-side topic filtering (broker uses Bloom filter so may have false-positives)
let filter_value = headers
.get("x-stream-filter-value")
.expect("`x-stream-filter-value` not present in message!")
.as_long_string()
.expect("`string`")
.to_string();
if self.topics.contains(&filter_value) {
// Deserialise the data
return Some(rmp_serde::from_slice(&delivery.data).expect("`data`"));
}
}
None
}
}

View File

@@ -0,0 +1,7 @@
mod consumer;
mod pool;
mod publish;
pub use consumer::Consumer;
pub use pool::get_channel;
pub use publish::publish_event;

View File

@@ -0,0 +1,100 @@
use async_std::sync::Mutex;
use lapin::{
Channel,
options::QueueDeclareOptions,
types::{AMQPValue, FieldTable},
};
use log::{debug, warn};
use revolt_config::{RabbitEventStream, config};
use std::sync::Arc;
use crate::create_client;
/// Get a handle to the event stream
pub async fn get_channel() -> Arc<Channel> {
let config = config().await;
static CONNECTIONS: Mutex<Vec<Arc<lapin::Channel>>> = Mutex::new(Vec::new());
let mut connections = CONNECTIONS.lock().await;
connections.retain(|item| {
if item.status().connected() {
true
} else {
warn!(
"Dropping connection with status {:?}",
item.status().state()
);
false
}
});
debug!(
"Connections: {}, Clients: {:?}",
connections.len(),
connections
.iter()
.map(Arc::strong_count)
.collect::<Vec<usize>>()
);
for channel in connections.iter() {
if Arc::strong_count(channel) < config.rabbit.event_stream.channels_per_conn {
return channel.clone();
}
}
let conn = create_client().await;
let channel = Arc::new(create_channel(conn, config.rabbit.event_stream).await);
connections.push(channel.clone());
channel
}
/// Create a channel
async fn create_channel(
conn: lapin::Connection,
event_stream: RabbitEventStream,
) -> lapin::Channel {
let channel = conn.create_channel().await.unwrap();
let mut args: FieldTable = Default::default();
args.insert(
// set queue type to stream
"x-queue-type".into(),
AMQPValue::LongString("stream".into()),
);
args.insert(
// max. size of the stream
"x-max-length-bytes".into(),
AMQPValue::LongLongInt(event_stream.stream_max_length_bytes),
);
args.insert(
// size of the Bloom filter
"x-stream-filter-size-bytes".into(),
AMQPValue::LongLongInt(event_stream.filter_size_bytes),
);
channel
.queue_declare(
&event_stream.queue,
QueueDeclareOptions {
durable: true,
..Default::default()
},
args,
)
.await
.unwrap();
channel
.basic_qos(event_stream.qos_prefetch, Default::default())
.await
.unwrap();
channel
}

View File

@@ -0,0 +1,35 @@
use lapin::{
Error,
protocol::basic::AMQPProperties,
publisher_confirm::PublisherConfirm,
types::{AMQPValue, FieldTable},
};
use revolt_config::config;
use serde::Serialize;
use crate::event_stream::get_channel;
/// Publish an event to the message broker
pub async fn publish_event<T: Serialize>(
channel: &str,
data: &T,
) -> Result<PublisherConfirm, Error> {
let config = config().await;
let mut headers: FieldTable = Default::default();
headers.insert(
"x-stream-filter-value".into(),
AMQPValue::LongString(channel.into()),
);
get_channel()
.await
.basic_publish(
&config.rabbit.event_stream.exchange,
&config.rabbit.event_stream.queue,
Default::default(),
&rmp_serde::to_vec_named(data).unwrap(),
AMQPProperties::default().with_headers(headers),
)
.await
}

View File

@@ -0,0 +1,18 @@
use revolt_config::config;
pub mod event_stream;
/// Create a lapin client
pub async fn create_client() -> lapin::Connection {
let config = config().await;
lapin::Connection::connect(
&format!(
"amqp://{}:{}@{}:{}/%2f",
config.rabbit.username, config.rabbit.password, config.rabbit.host, config.rabbit.port
),
Default::default(),
)
.await
.unwrap()
}

View File

@@ -28,6 +28,20 @@ port = 5672
username = "rabbituser" username = "rabbituser"
password = "rabbitpass" password = "rabbitpass"
[rabbit.event_stream]
# Configuration for event brokerage
# Using default/direct exchange
exchange = ""
queue = "revolt.events"
# Number of channels that can be opened per single TCP connection
channels_per_conn = 128
# Maximum size of the stream
stream_max_length_bytes = 5_000_000_000
# Size of the Bloom filter
filter_size_bytes = 26
# Number of messages to prefetch
qos_prefetch = 100
[api] [api]
[api.registration] [api.registration]

View File

@@ -108,12 +108,24 @@ pub struct Database {
pub redis: String, pub redis: String,
} }
#[derive(Deserialize, Debug, Clone)]
pub struct RabbitEventStream {
pub exchange: String,
pub queue: String,
pub channels_per_conn: usize,
pub stream_max_length_bytes: i64,
pub filter_size_bytes: i64,
pub qos_prefetch: u16,
}
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]
pub struct Rabbit { pub struct Rabbit {
pub host: String, pub host: String,
pub port: u16, pub port: u16,
pub username: String, pub username: String,
pub password: String, pub password: String,
pub event_stream: RabbitEventStream,
} }
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]

View File

@@ -37,6 +37,7 @@ revolt-permissions = { version = "0.8.8", path = "../permissions", features = [
"bson", "bson",
] } ] }
revolt-parser = { version = "0.8.8", path = "../parser" } revolt-parser = { version = "0.8.8", path = "../parser" }
revolt-broker = { version = "0.8.8", path = "../broker" }
# Utility # Utility
log = "0.4" log = "0.4"

View File

@@ -1,13 +1,5 @@
use std::sync::Arc;
use async_std::sync::Mutex;
use authifier::AuthifierEvent; use authifier::AuthifierEvent;
use lapin::{ use revolt_broker::event_stream;
options::QueueDeclareOptions,
protocol::basic::AMQPProperties,
types::{AMQPValue, FieldTable},
};
use revolt_config::config;
use revolt_result::Error; use revolt_result::Error;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -259,129 +251,13 @@ pub enum EventV1 {
Auth(AuthifierEvent), Auth(AuthifierEvent),
} }
// * not sure where to put this yet...
pub async fn create_client() -> lapin::Connection {
let config = config().await;
lapin::Connection::connect(
&format!(
"amqp://{}:{}@{}:{}/%2f",
config.rabbit.username, config.rabbit.password, config.rabbit.host, config.rabbit.port
),
Default::default(),
)
.await
.unwrap()
}
pub async fn create_event_stream_channel(conn: lapin::Connection) -> lapin::Channel {
static STREAM_NAME: &str = "revolt.events";
let channel = conn.create_channel().await.unwrap();
let mut args: FieldTable = Default::default();
args.insert(
// set queue type to stream
"x-queue-type".into(),
AMQPValue::LongString("stream".into()),
);
args.insert(
// max. size of the stream
"x-max-length-bytes".into(),
AMQPValue::LongLongInt(5_000_000_000), // 5 GB
);
args.insert(
// size of the Bloom filter
"x-stream-filter-size-bytes".into(),
AMQPValue::LongLongInt(26), // 26 B
);
channel
.queue_declare(
STREAM_NAME,
QueueDeclareOptions {
durable: true,
..Default::default()
},
args,
)
.await
.unwrap();
channel.basic_qos(100, Default::default()).await.unwrap();
channel
}
/// simple connection pooling algorithm
pub async fn get_event_stream_channel() -> Arc<lapin::Channel> {
static CLIENTS_PER_CONN: usize = 128;
static CONNECTIONS: Mutex<Vec<Arc<lapin::Channel>>> = Mutex::new(Vec::new());
let mut connections = CONNECTIONS.lock().await;
connections.retain(|item| {
if item.status().connected() {
true
} else {
info!(
"Dropping connection with status {:?}",
item.status().state()
);
false
}
});
info!(
"Connections: {}, Clients: {:?}",
connections.len(),
connections
.iter()
.map(Arc::strong_count)
.collect::<Vec<usize>>()
);
for channel in connections.iter() {
if Arc::strong_count(channel) < CLIENTS_PER_CONN {
return channel.clone();
}
}
let conn = create_client().await;
let channel = Arc::new(create_event_stream_channel(conn).await);
connections.push(channel.clone());
channel
}
// * ---
impl EventV1 { impl EventV1 {
/// Publish helper wrapper /// Publish helper wrapper
pub async fn p(self, channel: String) { pub async fn p(self, channel: String) {
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
info!("Publishing event to {channel}: {self:?}"); info!("Publishing event to {channel}: {self:?}");
static QUEUE_NAME: &str = "revolt.events"; let result = event_stream::publish_event(&channel, &self).await;
let mut headers: FieldTable = Default::default();
headers.insert(
"x-stream-filter-value".into(),
AMQPValue::LongString(channel.into()),
);
let properties: AMQPProperties = Default::default();
let result = get_event_stream_channel()
.await
.basic_publish(
"",
QUEUE_NAME,
Default::default(),
&rmp_serde::to_vec_named(&self).unwrap(),
properties.with_headers(headers),
)
.await;
#[cfg(not(debug_assertions))] #[cfg(not(debug_assertions))]
result.ok(); result.ok();

View File

@@ -71,6 +71,7 @@ lapin = { version = "3.0.0" }
# core # core
authifier = "1.0.15" authifier = "1.0.15"
revolt-config = { path = "../core/config" } revolt-config = { path = "../core/config" }
revolt-broker = { path = "../core/broker" }
revolt-database = { path = "../core/database", features = [ revolt-database = { path = "../core/database", features = [
"rocket-impl", "rocket-impl",
"redis-is-patched", "redis-is-patched",

View File

@@ -7,9 +7,9 @@ use authifier::{
use futures::StreamExt; use futures::StreamExt;
use lapin::types::AMQPValue; use lapin::types::AMQPValue;
use rand::Rng; use rand::Rng;
use revolt_broker::event_stream;
use revolt_database::{ use revolt_database::{
events::client::{get_event_stream_channel, EventV1}, events::client::EventV1, Channel, Database, Member, Message, Server, User, AMQP,
Channel, Database, Member, Message, Server, User, AMQP,
}; };
use revolt_database::{util::idempotency::IdempotencyKey, Role}; use revolt_database::{util::idempotency::IdempotencyKey, Role};
use revolt_models::v0; use revolt_models::v0;
@@ -40,7 +40,7 @@ impl TestHarness {
.collect(); .collect();
static QUEUE_NAME: &str = "revolt.events"; static QUEUE_NAME: &str = "revolt.events";
let consumer = get_event_stream_channel() let consumer = event_stream::get_channel()
.await .await
.basic_consume(QUEUE_NAME, &tag, Default::default(), Default::default()) .basic_consume(QUEUE_NAME, &tag, Default::default(), Default::default())
.await .await