#[tokio::test] async fn graceful_shutdown_flush_moves_hot_objects_and_releases_resident_bytes() { let dir = tempfile::tempdir().unwrap(); let mut settings = cold_settings(); settings.max_entries = 8; let shutdown = CancellationToken::new(); let cache = Cache::start( 11, Some(dir.path().to_path_buf()), settings.clone(), shutdown.clone(), ); fill(&cache, &settings, "/one", "first-payload").await; fill(&cache, &settings, "/two", "second-payload").await; assert_eq!(cache.view().entries, 2); assert!(cache.view().resident_bytes > 0); assert_eq!(cache.view().cold_entries, 0); assert_eq!(cache.flush_hot_to_cold().await, 2); let view = cache.view(); assert_eq!(view.entries, 0); assert_eq!(view.resident_bytes, 0); assert_eq!(view.hot_logical_bytes, 0); assert_eq!( view.transient_bytes, 0, "persisted ColdEntry must not retain the body allocation" ); assert_eq!(view.cold_entries, 2); shutdown.cancel(); let restart_shutdown = CancellationToken::new(); let restarted = Cache::start( 11, Some(dir.path().to_path_buf()), settings.clone(), restart_shutdown.clone(), ); assert_eq!(restarted.view().cold_entries, 2); restart_shutdown.cancel(); } #[tokio::test] async fn managed_update_flush_ignores_shutdown_toggle_when_cold_tier_is_enabled() { let dir = tempfile::tempdir().unwrap(); let mut settings = cold_settings(); settings.max_entries = 8; settings.cold_on_shutdown = false; let shutdown = CancellationToken::new(); let cache = Cache::start( 11, Some(dir.path().to_path_buf()), settings.clone(), shutdown.clone(), ); fill(&cache, &settings, "/update", "update-payload").await; assert_eq!(cache.flush_hot_to_cold().await, 0); assert_eq!(cache.view().entries, 1); assert_eq!(cache.flush_hot_to_cold_for_update().await, 1); let view = cache.view(); assert_eq!(view.entries, 0); assert_eq!(view.resident_bytes, 0); assert_eq!(view.cold_entries, 1); shutdown.cancel(); } #[tokio::test] async fn graceful_shutdown_flush_can_be_disabled() { let dir = tempfile::tempdir().unwrap(); let mut settings = cold_settings(); settings.max_entries = 8; settings.cold_on_shutdown = false; let shutdown = CancellationToken::new(); let cache = Cache::start( 11, Some(dir.path().to_path_buf()), settings.clone(), shutdown.clone(), ); fill(&cache, &settings, "/one", "first-payload").await; assert_eq!(cache.flush_hot_to_cold().await, 0); assert_eq!(cache.view().entries, 1); assert_eq!(cache.view().cold_entries, 0); shutdown.cancel(); } #[tokio::test] async fn cold_cache_uses_two_hex_character_shards() { let dir = tempfile::tempdir().unwrap(); let settings = cold_settings(); let shutdown = CancellationToken::new(); let cache = Cache::start( 11, Some(dir.path().to_path_buf()), settings.clone(), shutdown.clone(), ); fill(&cache, &settings, "/one", "first-payload").await; fill(&cache, &settings, "/two", "second-payload").await; wait_for_cold(&cache, 1).await; let root = dir.path().join(&settings.cold_path); let mut shards = Vec::new(); for item in std::fs::read_dir(&root).unwrap() { let item = item.unwrap(); let name = item.file_name().to_string_lossy().to_string(); assert!( item.path().is_dir(), "cold root must contain shard directories only" ); assert_eq!(name.len(), 2); assert!(name.bytes().all(|b| b.is_ascii_hexdigit())); shards.push(item.path()); } assert!(!shards.is_empty()); assert!(shards.iter().any(|shard| { std::fs::read_dir(shard) .unwrap() .filter_map(|entry| entry.ok()) .any(|entry| { let name = entry.file_name().to_string_lossy().to_string(); name.ends_with(".json") || name.ends_with(".body") }) })); shutdown.cancel(); } #[tokio::test] async fn legacy_flat_cold_cache_is_not_migrated() { let dir = tempfile::tempdir().unwrap(); let settings = cold_settings(); let root = dir.path().join(&settings.cold_path); std::fs::create_dir_all(&root).unwrap(); std::fs::write(root.join("legacy.body"), b"old").unwrap(); std::fs::write(root.join("legacy.json"), b"{}").unwrap(); let shutdown = CancellationToken::new(); let cache = Cache::start( 11, Some(dir.path().to_path_buf()), settings, shutdown.clone(), ); assert_eq!(cache.view().cold_entries, 0); assert!(!root.join("legacy.body").exists()); assert!(!root.join("legacy.json").exists()); shutdown.cancel(); } #[tokio::test] async fn manual_cold_cleanup_reports_removed_objects_and_freed_bytes() { let dir = tempfile::tempdir().unwrap(); let mut settings = cold_settings(); settings.max_entries = 1; let shutdown = CancellationToken::new(); let cache = Cache::start( 11, Some(dir.path().to_path_buf()), settings.clone(), shutdown.clone(), ); fill(&cache, &settings, "/one", "first-payload").await; fill(&cache, &settings, "/two", "second-payload").await; fill(&cache, &settings, "/three", "third-payload").await; wait_for_cold(&cache, 2).await; let job = cache .start_cold_cleanup(ColdCleanupRequest { unused_for_seconds: None, oldest: Some(1), hits_zero: false, }) .unwrap(); for _ in 0..100 { let current = cache.cold_cleanup_job(job.id).unwrap(); if current.status == "done" { assert_eq!(current.removed_entries, 1); assert!(current.freed_bytes > 0); assert_eq!(cache.view().cold_entries, 1); shutdown.cancel(); return; } tokio::time::sleep(Duration::from_millis(10)).await; } panic!("cold cleanup did not finish"); } #[tokio::test] async fn manual_cold_cleanup_is_single_flight() { let dir = tempfile::tempdir().unwrap(); let settings = cold_settings(); let shutdown = CancellationToken::new(); let cache = Cache::start( 11, Some(dir.path().to_path_buf()), settings, shutdown.clone(), ); let _held = cache.cleanup_slots.clone().try_acquire_owned().unwrap(); let result = cache.start_cold_cleanup(ColdCleanupRequest { unused_for_seconds: None, oldest: Some(1), hits_zero: false, }); assert!(result.is_err()); shutdown.cancel(); } #[tokio::test] async fn cold_cleanup_skips_an_entry_while_a_read_is_pinned() { let dir = tempfile::tempdir().unwrap(); let mut settings = cold_settings(); settings.max_entries = 1; let shutdown = CancellationToken::new(); let cache = Cache::start( 11, Some(dir.path().to_path_buf()), settings.clone(), shutdown.clone(), ); fill(&cache, &settings, "/one", "first-payload").await; fill(&cache, &settings, "/two", "second-payload").await; wait_for_cold(&cache, 1).await; let key = { let mut cold = cache.cold.lock().unwrap(); let key = *cold.entries.keys().next().unwrap(); cold.entries.get_mut(&key).unwrap().pinned_reads = 1; key }; let request = ColdCleanupRequest { unused_for_seconds: None, oldest: Some(1), hits_zero: false, }; let result = cache .remove_cold_candidates(Some(&request), None, None, 1) .await; assert_eq!(result.removed_entries, 0); assert!(cache.cold.lock().unwrap().entries.contains_key(&key)); cache .cold .lock() .unwrap() .entries .get_mut(&key) .unwrap() .pinned_reads = 0; let result = cache .remove_cold_candidates(Some(&request), None, None, 1) .await; assert_eq!(result.removed_entries, 1); assert!(!cache.cold.lock().unwrap().entries.contains_key(&key)); shutdown.cancel(); } #[test] fn old_cold_read_guard_does_not_unpin_a_replacement_with_the_same_key() { let cache = Cache::new(1); let key = [7_u8; 32]; let template = ColdEntry { base_key: key, vary: Vec::new(), revision: 1, scope: "app".to_owned(), scheme: "https".to_owned(), authority: "example.test".to_owned(), domain: "example.test".to_owned(), path: "/asset".to_owned(), target: "https://example.test/asset".to_owned(), tags: Vec::new(), created_at: SystemTime::now(), body_path: PathBuf::from("old.body"), meta_path: PathBuf::from("old.json"), body_bytes: 1, body_sha256: [0u8; 32], disk_bytes: 2, stored_at: SystemTime::now(), last_access: SystemTime::now(), age_at_store_seconds: 0, resident_age_at_store_seconds: 0, ttl: 60, stale_while_revalidate: 0, stale_if_error: 0, must_revalidate: false, proxy_revalidate: false, has_validator: false, hits: 0, transformed: false, sequence: 1, generation: 1, skip_compression: false, requires_revalidation: false, pinned_reads: 1, }; cache .cold .lock() .unwrap() .entries .insert(key, template.clone()); let old_guard = ColdReadGuard { cache: &cache, key, generation: 1, }; let mut replacement = template; replacement.sequence = 2; replacement.generation = 2; replacement.pinned_reads = 1; cache.cold.lock().unwrap().entries.insert(key, replacement); drop(old_guard); let cold = cache.cold.lock().unwrap(); let replacement = cold.entries.get(&key).unwrap(); assert_eq!(replacement.generation, 2); assert_eq!(replacement.pinned_reads, 1); } #[test] fn cold_path_cannot_escape_data_directory() { let mut settings = Settings::default(); settings.cold_path = "../outside".into(); assert!(settings.validate().is_err()); settings.cold_path = "/tmp/outside".into(); assert!(settings.validate().is_err()); settings.cold_path = "cache/cold".into(); assert!(settings.validate().is_ok()); }