mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
feat: 扩展 Rust gateway 全功能模块,新增 billing/crypto/wallet crate 及完整数据层
- 新增 aether-billing、aether-crypto、aether-wallet 独立 crate - aether-data 扩展 repository 层:announcements、auth_modules、billing、 candidate_selection、gemini_file_mappings、global_models、management_tokens、 oauth_providers、proxy_nodes、quota、users、wallet 等模块 - aether-gateway 新增 api/auth/billing/control/middleware/scheduler/usage/ video_tasks/hooks/maintenance/model_fetch/provider_transport 等功能模块 - 重构 executor decision 和 gateway state 为模块目录结构 - 新增 gateway router、frontdoor 路由层及对应测试 - Python 侧 API 路由重构,新增 compat/support 模块 - 前端 Logo 组件更新及 Provider 管理页面调整
This commit is contained in:
389
crates/aether-data/src/repository/proxy_nodes/memory.rs
Normal file
389
crates/aether-data/src/repository/proxy_nodes/memory.rs
Normal file
@@ -0,0 +1,389 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::RwLock;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use super::types::{
|
||||
normalize_proxy_metadata, ProxyNodeHeartbeatMutation, ProxyNodeReadRepository,
|
||||
ProxyNodeTunnelStatusMutation, ProxyNodeWriteRepository, StoredProxyNode, StoredProxyNodeEvent,
|
||||
};
|
||||
use crate::DataLayerError;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct InMemoryProxyNodeRepository {
|
||||
nodes: RwLock<BTreeMap<String, StoredProxyNode>>,
|
||||
events: RwLock<Vec<StoredProxyNodeEvent>>,
|
||||
}
|
||||
|
||||
impl InMemoryProxyNodeRepository {
|
||||
pub fn seed<I>(nodes: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = StoredProxyNode>,
|
||||
{
|
||||
Self {
|
||||
nodes: RwLock::new(
|
||||
nodes
|
||||
.into_iter()
|
||||
.map(|node| (node.id.clone(), node))
|
||||
.collect(),
|
||||
),
|
||||
events: RwLock::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn seed_with_events<I, J>(nodes: I, events: J) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = StoredProxyNode>,
|
||||
J: IntoIterator<Item = StoredProxyNodeEvent>,
|
||||
{
|
||||
Self {
|
||||
nodes: RwLock::new(
|
||||
nodes
|
||||
.into_iter()
|
||||
.map(|node| (node.id.clone(), node))
|
||||
.collect(),
|
||||
),
|
||||
events: RwLock::new(events.into_iter().collect()),
|
||||
}
|
||||
}
|
||||
|
||||
fn now_unix_secs() -> Option<u64> {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.ok()
|
||||
.map(|duration| duration.as_secs())
|
||||
}
|
||||
|
||||
fn next_event_id(events: &[StoredProxyNodeEvent]) -> i64 {
|
||||
events.iter().map(|event| event.id).max().unwrap_or(0) + 1
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProxyNodeReadRepository for InMemoryProxyNodeRepository {
|
||||
async fn list_proxy_nodes(&self) -> Result<Vec<StoredProxyNode>, DataLayerError> {
|
||||
let nodes = self.nodes.read().expect("proxy node repository lock");
|
||||
let mut items = nodes.values().cloned().collect::<Vec<_>>();
|
||||
items.sort_by(|left, right| left.name.cmp(&right.name).then(left.id.cmp(&right.id)));
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
async fn find_proxy_node(
|
||||
&self,
|
||||
node_id: &str,
|
||||
) -> Result<Option<StoredProxyNode>, DataLayerError> {
|
||||
let nodes = self.nodes.read().expect("proxy node repository lock");
|
||||
Ok(nodes.get(node_id).cloned())
|
||||
}
|
||||
|
||||
async fn list_proxy_node_events(
|
||||
&self,
|
||||
node_id: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<StoredProxyNodeEvent>, DataLayerError> {
|
||||
let events = self.events.read().expect("proxy node repository lock");
|
||||
let mut items = events
|
||||
.iter()
|
||||
.filter(|event| event.node_id == node_id)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
items.sort_by(|left, right| {
|
||||
right
|
||||
.created_at_unix_secs
|
||||
.unwrap_or(0)
|
||||
.cmp(&left.created_at_unix_secs.unwrap_or(0))
|
||||
.then(right.id.cmp(&left.id))
|
||||
});
|
||||
items.truncate(limit);
|
||||
Ok(items)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProxyNodeWriteRepository for InMemoryProxyNodeRepository {
|
||||
async fn apply_heartbeat(
|
||||
&self,
|
||||
mutation: &ProxyNodeHeartbeatMutation,
|
||||
) -> Result<Option<StoredProxyNode>, DataLayerError> {
|
||||
let mut nodes = self.nodes.write().expect("proxy node repository lock");
|
||||
let Some(node) = nodes.get_mut(&mutation.node_id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !node.tunnel_mode {
|
||||
return Err(DataLayerError::InvalidInput(
|
||||
"non-tunnel mode is no longer supported, please upgrade aether-proxy to use tunnel mode"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let now = Self::now_unix_secs();
|
||||
node.last_heartbeat_at_unix_secs = now;
|
||||
if node.status != "online" || !node.tunnel_connected {
|
||||
node.status = "online".to_string();
|
||||
node.tunnel_connected = true;
|
||||
node.tunnel_connected_at_unix_secs = now;
|
||||
node.updated_at_unix_secs = now;
|
||||
}
|
||||
|
||||
if let Some(value) = mutation.heartbeat_interval {
|
||||
node.heartbeat_interval = value;
|
||||
}
|
||||
if let Some(value) = mutation.active_connections {
|
||||
node.active_connections = value;
|
||||
}
|
||||
if let Some(value) = mutation.avg_latency_ms {
|
||||
node.avg_latency_ms = Some(value);
|
||||
}
|
||||
let normalized_proxy_metadata = normalize_proxy_metadata(
|
||||
mutation.proxy_metadata.as_ref(),
|
||||
mutation.proxy_version.as_deref(),
|
||||
);
|
||||
if let Some(value) = normalized_proxy_metadata {
|
||||
node.proxy_metadata = Some(value);
|
||||
}
|
||||
if let Some(value) = mutation.total_requests_delta.filter(|value| *value > 0) {
|
||||
node.total_requests += value;
|
||||
}
|
||||
if let Some(value) = mutation.failed_requests_delta.filter(|value| *value > 0) {
|
||||
node.failed_requests += value;
|
||||
}
|
||||
if let Some(value) = mutation.dns_failures_delta.filter(|value| *value > 0) {
|
||||
node.dns_failures += value;
|
||||
}
|
||||
if let Some(value) = mutation.stream_errors_delta.filter(|value| *value > 0) {
|
||||
node.stream_errors += value;
|
||||
}
|
||||
|
||||
Ok(Some(node.clone()))
|
||||
}
|
||||
|
||||
async fn update_tunnel_status(
|
||||
&self,
|
||||
mutation: &ProxyNodeTunnelStatusMutation,
|
||||
) -> Result<Option<StoredProxyNode>, DataLayerError> {
|
||||
let mut nodes = self.nodes.write().expect("proxy node repository lock");
|
||||
let Some(node) = nodes.get_mut(&mutation.node_id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let event_time = mutation
|
||||
.observed_at_unix_secs
|
||||
.or_else(Self::now_unix_secs)
|
||||
.unwrap_or(0);
|
||||
let event_type = if mutation.connected {
|
||||
"connected"
|
||||
} else {
|
||||
"disconnected"
|
||||
};
|
||||
let event_detail = mutation.detail.clone().unwrap_or_else(|| {
|
||||
format!(
|
||||
"[hub_node_status] conn_count={}",
|
||||
i32::max(mutation.conn_count, 0)
|
||||
)
|
||||
});
|
||||
let mut events = self.events.write().expect("proxy node repository lock");
|
||||
if let Some(last_transition) = node.tunnel_connected_at_unix_secs {
|
||||
if event_time < last_transition {
|
||||
let event_id = Self::next_event_id(&events);
|
||||
events.push(StoredProxyNodeEvent {
|
||||
id: event_id,
|
||||
node_id: mutation.node_id.clone(),
|
||||
event_type: event_type.to_string(),
|
||||
detail: Some(format!("[stale_ignored] {event_detail}")),
|
||||
created_at_unix_secs: Self::now_unix_secs(),
|
||||
});
|
||||
return Ok(Some(node.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
node.tunnel_connected = mutation.connected;
|
||||
node.tunnel_connected_at_unix_secs = Some(event_time);
|
||||
node.status = if mutation.connected {
|
||||
"online".to_string()
|
||||
} else {
|
||||
"offline".to_string()
|
||||
};
|
||||
node.updated_at_unix_secs = Some(event_time);
|
||||
let event_id = Self::next_event_id(&events);
|
||||
events.push(StoredProxyNodeEvent {
|
||||
id: event_id,
|
||||
node_id: mutation.node_id.clone(),
|
||||
event_type: event_type.to_string(),
|
||||
detail: Some(event_detail),
|
||||
created_at_unix_secs: Some(event_time),
|
||||
});
|
||||
Ok(Some(node.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::InMemoryProxyNodeRepository;
|
||||
use crate::repository::proxy_nodes::{
|
||||
ProxyNodeHeartbeatMutation, ProxyNodeReadRepository, ProxyNodeTunnelStatusMutation,
|
||||
ProxyNodeWriteRepository, StoredProxyNode, StoredProxyNodeEvent,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
fn sample_node() -> StoredProxyNode {
|
||||
StoredProxyNode::new(
|
||||
"node-1".to_string(),
|
||||
"proxy-1".to_string(),
|
||||
"127.0.0.1".to_string(),
|
||||
0,
|
||||
false,
|
||||
"offline".to_string(),
|
||||
30,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
true,
|
||||
false,
|
||||
2,
|
||||
)
|
||||
.expect("node should build")
|
||||
.with_runtime_fields(
|
||||
Some("test".to_string()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(json!({"allowed_ports": [443]})),
|
||||
Some(1_700_000_000),
|
||||
Some(1_700_000_001),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn applies_heartbeat_and_tunnel_status_mutations() {
|
||||
let repository = InMemoryProxyNodeRepository::seed(vec![sample_node()]);
|
||||
|
||||
let heartbeat = repository
|
||||
.apply_heartbeat(&ProxyNodeHeartbeatMutation {
|
||||
node_id: "node-1".to_string(),
|
||||
heartbeat_interval: Some(45),
|
||||
active_connections: Some(5),
|
||||
total_requests_delta: Some(8),
|
||||
avg_latency_ms: Some(12.5),
|
||||
failed_requests_delta: Some(2),
|
||||
dns_failures_delta: Some(1),
|
||||
stream_errors_delta: Some(3),
|
||||
proxy_metadata: Some(json!({"arch": "arm64"})),
|
||||
proxy_version: Some("1.2.3".to_string()),
|
||||
})
|
||||
.await
|
||||
.expect("heartbeat should succeed")
|
||||
.expect("node should exist");
|
||||
|
||||
assert_eq!(heartbeat.status, "online");
|
||||
assert_eq!(heartbeat.heartbeat_interval, 45);
|
||||
assert_eq!(heartbeat.active_connections, 5);
|
||||
assert_eq!(heartbeat.total_requests, 8);
|
||||
assert_eq!(heartbeat.failed_requests, 2);
|
||||
assert_eq!(heartbeat.dns_failures, 1);
|
||||
assert_eq!(heartbeat.stream_errors, 3);
|
||||
assert_eq!(
|
||||
heartbeat
|
||||
.proxy_metadata
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("version"))
|
||||
.and_then(|value| value.as_str()),
|
||||
Some("1.2.3")
|
||||
);
|
||||
|
||||
let stale = repository
|
||||
.update_tunnel_status(&ProxyNodeTunnelStatusMutation {
|
||||
node_id: "node-1".to_string(),
|
||||
connected: false,
|
||||
conn_count: 0,
|
||||
detail: None,
|
||||
observed_at_unix_secs: Some(1),
|
||||
})
|
||||
.await
|
||||
.expect("status update should succeed")
|
||||
.expect("node should exist");
|
||||
assert_eq!(stale.status, "online");
|
||||
|
||||
let stale_events = repository
|
||||
.list_proxy_node_events("node-1", 10)
|
||||
.await
|
||||
.expect("list events should succeed");
|
||||
assert_eq!(stale_events.len(), 1);
|
||||
assert_eq!(stale_events[0].event_type, "disconnected");
|
||||
assert_eq!(
|
||||
stale_events[0].detail.as_deref(),
|
||||
Some("[stale_ignored] [hub_node_status] conn_count=0")
|
||||
);
|
||||
|
||||
let updated = repository
|
||||
.update_tunnel_status(&ProxyNodeTunnelStatusMutation {
|
||||
node_id: "node-1".to_string(),
|
||||
connected: false,
|
||||
conn_count: 0,
|
||||
detail: None,
|
||||
observed_at_unix_secs: Some(1_800_000_000),
|
||||
})
|
||||
.await
|
||||
.expect("status update should succeed")
|
||||
.expect("node should exist");
|
||||
assert_eq!(updated.status, "offline");
|
||||
assert!(!updated.tunnel_connected);
|
||||
|
||||
let events = repository
|
||||
.list_proxy_node_events("node-1", 10)
|
||||
.await
|
||||
.expect("list events should succeed");
|
||||
assert_eq!(events.len(), 2);
|
||||
assert_eq!(events[0].event_type, "disconnected");
|
||||
assert_eq!(events[0].created_at_unix_secs, Some(1_800_000_000));
|
||||
assert_eq!(
|
||||
events[0].detail.as_deref(),
|
||||
Some("[hub_node_status] conn_count=0")
|
||||
);
|
||||
|
||||
let found = repository
|
||||
.find_proxy_node("node-1")
|
||||
.await
|
||||
.expect("find should succeed")
|
||||
.expect("node should exist");
|
||||
assert_eq!(found.status, "offline");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lists_seeded_proxy_node_events_in_descending_order() {
|
||||
let repository = InMemoryProxyNodeRepository::seed_with_events(
|
||||
vec![sample_node()],
|
||||
vec![
|
||||
StoredProxyNodeEvent {
|
||||
id: 1,
|
||||
node_id: "node-1".to_string(),
|
||||
event_type: "connected".to_string(),
|
||||
detail: Some("older".to_string()),
|
||||
created_at_unix_secs: Some(1_710_000_000),
|
||||
},
|
||||
StoredProxyNodeEvent {
|
||||
id: 2,
|
||||
node_id: "node-1".to_string(),
|
||||
event_type: "disconnected".to_string(),
|
||||
detail: Some("newer".to_string()),
|
||||
created_at_unix_secs: Some(1_710_000_100),
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
let events = repository
|
||||
.list_proxy_node_events("node-1", 1)
|
||||
.await
|
||||
.expect("list events should succeed");
|
||||
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0].id, 2);
|
||||
assert_eq!(events[0].detail.as_deref(), Some("newer"));
|
||||
}
|
||||
}
|
||||
10
crates/aether-data/src/repository/proxy_nodes/mod.rs
Normal file
10
crates/aether-data/src/repository/proxy_nodes/mod.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
mod memory;
|
||||
mod sql;
|
||||
mod types;
|
||||
|
||||
pub use memory::InMemoryProxyNodeRepository;
|
||||
pub use sql::SqlxProxyNodeRepository;
|
||||
pub use types::{
|
||||
ProxyNodeHeartbeatMutation, ProxyNodeReadRepository, ProxyNodeTunnelStatusMutation,
|
||||
ProxyNodeWriteRepository, StoredProxyNode, StoredProxyNodeEvent,
|
||||
};
|
||||
363
crates/aether-data/src/repository/proxy_nodes/sql.rs
Normal file
363
crates/aether-data/src/repository/proxy_nodes/sql.rs
Normal file
@@ -0,0 +1,363 @@
|
||||
use async_trait::async_trait;
|
||||
use sqlx::{postgres::PgRow, PgPool, Row};
|
||||
|
||||
use super::types::{
|
||||
normalize_proxy_metadata, ProxyNodeHeartbeatMutation, ProxyNodeReadRepository,
|
||||
ProxyNodeTunnelStatusMutation, ProxyNodeWriteRepository, StoredProxyNode, StoredProxyNodeEvent,
|
||||
};
|
||||
use crate::DataLayerError;
|
||||
|
||||
const FIND_PROXY_NODE_SQL: &str = r#"
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
ip,
|
||||
port,
|
||||
region,
|
||||
is_manual,
|
||||
proxy_url,
|
||||
proxy_username,
|
||||
proxy_password,
|
||||
CAST(status AS TEXT) AS status,
|
||||
registered_by,
|
||||
EXTRACT(EPOCH FROM last_heartbeat_at)::bigint AS last_heartbeat_at_unix_secs,
|
||||
heartbeat_interval,
|
||||
active_connections,
|
||||
total_requests,
|
||||
CAST(avg_latency_ms AS DOUBLE PRECISION) AS avg_latency_ms,
|
||||
failed_requests,
|
||||
dns_failures,
|
||||
stream_errors,
|
||||
proxy_metadata,
|
||||
hardware_info,
|
||||
estimated_max_concurrency,
|
||||
tunnel_mode,
|
||||
tunnel_connected,
|
||||
EXTRACT(EPOCH FROM tunnel_connected_at)::bigint AS tunnel_connected_at_unix_secs,
|
||||
remote_config,
|
||||
config_version,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint AS created_at_unix_secs,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint AS updated_at_unix_secs
|
||||
FROM proxy_nodes
|
||||
WHERE id = $1
|
||||
LIMIT 1
|
||||
"#;
|
||||
|
||||
const LIST_PROXY_NODES_SQL: &str = r#"
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
ip,
|
||||
port,
|
||||
region,
|
||||
is_manual,
|
||||
proxy_url,
|
||||
proxy_username,
|
||||
proxy_password,
|
||||
CAST(status AS TEXT) AS status,
|
||||
registered_by,
|
||||
EXTRACT(EPOCH FROM last_heartbeat_at)::bigint AS last_heartbeat_at_unix_secs,
|
||||
heartbeat_interval,
|
||||
active_connections,
|
||||
total_requests,
|
||||
CAST(avg_latency_ms AS DOUBLE PRECISION) AS avg_latency_ms,
|
||||
failed_requests,
|
||||
dns_failures,
|
||||
stream_errors,
|
||||
proxy_metadata,
|
||||
hardware_info,
|
||||
estimated_max_concurrency,
|
||||
tunnel_mode,
|
||||
tunnel_connected,
|
||||
EXTRACT(EPOCH FROM tunnel_connected_at)::bigint AS tunnel_connected_at_unix_secs,
|
||||
remote_config,
|
||||
config_version,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint AS created_at_unix_secs,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint AS updated_at_unix_secs
|
||||
FROM proxy_nodes
|
||||
ORDER BY name ASC, id ASC
|
||||
"#;
|
||||
|
||||
const LIST_PROXY_NODE_EVENTS_SQL: &str = r#"
|
||||
SELECT
|
||||
id,
|
||||
node_id,
|
||||
CAST(event_type AS TEXT) AS event_type,
|
||||
detail,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint AS created_at_unix_secs
|
||||
FROM proxy_node_events
|
||||
WHERE node_id = $1
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT $2
|
||||
"#;
|
||||
|
||||
const APPLY_HEARTBEAT_SQL: &str = r#"
|
||||
UPDATE proxy_nodes
|
||||
SET
|
||||
last_heartbeat_at = NOW(),
|
||||
status = CASE
|
||||
WHEN status <> 'online'::proxynodestatus OR tunnel_connected = FALSE
|
||||
THEN 'online'::proxynodestatus
|
||||
ELSE status
|
||||
END,
|
||||
tunnel_connected = CASE
|
||||
WHEN status <> 'online'::proxynodestatus OR tunnel_connected = FALSE
|
||||
THEN TRUE
|
||||
ELSE tunnel_connected
|
||||
END,
|
||||
tunnel_connected_at = CASE
|
||||
WHEN status <> 'online'::proxynodestatus OR tunnel_connected = FALSE
|
||||
THEN NOW()
|
||||
ELSE tunnel_connected_at
|
||||
END,
|
||||
updated_at = CASE
|
||||
WHEN status <> 'online'::proxynodestatus OR tunnel_connected = FALSE
|
||||
THEN NOW()
|
||||
ELSE updated_at
|
||||
END,
|
||||
heartbeat_interval = COALESCE($2, heartbeat_interval),
|
||||
active_connections = COALESCE($3, active_connections),
|
||||
avg_latency_ms = COALESCE($4, avg_latency_ms),
|
||||
proxy_metadata = COALESCE($5, proxy_metadata),
|
||||
total_requests = total_requests + GREATEST(COALESCE($6, 0), 0),
|
||||
failed_requests = failed_requests + GREATEST(COALESCE($7, 0), 0),
|
||||
dns_failures = dns_failures + GREATEST(COALESCE($8, 0), 0),
|
||||
stream_errors = stream_errors + GREATEST(COALESCE($9, 0), 0)
|
||||
WHERE id = $1
|
||||
"#;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SqlxProxyNodeRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl SqlxProxyNodeRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
fn optional_unix_secs(value: Option<i64>) -> Option<u64> {
|
||||
value.and_then(|value| u64::try_from(value).ok())
|
||||
}
|
||||
|
||||
fn row_to_stored(row: &PgRow) -> Result<StoredProxyNode, DataLayerError> {
|
||||
Ok(StoredProxyNode::new(
|
||||
row.try_get("id")?,
|
||||
row.try_get("name")?,
|
||||
row.try_get("ip")?,
|
||||
row.try_get("port")?,
|
||||
row.try_get("is_manual")?,
|
||||
row.try_get("status")?,
|
||||
row.try_get("heartbeat_interval")?,
|
||||
row.try_get("active_connections")?,
|
||||
row.try_get("total_requests")?,
|
||||
row.try_get("failed_requests")?,
|
||||
row.try_get("dns_failures")?,
|
||||
row.try_get("stream_errors")?,
|
||||
row.try_get("tunnel_mode")?,
|
||||
row.try_get("tunnel_connected")?,
|
||||
row.try_get("config_version")?,
|
||||
)?
|
||||
.with_manual_proxy_fields(
|
||||
row.try_get("proxy_url")?,
|
||||
row.try_get("proxy_username")?,
|
||||
row.try_get("proxy_password")?,
|
||||
)
|
||||
.with_runtime_fields(
|
||||
row.try_get("region")?,
|
||||
row.try_get("registered_by")?,
|
||||
Self::optional_unix_secs(row.try_get("last_heartbeat_at_unix_secs")?),
|
||||
row.try_get("avg_latency_ms")?,
|
||||
row.try_get("proxy_metadata")?,
|
||||
row.try_get("hardware_info")?,
|
||||
row.try_get("estimated_max_concurrency")?,
|
||||
Self::optional_unix_secs(row.try_get("tunnel_connected_at_unix_secs")?),
|
||||
row.try_get("remote_config")?,
|
||||
Self::optional_unix_secs(row.try_get("created_at_unix_secs")?),
|
||||
Self::optional_unix_secs(row.try_get("updated_at_unix_secs")?),
|
||||
))
|
||||
}
|
||||
|
||||
fn row_to_event(row: &PgRow) -> Result<StoredProxyNodeEvent, DataLayerError> {
|
||||
Ok(StoredProxyNodeEvent {
|
||||
id: row.try_get("id")?,
|
||||
node_id: row.try_get("node_id")?,
|
||||
event_type: row.try_get("event_type")?,
|
||||
detail: row.try_get("detail")?,
|
||||
created_at_unix_secs: Self::optional_unix_secs(row.try_get("created_at_unix_secs")?),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProxyNodeReadRepository for SqlxProxyNodeRepository {
|
||||
async fn list_proxy_nodes(&self) -> Result<Vec<StoredProxyNode>, DataLayerError> {
|
||||
let rows = sqlx::query(LIST_PROXY_NODES_SQL)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
rows.iter().map(Self::row_to_stored).collect()
|
||||
}
|
||||
|
||||
async fn find_proxy_node(
|
||||
&self,
|
||||
node_id: &str,
|
||||
) -> Result<Option<StoredProxyNode>, DataLayerError> {
|
||||
let row = sqlx::query(FIND_PROXY_NODE_SQL)
|
||||
.bind(node_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
row.map(|row| Self::row_to_stored(&row)).transpose()
|
||||
}
|
||||
|
||||
async fn list_proxy_node_events(
|
||||
&self,
|
||||
node_id: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<StoredProxyNodeEvent>, DataLayerError> {
|
||||
let rows = sqlx::query(LIST_PROXY_NODE_EVENTS_SQL)
|
||||
.bind(node_id)
|
||||
.bind(i64::try_from(limit).unwrap_or(i64::MAX))
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
rows.iter().map(Self::row_to_event).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProxyNodeWriteRepository for SqlxProxyNodeRepository {
|
||||
async fn apply_heartbeat(
|
||||
&self,
|
||||
mutation: &ProxyNodeHeartbeatMutation,
|
||||
) -> Result<Option<StoredProxyNode>, DataLayerError> {
|
||||
let existing = self.find_proxy_node(&mutation.node_id).await?;
|
||||
let Some(existing) = existing else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !existing.tunnel_mode {
|
||||
return Err(DataLayerError::InvalidInput(
|
||||
"non-tunnel mode is no longer supported, please upgrade aether-proxy to use tunnel mode"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let normalized_proxy_metadata = normalize_proxy_metadata(
|
||||
mutation.proxy_metadata.as_ref(),
|
||||
mutation.proxy_version.as_deref(),
|
||||
);
|
||||
|
||||
sqlx::query(APPLY_HEARTBEAT_SQL)
|
||||
.bind(&mutation.node_id)
|
||||
.bind(mutation.heartbeat_interval)
|
||||
.bind(mutation.active_connections)
|
||||
.bind(mutation.avg_latency_ms)
|
||||
.bind(normalized_proxy_metadata)
|
||||
.bind(mutation.total_requests_delta)
|
||||
.bind(mutation.failed_requests_delta)
|
||||
.bind(mutation.dns_failures_delta)
|
||||
.bind(mutation.stream_errors_delta)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
self.find_proxy_node(&mutation.node_id).await
|
||||
}
|
||||
|
||||
async fn update_tunnel_status(
|
||||
&self,
|
||||
mutation: &ProxyNodeTunnelStatusMutation,
|
||||
) -> Result<Option<StoredProxyNode>, DataLayerError> {
|
||||
let existing = self.find_proxy_node(&mutation.node_id).await?;
|
||||
let Some(existing) = existing else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let observed_at_unix_secs = mutation.observed_at_unix_secs;
|
||||
let event_type = if mutation.connected {
|
||||
"connected"
|
||||
} else {
|
||||
"disconnected"
|
||||
};
|
||||
let event_detail = mutation.detail.clone().unwrap_or_else(|| {
|
||||
format!(
|
||||
"[hub_node_status] conn_count={}",
|
||||
i32::max(mutation.conn_count, 0)
|
||||
)
|
||||
});
|
||||
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
if existing
|
||||
.tunnel_connected_at_unix_secs
|
||||
.zip(observed_at_unix_secs)
|
||||
.is_some_and(|(last_transition, observed_at)| observed_at < last_transition)
|
||||
{
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO proxy_node_events (node_id, event_type, detail, created_at)
|
||||
VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
NOW()
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.bind(&mutation.node_id)
|
||||
.bind(event_type)
|
||||
.bind(format!("[stale_ignored] {event_detail}"))
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
return self.find_proxy_node(&mutation.node_id).await;
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE proxy_nodes
|
||||
SET
|
||||
tunnel_connected = $2,
|
||||
tunnel_connected_at = CASE
|
||||
WHEN $3::double precision IS NULL THEN NOW()
|
||||
ELSE TO_TIMESTAMP($3::double precision)
|
||||
END,
|
||||
status = CASE
|
||||
WHEN $2 THEN 'online'::proxynodestatus
|
||||
ELSE 'offline'::proxynodestatus
|
||||
END,
|
||||
updated_at = CASE
|
||||
WHEN $3::double precision IS NULL THEN NOW()
|
||||
ELSE TO_TIMESTAMP($3::double precision)
|
||||
END
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(&mutation.node_id)
|
||||
.bind(mutation.connected)
|
||||
.bind(observed_at_unix_secs.map(|value| value as f64))
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO proxy_node_events (node_id, event_type, detail, created_at)
|
||||
VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
CASE
|
||||
WHEN $4::double precision IS NULL THEN NOW()
|
||||
ELSE TO_TIMESTAMP($4::double precision)
|
||||
END
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.bind(&mutation.node_id)
|
||||
.bind(event_type)
|
||||
.bind(event_detail)
|
||||
.bind(observed_at_unix_secs.map(|value| value as f64))
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
self.find_proxy_node(&mutation.node_id).await
|
||||
}
|
||||
}
|
||||
244
crates/aether-data/src/repository/proxy_nodes/types.rs
Normal file
244
crates/aether-data/src/repository/proxy_nodes/types.rs
Normal file
@@ -0,0 +1,244 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct StoredProxyNode {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub ip: String,
|
||||
pub port: i32,
|
||||
pub region: Option<String>,
|
||||
pub is_manual: bool,
|
||||
pub proxy_url: Option<String>,
|
||||
pub proxy_username: Option<String>,
|
||||
pub proxy_password: Option<String>,
|
||||
pub status: String,
|
||||
pub registered_by: Option<String>,
|
||||
pub last_heartbeat_at_unix_secs: Option<u64>,
|
||||
pub heartbeat_interval: i32,
|
||||
pub active_connections: i32,
|
||||
pub total_requests: i64,
|
||||
pub avg_latency_ms: Option<f64>,
|
||||
pub failed_requests: i64,
|
||||
pub dns_failures: i64,
|
||||
pub stream_errors: i64,
|
||||
pub proxy_metadata: Option<serde_json::Value>,
|
||||
pub hardware_info: Option<serde_json::Value>,
|
||||
pub estimated_max_concurrency: Option<i32>,
|
||||
pub tunnel_mode: bool,
|
||||
pub tunnel_connected: bool,
|
||||
pub tunnel_connected_at_unix_secs: Option<u64>,
|
||||
pub remote_config: Option<serde_json::Value>,
|
||||
pub config_version: i32,
|
||||
pub created_at_unix_secs: Option<u64>,
|
||||
pub updated_at_unix_secs: Option<u64>,
|
||||
}
|
||||
|
||||
impl StoredProxyNode {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
id: String,
|
||||
name: String,
|
||||
ip: String,
|
||||
port: i32,
|
||||
is_manual: bool,
|
||||
status: String,
|
||||
heartbeat_interval: i32,
|
||||
active_connections: i32,
|
||||
total_requests: i64,
|
||||
failed_requests: i64,
|
||||
dns_failures: i64,
|
||||
stream_errors: i64,
|
||||
tunnel_mode: bool,
|
||||
tunnel_connected: bool,
|
||||
config_version: i32,
|
||||
) -> Result<Self, crate::DataLayerError> {
|
||||
if id.trim().is_empty() {
|
||||
return Err(crate::DataLayerError::UnexpectedValue(
|
||||
"proxy_nodes.id is empty".to_string(),
|
||||
));
|
||||
}
|
||||
if name.trim().is_empty() {
|
||||
return Err(crate::DataLayerError::UnexpectedValue(
|
||||
"proxy_nodes.name is empty".to_string(),
|
||||
));
|
||||
}
|
||||
if ip.trim().is_empty() {
|
||||
return Err(crate::DataLayerError::UnexpectedValue(
|
||||
"proxy_nodes.ip is empty".to_string(),
|
||||
));
|
||||
}
|
||||
if status.trim().is_empty() {
|
||||
return Err(crate::DataLayerError::UnexpectedValue(
|
||||
"proxy_nodes.status is empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
id,
|
||||
name,
|
||||
ip,
|
||||
port,
|
||||
region: None,
|
||||
is_manual,
|
||||
proxy_url: None,
|
||||
proxy_username: None,
|
||||
proxy_password: None,
|
||||
status,
|
||||
registered_by: None,
|
||||
last_heartbeat_at_unix_secs: None,
|
||||
heartbeat_interval,
|
||||
active_connections,
|
||||
total_requests,
|
||||
avg_latency_ms: None,
|
||||
failed_requests,
|
||||
dns_failures,
|
||||
stream_errors,
|
||||
proxy_metadata: None,
|
||||
hardware_info: None,
|
||||
estimated_max_concurrency: None,
|
||||
tunnel_mode,
|
||||
tunnel_connected,
|
||||
tunnel_connected_at_unix_secs: None,
|
||||
remote_config: None,
|
||||
config_version,
|
||||
created_at_unix_secs: None,
|
||||
updated_at_unix_secs: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn with_runtime_fields(
|
||||
mut self,
|
||||
region: Option<String>,
|
||||
registered_by: Option<String>,
|
||||
last_heartbeat_at_unix_secs: Option<u64>,
|
||||
avg_latency_ms: Option<f64>,
|
||||
proxy_metadata: Option<serde_json::Value>,
|
||||
hardware_info: Option<serde_json::Value>,
|
||||
estimated_max_concurrency: Option<i32>,
|
||||
tunnel_connected_at_unix_secs: Option<u64>,
|
||||
remote_config: Option<serde_json::Value>,
|
||||
created_at_unix_secs: Option<u64>,
|
||||
updated_at_unix_secs: Option<u64>,
|
||||
) -> Self {
|
||||
self.region = region;
|
||||
self.registered_by = registered_by;
|
||||
self.last_heartbeat_at_unix_secs = last_heartbeat_at_unix_secs;
|
||||
self.avg_latency_ms = avg_latency_ms;
|
||||
self.proxy_metadata = proxy_metadata;
|
||||
self.hardware_info = hardware_info;
|
||||
self.estimated_max_concurrency = estimated_max_concurrency;
|
||||
self.tunnel_connected_at_unix_secs = tunnel_connected_at_unix_secs;
|
||||
self.remote_config = remote_config;
|
||||
self.created_at_unix_secs = created_at_unix_secs;
|
||||
self.updated_at_unix_secs = updated_at_unix_secs;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_manual_proxy_fields(
|
||||
mut self,
|
||||
proxy_url: Option<String>,
|
||||
proxy_username: Option<String>,
|
||||
proxy_password: Option<String>,
|
||||
) -> Self {
|
||||
self.proxy_url = proxy_url;
|
||||
self.proxy_username = proxy_username;
|
||||
self.proxy_password = proxy_password;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ProxyNodeHeartbeatMutation {
|
||||
pub node_id: String,
|
||||
pub heartbeat_interval: Option<i32>,
|
||||
pub active_connections: Option<i32>,
|
||||
pub total_requests_delta: Option<i64>,
|
||||
pub avg_latency_ms: Option<f64>,
|
||||
pub failed_requests_delta: Option<i64>,
|
||||
pub dns_failures_delta: Option<i64>,
|
||||
pub stream_errors_delta: Option<i64>,
|
||||
pub proxy_metadata: Option<serde_json::Value>,
|
||||
pub proxy_version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ProxyNodeTunnelStatusMutation {
|
||||
pub node_id: String,
|
||||
pub connected: bool,
|
||||
pub conn_count: i32,
|
||||
pub detail: Option<String>,
|
||||
pub observed_at_unix_secs: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct StoredProxyNodeEvent {
|
||||
pub id: i64,
|
||||
pub node_id: String,
|
||||
pub event_type: String,
|
||||
pub detail: Option<String>,
|
||||
pub created_at_unix_secs: Option<u64>,
|
||||
}
|
||||
|
||||
pub fn normalize_proxy_metadata(
|
||||
proxy_metadata: Option<&serde_json::Value>,
|
||||
proxy_version: Option<&str>,
|
||||
) -> Option<serde_json::Value> {
|
||||
let mut normalized = match proxy_metadata {
|
||||
Some(serde_json::Value::Object(map)) => map.clone(),
|
||||
Some(_) | None => serde_json::Map::new(),
|
||||
};
|
||||
|
||||
let raw_version = normalized
|
||||
.remove("version")
|
||||
.and_then(|value| value.as_str().map(str::to_string));
|
||||
let version = proxy_version
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| value.chars().take(20).collect::<String>())
|
||||
.or_else(|| {
|
||||
raw_version
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| value.chars().take(20).collect::<String>())
|
||||
});
|
||||
if let Some(version) = version {
|
||||
normalized.insert("version".to_string(), serde_json::Value::String(version));
|
||||
}
|
||||
|
||||
if normalized.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(serde_json::Value::Object(normalized))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ProxyNodeReadRepository: Send + Sync {
|
||||
async fn list_proxy_nodes(&self) -> Result<Vec<StoredProxyNode>, crate::DataLayerError>;
|
||||
|
||||
async fn find_proxy_node(
|
||||
&self,
|
||||
node_id: &str,
|
||||
) -> Result<Option<StoredProxyNode>, crate::DataLayerError>;
|
||||
|
||||
async fn list_proxy_node_events(
|
||||
&self,
|
||||
node_id: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<StoredProxyNodeEvent>, crate::DataLayerError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ProxyNodeWriteRepository: Send + Sync {
|
||||
async fn apply_heartbeat(
|
||||
&self,
|
||||
mutation: &ProxyNodeHeartbeatMutation,
|
||||
) -> Result<Option<StoredProxyNode>, crate::DataLayerError>;
|
||||
|
||||
async fn update_tunnel_status(
|
||||
&self,
|
||||
mutation: &ProxyNodeTunnelStatusMutation,
|
||||
) -> Result<Option<StoredProxyNode>, crate::DataLayerError>;
|
||||
}
|
||||
Reference in New Issue
Block a user