moontask

    Task queues and schedules for MoonBit: workers, retries, periodic beats, and the chain, group and chord canvas. Brokers and result stores are separate modules.

    task-queue
    job-queue
    queue
    worker
    celery
    scheduler
    cron
    crontab
    schedule
    retry
    backoff
    prefetch
    workflow
    canvas
    chain
    group
    chord
    dag
    beat
    periodic
    background-jobs
    distributed
    moonbit
    Download zip
    Version
    0.2.0
    License
    Apache-2.0
    Last updated
    20 hours ago
    Downloads
    35

    #moontask

    Work that outlives the request that asked for it.

    Task queues, retries, periodic beats, workflows, and the canvas that chains, groups and joins runs — for MoonBit.


    #Declaring a task

    #task.def on a function declares a task, as Celery's @app.task does:

    #task.def(name="orders.place", queue="orders", retries="1")
    pub fn place(order : Order, ctx : @moontask.Context) -> String raise {
    ...
    }

    The generator writes a Tasks record with a typed handle for each declared task, and register_tasks, which registers them all on an app. A worker calls it to run the tasks, a producer to send them:

    let tasks = register_tasks(app)
    let receipt = tasks.place.send({ id: "a1", cups: 2 }).get(timeout=5000L)

    A task takes one argument, the one JSON value its message carries (a record when there are several), and may end with the run's Context; it can be a plain function or an async fn. The options are name (the function's name when left out), queue, retries, timeout (250ms, 30s, 5m or 1h), acks_late and ignore_result, laid over the app's preset; the rest of a policy is set by calling App::task directly, as below.

    The generator wires in as a dev_build rule, in moon.mod:

    rule(name: "tasks", command: "moonx moonbitstack/moontask/cmd/tasks -C \"$mod_dir\" \"$input\" -o \"$output\"")

    and in the moon.pkg of the package, which imports moonbitstack/moontask and moonbitlang/core/json (for the FromJson and ToJson the argument derives):

    dev_build(rule: "tasks", input: "jobs.mbt", output: "jobs.g.mbt") formatter(ignore: [ "jobs.g.mbt" ])

    moon run src/examples/06-annotated shows it.

    #Without the annotation

    An app owns a broker, a result store and a clock. A task is a name, a policy and a body; sending one writes a message and hands back a handle that decodes the stored result back into the body's return type. A worker takes messages off the broker, runs them, and settles each one the way its policy says.

    async fn first_run() -> Unit {
    let now = fn() { @env.now().reinterpret_as_int64() }
    let broker = @moontask.MemoryBroker::new(clock=now)
    let app = @moontask.App::new(broker, backend=broker, counter=broker, clock=now)

    let greet = app.task("greet", fn(name : String, ctx : @moontask.Context) {
    "hello \{name}, attempt \{ctx.attempt}"
    })

    let worker = app.worker(concurrency=4)
    @async.with_task_group(async fn(group) {
    group.spawn_bg(async fn() { worker.run() }, no_wait=true)
    let handle = greet.send("ada")
    println(handle.get(timeout=5000L))
    worker.stop()
    })
    }

    #What is in the module

    PackageWhat it is
    moontask/specThe contracts — Broker, Backend, Counter, Inspector — and the message, outcome and state they carry
    moontaskApp, Task, Handle, Worker, Beat, the canvas, the executor a graph runs on, and an in-memory broker that is also a store and a counter
    moontask/scheduleA job on a rhythm inside one process, with no broker behind it
    moontask/flowWorkflows: steps that are tasks, ordered by a state graph, each run stamped with the instant it belongs to
    moontask/waitThe one place a wait is written, so the WebAssembly targets are handled once

    Three further modules live beside it, each with its own moon.mod so a queue that does not need them does not download them:

    ModuleWhat it is
    moontask-redisA broker and result store on Redis, with per-consumer leases and recovery of a worker that stopped — or on Celery's own key layout, so one queue can be shared with a Celery worker
    moontask-sqlA result store and a workflow run log on SQL, over any moondb async driver, so the same code runs on SQLite, MySQL and PostgreSQL
    moontask-adminA management interface as an ASGI application: a page, reports, sending, revoking, and a live stream of counts

    #A task's policy

    FieldMeaningDefault
    queueWhere the message goes"default"
    retriesHow many times a failed run is tried again0
    backoffThe wait between attemptsexponential, 1s base, 10min cap, jittered
    timeoutHow long one run may takeno limit
    acks_lateConfirm after the run instead of on receiptfalse
    ignore_resultKeep no resultfalse
    rateRuns per window, per workerno limit

    The jitter inside an exponential backoff is derived from the task id and the attempt rather than drawn at random, so the same task waits the same on every machine and a test can assert the delay.

    #The canvas

    async fn canvas() -> Unit {
    let now = fn() { 1_700_000_000_000L }
    let broker = @moontask.MemoryBroker::new(clock=now)
    let app = @moontask.App::new(broker, backend=broker, counter=broker, clock=now)
    let step = app.task("step", fn(value : Int32, _) { value * 10 })
    let total = app.task("total", fn(values : Array[Int32], _) {
    let mut sum = 0
    for value in values {
    sum = sum + value
    }
    sum
    })
    let chained = @moontask.chain([step.sig(1), step.sig(2), step.sig(3)]).send(app)
    let grouped = @moontask.group([step.sig(4), step.sig(5)]).send(app)
    let joined = @moontask.chord(
    [step.sig(1), step.sig(2), step.sig(3)],
    total.sig([]),
    ).send(app)
    let mapped = step.map([6, 7]).send(app)
    let starred = total.starmap([[1, 2], [3, 4]]).send(app)
    let chunked = step.chunks([[1], [2], [3]], size=2).send(app)
    println(
    "\{chained.ids().length()} \{grouped.ids().length()} \{joined.ids().length()}",
    )
    println(
    "\{mapped.ids().length()} \{starred.ids().length()} \{chunked.ids().length()}",
    )
    }

    A chain is a message carrying its own tail. A chord gives every part the same counter key and the callback behind it, so whichever worker finishes last sends the callback; a part that fails for good records the callback as failed rather than leaving it pending.

    map, starmap and chunks are the fan-out: one run per item, one run per tuple, and one run per slice of tuples.

    CallWhat it publishesWhat the handle answers
    task.map(items)one message per item, the item being that run's whole argumentthe results in the order given
    task.starmap(tuples)one message per tuple, the tuple being that run's argument listthe results in the order given
    task.chunks(tuples, size=2)one message per slice, each running its tuples in order in the worker that took itone list per slice, the last slice holding what is left

    A tuple of one travels as that single argument and a longer one as the argument list, which is how a consumer on the other side of a shared queue reads it too, so a fan-out published here is runnable by a worker there. chunks trades parallelism for dispatch: fewer messages and fewer reservations, at the cost of running a slice one item at a time, and a slice is retried as a whole by the chunk task's own policy rather than item by item — a run that needs its own retry budget goes through map or starmap.

    An empty map, starmap or chunks publishes nothing and hands back a handle that is already answered with an empty list, while chain([]) is refused: a chain with no link has nothing to run.

    #On a rhythm

    fn rhythm() -> Unit {
    let now = fn() { 1_700_000_000_000L }
    let broker = @moontask.MemoryBroker::new(clock=now)
    let app = @moontask.App::new(broker, backend=broker, counter=broker, clock=now)
    let tick = app.task("tick", fn(_ : Unit, _) { () })
    let nightly = @cron.parse("0 3 * * *") catch { _ => abort("valid expression") }
    let beat = app.beat()
    beat.add("tick", tick, (), @moontask.Schedule::Every(interval=1000L))
    beat.add("nightly", tick, (), @moontask.Schedule::Cron(expr=nightly))
    println(beat.pending().length())
    }

    #Workflows

    A flow is a set of steps and the dependencies between them. Each step is an ordinary task, so it runs wherever a worker picks it up; the order comes from a state graph, and each run is stamped with the logical instant it belongs to rather than the time it happened to start.

    async fn workflow() -> Unit {
    let now = fn() { 1_700_000_000_000L }
    let broker = @moontask.MemoryBroker::new(clock=now)
    let app = @moontask.App::new(broker, backend=broker, counter=broker, clock=now)
    let extract = app.task("extract", fn(_ : Unit, _) -> Json { { "rows": 3 } })
    let load = app.task("load", fn(args : Json, _) -> Json {
    { "written": 3, "from": args }
    })
    let flow = @flow.Flow::new(
    "etl",
    schedule=@moontask.Schedule::Every(interval=3_600_000L),
    )
    flow.step("extract", extract)
    flow.step("load", load, deps=["extract"])
    let worker = app.worker()
    @async.with_task_group(async fn(group) {
    group.spawn_bg(async fn() { worker.run() }, no_wait=true)
    let run = flow.run(app, 1_700_000_000_000L)
    println(run.to_string())
    println(run.output_of("load").unwrap().stringify())
    worker.stop()
    })
    }

    Three things are worth knowing about the arguments. A step that waits for nothing is called with the run's input; a step that waits for others is called with an object of their answers under their ids, so the fields its body reads are the ids it named. And a step names only steps registered before it, which is what makes a flow acyclic by construction.

    backfill(start, end) runs every instant the flow's schedule names in a window, each as its own run, and one that fails does not stop the ones behind it. catchup fills the instants a late start skipped, from the last run the flow knows about — in this process, or in the recorder when there is one. Give a flow a saver with keep(saver) and a run interrupted halfway resumes at the step it stopped on.

    A run answers Run, whose status says whether every step settled, and the record keeps the answers even when one did not. record(recorder) writes each run as it ends; moontask-sql implements the recorder over a table.

    #Results and run records on SQL

    moontask-sql is a result store and a recorder on one connection. The driver is yours, so the same code runs on SQLite, MySQL and PostgreSQL; nothing here opens a socket.

    import {
    "moonbitstack/moontask",
    "moonbitstack/moontask/flow",
    "moonbitstack/moontask-sql/sql",
    "moonbitstack/moonpostgres",
    }

    async fn sql_backend {
    let conn = @moonpostgres.PgConn::connect("127.0.0.1", 5432, "postgres", "postgres", "test")
    let store = @sql.Store::new(conn, clock=fn() { @env.now().reinterpret_as_int64() })
    store.ensure()
    let app = @moontask.App::new(broker, backend=store, clock=fn() { @env.now().reinterpret_as_int64() })
    let flow = @flow.Flow::new("etl")
    flow.record(store)
    }

    ensure() creates the two tables if they are not there yet, and is meant to be called once at start-up: a store that ran DDL on every write would put a schema change on the queue's hot path. The tables are named by Tables, so two queues can share one schema.

    A database that answers in the same call has a driver of that shape, and Store::inline takes it: the statement runs where it was made, which for a local file is what it does anyway, and the store writes against it exactly as it writes against a server.

    let db = @sqlite.SqliteDriver::open("queue.db")
    let store = @sql.Store::inline(db, clock=fn() { @env.now().reinterpret_as_int64() })
    store.ensure()

    #Across processes

    let conn = @socket.Connection::connect(@moonredis.ClientOptions::at("127.0.0.1", 6379))
    let broker = @redis.Broker::new(conn, clock=now, consumer="worker-7")
    let app = @moontask.App::new(broker, backend=broker.store(), counter=broker.store(), clock=now)

    A queue is a list per name, messages waiting for their eta sit in a sorted set, and what a consumer holds is a list of its own kept alive by a lease. broker.recover() hands back what a worker that stopped refreshing its lease was holding.

    The same module also speaks Celery's layout, so a queue can be worked from both sides:

    let broker = @redis.Broker::celery(conn, clock=now, consumer="worker-7")

    the queuethe list named after it, split across Celery's priority steps
    the payloadCelery's envelope: task and id in headers, arguments in the body
    what a consumer holdsCelery's unacked hash and its index, so either side can hand back a claim the other left
    a chaineach message carries the rest of its chain; a link that is not immutable is called with the previous link's result prepended
    a chordthe parts' results land in Celery's join keys as they settle, so the last part — on either side — closes it
    an etathe message sits on the queue and the consumer holds it, which is what Celery does

    interop/ holds the harness that proves it: bash interop/check.sh runs ten scenarios against a real Celery 5.5 — a chain, a chord, a fan-out, a handed-back reservation and an eta, each in both directions — and fails unless each side ran what the scenario claims.

    #Writing a broker

    Four calls. The receipt a claim hands back is opaque to the library:

    pub(open) trait Broker {
    async fn publish(Self, queue : String, message : @spec.Message) -> Unit raise @spec.Refused
    async fn consume(Self, queues : ArrayView[String], prefetch~ : Int32) -> @spec.Delivery raise @spec.Refused
    async fn ack(Self, delivery : @spec.Delivery) -> Unit raise @spec.Refused
    async fn reject(Self, delivery : @spec.Delivery, requeue~ : Bool) -> Unit raise @spec.Refused
    }

    A broker that also implements Backend and Counter is passed to App::new three times, which is what the in-memory one and the Redis one do.

    #Examples

    Seven runnable examples ship in src/examples: 01-queue takes a task from send to answer with one retry in between, 02-canvas runs a chain, a group and a chord, 03-retry shows the ladder an exponential backoff climbs, 04-flow runs a two-step workflow on a worker, 05-floor records a run as the events the home page's figure replays, 06-annotated declares its tasks with #task.def, and 07-fanout maps, star-maps and chunks one list. All of them pin their clock, so their output is the same on every run.

    moon run src/examples/01-queue moon run src/examples/02-canvas moon run src/examples/03-retry moon run src/examples/04-flow moon run src/examples/05-floor moon run src/examples/06-annotated moon run src/examples/07-fanout

    #License

    Apache-2.0.

    App

    pub struct App {
    broker : &
    Broker

    backend : &
    Backend
    ?
    counter : &
    Counter
    ?
    clock : () -> Int64
    tasks : Map[String, Registered]
    routes : Map[String, Wiring]
    running : Map[String,
    Task
    [Result[
    Json
    , String]]]
    seq : Int
    }

    The object every other type hangs off.

    It owns the three seams (broker, result store, chord counter), the clock the library reads instead of the system clock, and the registry that turns a message name back into a body. Nothing here is global: two apps in one process do not see each other's tasks.

    App::backend_of

    The result store the app was given, or None when it keeps nothing.

    App::beat

    fn App::beat(self : App) -> Beat

    A beat on app with nothing scheduled.

    App::executor

    fn App::executor(self : App, queue? : String) -> Dispatch

    An executor that sends the nodes of a superstep to queue and waits for each result.

    App::has_task

    fn App::has_task(self : App, name : String) -> Bool

    Whether a body is registered under name.

    App::new

    An app on broker, with backend keeping results, counter counting chord parts, and clock as the only source of time.

    The store and the counter are optional: an app without a store runs tasks nobody waits on, and an app without a counter refuses to build a chord. A broker that also implements Backend and Counter — the in-memory one and the Redis one both do — is passed three times.

    App::now

    fn App::now(self : App) -> Int64

    The app's time, in milliseconds since the epoch.

    App::policy_of

    fn App::policy_of(self : App, name : String) -> Policy?

    The policy registered under name, or None when nobody registered it.

    App::route

    fn App::route(self : App, graph : String, node : String, task : String, deps? : Array[String]) -> Unit

    Record that node of graph runs the task task, and reads deps.

    A flow registers this for every step it holds, which is how an executor — in this process or in another — finds the body behind a node name.

    App::send

    async fn App::send(self : App, name : String, args :
    Json
    , eta? : Int64, countdown? : Int64, expires? : Int64, queue? : String, priority? : Int, id? : String) -> Handle[
    Json
    ] raise
    Refused

    Publish a run of the task called name with a JSON argument, without a Task value in hand.

    This is the entry for a dispatcher that learns the name at run time — an HTTP handler, a management page, a message from another system — and the only thing it needs to keep is the name. An unknown name is refused rather than published, since a queue that accepts work nobody can run only moves the failure to a worker.

    App::task

    fn[A :
    FromJson
    , R :
    ToJson
    ] App::task(self : App, name : String, run : async (A, Context) -> R, policy? : Policy) -> Task[A, R]

    Register run under name and return the task a caller sends.

    Registering twice under the same name replaces the first, which is what makes it possible to run the same task under two policies in one test.

    App::task_names

    fn App::task_names(self : App) -> Array[String]

    Every registered name, sorted so a page listing them is stable.

    App::worker

    fn App::worker(self : App, queues? : Array[String], concurrency? : Int, prefetch? : Int) -> Worker

    A worker over queues that runs up to concurrency tasks at once.

    prefetch is how many messages one worker may hold unsettled: a worker that holds more than it can run looks busy while the queue behind it starves.

    Backoff

    pub(all) enum Backoff {
    Fixed(delay~ : Int64)
    Exponential(base~ : Int64, cap~ : Int64, jitter~ : Bool)
    } derive(Eq,
    Debug
    )

    How long to wait before the next attempt.

    Fixed waits the same time every time. Exponential doubles from base until it reaches cap, and with jitter on it spreads the wait over the whole interval instead of always waiting the full one — that is what keeps a thousand workers from retrying in the same millisecond after an outage.

    The spread is derived from the task id and the attempt number rather than drawn at random, so the same task retried twice on two machines waits the same both times, and a test can assert the delay.

    Backoff::delay

    fn Backoff::delay(self : Backoff, attempt : Int, seed : String) -> Int64

    The wait before attempt attempt, counting the first attempt as one.

    seed is what the spread is derived from: the task id in practice. Attempt zero never waits, since nothing has failed yet.

    Backoff::equal

    fn Backoff::equal(Backoff, Backoff) -> Bool

    Backoff::not_equal

    fn Backoff::not_equal(x : Backoff, y : Backoff) -> Bool

    Backoff::to_repr

    Beat

    pub struct Beat {
    app : App
    entries : Array[BeatEntry]
    stopping : Bool
    }

    The object that sends tasks on a schedule.

    A beat holds a list of entries and, every tick, sends the ones whose time has come. It keeps the phase: a schedule of every hour fires at the same minute every hour even if one tick ran late, because the next time is computed from the time that was due, not from the time the tick happened.

    Beat::add

    fn[A :
    ToJson
    , R] Beat::add(self : Beat, name : String, task : Task[A, R], args : A, schedule : Schedule) -> Unit

    Schedule task with args under name.

    The name identifies the entry, not the task: the same task may be scheduled twice under two names and two schedules.

    Beat::pending

    fn Beat::pending(self : Beat) -> Array[(String, Int64)]

    The entry names and when each fires next, soonest first.

    Beat::run

    async fn Beat::run(self : Beat) -> Unit raise
    Refused

    Send due entries, then wait for the next one. Runs until stop.

    Beat::stop

    fn Beat::stop(self : Beat) -> Unit

    Stop after the tick in progress.

    BeatEntry

    type BeatEntry

    One thing the beat sends.

    Context

    pub(all) struct Context {
    id : String
    name : String
    queue : String
    retries : Int
    attempt : Int
    last : Bool
    now : Int64
    } derive(Eq,
    Debug
    )

    What a task body is told about the run it is in.

    retries is how many attempts before this one failed, attempt is this one counting from one, and last says whether another attempt follows a failure — a body that must not repeat a side effect checks it before doing the work again.

    Context::equal

    fn Context::equal(Context, Context) -> Bool

    Context::not_equal

    fn Context::not_equal(x : Context, y : Context) -> Bool

    Context::to_repr

    Dispatch

    pub struct Dispatch {
    app : App
    queue : String
    }

    The executor a state graph runs on: every work item of a superstep becomes a queue message, and what comes back is the write that step contributes.

    A graph keeps the order, the durability and the retry policy of a run; this only decides where a node's body runs. Here it runs on the workers, so a workflow outlives the process that started it.

    Which task a node runs, and what it is called with, is the app's route for its graph and node, which is what a flow registers: a node that waits for nothing is called with the run's input, and a node that waits for others with an object of their answers under their ids. A node with no route writes nothing, and a graph with several roots uses one such node as its entry.

    Handle

    pub struct Handle[R] {
    app : App
    ids : Array[String]
    all : Bool
    codec : (
    Json
    ) -> R raise
    Refused

    }

    A run, or a set of runs, a caller can wait on and look at.

    The result type is the one the task returns: get decodes the stored JSON back into it. state and revoke work on anything, since a caller that wants to cancel a run does not care what it would have returned.

    Handle::get

    async fn[R] Handle::get(self : Handle[R], timeout? : Int64) -> R raise
    Refused

    Wait for the run and answer its value, or refuse with the reason it will not arrive: Refused::Failed when a body raised on its last attempt, Refused::Timeout when timeout milliseconds passed first, and Refused::Missing when the app keeps no results.

    Handle::ids

    fn[R] Handle::ids(self : Handle[R]) -> Array[String]

    The ids the handle waits on.

    Handle::of

    fn[R :
    FromJson
    ] Handle::of(app : App, ids : Array[String], all : Bool) -> Handle[R]

    A handle over ids whose value decodes into R; all makes get answer the list of results in the order of ids.

    Handle::of_json

    fn Handle::of_json(app : App, ids : Array[String], all : Bool) -> Handle[
    Json
    ]

    A handle over ids of runs that already answer in JSON.

    Handle::revoke

    async fn[R] Handle::revoke(self : Handle[R], terminate? : Bool) -> Unit raise
    Refused

    Cancel the run or the set: it will not be sent again, and with terminate a worker in this process that is running it right now stops.

    Terminating reaches only this process's workers; a run already going in another process is left to finish, since nothing else is reachable.

    Handle::state

    What the run is doing now, without waiting for it.

    A set reports Failed once any part failed, Revoked once any part was revoked, Succeeded once every part settled, and the state of the first unsettled run otherwise.

    Limiter

    type Limiter

    How a task is allowed to go, per worker: tokens runs left in the window that ends at reset.

    MemoryBroker

    A broker, result store, chord counter and inspector over maps.

    clock is the same function the app is given: the broker needs time to know when a delayed message is due, and the library never reads the system clock itself.

    MemoryBroker::add

    async fn MemoryBroker::add(self : MemoryBroker, key : String, delta : Int) -> Int raise
    Refused

    MemoryBroker::consume

    MemoryBroker::drop

    async fn MemoryBroker::drop(self : MemoryBroker, key : String) -> Unit raise
    Refused

    MemoryBroker::new

    fn MemoryBroker::new(clock~ : () -> Int64) -> MemoryBroker

    An empty broker whose time comes from clock.

    MemoryBroker::peek

    MemoryBroker::publish

    MemoryBroker::reject

    MemoryBroker::store

    Policy

    pub(all) struct Policy {
    queue : String
    retries : Int
    backoff : Backoff
    timeout : Int64?
    acks_late : Bool
    ignore_result : Bool
    rate : Rate?
    } derive(Eq,
    Debug
    )

    Everything about how a task is run, retried and confirmed.

    The defaults are the ones a queue is usually run with, and each of them is the safe side of the choice: no retries (a retry repeats the side effects of the first attempt), no timeout (a hung task is visible in the queue rather than silently cut), confirm on receipt (the message is not redelivered if the worker dies, which is the cheaper mistake for idempotent work).

    Policy::backoff_delay

    fn Policy::backoff_delay(self : Policy, attempt : Int, seed : String) -> Int64

    The wait before attempt attempt of a task whose id is seed.

    Policy::equal

    fn Policy::equal(Policy, Policy) -> Bool

    Policy::not_equal

    fn Policy::not_equal(x : Policy, y : Policy) -> Bool

    Policy::to_repr

    Rate

    pub(all) struct Rate {
    count : Int
    per : Int64
    } derive(Eq,
    Debug
    )

    How fast one task may go: count runs per per milliseconds.

    Rate::equal

    fn Rate::equal(Rate, Rate) -> Bool

    Rate::not_equal

    fn Rate::not_equal(x : Rate, y : Rate) -> Bool

    Rate::to_repr

    Registered

    type Registered

    A registered task, as the registry holds it: the policy and a body that takes the wire argument and answers in strings, so one map holds tasks of every type.

    Schedule

    pub(all) enum Schedule {
    Every(interval~ : Int64)
    Cron(expr~ :
    Cron
    )
    At(at~ : Int64)
    } derive(Eq,
    Debug
    )

    When a beat sends a task.

    Schedule::after

    fn Schedule::after(self : Schedule, at : Int64) -> Int64?

    The first time this schedule fires strictly after at, or None when it never fires again.

    Schedule::equal

    fn Schedule::equal(Schedule, Schedule) -> Bool

    Schedule::first

    fn Schedule::first(self : Schedule, now : Int64) -> Int64

    The first time this schedule fires at or after now.

    Schedule::not_equal

    fn Schedule::not_equal(x : Schedule, y : Schedule) -> Bool

    Schedule::to_repr

    Schedule::within

    fn Schedule::within(self : Schedule, start : Int64, end : Int64, limit? : Int) -> Array[Int64]

    Every instant this schedule names in [start, end), in order, up to limit of them.

    This is what a backfill walks: an interval counts from start, a cron expression from the first boundary at or after it, and a one-off instant only when it falls inside the window. A window that names more instants than limit stops there rather than running for ever.

    Shape

    pub(all) enum Shape {
    One
    All
    } derive(Eq,
    Debug
    )

    Whether a sig waits on one run or on all of them.

    Shape::equal

    fn Shape::equal(Shape, Shape) -> Bool

    Shape::not_equal

    fn Shape::not_equal(x : Shape, y : Shape) -> Bool

    Shape::to_repr

    Sig

    pub struct Sig {
    publish : Array[
    Message
    ]
    waits : Array[String]
    shape : Shape
    }

    A piece of work that has not been published yet.

    The messages are ready to go; waits is what a caller waits on afterwards, which for a chain is its last link and for a chord is the callback. Building a sig publishes nothing, so a canvas can be built once and sent twice.

    Every message this sig stands for, in the order they run: the links of a chain, the parts of a group.

    Sig::send

    Publish every message of the sig on app and return a handle on what it waits for.

    A chord's counter is opened here, before any part can finish: a part that arrived first would otherwise find no counter and take its own arrival for the end of the chord.

    A group with no part in it publishes nothing and hands back a handle that is already answered: a set of nothing is a set that has all settled.

    Task

    pub struct Task[A, R] {
    app : App
    name : String
    policy : Policy
    run : async (A, Context) -> R
    }

    A registered body. Sending it writes a message and hands back a handle.

    The argument type is what the body takes; the result type is what it returns, and the handle decodes the stored value back into it. Both go over the wire as JSON, so a task may be sent by one process and run by another.

    Task::chunks

    fn[A, R, B :
    ToJson
    ] Task::chunks(self : Task[A, R], tuples : Array[B], size? : Int) -> Sig raise
    Refused

    The runs of tuples, size of them per run, all at once.

    A part of the group is one run of the chunk task, which runs its slice of the tuples one after another in the worker that picked it up and answers their results in order. The handle answers one list per part, so the results of the whole fan-out are those lists in order; the last part holds what is left over.

    Batching trades parallelism for dispatch: one message per size runs is fewer broker round trips and fewer reservations.

    A slice is one run of the chunk task, so it is retried as a whole by that task's policy — the default one, since the message names the chunk task and not the task inside it — and a tuple that fails stops its slice. A run that needs its own retry budget is sent with map or starmap instead, where each run carries the policy of the task it belongs to.

    Task::map

    fn[A :
    ToJson
    , R] Task::map(self : Task[A, R], items : Array[A]) -> Sig

    One run of this task per item, all at once: a group whose parts are the items in the order they were given.

    Each item is the whole argument of its run, so a task that takes a record is mapped over records. The handle answers the results in that same order, and an empty list is a group with nothing to wait for, which answers an empty list rather than refusing.

    Task::name

    fn[A, R] Task::name(self : Task[A, R]) -> String

    The name the message carries.

    Task::policy

    fn[A, R] Task::policy(self : Task[A, R]) -> Policy

    The policy the registration was given.

    Task::send

    async fn[A :
    ToJson
    , R :
    FromJson
    ] Task::send(self : Task[A, R], args : A, eta? : Int64, countdown? : Int64, expires? : Int64, queue? : String, priority? : Int, id? : String) -> Handle[R] raise
    Refused

    Publish args and return a handle on the run.

    eta is when the message may first run and countdown is how long from now that is; eta wins when both are given, since it is the more specific of the two. expires is the time after which running it is pointless: a worker that finds it expired revokes it instead of running it late.

    Task::sig

    fn[A :
    ToJson
    , R] Task::sig(self : Task[A, R], args : A) -> Sig

    The run of args as a node of a canvas, without publishing it.

    Task::starmap

    fn[A, R, B :
    ToJson
    ] Task::starmap(self : Task[A, R], tuples : Array[B]) -> Sig raise
    Refused

    One run per tuple, all at once, each tuple spread across the run's arguments.

    This is the shape a fan-out takes when the runs take several arguments: a list per run, published as that run's argument list. The handle answers the results in the order the tuples were given.

    Wiring

    type Wiring

    The task behind a node, and the nodes whose answers it reads.

    Worker

    pub struct Worker {
    app : App
    queues : Array[String]
    concurrency : Int
    prefetch : Int
    limiters : Map[String, Limiter]
    consumers : Array[
    Task
    [Unit]]
    inflight : Int
    stopping : Bool
    done :
    Cond

    failure : Error?
    }

    The object that runs tasks.

    A worker consumes from queues, runs up to concurrency bodies at once, and settles each message according to the task's policy: a failure within the retry budget goes back on the queue with an eta computed from the backoff, a success advances the chain or the chord the message carries.

    stop is graceful: it stops taking new messages, waits up to grace milliseconds for the ones in flight, then cancels whatever is left.

    Worker::is_stopping

    fn Worker::is_stopping(self : Worker) -> Bool

    Whether stop has been called.

    Worker::prefetch

    fn Worker::prefetch(self : Worker) -> Int

    The most messages this worker holds unsettled at once.

    Worker::queues

    fn Worker::queues(self : Worker) -> Array[String]

    The queues this worker takes from.

    Worker::run

    async fn Worker::run(self : Worker) -> Unit

    Run until stop is called. The call returns once every consumer has ended, which is why a caller runs it in its own task.

    Worker::stop

    async fn Worker::stop(self : Worker, grace? : Int64) -> Unit

    Stop taking new work, give what is running up to grace milliseconds to finish, then cancel the rest.

    chain

    fn chain(sigs : Array[Sig]) -> Sig

    The runs in sigs, in order, each one started when the one before it succeeds.

    A chain of chains is flattened, so chain([a, chain([b, c])]) is a → b → c. Building a chain publishes nothing.

    chord

    fn chord(parts : Array[Sig], callback : Sig) -> Sig

    The runs in parts, then callback once every part has succeeded.

    The callback is sent with the list of part results as its argument, in the order the parts were given. Every part carries the same key and the same list of ids, so the part that finishes last can collect the set by itself.

    A part that fails for good does not hold the chord open: the last part to finish records the callback as failed, so nobody waits on a callback that will never be sent.

    chunk_name

    let chunk_name : String

    The name a chunk runs under.

    One body serves every worker, so a chunk published by one process is run by a worker in another: the message names the task the chunk is of, and the body that runs it comes with the worker rather than from whoever built the canvas.

    group

    fn group(sigs : Array[Sig]) -> Sig

    Every run in sigs, all at once, waited on together.

    A chain given to a group is not broken up: its head is published and its tail travels with it, so the group waits on the chain's last link. The handle's value is the list of results in the order the sigs were given.

    milliseconds_of

    fn milliseconds_of(moment :
    Moment
    ) -> Int64?

    Milliseconds since the epoch of an instant.

    moment_of

    fn moment_of(at : Int64) ->
    Moment
    ?

    An instant from milliseconds since the epoch.

    policy

    let policy : Policy

    The defaults, documented field by field on [Policy].