GPAL - Generally Positive Automation Library v1.0
GPAL The Fluent Automation LIbrary
Loading...
Searching...
No Matches
WorkflowScheduler.cs
1// =============================================================================
2// GPAL - Generally Positive Automation Library
3// Copyright © 2026 Software Decisions, Inc. All rights reserved.
4//
5// This file is part of GPAL.
6// Licensed under the Business Source License 1.1
7//
8// Primary development, architecture, and vision by Michael B. Vederman,
9// CEO of Software Decisions, Inc., Texas.
10//
11// Internal development maintained privately.
12// Public releases appear on GitHub: https://github.com/SoftwareDecisionsInc/GPAL.
13//
14// See LICENSE for full terms, including Additional Use Grant.
15// =============================================================================
16
17using System;
18using System.Collections.Generic;
19using System.Linq;
20using System.Threading;
21using System.Threading.Tasks;
22using static GenerallyPositive.Enums;
23
25{
30 public class WorkflowRun
31 {
33 public int Index { get; internal set; }
35 public bool Succeeded { get; internal set; }
37 public TimeSpan Duration { get; internal set; }
39 public Exception Failure { get; internal set; }
40 }
41
81 {
82 private readonly List<Action> workflows = new List<Action>();
83 private int maxAtOnce = 1;
84 private int staggerMs = 250;
85 private int timeoutMs = Timeout.Infinite;
86
87 internal WorkflowScheduler()
88 {
89 }
90
98 {
99 if (1 > howMany)
100 {
101 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Cannot run [{howMany}] at once, so one at a time it is", this, GPALObjectType.Browser);
102 howMany = 1;
103 }
104
105 maxAtOnce = howMany;
106
107 return this;
108 }
109
120 {
121 if (0 > delayInMs)
122 {
123 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Cannot stagger by [{delayInMs}] ms, so they start together", this, GPALObjectType.Browser);
124 delayInMs = 0;
125 }
126
127 staggerMs = delayInMs;
128
129 return this;
130 }
131
144 {
145 timeoutMs = 0 < timeoutInMs ? timeoutInMs : Timeout.Infinite;
146
147 return this;
148 }
149
157 {
158 if (null == workflow)
159 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Workflow is null in WithWorkflow, so an empty one was added instead", this, GPALObjectType.Browser);
160
161 workflows.Add(workflow ?? (() => { }));
162
163 return this;
164 }
165
172 {
173 return Run(out List<WorkflowRun> ignored);
174 }
175
183 public IAllowScheduledWorkflowExecution Run(out List<WorkflowRun> runs)
184 {
185 List<Action> running = workflows.ToList();
186 workflows.Clear();
187
188 WorkflowRun[] report = running.Select((w, index) => new WorkflowRun { Index = index }).ToArray();
189
190 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Running [{running.Count}] workflow(s), [{maxAtOnce}] at a time, [{staggerMs}] ms apart.", this, GPALObjectType.Browser);
191
192 // the moment the last workflow was let go, so the next one can be held back to staggerMs after it
193 object startLine = new object();
194 DateTime lastStart = DateTime.MinValue;
195
196 // not in a using: a workflow we gave up on is still holding a slot and still going to Release it,
197 // and releasing a disposed semaphore throws on a thread nobody is watching. disposed below, when
198 // the last of them is known to be done with it
199 SemaphoreSlim gate = new SemaphoreSlim(maxAtOnce, maxAtOnce);
200
201 Task[] started = running.Select((workflow, index) => Task.Run(() =>
202 {
203 gate.Wait();
204
205 // held here rather than in the loop that made the tasks, so a workflow that waited on the
206 // gate is spaced from the one before it too, not just from the one it was created after
207 if (0 < staggerMs)
208 {
209 lock (startLine)
210 {
211 TimeSpan gap = TimeSpan.FromMilliseconds(staggerMs);
212 TimeSpan since = DateTime.Now - lastStart;
213
214 // the first one through has waited since DateTime.MinValue, so it never sleeps
215 if (since < gap)
216 Thread.Sleep(gap - since);
217
218 lastStart = DateTime.Now;
219 }
220 }
221
222 // each task writes only its own entry, so the report needs no gate of its own
223 WorkflowRun mine = report[index];
224 DateTime began = DateTime.Now;
225
226 try
227 {
228 workflow();
229 mine.Succeeded = true;
230 }
231 catch (Exception ex)
232 {
233 mine.Failure = ex;
234
235 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Workflow [{index + 1}] failed", this, GPALObjectType.Browser, ex);
236 }
237 finally
238 {
239 mine.Duration = DateTime.Now - began;
240 gate.Release();
241 }
242 })).ToArray();
243
244 bool allFinished = Task.WaitAll(started, timeoutMs);
245
246 // copies, so the list handed back says what was true when Run gave up. the entries themselves stay
247 // live for any workflow still running, which would otherwise rewrite its own verdict afterwards
248 runs = report.Select(run => new WorkflowRun
249 {
250 Index = run.Index,
251 Succeeded = run.Succeeded,
252 Duration = run.Duration,
253 Failure = run.Failure ?? (true == run.Succeeded ? null : new TimeoutException($"Workflow [{run.Index + 1}] was still running after [{timeoutMs}] ms")),
254 }).ToList();
255
256 if (true == allFinished)
257 {
258 gate.Dispose();
259
260 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"All [{running.Count}] workflow(s) finished, [{runs.Count(r => false == r.Succeeded)}] of them failed.", this, GPALObjectType.Browser);
261 }
262 else
263 // the semaphore is left undisposed on purpose: the workflows still running are still going to
264 // release it, and it goes when they do rather than throwing on a thread nobody is watching
265 GPAL.PublishSimpleEvent(GPALEventType.WARNING,
266 $"Gave up waiting after [{timeoutMs}] ms with [{runs.Count(r => false == r.Succeeded)}] of [{running.Count}] workflow(s) still running. They keep going and keep their browsers.",
267 this, GPALObjectType.Browser);
268
269 return this;
270 }
271 }
272}
What became of one workflow handed to WorkflowScheduler: whether it finished, how long it took,...
TimeSpan Duration
How long it ran for, whether it finished or threw.
Exception Failure
What it threw, or null when it did not.
int Index
Where this workflow sat in the order they were added, counting from zero.
bool Succeeded
True when the workflow ran to the end without throwing.
IAllowScheduledWorkflowExecution Run()
Runs every workflow added and returns once they have all finished. One that throws is reported and th...
IAllowScheduledWorkflowExecution WithWorkflow(Action workflow)
Adds a workflow to be run. It takes nothing and is handed nothing: whatever it drives,...
IAllowScheduledWorkflowExecution WithTimeout(int timeoutInMs)
How long to wait for all the workflows before giving up on them. Without one, Run waits forever,...
IAllowScheduledWorkflowExecution Run(out List< WorkflowRun > runs)
Runs every workflow added and hands back what happened to each: whether it finished,...
IAllowScheduledWorkflowExecution WithStaggerStart(int delayInMs)
The least time between two workflows starting. Workflows that build a browser spend their first secon...
IAllowScheduledWorkflowExecution WithMaxAtOnce(int howMany)
How many workflows may be in flight at once. One, the default, runs them one after another....
Everything starts here, all the GPAL controls and global settings using fluent syntax are here....
Definition GPAL.cs:49
static void PublishSimpleEvent(GPALEventType gPALEventType, string msg, dynamic gPALObject=null, Enums.GPALObjectType gPALObjectType=GPALObjectType.None, Exception ex=null)
Publish a message to either the information channel or exception channel (if exception passed in) Pub...
Definition GPAL.cs:2406