Skip to main content

temporalio_sdk/runtime/
worker_tuner.rs

1//! Worker concurrency tuning for SDK workers.
2
3use std::{fmt::Debug, sync::Arc, time::Duration};
4
5use temporalio_sdk_core::{
6    ResourceBasedSlotsOptions as CoreResourceBasedSlotsOptions,
7    ResourceBasedTunerConfig as CoreResourceBasedTunerConfig,
8    ResourceController as CoreResourceController, ResourceSlotOptions as CoreResourceSlotOptions,
9    SlotKind as CoreSlotKind, SlotSupplierOptions as CoreSlotSupplierOptions,
10    TunerHolderOptions as CoreTunerHolderOptions, WorkerTuner as CoreWorkerTuner,
11};
12
13const DEFAULT_FIXED_SIZE_SLOTS: usize = 100;
14
15/// A worker tuner configuration.
16#[derive(Clone, Debug)]
17#[non_exhaustive]
18pub enum WorkerTuner {
19    /// A tuner that creates a resource controller scoped to this worker.
20    ResourceBased(ResourceBasedTuner),
21    /// A resource-based tuner using a controller that may be shared by multiple workers.
22    ResourceBasedWithController(ResourceBasedTunerWithController),
23    /// A tuner composed from independently selected slot suppliers.
24    TunerHolder(TunerHolder),
25}
26
27impl WorkerTuner {
28    pub(crate) fn to_core(&self) -> Result<Arc<dyn CoreWorkerTuner + Send + Sync>, String> {
29        match self {
30            Self::ResourceBased(tuner) => tuner.to_tuner_holder().to_core(),
31            Self::ResourceBasedWithController(tuner) => tuner.to_tuner_holder().to_core(),
32            Self::TunerHolder(tuner) => tuner.to_core(),
33        }
34    }
35}
36
37impl Default for WorkerTuner {
38    fn default() -> Self {
39        TunerHolder::builder()
40            .workflow_task_slot_supplier(FixedSizeSlotSupplier::new(DEFAULT_FIXED_SIZE_SLOTS))
41            .activity_task_slot_supplier(FixedSizeSlotSupplier::new(DEFAULT_FIXED_SIZE_SLOTS))
42            .local_activity_task_slot_supplier(FixedSizeSlotSupplier::new(DEFAULT_FIXED_SIZE_SLOTS))
43            .nexus_task_slot_supplier(FixedSizeSlotSupplier::new(DEFAULT_FIXED_SIZE_SLOTS))
44            .build()
45            .into()
46    }
47}
48
49impl From<ResourceBasedTuner> for WorkerTuner {
50    fn from(value: ResourceBasedTuner) -> Self {
51        Self::ResourceBased(value)
52    }
53}
54
55impl From<ResourceBasedTunerWithController> for WorkerTuner {
56    fn from(value: ResourceBasedTunerWithController) -> Self {
57        Self::ResourceBasedWithController(value)
58    }
59}
60
61impl From<TunerHolder> for WorkerTuner {
62    fn from(value: TunerHolder) -> Self {
63        Self::TunerHolder(value)
64    }
65}
66
67/// A tuner composed from independently selected slot suppliers.
68#[derive(Clone, Debug, bon::Builder)]
69#[builder(state_mod(vis = "pub"))]
70#[non_exhaustive]
71pub struct TunerHolder {
72    /// Supplies workflow-task slots.
73    #[builder(into)]
74    pub workflow_task_slot_supplier: SlotSupplier,
75    /// Supplies activity-task slots.
76    #[builder(into)]
77    pub activity_task_slot_supplier: SlotSupplier,
78    /// Supplies local-activity slots.
79    #[builder(into)]
80    pub local_activity_task_slot_supplier: SlotSupplier,
81    /// Supplies Nexus-task slots.
82    #[builder(into)]
83    pub nexus_task_slot_supplier: SlotSupplier,
84}
85
86impl TunerHolder {
87    fn to_core(&self) -> Result<Arc<dyn CoreWorkerTuner + Send + Sync>, String> {
88        let configurations = [
89            self.workflow_task_slot_supplier.resource_configuration(),
90            self.activity_task_slot_supplier.resource_configuration(),
91            self.local_activity_task_slot_supplier
92                .resource_configuration(),
93            self.nexus_task_slot_supplier.resource_configuration(),
94        ];
95        let mut configurations = configurations.into_iter().flatten();
96        let resource_based_config = configurations.next();
97        if let Some(first) = resource_based_config
98            && configurations.any(|other| !first.is_compatible_with(other))
99        {
100            return Err(
101                "cannot construct worker tuner with multiple different resource-based tuner configurations"
102                    .to_owned(),
103            );
104        }
105
106        CoreTunerHolderOptions::builder()
107            .workflow_slot_options(
108                self.workflow_task_slot_supplier
109                    .to_core(ResourceKind::Workflow),
110            )
111            .activity_slot_options(
112                self.activity_task_slot_supplier
113                    .to_core(ResourceKind::Activity),
114            )
115            .local_activity_slot_options(
116                self.local_activity_task_slot_supplier
117                    .to_core(ResourceKind::Activity),
118            )
119            .nexus_slot_options(self.nexus_task_slot_supplier.to_core(ResourceKind::Nexus))
120            .maybe_resource_based_config(resource_based_config.map(ResourceBasedConfig::to_core))
121            .build()
122            .map_err(|error| error.to_string())?
123            .build_tuner_holder()
124            .map(|tuner| Arc::new(tuner) as Arc<dyn CoreWorkerTuner + Send + Sync>)
125            .map_err(|error| error.to_string())
126    }
127}
128
129/// A resource-based tuner that creates a controller scoped to its worker.
130#[derive(Clone, Debug, bon::Builder)]
131#[builder(state_mod(vis = "pub"))]
132#[non_exhaustive]
133pub struct ResourceBasedTuner {
134    /// Target memory and CPU usage.
135    pub tuner_options: ResourceBasedTunerOptions,
136    /// Workflow-task slot options, or `None` to use defaults.
137    pub workflow_task_slot_options: Option<ResourceBasedSlotOptions>,
138    /// Activity-task slot options, or `None` to use defaults.
139    pub activity_task_slot_options: Option<ResourceBasedSlotOptions>,
140    /// Local-activity slot options, or `None` to use defaults.
141    pub local_activity_task_slot_options: Option<ResourceBasedSlotOptions>,
142    /// Nexus-task slot options, or `None` to use defaults.
143    pub nexus_task_slot_options: Option<ResourceBasedSlotOptions>,
144}
145
146impl ResourceBasedTuner {
147    fn to_tuner_holder(&self) -> TunerHolder {
148        TunerHolder::builder()
149            .workflow_task_slot_supplier(ResourceBasedSlotsForType::new(
150                self.tuner_options,
151                self.workflow_task_slot_options.unwrap_or_default(),
152            ))
153            .activity_task_slot_supplier(ResourceBasedSlotsForType::new(
154                self.tuner_options,
155                self.activity_task_slot_options.unwrap_or_default(),
156            ))
157            .local_activity_task_slot_supplier(ResourceBasedSlotsForType::new(
158                self.tuner_options,
159                self.local_activity_task_slot_options.unwrap_or_default(),
160            ))
161            .nexus_task_slot_supplier(ResourceBasedSlotsForType::new(
162                self.tuner_options,
163                self.nexus_task_slot_options.unwrap_or_default(),
164            ))
165            .build()
166    }
167}
168
169/// A resource-based tuner governed by a controller shared across workers.
170#[derive(Clone, Debug, bon::Builder)]
171#[builder(state_mod(vis = "pub"))]
172#[non_exhaustive]
173pub struct ResourceBasedTunerWithController {
174    /// The shared resource controller.
175    pub controller: ResourceBasedController,
176    /// Workflow-task slot options, or `None` to use defaults.
177    pub workflow_task_slot_options: Option<ResourceBasedSlotOptions>,
178    /// Activity-task slot options, or `None` to use defaults.
179    pub activity_task_slot_options: Option<ResourceBasedSlotOptions>,
180    /// Local-activity slot options, or `None` to use defaults.
181    pub local_activity_task_slot_options: Option<ResourceBasedSlotOptions>,
182    /// Nexus-task slot options, or `None` to use defaults.
183    pub nexus_task_slot_options: Option<ResourceBasedSlotOptions>,
184}
185
186impl ResourceBasedTunerWithController {
187    fn to_tuner_holder(&self) -> TunerHolder {
188        TunerHolder::builder()
189            .workflow_task_slot_supplier(ResourceBasedSlotsForType::with_controller(
190                self.controller.clone(),
191                self.workflow_task_slot_options.unwrap_or_default(),
192            ))
193            .activity_task_slot_supplier(ResourceBasedSlotsForType::with_controller(
194                self.controller.clone(),
195                self.activity_task_slot_options.unwrap_or_default(),
196            ))
197            .local_activity_task_slot_supplier(ResourceBasedSlotsForType::with_controller(
198                self.controller.clone(),
199                self.local_activity_task_slot_options.unwrap_or_default(),
200            ))
201            .nexus_task_slot_supplier(ResourceBasedSlotsForType::with_controller(
202                self.controller.clone(),
203                self.nexus_task_slot_options.unwrap_or_default(),
204            ))
205            .build()
206    }
207}
208
209/// Target resource usage for resource-based tuning.
210#[derive(Clone, Copy, Debug, PartialEq, bon::Builder)]
211#[builder(state_mod(vis = "pub"))]
212#[non_exhaustive]
213pub struct ResourceBasedTunerOptions {
214    /// Target system memory usage as a fraction from zero to one.
215    pub target_memory_usage: f64,
216    /// Target system CPU usage as a fraction from zero to one.
217    pub target_cpu_usage: f64,
218}
219
220impl ResourceBasedTunerOptions {
221    fn to_core(self) -> CoreResourceBasedSlotsOptions {
222        CoreResourceBasedSlotsOptions::builder()
223            .target_mem_usage(self.target_memory_usage)
224            .target_cpu_usage(self.target_cpu_usage)
225            .build()
226    }
227}
228
229/// Coordinates resource-based slot allocation across multiple workers.
230#[derive(Clone, derive_more::Debug)]
231pub struct ResourceBasedController(#[debug(skip)] Arc<CoreResourceController>);
232
233impl ResourceBasedController {
234    /// Creates a controller using system-wide memory and CPU measurements.
235    pub fn new(options: ResourceBasedTunerOptions) -> Self {
236        Self(Arc::new(CoreResourceController::new(options.to_core())))
237    }
238}
239
240/// Per-task-type options for resource-based slots.
241#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, bon::Builder)]
242#[builder(state_mod(vis = "pub"))]
243#[non_exhaustive]
244pub struct ResourceBasedSlotOptions {
245    /// Slots issued without consulting resource usage, or `None` to use the task-type default.
246    pub minimum_slots: Option<usize>,
247    /// Maximum slots that may be issued, or `None` to use the task-type default.
248    pub maximum_slots: Option<usize>,
249    /// Minimum delay between slots above the minimum, or `None` to use the task-type default.
250    pub ramp_throttle: Option<Duration>,
251}
252
253impl ResourceBasedSlotOptions {
254    fn to_core(self, kind: ResourceKind) -> CoreResourceSlotOptions {
255        let defaults = kind.slot_defaults();
256        CoreResourceSlotOptions::new(
257            self.minimum_slots.unwrap_or(defaults.minimum_slots),
258            self.maximum_slots.unwrap_or(defaults.maximum_slots),
259            self.ramp_throttle.unwrap_or(defaults.ramp_throttle),
260        )
261    }
262}
263
264/// Resource-based slot settings for one task type.
265#[derive(Clone, Debug)]
266pub struct ResourceBasedSlotsForType {
267    configuration: ResourceBasedConfig,
268    /// Per-task-type slot settings.
269    pub slot_options: ResourceBasedSlotOptions,
270}
271
272impl ResourceBasedSlotsForType {
273    /// Creates settings that construct a resource controller with the worker.
274    pub fn new(
275        tuner_options: ResourceBasedTunerOptions,
276        slot_options: ResourceBasedSlotOptions,
277    ) -> Self {
278        Self {
279            configuration: ResourceBasedConfig::Options(tuner_options),
280            slot_options,
281        }
282    }
283
284    /// Creates settings governed by a shared resource controller.
285    pub fn with_controller(
286        controller: ResourceBasedController,
287        slot_options: ResourceBasedSlotOptions,
288    ) -> Self {
289        Self {
290            configuration: ResourceBasedConfig::Controller(controller),
291            slot_options,
292        }
293    }
294
295    /// Returns target options when the controller is scoped to the worker.
296    pub fn tuner_options(&self) -> Option<ResourceBasedTunerOptions> {
297        match self.configuration {
298            ResourceBasedConfig::Options(options) => Some(options),
299            ResourceBasedConfig::Controller(_) => None,
300        }
301    }
302
303    /// Returns the shared controller, when configured.
304    pub fn controller(&self) -> Option<&ResourceBasedController> {
305        match &self.configuration {
306            ResourceBasedConfig::Options(_) => None,
307            ResourceBasedConfig::Controller(controller) => Some(controller),
308        }
309    }
310}
311
312#[derive(Clone, Debug)]
313enum ResourceBasedConfig {
314    Options(ResourceBasedTunerOptions),
315    Controller(ResourceBasedController),
316}
317
318impl ResourceBasedConfig {
319    fn is_compatible_with(&self, other: &Self) -> bool {
320        match (self, other) {
321            (Self::Options(left), Self::Options(right)) => left == right,
322            (Self::Controller(left), Self::Controller(right)) => Arc::ptr_eq(&left.0, &right.0),
323            (Self::Options(_), Self::Controller(_)) | (Self::Controller(_), Self::Options(_)) => {
324                false
325            }
326        }
327    }
328
329    fn to_core(&self) -> CoreResourceBasedTunerConfig {
330        match self {
331            Self::Options(options) => CoreResourceBasedTunerConfig::Options(options.to_core()),
332            Self::Controller(controller) => {
333                CoreResourceBasedTunerConfig::Controller(controller.0.clone())
334            }
335        }
336    }
337}
338
339/// A fixed-size slot supplier.
340#[derive(Clone, Copy, Debug, Eq, PartialEq)]
341#[non_exhaustive]
342pub struct FixedSizeSlotSupplier {
343    /// Maximum number of slots that may be issued.
344    pub num_slots: usize,
345}
346
347impl FixedSizeSlotSupplier {
348    /// Creates a fixed-size supplier.
349    pub fn new(num_slots: usize) -> Self {
350        Self { num_slots }
351    }
352}
353
354/// A fixed-size or resource-based slot supplier.
355#[derive(Clone, Debug)]
356#[non_exhaustive]
357pub enum SlotSupplier {
358    /// A supplier with a fixed concurrency limit.
359    FixedSize(FixedSizeSlotSupplier),
360    /// A supplier governed by resource usage.
361    ResourceBased(ResourceBasedSlotsForType),
362}
363
364impl From<FixedSizeSlotSupplier> for SlotSupplier {
365    fn from(value: FixedSizeSlotSupplier) -> Self {
366        Self::FixedSize(value)
367    }
368}
369
370impl From<ResourceBasedSlotsForType> for SlotSupplier {
371    fn from(value: ResourceBasedSlotsForType) -> Self {
372        Self::ResourceBased(value)
373    }
374}
375
376impl SlotSupplier {
377    fn resource_configuration(&self) -> Option<&ResourceBasedConfig> {
378        match self {
379            Self::ResourceBased(options) => Some(&options.configuration),
380            Self::FixedSize(_) => None,
381        }
382    }
383
384    fn to_core<SK: CoreSlotKind>(&self, kind: ResourceKind) -> CoreSlotSupplierOptions<SK> {
385        match self {
386            Self::FixedSize(supplier) => CoreSlotSupplierOptions::FixedSize {
387                slots: supplier.num_slots,
388            },
389            Self::ResourceBased(options) => {
390                CoreSlotSupplierOptions::ResourceBased(options.slot_options.to_core(kind))
391            }
392        }
393    }
394}
395
396#[derive(Clone, Copy)]
397enum ResourceKind {
398    Workflow,
399    Activity,
400    Nexus,
401}
402
403impl ResourceKind {
404    fn slot_defaults(self) -> ResourceSlotDefaults {
405        match self {
406            Self::Workflow => ResourceSlotDefaults {
407                minimum_slots: 2,
408                maximum_slots: 1_000,
409                ramp_throttle: Duration::from_millis(10),
410            },
411            Self::Activity | Self::Nexus => ResourceSlotDefaults {
412                minimum_slots: 1,
413                maximum_slots: 2_000,
414                ramp_throttle: Duration::from_millis(50),
415            },
416        }
417    }
418}
419
420struct ResourceSlotDefaults {
421    minimum_slots: usize,
422    maximum_slots: usize,
423    ramp_throttle: Duration,
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429
430    #[test]
431    fn composite_tuner_rejects_different_resource_options() {
432        let first_options = ResourceBasedTunerOptions::builder()
433            .target_memory_usage(0.5)
434            .target_cpu_usage(0.5)
435            .build();
436        let second_options = ResourceBasedTunerOptions::builder()
437            .target_memory_usage(0.6)
438            .target_cpu_usage(0.5)
439            .build();
440        let result = WorkerTuner::from(
441            TunerHolder::builder()
442                .workflow_task_slot_supplier(ResourceBasedSlotsForType::new(
443                    first_options,
444                    Default::default(),
445                ))
446                .activity_task_slot_supplier(ResourceBasedSlotsForType::new(
447                    second_options,
448                    Default::default(),
449                ))
450                .local_activity_task_slot_supplier(FixedSizeSlotSupplier::new(1))
451                .nexus_task_slot_supplier(FixedSizeSlotSupplier::new(1))
452                .build(),
453        )
454        .to_core();
455        assert!(
456            result
457                .err()
458                .is_some_and(|error| error.contains("different resource-based tuner"))
459        );
460    }
461}