Skip to content

Commit d76447b

Browse files
timl3136claude
andcommitted
Add first_run_at support to StartWorkflowOptions
- Add first_run_at datetime field for explicit cron workflow scheduling - Require timezone-aware datetime to prevent local/UTC ambiguity bugs - Validate timestamp is not before Unix epoch (matching Go client) - Add comprehensive tests for timezone handling and validation Co-Authored-By: Claude (claude-opus-4-5) <noreply@anthropic.com> Signed-off-by: Tim Li <ltim@uber.com>
1 parent 28d04d3 commit d76447b

File tree

2 files changed

+96
-2
lines changed

2 files changed

+96
-2
lines changed

cadence/client.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import os
22
import socket
33
import uuid
4-
from datetime import timedelta
4+
from datetime import datetime, timedelta
55
from typing import TypedDict, Unpack, Any, cast, Union
66

77
from grpc import ChannelCredentials, Compression
88
from google.protobuf.duration_pb2 import Duration
9+
from google.protobuf.timestamp_pb2 import Timestamp
910

1011
from cadence._internal.rpc.error import CadenceErrorInterceptor
1112
from cadence._internal.rpc.retry import RetryInterceptor
@@ -36,6 +37,7 @@ class StartWorkflowOptions(TypedDict, total=False):
3637
workflow_id: str
3738
task_start_to_close_timeout: timedelta
3839
cron_schedule: str
40+
first_run_at: datetime
3941

4042

4143
def _validate_and_apply_defaults(options: StartWorkflowOptions) -> StartWorkflowOptions:
@@ -56,6 +58,21 @@ def _validate_and_apply_defaults(options: StartWorkflowOptions) -> StartWorkflow
5658
elif task_timeout <= timedelta(0):
5759
raise ValueError("task_start_to_close_timeout must be greater than 0")
5860

61+
# Validate first_run_at (must be timezone-aware and not before Unix epoch)
62+
first_run_at = options.get("first_run_at")
63+
if first_run_at is not None:
64+
# Require timezone-aware datetime to prevent ambiguity
65+
if first_run_at.tzinfo is None:
66+
raise ValueError(
67+
"first_run_at must be timezone-aware. "
68+
"Use datetime.now(timezone.utc) or datetime(..., tzinfo=timezone.utc)"
69+
)
70+
# Validate timestamp is not before Unix epoch (matching Go client behavior)
71+
if first_run_at.timestamp() < 0:
72+
raise ValueError(
73+
"first_run_at cannot be before Unix epoch (January 1, 1970 UTC)"
74+
)
75+
5976
return options
6077

6178

@@ -186,6 +203,13 @@ def _build_start_workflow_request(
186203
if options.get("cron_schedule"):
187204
request.cron_schedule = options["cron_schedule"]
188205

206+
# Set first_run_at if provided
207+
first_run_at = options.get("first_run_at")
208+
if first_run_at is not None:
209+
first_run_timestamp = Timestamp()
210+
first_run_timestamp.FromDatetime(first_run_at)
211+
request.first_run_at.CopyFrom(first_run_timestamp)
212+
189213
return request
190214

191215
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,6 @@
11
import pytest
22
import uuid
3-
from datetime import timedelta
3+
from datetime import timedelta, datetime, timezone
44
from unittest.mock import AsyncMock, Mock, PropertyMock
55

66
from cadence.api.v1.common_pb2 import WorkflowExecution
@@ -241,6 +241,76 @@ async def test_build_request_with_cron_schedule(self, mock_client):
241241

242242
assert request.cron_schedule == "0 * * * *"
243243

244+
@pytest.mark.asyncio
245+
async def test_build_request_with_first_run_at_timezone_aware(self, mock_client):
246+
"""Test building request with timezone-aware first_run_at."""
247+
client = Client(domain="test-domain", target="localhost:7933")
248+
249+
first_run = datetime(2024, 6, 1, 12, 0, 0, tzinfo=timezone.utc)
250+
251+
options = StartWorkflowOptions(
252+
task_list="test-task-list",
253+
execution_start_to_close_timeout=timedelta(minutes=10),
254+
task_start_to_close_timeout=timedelta(seconds=30),
255+
cron_schedule="0 * * * *",
256+
first_run_at=first_run,
257+
)
258+
259+
request = client._build_start_workflow_request("TestWorkflow", (), options)
260+
261+
assert request.HasField("first_run_at")
262+
# Convert back and verify
263+
result_dt = request.first_run_at.ToDatetime()
264+
assert result_dt == first_run.replace(
265+
tzinfo=None
266+
) # ToDatetime returns naive UTC
267+
268+
def test_first_run_at_naive_datetime_raises_error(self):
269+
"""Test that timezone-naive first_run_at raises ValueError."""
270+
options = StartWorkflowOptions(
271+
task_list="test-task-list",
272+
execution_start_to_close_timeout=timedelta(minutes=10),
273+
first_run_at=datetime(2024, 1, 1, 12, 0, 0), # Naive
274+
)
275+
with pytest.raises(ValueError, match="must be timezone-aware"):
276+
_validate_and_apply_defaults(options)
277+
278+
def test_first_run_at_aware_datetime_is_valid(self):
279+
"""Test that timezone-aware first_run_at is valid."""
280+
options = StartWorkflowOptions(
281+
task_list="test-task-list",
282+
execution_start_to_close_timeout=timedelta(minutes=10),
283+
first_run_at=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
284+
)
285+
# Should not raise
286+
validated = _validate_and_apply_defaults(options)
287+
assert validated["first_run_at"] == datetime(
288+
2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc
289+
)
290+
291+
def test_first_run_at_before_epoch_raises_error(self):
292+
"""Test that first_run_at before Unix epoch raises ValueError."""
293+
options = StartWorkflowOptions(
294+
task_list="test-task-list",
295+
execution_start_to_close_timeout=timedelta(minutes=10),
296+
first_run_at=datetime(1960, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
297+
)
298+
with pytest.raises(ValueError, match="cannot be before Unix epoch"):
299+
_validate_and_apply_defaults(options)
300+
301+
def test_first_run_at_at_epoch_is_valid(self):
302+
"""Test that first_run_at exactly at Unix epoch is valid."""
303+
options = StartWorkflowOptions(
304+
task_list="test-task-list",
305+
execution_start_to_close_timeout=timedelta(minutes=10),
306+
first_run_at=datetime(1970, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
307+
)
308+
# Should not raise
309+
validated = _validate_and_apply_defaults(options)
310+
assert validated["first_run_at"] == datetime(
311+
1970, 1, 1, 0, 0, 0, tzinfo=timezone.utc
312+
)
313+
244314

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

0 commit comments

Comments
 (0)