seconds cron field support

This commit is contained in:
Mohammed Nafees
2025-08-22 15:16:01 +02:00
parent 80c4a0e3e7
commit c7101d7c43
8 changed files with 253 additions and 12 deletions

View File

@@ -157,5 +157,16 @@ export const extractCronTz = (cron: string): string => {
export const formatCron = (cron: string) => {
const cronExpression = cron.replace(/^CRON_TZ=([^\s]+)\s+/, '');
return CronPrettifier.toString(cronExpression || '');
// Validate field count before formatting (only 5 or 6 fields supported)
const fields = cronExpression.trim().split(/\s+/);
if (fields.length !== 5 && fields.length !== 6) {
return `Invalid cron expression: must have 5 or 6 fields, got ${fields.length}`;
}
try {
// cronstrue automatically handles both 5 and 6-field cron expressions
return CronPrettifier.toString(cronExpression || '');
} catch (error) {
return `Invalid cron expression: ${error}`;
}
};

View File

@@ -57,7 +57,7 @@ export function TriggerWorkflowForm({
const [scheduleTime, setScheduleTime] = useState<Date | undefined>(
new Date(),
);
const [cronExpression, setCronExpression] = useState<string>('* * * * *');
const [cronExpression, setCronExpression] = useState<string>('0 * * * *');
const [cronName, setCronName] = useState<string>('');
const [selectedWorkflowId, setSelectedWorkflowId] = useState<
@@ -66,8 +66,30 @@ export function TriggerWorkflowForm({
const cronPretty = useMemo(() => {
try {
const expr = cronExpression || '';
// Remove timezone prefix if present (CRON_TZ= or TZ=)
let cleanCron = expr;
if (expr.startsWith('CRON_TZ=')) {
const parts = expr.split(' ', 2);
if (parts.length === 2) {
cleanCron = parts[1];
}
} else if (expr.startsWith('TZ=')) {
const parts = expr.split(' ', 2);
if (parts.length === 2) {
cleanCron = parts[1];
}
}
// Validate field count first (only 5 or 6 fields supported)
const fields = cleanCron.trim().split(/\s+/);
if (fields.length !== 5 && fields.length !== 6) {
return { error: `Invalid: must have 5 or 6 fields, got ${fields.length}` };
}
return {
pretty: CronPrettifier.toString(cronExpression || '').toLowerCase(),
pretty: CronPrettifier.toString(expr).toLowerCase(),
};
} catch (e) {
console.error(e);
@@ -411,12 +433,15 @@ export function TriggerWorkflowForm({
type="text"
value={cronExpression}
onChange={(e) => setCronExpression(e.target.value)}
placeholder="e.g., 0 0 * * *"
placeholder="e.g., 0 0 * * *, 0 0 0 * * *, or CRON_TZ=America/New_York 0 0 * * *"
className="w-full"
/>
<div className="text-sm text-gray-500">
{cronPretty?.error || `(runs ${cronPretty?.pretty} UTC)`}
</div>
<div className="text-xs text-gray-400 mt-1">
Supports 5-field (minute hour day month weekday) or 6-field (second minute hour day month weekday) format. Timezone prefix (CRON_TZ= or TZ=) optional.
</div>
</div>
</TabsContent>
</Tabs>

View File

@@ -15,6 +15,7 @@ import (
"github.com/hatchet-dev/hatchet/pkg/repository"
"github.com/hatchet-dev/hatchet/pkg/repository/postgres/dbsqlc"
"github.com/hatchet-dev/hatchet/pkg/repository/postgres/sqlchelpers"
"github.com/hatchet-dev/hatchet/pkg/validator"
)
// runPollCronSchedules acquires a list of cron schedules from the database and schedules any which are not
@@ -87,8 +88,9 @@ func (t *TickerImpl) handleScheduleCron(ctx context.Context, cron *dbsqlc.PollCr
cronUUID := uuid.New()
// schedule the cron
withSeconds := validator.CronHasSeconds(cron.Cron)
_, err := t.userCronScheduler.NewJob(
gocron.CronJob(cron.Cron, false),
gocron.CronJob(cron.Cron, withSeconds),
gocron.NewTask(
t.runCronWorkflow(tenantId, workflowVersionId, cron.Cron, cronParentId, &cron.Name.String, cron.Input, additionalMetadata, &cron.Priority),
),

View File

@@ -3,6 +3,7 @@ package validator
import (
"encoding/json"
"regexp"
"strings"
"time"
"unicode"
@@ -36,7 +37,37 @@ func newValidator() *validator.Validate {
})
_ = validate.RegisterValidation("cron", func(fl validator.FieldLevel) bool {
_, err := cronParser.Parse(fl.Field().String())
var err error
cronExpr := fl.Field().String()
// Extract just the cron part for field count validation
cronPart := cronExpr
if strings.HasPrefix(cronExpr, "CRON_TZ=") {
parts := strings.SplitN(cronExpr, " ", 2)
if len(parts) == 2 {
cronPart = parts[1]
}
} else if strings.HasPrefix(cronExpr, "TZ=") {
parts := strings.SplitN(cronExpr, " ", 2)
if len(parts) == 2 {
cronPart = parts[1]
}
}
// Validate field count first - only allow 5 or 6 fields
fields := strings.Fields(cronPart)
if len(fields) != 5 && len(fields) != 6 {
return false
}
// Parse the cron part only - robfig/cron doesn't support timezone prefixes
if len(fields) == 6 {
// uses same logic as gocron internally does
p := cron.NewParser(cron.SecondOptional | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
_, err = p.Parse(cronPart)
} else {
_, err = cronParser.Parse(cronPart)
}
return err == nil
})
@@ -116,3 +147,25 @@ func isValidJSON(s string) bool {
var js map[string]interface{}
return json.Unmarshal([]byte(s), &js) == nil
}
// CronHasSeconds checks if a cron expression includes seconds (6 fields) or not (5 fields)
// Also validates that the expression has exactly 5 or 6 fields (rejects 7+ field expressions)
// Supports timezone prefixes like CRON_TZ= and TZ=
func CronHasSeconds(cronExpr string) bool {
// Extract just the cron part for field count analysis
cronPart := cronExpr
if strings.HasPrefix(cronExpr, "CRON_TZ=") {
parts := strings.SplitN(cronExpr, " ", 2)
if len(parts) == 2 {
cronPart = parts[1]
}
} else if strings.HasPrefix(cronExpr, "TZ=") {
parts := strings.SplitN(cronExpr, " ", 2)
if len(parts) == 2 {
cronPart = parts[1]
}
}
fields := strings.Fields(cronPart)
return len(fields) == 6
}

View File

@@ -56,6 +56,92 @@ func TestValidatorInvalidCron(t *testing.T) {
assert.ErrorContains(t, err, "validation for 'Cron' failed on the 'cron' tag", "should throw error on invalid cron")
}
func TestValidatorValidCronWithSeconds(t *testing.T) {
v := newValidator()
// Test 6-field cron expressions (with seconds)
testCases := []string{
"*/30 * * * * *", // Every 30 seconds
"0 */2 * * * *", // Every 2 minutes
"15 30 10 * * *", // Every day at 10:30:15
"0 0 0 1 * *", // First day of every month at midnight
"*/15 * * * * MON-FRI", // Every 15 seconds on weekdays
}
for _, cronExpr := range testCases {
err := v.Struct(&cronResource{
Cron: cronExpr,
})
assert.NoError(t, err, "should accept valid 6-field cron: %s", cronExpr)
}
}
func TestValidatorInvalidCronWithSeconds(t *testing.T) {
v := newValidator()
// Test invalid cron expressions (both 5 and 6-field)
testCases := []string{
"* * * *", // Too few fields
"* * * * * * *", // Too many fields
"invalid * * * *", // Invalid field value
"60 * * * * *", // Invalid seconds (60)
"* 60 * * * *", // Invalid minutes (60)
"* * 24 * * *", // Invalid hours (24)
}
for _, cronExpr := range testCases {
err := v.Struct(&cronResource{
Cron: cronExpr,
})
assert.ErrorContains(t, err, "validation for 'Cron' failed on the 'cron' tag", "should reject invalid cron: %s", cronExpr)
}
}
func TestCronHasSecondsDetection(t *testing.T) {
testCases := []struct {
expr string
hasSeconds bool
description string
}{
{"0 * * * *", false, "5-field standard cron"},
{"*/5 * * * *", false, "5-field with wildcard"},
{"0 0 * * *", false, "5-field daily"},
{"*/30 * * * * *", true, "6-field with seconds"},
{"0 */2 * * * *", true, "6-field every 2 minutes"},
{"15 30 10 * * *", true, "6-field specific time"},
{"0 0 0 1 * *", true, "6-field monthly"},
// Test timezone prefixes
{"CRON_TZ=America/New_York 0 * * * *", false, "5-field with CRON_TZ"},
{"TZ=UTC */30 * * * * *", true, "6-field with TZ"},
{"CRON_TZ=Europe/London 15 30 10 * * *", true, "6-field with CRON_TZ"},
}
for _, tc := range testCases {
result := CronHasSeconds(tc.expr)
assert.Equal(t, tc.hasSeconds, result, "Detection failed for %s: %s", tc.expr, tc.description)
}
}
func TestValidatorValidCronWithTimezone(t *testing.T) {
v := newValidator()
// Test cron expressions with timezone prefixes
testCases := []string{
"CRON_TZ=America/New_York 0 * * * *", // 5-field with CRON_TZ
"TZ=UTC */15 * * * *", // 5-field with TZ
"CRON_TZ=Europe/London */30 * * * * *", // 6-field with CRON_TZ
"TZ=Asia/Tokyo 0 */2 * * * *", // 6-field with TZ
"CRON_TZ=America/Los_Angeles 15 30 10 * * *", // 6-field specific time with timezone
}
for _, cronExpr := range testCases {
err := v.Struct(&cronResource{
Cron: cronExpr,
})
assert.NoError(t, err, "should accept valid cron with timezone: %s", cronExpr)
}
}
func TestValidatorValidDuration(t *testing.T) {
v := newValidator()

View File

@@ -2,6 +2,7 @@ package features
import (
"context"
"strings"
"github.com/google/uuid"
"github.com/robfig/cron/v3"
@@ -62,9 +63,26 @@ func NewCronsClient(
}
// ValidateCronExpression validates that a string is a valid cron expression.
// Supports both 5-field (standard) and 6-field (with seconds) cron expressions.
// Also supports timezone prefixes like CRON_TZ= and TZ=.
func ValidateCronExpression(expression string) bool {
parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
_, err := parser.Parse(expression)
// Extract cron part for validation (strip timezone if present)
cronPart := expression
if strings.HasPrefix(expression, "CRON_TZ=") {
parts := strings.SplitN(expression, " ", 2)
if len(parts) == 2 {
cronPart = parts[1]
}
} else if strings.HasPrefix(expression, "TZ=") {
parts := strings.SplitN(expression, " ", 2)
if len(parts) == 2 {
cronPart = parts[1]
}
}
// Validate using robfig/cron parser (which doesn't support timezone prefixes)
parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.SecondOptional)
_, err := parser.Parse(cronPart)
return err == nil
}

View File

@@ -38,6 +38,8 @@ class CreateCronTriggerConfig(BaseModel):
def validate_cron_expression(cls, v: str) -> str:
"""
Validates the cron expression to ensure it adheres to the expected format.
Supports both 5-field (minute hour day month weekday) and 6-field (second minute hour day month weekday) formats.
Also supports timezone prefixes like CRON_TZ= and TZ=.
:param v: The cron expression to validate.
@@ -48,10 +50,21 @@ class CreateCronTriggerConfig(BaseModel):
if not v:
raise ValueError("Cron expression is required")
parts = v.split()
if len(parts) != 5:
# Extract cron part for field count validation (timezone is supported but we validate the cron part)
cron_part = v
if v.startswith("CRON_TZ="):
parts = v.split(" ", 1)
if len(parts) == 2:
cron_part = parts[1]
elif v.startswith("TZ="):
parts = v.split(" ", 1)
if len(parts) == 2:
cron_part = parts[1]
parts = cron_part.split()
if len(parts) not in [5, 6]:
raise ValueError(
"Cron expression must have 5 parts: minute hour day month weekday"
"Cron expression must have 5 parts (minute hour day month weekday) or 6 parts (second minute hour day month weekday)"
)
for part in parts:

View File

@@ -8,12 +8,45 @@ import { applyNamespace } from '@hatchet/util/apply-namespace';
import { HatchetClient } from '../client';
import { workflowNameString, WorkflowsClient } from './workflows';
/**
* Validates a cron expression to support both 5-field and 6-field formats.
* Also supports timezone prefixes like CRON_TZ= and TZ=.
* @param expression - The cron expression to validate.
* @returns True if valid, false otherwise.
*/
function validateCronExpression(expression: string): boolean {
if (!expression || typeof expression !== 'string') {
return false;
}
// Extract cron part for field count validation (timezone is supported but we validate the cron part)
let cronPart = expression;
if (expression.startsWith('CRON_TZ=')) {
const parts = expression.split(' ', 2);
if (parts.length === 2) {
cronPart = parts[1];
}
} else if (expression.startsWith('TZ=')) {
const parts = expression.split(' ', 2);
if (parts.length === 2) {
cronPart = parts[1];
}
}
const fields = cronPart.trim().split(/\s+/);
// Only allow 5 or 6 fields (not 7 with year field)
return fields.length === 5 || fields.length === 6;
}
/**
* Schema for creating a Cron Trigger.
*/
export const CreateCronTriggerSchema = z.object({
name: z.string(),
expression: z.string(),
expression: z.string().refine(validateCronExpression, {
message: 'Cron expression must have 5 fields (minute hour day month weekday) or 6 fields (second minute hour day month weekday)',
}),
input: z.record(z.any()).optional(),
additionalMetadata: z.record(z.string()).optional(),
priority: z.number().optional(),