Skip to content

Commit 94cde72

Browse files
committed
feat: add timezone support for CRON triggers (#438)
Allow users to specify timezone when setting up CRON triggers to enable scheduling in local time zones with automatic DST handling. Changes: - Add timezone field to CronTrigger and DatabaseTriggers models - Implement timezone-aware cron scheduling using Python's zoneinfo - Validate timezone using IANA timezone database - Update Python SDK to support timezone parameter - Add comprehensive documentation with examples - Default to UTC for backward compatibility The timezone field is optional and defaults to "UTC". All trigger times are internally stored in UTC while croniter calculations respect the specified timezone, ensuring correct scheduling across time zones and DST transitions. Signed-off-by: Sparsh <sparsh.raj30@gmail.com>
1 parent 964d0f2 commit 94cde72

7 files changed

Lines changed: 84 additions & 17 deletions

File tree

docs/docs/exosphere/triggers.md

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,13 +61,15 @@ Define triggers in your graph template:
6161
{
6262
"type": "CRON",
6363
"value": {
64-
"expression": "0 9 * * 1-5"
64+
"expression": "0 9 * * 1-5",
65+
"timezone": "America/New_York"
6566
}
6667
},
6768
{
6869
"type": "CRON",
6970
"value": {
70-
"expression": "0 0 * * 0"
71+
"expression": "0 0 * * 0",
72+
"timezone": "UTC"
7173
}
7274
}
7375
],
@@ -77,6 +79,8 @@ Define triggers in your graph template:
7779
}
7880
```
7981

82+
**Note:** The `timezone` field is optional and defaults to `"UTC"` if not specified. Use IANA timezone names (e.g., `"America/New_York"`, `"Europe/London"`, `"Asia/Tokyo"`).
83+
8084
### Python SDK Example
8185

8286
```python
@@ -109,8 +113,8 @@ async def create_scheduled_graph():
109113

110114
# Define triggers for automatic execution
111115
triggers = [
112-
CronTrigger(expression="0 2 * * *"), # Daily at 2:00 AM
113-
CronTrigger(expression="0 */4 * * *") # Every 4 hours
116+
CronTrigger(expression="0 2 * * *", timezone="America/New_York"), # Daily at 2:00 AM EST/EDT
117+
CronTrigger(expression="0 */4 * * *", timezone="UTC") # Every 4 hours UTC
114118
]
115119

116120
# Create the graph with triggers
@@ -158,7 +162,7 @@ asyncio.run(create_scheduled_graph())
158162

159163
1. **Avoid Peak Times**: Schedule resource-intensive workflows during off-peak hours
160164
2. **Stagger Executions**: If you have multiple graphs, stagger their execution times
161-
3. **Consider Time Zones**: Cron expressions use server time (UTC by default)
165+
3. **Consider Time Zones**: Specify the `timezone` parameter to ensure your cron expressions run at the correct local time. If not specified, defaults to UTC.
162166
4. **Resource Planning**: Ensure your infrastructure can handle scheduled workloads
163167

