From 9541bb5d406b8f178df420a822a4e29118098df8 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Sat, 14 Nov 2026 02:24:33 +0000 Subject: [PATCH 1/2] Document and test omitted activity backoff maximum --- README.md | 9 +++++++++ src/lib.rs | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/README.md b/README.md index e81ec08..6f5b687 100644 --- a/README.md +++ b/README.md @@ -652,6 +652,15 @@ compares the complete stored list and the other known policy fields exactly; changing an unused entry is also a policy change. The default remains one attempt with an empty backoff list. +For generated backoff, `exponential_backoff(initial, coefficient, None)` selects +no maximum interval. Nine total attempts with an initial one-second delay and +coefficient two encode `1, 2, 4, 8, 16, 32, 64, 128` seconds. Pass `Some(maximum)` +to cap the intervals explicitly. The attempt budget and original schedule-to-close +deadline still bound retries; omission does not select a 60-second cap. Arithmetic +or protocol overflow returns `ActivityOptionsErrorKind::BackoffOverflow` during +validation. Replay preserves the recorded delay list, so adding a cap is a policy +change. + Activity handlers can use `ctx.activity_execution_id()` as the logical Server activity key, stable across retries, and read its `workflow_id()` and `run_id()`. These immutable optional accessors preserve the original claim. An older Server diff --git a/src/lib.rs b/src/lib.rs index d6547cc..dfff1d9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1843,6 +1843,9 @@ impl ActivityRetryPolicy { /// Generate durable retry intervals from `initial_interval` and `coefficient`. /// Each interval is capped by `maximum_interval`, when present, then rounded /// up to whole seconds. A 100 ms interval or cap therefore encodes one second. + /// Passing `None` selects no interval cap: intervals keep growing within the + /// finite attempt budget. Arithmetic or protocol overflow returns + /// [`ActivityOptionsErrorKind::BackoffOverflow`] during validation. /// Zero stays zero. This policy is separate from HTTP transport retries. pub fn exponential_backoff( mut self, @@ -23976,6 +23979,56 @@ mod tests { assert_eq!(commands[0]["heartbeat_timeout"], 15); } + #[test] + fn exponential_backoff_without_maximum_keeps_growing_and_replays() { + let mut options = retry_activity_options(); + options.retry_policy = Some( + ActivityRetryPolicy::new(9) + .exponential_backoff(Duration::from_secs(1), 2, None) + .non_retryable_error_type("PermanentError"), + ); + let expected = json!([1, 2, 4, 8, 16, 32, 64, 128]); + let ctx = workflow_context(Vec::new()); + let mut call = Box::pin(ctx.activity_with_options("flaky", options.clone(), json!([]))); + let mut cx = TaskContext::from_waker(noop_waker_ref()); + assert!(call.as_mut().poll(&mut cx).is_pending()); + let commands = ctx.take_commands().expect("one new schedule"); + assert_eq!(commands.len(), 1); + assert_eq!(commands[0]["retry_policy"]["max_attempts"], 9); + assert_eq!(commands[0]["retry_policy"]["backoff_seconds"], expected); + + let mut history = completed_retry_activity_history(); + history[0].payload["activity"]["retry_policy"]["max_attempts"] = json!(9); + history[0].payload["activity"]["retry_policy"]["backoff_seconds"] = expected; + history[2].payload["retry_backoff_seconds"] = json!(1); + let replay = workflow_context(history.clone()); + let mut replay_call = + Box::pin(replay.activity_with_options("flaky", options.clone(), json!([]))); + assert!(matches!( + replay_call.as_mut().poll(&mut cx), + Poll::Ready(Ok(value)) if value == json!({"status":"recovered"}) + )); + assert!(replay.take_commands().unwrap().is_empty()); + replay + .ensure_history_consumed() + .expect("recorded history consumed"); + + options.retry_policy.as_mut().unwrap().backoff = Some(ActivityBackoff::Exponential { + initial_interval: Duration::from_secs(1), + coefficient: 2, + maximum_interval: Some(Duration::from_secs(60)), + }); + let changed = workflow_context(history); + let mut changed_call = Box::pin(changed.activity_with_options("flaky", options, json!([]))); + let Poll::Ready(Err(Error::NonDeterministicReplay(failure))) = + changed_call.as_mut().poll(&mut cx) + else { + panic!("adding a cap must not reinterpret a recorded uncapped policy"); + }; + assert_eq!(failure.reason, "activity_retry_policy_mismatch"); + assert!(changed.take_commands().unwrap().is_empty()); + } + fn completed_worker_session_history(session: &WorkerSessionOptions) -> Vec { vec![ history_event( From 8f4d51a00c439659cc89bc1c2ae97a4b51504252 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Sat, 14 Nov 2026 02:28:00 +0000 Subject: [PATCH 2/2] Clarify that only a changed delay list changes replay policy --- README.md | 4 ++-- src/lib.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6f5b687..90e8bb8 100644 --- a/README.md +++ b/README.md @@ -658,8 +658,8 @@ coefficient two encode `1, 2, 4, 8, 16, 32, 64, 128` seconds. Pass `Some(maximum to cap the intervals explicitly. The attempt budget and original schedule-to-close deadline still bound retries; omission does not select a 60-second cap. Arithmetic or protocol overflow returns `ActivityOptionsErrorKind::BackoffOverflow` during -validation. Replay preserves the recorded delay list, so adding a cap is a policy -change. +validation. Replay preserves the recorded delay list. A cap that changes that +list also changes the policy compared during replay. Activity handlers can use `ctx.activity_execution_id()` as the logical Server activity key, stable across retries, and read its `workflow_id()` and `run_id()`. diff --git a/src/lib.rs b/src/lib.rs index dfff1d9..7324402 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1843,7 +1843,7 @@ impl ActivityRetryPolicy { /// Generate durable retry intervals from `initial_interval` and `coefficient`. /// Each interval is capped by `maximum_interval`, when present, then rounded /// up to whole seconds. A 100 ms interval or cap therefore encodes one second. - /// Passing `None` selects no interval cap: intervals keep growing within the + /// Passing `None` selects no interval cap. The generated list still has a /// finite attempt budget. Arithmetic or protocol overflow returns /// [`ActivityOptionsErrorKind::BackoffOverflow`] during validation. /// Zero stays zero. This policy is separate from HTTP transport retries.