Skip to content

Commit 663ea64

Browse files
authored
Add patch activation callback (#1639)
1 parent fa1d937 commit 663ea64

8 files changed

Lines changed: 381 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ to include examples, links to docs, or any other relevant information.
2020

2121
### Added
2222

23+
- Added the experimental `Worker` `patch_activation_callback` option, allowing workers
24+
to decide whether a first non-replay `workflow.patched` call should activate a patch
25+
during rolling deployments.
26+
2327
### Changed
2428

2529
### Deprecated

temporalio/worker/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
WorkerDeploymentConfig,
5959
)
6060
from ._workflow_instance import (
61+
PatchActivationInput,
6162
UnsandboxedWorkflowRunner,
6263
WorkflowInstance,
6364
WorkflowInstanceDetails,
@@ -77,6 +78,7 @@
7778
"PollerBehavior",
7879
"PollerBehaviorSimpleMaximum",
7980
"PollerBehaviorAutoscaling",
81+
"PatchActivationInput",
8082
# Interceptor base classes
8183
"Interceptor",
8284
"ActivityInboundInterceptor",

temporalio/worker/_replayer.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,7 @@ def on_eviction_hook(
255255
workflow_failure_exception_types=self._config.get(
256256
"workflow_failure_exception_types", []
257257
),
258+
patch_activation_callback=None,
258259
debug_mode=self._config.get("debug_mode", False),
259260
metric_meter=runtime.metric_meter,
260261
on_eviction_hook=on_eviction_hook,

temporalio/worker/_worker.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,11 @@
4040
_DEFAULT_WORKFLOW_TASK_EXTERNAL_STORAGE_CONCURRENCY,
4141
_WorkflowWorker,
4242
)
43-
from ._workflow_instance import UnsandboxedWorkflowRunner, WorkflowRunner
43+
from ._workflow_instance import (
44+
PatchActivationInput,
45+
UnsandboxedWorkflowRunner,
46+
WorkflowRunner,
47+
)
4448
from .workflow_sandbox import SandboxedWorkflowRunner
4549

4650
logger = logging.getLogger(__name__)
@@ -135,6 +139,7 @@ def __init__(
135139
use_worker_versioning: bool = False,
136140
disable_safe_workflow_eviction: bool = False,
137141
deployment_config: WorkerDeploymentConfig | None = None,
142+
patch_activation_callback: Callable[[PatchActivationInput], bool] | None = None,
138143
workflow_task_poller_behavior: PollerBehavior = PollerBehaviorSimpleMaximum(
139144
maximum=5
140145
),
@@ -307,6 +312,12 @@ def __init__(
307312
deployment_config: Deployment config for the worker. Exclusive with ``build_id`` and
308313
``use_worker_versioning``.
309314
WARNING: This is an experimental feature and may change in the future.
315+
patch_activation_callback: Callback to decide whether the first non-replay
316+
call to :py:func:`workflow.patched<temporalio.workflow.patched>` for a
317+
patch ID should activate that patch. The callback receives a
318+
:py:class:`PatchActivationInput` and must return ``True`` to activate the
319+
patch or ``False`` to leave it inactive.
320+
WARNING: This is an experimental feature and may change in the future.
310321
workflow_task_poller_behavior: Specify the behavior of workflow task polling.
311322
Defaults to a 5-poller maximum.
312323
activity_task_poller_behavior: Specify the behavior of activity task polling.
@@ -368,6 +379,7 @@ def __init__(
368379
use_worker_versioning=use_worker_versioning,
369380
disable_safe_workflow_eviction=disable_safe_workflow_eviction,
370381
deployment_config=deployment_config,
382+
patch_activation_callback=patch_activation_callback,
371383
workflow_task_poller_behavior=workflow_task_poller_behavior,
372384
activity_task_poller_behavior=activity_task_poller_behavior,
373385
nexus_task_poller_behavior=nexus_task_poller_behavior,
@@ -528,6 +540,7 @@ def check_activity(activity: str):
528540
workflow_failure_exception_types=config[
529541
"workflow_failure_exception_types"
530542
], # type: ignore[reportTypedDictNotRequiredAccess]
543+
patch_activation_callback=config.get("patch_activation_callback"),
531544
debug_mode=config["debug_mode"], # type: ignore[reportTypedDictNotRequiredAccess]
532545
disable_eager_activity_execution=config[
533546
"disable_eager_activity_execution"
@@ -982,6 +995,7 @@ class WorkerConfig(TypedDict, total=False):
982995
use_worker_versioning: bool
983996
disable_safe_workflow_eviction: bool
984997
deployment_config: WorkerDeploymentConfig | None
998+
patch_activation_callback: Callable[[PatchActivationInput], bool] | None
985999
workflow_task_poller_behavior: PollerBehavior
9861000
activity_task_poller_behavior: PollerBehavior
9871001
nexus_task_poller_behavior: PollerBehavior

temporalio/worker/_workflow.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
WorkflowInterceptorClassInput,
4343
)
4444
from ._workflow_instance import (
45+
PatchActivationInput,
4546
WorkflowInstance,
4647
WorkflowInstanceDetails,
4748
WorkflowRunner,
@@ -78,6 +79,7 @@ def __init__(
7879
data_converter: temporalio.converter.DataConverter,
7980
interceptors: Sequence[Interceptor],
8081
workflow_failure_exception_types: Sequence[type[BaseException]],
82+
patch_activation_callback: Callable[[PatchActivationInput], bool] | None,
8183
debug_mode: bool,
8284
disable_eager_activity_execution: bool,
8385
metric_meter: temporalio.common.MetricMeter,
@@ -145,6 +147,7 @@ def __init__(
145147
)
146148

147149
self._workflow_failure_exception_types = workflow_failure_exception_types
150+
self._patch_activation_callback = patch_activation_callback
148151
self._running_workflows: dict[str, _RunningWorkflow] = {}
149152
self._disable_eager_activity_execution = disable_eager_activity_execution
150153
self._on_eviction_hook = on_eviction_hook
@@ -798,6 +801,7 @@ def _create_workflow_instance(
798801
extern_functions=self._extern_functions,
799802
disable_eager_activity_execution=self._disable_eager_activity_execution,
800803
worker_level_failure_exception_types=self._workflow_failure_exception_types,
804+
patch_activation_callback=self._patch_activation_callback,
801805
last_completion_result=init.last_completion_result,
802806
last_failure=last_failure,
803807
)

temporalio/worker/_workflow_instance.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,17 @@ def set_worker_level_failure_exception_types(
131131
pass
132132

133133

134+
@dataclass(frozen=True)
135+
class PatchActivationInput:
136+
"""Input for the worker patch activation callback."""
137+
138+
workflow_info: temporalio.workflow.Info
139+
"""Information about the workflow execution calling ``patched``."""
140+
141+
patch_id: str
142+
"""Patch ID passed to ``patched``."""
143+
144+
134145
@dataclass(frozen=True)
135146
class WorkflowInstanceDetails:
136147
"""Immutable details for creating a workflow instance."""
@@ -144,6 +155,7 @@ class WorkflowInstanceDetails:
144155
extern_functions: Mapping[str, Callable]
145156
disable_eager_activity_execution: bool
146157
worker_level_failure_exception_types: Sequence[type[BaseException]]
158+
patch_activation_callback: Callable[[PatchActivationInput], bool] | None
147159
last_completion_result: temporalio.api.common.v1.Payloads
148160
last_failure: Failure | None
149161

@@ -264,6 +276,7 @@ def __init__(self, det: WorkflowInstanceDetails) -> None:
264276
self._worker_level_failure_exception_types = (
265277
det.worker_level_failure_exception_types
266278
)
279+
self._patch_activation_callback = det.patch_activation_callback
267280
self._primary_task: asyncio.Task[None] | None = None
268281
self._time_ns = 0
269282
self._cancel_reason: str | None = None
@@ -1363,7 +1376,20 @@ def workflow_patch(self, id: str, *, deprecated: bool) -> bool:
13631376
if use_patch is not None:
13641377
return use_patch
13651378

1366-
use_patch = not self._is_replaying or id in self._patches_notified
1379+
# Replay and history markers already determine the branch, and deprecation must
1380+
# keep existing patch semantics, so only a genuinely new patch consults the
1381+
# callback.
1382+
if deprecated or self._is_replaying or id in self._patches_notified:
1383+
use_patch = not self._is_replaying or id in self._patches_notified
1384+
elif self._patch_activation_callback is not None:
1385+
with self._as_read_only(in_query_or_validator=False):
1386+
use_patch = self._patch_activation_callback(
1387+
PatchActivationInput(workflow_info=self._info, patch_id=id)
1388+
)
1389+
if type(use_patch) is not bool:
1390+
raise TypeError("Patch activation callback must return true or false")
1391+
else:
1392+
use_patch = True
13671393
self._patches_memoized[id] = use_patch
13681394
if use_patch:
13691395
command = self._add_command()
@@ -1873,6 +1899,7 @@ def workflow_random_seed(self) -> int:
18731899
def workflow_register_random_seed_callback(
18741900
self, callback: Callable[[int], None]
18751901
) -> None:
1902+
self._assert_not_read_only("register random seed callback")
18761903
self._seed_callbacks.append(callback)
18771904

18781905
#### Calls from outbound impl ####

temporalio/worker/workflow_sandbox/_runner.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ def prepare_workflow(self, defn: temporalio.workflow._Definition) -> None:
8989
extern_functions={},
9090
disable_eager_activity_execution=False,
9191
worker_level_failure_exception_types=self._worker_level_failure_exception_types,
92+
patch_activation_callback=None,
9293
last_completion_result=Payloads(),
9394
last_failure=Failure(),
9495
),

0 commit comments

Comments
 (0)