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:
zhenyi
2026-06-11 22:50:38 +08:00
parent e72866db8d
commit c794b818ff
24 changed files with 984 additions and 952 deletions
Generated
+87 -76
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+90 -45
View File
@@ -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();
match event.event_type() {
etcd_client::EventType::Put => { // Parse JSON to extract the actual address
tracing::info!(service = %svc, key, addr, "service up"); let addr = serde_json::from_str::<serde_json::Value>(raw)
} .ok()
etcd_client::EventType::Delete => { .and_then(|v| {
tracing::info!(service = %svc, key, "service down"); v.get("addr")
} .and_then(|a| a.as_str())
} .map(|s| s.to_string())
})
.unwrap_or_else(|| raw.to_string());
match event.event_type() {
EventType::Put => {
tracing::info!(service = %svc, key, addr, "service up");
on_up(addr);
}
EventType::Delete => {
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;
} }
}); });
+417 -209
View File
@@ -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,160 +43,359 @@ 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()?; // --- etcd: connect, register, discover appks ---
let etcd = EtcdConfig::connect(etcd_endpoints, &etcd_prefix)
.await
.unwrap_or_else(|e| {
tracing::error!(error = %e, "etcd required but unavailable");
panic!("etcd required: {e}")
});
rt.block_on(async { // Register this service so others can discover us
// --- etcd: connect, register, discover appks --- let registry = ServiceRegistry::new(etcd.client(), &etcd_prefix);
let etcd = EtcdConfig::connect(etcd_endpoints, &etcd_prefix).await let imks_addr = etcd.get("IMKS_ADDR", "0.0.0.0:3000").await;
.unwrap_or_else(|e| { registry.register("imks", &imks_addr).await.ok();
tracing::error!(error = %e, "etcd required but unavailable");
panic!("etcd required: {e}")
});
// Register this service so others can discover us // Discover appks from etcd (priority > env).
let registry = ServiceRegistry::new(etcd.client(), &etcd_prefix); // etcd-registered addresses are bare "host:port" — prepend http:// for gRPC.
let imks_addr = etcd.get("IMKS_ADDR", "0.0.0.0:3000").await; let appks_addr = etcd
registry.register("imks", &imks_addr).await.ok(); .discover_service("appks")
.await
.ok()
.and_then(|addrs| addrs.into_iter().next())
.map(|addr| format!("http://{}", addr))
.unwrap_or_else(|| {
std::env::var("APPKS_GRPC_ADDR")
.unwrap_or_else(|_| "http://localhost:50051".to_string())
});
tracing::info!(appks_addr = %appks_addr, "appks discovered via etcd");
// Discover appks from etcd (priority > env) // Track the currently active appks address for health checks.
let appks_addr = etcd.discover_service("appks").await // Updated on successful connect, cleared on health failure.
.ok() let current_appks_addr: Arc<RwLock<Option<String>>> = Arc::new(RwLock::new(None));
.and_then(|addrs| addrs.into_iter().next())
.unwrap_or_else(|| {
std::env::var("APPKS_GRPC_ADDR").unwrap_or_else(|_| "http://localhost:50051".to_string())
});
tracing::info!(appks_addr = %appks_addr, "appks discovered via etcd");
etcd.start_service_watcher("appks");
let engine_config = EngineConfig::default(); // Start imks's own gRPC health server early so Redis/NATS stalls do not hide it.
let mut builder = SocketServerBuilder::new(engine_config); // Default: imks RPC health 50047, separate from HTTP (50048) and appks gRPC (50049).
let namespace_holder: Arc<OnceLock<Arc<imks::socket::namespace::NamespaceManager>>> = {
Arc::new(OnceLock::new()); 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");
}
});
}
// Pre-configure adapter for Redis/NATS mode. let engine_config = EngineConfig::default();
match deploy.adapter_mode.as_str() { let mut builder = SocketServerBuilder::new(engine_config);
"redis" => { let namespace_holder: Arc<OnceLock<Arc<imks::socket::namespace::NamespaceManager>>> =
let cluster_url = deploy.redis_cluster_url(); Arc::new(OnceLock::new());
let message_bus = Arc::new(
RedisMessageBus::new(&cluster_url) // Pre-configure adapter for Redis/NATS mode.
.await match deploy.adapter_mode.as_str() {
.map_err(|e| format!("Failed to connect to Redis cluster: {e}"))?, "redis" => {
); let redis_url = deploy
let redis_client = message_bus.client().clone(); .redis_url()
let server_id = deploy.server_id.clone(); .map_err(|e| format!("Invalid Redis configuration: {e}"))?;
let adapter = Arc::new(RedisAdapter::new( tracing::info!(
message_bus.clone() as Arc<_>, cluster_enabled = deploy.redis_cluster_enabled,
redis_client, "Configuring Redis adapter"
server_id, );
"/".into(), let message_bus = Arc::new(
make_local_broadcast_fn(namespace_holder.clone()), RedisMessageBus::new(&redis_url)
));
adapter
.init()
.await .await
.map_err(|e| format!("Failed to initialize Redis adapter: {e}"))?; .map_err(|e| format!("Failed to connect to Redis: {e}"))?,
builder = builder.adapter(adapter); );
tracing::info!("Redis adapter configured for multi-node"); let redis_client = message_bus.client().clone();
} let server_id = deploy.server_id.clone();
"nats" => { let adapter = Arc::new(RedisAdapter::new(
let message_bus = Arc::new( message_bus.clone() as Arc<_>,
NatsMessageBus::new(&deploy.nats_url) redis_client,
.await server_id,
.map_err(|e| format!("Failed to connect to NATS: {e}"))?, "/".into(),
); make_local_broadcast_fn(namespace_holder.clone()),
let server_id = deploy.server_id.clone(); ));
let adapter = Arc::new(NatsAdapter::new( adapter
message_bus.clone() as Arc<_>, .init()
server_id, .await
"/".into(), .map_err(|e| format!("Failed to initialize Redis adapter: {e}"))?;
make_local_broadcast_fn(namespace_holder.clone()), builder = builder.adapter(adapter);
)); tracing::info!("Redis adapter configured for multi-node");
adapter }
.init() "nats" => {
let message_bus = Arc::new(
NatsMessageBus::new(&deploy.nats_url)
.await .await
.map_err(|e| format!("Failed to initialize NATS adapter: {e}"))?; .map_err(|e| format!("Failed to connect to NATS: {e}"))?,
builder = builder.adapter(adapter); );
tracing::info!("NATS adapter configured for multi-node"); let server_id = deploy.server_id.clone();
} let adapter = Arc::new(NatsAdapter::new(
_ => { message_bus.clone() as Arc<_>,
tracing::info!("Local adapter (single-node mode)"); server_id,
} "/".into(),
make_local_broadcast_fn(namespace_holder.clone()),
));
adapter
.init()
.await
.map_err(|e| format!("Failed to initialize NATS adapter: {e}"))?;
builder = builder.adapter(adapter);
tracing::info!("NATS adapter configured for multi-node");
}
_ => {
tracing::info!("Local adapter (single-node mode)");
}
};
let socket_server = Arc::new(builder.build());
let _ = namespace_holder.set(socket_server.namespaces.clone());
// Connect to database (independent of appks gRPC availability)
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 {
appks_addr: appks_addr.clone(),
..RpcConfig::from_env()
}; };
move |addr: String| {
let socket_server = Arc::new(builder.build()); let service = service.clone();
let _ = namespace_holder.set(socket_server.namespaces.clone()); let repo = repo.clone();
let namespaces = namespaces.clone();
// Initialize database + gRPC + service let current_addr = current_addr.clone();
let service: Option<Arc<MessageService>> = { let mut rpc = rpc_config.clone();
let rpc_config = RpcConfig { // etcd-registered address is bare "host:port" — prepend scheme for gRPC
appks_addr: appks_addr.clone(), rpc.appks_addr = if addr.starts_with("http") {
..RpcConfig::from_env() addr
} else {
format!("http://{}", addr)
}; };
let db_config = DatabaseConfig::from_env(); async move {
match AppksClients::connect(&rpc).await {
Ok(clients) => {
match MessageService::new((*repo).clone(), clients, namespaces.clone())
.await
{
Ok(svc) => {
let svc = Arc::new(svc);
let mut guard = service.write().await;
*guard = Some(svc);
match AppksClients::connect(&rpc_config).await { // Update the active appks address for health checker
Ok(clients) => { let mut addr_guard = current_addr.write().await;
let db = Database::connect(&db_config) *addr_guard = Some(rpc.appks_addr.clone());
.await
.map_err(|e| format!("Database connection failed: {e}"))?;
imks::database::run_migrations(db.pool()) tracing::info!(
.await addr = %rpc.appks_addr,
.map_err(|e| format!("Database migration failed: {e}"))?; "Message service initialized"
);
let repo = MessageRepo::new(db.pool().clone()); true
}
let svc = MessageService::new(repo, clients, socket_server.namespaces.clone()) Err(e) => {
.await tracing::warn!(
.map_err(|e| format!("Failed to initialize message service: {e}"))?; addr = %rpc.appks_addr,
error = %e,
tracing::info!("Message service initialized with gRPC permission checks"); "Failed to init message service"
Some(Arc::new(svc)) );
} false
Err(e) => { }
tracing::warn!("gRPC unavailable: {e}. Running without permission checks."); }
None }
Err(e) => {
tracing::warn!(
addr = %rpc.appks_addr,
error = %e,
"gRPC connect failed"
);
false
}
} }
} }
}; }
};
// Register connect handler // Try initial connection: etcd-discovered addr first, then env fallback
let namespace = socket_server.of("/"); let env_fallback =
let svc_connect = service.clone(); std::env::var("APPKS_GRPC_ADDR").unwrap_or_else(|_| "http://localhost:50051".to_string());
namespace if !try_connect_service(appks_addr.clone()).await {
.on_connect(move |socket, auth_data| { if appks_addr != env_fallback {
if let Some(ref svc) = svc_connect { tracing::info!(
svc.authenticate_socket(socket, auth_data) etcd_addr = %appks_addr,
.map_err(|e| e.to_string())?; 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"
);
}
});
}
} }
}
});
}
// Increment connection metrics // Register connect handler
let m = telemetry::metrics::get(); let namespace = socket_server.of("/");
m.connections_active.add( let svc_connect = service.clone();
1, namespace
&telemetry::MetricsInstruments::namespace_attrs(&socket.namespace), .on_connect(move |socket, auth_data| {
); let svc = svc_connect.blocking_read();
m.connections_total.add( if let Some(ref svc) = *svc {
1, svc.authenticate_socket(socket, auth_data)
&telemetry::MetricsInstruments::namespace_attrs(&socket.namespace), .map_err(|e| e.to_string())?;
); }
telemetry::health::connection_connected();
tracing::info!( // Increment connection metrics
socket_sid = %socket.sid, let m = telemetry::metrics::get();
engine_sid = %socket.engine_sid, m.connections_active.add(
namespace = %socket.namespace, 1,
"Socket connected" &telemetry::MetricsInstruments::namespace_attrs(&socket.namespace),
); );
Ok(()) m.connections_total.add(
}) 1,
.await; &telemetry::MetricsInstruments::namespace_attrs(&socket.namespace),
);
telemetry::health::connection_connected();
tracing::info!(
socket_sid = %socket.sid,
engine_sid = %socket.engine_sid,
namespace = %socket.namespace,
"Socket connected"
);
Ok(())
})
.await;
// Register Socket.IO event handlers (always register — each handler reads the latest service)
// Register Socket.IO event handlers {
if let Some(ref svc) = service { let svc = service.clone();
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();
let event_name = $event.to_string(); let event_name = $event.to_string();
@@ -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();
@@ -222,83 +431,82 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}; };
} }
register_event!(svc, namespace, "channel:join", join_channel); register_event!(svc, namespace, "channel:join", join_channel);
register_event!(svc, namespace, "channel:leave", leave_channel); register_event!(svc, namespace, "channel:leave", leave_channel);
register_event!(svc, namespace, "message:send", send_message); register_event!(svc, namespace, "message:send", send_message);
register_event!(svc, namespace, "message:edit", edit_message); register_event!(svc, namespace, "message:edit", edit_message);
register_event!(svc, namespace, "message:delete", delete_message); register_event!(svc, namespace, "message:delete", delete_message);
register_event!(svc, namespace, "reaction:add", toggle_reaction); register_event!(svc, namespace, "reaction:add", toggle_reaction);
register_event!(svc, namespace, "pin:add", pin_message); register_event!(svc, namespace, "pin:add", pin_message);
register_event!(svc, namespace, "pin:remove", unpin_message); register_event!(svc, namespace, "pin:remove", unpin_message);
register_event!(svc, namespace, "poll:vote", poll_vote); register_event!(svc, namespace, "poll:vote", poll_vote);
register_event!(svc, namespace, "poll:vote:remove", poll_remove_vote); register_event!(svc, namespace, "poll:vote:remove", poll_remove_vote);
register_event!(svc, namespace, "typing:start", typing_start); register_event!(svc, namespace, "typing:start", typing_start);
register_event!(svc, namespace, "typing:stop", typing_stop); register_event!(svc, namespace, "typing:stop", typing_stop);
register_event!(svc, namespace, "presence:update", presence_update); register_event!(svc, namespace, "presence:update", presence_update);
register_event!(svc, namespace, "draft:save", save_draft); register_event!(svc, namespace, "draft:save", save_draft);
register_event!(svc, namespace, "draft:get", get_draft); register_event!(svc, namespace, "draft:get", get_draft);
register_event!(svc, namespace, "draft:delete", delete_draft); register_event!(svc, namespace, "draft:delete", delete_draft);
register_event!(svc, namespace, "read_state:mark", mark_read); register_event!(svc, namespace, "read_state:mark", mark_read);
register_event!(svc, namespace, "read_state:get", get_read_state); register_event!(svc, namespace, "read_state:get", get_read_state);
register_event!(svc, namespace, "notification:list", list_notifications); register_event!(svc, namespace, "notification:list", list_notifications);
register_event!( register_event!(
svc, svc,
namespace, namespace,
"notification:mark_read", "notification:mark_read",
mark_notification_read mark_notification_read
); );
register_event!( register_event!(
svc, svc,
namespace, namespace,
"notification:mark_all_read", "notification:mark_all_read",
mark_all_notifications_read mark_all_notifications_read
); );
register_event!(svc, namespace, "bookmark:add", add_bookmark); register_event!(svc, namespace, "bookmark:add", add_bookmark);
register_event!(svc, namespace, "bookmark:remove", remove_bookmark); register_event!(svc, namespace, "bookmark:remove", remove_bookmark);
register_event!(svc, namespace, "bookmark:list", list_bookmarks); register_event!(svc, namespace, "bookmark:list", list_bookmarks);
register_event!(svc, namespace, "thread:create", create_thread); register_event!(svc, namespace, "thread:create", create_thread);
register_event!(svc, namespace, "thread:resolve", resolve_thread); register_event!(svc, namespace, "thread:resolve", resolve_thread);
register_event!(svc, namespace, "thread:join", join_thread); register_event!(svc, namespace, "thread:join", join_thread);
register_event!(svc, namespace, "thread:leave", leave_thread); register_event!(svc, namespace, "thread:leave", leave_thread);
register_event!(svc, namespace, "thread:list", list_threads); register_event!(svc, namespace, "thread:list", list_threads);
register_event!(svc, namespace, "article:create", create_article); register_event!(svc, namespace, "article:create", create_article);
register_event!(svc, namespace, "article:update", update_article); register_event!(svc, namespace, "article:update", update_article);
register_event!(svc, namespace, "article:list", list_articles); register_event!(svc, namespace, "article:list", list_articles);
register_event!(svc, namespace, "article:delete", delete_article); register_event!(svc, namespace, "article:delete", delete_article);
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
if deploy.webtransport_enabled && !deploy.cert_path.is_empty() { if deploy.webtransport_enabled && !deploy.cert_path.is_empty() {
let engine = socket_server.engine.clone(); let engine = socket_server.engine.clone();
let wt_port = deploy.webtransport_port; let wt_port = deploy.webtransport_port;
let cert_path = deploy.cert_path.clone(); let cert_path = deploy.cert_path.clone();
let key_path = deploy.key_path.clone(); let key_path = deploy.key_path.clone();
let server = socket_server.clone(); let server = socket_server.clone();
tracing::info!("Starting HTTP on {} + WebTransport on {}", addr, wt_port); tracing::info!("Starting HTTP on {} + WebTransport on {}", addr, wt_port);
tokio::select! { tokio::select! {
result = server.run_http(addr) => { result = server.run_http(addr) => {
result?; result?;
} }
result = engine.run_webtransport(wt_port, &cert_path, &key_path) => { result = engine.run_webtransport(wt_port, &cert_path, &key_path) => {
result?; result?;
}
} }
} else {
tracing::info!("Socket.IO HTTP server listening on {}", addr);
socket_server.run_http(addr).await?;
} }
} else {
Ok::<(), Box<dyn std::error::Error>>(()) tracing::info!("Socket.IO HTTP server listening on {}", addr);
})?; socket_server.run_http(addr).await?;
}
// Graceful telemetry shutdown // Graceful telemetry shutdown
telemetry_guard.shutdown(); telemetry_guard.shutdown();
-23
View File
@@ -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;
-115
View File
@@ -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;
-76
View File
@@ -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;
-45
View File
@@ -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;
-98
View File
@@ -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;
-102
View File
@@ -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
View File
@@ -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
View File
@@ -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()))?;
+178 -53
View File
@@ -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())
.map_err(|e| MessageBusError::Redis(e.to_string()))?;
let conn = timeout(connect_timeout, client.get_connection_manager())
.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)
}
};
client let pubsub_client = Client::open(pubsub_url.as_str())
.wait_for_connect()
.await
.map_err(|e| MessageBusError::Redis(e.to_string()))?;
subscriber
.wait_for_connect()
.await
.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"),
}
}
}
+40 -28
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
} }
} }
+8 -4
View File
@@ -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
View File
@@ -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}"))
}),
} }
} }