Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions cmd/hatchet-migrate/migrate/migrations/20260512135156_v1_0_106.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
-- +goose Up
-- +goose StatementBegin
CREATE OR REPLACE FUNCTION convert_duration_to_interval(duration text) RETURNS interval AS $$
DECLARE
rest text;
sign_factor double precision := 1;
total_seconds double precision := 0;
m text[];
val double precision;
unit text;
factor double precision;
consumed integer;
BEGIN
IF duration IS NULL OR length(duration) = 0 THEN
RETURN '5 minutes'::interval;
END IF;

-- Legacy single-unit suffixes (d, w, y) keep calendar semantics for
-- backward compatibility. Only accepted as the entire input.
m := regexp_match(duration, '^([0-9]+)(d|w|y)$');
IF m IS NOT NULL THEN
val := m[1]::double precision;
unit := m[2];
CASE unit
WHEN 'd' THEN RETURN make_interval(days => val::int);
WHEN 'w' THEN RETURN make_interval(days => (val * 7)::int);
WHEN 'y' THEN RETURN make_interval(months => (val * 12)::int);
END CASE;
END IF;

rest := duration;

IF left(rest, 1) = '-' THEN
sign_factor := -1;
rest := substring(rest from 2);
ELSIF left(rest, 1) = '+' THEN
rest := substring(rest from 2);
END IF;

IF length(rest) = 0 THEN
RETURN '5 minutes'::interval;
END IF;

LOOP
EXIT WHEN length(rest) = 0;

m := regexp_match(rest, '^([0-9]+(?:\.[0-9]*)?|\.[0-9]+)(ms|s|m|h)');

IF m IS NULL THEN
RETURN '5 minutes'::interval;
END IF;

val := m[1]::double precision;
unit := m[2];

CASE unit
WHEN 'ms' THEN factor := 1e-3;
WHEN 's' THEN factor := 1;
WHEN 'm' THEN factor := 60;
WHEN 'h' THEN factor := 3600;
END CASE;

total_seconds := total_seconds + val * factor;

consumed := length(m[1]) + length(m[2]);
rest := substring(rest from consumed + 1);
END LOOP;

RETURN make_interval(secs => sign_factor * total_seconds);
END;
$$ LANGUAGE plpgsql;
-- +goose StatementEnd

-- +goose Down
-- +goose StatementBegin
CREATE OR REPLACE FUNCTION convert_duration_to_interval(duration text) RETURNS interval AS $$
DECLARE
num_value INT;
BEGIN
num_value := substring(duration from '^\d+');

RETURN CASE
WHEN duration LIKE '%ms' THEN make_interval(secs => num_value::float / 1000)
WHEN duration LIKE '%s' THEN make_interval(secs => num_value)
WHEN duration LIKE '%m' THEN make_interval(mins => num_value)
WHEN duration LIKE '%h' THEN make_interval(hours => num_value)
WHEN duration LIKE '%d' THEN make_interval(days => num_value)
WHEN duration LIKE '%w' THEN make_interval(days => num_value * 7)
WHEN duration LIKE '%y' THEN make_interval(months => num_value * 12)
ELSE '5 minutes'::interval
END;
END;
$$ LANGUAGE plpgsql;
-- +goose StatementEnd
10 changes: 7 additions & 3 deletions frontend/docs/pages/v1/error-handling/timeouts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,23 @@ There are two types of timeouts in Hatchet:

## Timeout Format

In Hatchet, timeouts are specified using a string in the format `<number><unit>`, where `<number>` is an integer and `<unit>` is one of:
In Hatchet, timeouts are duration strings: a sequence of decimal numbers, each with an optional fraction and a unit suffix. Valid units are:

- `ms` for milliseconds
- `s` for seconds
- `m` for minutes
- `h` for hours

For example:
Components are summed. For example:

- `10s` means 10 seconds
- `4m` means 4 minutes
- `1h` means 1 hour
- `1h30m` means 1 hour 30 minutes
- `42m30s` means 42 minutes 30 seconds
- `1.5h` means 1 hour 30 minutes

