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
100 changes: 84 additions & 16 deletions crates/client/src/grpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use crate::{
Client, Connection, LONG_POLL_TIMEOUT, PayloadErrorLimits, RequestExt, SharedReplaceableClient,
TEMPORAL_NAMESPACE_HEADER_KEY, TemporalServiceClient,
metrics::namespace_kv,
request_extensions::AttemptSemaphore,
retry::make_future_retry,
worker::{ClientWorkerSet, Slot},
};
Expand Down Expand Up @@ -153,18 +154,30 @@ impl RawGrpcCaller for Connection {
self.inner.memo_warn_size,
)?;

let info = self
.inner
.retry_options
.get_call_info(call_name, Some(&req));
let info = self.inner.retry_options.get_call_info(call_name, &req);
req.extensions_mut().insert(info.call_type);
if info.call_type.is_long() {
req.set_default_timeout(LONG_POLL_TIMEOUT);
}

let maybe_sem = req.extensions().get::<AttemptSemaphore>();

let fact = || {
let req_clone = req_cloner(&req);
callfn(req_clone)
let call_fut = callfn(req_clone);
async {
if let Some(sem) = maybe_sem {
let permit = sem
.0
.acquire()
.await
.expect("AttemptSemaphore must not be closed");
// Leaking the permit. If there's a retry, the permit will be restored by
// TonicErrorHandler.
permit.forget();
}
call_fut.await
}
};

