-
Notifications
You must be signed in to change notification settings - Fork 10
fix: handle redis timeouts and cap concurrent backup dispatch #95
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
RywJakkraphat
wants to merge
3
commits into
Portabase:main
Choose a base branch
from
RywJakkraphat:fix/redis-timeout-concurrency-limit
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| //! Regression coverage for the concurrency cap added alongside the #94 panic fixes. | ||
| //! `BackupService::dispatch` gates `execute_backup` behind `BACKUP_SEMAPHORE` via the | ||
| //! shared `run_with_permit` helper (`src/services/backup/dispatcher.rs`), configurable | ||
| //! via `MAX_CONCURRENT_BACKUPS`. | ||
|
|
||
| use crate::services::backup::dispatcher::{BACKUP_SEMAPHORE, run_with_permit}; | ||
| use crate::tests::init_tracing_for_test; | ||
| use std::sync::Arc; | ||
| use std::sync::atomic::{AtomicUsize, Ordering}; | ||
| use tokio::sync::Semaphore; | ||
| use tokio::time::{Duration, sleep}; | ||
|
|
||
| #[tokio::test] | ||
| async fn backup_semaphore_defaults_to_unlimited_when_max_concurrent_backups_unset() { | ||
| init_tracing_for_test(); | ||
|
|
||
| // Neither this test suite nor docker-compose.test.yml sets MAX_CONCURRENT_BACKUPS, | ||
| // so the real, process-wide BACKUP_SEMAPHORE must be None: dispatch() must not | ||
| // throttle backups unless an operator explicitly opts in. This depends on ambient | ||
| // environment (CONFIG is a process-wide Lazy singleton) — see settings::tests for | ||
| // hermetic coverage of the underlying parsing logic that doesn't have this caveat. | ||
| assert!( | ||
| BACKUP_SEMAPHORE.is_none(), | ||
| "expected no concurrency cap by default (MAX_CONCURRENT_BACKUPS unset)" | ||
| ); | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn semaphore_gated_execution_never_exceeds_configured_limit() { | ||
| init_tracing_for_test(); | ||
|
|
||
| // Calls the same run_with_permit() that dispatch() uses, so this exercises the | ||
| // real gating code path rather than a parallel reimplementation. A local Semaphore | ||
| // is used instead of the global BACKUP_SEMAPHORE because CONFIG.max_concurrent_backups | ||
| // is a process-wide singleton read once from the environment and can't be | ||
| // reconfigured per-test. | ||
| let limit = 2; | ||
| let semaphore = Arc::new(Semaphore::new(limit)); | ||
| let concurrent = Arc::new(AtomicUsize::new(0)); | ||
| let max_observed = Arc::new(AtomicUsize::new(0)); | ||
|
|
||
| let mut handles = Vec::new(); | ||
| for _ in 0..6 { | ||
| let semaphore = semaphore.clone(); | ||
| let concurrent = concurrent.clone(); | ||
| let max_observed = max_observed.clone(); | ||
|
|
||
| handles.push(tokio::spawn(async move { | ||
| run_with_permit(Some(semaphore.as_ref()), async move { | ||
| let now = concurrent.fetch_add(1, Ordering::SeqCst) + 1; | ||
| max_observed.fetch_max(now, Ordering::SeqCst); | ||
|
|
||
| sleep(Duration::from_millis(50)).await; | ||
|
|
||
| concurrent.fetch_sub(1, Ordering::SeqCst); | ||
| }) | ||
| .await; | ||
| })); | ||
| } | ||
|
|
||
| for handle in handles { | ||
| handle.await.unwrap(); | ||
| } | ||
|
|
||
| assert!( | ||
| max_observed.load(Ordering::SeqCst) <= limit, | ||
| "observed {} concurrent jobs, expected at most {}", | ||
| max_observed.load(Ordering::SeqCst), | ||
| limit | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,4 @@ | ||
| mod api_models_tests; | ||
| mod backup_dispatcher_tests; | ||
| mod backup_uploader_tests; | ||
| mod config_tests; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,3 +5,4 @@ mod edge_key_tests; | |
| mod file_tests; | ||
| mod normalize_cron_tests; | ||
| mod stream_tests; | ||
| mod task_manager_tests; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,111 @@ | ||
| use crate::tests::init_tracing_for_test; | ||
| use crate::utils::task_manager::cron::check_and_update_cron; | ||
| use crate::utils::task_manager::scheduler::{execute_task, scheduler_loop}; | ||
| use crate::utils::task_manager::tasks::SCHEDULE_KEY; | ||
| use redis::AsyncCommands; | ||
| use redis::aio::MultiplexedConnection; | ||
| use std::time::Duration; | ||
| use testcontainers::ContainerAsync; | ||
| use testcontainers::runners::AsyncRunner; | ||
| use testcontainers_modules::redis::Redis; | ||
| use url::Host; | ||
|
|
||
| #[tokio::test] | ||
| async fn execute_task_errors_instead_of_panicking_on_missing_generated_id() { | ||
| let result = execute_task("tasks.database.periodic_backup", vec![], None).await; | ||
|
|
||
| let err = result.expect_err("expected an error, not a panic, for empty args"); | ||
| assert!( | ||
| err.to_string().contains("generated_id"), | ||
| "expected error to mention missing generated_id, got: {err}" | ||
| ); | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn execute_task_errors_instead_of_panicking_on_missing_dbms() { | ||
| let args = vec!["some-generated-id".to_string()]; | ||
| let result = execute_task("tasks.database.periodic_backup", args, None).await; | ||
|
|
||
| let err = result.expect_err("expected an error, not a panic, for a single arg"); | ||
| assert!( | ||
| err.to_string().contains("dbms"), | ||
| "expected error to mention missing dbms, got: {err}" | ||
| ); | ||
| } | ||
|
|
||
| async fn start_redis() -> (ContainerAsync<Redis>, MultiplexedConnection) { | ||
| let container = Redis::default().start().await.unwrap(); | ||
|
|
||
| let host = container | ||
| .get_host() | ||
| .await | ||
| .unwrap_or(Host::parse("127.0.0.1").unwrap()); | ||
| let port = container.get_host_port_ipv4(6379).await.unwrap_or(6379); | ||
|
|
||
| let client = redis::Client::open(format!("redis://{host}:{port}")).unwrap(); | ||
| let conn = client.get_multiplexed_async_connection().await.unwrap(); | ||
|
|
||
| (container, conn) | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn check_and_update_cron_does_not_panic_on_malformed_stored_data() { | ||
| init_tracing_for_test(); | ||
| let (_container, mut conn) = start_redis().await; | ||
|
|
||
| let task_name = "malformed-task"; | ||
| let redis_key = format!("redbeat:{task_name}"); | ||
|
|
||
| let _: () = conn | ||
| .hset(&redis_key, "data", "not-valid-json") | ||
| .await | ||
| .unwrap(); | ||
|
|
||
| // Before the fix this unwrapped serde_json::from_str and panicked. It should | ||
| // now log the parse failure and return gracefully instead. | ||
| check_and_update_cron( | ||
| &mut conn, | ||
| Some("*/5 * * * *".to_string()), | ||
| vec![], | ||
| "tasks.database.periodic_backup", | ||
| task_name.to_string(), | ||
| None, | ||
| ) | ||
| .await; | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn scheduler_loop_skips_malformed_due_task_without_panicking() { | ||
| init_tracing_for_test(); | ||
| let (_container, mut conn) = start_redis().await; | ||
|
|
||
| let task_name = "malformed-due-task"; | ||
| let redis_key = format!("redbeat:{task_name}"); | ||
| let now = chrono::Local::now().timestamp(); | ||
|
|
||
| let _: () = conn | ||
| .hset(&redis_key, "data", "not-valid-json") | ||
| .await | ||
| .unwrap(); | ||
| let _: () = conn.zadd(SCHEDULE_KEY, &redis_key, now).await.unwrap(); | ||
|
|
||
| let handle = tokio::spawn(scheduler_loop(conn)); | ||
| let abort_handle = handle.abort_handle(); | ||
|
|
||
| // Before the fix this unwrapped serde_json::from_str inside the loop body | ||
| // (not a spawned subtask), so the panic would unwind scheduler_loop itself | ||
| // and the JoinHandle would resolve within the timeout. Post-fix it logs the | ||
| // parse failure, continues, and keeps looping forever, so the timeout should | ||
| // elapse instead. | ||
| let outcome = tokio::time::timeout(Duration::from_millis(1500), handle).await; | ||
| abort_handle.abort(); | ||
|
|
||
| match outcome { | ||
| Err(_elapsed) => { | ||
| // Still running after processing the due tick: it survived the bad payload. | ||
| } | ||
| Ok(join_result) => { | ||
| panic!("scheduler_loop exited instead of continuing to loop: {join_result:?}"); | ||
| } | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
Make the default-behavior test hermetic.
CONFIGandBACKUP_SEMAPHOREare process-wideLazyvalues, so this assertion can fail when the test runner inheritsMAX_CONCURRENT_BACKUPSor another test initializes configuration first. Test a pure constructor with explicit inputs, or isolate this check in a subprocess with the variable removed.🤖 Prompt for AI Agents