feat(config): integrate etcd for service discovery and config management
- Add etcd-client dependency for distributed configuration storage - Implement EtcdConfig with priority: etcd > environment variables > defaults - Add ServiceRegistry for service registration with lease keep-alive - Integrate etcd-based service discovery for appks gRPC connections - Add service watcher for real-time service instance updates - Migrate Redis configuration from single URL to cluster node list - Update Dockerfile with default IMKS_HOST and IMKS_PORT environment variables - Add etcd bootstrap configuration through environment variables - Implement Redis cluster URL building with optional authentication
This commit is contained in:
Generated
+87
-76
@@ -262,6 +262,12 @@ dependencies = [
|
|||||||
"rustversion",
|
"rustversion",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arcstr"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "asn1-rs"
|
name = "asn1-rs"
|
||||||
version = "0.7.2"
|
version = "0.7.2"
|
||||||
@@ -301,6 +307,17 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-lock"
|
||||||
|
version = "3.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
|
||||||
|
dependencies = [
|
||||||
|
"event-listener",
|
||||||
|
"event-listener-strategy",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-nats"
|
name = "async-nats"
|
||||||
version = "0.38.0"
|
version = "0.38.0"
|
||||||
@@ -412,6 +429,15 @@ dependencies = [
|
|||||||
"tower-service",
|
"tower-service",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "backon"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef"
|
||||||
|
dependencies = [
|
||||||
|
"fastrand",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.22.1"
|
version = "0.22.1"
|
||||||
@@ -502,16 +528,6 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bytes-utils"
|
|
||||||
version = "0.1.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35"
|
|
||||||
dependencies = [
|
|
||||||
"bytes",
|
|
||||||
"either",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytestring"
|
name = "bytestring"
|
||||||
version = "1.5.1"
|
version = "1.5.1"
|
||||||
@@ -576,6 +592,20 @@ version = "0.5.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "combine"
|
||||||
|
version = "4.6.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"memchr",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "concurrent-queue"
|
name = "concurrent-queue"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@@ -617,12 +647,6 @@ dependencies = [
|
|||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cookie-factory"
|
|
||||||
version = "0.3.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "396de984970346b0d9e93d1415082923c679e5ae5c3ee3dcbd104f5610af126b"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
version = "0.9.4"
|
version = "0.9.4"
|
||||||
@@ -978,6 +1002,16 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "event-listener-strategy"
|
||||||
|
version = "0.5.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
|
||||||
|
dependencies = [
|
||||||
|
"event-listener",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastrand"
|
name = "fastrand"
|
||||||
version = "2.4.1"
|
version = "2.4.1"
|
||||||
@@ -1012,15 +1046,6 @@ dependencies = [
|
|||||||
"miniz_oxide",
|
"miniz_oxide",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "float-cmp"
|
|
||||||
version = "0.10.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8"
|
|
||||||
dependencies = [
|
|
||||||
"num-traits",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "flume"
|
name = "flume"
|
||||||
version = "0.12.0"
|
version = "0.12.0"
|
||||||
@@ -1059,43 +1084,6 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "fred"
|
|
||||||
version = "10.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3a7b2fd0f08b23315c13b6156f971aeedb6f75fb16a29ac1872d2eabccc1490e"
|
|
||||||
dependencies = [
|
|
||||||
"arc-swap",
|
|
||||||
"async-trait",
|
|
||||||
"bytes",
|
|
||||||
"bytes-utils",
|
|
||||||
"float-cmp",
|
|
||||||
"fred-macros",
|
|
||||||
"futures",
|
|
||||||
"log",
|
|
||||||
"parking_lot",
|
|
||||||
"rand 0.8.6",
|
|
||||||
"redis-protocol",
|
|
||||||
"semver",
|
|
||||||
"socket2 0.5.10",
|
|
||||||
"tokio",
|
|
||||||
"tokio-stream",
|
|
||||||
"tokio-util",
|
|
||||||
"url",
|
|
||||||
"urlencoding",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "fred-macros"
|
|
||||||
version = "0.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1458c6e22d36d61507034d5afecc64f105c1d39712b7ac6ec3b352c423f715cc"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures"
|
name = "futures"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
@@ -1104,7 +1092,6 @@ checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-executor",
|
|
||||||
"futures-io",
|
"futures-io",
|
||||||
"futures-sink",
|
"futures-sink",
|
||||||
"futures-task",
|
"futures-task",
|
||||||
@@ -1630,8 +1617,8 @@ dependencies = [
|
|||||||
"base64",
|
"base64",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dashmap",
|
"dashmap",
|
||||||
|
"dotenvy",
|
||||||
"etcd-client",
|
"etcd-client",
|
||||||
"fred",
|
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"opentelemetry",
|
"opentelemetry",
|
||||||
@@ -1643,6 +1630,7 @@ dependencies = [
|
|||||||
"prost",
|
"prost",
|
||||||
"prost-types",
|
"prost-types",
|
||||||
"rand 0.9.4",
|
"rand 0.9.4",
|
||||||
|
"redis",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
@@ -2534,17 +2522,34 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redis-protocol"
|
name = "redis"
|
||||||
version = "6.0.0"
|
version = "1.2.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9cdba59219406899220fc4cdfd17a95191ba9c9afb719b5fa5a083d63109a9f1"
|
checksum = "a12e6b5f4d8ef33944e833e2b1859ad478deab6e431d7337b30ee2efe21f7543"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"arc-swap",
|
||||||
|
"arcstr",
|
||||||
|
"async-lock",
|
||||||
|
"backon",
|
||||||
"bytes",
|
"bytes",
|
||||||
"bytes-utils",
|
"cfg-if",
|
||||||
"cookie-factory",
|
"combine",
|
||||||
"crc16",
|
"crc16",
|
||||||
|
"futures-channel",
|
||||||
|
"futures-util",
|
||||||
|
"itoa",
|
||||||
"log",
|
"log",
|
||||||
"nom",
|
"num-bigint",
|
||||||
|
"percent-encoding",
|
||||||
|
"pin-project-lite",
|
||||||
|
"rand 0.9.4",
|
||||||
|
"ryu",
|
||||||
|
"sha1_smol",
|
||||||
|
"socket2 0.6.4",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
|
"url",
|
||||||
|
"xxhash-rust",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2918,6 +2923,12 @@ dependencies = [
|
|||||||
"digest 0.11.3",
|
"digest 0.11.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha1_smol"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.10.9"
|
version = "0.10.9"
|
||||||
@@ -3813,12 +3824,6 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "urlencoding"
|
|
||||||
version = "2.1.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8_iter"
|
name = "utf8_iter"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@@ -4317,6 +4322,12 @@ dependencies = [
|
|||||||
"time",
|
"time",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "xxhash-rust"
|
||||||
|
version = "0.8.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yasna"
|
name = "yasna"
|
||||||
version = "0.6.0"
|
version = "0.6.0"
|
||||||
|
|||||||
+3
-2
@@ -18,7 +18,7 @@ tonic = { version = "0.14", features = ["tls-ring"] }
|
|||||||
prost = "0.14"
|
prost = "0.14"
|
||||||
prost-types = "0.14"
|
prost-types = "0.14"
|
||||||
tonic-build = "0.14"
|
tonic-build = "0.14"
|
||||||
tonic-health = "0.14"
|
tonic-health = "0.14.6"
|
||||||
tonic-prost = "0.14"
|
tonic-prost = "0.14"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
actix-web = { version = "4", features = [] }
|
actix-web = { version = "4", features = [] }
|
||||||
@@ -46,11 +46,12 @@ tracing-opentelemetry = "0.33"
|
|||||||
opentelemetry-appender-tracing = "0.32"
|
opentelemetry-appender-tracing = "0.32"
|
||||||
opentelemetry-prometheus = "0.32"
|
opentelemetry-prometheus = "0.32"
|
||||||
prometheus = "0.14"
|
prometheus = "0.14"
|
||||||
fred = { version = "10", features = ["subscriber-client"] }
|
redis = { version = "1", features = ["cluster", "cluster-async", "aio", "tokio-comp", "connection-manager"] }
|
||||||
async-nats = "0.38"
|
async-nats = "0.38"
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
jsonwebtoken = "9"
|
jsonwebtoken = "9"
|
||||||
arc-swap = "1"
|
arc-swap = "1"
|
||||||
|
dotenvy = "0.15"
|
||||||
|
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
|
|||||||
+2
-2
@@ -30,9 +30,9 @@ RUN useradd -m -u 1000 imks && chown -R imks:imks /app
|
|||||||
USER imks
|
USER imks
|
||||||
|
|
||||||
ENV IMKS_HOST=0.0.0.0
|
ENV IMKS_HOST=0.0.0.0
|
||||||
ENV IMKS_PORT=3000
|
ENV IMKS_PORT=50048
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 50048
|
||||||
|
|
||||||
HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=3 \
|
HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=3 \
|
||||||
CMD curl -sf http://localhost:3000/health || exit 1
|
CMD curl -sf http://localhost:3000/health || exit 1
|
||||||
|
|||||||
+4
-1
@@ -132,7 +132,10 @@ impl SessionStore {
|
|||||||
if let Some(m) = crate::telemetry::metrics::try_get() {
|
if let Some(m) = crate::telemetry::metrics::try_get() {
|
||||||
m.engine_sessions_active.add(
|
m.engine_sessions_active.add(
|
||||||
1,
|
1,
|
||||||
&[opentelemetry::KeyValue::new("transport", transport.as_str())],
|
&[opentelemetry::KeyValue::new(
|
||||||
|
"transport",
|
||||||
|
transport.as_str(),
|
||||||
|
)],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
rx
|
rx
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use etcd_client::{Client, PutOptions, GetOptions, WatchOptions};
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use etcd_client::{Client, EventType, GetOptions, PutOptions, WatchOptions};
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use tokio_stream::StreamExt;
|
use tokio_stream::StreamExt;
|
||||||
|
|
||||||
@@ -14,7 +15,10 @@ impl EtcdConfig {
|
|||||||
let client = Client::connect(endpoints, None)
|
let client = Client::connect(endpoints, None)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("etcd connect: {e}"))?;
|
.map_err(|e| format!("etcd connect: {e}"))?;
|
||||||
Ok(Self { client: Arc::new(Mutex::new(client)), prefix: prefix.to_string() })
|
Ok(Self {
|
||||||
|
client: Arc::new(Mutex::new(client)),
|
||||||
|
prefix: prefix.to_string(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get config value: etcd first, then env var, then default.
|
/// Get config value: etcd first, then env var, then default.
|
||||||
@@ -41,7 +45,9 @@ impl EtcdConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_parsed<T: std::str::FromStr>(&self, key: &str, default: T) -> T
|
pub async fn get_parsed<T: std::str::FromStr>(&self, key: &str, default: T) -> T
|
||||||
where T::Err: std::fmt::Display, T: std::fmt::Display
|
where
|
||||||
|
T::Err: std::fmt::Display,
|
||||||
|
T: std::fmt::Display,
|
||||||
{
|
{
|
||||||
let s = self.get(key, &default.to_string()).await;
|
let s = self.get(key, &default.to_string()).await;
|
||||||
s.parse().unwrap_or(default)
|
s.parse().unwrap_or(default)
|
||||||
@@ -71,22 +77,44 @@ impl EtcdConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tracing::info!(service = service_name, count = addrs.len(), "discovered instances");
|
tracing::info!(
|
||||||
|
service = service_name,
|
||||||
|
count = addrs.len(),
|
||||||
|
"discovered instances"
|
||||||
|
);
|
||||||
Ok(addrs)
|
Ok(addrs)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Watch a service for live updates.
|
/// Watch a service prefix for live join/leave events.
|
||||||
pub fn start_service_watcher(&self, service_name: &str) {
|
///
|
||||||
|
/// Calls `on_up(addr)` when a new instance appears and `on_down(addr)`
|
||||||
|
/// when one disappears. The watcher runs in a background task and
|
||||||
|
/// automatically reconnects on failure.
|
||||||
|
pub fn start_service_watcher(
|
||||||
|
&self,
|
||||||
|
service_name: &str,
|
||||||
|
on_up: impl Fn(String) + Send + Sync + 'static,
|
||||||
|
on_down: impl Fn(String) + Send + Sync + 'static,
|
||||||
|
) {
|
||||||
let client = self.client.clone();
|
let client = self.client.clone();
|
||||||
let prefix = self.prefix.clone();
|
let prefix = self.prefix.clone();
|
||||||
let svc = service_name.to_string();
|
let svc = service_name.to_string();
|
||||||
let watch_prefix = format!("{}services/{}/", prefix, svc);
|
let watch_prefix = format!("{}services/{}/", prefix, svc);
|
||||||
|
|
||||||
|
let on_up = Arc::new(on_up);
|
||||||
|
let on_down = Arc::new(on_down);
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
let mut stream = {
|
let mut stream = {
|
||||||
let mut c = client.lock().await;
|
let mut c = client.lock().await;
|
||||||
match c.watch(watch_prefix.as_str(), Some(WatchOptions::new().with_prefix())).await {
|
match c
|
||||||
|
.watch(
|
||||||
|
watch_prefix.as_str(),
|
||||||
|
Some(WatchOptions::new().with_prefix()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(service = %svc, error = %e, "watch failed, retry in 3s");
|
tracing::warn!(service = %svc, error = %e, "watch failed, retry in 3s");
|
||||||
@@ -96,23 +124,36 @@ impl EtcdConfig {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
while let Some(resp) = stream.next().await {
|
while let Some(resp) = stream.next().await {
|
||||||
if let Ok(resp) = resp {
|
let Ok(resp) = resp else { break };
|
||||||
for event in resp.events() {
|
for event in resp.events() {
|
||||||
if let Some(kv) = event.kv() {
|
let Some(kv) = event.kv() else { continue };
|
||||||
let addr = kv.value_str().unwrap_or_default();
|
let raw = kv.value_str().unwrap_or_default();
|
||||||
let key = kv.key_str().unwrap_or_default();
|
let key = kv.key_str().unwrap_or_default();
|
||||||
|
|
||||||
|
// Parse JSON to extract the actual address
|
||||||
|
let addr = serde_json::from_str::<serde_json::Value>(raw)
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| {
|
||||||
|
v.get("addr")
|
||||||
|
.and_then(|a| a.as_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| raw.to_string());
|
||||||
|
|
||||||
match event.event_type() {
|
match event.event_type() {
|
||||||
etcd_client::EventType::Put => {
|
EventType::Put => {
|
||||||
tracing::info!(service = %svc, key, addr, "service up");
|
tracing::info!(service = %svc, key, addr, "service up");
|
||||||
|
on_up(addr);
|
||||||
}
|
}
|
||||||
etcd_client::EventType::Delete => {
|
EventType::Delete => {
|
||||||
tracing::info!(service = %svc, key, "service down");
|
tracing::info!(service = %svc, key, "service down");
|
||||||
|
on_down(addr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
tracing::warn!(service = %svc, "watch stream ended, restarting in 3s");
|
||||||
}
|
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -126,7 +167,10 @@ pub struct ServiceRegistry {
|
|||||||
|
|
||||||
impl ServiceRegistry {
|
impl ServiceRegistry {
|
||||||
pub fn new(client: Arc<Mutex<Client>>, prefix: &str) -> Self {
|
pub fn new(client: Arc<Mutex<Client>>, prefix: &str) -> Self {
|
||||||
Self { client, prefix: prefix.to_string() }
|
Self {
|
||||||
|
client,
|
||||||
|
prefix: prefix.to_string(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn register(&self, service_name: &str, addr: &str) -> Result<(), String> {
|
pub async fn register(&self, service_name: &str, addr: &str) -> Result<(), String> {
|
||||||
@@ -143,43 +187,44 @@ impl ServiceRegistry {
|
|||||||
|
|
||||||
let lease = {
|
let lease = {
|
||||||
let mut client = self.client.lock().await;
|
let mut client = self.client.lock().await;
|
||||||
client.lease_grant(15, None).await.map_err(|e| format!("lease: {e}"))?
|
client
|
||||||
|
.lease_grant(60, None)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("lease: {e}"))?
|
||||||
};
|
};
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut client = self.client.lock().await;
|
let mut client = self.client.lock().await;
|
||||||
let opts = PutOptions::new().with_lease(lease.id());
|
let opts = PutOptions::new().with_lease(lease.id());
|
||||||
client.put(key.clone(), value, Some(opts)).await.map_err(|e| format!("put: {e}"))?;
|
client
|
||||||
|
.put(key.clone(), value, Some(opts))
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("put: {e}"))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!(service = service_name, instance = %instance_id, addr = %addr, "registered in etcd");
|
tracing::info!(service = service_name, instance = %instance_id, addr = %addr, "registered in etcd");
|
||||||
|
|
||||||
let c = self.client.clone();
|
let c = self.client.clone();
|
||||||
|
let lease_id = lease.id();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
let (mut keeper, mut stream) = {
|
||||||
|
let mut client = c.lock().await;
|
||||||
|
match client.lease_keep_alive(lease_id).await {
|
||||||
|
Ok(pair) => pair,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(lease_id, error = %e, "failed to start lease keepalive");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut interval = tokio::time::interval(std::time::Duration::from_secs(10));
|
||||||
loop {
|
loop {
|
||||||
let result = {
|
interval.tick().await;
|
||||||
let mut client = c.lock().await;
|
if let Err(e) = keeper.keep_alive().await {
|
||||||
client.lease_keep_alive(lease.id()).await
|
tracing::warn!(lease_id, error = %e, "lease keepalive failed");
|
||||||
};
|
|
||||||
match result {
|
|
||||||
Ok((_keeper, mut stream)) => {
|
|
||||||
while stream.next().await.is_some() {}
|
|
||||||
}
|
|
||||||
Err(e) => tracing::warn!(lease_id = lease.id(), error = %e, "keepalive failed"),
|
|
||||||
}
|
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
|
||||||
let new_lease = {
|
|
||||||
let mut client = c.lock().await;
|
|
||||||
client.lease_grant(15, None).await
|
|
||||||
};
|
|
||||||
if let Ok(lr) = new_lease {
|
|
||||||
let instance = serde_json::json!({"addr": &addr, "port": 0, "version": env!("CARGO_PKG_VERSION")});
|
|
||||||
if let Ok(v) = serde_json::to_string(&instance) {
|
|
||||||
let mut client = c.lock().await;
|
|
||||||
let opts = PutOptions::new().with_lease(lr.id());
|
|
||||||
let _ = client.put(key.clone(), v, Some(opts)).await;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
let _ = stream.message().await;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
use std::sync::{Arc, OnceLock};
|
use std::sync::{Arc, OnceLock};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
use imks::database::{Database, DatabaseConfig};
|
use imks::database::{Database, DatabaseConfig};
|
||||||
use imks::engine::server::EngineConfig;
|
use imks::engine::server::EngineConfig;
|
||||||
@@ -12,7 +15,10 @@ use imks::socket::server::SocketServerBuilder;
|
|||||||
use imks::svc::{DeployConfig, MessageService};
|
use imks::svc::{DeployConfig, MessageService};
|
||||||
use imks::telemetry;
|
use imks::telemetry;
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
|
||||||
// Initialize observability stack (traces, metrics, logs, health)
|
// Initialize observability stack (traces, metrics, logs, health)
|
||||||
let telemetry_guard = telemetry::init();
|
let telemetry_guard = telemetry::init();
|
||||||
telemetry::health::init_counters();
|
telemetry::health::init_counters();
|
||||||
@@ -26,8 +32,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.map(|s| s.trim().to_string())
|
.map(|s| s.trim().to_string())
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.collect();
|
.collect();
|
||||||
let etcd_prefix = std::env::var("ETCD_KEY_PREFIX")
|
let etcd_prefix = std::env::var("ETCD_KEY_PREFIX").unwrap_or_else(|_| "/appks/".to_string());
|
||||||
.unwrap_or_else(|_| "/appks/".to_string());
|
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
adapter = %deploy.adapter_mode,
|
adapter = %deploy.adapter_mode,
|
||||||
@@ -38,11 +43,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
let addr = "0.0.0.0:50048";
|
let addr = "0.0.0.0:50048";
|
||||||
|
|
||||||
let rt = tokio::runtime::Runtime::new()?;
|
|
||||||
|
|
||||||
rt.block_on(async {
|
|
||||||
// --- etcd: connect, register, discover appks ---
|
// --- etcd: connect, register, discover appks ---
|
||||||
let etcd = EtcdConfig::connect(etcd_endpoints, &etcd_prefix).await
|
let etcd = EtcdConfig::connect(etcd_endpoints, &etcd_prefix)
|
||||||
|
.await
|
||||||
.unwrap_or_else(|e| {
|
.unwrap_or_else(|e| {
|
||||||
tracing::error!(error = %e, "etcd required but unavailable");
|
tracing::error!(error = %e, "etcd required but unavailable");
|
||||||
panic!("etcd required: {e}")
|
panic!("etcd required: {e}")
|
||||||
@@ -53,15 +56,37 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let imks_addr = etcd.get("IMKS_ADDR", "0.0.0.0:3000").await;
|
let imks_addr = etcd.get("IMKS_ADDR", "0.0.0.0:3000").await;
|
||||||
registry.register("imks", &imks_addr).await.ok();
|
registry.register("imks", &imks_addr).await.ok();
|
||||||
|
|
||||||
// Discover appks from etcd (priority > env)
|
// Discover appks from etcd (priority > env).
|
||||||
let appks_addr = etcd.discover_service("appks").await
|
// etcd-registered addresses are bare "host:port" — prepend http:// for gRPC.
|
||||||
|
let appks_addr = etcd
|
||||||
|
.discover_service("appks")
|
||||||
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|addrs| addrs.into_iter().next())
|
.and_then(|addrs| addrs.into_iter().next())
|
||||||
|
.map(|addr| format!("http://{}", addr))
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| {
|
||||||
std::env::var("APPKS_GRPC_ADDR").unwrap_or_else(|_| "http://localhost:50051".to_string())
|
std::env::var("APPKS_GRPC_ADDR")
|
||||||
|
.unwrap_or_else(|_| "http://localhost:50051".to_string())
|
||||||
});
|
});
|
||||||
tracing::info!(appks_addr = %appks_addr, "appks discovered via etcd");
|
tracing::info!(appks_addr = %appks_addr, "appks discovered via etcd");
|
||||||
etcd.start_service_watcher("appks");
|
|
||||||
|
// Track the currently active appks address for health checks.
|
||||||
|
// Updated on successful connect, cleared on health failure.
|
||||||
|
let current_appks_addr: Arc<RwLock<Option<String>>> = Arc::new(RwLock::new(None));
|
||||||
|
|
||||||
|
// Start imks's own gRPC health server early so Redis/NATS stalls do not hide it.
|
||||||
|
// Default: imks RPC health 50047, separate from HTTP (50048) and appks gRPC (50049).
|
||||||
|
{
|
||||||
|
let grpc_health_addr: std::net::SocketAddr = std::env::var("IMKS_GRPC_HEALTH_ADDR")
|
||||||
|
.unwrap_or_else(|_| "0.0.0.0:50047".to_string())
|
||||||
|
.parse()
|
||||||
|
.expect("Invalid IMKS_GRPC_HEALTH_ADDR");
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = imks::rpc::health::start_health_server(grpc_health_addr).await {
|
||||||
|
tracing::error!(error = %e, "imks gRPC health server failed");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let engine_config = EngineConfig::default();
|
let engine_config = EngineConfig::default();
|
||||||
let mut builder = SocketServerBuilder::new(engine_config);
|
let mut builder = SocketServerBuilder::new(engine_config);
|
||||||
@@ -71,11 +96,17 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
// Pre-configure adapter for Redis/NATS mode.
|
// Pre-configure adapter for Redis/NATS mode.
|
||||||
match deploy.adapter_mode.as_str() {
|
match deploy.adapter_mode.as_str() {
|
||||||
"redis" => {
|
"redis" => {
|
||||||
let cluster_url = deploy.redis_cluster_url();
|
let redis_url = deploy
|
||||||
|
.redis_url()
|
||||||
|
.map_err(|e| format!("Invalid Redis configuration: {e}"))?;
|
||||||
|
tracing::info!(
|
||||||
|
cluster_enabled = deploy.redis_cluster_enabled,
|
||||||
|
"Configuring Redis adapter"
|
||||||
|
);
|
||||||
let message_bus = Arc::new(
|
let message_bus = Arc::new(
|
||||||
RedisMessageBus::new(&cluster_url)
|
RedisMessageBus::new(&redis_url)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to connect to Redis cluster: {e}"))?,
|
.map_err(|e| format!("Failed to connect to Redis: {e}"))?,
|
||||||
);
|
);
|
||||||
let redis_client = message_bus.client().clone();
|
let redis_client = message_bus.client().clone();
|
||||||
let server_id = deploy.server_id.clone();
|
let server_id = deploy.server_id.clone();
|
||||||
@@ -121,46 +152,220 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let socket_server = Arc::new(builder.build());
|
let socket_server = Arc::new(builder.build());
|
||||||
let _ = namespace_holder.set(socket_server.namespaces.clone());
|
let _ = namespace_holder.set(socket_server.namespaces.clone());
|
||||||
|
|
||||||
// Initialize database + gRPC + service
|
// Connect to database (independent of appks gRPC availability)
|
||||||
let service: Option<Arc<MessageService>> = {
|
let db_config = DatabaseConfig::from_env();
|
||||||
|
let db = Database::connect(&db_config)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Database connection failed: {e}"))?;
|
||||||
|
imks::database::run_migrations(db.pool())
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Database migration failed: {e}"))?;
|
||||||
|
let repo = Arc::new(MessageRepo::new(db.pool().clone()));
|
||||||
|
tracing::info!("Database connected and migrated");
|
||||||
|
|
||||||
|
// Shared service handle — swapped atomically when appks comes/goes
|
||||||
|
let service: Arc<RwLock<Option<Arc<MessageService>>>> = Arc::new(RwLock::new(None));
|
||||||
|
|
||||||
|
// Helper to try creating a MessageService given an appks address.
|
||||||
|
// Returns true on success.
|
||||||
|
let try_connect_service = {
|
||||||
|
let service = service.clone();
|
||||||
|
let repo = repo.clone();
|
||||||
|
let namespaces = socket_server.namespaces.clone();
|
||||||
|
let current_addr = current_appks_addr.clone();
|
||||||
let rpc_config = RpcConfig {
|
let rpc_config = RpcConfig {
|
||||||
appks_addr: appks_addr.clone(),
|
appks_addr: appks_addr.clone(),
|
||||||
..RpcConfig::from_env()
|
..RpcConfig::from_env()
|
||||||
};
|
};
|
||||||
let db_config = DatabaseConfig::from_env();
|
move |addr: String| {
|
||||||
|
let service = service.clone();
|
||||||
match AppksClients::connect(&rpc_config).await {
|
let repo = repo.clone();
|
||||||
|
let namespaces = namespaces.clone();
|
||||||
|
let current_addr = current_addr.clone();
|
||||||
|
let mut rpc = rpc_config.clone();
|
||||||
|
// etcd-registered address is bare "host:port" — prepend scheme for gRPC
|
||||||
|
rpc.appks_addr = if addr.starts_with("http") {
|
||||||
|
addr
|
||||||
|
} else {
|
||||||
|
format!("http://{}", addr)
|
||||||
|
};
|
||||||
|
async move {
|
||||||
|
match AppksClients::connect(&rpc).await {
|
||||||
Ok(clients) => {
|
Ok(clients) => {
|
||||||
let db = Database::connect(&db_config)
|
match MessageService::new((*repo).clone(), clients, namespaces.clone())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Database connection failed: {e}"))?;
|
{
|
||||||
|
Ok(svc) => {
|
||||||
|
let svc = Arc::new(svc);
|
||||||
|
let mut guard = service.write().await;
|
||||||
|
*guard = Some(svc);
|
||||||
|
|
||||||
imks::database::run_migrations(db.pool())
|
// Update the active appks address for health checker
|
||||||
.await
|
let mut addr_guard = current_addr.write().await;
|
||||||
.map_err(|e| format!("Database migration failed: {e}"))?;
|
*addr_guard = Some(rpc.appks_addr.clone());
|
||||||
|
|
||||||
let repo = MessageRepo::new(db.pool().clone());
|
tracing::info!(
|
||||||
|
addr = %rpc.appks_addr,
|
||||||
let svc = MessageService::new(repo, clients, socket_server.namespaces.clone())
|
"Message service initialized"
|
||||||
.await
|
);
|
||||||
.map_err(|e| format!("Failed to initialize message service: {e}"))?;
|
true
|
||||||
|
|
||||||
tracing::info!("Message service initialized with gRPC permission checks");
|
|
||||||
Some(Arc::new(svc))
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!("gRPC unavailable: {e}. Running without permission checks.");
|
tracing::warn!(
|
||||||
None
|
addr = %rpc.appks_addr,
|
||||||
|
error = %e,
|
||||||
|
"Failed to init message service"
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
addr = %rpc.appks_addr,
|
||||||
|
error = %e,
|
||||||
|
"gRPC connect failed"
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Try initial connection: etcd-discovered addr first, then env fallback
|
||||||
|
let env_fallback =
|
||||||
|
std::env::var("APPKS_GRPC_ADDR").unwrap_or_else(|_| "http://localhost:50051".to_string());
|
||||||
|
if !try_connect_service(appks_addr.clone()).await {
|
||||||
|
if appks_addr != env_fallback {
|
||||||
|
tracing::info!(
|
||||||
|
etcd_addr = %appks_addr,
|
||||||
|
fallback = %env_fallback,
|
||||||
|
"etcd-discovered appks unreachable, trying env fallback"
|
||||||
|
);
|
||||||
|
try_connect_service(env_fallback).await;
|
||||||
|
} else {
|
||||||
|
tracing::warn!(
|
||||||
|
%appks_addr,
|
||||||
|
"Initial connection to appks failed — will retry when etcd discovers new instances"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch etcd for appks join/leave — reconnect dynamically
|
||||||
|
{
|
||||||
|
let try_connect = try_connect_service.clone();
|
||||||
|
let svc_clear = service.clone();
|
||||||
|
let addr_clear = current_appks_addr.clone();
|
||||||
|
etcd.start_service_watcher(
|
||||||
|
"appks",
|
||||||
|
move |addr| {
|
||||||
|
tracing::info!(%addr, "etcd watcher: appks instance UP, attempting reconnection");
|
||||||
|
let try_connect = try_connect.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let ok = try_connect(addr).await;
|
||||||
|
tracing::info!(ok, "etcd watcher: reconnection result");
|
||||||
|
});
|
||||||
|
},
|
||||||
|
move |_addr| {
|
||||||
|
let svc_clear = svc_clear.clone();
|
||||||
|
let addr_clear = addr_clear.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut guard = svc_clear.write().await;
|
||||||
|
*guard = None;
|
||||||
|
// Also clear the tracked address so health checker stops probing
|
||||||
|
let mut addr_guard = addr_clear.write().await;
|
||||||
|
*addr_guard = None;
|
||||||
|
tracing::warn!(
|
||||||
|
"etcd watcher: appks instance DOWN — running without permission checks"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health check loop: probe appks gRPC health every 5 seconds.
|
||||||
|
// If appks becomes unhealthy, clear the MessageService (degraded mode)
|
||||||
|
// and trigger an immediate reconnection attempt.
|
||||||
|
{
|
||||||
|
let health_service = service.clone();
|
||||||
|
let health_addr = current_appks_addr.clone();
|
||||||
|
let health_reconnect = try_connect_service.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
tracing::info!(
|
||||||
|
interval_secs = 5,
|
||||||
|
"Health check loop started — probing appks gRPC health every 5s"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut last_logged_ok = false;
|
||||||
|
let mut waiting_ticks = 0u64;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||||
|
|
||||||
|
// Snapshot the current appks address
|
||||||
|
let addr = health_addr.read().await.clone();
|
||||||
|
let Some(ref addr) = addr else {
|
||||||
|
// No appks connected yet
|
||||||
|
waiting_ticks += 1;
|
||||||
|
if !last_logged_ok && waiting_ticks % 12 == 1 {
|
||||||
|
// Log every ~60s while waiting (12 × 5s)
|
||||||
|
tracing::info!(
|
||||||
|
waiting_secs = waiting_ticks * 5,
|
||||||
|
"No appks connection — health check paused, waiting for etcd discovery"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
waiting_ticks = 0;
|
||||||
|
|
||||||
|
match imks::rpc::health::check_appks_health(addr).await {
|
||||||
|
Ok(true) => {
|
||||||
|
if !last_logged_ok {
|
||||||
|
tracing::info!(%addr, "appks health check OK");
|
||||||
|
last_logged_ok = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(false) | Err(_) => {
|
||||||
|
tracing::warn!(%addr, "appks health check FAILED — entering degraded mode");
|
||||||
|
|
||||||
|
// Clear the MessageService so event handlers drop requests
|
||||||
|
let mut guard = health_service.write().await;
|
||||||
|
*guard = None;
|
||||||
|
|
||||||
|
// Clear the tracked address so the next cycle skips
|
||||||
|
// health checks until a new connection is established
|
||||||
|
let mut addr_guard = health_addr.write().await;
|
||||||
|
*addr_guard = None;
|
||||||
|
|
||||||
|
last_logged_ok = false;
|
||||||
|
|
||||||
|
// Trigger an immediate reconnection attempt.
|
||||||
|
// If the same address recovers, try_connect will restore
|
||||||
|
// the service. If not, we wait for etcd discovery.
|
||||||
|
let reconnect = health_reconnect.clone();
|
||||||
|
let reconnect_addr = addr.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if !reconnect(reconnect_addr).await {
|
||||||
|
tracing::warn!(
|
||||||
|
"Immediate reconnection after health failure failed, waiting for etcd discovery"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Register connect handler
|
// Register connect handler
|
||||||
let namespace = socket_server.of("/");
|
let namespace = socket_server.of("/");
|
||||||
let svc_connect = service.clone();
|
let svc_connect = service.clone();
|
||||||
namespace
|
namespace
|
||||||
.on_connect(move |socket, auth_data| {
|
.on_connect(move |socket, auth_data| {
|
||||||
if let Some(ref svc) = svc_connect {
|
let svc = svc_connect.blocking_read();
|
||||||
|
if let Some(ref svc) = *svc {
|
||||||
svc.authenticate_socket(socket, auth_data)
|
svc.authenticate_socket(socket, auth_data)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
@@ -187,10 +392,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
// Register Socket.IO event handlers (always register — each handler reads the latest service)
|
||||||
|
{
|
||||||
// Register Socket.IO event handlers
|
let svc = service.clone();
|
||||||
if let Some(ref svc) = service {
|
|
||||||
macro_rules! register_event {
|
macro_rules! register_event {
|
||||||
($svc:expr, $ns:expr, $event:expr, $method:ident) => {
|
($svc:expr, $ns:expr, $event:expr, $method:ident) => {
|
||||||
let s = $svc.clone();
|
let s = $svc.clone();
|
||||||
@@ -208,8 +412,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
);
|
);
|
||||||
let _enter = _span.enter();
|
let _enter = _span.enter();
|
||||||
|
|
||||||
|
let svc_guard = s.read().await;
|
||||||
|
let Some(ref svc) = *svc_guard else {
|
||||||
|
tracing::warn!(event = %event, "No message service available, dropping event");
|
||||||
|
return;
|
||||||
|
};
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
if let Err(e) = s.$method(socket, &data).await {
|
if let Err(e) = svc.$method(socket, &data).await {
|
||||||
tracing::error!(event = %event, error = %e, "Event handler failed");
|
tracing::error!(event = %event, error = %e, "Event handler failed");
|
||||||
}
|
}
|
||||||
let elapsed = start.elapsed().as_secs_f64();
|
let elapsed = start.elapsed().as_secs_f64();
|
||||||
@@ -268,10 +477,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
register_event!(svc, namespace, "component:interact", interact_component);
|
register_event!(svc, namespace, "component:interact", interact_component);
|
||||||
register_event!(svc, namespace, "component:update", update_component);
|
register_event!(svc, namespace, "component:update", update_component);
|
||||||
|
|
||||||
// Start scheduled message dispatcher (background task)
|
tracing::info!("Registered Socket.IO event handlers");
|
||||||
svc.clone().start_scheduled_dispatcher();
|
}
|
||||||
|
|
||||||
tracing::info!("Registered Socket.IO event handlers with observability instrumentation");
|
// Start scheduled message dispatcher (once)
|
||||||
|
if let Some(ref svc) = *service.read().await {
|
||||||
|
svc.clone().start_scheduled_dispatcher();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start servers
|
// Start servers
|
||||||
@@ -297,9 +508,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
socket_server.run_http(addr).await?;
|
socket_server.run_http(addr).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok::<(), Box<dyn std::error::Error>>(())
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Graceful telemetry shutdown
|
// Graceful telemetry shutdown
|
||||||
telemetry_guard.shutdown();
|
telemetry_guard.shutdown();
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
-- Create message_thread before migrations that reference it.
|
|
||||||
-- Safe for existing databases because the table may already exist from 004.
|
|
||||||
|
|
||||||
BEGIN;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS message_thread (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
channel_id UUID NOT NULL,
|
|
||||||
root_message_id UUID NOT NULL REFERENCES message(id) ON DELETE CASCADE,
|
|
||||||
created_by UUID NOT NULL,
|
|
||||||
replies_count BIGINT NOT NULL DEFAULT 0,
|
|
||||||
participants_count BIGINT NOT NULL DEFAULT 0,
|
|
||||||
last_reply_message_id UUID NULL REFERENCES message(id) ON DELETE SET NULL,
|
|
||||||
last_reply_at TIMESTAMPTZ NULL,
|
|
||||||
resolved BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
resolved_by UUID NULL,
|
|
||||||
resolved_at TIMESTAMPTZ NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
CONSTRAINT uq_message_thread_root UNIQUE (root_message_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
-- ============================================================
|
|
||||||
-- Migration: 001_message_rich_content.sql
|
|
||||||
-- Tables: message_attachment, message_embed, message_embed_field,
|
|
||||||
-- message_poll, message_poll_option, message_poll_vote
|
|
||||||
-- ============================================================
|
|
||||||
-- These tables extend the existing `message` table (from appks 001_init.sql)
|
|
||||||
-- with Discord-style rich content: file attachments, link preview embeds,
|
|
||||||
-- and interactive polls.
|
|
||||||
|
|
||||||
BEGIN;
|
|
||||||
|
|
||||||
-- models/message_attachment.rs → message_attachment
|
|
||||||
CREATE TABLE IF NOT EXISTS message_attachment (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
message_id UUID NOT NULL REFERENCES message(id) ON DELETE CASCADE,
|
|
||||||
filename TEXT NOT NULL,
|
|
||||||
content_type TEXT NULL,
|
|
||||||
size BIGINT NOT NULL,
|
|
||||||
url TEXT NOT NULL,
|
|
||||||
storage_key TEXT NULL,
|
|
||||||
width INTEGER NULL,
|
|
||||||
height INTEGER NULL,
|
|
||||||
duration_secs DOUBLE PRECISION NULL,
|
|
||||||
blurhash TEXT NULL,
|
|
||||||
spoiler BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_message_attachment_message_id
|
|
||||||
ON message_attachment (message_id);
|
|
||||||
|
|
||||||
-- models/message_embed.rs → message_embed
|
|
||||||
CREATE TABLE IF NOT EXISTS message_embed (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
message_id UUID NOT NULL REFERENCES message(id) ON DELETE CASCADE,
|
|
||||||
embed_type TEXT NOT NULL,
|
|
||||||
title TEXT NULL,
|
|
||||||
description TEXT NULL,
|
|
||||||
url TEXT NULL,
|
|
||||||
color INTEGER NULL,
|
|
||||||
image_url TEXT NULL,
|
|
||||||
image_width INTEGER NULL,
|
|
||||||
image_height INTEGER NULL,
|
|
||||||
thumbnail_url TEXT NULL,
|
|
||||||
thumbnail_width INTEGER NULL,
|
|
||||||
thumbnail_height INTEGER NULL,
|
|
||||||
video_url TEXT NULL,
|
|
||||||
video_width INTEGER NULL,
|
|
||||||
video_height INTEGER NULL,
|
|
||||||
author_name TEXT NULL,
|
|
||||||
author_url TEXT NULL,
|
|
||||||
author_icon_url TEXT NULL,
|
|
||||||
footer_text TEXT NULL,
|
|
||||||
footer_icon_url TEXT NULL,
|
|
||||||
provider_name TEXT NULL,
|
|
||||||
provider_url TEXT NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_message_embed_message_id
|
|
||||||
ON message_embed (message_id);
|
|
||||||
|
|
||||||
-- models/message_embed.rs → message_embed_field
|
|
||||||
CREATE TABLE IF NOT EXISTS message_embed_field (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
embed_id UUID NOT NULL REFERENCES message_embed(id) ON DELETE CASCADE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
value TEXT NOT NULL,
|
|
||||||
inline BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
position INTEGER NOT NULL DEFAULT 0
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_message_embed_field_embed_id
|
|
||||||
ON message_embed_field (embed_id);
|
|
||||||
|
|
||||||
-- models/message_poll.rs → message_poll
|
|
||||||
CREATE TABLE IF NOT EXISTS message_poll (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
message_id UUID NOT NULL REFERENCES message(id) ON DELETE CASCADE,
|
|
||||||
question TEXT NOT NULL,
|
|
||||||
allow_multiselect BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
max_selections INTEGER NULL,
|
|
||||||
expires_at TIMESTAMPTZ NULL,
|
|
||||||
total_votes BIGINT NOT NULL DEFAULT 0,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
CONSTRAINT uq_message_poll_message UNIQUE (message_id)
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_message_poll_message_id
|
|
||||||
ON message_poll (message_id);
|
|
||||||
|
|
||||||
-- models/message_poll.rs → message_poll_option
|
|
||||||
CREATE TABLE IF NOT EXISTS message_poll_option (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
poll_id UUID NOT NULL REFERENCES message_poll(id) ON DELETE CASCADE,
|
|
||||||
text TEXT NOT NULL,
|
|
||||||
emoji TEXT NULL,
|
|
||||||
vote_count BIGINT NOT NULL DEFAULT 0,
|
|
||||||
position INTEGER NOT NULL DEFAULT 0
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_message_poll_option_poll_id
|
|
||||||
ON message_poll_option (poll_id);
|
|
||||||
|
|
||||||
-- models/message_poll.rs → message_poll_vote
|
|
||||||
CREATE TABLE IF NOT EXISTS message_poll_vote (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
poll_id UUID NOT NULL REFERENCES message_poll(id) ON DELETE CASCADE,
|
|
||||||
option_id UUID NOT NULL REFERENCES message_poll_option(id) ON DELETE CASCADE,
|
|
||||||
user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
CONSTRAINT uq_message_poll_vote UNIQUE (poll_id, user_id, option_id)
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_message_poll_vote_poll_id
|
|
||||||
ON message_poll_vote (poll_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_message_poll_vote_user_id
|
|
||||||
ON message_poll_vote (user_id);
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
-- ============================================================
|
|
||||||
-- Migration: 002_message_social.sql
|
|
||||||
-- Tables: message_pin, message_read_state, message_draft, message_edit
|
|
||||||
-- ============================================================
|
|
||||||
-- Extends the message subsystem with pinned messages, read receipts,
|
|
||||||
-- drafts, and edit history.
|
|
||||||
|
|
||||||
BEGIN;
|
|
||||||
|
|
||||||
-- models/message_pin.rs → message_pin
|
|
||||||
CREATE TABLE IF NOT EXISTS message_pin (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
channel_id UUID NOT NULL,
|
|
||||||
message_id UUID NOT NULL REFERENCES message(id) ON DELETE CASCADE,
|
|
||||||
pinned_by UUID NOT NULL,
|
|
||||||
position INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
CONSTRAINT uq_message_pin_channel_message UNIQUE (channel_id, message_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_message_pin_channel_id
|
|
||||||
ON message_pin (channel_id);
|
|
||||||
|
|
||||||
-- models/message_read_state.rs → message_read_state
|
|
||||||
CREATE TABLE IF NOT EXISTS message_read_state (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
channel_id UUID NOT NULL,
|
|
||||||
user_id UUID NOT NULL,
|
|
||||||
last_read_message_id UUID NULL REFERENCES message(id) ON DELETE SET NULL,
|
|
||||||
last_read_at TIMESTAMPTZ NULL,
|
|
||||||
unread_count BIGINT NOT NULL DEFAULT 0,
|
|
||||||
unread_mentions BIGINT NOT NULL DEFAULT 0,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
CONSTRAINT uq_message_read_state_channel_user UNIQUE (channel_id, user_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_message_read_state_user_id
|
|
||||||
ON message_read_state (user_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_message_read_state_channel_id
|
|
||||||
ON message_read_state (channel_id);
|
|
||||||
|
|
||||||
-- models/message_draft.rs → message_draft
|
|
||||||
CREATE TABLE IF NOT EXISTS message_draft (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
channel_id UUID NOT NULL,
|
|
||||||
user_id UUID NOT NULL,
|
|
||||||
thread_id UUID NULL REFERENCES message_thread(id) ON DELETE CASCADE,
|
|
||||||
reply_to_message_id UUID NULL REFERENCES message(id) ON DELETE SET NULL,
|
|
||||||
body TEXT NOT NULL DEFAULT '',
|
|
||||||
metadata JSONB NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
CONSTRAINT uq_message_draft_channel_user_thread
|
|
||||||
UNIQUE (channel_id, user_id, thread_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_message_draft_user_id
|
|
||||||
ON message_draft (user_id);
|
|
||||||
|
|
||||||
-- models/message_edit.rs → message_edit
|
|
||||||
CREATE TABLE IF NOT EXISTS message_edit (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
message_id UUID NOT NULL REFERENCES message(id) ON DELETE CASCADE,
|
|
||||||
edited_by UUID NOT NULL,
|
|
||||||
old_body TEXT NOT NULL,
|
|
||||||
new_body TEXT NOT NULL,
|
|
||||||
edited_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_message_edit_message_id
|
|
||||||
ON message_edit (message_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_message_edit_edited_at
|
|
||||||
ON message_edit (edited_at);
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
-- ============================================================
|
|
||||||
-- Migration: 003_message_article.sql
|
|
||||||
-- Tables: message_article
|
|
||||||
-- ============================================================
|
|
||||||
-- Extends the message subsystem with forum-style article posts.
|
|
||||||
-- Articles extend regular messages with title, cover image, tags,
|
|
||||||
-- and view/like stats. Rendered as waterfall cards in forum channels.
|
|
||||||
|
|
||||||
BEGIN;
|
|
||||||
|
|
||||||
-- models/message_article.rs → message_article
|
|
||||||
CREATE TABLE IF NOT EXISTS message_article (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
message_id UUID NOT NULL REFERENCES message(id) ON DELETE CASCADE,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
summary TEXT NULL,
|
|
||||||
cover_url TEXT NULL,
|
|
||||||
cover_width INTEGER NULL,
|
|
||||||
cover_height INTEGER NULL,
|
|
||||||
cover_color TEXT NULL,
|
|
||||||
tags JSONB NULL,
|
|
||||||
view_count BIGINT NOT NULL DEFAULT 0,
|
|
||||||
like_count BIGINT NOT NULL DEFAULT 0,
|
|
||||||
bookmark_count BIGINT NOT NULL DEFAULT 0,
|
|
||||||
reply_count BIGINT NOT NULL DEFAULT 0,
|
|
||||||
last_reply_message_id UUID NULL REFERENCES message(id) ON DELETE SET NULL,
|
|
||||||
last_reply_at TIMESTAMPTZ NULL,
|
|
||||||
last_reply_user_id UUID NULL,
|
|
||||||
is_pinned_to_top BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
is_answered BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
answered_by UUID NULL,
|
|
||||||
answered_at TIMESTAMPTZ NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
CONSTRAINT uq_message_article_message UNIQUE (message_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_message_article_last_reply_at
|
|
||||||
ON message_article (last_reply_at DESC NULLS LAST);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_message_article_is_pinned_to_top
|
|
||||||
ON message_article (is_pinned_to_top DESC, last_reply_at DESC NULLS LAST);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_message_article_view_count
|
|
||||||
ON message_article (view_count DESC);
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
-- ============================================================
|
|
||||||
-- Migration: 004_message_social_part2.sql
|
|
||||||
-- Tables: message_reaction, message_bookmark, message_mention,
|
|
||||||
-- message_thread, message_thread_participant
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
BEGIN;
|
|
||||||
|
|
||||||
-- models/message_reaction.rs → message_reaction
|
|
||||||
CREATE TABLE IF NOT EXISTS message_reaction (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
message_id UUID NOT NULL REFERENCES message(id) ON DELETE CASCADE,
|
|
||||||
channel_id UUID NOT NULL,
|
|
||||||
user_id UUID NOT NULL,
|
|
||||||
content TEXT NOT NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
CONSTRAINT uq_message_reaction_user_content UNIQUE (message_id, user_id, content)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_message_reaction_message_id
|
|
||||||
ON message_reaction (message_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_message_reaction_user_id
|
|
||||||
ON message_reaction (user_id);
|
|
||||||
|
|
||||||
-- models/message_bookmark.rs → message_bookmark
|
|
||||||
CREATE TABLE IF NOT EXISTS message_bookmark (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
message_id UUID NOT NULL REFERENCES message(id) ON DELETE CASCADE,
|
|
||||||
channel_id UUID NOT NULL,
|
|
||||||
user_id UUID NOT NULL,
|
|
||||||
note TEXT NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
CONSTRAINT uq_message_bookmark_user_message UNIQUE (user_id, message_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_message_bookmark_user_id
|
|
||||||
ON message_bookmark (user_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_message_bookmark_message_id
|
|
||||||
ON message_bookmark (message_id);
|
|
||||||
|
|
||||||
-- models/message_mention.rs → message_mention
|
|
||||||
CREATE TABLE IF NOT EXISTS message_mention (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
message_id UUID NOT NULL REFERENCES message(id) ON DELETE CASCADE,
|
|
||||||
channel_id UUID NOT NULL,
|
|
||||||
mentioned_user_id UUID NOT NULL,
|
|
||||||
mentioned_by UUID NOT NULL,
|
|
||||||
read_at TIMESTAMPTZ NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_message_mention_message_id
|
|
||||||
ON message_mention (message_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_message_mention_mentioned_user
|
|
||||||
ON message_mention (mentioned_user_id);
|
|
||||||
|
|
||||||
-- models/message_thread.rs → message_thread
|
|
||||||
CREATE TABLE IF NOT EXISTS message_thread (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
channel_id UUID NOT NULL,
|
|
||||||
root_message_id UUID NOT NULL REFERENCES message(id) ON DELETE CASCADE,
|
|
||||||
created_by UUID NOT NULL,
|
|
||||||
replies_count BIGINT NOT NULL DEFAULT 0,
|
|
||||||
participants_count BIGINT NOT NULL DEFAULT 0,
|
|
||||||
last_reply_message_id UUID NULL REFERENCES message(id) ON DELETE SET NULL,
|
|
||||||
last_reply_at TIMESTAMPTZ NULL,
|
|
||||||
resolved BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
resolved_by UUID NULL,
|
|
||||||
resolved_at TIMESTAMPTZ NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
CONSTRAINT uq_message_thread_root UNIQUE (root_message_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_message_thread_channel_id
|
|
||||||
ON message_thread (channel_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_message_thread_last_reply_at
|
|
||||||
ON message_thread (last_reply_at DESC NULLS LAST);
|
|
||||||
|
|
||||||
-- models/message_thread_participant.rs → message_thread_participant
|
|
||||||
CREATE TABLE IF NOT EXISTS message_thread_participant (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
thread_id UUID NOT NULL REFERENCES message_thread(id) ON DELETE CASCADE,
|
|
||||||
user_id UUID NOT NULL,
|
|
||||||
joined_reason TEXT NULL,
|
|
||||||
last_read_message_id UUID NULL REFERENCES message(id) ON DELETE SET NULL,
|
|
||||||
last_read_at TIMESTAMPTZ NULL,
|
|
||||||
joined_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
CONSTRAINT uq_thread_participant UNIQUE (thread_id, user_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_thread_participant_thread_id
|
|
||||||
ON message_thread_participant (thread_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_thread_participant_user_id
|
|
||||||
ON message_thread_participant (user_id);
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
-- ============================================================
|
|
||||||
-- Migration: 005_message_misc.sql
|
|
||||||
-- Tables: message_notification, message_scheduled, message_sticker,
|
|
||||||
-- message_forward, message_component
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
BEGIN;
|
|
||||||
|
|
||||||
-- models/message_notification.rs → message_notification
|
|
||||||
CREATE TABLE IF NOT EXISTS message_notification (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
message_id UUID NOT NULL REFERENCES message(id) ON DELETE CASCADE,
|
|
||||||
channel_id UUID NOT NULL,
|
|
||||||
user_id UUID NOT NULL,
|
|
||||||
reason TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'pending',
|
|
||||||
delivery_channel TEXT NULL,
|
|
||||||
delivered_at TIMESTAMPTZ NULL,
|
|
||||||
read_at TIMESTAMPTZ NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_message_notification_user_id
|
|
||||||
ON message_notification (user_id, created_at DESC);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_message_notification_status
|
|
||||||
ON message_notification (status);
|
|
||||||
|
|
||||||
-- models/message_scheduled.rs → message_scheduled
|
|
||||||
CREATE TABLE IF NOT EXISTS message_scheduled (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
channel_id UUID NOT NULL,
|
|
||||||
author_id UUID NOT NULL,
|
|
||||||
thread_id UUID NULL REFERENCES message_thread(id) ON DELETE SET NULL,
|
|
||||||
reply_to_message_id UUID NULL REFERENCES message(id) ON DELETE SET NULL,
|
|
||||||
body TEXT NOT NULL,
|
|
||||||
metadata JSONB NULL,
|
|
||||||
scheduled_at TIMESTAMPTZ NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'pending',
|
|
||||||
sent_message_id UUID NULL REFERENCES message(id) ON DELETE SET NULL,
|
|
||||||
error TEXT NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_message_scheduled_status_at
|
|
||||||
ON message_scheduled (status, scheduled_at);
|
|
||||||
|
|
||||||
-- models/message_sticker.rs → message_sticker
|
|
||||||
CREATE TABLE IF NOT EXISTS message_sticker (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
message_id UUID NOT NULL REFERENCES message(id) ON DELETE CASCADE,
|
|
||||||
sticker_id UUID NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
image_url TEXT NOT NULL,
|
|
||||||
format_type TEXT NOT NULL DEFAULT 'png',
|
|
||||||
pack_name TEXT NULL,
|
|
||||||
tags TEXT NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_message_sticker_message_id
|
|
||||||
ON message_sticker (message_id);
|
|
||||||
|
|
||||||
-- models/message_forward.rs → message_forward
|
|
||||||
CREATE TABLE IF NOT EXISTS message_forward (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
message_id UUID NOT NULL REFERENCES message(id) ON DELETE CASCADE,
|
|
||||||
source_message_id UUID NOT NULL REFERENCES message(id) ON DELETE CASCADE,
|
|
||||||
source_channel_id UUID NOT NULL,
|
|
||||||
forwarded_by UUID NOT NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_message_forward_message_id
|
|
||||||
ON message_forward (message_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_message_forward_source_message_id
|
|
||||||
ON message_forward (source_message_id);
|
|
||||||
|
|
||||||
-- models/message_component.rs → message_component
|
|
||||||
CREATE TABLE IF NOT EXISTS message_component (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
message_id UUID NOT NULL REFERENCES message(id) ON DELETE CASCADE,
|
|
||||||
row INTEGER NOT NULL DEFAULT 0,
|
|
||||||
position INTEGER NOT NULL DEFAULT 0,
|
|
||||||
component_type TEXT NOT NULL,
|
|
||||||
custom_id TEXT NOT NULL,
|
|
||||||
label TEXT NULL,
|
|
||||||
emoji TEXT NULL,
|
|
||||||
style TEXT NULL,
|
|
||||||
url TEXT NULL,
|
|
||||||
disabled BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
placeholder TEXT NULL,
|
|
||||||
min_values INTEGER NULL,
|
|
||||||
max_values INTEGER NULL,
|
|
||||||
options JSONB NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_message_component_message_id
|
|
||||||
ON message_component (message_id);
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
-- Align imks-managed IDs with application-generated UUID v7 values and
|
|
||||||
-- make top-level drafts unique when thread_id is NULL.
|
|
||||||
|
|
||||||
BEGIN;
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_message_draft_channel_user_no_thread
|
|
||||||
ON message_draft (channel_id, user_id)
|
|
||||||
WHERE thread_id IS NULL;
|
|
||||||
|
|
||||||
ALTER TABLE message_attachment ALTER COLUMN id DROP DEFAULT;
|
|
||||||
ALTER TABLE message_embed ALTER COLUMN id DROP DEFAULT;
|
|
||||||
ALTER TABLE message_embed_field ALTER COLUMN id DROP DEFAULT;
|
|
||||||
ALTER TABLE message_poll ALTER COLUMN id DROP DEFAULT;
|
|
||||||
ALTER TABLE message_poll_option ALTER COLUMN id DROP DEFAULT;
|
|
||||||
ALTER TABLE message_poll_vote ALTER COLUMN id DROP DEFAULT;
|
|
||||||
ALTER TABLE message_pin ALTER COLUMN id DROP DEFAULT;
|
|
||||||
ALTER TABLE message_read_state ALTER COLUMN id DROP DEFAULT;
|
|
||||||
ALTER TABLE message_draft ALTER COLUMN id DROP DEFAULT;
|
|
||||||
ALTER TABLE message_edit ALTER COLUMN id DROP DEFAULT;
|
|
||||||
ALTER TABLE message_article ALTER COLUMN id DROP DEFAULT;
|
|
||||||
ALTER TABLE message_reaction ALTER COLUMN id DROP DEFAULT;
|
|
||||||
ALTER TABLE message_bookmark ALTER COLUMN id DROP DEFAULT;
|
|
||||||
ALTER TABLE message_mention ALTER COLUMN id DROP DEFAULT;
|
|
||||||
ALTER TABLE message_thread ALTER COLUMN id DROP DEFAULT;
|
|
||||||
ALTER TABLE message_thread_participant ALTER COLUMN id DROP DEFAULT;
|
|
||||||
ALTER TABLE message_notification ALTER COLUMN id DROP DEFAULT;
|
|
||||||
ALTER TABLE message_scheduled ALTER COLUMN id DROP DEFAULT;
|
|
||||||
ALTER TABLE message_sticker ALTER COLUMN id DROP DEFAULT;
|
|
||||||
ALTER TABLE message_forward ALTER COLUMN id DROP DEFAULT;
|
|
||||||
ALTER TABLE message_component ALTER COLUMN id DROP DEFAULT;
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
pub mod clients;
|
pub mod clients;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod health;
|
||||||
|
|
||||||
pub use clients::AppksClients;
|
pub use clients::AppksClients;
|
||||||
pub use config::RpcConfig;
|
pub use config::RpcConfig;
|
||||||
|
|||||||
+13
-10
@@ -3,13 +3,12 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use fred::clients::Client;
|
|
||||||
use fred::interfaces::{KeysInterface, SetsInterface};
|
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
use crate::socket::adapter::{
|
use crate::socket::adapter::{
|
||||||
Adapter, AdapterError, BroadcastOptions, BusMessage, LocalBroadcastFn, SocketInfo,
|
Adapter, AdapterError, BroadcastOptions, BusMessage, LocalBroadcastFn, SocketInfo,
|
||||||
};
|
};
|
||||||
|
use crate::socket::message_bus::redis::RedisCommandClient;
|
||||||
use crate::socket::message_bus::MessageBus;
|
use crate::socket::message_bus::MessageBus;
|
||||||
use crate::socket::packet::Packet;
|
use crate::socket::packet::Packet;
|
||||||
use crate::socket::parser;
|
use crate::socket::parser;
|
||||||
@@ -68,7 +67,7 @@ async fn handle_bus_message(
|
|||||||
|
|
||||||
pub struct RedisAdapter {
|
pub struct RedisAdapter {
|
||||||
message_bus: Arc<dyn MessageBus>,
|
message_bus: Arc<dyn MessageBus>,
|
||||||
redis_client: Client,
|
redis_client: RedisCommandClient,
|
||||||
room_subscribers: DashMap<String, mpsc::Receiver<Vec<u8>>>,
|
room_subscribers: DashMap<String, mpsc::Receiver<Vec<u8>>>,
|
||||||
socket_rooms: DashMap<String, HashSet<String>>,
|
socket_rooms: DashMap<String, HashSet<String>>,
|
||||||
rooms: DashMap<String, HashSet<String>>,
|
rooms: DashMap<String, HashSet<String>>,
|
||||||
@@ -83,7 +82,7 @@ pub struct RedisAdapter {
|
|||||||
impl RedisAdapter {
|
impl RedisAdapter {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
message_bus: Arc<dyn MessageBus>,
|
message_bus: Arc<dyn MessageBus>,
|
||||||
redis_client: Client,
|
redis_client: RedisCommandClient,
|
||||||
server_id: String,
|
server_id: String,
|
||||||
namespace: String,
|
namespace: String,
|
||||||
on_local_broadcast: LocalBroadcastFn,
|
on_local_broadcast: LocalBroadcastFn,
|
||||||
@@ -195,12 +194,12 @@ impl Adapter for RedisAdapter {
|
|||||||
let srk = socket_rooms_key(ns, sid);
|
let srk = socket_rooms_key(ns, sid);
|
||||||
|
|
||||||
self.redis_client
|
self.redis_client
|
||||||
.sadd::<(), _, _>(&rk, sid)
|
.query::<()>(redis::cmd("SADD").arg(&rk).arg(sid))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AdapterError::Redis(e.to_string()))?;
|
.map_err(|e| AdapterError::Redis(e.to_string()))?;
|
||||||
|
|
||||||
self.redis_client
|
self.redis_client
|
||||||
.sadd::<(), _, _>(&srk, room)
|
.query::<()>(redis::cmd("SADD").arg(&srk).arg(room))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AdapterError::Redis(e.to_string()))?;
|
.map_err(|e| AdapterError::Redis(e.to_string()))?;
|
||||||
|
|
||||||
@@ -241,12 +240,12 @@ impl Adapter for RedisAdapter {
|
|||||||
let srk = socket_rooms_key(ns, sid);
|
let srk = socket_rooms_key(ns, sid);
|
||||||
|
|
||||||
self.redis_client
|
self.redis_client
|
||||||
.srem::<(), _, _>(&rk, sid)
|
.query::<()>(redis::cmd("SREM").arg(&rk).arg(sid))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AdapterError::Redis(e.to_string()))?;
|
.map_err(|e| AdapterError::Redis(e.to_string()))?;
|
||||||
|
|
||||||
self.redis_client
|
self.redis_client
|
||||||
.srem::<(), _, _>(&srk, room)
|
.query::<()>(redis::cmd("SREM").arg(&srk).arg(room))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AdapterError::Redis(e.to_string()))?;
|
.map_err(|e| AdapterError::Redis(e.to_string()))?;
|
||||||
|
|
||||||
@@ -308,7 +307,11 @@ impl Adapter for RedisAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let rk = room_key(ns, room);
|
let rk = room_key(ns, room);
|
||||||
if let Err(e) = self.redis_client.srem::<(), _, _>(&rk, sid).await {
|
if let Err(e) = self
|
||||||
|
.redis_client
|
||||||
|
.query::<()>(redis::cmd("SREM").arg(&rk).arg(sid))
|
||||||
|
.await
|
||||||
|
{
|
||||||
tracing::warn!("Redis SREM room error: {}", e);
|
tracing::warn!("Redis SREM room error: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -316,7 +319,7 @@ impl Adapter for RedisAdapter {
|
|||||||
|
|
||||||
let srk = socket_rooms_key(ns, sid);
|
let srk = socket_rooms_key(ns, sid);
|
||||||
self.redis_client
|
self.redis_client
|
||||||
.del::<(), _>(&srk)
|
.query::<()>(redis::cmd("DEL").arg(&srk))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AdapterError::Redis(e.to_string()))?;
|
.map_err(|e| AdapterError::Redis(e.to_string()))?;
|
||||||
|
|
||||||
|
|||||||
+176
-51
@@ -1,54 +1,106 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use fred::clients::{Client, SubscriberClient};
|
use futures_util::StreamExt;
|
||||||
use fred::interfaces::{ClientLike, EventInterface, PubsubInterface};
|
use redis::aio::ConnectionManager;
|
||||||
use fred::prelude::*;
|
use redis::cluster::ClusterClient;
|
||||||
|
use redis::cluster_async::ClusterConnection;
|
||||||
|
use redis::{Client, FromRedisValue};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
use tokio::time::{Duration, timeout};
|
||||||
|
|
||||||
use crate::socket::message_bus::{MessageBus, MessageBusError};
|
use crate::socket::message_bus::{MessageBus, MessageBusError};
|
||||||
|
|
||||||
|
const REDIS_CONNECT_TIMEOUT_SECS: u64 = 5;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub enum RedisCommandClient {
|
||||||
|
Single(ConnectionManager),
|
||||||
|
Cluster(ClusterConnection),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RedisCommandClient {
|
||||||
|
pub async fn query<T: FromRedisValue>(&self, cmd: &mut redis::Cmd) -> redis::RedisResult<T> {
|
||||||
|
match self {
|
||||||
|
Self::Single(conn) => {
|
||||||
|
let mut conn = conn.clone();
|
||||||
|
cmd.query_async(&mut conn).await
|
||||||
|
}
|
||||||
|
Self::Cluster(conn) => {
|
||||||
|
let mut conn = conn.clone();
|
||||||
|
cmd.query_async(&mut conn).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct RedisMessageBus {
|
pub struct RedisMessageBus {
|
||||||
client: Client,
|
command_client: RedisCommandClient,
|
||||||
subscriber: SubscriberClient,
|
pubsub_client: Client,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RedisMessageBus {
|
impl RedisMessageBus {
|
||||||
/// Connect to a Redis cluster.
|
/// Connect to Redis using the same `redis` crate as appks.
|
||||||
///
|
///
|
||||||
/// `cluster_url` should be in `redis-cluster://` format, e.g.:
|
/// Supports both single-node `redis://host:port` and cluster
|
||||||
/// `redis-cluster://host1:6379,host2:6379,host3:6379`
|
/// `redis-cluster://host1:6379?node=host2:6379` URLs.
|
||||||
pub async fn new(cluster_url: &str) -> Result<Self, MessageBusError> {
|
pub async fn new(redis_url: &str) -> Result<Self, MessageBusError> {
|
||||||
let config =
|
tracing::info!("Connecting to Redis");
|
||||||
Config::from_url(cluster_url).map_err(|e| MessageBusError::Redis(e.to_string()))?;
|
|
||||||
|
|
||||||
let client = Client::new(config.clone(), None, None, None);
|
let connect_timeout = Duration::from_secs(REDIS_CONNECT_TIMEOUT_SECS);
|
||||||
let subscriber = SubscriberClient::new(config, None, None, None);
|
let parsed = parse_redis_url(redis_url)?;
|
||||||
|
|
||||||
let _ = client.connect().await;
|
let (command_client, pubsub_url) = match parsed {
|
||||||
let _ = subscriber.connect().await;
|
ParsedRedisConfig::Single(url) => {
|
||||||
|
let client = Client::open(url.as_str())
|
||||||
client
|
|
||||||
.wait_for_connect()
|
|
||||||
.await
|
|
||||||
.map_err(|e| MessageBusError::Redis(e.to_string()))?;
|
.map_err(|e| MessageBusError::Redis(e.to_string()))?;
|
||||||
subscriber
|
let conn = timeout(connect_timeout, client.get_connection_manager())
|
||||||
.wait_for_connect()
|
|
||||||
.await
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
MessageBusError::Redis(format!(
|
||||||
|
"Redis connection timeout after {REDIS_CONNECT_TIMEOUT_SECS}s"
|
||||||
|
))
|
||||||
|
})?
|
||||||
|
.map_err(|e| MessageBusError::Redis(e.to_string()))?;
|
||||||
|
(RedisCommandClient::Single(conn), url)
|
||||||
|
}
|
||||||
|
ParsedRedisConfig::Cluster(nodes) => {
|
||||||
|
let cluster_client = ClusterClient::new(nodes.iter().map(String::as_str).collect::<Vec<_>>())
|
||||||
|
.map_err(|e| MessageBusError::Redis(e.to_string()))?;
|
||||||
|
let conn = timeout(connect_timeout, cluster_client.get_async_connection())
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
MessageBusError::Redis(format!(
|
||||||
|
"Redis cluster connection timeout after {REDIS_CONNECT_TIMEOUT_SECS}s"
|
||||||
|
))
|
||||||
|
})?
|
||||||
|
.map_err(|e| MessageBusError::Redis(e.to_string()))?;
|
||||||
|
let pubsub_url = nodes
|
||||||
|
.first()
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| MessageBusError::Redis("Redis cluster nodes are empty".into()))?;
|
||||||
|
(RedisCommandClient::Cluster(conn), pubsub_url)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let pubsub_client = Client::open(pubsub_url.as_str())
|
||||||
.map_err(|e| MessageBusError::Redis(e.to_string()))?;
|
.map_err(|e| MessageBusError::Redis(e.to_string()))?;
|
||||||
|
|
||||||
tracing::info!(cluster_url, "Redis cluster connected");
|
tracing::info!("Redis connected");
|
||||||
Ok(Self { client, subscriber })
|
Ok(Self {
|
||||||
|
command_client,
|
||||||
|
pubsub_client,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn client(&self) -> &Client {
|
pub fn client(&self) -> RedisCommandClient {
|
||||||
&self.client
|
self.command_client.clone()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl MessageBus for RedisMessageBus {
|
impl MessageBus for RedisMessageBus {
|
||||||
async fn publish(&self, channel: &str, message: &[u8]) -> Result<(), MessageBusError> {
|
async fn publish(&self, channel: &str, message: &[u8]) -> Result<(), MessageBusError> {
|
||||||
self.client
|
self.command_client
|
||||||
.publish::<(), _, Vec<u8>>(channel, message.to_vec())
|
.query::<()>(redis::cmd("PUBLISH").arg(channel).arg(message))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| MessageBusError::Redis(e.to_string()))?;
|
.map_err(|e| MessageBusError::Redis(e.to_string()))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -56,23 +108,27 @@ impl MessageBus for RedisMessageBus {
|
|||||||
|
|
||||||
async fn subscribe(&self, channel: &str) -> Result<mpsc::Receiver<Vec<u8>>, MessageBusError> {
|
async fn subscribe(&self, channel: &str) -> Result<mpsc::Receiver<Vec<u8>>, MessageBusError> {
|
||||||
let (tx, rx) = mpsc::channel::<Vec<u8>>(256);
|
let (tx, rx) = mpsc::channel::<Vec<u8>>(256);
|
||||||
|
let mut pubsub = self
|
||||||
self.subscriber
|
.pubsub_client
|
||||||
.subscribe(channel.to_string())
|
.get_async_pubsub()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| MessageBusError::Redis(e.to_string()))?;
|
.map_err(|e| MessageBusError::Redis(e.to_string()))?;
|
||||||
|
|
||||||
let subscriber = self.subscriber.clone();
|
pubsub
|
||||||
let channel_owned = channel.to_string();
|
.subscribe(channel)
|
||||||
let mut message_rx = subscriber.message_rx();
|
.await
|
||||||
|
.map_err(|e| MessageBusError::Redis(e.to_string()))?;
|
||||||
|
|
||||||
|
let channel_owned = channel.to_string();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Ok(message) = message_rx.recv().await {
|
let mut stream = pubsub.on_message();
|
||||||
if message.channel == channel_owned {
|
while let Some(message) = stream.next().await {
|
||||||
let data: Vec<u8> = FromValue::from_value(message.value).unwrap_or_default();
|
if message.get_channel_name() != channel_owned {
|
||||||
if tx.send(data).await.is_err() {
|
continue;
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
let payload = message.get_payload::<Vec<u8>>().unwrap_or_default();
|
||||||
|
if tx.send(payload).await.is_err() {
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -80,23 +136,92 @@ impl MessageBus for RedisMessageBus {
|
|||||||
Ok(rx)
|
Ok(rx)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn unsubscribe(&self, channel: &str) -> Result<(), MessageBusError> {
|
async fn unsubscribe(&self, _channel: &str) -> Result<(), MessageBusError> {
|
||||||
self.subscriber
|
// Each subscription owns its dedicated async PubSub connection inside
|
||||||
.unsubscribe(channel.to_string())
|
// the spawned listener task. Dropping the receiver stops local delivery.
|
||||||
.await
|
|
||||||
.map_err(|e| MessageBusError::Redis(e.to_string()))?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn close(&self) -> Result<(), MessageBusError> {
|
async fn close(&self) -> Result<(), MessageBusError> {
|
||||||
self.client
|
|
||||||
.quit()
|
|
||||||
.await
|
|
||||||
.map_err(|e| MessageBusError::Redis(e.to_string()))?;
|
|
||||||
self.subscriber
|
|
||||||
.quit()
|
|
||||||
.await
|
|
||||||
.map_err(|e| MessageBusError::Redis(e.to_string()))?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum ParsedRedisConfig {
|
||||||
|
Single(String),
|
||||||
|
Cluster(Vec<String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_redis_url(redis_url: &str) -> Result<ParsedRedisConfig, MessageBusError> {
|
||||||
|
let Some(rest) = redis_url.strip_prefix("redis-cluster://") else {
|
||||||
|
return Ok(ParsedRedisConfig::Single(redis_url.to_string()));
|
||||||
|
};
|
||||||
|
|
||||||
|
let (first, query) = rest.split_once('?').unwrap_or((rest, ""));
|
||||||
|
let (auth, first_node) = split_auth(first);
|
||||||
|
let mut nodes = Vec::new();
|
||||||
|
|
||||||
|
if !first_node.is_empty() {
|
||||||
|
nodes.push(to_redis_node_url(auth, first_node));
|
||||||
|
}
|
||||||
|
|
||||||
|
for part in query.split('&') {
|
||||||
|
let Some(node) = part.strip_prefix("node=") else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !node.is_empty() {
|
||||||
|
nodes.push(to_redis_node_url(auth, node));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if nodes.is_empty() {
|
||||||
|
return Err(MessageBusError::Redis("Redis cluster URL has no nodes".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ParsedRedisConfig::Cluster(nodes))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn split_auth(value: &str) -> (&str, &str) {
|
||||||
|
value
|
||||||
|
.rsplit_once('@')
|
||||||
|
.map(|(auth, host)| (auth, host))
|
||||||
|
.unwrap_or(("", value))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_redis_node_url(auth: &str, node: &str) -> String {
|
||||||
|
if node.starts_with("redis://") || node.starts_with("rediss://") {
|
||||||
|
return node.to_string();
|
||||||
|
}
|
||||||
|
if auth.is_empty() {
|
||||||
|
format!("redis://{node}")
|
||||||
|
} else {
|
||||||
|
format!("redis://{auth}@{node}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_single_redis_url() {
|
||||||
|
match parse_redis_url("redis://127.0.0.1:6379").unwrap() {
|
||||||
|
ParsedRedisConfig::Single(url) => assert_eq!(url, "redis://127.0.0.1:6379"),
|
||||||
|
ParsedRedisConfig::Cluster(_) => panic!("expected single redis config"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_cluster_url_for_redis_rs() {
|
||||||
|
match parse_redis_url("redis-cluster://:pass@127.0.0.1:6380?node=127.0.0.1:6381").unwrap() {
|
||||||
|
ParsedRedisConfig::Cluster(nodes) => assert_eq!(
|
||||||
|
nodes,
|
||||||
|
vec![
|
||||||
|
"redis://:pass@127.0.0.1:6380".to_string(),
|
||||||
|
"redis://:pass@127.0.0.1:6381".to_string()
|
||||||
|
]
|
||||||
|
),
|
||||||
|
ParsedRedisConfig::Single(_) => panic!("expected cluster redis config"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use fred::prelude::*;
|
|
||||||
|
|
||||||
|
use crate::socket::message_bus::redis::RedisCommandClient;
|
||||||
use crate::socket::message_bus::redis::RedisMessageBus;
|
use crate::socket::message_bus::redis::RedisMessageBus;
|
||||||
use crate::socket::session_store::{SessionError, SessionInfo, SessionStoreTrait};
|
use crate::socket::session_store::{SessionError, SessionInfo, SessionStoreTrait};
|
||||||
|
|
||||||
@@ -17,14 +17,14 @@ const DEFAULT_TTL_SECS: u64 = 60;
|
|||||||
const KEY_PREFIX: &str = "socket.io:session";
|
const KEY_PREFIX: &str = "socket.io:session";
|
||||||
|
|
||||||
pub struct RedisSessionStore {
|
pub struct RedisSessionStore {
|
||||||
client: Client,
|
client: RedisCommandClient,
|
||||||
ttl_secs: u64,
|
ttl_secs: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RedisSessionStore {
|
impl RedisSessionStore {
|
||||||
pub fn new(bus: &RedisMessageBus, ttl_secs: Option<u64>) -> Self {
|
pub fn new(bus: &RedisMessageBus, ttl_secs: Option<u64>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
client: bus.client().clone(),
|
client: bus.client(),
|
||||||
ttl_secs: ttl_secs.unwrap_or(DEFAULT_TTL_SECS),
|
ttl_secs: ttl_secs.unwrap_or(DEFAULT_TTL_SECS),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -45,22 +45,29 @@ impl SessionStoreTrait for RedisSessionStore {
|
|||||||
let key = self.key(sid);
|
let key = self.key(sid);
|
||||||
let now = now_millis();
|
let now = now_millis();
|
||||||
|
|
||||||
// Batch all fields in a single HSET call for efficiency
|
// Batch all fields in a single HMSET-style call
|
||||||
let fields: Vec<(&str, String)> = vec![
|
|
||||||
("sid", sid.to_string()),
|
|
||||||
("transport", transport.to_string()),
|
|
||||||
("state", "connecting".to_string()),
|
|
||||||
("server_id", server_id.to_string()),
|
|
||||||
("created_at", now.to_string()),
|
|
||||||
("last_ping", now.to_string()),
|
|
||||||
];
|
|
||||||
self.client
|
self.client
|
||||||
.hset::<(), _, _>(&key, fields)
|
.query::<()>(
|
||||||
|
redis::cmd("HSET")
|
||||||
|
.arg(&key)
|
||||||
|
.arg("sid")
|
||||||
|
.arg(sid)
|
||||||
|
.arg("transport")
|
||||||
|
.arg(transport)
|
||||||
|
.arg("state")
|
||||||
|
.arg("connecting")
|
||||||
|
.arg("server_id")
|
||||||
|
.arg(server_id)
|
||||||
|
.arg("created_at")
|
||||||
|
.arg(now.to_string())
|
||||||
|
.arg("last_ping")
|
||||||
|
.arg(now.to_string()),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| SessionError::Redis(e.to_string()))?;
|
.map_err(|e| SessionError::Redis(e.to_string()))?;
|
||||||
|
|
||||||
self.client
|
self.client
|
||||||
.expire::<(), _>(&key, self.ttl_secs as i64, None)
|
.query::<()>(redis::cmd("EXPIRE").arg(&key).arg(self.ttl_secs as i64))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| SessionError::Redis(e.to_string()))?;
|
.map_err(|e| SessionError::Redis(e.to_string()))?;
|
||||||
|
|
||||||
@@ -70,11 +77,9 @@ impl SessionStoreTrait for RedisSessionStore {
|
|||||||
async fn get(&self, sid: &str) -> Result<Option<SessionInfo>, SessionError> {
|
async fn get(&self, sid: &str) -> Result<Option<SessionInfo>, SessionError> {
|
||||||
let key = self.key(sid);
|
let key = self.key(sid);
|
||||||
|
|
||||||
// Use hgetall directly — if the key doesn't exist Redis returns an empty map.
|
|
||||||
// This avoids the TOCTOU race between EXISTS and HGETALL.
|
|
||||||
let values: std::collections::HashMap<String, String> = self
|
let values: std::collections::HashMap<String, String> = self
|
||||||
.client
|
.client
|
||||||
.hgetall::<std::collections::HashMap<String, String>, _>(&key)
|
.query(redis::cmd("HGETALL").arg(&key))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| SessionError::Redis(e.to_string()))?;
|
.map_err(|e| SessionError::Redis(e.to_string()))?;
|
||||||
|
|
||||||
@@ -103,14 +108,13 @@ impl SessionStoreTrait for RedisSessionStore {
|
|||||||
async fn set_state(&self, sid: &str, state: &str) -> Result<(), SessionError> {
|
async fn set_state(&self, sid: &str, state: &str) -> Result<(), SessionError> {
|
||||||
let key = self.key(sid);
|
let key = self.key(sid);
|
||||||
|
|
||||||
// Use HSET (not HSETNX) to overwrite existing fields
|
|
||||||
self.client
|
self.client
|
||||||
.hset::<(), _, _>(&key, ("state", state))
|
.query::<()>(redis::cmd("HSET").arg(&key).arg("state").arg(state))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| SessionError::Redis(e.to_string()))?;
|
.map_err(|e| SessionError::Redis(e.to_string()))?;
|
||||||
|
|
||||||
self.client
|
self.client
|
||||||
.expire::<(), _>(&key, self.ttl_secs as i64, None)
|
.query::<()>(redis::cmd("EXPIRE").arg(&key).arg(self.ttl_secs as i64))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| SessionError::Redis(e.to_string()))?;
|
.map_err(|e| SessionError::Redis(e.to_string()))?;
|
||||||
|
|
||||||
@@ -120,14 +124,18 @@ impl SessionStoreTrait for RedisSessionStore {
|
|||||||
async fn set_transport(&self, sid: &str, transport: &str) -> Result<(), SessionError> {
|
async fn set_transport(&self, sid: &str, transport: &str) -> Result<(), SessionError> {
|
||||||
let key = self.key(sid);
|
let key = self.key(sid);
|
||||||
|
|
||||||
// Use HSET (not HSETNX) to overwrite existing fields
|
|
||||||
self.client
|
self.client
|
||||||
.hset::<(), _, _>(&key, ("transport", transport))
|
.query::<()>(
|
||||||
|
redis::cmd("HSET")
|
||||||
|
.arg(&key)
|
||||||
|
.arg("transport")
|
||||||
|
.arg(transport),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| SessionError::Redis(e.to_string()))?;
|
.map_err(|e| SessionError::Redis(e.to_string()))?;
|
||||||
|
|
||||||
self.client
|
self.client
|
||||||
.expire::<(), _>(&key, self.ttl_secs as i64, None)
|
.query::<()>(redis::cmd("EXPIRE").arg(&key).arg(self.ttl_secs as i64))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| SessionError::Redis(e.to_string()))?;
|
.map_err(|e| SessionError::Redis(e.to_string()))?;
|
||||||
|
|
||||||
@@ -138,14 +146,18 @@ impl SessionStoreTrait for RedisSessionStore {
|
|||||||
let key = self.key(sid);
|
let key = self.key(sid);
|
||||||
let now = now_millis();
|
let now = now_millis();
|
||||||
|
|
||||||
// Use HSET (not HSETNX) to overwrite existing fields
|
|
||||||
self.client
|
self.client
|
||||||
.hset::<(), _, _>(&key, ("last_ping", now.to_string()))
|
.query::<()>(
|
||||||
|
redis::cmd("HSET")
|
||||||
|
.arg(&key)
|
||||||
|
.arg("last_ping")
|
||||||
|
.arg(now.to_string()),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| SessionError::Redis(e.to_string()))?;
|
.map_err(|e| SessionError::Redis(e.to_string()))?;
|
||||||
|
|
||||||
self.client
|
self.client
|
||||||
.expire::<(), _>(&key, self.ttl_secs as i64, None)
|
.query::<()>(redis::cmd("EXPIRE").arg(&key).arg(self.ttl_secs as i64))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| SessionError::Redis(e.to_string()))?;
|
.map_err(|e| SessionError::Redis(e.to_string()))?;
|
||||||
|
|
||||||
@@ -156,7 +168,7 @@ impl SessionStoreTrait for RedisSessionStore {
|
|||||||
let key = self.key(sid);
|
let key = self.key(sid);
|
||||||
|
|
||||||
self.client
|
self.client
|
||||||
.del::<(), _>(&key)
|
.query::<()>(redis::cmd("DEL").arg(&key))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| SessionError::Redis(e.to_string()))?;
|
.map_err(|e| SessionError::Redis(e.to_string()))?;
|
||||||
|
|
||||||
@@ -168,7 +180,7 @@ impl SessionStoreTrait for RedisSessionStore {
|
|||||||
|
|
||||||
let exists: bool = self
|
let exists: bool = self
|
||||||
.client
|
.client
|
||||||
.exists::<bool, _>(&key)
|
.query(redis::cmd("EXISTS").arg(&key))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| SessionError::Redis(e.to_string()))?;
|
.map_err(|e| SessionError::Redis(e.to_string()))?;
|
||||||
|
|
||||||
|
|||||||
+128
-5
@@ -10,6 +10,10 @@ use std::env;
|
|||||||
pub struct DeployConfig {
|
pub struct DeployConfig {
|
||||||
/// "local" | "redis" | "nats"
|
/// "local" | "redis" | "nats"
|
||||||
pub adapter_mode: String,
|
pub adapter_mode: String,
|
||||||
|
/// Redis URL for single-node Redis, e.g. `redis://localhost:6379`.
|
||||||
|
pub redis_url: Option<String>,
|
||||||
|
/// Whether Redis cluster mode is enabled.
|
||||||
|
pub redis_cluster_enabled: bool,
|
||||||
/// Redis cluster nodes, comma-separated host:port pairs.
|
/// Redis cluster nodes, comma-separated host:port pairs.
|
||||||
/// Example: "redis1:6379,redis2:6379,redis3:6379"
|
/// Example: "redis1:6379,redis2:6379,redis3:6379"
|
||||||
pub redis_cluster_nodes: String,
|
pub redis_cluster_nodes: String,
|
||||||
@@ -35,9 +39,19 @@ impl DeployConfig {
|
|||||||
|
|
||||||
Self {
|
Self {
|
||||||
adapter_mode: env::var("IMKS_ADAPTER").unwrap_or_else(|_| "local".into()),
|
adapter_mode: env::var("IMKS_ADAPTER").unwrap_or_else(|_| "local".into()),
|
||||||
|
redis_url: env::var("IMKS_REDIS_URL")
|
||||||
|
.ok()
|
||||||
|
.or_else(|| env::var("APP_REDIS_URL").ok())
|
||||||
|
.filter(|v| !v.trim().is_empty()),
|
||||||
|
redis_cluster_enabled: env_bool("IMKS_REDIS_CLUSTER_ENABLED")
|
||||||
|
.or_else(|| env_bool("APP_REDIS_CLUSTER_ENABLED"))
|
||||||
|
.unwrap_or(false),
|
||||||
redis_cluster_nodes: env::var("IMKS_REDIS_CLUSTER_NODES")
|
redis_cluster_nodes: env::var("IMKS_REDIS_CLUSTER_NODES")
|
||||||
.unwrap_or_else(|_| "localhost:6379,localhost:6380,localhost:6381".into()),
|
.or_else(|_| env::var("APP_REDIS_CLUSTER_NODES"))
|
||||||
redis_password: env::var("IMKS_REDIS_PASSWORD").unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
|
redis_password: env::var("IMKS_REDIS_PASSWORD")
|
||||||
|
.or_else(|_| env::var("APP_REDIS_PASSWORD"))
|
||||||
|
.unwrap_or_default(),
|
||||||
nats_url: env::var("IMKS_NATS_URL").unwrap_or_else(|_| "nats://localhost:4222".into()),
|
nats_url: env::var("IMKS_NATS_URL").unwrap_or_else(|_| "nats://localhost:4222".into()),
|
||||||
server_id,
|
server_id,
|
||||||
webtransport_enabled: env::var("IMKS_WT_ENABLED")
|
webtransport_enabled: env::var("IMKS_WT_ENABLED")
|
||||||
@@ -52,15 +66,56 @@ impl DeployConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a redis-rs compatible Redis URL.
|
||||||
|
///
|
||||||
|
/// This mirrors appks:
|
||||||
|
/// - cluster disabled: require `IMKS_REDIS_URL` or `APP_REDIS_URL`
|
||||||
|
/// - cluster enabled: require `IMKS_REDIS_CLUSTER_NODES` or `APP_REDIS_CLUSTER_NODES`
|
||||||
|
pub fn redis_url(&self) -> Result<String, String> {
|
||||||
|
if self.redis_cluster_enabled {
|
||||||
|
return self.redis_cluster_url();
|
||||||
|
}
|
||||||
|
|
||||||
|
self.redis_url.clone().ok_or_else(|| {
|
||||||
|
"Redis cluster disabled but IMKS_REDIS_URL/APP_REDIS_URL is not set".into()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Build a redis-cluster URL from cluster_nodes and optional password.
|
/// Build a redis-cluster URL from cluster_nodes and optional password.
|
||||||
/// Format: redis-cluster://[:password@]host1:port1,host2:port2,...
|
///
|
||||||
pub fn redis_cluster_url(&self) -> String {
|
/// Produces a redis-rs compatible URL in the format:
|
||||||
|
/// `redis-cluster://[password@]host1:port1?node=host2:port2&node=host3:port3`
|
||||||
|
///
|
||||||
|
/// The first node becomes the URL authority host:port; additional nodes
|
||||||
|
/// are appended as `node` query parameters.
|
||||||
|
pub fn redis_cluster_url(&self) -> Result<String, String> {
|
||||||
let auth = if self.redis_password.is_empty() {
|
let auth = if self.redis_password.is_empty() {
|
||||||
String::new()
|
String::new()
|
||||||
} else {
|
} else {
|
||||||
format!(":{}@", self.redis_password)
|
format!(":{}@", self.redis_password)
|
||||||
};
|
};
|
||||||
format!("redis-cluster://{}{}", auth, self.redis_cluster_nodes)
|
|
||||||
|
let nodes: Vec<String> = self
|
||||||
|
.redis_cluster_nodes
|
||||||
|
.split(',')
|
||||||
|
.map(normalize_redis_cluster_node)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if nodes.is_empty() {
|
||||||
|
return Err("Redis cluster enabled but IMKS_REDIS_CLUSTER_NODES/APP_REDIS_CLUSTER_NODES is empty".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
if nodes.len() == 1 {
|
||||||
|
return Ok(format!("redis-cluster://{}{}", auth, nodes[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut url = format!("redis-cluster://{}{}", auth, nodes[0]);
|
||||||
|
for (i, node) in nodes.iter().skip(1).enumerate() {
|
||||||
|
url.push(if i == 0 { '?' } else { '&' });
|
||||||
|
url.push_str(&format!("node={}", node));
|
||||||
|
}
|
||||||
|
Ok(url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,8 +125,76 @@ impl Default for DeployConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn env_bool(key: &str) -> Option<bool> {
|
||||||
|
env::var(key).ok().map(|v| v == "true" || v == "1")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_redis_cluster_node(node: &str) -> String {
|
||||||
|
let trimmed = node.trim();
|
||||||
|
let without_scheme = trimmed
|
||||||
|
.strip_prefix("redis://")
|
||||||
|
.or_else(|| trimmed.strip_prefix("rediss://"))
|
||||||
|
.unwrap_or(trimmed);
|
||||||
|
let without_auth = without_scheme
|
||||||
|
.rsplit_once('@')
|
||||||
|
.map(|(_, host)| host)
|
||||||
|
.unwrap_or(without_scheme);
|
||||||
|
without_auth
|
||||||
|
.split('/')
|
||||||
|
.next()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
fn hostname() -> String {
|
fn hostname() -> String {
|
||||||
env::var("HOSTNAME")
|
env::var("HOSTNAME")
|
||||||
.or_else(|_| env::var("HOST"))
|
.or_else(|_| env::var("HOST"))
|
||||||
.unwrap_or_else(|_| "imks-node-1".into())
|
.unwrap_or_else(|_| "imks-node-1".into())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_cluster_nodes_accepts_appks_style_urls() {
|
||||||
|
assert_eq!(
|
||||||
|
normalize_redis_cluster_node("redis://127.0.0.1:6380"),
|
||||||
|
"127.0.0.1:6380"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_redis_cluster_node("rediss://127.0.0.1:6381/0"),
|
||||||
|
"127.0.0.1:6381"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_redis_cluster_node("redis://:secret@127.0.0.1:6382"),
|
||||||
|
"127.0.0.1:6382"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_redis_cluster_node("127.0.0.1:6383"),
|
||||||
|
"127.0.0.1:6383"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn redis_cluster_url_normalizes_nodes() {
|
||||||
|
let config = DeployConfig {
|
||||||
|
adapter_mode: "redis".into(),
|
||||||
|
redis_url: None,
|
||||||
|
redis_cluster_enabled: true,
|
||||||
|
redis_cluster_nodes: "redis://127.0.0.1:6380,redis://127.0.0.1:6381".into(),
|
||||||
|
redis_password: String::new(),
|
||||||
|
nats_url: "nats://localhost:4222".into(),
|
||||||
|
server_id: "test".into(),
|
||||||
|
webtransport_enabled: false,
|
||||||
|
webtransport_port: 3001,
|
||||||
|
cert_path: String::new(),
|
||||||
|
key_path: String::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
config.redis_cluster_url().unwrap(),
|
||||||
|
"redis-cluster://127.0.0.1:6380?node=127.0.0.1:6381"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+1
-2
@@ -34,8 +34,7 @@ impl MessageService {
|
|||||||
let thread_id: Option<Uuid> = Self::parse_optional(payload, "thread_id")?;
|
let thread_id: Option<Uuid> = Self::parse_optional(payload, "thread_id")?;
|
||||||
let reply_to_message_id: Option<Uuid> =
|
let reply_to_message_id: Option<Uuid> =
|
||||||
Self::parse_optional(payload, "reply_to_message_id")?;
|
Self::parse_optional(payload, "reply_to_message_id")?;
|
||||||
let metadata: Option<serde_json::Value> =
|
let metadata: Option<serde_json::Value> = Self::parse_optional(payload, "metadata")?;
|
||||||
Self::parse_optional(payload, "metadata")?;
|
|
||||||
let scheduled_at_str: String = Self::parse_field(payload, "scheduled_at")?;
|
let scheduled_at_str: String = Self::parse_field(payload, "scheduled_at")?;
|
||||||
|
|
||||||
let scheduled_at: DateTime<Utc> = chrono::DateTime::parse_from_rfc3339(&scheduled_at_str)
|
let scheduled_at: DateTime<Utc> = chrono::DateTime::parse_from_rfc3339(&scheduled_at_str)
|
||||||
|
|||||||
+1
-4
@@ -32,10 +32,7 @@ impl Default for TelemetryConfig {
|
|||||||
Self {
|
Self {
|
||||||
service_name: env_or("OTEL_SERVICE_NAME", "imks"),
|
service_name: env_or("OTEL_SERVICE_NAME", "imks"),
|
||||||
service_version: env_or("OTEL_SERVICE_VERSION", env!("CARGO_PKG_VERSION")),
|
service_version: env_or("OTEL_SERVICE_VERSION", env!("CARGO_PKG_VERSION")),
|
||||||
otlp_endpoint: env_or(
|
otlp_endpoint: env_or("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317"),
|
||||||
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
|
||||||
"http://localhost:4317",
|
|
||||||
),
|
|
||||||
otlp_protocol: detect_otlp_protocol(),
|
otlp_protocol: detect_otlp_protocol(),
|
||||||
traces_enabled: env_bool("OTEL_TRACES_ENABLED", true),
|
traces_enabled: env_bool("OTEL_TRACES_ENABLED", true),
|
||||||
metrics_enabled: env_bool("OTEL_METRICS_ENABLED", true),
|
metrics_enabled: env_bool("OTEL_METRICS_ENABLED", true),
|
||||||
|
|||||||
+2
-9
@@ -52,10 +52,7 @@ pub fn connections_active_count() -> u64 {
|
|||||||
|
|
||||||
/// Returns the server uptime in seconds.
|
/// Returns the server uptime in seconds.
|
||||||
pub fn uptime_secs() -> u64 {
|
pub fn uptime_secs() -> u64 {
|
||||||
START_TIME
|
START_TIME.get().map(|t| t.elapsed().as_secs()).unwrap_or(0)
|
||||||
.get()
|
|
||||||
.map(|t| t.elapsed().as_secs())
|
|
||||||
.unwrap_or(0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
@@ -147,11 +144,7 @@ pub async fn health_check(checks: actix_web::web::Data<Arc<HealthCheckFns>>) ->
|
|||||||
.iter()
|
.iter()
|
||||||
.filter_map(|c| c.as_ref())
|
.filter_map(|c| c.as_ref())
|
||||||
.all(|c| c.status == "up");
|
.all(|c| c.status == "up");
|
||||||
if all_up {
|
if all_up { "healthy" } else { "degraded" }
|
||||||
"healthy"
|
|
||||||
} else {
|
|
||||||
"degraded"
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
"healthy"
|
"healthy"
|
||||||
};
|
};
|
||||||
|
|||||||
+7
-7
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge;
|
use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge;
|
||||||
use opentelemetry_otlp::{LogExporter, Protocol, WithExportConfig};
|
use opentelemetry_otlp::{LogExporter, Protocol, WithExportConfig};
|
||||||
use opentelemetry_sdk::logs::SdkLoggerProvider;
|
|
||||||
use opentelemetry_sdk::Resource;
|
use opentelemetry_sdk::Resource;
|
||||||
use tracing_subscriber::fmt::format::FmtSpan;
|
use opentelemetry_sdk::logs::SdkLoggerProvider;
|
||||||
use tracing_subscriber::layer::SubscriberExt;
|
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
use tracing_subscriber::Registry;
|
use tracing_subscriber::Registry;
|
||||||
|
use tracing_subscriber::fmt::format::FmtSpan;
|
||||||
|
use tracing_subscriber::layer::SubscriberExt;
|
||||||
|
|
||||||
use super::config::{OtlpProtocol, TelemetryConfig};
|
use super::config::{OtlpProtocol, TelemetryConfig};
|
||||||
use crate::ImksResult;
|
use crate::ImksResult;
|
||||||
@@ -35,7 +35,9 @@ pub fn init_subscriber(
|
|||||||
let (logger_provider, log_bridge_layer) = if config.logs_enabled {
|
let (logger_provider, log_bridge_layer) = if config.logs_enabled {
|
||||||
let exporter = build_log_exporter(config)?;
|
let exporter = build_log_exporter(config)?;
|
||||||
|
|
||||||
let resource = resource.cloned().unwrap_or_else(|| Resource::builder().build());
|
let resource = resource
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| Resource::builder().build());
|
||||||
|
|
||||||
let provider = SdkLoggerProvider::builder()
|
let provider = SdkLoggerProvider::builder()
|
||||||
.with_resource(resource)
|
.with_resource(resource)
|
||||||
@@ -72,9 +74,7 @@ pub fn init_subscriber(
|
|||||||
set_subscriber(subscriber);
|
set_subscriber(subscriber);
|
||||||
}
|
}
|
||||||
(None, None) => {
|
(None, None) => {
|
||||||
let subscriber = Registry::default()
|
let subscriber = Registry::default().with(env_filter).with(make_json_fmt());
|
||||||
.with(env_filter)
|
|
||||||
.with(make_json_fmt());
|
|
||||||
set_subscriber(subscriber);
|
set_subscriber(subscriber);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
use opentelemetry::KeyValue;
|
||||||
use opentelemetry::global;
|
use opentelemetry::global;
|
||||||
use opentelemetry::metrics::{Counter, Histogram, Meter, UpDownCounter};
|
use opentelemetry::metrics::{Counter, Histogram, Meter, UpDownCounter};
|
||||||
use opentelemetry::KeyValue;
|
|
||||||
use opentelemetry_sdk::metrics::SdkMeterProvider;
|
|
||||||
use opentelemetry_sdk::Resource;
|
use opentelemetry_sdk::Resource;
|
||||||
|
use opentelemetry_sdk::metrics::SdkMeterProvider;
|
||||||
use prometheus::{Encoder, Registry, TextEncoder};
|
use prometheus::{Encoder, Registry, TextEncoder};
|
||||||
|
|
||||||
use crate::ImksResult;
|
use crate::ImksResult;
|
||||||
@@ -47,7 +47,9 @@ pub fn init_metrics(
|
|||||||
let exporter = opentelemetry_prometheus::exporter()
|
let exporter = opentelemetry_prometheus::exporter()
|
||||||
.with_registry(registry)
|
.with_registry(registry)
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| crate::ImksError::Internal(format!("failed to build Prometheus exporter: {e}")))?;
|
.map_err(|e| {
|
||||||
|
crate::ImksError::Internal(format!("failed to build Prometheus exporter: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
let provider = SdkMeterProvider::builder()
|
let provider = SdkMeterProvider::builder()
|
||||||
.with_resource(resource.clone())
|
.with_resource(resource.clone())
|
||||||
@@ -153,7 +155,9 @@ impl MetricsInstruments {
|
|||||||
///
|
///
|
||||||
/// Encodes the Prometheus text format from the shared registry.
|
/// Encodes the Prometheus text format from the shared registry.
|
||||||
pub async fn metrics_handler() -> actix_web::HttpResponse {
|
pub async fn metrics_handler() -> actix_web::HttpResponse {
|
||||||
let registry = PROMETHEUS_REGISTRY.get().expect("Prometheus registry not initialized");
|
let registry = PROMETHEUS_REGISTRY
|
||||||
|
.get()
|
||||||
|
.expect("Prometheus registry not initialized");
|
||||||
|
|
||||||
let metric_families = registry.gather();
|
let metric_families = registry.gather();
|
||||||
let encoder = TextEncoder::new();
|
let encoder = TextEncoder::new();
|
||||||
|
|||||||
+2
-4
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
use opentelemetry::trace::TracerProvider as _;
|
use opentelemetry::trace::TracerProvider as _;
|
||||||
use opentelemetry_otlp::{Protocol, SpanExporter, WithExportConfig};
|
use opentelemetry_otlp::{Protocol, SpanExporter, WithExportConfig};
|
||||||
|
use opentelemetry_sdk::Resource;
|
||||||
use opentelemetry_sdk::propagation::TraceContextPropagator;
|
use opentelemetry_sdk::propagation::TraceContextPropagator;
|
||||||
use opentelemetry_sdk::trace::{SdkTracerProvider, Tracer};
|
use opentelemetry_sdk::trace::{SdkTracerProvider, Tracer};
|
||||||
use opentelemetry_sdk::Resource;
|
|
||||||
use tracing_opentelemetry::OpenTelemetryLayer;
|
use tracing_opentelemetry::OpenTelemetryLayer;
|
||||||
use tracing_subscriber::Registry;
|
use tracing_subscriber::Registry;
|
||||||
|
|
||||||
@@ -24,9 +24,7 @@ fn build_span_exporter(config: &TelemetryConfig) -> ImksResult<SpanExporter> {
|
|||||||
.with_protocol(Protocol::HttpBinary)
|
.with_protocol(Protocol::HttpBinary)
|
||||||
.with_endpoint(&config.otlp_endpoint)
|
.with_endpoint(&config.otlp_endpoint)
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| {
|
.map_err(|e| crate::ImksError::Internal(format!("OTLP HTTP span exporter: {e}"))),
|
||||||
crate::ImksError::Internal(format!("OTLP HTTP span exporter: {e}"))
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user