Rand Stats

JobQueue

zef:apogee

Actions Status

NAME

JobQueue - dependency-aware job queues: lanes, priorities, dedup, cancellation and a cross-queue DAG

SYNOPSIS

use JobQueue;

class GreetJob does Job {
    has Str $.who is required;
}

# A gate Promise stands in for real work here; in production the
# runner talks to a backend and honours $job.cancelled as it goes.
my %gates;

my $queue = JobQueue::Queue.new(
    name            => 'greeter',
    is-parallel-now => -> { False },        # one at a time
    run-job         => -> $job {
        %gates{$job.id} = my $gate = Promise.new;
        start {
            await $gate;
            $job.cancelled ?? 'cancelled' !! do { say "hello, {$job.who}"; 'done' };
        }
    },
);

my $a = $queue.enqueue(GreetJob.new(scope-id => 'room-1', who => 'Ada'));
my $b = $queue.enqueue(GreetJob.new(scope-id => 'room-1', who => 'Grace'));

say $queue.running-count;      # 1 — serial mode
say $queue.pending-count;      # 1

%gates{$a.id}.keep;            # let the first job finish
await $a.completion;           # 'done'

$queue.cancel($b.id);          # cancelled before it ever ran
say $b.state;                  # cancelled

DESCRIPTION

JobQueue is the scheduling nucleus extracted from a production TUI chat client, where "just start { } it" collapsed under contact with reality: a backend that allows one request at a time, a UI that has to render "queued (#3 of 5)", a user who cancels mid-stream, and a turn made of five interdependent inference calls across two different backends.

It gives you four pieces.

Job — the unit of work

A role you compose into your own class. It carries the immutable inputs captured at enqueue time, a cancel Promise, a completion Promise, and the scheduling policy (lane, priority, dedup-key, depends-on, after). Its three state transitions — finish, request-cancel, begin-running — are atomic and return True only to the caller that performed them, which is how exactly one side ends up owning the terminal event.

JobQueue::Queue — one resource, one queue

Serial or parallel (re-evaluated per dispatch, so a config flip picks up at the next free slot), with a max-concurrent cap, lane serialisation, priority dispatch and live-job dedup. Cancellation is cooperative: the queue keeps the token and calls your on-cancel; your runner unwinds.

JobQueue::Coordinator — the DAG

A registry of named queues plus a dependency graph across them. Declare the chain flat instead of nesting .then callbacks:

my $coord = JobQueue::Coordinator.new;
$coord.register-queue('text',  $text-queue);
$coord.register-queue('image', $image-queue);

my $summary = $coord.submit('text', SummaryJob.new(scope-id => $doc));
$coord.submit('text',  TagJob.new(  scope-id => $doc, depends-on => [$summary.id]));
$coord.submit('image', CoverJob.new(scope-id => $doc, depends-on => [$summary.id]));
$coord.submit('text',  AuditJob.new(scope-id => $doc, after      => [$summary.id]));

$coord.tick;    # per frame: release, supersede, pump, prune

Hard edges (depends-on) require their dependency to finish done, and a failure cascades transitively as superseded. Soft edges (after) only wait for a terminal state and run regardless of the outcome. Each dependency's state and result land in the dependent's dep-results at release.

JobQueue::FailedRegistry — retry with identical inputs

A capped, insertion-ordered map of failed and user-cancelled jobs, with an atomic claim so two retry clicks can't both win.

GUARANTEES

These are not incidental behaviours; they are the contracts the implementation exists to hold, each one paid for by a production bug.

THE STORE SINK CONTRACT

Both the queue and the coordinator take an optional store. The entire interface is one method:

method dispatch(Str:D $event, *%payload) { ... }

and the queue always calls it in exactly one shape:

$store.dispatch($event, queue => $queue-name, job => %snapshot);

%snapshot comes from JobQueue::Queue.job-snapshot: plain scalars only — id, scope-id, state, timestamps, lane, priority, phase, error, plus whatever your class's snapshot-extras adds. It is safe to drop into a Redux-style store, serialise, or send over a socket. The full Job object is not: it holds Promises, closures and a Lock.

$.store is untyped so any object answering dispatch works. JobQueue::EventSink names the contract if you want the compile-time check:

class LedgerSink does JobQueue::EventSink {
    has %.rows;
    method dispatch(Str:D $event, *%payload) {
        %!rows{%payload<job><id>} = %( :$event, |%payload<job> );
    }
}

Leaving store undefined disables the event stream entirely.

OBSERVABILITY

The queue and coordinator log structured events and open tracing spans, but they bring no logging framework with them. Inject log and tracer objects; both default to silent null objects. See JobQueue::Observability for the (very small) contracts and adapter examples.

my $queue = JobQueue::Queue.new(
    :$name, :&is-parallel-now, :&run-job,
    log    => MyLogAdapter.new,
    tracer => MySpanTracer.new,
);

EXTENDING A JOB

Two override points let your job class contribute payload without the queue ever learning your class exists:

class RenderJob does Job {
    has Int $.message-id is rw;
    has Str $.template is required;

    # merged into the store-event snapshot
    method snapshot-extras(--> Hash) {
        my %e = template => $!template;
        %e<message-id> = $!message-id if $!message-id.defined;
        %e;
    }

    # merged into dependents' dep-results{ this-job-id }
    method dep-extras(--> Hash) {
        $!message-id.defined ?? %( message-id => $!message-id ) !! %();
    }
}

Queue-owned keys always win over extras, so a job cannot make its own ledger row lie about its state. Override with the exact signature shown — Raku will accept a different one and quietly leave the role's version in charge.

MODULES

use JobQueue; loads all of them and exports the Job role and the keep-once sub; the classes are reachable by their full names.

AUTHOR

Matt Doughty matt@apogee.guru

COPYRIGHT AND LICENSE

Copyright 2026 Matt Doughty

This library is free software; you can redistribute it and/or modify it under the Artistic License 2.0.

sub EXPORT

sub EXPORT(
    |
) returns Mu

Re-export the lexical symbols of the sub-modules so use JobQueue is a complete entry point. The classes (JobQueue::Queue and friends) are global package names and are already visible from the use statements above; only Job and keep-once are lexical exports that need forwarding.