perf: improve worker and storage throughput
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -176,6 +176,7 @@ async fn create_storage_endpoint(
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "创建存储端点失败").with_source(err))?;
|
||||
state.storage_cache.invalidate();
|
||||
|
||||
audit_storage_action(
|
||||
&state,
|
||||
@@ -336,6 +337,7 @@ async fn update_storage_endpoint(
|
||||
"端点配置在测试期间发生变化,请重新编辑",
|
||||
)
|
||||
})?;
|
||||
state.storage_cache.invalidate();
|
||||
|
||||
audit_storage_action(
|
||||
&state,
|
||||
@@ -364,9 +366,18 @@ async fn test_storage_endpoint(
|
||||
let (_jar, admin_id) = require_admin(&state, jar, &headers, ip).await?;
|
||||
let endpoint = storage::get_endpoint(&state, endpoint_id).await?;
|
||||
ensure_configurable(&endpoint)?;
|
||||
let tested_config_updated_at = endpoint.updated_at;
|
||||
if let Err(err) = storage::test_endpoint(&state, &endpoint).await {
|
||||
let detail = storage_test_message(&err);
|
||||
record_test_result(&state, endpoint_id, false, Some(&detail), admin_id).await?;
|
||||
record_test_result(
|
||||
&state,
|
||||
endpoint_id,
|
||||
false,
|
||||
Some(&detail),
|
||||
admin_id,
|
||||
tested_config_updated_at,
|
||||
)
|
||||
.await?;
|
||||
audit_storage_action(
|
||||
&state,
|
||||
admin_id,
|
||||
@@ -379,7 +390,15 @@ async fn test_storage_endpoint(
|
||||
return Err(AppError::new(ErrorCode::StorageUnavailable, detail));
|
||||
}
|
||||
|
||||
record_test_result(&state, endpoint_id, true, None, admin_id).await?;
|
||||
record_test_result(
|
||||
&state,
|
||||
endpoint_id,
|
||||
true,
|
||||
None,
|
||||
admin_id,
|
||||
tested_config_updated_at,
|
||||
)
|
||||
.await?;
|
||||
let endpoint = storage::get_endpoint(&state, endpoint_id).await?;
|
||||
ensure_configurable(&endpoint)?;
|
||||
audit_storage_action(
|
||||
@@ -416,7 +435,15 @@ async fn activate_storage_endpoint(
|
||||
|
||||
if let Err(err) = storage::test_endpoint(&state, &endpoint).await {
|
||||
let detail = storage_test_message(&err);
|
||||
record_test_result(&state, endpoint_id, false, Some(&detail), admin_id).await?;
|
||||
record_test_result(
|
||||
&state,
|
||||
endpoint_id,
|
||||
false,
|
||||
Some(&detail),
|
||||
admin_id,
|
||||
tested_config_updated_at,
|
||||
)
|
||||
.await?;
|
||||
return Err(AppError::new(ErrorCode::StorageUnavailable, detail));
|
||||
}
|
||||
|
||||
@@ -456,6 +483,7 @@ async fn activate_storage_endpoint(
|
||||
tx.commit().await.map_err(|err| {
|
||||
AppError::new(ErrorCode::Internal, "提交存储切换事务失败").with_source(err)
|
||||
})?;
|
||||
state.storage_cache.invalidate();
|
||||
|
||||
let endpoint = storage::get_endpoint(&state, endpoint_id).await?;
|
||||
audit_storage_action(
|
||||
@@ -505,6 +533,7 @@ async fn delete_storage_endpoint(
|
||||
"端点配置已发生变化,请刷新后重试",
|
||||
));
|
||||
}
|
||||
state.storage_cache.invalidate();
|
||||
audit_storage_action(
|
||||
&state,
|
||||
admin_id,
|
||||
@@ -624,8 +653,9 @@ async fn record_test_result(
|
||||
ok: bool,
|
||||
error: Option<&str>,
|
||||
admin_id: Uuid,
|
||||
expected_updated_at: DateTime<Utc>,
|
||||
) -> Result<(), AppError> {
|
||||
sqlx::query(
|
||||
let updated = sqlx::query(
|
||||
r#"
|
||||
UPDATE storage_endpoints
|
||||
SET last_test_at = NOW(),
|
||||
@@ -633,16 +663,25 @@ async fn record_test_result(
|
||||
last_test_error = $3,
|
||||
updated_at = NOW(),
|
||||
updated_by = $4
|
||||
WHERE id = $1
|
||||
WHERE id = $1 AND updated_at = $5 AND deleted_at IS NULL
|
||||
"#,
|
||||
)
|
||||
.bind(endpoint_id)
|
||||
.bind(ok)
|
||||
.bind(error)
|
||||
.bind(admin_id)
|
||||
.bind(expected_updated_at)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "记录存储测试结果失败").with_source(err))?;
|
||||
if updated.rows_affected() == 0 {
|
||||
state.storage_cache.invalidate();
|
||||
return Err(AppError::new(
|
||||
ErrorCode::InvalidRequest,
|
||||
"端点配置在测试期间发生变化,请重新测试",
|
||||
));
|
||||
}
|
||||
state.storage_cache.invalidate();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ pub struct Config {
|
||||
|
||||
pub redis_url: String,
|
||||
|
||||
pub worker_task_concurrency: u32,
|
||||
pub worker_concurrency: u32,
|
||||
pub image_processing_concurrency: u32,
|
||||
|
||||
@@ -62,11 +63,16 @@ impl Config {
|
||||
let redis_url = env_string("REDIS_URL")
|
||||
.ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "缺少环境变量 REDIS_URL"))?;
|
||||
|
||||
let worker_concurrency = env_u32("WORKER_CONCURRENCY").unwrap_or_else(|| {
|
||||
std::thread::available_parallelism()
|
||||
.map(|v| v.get() as u32)
|
||||
.unwrap_or(4)
|
||||
});
|
||||
let worker_task_concurrency = env_u32("WORKER_TASK_CONCURRENCY")
|
||||
.filter(|value| *value > 0)
|
||||
.unwrap_or(4);
|
||||
let worker_concurrency = env_u32("WORKER_CONCURRENCY")
|
||||
.filter(|value| *value > 0)
|
||||
.unwrap_or_else(|| {
|
||||
std::thread::available_parallelism()
|
||||
.map(|v| v.get() as u32)
|
||||
.unwrap_or(4)
|
||||
});
|
||||
let image_processing_concurrency = env_u32("IMAGE_PROCESSING_CONCURRENCY")
|
||||
.filter(|value| *value > 0)
|
||||
.unwrap_or_else(|| {
|
||||
@@ -126,6 +132,7 @@ impl Config {
|
||||
database_url,
|
||||
database_max_connections,
|
||||
redis_url,
|
||||
worker_task_concurrency,
|
||||
worker_concurrency,
|
||||
image_processing_concurrency,
|
||||
jwt_secret,
|
||||
|
||||
@@ -44,6 +44,7 @@ async fn main() -> Result<(), AppError> {
|
||||
mailer: std::sync::Arc::new(mailer),
|
||||
image_processing_semaphore,
|
||||
runtime_policy_cache: crate::services::settings::RuntimePolicyCache::new(),
|
||||
storage_cache: crate::services::storage::StorageCache::new(),
|
||||
};
|
||||
|
||||
match state.config.role.as_str() {
|
||||
|
||||
@@ -13,14 +13,17 @@ use aws_sdk_s3::Client;
|
||||
use bytes::Bytes;
|
||||
use chrono::{DateTime, Datelike, Utc};
|
||||
use sqlx::FromRow;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use url::Url;
|
||||
use uuid::Uuid;
|
||||
|
||||
const MULTIPART_THRESHOLD: u64 = 64 * 1024 * 1024;
|
||||
const MULTIPART_PART_SIZE: usize = 16 * 1024 * 1024;
|
||||
const STORAGE_ENDPOINT_CACHE_TTL: Duration = Duration::from_secs(5);
|
||||
|
||||
#[derive(Debug, Clone, FromRow)]
|
||||
pub struct StorageEndpoint {
|
||||
@@ -60,6 +63,153 @@ pub struct ObjectLocator {
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
enum EndpointKind {
|
||||
Internal,
|
||||
Public,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct StorageCache {
|
||||
inner: Arc<RwLock<StorageCacheState>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct StorageCacheState {
|
||||
active_endpoint: Option<CachedActiveEndpoint>,
|
||||
endpoints: HashMap<Uuid, CachedEndpoint>,
|
||||
clients: HashMap<(Uuid, EndpointKind), CachedClient>,
|
||||
}
|
||||
|
||||
struct CachedActiveEndpoint {
|
||||
loaded_at: Instant,
|
||||
endpoint: Option<StorageEndpoint>,
|
||||
}
|
||||
|
||||
struct CachedEndpoint {
|
||||
loaded_at: Instant,
|
||||
endpoint: StorageEndpoint,
|
||||
}
|
||||
|
||||
struct CachedClient {
|
||||
identity: ClientIdentity,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq)]
|
||||
struct ClientIdentity {
|
||||
endpoint_url: String,
|
||||
region: String,
|
||||
force_path_style: bool,
|
||||
access_key_encrypted: String,
|
||||
secret_key_encrypted: String,
|
||||
}
|
||||
|
||||
impl ClientIdentity {
|
||||
fn new(endpoint: &StorageEndpoint, kind: EndpointKind) -> Self {
|
||||
Self {
|
||||
endpoint_url: match kind {
|
||||
EndpointKind::Internal => endpoint.internal_endpoint.clone(),
|
||||
EndpointKind::Public => endpoint.public_endpoint.clone(),
|
||||
},
|
||||
region: endpoint.region.clone(),
|
||||
force_path_style: endpoint.force_path_style,
|
||||
access_key_encrypted: endpoint.access_key_encrypted.clone(),
|
||||
secret_key_encrypted: endpoint.secret_key_encrypted.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StorageCache {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn invalidate(&self) {
|
||||
*self
|
||||
.inner
|
||||
.write()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner()) = StorageCacheState::default();
|
||||
}
|
||||
|
||||
fn active_endpoint(&self) -> Option<Option<StorageEndpoint>> {
|
||||
let cache = self
|
||||
.inner
|
||||
.read()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let cached = cache.active_endpoint.as_ref()?;
|
||||
(cached.loaded_at.elapsed() < STORAGE_ENDPOINT_CACHE_TTL).then(|| cached.endpoint.clone())
|
||||
}
|
||||
|
||||
fn store_active_endpoint(&self, endpoint: Option<StorageEndpoint>) {
|
||||
let mut cache = self
|
||||
.inner
|
||||
.write()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if let Some(endpoint) = endpoint.as_ref() {
|
||||
cache.endpoints.insert(
|
||||
endpoint.id,
|
||||
CachedEndpoint {
|
||||
loaded_at: Instant::now(),
|
||||
endpoint: endpoint.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
cache.active_endpoint = Some(CachedActiveEndpoint {
|
||||
loaded_at: Instant::now(),
|
||||
endpoint,
|
||||
});
|
||||
}
|
||||
|
||||
fn endpoint(&self, endpoint_id: Uuid) -> Option<StorageEndpoint> {
|
||||
let cache = self
|
||||
.inner
|
||||
.read()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let cached = cache.endpoints.get(&endpoint_id)?;
|
||||
(cached.loaded_at.elapsed() < STORAGE_ENDPOINT_CACHE_TTL).then(|| cached.endpoint.clone())
|
||||
}
|
||||
|
||||
fn store_endpoint(&self, endpoint: StorageEndpoint) {
|
||||
self.inner
|
||||
.write()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.endpoints
|
||||
.insert(
|
||||
endpoint.id,
|
||||
CachedEndpoint {
|
||||
loaded_at: Instant::now(),
|
||||
endpoint,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn client(&self, endpoint: &StorageEndpoint, kind: EndpointKind) -> Option<Client> {
|
||||
let identity = ClientIdentity::new(endpoint, kind);
|
||||
self.inner
|
||||
.read()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.clients
|
||||
.get(&(endpoint.id, kind))
|
||||
.filter(|cached| cached.identity == identity)
|
||||
.map(|cached| cached.client.clone())
|
||||
}
|
||||
|
||||
fn store_client(&self, endpoint: &StorageEndpoint, kind: EndpointKind, client: Client) {
|
||||
self.inner
|
||||
.write()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.clients
|
||||
.insert(
|
||||
(endpoint.id, kind),
|
||||
CachedClient {
|
||||
identity: ClientIdentity::new(endpoint, kind),
|
||||
client,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_endpoints(state: &AppState) -> Result<Vec<StorageEndpoint>, AppError> {
|
||||
sqlx::query_as::<_, StorageEndpoint>(
|
||||
r#"
|
||||
@@ -82,7 +232,11 @@ pub async fn get_endpoint(
|
||||
state: &AppState,
|
||||
endpoint_id: Uuid,
|
||||
) -> Result<StorageEndpoint, AppError> {
|
||||
sqlx::query_as::<_, StorageEndpoint>(
|
||||
if let Some(endpoint) = state.storage_cache.endpoint(endpoint_id) {
|
||||
return Ok(endpoint);
|
||||
}
|
||||
|
||||
let endpoint = sqlx::query_as::<_, StorageEndpoint>(
|
||||
r#"
|
||||
SELECT id, name, internal_endpoint, public_endpoint, bucket, region,
|
||||
access_key_encrypted, secret_key_encrypted, access_key_hint,
|
||||
@@ -97,11 +251,17 @@ pub async fn get_endpoint(
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询存储端点失败").with_source(err))?
|
||||
.ok_or_else(|| AppError::new(ErrorCode::NotFound, "存储端点不存在"))
|
||||
.ok_or_else(|| AppError::new(ErrorCode::NotFound, "存储端点不存在"))?;
|
||||
state.storage_cache.store_endpoint(endpoint.clone());
|
||||
Ok(endpoint)
|
||||
}
|
||||
|
||||
pub async fn active_endpoint(state: &AppState) -> Result<Option<StorageEndpoint>, AppError> {
|
||||
sqlx::query_as::<_, StorageEndpoint>(
|
||||
if let Some(endpoint) = state.storage_cache.active_endpoint() {
|
||||
return Ok(endpoint);
|
||||
}
|
||||
|
||||
let endpoint = sqlx::query_as::<_, StorageEndpoint>(
|
||||
r#"
|
||||
SELECT id, name, internal_endpoint, public_endpoint, bucket, region,
|
||||
access_key_encrypted, secret_key_encrypted, access_key_hint,
|
||||
@@ -115,7 +275,9 @@ pub async fn active_endpoint(state: &AppState) -> Result<Option<StorageEndpoint>
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询活动存储端点失败").with_source(err))
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询活动存储端点失败").with_source(err))?;
|
||||
state.storage_cache.store_active_endpoint(endpoint.clone());
|
||||
Ok(endpoint)
|
||||
}
|
||||
|
||||
pub fn result_key(retention_hours: i64, task_id: Uuid, file_id: Uuid, extension: &str) -> String {
|
||||
@@ -564,6 +726,10 @@ fn client_for(
|
||||
endpoint: &StorageEndpoint,
|
||||
kind: EndpointKind,
|
||||
) -> Result<Client, AppError> {
|
||||
if let Some(client) = state.storage_cache.client(endpoint, kind) {
|
||||
return Ok(client);
|
||||
}
|
||||
|
||||
let access_key =
|
||||
settings::decrypt_secret(state, &endpoint.access_key_encrypted).map_err(|err| {
|
||||
AppError::new(ErrorCode::StorageUnavailable, "存储端点凭据不可用").with_source(err)
|
||||
@@ -576,13 +742,17 @@ fn client_for(
|
||||
EndpointKind::Internal => &endpoint.internal_endpoint,
|
||||
EndpointKind::Public => &endpoint.public_endpoint,
|
||||
};
|
||||
Ok(build_client(
|
||||
let client = build_client(
|
||||
access_key,
|
||||
secret_key,
|
||||
endpoint_url,
|
||||
&endpoint.region,
|
||||
endpoint.force_path_style,
|
||||
))
|
||||
);
|
||||
state
|
||||
.storage_cache
|
||||
.store_client(endpoint, kind, client.clone());
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
fn build_client(
|
||||
@@ -774,12 +944,6 @@ fn cors_allows_origin(allowed_origin: Option<&str>, expected_origin: &str) -> bo
|
||||
allowed_origin.is_some_and(|value| value == "*" || value == expected_origin)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum EndpointKind {
|
||||
Internal,
|
||||
Public,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -860,4 +1024,61 @@ mod tests {
|
||||
"https://tp.workyai.cn"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storage_cache_reuses_matching_clients_and_invalidates_changes() {
|
||||
let cache = StorageCache::new();
|
||||
let mut endpoint = test_storage_endpoint();
|
||||
let client = build_client(
|
||||
"access-key".to_string(),
|
||||
"secret-key".to_string(),
|
||||
&endpoint.internal_endpoint,
|
||||
&endpoint.region,
|
||||
endpoint.force_path_style,
|
||||
);
|
||||
|
||||
cache.store_client(&endpoint, EndpointKind::Internal, client);
|
||||
assert!(cache.client(&endpoint, EndpointKind::Internal).is_some());
|
||||
assert!(cache.client(&endpoint, EndpointKind::Public).is_none());
|
||||
|
||||
endpoint.internal_endpoint = "https://new-storage.example.com".to_string();
|
||||
assert!(cache.client(&endpoint, EndpointKind::Internal).is_none());
|
||||
|
||||
cache.invalidate();
|
||||
assert!(cache.client(&endpoint, EndpointKind::Internal).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_endpoint_cache_distinguishes_cached_local_backend() {
|
||||
let cache = StorageCache::new();
|
||||
assert!(cache.active_endpoint().is_none());
|
||||
cache.store_active_endpoint(None);
|
||||
assert!(matches!(cache.active_endpoint(), Some(None)));
|
||||
cache.invalidate();
|
||||
assert!(cache.active_endpoint().is_none());
|
||||
}
|
||||
|
||||
fn test_storage_endpoint() -> StorageEndpoint {
|
||||
let now = Utc::now();
|
||||
StorageEndpoint {
|
||||
id: Uuid::new_v4(),
|
||||
name: "test".to_string(),
|
||||
internal_endpoint: "https://storage.example.com".to_string(),
|
||||
public_endpoint: "https://files.example.com".to_string(),
|
||||
bucket: "images".to_string(),
|
||||
region: "garage".to_string(),
|
||||
access_key_encrypted: "encrypted-access".to_string(),
|
||||
secret_key_encrypted: "encrypted-secret".to_string(),
|
||||
access_key_hint: "test".to_string(),
|
||||
force_path_style: true,
|
||||
presign_ttl_seconds: 300,
|
||||
is_active: true,
|
||||
last_test_at: None,
|
||||
last_test_ok: None,
|
||||
last_test_error: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
deleted_at: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::config::Config;
|
||||
use crate::services::mail::Mailer;
|
||||
use crate::services::settings::RuntimePolicyCache;
|
||||
use crate::services::storage::StorageCache;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
@@ -10,4 +11,5 @@ pub struct AppState {
|
||||
pub mailer: std::sync::Arc<Mailer>,
|
||||
pub image_processing_semaphore: std::sync::Arc<tokio::sync::Semaphore>,
|
||||
pub runtime_policy_cache: RuntimePolicyCache,
|
||||
pub storage_cache: StorageCache,
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ use redis::AsyncCommands;
|
||||
use sqlx::FromRow;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::Semaphore;
|
||||
use tokio::task::JoinSet;
|
||||
use uuid::Uuid;
|
||||
@@ -21,6 +21,13 @@ const GROUP_NAME: &str = metrics::QUEUE_GROUP_NAME;
|
||||
const DEAD_STREAM_KEY: &str = metrics::DEAD_STREAM_KEY;
|
||||
const MAX_DELIVERIES: usize = 3;
|
||||
const STALE_MESSAGE_IDLE_MS: usize = 5 * 60 * 1000;
|
||||
const MESSAGE_HEARTBEAT_SECONDS: u64 = 60;
|
||||
const QUEUE_BLOCK_MS: usize = 1_000;
|
||||
const MAINTENANCE_INTERVAL_SECONDS: u64 = 300;
|
||||
const MAINTENANCE_BATCH_SIZE: i64 = 1_000;
|
||||
const MAX_MAINTENANCE_BATCHES: usize = 20;
|
||||
const INITIAL_RETRY_SECONDS: u64 = 2;
|
||||
const MAX_RETRY_SECONDS: u64 = 30;
|
||||
|
||||
pub async fn run(state: AppState) -> Result<(), AppError> {
|
||||
tracing::info!("Worker started");
|
||||
@@ -29,21 +36,80 @@ pub async fn run(state: AppState) -> Result<(), AppError> {
|
||||
|
||||
let consumer = format!("worker_{}", Uuid::new_v4());
|
||||
ensure_group(&state).await?;
|
||||
tokio::spawn(maintenance_loop(state.clone()));
|
||||
|
||||
let mut last_maintenance = Instant::now();
|
||||
let task_concurrency = state.config.worker_task_concurrency.max(1) as usize;
|
||||
let mut inflight = JoinSet::new();
|
||||
let mut poll_backoff = Duration::from_secs(INITIAL_RETRY_SECONDS);
|
||||
tracing::info!(task_concurrency, "Worker task scheduler ready");
|
||||
|
||||
loop {
|
||||
if let Err(err) = poll_once(&state, &consumer).await {
|
||||
tracing::error!(error = ?err, "worker poll error");
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
while let Some(result) = inflight.try_join_next() {
|
||||
log_message_task_result(result);
|
||||
}
|
||||
|
||||
if last_maintenance.elapsed().as_secs() >= 300 {
|
||||
if let Err(err) = maintenance(&state).await {
|
||||
tracing::error!(error = ?err, "maintenance failed");
|
||||
let available = task_concurrency.saturating_sub(inflight.len());
|
||||
if available > 0 {
|
||||
match read_messages(&state, &consumer, available).await {
|
||||
Ok(messages) => {
|
||||
poll_backoff = Duration::from_secs(INITIAL_RETRY_SECONDS);
|
||||
for message in messages {
|
||||
let state = state.clone();
|
||||
let consumer = consumer.clone();
|
||||
inflight.spawn(async move {
|
||||
process_message_with_retries(state, consumer, message).await
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!(
|
||||
error = ?err,
|
||||
retry_in_seconds = poll_backoff.as_secs(),
|
||||
"worker poll error"
|
||||
);
|
||||
tokio::time::sleep(poll_backoff).await;
|
||||
poll_backoff = next_backoff(poll_backoff);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
last_maintenance = Instant::now();
|
||||
}
|
||||
|
||||
if inflight.len() >= task_concurrency {
|
||||
if let Some(result) = inflight.join_next().await {
|
||||
log_message_task_result(result);
|
||||
}
|
||||
} else {
|
||||
tokio::select! {
|
||||
result = inflight.join_next(), if !inflight.is_empty() => {
|
||||
if let Some(result) = result {
|
||||
log_message_task_result(result);
|
||||
}
|
||||
}
|
||||
_ = tokio::time::sleep(Duration::from_millis(25)) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn maintenance_loop(state: AppState) {
|
||||
let mut interval = tokio::time::interval_at(
|
||||
tokio::time::Instant::now() + Duration::from_secs(MAINTENANCE_INTERVAL_SECONDS),
|
||||
Duration::from_secs(MAINTENANCE_INTERVAL_SECONDS),
|
||||
);
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
loop {
|
||||
interval.tick().await;
|
||||
if let Err(err) = maintenance(&state).await {
|
||||
tracing::error!(error = ?err, "maintenance failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn log_message_task_result(result: Result<Result<(), AppError>, tokio::task::JoinError>) {
|
||||
match result {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(err)) => tracing::error!(error = ?err, "worker message task stopped"),
|
||||
Err(err) => tracing::error!(error = ?err, "worker message task panicked"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,51 +137,144 @@ async fn ensure_group(state: &AppState) -> Result<(), AppError> {
|
||||
}
|
||||
}
|
||||
|
||||
async fn poll_once(state: &AppState, consumer: &str) -> Result<(), AppError> {
|
||||
async fn read_messages(
|
||||
state: &AppState,
|
||||
consumer: &str,
|
||||
count: usize,
|
||||
) -> Result<Vec<StreamId>, AppError> {
|
||||
let mut conn = state.redis.clone();
|
||||
|
||||
if let Some(msg) = claim_stale_message(&mut conn, consumer).await? {
|
||||
return handle_message(state, &mut conn, msg).await;
|
||||
return Ok(vec![msg]);
|
||||
}
|
||||
|
||||
// Retry messages already delivered to this consumer before taking new work.
|
||||
let pending_opts = StreamReadOptions::default()
|
||||
let opts = StreamReadOptions::default()
|
||||
.group(GROUP_NAME, consumer)
|
||||
.count(1);
|
||||
let mut reply: redis::streams::StreamReadReply = conn
|
||||
.xread_options(&[STREAM_KEY], &["0"], &pending_opts)
|
||||
.count(count)
|
||||
.block(QUEUE_BLOCK_MS);
|
||||
let reply: redis::streams::StreamReadReply = conn
|
||||
.xread_options(&[STREAM_KEY], &[">"], &opts)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "读取待重试任务失败").with_source(err))?;
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "读取队列失败").with_source(err))?;
|
||||
|
||||
if !reply.keys.iter().any(|key| !key.ids.is_empty()) {
|
||||
let opts = StreamReadOptions::default()
|
||||
.group(GROUP_NAME, consumer)
|
||||
.count(1)
|
||||
.block(5000);
|
||||
Ok(reply
|
||||
.keys
|
||||
.into_iter()
|
||||
.flat_map(|key| key.ids.into_iter())
|
||||
.collect())
|
||||
}
|
||||
|
||||
reply = conn
|
||||
.xread_options(&[STREAM_KEY], &[">"], &opts)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "读取队列失败").with_source(err))?;
|
||||
}
|
||||
async fn process_message_with_retries(
|
||||
state: AppState,
|
||||
consumer: String,
|
||||
mut message: StreamId,
|
||||
) -> Result<(), AppError> {
|
||||
loop {
|
||||
match handle_message_with_heartbeat(&state, &consumer, &message).await {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(err) => {
|
||||
let mut conn = state.redis.clone();
|
||||
let deliveries = match pending_delivery_count(&mut conn, &message.id).await {
|
||||
Ok(deliveries) => deliveries,
|
||||
Err(count_err) => {
|
||||
tracing::warn!(
|
||||
message_id = %message.id,
|
||||
error = ?count_err,
|
||||
"failed to read delivery count; using initial retry delay"
|
||||
);
|
||||
1
|
||||
}
|
||||
};
|
||||
let delay = delivery_backoff(deliveries);
|
||||
tracing::warn!(
|
||||
message_id = %message.id,
|
||||
deliveries,
|
||||
retry_in_seconds = delay.as_secs(),
|
||||
error = ?err,
|
||||
"worker message handling failed; retry scheduled"
|
||||
);
|
||||
tokio::time::sleep(delay).await;
|
||||
|
||||
if reply.keys.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for key in reply.keys {
|
||||
for msg in key.ids {
|
||||
handle_message(state, &mut conn, msg).await?;
|
||||
let mut reclaim_backoff = Duration::from_secs(INITIAL_RETRY_SECONDS);
|
||||
loop {
|
||||
match redeliver_message(&mut conn, &consumer, &message.id).await {
|
||||
Ok(Some(redelivered)) => {
|
||||
message = redelivered;
|
||||
break;
|
||||
}
|
||||
Ok(None) => return Ok(()),
|
||||
Err(reclaim_err) => {
|
||||
tracing::error!(
|
||||
message_id = %message.id,
|
||||
retry_in_seconds = reclaim_backoff.as_secs(),
|
||||
error = ?reclaim_err,
|
||||
"failed to reclaim pending worker message"
|
||||
);
|
||||
tokio::time::sleep(reclaim_backoff).await;
|
||||
reclaim_backoff = next_backoff(reclaim_backoff);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_message_with_heartbeat(
|
||||
state: &AppState,
|
||||
consumer: &str,
|
||||
message: &StreamId,
|
||||
) -> Result<(), AppError> {
|
||||
let mut conn = state.redis.clone();
|
||||
let handling = handle_message(state, &mut conn, message);
|
||||
tokio::pin!(handling);
|
||||
let mut heartbeat = tokio::time::interval_at(
|
||||
tokio::time::Instant::now() + Duration::from_secs(MESSAGE_HEARTBEAT_SECONDS),
|
||||
Duration::from_secs(MESSAGE_HEARTBEAT_SECONDS),
|
||||
);
|
||||
heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
result = &mut handling => return result,
|
||||
_ = heartbeat.tick() => {
|
||||
if let Err(err) = touch_pending_message(state, consumer, &message.id).await {
|
||||
tracing::warn!(
|
||||
message_id = %message.id,
|
||||
error = ?err,
|
||||
"failed to refresh worker message heartbeat"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn touch_pending_message(
|
||||
state: &AppState,
|
||||
consumer: &str,
|
||||
message_id: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let mut conn = state.redis.clone();
|
||||
redis::cmd("XCLAIM")
|
||||
.arg(STREAM_KEY)
|
||||
.arg(GROUP_NAME)
|
||||
.arg(consumer)
|
||||
.arg(0)
|
||||
.arg(message_id)
|
||||
.arg("JUSTID")
|
||||
.query_async::<_, redis::Value>(&mut conn)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
AppError::new(ErrorCode::Internal, "刷新队列任务心跳失败").with_source(err)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_message(
|
||||
state: &AppState,
|
||||
conn: &mut redis::aio::ConnectionManager,
|
||||
msg: StreamId,
|
||||
msg: &StreamId,
|
||||
) -> Result<(), AppError> {
|
||||
let Some(task_id_str) = msg.get::<String>("task_id") else {
|
||||
ack_message(conn, &msg.id).await?;
|
||||
@@ -147,17 +306,25 @@ async fn handle_message(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
tracing::warn!(
|
||||
task_id = %task_id,
|
||||
deliveries,
|
||||
error = %err,
|
||||
"task processing failed; message left pending for retry"
|
||||
);
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn redeliver_message(
|
||||
conn: &mut redis::aio::ConnectionManager,
|
||||
consumer: &str,
|
||||
message_id: &str,
|
||||
) -> Result<Option<StreamId>, AppError> {
|
||||
let claimed: StreamClaimReply = conn
|
||||
.xclaim(STREAM_KEY, GROUP_NAME, consumer, 0, &[message_id])
|
||||
.await
|
||||
.map_err(|err| {
|
||||
AppError::new(ErrorCode::Internal, "重新认领待重试任务失败").with_source(err)
|
||||
})?;
|
||||
Ok(claimed.ids.into_iter().next())
|
||||
}
|
||||
|
||||
async fn claim_stale_message(
|
||||
conn: &mut redis::aio::ConnectionManager,
|
||||
consumer: &str,
|
||||
@@ -212,6 +379,21 @@ fn should_dead_letter(deliveries: usize) -> bool {
|
||||
deliveries >= MAX_DELIVERIES
|
||||
}
|
||||
|
||||
fn delivery_backoff(deliveries: usize) -> Duration {
|
||||
let exponent = deliveries.saturating_sub(1).min(4) as u32;
|
||||
Duration::from_secs(
|
||||
INITIAL_RETRY_SECONDS
|
||||
.saturating_mul(2_u64.saturating_pow(exponent))
|
||||
.min(MAX_RETRY_SECONDS),
|
||||
)
|
||||
}
|
||||
|
||||
fn next_backoff(current: Duration) -> Duration {
|
||||
current
|
||||
.saturating_mul(2)
|
||||
.min(Duration::from_secs(MAX_RETRY_SECONDS))
|
||||
}
|
||||
|
||||
async fn write_dead_letter(
|
||||
conn: &mut redis::aio::ConnectionManager,
|
||||
message_id: &str,
|
||||
@@ -1026,22 +1208,38 @@ async fn maintenance(state: &AppState) -> Result<(), AppError> {
|
||||
}
|
||||
|
||||
async fn settle_finished_anonymous_reservations(state: &AppState) -> Result<(), AppError> {
|
||||
let task_ids: Vec<Uuid> = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT id
|
||||
FROM tasks
|
||||
WHERE anonymous_units_reserved > 0
|
||||
AND status IN ('completed', 'failed', 'cancelled')
|
||||
ORDER BY completed_at ASC NULLS FIRST
|
||||
LIMIT 200
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询待结算匿名任务失败").with_source(err))?;
|
||||
for _ in 0..MAX_MAINTENANCE_BATCHES {
|
||||
let task_ids: Vec<Uuid> = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT id
|
||||
FROM tasks
|
||||
WHERE anonymous_units_reserved > 0
|
||||
AND status IN ('completed', 'failed', 'cancelled')
|
||||
ORDER BY completed_at ASC NULLS FIRST
|
||||
LIMIT $1
|
||||
"#,
|
||||
)
|
||||
.bind(MAINTENANCE_BATCH_SIZE)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
AppError::new(ErrorCode::Internal, "查询待结算匿名任务失败").with_source(err)
|
||||
})?;
|
||||
|
||||
for task_id in task_ids {
|
||||
quota::settle_anonymous_task_reservation(state, task_id).await?;
|
||||
let batch_len = task_ids.len();
|
||||
let mut settled = 0usize;
|
||||
for task_id in task_ids {
|
||||
match quota::settle_anonymous_task_reservation(state, task_id).await {
|
||||
Ok(_) => settled += 1,
|
||||
Err(err) => {
|
||||
tracing::warn!(task_id = %task_id, error = %err, "anonymous quota settlement deferred")
|
||||
}
|
||||
}
|
||||
}
|
||||
if settled == 0 || batch_len < MAINTENANCE_BATCH_SIZE as usize {
|
||||
break;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1117,20 +1315,34 @@ async fn cleanup_expired_records(state: &AppState) -> Result<(), AppError> {
|
||||
}
|
||||
|
||||
async fn cleanup_expired_tasks(state: &AppState) -> Result<(), AppError> {
|
||||
let task_ids: Vec<Uuid> =
|
||||
sqlx::query_scalar("SELECT id FROM tasks WHERE expires_at < NOW() LIMIT 200")
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
for _ in 0..MAX_MAINTENANCE_BATCHES {
|
||||
let task_ids: Vec<Uuid> = sqlx::query_scalar(
|
||||
"SELECT id FROM tasks WHERE expires_at < NOW() ORDER BY expires_at ASC LIMIT $1",
|
||||
)
|
||||
.bind(MAINTENANCE_BATCH_SIZE)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询过期任务失败").with_source(err))?;
|
||||
|
||||
if task_ids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for task_id in task_ids {
|
||||
if let Err(err) = cleanup_expired_task(state, task_id).await {
|
||||
tracing::warn!(task_id = %task_id, error = %err, "expired task cleanup deferred");
|
||||
if task_ids.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
let batch_len = task_ids.len();
|
||||
let mut cleaned = 0usize;
|
||||
for task_id in task_ids {
|
||||
match cleanup_expired_task(state, task_id).await {
|
||||
Ok(()) => cleaned += 1,
|
||||
Err(err) => {
|
||||
tracing::warn!(task_id = %task_id, error = %err, "expired task cleanup deferred")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if cleaned == 0 || batch_len < MAINTENANCE_BATCH_SIZE as usize {
|
||||
break;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -1232,4 +1444,21 @@ mod tests {
|
||||
assert!(should_dead_letter(3));
|
||||
assert!(should_dead_letter(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_backoff_is_exponential_and_capped() {
|
||||
assert_eq!(delivery_backoff(1), Duration::from_secs(2));
|
||||
assert_eq!(delivery_backoff(2), Duration::from_secs(4));
|
||||
assert_eq!(delivery_backoff(3), Duration::from_secs(8));
|
||||
assert_eq!(delivery_backoff(10), Duration::from_secs(30));
|
||||
assert_eq!(
|
||||
next_backoff(Duration::from_secs(30)),
|
||||
Duration::from_secs(30)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_heartbeat_precedes_stale_claim_threshold() {
|
||||
assert!(MESSAGE_HEARTBEAT_SECONDS * 1_000 < STALE_MESSAGE_IDLE_MS as u64);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user