Forest Logo
search
package_2

taskrunner

By @karlobii

Roblox

Mirrored from Wally

TaskRunner

A small Wally package that wraps a Luau coroutine with:

  • Lifecycle managementIdle → Running → [Retrying] → Completed | Failed | Cancelled | TimedOut, exposed via Status and a StatusChanged signal.
  • Priority — a numeric priority you set yourself, honored by the bundled Scheduler (highest priority runs first, FIFO among ties).
  • Timeout awareness — a hard watchdog that cancels the underlying thread, plus a Handle your function can poll cooperatively (handle:CheckTimeout(), handle:TimeLeft()).
  • Automatic retry — configurable attempt count, delay, exponential backoff, jitter, and an optional shouldRetry(err, attempt) predicate.
  • Structured result/error propagation — every run ends in a Result<T> table ({ ok, value, error, attempts, elapsed }) instead of a bare pcall boolean.

Installation

# wally.toml
[dependencies]
TaskRunner = "karlobii/taskrunner@0.1.0"

Quick start

local TaskRunner = require(Packages.TaskRunner)

local job = TaskRunner.new(function(handle)
    handle:CheckTimeout() -- bail out early if we're already over time/cancelled

    local response = httpService:RequestAsync({ Url = "https://example.com" })
    if response.StatusCode ~= 200 then
        error("bad status " .. response.StatusCode)
    end
    return response.Body
end, {
    name = "FetchExample",
    priority = TaskRunner.Priority.High,
    timeout = 5,       -- seconds
    retries = 3,       -- retry up to 3 times after the first attempt
    retryDelay = 0.5,  -- base delay between attempts
    backoff = 2,       -- exponential backoff multiplier
    jitter = 0.25,     -- up to +0.25s of random jitter added to each delay
})

job.Completed:Connect(function(result)
    print("got body:", result.value)
end)

job.Failed:Connect(function(result)
    warn(("failed after %d attempts: %s"):format(result.attempts, result.error))
end)

job:Start()

Or synchronously from another coroutine/thread:

local result = TaskRunner.new(fn, { timeout = 3 }):Start():Await()
if result.ok then
    print(result.value)
else
    warn(result.error)
end

API

TaskRunner.new(fn, options?) -> TaskRunnerInstance

fn is called as fn(handle). Its return value becomes Result.value on success; any error thrown becomes Result.error on failure (after retries are exhausted).

options:

FieldTypeDefaultDescription
namestring"TaskRunner"Label, useful in logs/errors.
prioritynumberTaskRunner.Priority.NormalConsumed by Scheduler; otherwise informational.
timeoutnumber?nil (no timeout)Seconds before the attempt is force-cancelled.
retriesnumber0Additional attempts after the first failure.
retryDelaynumber0Base delay in seconds before a retry.
backoffnumber1Multiplier applied to retryDelay per attempt (retryDelay * backoff^(attempt-1)).
jitternumber0Adds random() * jitter extra seconds to each retry delay.
shouldRetry(err: string, attempt: number) -> booleannilReturn false to stop retrying regardless of retries left.

Instance members

  • Status — one of TaskRunner.Status.{Idle, Running, Retrying, Completed, Failed, Cancelled, TimedOut}.
  • Attempts — number of attempts made so far.
  • Result — the last Result<T> once terminal, else nil.
  • Completed, Failed, CancelledSignal<Result<T>>.
  • RetryingSignal<attempt: number, error: string>, fired before each retry sleep.
  • StatusChangedSignal<newStatus, oldStatus>.
  • :Start() -> self — begins execution; can only be called once.
  • :Await() -> Result<T> — cooperatively waits (via task.wait) until terminal, then returns Result.
  • :Cancel(reason: string?) — cancels immediately from any non-terminal state.
  • :IsDone() -> boolean.

Handle (passed into your function)

  • handle:IsCancelled() -> boolean
  • handle:TimeLeft() -> number? — seconds until the deadline, or nil if no timeout is set.
  • handle:CheckTimeout() — throws if cancelled or past the deadline; call this at loop boundaries in long-running work so timeouts/cancellation are cooperative rather than only enforced by the hard watchdog.
  • handle.Attempt — the current attempt number (1 on the first try).

Result<T>

{
    ok: boolean,
    value: T?,       -- present when ok == true
    error: string?,  -- present when ok == false
    attempts: number,
    elapsed: number, -- seconds since :Start()
}

TaskRunner.Scheduler

Optional bounded-concurrency priority queue for running many TaskRunners together.

local scheduler = TaskRunner.Scheduler.new({ maxConcurrent = 4 })
scheduler:Add(jobA)
scheduler:Add(jobB) -- higher-priority jobs run first regardless of add order
scheduler.Idle:Connect(function()
    print("queue drained")
end)
  • Scheduler.new({ maxConcurrent: number? }) -> Scheduler
  • :Add(runner) -> runner — queues an un-started TaskRunner; starts it once a concurrency slot frees up.
  • :CancelAll(reason: string?) — cancels every queued and in-flight runner.
  • :RunningCount(), :QueuedCount().
  • .IdleSignal<>, fires whenever the queue empties and nothing is running.

Design notes

  • The hard timeout is enforced with task.cancel on the underlying coroutine's thread. This works for any coroutine that yields periodically — e.g. one that calls task.wait in a loop — even if it never calls handle:CheckTimeout(). It cannot interrupt a coroutine that never yields at all (a tight, non-yielding CPU loop): Luau's cooperative scheduling means nothing can preempt code that never hands control back. If your work might spin without yielding, call handle:CheckTimeout() inside the loop yourself so it can bail out cooperatively.
  • Retries reuse the same TaskRunnerInstanceAttempts accumulates across the whole lifecycle, and Result.attempts reflects the total attempts made when the runner settles.
  • TaskRunner:Start() is one-shot by design; construct a fresh instance if you need to run the same logic again.

Package Details

Install command (Click to copy)


Version

0.1.0

License

MIT

check_circle

Safe for commercial use

Automated license review — not legal advice.