1use super::*;
2
3const MAX_ATTEMPTS: u64 = 100;
4const MAX_HEARTBEATS: usize = 1000;
5const LEASE_RENEWAL_INTERVAL: Duration = Duration::from_secs(1);
6
7#[derive(Clone, Debug, Default, PartialEq, Eq)]
12pub struct LocalActivityOptions {
13 pub retry_policy: Option<ActivityRetryPolicy>,
14 pub start_to_close_timeout: Option<Duration>,
15 pub schedule_to_close_timeout: Option<Duration>,
16 pub heartbeat_timeout: Option<Duration>,
17}
18
19impl LocalActivityOptions {
20 pub fn new() -> Self {
21 Self::default()
22 }
23 pub fn retry_policy(mut self, policy: ActivityRetryPolicy) -> Self {
24 self.retry_policy = Some(policy);
25 self
26 }
27 pub fn start_to_close_timeout(mut self, timeout: Duration) -> Self {
28 self.start_to_close_timeout = Some(timeout);
29 self
30 }
31 pub fn schedule_to_close_timeout(mut self, timeout: Duration) -> Self {
32 self.schedule_to_close_timeout = Some(timeout);
33 self
34 }
35 pub fn heartbeat_timeout(mut self, timeout: Duration) -> Self {
36 self.heartbeat_timeout = Some(timeout);
37 self
38 }
39 fn into_activity_options(self) -> ActivityOptions {
40 ActivityOptions {
41 retry_policy: self.retry_policy,
42 start_to_close_timeout: self.start_to_close_timeout,
43 schedule_to_close_timeout: self.schedule_to_close_timeout,
44 heartbeat_timeout: self.heartbeat_timeout,
45 ..ActivityOptions::default()
46 }
47 }
48}
49
50pub(super) fn validate(options: &ValidatedActivityOptions) -> Result<()> {
51 let max_attempts = options
52 .retry_policy
53 .as_ref()
54 .and_then(|policy| policy["max_attempts"].as_u64())
55 .unwrap_or(1);
56 if max_attempts > MAX_ATTEMPTS {
57 return Err(Error::InvalidActivityOptions(ActivityOptionsError::new(
58 ActivityOptionsErrorKind::InvalidMaxAttempts,
59 Some("max_attempts"),
60 "local activities support at most 100 attempts",
61 )));
62 }
63 for seconds in [
64 options.start_to_close_timeout,
65 options.schedule_to_close_timeout,
66 options.heartbeat_timeout,
67 ]
68 .into_iter()
69 .flatten()
70 .chain(options.retry_policy.iter().flat_map(|p| {
71 p["backoff_seconds"]
72 .as_array()
73 .into_iter()
74 .flatten()
75 .filter_map(Value::as_u64)
76 })) {
77 if Instant::now()
78 .checked_add(Duration::from_secs(seconds))
79 .is_none()
80 {
81 return Err(Error::InvalidActivityOptions(ActivityOptionsError::new(
82 ActivityOptionsErrorKind::TimeoutOverflow,
83 Some("local_activity"),
84 "local timing exceeds the monotonic clock range",
85 )));
86 }
87 }
88 Ok(())
89}
90
91impl WorkflowContext {
92 pub fn local_activity<T: Serialize>(
98 &self,
99 activity_type: impl Into<String>,
100 args: T,
101 ) -> ActivityCall {
102 self.local_activity_with_options(activity_type, LocalActivityOptions::new(), args)
103 }
104
105 pub fn local_activity_with_options<T: Serialize>(
106 &self,
107 activity_type: impl Into<String>,
108 options: LocalActivityOptions,
109 args: T,
110 ) -> ActivityCall {
111 let mut call =
112 self.activity_with_options(activity_type, options.into_activity_options(), args);
113 call.local = true;
114 call
115 }
116
117 pub async fn local_activity_avro_value<T: Serialize>(
119 &self,
120 activity_type: impl Into<String>,
121 args: T,
122 ) -> Result<AvroValue> {
123 self.local_activity_avro_value_with_options(
124 activity_type,
125 LocalActivityOptions::new(),
126 args,
127 )
128 .await
129 }
130
131 pub async fn local_activity_avro_value_with_options<T: Serialize>(
132 &self,
133 activity_type: impl Into<String>,
134 options: LocalActivityOptions,
135 args: T,
136 ) -> Result<AvroValue> {
137 let mut call = self.local_activity_with_options(activity_type, options, args);
138 std::future::poll_fn(|cx| Pin::new(&mut call).poll_avro_value(cx)).await
139 }
140
141 pub async fn local_activity_typed<I: Serialize, O: DeserializeOwned>(
142 &self,
143 activity_type: impl Into<String>,
144 args: I,
145 ) -> Result<O> {
146 self.local_activity_typed_with_options(activity_type, LocalActivityOptions::new(), args)
147 .await
148 }
149
150 pub async fn local_activity_typed_with_options<I: Serialize, O: DeserializeOwned>(
151 &self,
152 activity_type: impl Into<String>,
153 options: LocalActivityOptions,
154 args: I,
155 ) -> Result<O> {
156 let activity_type = activity_type.into();
157 let result = self
158 .local_activity_avro_value_with_options(activity_type.clone(), options, args)
159 .await?;
160 decode_handler_result(result, HandlerKind::Activity, &activity_type)
161 }
162}
163
164#[derive(Debug)]
165pub(super) struct Request {
166 pub command_index: usize,
167 pub options: ValidatedActivityOptions,
168 pub arguments: AvroValue,
169 pub result: Arc<Mutex<Option<ActivityOutcome>>>,
170}
171
172#[derive(Debug)]
173pub(super) struct Heartbeats {
174 active: bool,
175 started: Instant,
176 last: Instant,
177 reports: Vec<Value>,
178 capacity: usize,
179 overflowed: bool,
180}
181
182impl Heartbeats {
183 pub fn record<T: Serialize>(&mut self, details: T) -> Result<ActivityHeartbeatResponse> {
184 if !self.active {
185 return Ok(heartbeat_response(false));
186 }
187 if self.reports.len() >= self.capacity {
188 self.overflowed = true;
189 return Err(Error::WorkerLoop(
190 "local_activity_heartbeat_limit_exceeded: at most 1000 reports per local command"
191 .into(),
192 ));
193 }
194 let details = encode_typed_envelope(&AvroValue::from_serialize(&details)?, DEFAULT_CODEC)?;
195 let now = Instant::now();
196 self.reports.push(
197 json!({"elapsed_ms": millis(now.duration_since(self.started)), "details": details}),
198 );
199 self.last = now;
200 Ok(heartbeat_response(true))
201 }
202}
203
204fn heartbeat_response(active: bool) -> ActivityHeartbeatResponse {
205 ActivityHeartbeatResponse {
206 cancel_requested: false,
207 heartbeat_recorded: active,
208 can_continue: Some(active),
209 reason: (!active).then(|| "local_activity_attempt_closed".into()),
210 run_closed_reason: None,
211 run_closed_at: None,
212 lease_expires_at: None,
213 last_heartbeat_at: None,
214 }
215}
216
217struct CloseAttempt(Arc<Mutex<Heartbeats>>);
218impl Drop for CloseAttempt {
219 fn drop(&mut self) {
220 if let Ok(mut heartbeats) = self.0.lock() {
221 heartbeats.active = false;
222 }
223 }
224}
225
226fn millis(duration: Duration) -> u64 {
227 u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
228}
229
230impl Worker {
231 pub(super) async fn execute_workflow_with_local_activities(
232 &self,
233 task: WorkflowTask,
234 ) -> Result<Option<WorkflowTaskDecision>> {
235 let (task, context, mut future) = match self.prepare_workflow_task_execution(task, None)? {
236 PreparedWorkflowTask::Decision(decision) => return Ok(Some(decision)),
237 PreparedWorkflowTask::Execution {
238 task,
239 context,
240 future,
241 } => (task, context, future),
242 };
243 loop {
244 let outcome = Self::poll_workflow_future(&context, &mut future)?;
245 let requests = {
246 let mut state = context
247 .state
248 .lock()
249 .map_err(|_| Error::WorkflowStatePoisoned)?;
250 std::mem::take(&mut state.local_activity_requests)
251 };
252 if requests.is_empty() {
253 return self
254 .finish_workflow_task_execution(&task, &context, outcome)
255 .map(Some);
256 }
257 if !self.client.local_activities_enabled {
258 return Err(Error::WorkerLoop("local_activities_not_enabled: opt in with Worker::local_activities(true) before executing inline local work".into()));
259 }
260 if !outcome.is_pending() {
263 return Err(Error::WorkflowYieldedWithoutCommand);
264 }
265 for request in requests {
266 let Some((wire, result)) = self
267 .execute_local_activity(&task, &context, &request)
268 .await?
269 else {
270 return Ok(None);
273 };
274 let mut state = context
275 .state
276 .lock()
277 .map_err(|_| Error::WorkflowStatePoisoned)?;
278 state.commands[request.command_index] = wire;
279 *request
280 .result
281 .lock()
282 .map_err(|_| Error::WorkflowStatePoisoned)? = Some(result);
283 }
284 }
285 }
286
287 async fn local_claim_is_active(&self, task: &WorkflowTask) -> bool {
288 tokio::select! {
289 biased;
290 _ = self.local_worker_stopped() => false,
291 receipt = self.client.heartbeat_workflow_task_with_protocol(task, None, WORKER_PROTOCOL_VERSION) => {
292 receipt.is_ok_and(|receipt| receipt.cancellation_request.is_none())
293 }
294 }
295 }
296
297 async fn local_worker_stopped(&self) {
298 match &self.client.worker_storage_admission {
299 Some(admission) => wait_for_worker_stop(&admission.stop).await,
300 None => std::future::pending::<()>().await,
301 }
302 }
303
304 async fn externalize_local_command(
305 &self,
306 task: &WorkflowTask,
307 context: &WorkflowContext,
308 index: usize,
309 wire: &mut Value,
310 ) -> Result<()> {
311 let mut commands = context
312 .state
313 .lock()
314 .map_err(|_| Error::WorkflowStatePoisoned)?
315 .commands
316 .clone();
317 commands[index] = wire.clone();
318 let mut body = json!({"lease_owner": task.lease_owner, "workflow_task_attempt": task.workflow_task_attempt,
319 "commands": commands});
320 let path = format!("/worker/workflow-tasks/{}/complete", task.task_id);
321 self.client
322 .externalize_runtime_payloads(
323 &mut body,
324 &path,
325 RequestProtocol::Worker(WORKER_PROTOCOL_VERSION),
326 )
327 .await?;
328 *wire = body["commands"][index].take();
329 Ok(())
330 }
331
332 async fn execute_local_activity(
333 &self,
334 task: &WorkflowTask,
335 context: &WorkflowContext,
336 request: &Request,
337 ) -> Result<Option<(Value, ActivityOutcome)>> {
338 let mut wire = context
339 .state
340 .lock()
341 .map_err(|_| Error::WorkflowStatePoisoned)?
342 .commands[request.command_index]
343 .clone();
344 if self
346 .externalize_local_command(task, context, request.command_index, &mut wire)
347 .await
348 .is_err()
349 {
350 return Ok(None);
351 }
352 let activity_type = wire["activity_type"]
353 .as_str()
354 .unwrap_or_default()
355 .to_owned();
356 let policy = request.options.retry_policy.as_ref();
357 let max_attempts = policy
358 .and_then(|policy| policy["max_attempts"].as_u64())
359 .unwrap_or(1);
360 let started = Instant::now();
361 let total_deadline = request
362 .options
363 .schedule_to_close_timeout
364 .map(|seconds| started + Duration::from_secs(seconds));
365 let mut attempts = Vec::new();
366 let mut heartbeat_count = 0;
367 for number in 1..=max_attempts {
368 if !self.local_claim_is_active(task).await {
369 return Ok(None);
370 }
371 let attempt_started = Instant::now();
372 let reports = Arc::new(Mutex::new(Heartbeats {
373 active: true,
374 started: attempt_started,
375 last: attempt_started,
376 reports: Vec::new(),
377 capacity: MAX_HEARTBEATS - heartbeat_count,
378 overflowed: false,
379 }));
380 let close = CloseAttempt(reports.clone());
381 let attempt_id = format!(
382 "{:x}",
383 Sha256::digest(
384 format!(
385 "{}\0{}\0{}\0{}",
386 task.task_id, task.workflow_task_attempt, request.command_index, number
387 )
388 .as_bytes()
389 )
390 );
391 let ctx = ActivityContext {
392 client: self.client.clone(),
393 task_id: task.task_id.clone(),
394 activity_attempt_id: attempt_id.clone(),
395 lease_owner: task.lease_owner.clone().unwrap_or_default(),
396 activity_type: activity_type.clone(),
397 attempt_number: number,
398 task_queue: self.task_queue.clone(),
399 worker_id: self.worker_id.clone(),
400 claim_guard: None,
401 local_heartbeats: Some(reports.clone()),
402 worker_session: None,
403 };
404 let callback = self
405 .activities
406 .get(&activity_type)
407 .filter(|_| !total_deadline.is_some_and(|deadline| Instant::now() >= deadline));
408 let mut future: ActivityFuture = match callback {
409 Some(callback) => callback(ctx, request.arguments.clone()),
410 None if total_deadline.is_some_and(|deadline| Instant::now() >= deadline) => {
411 Box::pin(std::future::pending())
412 }
413 None => {
414 let unknown = activity_type.clone();
415 Box::pin(async move { Err(Error::ActivityNotRegistered(unknown)) })
416 }
417 };
418 let attempt_deadline = request
419 .options
420 .start_to_close_timeout
421 .map(|seconds| attempt_started + Duration::from_secs(seconds));
422 let outcome = loop {
423 let heartbeat_deadline = request
424 .options
425 .heartbeat_timeout
426 .map(|seconds| {
427 reports
428 .lock()
429 .map(|reports| reports.last + Duration::from_secs(seconds))
430 })
431 .transpose()
432 .map_err(|_| Error::WorkflowStatePoisoned)?;
433 let next_timeout = [
434 (total_deadline, "schedule_to_close"),
435 (attempt_deadline, "start_to_close"),
436 (heartbeat_deadline, "heartbeat"),
437 ]
438 .into_iter()
439 .filter_map(|(deadline, kind)| deadline.map(|d| (d, kind)))
440 .min_by_key(|(deadline, _)| *deadline);
441 let timeout_kind = next_timeout
442 .map(|(_, kind)| kind)
443 .unwrap_or("start_to_close");
444 let timeout = async {
445 match next_timeout {
446 Some((deadline, _)) => tokio::time::sleep_until(deadline.into()).await,
447 None => std::future::pending::<()>().await,
448 }
449 };
450 tokio::pin!(timeout);
451 tokio::select! {
452 biased;
453 _ = self.local_worker_stopped() => return Ok(None),
454 _ = &mut timeout => {
455 if timeout_kind == "heartbeat" && heartbeat_extended(&reports, request.options.heartbeat_timeout)? { continue; }
456 break Err((format!("local activity exceeded {timeout_kind} timeout"), "LocalActivityTimeout".to_owned(), false, Some(timeout_kind)));
457 },
458 result = &mut future => break result.map_err(local_error),
459 _ = tokio::time::sleep(LEASE_RENEWAL_INTERVAL) => {
460 tokio::select! {
461 biased;
462 _ = &mut timeout => {
463 if timeout_kind == "heartbeat" && heartbeat_extended(&reports, request.options.heartbeat_timeout)? { continue; }
464 break Err((format!("local activity exceeded {timeout_kind} timeout"), "LocalActivityTimeout".to_owned(), false, Some(timeout_kind)));
465 },
466 active = self.local_claim_is_active(task) => { if !active { return Ok(None); } }
467 }
468 }
469 }
470 };
471 drop(future);
472 drop(close);
473 let (heartbeats, overflowed) = {
474 let mut reports = reports.lock().map_err(|_| Error::WorkflowStatePoisoned)?;
475 (std::mem::take(&mut reports.reports), reports.overflowed)
476 };
477 heartbeat_count += heartbeats.len();
478 let outcome = if overflowed {
479 Err((
480 "local activity exceeded 1000 heartbeat reports".into(),
481 "LocalActivityHeartbeatLimit".into(),
482 true,
483 None,
484 ))
485 } else {
486 outcome
487 };
488 let mut report = json!({"attempt_number": number, "attempt_id": attempt_id,
489 "duration_ms": millis(attempt_started.elapsed()), "heartbeats": heartbeats});
490 let (result, failure) = match outcome {
491 Ok(result) => match encode_typed_envelope(&result, &task.payload_codec) {
492 Ok(envelope) => {
493 wire["result"] = envelope;
494 (Some(result), None)
495 }
496 Err(error) => (
497 None,
498 Some((
499 error.to_string(),
500 "LocalActivityResultCodecError".into(),
501 true,
502 None,
503 )),
504 ),
505 },
506 Err(failure) => (None, Some(failure)),
507 };
508 if let Some((message, exception_type, mut non_retryable, timeout_kind)) = failure {
509 non_retryable |= policy
510 .and_then(|policy| policy["non_retryable_error_types"].as_array())
511 .is_some_and(|types| {
512 types
513 .iter()
514 .any(|t| t.as_str() == Some(exception_type.as_str()))
515 });
516 let status = if timeout_kind.is_some() {
517 "timed_out"
518 } else {
519 "failed"
520 };
521 report["outcome"] = json!(status);
522 report["message"] = json!(message);
523 report["exception_type"] = json!(exception_type);
524 report["non_retryable"] = json!(non_retryable);
525 if let Some(kind) = timeout_kind {
526 report["timeout_kind"] = json!(kind);
527 }
528 let retry = number < max_attempts
529 && !non_retryable
530 && timeout_kind != Some("schedule_to_close");
531 if retry {
532 let backoff = policy
533 .and_then(|p| p["backoff_seconds"].get((number - 1) as usize))
534 .and_then(Value::as_u64)
535 .unwrap_or(0);
536 report["retry_reason"] = json!(if timeout_kind.is_some() {
537 "timeout"
538 } else {
539 "failure"
540 });
541 report["backoff_seconds"] = json!(backoff);
542 attempts.push(report);
543 let wake = Instant::now() + Duration::from_secs(backoff);
544 while Instant::now() < wake {
545 if total_deadline.is_some_and(|deadline| Instant::now() >= deadline) {
548 break;
549 }
550 if !self.local_claim_is_active(task).await {
551 return Ok(None);
552 }
553 let deadline = total_deadline.map_or(wake, |d| d.min(wake));
554 tokio::time::sleep(
555 deadline
556 .saturating_duration_since(Instant::now())
557 .min(LEASE_RENEWAL_INTERVAL),
558 )
559 .await;
560 }
561 continue;
562 }
563 attempts.push(report);
564 for field in [
565 "outcome",
566 "message",
567 "exception_type",
568 "non_retryable",
569 "timeout_kind",
570 ] {
571 if let Some(value) = attempts.last().unwrap().get(field) {
572 wire[field] = value.clone();
573 }
574 }
575 wire["attempts"] = json!(attempts);
576 let mut payload = wire.clone();
579 payload["attempt_number"] = json!(number);
580 payload["failure_category"] = json!(if timeout_kind.is_some() {
581 "timeout"
582 } else {
583 "application"
584 });
585 if timeout_kind.is_some() {
586 payload["exception_class"] = payload["exception_type"].take();
587 payload.as_object_mut().unwrap().remove("exception_type");
588 payload.as_object_mut().unwrap().remove("non_retryable");
589 }
590 let event = HistoryEvent {
591 event_type: if timeout_kind.is_some() {
592 "ActivityTimedOut"
593 } else {
594 "ActivityFailed"
595 }
596 .into(),
597 payload,
598 raw: HashMap::new(),
599 };
600 let result =
601 activity_outcome(&event, &task.payload_codec, Some(activity_type.clone()))?;
602 if self
603 .externalize_local_command(task, context, request.command_index, &mut wire)
604 .await
605 .is_err()
606 || !self.local_claim_is_active(task).await
607 {
608 return Ok(None);
609 }
610 return Ok(Some((wire, result)));
611 }
612 report["outcome"] = json!("completed");
613 attempts.push(report);
614 wire["outcome"] = json!("completed");
615 wire["attempts"] = json!(attempts);
616 if self
617 .externalize_local_command(task, context, request.command_index, &mut wire)
618 .await
619 .is_err()
620 || !self.local_claim_is_active(task).await
621 {
622 return Ok(None);
623 }
624 return Ok(Some((wire, Ok(result.unwrap()))));
625 }
626 unreachable!("validated local retry budget includes at least one attempt")
627 }
628}
629
630fn local_error(error: Error) -> (String, String, bool, Option<&'static str>) {
631 let (kind, non_retryable) = match &error {
632 Error::ActivityFailed(failure) => (
633 failure
634 .exception_type
635 .clone()
636 .unwrap_or_else(|| "RustActivityError".into()),
637 failure.non_retryable,
638 ),
639 Error::ActivityNotRegistered(_) => ("ActivityNotRegistered".into(), true),
640 Error::Codec(_) | Error::HandlerType { .. } => ("ActivityCodecError".into(), true),
641 _ => ("RustActivityError".into(), false),
642 };
643 if kind.len() > 255 {
644 return (
645 "local activity exception type exceeded 255 bytes".into(),
646 "LocalActivityFailureMetadataError".into(),
647 true,
648 None,
649 );
650 }
651 (error.to_string(), kind, non_retryable, None)
652}
653
654fn heartbeat_extended(reports: &Arc<Mutex<Heartbeats>>, timeout: Option<u64>) -> Result<bool> {
655 let reports = reports.lock().map_err(|_| Error::WorkflowStatePoisoned)?;
656 Ok(timeout.is_some_and(|seconds| reports.last + Duration::from_secs(seconds) > Instant::now()))
657}