164168
### Error Handling
@@ -191,11 +195,31 @@ result = await state_manager.upsert_graph(
191195
)
192196
```
193197

198+
## Timezone Support
199+
200+
Triggers now support specifying a timezone for cron expressions, allowing you to schedule jobs in your local timezone:
201+
202+
```python
203+
# Schedule a report to run at 9 AM New York time (handles DST automatically)
204+
CronTrigger(expression="0 9 * * 1-5", timezone="America/New_York")
205+
206+
# Schedule a job at 5 PM London time
207+
CronTrigger(expression="0 17 * * *", timezone="Europe/London")
208+
209+
# Schedule using UTC (default)
210+
CronTrigger(expression="0 12 * * *", timezone="UTC")
211+
```
212+
213+
**Important Notes:**
214+
- Use IANA timezone names (e.g., `"America/New_York"`, `"Europe/London"`, `"Asia/Tokyo"`)
215+
- Timezones automatically handle Daylight Saving Time (DST) transitions
216+
- If no timezone is specified, defaults to `"UTC"`
217+
- All trigger times are internally stored in UTC for consistency
218+
194219
## Limitations
195220

196221
- **CRON Only**: Currently only cron-based scheduling is supported
197222
- **No Manual Override**: Scheduled executions cannot be manually cancelled once triggered
198-
- **Time Zone**: All cron expressions are evaluated in server time (UTC)
199223
- **Minimum Interval**: Avoid scheduling more frequently than every minute
200224

201225
## Next Steps

python-sdk/exospherehost/models.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,4 +160,5 @@ def validate_default_values(cls, v: dict[str, str]) -> dict[str, str]:
160160
return normalized_dict
161161

162162
class CronTrigger(BaseModel):
163-
expression: str = Field(..., description="Cron expression for scheduling automatic graph execution. Uses standard 5-field format: minute hour day-of-month month day-of-week. Example: '0 9 * * 1-5' for weekdays at 9 AM.")
163+
expression: str = Field(..., description="Cron expression for scheduling automatic graph execution. Uses standard 5-field format: minute hour day-of-month month day-of-week. Example: '0 9 * * 1-5' for weekdays at 9 AM.")
164+
timezone: str = Field(default="UTC", description="Timezone for the cron expression (e.g., 'America/New_York', 'Europe/London', 'UTC'). Defaults to 'UTC'.")

python-sdk/exospherehost/statemanager.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,8 @@ async def upsert_graph(self, graph_name: str, graph_nodes: list[GraphNodeModel],
173173
{
174174
"type": "CRON",
175175
"value": {
176-
"expression": trigger.expression
176+
"expression": trigger.expression,
177+
"timezone": trigger.timezone
177178
}
178179
}
179180
for trigger in triggers

state-manager/app/models/db/trigger.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
class DatabaseTriggers(Document):
1010
type: TriggerTypeEnum = Field(..., description="Type of the trigger")
1111
expression: Optional[str] = Field(default=None, description="Expression of the trigger")
12+
timezone: Optional[str] = Field(default="UTC", description="Timezone for the trigger")
1213
graph_name: str = Field(..., description="Name of the graph")
1314
namespace: str = Field(..., description="Namespace of the graph")
1415
trigger_time: datetime = Field(..., description="Trigger time of the trigger")

state-manager/app/models/trigger_models.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
from pydantic import BaseModel, Field, field_validator, model_validator
22
from enum import Enum
33
from croniter import croniter
4-
from typing import Self
4+
from typing import Self, Optional
5+
from zoneinfo import ZoneInfo, available_timezones
56

67
class TriggerTypeEnum(str, Enum):
78
CRON = "CRON"
@@ -15,6 +16,7 @@ class TriggerStatusEnum(str, Enum):
1516

1617
class CronTrigger(BaseModel):
1718
expression: str = Field(..., description="Cron expression for the trigger")
19+
timezone: Optional[str] = Field(default="UTC", description="Timezone for the cron expression (e.g., 'America/New_York', 'Europe/London', 'UTC')")
1820

1921
@field_validator("expression")
2022
@classmethod
@@ -23,6 +25,15 @@ def validate_expression(cls, v: str) -> str:
2325
raise ValueError("Invalid cron expression")
2426
return v
2527

28+
@field_validator("timezone")
29+
@classmethod
30+
def validate_timezone(cls, v: Optional[str]) -> str:
31+
if v is None:
32+
return "UTC"
33+
if v not in available_timezones():
34+
raise ValueError(f"Invalid timezone: {v}. Must be a valid IANA timezone (e.g., 'America/New_York', 'Europe/London', 'UTC')")
35+
return v
36+
2637
class Trigger(BaseModel):
2738
type: TriggerTypeEnum = Field(..., description="Type of the trigger")
2839
value: dict = Field(default_factory=dict, description="Value of the trigger")

state-manager/app/tasks/trigger_cron.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from pymongo import ReturnDocument
99
from pymongo.errors import DuplicateKeyError
1010
from app.config.settings import get_settings
11+
from zoneinfo import ZoneInfo
1112
import croniter
1213
import asyncio
1314

@@ -42,15 +43,26 @@ async def mark_as_failed(trigger: DatabaseTriggers):
4243

4344
async def create_next_triggers(trigger: DatabaseTriggers, cron_time: datetime):
4445
assert trigger.expression is not None
45-
iter = croniter.croniter(trigger.expression, trigger.trigger_time)
46+
47+
# Use the trigger's timezone, defaulting to UTC if not specified
48+
tz = ZoneInfo(trigger.timezone or "UTC")
49+
50+
# Convert trigger_time to the specified timezone for croniter
51+
trigger_time_tz = trigger.trigger_time.replace(tzinfo=ZoneInfo("UTC")).astimezone(tz)
52+
iter = croniter.croniter(trigger.expression, trigger_time_tz)
4653

4754
while True:
48-
next_trigger_time = iter.get_next(datetime)
55+
# Get next trigger time in the specified timezone
56+
next_trigger_time_tz = iter.get_next(datetime)
57+
58+
# Convert back to UTC for storage
59+
next_trigger_time = next_trigger_time_tz.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
4960

5061
try:
5162
await DatabaseTriggers(
5263
type=TriggerTypeEnum.CRON,
5364
expression=trigger.expression,
65+
timezone=trigger.timezone,
5466
graph_name=trigger.graph_name,
5567
namespace=trigger.namespace,
5668
trigger_time=next_trigger_time,

state-manager/app/tasks/verify_graph.py

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from datetime import datetime
55
from json_schema_to_pydantic import create_model
6+
from zoneinfo import ZoneInfo
67

78
from app.models.db.graph_template_model import GraphTemplate
89
from app.models.graph_template_validation_status import GraphTemplateValidationStatus
@@ -101,20 +102,36 @@ async def verify_inputs(graph_template: GraphTemplate, registered_nodes: list[Re
101102
return errors
102103

103104
async def create_crons(graph_template: GraphTemplate):
104-
expressions_to_create = set([trigger.value["expression"] for trigger in graph_template.triggers if trigger.type == TriggerTypeEnum.CRON])
105+
# Build a map of (expression, timezone) -> trigger for deduplication
106+
triggers_to_create = {}
107+
for trigger in graph_template.triggers:
108+
if trigger.type == TriggerTypeEnum.CRON:
109+
expression = trigger.value["expression"]
110+
timezone = trigger.value.get("timezone", "UTC")
111+
triggers_to_create[(expression, timezone)] = trigger
105112

106113
current_time = datetime.now()
107-
114+
108115
new_db_triggers = []
109-
for expression in expressions_to_create:
110-
iter = croniter.croniter(expression, current_time)
116+
for (expression, timezone), trigger in triggers_to_create.items():
117+
# Use the trigger's timezone, defaulting to UTC
118+
tz = ZoneInfo(timezone)
119+
120+
# Get current time in the specified timezone
121+
current_time_tz = current_time.replace(tzinfo=ZoneInfo("UTC")).astimezone(tz)
122+
iter = croniter.croniter(expression, current_time_tz)
123+
124+
# Get next trigger time in the specified timezone
125+
next_trigger_time_tz = iter.get_next(datetime)
126+
127+
# Convert back to UTC for storage (remove timezone info for storage)
128+
next_trigger_time = next_trigger_time_tz.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
111129

112-
next_trigger_time = iter.get_next(datetime)
113-
114130
new_db_triggers.append(
115131
DatabaseTriggers(
116132
type=TriggerTypeEnum.CRON,
117133
expression=expression,
134+
timezone=timezone,
118135
graph_name=graph_template.name,
119136
namespace=graph_template.namespace,
120137
trigger_status=TriggerStatusEnum.PENDING,

0 commit comments

Comments
 (0)