Class DagScheduler
Production DAG scheduler for the Typhon Runtime. Executes a static system DAG on a pool of worker threads, with any-worker dispatch and inline continuation.
public sealed class DagScheduler : HighResolutionTimerServiceBase, IResource, IDisposable, IMetricSource
- Inheritance
-
HighResolutionTimerServiceBaseDagScheduler
- Implements
- Inherited Members
- Extension Methods
Remarks
Derives from Typhon.Engine.Internals.HighResolutionTimerServiceBase to leverage its three-phase wait (Sleep → Yield → Spin), self-calibrated sleep threshold, timing error tracking, and ResourceNode lifecycle.
The timer thread acts as a metronome: it resets per-tick state, bumps the generation counter (waking workers), and waits for tick completion. All dispatch decisions happen on workers (POC decision D2: any-worker dispatch, no scheduler thread).
Between ticks, workers block on _tickStartSignal — a ManualResetEventSlim constructed with spinCount: 0 — inside a loop that
re-checks _tickGeneration, with a 50 ms timeout as a shutdown-liveness backstop. The three-phase wait (Sleep → Yield → Spin) belongs to the timer
thread's metronome in Typhon.Engine.Internals.HighResolutionTimerServiceBase, not to the workers; _nextTickTimestamp is likewise metronome state, read by
GetNextTick and never by a worker.
Constructors
DagScheduler(SystemDefinition[], int[], IReadOnlyList<Track>, int, RuntimeOptions, IResource, EventQueueBase[], ILogger)
Creates a new DAG scheduler.
public DagScheduler(SystemDefinition[] systems, int[] topologicalOrder, IReadOnlyList<Track> tracks, int deferredTrackStartIndex, RuntimeOptions options, IResource parent, EventQueueBase[] eventQueues = null, ILogger logger = null)
Parameters
systemsSystemDefinition[]System definitions from Build().
topologicalOrderint[]Topological order from Build().
tracksIReadOnlyList<Track>The track hierarchy in execution order — each track carries its DAGs and their resolved system indices.
deferredTrackStartIndexintTracks from this index onward are dispatched on demand by the runtime (e.g. Engine-Post after serial fence prep), not by the in-tick track loop.
optionsRuntimeOptionsRuntime configuration.
parentIResourceParent resource node (typically Runtime).
eventQueuesEventQueueBase[]Event queues to reset at each tick start. Can be empty.
loggerILoggerOptional logger for diagnostics.
Fields
UnhandledExceptionCallback
Public hook fired when the WorkerLoop's outer safety net catches an unhandled exception that escaped every inner try/catch (engine bug, runaway exception from inside a catch handler, etc.). User code can subscribe to log to file, send to telemetry, request graceful shutdown, or escalate to a debugger break. Invoked on the worker thread that caught the exception. Defaults to null (no-op).
Note: The vast majority of user-code exceptions are already caught by the per-system handlers in
Typhon.Engine.DagScheduler.ProcessParallelQuery(System.Int32,System.Int32,System.Boolean), Typhon.Engine.DagScheduler.ProcessCallbackOrQuery(System.Int32,System.Int32,System.Boolean), Typhon.Engine.DagScheduler.ProcessPipeline(System.Int32,System.Int32,System.Boolean), and
Typhon.Engine.DagScheduler.ExecuteInline(System.Int32,System.Int32,System.Boolean). Those paths log via LogSystemException and capture via
CaptureSystemException without invoking this callback. This callback fires ONLY when the outer WorkerLoop safety net catches something — meaning
an inner handler didn't, which is itself a bug worth surfacing prominently.
public Action<int, string, Exception> UnhandledExceptionCallback
Field Value
Properties
AllSystemCount
Total number of systems in the DAG, including engine-internal systems (e.g. FenceExec).
public int AllSystemCount { get; }
Property Value
CurrentOverloadLevel
Current overload response level.
public OverloadLevel CurrentOverloadLevel { get; }
Property Value
CurrentTickNumber
Number of ticks executed so far.
public long CurrentTickNumber { get; }
Property Value
DispatcherWorkerId
The reserved worker slot for the dispatcher (timer) thread — always WorkerCount, one past the last real worker (#860).
public int DispatcherWorkerId { get; }
Property Value
Remarks
The timer thread runs system bodies in one narrow case: Typhon.Engine.DagScheduler.MarkTrackRootsReady(System.Int32[]) skips a root, and OnSystemComplete chains the
successor into ExecuteInline right there. Internally the scheduler passes workerId = -1 for this thread; Typhon.Engine.DagScheduler.ToWorkerSlot(System.Int32)
translates it to this slot so system code can index per-worker state on every path that runs system code.
Why a dedicated slot is safe: disjointness, not quiescence. It is tempting to argue that the inline execution happens before
_tickStartSignal.Set() wakes the pool and is therefore serialized against every worker. That is NOT true across tracks:
Typhon.Engine.DagScheduler.DispatchTrackMultiThreaded(System.Int32) clears _tickInProgress only after its completion barrier, so a worker that has already evaluated
its while (_tickInProgress == 1 && _systemsRemaining.Value > 0) loop condition can still be inside FindReadySystem while
the timer thread has advanced into the next track's Typhon.Engine.DagScheduler.MarkTrackRootsReady(System.Int32[]). The slot is safe because the dispatcher never writes a
worker's slot and no worker ever writes this one — do not weaken that to "the dispatcher can share a worker slot because nothing overlaps".
Consequence: per-worker structures reachable from system code must be sized WorkerCount + 1, and
WorkerId ranges over [0, WorkerCount], not [0, WorkerCount).
EventQueues
All registered event queues. Read-only view exposed for static-data introspection (the profiler builds the v7 EventQueueRecord catalog from this) — not for hot-path use; system code should go through the pre-allocated ConsumedQueues array instead.
public IReadOnlyList<EventQueueBase> EventQueues { get; }
Property Value
IsTickAborted
True once a fatal system exception has aborted a tick under AbortTickAndStop. Terminal — never cleared. No further ticks execute.
public bool IsTickAborted { get; }
Property Value
SystemCount
Number of user-registered systems (systems whose track does not carry the EngineTag — i.e. excludes the Fence DAG). This is the count callers usually want — "the systems I registered". For the total including engine-internal systems use AllSystemCount.
public int SystemCount { get; }
Property Value
Systems
All registered systems across every track and DAG, indexed by system index. Used by the scheduler for dispatch, lookup, and topological reasoning — the index identity is load-bearing for chunk dispatch state and dependency wiring, so this array stays as the canonical source for everything index-keyed (including diagnostics/profiler that walk every entry by raw index).
public SystemDefinition[] Systems { get; }
Property Value
Remarks
When you only care about systems the user registered (counting them in a test, iterating them in a DAG-viewer that hides engine plumbing), prefer UserSystems — it filters out systems whose track carries the EngineTag (e.g. the Fence DAG) so the count is stable regardless of which engine-internal DAGs the runtime registers.
Telemetry
Telemetry ring buffer for diagnostic inspection.
public TickTelemetryRing Telemetry { get; }
Property Value
ThreadName
Thread name set by derived classes for diagnostics.
protected override string ThreadName { get; }
Property Value
Tracks
The runtime partitioning hierarchy — tracks in execution order, each carrying its DAGs. Static for the scheduler's lifetime.
public IReadOnlyList<Track> Tracks { get; }
Property Value
UserSystems
User-registered systems only (filtered Systems with engine-tagged-track entries removed). Indices in this view do NOT match the canonical system index — use this only for iteration and counting, not for index-keyed lookups.
public SystemDefinition[] UserSystems { get; }
Property Value
WorkerCount
Number of worker threads.
public int WorkerCount { get; }
Property Value
WorkerSlotCount
Number of distinct worker slots a context can report — WorkerCount real workers plus the dispatcher slot (#860).
public int WorkerSlotCount { get; }
Property Value
Remarks
Two different indices share the name "worker id" — size against the right one. This value sizes arrays indexed by
WorkerId, the slot a system body sees. The scheduler's own per-worker pools (_partitionViews, the tier-range view
pool, ParallelTransactionAccessor.GetWorkerAccessor) are indexed by the internal workerId that chunk dispatch hands out,
which is always a real pool index and never the dispatcher slot — they are correctly sized WorkerCount and must stay that way.
Methods
DispatchDeferredTracks()
Dispatches every runtime-deferred track (Engine-Post and beyond) in execution order. Called by TyphonRuntime.OnTickEndInternal after the serial
WriteTickFence prep, so the Fence DAG sees a populated FenceContext. No-op when every deferred track is empty (e.g. parallel fence disabled).
public void DispatchDeferredTracks()
DispatchTrack(int)
Dispatches a single track by its execution-order index. The in-tick tracks are dispatched automatically each tick; the runtime calls this (via DispatchDeferredTracks()) for the deferred Engine-Post track after serial fence prep. Branches on worker count: single-threaded mode runs the track synchronously on the caller.
public void DispatchTrack(int trackIndex)
Parameters
trackIndexint
Dispose(bool)
Stops the timer thread and disposes managed resources. Follows the standard ResourceNode disposal pattern. Idempotent with Typhon.Engine.Internals.HighResolutionTimerServiceBase.StopTimerThread() — calling this after the timer has already been stopped just runs the managed-resource cleanup.
protected override void Dispose(bool disposing)
Parameters
disposingbool
DumpHangDiagnostic()
Returns a multi-line snapshot of the scheduler's current tick state, suitable for logging when a tick fails to complete. Cheap enough to call ad-hoc — O(systemCount) reads of internal counters.
public string DumpHangDiagnostic()
Returns
ExecuteCallbacks(long, long)
Execute the callback(s) due at this tick. Called by the timer loop immediately after the three-phase wait completes.
protected override void ExecuteCallbacks(long scheduledTick, long actualTick)
Parameters
scheduledTicklongThe Stopwatch timestamp that was targeted (as returned by Typhon.Engine.Internals.HighResolutionTimerServiceBase.GetNextTick()).
actualTicklongThe Stopwatch timestamp when the wait actually completed. The difference
abs(actualTick - scheduledTick)is the timing error for this tick.
GetNextTick()
Returns the Stopwatch timestamp of the next tick to wait for. Called by the timer loop after each callback execution to determine the next wake-up time.
protected override long GetNextTick()
Returns
- long
The GetTimestamp() value at which the next tick should fire. Return MaxValue to idle indefinitely (e.g., no registrations).
Remarks
Returning a timestamp in the past causes the wait loop to exit immediately and call ExecuteCallbacks(long, long) without delay (catch-up behavior). Derived classes should use metronome-style advancement (nextTick += interval) rather than computing from current time, to prevent drift accumulation.
OnWaitComplete(long, long, long, byte)
Emit a SchedulerMetronomeWait span for the inter-tick wait that just completed. Called on the TickDriver thread by Typhon.Engine.Internals.HighResolutionTimerServiceBase.
protected override void OnWaitComplete(long scheduledTimestamp, long startTimestamp, long endTimestamp, byte phaseFlags)
Parameters
Remarks
Why this exists (issue #289 follow-up). Without this hook the timer thread's three-phase wait between ticks emits no profiler events — appearing
as dead time on the trace even when the engine is intentionally throttling itself via Typhon.Engine.Internals.OverloadDetector.TickMultiplier. The span carries
the multiplier and an intentClass byte (CatchUp / Throttled / Headroom) so a trace viewer can answer why the metronome was waiting.
RegisterContext<TContext>(TContext)
Bind an ambient TContext onto every registered system deriving from ChunkedCallbackSystem<TContext>.
Must be called after the schedule is built (systems must already be registered) and before Start().
public void RegisterContext<TContext>(TContext context) where TContext : class
Parameters
contextTContext
Type Parameters
TContext
Shutdown()
Stops the worker pool: signals shutdown, releases any worker parked between ticks, and joins them.
A tick already in flight is ABANDONED, not drained. That is not a choice so much as an admission: this method signals every worker to stop before it waits, so the systems remaining in that tick have nobody left to run them, and "waiting for the current tick to finish" could only ever mean waiting forever. A caller that needs the in-flight tick's work to land must stop dispatching and let the tick complete BEFORE calling this.
Does NOT stop the timer thread — Dispose(bool) does, via the base class, and that is the only quiescence point. The thread stays alive
here but stops producing ticks: GetNextTick() returns long.MaxValue and ExecuteCallbacks(long, long) bails out, both gated at
tick ENTRY. A tick already past that gate therefore runs to completion, and the increment of CurrentTickNumber is the last statement of
that tick's telemetry finalizer — so the counter can advance by ONE after this method returns. Never by more: _workerShutdown is published
before the join, so every later tick early-returns at the gate. Callers that need "nothing is running" must use Dispose(bool).
May legitimately be called ON the timer thread. OnTickAborted fires inside the tick-end callback and its documented
response is FatalStop(), which lands here — so on that path this returns INTO the tick it just stopped, and the one-tick
advance above is guaranteed rather than merely possible. Do not "fix" that by joining the timer thread here: it would be a self-join, which
StopTimerThread's 2 s bound turns into a stall that returns false and changes nothing. See issue #404.
public void Shutdown()
Start()
Starts the worker threads and the tick driver timer thread.
public void Start()