feat(events): publish local events to RocketMQ through an outbox relay
- retire the im WebSocket gateway and drive chat sending from apihost REST
- add the queue crate: RocketMQ producer, subject/route rules, and an
outbox relay (500ms poll, 16 per batch, 60s lease, per-ordering-key
serialisation, 50 attempts, 7-day retention)
- add [rocketmq] config (enabled defaults to false) and start the relay
from apihost when it is on
- emit events in the EventDocs wire format: the body is only
{id, kind, occurred_at, summary}, the payload stays in event_outbox
- cover chat room/user events, site notifications, and issue and
pull-request timeline events; outbound webhooks, push, mail, LiveKit
and KenShell git calls stay outside the queue
Co-Authored-By: Claude Code <noreply@anthropic.com>
ZhenYi committed
Modified app/apihost/src/main.rs app/apihost/src/main.rs +16 −0 View app/apihost/src/main.rs
@@ -1,3 +1,5 @@
1#![recursion_limit = "256"]
2
13use std::net::SocketAddr;
24
35use anyhow::Context;
@@ -38,6 +40,20 @@
3840
3941 tokio::spawn(chat::worker::run_background_tasks(state.clone()));
4042
43 if cfg.rocketmq.enabled {
44 let conn = queue::RocketMqConn::init(&cfg.rocketmq)
45 .await
46 .map_err(|err| anyhow::anyhow!("failed to start the rocketmq producer: {err}"))?;
47 tokio::spawn(queue::relay::run(
48 state.database.primary().clone(),
49 queue::Publisher::new(conn),
50 CancellationToken::new(),
51 ));
52 info!("event outbox relay started");
53 } else {
54 info!("rocketmq is disabled; chat message events are not published");
55 }
56
4157 // Web Push 消费者:与其它后台任务一起挂在 apihost 上,不为它单开进程。
4258 // 配置非法(密钥缺失或公私钥不成对)时直接报错退出——静默不投递比启动
4359 // 失败更难被发现。
Modified app/apihost/Cargo.toml app/apihost/Cargo.toml +1 −0 View app/apihost/Cargo.toml
@@ -9,6 +9,7 @@
99auth = { workspace = true }
1010chat = { workspace = true }
1111push = { workspace = true }
12queue = { workspace = true }
1213export = { workspace = true }
1314jwt = { workspace = true }
1415config = { workspace = true }
Deleted app/im/src/frames.rs app/im/src/frames.rs +0 −62
@@ -1,62 +0,0 @@
1use chat::services::messages::MessageDto;
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5#[derive(Debug, Clone, Deserialize)]
6#[serde(tag = "op", rename_all = "snake_case")]
7pub enum ClientFrame {
8 Subscribe {
9 room_id: Uuid,
10 #[serde(default)]
11 resync: Option<bool>,
12 },
13 Unsubscribe {
14 room_id: Uuid,
15 },
16 Resync {
17 room_id: Uuid,
18 after_seq: i64,
19 },
20 Typing {
21 room_id: Uuid,
22 #[serde(default)]
23 thread_id: Option<Uuid>,
24 },
25 Ping,
26}
27
28#[derive(Debug, Clone, Serialize)]
29#[serde(tag = "type", rename_all = "snake_case")]
30pub enum ServerFrame {
31 Ready {
32 user_id: i64,
33 session_id: Uuid,
34 },
35 Event {
36 room_id: Uuid,
37 event: serde_json::Value,
38 },
39 UserEvent {
40 event: serde_json::Value,
41 },
42 History {
43 room_id: Uuid,
44 messages: Vec<MessageDto>,
45 },
46 Gap {
47 room_id: Uuid,
48 from: i64,
49 to: i64,
50 },
51 ResyncRequired {
52 room_id: Uuid,
53 reason: String,
54 },
55 Error {
56 message: String,
57 },
58 Pong,
59 Bye {
60 message: String,
61 },
62}
Deleted app/im/src/gateway.rs app/im/src/gateway.rs +0 −258
@@ -1,258 +0,0 @@
1use std::convert::Infallible;
2
3use chat::services::service::ChatService;
4use http_body_util::{BodyExt as _, Empty, Full};
5use hyper::body::{Bytes, Incoming};
6use hyper::header::{
7 HeaderValue, SEC_WEBSOCKET_ACCEPT, SEC_WEBSOCKET_KEY, SEC_WEBSOCKET_PROTOCOL,
8 SEC_WEBSOCKET_VERSION,
9};
10use hyper::{Method, Request, Response, StatusCode};
11use hyper_util::rt::TokioIo;
12use sea_orm::{ConnectionTrait, DbBackend, Statement};
13use tokio_tungstenite::WebSocketStream;
14use tokio_tungstenite::tungstenite::handshake::derive_accept_key;
15use tokio_tungstenite::tungstenite::protocol::{Role, WebSocketConfig};
16use uuid::Uuid;
17
18use crate::jwt::consume_jti;
19use std::sync::{Arc, OnceLock};
20use crate::socket::{ServerWs, handle_socket};
21
22const WS_PATH: &str = "/ws/v1";
23const JWT_PROTOCOL_PREFIX: &str = "gitdata.jwt.";
24
25type BoxBody = http_body_util::combinators::BoxBody<Bytes, Infallible>;
26
27pub async fn route(
28 service: ChatService,
29 mut req: Request<Incoming>,
30) -> Result<Response<BoxBody>, Infallible> {
31 match (req.method(), req.uri().path()) {
32 (&Method::GET, "/healthz") => Ok(text_response(StatusCode::OK, "ok")),
33 (&Method::GET, "/readyz") => Ok(readiness(&service).await),
34 (&Method::GET, WS_PATH) => Ok(handshake(service, &mut req).await),
35 _ => Ok(text_response(StatusCode::NOT_FOUND, "not found")),
36 }
37}
38
39fn text_response(status: StatusCode, body: &'static str) -> Response<BoxBody> {
40 Response::builder()
41 .status(status)
42 .header("content-type", "text/plain; charset=utf-8")
43 .body(Full::new(Bytes::from(body)).boxed())
44 .unwrap()
45}
46
47async fn readiness(service: &ChatService) -> Response<BoxBody> {
48 let db_ok = service
49 .db()
50 .read()
51 .query_one_raw(Statement::from_sql_and_values(
52 DbBackend::Postgres,
53 "SELECT 1",
54 [],
55 ))
56 .await
57 .is_ok();
58 let mut conn = service.state.redis.connection_clone();
59 let redis_ok: bool = match &mut conn {
60 export::RedisConnection::Standalone(c) => {
61 redis::cmd("PING").query_async::<String>(c).await.is_ok()
62 }
63 export::RedisConnection::Cluster(c) => {
64 redis::cmd("PING").query_async::<String>(c).await.is_ok()
65 }
66 };
67 if db_ok && redis_ok {
68 text_response(StatusCode::OK, "ready")
69 } else {
70 text_response(StatusCode::SERVICE_UNAVAILABLE, "not ready")
71 }
72}
73
74fn host_of(raw: &str) -> Option<&str> {
75 let rest = raw.split_once("://").map(|(_, r)| r).unwrap_or(raw);
76 let host = rest.split('/').next()?;
77 let host = host.rsplit_once('@').map(|(_, h)| h).unwrap_or(host);
78 let host = match host.rsplit_once(':') {
79 Some((h, port)) if !port.is_empty() && port.chars().all(|c| c.is_ascii_digit()) => h,
80 _ => host,
81 };
82 (!host.is_empty()).then_some(host)
83}
84
85fn origin_allowed(service: &ChatService, headers: &hyper::HeaderMap) -> bool {
86 let Some(origin) = headers
87 .get(hyper::header::ORIGIN)
88 .and_then(|v| v.to_str().ok())
89 else {
90 return true;
91 };
92 let Some(origin_host) = host_of(origin) else {
93 return false;
94 };
95 let mut allowed: Vec<&str> = vec![];
96 if let Some(host) = host_of(&service.config().web.web_base_url) {
97 allowed.push(host);
98 }
99 for entry in &service.config().web.cors_allow_origins {
100 if let Some(host) = host_of(entry) {
101 allowed.push(host);
102 }
103 }
104 allowed.contains(&origin_host)
105}
106
107fn extract_jwt(headers: &hyper::HeaderMap) -> Option<String> {
108 for value in headers.get_all(SEC_WEBSOCKET_PROTOCOL) {
109 let Ok(raw) = value.to_str() else {
110 continue;
111 };
112 for proto in raw.split(',') {
113 let proto = proto.trim();
114 if let Some(token) = proto.strip_prefix(JWT_PROTOCOL_PREFIX)
115 && !token.is_empty()
116 {
117 return Some(token.to_string());
118 }
119 }
120 }
121 None
122}
123
124async fn handshake(service: ChatService, req: &mut Request<Incoming>) -> Response<BoxBody> {
125 let headers = req.headers();
126 let bad = |reason: &'static str, origin: Option<String>| {
127 match reason {
128 "origin" => {
129 tracing::warn!(origin = %origin.unwrap_or_default(), "ws reject: origin not allowed")
130 }
131 "missing-protocol" => tracing::warn!("ws reject: jwt subprotocol missing"),
132 "bad-headers" => tracing::warn!("ws reject: invalid websocket handshake headers"),
133 _ => {}
134 }
135 text_response(StatusCode::BAD_REQUEST, "bad request")
136 };
137
138 let key = match headers.get(SEC_WEBSOCKET_KEY).and_then(|v| v.to_str().ok()) {
139 Some(key) => key.to_string(),
140 None => return bad("bad-headers", None),
141 };
142 let version_ok = headers
143 .get(SEC_WEBSOCKET_VERSION)
144 .and_then(|v| v.to_str().ok())
145 == Some("13");
146 let upgrade_ok = headers
147 .get(hyper::header::UPGRADE)
148 .and_then(|v| v.to_str().ok())
149 .is_some_and(|v| v.eq_ignore_ascii_case("websocket"))
150 && headers
151 .get(hyper::header::CONNECTION)
152 .and_then(|v| v.to_str().ok())
153 .unwrap_or_default()
154 .split(',')
155 .any(|token| token.trim().eq_ignore_ascii_case("upgrade"));
156 if !version_ok || !upgrade_ok {
157 return bad("bad-headers", None);
158 }
159 if !origin_allowed(&service, headers) {
160 let origin = headers
161 .get(hyper::header::ORIGIN)
162 .and_then(|v| v.to_str().ok())
163 .map(str::to_string);
164 return bad("origin", origin);
165 }
166 let Some(token) = extract_jwt(headers) else {
167 return bad("missing-protocol", None);
168 };
169 let Some(verifier) = verifier(&service.config().oauth) else {
170 tracing::warn!("ws reject: oauth verifier is unavailable");
171 return text_response(StatusCode::FORBIDDEN, "forbidden");
172 };
173 let claims = match verifier
174 .verify_service(&token, verifier.chat_audience())
175 .await
176 {
177 Ok(claims) => claims,
178 Err(error) => {
179 tracing::warn!(%error, "ws reject: service token verify failed");
180 return text_response(StatusCode::FORBIDDEN, "forbidden");
181 }
182 };
183 let uid = claims.sub;
184 let jti = claims.jti.clone();
185 let token_for_introspect = token.clone();
186
187 let on_upgrade = hyper::upgrade::on(req);
188 let max_frame_bytes = service.config().chat.ws.max_frame_bytes;
189 let response = Response::builder()
190 .status(StatusCode::SWITCHING_PROTOCOLS)
191 .header(hyper::header::CONNECTION, "Upgrade")
192 .header(hyper::header::UPGRADE, "websocket")
193 .header(SEC_WEBSOCKET_ACCEPT, derive_accept_key(key.as_bytes()))
194 .header(
195 SEC_WEBSOCKET_PROTOCOL,
196 HeaderValue::from_str(&format!("{JWT_PROTOCOL_PREFIX}{token}")).unwrap(),
197 )
198 .body(Empty::new().boxed())
199 .unwrap();
200
201 tokio::spawn(async move {
202 let upgraded = match on_upgrade.await {
203 Ok(upgraded) => upgraded,
204 Err(error) => {
205 tracing::debug!(%error, "ws upgrade did not complete");
206 return;
207 }
208 };
209 let config = WebSocketConfig::default()
210 .max_message_size(Some(max_frame_bytes))
211 .max_frame_size(Some(max_frame_bytes));
212 let mut ws: ServerWs =
213 WebSocketStream::from_raw_socket(TokioIo::new(upgraded), Role::Server, Some(config))
214 .await;
215
216 let now = std::time::SystemTime::now()
217 .duration_since(std::time::UNIX_EPOCH)
218 .unwrap_or_default()
219 .as_secs() as i64;
220 if !consume_jti(&service.state.redis, &jti, claims.exp - now).await {
221 tracing::warn!(%jti, "ws reject: jwt already used or redis unavailable");
222 let _ = ws.close(None).await;
223 return;
224 }
225 if let Some(client) = service.state.kenauth.as_ref() {
226 let active = client
227 .introspect_token(&token_for_introspect)
228 .await
229 .map(|response| {
230 response.active
231 && response
232 .principal
233 .as_ref()
234 .is_some_and(|principal| principal.user_id == uid)
235 })
236 .unwrap_or(false);
237 if !active {
238 tracing::warn!(%uid, "ws reject: service token is not active");
239 let _ = ws.close(None).await;
240 return;
241 }
242 }
243 handle_socket(ws, service, uid, Uuid::now_v7()).await;
244 });
245
246 response
247}
248
249static VERIFIER: OnceLock<Option<Arc<jwt::JwtVerifier>>> = OnceLock::new();
250
251fn verifier(config: &config::oauth::OAuthConfig) -> Option<&'static Arc<jwt::JwtVerifier>> {
252 if !config.enabled {
253 return None;
254 }
255 VERIFIER
256 .get_or_init(|| jwt::JwtVerifier::new(config).ok())
257 .as_ref()
258}
Deleted app/im/src/jwt.rs app/im/src/jwt.rs +0 −29
@@ -1,29 +0,0 @@
1use export::AppRedis;
2
3pub async fn consume_jti(redis: &AppRedis, jti: &str, ttl_secs: i64) -> bool {
4 use export::RedisConnection;
5 let mut conn = redis.connection_clone();
6 let result: Result<Option<String>, redis::RedisError> = match &mut conn {
7 RedisConnection::Standalone(c) => {
8 redis::cmd("SET")
9 .arg(format!("chat:jwt_used:{jti}"))
10 .arg("1")
11 .arg("NX")
12 .arg("EX")
13 .arg(ttl_secs.max(1))
14 .query_async(c)
15 .await
16 }
17 RedisConnection::Cluster(c) => {
18 redis::cmd("SET")
19 .arg(format!("chat:jwt_used:{jti}"))
20 .arg("1")
21 .arg("NX")
22 .arg("EX")
23 .arg(ttl_secs.max(1))
24 .query_async(c)
25 .await
26 }
27 };
28 matches!(result, Ok(Some(_)))
29}
Deleted app/im/src/main.rs app/im/src/main.rs +0 −104
@@ -1,104 +0,0 @@
1mod frames;
2mod gateway;
3mod jwt;
4mod socket;
5
6use std::net::SocketAddr;
7
8use anyhow::Context;
9use chat::services::service::ChatService;
10use config::scoped::{ImConfig, ScopedConfig};
11use hyper_util::rt::TokioIo;
12use tokio::net::TcpListener;
13use tracing::Instrument as _;
14use tracing::info;
15
16#[tokio::main]
17async fn main() -> anyhow::Result<()> {
18 export::telemetry::init_tracing();
19
20 let cfg = ImConfig::load().context("loading configuration")?;
21 if !cfg.im.enabled() {
22 info!("im is disabled by config; exiting");
23 return Ok(());
24 }
25
26 info!("connecting to database / redis / storage / email");
27 let state = export::AppState::with_config(cfg.clone().into_app_config())
28 .await
29 .context("building AppState")?;
30 info!("dependencies ready");
31
32 if let Err(error) = export::telemetry::init_meter_provider() {
33 tracing::warn!(%error, "failed to init meter provider; metrics disabled");
34 }
35
36 let chat = ChatService::new(state);
37
38 let bind_addr = cfg.im.bind_addr().to_string();
39 let port = cfg.im.port();
40 let addr: SocketAddr = format!("{bind_addr}:{port}")
41 .parse()
42 .with_context(|| format!("parsing bind address {bind_addr}:{port}"))?;
43
44 let listener = TcpListener::bind(addr)
45 .await
46 .with_context(|| format!("binding {addr}"))?;
47 info!(%addr, "im listening");
48
49 loop {
50 tokio::select! {
51 _ = export::telemetry::shutdown_signal() => break,
52 accepted = listener.accept() => {
53 let (stream, _peer) = accepted.context("accepting connection")?;
54 let service = chat.clone();
55 tokio::spawn(async move {
56 let io = TokioIo::new(stream);
57 let service = hyper::service::service_fn(move |mut req| {
58 let service = service.clone();
59 let request_id = req
60 .headers()
61 .get(export::middleware::REQUEST_ID_HEADER)
62 .and_then(|value| value.to_str().ok())
63 .filter(|value| !value.is_empty() && value.len() <= 128)
64 .map(str::to_owned)
65 .unwrap_or_else(export::middleware::new_request_id);
66 req.extensions_mut()
67 .insert(export::middleware::RequestId(request_id.clone()));
68 let span = export::telemetry::http_span(&req);
69 if let Some(traceparent) = query_traceparent(req.uri().query()) {
70 export::telemetry::set_parent_from_traceparent(&span, Some(traceparent));
71 }
72 let response_header = request_id.parse().ok();
73 async move {
74 let mut response = gateway::route(service, req).await;
75 if let (Ok(response), Some(header)) = (&mut response, response_header) {
76 response
77 .headers_mut()
78 .insert(export::middleware::REQUEST_ID_HEADER, header);
79 }
80 response
81 }
82 .instrument(span)
83 });
84 if let Err(error) = hyper::server::conn::http1::Builder::new()
85 .serve_connection(io, service)
86 .with_upgrades()
87 .await
88 {
89 tracing::debug!(%error, "im connection closed");
90 }
91 });
92 }
93 }
94 }
95
96 info!("im shut down");
97 Ok(())
98}
99
100fn query_traceparent(query: Option<&str>) -> Option<&str> {
101 query?
102 .split('&')
103 .find_map(|pair| pair.strip_prefix("traceparent="))
104}
Deleted app/im/src/socket.rs app/im/src/socket.rs +0 −532
@@ -1,532 +0,0 @@
1use std::collections::HashMap;
2use std::time::Duration;
3
4use chat::errors::ChatError;
5use chat::services::messages::ListMessageQuery;
6use chat::services::service::ChatService;
7use chat::share::realtime::RoomEvent;
8use futures::{SinkExt, StreamExt};
9use hyper::upgrade::Upgraded;
10use hyper_util::rt::TokioIo;
11use tokio::sync::mpsc;
12use tokio_tungstenite::WebSocketStream;
13use tokio_tungstenite::tungstenite::{self, Message};
14use tokio_util::sync::CancellationToken;
15use uuid::Uuid;
16
17use crate::frames::{ClientFrame, ServerFrame};
18
19pub type ServerWs = WebSocketStream<TokioIo<Upgraded>>;
20
21#[derive(Clone)]
22enum Inbox {
23 Room(Uuid, Result<String, ()>),
24 User(String),
25}
26
27struct Subscription {
28 cancel: CancellationToken,
29 handle: tokio::task::JoinHandle<()>,
30}
31
32/// Push into the socket inbox without ever deadlocking the forwarder:
33/// bounded retry, then drop the payload (gap detection resyncs the client).
34async fn best_effort_send(
35 tx: &mpsc::Sender<Inbox>,
36 item: Inbox,
37 cancel: &CancellationToken,
38) -> bool {
39 for _ in 0..1000 {
40 match tx.try_send(item.clone()) {
41 Ok(()) => return true,
42 Err(mpsc::error::TrySendError::Full(_)) => {
43 tokio::select! {
44 _ = cancel.cancelled() => return false,
45 _ = tokio::time::sleep(Duration::from_millis(1)) => {}
46 }
47 }
48 Err(mpsc::error::TrySendError::Closed(_)) => return false,
49 }
50 }
51 false
52}
53
54pub async fn handle_socket(socket: ServerWs, service: ChatService, uid: i64, session_id: Uuid) {
55 let _connection = export::metrics::ImConnectionGuard::new();
56 let (mut sink, mut stream) = socket.split();
57
58 let (inbox_tx, mut inbox_rx) = mpsc::channel::<Inbox>(1024);
59
60 let user_cancel = CancellationToken::new();
61 let mut user_rx = match service.realtime.subscribe_user(uid).await {
62 Ok(rx) => rx,
63 Err(error) => {
64 tracing::warn!(%error, "ws user subscribe failed");
65 let _ = close_with_unsplit(sink, "internal error").await;
66 return;
67 }
68 };
69 let user_handle = {
70 let tx = inbox_tx.clone();
71 let cancel = user_cancel.clone();
72 tokio::spawn(async move {
73 loop {
74 tokio::select! {
75 _ = cancel.cancelled() => break,
76 payload = user_rx.recv() => {
77 let Ok(payload) = payload else { break };
78 if !best_effort_send(&tx, Inbox::User(payload), &cancel).await {
79 break;
80 }
81 }
82 }
83 }
84 })
85 };
86
87 let ready = ServerFrame::Ready {
88 user_id: uid,
89 session_id,
90 };
91 if send_frame(&mut sink, &ready).await.is_err() {
92 user_cancel.cancel();
93 let _ = user_handle.await;
94 service.realtime.unsubscribe_user(uid).await;
95 return;
96 }
97
98 // 在线标记:推送 worker 用它决定"这个用户已经有实时通道,不需要系统
99 // 通知"。同一个账号可以同时开多个连接,每条连接在集合里各占一个成员,
100 // 只有全部断开(或成员过期)才算离线。
101 let online = OnlineMarker::claim(&service, uid).await;
102 // 可见在线状态跟随同一条 WS 生命周期:claim 即上线(用户已有的 Away/
103 // Dnd/隐身状态原样保留、只续期),tick 续期,最后一条连接断开时下线。
104 service.mark_online(uid).await;
105
106 let heartbeat_secs = service.config().chat.ws.heartbeat_secs.max(5);
107 let idle_limit = Duration::from_secs(heartbeat_secs * 3);
108 let mut last_activity = tokio::time::Instant::now();
109 let mut ticker = tokio::time::interval(Duration::from_secs(heartbeat_secs));
110 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
111
112 let mut subscriptions: HashMap<Uuid, Subscription> = HashMap::new();
113 let mut last_seq: HashMap<Uuid, i64> = HashMap::new();
114 let max_subscriptions = service.config().chat.ws.max_subscriptions;
115 let mut last_typing: Option<tokio::time::Instant> = None;
116 let mut tick_count: u32 = 0;
117
118 loop {
119 tokio::select! {
120 frame = stream.next() => {
121 let Some(frame) = frame else { break };
122 let Ok(frame) = frame else { break };
123 last_activity = tokio::time::Instant::now();
124 match frame {
125 Message::Text(text) => {
126 let frame: Result<ClientFrame, _> = serde_json::from_str(&text);
127 match frame {
128 Ok(frame) => {
129 handle_client_frame(
130 &mut sink,
131 &service,
132 uid,
133 frame,
134 &mut subscriptions,
135 &mut last_seq,
136 &mut last_typing,
137 &inbox_tx,
138 max_subscriptions,
139 )
140 .await;
141 }
142 Err(_) => {
143 let _ = send_frame(&mut sink, &ServerFrame::Error {
144 message: "invalid frame".to_string(),
145 }).await;
146 }
147 }
148 }
149 Message::Ping(payload) => {
150 let _ = sink.send(Message::Pong(payload)).await;
151 }
152 Message::Close(_) => break,
153 _ => {}
154 }
155 }
156 inbox = inbox_rx.recv() => {
157 let Some(inbox) = inbox else { break };
158 match inbox {
159 Inbox::Room(room_id, Ok(payload)) => {
160 let gap = check_seq_gap(&payload, &mut last_seq, room_id);
161 if let Some(frame) = gap
162 && send_frame(&mut sink, &frame).await.is_err()
163 {
164 break;
165 }
166 let frame = ServerFrame::Event {
167 room_id,
168 event: serde_json::from_str(&payload)
169 .unwrap_or(serde_json::Value::Null),
170 };
171 if send_frame(&mut sink, &frame).await.is_err() {
172 break;
173 }
174 }
175 Inbox::Room(room_id, Err(())) => {
176 let frame = ServerFrame::ResyncRequired {
177 room_id,
178 reason: "lagged".to_string(),
179 };
180 last_seq.remove(&room_id);
181 if send_frame(&mut sink, &frame).await.is_err() {
182 break;
183 }
184 }
185 Inbox::User(payload) => {
186 let frame = ServerFrame::UserEvent {
187 event: serde_json::from_str(&payload)
188 .unwrap_or(serde_json::Value::Null),
189 };
190 if send_frame(&mut sink, &frame).await.is_err() {
191 break;
192 }
193 }
194 }
195 }
196 _ = ticker.tick() => {
197 if last_activity.elapsed() > idle_limit {
198 let _ = send_frame(&mut sink, &ServerFrame::Bye {
199 message: "idle timeout".to_string(),
200 }).await;
201 break;
202 }
203 // 续期必须在 tick 里做而不是只在 ready 时写一次:TTL 到期而
204 // 进程还活着(例如用户只是在读、没有发帧)时,键消失会让推送
205 // worker 把他当成离线,于是同一个事件既弹 toast 又弹系统通知;
206 // 可见在线状态同样靠这里的心跳保活。
207 online.refresh().await;
208 service.touch_presence(uid).await;
209 tick_count = tick_count.wrapping_add(1);
210 if tick_count.is_multiple_of(2) {
211 let mut revoked: Vec<Uuid> = Vec::new();
212 for &room_id in subscriptions.keys() {
213 if service_can_read(&service, uid, room_id).await.is_err() {
214 revoked.push(room_id);
215 }
216 }
217 for room_id in revoked {
218 if let Some(sub) = subscriptions.remove(&room_id) {
219 sub.cancel.cancel();
220 let _ = sub.handle.await;
221 service.realtime.unsubscribe_room(room_id).await;
222 last_seq.remove(&room_id);
223 if send_frame(&mut sink, &ServerFrame::ResyncRequired {
224 room_id,
225 reason: "access revoked".to_string(),
226 })
227 .await
228 .is_err()
229 {
230 break;
231 }
232 }
233 }
234 }
235 }
236 }
237 }
238
239 // 计数归零=最后一条连接关闭;Redis 故障时 release 返回 None,此时不判定
240 // 离线,交给 TTL 兜底。
241 if online.release().await == Some(0) {
242 service.mark_offline(uid).await;
243 }
244 let mut teardown: Vec<(Uuid, tokio::task::JoinHandle<()>)> = Vec::new();
245 for (room_id, sub) in subscriptions {
246 sub.cancel.cancel();
247 teardown.push((room_id, sub.handle));
248 }
249 user_cancel.cancel();
250 // Releasing the local sender unblocks any forwarder parked on a full
251 // inbox, guaranteeing the awaits below terminate.
252 drop(inbox_tx);
253 let _ = user_handle.await;
254 for (room_id, handle) in teardown {
255 // Await the forwarder so its broadcast receiver is released before
256 // the unsubscribe refcount check.
257 let _ = handle.await;
258 service.realtime.unsubscribe_room(room_id).await;
259 }
260 service.realtime.unsubscribe_user(uid).await;
261}
262
263/// 一条 WS 连接的"在线"占位。
264///
265/// 每条连接是集合里的一个成员(分数=到期时刻):多标签页/多实例各自登记,
266/// 只有全部断开才算离线;进程被强杀留下的成员会随分数过期被惰性清理,不会
267/// 留下永久在线的幽灵,也不会污染在线计数。
268struct OnlineMarker {
269 key: String,
270 member: String,
271 ttl_secs: u64,
272 redis: export::AppRedis,
273}
274
275impl OnlineMarker {
276 async fn claim(service: &ChatService, uid: i64) -> Self {
277 let ttl_secs = service.config().chat.online_ttl_secs();
278 let marker = Self {
279 key: export::push::online_key(uid),
280 // 每条连接一个唯一成员:多标签页/多实例各自登记,互不覆盖。
281 member: Uuid::now_v7().to_string(),
282 ttl_secs,
283 redis: service.state.redis.clone(),
284 };
285 marker.refresh().await;
286 marker
287 }
288
289 /// 登记/续期本连接;过期的旧成员在这里被顺手清掉。续期是幂等的,
290 /// 心跳不会像计数型占位那样把数字越推越高。
291 async fn refresh(&self) {
292 if let Err(error) =
293 export::redis::zset_touch(&self.redis, &self.key, &self.member, self.ttl_secs).await
294 {
295 tracing::warn!(%error, key = %self.key, "online marker refresh failed");
296 }
297 }
298
299 /// 注销本连接,返回剩余**存活**连接数;归零即最后一条连接退出。
300 /// Redis 故障返回 `None`,调用方不应据此判定离线(TTL 会兜底)。
301 async fn release(&self) -> Option<i64> {
302 match export::redis::zset_release(&self.redis, &self.key, &self.member).await {
303 Ok(remaining) => Some(remaining),
304 Err(error) => {
305 tracing::warn!(%error, key = %self.key, "online marker release failed");
306 None
307 }
308 }
309 }
310}
311fn check_seq_gap(
312 payload: &str,
313 last_seq: &mut HashMap<Uuid, i64>,
314 room_id: Uuid,
315) -> Option<ServerFrame> {
316 let event: serde_json::Value = serde_json::from_str(payload).ok()?;
317 let seq = event.get("seq")?.as_i64()?;
318 let last = last_seq.get(&room_id).copied();
319 // Only advance the cursor; edit/pin events may carry older seq values.
320 if last.is_some_and(|l| seq <= l) {
321 return None;
322 }
323 last_seq.insert(room_id, seq);
324 match last {
325 Some(last) if seq > last + 1 => Some(ServerFrame::Gap {
326 room_id,
327 from: last + 1,
328 to: seq,
329 }),
330 _ => None,
331 }
332}
333
334#[allow(clippy::too_many_arguments)]
335async fn handle_client_frame<S>(
336 sink: &mut S,
337 service: &ChatService,
338 uid: i64,
339 frame: ClientFrame,
340 subscriptions: &mut HashMap<Uuid, Subscription>,
341 last_seq: &mut HashMap<Uuid, i64>,
342 last_typing: &mut Option<tokio::time::Instant>,
343 inbox_tx: &mpsc::Sender<Inbox>,
344 max_subscriptions: usize,
345) where
346 S: futures::Sink<Message, Error = tungstenite::Error> + Unpin,
347{
348 match frame {
349 ClientFrame::Subscribe { room_id, resync } => {
350 if subscriptions.contains_key(&room_id) {
351 return;
352 }
353 if subscriptions.len() >= max_subscriptions {
354 let _ = send_frame(
355 sink,
356 &ServerFrame::Error {
357 message: "subscription limit reached".to_string(),
358 },
359 )
360 .await;
361 return;
362 }
363 if service_can_read(service, uid, room_id).await.is_err() {
364 let _ = send_frame(
365 sink,
366 &ServerFrame::Error {
367 message: "forbidden".to_string(),
368 },
369 )
370 .await;
371 return;
372 }
373 let rx = match service.realtime.subscribe_room(room_id).await {
374 Ok(rx) => rx,
375 Err(_) => {
376 let _ = send_frame(
377 sink,
378 &ServerFrame::Error {
379 message: "subscribe failed".to_string(),
380 },
381 )
382 .await;
383 return;
384 }
385 };
386 let cancel = CancellationToken::new();
387 let handle = {
388 let tx = inbox_tx.clone();
389 let child = cancel.clone();
390 let mut rx = rx;
391 tokio::spawn(async move {
392 loop {
393 tokio::select! {
394 _ = child.cancelled() => break,
395 result = rx.recv() => {
396 let item = match result {
397 Ok(payload) => Inbox::Room(room_id, Ok(payload)),
398 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
399 Inbox::Room(room_id, Err(()))
400 }
401 Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
402 };
403 // Non-blocking fallback: overflow drops the
404 // frame; seq gap detection triggers resync.
405 if !best_effort_send(&tx, item, &child).await {
406 break;
407 }
408 }
409 }
410 }
411 })
412 };
413 subscriptions.insert(room_id, Subscription { cancel, handle });
414
415 let history_limit = service.config().chat.ws.history_limit;
416 if let Ok(page) = service
417 .list_messages(
418 uid,
419 room_id,
420 ListMessageQuery {
421 before_seq: None,
422 after_seq: None,
423 limit: Some(history_limit),
424 thread_id: None,
425 author_id: None,
426 },
427 )
428 .await
429 {
430 if resync.unwrap_or(true) {
431 let frame = ServerFrame::History {
432 room_id,
433 messages: page.items,
434 };
435 let _ = send_frame(sink, &frame).await;
436 }
437 if let Some(newest) = page.newest_seq {
438 last_seq.insert(room_id, newest);
439 }
440 }
441 }
442 ClientFrame::Unsubscribe { room_id } => {
443 if let Some(sub) = subscriptions.remove(&room_id) {
444 sub.cancel.cancel();
445 let _ = sub.handle.await;
446 last_seq.remove(&room_id);
447 service.realtime.unsubscribe_room(room_id).await;
448 }
449 }
450 ClientFrame::Resync { room_id, after_seq } => {
451 if service_can_read(service, uid, room_id).await.is_err() {
452 return;
453 }
454 if let Ok(page) = service
455 .list_messages(
456 uid,
457 room_id,
458 ListMessageQuery {
459 before_seq: None,
460 after_seq: Some(after_seq),
461 limit: Some(service.config().chat.ws.history_limit),
462 thread_id: None,
463 author_id: None,
464 },
465 )
466 .await
467 {
468 if let Some(newest) = page.newest_seq {
469 last_seq.insert(room_id, newest);
470 }
471 let frame = ServerFrame::History {
472 room_id,
473 messages: page.items,
474 };
475 let _ = send_frame(sink, &frame).await;
476 }
477 }
478 ClientFrame::Typing { room_id, thread_id } => {
479 // Coarse throttle: at most one typing broadcast per 3s per socket.
480 let now = tokio::time::Instant::now();
481 if last_typing.is_some_and(|prev| now.duration_since(prev).as_secs() < 3) {
482 return;
483 }
484 if service_can_read(service, uid, room_id).await.is_ok() {
485 *last_typing = Some(now);
486 let event = RoomEvent::Typing {
487 room_id,
488 user_id: uid,
489 thread_id,
490 ttl_secs: service.config().chat.typing_ttl_secs.max(1),
491 };
492 service.realtime.publish_room(&event).await;
493 }
494 }
495 ClientFrame::Ping => {
496 let _ = send_frame(sink, &ServerFrame::Pong).await;
497 }
498 }
499}
500
501async fn service_can_read(
502 service: &ChatService,
503 uid: i64,
504 room_id: Uuid,
505) -> Result<(), ChatError> {
506 chat::share::lookup::require_room_readable(service, room_id, uid)
507 .await
508 .map(|_| ())
509}
510
511async fn send_frame<S>(sink: &mut S, frame: &ServerFrame) -> Result<(), tungstenite::Error>
512where
513 S: futures::Sink<Message, Error = tungstenite::Error> + Unpin,
514{
515 let payload = match serde_json::to_string(frame) {
516 Ok(payload) => payload,
517 Err(_) => return Ok(()),
518 };
519 sink.send(Message::Text(payload.into())).await
520}
521
522async fn close_with_unsplit<S>(mut sink: S, message: &str) -> Result<(), tungstenite::Error>
523where
524 S: futures::Sink<Message, Error = tungstenite::Error> + Unpin,
525{
526 let frame = ServerFrame::Bye {
527 message: message.to_string(),
528 };
529 let payload = serde_json::to_string(&frame).unwrap_or_default();
530 sink.send(Message::Text(payload.into())).await?;
531 sink.send(Message::Close(None)).await
532}
Deleted app/im/Cargo.toml app/im/Cargo.toml +0 −28
@@ -1,28 +0,0 @@
1[package]
2name = "im"
3version.workspace = true
4edition.workspace = true
5publish = false
6
7[dependencies]
8chat = { workspace = true }
9config = { workspace = true }
10export = { workspace = true }
11jwt = { workspace = true }
12anyhow = { workspace = true }
13bytes = { workspace = true }
14futures = { workspace = true }
15http = "1"
16http-body-util = { workspace = true }
17hyper = { workspace = true }
18hyper-util = { workspace = true }
19redis = { workspace = true }
20sea-orm = { workspace = true }
21serde = { workspace = true, features = ["derive"] }
22serde_json = { workspace = true }
23time = { workspace = true }
24tokio = { workspace = true, features = ["signal", "net", "macros", "rt-multi-thread"] }
25tokio-tungstenite = { workspace = true }
26tokio-util = { workspace = true }
27tracing = { workspace = true }
28uuid = { workspace = true }
Modified charts/core/templates/_helpers.tpl charts/core/templates/_helpers.tpl +0 −2 View charts/core/templates/_helpers.tpl
@@ -57,8 +57,6 @@
5757{{- define "gitdata.env" -}}
5858- name: APP_APIHOST__PORT
5959 value: {{ .Values.apihost.port | quote }}
60- name: APP_IM__PORT
61 value: {{ .Values.im.port | quote }}
6260- name: APP_GIT__KENSHELL_ENDPOINT
6361 value: {{ .Values.kenshell.endpoint | quote }}
6462- name: APP_GIT__KENSHELL_RPC_KEY
Deleted charts/core/templates/im.yaml charts/core/templates/im.yaml +0 −163
@@ -1,163 +0,0 @@
1{{- if .Values.im.enabled }}
2apiVersion: apps/v1
3kind: Deployment
4metadata:
5 name: {{ printf "%s-im" (include "gitdata.fullname" .) }}
6 labels:
7 {{- include "gitdata.labels" . | nindent 4 }}
8 app.kubernetes.io/component: im
9spec:
10 {{- if .Values.im.autoscaling.enabled }}
11 # 开着 HPA 时不要声明 replicas:HPA 通过 /scale 子资源写这个字段,
12 # kube-controller-manager 于是成为它的 manager;helm 的 server-side apply
13 # 再写一次就会报 conflict with "kube-controller-manager" with subresource
14 # "scale": .spec.replicas —— 升级直接失败;而且就算写进去,也会把已经扩上
15 # 去的副本数硬拉回 minReplicas。省略即让出所有权:SSA 不会因此把字段清成
16 # 默认值,副本数继续由 scale 子资源持有。
17 {{- else }}
18 replicas: {{ .Values.im.replicaCount }}
19 {{- end }}
20 revisionHistoryLimit: 3
21 strategy:
22 type: RollingUpdate
23 rollingUpdate:
24 maxSurge: {{ .Values.im.maxSurge }}
25 maxUnavailable: {{ .Values.im.maxUnavailable }}
26 selector:
27 matchLabels:
28 {{- include "gitdata.selectorLabels" . | nindent 6 }}
29 app.kubernetes.io/component: im
30 template:
31 metadata:
32 labels:
33 {{- include "gitdata.selectorLabels" . | nindent 8 }}
34 app.kubernetes.io/component: im
35 {{- with .Values.podLabels }}
36 {{- toYaml . | nindent 8 }}
37 {{- end }}
38 annotations:
39 checksum/env: {{ toYaml .Values.config.env | sha256sum }}
40 {{- with .Values.podAnnotations }}
41 {{- toYaml . | nindent 8 }}
42 {{- end }}
43 spec:
44 serviceAccountName: {{ include "gitdata.serviceAccountName" . }}
45 {{- include "gitdata.imagePullSecrets" . | nindent 6 }}
46 terminationGracePeriodSeconds: 45
47 securityContext:
48 {{- toYaml .Values.podSecurityContext | nindent 8 }}
49 {{- if or (eq .Values.im.podAntiAffinity "soft") (eq .Values.im.podAntiAffinity "hard") }}
50 affinity:
51 {{- include "gitdata.podAntiAffinity" (dict "ctx" . "mode" .Values.im.podAntiAffinity "component" "im") | nindent 8 }}
52 {{- end }}
53 {{- with .Values.nodeSelector }}
54 nodeSelector:
55 {{- toYaml . | nindent 8 }}
56 {{- end }}
57 {{- with .Values.tolerations }}
58 tolerations:
59 {{- toYaml . | nindent 8 }}
60 {{- end }}
61 containers:
62 - name: im
63 image: {{ include "gitdata.image" . }}
64 imagePullPolicy: {{ .Values.image.pullPolicy }}
65 args: ["im"]
66 {{- include "gitdata.envFrom" . | nindent 10 }}
67 env:
68 {{- include "gitdata.env" . | nindent 12 }}
69 {{- with .Values.im.extraEnv }}
70 {{- toYaml . | nindent 12 }}
71 {{- end }}
72 ports:
73 - name: http
74 containerPort: {{ .Values.im.port }}
75 livenessProbe:
76 httpGet: { path: /healthz, port: http }
77 initialDelaySeconds: 10
78 periodSeconds: 15
79 timeoutSeconds: 3
80 readinessProbe:
81 httpGet: { path: /readyz, port: http }
82 initialDelaySeconds: 5
83 periodSeconds: 10
84 timeoutSeconds: 3
85 failureThreshold: 3
86 lifecycle:
87 preStop:
88 exec:
89 command: ["/bin/sh", "-c", "sleep 5"]
90 securityContext:
91 {{- toYaml .Values.securityContext | nindent 12 }}
92 resources:
93 {{- toYaml .Values.im.resources | nindent 12 }}
94---
95apiVersion: v1
96kind: Service
97metadata:
98 name: {{ printf "%s-im" (include "gitdata.fullname" .) }}
99 labels:
100 {{- include "gitdata.labels" . | nindent 4 }}
101 app.kubernetes.io/component: im
102spec:
103 type: ClusterIP
104 selector:
105 {{- include "gitdata.selectorLabels" . | nindent 4 }}
106 app.kubernetes.io/component: im
107 ports:
108 - name: http
109 port: {{ .Values.im.port }}
110 targetPort: http
111{{- end }}
112{{- if .Values.im.autoscaling.enabled }}
113---
114apiVersion: autoscaling/v2
115kind: HorizontalPodAutoscaler
116metadata:
117 name: {{ printf "%s-im" (include "gitdata.fullname" .) }}
118 labels:
119 {{- include "gitdata.labels" . | nindent 4 }}
120 app.kubernetes.io/component: im
121spec:
122 scaleTargetRef:
123 apiVersion: apps/v1
124 kind: Deployment
125 name: {{ printf "%s-im" (include "gitdata.fullname" .) }}
126 minReplicas: {{ .Values.im.autoscaling.minReplicas }}
127 maxReplicas: {{ .Values.im.autoscaling.maxReplicas }}
128 metrics:
129 - type: Resource
130 resource:
131 name: cpu
132 target:
133 type: Utilization
134 averageUtilization: {{ .Values.im.autoscaling.targetCPUUtilizationPercentage }}
135 {{- if gt (int (.Values.im.autoscaling.targetMemoryUtilizationPercentage | default 0)) 0 }}
136 - type: Resource
137 resource:
138 name: memory
139 target:
140 type: Utilization
141 averageUtilization: {{ .Values.im.autoscaling.targetMemoryUtilizationPercentage }}
142 {{- end }}
143 {{- with .Values.im.autoscaling.behavior }}
144 behavior:
145 {{- toYaml . | nindent 4 }}
146 {{- end }}
147{{- end }}
148{{- if .Values.im.pdb.enabled }}
149---
150apiVersion: policy/v1
151kind: PodDisruptionBudget
152metadata:
153 name: {{ printf "%s-im" (include "gitdata.fullname" .) }}
154 labels:
155 {{- include "gitdata.labels" . | nindent 4 }}
156 app.kubernetes.io/component: im
157spec:
158 minAvailable: {{ .Values.im.pdb.minAvailable }}
159 selector:
160 matchLabels:
161 {{- include "gitdata.selectorLabels" . | nindent 6 }}
162 app.kubernetes.io/component: im
163{{- end }}
Modified charts/core/templates/ingress.yaml charts/core/templates/ingress.yaml +0 −10 View charts/core/templates/ingress.yaml
@@ -41,16 +41,6 @@
4141 - host: {{ .Values.ingress.host | quote }}
4242 http:
4343 paths:
44 {{- if .Values.im.enabled }}
45 # chat WS 长连接网关(最长前缀匹配优先于 "/")
46 - path: /ws/v1
47 pathType: Prefix
48 backend:
49 service:
50 name: {{ printf "%s-im" $fullName }}
51 port:
52 name: http
53 {{- end }}
5444 - path: /
5545 pathType: Prefix
5646 backend:
Modified charts/core/Chart.yaml charts/core/Chart.yaml +1 −1 View charts/core/Chart.yaml
@@ -1,6 +1,6 @@
11apiVersion: v2
22name: gitdata
3description: GitData — Rust workspace (apihost / im / migrate)
3description: GitData — Rust workspace (apihost / migrate)
44type: application
55version: 0.1.0
66appVersion: "1.0.0"
Modified charts/core/values.yaml charts/core/values.yaml +5 −26 View charts/core/values.yaml
@@ -35,6 +35,10 @@
3535 APP_PUSH__ENABLED: "false"
3636 APP_PUSH__VAPID_PUBLIC_KEY: ""
3737 APP_PUSH__VAPID_SUBJECT: "mailto:ops@example.com"
38 # 事件投递:消息事件写入 event_outbox,由 apihost 内嵌 relay 投递到 RocketMQ。
39 APP_ROCKETMQ__ENABLED: "false"
40 APP_ROCKETMQ__NAME_SERVER: "rocketmq-namesrv:9876"
41 APP_ROCKETMQ__TOPIC_PREFIX: "gitdata"
3842 # 认证中心:浏览器走 OIDC,服务间走 gRPC(x-rpc-key)。
3943 APP_OAUTH__ENABLED: "true"
4044 APP_OAUTH__ISSUER: "https://auth.gitdata.ai"
@@ -134,31 +138,6 @@
134138 sshCloneHost: ""
135139 sshClonePort: 22
136140
137im:
138 enabled: true
139 replicaCount: 2
140 port: 3001
141 resources:
142 requests: { cpu: 100m, memory: 128Mi }
143 limits: { memory: 512Mi }
144 autoscaling:
145 enabled: false
146 minReplicas: 2
147 maxReplicas: 6
148 targetCPUUtilizationPercentage: 70
149 # 0 disables the memory metric. Against the 128Mi request this is the
150 # connection-count guard: each live socket costs buffers plus its own
151 # pubsub pump, and the limit (512Mi) is far above where a pod should be.
152 targetMemoryUtilizationPercentage: 0
153 behavior: {}
154 maxSurge: 1
155 maxUnavailable: 0
156 podAntiAffinity: soft # soft | hard | ""
157 extraEnv: []
158 pdb:
159 enabled: true
160 minAvailable: 1
161
162141# ---------------------------------------------------------------------------
163142# 存储:apihost 使用的本地仓库/对象缓存目录(bare 仓库已迁到 KenShell)
164143# ---------------------------------------------------------------------------
@@ -172,7 +151,7 @@
172151 mountPath: /opt/gitdata
173152
174153# ---------------------------------------------------------------------------
175# Ingress:同源 TLS 终结;/ws/v1 -> im,其余 -> apihost。
154# Ingress:同源 TLS 终结,全部路径 -> apihost。
176155# git smart HTTP(*.git)由 KenShell 自己的入口接管,不在本 chart 内。
177156# ---------------------------------------------------------------------------
178157ingress:
Modified charts/prod/values/prod.yaml charts/prod/values/prod.yaml +0 −27 View charts/prod/values/prod.yaml
@@ -98,30 +98,3 @@
9898
9999migrate:
100100 enabled: true
101
102
103im:
104 enabled: true
105 autoscaling:
106 enabled: true
107 minReplicas: 2
108 maxReplicas: 6
109 targetCPUUtilizationPercentage: 70
110 targetMemoryUtilizationPercentage: 80
111 behavior:
112 scaleUp:
113 stabilizationWindowSeconds: 30
114 policies:
115 - type: Percent
116 value: 100
117 periodSeconds: 60
118 - type: Pods
119 value: 2
120 periodSeconds: 60
121 selectPolicy: Max
122 scaleDown:
123 stabilizationWindowSeconds: 600
124 policies:
125 - type: Pods
126 value: 1
127 periodSeconds: 120
Modified config/default.toml config/default.toml +7 −4 View config/default.toml
@@ -226,10 +226,13 @@
226226http_bind_addr = "0.0.0.0"
227227http_port = 9418
228228
229[im]
230enabled = true
231bind_addr = "0.0.0.0"
232port = 3001
229[rocketmq]
230enabled = false
231name_server = "127.0.0.1:9876"
232producer_group = "gitdata"
233topic_prefix = "gitdata"
234send_timeout_ms = 5000
235max_message_bytes = 4194304
233236
234237[push]
235238enabled = false
Modified lib/chat/src/services/messages.rs lib/chat/src/services/messages.rs +2 −2 View lib/chat/src/services/messages.rs
@@ -741,7 +741,7 @@
741741 }
742742 }
743743 txn_result?;
744 pending.flush(&self.state.redis).await;
744 pending.flush(&self.state).await;
745745
746746 let row = messages::Entity::find_by_id(message_id)
747747 .one(self.db().read())
@@ -1113,7 +1113,7 @@
11131113 })
11141114 })
11151115 .await?;
1116 pending.flush(&self.state.redis).await;
1116 pending.flush(&self.state).await;
11171117
11181118 let row = messages::Entity::find_by_id(message_id)
11191119 .one(self.db().read())
Modified lib/chat/src/services/reminders.rs lib/chat/src/services/reminders.rs +1 −1 View lib/chat/src/services/reminders.rs
@@ -276,7 +276,7 @@
276276 }),
277277 )
278278 .await;
279 pending.flush(&self.state.redis).await;
279 pending.flush(&self.state).await;
280280 }
281281 Ok(count)
282282 }
Modified lib/chat/src/services/rooms.rs lib/chat/src/services/rooms.rs +1 −1 View lib/chat/src/services/rooms.rs
@@ -814,7 +814,7 @@
814814 Some(caller),
815815 )
816816 .await?;
817 pending.flush(&self.state.redis).await;
817 pending.flush(&self.state).await;
818818 }
819819 let mut uids = vec![row.inviter_id];
820820 if let Some(invitee) = row.invitee_user_id {
Modified lib/chat/src/services/service.rs lib/chat/src/services/service.rs +6 −1 View lib/chat/src/services/service.rs
@@ -20,7 +20,12 @@
2020
2121impl ChatService {
2222 pub fn new(state: AppState) -> Self {
23 let realtime = Realtime::new(state.redis.clone()).expect("chat realtime client");
23 let realtime = Realtime::new(
24 state.redis.clone(),
25 state.database.write().clone(),
26 state.events_enabled,
27 )
28 .expect("chat realtime client");
2429 Self { state, realtime }
2530 }
2631
Added lib/chat/src/share/events.rs lib/chat/src/share/events.rs +109 −0 View lib/chat/src/share/events.rs
@@ -0,0 +1,109 @@
1use sea_orm::DatabaseConnection;
2use serde_json::Value;
3
4use crate::errors::ChatError;
5use crate::share::realtime::RoomEvent;
6
7pub fn room_kind(event: &RoomEvent) -> &'static str {
8 match event {
9 RoomEvent::MessagePublished { .. } => "chat.message.create",
10 RoomEvent::MessageEdited { .. } => "chat.message.update",
11 RoomEvent::MessageDeleted { .. } => "chat.message.delete",
12 RoomEvent::MessagesDeleted { .. } => "chat.messages.delete",
13 RoomEvent::MessagePinned { pinned: true, .. } => "chat.pin.create",
14 RoomEvent::MessagePinned { pinned: false, .. } => "chat.pin.delete",
15 RoomEvent::ReactionUpdated { .. } => "chat.reaction.update",
16 RoomEvent::Typing { .. } => "chat.typing.create",
17 RoomEvent::RoomUpdated { .. } => "chat.room.update",
18 RoomEvent::MemberJoined { .. } => "chat.member.create",
19 RoomEvent::MemberLeft { .. } => "chat.member.delete",
20 RoomEvent::ThreadUpdated { .. } => "chat.thread.update",
21 RoomEvent::VoicePresence { .. } => "chat.voice.presence",
22 RoomEvent::VoiceHands { .. } => "chat.voice.hand",
23 RoomEvent::VoiceLayout { .. } => "chat.voice.layout",
24 RoomEvent::VoiceModeration { .. } => "chat.voice.moderate",
25 }
26}
27
28pub fn user_kind(event: &Value) -> &'static str {
29 match event.get("kind").and_then(Value::as_str) {
30 Some("mention") => "chat.mention.create",
31 Some("reminder") => "chat.reminder.create",
32 Some("room_added") => "chat.membership.create",
33 Some("read_marker") => "chat.read_state.update",
34 Some("presence") => "chat.presence.update",
35 _ => "chat.notification.create",
36 }
37}
38
39fn room_actor(event: &RoomEvent) -> Option<i64> {
40 match event {
41 RoomEvent::Typing { user_id, .. }
42 | RoomEvent::MemberJoined { user_id, .. }
43 | RoomEvent::MemberLeft { user_id, .. }
44 | RoomEvent::VoiceModeration { user_id, .. } => Some(*user_id),
45 RoomEvent::MessagePublished { message, .. } | RoomEvent::MessageEdited { message, .. } => {
46 message.get("author_id").and_then(Value::as_i64)
47 }
48 _ => None,
49 }
50}
51
52pub async fn enqueue_room(
53 db: &DatabaseConnection,
54 enabled: bool,
55 event: &RoomEvent,
56) -> Result<(), ChatError> {
57 if !enabled {
58 return Ok(());
59 }
60
61 let mut labels = queue::Labels::new().with("room_id", event.room_id());
62 if let Some(actor) = room_actor(event) {
63 labels.insert("user_id", actor);
64 }
65 if let Some(request_id) = export::events::request_id() {
66 labels.insert("request_id", request_id);
67 }
68
69 export::events::enqueue(
70 db,
71 enabled,
72 room_kind(event),
73 labels,
74 event,
75 format!("chat:room:{}", event.room_id()),
76 )
77 .await?;
78 Ok(())
79}
80
81pub async fn enqueue_user(
82 db: &DatabaseConnection,
83 enabled: bool,
84 uid: i64,
85 event: &Value,
86) -> Result<(), ChatError> {
87 if !enabled {
88 return Ok(());
89 }
90
91 let mut labels = queue::Labels::new().with("user_id", uid);
92 if let Some(room_id) = event.get("room_id").and_then(Value::as_str) {
93 labels.insert("room_id", room_id);
94 }
95 if let Some(request_id) = export::events::request_id() {
96 labels.insert("request_id", request_id);
97 }
98
99 export::events::enqueue(
100 db,
101 enabled,
102 user_kind(event),
103 labels,
104 event,
105 format!("chat:user:{uid}"),
106 )
107 .await?;
108 Ok(())
109}
Modified lib/chat/src/share/mod.rs lib/chat/src/share/mod.rs +1 −0 View lib/chat/src/share/mod.rs
@@ -1,4 +1,5 @@
11pub mod authz;
2pub mod events;
23pub mod guard;
34pub mod lookup;
45pub mod mention;
Modified lib/chat/src/share/notify.rs lib/chat/src/share/notify.rs +2 −2 View lib/chat/src/share/notify.rs
@@ -270,7 +270,7 @@
270270 )
271271 .await;
272272 }
273 pending.flush(&service.state.redis).await;
273 pending.flush(&service.state).await;
274274}
275275
276276/// Mention e-mails honour per-room / per-space mute and notification level:
@@ -321,7 +321,6 @@
321321 seq,
322322 message,
323323 };
324 service.realtime.publish_room(&event).await;
325324 if let Ok(payload) = serde_json::to_value(&event) {
326325 crate::services::outbound::enqueue_outbound_event(
327326 service,
@@ -331,6 +330,7 @@
331330 )
332331 .await;
333332 }
333 service.realtime.publish_room(&event).await;
334334}
335335
336336pub async fn advance_read_state(
Modified lib/chat/src/share/realtime.rs lib/chat/src/share/realtime.rs +26 −1 View lib/chat/src/share/realtime.rs
@@ -132,6 +132,8 @@
132132struct Inner {
133133 redis: export::AppRedis,
134134 pubsub_client: redis::Client,
135 db: sea_orm::DatabaseConnection,
136 events_enabled: bool,
135137 rooms: Mutex<HashMap<Uuid, ChannelEntry>>,
136138 users: Mutex<HashMap<i64, ChannelEntry>>,
137139}
@@ -142,7 +144,11 @@
142144}
143145
144146impl Realtime {
145 pub fn new(redis: export::AppRedis) -> Result<Self, ChatError> {
147 pub fn new(
148 redis: export::AppRedis,
149 db: sea_orm::DatabaseConnection,
150 events_enabled: bool,
151 ) -> Result<Self, ChatError> {
146152 let url = redis.config().url().to_string();
147153 let first = url
148154 .split(',')
@@ -154,6 +160,8 @@
154160 inner: Arc::new(Inner {
155161 redis,
156162 pubsub_client,
163 db,
164 events_enabled,
157165 rooms: Mutex::new(HashMap::new()),
158166 users: Mutex::new(HashMap::new()),
159167 }),
@@ -161,10 +169,27 @@
161169 }
162170
163171 pub async fn publish_room(&self, event: &RoomEvent) {
172 if let Err(error) =
173 crate::share::events::enqueue_room(&self.inner.db, self.inner.events_enabled, event)
174 .await
175 {
176 tracing::warn!(%error, room_id = %event.room_id(), "chat room event enqueue failed");
177 }
164178 self.publish(&room_channel(event.room_id()), event).await;
165179 }
166180
167181 pub async fn publish_user<T: Serialize>(&self, uid: i64, event: &T) {
182 if let Ok(value) = serde_json::to_value(event)
183 && let Err(error) = crate::share::events::enqueue_user(
184 &self.inner.db,
185 self.inner.events_enabled,
186 uid,
187 &value,
188 )
189 .await
190 {
191 tracing::warn!(%error, %uid, "chat user event enqueue failed");
192 }
168193 self.publish(&user_channel(uid), event).await;
169194 }
170195
Modified lib/chat/Cargo.toml lib/chat/Cargo.toml +1 −0 View lib/chat/Cargo.toml
@@ -10,6 +10,7 @@
1010i18n = { workspace = true }
1111imaging = { workspace = true }
1212models = { workspace = true }
13queue = { workspace = true }
1314social = { workspace = true }
1415jwt = { workspace = true }
1516kenauth = { workspace = true }
Deleted lib/config/src/im.rs lib/config/src/im.rs +0 −39
@@ -1,39 +0,0 @@
1use std::net::IpAddr;
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Default, Serialize, Deserialize)]
6#[serde(default)]
7pub struct ImServerConfig {
8 pub enabled: Option<bool>,
9 pub bind_addr: Option<String>,
10 pub port: Option<u16>,
11}
12
13impl ImServerConfig {
14 pub fn enabled(&self) -> bool {
15 self.enabled.unwrap_or(true)
16 }
17
18 pub fn bind_addr(&self) -> &str {
19 self.bind_addr.as_deref().unwrap_or("0.0.0.0")
20 }
21
22 pub fn port(&self) -> u16 {
23 self.port.unwrap_or(3001)
24 }
25
26 pub fn is_valid(&self) -> Result<(), String> {
27 if !self.enabled() {
28 return Ok(());
29 }
30 let bind = self.bind_addr();
31 if bind.parse::<IpAddr>().is_err() {
32 return Err(format!("im.bind_addr is not a valid IP: {bind:?}"));
33 }
34 if self.port == Some(0) {
35 return Err("im.port must be greater than 0".to_string());
36 }
37 Ok(())
38 }
39}
Modified lib/config/src/lib.rs lib/config/src/lib.rs +4 −4 View lib/config/src/lib.rs
@@ -5,7 +5,6 @@
55pub mod email;
66pub mod git;
77pub mod githost;
8pub mod im;
98pub mod internal;
109pub mod issues;
1110pub mod livekit;
@@ -14,6 +13,7 @@
1413pub mod push;
1514pub mod quota;
1615pub mod redis;
16pub mod rocketmq;
1717pub mod s3;
1818pub mod scoped;
1919pub mod space;
@@ -32,7 +32,6 @@
3232use crate::email::EmailConfig;
3333use crate::git::GitConfig;
3434use crate::githost::GithostConfig;
35use crate::im::ImServerConfig;
3635use crate::internal::InternalConfig;
3736use crate::issues::IssueConfig;
3837use crate::livekit::LiveKitConfig;
@@ -40,6 +39,7 @@
4039use crate::pow::PowConfig;
4140use crate::push::PushConfig;
4241use crate::redis::RedisConfig;
42use crate::rocketmq::RocketMqConfig;
4343use crate::s3::S3Config;
4444use crate::space::SpaceConfig;
4545use crate::user::UserConfig;
@@ -66,7 +66,7 @@
6666 pub email: EmailConfig,
6767 pub apihost: ApihostConfig,
6868 pub githost: GithostConfig,
69 pub im: ImServerConfig,
69 pub rocketmq: RocketMqConfig,
7070 pub internal: InternalConfig,
7171 pub push: PushConfig,
7272}
@@ -84,7 +84,7 @@
8484 self.chat.is_valid().map_err(ConfigError::Validation)?;
8585 self.apihost.is_valid().map_err(ConfigError::Validation)?;
8686 self.githost.is_valid().map_err(ConfigError::Validation)?;
87 self.im.is_valid().map_err(ConfigError::Validation)?;
87 self.rocketmq.is_valid().map_err(ConfigError::Validation)?;
8888 self.internal.is_valid().map_err(ConfigError::Validation)?;
8989 self.livekit.is_valid().map_err(ConfigError::Validation)?;
9090 self.oauth.is_valid().map_err(ConfigError::Validation)?;
Added lib/config/src/rocketmq.rs lib/config/src/rocketmq.rs +121 −0 View lib/config/src/rocketmq.rs
@@ -0,0 +1,121 @@
1use serde::Deserialize;
2
3const DEFAULT_NAME_SERVER: &str = "127.0.0.1:9876";
4const DEFAULT_PRODUCER_GROUP: &str = "gitdata";
5const DEFAULT_TOPIC_PREFIX: &str = "gitdata";
6const DEFAULT_SEND_TIMEOUT_MS: u64 = 5_000;
7const DEFAULT_MAX_MESSAGE_BYTES: usize = 4 * 1024 * 1024;
8const MAX_TOPIC_LEN: usize = 127;
9
10fn default_name_server() -> String {
11 DEFAULT_NAME_SERVER.to_string()
12}
13
14fn default_producer_group() -> String {
15 DEFAULT_PRODUCER_GROUP.to_string()
16}
17
18fn default_topic_prefix() -> String {
19 DEFAULT_TOPIC_PREFIX.to_string()
20}
21
22fn default_send_timeout_ms() -> u64 {
23 DEFAULT_SEND_TIMEOUT_MS
24}
25
26fn default_max_message_bytes() -> usize {
27 DEFAULT_MAX_MESSAGE_BYTES
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
31#[serde(default)]
32pub struct RocketMqConfig {
33 pub enabled: bool,
34 #[serde(default = "default_name_server")]
35 pub name_server: String,
36 #[serde(default = "default_producer_group")]
37 pub producer_group: String,
38 #[serde(default = "default_topic_prefix")]
39 pub topic_prefix: String,
40 #[serde(default = "default_send_timeout_ms")]
41 pub send_timeout_ms: u64,
42 #[serde(default = "default_max_message_bytes")]
43 pub max_message_bytes: usize,
44}
45
46impl Default for RocketMqConfig {
47 fn default() -> Self {
48 Self {
49 enabled: false,
50 name_server: default_name_server(),
51 producer_group: default_producer_group(),
52 topic_prefix: default_topic_prefix(),
53 send_timeout_ms: default_send_timeout_ms(),
54 max_message_bytes: default_max_message_bytes(),
55 }
56 }
57}
58
59impl RocketMqConfig {
60 pub fn name_servers(&self) -> Vec<&str> {
61 self.name_server
62 .split([';', ','])
63 .map(str::trim)
64 .filter(|server| !server.is_empty())
65 .collect()
66 }
67
68 pub fn topic(&self, area: &str) -> String {
69 format!("{}-{area}", self.topic_prefix)
70 }
71
72 pub fn is_valid(&self) -> Result<(), String> {
73 if !self.enabled {
74 return Ok(());
75 }
76 let servers = self.name_servers();
77 if servers.is_empty() {
78 return Err("rocketmq.name_server must not be blank".to_string());
79 }
80 for server in servers {
81 let (host, port) = server.rsplit_once(':').ok_or_else(|| {
82 format!("rocketmq.name_server `{server}` must use the `host:port` form")
83 })?;
84 if host.trim().is_empty() {
85 return Err(format!("rocketmq.name_server `{server}` has an empty host"));
86 }
87 let port = port
88 .parse::<u16>()
89 .map_err(|_| format!("rocketmq.name_server `{server}` has an invalid port"))?;
90 if port == 0 {
91 return Err(format!("rocketmq.name_server `{server}` has a zero port"));
92 }
93 }
94 if !is_valid_identifier(&self.producer_group) {
95 return Err("rocketmq.producer_group must use letters, digits, `_` or `-`".to_string());
96 }
97 if !is_valid_identifier(&self.topic_prefix) {
98 return Err("rocketmq.topic_prefix must use letters, digits, `_` or `-`".to_string());
99 }
100 if self.topic_prefix.len() + 1 + 64 > MAX_TOPIC_LEN {
101 return Err(format!(
102 "rocketmq.topic_prefix leaves no room for a topic under {MAX_TOPIC_LEN} characters"
103 ));
104 }
105 if self.send_timeout_ms == 0 {
106 return Err("rocketmq.send_timeout_ms must be greater than zero".to_string());
107 }
108 if self.max_message_bytes == 0 {
109 return Err("rocketmq.max_message_bytes must be greater than zero".to_string());
110 }
111 Ok(())
112 }
113}
114
115fn is_valid_identifier(value: &str) -> bool {
116 !value.is_empty()
117 && value.len() <= MAX_TOPIC_LEN
118 && value
119 .chars()
120 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-'))
121}
Modified lib/config/src/scoped.rs lib/config/src/scoped.rs +4 −43 View lib/config/src/scoped.rs
@@ -9,7 +9,6 @@
99use crate::error::ConfigError;
1010use crate::git::GitConfig;
1111use crate::githost::GithostConfig as GithostSection;
12use crate::im::ImServerConfig;
1312use crate::issues::IssueConfig;
1413use crate::livekit::LiveKitConfig;
1514use crate::oauth::OAuthConfig;
@@ -17,6 +16,7 @@
1716use crate::push::PushConfig;
1817use crate::quota::QuotaConfig;
1918use crate::redis::RedisConfig;
19use crate::rocketmq::RocketMqConfig;
2020use crate::s3::S3Config;
2121use crate::space::SpaceConfig;
2222use crate::user::UserConfig;
@@ -103,6 +103,7 @@
103103 pub oauth: OAuthConfig,
104104 pub apihost: ApihostSection,
105105 pub livekit: LiveKitConfig,
106 pub rocketmq: RocketMqConfig,
106107 pub push: PushConfig,
107108}
108109
@@ -125,6 +126,7 @@
125126 oauth: self.oauth,
126127 apihost: self.apihost,
127128 livekit: self.livekit,
129 rocketmq: self.rocketmq,
128130 push: self.push,
129131 ..Default::default()
130132 }
@@ -141,48 +143,7 @@
141143 self.apihost.is_valid()?;
142144 self.livekit.is_valid()?;
143145 self.oauth.is_valid()?;
146 self.rocketmq.is_valid()?;
144147 self.push.is_valid()
145 }
146}
147
148#[derive(Clone, Debug, Default, Deserialize)]
149#[serde(default)]
150pub struct ImConfig {
151 pub database: DatabaseConfig,
152 pub redis: RedisConfig,
153 pub s3: S3Config,
154 pub email: EmailConfig,
155 pub oauth: OAuthConfig,
156 pub chat: ChatConfig,
157 pub web: WebConfig,
158 pub auth: AuthConfig,
159 pub im: ImServerConfig,
160}
161
162impl ImConfig {
163 pub fn into_app_config(self) -> AppConfig {
164 AppConfig {
165 database: self.database,
166 redis: self.redis,
167 s3: self.s3,
168 email: self.email,
169 oauth: self.oauth,
170 chat: self.chat,
171 web: self.web,
172 auth: self.auth,
173 im: self.im,
174 ..Default::default()
175 }
176 }
177}
178
179impl ScopedConfig for ImConfig {
180 fn validate_scoped(&self) -> Result<(), String> {
181 self.database.validate()?;
182 self.redis.validate()?;
183 self.auth.validate()?;
184 self.chat.is_valid()?;
185 self.oauth.is_valid()?;
186 self.im.is_valid()
187148 }
188149}
Added lib/export/src/events.rs lib/export/src/events.rs +46 −0 View lib/export/src/events.rs
@@ -0,0 +1,46 @@
1use models::system::event_outbox;
2use queue::{Envelope, Labels};
3use sea_orm::{ActiveModelTrait, ConnectionTrait, DbErr, Set};
4use serde::Serialize;
5
6pub async fn enqueue<T: Serialize>(
7 db: &impl ConnectionTrait,
8 enabled: bool,
9 kind: &str,
10 labels: Labels,
11 payload: T,
12 ordering_key: impl Into<String>,
13) -> Result<(), DbErr> {
14 if !enabled {
15 return Ok(());
16 }
17
18 let source = kind.split('.').next().unwrap_or_default().to_string();
19 let envelope = Envelope::new(kind, payload)
20 .with_source(source)
21 .with_labels(labels);
22 let subject = envelope
23 .subject()
24 .map_err(|error| DbErr::Custom(error.to_string()))?;
25 let stored = serde_json::to_value(&envelope)
26 .map_err(|error| DbErr::Custom(format!("encoding outbox event: {error}")))?;
27
28 event_outbox::ActiveModel {
29 id: Set(envelope.id),
30 subject: Set(subject),
31 kind: Set(kind.to_string()),
32 ordering_key: Set(ordering_key.into()),
33 envelope: Set(stored),
34 occurred_at: Set(envelope.occurred_at),
35 available_at: Set(envelope.occurred_at),
36 ..Default::default()
37 }
38 .insert(db)
39 .await?;
40
41 Ok(())
42}
43
44pub fn request_id() -> Option<String> {
45 crate::middleware::request_id()
46}
Modified lib/export/src/lib.rs lib/export/src/lib.rs +5 −0 View lib/export/src/lib.rs
@@ -1,6 +1,7 @@
11mod api_error;
22pub mod database;
33pub mod error;
4pub mod events;
45pub mod health;
56pub mod imaging;
67pub mod metrics;
@@ -35,6 +36,7 @@
3536 pub config: AppConfig,
3637 pub kenauth: Option<Arc<kenauth::KenAuthClient>>,
3738 pub email_rpc: Option<Arc<kenauth::KenAuthClient>>,
39 pub events_enabled: bool,
3840}
3941
4042impl AppState {
@@ -76,6 +78,8 @@
7678 kenauth.clone()
7779 };
7880
81 let events_enabled = config.rocketmq.enabled;
82
7983 Ok(Self {
8084 database,
8185 redis,
@@ -83,6 +87,7 @@
8387 config,
8488 kenauth,
8589 email_rpc,
90 events_enabled,
8691 })
8792 }
8893}
Modified lib/export/src/notify.rs lib/export/src/notify.rs +29 −11 View lib/export/src/notify.rs
@@ -1,8 +1,9 @@
11use std::sync::{Arc, Mutex};
22
3use queue::Labels;
34use serde_json::Value;
45
5use crate::redis::AppRedis;
6use crate::AppState;
67
78/// The chat WS per-user channel; every crate that writes a notification
89/// publishes to it so connected clients receive the row in real time.
@@ -17,7 +18,8 @@
1718 })
1819}
1920
20pub async fn publish_user_event(redis: &AppRedis, uid: i64, event: &Value) {
21pub async fn publish_user_event(state: &AppState, uid: i64, event: &Value) {
22 let redis = &state.redis;
2123 let payload = match serde_json::to_string(event) {
2224 Ok(payload) => payload,
2325 Err(error) => {
@@ -32,17 +34,33 @@
3234 // Every `notification` user event is also a candidate for an OS push.
3335 // Enqueueing here — rather than at each of the nine writers — is what makes
3436 // "websocket fan-out and push fan-out never drift" true by construction.
35 if event.get("kind").and_then(Value::as_str) == Some("notification")
36 && let Some(notification) = event.get("notification")
37 && let Some(job) = crate::push::PushJob::from_notification(uid, notification)
38 {
39 crate::push::enqueue(redis, &job).await;
37 if event.get("kind").and_then(Value::as_str) == Some("notification") {
38 if let Some(notification) = event.get("notification")
39 && let Some(job) = crate::push::PushJob::from_notification(uid, notification)
40 {
41 crate::push::enqueue(redis, &job).await;
42 }
43 let labels = Labels::new()
44 .with("user_id", uid)
45 .with_opt("request_id", crate::events::request_id());
46 if let Err(error) = crate::events::enqueue(
47 state.database.write(),
48 state.events_enabled,
49 "notify.notification.create",
50 labels,
51 event,
52 format!("notify:user:{uid}"),
53 )
54 .await
55 {
56 tracing::warn!(%error, %uid, "notification event enqueue failed");
57 }
4058 }
4159}
4260
43pub async fn publish_notification(redis: &AppRedis, uid: i64, notification: Value) {
61pub async fn publish_notification(state: &AppState, uid: i64, notification: Value) {
4462 let event = notification_event_json(notification);
45 publish_user_event(redis, uid, &event).await;
63 publish_user_event(state, uid, &event).await;
4664}
4765
4866/// Notifications created inside a DB transaction; flush after commit.
@@ -61,13 +79,13 @@
6179 }
6280 }
6381
64 pub async fn flush(&self, redis: &AppRedis) {
82 pub async fn flush(&self, state: &AppState) {
6583 let batch: Vec<(i64, Value)> = match self.inner.lock() {
6684 Ok(mut pending) => std::mem::take(&mut *pending),
6785 Err(_) => return,
6886 };
6987 for (uid, event) in batch {
70 publish_user_event(redis, uid, &event).await;
88 publish_user_event(state, uid, &event).await;
7189 }
7290 }
7391}
Modified lib/export/Cargo.toml lib/export/Cargo.toml +2 −0 View lib/export/Cargo.toml
@@ -5,6 +5,8 @@
55
66[dependencies]
77config = { workspace = true }
8models = { workspace = true }
9queue = { workspace = true }
810storage = { workspace = true }
911sea-orm = { workspace = true }
1012redis = { workspace = true }
Modified lib/issues/src/services/comments.rs lib/issues/src/services/comments.rs +1 −1 View lib/issues/src/services/comments.rs
@@ -238,7 +238,7 @@
238238 })
239239 })
240240 .await?;
241 pending.flush(&self.state.redis).await;
241 pending.flush(&self.state).await;
242242
243243 let mut email_recipients = Vec::new();
244244 if let Some(pp_uid) = parent_poster_id {
Modified lib/issues/src/services/issues.rs lib/issues/src/services/issues.rs +1 −1 View lib/issues/src/services/issues.rs
@@ -664,7 +664,7 @@
664664 })
665665 })
666666 .await?;
667 pending.flush(&self.state.redis).await;
667 pending.flush(&self.state).await;
668668
669669 if !mentioned_uids.is_empty() {
670670 let emails = self.primary_emails(&mentioned_uids).await?;
Modified lib/issues/src/services/members.rs lib/issues/src/services/members.rs +1 −1 View lib/issues/src/services/members.rs
@@ -157,7 +157,7 @@
157157 })
158158 })
159159 .await?;
160 pending.flush(&self.state.redis).await;
160 pending.flush(&self.state).await;
161161
162162 if kind_enum == IssueMemberKind::Assignee && target_uid != caller {
163163 let emails = self.primary_emails(&[target_uid]).await?;
Modified lib/issues/src/services/service.rs lib/issues/src/services/service.rs +54 −0 View lib/issues/src/services/service.rs
@@ -110,6 +110,15 @@
110110 new_title,
111111 content,
112112 } = detail;
113 let payload = serde_json::json!({
114 "issue_id": issue_id,
115 "user_id": user_id,
116 "event_type": event_type,
117 "comment_id": &comment_id,
118 "old_title": &old_title,
119 "new_title": &new_title,
120 "content": &content,
121 });
113122 issue_event_line::ActiveModel {
114123 id: Set(Uuid::now_v7()),
115124 issue_id: Set(Some(issue_id)),
@@ -127,6 +136,22 @@
127136 }
128137 .insert(txn)
129138 .await?;
139
140 let mut labels = queue::Labels::new()
141 .with("issue_id", issue_id)
142 .with("user_id", user_id);
143 if let Some(request_id) = export::events::request_id() {
144 labels.insert("request_id", request_id);
145 }
146 export::events::enqueue(
147 txn,
148 self.state.events_enabled,
149 event_kind(event_type),
150 labels,
151 payload,
152 format!("issue:{issue_id}"),
153 )
154 .await?;
130155 Ok(())
131156 }
132157
@@ -260,3 +285,32 @@
260285 .map_err(|error| IssueError::Internal(format!("email send failed: {error}")))
261286 }
262287}
288
289fn event_kind(event_type: IssueEventType) -> &'static str {
290 match event_type {
291 IssueEventType::Created => "issue.issue.create",
292 IssueEventType::TitleUpdated => "issue.title.update",
293 IssueEventType::DescriptionUpdated => "issue.description.update",
294 IssueEventType::ContentUpdated => "issue.content.update",
295 IssueEventType::MilestoneUpdated => "issue.milestone.update",
296 IssueEventType::PriorityUpdated => "issue.priority.update",
297 IssueEventType::DueUpdated => "issue.due.update",
298 IssueEventType::Closed => "issue.issue.close",
299 IssueEventType::Reopened => "issue.issue.reopen",
300 IssueEventType::CommentCreated => "issue.comment.create",
301 IssueEventType::CommentUpdated => "issue.comment.update",
302 IssueEventType::CommentDeleted => "issue.comment.delete",
303 IssueEventType::MemberAdded => "issue.member.create",
304 IssueEventType::MemberRemoved => "issue.member.delete",
305 IssueEventType::LabelAdded => "issue.label.create",
306 IssueEventType::LabelRemoved => "issue.label.delete",
307 IssueEventType::DependencyAdded => "issue.dependency.create",
308 IssueEventType::DependencyRemoved => "issue.dependency.delete",
309 IssueEventType::Pinned => "issue.pin.create",
310 IssueEventType::Unpinned => "issue.pin.delete",
311 IssueEventType::RepoRelationAdded => "issue.repo-relation.create",
312 IssueEventType::RepoRelationRemoved => "issue.repo-relation.delete",
313 IssueEventType::PullLinked => "issue.pull-link.create",
314 IssueEventType::PullUnlinked => "issue.pull-link.delete",
315 }
316}
Modified lib/issues/Cargo.toml lib/issues/Cargo.toml +1 −0 View lib/issues/Cargo.toml
@@ -9,6 +9,7 @@
99export = { workspace = true }
1010i18n = { workspace = true }
1111models = { workspace = true }
12queue = { workspace = true }
1213jwt = { workspace = true }
1314kenauth = { workspace = true }
1415space = { workspace = true }
Added lib/models/src/system/event_outbox.rs lib/models/src/system/event_outbox.rs +25 −0 View lib/models/src/system/event_outbox.rs
@@ -0,0 +1,25 @@
1use sea_orm::JsonValue;
2use sea_orm::entity::prelude::*;
3use time::OffsetDateTime;
4use uuid::Uuid;
5
6#[sea_orm::model]
7#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
8#[sea_orm(table_name = "event_outbox")]
9pub struct Model {
10 #[sea_orm(primary_key, auto_increment = false)]
11 pub id: Uuid,
12 pub subject: String,
13 pub kind: String,
14 pub ordering_key: String,
15 pub envelope: JsonValue,
16 pub occurred_at: OffsetDateTime,
17 pub available_at: OffsetDateTime,
18 #[sea_orm(default_value = 0)]
19 pub attempts: i32,
20 pub lease_id: Option<Uuid>,
21 pub leased_until: Option<OffsetDateTime>,
22 pub published_at: Option<OffsetDateTime>,
23}
24
25impl ActiveModelBehavior for ActiveModel {}
Modified lib/models/src/system/mod.rs lib/models/src/system/mod.rs +1 −0 View lib/models/src/system/mod.rs
@@ -1,3 +1,4 @@
1pub mod event_outbox;
12pub mod system_announcements;
23pub mod system_permissions;
34pub mod system_rate_limits;
Modified lib/models/schema_extras.sql lib/models/schema_extras.sql +11 −0 View lib/models/schema_extras.sql
@@ -251,3 +251,14 @@
251251 CHECK ((has_merged = false) OR merged_at IS NOT NULL);
252252 END IF;
253253END $$;
254
255-- ---------------------------------------------------------------------------
256-- event_outbox: relay claims un-published rows; the partial indexes keep the
257-- claim query and per-ordering-key head lookup cheap as the table grows.
258-- ---------------------------------------------------------------------------
259CREATE INDEX IF NOT EXISTS idx_event_outbox_pending
260 ON event_outbox (available_at, occurred_at, id) WHERE published_at IS NULL;
261CREATE INDEX IF NOT EXISTS idx_event_outbox_ordering
262 ON event_outbox (ordering_key, occurred_at, id) WHERE published_at IS NULL;
263CREATE INDEX IF NOT EXISTS idx_event_outbox_published
264 ON event_outbox (published_at) WHERE published_at IS NOT NULL;
Modified lib/pulls/src/services/changes.rs lib/pulls/src/services/changes.rs +1 −0 View lib/pulls/src/services/changes.rs
@@ -146,6 +146,7 @@
146146 if moved {
147147 write_event(
148148 self.db_write(),
149 self.state.events_enabled,
149150 &updated,
150151 actor,
151152 EVENT_HEAD_UPDATED,
Modified lib/pulls/src/services/events.rs lib/pulls/src/services/events.rs +53 −1 View lib/pulls/src/services/events.rs
@@ -49,6 +49,7 @@
4949
5050pub async fn write_event(
5151 db: &impl ConnectionTrait,
52 enabled: bool,
5253 pull: &pull_requests::Model,
5354 actor: Option<i64>,
5455 event_type: &str,
@@ -59,6 +60,13 @@
5960 Some(actor) => actor,
6061 None => pull.author_id,
6162 };
63 let payload = serde_json::json!({
64 "pull_request_id": pull.id,
65 "user_id": actor,
66 "event_type": event_type,
67 "commit_sha": &commit_sha,
68 "content": &content,
69 });
6270 let row = pull_request_event_line::ActiveModel {
6371 id: Set(Uuid::now_v7()),
6472 pull_request_id: Set(pull.id),
@@ -69,9 +77,52 @@
6977 created_at: Set(OffsetDateTime::now_utc()),
7078 };
7179 row.insert(db).await?;
72 Ok(())
80
81 let Some(kind) = event_kind(event_type) else {
82 return Ok(());
83 };
84 let mut labels = queue::Labels::new()
85 .with("pull_id", pull.id)
86 .with("user_id", actor);
87 if let Some(request_id) = export::events::request_id() {
88 labels.insert("request_id", request_id);
89 }
90 export::events::enqueue(
91 db,
92 enabled,
93 kind,
94 labels,
95 payload,
96 format!("pull:{}", pull.id),
97 )
98 .await
7399}
74100
101fn event_kind(event_type: &str) -> Option<&'static str> {
102 Some(match event_type {
103 EVENT_CREATED => "pull.pull.create",
104 EVENT_UPDATED => "pull.pull.update",
105 EVENT_CLOSED => "pull.pull.close",
106 EVENT_REOPENED => "pull.pull.reopen",
107 EVENT_MERGED => "pull.pull.merge",
108 EVENT_COMMENTED => "pull.comment.create",
109 EVENT_REVIEW_SUBMITTED => "pull.review.create",
110 EVENT_REVIEW_DISMISSED => "pull.review.delete",
111 EVENT_REVIEW_REQUESTED => "pull.review-request.create",
112 EVENT_REVIEW_REQUEST_CANCELLED => "pull.review-request.delete",
113 EVENT_HEAD_UPDATED => "pull.head.update",
114 EVENT_LABELED => "pull.label.create",
115 EVENT_UNLABELED => "pull.label.delete",
116 EVENT_ASSIGNED => "pull.assignee.create",
117 EVENT_UNASSIGNED => "pull.assignee.delete",
118 EVENT_LOCKED => "pull.lock.create",
119 EVENT_UNLOCKED => "pull.lock.delete",
120 EVENT_AUTO_MERGE_ENABLED => "pull.auto-merge.create",
121 EVENT_AUTO_MERGE_DISABLED => "pull.auto-merge.delete",
122 _ => return None,
123 })
124}
125
75126impl PullService {
76127 pub async fn record_event(
77128 &self,
@@ -83,6 +134,7 @@
83134 ) -> Result<(), PullError> {
84135 write_event(
85136 self.db_write(),
137 self.state.events_enabled,
86138 pull,
87139 actor,
88140 event_type,
Modified lib/pulls/src/services/pulls.rs lib/pulls/src/services/pulls.rs +1 −0 View lib/pulls/src/services/pulls.rs
@@ -403,6 +403,7 @@
403403 let pull = created.insert(&txn).await?;
404404 write_event(
405405 &txn,
406 self.state.events_enabled,
406407 &pull,
407408 Some(caller),
408409 EVENT_CREATED,
Modified lib/pulls/src/services/reviewers.rs lib/pulls/src/services/reviewers.rs +2 −0 View lib/pulls/src/services/reviewers.rs
@@ -130,6 +130,7 @@
130130 }
131131 write_event(
132132 self.db_write(),
133 self.state.events_enabled,
133134 pull,
134135 Some(caller),
135136 EVENT_REVIEW_REQUESTED,
@@ -163,6 +164,7 @@
163164 .await?;
164165 write_event(
165166 self.db_write(),
167 self.state.events_enabled,
166168 pull,
167169 Some(caller),
168170 EVENT_REVIEW_REQUEST_CANCELLED,
Modified lib/pulls/src/services/reviews.rs lib/pulls/src/services/reviews.rs +1 −0 View lib/pulls/src/services/reviews.rs
@@ -138,6 +138,7 @@
138138 if state != ReviewState::Pending {
139139 write_event(
140140 &txn,
141 self.state.events_enabled,
141142 pull,
142143 Some(caller),
143144 EVENT_REVIEW_SUBMITTED,
Modified lib/pulls/Cargo.toml lib/pulls/Cargo.toml +1 −0 View lib/pulls/Cargo.toml
@@ -9,6 +9,7 @@
99git = { workspace = true }
1010issues = { workspace = true }
1111models = { workspace = true }
12queue = { workspace = true }
1213jwt = { workspace = true }
1314space = { workspace = true }
1415axum = { workspace = true }
Added lib/queue/src/conn.rs lib/queue/src/conn.rs +73 −0 View lib/queue/src/conn.rs
@@ -0,0 +1,73 @@
1use std::sync::Arc;
2
3use config::rocketmq::RocketMqConfig;
4use rocketmq_client_rust::producer::default_mq_producer::DefaultMQProducer;
5use rocketmq_common::common::message::message_single::Message;
6use tokio::sync::Mutex;
7
8use crate::error::{QueueError, QueueResult};
9
10#[derive(Clone)]
11pub struct RocketMqConn {
12 producer: Arc<Mutex<DefaultMQProducer>>,
13 config: RocketMqConfig,
14}
15
16impl std::fmt::Debug for RocketMqConn {
17 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18 formatter
19 .debug_struct("RocketMqConn")
20 .field("name_server", &self.config.name_server)
21 .field("producer_group", &self.config.producer_group)
22 .field("topic_prefix", &self.config.topic_prefix)
23 .finish()
24 }
25}
26
27impl RocketMqConn {
28 pub async fn init(config: &RocketMqConfig) -> QueueResult<Self> {
29 config.is_valid().map_err(QueueError::config)?;
30
31 let mut producer = DefaultMQProducer::builder()
32 .producer_group(config.producer_group.clone())
33 .name_server_addr(config.name_server.replace(',', ";"))
34 .build();
35 producer.start().await.map_err(|error| {
36 QueueError::connect(format!("failed to start the rocketmq producer: {error}"))
37 })?;
38
39 Ok(Self {
40 producer: Arc::new(Mutex::new(producer)),
41 config: config.clone(),
42 })
43 }
44
45 pub fn config(&self) -> &RocketMqConfig {
46 &self.config
47 }
48
49 pub fn max_payload(&self) -> usize {
50 self.config.max_message_bytes
51 }
52
53 pub async fn send(&self, topic: &str, message: Message) -> QueueResult<()> {
54 let mut producer = self.producer.lock().await;
55 let result = producer
56 .send_with_timeout(message, self.config.send_timeout_ms)
57 .await
58 .map_err(|error| {
59 QueueError::publish(format!("failed to publish to `{topic}`: {error}"))
60 })?;
61 if result.is_none() {
62 return Err(QueueError::publish(format!(
63 "producer returned no send result for `{topic}`"
64 )));
65 }
66 Ok(())
67 }
68
69 pub async fn shutdown(&self) {
70 let mut producer = self.producer.lock().await;
71 producer.shutdown().await;
72 }
73}
Added lib/queue/src/envelope.rs lib/queue/src/envelope.rs +207 −0 View lib/queue/src/envelope.rs
@@ -0,0 +1,207 @@
1use std::collections::BTreeMap;
2
3use bytes::Bytes;
4use serde::{Deserialize, Serialize, de::DeserializeOwned};
5use serde_json::Value;
6use time::OffsetDateTime;
7use uuid::Uuid;
8
9use crate::error::{QueueError, QueueResult};
10use crate::subject;
11
12pub const SCHEMA_VERSION: u16 = 1;
13pub const EVENT_PREFIX: &str = "events.";
14
15fn schema_version() -> u16 {
16 SCHEMA_VERSION
17}
18
19#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(transparent)]
21pub struct Labels(BTreeMap<String, String>);
22
23impl Labels {
24 pub fn new() -> Self {
25 Self::default()
26 }
27
28 pub fn with(mut self, key: impl Into<String>, value: impl std::fmt::Display) -> Self {
29 self.insert(key, value);
30 self
31 }
32
33 pub fn with_opt(
34 mut self,
35 key: impl Into<String>,
36 value: Option<impl std::fmt::Display>,
37 ) -> Self {
38 if let Some(value) = value {
39 self.insert(key, value);
40 }
41 self
42 }
43
44 pub fn insert(&mut self, key: impl Into<String>, value: impl std::fmt::Display) {
45 self.0.insert(key.into(), value.to_string());
46 }
47
48 pub fn get(&self, key: &str) -> Option<&str> {
49 self.0.get(key).map(String::as_str)
50 }
51
52 pub fn is_empty(&self) -> bool {
53 self.0.is_empty()
54 }
55}
56
57#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
58pub struct Envelope<T> {
59 pub id: Uuid,
60 #[serde(default = "schema_version")]
61 pub schema_version: u16,
62 pub kind: String,
63 #[serde(default)]
64 pub source: String,
65 #[serde(default, skip_serializing_if = "Labels::is_empty")]
66 pub labels: Labels,
67 pub occurred_at: OffsetDateTime,
68 pub payload: T,
69}
70
71impl<T> Envelope<T> {
72 pub fn new(kind: impl Into<String>, payload: T) -> Self {
73 Self {
74 id: Uuid::now_v7(),
75 schema_version: SCHEMA_VERSION,
76 kind: kind.into(),
77 source: String::new(),
78 labels: Labels::new(),
79 occurred_at: OffsetDateTime::now_utc(),
80 payload,
81 }
82 }
83
84 pub fn with_source(mut self, source: impl Into<String>) -> Self {
85 self.source = source.into();
86 self
87 }
88
89 pub fn with_labels(mut self, labels: Labels) -> Self {
90 self.labels = labels;
91 self
92 }
93
94 pub fn subject(&self) -> QueueResult<String> {
95 let kind = subject::normalize(&self.kind);
96 if !subject::is_specific(&kind) {
97 return Err(QueueError::payload(format!(
98 "envelope kind `{}` is not a concrete subject",
99 self.kind
100 )));
101 }
102 Ok(format!("{EVENT_PREFIX}{kind}"))
103 }
104}
105
106impl<T: Serialize> Envelope<T> {
107 pub fn encode(&self) -> QueueResult<Bytes> {
108 serde_json::to_vec(self)
109 .map(Bytes::from)
110 .map_err(QueueError::from)
111 }
112
113 pub fn summary_body(&self, summary: &str) -> QueueResult<Bytes> {
114 serde_json::to_vec(&Summary {
115 id: self.id,
116 kind: &self.kind,
117 occurred_at: &self.occurred_at,
118 summary,
119 })
120 .map(Bytes::from)
121 .map_err(QueueError::from)
122 }
123
124 pub fn properties(&self) -> Vec<(String, String)> {
125 let payload = serde_json::to_value(&self.payload).ok();
126 self.properties_of(payload.as_ref())
127 }
128}
129
130impl<T> Envelope<T> {
131 fn properties_of(&self, payload: Option<&Value>) -> Vec<(String, String)> {
132 let mut properties = Vec::with_capacity(4);
133
134 let user = self
135 .labels
136 .get("user_id")
137 .map(str::to_owned)
138 .or_else(|| payload.and_then(|value| field_text(value, "user_id")))
139 .or_else(|| payload.and_then(|value| field_text(value, "target_user_id")))
140 .or_else(|| self.labels.get("actor_user_id").map(str::to_owned));
141 if let Some(user) = user {
142 properties.push(("user".to_string(), user));
143 }
144
145 let org = self
146 .labels
147 .get("organization_id")
148 .map(str::to_owned)
149 .or_else(|| payload.and_then(|value| field_text(value, "organization_id")));
150 if let Some(org) = org {
151 properties.push(("org".to_string(), org));
152 }
153
154 let request_id = self
155 .labels
156 .get("request_id")
157 .map(str::to_owned)
158 .or_else(|| payload.and_then(|value| field_text(value, "request_id")));
159 if let Some(request_id) = request_id {
160 properties.push(("request_id".to_string(), request_id));
161 }
162
163 let room = self
164 .labels
165 .get("room_id")
166 .map(str::to_owned)
167 .or_else(|| payload.and_then(|value| field_text(value, "room_id")));
168 if let Some(room) = room {
169 properties.push(("room_id".to_string(), room));
170 }
171
172 for key in ["issue_id", "pull_id"] {
173 let value = self
174 .labels
175 .get(key)
176 .map(str::to_owned)
177 .or_else(|| payload.and_then(|value| field_text(value, key)));
178 if let Some(value) = value {
179 properties.push((key.to_string(), value));
180 }
181 }
182
183 properties
184 }
185}
186
187impl<T: DeserializeOwned> Envelope<T> {
188 pub fn decode(payload: &[u8]) -> QueueResult<Self> {
189 serde_json::from_slice(payload).map_err(QueueError::from)
190 }
191}
192
193#[derive(Serialize)]
194struct Summary<'a> {
195 id: Uuid,
196 kind: &'a str,
197 occurred_at: &'a OffsetDateTime,
198 summary: &'a str,
199}
200
201fn field_text(value: &Value, key: &str) -> Option<String> {
202 match value.get(key)? {
203 Value::String(text) => Some(text.clone()),
204 Value::Number(number) => Some(number.to_string()),
205 _ => None,
206 }
207}
Added lib/queue/src/error.rs lib/queue/src/error.rs +58 −0 View lib/queue/src/error.rs
@@ -0,0 +1,58 @@
1use std::fmt;
2
3pub type QueueResult<T> = Result<T, QueueError>;
4
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub enum QueueError {
7 Config(String),
8 Connect(String),
9 Publish(String),
10 Payload(String),
11}
12
13impl QueueError {
14 pub fn config(message: impl Into<String>) -> Self {
15 Self::Config(message.into())
16 }
17
18 pub fn connect(message: impl Into<String>) -> Self {
19 Self::Connect(message.into())
20 }
21
22 pub fn publish(message: impl Into<String>) -> Self {
23 Self::Publish(message.into())
24 }
25
26 pub fn payload(message: impl Into<String>) -> Self {
27 Self::Payload(message.into())
28 }
29
30 pub fn message(&self) -> &str {
31 match self {
32 Self::Config(message)
33 | Self::Connect(message)
34 | Self::Publish(message)
35 | Self::Payload(message) => message,
36 }
37 }
38}
39
40impl fmt::Display for QueueError {
41 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
42 let label = match self {
43 Self::Config(_) => "config",
44 Self::Connect(_) => "connect",
45 Self::Publish(_) => "publish",
46 Self::Payload(_) => "payload",
47 };
48 write!(formatter, "rocketmq {label}: {}", self.message())
49 }
50}
51
52impl std::error::Error for QueueError {}
53
54impl From<serde_json::Error> for QueueError {
55 fn from(error: serde_json::Error) -> Self {
56 Self::Payload(error.to_string())
57 }
58}
Added lib/queue/src/lib.rs lib/queue/src/lib.rs +13 −0 View lib/queue/src/lib.rs
@@ -0,0 +1,13 @@
1pub mod conn;
2pub mod envelope;
3pub mod error;
4pub mod publisher;
5pub mod relay;
6pub mod route;
7pub mod subject;
8
9pub use conn::RocketMqConn;
10pub use envelope::{EVENT_PREFIX, Envelope, Labels, SCHEMA_VERSION};
11pub use error::{QueueError, QueueResult};
12pub use publisher::Publisher;
13pub use route::Route;
Added lib/queue/src/publisher.rs lib/queue/src/publisher.rs +65 −0 View lib/queue/src/publisher.rs
@@ -0,0 +1,65 @@
1use bytes::Bytes;
2use rocketmq_common::common::message::message_single::Message;
3
4use crate::conn::RocketMqConn;
5use crate::error::{QueueError, QueueResult};
6
7#[derive(Clone, Debug)]
8pub struct Publisher {
9 conn: RocketMqConn,
10}
11
12impl Publisher {
13 pub fn new(conn: RocketMqConn) -> Self {
14 Self { conn }
15 }
16
17 pub fn conn(&self) -> &RocketMqConn {
18 &self.conn
19 }
20
21 pub fn max_payload(&self) -> usize {
22 self.conn.max_payload()
23 }
24
25 pub fn topic(&self, area: &str) -> String {
26 self.conn.config().topic(area)
27 }
28
29 pub async fn publish(
30 &self,
31 topic: &str,
32 tag: &str,
33 keys: Vec<String>,
34 properties: Vec<(String, String)>,
35 body: Bytes,
36 ) -> QueueResult<()> {
37 let max_payload = self.max_payload();
38 if body.len() > max_payload {
39 return Err(QueueError::payload(format!(
40 "event body for `{topic}` is {} bytes, exceeding the {max_payload}-byte limit",
41 body.len()
42 )));
43 }
44
45 let mut builder = Message::builder()
46 .topic(topic)
47 .tags(tag)
48 .keys(keys)
49 .body(body.to_vec());
50 for (key, value) in properties {
51 builder = builder.raw_property(key.clone(), value).map_err(|error| {
52 QueueError::publish(format!(
53 "invalid message property `{key}` for `{topic}`: {error}"
54 ))
55 })?;
56 }
57 let message = builder.build().map_err(|error| {
58 QueueError::publish(format!(
59 "failed to build the message for `{topic}`: {error}"
60 ))
61 })?;
62
63 self.conn.send(topic, message).await
64 }
65}
Added lib/queue/src/relay.rs lib/queue/src/relay.rs +191 −0 View lib/queue/src/relay.rs
@@ -0,0 +1,191 @@
1use std::time::Duration;
2
3use sea_orm::{ConnectionTrait, DatabaseConnection, DbBackend, DbErr, Statement};
4use serde_json::Value;
5use tokio_util::sync::CancellationToken;
6use uuid::Uuid;
7
8use crate::envelope::Envelope;
9use crate::error::QueueError;
10use crate::publisher::Publisher;
11use crate::route;
12
13const BATCH_SIZE: i64 = 16;
14const POLL_INTERVAL: Duration = Duration::from_millis(500);
15const PUBLISH_TIMEOUT: Duration = Duration::from_secs(10);
16const MAX_PUBLISH_ATTEMPTS: i32 = 50;
17const PURGE_INTERVAL: Duration = Duration::from_secs(600);
18const PURGED_RETENTION_SECS: i64 = 7 * 24 * 60 * 60;
19
20const CLAIM: &str = "
21WITH pending AS (
22 SELECT candidate.id FROM event_outbox AS candidate
23 WHERE candidate.published_at IS NULL AND candidate.available_at <= CURRENT_TIMESTAMP
24 AND (candidate.leased_until IS NULL OR candidate.leased_until <= CURRENT_TIMESTAMP)
25 AND NOT EXISTS (
26 SELECT 1 FROM event_outbox AS earlier
27 WHERE earlier.ordering_key = candidate.ordering_key AND earlier.published_at IS NULL
28 AND (earlier.occurred_at, earlier.id) < (candidate.occurred_at, candidate.id)
29 )
30 ORDER BY candidate.occurred_at, candidate.id
31 LIMIT $1 FOR UPDATE OF candidate SKIP LOCKED
32)
33UPDATE event_outbox AS events
34SET lease_id = $2, leased_until = CURRENT_TIMESTAMP + INTERVAL '60 seconds',
35 attempts = LEAST(events.attempts, 2147483646) + 1
36FROM pending WHERE events.id = pending.id
37RETURNING events.id, events.kind, events.envelope, events.attempts
38";
39
40struct PendingEvent {
41 id: Uuid,
42 kind: String,
43 envelope: Value,
44 attempts: i32,
45}
46
47pub async fn run(db: DatabaseConnection, publisher: Publisher, cancel: CancellationToken) {
48 let mut purge_at = tokio::time::Instant::now() + PURGE_INTERVAL;
49 loop {
50 if cancel.is_cancelled() {
51 return;
52 }
53 if tokio::time::Instant::now() >= purge_at {
54 purge_at = tokio::time::Instant::now() + PURGE_INTERVAL;
55 purge_published(&db).await;
56 }
57 match run_batch(&db, &publisher).await {
58 Ok(count) if count > 0 => continue,
59 Ok(_) => {}
60 Err(error) => tracing::error!(%error, "event outbox relay failed"),
61 }
62 tokio::select! {
63 _ = cancel.cancelled() => return,
64 _ = tokio::time::sleep(POLL_INTERVAL) => {}
65 }
66 }
67}
68
69async fn run_batch(db: &DatabaseConnection, publisher: &Publisher) -> Result<usize, DbErr> {
70 let lease_id = Uuid::now_v7();
71 let claimed = db
72 .query_all_raw(Statement::from_sql_and_values(
73 DbBackend::Postgres,
74 CLAIM,
75 [BATCH_SIZE.into(), lease_id.into()],
76 ))
77 .await?;
78
79 let mut pending = Vec::with_capacity(claimed.len());
80 for row in claimed {
81 pending.push(PendingEvent {
82 id: row.try_get("", "id")?,
83 kind: row.try_get("", "kind")?,
84 envelope: row.try_get("", "envelope")?,
85 attempts: row.try_get("", "attempts")?,
86 });
87 }
88
89 let count = pending.len();
90 for row in pending {
91 publish(db, publisher, lease_id, row).await?;
92 }
93 Ok(count)
94}
95
96async fn publish(
97 db: &DatabaseConnection,
98 publisher: &Publisher,
99 lease_id: Uuid,
100 row: PendingEvent,
101) -> Result<(), DbErr> {
102 let result = tokio::time::timeout(PUBLISH_TIMEOUT, publish_body(publisher, &row))
103 .await
104 .unwrap_or_else(|_| Err(QueueError::publish("outbox publish timed out")));
105
106 let statement = match result {
107 Ok(()) => Statement::from_sql_and_values(
108 DbBackend::Postgres,
109 "UPDATE event_outbox SET published_at = CURRENT_TIMESTAMP, lease_id = NULL, leased_until = NULL WHERE id = $1 AND lease_id = $2 AND published_at IS NULL",
110 [row.id.into(), lease_id.into()],
111 ),
112 Err(error) => {
113 if row.attempts >= MAX_PUBLISH_ATTEMPTS {
114 tracing::error!(
115 event_id = %row.id,
116 kind = %row.kind,
117 attempt = row.attempts,
118 %error,
119 "outbox event exceeded the retry budget and was discarded"
120 );
121 Statement::from_sql_and_values(
122 DbBackend::Postgres,
123 "UPDATE event_outbox SET published_at = CURRENT_TIMESTAMP, lease_id = NULL, leased_until = NULL WHERE id = $1 AND lease_id = $2 AND published_at IS NULL",
124 [row.id.into(), lease_id.into()],
125 )
126 } else {
127 tracing::warn!(
128 event_id = %row.id,
129 kind = %row.kind,
130 attempt = row.attempts,
131 %error,
132 "outbox publish will retry"
133 );
134 let delay = retry_delay(row.attempts);
135 Statement::from_sql_and_values(
136 DbBackend::Postgres,
137 "UPDATE event_outbox SET available_at = CURRENT_TIMESTAMP + ($3 * INTERVAL '1 second'), lease_id = NULL, leased_until = NULL WHERE id = $1 AND lease_id = $2 AND published_at IS NULL",
138 [row.id.into(), lease_id.into(), delay.into()],
139 )
140 }
141 }
142 };
143
144 let updated = db.execute_raw(statement).await?;
145 if updated.rows_affected() == 0 {
146 tracing::warn!(event_id = %row.id, "outbox lease was reclaimed by another relay");
147 }
148 Ok(())
149}
150
151async fn publish_body(publisher: &Publisher, row: &PendingEvent) -> Result<(), QueueError> {
152 let envelope: Envelope<Value> = serde_json::from_value(row.envelope.clone())?;
153 if envelope.id != row.id || envelope.kind != row.kind {
154 return Err(QueueError::payload("outbox event identity is invalid"));
155 }
156 let route = route::route(&envelope.kind)?;
157 let topic = publisher.topic(&route.area);
158 let body = envelope.summary_body(&route.tag)?;
159 let properties = envelope.properties();
160 publisher
161 .publish(
162 &topic,
163 &route.tag,
164 vec![row.id.to_string()],
165 properties,
166 body,
167 )
168 .await
169}
170
171fn retry_delay(attempts: i32) -> f64 {
172 f64::from(2_u32.pow(attempts.clamp(1, 8) as u32))
173}
174
175async fn purge_published(db: &DatabaseConnection) {
176 match db
177 .execute_unprepared(&format!(
178 "DELETE FROM event_outbox WHERE published_at < CURRENT_TIMESTAMP - INTERVAL '{PURGED_RETENTION_SECS} seconds'"
179 ))
180 .await
181 {
182 Ok(result) if result.rows_affected() > 0 => {
183 tracing::info!(
184 rows = result.rows_affected(),
185 "purged published outbox events"
186 );
187 }
188 Ok(_) => {}
189 Err(error) => tracing::warn!(%error, "outbox purge failed"),
190 }
191}
Added lib/queue/src/route.rs lib/queue/src/route.rs +23 −0 View lib/queue/src/route.rs
@@ -0,0 +1,23 @@
1use crate::error::{QueueError, QueueResult};
2
3pub struct Route {
4 pub area: String,
5 pub tag: String,
6}
7
8pub fn route(kind: &str) -> QueueResult<Route> {
9 let mut parts = kind.split('.');
10 let domain = parts.next().unwrap_or_default();
11 let name = parts.next().unwrap_or_default();
12 let operate = parts.next().unwrap_or_default();
13 if domain.is_empty() || name.is_empty() || operate.is_empty() || parts.next().is_some() {
14 return Err(QueueError::payload(format!(
15 "event kind `{kind}` must use `domain.name.operate`"
16 )));
17 }
18
19 Ok(Route {
20 area: domain.to_string(),
21 tag: format!("{name}-{operate}"),
22 })
23}
Added lib/queue/src/subject.rs lib/queue/src/subject.rs +19 −0 View lib/queue/src/subject.rs
@@ -0,0 +1,19 @@
1pub const SEPARATOR: char = '.';
2pub const MAX_SUBJECT_LEN: usize = 255;
3pub const MAX_TOKEN_LEN: usize = 64;
4
5fn is_token_char(c: char) -> bool {
6 !(c.is_whitespace() || c.is_control() || matches!(c, '.' | '*' | '>' | '/' | '\\' | '$'))
7}
8
9pub fn is_specific(subject: &str) -> bool {
10 !subject.is_empty()
11 && subject.len() <= MAX_SUBJECT_LEN
12 && subject.split(SEPARATOR).all(|token| {
13 !token.is_empty() && token.len() <= MAX_TOKEN_LEN && token.chars().all(is_token_char)
14 })
15}
16
17pub fn normalize(value: &str) -> String {
18 value.trim().trim_matches(SEPARATOR).to_owned()
19}
Added lib/queue/Cargo.toml lib/queue/Cargo.toml +18 −0 View lib/queue/Cargo.toml
@@ -0,0 +1,18 @@
1[package]
2name = "queue"
3version.workspace = true
4edition.workspace = true
5
6[dependencies]
7config = { workspace = true }
8sea-orm = { workspace = true }
9serde = { workspace = true, features = ["derive"] }
10serde_json = { workspace = true }
11bytes = { workspace = true }
12uuid = { workspace = true }
13time = { workspace = true }
14tokio = { workspace = true, features = ["time", "sync", "rt"] }
15tokio-util = { workspace = true }
16tracing = { workspace = true }
17rocketmq-client-rust = { workspace = true }
18rocketmq-common = { workspace = true }
Modified lib/space/src/services/invites.rs lib/space/src/services/invites.rs +1 −1 View lib/space/src/services/invites.rs
@@ -297,7 +297,7 @@
297297 }
298298 .insert(self.db().write())
299299 .await?;
300 export::notify::publish_notification(&self.state.redis, invitee, row.to_event_value())
300 export::notify::publish_notification(&self.state, invitee, row.to_event_value())
301301 .await;
302302 }
303303
Modified lib/space/src/services/join_requests.rs lib/space/src/services/join_requests.rs +1 −1 View lib/space/src/services/join_requests.rs
@@ -234,7 +234,7 @@
234234 }
235235 .insert(self.db().write())
236236 .await?;
237 export::notify::publish_notification(&self.state.redis, recipient, row.to_event_value())
237 export::notify::publish_notification(&self.state, recipient, row.to_event_value())
238238 .await;
239239 Ok(())
240240 }
Modified scripts/dev.ps1 scripts/dev.ps1 +2 −5 View scripts/dev.ps1
@@ -28,20 +28,17 @@
2828$Services = [ordered]@{
2929 migrate = @{ Exe = 'migrate'; Port = 0; Health = $false }
3030 apihost = @{ Exe = 'apihost'; Port = 4081; Health = $true }
31 im = @{ Exe = 'im'; Port = 4082; Health = $true }
3231 web = @{ Bun = 'web'; Port = 5173; Health = $false }
3332}
34$Order = @('migrate', 'apihost', 'im', 'web')
35$Backends = @('migrate', 'apihost', 'im')
33$Order = @('migrate', 'apihost', 'web')
34$Backends = @('migrate', 'apihost')
3635$Aliases = @{
3736 api = 'apihost'
38 chat = 'im'
3937 ui = 'web'
4038}
4139
4240$Urls = [ordered]@{
4341 apihost = 'http://127.0.0.1:4081 (Swagger: /api/docs)'
44 im = 'ws://127.0.0.1:4082'
4542 web = 'http://127.0.0.1:5173'
4643}
4744
Modified scripts/dev.sh scripts/dev.sh +2 −5 View scripts/dev.sh
@@ -26,8 +26,8 @@
2626DEV_HOST_KEY="$DEV_DIR/ssh_host_ed25519"
2727COMPOSE_FILE="$ROOT/docker-compose.dev.yml"
2828
29ORDER="apihost im web"
30BACKENDS="apihost im"
29ORDER="apihost web"
30BACKENDS="apihost"
3131INFRA="postgresql:5432 redis:6379 s3:9000"
3232
3333if [ -t 1 ]; then
@@ -52,7 +52,6 @@
5252port_of() {
5353 case "$1" in
5454 apihost) echo 4081 ;;
55 im) echo 4082 ;;
5655 web) echo 5173 ;;
5756 *) echo 0 ;;
5857 esac
@@ -67,7 +66,6 @@
6766alias_of() {
6867 case "$1" in
6968 api) echo apihost ;;
70 chat) echo im ;;
7169 ui) echo web ;;
7270 *) echo "$1" ;;
7371 esac
@@ -76,7 +74,6 @@
7674url_of() {
7775 case "$1" in
7876 apihost) echo "http://127.0.0.1:4081 (Swagger: /api/docs)" ;;
79 im) echo "ws://127.0.0.1:4082" ;;
8077 web) echo "http://127.0.0.1:5173" ;;
8178 esac
8279}
Modified Cargo.lock Cargo.lock +935 −68 View Cargo.lock
@@ -178,10 +178,11 @@
178178 "auth",
179179 "axum",
180180 "chat",
181 "config",
181 "config 1.0.0",
182182 "export",
183183 "jwt",
184184 "push",
185 "queue",
185186 "tokio",
186187 "tokio-util",
187188 "tracing",
@@ -228,6 +229,12 @@
228229]
229230
230231[[package]]
232name = "arraydeque"
233version = "0.5.1"
234source = "registry+https://github.com/rust-lang/crates.io-index"
235checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
236
237[[package]]
231238name = "arrayvec"
232239version = "0.7.8"
233240source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -496,7 +503,7 @@
496503 "async-trait",
497504 "axum",
498505 "base64 0.23.1",
499 "config",
506 "config 1.0.0",
500507 "export",
501508 "hkdf 0.13.0",
502509 "hmac 0.13.0",
@@ -535,10 +542,10 @@
535542checksum = "01eb8a458c003bd8fe37badba2d8a5a3839748b6ade5cc2d92c2c6b9c6d5a437"
536543dependencies = [
537544 "attohttpc",
538 "dirs",
545 "dirs 5.0.1",
539546 "log 0.4.33",
540547 "quick-xml",
541 "rust-ini",
548 "rust-ini 0.18.0",
542549 "serde",
543550 "thiserror 1.0.69",
544551 "time 0.3.55",
@@ -968,7 +975,7 @@
968975 "axum",
969976 "base64 0.23.1",
970977 "bytes",
971 "config",
978 "config 1.0.0",
972979 "encoding_rs",
973980 "export",
974981 "futures",
@@ -981,6 +988,7 @@
981988 "livekit-api",
982989 "livekit-protocol",
983990 "models",
991 "queue",
984992 "rand 0.8.7",
985993 "redis",
986994 "reqwest 0.12.28",
@@ -1001,6 +1009,17 @@
10011009]
10021010
10031011[[package]]
1012name = "cheetah-string"
1013version = "1.2.0"
1014source = "registry+https://github.com/rust-lang/crates.io-index"
1015checksum = "a0e88622a110f2e13dd967659b323da9481172a1092d8b1c9a45194f2a36b758"
1016dependencies = [
1017 "bytes",
1018 "memchr",
1019 "serde",
1020]
1021
1022[[package]]
10041023name = "chrono"
10051024version = "0.4.45"
10061025source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1015,6 +1034,16 @@
10151034]
10161035
10171036[[package]]
1037name = "chrono-tz"
1038version = "0.10.4"
1039source = "registry+https://github.com/rust-lang/crates.io-index"
1040checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
1041dependencies = [
1042 "chrono",
1043 "phf 0.12.1",
1044]
1045
1046[[package]]
10181047name = "cipher"
10191048version = "0.4.4"
10201049source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1110,6 +1139,26 @@
11101139
11111140[[package]]
11121141name = "config"
1142version = "0.15.27"
1143source = "registry+https://github.com/rust-lang/crates.io-index"
1144checksum = "38e9fbc53afcec54441422f1c596cd054655b32e70e1cbac956651b7b175a404"
1145dependencies = [
1146 "async-trait",
1147 "convert_case",
1148 "json5",
1149 "pathdiff",
1150 "ron",
1151 "rust-ini 0.21.3",
1152 "serde-untagged",
1153 "serde_core",
1154 "serde_json",
1155 "toml 1.1.8+spec-1.1.0",
1156 "winnow 1.0.4",
1157 "yaml-rust2",
1158]
1159
1160[[package]]
1161name = "config"
11131162version = "1.0.0"
11141163dependencies = [
11151164 "base64 0.23.1",
@@ -1161,6 +1210,25 @@
11611210checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
11621211
11631212[[package]]
1213name = "convert_case"
1214version = "0.6.0"
1215source = "registry+https://github.com/rust-lang/crates.io-index"
1216checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca"
1217dependencies = [
1218 "unicode-segmentation",
1219]
1220
1221[[package]]
1222name = "core-foundation"
1223version = "0.9.4"
1224source = "registry+https://github.com/rust-lang/crates.io-index"
1225checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
1226dependencies = [
1227 "core-foundation-sys",
1228 "libc",
1229]
1230
1231[[package]]
11641232name = "core-foundation"
11651233version = "0.10.1"
11661234source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1231,6 +1299,36 @@
12311299]
12321300
12331301[[package]]
1302name = "cron"
1303version = "0.16.0"
1304source = "registry+https://github.com/rust-lang/crates.io-index"
1305checksum = "089df96cf6a25253b4b6b6744d86f91150a3d4df546f31a95def47976b8cba97"
1306dependencies = [
1307 "chrono",
1308 "once_cell",
1309 "phf 0.11.3",
1310 "winnow 0.7.15",
1311]
1312
1313[[package]]
1314name = "crossbeam-channel"
1315version = "0.5.17"
1316source = "registry+https://github.com/rust-lang/crates.io-index"
1317checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1"
1318dependencies = [
1319 "crossbeam-utils",
1320]
1321
1322[[package]]
1323name = "crossbeam-epoch"
1324version = "0.9.21"
1325source = "registry+https://github.com/rust-lang/crates.io-index"
1326checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
1327dependencies = [
1328 "crossbeam-utils",
1329]
1330
1331[[package]]
12341332name = "crossbeam-queue"
12351333version = "0.3.13"
12361334source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1338,6 +1436,7 @@
13381436 "ident_case",
13391437 "proc-macro2",
13401438 "quote",
1439 "strsim",
13411440 "syn 2.0.119",
13421441]
13431442
@@ -1353,6 +1452,21 @@
13531452]
13541453
13551454[[package]]
1455name = "dashmap"
1456version = "6.2.1"
1457source = "registry+https://github.com/rust-lang/crates.io-index"
1458checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
1459dependencies = [
1460 "cfg-if",
1461 "crossbeam-utils",
1462 "hashbrown 0.14.5",
1463 "lock_api",
1464 "once_cell",
1465 "parking_lot_core",
1466 "serde",
1467]
1468
1469[[package]]
13561470name = "data-encoding"
13571471version = "2.11.1"
13581472source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1411,6 +1525,37 @@
14111525]
14121526
14131527[[package]]
1528name = "derive_builder"
1529version = "0.20.2"
1530source = "registry+https://github.com/rust-lang/crates.io-index"
1531checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
1532dependencies = [
1533 "derive_builder_macro",
1534]
1535
1536[[package]]
1537name = "derive_builder_core"
1538version = "0.20.2"
1539source = "registry+https://github.com/rust-lang/crates.io-index"
1540checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
1541dependencies = [
1542 "darling",
1543 "proc-macro2",
1544 "quote",
1545 "syn 2.0.119",
1546]
1547
1548[[package]]
1549name = "derive_builder_macro"
1550version = "0.20.2"
1551source = "registry+https://github.com/rust-lang/crates.io-index"
1552checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
1553dependencies = [
1554 "derive_builder_core",
1555 "syn 2.0.119",
1556]
1557
1558[[package]]
14141559name = "derive_more"
14151560version = "2.1.1"
14161561source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1438,7 +1583,7 @@
14381583source = "registry+https://github.com/rust-lang/crates.io-index"
14391584checksum = "a2ca8e71544c1b67dcdbc2699ab258828aff985e5bc8d5f6b486d90d7df2f848"
14401585dependencies = [
1441 "core-foundation",
1586 "core-foundation 0.10.1",
14421587 "jni",
14431588 "libc",
14441589 "thiserror 2.0.20",
@@ -1477,7 +1622,16 @@
14771622source = "registry+https://github.com/rust-lang/crates.io-index"
14781623checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
14791624dependencies = [
1480 "dirs-sys",
1625 "dirs-sys 0.4.1",
1626]
1627
1628[[package]]
1629name = "dirs"
1630version = "6.0.0"
1631source = "registry+https://github.com/rust-lang/crates.io-index"
1632checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
1633dependencies = [
1634 "dirs-sys 0.5.0",
14811635]
14821636
14831637[[package]]
@@ -1488,11 +1642,23 @@
14881642dependencies = [
14891643 "libc",
14901644 "option-ext",
1491 "redox_users",
1645 "redox_users 0.4.6",
14921646 "windows-sys 0.48.0",
14931647]
14941648
14951649[[package]]
1650name = "dirs-sys"
1651version = "0.5.0"
1652source = "registry+https://github.com/rust-lang/crates.io-index"
1653checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
1654dependencies = [
1655 "libc",
1656 "option-ext",
1657 "redox_users 0.5.3",
1658 "windows-sys 0.61.2",
1659]
1660
1661[[package]]
14961662name = "dispatch2"
14971663version = "0.3.1"
14981664source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1520,6 +1686,15 @@
15201686checksum = "0688c2a7f92e427f44895cd63841bff7b29f8d7a1648b9e7e07a4a365b2e1257"
15211687
15221688[[package]]
1689name = "dlv-list"
1690version = "0.5.2"
1691source = "registry+https://github.com/rust-lang/crates.io-index"
1692checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f"
1693dependencies = [
1694 "const-random",
1695]
1696
1697[[package]]
15231698name = "dotenvy"
15241699version = "0.15.7"
15251700source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1612,6 +1787,17 @@
16121787checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
16131788
16141789[[package]]
1790name = "erased-serde"
1791version = "0.4.11"
1792source = "registry+https://github.com/rust-lang/crates.io-index"
1793checksum = "1e59b668157f32d3459d4bd634861a06ffa2503a461ca5f0916b9f0252605e66"
1794dependencies = [
1795 "serde",
1796 "serde_core",
1797 "typeid",
1798]
1799
1800[[package]]
16151801name = "errno"
16161802version = "0.3.14"
16171803source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1667,14 +1853,16 @@
16671853 "axum",
16681854 "axum-otel-metrics",
16691855 "bytes",
1670 "config",
1856 "config 1.0.0",
16711857 "http-body-util",
16721858 "imaging",
16731859 "kenauth",
1860 "models",
16741861 "opentelemetry",
16751862 "opentelemetry-http",
16761863 "opentelemetry-otlp",
16771864 "opentelemetry_sdk",
1865 "queue",
16781866 "redis",
16791867 "reqwest 0.12.28",
16801868 "sea-orm",
@@ -1699,6 +1887,9 @@
16991887version = "2.5.0"
17001888source = "registry+https://github.com/rust-lang/crates.io-index"
17011889checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
1890dependencies = [
1891 "getrandom 0.4.3",
1892]
17021893
17031894[[package]]
17041895name = "fdeflate"
@@ -1734,7 +1925,7 @@
17341925 "atomic",
17351926 "pear",
17361927 "serde",
1737 "toml",
1928 "toml 0.8.23",
17381929 "uncased",
17391930 "version_check 0.9.5",
17401931]
@@ -1758,6 +1949,7 @@
17581949checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
17591950dependencies = [
17601951 "crc32fast",
1952 "libz-sys",
17611953 "miniz_oxide",
17621954 "zlib-rs",
17631955]
@@ -1768,6 +1960,7 @@
17681960source = "registry+https://github.com/rust-lang/crates.io-index"
17691961checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be"
17701962dependencies = [
1963 "fastrand",
17711964 "futures-core",
17721965 "futures-sink",
17731966 "spin",
@@ -1980,6 +2173,17 @@
19802173]
19812174
19822175[[package]]
2176name = "getset"
2177version = "0.1.7"
2178source = "registry+https://github.com/rust-lang/crates.io-index"
2179checksum = "6cf442baaabe4213ce7d1239afc26c039180b6456da2cededa316ae2c8a77a77"
2180dependencies = [
2181 "proc-macro2",
2182 "quote",
2183 "syn 2.0.119",
2184]
2185
2186[[package]]
19832187name = "ghash"
19842188version = "0.5.1"
19852189source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2017,7 +2221,7 @@
20172221 "base64 0.23.1",
20182222 "bytes",
20192223 "cache",
2020 "config",
2224 "config 1.0.0",
20212225 "export",
20222226 "flate2",
20232227 "futures",
@@ -2157,6 +2361,18 @@
21572361
21582362[[package]]
21592363name = "hashbrown"
2364version = "0.14.5"
2365source = "registry+https://github.com/rust-lang/crates.io-index"
2366checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
2367
2368[[package]]
2369name = "hashbrown"
2370version = "0.15.5"
2371source = "registry+https://github.com/rust-lang/crates.io-index"
2372checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
2373
2374[[package]]
2375name = "hashbrown"
21602376version = "0.16.1"
21612377source = "registry+https://github.com/rust-lang/crates.io-index"
21622378checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
@@ -2225,7 +2441,7 @@
22252441 "hayro-syntax",
22262442 "kurbo",
22272443 "moxcms",
2228 "phf",
2444 "phf 0.13.1",
22292445 "rustc-hash",
22302446 "siphasher",
22312447 "skrifa",
@@ -2359,6 +2575,17 @@
23592575]
23602576
23612577[[package]]
2578name = "hostname"
2579version = "0.4.2"
2580source = "registry+https://github.com/rust-lang/crates.io-index"
2581checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd"
2582dependencies = [
2583 "cfg-if",
2584 "libc",
2585 "windows-link",
2586]
2587
2588[[package]]
23622589name = "http"
23632590version = "0.2.12"
23642591source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2571,9 +2798,11 @@
25712798 "percent-encoding 2.3.2",
25722799 "pin-project-lite",
25732800 "socket2 0.6.5",
2801 "system-configuration",
25742802 "tokio",
25752803 "tower-service",
25762804 "tracing",
2805 "windows-registry",
25772806]
25782807
25792808[[package]]
@@ -2728,33 +2957,6 @@
27282957dependencies = [
27292958 "icu_normalizer",
27302959 "icu_properties",
2731]
2732
2733[[package]]
2734name = "im"
2735version = "1.0.0"
2736dependencies = [
2737 "anyhow",
2738 "bytes",
2739 "chat",
2740 "config",
2741 "export",
2742 "futures",
2743 "http 1.5.0",
2744 "http-body-util",
2745 "hyper 1.11.0",
2746 "hyper-util",
2747 "jwt",
2748 "redis",
2749 "sea-orm",
2750 "serde",
2751 "serde_json",
2752 "time 0.3.55",
2753 "tokio",
2754 "tokio-tungstenite",
2755 "tokio-util",
2756 "tracing",
2757 "uuid",
27582960]
27592961
27602962[[package]]
@@ -2847,18 +3049,25 @@
28473049checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78"
28483050
28493051[[package]]
3052name = "ipnetwork"
3053version = "0.21.1"
3054source = "registry+https://github.com/rust-lang/crates.io-index"
3055checksum = "cf370abdafd54d13e54a620e8c3e1145f28e46cc9d704bc6d94414559df41763"
3056
3057[[package]]
28503058name = "issues"
28513059version = "1.0.0"
28523060dependencies = [
28533061 "async-trait",
28543062 "axum",
2855 "config",
3063 "config 1.0.0",
28563064 "export",
28573065 "http 0.2.12",
28583066 "i18n",
28593067 "jwt",
28603068 "kenauth",
28613069 "models",
3070 "queue",
28623071 "sea-orm",
28633072 "serde",
28643073 "serde_json",
@@ -2968,6 +3177,16 @@
29683177]
29693178
29703179[[package]]
3180name = "json5"
3181version = "1.3.6"
3182source = "registry+https://github.com/rust-lang/crates.io-index"
3183checksum = "e46128c9fb848c29097da3149e627cce5496d0b2820a9be06d12ee266c09c749"
3184dependencies = [
3185 "serde",
3186 "ucd-trie",
3187]
3188
3189[[package]]
29713190name = "jsonwebtoken"
29723191version = "9.3.1"
29733192source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3003,7 +3222,7 @@
30033222dependencies = [
30043223 "axum",
30053224 "base64 0.23.1",
3006 "config",
3225 "config 1.0.0",
30073226 "jsonwebtoken 9.3.1",
30083227 "reqwest 0.12.28",
30093228 "serde",
@@ -3068,7 +3287,7 @@
30683287name = "kenauth"
30693288version = "1.0.0"
30703289dependencies = [
3071 "config",
3290 "config 1.0.0",
30723291 "prost 0.14.4",
30733292 "prost-types 0.14.1",
30743293 "thiserror 2.0.20",
@@ -3212,6 +3431,17 @@
32123431]
32133432
32143433[[package]]
3434name = "libz-sys"
3435version = "1.1.31"
3436source = "registry+https://github.com/rust-lang/crates.io-index"
3437checksum = "ff20308ecabd635a2dc1dd5834a86c462cca20efba1d5d3ddab1f8aea3455cb2"
3438dependencies = [
3439 "cc",
3440 "pkg-config",
3441 "vcpkg",
3442]
3443
3444[[package]]
32153445name = "linebender_resource_handle"
32163446version = "0.1.1"
32173447source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3338,6 +3568,17 @@
33383568]
33393569
33403570[[package]]
3571name = "local-ip-address"
3572version = "0.6.13"
3573source = "registry+https://github.com/rust-lang/crates.io-index"
3574checksum = "aa08fb2b1ec3ea84575e94b489d06d4ce0cbf052d12acd515838f50e3c3d63e3"
3575dependencies = [
3576 "libc",
3577 "neli",
3578 "windows-sys 0.61.2",
3579]
3580
3581[[package]]
33413582name = "lock_api"
33423583version = "0.4.14"
33433584source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3368,6 +3609,12 @@
33683609checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
33693610
33703611[[package]]
3612name = "lz4_flex"
3613version = "0.13.1"
3614source = "registry+https://github.com/rust-lang/crates.io-index"
3615checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e"
3616
3617[[package]]
33713618name = "mac_address"
33723619version = "1.1.8"
33733620source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3425,6 +3672,16 @@
34253672
34263673[[package]]
34273674name = "md-5"
3675version = "0.10.6"
3676source = "registry+https://github.com/rust-lang/crates.io-index"
3677checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
3678dependencies = [
3679 "cfg-if",
3680 "digest 0.10.7",
3681]
3682
3683[[package]]
3684name = "md-5"
34283685version = "0.11.0"
34293686source = "registry+https://github.com/rust-lang/crates.io-index"
34303687checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98"
@@ -3459,7 +3716,7 @@
34593716version = "1.0.0"
34603717dependencies = [
34613718 "anyhow",
3462 "config",
3719 "config 1.0.0",
34633720 "models",
34643721 "sea-orm",
34653722 "tokio",
@@ -3563,6 +3820,23 @@
35633820]
35643821
35653822[[package]]
3823name = "moka"
3824version = "0.12.16"
3825source = "registry+https://github.com/rust-lang/crates.io-index"
3826checksum = "4293f18e7567a1caf3c584855554377025c65e0aa445344d04171f5ad63d19b9"
3827dependencies = [
3828 "crossbeam-channel",
3829 "crossbeam-epoch",
3830 "crossbeam-utils",
3831 "equivalent",
3832 "parking_lot",
3833 "portable-atomic",
3834 "smallvec",
3835 "tagptr",
3836 "uuid",
3837]
3838
3839[[package]]
35663840name = "moxcms"
35673841version = "0.8.1"
35683842source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3596,6 +3870,36 @@
35963870]
35973871
35983872[[package]]
3873name = "neli"
3874version = "0.7.5"
3875source = "registry+https://github.com/rust-lang/crates.io-index"
3876checksum = "6b152f4f18819e638ea350a740b16de75099302b5ec3a58f4f8766ef2fcf695d"
3877dependencies = [
3878 "bitflags 2.13.1",
3879 "byteorder",
3880 "derive_builder",
3881 "getset",
3882 "libc",
3883 "linux-raw-sys",
3884 "log 0.4.33",
3885 "neli-proc-macros",
3886 "parking_lot",
3887]
3888
3889[[package]]
3890name = "neli-proc-macros"
3891version = "0.2.2"
3892source = "registry+https://github.com/rust-lang/crates.io-index"
3893checksum = "05d8d08c6e98f20a62417478ebf7be8e1425ec9acecc6f63e22da633f6b71609"
3894dependencies = [
3895 "either",
3896 "proc-macro2",
3897 "quote",
3898 "serde",
3899 "syn 2.0.119",
3900]
3901
3902[[package]]
35993903name = "nix"
36003904version = "0.29.0"
36013905source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4040,13 +4344,23 @@
40404344source = "registry+https://github.com/rust-lang/crates.io-index"
40414345checksum = "ccd746e37177e1711c20dd619a1620f34f5c8b569c53590a72dedd5344d8924a"
40424346dependencies = [
4043 "dlv-list",
4347 "dlv-list 0.3.0",
40444348 "hashbrown 0.12.3",
40454349]
40464350
40474351[[package]]
4048name = "os_info"
4049version = "3.15.0"
4352name = "ordered-multimap"
4353version = "0.7.3"
4354source = "registry+https://github.com/rust-lang/crates.io-index"
4355checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79"
4356dependencies = [
4357 "dlv-list 0.5.2",
4358 "hashbrown 0.14.5",
4359]
4360
4361[[package]]
4362name = "os_info"
4363version = "3.15.0"
40504364source = "registry+https://github.com/rust-lang/crates.io-index"
40514365checksum = "9cf20a545b305cf1da722b236b5155c9bb35f1d5ceb28c048bd96ca842f41b5b"
40524366dependencies = [
@@ -4148,6 +4462,12 @@
41484462]
41494463
41504464[[package]]
4465name = "pathdiff"
4466version = "0.2.3"
4467source = "registry+https://github.com/rust-lang/crates.io-index"
4468checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
4469
4470[[package]]
41514471name = "pbjson"
41524472version = "0.6.0"
41534473source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4283,12 +4603,41 @@
42834603
42844604[[package]]
42854605name = "phf"
4606version = "0.11.3"
4607source = "registry+https://github.com/rust-lang/crates.io-index"
4608checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
4609dependencies = [
4610 "phf_macros 0.11.3",
4611 "phf_shared 0.11.3",
4612]
4613
4614[[package]]
4615name = "phf"
4616version = "0.12.1"
4617source = "registry+https://github.com/rust-lang/crates.io-index"
4618checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"
4619dependencies = [
4620 "phf_shared 0.12.1",
4621]
4622
4623[[package]]
4624name = "phf"
42864625version = "0.13.1"
42874626source = "registry+https://github.com/rust-lang/crates.io-index"
42884627checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
42894628dependencies = [
4290 "phf_macros",
4291 "phf_shared",
4629 "phf_macros 0.13.1",
4630 "phf_shared 0.13.1",
4631]
4632
4633[[package]]
4634name = "phf_generator"
4635version = "0.11.3"
4636source = "registry+https://github.com/rust-lang/crates.io-index"
4637checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
4638dependencies = [
4639 "phf_shared 0.11.3",
4640 "rand 0.8.7",
42924641]
42934642
42944643[[package]]
@@ -4298,7 +4647,20 @@
42984647checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
42994648dependencies = [
43004649 "fastrand",
4301 "phf_shared",
4650 "phf_shared 0.13.1",
4651]
4652
4653[[package]]
4654name = "phf_macros"
4655version = "0.11.3"
4656source = "registry+https://github.com/rust-lang/crates.io-index"
4657checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
4658dependencies = [
4659 "phf_generator 0.11.3",
4660 "phf_shared 0.11.3",
4661 "proc-macro2",
4662 "quote",
4663 "syn 2.0.119",
43024664]
43034665
43044666[[package]]
@@ -4307,8 +4669,8 @@
43074669source = "registry+https://github.com/rust-lang/crates.io-index"
43084670checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
43094671dependencies = [
4310 "phf_generator",
4311 "phf_shared",
4672 "phf_generator 0.13.1",
4673 "phf_shared 0.13.1",
43124674 "proc-macro2",
43134675 "quote",
43144676 "syn 2.0.119",
@@ -4316,6 +4678,24 @@
43164678
43174679[[package]]
43184680name = "phf_shared"
4681version = "0.11.3"
4682source = "registry+https://github.com/rust-lang/crates.io-index"
4683checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
4684dependencies = [
4685 "siphasher",
4686]
4687
4688[[package]]
4689name = "phf_shared"
4690version = "0.12.1"
4691source = "registry+https://github.com/rust-lang/crates.io-index"
4692checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
4693dependencies = [
4694 "siphasher",
4695]
4696
4697[[package]]
4698name = "phf_shared"
43194699version = "0.13.1"
43204700source = "registry+https://github.com/rust-lang/crates.io-index"
43214701checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
@@ -4703,13 +5083,14 @@
47035083version = "1.0.0"
47045084dependencies = [
47055085 "axum",
4706 "config",
5086 "config 1.0.0",
47075087 "export",
47085088 "git",
47095089 "globset",
47105090 "issues",
47115091 "jwt",
47125092 "models",
5093 "queue",
47135094 "sea-orm",
47145095 "serde",
47155096 "serde_json",
@@ -4725,7 +5106,7 @@
47255106version = "1.0.0"
47265107dependencies = [
47275108 "base64 0.23.1",
4728 "config",
5109 "config 1.0.0",
47295110 "export",
47305111 "http 1.5.0",
47315112 "models",
@@ -4747,6 +5128,24 @@
47475128checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
47485129
47495130[[package]]
5131name = "queue"
5132version = "1.0.0"
5133dependencies = [
5134 "bytes",
5135 "config 1.0.0",
5136 "rocketmq-client-rust",
5137 "rocketmq-common",
5138 "sea-orm",
5139 "serde",
5140 "serde_json",
5141 "time 0.3.55",
5142 "tokio",
5143 "tokio-util",
5144 "tracing",
5145 "uuid",
5146]
5147
5148[[package]]
47505149name = "quick-error"
47515150version = "2.0.1"
47525151source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4932,6 +5331,19 @@
49325331]
49335332
49345333[[package]]
5334name = "rcgen"
5335version = "0.13.2"
5336source = "registry+https://github.com/rust-lang/crates.io-index"
5337checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2"
5338dependencies = [
5339 "pem",
5340 "ring",
5341 "rustls-pki-types",
5342 "time 0.3.55",
5343 "yasna",
5344]
5345
5346[[package]]
49355347name = "read-fonts"
49365348version = "0.39.2"
49375349source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4993,6 +5405,16 @@
49935405]
49945406
49955407[[package]]
5408name = "redox_users"
5409version = "0.5.3"
5410source = "registry+https://github.com/rust-lang/crates.io-index"
5411checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0"
5412dependencies = [
5413 "libredox",
5414 "thiserror 2.0.20",
5415]
5416
5417[[package]]
49965418name = "regex"
49975419version = "1.13.1"
49985420source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5076,9 +5498,11 @@
50765498dependencies = [
50775499 "base64 0.22.1",
50785500 "bytes",
5501 "encoding_rs",
50795502 "futures-channel",
50805503 "futures-core",
50815504 "futures-util",
5505 "h2 0.4.19",
50825506 "http 1.5.0",
50835507 "http-body 1.1.0",
50845508 "http-body-util",
@@ -5087,12 +5511,15 @@
50875511 "hyper-util",
50885512 "js-sys",
50895513 "log 0.4.33",
5514 "mime 0.3.17",
50905515 "percent-encoding 2.3.2",
50915516 "pin-project-lite",
50925517 "quinn",
50935518 "rustls",
50945519 "rustls-pki-types",
50955520 "rustls-platform-verifier",
5521 "serde",
5522 "serde_json",
50965523 "sync_wrapper",
50975524 "tokio",
50985525 "tokio-rustls",
@@ -5159,6 +5586,208 @@
51595586]
51605587
51615588[[package]]
5589name = "rocketmq-auth"
5590version = "0.9.0"
5591source = "registry+https://github.com/rust-lang/crates.io-index"
5592checksum = "4e0584102ce3b4f0286e6e1f80466841f6e55365a22f5cc5232370a9dbb112ca"
5593dependencies = [
5594 "base64 0.22.1",
5595 "cheetah-string",
5596 "chrono",
5597 "hex",
5598 "hmac 0.13.0",
5599 "ipnetwork",
5600 "md-5 0.11.0",
5601 "moka",
5602 "rocketmq-common",
5603 "rocketmq-error",
5604 "rocketmq-remoting",
5605 "serde",
5606 "serde_json",
5607 "serde_yaml",
5608 "sha1 0.11.0",
5609 "sha2 0.11.0",
5610 "thiserror 2.0.20",
5611 "tokio",
5612 "tracing",
5613]
5614
5615[[package]]
5616name = "rocketmq-client-rust"
5617version = "0.9.0"
5618source = "registry+https://github.com/rust-lang/crates.io-index"
5619checksum = "48fb082c2b5c1f32ee0156b21a633b82630c6ecb0a4cb755b7897a1a735ff9d6"
5620dependencies = [
5621 "bytes",
5622 "cheetah-string",
5623 "dashmap",
5624 "futures",
5625 "num_cpus",
5626 "parking_lot",
5627 "rand 0.10.2",
5628 "rocketmq-auth",
5629 "rocketmq-common",
5630 "rocketmq-error",
5631 "rocketmq-remoting",
5632 "rocketmq-runtime",
5633 "rocketmq-rust",
5634 "serde",
5635 "serde_json",
5636 "smallvec",
5637 "tokio",
5638 "tokio-util",
5639 "tracing",
5640 "trait-variant",
5641]
5642
5643[[package]]
5644name = "rocketmq-common"
5645version = "0.9.0"
5646source = "registry+https://github.com/rust-lang/crates.io-index"
5647checksum = "d7f82de53d643f6c88f11fb4e5803063e20de7a9aa4eb5b9fee2a3963a4f5a2c"
5648dependencies = [
5649 "anyhow",
5650 "base64 0.22.1",
5651 "byteorder",
5652 "bytes",
5653 "cheetah-string",
5654 "chrono",
5655 "chrono-tz",
5656 "config 0.15.27",
5657 "crc",
5658 "dashmap",
5659 "dirs 6.0.0",
5660 "flate2",
5661 "form_urlencoded",
5662 "futures",
5663 "hostname",
5664 "iana-time-zone",
5665 "local-ip-address",
5666 "lz4_flex",
5667 "md-5 0.10.6",
5668 "num_cpus",
5669 "opentelemetry",
5670 "parking_lot",
5671 "regex",
5672 "reqwest 0.13.4",
5673 "rocketmq-error",
5674 "rocketmq-rust",
5675 "serde",
5676 "serde_json",
5677 "tempfile",
5678 "thiserror 2.0.20",
5679 "time 0.3.55",
5680 "tokio",
5681 "tracing",
5682 "tracing-appender",
5683 "tracing-subscriber",
5684 "trait-variant",
5685 "uuid",
5686 "zstd",
5687]
5688
5689[[package]]
5690name = "rocketmq-error"
5691version = "0.9.0"
5692source = "registry+https://github.com/rust-lang/crates.io-index"
5693checksum = "38fade7f274ff354923e0d453e53e61f1fcf7271dfdf7bdee86760579c561711"
5694dependencies = [
5695 "anyhow",
5696 "config 0.15.27",
5697 "serde_json",
5698 "thiserror 2.0.20",
5699]
5700
5701[[package]]
5702name = "rocketmq-macros"
5703version = "0.9.0"
5704source = "registry+https://github.com/rust-lang/crates.io-index"
5705checksum = "74f9ab7ab4f2096ed8ab9f15be489c9be963ada4fffc0700222a61a9777f250a"
5706dependencies = [
5707 "proc-macro2",
5708 "quote",
5709 "syn 2.0.119",
5710]
5711
5712[[package]]
5713name = "rocketmq-remoting"
5714version = "0.9.0"
5715source = "registry+https://github.com/rust-lang/crates.io-index"
5716checksum = "3b609e95e018894c42c90654ea8d9b9be20d2566760ecbfa54b132c64a43bcb9"
5717dependencies = [
5718 "anyhow",
5719 "arc-swap",
5720 "bitvec",
5721 "bytemuck",
5722 "bytes",
5723 "cheetah-string",
5724 "dashmap",
5725 "flate2",
5726 "flume",
5727 "futures",
5728 "futures-util",
5729 "num_cpus",
5730 "parking_lot",
5731 "rand 0.10.2",
5732 "rcgen",
5733 "rocketmq-common",
5734 "rocketmq-error",
5735 "rocketmq-macros",
5736 "rocketmq-rust",
5737 "rustls-native-certs",
5738 "rustls-pemfile",
5739 "serde",
5740 "serde_json",
5741 "serde_json_any_key",
5742 "tokio",
5743 "tokio-rustls",
5744 "tokio-util",
5745 "tracing",
5746 "trait-variant",
5747 "uuid",
5748]
5749
5750[[package]]
5751name = "rocketmq-runtime"
5752version = "0.9.0"
5753source = "registry+https://github.com/rust-lang/crates.io-index"
5754checksum = "77fc99d6c519fbc6398571920810cda636d134386232d2ec721dd720760d0b6a"
5755dependencies = [
5756 "tokio",
5757]
5758
5759[[package]]
5760name = "rocketmq-rust"
5761version = "0.9.0"
5762source = "registry+https://github.com/rust-lang/crates.io-index"
5763checksum = "b610d847664dabce9ac7c41999f39573532c460af2eda3cdc517c8d09ca96c52"
5764dependencies = [
5765 "anyhow",
5766 "chrono",
5767 "cron",
5768 "parking_lot",
5769 "rocketmq-error",
5770 "serde",
5771 "tokio",
5772 "tokio-util",
5773 "tracing",
5774 "uuid",
5775]
5776
5777[[package]]
5778name = "ron"
5779version = "0.12.3"
5780source = "registry+https://github.com/rust-lang/crates.io-index"
5781checksum = "97b3fe97c18ff24299ea56f802c5693eb34266fb0bc8317f4e101debc8a3ceaf"
5782dependencies = [
5783 "bitflags 2.13.1",
5784 "serde",
5785 "serde_derive",
5786 "typeid",
5787 "unicode-ident",
5788]
5789
5790[[package]]
51625791name = "rsa"
51635792version = "0.9.10"
51645793source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5221,7 +5850,17 @@
52215850checksum = "f6d5f2436026b4f6e79dc829837d467cc7e9a55ee40e750d716713540715a2df"
52225851dependencies = [
52235852 "cfg-if",
5224 "ordered-multimap",
5853 "ordered-multimap 0.4.3",
5854]
5855
5856[[package]]
5857name = "rust-ini"
5858version = "0.21.3"
5859source = "registry+https://github.com/rust-lang/crates.io-index"
5860checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7"
5861dependencies = [
5862 "cfg-if",
5863 "ordered-multimap 0.7.3",
52255864]
52265865
52275866[[package]]
@@ -5312,6 +5951,7 @@
53125951checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
53135952dependencies = [
53145953 "aws-lc-rs",
5954 "log 0.4.33",
53155955 "once_cell",
53165956 "ring",
53175957 "rustls-pki-types",
@@ -5333,6 +5973,15 @@
53335973]
53345974
53355975[[package]]
5976name = "rustls-pemfile"
5977version = "2.2.0"
5978source = "registry+https://github.com/rust-lang/crates.io-index"
5979checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
5980dependencies = [
5981 "rustls-pki-types",
5982]
5983
5984[[package]]
53365985name = "rustls-pki-types"
53375986version = "1.15.1"
53385987source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5348,7 +5997,7 @@
53485997source = "registry+https://github.com/rust-lang/crates.io-index"
53495998checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
53505999dependencies = [
5351 "core-foundation",
6000 "core-foundation 0.10.1",
53526001 "core-foundation-sys",
53536002 "jni",
53546003 "log 0.4.33",
@@ -5608,7 +6257,7 @@
56086257checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
56096258dependencies = [
56106259 "bitflags 2.13.1",
5611 "core-foundation",
6260 "core-foundation 0.10.1",
56126261 "core-foundation-sys",
56136262 "libc",
56146263 "security-framework-sys",
@@ -5641,6 +6290,18 @@
56416290]
56426291
56436292[[package]]
6293name = "serde-untagged"
6294version = "0.1.9"
6295source = "registry+https://github.com/rust-lang/crates.io-index"
6296checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058"
6297dependencies = [
6298 "erased-serde",
6299 "serde",
6300 "serde_core",
6301 "typeid",
6302]
6303
6304[[package]]
56446305name = "serde_core"
56456306version = "1.0.229"
56466307source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5674,6 +6335,16 @@
56746335]
56756336
56766337[[package]]
6338name = "serde_json_any_key"
6339version = "2.1.0"
6340source = "registry+https://github.com/rust-lang/crates.io-index"
6341checksum = "b82e4b1d3a2cb67817b8c403eac7b98225c2e1c855a13ef9b80b9bac63a06c20"
6342dependencies = [
6343 "serde",
6344 "serde_json",
6345]
6346
6347[[package]]
56776348name = "serde_path_to_error"
56786349version = "0.1.20"
56796350source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5694,6 +6365,15 @@
56946365]
56956366
56966367[[package]]
6368name = "serde_spanned"
6369version = "1.1.2"
6370source = "registry+https://github.com/rust-lang/crates.io-index"
6371checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c"
6372dependencies = [
6373 "serde_core",
6374]
6375
6376[[package]]
56976377name = "serde_urlencoded"
56986378version = "0.7.1"
56996379source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5706,6 +6386,19 @@
57066386]
57076387
57086388[[package]]
6389name = "serde_yaml"
6390version = "0.9.34+deprecated"
6391source = "registry+https://github.com/rust-lang/crates.io-index"
6392checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
6393dependencies = [
6394 "indexmap",
6395 "itoa",
6396 "ryu",
6397 "serde",
6398 "unsafe-libyaml",
6399]
6400
6401[[package]]
57096402name = "sha1"
57106403version = "0.10.7"
57116404source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5915,7 +6608,7 @@
59156608dependencies = [
59166609 "async-trait",
59176610 "axum",
5918 "config",
6611 "config 1.0.0",
59196612 "export",
59206613 "i18n",
59216614 "imaging",
@@ -6111,7 +6804,7 @@
61116804 "hmac 0.13.0",
61126805 "itoa",
61136806 "log 0.4.33",
6114 "md-5",
6807 "md-5 0.11.0",
61156808 "memchr",
61166809 "rand 0.10.2",
61176810 "rust_decimal",
@@ -6172,7 +6865,7 @@
61726865version = "1.0.0"
61736866dependencies = [
61746867 "bytes",
6175 "config",
6868 "config 1.0.0",
61766869 "futures",
61776870 "http 0.2.12",
61786871 "rust-s3-async",
@@ -6192,6 +6885,12 @@
61926885]
61936886
61946887[[package]]
6888name = "strsim"
6889version = "0.11.1"
6890source = "registry+https://github.com/rust-lang/crates.io-index"
6891checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
6892
6893[[package]]
61956894name = "strum"
61966895version = "0.28.0"
61976896source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6234,6 +6933,12 @@
62346933]
62356934
62366935[[package]]
6936name = "symlink"
6937version = "0.1.0"
6938source = "registry+https://github.com/rust-lang/crates.io-index"
6939checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
6940
6941[[package]]
62376942name = "syn"
62386943version = "1.0.109"
62396944source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6287,6 +6992,33 @@
62876992]
62886993
62896994[[package]]
6995name = "system-configuration"
6996version = "0.7.0"
6997source = "registry+https://github.com/rust-lang/crates.io-index"
6998checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
6999dependencies = [
7000 "bitflags 2.13.1",
7001 "core-foundation 0.9.4",
7002 "system-configuration-sys",
7003]
7004
7005[[package]]
7006name = "system-configuration-sys"
7007version = "0.6.0"
7008source = "registry+https://github.com/rust-lang/crates.io-index"
7009checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
7010dependencies = [
7011 "core-foundation-sys",
7012 "libc",
7013]
7014
7015[[package]]
7016name = "tagptr"
7017version = "0.2.0"
7018source = "registry+https://github.com/rust-lang/crates.io-index"
7019checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
7020
7021[[package]]
62907022name = "tap"
62917023version = "1.0.1"
62927024source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6438,6 +7170,7 @@
64387170 "bytes",
64397171 "libc",
64407172 "mio",
7173 "parking_lot",
64417174 "pin-project-lite",
64427175 "signal-hook-registry",
64437176 "socket2 0.6.5",
@@ -6512,9 +7245,13 @@
65127245dependencies = [
65137246 "bytes",
65147247 "futures-core",
7248 "futures-io",
65157249 "futures-sink",
7250 "futures-util",
7251 "hashbrown 0.15.5",
65167252 "libc",
65177253 "pin-project-lite",
7254 "slab",
65187255 "tokio",
65197256]
65207257
@@ -6525,12 +7262,27 @@
65257262checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
65267263dependencies = [
65277264 "serde",
6528 "serde_spanned",
7265 "serde_spanned 0.6.9",
65297266 "toml_datetime 0.6.11",
65307267 "toml_edit 0.22.27",
65317268]
65327269
65337270[[package]]
7271name = "toml"
7272version = "1.1.8+spec-1.1.0"
7273source = "registry+https://github.com/rust-lang/crates.io-index"
7274checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d"
7275dependencies = [
7276 "indexmap",
7277 "serde_core",
7278 "serde_spanned 1.1.2",
7279 "toml_datetime 1.1.2+spec-1.1.0",
7280 "toml_parser",
7281 "toml_writer",
7282 "winnow 1.0.4",
7283]
7284
7285[[package]]
65347286name = "toml_datetime"
65357287version = "0.6.11"
65367288source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6541,9 +7293,9 @@
65417293
65427294[[package]]
65437295name = "toml_datetime"
6544version = "1.1.1+spec-1.1.0"
7296version = "1.1.2+spec-1.1.0"
65457297source = "registry+https://github.com/rust-lang/crates.io-index"
6546checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
7298checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053"
65477299dependencies = [
65487300 "serde_core",
65497301]
@@ -6556,7 +7308,7 @@
65567308dependencies = [
65577309 "indexmap",
65587310 "serde",
6559 "serde_spanned",
7311 "serde_spanned 0.6.9",
65607312 "toml_datetime 0.6.11",
65617313 "toml_write",
65627314 "winnow 0.7.15",
@@ -6569,16 +7321,16 @@
65697321checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b"
65707322dependencies = [
65717323 "indexmap",
6572 "toml_datetime 1.1.1+spec-1.1.0",
7324 "toml_datetime 1.1.2+spec-1.1.0",
65737325 "toml_parser",
65747326 "winnow 1.0.4",
65757327]
65767328
65777329[[package]]
65787330name = "toml_parser"
6579version = "1.1.3+spec-1.1.0"
7331version = "1.1.5+spec-1.1.0"
65807332source = "registry+https://github.com/rust-lang/crates.io-index"
6581checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
7333checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c"
65827334dependencies = [
65837335 "winnow 1.0.4",
65847336]
@@ -6590,6 +7342,12 @@
65907342checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
65917343
65927344[[package]]
7345name = "toml_writer"
7346version = "1.1.3+spec-1.1.0"
7347source = "registry+https://github.com/rust-lang/crates.io-index"
7348checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770"
7349
7350[[package]]
65937351name = "tonic"
65947352version = "0.14.5"
65957353source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6755,6 +7513,19 @@
67557513]
67567514
67577515[[package]]
7516name = "tracing-appender"
7517version = "0.2.5"
7518source = "registry+https://github.com/rust-lang/crates.io-index"
7519checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c"
7520dependencies = [
7521 "crossbeam-channel",
7522 "symlink",
7523 "thiserror 2.0.20",
7524 "time 0.3.55",
7525 "tracing-subscriber",
7526]
7527
7528[[package]]
67587529name = "tracing-attributes"
67597530version = "0.1.31"
67607531source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6815,12 +7586,24 @@
68157586 "sharded-slab",
68167587 "smallvec",
68177588 "thread_local",
7589 "time 0.3.55",
68187590 "tracing",
68197591 "tracing-core",
68207592 "tracing-log",
68217593]
68227594
68237595[[package]]
7596name = "trait-variant"
7597version = "0.1.3"
7598source = "registry+https://github.com/rust-lang/crates.io-index"
7599checksum = "b19a4867a870f6edc4c283f2b455804b1879c0baf0e642f26b03ed8ee262d9d3"
7600dependencies = [
7601 "proc-macro2",
7602 "quote",
7603 "syn 2.0.119",
7604]
7605
7606[[package]]
68247607name = "traitobject"
68257608version = "0.1.1"
68267609source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6858,12 +7641,24 @@
68587641checksum = "1410f6f91f21d1612654e7cc69193b0334f909dcf2c790c4826254fbb86f8887"
68597642
68607643[[package]]
7644name = "typeid"
7645version = "1.0.3"
7646source = "registry+https://github.com/rust-lang/crates.io-index"
7647checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
7648
7649[[package]]
68617650name = "typenum"
68627651version = "1.20.1"
68637652source = "registry+https://github.com/rust-lang/crates.io-index"
68647653checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
68657654
68667655[[package]]
7656name = "ucd-trie"
7657version = "0.1.7"
7658source = "registry+https://github.com/rust-lang/crates.io-index"
7659checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
7660
7661[[package]]
68677662name = "uncased"
68687663version = "0.9.10"
68697664source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6915,6 +7710,12 @@
69157710checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
69167711
69177712[[package]]
7713name = "unicode-segmentation"
7714version = "1.13.3"
7715source = "registry+https://github.com/rust-lang/crates.io-index"
7716checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
7717
7718[[package]]
69187719name = "unicode-xid"
69197720version = "0.2.6"
69207721source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6941,6 +7742,12 @@
69417742]
69427743
69437744[[package]]
7745name = "unsafe-libyaml"
7746version = "0.2.11"
7747source = "registry+https://github.com/rust-lang/crates.io-index"
7748checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
7749
7750[[package]]
69447751name = "untrusted"
69457752version = "0.9.0"
69467753source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6976,7 +7783,7 @@
69767783 "async-trait",
69777784 "axum",
69787785 "base64 0.23.1",
6979 "config",
7786 "config 1.0.0",
69807787 "export",
69817788 "futures",
69827789 "i18n",
@@ -7054,6 +7861,7 @@
70547861dependencies = [
70557862 "getrandom 0.4.3",
70567863 "js-sys",
7864 "rand 0.10.2",
70577865 "serde_core",
70587866 "wasm-bindgen",
70597867]
@@ -7282,7 +8090,7 @@
72828090 "auth",
72838091 "axum",
72848092 "chat",
7285 "config",
8093 "config 1.0.0",
72868094 "export",
72878095 "git",
72888096 "issues",
@@ -7415,6 +8223,17 @@
74158223checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
74168224
74178225[[package]]
8226name = "windows-registry"
8227version = "0.6.1"
8228source = "registry+https://github.com/rust-lang/crates.io-index"
8229checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
8230dependencies = [
8231 "windows-link",
8232 "windows-result",
8233 "windows-strings",
8234]
8235
8236[[package]]
74188237name = "windows-result"
74198238version = "0.4.1"
74208239source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -7701,12 +8520,32 @@
77018520checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6"
77028521
77038522[[package]]
8523name = "yaml-rust2"
8524version = "0.11.1"
8525source = "registry+https://github.com/rust-lang/crates.io-index"
8526checksum = "b36710ce3a279cfce8465dbab826f161675a262950b922cb2c3663852dfe9eb0"
8527dependencies = [
8528 "arraydeque",
8529 "encoding_rs",
8530 "hashlink",
8531]
8532
8533[[package]]
77048534name = "yansi"
77058535version = "1.0.1"
77068536source = "registry+https://github.com/rust-lang/crates.io-index"
77078537checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
77088538
77098539[[package]]
8540name = "yasna"
8541version = "0.5.2"
8542source = "registry+https://github.com/rust-lang/crates.io-index"
8543checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
8544dependencies = [
8545 "time 0.3.55",
8546]
8547
8548[[package]]
77108549name = "yoke"
77118550version = "0.8.3"
77128551source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -7862,6 +8701,34 @@
78628701]
78638702
78648703[[package]]
8704name = "zstd"
8705version = "0.13.3"
8706source = "registry+https://github.com/rust-lang/crates.io-index"
8707checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
8708dependencies = [
8709 "zstd-safe",
8710]
8711
8712[[package]]
8713name = "zstd-safe"
8714version = "7.3.0"
8715source = "registry+https://github.com/rust-lang/crates.io-index"
8716checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882"
8717dependencies = [
8718 "zstd-sys",
8719]
8720
8721[[package]]
8722name = "zstd-sys"
8723version = "2.1.2+zstd.1.5.7"
8724source = "registry+https://github.com/rust-lang/crates.io-index"
8725checksum = "481ada9c543b2323cbb7bbdbb5249177fbf0188ed959ed2c15e4b22421c84ed3"
8726dependencies = [
8727 "cc",
8728 "pkg-config",
8729]
8730
8731[[package]]
78658732name = "zune-core"
78668733version = "0.4.12"
78678734source = "registry+https://github.com/rust-lang/crates.io-index"
Modified Cargo.toml Cargo.toml +3 −3 View Cargo.toml
@@ -34,6 +34,7 @@
3434webapi = { path = "lib/webapi" }
3535pulls = { path = "lib/pulls" }
3636social = { path = "lib/social" }
37queue = { path = "lib/queue" }
3738web = { path = "web" }
3839
3940
@@ -88,9 +89,6 @@
8889anyhow = "1"
8990tracing-subscriber = { version = "0.3", features = ["env-filter"] }
9091jsonwebtoken = "9"
91hyper = { version = "1", features = ["server", "http1"] }
92hyper-util = { version = "0.1", features = ["tokio"] }
93tokio-tungstenite = "0.29"
9492
9593regex = "1"
9694url = "2"
@@ -101,3 +99,5 @@
10199prost = "0.14"
102100prost-types = "0.14"
103101tonic-prost-build = "0.14"
102rocketmq-client-rust = "0.9.0"
103rocketmq-common = "0.9.0"
Modified Dockerfile Dockerfile +3 −3 View Dockerfile
@@ -38,7 +38,7 @@
3838 /build/target/release/deps/web-* 2>/dev/null; \
3939 cargo build --release --workspace --bins && \
4040 mkdir -p /out && \
41 for b in apihost migrate openapi im; do \
41 for b in apihost migrate openapi; do \
4242 cp "target/release/$b" /out/; \
4343 done
4444
@@ -58,11 +58,11 @@
5858WORKDIR /app
5959
6060COPY --chown=gitdata:gitdata config/default.toml config/default.toml
61COPY --from=builder /out/apihost /out/migrate /out/openapi /out/im /usr/local/bin/
61COPY --from=builder /out/apihost /out/migrate /out/openapi /usr/local/bin/
6262
6363USER gitdata
6464
65EXPOSE 3000 3001
65EXPOSE 3000
6666
6767CMD ["apihost"]
6868
Modified justfile justfile +3 −4 View justfile
@@ -62,7 +62,7 @@
6262up *svc='':
6363 @{{ invoke }}"{{ dev }}" up {{ svc }}
6464
65# 仅启动后端服务(apihost/im)
65# 仅启动后端服务(apihost)
6666up-backend:
6767 @{{ invoke }}"{{ dev }}" up backends
6868
@@ -70,7 +70,7 @@
7070up-frontend:
7171 @{{ invoke }}"{{ dev }}" up frontends
7272
73# 停止全部服务,或指定:just down apihost im
73# 停止全部服务,或指定:just down apihost
7474down *svc='':
7575 @{{ invoke }}"{{ dev }}" down {{ svc }}
7676
@@ -78,7 +78,7 @@
7878restart *svc='':
7979 @{{ invoke }}"{{ dev }}" restart {{ svc }}
8080
81# 最近日志:just logs / just logs apihost / just logs "apihost im"
81# 最近日志:just logs / just logs apihost / just logs "apihost web"
8282logs *svc="apihost":
8383 @{{ invoke }}"{{ dev }}" logs {{ svc }}
8484
@@ -97,7 +97,6 @@
9797# URL 速查
9898urls:
9999 @echo " apihost http://127.0.0.1:4081 (Swagger: /api/docs)"
100 @echo " im ws://127.0.0.1:4082"
101100 @echo " web http://127.0.0.1:5173"
102101
103102# 重新生成 openapi.json
Added rust-toolchain.toml rust-toolchain.toml +4 −0 View rust-toolchain.toml
@@ -0,0 +1,4 @@
1[toolchain]
2channel = "nightly-2026-10-27"
3components = ["rustfmt", "clippy"]
4profile = "minimal"