let res = make_future_retry(info, fact);
Expand Down Expand Up @@ -2015,14 +2028,25 @@ proxier! {
#[cfg(test)]
mod tests {
use super::*;
use crate::{ClientOptions, ConnectionOptions};
use std::collections::HashSet;
use crate::{
ClientOptions, ConnectionOptions,
callback_based::{CallbackBasedGrpcService, GrpcSuccessResponse},
};
use assert_matches::assert_matches;
use futures_util::task::noop_waker_ref;
use prost::Message;
use std::{
collections::HashSet,
sync::atomic::{AtomicBool, Ordering},
task::{Context, Poll},
};
use temporalio_common::{
protos::temporal::api::{
operatorservice::v1::DeleteNamespaceRequest, workflowservice::v1::ListNamespacesRequest,
},
worker::WorkerTaskTypes,
};
use tokio::sync::Semaphore;
use tonic::IntoRequest;
use url::Url;
use uuid::Uuid;
Expand Down Expand Up @@ -2412,21 +2436,17 @@ mod tests {
/// so that eager workflow start can reserve a slot and dispatch the WFT.
#[tokio::test]
async fn connection_eager_start_dispatches_wft() {
use crate::{
ConnectionOptions,
callback_based::{CallbackBasedGrpcService, GrpcSuccessResponse},
worker::{MockClientWorker, MockSlot},
};
use prost::Message;
use std::sync::atomic::{AtomicBool, Ordering};
use temporalio_common::protos::temporal::api::workflowservice::v1::PollWorkflowTaskQueueResponse;
use crate::worker::{MockClientWorker, MockSlot};

let dispatched = Arc::new(AtomicBool::new(false));
let dispatched_clone = dispatched.clone();

// Create a callback-based service that returns an eager_workflow_task in the response
let service_override = CallbackBasedGrpcService {
callback: Arc::new(|_req| {
callback: Arc::new(|req| {
assert_eq!(req.rpc, "StartWorkflowExecution");
let decoded = StartWorkflowExecutionRequest::decode(req.proto).unwrap();
assert!(decoded.request_eager_execution);
Box::pin(async {
let resp = StartWorkflowExecutionResponse {
run_id: "test-run-id".to_string(),
Expand Down Expand Up @@ -2517,4 +2537,52 @@ mod tests {
"Eager workflow task should have been dispatched to the worker"
);
}

#[tokio::test]
async fn call_blocks_on_attempt_semaphore() {
let can_proceed = Arc::new(AtomicBool::new(false));
let can_proceed_clone = can_proceed.clone();

// Create a callback-based service that returns an eager_workflow_task in the response
let service_override = CallbackBasedGrpcService {
callback: Arc::new(move |req| {
assert_eq!(req.rpc, "PollWorkflowTaskQueue");
PollWorkflowTaskQueueRequest::decode(req.proto).unwrap();
assert!(can_proceed_clone.load(Ordering::Acquire));
Box::pin(async {
Ok(GrpcSuccessResponse {
headers: Default::default(),
proto: PollWorkflowTaskQueueResponse::default().encode_to_vec(),
})
})
}),
};

let opts = ConnectionOptions::new(Url::parse("http://localhost:7233").unwrap())
.skip_get_system_info(true)
.service_override(service_override)
.dns_load_balancing(None)
.build();
let mut connection = crate::Connection::connect(opts).await.unwrap();

let sem = Arc::new(Semaphore::new(0));
let mut req = PollWorkflowTaskQueueRequest::default().into_request();
req.extensions_mut().insert(AttemptSemaphore(sem.clone()));

let mut req_fut = connection.poll_workflow_task_queue(req);

// make sure we are blocked
for _ in 0..10 {
assert_matches!(
req_fut
.as_mut()
.poll(&mut Context::from_waker(noop_waker_ref())),
Poll::Pending
);
}

can_proceed.store(true, Ordering::Release);
sem.add_permits(1);
req_fut.await.unwrap();
}
}
50 changes: 49 additions & 1 deletion crates/client/src/request_extensions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@
//! request handling logic.

use crate::RetryOptions;
use std::time::Duration;
use dyn_clone::DynClone;
use std::{
fmt::{Debug, Formatter},
sync::Arc,
time::Duration,
};
use tokio::sync::Semaphore;

/// A request extension that, when set, should make the retry behavior consider this call to be a
/// worker task long poll.
Expand All @@ -24,6 +30,48 @@ pub struct NoRetryOnMatching {
#[derive(Clone, Debug)]
pub struct RetryConfigForCall(pub RetryOptions);

/// A request extension that forces overriding the current retry policy of the [crate::Connection]
/// used in throttling (resource exhausted) scenarios.
#[derive(Clone, Debug)]
pub struct ThrottleRetryConfigForCall(pub RetryOptions);

/// A request extension that calls a callback after a request failed if it's going to be retried.
#[derive(Clone)]
pub struct RetryCallback(Box<dyn RetryCallbackTrait>);

impl RetryCallback {
/// Make a new instance from the provided closure.
pub fn new(callback: impl FnMut(&tonic::Status) + Clone + Send + Sync + 'static) -> Self {
Self(Box::new(callback))
}

/// Call the callback.
pub fn call(&mut self, status: &tonic::Status) {
self.0(status);
}
}

impl Debug for RetryCallback {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.write_str("RetryCallback")
}
}

/// Internal. Closure type for [`RetryCallback`].
pub(crate) trait RetryCallbackTrait:
FnMut(&tonic::Status) + DynClone + Send + Sync + 'static
{
}
impl<T> RetryCallbackTrait for T where T: FnMut(&tonic::Status) + Clone + Send + Sync + 'static {}
dyn_clone::clone_trait_object!(RetryCallbackTrait);

/// A request extension that obtains a permit from the provided semaphore before every attempt.
/// On a retryable error, the permit is released until next attempt. On the final response, whether
/// successful or an error, the permit is leaked (this can be used to implement cancellation logic
/// that only runs between attempts and not during a request and not after completion).
#[derive(Clone, Debug)]
pub struct AttemptSemaphore(pub Arc<Semaphore>);
Comment on lines +60 to +73

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can simplify this a fair amount. Right now it's easy to forget to use the semaphore properly and we could solve that by reversing the way it works. EX: If anyone forgets to pass in an attempt semaphore, that call can get interrupted immediately.

Rather than initializing it with a permit, we should init it with 0, then the permit gets added in TonicErrorHandler::wait_retry and every attempt after the first attempt acquires in the gRPC interceptor but wait to forget it until just before running.

Here's an AI sketch of the changes:

  1. crates/client/src/grpc.rs: only retries acquire a permit
let maybe_sem = req.extensions().get::<AttemptSemaphore>();
let mut is_first_attempt = true;

let fact = || {
    let req_clone = req_cloner(&req);
    let call_fut = callfn(req_clone);
    // The permit for a retry is the one TonicErrorHandler added when the previous attempt failed.
    let gate = (!std::mem::take(&mut is_first_attempt)).then_some(maybe_sem).flatten();
    async move {
        if let Some(sem) = gate {
            sem.0.acquire().await.expect("AttemptSemaphore must not be closed").forget();
        }
        call_fut.await
    }
};

make_future_retry takes an FnMut, so mutating the flag inside the closure is fine.

  1. crates/client/src/retry.rs: no change

wait_retry already does sem.add_permits(1) before returning WaitRetry. The ForwardError and success paths leave the count at 0, as they do now.

  1. crates/client/src/request_extensions.rs: update the doc
/// A request extension that exposes when a call is between retry attempts. The semaphore must start
/// with 0 permits. A permit is added after an attempt fails with a retryable error, and taken back
/// before the next attempt starts. Acquiring a permit from outside therefore succeeds only between
/// attempts, which is the only point at which the call can be abandoned without losing a response.
#[derive(Clone, Debug)]
pub struct AttemptSemaphore(pub Arc<Semaphore>);
  1. crates/sdk-core/src/pollers/poll_buffer.rs: start at 0, poll branch first
let sem = Arc::new(Semaphore::new(0));
(Some(sem.clone()), async move {
    // Only resolves between retry attempts, so no server response can be dropped.
    if let Ok(permit) = sem.acquire().await {
        permit.forget();
    }
}.left_future())
...
let r = tokio::select! {
    biased;
    r = pf(PollFnOptions { timeout_override, attempt_semaphore }) => r,
    _ = async { shutdown.cancelled().await; poll_interruptor.await } => return,
};

Putting the poll branch first also fixes another issue: a poll that has just completed wins the tie. That's safe in graceful mode, because the waiting interrupt branch is first in the semaphore's FIFO queue. When a retry adds a permit, it goes to the interrupt branch, not to the next attempt.

  1. Mocks and tests
  • Remove both "MUST lock the semaphore" notes in mocks.rs. Mocks that ignore the semaphore now behave like the old code: polls run to completion.
  • shutdown_blocks_until_shutdown_rpc_first_attempt_done: the mock no longer needs to touch the semaphore.
  • shutdown_proceeds_on_poll_retryable_error: simulate a retry with sem.add_permits(1) instead of try_acquire/drop.
  • Add one unit test in grpc.rs: the first attempt goes through with 0 permits, and the second waits until add_permits(1).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The other thing we can do I think is eliminate RetryCallback entirely, and re-use the semaphore to deal with the same thing:

// initiate_shutdown
let between_attempts = Arc::new(Semaphore::new(0));
let join_handle = tokio::spawn(async move {
    client.shutdown_worker(sticky_name, task_queue, task_queue_types, heartbeat,
                           Some(between_attempts_clone)).await ...
});
*guard = Some(ShutdownRpcContext { join_handle, between_attempts });

// shutdown
select! {
    res = &mut ctx.join_handle => handle_join(res),
    _ = ctx.between_attempts.acquire() => {
        warn!("ShutdownWorker RPC still failing after pollers stopped; abandoning it");
        ctx.join_handle.abort();
        #[cfg(debug_assertions)]
        handle_join(ctx.join_handle.await);
    }
}

It does mean we will now possibly wait for an in-flight ShutdownWorker rpc to finish, but, that's totally fine.


/// Per-call payload/memo size error limits, attached to a request's extensions by a caller that
/// wants error-level enforcement on this call.
#[doc(hidden)]
Expand Down
Loading
Loading