Browse Source

feat: the scheduler now honours the cron expression it always asked for

The schedule-trigger node has offered a cron expression and a timezone since
it was written, and the scheduler read neither. A workflow set to "0 2 * * 1"
ran every N minutes instead, and nothing anywhere said so - the trigger looked
configured, the scheduler looked healthy, and the workflow ran on a schedule
nobody had chosen.

lib/common/cron parses the five-field form people actually write - "*", "5",
"1,15", "2-6", "*/15", "2-8/2", Sunday as 0 or 7 - and answers the only
question the scheduler asks: the first matching time strictly after now, in
the workflow's timezone. Walking local minutes rather than doing arithmetic on
a UTC instant is what makes daylight saving come out right: on the night the
clocks go forward 02:30 does not exist, and on the night they go back it
happens twice. choose::earliest fires the first once and the second once.

Three things that were being decided from the wrong field:

- A cron that cannot be parsed leaves the workflow unscheduled, with the
  offending field named in the log. Falling back to the interval would have
  recreated the very bug this fixes.
- next_run is only carried across a re-registration when the schedule itself
  is unchanged. It was carried unconditionally, so switching an hourly trigger
  to "Monday 02:00" left it due in 54 minutes - and, long before cron existed,
  shortening an interval left it waiting the old one out.
- The stuck-run timeout no longer derives from interval_minutes on a cron
  entry, where that field is the poll interval and not the schedule.

The controller's registration site was also dropping the overlap policy,
maxConcurrent and maxRunMinutes on the floor - a separate bug, fixed here
because this is the call it was getting wrong.
fszontagh 1 month ago
parent
commit
4f982634ab

+ 1 - 0
CMakeLists.txt

