Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 68 additions & 29 deletions src/runtime/dispatchers/worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -590,7 +590,7 @@ async fn run_activity_with_cancellation(
let duration_seconds = start_time.elapsed().as_secs_f64();
rt.record_activity_execution(&ctx.activity_name, "cancelled", duration_seconds, 0, ctx.tag.as_deref());

let result = rt.history_store.ack_work_item(&ctx.lock_token, None).await;
let result = ack_work_item_with_retry(rt, ctx, None).await;
if let Err(e) = &result {
tracing::warn!(
target: "duroxide::runtime",
Expand All @@ -609,6 +609,47 @@ async fn run_activity_with_cancellation(
// Activity Completion Handlers
// ============================================================================

/// Maximum number of attempts when acking an activity completion.
const MAX_ACK_ATTEMPTS: u32 = 5;

/// Ack a work item, retrying transient provider failures with exponential backoff.
///
/// Activities have at-least-once semantics, so an ack failure is not a correctness
/// violation — but it is expensive: the caller abandons the work item, the activity is
/// redelivered, and the handler runs again. Retryable provider errors (database lock
/// contention, transient connection failures) usually clear within milliseconds, so
/// retrying here avoids most redundant re-executions. This mirrors the retry handling
/// the dispatcher loop already applies to `fetch_work_item`.
async fn ack_work_item_with_retry(
rt: &Arc<Runtime>,
ctx: &ActivityWorkContext,
item: Option<WorkItem>,
) -> Result<(), crate::providers::ProviderError> {
let mut attempt = 1;
loop {
match rt.history_store.ack_work_item(&ctx.lock_token, item.clone()).await {
Ok(()) => return Ok(()),
Err(e) if e.is_retryable() && attempt < MAX_ACK_ATTEMPTS => {
let backoff_ms = (50 * 2_u64.pow(attempt)).min(1000);
warn!(
instance = %ctx.instance,
execution_id = ctx.execution_id,
activity_id = ctx.activity_id,
worker_id = %ctx.worker_id,
attempt = attempt,
max_attempts = MAX_ACK_ATTEMPTS,
error = %e,
"worker: ack failed (retryable), backing off {}ms",
backoff_ms
);
tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
attempt += 1;
}
Err(e) => return Err(e),
}
}
}

/// Handle successful activity completion.
async fn handle_activity_success(
rt: &Arc<Runtime>,
Expand All @@ -635,18 +676,17 @@ async fn handle_activity_success(

rt.record_activity_execution(&ctx.activity_name, "success", duration_seconds, 0, ctx.tag.as_deref());

let ack_result = rt
.history_store
.ack_work_item(
&ctx.lock_token,
Some(WorkItem::ActivityCompleted {
instance: ctx.instance.clone(),
execution_id: ctx.execution_id,
id: ctx.activity_id,
result,
}),
)
.await;
let ack_result = ack_work_item_with_retry(
rt,
ctx,
Some(WorkItem::ActivityCompleted {
instance: ctx.instance.clone(),
execution_id: ctx.execution_id,
id: ctx.activity_id,
result,
}),
)
.await;

(ack_result, ActivityOutcome::Success)
}
Expand Down Expand Up @@ -677,22 +717,21 @@ async fn handle_activity_error(

rt.record_activity_execution(&ctx.activity_name, "app_error", duration_seconds, 0, ctx.tag.as_deref());

let ack_result = rt
.history_store
.ack_work_item(
&ctx.lock_token,
Some(WorkItem::ActivityFailed {
instance: ctx.instance.clone(),
execution_id: ctx.execution_id,
id: ctx.activity_id,
details: crate::ErrorDetails::Application {
kind: crate::AppErrorKind::ActivityFailed,
message: error,
retryable: false,
},
}),
)
.await;
let ack_result = ack_work_item_with_retry(
rt,
ctx,
Some(WorkItem::ActivityFailed {
instance: ctx.instance.clone(),
execution_id: ctx.execution_id,
id: ctx.activity_id,
details: crate::ErrorDetails::Application {
kind: crate::AppErrorKind::ActivityFailed,
message: error,
retryable: false,
},
}),
)
.await;

(ack_result, ActivityOutcome::AppError)
}
Expand Down
27 changes: 27 additions & 0 deletions tests/common/fault_injection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,8 @@ impl Provider for FilterBypassProvider {
pub struct FailingProvider {
inner: Arc<SqliteProvider>,
fail_next_ack_work_item: AtomicBool,
/// Number of remaining `ack_work_item` calls to fail with a *retryable* error
retryable_ack_work_item_failures: AtomicU32,
fail_next_ack_orchestration_item: AtomicBool,
fail_next_fetch_orchestration_item: AtomicBool,
fail_next_fetch_work_item: AtomicBool,
Expand All @@ -486,6 +488,7 @@ impl FailingProvider {
Self {
inner,
fail_next_ack_work_item: AtomicBool::new(false),
retryable_ack_work_item_failures: AtomicU32::new(0),
fail_next_ack_orchestration_item: AtomicBool::new(false),
fail_next_fetch_orchestration_item: AtomicBool::new(false),
fail_next_fetch_work_item: AtomicBool::new(false),
Expand All @@ -498,6 +501,19 @@ impl FailingProvider {
self.fail_next_ack_work_item.store(true, Ordering::SeqCst);
}

/// Fail the next `count` `ack_work_item` calls with a *retryable* error.
///
/// Unlike `fail_next_ack_work_item`, the underlying ack is not performed, so the
/// work item stays locked and the caller is expected to retry in place.
pub fn fail_next_ack_work_item_retryable(&self, count: u32) {
self.retryable_ack_work_item_failures.store(count, Ordering::SeqCst);
}

/// Number of injected retryable ack failures that have not been consumed yet.
pub fn remaining_retryable_ack_failures(&self) -> u32 {
self.retryable_ack_work_item_failures.load(Ordering::SeqCst)
}

pub fn fail_next_ack_orchestration_item(&self) {
self.fail_next_ack_orchestration_item.store(true, Ordering::SeqCst);
}
Expand Down Expand Up @@ -624,6 +640,17 @@ impl Provider for FailingProvider {
}

async fn ack_work_item(&self, token: &str, completion: Option<WorkItem>) -> Result<(), ProviderError> {
if self
.retryable_ack_work_item_failures
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |n| n.checked_sub(1).filter(|_| n > 0))
.is_ok()
{
return Err(ProviderError::retryable(
"ack_work_item",
"simulated transient lock contention",
));
}

if self.fail_next_ack_work_item.swap(false, Ordering::SeqCst) {
// If ack_then_fail is set, do the actual ack first
if self.ack_then_fail.load(Ordering::SeqCst) {
Expand Down
6 changes: 5 additions & 1 deletion tests/session_e2e_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1583,9 +1583,13 @@ async fn test_multi_worker_heterogeneous_config() {
// A should hold at most 1 session concurrently. Worker B picks up the rest
// while A is busy. A may process more than 1 *total* (serially), but B
// should handle at least 1.
//
// Activities have at-least-once semantics: an ack failure or lock expiry
// redelivers the work item, so the counters can exceed the number of
// scheduled activities. Assert a lower bound, not equality.
let a = worker_a_sessions.load(Ordering::SeqCst);
let b = worker_b_sessions.load(Ordering::SeqCst);
assert_eq!(a + b, 3, "Total should be 3, got A={a} B={b}");
assert!(a + b >= 3, "All 3 activities should have run, got A={a} B={b}");
assert!(
b >= 1,
"Worker B should handle at least 1 session (overflow from A's max_sessions=1), got A={a} B={b}"
Expand Down
84 changes: 84 additions & 0 deletions tests/worker_reliability_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -411,3 +411,87 @@ async fn worker_abandon_on_ack_failure_enables_retry() {

rt.shutdown(None).await;
}

/// Test that transient (retryable) ack failures are retried in place instead of
/// abandoning the work item.
///
/// Abandoning on a retryable ack failure redelivers the work item and re-executes the
/// activity handler. Activities are only guaranteed at-least-once, but re-running a
/// handler because the database was briefly contended is wasteful and surprising, so the
/// worker retries the ack before falling back to abandon.
#[tokio::test]
async fn worker_retries_retryable_ack_failure_without_reexecuting_activity() {
use common::fault_injection::FailingProvider;
use duroxide::providers::sqlite::SqliteProvider;
use duroxide::runtime::RuntimeOptions;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Duration;

let sqlite = Arc::new(SqliteProvider::new_in_memory().await.unwrap());
let failing_provider = Arc::new(FailingProvider::new(sqlite));

let execution_count = Arc::new(AtomicU32::new(0));
let exec_count_clone = execution_count.clone();

let activities = runtime::registry::ActivityRegistry::builder()
.register("CountingActivity", move |_ctx: ActivityContext, _input: String| {
let count = exec_count_clone.clone();
async move {
count.fetch_add(1, Ordering::SeqCst);
Ok("done".to_string())
}
})
.build();

let orchestrations = runtime::registry::OrchestrationRegistry::builder()
.register("RetryAckOrch", |ctx: OrchestrationContext, _input: String| async move {
ctx.schedule_activity("CountingActivity", "{}").await
})
.build();

let options = RuntimeOptions {
// Long lock timeout so any retry must come from the in-place ack retry,
// not from lock expiry redelivery.
worker_lock_timeout: Duration::from_secs(30),
..Default::default()
};

let provider: Arc<dyn duroxide::providers::Provider> = failing_provider.clone();
let rt = runtime::Runtime::start_with_options(provider.clone(), activities, orchestrations, options).await;
let client = Client::new(provider.clone());

// Fail the first 3 acks with a retryable error. The worker allows 5 attempts,
// so the 4th attempt should succeed.
failing_provider.fail_next_ack_work_item_retryable(3);

client
.start_orchestration("retry-ack-test", "RetryAckOrch", "")
.await
.unwrap();

let status = client
.wait_for_orchestration("retry-ack-test", Duration::from_secs(10))
.await
.unwrap();

match status {
runtime::OrchestrationStatus::Completed { output, .. } => {
assert_eq!(output, "done");
}
other => panic!("Unexpected status: {other:?}"),
}

assert_eq!(
failing_provider.remaining_retryable_ack_failures(),
0,
"All injected retryable ack failures should have been exercised"
);

assert_eq!(
execution_count.load(Ordering::SeqCst),
1,
"Activity must not be re-executed when the ack failure was retryable"
);

rt.shutdown(None).await;
}
Loading