Skip to content

Commit 5584ba6

Browse files
committed
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 Signed-off-by: Tim Li <ltim@uber.com>
1 parent ba83131 commit 5584ba6

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
@@ -38,6 +39,7 @@ class StartWorkflowOptions(TypedDict, total=False):
3839
cron_schedule: str
3940
delay_start: timedelta
4041
jitter_start: timedelta
42+
first_run_at: datetime
4143

4244

4345
def _validate_and_apply_defaults(options: StartWorkflowOptions) -> StartWorkflowOptions:
@@ -68,6 +70,21 @@ def _validate_and_apply_defaults(options: StartWorkflowOptions) -> StartWorkflow
6870
if jitter_start is not None and jitter_start < timedelta(0):
6971
raise ValueError("jitter_start cannot be negative")
7072

73+
# Validate first_run_at (must be timezone-aware and not before Unix epoch)
74+
first_run_at = options.get("first_run_at")
75+
if first_run_at is not None:
76+
# Require timezone-aware datetime to prevent ambiguity
77+
if first_run_at.tzinfo is None:
78+
raise ValueError(
79+
"first_run_at must be timezone-aware. "
80+
"Use datetime.now(timezone.utc) or datetime(..., tzinfo=timezone.utc)"
81+
)
82+
# Validate timestamp is not before Unix epoch (matching Go client behavior)
83+
if first_run_at.timestamp() < 0:
84+
raise ValueError(
85+
"first_run_at cannot be before Unix epoch (January 1, 1970 UTC)"
86+
)
87+
7188
return options
7289

7390

@@ -212,6 +229,13 @@ def _build_start_workflow_request(
212229
jitter_duration.FromTimedelta(jitter_start)
213230
request.jitter_start.CopyFrom(jitter_duration)
214231

232+
# Set first_run_at if provided
233+
first_run_at = options.get("first_run_at")
234+
if first_run_at is not None:
235+
first_run_timestamp = Timestamp()
236+
first_run_timestamp.FromDatetime(first_run_at)
237+
request.first_run_at.CopyFrom(first_run_timestamp)
238+
215239
return request
216240

217241
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
@@ -323,6 +323,76 @@ def test_zero_delay_start_is_valid(self):
323323
validated = _validate_and_apply_defaults(options)
324324
assert validated["delay_start"] == timedelta(0)
325325

326+
@pytest.mark.asyncio
327+
async def test_build_request_with_first_run_at_timezone_aware(self, mock_client):
328+
"""Test building request with timezone-aware first_run_at."""
329+
client = Client(domain="test-domain", target="localhost:7933")
330+
331+
first_run = datetime(2024, 6, 1, 12, 0, 0, tzinfo=timezone.utc)
332+
333+
options = StartWorkflowOptions(
334+
task_list="test-task-list",
335+
execution_start_to_close_timeout=timedelta(minutes=10),
336+
task_start_to_close_timeout=timedelta(seconds=30),
337+
cron_schedule="0 * * * *",
338+
first_run_at=first_run,
339+
)
340+
341+
request = client._build_start_workflow_request("TestWorkflow", (), options)
342+
343+
assert request.HasField("first_run_at")
344+
# Convert back and verify
345+
result_dt = request.first_run_at.ToDatetime()
346+
assert result_dt == first_run.replace(
347+
tzinfo=None
348+
) # ToDatetime returns naive UTC
349+
350+
def test_first_run_at_naive_datetime_raises_error(self):
351+
"""Test that timezone-naive first_run_at raises ValueError."""
352+
options = StartWorkflowOptions(
353+
task_list="test-task-list",
354+
execution_start_to_close_timeout=timedelta(minutes=10),
355+
first_run_at=datetime(2024, 1, 1, 12, 0, 0), # Naive
356+
)
357+
with pytest.raises(ValueError, match="must be timezone-aware"):
358+
_validate_and_apply_defaults(options)
359+
360+
def test_first_run_at_aware_datetime_is_valid(self):
361+
"""Test that timezone-aware first_run_at is valid."""
362+
options = StartWorkflowOptions(
363+
task_list="test-task-list",
364+
execution_start_to_close_timeout=timedelta(minutes=10),
365+
first_run_at=datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
366+
)
367+
# Should not raise
368+
validated = _validate_and_apply_defaults(options)
369+
assert validated["first_run_at"] == datetime(
370+
2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc
371+
)
372+
373+
def test_first_run_at_before_epoch_raises_error(self):
374+
"""Test that first_run_at before Unix epoch raises ValueError."""
375+
options = StartWorkflowOptions(
376+
task_list="test-task-list",
377+
execution_start_to_close_timeout=timedelta(minutes=10),
378+
first_run_at=datetime(1960, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
379+
)
380+
with pytest.raises(ValueError, match="cannot be before Unix epoch"):
381+
_validate_and_apply_defaults(options)
382+
383+
def test_first_run_at_at_epoch_is_valid(self):
384+
"""Test that first_run_at exactly at Unix epoch is valid."""
385+
options = StartWorkflowOptions(
386+
task_list="test-task-list",
387+
execution_start_to_close_timeout=timedelta(minutes=10),
388+
first_run_at=datetime(1970, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
389+
)
390+
# Should not raise
391+
validated = _validate_and_apply_defaults(options)
392+
assert validated["first_run_at"] == datetime(
393+
1970, 1, 1, 0, 0, 0, tzinfo=timezone.utc
394+
)
395+
326396

327397
class TestClientStartWorkflow:
328398
"""Test Client.start_workflow method."""

0 commit comments

Comments
 (0)