Skip to content

API Scheduler

iKryptonic edited this page May 1, 2026 · 13 revisions

API: Scheduler

Scheduler is the framework's frame-budgeted task runner. It schedules work on per-event min-heaps, executes due tasks until the frame budget is exhausted, tracks runtime / memory metrics, and exposes snapshots used by ServiceManager.

Important

This page documents FSM/Orchestrator/Core/Scheduler/init.luau, TaskPool.luau, and PerformanceManager.luau.


Overview

Scheduler is designed for recurring engine work that should be:

  • tied to a specific RunService event
  • ordered by due time using a min-heap
  • capped by a frame budget
  • observable through runtime stats and history

Execution pipeline

[Schedule] ──→ [TaskPool.Get] ──→ [Event heap by NextRun] ──→ [Step(event)]
         ──→ [Pop due tasks] ──→ [Execute on coroutine pool]
         ──→ [Update runtime stats / history] ──→ [Requeue recurring tasks]

Core Concepts

Event buckets

Scheduler keeps a separate heap per event name.

Event Availability
Heartbeat server + client
Stepped server + client
PreSimulation server + client
PostSimulation server + client
PreAnimation server + client
RenderStepped client only
PreRender client only

Priority semantics

Unlike BaseStateMachine.Priority, larger numeric Scheduler priority values run sooner, because due time is weighted like this:

NextRun = os.clock() + delay - (priority * PriorityWeight)

So with the default PriorityWeight = 0.001:

  • priority 1 shifts the effective due time earlier by 0.001s
  • priority 10 shifts it earlier by 0.010s

Caution

Scheduler priority and FSM priority are not the same mental model. For FSMs, bigger numbers mean less frequent updates. For Scheduler, bigger numbers mean earlier weighted execution.

Settings

A new scheduler instance uses either Scheduler.Initialize(Util) settings or explicit overrides passed to Scheduler.new(settings).

Setting Type Default Meaning
FrameBudget number 0.005 Maximum seconds Step(...) should spend executing due tasks before deferring the rest
PriorityWeight number 0.001 Time shift applied per priority point

Initialization and Construction

Scheduler.Initialize(Util)

Scheduler.Initialize(Util: any) -> ()

One-time module initialization used by Orchestrator.

  • @reads: Initialized
  • @writes: module settings, module logger, static strings cache

Scheduler.new(settings?)

Scheduler.new(settings: any?) -> Scheduler

Creates an isolated scheduler instance with its own task registry, history, heaps, connections, and PerformanceManager facade.

Returns

  • Scheduler instance

Side effects

  • @reads: Scheduler.Settings
  • @writes: Tasks, History, _heaps, _running, _connections, TaskStats, LastFrameStats, PerformanceManager

Example

local Scheduler = require(path.To.Scheduler)
local scheduler = Scheduler.new({
    FrameBudget = 0.003,
    PriorityWeight = 0.002,
})

scheduler:Start()

Task Record Shape

A scheduled task record looks like this (from TaskPool.Task):

{
    Name: string,
    Action: () -> (),
    Delay: number,
    IsRecurringTask: boolean,
    Priority: number,
    Event: string,
    NextRun: number,
    RunCount: number,
    TotalRunTime: number,
    MaxRunTime: number,
    TotalMemory: number,
    MaxMemory: number,
    CreationTime: number,
    LastRunTime: number?,
}

Important metrics

Field Meaning
RunCount number of successful execution attempts recorded on this live task record
TotalRunTime cumulative execution time in seconds
MaxRunTime slowest observed execution
TotalMemory cumulative positive memory delta measured around executions
MaxMemory highest positive memory delta
CreationTime os.clock() timestamp when the task record was scheduled
LastRunTime wall-clock timestamp recorded at the start of the most recent execution

LastRunTime is what ServiceManager uses to show β€œactive” vs β€œidle” tasks more reliably than checking _running alone.


Core Methods

:Schedule(params)

Scheduler:Schedule(params: {
    TaskName: string,
    TaskAction: () -> (),
    TaskExecutionDelay: number?,
    IsRecurringTask: boolean?,
    Priority: number?,
    Event: string?,
}) -> Task?

Schedules a task into the heap for params.Event.

Parameters

Field Type Required Default Details
TaskName string yes β€” Unique registry key.
TaskAction function yes β€” Work to execute.
TaskExecutionDelay number? no 0 Delay before first run, in seconds.
IsRecurringTask boolean? no false If true, the task is requeued after each execution.
Priority number? no 1 Higher means earlier weighted execution.
Event string? no Heartbeat Event heap to schedule into.

Returns

  • pooled task record on success
  • nil if TaskName or TaskAction is missing / invalid

Side effects

  • @reads: existing Tasks, _heaps
  • @writes: Tasks, _heaps, _taskCount

Overwrite behavior

If a task with the same name already exists:

  • the old registry entry is replaced immediately
  • the old heap entry is not eagerly removed
  • lazy heap cleanup later discards the stale entry

Example: recurring heartbeat task

