From 23ddc5221753dbdf12f56fdce33684854d8f87ed Mon Sep 17 00:00:00 2001 From: Albert Callarisa Date: Fri, 17 Jul 2026 07:59:29 +0200 Subject: [PATCH] Normalize timezone-aware create_timer fire_at to naive UTC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A timezone-aware fire_at is now converted to naive UTC in create_timer_internal before the CreateTimerAction is built. The wire value was previously correct only implicitly — protobuf's Timestamp.FromDatetime normalizes aware datetimes — so this makes the naive-UTC invariant explicit in SDK code, keeping fire_at safe to compare against current_utc_datetime (always naive UTC) if such comparisons are ever added. The isinstance guard is needed because fire_at may also be the sentinel Timestamp used for indefinite external-event waits. Adds executor-level regression tests: an aware fire_at schedules at the same absolute instant as its naive-UTC equivalent, and replays cleanly against history recorded in naive UTC. Signed-off-by: Albert Callarisa --- dapr/ext/workflow/_durabletask/worker.py | 8 ++- .../test_orchestration_executor.py | 60 +++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/dapr/ext/workflow/_durabletask/worker.py b/dapr/ext/workflow/_durabletask/worker.py index bdc9ff486..f7d0c04cc 100644 --- a/dapr/ext/workflow/_durabletask/worker.py +++ b/dapr/ext/workflow/_durabletask/worker.py @@ -18,7 +18,7 @@ import threading import warnings from concurrent.futures import ThreadPoolExecutor -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from threading import Event, Thread from types import GeneratorType from typing import Any, Generator, Iterator, Optional, Sequence, TypeVar, Union @@ -1399,6 +1399,12 @@ def create_timer_internal( id = self.next_sequence_number() if isinstance(fire_at, timedelta): fire_at = self.current_utc_datetime + fire_at + # Normalize timezone-aware datetimes to naive UTC so they can be safely + # compared with current_utc_datetime (which is always naive UTC). A + # tzinfo whose utcoffset() is None is still naive and must be left as-is + # (calling astimezone() on it would raise ValueError). + if isinstance(fire_at, datetime) and fire_at.utcoffset() is not None: + fire_at = fire_at.astimezone(timezone.utc).replace(tzinfo=None) action = ph.new_create_timer_action(id, fire_at, origin=origin) self._pending_actions[id] = action diff --git a/tests/ext/workflow/durabletask/test_orchestration_executor.py b/tests/ext/workflow/durabletask/test_orchestration_executor.py index fa9d570f4..fab9aa7cf 100644 --- a/tests/ext/workflow/durabletask/test_orchestration_executor.py +++ b/tests/ext/workflow/durabletask/test_orchestration_executor.py @@ -155,6 +155,66 @@ def delay_orchestrator(ctx: task.OrchestrationContext, _): assert complete_action.result.value == '"done"' # results are JSON-encoded +def test_create_timer_with_timezone_aware_datetime(): + """A timezone-aware fire_at must schedule the timer at the same absolute + instant as its naive-UTC equivalent.""" + + fire_at_aware = datetime(2030, 6, 1, 9, 0, 0, tzinfo=timezone(timedelta(hours=9))) + fire_at_utc = datetime(2030, 6, 1, 0, 0, 0) + + def delay_orchestrator(ctx: task.OrchestrationContext, _): + yield ctx.create_timer(fire_at_aware) + return 'done' + + registry = worker._Registry() + name = registry.add_orchestrator(delay_orchestrator) + + start_time = datetime(2030, 1, 1, 12, 0, 0) + new_events = [ + helpers.new_workflow_started_event(start_time), + helpers.new_execution_started_event(name, TEST_INSTANCE_ID, encoded_input=None), + ] + executor = worker._OrchestrationExecutor(registry, TEST_LOGGER) + result = executor.execute(TEST_INSTANCE_ID, [], new_events) + actions = result.actions + + assert actions is not None + assert len(actions) == 1 + assert actions[0].HasField('createTimer') + assert actions[0].createTimer.fireAt.ToDatetime() == fire_at_utc + + +def test_timer_fired_completion_with_timezone_aware_datetime(): + """A timer created from a timezone-aware datetime must replay cleanly + against history recorded in naive UTC (as the sidecar stores it).""" + + fire_at_aware = datetime(2030, 6, 1, 9, 0, 0, tzinfo=timezone(timedelta(hours=9))) + fire_at_utc = datetime(2030, 6, 1, 0, 0, 0) + + def delay_orchestrator(ctx: task.OrchestrationContext, _): + yield ctx.create_timer(fire_at_aware) + return 'done' + + registry = worker._Registry() + name = registry.add_orchestrator(delay_orchestrator) + + start_time = datetime(2030, 1, 1, 12, 0, 0) + old_events = [ + helpers.new_workflow_started_event(start_time), + helpers.new_execution_started_event(name, TEST_INSTANCE_ID, encoded_input=None), + helpers.new_timer_created_event(1, fire_at_utc), + ] + new_events = [helpers.new_timer_fired_event(1, fire_at_utc)] + + executor = worker._OrchestrationExecutor(registry, TEST_LOGGER) + result = executor.execute(TEST_INSTANCE_ID, old_events, new_events) + actions = result.actions + + complete_action = get_and_validate_single_complete_workflow_action(actions) + assert complete_action.workflowStatus == pb.ORCHESTRATION_STATUS_COMPLETED + assert complete_action.result.value == '"done"' # results are JSON-encoded + + def test_schedule_activity_actions(): """Test the actions output for the call_activity orchestrator method"""