Skip to content

Commit 42040ef

Browse files
committed
feat: scheduling strategy system
1 parent ee7d58c commit 42040ef

10 files changed

Lines changed: 530 additions & 13 deletions

File tree

src/core/Flowthru.Core/Flows/ExecutionOptions.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Flowthru.Core.Graph;
2+
using Flowthru.Core.Graph.Scheduling;
23
using Flowthru.Core.Results;
34

45
namespace Flowthru.Core.Flows;
@@ -69,6 +70,23 @@ public class ExecutionOptions
6970
/// </remarks>
7071
public FlowSliceStrategy? SliceStrategy { get; set; }
7172

73+
/// <summary>
74+
/// Priority strategy used to order ready steps on each dispatch cycle.
75+
/// </summary>
76+
/// <remarks>
77+
/// <para>
78+
/// When <c>null</c> (default), the executor selects a strategy automatically:
79+
/// <see cref="Graph.Scheduling.FifoSchedulingStrategy"/> for sequential execution
80+
/// (<see cref="MaxDegreeOfParallelism"/> = 1), and
81+
/// <see cref="Graph.Scheduling.CriticalPathSchedulingStrategy"/> for parallel execution.
82+
/// </para>
83+
/// <para>
84+
/// Provide an explicit value to override this default — for example, to force FIFO
85+
/// ordering even under parallelism, or to supply a custom strategy.
86+
/// </para>
87+
/// </remarks>
88+
public ISchedulingStrategy? SchedulingStrategy { get; set; }
89+
7290
/// <summary>
7391
/// Gets the configured formatter or creates a default one.
7492
/// </summary>

src/core/Flowthru.Core/Flows/Flow.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using Flowthru.Core.Graph;
66
using Flowthru.Core.Graph.Meta;
77
using Flowthru.Core.Graph.Meta.Models;
8+
using Flowthru.Core.Graph.Scheduling;
89
using Flowthru.Core.Graph.Validation;
910
using Microsoft.Extensions.Logging;
1011

@@ -266,7 +267,11 @@ public void Build(FlowSliceStrategy? sliceStrategy = null)
266267
var stepsToExecute = _slicedSteps ?? _steps;
267268
DependencyAnalyzer.AssignLayers(stepsToExecute);
268269

269-
// Step 4: Group steps by layer for execution
270+
// Step 4: Compute heights for critical-path scheduling
271+
// Height = longest path to a sink; used by CriticalPathSchedulingStrategy.
272+
DependencyAnalyzer.ComputeHeights(stepsToExecute);
273+
274+
// Step 5: Group steps by layer for execution
270275
ExecutionLayers = DependencyAnalyzer.GroupByLayer(stepsToExecute).ToList();
271276

272277
Logger?.LogInformation(
@@ -642,6 +647,10 @@ CancellationToken cancellationToken
642647
stepList,
643648
parallelism,
644649
ExecuteStepWithTrackingAsync,
650+
options.SchedulingStrategy
651+
?? (
652+
parallelism == 1 ? new FifoSchedulingStrategy() : new CriticalPathSchedulingStrategy()
653+
),
645654
Logger
646655
);
647656

src/core/Flowthru.Core/Graph/DependencyAnalyzer.cs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -516,6 +516,51 @@ List<FlowStep> allSteps
516516
return result;
517517
}
518518

519+
/// <summary>
520+
/// Computes the height of every node in the DAG: the length of the longest path
521+
/// from that node to any sink (a node with no dependents).
522+
/// </summary>
523+
/// <param name="nodes">Steps whose heights should be computed (must already have
524+
/// dependencies resolved via <see cref="BuildDependencyGraph"/>).</param>
525+
/// <remarks>
526+
/// <para>
527+
/// Height is defined recursively:
528+
/// <code>
529+
/// height(sink) = 0
530+
/// height(n) = 1 + max(height(d) for d in dependents(n))
531+
/// </code>
532+
/// This is computed iteratively in reverse-topological order (sinks first) in O(V+E).
533+
/// </para>
534+
/// <para>
535+
/// Used by <see cref="Scheduling.CriticalPathSchedulingStrategy"/> to prioritise ready
536+
/// steps that gate the most downstream work.
537+
/// </para>
538+
/// </remarks>
539+
public static void ComputeHeights(List<FlowStep> nodes)
540+
{
541+
// Build reverse adjacency: step → steps that depend on it.
542+
var dependents = nodes.ToDictionary(n => n, _ => new List<FlowStep>());
543+
foreach (var node in nodes)
544+
{
545+
foreach (var dep in node.Dependencies)
546+
{
547+
if (dependents.TryGetValue(dep, out var list))
548+
{
549+
list.Add(node);
550+
}
551+
}
552+
}
553+
554+
// Process nodes in reverse topological order (sinks first).
555+
// Re-derive topological order from Layer assignments: highest Layer = processed first.
556+
var ordered = nodes.OrderByDescending(n => n.Layer);
557+
foreach (var node in ordered)
558+
{
559+
var deps = dependents[node];
560+
node.Height = deps.Count == 0 ? 0 : 1 + deps.Max(d => d.Height);
561+
}
562+
}
563+
519564
/// <summary>
520565
/// Builds a reverse dependency map (node → nodes that depend on it).
521566
/// </summary>