scheduler:Schedule({
    TaskName = "UpdateThreatMap",
    Event = "Heartbeat",
    TaskExecutionDelay = 0,
    IsRecurringTask = true,
    Priority = 5,
    TaskAction = function()
        print("tick")
    end,
})

Example: client render task

scheduler:Schedule({
    TaskName = "UpdateCrosshair",
    Event = "RenderStepped",
    TaskExecutionDelay = 0,
    IsRecurringTask = true,
    Priority = 2,
    TaskAction = function()
        -- client-only UI update
    end,
})

:Deschedule(name)

Scheduler:Deschedule(name: string) -> ()

Cancels a registered task by name.

Behavior

  • adds a Cancelled history entry
  • decrements _taskCount
  • removes the registry entry
  • leaves any stale heap nodes for lazy cleanup during Step(...)

:ExecuteTask(taskOrName)

Scheduler:ExecuteTask(taskOrName: string | Task) -> ()

Runs a task immediately through the normal internal execution wrapper.

Important note

This bypasses frame-budget throttling because the task is executed right away, not by waiting for Step(...).

Example

scheduler:ExecuteTask("UpdateThreatMap")

:Start()

Scheduler:Start() -> ()

Connects Scheduler:Step(eventName) to all supported RunService events for the current runtime context.

Behavior

  • no-ops if already started (_connections.Heartbeat exists)
  • connects server/client shared events
  • connects RenderStepped and PreRender only on clients
  • logs "Scheduler started."

:Stop()

Scheduler:Stop() -> ()

Disconnects every tracked RunService connection and clears _connections.

:Step(eventName?)

Scheduler:Step(eventName: string?) -> ()

Processes the heap for one event bucket.

Algorithm

  1. choose eventName or default to Heartbeat
  2. stop early if no heap / empty heap
  3. pop due tasks from the min-heap into a temporary list
  4. lazily discard stale heap entries whose registry entry no longer matches
  5. execute each due task in order
  6. if recurring and still live, recompute NextRun and push back into heap
  7. if frame budget is exhausted, push remaining due-but-not-yet-run tasks back into the heap
  8. update LastFrameStats

Side effects

  • @reads: event heap, current time, Settings.FrameBudget, live registry
  • @writes: heap contents, _running, task runtime counters, LastFrameStats, history

Example: manual stepping for tests

local isolated = Scheduler.new({ FrameBudget = 1 })

isolated:Schedule({
    TaskName = "OneShot",
    TaskAction = function()
        print("ran")
    end,
})

isolated:Step("Heartbeat")

:Clear()

Scheduler:Clear() -> ()

Resets task registry, heaps, history, frame stats, task stats, history cursor, and task count.

:GetTask(name)

Scheduler:GetTask(name: string) -> Task?

Returns the current live registry entry for a task.

:GetTaskCount()

Scheduler:GetTaskCount() -> number

O(1) count of live scheduled tasks.

:ResetTask(name)

Scheduler:ResetTask(name: string) -> ()

Resets runtime counters for a task.

If the task table has a custom Reset() method, Scheduler calls that instead of zeroing the standard counters.

:GetSyncData()

Scheduler:GetSyncData() -> {
    Tasks: {[string]: any},
    Logs: any,
    Settings: any,
    History: {any},
    LastFrameStats: { FrameTime: number, TaskCount: number, Budget: number },
    TaskStats: any,
}

Builds the serializable snapshot used by ServiceManager.

Important behavior

  • strips function values from task records
  • includes logger history
  • includes settings, history, and frame stats

:AddToHistory(task, status, duration, err?)

Scheduler:AddToHistory(taskObj: any, status: any, duration: any, err: string?) -> ()

Appends an entry into the 100-slot rolling history buffer.

History entries look like:

{
    Name = task.Name,
    Status = "Completed" | "Failed" | "Cancelled",
    Duration = number,
    Time = os.time(),
    Error = string?,
}

:GenerateKey()

Scheduler:GenerateKey() -> string

Returns HttpService:GenerateGUID(false).

:GetThreadPoolStats()

Scheduler:GetThreadPoolStats() -> {
    TotalThreads: number,
    FreeThreads: number,
    ActiveThreads: number,
    PeakThreads: number,
}

Returns global coroutine-pool stats used by the scheduler runtime.

:PrunePool()

Scheduler:PrunePool() -> ()

Shrinks TaskPool down to 20 cached task tables when the pool has grown above 100.

:CheckAdmin(player)

Scheduler:CheckAdmin(player: Player) -> boolean

Returns true in Studio or when player.UserId == game.CreatorId.


Heap and Budget Details

Min-heap ordering

Each event bucket uses a classic min-heap ordered by task.NextRun.

  • _heapPush(heap, item) bubbles up while item.NextRun < parent.NextRun
  • _heapPop(heap) removes the earliest task and heapifies downward

Lazy stale-entry cleanup

Because overwriting a task name does not delete old heap nodes immediately, Step(...) performs this check at pop time:

