Repository navigation
fix(worker): drain finalization on graceful shutdown (0.6) #643
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
Merged
hardbyte
merged 4 commits into
release/0.6.6
from
brian/fix-graceful-restart-duplicate-0.6
Nov 6, 2026
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
7e6259e
fix(worker): drain finalization on graceful shutdown (0.6)
hardbyte 0b43d40
merge: prepare graceful shutdown after approved queue-storage fixes
hardbyte 9dcb8c1
fix(queue-storage): ignore retired ready rows in point reads
hardbyte 050e87b
merge: include live-ready reads in first approved release candidate
hardbyte 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
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
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
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
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
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,186 @@ | ||
| //! Shared CI contention scaling for the nightly chaos and benchmark suites. | ||
| //! | ||
| //! The nightly suites run on shared GitHub runners whose CPU allocation | ||
| //! varies run to run. Three assertion shapes are sensitive to that and | ||
| //! flaked repeatedly through 2026-07 (#399, #434): | ||
| //! | ||
| //! 1. **Wall-clock waits.** A test waits N seconds for a state the | ||
| //! runtime reaches in milliseconds when it has a core to itself. | ||
| //! Scale with [`scaled_timeout`]. | ||
| //! | ||
| //! 2. **Aggressive rescue cadences.** A chaos client sets | ||
| //! `heartbeat_staleness` a few multiples above `heartbeat_interval` | ||
| //! so its own backdating triggers rescue promptly. Under contention | ||
| //! a *live* worker's heartbeat can miss that window, so the runtime | ||
| //! correctly rescues a healthy attempt — and the test sees a genuine | ||
| //! duplicate completion, which reads as a correctness failure. Scale | ||
| //! with [`scaled_staleness`]: the interval stays fast so rescue is | ||
| //! still exercised, but the staleness window gains the same margin | ||
| //! as the waits around it. | ||
| //! | ||
| //! 3. **Minimum-progress floors.** A gate asserts "at least N of these | ||
| //! happened" to catch a stalled subsystem. The floor must sit far | ||
| //! below the *observed* operating point, not just below the nominal | ||
| //! one. Scale with [`contention_floor`]. | ||
| //! | ||
| //! Scaling only ever loosens a bound, and only on CI. A local run keeps | ||
| //! the strict values, so a real regression still fails fast on a | ||
| //! developer machine. Override with `AWA_CHAOS_TIMEOUT_MULTIPLIER`. | ||
| #![allow(dead_code)] | ||
|
|
||
| use std::time::Duration; | ||
|
|
||
| /// Upper bound on the override. `Duration::mul_f64` panics on a non-finite | ||
| /// or overflowing result, so an unbounded override would turn a typo into a | ||
| /// failed nightly rather than a loose bound. | ||
| const MAX_MULTIPLIER: f64 = 100.0; | ||
|
|
||
| /// How much slack to give contention-sensitive bounds. `1.0` locally, | ||
| /// `3.0` on CI, or the `AWA_CHAOS_TIMEOUT_MULTIPLIER` override (clamped to | ||
| /// `1.0 ..= MAX_MULTIPLIER` so the override can only ever loosen, and can | ||
| /// never produce a duration `mul_f64` refuses). | ||
| pub fn chaos_timeout_multiplier() -> f64 { | ||
| let override_var = std::env::var("AWA_CHAOS_TIMEOUT_MULTIPLIER").ok(); | ||
| multiplier_from(override_var.as_deref(), std::env::var_os("CI").is_some()) | ||
| } | ||
|
|
||
| /// The multiplier decision, with the environment passed in so it can be | ||
| /// tested without mutating process-global state. | ||
| fn multiplier_from(override_var: Option<&str>, is_ci: bool) -> f64 { | ||
| // `"inf"` and `"nan"` parse successfully as f64, and both reach | ||
| // `Duration::mul_f64`, which panics rather than saturating. Treat a | ||
| // non-finite override as no override at all. | ||
| if let Some(parsed) = override_var | ||
| .and_then(|raw| raw.parse::<f64>().ok()) | ||
| .filter(|parsed| parsed.is_finite()) | ||
| { | ||
| return parsed.clamp(1.0, MAX_MULTIPLIER); | ||
| } | ||
|
|
||
| if is_ci { | ||
| 3.0 | ||
| } else { | ||
| 1.0 | ||
| } | ||
| } | ||
|
|
||
| /// Grow a wait deadline by the contention multiplier. | ||
| pub fn scaled_timeout(timeout: Duration) -> Duration { | ||
| timeout.mul_f64(chaos_timeout_multiplier()) | ||
| } | ||
|
|
||
| /// Grow a heartbeat-staleness window by the contention multiplier. | ||
| /// | ||
| /// Distinct from [`scaled_timeout`] only in intent: this one is passed to | ||
| /// `ClientBuilder::heartbeat_staleness`, where the cost of being too tight | ||
| /// is a spurious rescue of a live attempt rather than a timeout. Paired | ||
| /// heartbeat/rescue *intervals* are deliberately left unscaled so the | ||
| /// rescue path still runs at chaos cadence. | ||
| pub fn scaled_staleness(staleness: Duration) -> Duration { | ||
| staleness.mul_f64(chaos_timeout_multiplier()) | ||
| } | ||
|
|
||
| /// Shrink a minimum-progress floor by the contention multiplier. | ||
| /// | ||
| /// Use for "this subsystem must have advanced at least N times" gates. | ||
| /// The returned floor is at least 1: the regression these gates exist to | ||
| /// catch is a fully stalled subsystem, so zero progress must still fail. | ||
| pub fn contention_floor(nominal: i64) -> i64 { | ||
| let scaled = (nominal as f64 / chaos_timeout_multiplier()).floor() as i64; | ||
| scaled.max(1) | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
|
|
||
| #[test] | ||
| fn local_runs_keep_strict_bounds() { | ||
| assert_eq!(multiplier_from(None, false), 1.0); | ||
| } | ||
|
|
||
| #[test] | ||
| fn ci_runs_get_margin() { | ||
| assert_eq!(multiplier_from(None, true), 3.0); | ||
| } | ||
|
|
||
| #[test] | ||
| fn override_can_only_loosen() { | ||
| assert_eq!(multiplier_from(Some("0.1"), true), 1.0); | ||
| assert_eq!(multiplier_from(Some("-5"), true), 1.0); | ||
| assert_eq!(multiplier_from(Some("6"), false), 6.0); | ||
| } | ||
|
|
||
| #[test] | ||
| fn unparseable_override_falls_back_to_the_environment() { | ||
| assert_eq!(multiplier_from(Some("banana"), true), 3.0); | ||
| assert_eq!(multiplier_from(Some(""), false), 1.0); | ||
| } | ||
|
|
||
| #[test] | ||
| fn non_finite_override_falls_back_to_the_environment() { | ||
| // These all parse as f64 and would otherwise reach `mul_f64`, which | ||
| // panics on a non-finite result instead of saturating. | ||
| for raw in ["inf", "-inf", "infinity", "NaN", "nan"] { | ||
| assert_eq!(multiplier_from(Some(raw), true), 3.0, "raw={raw}"); | ||
| assert_eq!(multiplier_from(Some(raw), false), 1.0, "raw={raw}"); | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn oversized_override_is_clamped() { | ||
| assert_eq!(multiplier_from(Some("1e308"), true), MAX_MULTIPLIER); | ||
| assert_eq!(multiplier_from(Some("10000"), true), MAX_MULTIPLIER); | ||
| } | ||
|
|
||
| #[test] | ||
| fn every_multiplier_produces_a_usable_duration() { | ||
| // `Duration::mul_f64` panics rather than saturating, so the clamp is | ||
| // what keeps a bad override from failing the nightly it was set to | ||
| // rescue. | ||
| for raw in [ | ||
| "inf", "-inf", "NaN", "1e308", "10000", "0", "-1", "banana", "", | ||
| ] { | ||
| for is_ci in [true, false] { | ||
| let m = multiplier_from(Some(raw), is_ci); | ||
| assert!(m.is_finite() && (1.0..=MAX_MULTIPLIER).contains(&m)); | ||
| // Would panic if the multiplier were unbounded. | ||
| let _ = Duration::from_secs(60).mul_f64(m); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn scaling_a_timeout_grows_it() { | ||
| // 250ms staleness against a 50ms heartbeat interval is a 5x margin | ||
| // locally; on CI it becomes 15x, which is what stops a contended | ||
| // runner's live worker from being rescued as if it had died. | ||
| assert_eq!( | ||
| Duration::from_millis(250).mul_f64(multiplier_from(None, true)), | ||
| Duration::from_millis(750) | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn progress_floor_keeps_margin_under_the_observed_rate() { | ||
| // The receipt gate's nominal floor is duration_secs / 4 = 45 for a | ||
| // 180s run. #399 saw a healthy run produce 41, so the CI floor has | ||
| // to sit well under that. | ||
| let ci_floor = (45.0_f64 / multiplier_from(None, true)).floor() as i64; | ||
| assert_eq!(ci_floor, 15); | ||
| assert!( | ||
| ci_floor < 41, | ||
| "floor must clear the observed operating point" | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn progress_floor_never_reaches_zero() { | ||
| // Whatever the multiplier, a fully pinned ring (zero rotations) has | ||
| // to keep failing — that is the regression the gate exists for. | ||
| assert_eq!(contention_floor(45), contention_floor(45).max(1)); | ||
| let absurd = (45.0_f64 / multiplier_from(Some("10000"), true)).floor() as i64; | ||
| assert_eq!(absurd.max(1), 1); | ||
| assert_eq!(contention_floor(0), 1); | ||
| } | ||
| } |
Oops, something went wrong.
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.
When an outcome lifecycle hook is slow or hangs, this tracked future remains live through
dispatch_lifecycle_eventat line 572, soClient::shutdownwaits until the entire drain deadline expires. This regresses the documented contract indocs/lifecycle-hooks.md:75-80that hooks are best-effort and shutdown does not wait for them, turning user hook latency into shutdown latency. Track only the durable completion work and leave lifecycle dispatch detached.Useful? React with 👍 / 👎.