@@ -21,6 +21,7 @@ add_library(smartbotic_common STATIC
     lib/common/string_utils.cpp
     lib/common/config_defaults.cpp
     lib/common/config_arg.cpp
+    lib/common/cron.cpp
 )
 target_include_directories(smartbotic_common PUBLIC
     ${CMAKE_CURRENT_SOURCE_DIR}/lib

+ 232 - 0
lib/common/cron.cpp

@@ -0,0 +1,232 @@
+#include "common/cron.hpp"
+
+#include <algorithm>
+#include <sstream>
+#include <vector>
+
+namespace smartbotic::common {
+
+namespace {
+
+std::vector<std::string> split(const std::string& text, char separator) {
+    std::vector<std::string> parts;
+    std::string current;
+    std::istringstream stream(text);
+    while (std::getline(stream, current, separator)) {
+        parts.push_back(current);
+    }
+    return parts;
+}
+
+std::string trim(const std::string& text) {
+    const auto first = text.find_first_not_of(" \t\r\n");
+    if (first == std::string::npos) return {};
+    const auto last = text.find_last_not_of(" \t\r\n");
+    return text.substr(first, last - first + 1);
+}
+
+// Fills `slots` from one field. Reports what was wrong rather than which
+// character it stopped at, because the person reading has the expression in
+// front of them and needs to know which part of it to change.
+std::optional<std::string> fillField(const std::string& field, int low, int high,
+                                     bool* slots, bool& restricted,
+                                     const char* field_name) {
+    const std::string spec = trim(field);
+    if (spec.empty()) {
+        return std::string("the ") + field_name + " field is empty";
+    }
+
+    restricted = (spec != "*");
+
+    for (const auto& piece : split(spec, ',')) {
+        const std::string part = trim(piece);
+        if (part.empty()) {
+            return std::string("the ") + field_name + " field has an empty entry between commas";
+        }
+
+        // "*/5" and "2-8/2" both put the step after a slash.
+        int step = 1;
+        std::string range_part = part;
+        const auto slash = part.find('/');
+        if (slash != std::string::npos) {
+            range_part = part.substr(0, slash);
+            const std::string step_text = part.substr(slash + 1);
+            try {
+                step = std::stoi(step_text);
+            } catch (const std::exception&) {
+                return std::string("the ") + field_name + " field has \"" + step_text +
+                       "\" after a slash, which has to be a whole number";
+            }
+            if (step <= 0) {
+                return std::string("the ") + field_name + " field has a step of " +
+                       std::to_string(step) + ", which has to be at least 1";
+            }
+        }
+
+        int from = low;
+        int to = high;
+        if (range_part != "*") {
+            const auto dash = range_part.find('-');
+            try {
+                if (dash == std::string::npos) {
+                    from = to = std::stoi(range_part);
+                    // "5/15" means "from 5 onwards in steps of 15", not "5 only".
+                    if (slash != std::string::npos) {
+                        to = high;
+                    }
+                } else {
+                    from = std::stoi(range_part.substr(0, dash));
+                    to = std::stoi(range_part.substr(dash + 1));
+                }
+            } catch (const std::exception&) {
+                return std::string("the ") + field_name + " field has \"" + range_part +
+                       "\", which is not a number or a range";
+            }
+        }
+
+        // Sunday is 0 in this implementation; 7 is accepted because half the
+        // world writes it that way.
+        if (std::string(field_name) == "day-of-week") {
+            if (from == 7) from = 0;
+            if (to == 7) to = 0;
+        }
+
+        if (from < low || from > high || to < low || to > high) {
+            return std::string("the ") + field_name + " field has " + std::to_string(from) +
+                   "-" + std::to_string(to) + ", outside the allowed " + std::to_string(low) +
+                   "-" + std::to_string(high);
+        }
+        if (from > to) {
+            return std::string("the ") + field_name + " field has the range " +
+                   std::to_string(from) + "-" + std::to_string(to) + " backwards";
+        }
+
+        for (int value = from; value <= to; value += step) {
+            slots[value] = true;
+        }
+    }
+    return std::nullopt;
+}
+
+} // namespace
+
+Result<CronSchedule> CronSchedule::parse(const std::string& expression) {
+    const std::string trimmed = trim(expression);
+    if (trimmed.empty()) {
+        return Error(ErrorCode::InvalidArgument, "The cron expression is empty");
+    }
+
+    std::vector<std::string> fields;
+    std::istringstream stream(trimmed);
+    std::string field;
+    while (stream >> field) {
+        fields.push_back(field);
+    }
+    if (fields.size() != 5) {
+        return Error(ErrorCode::InvalidArgument,
+                     "A cron expression has five fields - minute hour day-of-month month "
+                     "day-of-week - but this one has " + std::to_string(fields.size()) +
+                     ": \"" + trimmed + "\"");
+    }
+
+    CronSchedule schedule;
+    schedule.expression_ = trimmed;
+    bool ignored = false;
+
+    struct FieldSpec { const std::string& text; int low; int high; bool* slots; bool* restricted;
+                       const char* name; };
+    const FieldSpec specs[] = {
+        {fields[0], 0, 59, schedule.minutes_,       &ignored,                          "minute"},
+        {fields[1], 0, 23, schedule.hours_,         &ignored,                          "hour"},
+        {fields[2], 1, 31, schedule.days_of_month_, &schedule.day_of_month_restricted_, "day-of-month"},
+        {fields[3], 1, 12, schedule.months_,        &ignored,                          "month"},
+        {fields[4], 0, 6,  schedule.days_of_week_,  &schedule.day_of_week_restricted_,  "day-of-week"},
+    };
+    for (const auto& spec : specs) {
+        auto problem = fillField(spec.text, spec.low, spec.high, spec.slots, *spec.restricted,
+                                 spec.name);
+        if (problem) {
+            return Error(ErrorCode::InvalidArgument, *problem + " (in \"" + trimmed + "\")");
+        }
+    }
+
+    return schedule;
+}
+
+std::optional<std::chrono::system_clock::time_point> CronSchedule::nextAfter(
+    std::chrono::system_clock::time_point after, const std::string& timezone) const {
+
+    const std::chrono::time_zone* zone = nullptr;
+    try {
+        zone = std::chrono::locate_zone(timezone.empty() ? "UTC" : timezone);
+    } catch (const std::exception&) {
+        try {
+            zone = std::chrono::locate_zone("UTC");
+        } catch (const std::exception&) {
+            return std::nullopt;
+        }
+    }
+
+    // Walked a minute at a time in local time, which is what a cron expression
+    // is written in. Four years of minutes is the ceiling: it covers the 29th
+    // of February, and an expression that matches nothing in four years matches
+    // nothing ever.
+    //
+    // Local time is also why this cannot be arithmetic on a UTC instant. On the
+    // night a clock goes back, 02:30 happens twice; when it goes forward, it
+    // does not happen at all. zoned_time decides which instant a local time
+    // means, so a schedule set for 02:30 behaves the way the person who set it
+    // expects rather than the way an offset would.
+    auto local = std::chrono::zoned_time{zone, after}.get_local_time();
+    local = std::chrono::floor<std::chrono::minutes>(local) + std::chrono::minutes(1);
+
+    constexpr int kMaxMinutes = 4 * 366 * 24 * 60;
+    for (int step = 0; step < kMaxMinutes; ++step, local += std::chrono::minutes(1)) {
+        const auto day = std::chrono::floor<std::chrono::days>(local);
+        const std::chrono::year_month_day date{day};
+        const std::chrono::hh_mm_ss time{local - day};
+
+        const int minute = static_cast<int>(time.minutes().count());
+        const int hour = static_cast<int>(time.hours().count());
+        const int day_of_month = static_cast<int>(unsigned(date.day()));
+        const int month = static_cast<int>(unsigned(date.month()));
+        const int day_of_week = static_cast<int>(
+            std::chrono::weekday{day}.c_encoding());  // Sunday is 0
+
+        if (!minutes_[minute] || !hours_[hour] || !months_[month]) {
+            continue;
+        }
+
+        // The historical day rule: when both day fields are restricted a match
+        // on either is enough, so "0 0 1 * 1" is the first of the month AND
+        // every Monday. When only one is restricted, that one has to match.
+        const bool dom_ok = days_of_month_[day_of_month];
+        const bool dow_ok = days_of_week_[day_of_week];
+        bool day_ok;
+        if (day_of_month_restricted_ && day_of_week_restricted_) {
+            day_ok = dom_ok || dow_ok;
+        } else {
+            day_ok = dom_ok && dow_ok;
+        }
+        if (!day_ok) {
+            continue;
+        }
+
+        // choose::earliest decides the two cases a wall clock cannot express
+        // on its own, and both decisions are deliberate.
+        //
+        // When the clocks go forward, a schedule set for 02:30 names a time
+        // that does not happen. This fires it at the transition instead - once,
+        // just after the gap - because a daily job silently not running one day
+        // a year is the worse of the two failures.
+        //
+        // When the clocks go back, 02:30 happens twice. This takes the first,
+        // so the job runs once rather than twice; a workflow that posts or
+        // charges would otherwise do it twice on that one night.
+        return std::chrono::zoned_time{zone, local, std::chrono::choose::earliest}.get_sys_time();
+    }
+
+    return std::nullopt;
+}
+
+} // namespace smartbotic::common

+ 55 - 0
lib/common/cron.hpp

@@ -0,0 +1,55 @@
+#pragma once
+
+#include <chrono>
+#include <optional>
+#include <string>
+
+#include "common/error.hpp"
+
+namespace smartbotic::common {
+
+// A five-field cron expression: minute hour day-of-month month day-of-week.
+//
+// Supports the forms people actually write - "*", "5", "1,15", "2-6", "*/15",
+// "2-8/2" - and Sunday as either 0 or 7. Seconds are not a field: the scheduler
+// ticks in minutes, and offering a resolution it cannot honour would be a lie.
+//
+// This exists because the schedule-trigger node has offered a cron expression
+// and a timezone since it was written, and nothing ever read them. A workflow
+// set to "0 2 * * 1" ran every N minutes instead, and nothing said so.
+class CronSchedule {
+public:
+    // Parses, or explains what is wrong with the expression. The message names
+    // the offending field, because "invalid cron expression" tells somebody
+    // staring at five numbers nothing they did not already know.
+    static Result<CronSchedule> parse(const std::string& expression);
+
+    // The first matching time strictly after `after`, in the named timezone.
+    //
+    // Returns nothing when the expression can never match - "30 4 31 2 *", the
+    // 31st of February - rather than searching for ever. A schedule that cannot
+    // fire is a mistake worth reporting, not a workflow that quietly never runs.
+    //
+    // An unknown timezone falls back to UTC and is reported by the caller; DST
+    // is the standard library's problem, not this class's.
+    std::optional<std::chrono::system_clock::time_point> nextAfter(
+        std::chrono::system_clock::time_point after,
+        const std::string& timezone) const;
+
+    const std::string& expression() const { return expression_; }
+
+private:
+    // One bit per possible value, so matching is a lookup rather than a search.
+    bool minutes_[60] = {};
+    bool hours_[24] = {};
+    bool days_of_month_[32] = {};   // 1-31, index 0 unused
+    bool months_[13] = {};          // 1-12, index 0 unused
+    bool days_of_week_[7] = {};     // 0-6, Sunday is 0
+    // Cron's day rule is an OR when both day fields are restricted, and an AND
+    // when only one is - the historical behaviour every implementation copies.
+    bool day_of_month_restricted_ = false;
+    bool day_of_week_restricted_ = false;
+    std::string expression_;
+};
+
+} // namespace smartbotic::common

+ 20 - 5
src/webserver/api/workflow_controller.cpp

@@ -1242,17 +1242,32 @@ void WorkflowController::updateScheduledTriggers(const std::string& workflow_id,
         auto config = common::applyConfigDefaults(
             node.value("config", nlohmann::json::object()), node_def.config_schema);
         int interval = config.value("pollInterval", 0);
-
-        if (interval > 0) {
+            // A cron expression is only honoured when the trigger is in cron mode.
+            // Both fields exist on every schedule-trigger, and reading the
+            // expression regardless would turn an interval trigger that happens
+            // to carry a leftover expression into a cron one.
+            const std::string mode = config.value("mode", std::string("interval"));
+            const std::string cron_expression =
+                mode == "cron" ? config.value("cronExpression", std::string()) : std::string();
+            const std::string timezone = config.value("timezone", std::string());
+
+        if (interval > 0 || !cron_expression.empty()) {
+            // The overlap settings are read here too. They were left at their
+            // defaults on this path while the startup path passed them, so a
+            // workflow re-registered by a save quietly lost its overlap policy
+            // until the next restart.
             scheduler_.registerWorkflow(
                 workflow_id,
                 workflow_name,
                 node_id,
                 node_type,
-                interval
+                interval,
+                overlapPolicyFromString(config.value("overlapPolicy", std::string("skip"))),
+                config.value("maxConcurrent", 1),
+                config.value("maxRunMinutes", 0),
+                cron_expression,
+                timezone
             );
-            LOG_INFO("Registered workflow '{}' for scheduled execution every {} minutes",
-                     workflow_name, interval);
         }
     }
 }

+ 93 - 12
src/webserver/scheduler/workflow_scheduler.cpp

@@ -50,6 +50,39 @@ void WorkflowScheduler::stop() {
     spdlog::info("Workflow scheduler stopped");
 }
 
+namespace {
+
+// When this entry should next run, as a steady_clock point.
+//
+// For a cron entry the answer comes from wall-clock time in the workflow's
+// timezone and is then expressed as a delay from `from`. Recomputed after every
+// run rather than advanced by a fixed step, so a schedule stays pinned to the
+// times it names even across a daylight-saving change.
+std::chrono::steady_clock::time_point nextRunAfter(const ScheduledWorkflow& entry,
+                                                   std::chrono::steady_clock::time_point from) {
+    if (!entry.cron) {
+        return from + std::chrono::minutes(entry.interval_minutes);
+    }
+
+    const auto wall_now = std::chrono::system_clock::now();
+    auto wall_next = entry.cron->nextAfter(wall_now, entry.timezone);
+    if (!wall_next) {
+        // Nothing this expression can ever match - "30 4 31 2 *" and the like.
+        // Parked rather than retried every tick, and said out loud once here.
+        spdlog::error("Workflow '{}' has cron \"{}\" which can never match a real date; "
+                      "it will not run", entry.workflow_name, entry.cron->expression());
+        return from + std::chrono::hours(24 * 365);
+    }
+
+    auto delay = *wall_next - wall_now;
+    if (delay < std::chrono::seconds(0)) {
+        delay = std::chrono::seconds(0);
+    }
+    return from + std::chrono::duration_cast<std::chrono::steady_clock::duration>(delay);
+}
+
+} // namespace
+
 void WorkflowScheduler::registerWorkflow(
     const std::string& workflow_id,
     const std::string& workflow_name,
@@ -58,9 +91,26 @@ void WorkflowScheduler::registerWorkflow(
     int interval_minutes,
     OverlapPolicy overlap_policy,
     int max_concurrent,
-    int max_run_minutes
+    int max_run_minutes,
+    const std::string& cron_expression,
+    const std::string& timezone
 ) {
-    if (interval_minutes <= 0) {
+    std::optional<common::CronSchedule> cron;
+    if (!cron_expression.empty()) {
+        auto parsed = common::CronSchedule::parse(cron_expression);
+        if (parsed.failed()) {
+            // Not scheduled at all. Falling back to the interval would be the
+            // very failure this replaced: a workflow running on a schedule
+            // nobody asked for, with nothing saying so.
+            spdlog::error("Workflow '{}' ({}) has a cron expression that cannot be used, so it "
+                          "is not scheduled: {}", workflow_name, workflow_id,
+                          parsed.error().message());
+            return;
+        }
+        cron = parsed.value();
+    }
+
+    if (!cron && interval_minutes <= 0) {
         spdlog::debug("Workflow {} has interval 0, not scheduling", workflow_id);
         return;
     }
@@ -75,8 +125,10 @@ void WorkflowScheduler::registerWorkflow(
     entry.trigger_node_id = trigger_node_id;
     entry.trigger_type = trigger_type;
     entry.interval_minutes = interval_minutes;
+    entry.cron = cron;
+    entry.timezone = timezone;
     entry.last_run = now;  // Consider it just ran to avoid immediate execution
-    entry.next_run = now + std::chrono::minutes(interval_minutes);
+    entry.next_run = nextRunAfter(entry, now);
     entry.overlap_policy = overlap_policy;
     entry.max_concurrent = max_concurrent > 0 ? max_concurrent : 1;
     entry.max_run_minutes = max_run_minutes;
@@ -87,14 +139,32 @@ void WorkflowScheduler::registerWorkflow(
     if (existing != workflows_.end()) {
         entry.active_runs = existing->second.active_runs;
         entry.pending_start = existing->second.pending_start;
-        entry.next_run = existing->second.next_run;
+
+        // The next run is only carried over when the schedule itself has not
+        // changed. Keeping it unconditionally means a schedule edit does not
+        // take effect until the workflow next fires on the OLD schedule -
+        // switching an hourly trigger to "Monday 02:00" left it due in 54
+        // minutes, and changing an interval from an hour to five minutes left
+        // it waiting the old hour out.
+        const auto& before = existing->second;
+        const std::string before_cron = before.cron ? before.cron->expression() : std::string();
+        const std::string after_cron = entry.cron ? entry.cron->expression() : std::string();
+        const bool same_schedule = before_cron == after_cron &&
+                                   before.timezone == entry.timezone &&
+                                   before.interval_minutes == entry.interval_minutes;
+        if (same_schedule) {
+            entry.next_run = before.next_run;
+        }
     }
 
     workflows_[workflow_id] = entry;
 
-    spdlog::info("Scheduled workflow '{}' ({}) with {} trigger, interval: {} minutes, "
-                 "overlap: {}, maxConcurrent: {}",
-                 workflow_name, workflow_id, trigger_type, interval_minutes,
+    const std::string when = entry.cron
+        ? ("cron \"" + entry.cron->expression() + "\"" +
+           (entry.timezone.empty() ? std::string(" (UTC)") : " (" + entry.timezone + ")"))
+        : ("every " + std::to_string(interval_minutes) + " minutes");
+    spdlog::info("Scheduled workflow '{}' ({}) with {} trigger, {}, overlap: {}, maxConcurrent: {}",
+                 workflow_name, workflow_id, trigger_type, when,
                  overlapPolicyToString(entry.overlap_policy), entry.max_concurrent);
 }
 
@@ -184,6 +254,14 @@ void WorkflowScheduler::notifyExecutionStarted(const std::string& workflow_id,
     }
 
     int minutes = it->second.max_run_minutes;
+    if (minutes <= 0 && it->second.cron) {
+        // Deriving the stuck-run timeout from interval_minutes would be wrong
+        // here: on a cron entry that field is the trigger's poll interval, not
+        // its schedule. The gap to the next occurrence is the real horizon -
+        // past it, a run that has not finished is holding up the next one.
+        const auto gap = it->second.next_run - std::chrono::steady_clock::now();
+        minutes = static_cast<int>(std::chrono::duration_cast<std::chrono::minutes>(gap).count());
+    }
     if (minutes <= 0) {
         minutes = it->second.interval_minutes * 5;
     }
@@ -332,7 +410,7 @@ void WorkflowScheduler::checkAndExecute() {
                     if (active >= entry.max_concurrent) {
                         spdlog::info("Workflow '{}' at concurrency limit {}, skipping tick",
                                      entry.workflow_name, entry.max_concurrent);
-                        entry.next_run = now + std::chrono::minutes(entry.interval_minutes);
+                        entry.next_run = nextRunAfter(entry, now);
                         continue;
                     }
                     break;
@@ -344,7 +422,7 @@ void WorkflowScheduler::checkAndExecute() {
                                          entry.workflow_name);
                             entry.pending_start = true;
                         }
-                        entry.next_run = now + std::chrono::minutes(entry.interval_minutes);
+                        entry.next_run = nextRunAfter(entry, now);
                         continue;
                     }
                     break;
@@ -355,7 +433,7 @@ void WorkflowScheduler::checkAndExecute() {
                         spdlog::info("Workflow '{}' still running ({} in flight), skipping this "
                                      "tick",
                                      entry.workflow_name, active);
-                        entry.next_run = now + std::chrono::minutes(entry.interval_minutes);
+                        entry.next_run = nextRunAfter(entry, now);
                         continue;
                     }
                     break;
@@ -389,9 +467,12 @@ void WorkflowScheduler::checkAndExecute() {
                 // and the following tick fires immediately.
                 auto dispatched_at = std::chrono::steady_clock::now();
                 it->second.last_run = dispatched_at;
-                it->second.next_run = dispatched_at + std::chrono::minutes(it->second.interval_minutes);
+                it->second.next_run = nextRunAfter(it->second, dispatched_at);
 
-                auto next_in_minutes = it->second.interval_minutes;
+                // Read back from next_run rather than the interval, which a
+                // cron entry does not run on.
+                const auto next_in_minutes = std::chrono::duration_cast<std::chrono::minutes>(
+                    it->second.next_run - dispatched_at).count();
                 spdlog::debug("Workflow '{}' next run in {} minutes",
                               entry.workflow_name, next_in_minutes);
             }

+ 21 - 1
src/webserver/scheduler/workflow_scheduler.hpp

@@ -7,6 +7,9 @@
 #include <thread>
 #include <atomic>
 #include <chrono>
+#include <optional>
+
+#include "common/cron.hpp"
 #include <functional>
 #include <nlohmann/json.hpp>
 
@@ -34,6 +37,16 @@ struct ScheduledWorkflow {
     std::chrono::steady_clock::time_point last_run;
     std::chrono::steady_clock::time_point next_run;
 
+    // Set when the trigger asked for a cron schedule rather than an interval.
+    // The two are exclusive: with a cron, interval_minutes is not consulted.
+    //
+    // next_run stays a steady_clock point even for a cron entry. steady_clock
+    // is what the tick loop compares against and it does not jump when the
+    // system clock is corrected; the wall-clock answer is converted to a delta
+    // and applied to it, and recomputed after every run rather than accumulated.
+    std::optional<common::CronSchedule> cron;
+    std::string timezone;
+
     OverlapPolicy overlap_policy = OverlapPolicy::Skip;
     int max_concurrent = 1;
     int max_run_minutes = 0;  // 0 means derive as 5x the interval
@@ -102,7 +115,14 @@ public:
                           int interval_minutes,
                           OverlapPolicy overlap_policy = OverlapPolicy::Skip,
                           int max_concurrent = 1,
-                          int max_run_minutes = 0);
+                          int max_run_minutes = 0,
+                          // A cron expression takes precedence over the
+                          // interval. One that cannot be parsed is refused with
+                          // the reason logged, and the workflow is left
+                          // unscheduled rather than quietly run on the interval
+                          // instead - which is the bug this whole change fixes.
+                          const std::string& cron_expression = "",
+                          const std::string& timezone = "");
 
     /**
      * Record that a dispatched run has started. Dispatch is fire-and-forget, so

+ 13 - 5
src/webserver/webserver_service.cpp

@@ -638,8 +638,16 @@ void WebServerService::loadScheduledWorkflows() {
             auto config = smartbotic::common::applyConfigDefaults(
                 node.value("config", nlohmann::json::object()), node_def.config_schema);
             int interval = config.value("pollInterval", 0);
-
-            if (interval > 0) {
+            // A cron expression is only honoured when the trigger is in cron mode.
+            // Both fields exist on every schedule-trigger, and reading the
+            // expression regardless would turn an interval trigger that happens
+            // to carry a leftover expression into a cron one.
+            const std::string mode = config.value("mode", std::string("interval"));
+            const std::string cron_expression =
+                mode == "cron" ? config.value("cronExpression", std::string()) : std::string();
+            const std::string timezone = config.value("timezone", std::string());
+
+            if (interval > 0 || !cron_expression.empty()) {
                 auto policy = overlapPolicyFromString(
                     config.value("overlapPolicy", std::string("skip")));
 
@@ -651,11 +659,11 @@ void WebServerService::loadScheduledWorkflows() {
                     interval,
                     policy,
                     config.value("maxConcurrent", 1),
-                    config.value("maxRunMinutes", 0)
+                    config.value("maxRunMinutes", 0),
+                    cron_expression,
+                    timezone
                 );
                 registered_count++;
-                LOG_DEBUG("Registered workflow '{}' ({}) for scheduled execution every {} minutes",
-                         workflow_name, workflow_id, interval);
             }
         }
     }