mirror of
https://github.com/hatchet-dev/hatchet.git
synced 2025-12-30 05:09:44 -06:00
seconds cron field support
This commit is contained in:
@@ -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}`;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user