|
@@ -3,6 +3,20 @@
|
|
|
|
|
|
|
|
namespace smartbotic::webserver {
|
|
namespace smartbotic::webserver {
|
|
|
|
|
|
|
|
|
|
+OverlapPolicy overlapPolicyFromString(const std::string& value) {
|
|
|
|
|
+ if (value == "queue") return OverlapPolicy::Queue;
|
|
|
|
|
+ if (value == "allow") return OverlapPolicy::Allow;
|
|
|
|
|
+ return OverlapPolicy::Skip;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+std::string overlapPolicyToString(OverlapPolicy policy) {
|
|
|
|
|
+ switch (policy) {
|
|
|
|
|
+ case OverlapPolicy::Queue: return "queue";
|
|
|
|
|
+ case OverlapPolicy::Allow: return "allow";
|
|
|
|
|
+ default: return "skip";
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
WorkflowScheduler::WorkflowScheduler() = default;
|
|
WorkflowScheduler::WorkflowScheduler() = default;
|
|
|
|
|
|
|
|
WorkflowScheduler::~WorkflowScheduler() {
|
|
WorkflowScheduler::~WorkflowScheduler() {
|
|
@@ -41,7 +55,10 @@ void WorkflowScheduler::registerWorkflow(
|
|
|
const std::string& workflow_name,
|
|
const std::string& workflow_name,
|
|
|
const std::string& trigger_node_id,
|
|
const std::string& trigger_node_id,
|
|
|
const std::string& trigger_type,
|
|
const std::string& trigger_type,
|
|
|
- int interval_minutes
|
|
|
|
|
|
|
+ int interval_minutes,
|
|
|
|
|
+ OverlapPolicy overlap_policy,
|
|
|
|
|
+ int max_concurrent,
|
|
|
|
|
+ int max_run_minutes
|
|
|
) {
|
|
) {
|
|
|
if (interval_minutes <= 0) {
|
|
if (interval_minutes <= 0) {
|
|
|
spdlog::debug("Workflow {} has interval 0, not scheduling", workflow_id);
|
|
spdlog::debug("Workflow {} has interval 0, not scheduling", workflow_id);
|
|
@@ -61,11 +78,25 @@ void WorkflowScheduler::registerWorkflow(
|
|
|
entry.last_run = now; // Consider it just ran to avoid immediate execution
|
|
entry.last_run = now; // Consider it just ran to avoid immediate execution
|
|
|
entry.next_run = now + std::chrono::minutes(interval_minutes);
|
|
entry.next_run = now + std::chrono::minutes(interval_minutes);
|
|
|
entry.running = false;
|
|
entry.running = false;
|
|
|
|
|
+ entry.overlap_policy = overlap_policy;
|
|
|
|
|
+ entry.max_concurrent = max_concurrent > 0 ? max_concurrent : 1;
|
|
|
|
|
+ entry.max_run_minutes = max_run_minutes;
|
|
|
|
|
+
|
|
|
|
|
+ // Preserve in-flight state across a re-registration, which happens whenever
|
|
|
|
|
+ // the workflow is saved while active. Dropping it would leak a slot.
|
|
|
|
|
+ auto existing = workflows_.find(workflow_id);
|
|
|
|
|
+ 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;
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
workflows_[workflow_id] = entry;
|
|
workflows_[workflow_id] = entry;
|
|
|
|
|
|
|
|
- spdlog::info("Scheduled workflow '{}' ({}) with {} trigger, interval: {} minutes",
|
|
|
|
|
- workflow_name, workflow_id, trigger_type, interval_minutes);
|
|
|
|
|
|
|
+ spdlog::info("Scheduled workflow '{}' ({}) with {} trigger, interval: {} minutes, "
|
|
|
|
|
+ "overlap: {}, maxConcurrent: {}",
|
|
|
|
|
+ workflow_name, workflow_id, trigger_type, interval_minutes,
|
|
|
|
|
+ overlapPolicyToString(entry.overlap_policy), entry.max_concurrent);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
void WorkflowScheduler::unregisterWorkflow(const std::string& workflow_id) {
|
|
void WorkflowScheduler::unregisterWorkflow(const std::string& workflow_id) {
|
|
@@ -140,6 +171,82 @@ void WorkflowScheduler::schedulerLoop() {
|
|
|
spdlog::info("Scheduler loop ended");
|
|
spdlog::info("Scheduler loop ended");
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+void WorkflowScheduler::notifyExecutionStarted(const std::string& workflow_id,
|
|
|
|
|
+ const std::string& execution_id) {
|
|
|
|
|
+ std::lock_guard<std::mutex> lock(mutex_);
|
|
|
|
|
+
|
|
|
|
|
+ auto it = workflows_.find(workflow_id);
|
|
|
|
|
+ if (it == workflows_.end()) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ int minutes = it->second.max_run_minutes;
|
|
|
|
|
+ if (minutes <= 0) {
|
|
|
|
|
+ minutes = it->second.interval_minutes * 5;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (minutes <= 0) {
|
|
|
|
|
+ minutes = 60;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // The run may already have reported completion: a fast workflow can finish
|
|
|
|
|
+ // before ExecuteWorkflow returns. Consume that and leave the slot free.
|
|
|
|
|
+ if (finished_before_start_.erase(execution_id) > 0) {
|
|
|
|
|
+ spdlog::debug("Workflow '{}' run {} finished before its dispatch returned",
|
|
|
|
|
+ it->second.workflow_name, execution_id);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ it->second.active_runs[execution_id] =
|
|
|
|
|
+ std::chrono::steady_clock::now() + std::chrono::minutes(minutes);
|
|
|
|
|
+
|
|
|
|
|
+ spdlog::debug("Workflow '{}' run {} started, {} active",
|
|
|
|
|
+ it->second.workflow_name, execution_id, it->second.active_runs.size());
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+void WorkflowScheduler::notifyExecutionFinished(const std::string& workflow_id,
|
|
|
|
|
+ const std::string& execution_id) {
|
|
|
|
|
+ std::lock_guard<std::mutex> lock(mutex_);
|
|
|
|
|
+
|
|
|
|
|
+ auto it = workflows_.find(workflow_id);
|
|
|
|
|
+
|
|
|
|
|
+ // Execution events do not always carry the workflow id, so fall back to
|
|
|
|
|
+ // locating the run by its execution id, which the scheduler already tracks.
|
|
|
|
|
+ if (it == workflows_.end() || !it->second.active_runs.count(execution_id)) {
|
|
|
|
|
+ it = workflows_.end();
|
|
|
|
|
+ for (auto candidate = workflows_.begin(); candidate != workflows_.end(); ++candidate) {
|
|
|
|
|
+ if (candidate->second.active_runs.count(execution_id)) {
|
|
|
|
|
+ it = candidate;
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (it == workflows_.end()) {
|
|
|
|
|
+ // Arrived before the dispatch call returned; remember it so the pending
|
|
|
|
|
+ // notifyExecutionStarted does not register a slot nobody will release.
|
|
|
|
|
+ finished_before_start_.insert(execution_id);
|
|
|
|
|
+ if (finished_before_start_.size() > 256) {
|
|
|
|
|
+ finished_before_start_.erase(finished_before_start_.begin());
|
|
|
|
|
+ }
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (it->second.active_runs.erase(execution_id) == 0) {
|
|
|
|
|
+ finished_before_start_.insert(execution_id);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ spdlog::debug("Workflow '{}' run {} finished, {} active",
|
|
|
|
|
+ it->second.workflow_name, execution_id, it->second.active_runs.size());
|
|
|
|
|
+
|
|
|
|
|
+ // A deferred start becomes eligible on the next pass, which is at most
|
|
|
|
|
+ // CHECK_INTERVAL_SECONDS away.
|
|
|
|
|
+ if (it->second.pending_start && it->second.active_runs.empty()) {
|
|
|
|
|
+ spdlog::info("Workflow '{}' has a deferred run, starting it now",
|
|
|
|
|
+ it->second.workflow_name);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
void WorkflowScheduler::checkAndExecute() {
|
|
void WorkflowScheduler::checkAndExecute() {
|
|
|
auto now = std::chrono::steady_clock::now();
|
|
auto now = std::chrono::steady_clock::now();
|
|
|
|
|
|
|
@@ -150,16 +257,68 @@ void WorkflowScheduler::checkAndExecute() {
|
|
|
std::lock_guard<std::mutex> lock(mutex_);
|
|
std::lock_guard<std::mutex> lock(mutex_);
|
|
|
|
|
|
|
|
for (auto& [id, entry] : workflows_) {
|
|
for (auto& [id, entry] : workflows_) {
|
|
|
- // Skip if already running
|
|
|
|
|
- if (entry.running) {
|
|
|
|
|
|
|
+ // Release slots held by runs that never reported completion. Without
|
|
|
|
|
+ // this a lost event would block a skip or queue workflow forever.
|
|
|
|
|
+ for (auto it = entry.active_runs.begin(); it != entry.active_runs.end(); ) {
|
|
|
|
|
+ if (now >= it->second) {
|
|
|
|
|
+ spdlog::warn("Workflow '{}' run {} exceeded its deadline, releasing the slot",
|
|
|
|
|
+ entry.workflow_name, it->first);
|
|
|
|
|
+ it = entry.active_runs.erase(it);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ ++it;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const int active = static_cast<int>(entry.active_runs.size());
|
|
|
|
|
+ const bool due = now >= entry.next_run;
|
|
|
|
|
+ const bool queued = entry.pending_start && active == 0;
|
|
|
|
|
+
|
|
|
|
|
+ if (!due && !queued) {
|
|
|
continue;
|
|
continue;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // Check if it's time to run
|
|
|
|
|
- if (now >= entry.next_run) {
|
|
|
|
|
- entry.running = true;
|
|
|
|
|
|
|
+ // A deferred start fires as soon as the slot frees, regardless of the
|
|
|
|
|
+ // interval boundary.
|
|
|
|
|
+ if (queued) {
|
|
|
|
|
+ entry.pending_start = false;
|
|
|
to_execute.push_back(entry);
|
|
to_execute.push_back(entry);
|
|
|
|
|
+ continue;
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ switch (entry.overlap_policy) {
|
|
|
|
|
+ case OverlapPolicy::Allow:
|
|
|
|
|
+ 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);
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ break;
|
|
|
|
|
+
|
|
|
|
|
+ case OverlapPolicy::Queue:
|
|
|
|
|
+ if (active > 0) {
|
|
|
|
|
+ if (!entry.pending_start) {
|
|
|
|
|
+ spdlog::info("Workflow '{}' still running, deferring this tick",
|
|
|
|
|
+ entry.workflow_name);
|
|
|
|
|
+ entry.pending_start = true;
|
|
|
|
|
+ }
|
|
|
|
|
+ entry.next_run = now + std::chrono::minutes(entry.interval_minutes);
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ break;
|
|
|
|
|
+
|
|
|
|
|
+ case OverlapPolicy::Skip:
|
|
|
|
|
+ default:
|
|
|
|
|
+ if (active > 0) {
|
|
|
|
|
+ spdlog::info("Workflow '{}' still running, skipping this tick",
|
|
|
|
|
+ entry.workflow_name);
|
|
|
|
|
+ entry.next_run = now + std::chrono::minutes(entry.interval_minutes);
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ to_execute.push_back(entry);
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -182,9 +341,13 @@ void WorkflowScheduler::checkAndExecute() {
|
|
|
std::lock_guard<std::mutex> lock(mutex_);
|
|
std::lock_guard<std::mutex> lock(mutex_);
|
|
|
auto it = workflows_.find(entry.workflow_id);
|
|
auto it = workflows_.find(entry.workflow_id);
|
|
|
if (it != workflows_.end()) {
|
|
if (it != workflows_.end()) {
|
|
|
|
|
+ // Schedule from the moment dispatch finished, not from the start of
|
|
|
|
|
+ // this pass: otherwise a slow dispatch leaves next_run in the past
|
|
|
|
|
+ // and the following tick fires immediately.
|
|
|
|
|
+ auto dispatched_at = std::chrono::steady_clock::now();
|
|
|
it->second.running = false;
|
|
it->second.running = false;
|
|
|
- it->second.last_run = now;
|
|
|
|
|
- it->second.next_run = now + std::chrono::minutes(it->second.interval_minutes);
|
|
|
|
|
|
|
+ it->second.last_run = dispatched_at;
|
|
|
|
|
+ it->second.next_run = dispatched_at + std::chrono::minutes(it->second.interval_minutes);
|
|
|
|
|
|
|
|
auto next_in_minutes = it->second.interval_minutes;
|
|
auto next_in_minutes = it->second.interval_minutes;
|
|
|
spdlog::debug("Workflow '{}' next run in {} minutes",
|
|
spdlog::debug("Workflow '{}' next run in {} minutes",
|