feat(cron): configurable default timezone for cron expressions (#117)

* feat(cron): configurable default timezone for cron expressions

Cron expressions (e.g. "0 8 * * *") are evaluated relative to a timezone.
Without an explicit per-job timezone, they default to the server's system
timezone, which may not match the user's local time — especially in Docker
containers (default UTC) or multi-region deployments.

This adds a `default_timezone` setting to `CronConfig` (IANA format, e.g.
"Asia/Ho_Chi_Minh") that is applied as fallback when a cron job has no
explicit `schedule.tz`. The setting is configurable via the UI config page
(Integrations → Cron Scheduler) and hot-reloads on config changes.

Backend:
- Add `DefaultTimezone` field to `CronConfig`
- Add `SetDefaultTimezone()` to `CronStore` interface + PG implementation
- Apply default TZ in `AddJob()` when `schedule.TZ` is empty
- Wire at startup + subscribe to config change events for hot reload
- Update cron tool description so LLM knows about gateway default

Frontend:
- Add timezone dropdown (20 common IANA timezones) to Cron config section
- Add i18n keys for en, vi, zh

* fix(cron): apply default timezone to existing jobs via computeNextRun

Pass defaultTZ as fallback to computeNextRun so existing cron jobs
(with timezone = NULL in DB) also use the gateway's configured default
timezone when computing next_run_at. This ensures old jobs benefit
from the timezone setting without needing a DB migration or backfill.

---------

Co-authored-by: Luvu182 <208665161+Luvu182@users.noreply.github.com>
This commit is contained in:
Luan VuandLuvu182 authored and GitHub committed 2026-03-10 18:44:28 +07:00
1 parent d874266e87
commit 25fd9c9d6d
13 files changed
+102 -13

No files matched your search

+16 -1
View File
@@ -295,11 +295,14 @@ func runGateway() {
redisClient := initRedisClient(cfg)
defer shutdownRedis(redisClient)
// Wire cron retry config from config.json
// Wire cron config from config.json
cronRetryCfg := cfg.Cron.ToRetryConfig()
// Apply retry config via type assertion on the concrete cron store.
pgStores.Cron.SetOnJob(nil) // ensure initialized; actual handler set below
_ = cronRetryCfg // config available; pg cron store reads it internally
if cfg.Cron.DefaultTimezone != "" {
pgStores.Cron.SetDefaultTimezone(cfg.Cron.DefaultTimezone)
}
// Load secrets from config_secrets table before env overrides.
// Precedence: config.json → DB secrets → env vars (highest).
@@ -843,6 +846,18 @@ func runGateway() {
})
}
// Reload cron default timezone on config changes via pub/sub.
msgBus.Subscribe("cron-config-reload", func(evt bus.Event) {
if evt.Name != bus.TopicConfigChanged {
return
}
updatedCfg, ok := evt.Payload.(*config.Config)
if !ok {
return
}
pgStores.Cron.SetDefaultTimezone(updatedCfg.Cron.DefaultTimezone)
})
// Reload web_fetch domain policy on config changes via pub/sub.
msgBus.Subscribe("webfetch-config-reload", func(evt bus.Event) {
if evt.Name != bus.TopicConfigChanged {
+4 -3
View File
@@ -310,9 +310,10 @@ type TelemetryConfig struct {
// CronConfig configures the cron job system.
type CronConfig struct {
MaxRetries int `json:"max_retries,omitempty"` // max retry attempts on failure (default 3, 0 = no retry)
RetryBaseDelay string `json:"retry_base_delay,omitempty"` // initial backoff delay (default "2s", Go duration)
RetryMaxDelay string `json:"retry_max_delay,omitempty"` // maximum backoff delay (default "30s", Go duration)
MaxRetries int `json:"max_retries,omitempty"` // max retry attempts on failure (default 3, 0 = no retry)
RetryBaseDelay string `json:"retry_base_delay,omitempty"` // initial backoff delay (default "2s", Go duration)
RetryMaxDelay string `json:"retry_max_delay,omitempty"` // maximum backoff delay (default "30s", Go duration)
DefaultTimezone string `json:"default_timezone,omitempty"` // IANA timezone for cron expressions when not set per-job (e.g. "Asia/Ho_Chi_Minh")
}
// ToRetryConfig converts CronConfig to cron.RetryConfig with defaults applied.
+1
View File
@@ -105,6 +105,7 @@ type CronStore interface {
SetOnJob(handler func(job *CronJob) (*CronJobResult, error))
SetOnEvent(handler func(event CronEvent))
RunJob(jobID string, force bool) (ran bool, reason string, err error)
SetDefaultTimezone(tz string)
// Due job detection (for scheduler)
GetDueJobs(now time.Time) []CronJob
+10 -1
View File
@@ -28,7 +28,8 @@ type PGCronStore struct {
cacheTime time.Time
cacheTTL time.Duration
retryCfg cron.RetryConfig
retryCfg cron.RetryConfig
defaultTZ string // fallback IANA timezone for cron jobs without explicit TZ
}
func NewPGCronStore(db *sql.DB) *PGCronStore {
@@ -42,6 +43,14 @@ func (s *PGCronStore) SetRetryConfig(cfg cron.RetryConfig) {
s.retryCfg = cfg
}
// SetDefaultTimezone sets the fallback IANA timezone for cron expressions
// when a job does not specify its own timezone.
func (s *PGCronStore) SetDefaultTimezone(tz string) {
s.mu.Lock()
defer s.mu.Unlock()
s.defaultTZ = tz
}
func (s *PGCronStore) Start() error {
s.mu.Lock()
defer s.mu.Unlock()
+5 -1
View File
@@ -11,6 +11,10 @@ import (
)
func (s *PGCronStore) AddJob(name string, schedule store.CronSchedule, message string, deliver bool, channel, to, agentID, userID string) (*store.CronJob, error) {
// Apply default timezone for cron expressions when not set per-job.
if schedule.TZ == "" && schedule.Kind == "cron" && s.defaultTZ != "" {
schedule.TZ = s.defaultTZ
}
if schedule.TZ != "" {
if _, err := time.LoadLocation(schedule.TZ); err != nil {
return nil, fmt.Errorf("invalid timezone: %s", schedule.TZ)
@@ -58,7 +62,7 @@ func (s *PGCronStore) AddJob(name string, schedule store.CronSchedule, message s
intervalMS = schedule.EveryMS
}
nextRun := computeNextRun(&schedule, now)
nextRun := computeNextRun(&schedule, now, s.defaultTZ)
_, err := s.db.Exec(
`INSERT INTO cron_jobs (id, agent_id, user_id, name, enabled, schedule_kind, cron_expression, run_at, timezone,
+10 -3
View File
@@ -103,7 +103,10 @@ func scanCronSingleRow(row *sql.Row) (*store.CronJob, error) {
// --- Helpers ---
func computeNextRun(schedule *store.CronSchedule, now time.Time) *time.Time {
// computeNextRun calculates the next run time for a schedule.
// defaultTZ is the gateway-level fallback IANA timezone used when the
// schedule itself does not specify a timezone (existing jobs with TZ = NULL).
func computeNextRun(schedule *store.CronSchedule, now time.Time, defaultTZ string) *time.Time {
switch schedule.Kind {
case "at":
if schedule.AtMS != nil {
@@ -123,9 +126,13 @@ func computeNextRun(schedule *store.CronSchedule, now time.Time) *time.Time {
if schedule.Expr == "" {
return nil
}
tz := schedule.TZ
if tz == "" {
tz = defaultTZ
}
evalTime := now
if schedule.TZ != "" {
if loc, err := time.LoadLocation(schedule.TZ); err == nil {
if tz != "" {
if loc, err := time.LoadLocation(tz); err == nil {
evalTime = now.In(loc)
}
}
+2 -2
View File
@@ -102,7 +102,7 @@ func (s *PGCronStore) recomputeStaleJobs() {
schedule.TZ = *tz
}
next := computeNextRun(&schedule, now)
next := computeNextRun(&schedule, now, s.defaultTZ)
if next == nil {
if scheduleKind == "at" {
s.db.Exec("UPDATE cron_jobs SET enabled = false, updated_at = $1 WHERE id = $2", now, id)
@@ -236,7 +236,7 @@ func (s *PGCronStore) executeOneJob(job store.CronJob, handler func(job *store.C
}
} else if id, parseErr := uuid.Parse(job.ID); parseErr == nil {
schedule := job.Schedule
next := computeNextRun(&schedule, now)
next := computeNextRun(&schedule, now, s.defaultTZ)
s.db.Exec(
"UPDATE cron_jobs SET last_run_at = $1, last_status = $2, last_error = $3, next_run_at = $4, updated_at = $5 WHERE id = $6",
now, status, lastError, next, now, id,
+1 -1
View File
@@ -131,7 +131,7 @@ func (s *PGCronStore) UpdateJob(jobID string, patch store.CronJobPatch) (*store.
}
}
next := computeNextRun(&merged, time.Now())
next := computeNextRun(&merged, time.Now(), s.defaultTZ)
updates["next_run_at"] = next
}
if patch.DeleteAfterRun != nil {
+1 -1
View File
@@ -57,7 +57,7 @@ SCHEDULE TYPES (schedule.kind):
- "every": Recurring interval
{ "kind": "every", "everyMs": <interval-ms> }
- "cron": Cron expression
{ "kind": "cron", "expr": "<5-field cron expression>", "tz": "<optional-timezone>" }
{ "kind": "cron", "expr": "<5-field cron expression>", "tz": "<optional IANA timezone, e.g. Asia/Ho_Chi_Minh; omit to use gateway default>" }
CRITICAL CONSTRAINTS:
- name must be a valid slug (lowercase letters, numbers, hyphens only)
+3
View File
@@ -232,6 +232,9 @@
"cron.baseDelayTip": "Initial backoff delay between retries (e.g. 2s, 500ms).",
"cron.maxDelay": "Max Delay",
"cron.maxDelayTip": "Maximum backoff delay between retries (e.g. 30s, 5m).",
"cron.defaultTimezone": "Default Timezone",
"cron.defaultTimezoneTip": "IANA timezone for cron expressions when not set per-job (e.g. Asia/Ho_Chi_Minh). Leave empty to use server system timezone.",
"cron.defaultTimezonePlaceholder": "System timezone",
"telemetry.title": "Telemetry (OpenTelemetry)",
"telemetry.description": "Export traces and metrics to an OpenTelemetry-compatible backend",
+3
View File
@@ -232,6 +232,9 @@
"cron.baseDelayTip": "Độ trễ backoff ban đầu giữa các lần thử lại (vd: 2s, 500ms).",
"cron.maxDelay": "Độ trễ tối đa",
"cron.maxDelayTip": "Độ trễ backoff tối đa giữa các lần thử lại (vd: 30s, 5m).",
"cron.defaultTimezone": "Múi giờ mặc định",
"cron.defaultTimezoneTip": "Múi giờ IANA cho biểu thức cron khi không được đặt cho từng job (vd: Asia/Ho_Chi_Minh). Để trống sẽ dùng múi giờ hệ thống.",
"cron.defaultTimezonePlaceholder": "Múi giờ hệ thống",
"telemetry.title": "Telemetry (OpenTelemetry)",
"telemetry.description": "Xuất trace và metric sang backend tương thích OpenTelemetry",
+3
View File
@@ -232,6 +232,9 @@
"cron.baseDelayTip": "重试之间的初始退避延迟(例如 2s、500ms)。",
"cron.maxDelay": "最大延迟",
"cron.maxDelayTip": "重试之间的最大退避延迟(例如 30s、5m)。",
"cron.defaultTimezone": "默认时区",
"cron.defaultTimezoneTip": "未为单个任务设置时区时,cron 表达式使用的 IANA 时区(例如 Asia/Shanghai)。留空则使用服务器系统时区。",
"cron.defaultTimezonePlaceholder": "系统时区",
"telemetry.title": "遥测(OpenTelemetry)",
"telemetry.description": "将追踪和指标导出到兼容 OpenTelemetry 的后端",
@@ -3,13 +3,38 @@ import { Save } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { InfoLabel } from "@/components/shared/info-label";
const TIMEZONES = [
{ value: "UTC", label: "UTC" },
{ value: "Asia/Ho_Chi_Minh", label: "Asia/Ho_Chi_Minh (UTC+7)" },
{ value: "Asia/Bangkok", label: "Asia/Bangkok (UTC+7)" },
{ value: "Asia/Shanghai", label: "Asia/Shanghai (UTC+8)" },
{ value: "Asia/Tokyo", label: "Asia/Tokyo (UTC+9)" },
{ value: "Asia/Seoul", label: "Asia/Seoul (UTC+9)" },
{ value: "Asia/Singapore", label: "Asia/Singapore (UTC+8)" },
{ value: "Asia/Kolkata", label: "Asia/Kolkata (UTC+5:30)" },
{ value: "Asia/Dubai", label: "Asia/Dubai (UTC+4)" },
{ value: "Europe/London", label: "Europe/London (UTC+0)" },
{ value: "Europe/Paris", label: "Europe/Paris (UTC+1)" },
{ value: "Europe/Berlin", label: "Europe/Berlin (UTC+1)" },
{ value: "Europe/Moscow", label: "Europe/Moscow (UTC+3)" },
{ value: "America/New_York", label: "America/New_York (UTC-5)" },
{ value: "America/Chicago", label: "America/Chicago (UTC-6)" },
{ value: "America/Denver", label: "America/Denver (UTC-7)" },
{ value: "America/Los_Angeles", label: "America/Los_Angeles (UTC-8)" },
{ value: "America/Sao_Paulo", label: "America/Sao_Paulo (UTC-3)" },
{ value: "Australia/Sydney", label: "Australia/Sydney (UTC+11)" },
{ value: "Pacific/Auckland", label: "Pacific/Auckland (UTC+13)" },
];
interface CronData {
max_retries?: number;
retry_base_delay?: string;
retry_max_delay?: string;
default_timezone?: string;
}
const DEFAULT: CronData = {};
@@ -44,6 +69,24 @@ export function CronSection({ data, onSave, saving }: Props) {
<CardDescription>{t("cron.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-1.5">
<InfoLabel tip={t("cron.defaultTimezoneTip")}>{t("cron.defaultTimezone")}</InfoLabel>
<Select
value={draft.default_timezone || "__system__"}
onValueChange={(v) => update({ default_timezone: v === "__system__" ? "" : v })}
>
<SelectTrigger>
<SelectValue placeholder={t("cron.defaultTimezonePlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__system__">{t("cron.defaultTimezonePlaceholder")}</SelectItem>
{TIMEZONES.map((tz) => (
<SelectItem key={tz.value} value={tz.value}>{tz.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="grid gap-1.5">
<InfoLabel tip={t("cron.maxRetriesTip")}>{t("cron.maxRetries")}</InfoLabel>