If no unit is specified, seconds are assumed.
A unit is required; a bare number like `42` is rejected.

<Callout type="info">
In the Python SDK, timeouts can also be specified as a `datetime.timedelta`
Expand Down
10 changes: 7 additions & 3 deletions frontend/docs/pages/v1/timeouts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,23 @@ There are two types of timeouts in Hatchet:

## Timeout Format

In Hatchet, timeouts are specified using a string in the format `<number><unit>`, where `<number>` is an integer and `<unit>` is one of:
In Hatchet, timeouts are duration strings: a sequence of decimal numbers, each with an optional fraction and a unit suffix. Valid units are:

- `ms` for milliseconds
- `s` for seconds
- `m` for minutes
- `h` for hours

For example:
Components are summed. For example:

- `10s` means 10 seconds
- `4m` means 4 minutes
- `1h` means 1 hour
- `1h30m` means 1 hour 30 minutes
- `42m30s` means 42 minutes 30 seconds
- `1.5h` means 1 hour 30 minutes

If no unit is specified, seconds are assumed.
A unit is required; a bare number like `42` is rejected.

<Callout type="info">
In the Python SDK, timeouts can also be specified as a `datetime.timedelta`
Expand Down
75 changes: 75 additions & 0 deletions pkg/repository/duration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
//go:build !e2e && !load && !rampup && !integration

package repository

import (
"context"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestConvertDurationToInterval(t *testing.T) {
pool, cleanup := setupPostgresWithMigration(t)
defer cleanup()

ctx := context.Background()

epochCases := []struct {
name string
input string
seconds float64
}{
{"single second", "42s", 42},
{"single minute", "11m", 660},
{"single hour", "1h", 3600},
{"minute and second", "42m30s", 2550},
{"hour and minute", "1h30m", 5400},
{"hour minute second", "1h30m5s", 5405},
{"milliseconds", "1500ms", 1.5},
{"decimal hour", "1.5h", 5400},
{"negative decimal hour", "-1.5h", -5400},
{"zero with unit", "0s", 0},
{"invalid falls back to five minutes", "bad", 300},
{"mixed multi-unit and legacy is rejected", "30s1d", 300},
{"missing unit falls back", "42", 300},
}

for _, tc := range epochCases {
t.Run(tc.name, func(t *testing.T) {
var got float64
err := pool.QueryRow(ctx,
`SELECT EXTRACT(EPOCH FROM convert_duration_to_interval($1))::double precision`,
tc.input,
).Scan(&got)
require.NoError(t, err)

assert.InDelta(t, tc.seconds, got, 1e-9, "input=%q", tc.input)
})
}

legacyCases := []struct {
name string
input string
expected string
}{
{"single day stays calendar", "1d", "1 day"},
{"single week stays calendar", "1w", "7 days"},
{"single year stays calendar", "1y", "12 mons"},
{"multiple days stay calendar", "10d", "10 days"},
}

for _, tc := range legacyCases {
t.Run(tc.name, func(t *testing.T) {
var equal bool
err := pool.QueryRow(ctx,
`SELECT convert_duration_to_interval($1) = $2::interval`,
tc.input, tc.expected,
).Scan(&equal)
require.NoError(t, err)

assert.True(t, equal, "input=%q expected interval=%q", tc.input, tc.expected)
})
}
}
25 changes: 25 additions & 0 deletions pkg/validator/validator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,36 @@ func TestValidatorDuration(t *testing.T) {
name: "valid duration",
input: "5s",
},
{
name: "minutes and seconds",
input: "42m30s",
},
{
name: "hours minutes seconds",
input: "1h30m5s",
},
{
name: "decimal hour",
input: "1.5h",
},
{
name: "milliseconds",
input: "1500ms",
},
{
name: "negative decimal hour",
input: "-1.5h",
},
{
name: "invalid duration (missing unit)",
input: "5",
wantErrTag: "duration",
},
{
name: "invalid duration (trailing garbage)",
input: "42m30sX",
wantErrTag: "duration",
},
}

v := newValidator()
Expand Down