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.
1061 lines
33 KiB
Rust
1061 lines
33 KiB
Rust
//! PlaybackState L2 cache integration tests
|
|
//!
|
|
//! Tests the L2 (Redis) caching layer for PlaybackService, including:
|
|
//! - L1 hit behavior
|
|
//! - L1 miss with L2 fallback
|
|
//! - L2 miss with PostgreSQL fallback
|
|
//! - Cross-replica consistency via L2 cache
|
|
//!
|
|
|
|
use std::sync::Arc;
|
|
|
|
use chrono::Utc;
|
|
use redis::AsyncCommands;
|
|
use sqlx::PgPool;
|
|
use synctv_core::{
|
|
cache::{CacheL2Backend, KeyBuilder, PlaybackStateCache, RedisCacheL2, UsernameCache},
|
|
models::{
|
|
Media, MediaId, PlaylistId, RoomId, SourceProvider, User, UserId, UserRole, UserStatus,
|
|
},
|
|
repository::{MediaRepository, RoomPlaybackStateRepository, UserRepository},
|
|
service::{
|
|
BruteForceProtection, InMemoryTokenBlacklistStore, JwtService, RoomService,
|
|
RoomServiceOptions, UserService,
|
|
},
|
|
};
|
|
use synctv_core_testing::{
|
|
redis_connection_manager, start_redis as start_test_redis,
|
|
start_redis_client_manager_with_label, timestamped_l2_envelope,
|
|
};
|
|
use synctv_core_testing::{TestOptionExt, TestResultExt};
|
|
|
|
// Test Helpers
|
|
|
|
async fn start_redis() -> (
|
|
synctv_core_testing::RedisContainer,
|
|
redis::aio::ConnectionManager,
|
|
) {
|
|
start_test_redis().await
|
|
}
|
|
|
|
fn make_user_service(pool: &PgPool) -> UserService {
|
|
let secret = "Test_Secret_Key_For_JWT_Tokens_32Bytes!!";
|
|
let jwt_service = JwtService::new(secret).checked("JWT service should be created");
|
|
let username_cache = UsernameCache::local_only("test:username:".to_string(), 100, 60);
|
|
let token_blacklist = Arc::new(InMemoryTokenBlacklistStore::new(1000, 3600, 86400));
|
|
let key_builder = KeyBuilder::new("test");
|
|
let brute_force = BruteForceProtection::in_memory("test".to_string());
|
|
|
|
UserService::new_for_tests(
|
|
pool,
|
|
jwt_service,
|
|
username_cache,
|
|
token_blacklist,
|
|
key_builder,
|
|
brute_force,
|
|
)
|
|
}
|
|
|
|
fn make_room_service(pool: PgPool) -> RoomService {
|
|
let user_service = make_user_service(&pool);
|
|
|
|
RoomService::new_for_tests(pool, user_service).checked("room service should build")
|
|
}
|
|
|
|
fn make_user(username: &str) -> User {
|
|
let now = Utc::now();
|
|
User {
|
|
id: UserId::new(),
|
|
username: username.to_string(),
|
|
role: UserRole::User,
|
|
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,
|
|
}
|
|
}
|
|
|
|
async fn attach_test_media(
|
|
pool: &PgPool,
|
|
room_id: RoomId,
|
|
owner_id: UserId,
|
|
) -> synctv_core::models::RoomPlaybackState {
|
|
let media = Media {
|
|
id: MediaId::new(),
|
|
playlist_id: None,
|
|
room_id,
|
|
creator_id: Some(owner_id),
|
|
name: "Playback Cache Test Video".to_string(),
|
|
description: String::new(),
|
|
position: 0.0,
|
|
source_provider: SourceProvider::DirectUrl,
|
|
source_config: synctv_core_testing::direct_url_media_source_config(
|
|
"https://example.com/video.mp4",
|
|
),
|
|
provider_instance_name: None,
|
|
cover_file_reference_id: None,
|
|
thumbnail_file_reference_id: None,
|
|
added_at: Utc::now(),
|
|
updated_at: Utc::now(),
|
|
version: 0,
|
|
};
|
|
let media = MediaRepository::new(pool.clone())
|
|
.create(&media)
|
|
.await
|
|
.checked("test media should be created");
|
|
let playback_repo = RoomPlaybackStateRepository::new(pool.clone());
|
|
let mut state = playback_repo
|
|
.create_or_get(&room_id)
|
|
.await
|
|
.checked("playback state should be created");
|
|
state.playing_media_id = Some(media.id);
|
|
state.playing_playlist_id = None;
|
|
state.target = None;
|
|
state.position = 0.0;
|
|
playback_repo
|
|
.update(&state)
|
|
.await
|
|
.checked("playback state should attach test media")
|
|
}
|
|
|
|
// Test 1: L1 Cache Hit Behavior
|
|
|
|
/// Test that when L1 cache has the data, it's returned without DB/L2 lookup.
|
|
///
|
|
/// Scenario:
|
|
/// - Create a room (which creates playback state)
|
|
/// - Get state (populates L1)
|
|
/// - Get state again (should hit L1, no additional DB queries)
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_playback_state_l1_cache_hit() {
|
|
let (_container, pool) = synctv_core_testing::create_test_pool().await;
|
|
let user_repo = UserRepository::new(pool.clone());
|
|
let room_service = make_room_service(pool.clone());
|
|
|
|
let owner = user_repo
|
|
.create(&make_user("l1_hit_owner"))
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let room = room_service
|
|
.create_room(
|
|
"L1 Hit Room".to_string(),
|
|
String::new(),
|
|
owner.id,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let playback_service = room_service.playback_service();
|
|
|
|
// First call - populates L1 cache
|
|
let state1 = playback_service
|
|
.get_state(&room.id)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
assert!(state1.version >= 0);
|
|
|
|
// Second call - should hit L1 cache (no DB query)
|
|
let state2 = playback_service
|
|
.get_state(&room.id)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
assert_eq!(state2.room_id, state1.room_id);
|
|
assert_eq!(state2.version, state1.version);
|
|
}
|
|
|
|
// Test 2: L1 Miss Should Check L2
|
|
|
|
/// Test that when L1 cache misses, L2 (Redis) is checked.
|
|
///
|
|
/// Scenario:
|
|
/// - Create a room with playback state
|
|
/// - Populate L2 cache with playback state
|
|
/// - Clear L1 cache
|
|
/// - Get state should hit L2 (not DB)
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_playback_state_l1_miss_hits_l2() {
|
|
let (_pg_container, pool) = synctv_core_testing::create_test_pool().await;
|
|
let (_redis_container, redis_conn) = start_redis().await;
|
|
|
|
// Verify Redis is working
|
|
let mut conn = redis_conn.clone();
|
|
let _: () = conn
|
|
.set_ex("test:ping", "pong", 60)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let user_repo = UserRepository::new(pool.clone());
|
|
let room_service = make_room_service(pool.clone());
|
|
|
|
let owner = user_repo
|
|
.create(&make_user("l2_hit_owner"))
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let room = room_service
|
|
.create_room(
|
|
"L2 Hit Room".to_string(),
|
|
String::new(),
|
|
owner.id,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let playback_service = room_service.playback_service();
|
|
|
|
// First call populates L1 and L2.
|
|
let state1 = playback_service
|
|
.get_state(&room.id)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
// Manually populate L2 to verify it is checked.
|
|
let l2 = RedisCacheL2::from_runtime(synctv_core::direct_runtime(redis_conn));
|
|
let l2_key = format!("synctv:playback:{}", room.id);
|
|
|
|
// Manually set in L2 to simulate it being there
|
|
let state_json = serde_json::to_string(&state1).checked("test operation should succeed");
|
|
l2.set(&l2_key, &state_json, 300)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
// Verify L2 has the data
|
|
let from_l2 = l2
|
|
.get(&l2_key)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
assert!(from_l2.is_some(), "L2 should have the playback state");
|
|
}
|
|
|
|
// Test 3: L2 Miss Should Read From PostgreSQL
|
|
|
|
/// Test that when both L1 and L2 miss, PostgreSQL is queried.
|
|
///
|
|
/// Scenario:
|
|
/// - Create a room with playback state
|
|
/// - Clear both L1 and L2
|
|
/// - Get state should query DB and populate both caches
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_playback_state_l2_miss_reads_from_db() {
|
|
let (_pg_container, pool) = synctv_core_testing::create_test_pool().await;
|
|
let (_redis_container, _redis_conn) = start_redis().await;
|
|
|
|
let user_repo = UserRepository::new(pool.clone());
|
|
let room_service = make_room_service(pool.clone());
|
|
|
|
let owner = user_repo
|
|
.create(&make_user("db_fallback_owner"))
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let room = room_service
|
|
.create_room(
|
|
"DB Fallback Room".to_string(),
|
|
String::new(),
|
|
owner.id,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let playback_service = room_service.playback_service();
|
|
|
|
// Get state from DB
|
|
let state = playback_service
|
|
.get_state(&room.id)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
assert!(state.version >= 0, "Should have valid state from DB");
|
|
|
|
// Verify in PostgreSQL directly
|
|
let db_state: synctv_core::models::RoomPlaybackState = sqlx::query_as!(
|
|
synctv_core::models::RoomPlaybackState,
|
|
r#"SELECT state.room_id AS "room_id!: RoomId",
|
|
state.playing_media_id AS "playing_media_id?: MediaId",
|
|
state.playing_playlist_id AS "playing_playlist_id?: PlaylistId",
|
|
state.target AS "target?: synctv_core::models::ProviderTarget",
|
|
state.current_progress_id AS "current_progress_id?",
|
|
state.history_cursor_id AS "history_cursor_id?",
|
|
COALESCE(progress."position", 0.0) AS "position!",
|
|
state.speed AS "speed!",
|
|
state.is_playing AS "is_playing!",
|
|
state.playback_generation AS "playback_generation!",
|
|
state.updated_at AS "updated_at!",
|
|
state.version AS "version!"
|
|
FROM room_playback_state state
|
|
LEFT JOIN room_playback_progress progress ON progress.id = state.current_progress_id
|
|
WHERE state.room_id = $1"#,
|
|
room.id.as_i64(),
|
|
)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
assert_eq!(db_state.room_id, room.id);
|
|
}
|
|
|
|
/// Test that `get_state()` persists the default row via `create_or_get()` instead of
|
|
/// returning an unpersisted synthetic value.
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_playback_state_get_state_persists_missing_row() {
|
|
let (_pg_container, pool) = synctv_core_testing::create_test_pool().await;
|
|
let user_repo = UserRepository::new(pool.clone());
|
|
let room_service = make_room_service(pool.clone());
|
|
|
|
let owner = user_repo
|
|
.create(&make_user("persist_missing_owner"))
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let room = room_service
|
|
.create_room(
|
|
"Persist Missing Playback Row".to_string(),
|
|
String::new(),
|
|
owner.id,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
sqlx::query!(
|
|
"DELETE FROM room_playback_state WHERE room_id = $1",
|
|
room.id.as_i64()
|
|
)
|
|
.execute(&pool)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let state = room_service
|
|
.playback_service()
|
|
.get_state(&room.id)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let persisted = sqlx::query!(
|
|
r#"SELECT room_id AS "room_id!: RoomId" FROM room_playback_state WHERE room_id = $1"#,
|
|
room.id.as_i64()
|
|
)
|
|
.fetch_optional(&pool)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
assert_eq!(state.room_id, room.id);
|
|
assert!(
|
|
persisted.is_some(),
|
|
"get_state() must create the missing playback row instead of caching an ephemeral default"
|
|
);
|
|
|
|
room_service.playback_service().shutdown().await;
|
|
pool.close().await;
|
|
}
|
|
|
|
// Test 4: Cross-Replica Consistency via L2
|
|
|
|
/// Test that state updates are visible across "replicas" via L2 cache.
|
|
///
|
|
/// Scenario:
|
|
/// - Create room on "replica A"
|
|
/// - Update playback state (populates L2)
|
|
/// - "Replica B" clears L1 and reads - should get updated state from L2
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_playback_state_cross_replica_consistency() {
|
|
let (_pg_container, pool) = synctv_core_testing::create_test_pool().await;
|
|
let (_redis_container, redis_conn) = start_redis().await;
|
|
|
|
let user_repo = UserRepository::new(pool.clone());
|
|
let room_service = make_room_service(pool.clone());
|
|
|
|
let owner = user_repo
|
|
.create(&make_user("cross_replica_owner"))
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let room = room_service
|
|
.create_room(
|
|
"Cross Replica Room".to_string(),
|
|
String::new(),
|
|
owner.id,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let playback_service = room_service.playback_service();
|
|
attach_test_media(&pool, room.id, owner.id).await;
|
|
|
|
// Update playback state
|
|
let updated_state = playback_service
|
|
.seek(room.id, owner.id, 123.45)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
// Verify state is updated in DB
|
|
let db_state = playback_service
|
|
.get_state(&room.id)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
assert!(
|
|
(db_state.position - 123.45).abs() < f64::EPSILON,
|
|
"State should be updated in DB"
|
|
);
|
|
|
|
// Manually test L2 propagation
|
|
let l2 = RedisCacheL2::from_runtime(synctv_core::direct_runtime(redis_conn));
|
|
let l2_key = format!("synctv:playback:{}", room.id);
|
|
|
|
// Set in L2 (simulating what the implementation should do)
|
|
let state_json =
|
|
serde_json::to_string(&updated_state.state).checked("test operation should succeed");
|
|
l2.set(&l2_key, &state_json, 300)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
// Read from L2 (simulating another replica)
|
|
let from_l2 = l2
|
|
.get(&l2_key)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
assert!(from_l2.is_some(), "L2 should have the updated state");
|
|
|
|
let deserialized: synctv_core::models::RoomPlaybackState =
|
|
serde_json::from_str(&from_l2.checked("test operation should succeed"))
|
|
.checked("test operation should succeed");
|
|
assert!(
|
|
(deserialized.position - 123.45).abs() < f64::EPSILON,
|
|
"L2 should have updated position"
|
|
);
|
|
}
|
|
|
|
// Test 5: Cache Invalidation on State Update
|
|
|
|
/// Test that cache is properly invalidated when state is updated.
|
|
///
|
|
/// Scenario:
|
|
/// - Get state (populates cache)
|
|
/// - Update state (should invalidate cache)
|
|
/// - Get state again (should fetch fresh from DB)
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_playback_state_cache_invalidation_on_update() {
|
|
let (_container, pool) = synctv_core_testing::create_test_pool().await;
|
|
let user_repo = UserRepository::new(pool.clone());
|
|
let room_service = make_room_service(pool.clone());
|
|
|
|
let owner = user_repo
|
|
.create(&make_user("cache_inv_owner"))
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let room = room_service
|
|
.create_room(
|
|
"Cache Inv Room".to_string(),
|
|
String::new(),
|
|
owner.id,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let playback_service = room_service.playback_service();
|
|
attach_test_media(&pool, room.id, owner.id).await;
|
|
|
|
// Get initial state (populates cache)
|
|
let initial_state = playback_service
|
|
.get_state(&room.id)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
let initial_version = initial_state.version;
|
|
|
|
// Update state (should invalidate cache)
|
|
let _ = playback_service
|
|
.seek(room.id, owner.id, 50.0)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
// Get state again (should fetch fresh from DB, not stale cache)
|
|
let updated_state = playback_service
|
|
.get_state(&room.id)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
// Version should be incremented
|
|
assert!(
|
|
updated_state.version > initial_version,
|
|
"Version should be incremented after update"
|
|
);
|
|
assert!(
|
|
(updated_state.position - 50.0).abs() < f64::EPSILON,
|
|
"Position should be updated"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_playback_get_state_bypasses_stale_l1_without_invalidation() {
|
|
let (_container, pool) = synctv_core_testing::create_test_pool().await;
|
|
let user_repo = UserRepository::new(pool.clone());
|
|
let room_service = make_room_service(pool.clone());
|
|
|
|
let owner = user_repo
|
|
.create(&make_user("strong_playback_owner"))
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let room = room_service
|
|
.create_room(
|
|
"Strong Playback Room".to_string(),
|
|
String::new(),
|
|
owner.id,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let playback_service = room_service.playback_service();
|
|
let attached_state = attach_test_media(&pool, room.id, owner.id).await;
|
|
|
|
let cached_state = playback_service
|
|
.get_state_eventually_consistent(&room.id)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
sqlx::query!(
|
|
r#"WITH progress_update AS (
|
|
UPDATE room_playback_progress
|
|
SET "position" = $2, version = version + 1
|
|
WHERE id = $1
|
|
RETURNING room_id
|
|
)
|
|
UPDATE room_playback_state state
|
|
SET version = version + 1, updated_at = NOW()
|
|
FROM progress_update
|
|
WHERE state.room_id = progress_update.room_id"#,
|
|
attached_state
|
|
.current_progress_id
|
|
.checked("current progress id"),
|
|
77.0_f64,
|
|
)
|
|
.execute(&pool)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let eventual_state = playback_service
|
|
.get_state_eventually_consistent(&room.id)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
assert_eq!(
|
|
eventual_state.version, cached_state.version,
|
|
"eventual path should demonstrate the stale L1 fixture is still present"
|
|
);
|
|
|
|
let strong_state = playback_service
|
|
.get_state(&room.id)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
assert!(
|
|
strong_state.version > cached_state.version,
|
|
"strong get_state must bypass stale L1"
|
|
);
|
|
assert!(
|
|
(strong_state.position - 77.0).abs() < f64::EPSILON,
|
|
"strong get_state must read the DB-updated playback position"
|
|
);
|
|
}
|
|
|
|
// Test 6: L2 TTL Enforcement
|
|
|
|
/// Test that L2 cache entries have proper TTL.
|
|
///
|
|
/// Playback state changes frequently, so L2 TTL should be short.
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_playback_state_l2_has_proper_ttl() {
|
|
let (_redis_container, redis_conn) = start_redis().await;
|
|
|
|
let l2 = RedisCacheL2::from_runtime(synctv_core::direct_runtime(redis_conn.clone()));
|
|
let l2_key = "test:playback:ttl_test";
|
|
|
|
// Set a value with TTL
|
|
let test_state =
|
|
synctv_core::models::RoomPlaybackState::new(RoomId::expect_positive(1_001_000));
|
|
let state_json = serde_json::to_string(&test_state).checked("test operation should succeed");
|
|
l2.set(l2_key, &state_json, 60)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
// Verify TTL is set
|
|
let mut conn = redis_conn.clone();
|
|
let ttl: i64 = conn
|
|
.ttl(l2_key)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
assert!(ttl > 0 && ttl <= 60, "TTL should be set and <= 60 seconds");
|
|
}
|
|
|
|
// Test 7: Version-Based Cache Update (Prevents Stale Overwrites)
|
|
|
|
/// Test that older versions don't overwrite newer versions in L2.
|
|
///
|
|
/// Scenario:
|
|
/// - Set state with version 10 in L2
|
|
/// - Try to set state with version 5 (older)
|
|
/// - L2 should reject the older state
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_playback_state_l2_version_check_prevents_stale_overwrite() {
|
|
let (_redis_container, redis_conn) = start_redis().await;
|
|
|
|
let l2 = RedisCacheL2::from_runtime(synctv_core::direct_runtime(redis_conn.clone()));
|
|
let l2_key = "test:playback:version_test";
|
|
|
|
let mut newer_state =
|
|
synctv_core::models::RoomPlaybackState::new(RoomId::expect_positive(1_001_001));
|
|
newer_state.version = 10;
|
|
newer_state.position = 100.0;
|
|
newer_state.updated_at = Utc::now();
|
|
|
|
let newer_ts = newer_state.updated_at.timestamp_millis();
|
|
let newer_json = timestamped_l2_envelope(&newer_state, newer_ts);
|
|
|
|
// Set newer state first
|
|
let was_set = l2
|
|
.set_if_newer(l2_key, &newer_json, 300, newer_ts)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
assert!(was_set, "Newer state should be set");
|
|
|
|
let mut older_state =
|
|
synctv_core::models::RoomPlaybackState::new(RoomId::expect_positive(1_001_001));
|
|
older_state.version = 5;
|
|
older_state.position = 50.0;
|
|
older_state.updated_at = Utc::now() - chrono::Duration::seconds(10);
|
|
|
|
let older_ts = older_state.updated_at.timestamp_millis();
|
|
let older_json = timestamped_l2_envelope(&older_state, older_ts);
|
|
|
|
// Try to set older state - should be rejected
|
|
let was_set = l2
|
|
.set_if_newer(l2_key, &older_json, 300, older_ts)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
assert!(!was_set, "Older state should NOT overwrite newer state");
|
|
|
|
// Verify L2 still has the newer state
|
|
let from_l2 = l2
|
|
.get(l2_key)
|
|
.await
|
|
.checked("test operation should succeed")
|
|
.checked("test operation should succeed");
|
|
let stored: serde_json::Value =
|
|
serde_json::from_str(&from_l2).checked("test operation should succeed");
|
|
let stored: synctv_core::models::RoomPlaybackState =
|
|
serde_json::from_value(stored["payload"].clone()).checked("test operation should succeed");
|
|
assert_eq!(stored.version, 10, "Version should still be 10");
|
|
}
|
|
|
|
// Test 8: SingleFlight Prevents Thundering Herd on L2 Miss
|
|
|
|
/// Test that concurrent requests for the same key don't all hit L2/DB.
|
|
///
|
|
/// Scenario:
|
|
/// - Clear cache for a room
|
|
/// - Spawn 10 concurrent get_state requests
|
|
/// - Only one should hit the DB (SingleFlight deduplication)
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_playback_state_singleflight_prevents_thundering_herd() {
|
|
let (_container, pool) = synctv_core_testing::create_test_pool().await;
|
|
let user_repo = UserRepository::new(pool.clone());
|
|
let room_service = Arc::new(make_room_service(pool.clone()));
|
|
|
|
let owner = user_repo
|
|
.create(&make_user("singleflight_owner"))
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let room = room_service
|
|
.create_room(
|
|
"SingleFlight Room".to_string(),
|
|
String::new(),
|
|
owner.id,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
// Spawn concurrent get_state requests
|
|
let mut handles = vec![];
|
|
let barrier = Arc::new(tokio::sync::Barrier::new(10));
|
|
|
|
for _ in 0..10 {
|
|
let rs = room_service.clone();
|
|
let rid = room.id;
|
|
let b = barrier.clone();
|
|
|
|
let handle = tokio::spawn(async move {
|
|
b.wait().await;
|
|
rs.playback_service().get_state(&rid).await
|
|
});
|
|
handles.push(handle);
|
|
}
|
|
|
|
let results: Vec<_> = futures::future::join_all(handles).await;
|
|
|
|
// All should succeed
|
|
let mut success_count = 0;
|
|
for result in &results {
|
|
match result {
|
|
Ok(Ok(_)) => success_count += 1,
|
|
Ok(Err(e)) => {
|
|
std::panic::panic_any(format!("playback state request failed: {e:?}"));
|
|
}
|
|
Err(e) => std::panic::panic_any(format!("playback state task should complete: {e:?}")),
|
|
}
|
|
}
|
|
|
|
assert_eq!(success_count, 10, "All requests should succeed");
|
|
}
|
|
|
|
// Test 9: PubSub + L2 Fallback for Cross-Replica Sync
|
|
|
|
/// Test that when PubSub fails, L2 provides fallback consistency.
|
|
///
|
|
/// Scenario:
|
|
/// - Replica A updates state, writes to L2
|
|
/// - PubSub message is lost (simulated)
|
|
/// - Replica B reads from L2 on L1 miss
|
|
/// - Replica B should see the updated state
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_playback_state_l2_fallback_when_pubsub_fails() {
|
|
let (_pg_container, pool) = synctv_core_testing::create_test_pool().await;
|
|
let (_redis_container, redis_conn) = start_redis().await;
|
|
|
|
let l2 = RedisCacheL2::from_runtime(synctv_core::direct_runtime(redis_conn.clone()));
|
|
|
|
let user_repo = UserRepository::new(pool.clone());
|
|
let room_service = make_room_service(pool.clone());
|
|
|
|
let owner = user_repo
|
|
.create(&make_user("l2_fallback_owner"))
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let room = room_service
|
|
.create_room(
|
|
"L2 Fallback Room".to_string(),
|
|
String::new(),
|
|
owner.id,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let playback_service = room_service.playback_service();
|
|
attach_test_media(&pool, room.id, owner.id).await;
|
|
|
|
// Update state
|
|
let result = playback_service
|
|
.seek(room.id, owner.id, 200.0)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
// Write to L2 to simulate a value produced by another replica.
|
|
let l2_key = format!("synctv:playback:{}", room.id);
|
|
let state_json = serde_json::to_string(&result.state).checked("test operation should succeed");
|
|
l2.set(&l2_key, &state_json, 300)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
// Clear L1 to simulate another replica
|
|
playback_service.invalidate_playback_cache(&room.id).await;
|
|
|
|
let fresh_state = playback_service
|
|
.get_state(&room.id)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
assert!(
|
|
(fresh_state.position - 200.0).abs() < f64::EPSILON,
|
|
"State should be read correctly from DB after L1 invalidation"
|
|
);
|
|
}
|
|
|
|
/// Test that cross-replica playback invalidation clears Redis L2 so stale entries
|
|
/// cannot repopulate L1 after an invalidation event.
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_playback_state_cross_replica_invalidation_clears_l2() {
|
|
let (_pg_container, pool) = synctv_core_testing::create_test_pool().await;
|
|
let (_redis_container, redis_client, redis_conn) =
|
|
start_redis_client_manager_with_label("playback-l2-invalidation").await;
|
|
|
|
let user_repo = UserRepository::new(pool.clone());
|
|
|
|
let l2_backend: Arc<dyn CacheL2Backend> = Arc::new(RedisCacheL2::from_runtime(
|
|
synctv_core::direct_runtime(redis_conn.clone()),
|
|
));
|
|
let l2_cache = PlaybackStateCache::new(
|
|
l2_backend,
|
|
128,
|
|
5,
|
|
60,
|
|
"test:playback:invalidate:".to_string(),
|
|
);
|
|
|
|
let cache_stream = format!(
|
|
"test:playback:invalidate:stream:{}",
|
|
synctv_common::snanoid!(8)
|
|
);
|
|
let subscriber = Arc::new(synctv_core::cache::CacheInvalidationService::from_runtime(
|
|
synctv_core::direct_runtime(redis_connection_manager(&redis_client).await),
|
|
"node-subscriber".to_string(),
|
|
cache_stream.clone(),
|
|
));
|
|
subscriber
|
|
.start()
|
|
.await
|
|
.checked("test operation should succeed");
|
|
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
|
|
|
let publisher = Arc::new(synctv_core::cache::CacheInvalidationService::from_runtime(
|
|
synctv_core::direct_runtime(redis_connection_manager(&redis_client).await),
|
|
"node-publisher".to_string(),
|
|
cache_stream,
|
|
));
|
|
|
|
let user_service = make_user_service(&pool);
|
|
let room_service = RoomService::new_with_options(
|
|
pool.clone(),
|
|
user_service,
|
|
RoomServiceOptions {
|
|
cache_invalidation: Some(subscriber.clone()),
|
|
playback_l2_cache: l2_cache.clone(),
|
|
..RoomServiceOptions::test_defaults_with_settings(pool.clone())
|
|
},
|
|
)
|
|
.checked("room service should build");
|
|
room_service
|
|
.playback_service()
|
|
.start()
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let owner = user_repo
|
|
.create(&make_user("invalidate_l2_owner"))
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let room = room_service
|
|
.create_room(
|
|
"Invalidate L2 Playback".to_string(),
|
|
String::new(),
|
|
owner.id,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let playback_service = room_service.playback_service();
|
|
let state = playback_service
|
|
.get_state(&room.id)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
l2_cache
|
|
.set(&room.id, state.clone())
|
|
.await
|
|
.checked("test operation should succeed");
|
|
assert!(l2_cache
|
|
.get(&room.id)
|
|
.await
|
|
.checked("test operation should succeed")
|
|
.is_some());
|
|
|
|
publisher
|
|
.invalidate_playback_state(&room.id)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
|
|
|
assert!(
|
|
l2_cache
|
|
.get(&room.id)
|
|
.await
|
|
.checked("test operation should succeed")
|
|
.is_none(),
|
|
"cross-replica invalidation must remove stale playback state from Redis L2"
|
|
);
|
|
}
|
|
|
|
/// `PlaybackStateCache` serves entries from L1, L2, and invalidates both layers.
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_playback_state_cache_direct_with_redis() {
|
|
let (_redis_container, redis_conn) = start_redis().await;
|
|
|
|
let l2_backend = Arc::new(RedisCacheL2::from_runtime(synctv_core::direct_runtime(
|
|
redis_conn.clone(),
|
|
)));
|
|
let cache = PlaybackStateCache::new(
|
|
l2_backend,
|
|
100, // L1 max capacity
|
|
5, // L1 TTL seconds
|
|
60, // L2 TTL seconds
|
|
"test:playback:".to_string(),
|
|
);
|
|
|
|
let room_id = RoomId::expect_positive(10_000_005);
|
|
let state = synctv_core::models::RoomPlaybackState::new(room_id);
|
|
|
|
// Cache miss
|
|
assert!(
|
|
cache
|
|
.get(&room_id)
|
|
.await
|
|
.checked("test operation should succeed")
|
|
.is_none(),
|
|
"Should be a cache miss initially"
|
|
);
|
|
|
|
// Set in cache
|
|
cache
|
|
.set(&room_id, state.clone())
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
// L1 hit
|
|
let from_cache = cache
|
|
.get(&room_id)
|
|
.await
|
|
.checked("test operation should succeed")
|
|
.checked("test operation should succeed");
|
|
assert_eq!(from_cache.room_id, room_id);
|
|
|
|
// Clear L1, then check L2
|
|
cache.clear_l1();
|
|
|
|
// L2 hit
|
|
let from_l2 = cache
|
|
.get(&room_id)
|
|
.await
|
|
.checked("test operation should succeed")
|
|
.checked("test operation should succeed");
|
|
assert_eq!(
|
|
from_l2.room_id, room_id,
|
|
"Should get from L2 after L1 clear"
|
|
);
|
|
|
|
// Invalidate both
|
|
cache
|
|
.invalidate(&room_id)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
// Both should be gone
|
|
assert!(
|
|
cache
|
|
.get(&room_id)
|
|
.await
|
|
.checked("test operation should succeed")
|
|
.is_none(),
|
|
"Should be gone after invalidation"
|
|
);
|
|
}
|
|
|
|
/// `set_if_newer` keeps newer Redis-backed playback cache entries.
|
|
#[tokio::test]
|
|
#[ignore = "Requires Docker"]
|
|
async fn test_playback_state_cache_set_if_newer_with_redis() {
|
|
let (_redis_container, redis_conn) = start_redis().await;
|
|
|
|
let l2_backend = Arc::new(RedisCacheL2::from_runtime(synctv_core::direct_runtime(
|
|
redis_conn.clone(),
|
|
)));
|
|
let cache = PlaybackStateCache::new(l2_backend, 100, 5, 60, "test:playback:newer:".to_string());
|
|
|
|
let room_id = RoomId::expect_positive(10_000_006);
|
|
|
|
let mut state1 = synctv_core::models::RoomPlaybackState::new(room_id);
|
|
state1.version = 5;
|
|
state1.position = 50.0;
|
|
state1.updated_at = Utc::now();
|
|
|
|
// Set initial state
|
|
cache
|
|
.set(&room_id, state1.clone())
|
|
.await
|
|
.checked("test operation should succeed");
|
|
|
|
let mut state2 = synctv_core::models::RoomPlaybackState::new(room_id);
|
|
state2.version = 10;
|
|
state2.position = 100.0;
|
|
state2.updated_at = Utc::now() + chrono::Duration::seconds(10);
|
|
|
|
// Set newer state - should succeed
|
|
let was_set = cache
|
|
.set_if_newer(&room_id, state2.clone())
|
|
.await
|
|
.checked("test operation should succeed");
|
|
assert!(was_set, "Newer state should be set");
|
|
|
|
// Clear L1 to force read from L2
|
|
cache.clear_l1();
|
|
|
|
// Verify we get the newer state
|
|
let from_cache = cache
|
|
.get(&room_id)
|
|
.await
|
|
.checked("test operation should succeed")
|
|
.checked("test operation should succeed");
|
|
assert_eq!(from_cache.version, 10, "Should have version 10");
|
|
assert!(
|
|
(from_cache.position - 100.0).abs() < f64::EPSILON,
|
|
"Should have position 100"
|
|
);
|
|
|
|
let mut state3 = synctv_core::models::RoomPlaybackState::new(room_id);
|
|
state3.version = 3;
|
|
state3.position = 25.0;
|
|
state3.updated_at = Utc::now() - chrono::Duration::seconds(30);
|
|
|
|
// Try to set older state - should be rejected
|
|
let was_set = cache
|
|
.set_if_newer(&room_id, state3)
|
|
.await
|
|
.checked("test operation should succeed");
|
|
assert!(!was_set, "Older state should be rejected");
|
|
|
|
// Verify we still have the newer state
|
|
cache.clear_l1();
|
|
let from_cache = cache
|
|
.get(&room_id)
|
|
.await
|
|
.checked("test operation should succeed")
|
|
.checked("test operation should succeed");
|
|
assert_eq!(from_cache.version, 10, "Should still have version 10");
|
|
}
|