if self.Tasks[t.Name] ~= t then
    self:_heapPop(heap)
    if not self._running[t.Name] then
        TaskPool.Release(t)
    end
end

This is why reusing task names is safe without paying removal costs up front.

Budget exhaustion behavior

If the frame budget is hit mid-loop, Scheduler pushes every remaining due task back into the heap untouched so they can run on a later step.


Coroutine Pool / Thread Pool

Scheduler does not create a fresh coroutine for every task forever. It keeps a pool of reusable worker threads:

  • freeThreads - available coroutines
  • totalThreads - how many have ever been created
  • activeThreads - how many are currently executing task bodies
  • peakThreads - high-water mark

Why this matters

  • reduces allocation churn for frequently executed tasks
  • makes GetThreadPoolStats() useful for diagnosing scheduler load spikes
  • keeps the hot path inside _internalExecute(...) relatively cheap

Internal execution wrapper

When a task runs, Scheduler:

  1. grabs or creates a worker coroutine
  2. records LastRunTime
  3. measures runtime with os.clock()
  4. samples memory before / after with collectgarbage("count")
  5. updates RunCount, TotalRunTime, MaxRunTime, TotalMemory, and MaxMemory
  6. records a history row for non-recurring tasks
  7. releases finished one-shot tasks back into TaskPool

PerformanceManager Integration

Every scheduler instance gets:

scheduler.PerformanceManager

That facade provides read-only inspection helpers.

PerformanceManager:GetActiveTasks()

Returns a shallow copy of _running.

PerformanceManager:GetTaskAverage(name)

Returns average runtime for a task, or 0.

PerformanceManager:GetTaskMaximum(name)

Returns max runtime for a task, or 0.

PerformanceManager:GetTaskCount(name)

Returns RunCount for a task, or 0.

PerformanceManager:GetStatsSnapshot()

Returns a serializable map of per-task averages / maxima:

{
    TaskName = {
        RunCount = number,
        RunTimeAverage = number,
        RunTimeMax = number,
        MemoryAverage = number,
        MemoryMax = number,
    }
}

Example

local stats = scheduler.PerformanceManager:GetStatsSnapshot()
for taskName, row in pairs(stats) do
    print(taskName, row.RunTimeAverage, row.MemoryMax)
end

TaskPool Integration

Task descriptors are recycled through TaskPool.

TaskPool.Get()

  • returns a previously released task table if available
  • otherwise allocates a new task record with zeroed fields

TaskPool.Release(task)

  • clears the function reference (Action = nil) to avoid closure leaks
  • zeroes runtime / scheduling fields
  • pushes the record back into the pool

Why it matters for docs users

If you capture a task record and later deschedule it, that table may eventually be reused for a totally different scheduled task after it is released back to the pool.


Real Examples

Poll every 5 seconds on Heartbeat

scheduler:Schedule({
    TaskName = "RefreshDoorMetrics",
    TaskExecutionDelay = 5,
    IsRecurringTask = true,
    Priority = 3,
    Event = "Heartbeat",
    TaskAction = function()
        print("refresh")
    end,
})

One-shot delayed task

scheduler:Schedule({
    TaskName = "OpenDoorLater",
    TaskExecutionDelay = 2,
    IsRecurringTask = false,
    Priority = 1,
    Event = "Heartbeat",
    TaskAction = function()
        print("door should open now")
    end,
})

Inspecting a live task

local taskRow = scheduler:GetTask("RefreshDoorMetrics")
if taskRow then
    print(taskRow.NextRun, taskRow.LastRunTime)
end

Edge Cases & Gotchas

  1. Scheduler priority is inverted from FSM priority. Bigger Scheduler priority means earlier due time, not lower frequency.
  2. ExecuteTask() bypasses budget pacing. Use it sparingly for tools / manual triggers.
  3. Overwriting a task name leaves stale heap nodes behind temporarily. This is expected; Step(...) lazily clears them.
  4. A recurring task stays alive until explicitly descheduled. Non-recurring tasks unregister themselves after a successful execution wrapper path.
  5. LastFrameStats.TaskCount counts popped due tasks, not necessarily fully completed tasks when budget exhaustion requeues leftovers. Treat it as a step-level diagnostic, not a billing-grade metric.
  6. Task records are pooled. Do not hold onto them forever as immutable IDs.
  7. CheckAdmin() only checks Studio or game.CreatorId. It is intentionally simple.
  8. Client-only events cannot be stepped by server-created listeners. RenderStepped / PreRender are only connected on clients.
  9. Long-running task bodies still occupy worker threads. The scheduler budgets dispatch time, not the full downstream lifetime of arbitrary yielding task bodies.
  10. Dashboard active/idle state is smoothed. ServiceManager derives it from LastRunTime and an adaptive window, not strictly from _running.

Related Pages

🏠 Home


πŸš€ Getting Started


πŸ—οΈ Architecture


πŸ”§ API Reference


πŸ“š Guides


βš™οΈ Advanced


πŸ› οΈ Development


πŸ“– Reference

Clone this wiki locally