src/core/Flowthru.Core/Graph/FlowStep.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,17 @@ public class FlowStep
100100
/// </summary>
101101
public int Layer { get; set; } = -1; // -1 indicates not yet assigned
102102

103+
/// <summary>
104+
/// Height in the DAG: the length of the longest path from this step to any sink (leaf).
105+
/// Sinks have height 0. Used by critical-path scheduling to prioritise steps that unblock
106+
/// the most downstream work.
107+
/// </summary>
108+
/// <remarks>
109+
/// Populated by <see cref="DependencyAnalyzer.ComputeHeights"/> after the dependency
110+
/// graph has been built. A value of -1 indicates heights have not yet been computed.
111+
/// </remarks>
112+
public int Height { get; set; } = -1; // -1 indicates not yet computed
113+
103114
/// <summary>
104115
/// Creates a new Flow step with a transformation function.
105116
/// </summary>
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
namespace Flowthru.Core.Graph.Scheduling;
2+
3+
/// <summary>
4+
/// Scheduling strategy that prioritises steps with the longest remaining critical path
5+
/// (Highest Level First / HLF).
6+
/// </summary>
7+
/// <remarks>
8+
/// <para>
9+
/// When multiple steps are ready simultaneously, this strategy dispatches the step
10+
/// with the greatest <see cref="FlowStep.Height"/> first — where height is the length
11+
/// of the longest path from that step to any leaf in the DAG.
12+
/// </para>
13+
/// <para>
14+
/// <strong>Rationale.</strong> Starting a high-height step unblocks more downstream
15+
/// parallelism sooner, keeping worker threads saturated. Graham (1966) proved that any
16+
/// list-scheduling algorithm using this priority order achieves a makespan within a
17+
/// factor of <c>2 − 1/m</c> of optimal on <c>m</c> identical machines — the best
18+
/// polynomial-time guarantee known for <c>P|prec|C_max</c>.
19+
/// </para>
20+
/// <para>
21+
/// <strong>Prerequisites.</strong> <see cref="FlowStep.Height"/> values must have been
22+
/// populated by <see cref="DependencyAnalyzer.ComputeHeights"/> before this strategy
23+
/// is used. Steps with <c>Height == -1</c> are treated as height 0.
24+
/// </para>
25+
/// <para>
26+
/// Steps with equal height retain their relative arrival order (stable sort), so the
27+
/// strategy degrades gracefully to FIFO when all ready steps share the same height.
28+
/// </para>
29+
/// </remarks>
30+
public sealed class CriticalPathSchedulingStrategy : ISchedulingStrategy
31+
{
32+
/// <inheritdoc/>
33+
public IReadOnlyList<FlowStep> Prioritize(
34+
IReadOnlyList<FlowStep> readySteps,
35+
SchedulingContext context
36+
)
37+
{
38+
// OrderByDescending is a stable sort — equal-height steps keep arrival order.
39+
return readySteps.OrderByDescending(s => s.Height >= 0 ? s.Height : 0).ToList();
40+
}
41+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
namespace Flowthru.Core.Graph.Scheduling;
2+
3+
/// <summary>
4+
/// Scheduling strategy that preserves arrival order (first-in, first-out).
5+
/// </summary>
6+
/// <remarks>
7+
/// Equivalent to the behaviour of the original <c>ConcurrentQueue</c>-based
8+
/// dispatcher: steps become eligible for dispatch in the order their last
9+
/// dependency completes, and that order is preserved when claiming worker slots.
10+
/// </remarks>
11+
public sealed class FifoSchedulingStrategy : ISchedulingStrategy
12+
{
13+
/// <inheritdoc/>
14+
public IReadOnlyList<FlowStep> Prioritize(
15+
IReadOnlyList<FlowStep> readySteps,
16+
SchedulingContext context
17+
) => readySteps;
18+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
namespace Flowthru.Core.Graph.Scheduling;
2+
3+
/// <summary>
4+
/// Defines the priority ordering for ready steps in the task-graph scheduler.
5+
/// </summary>
6+
/// <remarks>
7+
/// <para>
8+
/// When multiple steps are ready to dispatch simultaneously (all dependencies satisfied),
9+
/// the scheduler delegates to an <see cref="ISchedulingStrategy"/> to determine which
10+
/// step should be dispatched first. This affects which steps claim a worker slot when
11+
/// the degree of parallelism is limited.
12+
/// </para>
13+
/// <para>
14+
/// Implementations receive the currently ready steps and a <see cref="SchedulingContext"/>
15+
/// containing graph structure and any available historical data, then return the steps in
16+
/// dispatch-priority order (highest priority first).
17+
/// </para>
18+
/// <para>
19+
/// The strategy is invoked each time the dispatch loop drains the ready queue, ensuring
20+
/// newly-unblocked steps are ranked relative to any that were already waiting.
21+
/// </para>
22+
/// </remarks>
23+
public interface ISchedulingStrategy
24+
{
25+
/// <summary>
26+
/// Returns <paramref name="readySteps"/> sorted in dispatch-priority order,
27+
/// highest priority first.
28+
/// </summary>
29+
/// <param name="readySteps">Steps whose dependencies have all completed and that
30+
/// are eligible for immediate dispatch.</param>
31+
/// <param name="context">Read-only graph context available to inform ordering decisions.</param>
32+
/// <returns>The same steps in priority order. Must contain exactly the same elements
33+
/// as <paramref name="readySteps"/>.</returns>
34+
IReadOnlyList<FlowStep> Prioritize(IReadOnlyList<FlowStep> readySteps, SchedulingContext context);
35+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
namespace Flowthru.Core.Graph.Scheduling;
2+
3+
/// <summary>
4+
/// Read-only graph context passed to <see cref="ISchedulingStrategy.Prioritize"/> on
5+
/// each dispatch cycle.
6+
/// </summary>
7+
/// <remarks>
8+
/// Carries structural information about the DAG that strategies may use to make ordering
9+
/// decisions. Designed to be extended: future fields (e.g., historical step durations)
10+
/// can be added here without changing the <see cref="ISchedulingStrategy"/> signature.
11+
/// </remarks>
12+
/// <param name="Dependents">
13+
/// Reverse adjacency map: for each step, the list of steps that depend on it.
14+
/// A step with an empty list is a sink (no descendants).
15+
/// </param>
16+
public sealed record SchedulingContext(
17+
IReadOnlyDictionary<FlowStep, IReadOnlyList<FlowStep>> Dependents
18+
);

src/core/Flowthru.Core/Graph/TaskGraphExecutor.cs

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.Collections.Concurrent;
22
using Flowthru.Core.Flows;
3+
using Flowthru.Core.Graph.Scheduling;
34
using Microsoft.Extensions.Logging;
45

56
namespace Flowthru.Core.Graph;
@@ -29,6 +30,7 @@ internal sealed class TaskGraphExecutor
2930
{
3031
private readonly IReadOnlyList<FlowStep> _steps;
3132
private readonly int _maxDegreeOfParallelism;
33+
private readonly ISchedulingStrategy _strategy;
3234
private readonly ILogger? _logger;
3335
private readonly Func<FlowStep, CancellationToken, Task<StepResult>> _executeStep;
3436

@@ -42,11 +44,13 @@ internal sealed class TaskGraphExecutor
4244
/// Pass <see cref="int.MaxValue"/> for unbounded parallelism.
4345
/// </param>
4446
/// <param name="executeStep">Per-step execution delegate (matches <c>ExecuteStepWithTrackingAsync</c>).</param>
47+
/// <param name="strategy">Priority strategy used to order ready steps on each dispatch cycle.</param>
4548
/// <param name="logger">Optional logger.</param>
4649
internal TaskGraphExecutor(
4750
IReadOnlyList<FlowStep> steps,
4851
int maxDegreeOfParallelism,
4952
Func<FlowStep, CancellationToken, Task<StepResult>> executeStep,
53+
ISchedulingStrategy strategy,
5054
ILogger? logger = null
5155
)
5256
{
@@ -61,6 +65,7 @@ internal TaskGraphExecutor(
6165
_steps = steps;
6266
_maxDegreeOfParallelism = maxDegreeOfParallelism == -1 ? int.MaxValue : maxDegreeOfParallelism;
6367
_executeStep = executeStep;
68+
_strategy = strategy;
6469
_logger = logger;
6570
}
6671

@@ -96,7 +101,10 @@ CancellationToken cancellationToken
96101
);
97102

98103
// Reverse adjacency: for each step, which steps depend on it?
99-
var dependents = _steps.ToDictionary(s => s, _ => new List<FlowStep>());
104+
var dependents = _steps.ToDictionary(
105+
s => s,
106+
s => (IReadOnlyList<FlowStep>)new List<FlowStep>()
107+
);
100108
foreach (var step in _steps)
101109
{
102110
foreach (var dep in step.Dependencies)
@@ -105,14 +113,17 @@ CancellationToken cancellationToken
105113
// steps that are in _steps (sliced or full set).
106114
if (dependents.TryGetValue(dep, out var list))
107115
{
108-
list.Add(step);
116+
((List<FlowStep>)list).Add(step);
109117
}
110118
}
111119
}
112120

113-
// Steps whose upstream is fully satisfied (or has no dependencies).
114-
// Channel is unbounded write / bounded dispatch (controlled by the semaphore).
115-
var readyQueue = new ConcurrentQueue<FlowStep>(_steps.Where(s => s.Dependencies.Count == 0));
121+
var schedulingContext = new SchedulingContext(dependents);
122+
123+
// Newly-ready steps are added here by concurrent task completions; a ConcurrentBag
124+
// is safe for multi-producer, single-consumer access. On each dispatch cycle the
125+
// main loop drains it into a List and passes it to the strategy for ordering.
126+
var readyBag = new ConcurrentBag<FlowStep>(_steps.Where(s => s.Dependencies.Count == 0));
116127

117128
// Tracks which steps were skipped because an upstream dependency failed.
118129
var skipped = new HashSet<FlowStep>();
@@ -139,7 +150,16 @@ CancellationToken cancellationToken
139150
while (results.Count + skipped.Count < totalSteps)
140151
{
141152
// Drain all currently runnable steps into in-flight tasks.
142-
while (readyQueue.TryDequeue(out var step))
153+
// Collect from the concurrent bag into a snapshot, then ask the strategy to order them.
154+
var readySnapshot = new List<FlowStep>();
155+
while (readyBag.TryTake(out var taken))
156+
{
157+
readySnapshot.Add(taken);
158+
}
159+
160+
var prioritised = _strategy.Prioritize(readySnapshot, schedulingContext);
161+
162+
foreach (var step in prioritised)
143163
{
144164
if (skipped.Contains(step))
145165
{
@@ -208,7 +228,7 @@ CancellationToken cancellationToken
208228
);
209229

210230
// Notify dependents — decrement their pending count.
211-
EnqueueReadyDependents(capturedStep, pendingDeps, dependents, skipped, readyQueue);
231+
EnqueueReadyDependents(capturedStep, pendingDeps, dependents, skipped, readyBag);
212232
}
213233
}
214234
finally
@@ -223,7 +243,7 @@ CancellationToken cancellationToken
223243
inFlight.Add(task);
224244
}
225245

226-
if (inFlight.Count == 0)
246+
if (inFlight.Count == 0 && readyBag.IsEmpty)
227247
{
228248
// Nothing dispatched and nothing running. If not all steps accounted for,
229249
// the dependency graph has a cycle that AssignLayers should have caught.
@@ -290,9 +310,9 @@ CancellationToken cancellationToken
290310
private static void EnqueueReadyDependents(
291311
FlowStep completedStep,
292312
ConcurrentDictionary<FlowStep, int> pendingDeps,
293-
Dictionary<FlowStep, List<FlowStep>> dependents,
313+
IReadOnlyDictionary<FlowStep, IReadOnlyList<FlowStep>> dependents,
294314
HashSet<FlowStep> skipped,
295-
ConcurrentQueue<FlowStep> readyQueue
315+
ConcurrentBag<FlowStep> readyBag
296316
)
297317
{
298318
foreach (var dependent in dependents[completedStep])
@@ -305,15 +325,15 @@ ConcurrentQueue<FlowStep> readyQueue
305325
var remaining = pendingDeps.AddOrUpdate(dependent, 0, (_, current) => current - 1);
306326
if (remaining == 0)
307327
{
308-
readyQueue.Enqueue(dependent);
328+
readyBag.Add(dependent);
309329
}
310330
}
311331
}
312332

313333
private static void SkipDownstream(
314334
FlowStep failedStep,
315335
HashSet<FlowStep> skipped,
316-
Dictionary<FlowStep, List<FlowStep>> dependents
336+
IReadOnlyDictionary<FlowStep, IReadOnlyList<FlowStep>> dependents
317337
)
318338
{
319339
// BFS over the dependents graph.

0 commit comments

Comments
 (0)