Skip to content

Commit 44ce473

Browse files
timl3136claude
andcommitted
feat: Add first_run_at support to StartWorkflowOptions
Add explicit first run timestamp for cron workflows: - Add first_run_at datetime field to StartWorkflowOptions - Add warning for timezone-naive datetimes (treated as UTC) - Convert datetime to protobuf Timestamp in request building - Add comprehensive tests for aware/naive datetime handling Co-Authored-By: Claude (claude-opus-4-5) <noreply@anthropic.com> Signed-off-by: Tim Li <ltim@uber.com>
1 parent 6cff798 commit 44ce473

File tree

2 files changed

+91
-2
lines changed

2 files changed

+91
-2
lines changed

cadence/client.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import os
22
import socket
33
import uuid
4-
from datetime import timedelta
4+
import warnings
5+
from datetime import datetime, timedelta
56
from typing import TypedDict, Unpack, Any, cast, Union
67

78
from grpc import ChannelCredentials, Compression
89
from google.protobuf.duration_pb2 import Duration
10+
from google.protobuf.timestamp_pb2 import Timestamp
911

1012
from cadence._internal.rpc.error import CadenceErrorInterceptor
1113
from cadence._internal.rpc.retry import RetryInterceptor
@@ -36,6 +38,7 @@ class StartWorkflowOptions(TypedDict, total=False):
3638
workflow_id: str
3739
task_start_to_close_timeout: timedelta
3840
cron_schedule: str
41+
first_run_at: datetime
3942

4043

4144
def _validate_and_apply_defaults(options: StartWorkflowOptions) -> StartWorkflowOptions:
@@ -56,6 +59,15 @@ def _validate_and_apply_defaults(options: StartWorkflowOptions) -> StartWorkflow
5659
elif task_timeout <= timedelta(0):
5760
raise ValueError("task_start_to_close_timeout must be greater than 0")
5861

62+
# Warn if first_run_at is timezone-naive
63+
first_run_at = options.get("first_run_at")
64+
if first_run_at is not None and first_run_at.tzinfo is None:
65+
warnings.warn(
66+
"first_run_at is timezone-naive; it will be treated as UTC",
67+
UserWarning,
68+
stacklevel=3,
69+
)
70+
5971
return options
6072

6173

@@ -186,6 +198,13 @@ def _build_start_workflow_request(
186198
if options.get("cron_schedule"):
187199
request.cron_schedule = options["cron_schedule"]
188200

201+
# Set first_run_at if provided
202+
first_run_at = options.get("first_run_at")
203+
if first_run_at is not None:
204+
first_run_timestamp = Timestamp()
205+
first_run_timestamp.FromDatetime(first_run_at)
206+
request.first_run_at.CopyFrom(first_run_timestamp)
207+
189208
return request
190209

191210
async def start_workflow(

tests/cadence/test_client_workflow.py

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import pytest
22
import uuid
3-
from datetime import timedelta
3+
import warnings
4+
from datetime import timedelta, datetime, timezone
45
from unittest.mock import AsyncMock, Mock, PropertyMock
56

67
from cadence.api.v1.common_pb2 import WorkflowExecution
@@ -241,6 +242,75 @@ async def test_build_request_with_cron_schedule(self, mock_client):
241242

242243
assert request.cron_schedule == "0 * * * *"
243244

245+
@pytest.mark.asyncio
246+
async def test_build_request_with_first_run_at_timezone_aware(self, mock_client):
247+
"""Test building request with timezone-aware first_run_at."""
248+
client = Client(domain="test-domain", target="localhost:7933")
249+
250+
first_run = datetime(2024, 6, 1, 12, 0, 0, tzinfo=timezone.utc)
251+
252+
options = StartWorkflowOptions(
253+
task_list="test-task-list",
254+
execution_start_to_close_timeout=timedelta(minutes=10),
255+
task_start_to_close_timeout=timedelta(seconds=30),
256+
cron_schedule="0 * * * *",
257+
first_run_at=first_run,
258+
)
259+
260+
request = client._build_start_workflow_request("TestWorkflow", (), options)
261+
262+
assert request.HasField("first_run_at")
263+
# Convert back and verify
264+
result_dt = request.first_run_at.ToDatetime()
265+
assert result_dt == first_run.replace(
266+
tzinfo=None
267+
) # ToDatetime returns naive UTC
268+
269+
@pytest.mark.asyncio
270+
async def test_build_request_with_first_run_at_naive(self, mock_client):
271+
"""Test building request with timezone-naive first_run_at."""
272+
client = Client(domain="test-domain", target="localhost:7933")
273+
274+
first_run = datetime(2024, 6, 1, 12, 0, 0) # Naive datetime
275+
276+
options = StartWorkflowOptions(
277+
task_list="test-task-list",
278+
execution_start_to_close_timeout=timedelta(minutes=10),
279+
task_start_to_close_timeout=timedelta(seconds=30),
280+
first_run_at=first_run,
281+
)
282+
283+
request = client._build_start_workflow_request("TestWorkflow", (), options)
284+
285+
assert request.HasField("first_run_at")
286+
287+
def test_first_run_at_naive_datetime_warns(self):
288+
"""Test that timezone-naive first_run_at produces warning."""
289+
options = StartWorkflowOptions(
290+
task_list="test-task-list",
291+
execution_start_to_close_timeout=timedelta(minutes=10),
292+
first_run_at=datetime(2024, 1, 1, 12, 0, 0), # Naive
293+
)
294+
with pytest.warns(UserWarning, match="timezone-naive"):
295+
_validate_and_apply_defaults(options)
296+
297+
def test_first_run_at_aware_datetime_no_warning(self):
298+
"""Test that timezone-aware first_run_at produces no warning."""
299+
options = StartWorkflowOptions(
300+
task_list="test-task-list",
301+
execution_start_to_close_timeout=timedelta(minutes=10),
302+
first_run_at=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
303+
)
304+
# Should not warn - use warnings.catch_warnings to verify
305+
with warnings.catch_warnings(record=True) as w:
306+
warnings.simplefilter("always")
307+
_validate_and_apply_defaults(options)
308+
# Filter for our specific warning
309+
relevant_warnings = [
310+
warning for warning in w if "timezone-naive" in str(warning.message)
311+
]
312+
assert len(relevant_warnings) == 0
313+
244314

245315
class TestClientStartWorkflow:
246316
"""Test Client.start_workflow method."""

0 commit comments

Comments
 (0)