mirror of https://github.com/synctv-org/synctv
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
838 lines
26 KiB
Rust
838 lines
26 KiB
Rust
//! Admin concurrency tests
|
|
//!
|
|
//! Tests concurrent admin operations including:
|
|
//! - Concurrent user ban operations
|
|
//! - Concurrent room ban operations
|
|
//! - Settings update concurrency with optimistic lock
|
|
//! - Optimistic lock conflict retry scenarios
|
|
//!
|
|
//! Docker tests: cargo test -p synctv-core --test `admin_concurrency_tests` -- --ignored --nocapture
|
|
|
|
use std::sync::Arc;
|
|
|
|
use chrono::Utc;
|
|
use sqlx::PgPool;
|
|
use synctv_core::{
|
|
models::{
|
|
AddMemberOptions, Room, RoomId, RoomRole, RoomSettings, RoomStatus, User, UserId, UserRole,
|
|
UserStatus,
|
|
},
|
|
repository::{RoomMemberRepository, RoomRepository, RoomSettingsRepository, UserRepository},
|
|
service::{MemberService, NotificationService, PermissionService},
|
|
Error,
|
|
};
|
|
use synctv_core_testing::{create_test_pool, ok, some};
|
|
use tokio::sync::Barrier;
|
|
// Test Infrastructure
|
|
|
|
fn make_user_with_role(username: &str, role: UserRole) -> User {
|
|
let now = Utc::now();
|
|
User {
|
|
id: UserId::new(),
|
|
username: username.to_string(),
|
|
role,
|
|
avatar_file_reference_id: None,
|
|
status: UserStatus::Active,
|
|
signup_method: synctv_core::models::SignupMethod::Email,
|
|
created_at: now,
|
|
updated_at: now,
|
|
version: 0,
|
|
deleted_at: None,
|
|
is_banned: false,
|
|
banned_at: None,
|
|
banned_by: None,
|
|
banned_reason: None,
|
|
}
|
|
}
|
|
|
|
fn make_room(name: &str, description: &str, owner: &UserId) -> Room {
|
|
let now = Utc::now();
|
|
Room {
|
|
id: RoomId::new(),
|
|
name: name.to_string(),
|
|
description: description.to_string(),
|
|
cover_file_reference_id: None,
|
|
category: None,
|
|
labels: Vec::new(),
|
|
created_by: *owner,
|
|
status: RoomStatus::Active,
|
|
is_banned: false,
|
|
is_public: true,
|
|
closed_at: None,
|
|
created_at: now,
|
|
updated_at: now,
|
|
deleted_at: None,
|
|
version: 0,
|
|
last_activity_at: now,
|
|
}
|
|
}
|
|
|
|
async fn setup_test_room(pool: &PgPool, room_name: &str) -> (User, Room) {
|
|
let user_repo = UserRepository::new(pool.clone());
|
|
let room_repo = RoomRepository::new(pool.clone());
|
|
|
|
// Use a unique owner name derived from the room name to avoid duplicate username conflicts
|
|
let owner_name = format!("owner_{}", room_name.replace(' ', "_").to_lowercase());
|
|
let owner = ok(
|
|
user_repo
|
|
.create(&make_user_with_role(&owner_name, UserRole::User))
|
|
.await,
|
|
"room owner should be created",
|
|
);
|
|
|
|
let room = ok(
|
|
room_repo
|
|
.create(&make_room(room_name, "Test room", &owner.id))
|
|
.await,
|
|
"room should be created",
|
|
);
|
|
|
|
// Add owner as member
|
|
let member_repo = RoomMemberRepository::new(pool.clone());
|
|
let owner_member = synctv_core::models::RoomMember::new(room.id, owner.id, RoomRole::Creator);
|
|
ok(
|
|
member_repo.add(&owner_member).await,
|
|
"room owner member should be added",
|
|
);
|
|
|
|
(owner, room)
|
|
}
|
|
|
|
async fn load_user(repo: &UserRepository, user_id: &UserId) -> User {
|
|
some(
|
|
ok(repo.get_by_id(user_id).await, "user should be fetched"),
|
|
"user should exist",
|
|
)
|
|
}
|
|
|
|
async fn is_banned(repo: &UserRepository, user_id: &UserId) -> bool {
|
|
ok(repo.is_banned(user_id).await, "ban state should be fetched")
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_concurrent_user_ban_operations() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let user_repo = Arc::new(UserRepository::new(pool.clone()));
|
|
|
|
let mut users = Vec::with_capacity(10);
|
|
for i in 0..10 {
|
|
let user = ok(
|
|
user_repo
|
|
.create(&make_user_with_role(
|
|
&format!("concurrent_ban_{i}"),
|
|
UserRole::User,
|
|
))
|
|
.await,
|
|
"test user should be created",
|
|
);
|
|
users.push(user);
|
|
}
|
|
|
|
let barrier = Arc::new(Barrier::new(10));
|
|
|
|
// Concurrently ban all users
|
|
let mut handles = Vec::with_capacity(10);
|
|
for user in users {
|
|
let repo_clone = user_repo.clone();
|
|
let barrier_clone = barrier.clone();
|
|
|
|
let handle = tokio::spawn(async move {
|
|
barrier_clone.wait().await;
|
|
repo_clone
|
|
.ban(&user.id, None, Some("concurrent test".to_string()))
|
|
.await
|
|
});
|
|
handles.push(handle);
|
|
}
|
|
|
|
// All bans should succeed
|
|
let mut success_count = 0;
|
|
for handle in handles {
|
|
match ok(handle.await, "ban task should complete") {
|
|
Ok(updated) => {
|
|
assert_eq!(updated.status, UserStatus::Banned);
|
|
assert!(is_banned(&user_repo, &updated.id).await);
|
|
success_count += 1;
|
|
}
|
|
Err(e) => std::panic::panic_any(format!("ban operation should succeed: {e:?}")),
|
|
}
|
|
}
|
|
|
|
assert_eq!(success_count, 10, "All ban operations should succeed");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_concurrent_ban_unban_same_user() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let user_repo = Arc::new(UserRepository::new(pool.clone()));
|
|
|
|
let user = ok(
|
|
user_repo
|
|
.create(&make_user_with_role("ban_unban_user", UserRole::User))
|
|
.await,
|
|
"test user should be created",
|
|
);
|
|
|
|
// 5 tasks try to ban, 5 try to revoke the active ban concurrently.
|
|
let barrier = Arc::new(Barrier::new(10));
|
|
let user_id = user.id;
|
|
|
|
let mut handles = Vec::with_capacity(10);
|
|
|
|
// 5 ban operations
|
|
for _ in 0..5 {
|
|
let repo_clone = user_repo.clone();
|
|
let barrier_clone = barrier.clone();
|
|
let uid = user_id;
|
|
|
|
let handle = tokio::spawn(async move {
|
|
barrier_clone.wait().await;
|
|
repo_clone
|
|
.ban(&uid, None, Some("concurrent test".to_string()))
|
|
.await
|
|
});
|
|
handles.push(handle);
|
|
}
|
|
|
|
// 5 active operations
|
|
for _ in 0..5 {
|
|
let repo_clone = user_repo.clone();
|
|
let barrier_clone = barrier.clone();
|
|
let uid = user_id;
|
|
|
|
let handle = tokio::spawn(async move {
|
|
barrier_clone.wait().await;
|
|
repo_clone.unban(&uid).await
|
|
});
|
|
handles.push(handle);
|
|
}
|
|
|
|
let mut success_count = 0;
|
|
for handle in handles {
|
|
match ok(handle.await, "ban/unban task should complete") {
|
|
Ok(_) => success_count += 1,
|
|
Err(Error::AlreadyExists(_) | Error::NotFound(_)) => {}
|
|
Err(e) => {
|
|
std::panic::panic_any(format!("ban operation failed unexpectedly: {e:?}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
assert!(
|
|
success_count > 0,
|
|
"At least one ban/unban operation should succeed"
|
|
);
|
|
|
|
let final_user = load_user(&user_repo, &user_id).await;
|
|
assert_eq!(
|
|
final_user.status == UserStatus::Banned,
|
|
is_banned(&user_repo, &user_id).await,
|
|
"derived user status must stay consistent with active user_bans"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_concurrent_room_ban_operations() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let room_repo = Arc::new(RoomRepository::new(pool.clone()));
|
|
|
|
let mut room_ids = Vec::with_capacity(10);
|
|
for i in 0..10 {
|
|
let (_owner, room) = setup_test_room(&pool, &format!("Concurrent Ban Room {i}")).await;
|
|
room_ids.push(room.id);
|
|
}
|
|
|
|
let barrier = Arc::new(Barrier::new(10));
|
|
|
|
// Concurrently ban all rooms
|
|
let mut handles = Vec::with_capacity(10);
|
|
for room_id in room_ids {
|
|
let repo_clone = room_repo.clone();
|
|
let barrier_clone = barrier.clone();
|
|
|
|
let handle = tokio::spawn(async move {
|
|
barrier_clone.wait().await;
|
|
repo_clone.update_ban_status(&room_id, true).await
|
|
});
|
|
handles.push(handle);
|
|
}
|
|
|
|
// All bans should succeed
|
|
let mut success_count = 0;
|
|
for handle in handles {
|
|
match ok(handle.await, "room ban task should complete") {
|
|
Ok(updated) => {
|
|
assert!(updated.is_banned);
|
|
success_count += 1;
|
|
}
|
|
Err(e) => std::panic::panic_any(format!("room ban should succeed: {e:?}")),
|
|
}
|
|
}
|
|
|
|
assert_eq!(success_count, 10, "All room ban operations should succeed");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_concurrent_room_status_changes() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let room_repo = Arc::new(RoomRepository::new(pool.clone()));
|
|
|
|
let (_owner, room) = setup_test_room(&pool, "Status Change Room").await;
|
|
let room_id = room.id;
|
|
|
|
// 10 tasks try different status changes
|
|
let barrier = Arc::new(Barrier::new(10));
|
|
let statuses = vec![
|
|
RoomStatus::Active,
|
|
RoomStatus::Closed,
|
|
RoomStatus::Closed,
|
|
RoomStatus::Active,
|
|
RoomStatus::Closed,
|
|
RoomStatus::Closed,
|
|
RoomStatus::Active,
|
|
RoomStatus::Closed,
|
|
RoomStatus::Closed,
|
|
RoomStatus::Active,
|
|
];
|
|
|
|
let mut handles = Vec::with_capacity(10);
|
|
for status in statuses {
|
|
let repo_clone = room_repo.clone();
|
|
let barrier_clone = barrier.clone();
|
|
let rid = room_id;
|
|
|
|
let handle = tokio::spawn(async move {
|
|
barrier_clone.wait().await;
|
|
repo_clone.update_status(&rid, status).await
|
|
});
|
|
handles.push(handle);
|
|
}
|
|
|
|
// All operations should succeed
|
|
let mut success_count = 0;
|
|
for handle in handles {
|
|
match ok(handle.await, "room status task should complete") {
|
|
Ok(_) => success_count += 1,
|
|
Err(e) => std::panic::panic_any(format!("status change should succeed: {e:?}")),
|
|
}
|
|
}
|
|
|
|
assert_eq!(success_count, 10, "All status operations should succeed");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_concurrent_global_settings_update_optimistic_lock() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let user_repo = UserRepository::new(pool.clone());
|
|
|
|
let user = ok(
|
|
user_repo
|
|
.create(&make_user_with_role("settings_user", UserRole::User))
|
|
.await,
|
|
"settings test user should be created",
|
|
);
|
|
|
|
let barrier = Arc::new(Barrier::new(5));
|
|
let user_id = user.id;
|
|
|
|
// 5 concurrent updates to the same user
|
|
let mut handles = Vec::with_capacity(5);
|
|
for i in 0..5 {
|
|
let repo_clone = Arc::new(UserRepository::new(pool.clone()));
|
|
let barrier_clone = barrier.clone();
|
|
let uid = user_id;
|
|
|
|
let handle = tokio::spawn(async move {
|
|
barrier_clone.wait().await;
|
|
|
|
// Retry loop for optimistic lock conflicts
|
|
let mut retries = 0;
|
|
let max_retries = 3;
|
|
loop {
|
|
let current = load_user(&repo_clone, &uid).await;
|
|
let mut updated = current.clone();
|
|
updated.username = format!("updated_user_{i}");
|
|
|
|
match repo_clone.update(&updated, current.version).await {
|
|
Ok(result) => break Ok(result),
|
|
Err(Error::OptimisticLockConflict) => {
|
|
retries += 1;
|
|
if retries >= max_retries {
|
|
break Err(Error::OptimisticLockConflict);
|
|
}
|
|
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
|
|
}
|
|
Err(e) => break Err(e),
|
|
}
|
|
}
|
|
});
|
|
handles.push(handle);
|
|
}
|
|
|
|
let mut success_count = 0;
|
|
let mut conflict_count = 0;
|
|
|
|
for handle in handles {
|
|
match ok(handle.await, "user update task should complete") {
|
|
Ok(_) => success_count += 1,
|
|
Err(Error::OptimisticLockConflict) => conflict_count += 1,
|
|
Err(e) => std::panic::panic_any(format!("unexpected error: {e:?}")),
|
|
}
|
|
}
|
|
|
|
// With retry, most should succeed
|
|
assert!(
|
|
success_count >= 3,
|
|
"At least 3 updates should succeed with retry"
|
|
);
|
|
|
|
tracing::info!(
|
|
"Concurrent user updates: {} succeeded, {} failed after retries",
|
|
success_count,
|
|
conflict_count
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_optimistic_lock_conflict_detected() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let user_repo = UserRepository::new(pool.clone());
|
|
|
|
let user = ok(
|
|
user_repo
|
|
.create(&make_user_with_role("lock_conflict", UserRole::User))
|
|
.await,
|
|
"lock conflict user should be created",
|
|
);
|
|
|
|
// Read same user twice
|
|
let read1 = load_user(&user_repo, &user.id).await;
|
|
let read2 = load_user(&user_repo, &user.id).await;
|
|
|
|
// First update succeeds
|
|
let mut update1 = read1.clone();
|
|
update1.role = UserRole::Admin;
|
|
let result1 = user_repo.update(&update1, read1.version).await;
|
|
assert!(result1.is_ok());
|
|
|
|
// Second update with stale version fails
|
|
let mut update2 = read2.clone();
|
|
update2.role = UserRole::Admin;
|
|
let result2 = user_repo.update(&update2, read2.version).await;
|
|
|
|
assert!(
|
|
matches!(result2, Err(Error::OptimisticLockConflict)),
|
|
"Should detect optimistic lock conflict"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_optimistic_lock_retry_succeeds() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let user_repo = Arc::new(UserRepository::new(pool.clone()));
|
|
|
|
let user = ok(
|
|
user_repo
|
|
.create(&make_user_with_role("retry_success", UserRole::User))
|
|
.await,
|
|
"retry test user should be created",
|
|
);
|
|
|
|
let barrier = Arc::new(Barrier::new(2));
|
|
let user_id = user.id;
|
|
|
|
// Task 1: Update without retry
|
|
let repo1 = user_repo.clone();
|
|
let barrier1 = barrier.clone();
|
|
let uid1 = user_id;
|
|
|
|
let handle1 = tokio::spawn(async move {
|
|
barrier1.wait().await;
|
|
let current = load_user(&repo1, &uid1).await;
|
|
let mut updated = current.clone();
|
|
updated.role = UserRole::Admin;
|
|
repo1.update(&updated, current.version).await
|
|
});
|
|
|
|
// Task 2: Update with retry
|
|
let repo2 = user_repo.clone();
|
|
let barrier2 = barrier.clone();
|
|
let uid2 = user_id;
|
|
|
|
let handle2 = tokio::spawn(async move {
|
|
barrier2.wait().await;
|
|
|
|
// Retry loop
|
|
let mut retries = 0;
|
|
let max_retries = 5;
|
|
loop {
|
|
let current = load_user(&repo2, &uid2).await;
|
|
let mut updated = current.clone();
|
|
updated.username = "retried_user".to_string();
|
|
|
|
match repo2.update(&updated, current.version).await {
|
|
Ok(result) => break Ok(result),
|
|
Err(Error::OptimisticLockConflict) => {
|
|
retries += 1;
|
|
if retries >= max_retries {
|
|
break Err(Error::OptimisticLockConflict);
|
|
}
|
|
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
|
|
}
|
|
Err(e) => break Err(e),
|
|
}
|
|
}
|
|
});
|
|
|
|
let result1 = ok(handle1.await, "first retry task should complete");
|
|
let result2 = ok(handle2.await, "second retry task should complete");
|
|
|
|
// Task 2 (with retry) should always succeed regardless of race order
|
|
assert!(
|
|
result2.is_ok(),
|
|
"Update with retry should succeed: {result2:?}"
|
|
);
|
|
|
|
// Task 1 (no retry) may or may not succeed depending on timing —
|
|
// if Task 2's first attempt commits before Task 1, Task 1 gets an
|
|
// OptimisticLockConflict with no retry to recover.
|
|
assert!(
|
|
result1.is_ok() || matches!(result1, Err(Error::OptimisticLockConflict)),
|
|
"Task without retry should either succeed or get OptimisticLockConflict: {result1:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_high_concurrency_status_updates() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let user_repo = Arc::new(UserRepository::new(pool.clone()));
|
|
|
|
let user = ok(
|
|
user_repo
|
|
.create(&make_user_with_role("stress_user", UserRole::User))
|
|
.await,
|
|
"stress test user should be created",
|
|
);
|
|
|
|
let barrier = Arc::new(Barrier::new(50));
|
|
let user_id = user.id;
|
|
|
|
// 50 concurrent ban/unban attempts should not corrupt account facts.
|
|
let mut handles = Vec::with_capacity(50);
|
|
for i in 0..50 {
|
|
let repo_clone = user_repo.clone();
|
|
let barrier_clone = barrier.clone();
|
|
let uid = user_id;
|
|
|
|
let handle = tokio::spawn(async move {
|
|
barrier_clone.wait().await;
|
|
if i % 2 == 0 {
|
|
repo_clone
|
|
.ban(&uid, None, Some("stress test".to_string()))
|
|
.await
|
|
} else {
|
|
repo_clone.unban(&uid).await
|
|
}
|
|
});
|
|
handles.push(handle);
|
|
}
|
|
|
|
let mut success_count = 0;
|
|
for handle in handles {
|
|
match ok(handle.await, "status stress task should complete") {
|
|
Ok(_) => success_count += 1,
|
|
Err(Error::NotFound(_)) => {}
|
|
Err(e) => tracing::warn!("Operation failed: {:?}", e),
|
|
}
|
|
}
|
|
|
|
assert!(
|
|
success_count > 0,
|
|
"At least one ban-state operation should succeed"
|
|
);
|
|
let final_user = load_user(&user_repo, &user_id).await;
|
|
assert_eq!(
|
|
final_user.status == UserStatus::Banned,
|
|
is_banned(&user_repo, &user_id).await,
|
|
"derived user status must stay consistent with active user_bans"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_concurrent_room_ban_with_members_joining() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let user_repo = UserRepository::new(pool.clone());
|
|
let room_repo = Arc::new(RoomRepository::new(pool.clone()));
|
|
|
|
let (_owner, room) = setup_test_room(&pool, "Ban With Join Room").await;
|
|
|
|
let mut users = Vec::with_capacity(10);
|
|
for i in 0..10 {
|
|
let user = ok(
|
|
user_repo
|
|
.create(&make_user_with_role(
|
|
&format!("join_ban_{i}"),
|
|
UserRole::User,
|
|
))
|
|
.await,
|
|
"join/ban test user should be created",
|
|
);
|
|
users.push(user);
|
|
}
|
|
|
|
let barrier = Arc::new(Barrier::new(11)); // 10 joins + 1 ban
|
|
|
|
// Setup member service
|
|
let member_repo = RoomMemberRepository::new(pool.clone());
|
|
let room_repo_for_service = RoomRepository::new(pool.clone());
|
|
let room_settings_repo = RoomSettingsRepository::new(pool.clone());
|
|
let permission_service = ok(
|
|
PermissionService::new(
|
|
member_repo.clone(),
|
|
room_repo_for_service.clone(),
|
|
None,
|
|
1000,
|
|
300,
|
|
),
|
|
"permission service should build",
|
|
);
|
|
let member_service = MemberService::new_with_runtime(
|
|
member_repo.clone(),
|
|
room_repo_for_service.clone(),
|
|
Some(room_settings_repo),
|
|
permission_service.clone(),
|
|
None,
|
|
None,
|
|
NotificationService::default(),
|
|
);
|
|
|
|
let room_id = room.id;
|
|
|
|
// 10 join tasks
|
|
let mut join_handles = Vec::with_capacity(10);
|
|
for user in users {
|
|
let ms = member_service.clone();
|
|
let bc = barrier.clone();
|
|
let rid = room_id;
|
|
|
|
let handle = tokio::spawn(async move {
|
|
bc.wait().await;
|
|
let options = AddMemberOptions::default().with_max_members(0);
|
|
ms.add_member_with_options(rid, user.id, RoomRole::Member, options)
|
|
.await
|
|
});
|
|
join_handles.push(handle);
|
|
}
|
|
|
|
// 1 ban task
|
|
let rr = room_repo.clone();
|
|
let bc = barrier.clone();
|
|
let rid = room_id;
|
|
|
|
let ban_handle = tokio::spawn(async move {
|
|
bc.wait().await;
|
|
rr.update_ban_status(&rid, true).await
|
|
});
|
|
|
|
// Collect results for join handles
|
|
let mut join_success = 0;
|
|
let mut join_failed = 0;
|
|
|
|
for handle in join_handles {
|
|
match ok(handle.await, "join task should complete") {
|
|
Ok(_) => join_success += 1,
|
|
Err(_) => join_failed += 1,
|
|
}
|
|
}
|
|
|
|
// Collect result for ban handle
|
|
let ban_success = ok(ban_handle.await, "room ban task should complete").is_ok();
|
|
|
|
// Ban should succeed
|
|
assert!(ban_success, "Room ban should succeed");
|
|
|
|
// Some joins may succeed before ban, some after (will fail on banned room)
|
|
tracing::info!(
|
|
"Join results: {} succeeded, {} failed, ban: {}",
|
|
join_success,
|
|
join_failed,
|
|
ban_success
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_concurrent_room_settings_update() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
|
|
let (_owner, room) = setup_test_room(&pool, "Settings Update Room").await;
|
|
|
|
// Initialize room settings
|
|
let room_settings_repo = Arc::new(RoomSettingsRepository::new(pool.clone()));
|
|
ok(
|
|
room_settings_repo
|
|
.set_settings(&room.id, &RoomSettings::default())
|
|
.await,
|
|
"room settings should be created",
|
|
);
|
|
|
|
let barrier = Arc::new(Barrier::new(10));
|
|
let room_id = room.id;
|
|
|
|
// 10 concurrent settings updates
|
|
let mut handles = Vec::with_capacity(10);
|
|
for i in 0..10 {
|
|
let repo = room_settings_repo.clone();
|
|
let bc = barrier.clone();
|
|
let rid = room_id;
|
|
|
|
let handle = tokio::spawn(async move {
|
|
bc.wait().await;
|
|
|
|
// Retry loop for optimistic lock with exponential backoff + jitter
|
|
let mut retries = 0u32;
|
|
let max_retries = 10;
|
|
loop {
|
|
let (settings, version) = ok(
|
|
repo.get_with_version(&rid).await,
|
|
"room settings should be fetched",
|
|
);
|
|
let mut updated = settings.clone();
|
|
updated.max_members = synctv_core::models::room_settings::MaxMembers(
|
|
50 + u64::try_from(i).unwrap_or_default(),
|
|
);
|
|
|
|
match repo
|
|
.set_settings_with_version(&rid, &updated, version)
|
|
.await
|
|
{
|
|
Ok(_) => break Ok(()),
|
|
Err(Error::OptimisticLockConflict) => {
|
|
retries += 1;
|
|
if retries >= max_retries {
|
|
break Err(Error::OptimisticLockConflict);
|
|
}
|
|
let base_ms = 10u64 * (1u64 << retries.min(5));
|
|
let jitter = (u64::try_from(i).unwrap_or_default() * 7
|
|
+ u64::from(retries) * 3)
|
|
% base_ms;
|
|
tokio::time::sleep(tokio::time::Duration::from_millis(base_ms + jitter))
|
|
.await;
|
|
}
|
|
Err(e) => break Err(e),
|
|
}
|
|
}
|
|
});
|
|
handles.push(handle);
|
|
}
|
|
|
|
let mut success_count = 0;
|
|
let mut conflict_count = 0;
|
|
|
|
for handle in handles {
|
|
match ok(handle.await, "room settings update task should complete") {
|
|
Ok(()) => success_count += 1,
|
|
Err(Error::OptimisticLockConflict) => conflict_count += 1,
|
|
Err(e) => std::panic::panic_any(format!("unexpected error: {e:?}")),
|
|
}
|
|
}
|
|
|
|
// With retry + backoff, most should succeed (at least half)
|
|
assert!(
|
|
success_count >= 5,
|
|
"At least 5 settings updates should succeed with retry, got {success_count}"
|
|
);
|
|
|
|
tracing::info!(
|
|
"Concurrent settings updates: {} succeeded, {} failed after retries",
|
|
success_count,
|
|
conflict_count
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_concurrent_role_updates_same_user() {
|
|
let (_container, pool) = create_test_pool().await;
|
|
let user_repo = Arc::new(UserRepository::new(pool.clone()));
|
|
|
|
let user = ok(
|
|
user_repo
|
|
.create(&make_user_with_role("role_update_target", UserRole::User))
|
|
.await,
|
|
"role update test user should be created",
|
|
);
|
|
|
|
let barrier = Arc::new(Barrier::new(5));
|
|
let user_id = user.id;
|
|
|
|
// 5 concurrent role updates
|
|
let mut handles = Vec::with_capacity(5);
|
|
for _ in 0..5 {
|
|
let repo = user_repo.clone();
|
|
let bc = barrier.clone();
|
|
let uid = user_id;
|
|
|
|
let handle = tokio::spawn(async move {
|
|
bc.wait().await;
|
|
|
|
// Retry loop
|
|
let mut retries = 0;
|
|
let max_retries = 3;
|
|
loop {
|
|
let current = load_user(&repo, &uid).await;
|
|
let mut updated = current.clone();
|
|
|
|
// Toggle between User and Admin
|
|
updated.role = if current.role == UserRole::User {
|
|
UserRole::Admin
|
|
} else {
|
|
UserRole::User
|
|
};
|
|
|
|
match repo.update(&updated, current.version).await {
|
|
Ok(result) => break Ok(result),
|
|
Err(Error::OptimisticLockConflict) => {
|
|
retries += 1;
|
|
if retries >= max_retries {
|
|
break Err(Error::OptimisticLockConflict);
|
|
}
|
|
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
|
|
}
|
|
Err(e) => break Err(e),
|
|
}
|
|
}
|
|
});
|
|
handles.push(handle);
|
|
}
|
|
|
|
let mut success_count = 0;
|
|
|
|
for handle in handles {
|
|
match ok(handle.await, "role update task should complete") {
|
|
Ok(_) => success_count += 1,
|
|
Err(Error::OptimisticLockConflict) => {}
|
|
Err(e) => std::panic::panic_any(format!("unexpected error: {e:?}")),
|
|
}
|
|
}
|
|
|
|
// At least some should succeed
|
|
assert!(success_count >= 3, "At least 3 role updates should succeed");
|
|
|
|
// Verify final state is valid
|
|
let final_user = load_user(&user_repo, &user_id).await;
|
|
assert!(
|
|
final_user.role == UserRole::User || final_user.role == UserRole::Admin,
|
|
"Final role should be valid"
|
|
);
|
|
}
|