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.
synctv/synctv-core/tests/playback_l2_cache_tests.rs

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");
}