feat: init

This commit is contained in:
zhenyi
2026-06-07 11:30:56 +08:00
commit 563381c1ca
361 changed files with 41327 additions and 0 deletions
+160
View File
@@ -0,0 +1,160 @@
use etcd_client::{GetOptions, WatchOptions};
use tokio_stream::StreamExt;
use uuid::Uuid;
use crate::error::{AppError, AppResult};
use crate::pb::{EmailClient, RepoClient};
use super::types::ServiceInstance;
use super::{EtcdRegistry, EtcdRegistryInner};
impl EtcdRegistry {
pub async fn start_discovery(&self) -> AppResult<()> {
self.load_initial("git").await?;
self.load_initial("mail").await?;
self.spawn_watch("git");
self.spawn_watch("mail");
Ok(())
}
async fn load_initial(&self, service: &str) -> AppResult<()> {
let prefix = self.service_prefix(service);
let resp = {
let mut client = self.inner.client.lock().await;
client
.get(prefix.as_str(), Some(GetOptions::new().with_prefix()))
.await
.map_err(|e| AppError::Config(format!("etcd get {prefix} failed: {e}")))?
};
for kv in resp.kvs() {
let key = kv.key_str().unwrap_or_default();
let value = kv.value_str().unwrap_or_default();
if let Ok(instance) = serde_json::from_str::<ServiceInstance>(value) {
Self::upsert_instance(&self.inner, service, key, &instance);
}
}
tracing::info!(
service = service,
prefix = prefix.as_str(),
"etcd initial discovery complete"
);
Ok(())
}
fn spawn_watch(&self, service: &str) {
let prefix = self.service_prefix(service);
let inner = self.inner.clone();
let service = service.to_string();
tokio::spawn(async move {
loop {
match Self::watch_loop(&inner, &prefix, &service).await {
Ok(()) => break,
Err(e) => {
tracing::warn!(
service = service.as_str(),
error = %e,
"etcd watch disconnected, retrying in 3s"
);
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
}
}
}
});
}
async fn watch_loop(inner: &EtcdRegistryInner, prefix: &str, service: &str) -> AppResult<()> {
let (mut watcher, mut stream) = {
let mut client = inner.client.lock().await;
client
.watch(prefix, Some(WatchOptions::new().with_prefix()))
.await
.map_err(|e| AppError::Config(format!("etcd watch {prefix} failed: {e}")))?
};
let _keep = &mut watcher;
while let Some(resp) = stream.next().await {
let resp =
resp.map_err(|e| AppError::Config(format!("etcd watch stream error: {e}")))?;
for event in resp.events() {
let Some(kv) = event.kv() else { continue };
let key = kv.key_str().unwrap_or_default();
match event.event_type() {
etcd_client::EventType::Put => {
let value = kv.value_str().unwrap_or_default();
if let Ok(instance) = serde_json::from_str::<ServiceInstance>(value) {
Self::upsert_instance(inner, service, key, &instance);
tracing::info!(service = service, key = key, "etcd service upserted");
}
}
etcd_client::EventType::Delete => {
Self::remove_instance(inner, service, key);
tracing::info!(service = service, key = key, "etcd service removed");
}
}
}
}
Ok(())
}
pub(crate) fn service_prefix(&self, service: &str) -> String {
format!("{}services/{service}/", self.inner.key_prefix)
}
fn extract_id_from_key(key: &str) -> Option<Uuid> {
key.rsplit('/').next()?.parse().ok()
}
fn upsert_instance(
inner: &EtcdRegistryInner,
service: &str,
key: &str,
instance: &ServiceInstance,
) {
let Some(node_id) = Self::extract_id_from_key(key) else {
tracing::warn!(key = key, "etcd key has no valid UUID suffix");
return;
};
let addr = instance.addr.clone();
match service {
"git" => match RepoClient::lazy_connect(&addr) {
Ok(client) => {
inner.git_nodes.insert(node_id, client);
}
Err(e) => {
tracing::error!(key = key, addr = addr.as_str(), error = %e, "git client connect failed");
}
},
"mail" => match EmailClient::lazy_connect(&addr) {
Ok(client) => {
inner.mail_nodes.insert(node_id, client);
}
Err(e) => {
tracing::error!(key = key, addr = addr.as_str(), error = %e, "mail client connect failed");
}
},
_ => {}
}
}
fn remove_instance(inner: &EtcdRegistryInner, service: &str, key: &str) {
let Some(node_id) = Self::extract_id_from_key(key) else {
return;
};
match service {
"git" => {
inner.git_nodes.remove(&node_id);
}
"mail" => {
inner.mail_nodes.remove(&node_id);
}
_ => {}
}
}
}
+88
View File
@@ -0,0 +1,88 @@
mod discovery;
mod register;
mod types;
pub use types::ServiceInstance;
use std::sync::Arc;
use std::sync::atomic::AtomicI64;
use dashmap::DashMap;
use etcd_client::Client;
use tokio::sync::Mutex;
use uuid::Uuid;
use crate::config::AppConfig;
use crate::error::{AppError, AppResult};
use crate::pb::{EmailClient, RepoClient};
#[derive(Clone)]
pub struct EtcdRegistry {
pub(crate) inner: Arc<EtcdRegistryInner>,
}
pub(crate) struct EtcdRegistryInner {
pub client: Mutex<Client>,
pub config: AppConfig,
pub key_prefix: String,
pub git_nodes: DashMap<Uuid, RepoClient>,
pub mail_nodes: DashMap<Uuid, EmailClient>,
pub lease_id: AtomicI64,
}
impl EtcdRegistry {
pub async fn connect(config: &AppConfig) -> AppResult<Self> {
let endpoints = config.etcd_endpoints()?;
let timeout = config.etcd_connect_timeout()?;
let opts = etcd_client::ConnectOptions::new()
.with_connect_timeout(std::time::Duration::from_secs(timeout));
let client = Client::connect(&endpoints, Some(opts))
.await
.map_err(|e| AppError::Config(format!("etcd connect failed: {e}")))?;
if let (Some(user), Some(pass)) = (config.etcd_username()?, config.etcd_password()?) {
let auth_resp = client
.auth_client()
.authenticate(user, pass)
.await
.map_err(|e| AppError::Config(format!("etcd auth failed: {e}")))?;
let token = auth_resp.token().to_string();
tracing::info!(token_len = token.len(), "etcd authenticated");
}
let key_prefix = config.etcd_key_prefix()?;
Ok(Self {
inner: Arc::new(EtcdRegistryInner {
client: Mutex::new(client),
config: config.clone(),
key_prefix,
git_nodes: DashMap::new(),
mail_nodes: DashMap::new(),
lease_id: AtomicI64::new(0),
}),
})
}
pub fn get_git_client(&self, node_id: &Uuid) -> Option<RepoClient> {
self.inner.git_nodes.get(node_id).map(|c| c.clone())
}
pub fn git_node_ids(&self) -> Vec<Uuid> {
self.inner.git_nodes.iter().map(|e| *e.key()).collect()
}
pub fn get_email_client(&self) -> Option<EmailClient> {
self.inner
.mail_nodes
.iter()
.next()
.map(|e| e.value().clone())
}
pub fn has_git_nodes(&self) -> bool {
!self.inner.git_nodes.is_empty()
}
}
+113
View File
@@ -0,0 +1,113 @@
use std::collections::HashMap;
use std::sync::atomic::Ordering;
use etcd_client::PutOptions;
use tokio_stream::StreamExt;
use crate::error::{AppError, AppResult};
use super::EtcdRegistry;
use super::types::ServiceInstance;
impl EtcdRegistry {
pub async fn register_self(&self, service_name: &str) -> AppResult<()> {
let ttl = self.inner.config.etcd_lease_ttl()?;
let listen_addr = self.inner.config.rpc_self_listen_addr()?;
let instance_id = uuid::Uuid::now_v7().to_string();
let key = format!(
"{}services/{service_name}/{instance_id}",
self.inner.key_prefix
);
let instance = ServiceInstance {
addr: listen_addr.clone(),
metadata: HashMap::new(),
};
let value = serde_json::to_string(&instance)?;
let lease_resp = {
let mut client = self.inner.client.lock().await;
client
.lease_grant(ttl as i64, None)
.await
.map_err(|e| AppError::Config(format!("etcd lease_grant failed: {e}")))?
};
let lease_id = lease_resp.id();
self.inner.lease_id.store(lease_id, Ordering::SeqCst);
{
let mut client = self.inner.client.lock().await;
let opts = PutOptions::new().with_lease(lease_id);
client
.put(key.clone(), value, Some(opts))
.await
.map_err(|e| AppError::Config(format!("etcd put failed: {e}")))?;
}
tracing::info!(
service = service_name,
addr = listen_addr.as_str(),
lease_id = lease_id,
"registered self in etcd"
);
self.spawn_keep_alive(lease_id, key);
Ok(())
}
fn spawn_keep_alive(&self, lease_id: i64, key: String) {
let inner = self.inner.clone();
let interval = self.inner.config.etcd_keep_alive_interval().unwrap_or(10);
tokio::spawn(async move {
loop {
let result = {
let mut client = inner.client.lock().await;
client.lease_keep_alive(lease_id).await
};
match result {
Ok((_keeper, mut stream)) => {
while let Some(resp) = stream.next().await {
if let Err(e) = resp {
tracing::warn!(lease_id = lease_id, error = %e, "keep-alive stream error");
break;
}
}
}
Err(e) => {
tracing::warn!(lease_id = lease_id, error = %e, "keep-alive failed");
}
}
tokio::time::sleep(std::time::Duration::from_secs(interval)).await;
let re_grant = {
let mut client = inner.client.lock().await;
client
.lease_grant(inner.config.etcd_lease_ttl().unwrap_or(15) as i64, None)
.await
};
if let Ok(current) = re_grant {
let new_lease = current.id();
inner.lease_id.store(new_lease, Ordering::SeqCst);
let instance = ServiceInstance {
addr: inner.config.rpc_self_listen_addr().unwrap_or_default(),
metadata: HashMap::new(),
};
if let Ok(value) = serde_json::to_string(&instance) {
let mut client = inner.client.lock().await;
let opts = PutOptions::new().with_lease(new_lease);
let _ = client.put(key.clone(), value, Some(opts)).await;
}
tracing::info!(old = lease_id, new = new_lease, "etcd lease renewed");
}
}
});
}
}
+10
View File
@@ -0,0 +1,10 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceInstance {
pub addr: String,
#[serde(default)]
pub metadata: HashMap<String, String>,
}