You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix(plugin-reports): a non-member schedule timezone no longer discards the cron expression
`ReportService.scheduleReport`'s eager guard used the callback-less
`new Cron(expr, { timezone })`, which validates the EXPRESSION and lets any
string through as the zone: croner defers that judgement to `nextRun()`.
`nextRunAt` then caught the deferred `CronDate` TypeError and returned
`from + interval_minutes`, so a schedule authored as "every weekday 09:00
Asia/Shanghai" fired every 1440 minutes forever, re-derived on every sweep
through `advanceSchedule` — the verbatim outcome the guard's own comment says
it exists to prevent — and the single warning it emitted named the cron
expression, which was fine, rather than the timezone, which was not.
- `scheduleReport` asks `isValueDomainMember('iana_time_zone', …)` from
`@objectstack/spec/shared` — the same predicate `sys_report_schedule.timezone`
enforces on write — so the service door and the storage door give one answer,
and the refusal names the input that is actually wrong. The row now stores the
same string the scheduler evaluates.
- On a sweep, a schedule whose stored zone is not a member and which carries a
cron expression is not run and its `next_run_at` is not advanced;
`last_status` / `last_error` (both pre-existing columns) carry the reason.
`active` and the past `next_run_at` are left alone deliberately, so correcting
the zone resumes the schedule on the next sweep with no second action.
Interval-only schedules are untouched — interval arithmetic never reads the
zone.
- Both fall-back warnings now name the expression AND the zone, and the second
no longer asserts the expression is the broken half.
Fixes#16291
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
`ReportService` no longer discards a report schedule's cron expression when its `timezone` is not a real IANA zone — and when it does fall back to interval, it says which of the two inputs failed.
6
+
7
+
croner (10.0.1) answers a non-member zone in three different ways, and only the middle one was ever reached here: `new Cron(expr, { timezone })`**without a callback** validates the expression and lets any zone through, `nextRun()` on that instance then throws a `CronDate` conversion `TypeError`, and the callback form throws at construction. `scheduleReport`'s eager guard used the callback-less form, so the timezone half of its own input passed straight under a guard whose stated purpose was "a clear error at schedule time instead of a schedule that silently falls back to interval on sweep" — and `nextRunAt` caught that deferred throw and returned `from + interval_minutes`. A schedule authored as "every weekday 09:00 Asia/Shanghai" became "every 1440 minutes, forever", re-derived on every sweep, logged only as a complaint about a cron expression that was perfectly good.
8
+
9
+
-**The create-time guard now asks the right question.**`scheduleReport` consults `isValueDomainMember('iana_time_zone', …)` from `@objectstack/spec/shared` — the same predicate `sys_report_schedule.timezone`'s `valueDomain` declaration enforces on write — and refuses a non-member with `VALIDATION_FAILED: invalid timezone '<zone>': not a member of the 'iana_time_zone' value domain`. One answer at both doors, so this one cannot accept what the storage gate refuses; it only says so earlier and names the input that is actually wrong. It applies whether or not a `cron_expression` is set, because the storage gate does too.
10
+
-**The row now stores the string the scheduler evaluates.** An empty `timezone` was stored verbatim while every `new Cron` call site read it as `UTC`; it is normalised to `UTC` on the way in.
11
+
-**A schedule already holding an unusable zone is stopped, not rescheduled.**`valueDomain` is written-values-only, so rows stored before that declaration are never re-validated and no refusal reaches them. On a sweep, a schedule with a `cron_expression` whose zone is not a member is now not run and its `next_run_at` is not advanced; `last_status` becomes `failed` and `last_error` names the zone, the expression and what to do. `active` stays set and `next_run_at` stays in the past deliberately — the same posture this loop already takes for a schedule whose report has vanished — so correcting the zone resumes the schedule on the next sweep with no second action. Interval-only schedules are untouched: interval arithmetic never consults the zone, so a legacy bad value there still delivers on the cadence its author asked for.
12
+
-**Both fall-back warnings name both inputs.** The "no next occurrence" and the former "invalid cron" lines each mentioned only the expression, so either of them on a timezone fault sent an investigator to audit the half that was fine. They now carry the expression *and* the zone, and the second no longer asserts the expression is the broken one.
0 commit comments