connection-pool-fix
Some checks failed
Deploy on push / deploy (push) Failing after 4s

This commit is contained in:
2025-09-22 01:23:16 +03:00
parent 91e5f5dac4
commit 3ff469c8a1
7 changed files with 528 additions and 95 deletions

View File

@@ -23,6 +23,7 @@
- **Упрощение**: Заменена `extract_and_validate_token` на `authenticate_request`
#### 🏗️ Архитектурные улучшения
- Используем redis connection pool
- **Библиотечная цель**: Добавлена `lib.rs` для тестирования модулей
- **Модульность**: Четкое разделение ответственности между модулями
- **Единообразие**: Все handlers теперь используют одинаковую логику аутентификации

View File

@@ -6,11 +6,139 @@ use aws_config::BehaviorVersion;
use aws_sdk_s3::{Client as S3Client, config::Credentials};
use log::warn;
use redis::{AsyncCommands, Client as RedisClient, aio::MultiplexedConnection};
use std::{env, time::Duration};
use std::{env, sync::Arc, time::Duration};
use tokio::sync::Mutex;
/// Redis Connection Pool
#[derive(Clone)]
pub struct RedisConnectionPool {
client: RedisClient,
max_connections: usize,
connections: Arc<Mutex<Vec<MultiplexedConnection>>>,
timeout: Duration,
}
impl RedisConnectionPool {
/// Создает новый connection pool
pub async fn new(
redis_url: String,
max_connections: usize,
timeout: Duration,
) -> Result<Self, redis::RedisError> {
let client = RedisClient::open(redis_url)?;
let connections = Arc::new(Mutex::new(Vec::new()));
let pool = Self {
client,
max_connections,
connections,
timeout,
};
// Предварительно создаем несколько соединений
pool.warm_up_connections(3).await?;
Ok(pool)
}
/// Предварительно создает соединения для пула
async fn warm_up_connections(&self, count: usize) -> Result<(), redis::RedisError> {
let mut connections = self.connections.lock().await;
for _ in 0..count.min(self.max_connections) {
match tokio::time::timeout(self.timeout, self.client.get_multiplexed_async_connection())
.await
{
Ok(Ok(conn)) => {
connections.push(conn);
}
Ok(Err(e)) => {
warn!("Failed to create Redis connection during warm-up: {}", e);
return Err(e);
}
Err(_) => {
warn!("Timeout creating Redis connection during warm-up");
return Err(redis::RedisError::from((
redis::ErrorKind::IoError,
"Connection timeout",
)));
}
}
}
warn!(
"✅ Redis connection pool warmed up with {} connections",
connections.len()
);
Ok(())
}
/// Получает соединение из пула
pub async fn get_connection(&self) -> Result<MultiplexedConnection, redis::RedisError> {
let mut connections = self.connections.lock().await;
// Пытаемся взять существующее соединение
if let Some(conn) = connections.pop() {
return Ok(conn);
}
// Если соединений нет, создаем новое
drop(connections); // Освобождаем мьютекс
match tokio::time::timeout(self.timeout, self.client.get_multiplexed_async_connection())
.await
{
Ok(Ok(conn)) => Ok(conn),
Ok(Err(e)) => {
warn!("Failed to create new Redis connection: {}", e);
Err(e)
}
Err(_) => {
warn!("Timeout creating new Redis connection");
Err(redis::RedisError::from((
redis::ErrorKind::IoError,
"Connection timeout",
)))
}
}
}
/// Возвращает соединение обратно в пул
pub async fn return_connection(&self, conn: MultiplexedConnection) {
let mut connections = self.connections.lock().await;
if connections.len() < self.max_connections {
connections.push(conn);
}
// Если пул полный, соединение просто закрывается
}
/// Проверяет здоровье пула
pub async fn health_check(&self) -> bool {
match self.get_connection().await {
Ok(mut conn) => {
match tokio::time::timeout(Duration::from_secs(2), conn.ping::<String>()).await {
Ok(Ok(_)) => {
self.return_connection(conn).await;
true
}
_ => false,
}
}
Err(_) => false,
}
}
/// Получает статистику пула
pub async fn get_stats(&self) -> (usize, usize) {
let connections = self.connections.lock().await;
(connections.len(), self.max_connections)
}
}
#[derive(Clone)]
pub struct AppState {
pub redis: Option<MultiplexedConnection>,
pub redis_pool: Option<RedisConnectionPool>,
pub storj_client: S3Client,
pub aws_client: S3Client,
pub bucket: String,
@@ -27,6 +155,43 @@ impl AppState {
Self::new_with_config(security_config).await
}
/// Получает соединение из Redis connection pool
pub async fn get_redis_connection(&self) -> Result<MultiplexedConnection, redis::RedisError> {
if let Some(ref pool) = self.redis_pool {
pool.get_connection().await
} else {
Err(redis::RedisError::from((
redis::ErrorKind::IoError,
"Redis pool not available",
)))
}
}
/// Возвращает соединение обратно в пул
pub async fn return_redis_connection(&self, conn: MultiplexedConnection) {
if let Some(ref pool) = self.redis_pool {
pool.return_connection(conn).await;
}
}
/// Проверяет здоровье Redis connection pool
pub async fn redis_health_check(&self) -> bool {
if let Some(ref pool) = self.redis_pool {
pool.health_check().await
} else {
false
}
}
/// Получает статистику Redis connection pool
pub async fn redis_pool_stats(&self) -> Option<(usize, usize)> {
if let Some(ref pool) = self.redis_pool {
Some(pool.get_stats().await)
} else {
None
}
}
/// Инициализация с кастомной конфигурацией безопасности.
pub async fn new_with_config(security_config: SecurityConfig) -> Self {
log::warn!("🚀 Starting AppState initialization...");
@@ -89,64 +254,33 @@ impl AppState {
security_config: SecurityConfig,
redis_url: String,
) -> Self {
let redis_client = match RedisClient::open(redis_url) {
Ok(client) => {
log::warn!("✅ Redis client created successfully");
client
}
Err(e) => {
log::error!("❌ Failed to create Redis client: {}", e);
panic!("Redis client creation failed: {}", e);
}
};
// Устанавливаем таймаут для Redis операций с graceful fallback
log::warn!(
"🔄 Attempting Redis connection with timeout: {}s",
security_config.request_timeout_seconds
);
let redis_connection = match tokio::time::timeout(
// Создаем Redis connection pool
let redis_pool = match RedisConnectionPool::new(
redis_url.clone(),
20, // max_connections согласно руководству
Duration::from_secs(security_config.request_timeout_seconds),
redis_client.get_multiplexed_async_connection(),
)
.await
{
Ok(Ok(mut conn)) => {
log::warn!("✅ Redis connection established");
// Тестируем подключение простой командой
match tokio::time::timeout(Duration::from_secs(2), conn.ping::<String>()).await {
Ok(Ok(result)) => {
log::warn!("✅ Redis PING successful: {}", result);
Some(conn)
}
Ok(Err(e)) => {
log::warn!("⚠️ Redis PING failed: {}", e);
None
}
Err(_) => {
log::warn!("⚠️ Redis PING timeout");
None
}
}
Ok(pool) => {
log::warn!("✅ Redis connection pool created successfully");
Some(pool)
}
Ok(Err(e)) => {
log::warn!("⚠️ Redis connection failed: {}", e);
log::warn!(" Error type: {:?}", e.kind());
log::warn!("⚠️ Running in fallback mode without Redis (quotas disabled)");
None
}
Err(_) => {
log::warn!(
"⚠️ Redis connection timeout after {} seconds",
security_config.request_timeout_seconds
);
Err(e) => {
log::warn!("⚠️ Redis connection pool creation failed: {}", e);
log::warn!("⚠️ Running in fallback mode without Redis (quotas disabled)");
None
}
};
// Одиночное соединение больше не нужно - используем только connection pool
// Получаем конфигурацию для S3 (Storj)
let s3_access_key = env::var("STORJ_ACCESS_KEY").expect("STORJ_ACCESS_KEY must be set");
let s3_secret_key = env::var("STORJ_SECRET_KEY").expect("STORJ_SECRET_KEY must be set");
@@ -209,7 +343,7 @@ impl AppState {
let aws_client = S3Client::new(&aws_config);
let app_state = AppState {
redis: redis_connection,
redis_pool,
storj_client,
aws_client,
bucket,
@@ -226,10 +360,13 @@ impl AppState {
pub async fn cache_filelist(&self) {
warn!("caching AWS filelist...");
// Проверяем доступность Redis
let Some(mut redis) = self.redis.clone() else {
warn!("⚠️ Redis not available, skipping filelist caching");
return;
// Получаем соединение из пула
let mut redis = match self.get_redis_connection().await {
Ok(conn) => conn,
Err(_) => {
warn!("⚠️ Redis pool not available, skipping filelist caching");
return;
}
};
// Запрашиваем список файлов из Storj S3
@@ -249,31 +386,43 @@ impl AppState {
}
warn!("cached {} files", filelist.len());
// Возвращаем соединение в пул
self.return_redis_connection(redis).await;
}
/// Получает путь из ключа (имени файла) в Redis с таймаутом.
pub async fn get_path(&self, filename: &str) -> Result<Option<String>, actix_web::Error> {
let Some(mut redis) = self.redis.clone() else {
warn!("⚠️ Redis not available, returning None for path lookup");
return Ok(None);
let mut redis = match self.get_redis_connection().await {
Ok(conn) => conn,
Err(_) => {
warn!("⚠️ Redis pool not available, returning None for path lookup");
return Ok(None);
}
};
let new_path: Option<String> =
let result: Option<String> =
tokio::time::timeout(self.request_timeout, redis.hget(PATH_MAPPING_KEY, filename))
.await
.map_err(|_| ErrorInternalServerError("Redis operation timeout"))?
.map_err(|_| ErrorInternalServerError("Failed to get path mapping from Redis"))?;
Ok(new_path)
// Возвращаем соединение в пул
self.return_redis_connection(redis).await;
Ok(result)
}
pub async fn set_path(&self, filename: &str, filepath: &str) {
let Some(mut redis) = self.redis.clone() else {
warn!(
"⚠️ Redis not available, skipping path caching for {}",
filename
);
return;
let mut redis = match self.get_redis_connection().await {
Ok(conn) => conn,
Err(_) => {
warn!(
"⚠️ Redis pool not available, skipping path caching for {}",
filename
);
return;
}
};
if let Err(e) = tokio::time::timeout(
@@ -284,16 +433,22 @@ impl AppState {
{
warn!("⚠️ Redis operation failed for {}: {}", filename, e);
}
// Возвращаем соединение в пул
self.return_redis_connection(redis).await;
}
/// создает или получает текущее значение квоты пользователя с таймаутом
pub async fn get_or_create_quota(&self, user_id: &str) -> Result<u64, actix_web::Error> {
let Some(mut redis) = self.redis.clone() else {
warn!(
"⚠️ Redis not available, returning default quota for user {}",
user_id
);
return Ok(0); // Возвращаем 0 как fallback
let mut redis = match self.get_redis_connection().await {
Ok(conn) => conn,
Err(_) => {
warn!(
"⚠️ Redis pool not available, returning default quota for user {}",
user_id
);
return Ok(0); // Возвращаем 0 как fallback
}
};
let quota_key = format!("quota:{}", user_id);
@@ -313,8 +468,12 @@ impl AppState {
.map_err(|_| ErrorInternalServerError("Redis timeout setting user quota"))?
.map_err(|_| ErrorInternalServerError("Failed to set initial user quota in Redis"))?;
// Возвращаем соединение в пул
self.return_redis_connection(redis).await;
Ok(0) // Возвращаем 0 как начальную квоту
} else {
// Возвращаем соединение в пул
self.return_redis_connection(redis).await;
Ok(quota)
}
}
@@ -325,12 +484,15 @@ impl AppState {
user_id: &str,
bytes: u64,
) -> Result<u64, actix_web::Error> {
let Some(mut redis) = self.redis.clone() else {
warn!(
"⚠️ Redis not available, skipping quota increment for user {}",
user_id
);
return Ok(0); // Возвращаем 0 как fallback
let mut redis = match self.get_redis_connection().await {
Ok(conn) => conn,
Err(_) => {
warn!(
"⚠️ Redis pool not available, skipping quota increment for user {}",
user_id
);
return Ok(0); // Возвращаем 0 как fallback
}
};
let quota_key = format!("quota:{}", user_id);
@@ -354,6 +516,9 @@ impl AppState {
.await
.map_err(|_| ErrorInternalServerError("Redis timeout setting initial user quota"))?
.map_err(|_| ErrorInternalServerError("Failed to set initial user quota in Redis"))?;
// Возвращаем соединение в пул
self.return_redis_connection(redis).await;
return Ok(bytes);
}
@@ -366,6 +531,8 @@ impl AppState {
.map_err(|_| ErrorInternalServerError("Redis timeout incrementing user quota"))?
.map_err(|_| ErrorInternalServerError("Failed to increment user quota in Redis"))?;
// Возвращаем соединение в пул
self.return_redis_connection(redis).await;
Ok(new_quota)
}

View File

@@ -242,6 +242,46 @@ pub async fn authenticate_request(
})
}
/// Универсальная функция аутентификации с использованием connection pool
/// Автоматически получает и возвращает соединения из пула
pub async fn authenticate_request_with_pool(
req: &actix_web::HttpRequest,
app_state: &crate::app_state::AppState,
) -> Result<Author, actix_web::Error> {
// Извлекаем токен из запроса
let token = extract_token_from_request(req).ok_or_else(|| {
warn!("No authorization token provided");
actix_web::error::ErrorUnauthorized("Authorization token required")
})?;
// Получаем соединение из пула
match app_state.get_redis_connection().await {
Ok(mut conn) => {
// Валидируем токен с Redis соединением
let result =
secure_token_validation(&token, Some(&mut conn), app_state.request_timeout).await;
// Возвращаем соединение в пул
app_state.return_redis_connection(conn).await;
result.map_err(|e| {
warn!("Token validation failed: {}", e);
actix_web::error::ErrorUnauthorized("Invalid or expired token")
})
}
Err(_) => {
// Fallback на JWT-only валидацию если Redis недоступен
warn!("Redis pool unavailable, falling back to JWT-only validation");
secure_token_validation(&token, None, app_state.request_timeout)
.await
.map_err(|e| {
warn!("Token validation failed: {}", e);
actix_web::error::ErrorUnauthorized("Invalid or expired token")
})
}
}
}
/// Сохраняет имя файла в Redis для пользователя
pub async fn user_added_file(
redis: Option<&mut MultiplexedConnection>,

View File

@@ -3,7 +3,7 @@ use actix_web::{HttpRequest, HttpResponse, Result, web};
use log::{error, info, warn};
use crate::app_state::AppState;
use crate::auth::{authenticate_request, user_added_file};
use crate::auth::{authenticate_request_with_pool, user_added_file};
use crate::handlers::MAX_USER_QUOTA_BYTES;
use crate::lookup::store_file_info;
use crate::s3_utils::{self, generate_key_with_extension, upload_to_s3};
@@ -20,11 +20,8 @@ pub async fn upload_handler(
mut payload: Multipart,
state: web::Data<AppState>,
) -> Result<HttpResponse, actix_web::Error> {
// Получаем Redis соединение для проверки сессий
let mut redis_conn = state.redis.clone();
// Универсальная аутентификация с извлечением токена и валидацией
let author = authenticate_request(&req, redis_conn.as_mut(), state.request_timeout).await?;
// Универсальная аутентификация с connection pool
let author = authenticate_request_with_pool(&req, &state).await?;
let user_id = &author.user_id;
info!(
@@ -174,16 +171,26 @@ pub async fn upload_handler(
));
}
// Сохраняем информацию о файле в Redis
if let Err(e) = store_file_info(redis_conn.as_mut(), &filename, &content_type).await
{
error!("Failed to store file info in Redis: {}", e);
// Не прерываем процесс, файл уже загружен в S3
}
// Получаем соединение из пула для Redis операций
if let Ok(mut redis_conn) = state.get_redis_connection().await {
// Сохраняем информацию о файле в Redis
if let Err(e) =
store_file_info(Some(&mut redis_conn), &filename, &content_type).await
{
error!("Failed to store file info in Redis: {}", e);
// Не прерываем процесс, файл уже загружен в S3
}
if let Err(e) = user_added_file(redis_conn.as_mut(), user_id, &filename).await {
error!("Failed to record user file association: {}", e);
// Не прерываем процесс
if let Err(e) = user_added_file(Some(&mut redis_conn), user_id, &filename).await
{
error!("Failed to record user file association: {}", e);
// Не прерываем процесс
}
// Возвращаем соединение в пул
state.return_redis_connection(redis_conn).await;
} else {
warn!("Redis pool unavailable, skipping file metadata storage");
}
// Сохраняем маппинг пути

View File

@@ -3,7 +3,7 @@ use log::{error, info, warn};
use serde::Serialize;
use crate::app_state::AppState;
use crate::auth::{Author, authenticate_request};
use crate::auth::{Author, authenticate_request_with_pool};
#[derive(Serialize)]
pub struct UserWithQuotaResponse {
@@ -26,11 +26,8 @@ pub async fn get_current_user_handler(
) -> Result<HttpResponse, actix_web::Error> {
info!("Getting user info for valid token");
// Получаем Redis соединение для проверки сессий
let mut redis_conn = state.redis.clone();
// Универсальная аутентификация с извлечением токена и валидацией
let user = match authenticate_request(&req, redis_conn.as_mut(), state.request_timeout).await {
// Универсальная аутентификация с connection pool
let user = match authenticate_request_with_pool(&req, &state).await {
Ok(user) => {
info!(
"Successfully retrieved user info: user_id={}, username={:?}",

View File

@@ -10,5 +10,8 @@ pub mod security;
pub mod thumbnail;
// Реэкспортируем основные типы для удобства
pub use app_state::AppState;
pub use auth::{Author, authenticate_request, extract_token_from_request, secure_token_validation};
pub use app_state::{AppState, RedisConnectionPool};
pub use auth::{
Author, authenticate_request, authenticate_request_with_pool, extract_token_from_request,
secure_token_validation,
};

218
tests/redis_pool_test.rs Normal file
View File

@@ -0,0 +1,218 @@
use quoter::{AppState, RedisConnectionPool, authenticate_request_with_pool};
use std::time::Duration;
use tokio::time::sleep;
/// Тест создания Redis connection pool
#[tokio::test]
async fn test_redis_connection_pool_creation() {
// Используем невалидный URL для тестирования обработки ошибок
let invalid_url = "redis://invalid-host:6379";
let result = RedisConnectionPool::new(invalid_url.to_string(), 5, Duration::from_secs(1)).await;
// Должен вернуть ошибку для невалидного URL
assert!(result.is_err());
}
/// Тест статистики connection pool
#[tokio::test]
async fn test_redis_pool_stats() {
// Создаем мок пула (в реальном тесте нужен валидный Redis)
// Этот тест демонстрирует API
// В реальном окружении с Redis:
// let pool = RedisConnectionPool::new(
// "redis://localhost:6379".to_string(),
// 10,
// Duration::from_secs(5)
// ).await.unwrap();
//
// let (available, max) = pool.get_stats().await;
// assert_eq!(max, 10);
// assert!(available <= max);
// Для CI/CD без Redis просто проверяем, что код компилируется
assert!(true);
}
/// Тест health check connection pool
#[tokio::test]
async fn test_redis_pool_health_check() {
// Тест с невалидным URL должен вернуть false
if let Ok(pool) = RedisConnectionPool::new(
"redis://invalid-host:6379".to_string(),
5,
Duration::from_millis(100), // Короткий таймаут
)
.await
{
let health = pool.health_check().await;
assert!(!health); // Должен быть false для невалидного хоста
}
}
/// Тест получения соединения из пула
#[tokio::test]
async fn test_redis_pool_get_connection() {
// Тест с невалидным URL для проверки обработки ошибок
if let Ok(pool) = RedisConnectionPool::new(
"redis://invalid-host:6379".to_string(),
5,
Duration::from_millis(100),
)
.await
{
let result = pool.get_connection().await;
// Должен вернуть ошибку для невалидного хоста
assert!(result.is_err());
}
}
/// Тест возврата соединения в пул
#[tokio::test]
async fn test_redis_pool_return_connection() {
// Демонстрирует API для возврата соединений
// В реальном тесте с валидным Redis:
//
// let pool = RedisConnectionPool::new(...).await.unwrap();
// let conn = pool.get_connection().await.unwrap();
// pool.return_connection(conn).await;
//
// let (available_after, _) = pool.get_stats().await;
// assert_eq!(available_after, available_before + 1);
assert!(true); // Проверяем компиляцию
}
/// Тест производительности connection pool
#[tokio::test]
async fn test_redis_pool_performance() {
use std::time::Instant;
// Тест создания пула (без реального Redis)
let start = Instant::now();
for _ in 0..100 {
let _result = RedisConnectionPool::new(
"redis://invalid-host:6379".to_string(),
5,
Duration::from_millis(1), // Очень короткий таймаут
)
.await;
// Игнорируем результат, так как Redis недоступен
}
let duration = start.elapsed();
println!("100 pool creation attempts took: {:?}", duration);
// Проверяем, что операции выполняются быстро
assert!(duration < Duration::from_secs(10));
}
/// Тест concurrent доступа к пулу
#[tokio::test]
async fn test_redis_pool_concurrent_access() {
// Демонстрирует concurrent использование пула
let tasks = (0..10).map(|i| {
tokio::spawn(async move {
// В реальном тесте здесь был бы доступ к пулу
sleep(Duration::from_millis(i * 10)).await;
format!("Task {} completed", i)
})
});
let results: Vec<_> = futures::future::join_all(tasks).await;
// Проверяем, что все задачи завершились успешно
for (i, result) in results.iter().enumerate() {
assert!(result.is_ok());
assert_eq!(result.as_ref().unwrap(), &format!("Task {} completed", i));
}
}
/// Тест AppState с Redis connection pool
#[tokio::test]
async fn test_app_state_redis_pool_methods() {
// Тестируем методы AppState для работы с пулом
// В реальном окружении нужен валидный Redis
// Создаем AppState без Redis (для тестирования fallback)
use quoter::security::SecurityConfig;
// Этот тест проверяет, что методы существуют и компилируются
// В реальном тесте с Redis:
// let app_state = AppState::new().await;
// let health = app_state.redis_health_check().await;
// let stats = app_state.redis_pool_stats().await;
assert!(true); // Проверяем компиляцию
}
/// Тест authenticate_request_with_pool
#[tokio::test]
async fn test_authenticate_request_with_pool() {
use actix_web::test;
use quoter::security::SecurityConfig;
// Создаем тестовый запрос
let req = test::TestRequest::default()
.insert_header(("authorization", "Bearer invalid-token"))
.to_http_request();
// В реальном тесте здесь был бы валидный AppState с Redis
// let app_state = AppState::new().await;
// let result = authenticate_request_with_pool(&req, &app_state).await;
// assert!(result.is_err()); // Невалидный токен должен быть отклонен
// Для CI/CD проверяем, что функция существует
assert!(true);
}
/// Тест graceful fallback при недоступности Redis
#[tokio::test]
async fn test_redis_fallback_behavior() {
// Тестируем поведение при недоступности Redis
// Система должна работать в JWT-only режиме
// В реальном тесте:
// 1. Создаем AppState с недоступным Redis
// 2. Проверяем, что аутентификация работает через JWT
// 3. Проверяем, что операции с квотами возвращают fallback значения
assert!(true); // Проверяем компиляцию
}
/// Интеграционный тест Redis connection pool
#[tokio::test]
async fn test_redis_pool_integration() {
// Полный интеграционный тест (требует реального Redis)
// Проверяем переменную окружения для интеграционных тестов
if std::env::var("REDIS_INTEGRATION_TEST").is_ok() {
// Запускаем интеграционные тесты только если установлена переменная
let redis_url =
std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://localhost:6379".to_string());
if let Ok(pool) = RedisConnectionPool::new(redis_url, 10, Duration::from_secs(5)).await {
// Тестируем получение соединения
let conn_result = pool.get_connection().await;
assert!(conn_result.is_ok());
if let Ok(conn) = conn_result {
// Возвращаем соединение в пул
pool.return_connection(conn).await;
}
// Тестируем health check
let health = pool.health_check().await;
assert!(health);
// Тестируем статистику
let (available, max) = pool.get_stats().await;
assert_eq!(max, 10);
assert!(available <= max);
}
} else {
println!("Skipping Redis integration test (set REDIS_INTEGRATION_TEST=1 to enable)");
}
}