NAME

Punk::Plugin::Queue - Punk::Queue for Punk applications

SYNOPSIS

package MyApp;
use Punk;
use Punk::Plugin::Queue;             # compile time: the keywords

plugin 'Queue' => {                  # runtime: the configuration
    dsn   => 'dbi:Pg:dbname=myapp',
    admin => { prefix => '/queue', guard => 'Web::Auth#admin' },
};

queue 'mail' => { attempts => 5, timeout => 30 };

task 'mail.send'    => 'Job::Mail#send';
task 'report.build' => sub {
    my ($job, $year) = @_;
    return { pages => 3 };
};

cron '0 3 * * *' => 'Reports#nightly';

post '/reports' => sub {
    my ($c) = @_;
    return $c->json({ job => $c->enqueue('report.build' => [2026]) });
};

DESCRIPTION

One class, two entry points. use Punk::Plugin::Queue at compile time installs the queue, task and cron keywords into the caller; plugin 'Queue' at runtime configures the queue, installs the $c->queue, $c->enqueue and $c->job helpers, and optionally mounts the admin UI and an in-server worker. import never registers and never takes configuration, so configuration lives in exactly one place. plugin 'Queue' also installs the keywords, so putting the plugin line first works too - but only for parenthesised calls (task(...)): plugin runs at runtime, so the bare keyword syntax on later lines still needs the compile-time use. The SYNOPSIS form is the one to teach.

The keywords are installed through Punk's registrar ($app->install_kw, Punk 0.04), so they are magic CVs in the application class beside get and helper, and this plugin never touches its symbol table. use Punk has to come first for that reason: a class that is not a Punk application has no registrar to install into, and says so.

Targets resolve like route targets: 'Job::Mail#send' is MyApp::Controller::Job::Mail::send, '+Full::Class#method' escapes the namespace, and a typo croaks at to_app, not at first dispatch.

THE ADMIN UI

plugin 'Queue' => { dsn => ..., admin => {
    prefix    => '/queue',            # default
    guard     => 'Web::Auth#admin',   # REQUIRED
    live      => 1,                   # optional websocket updates
    templates => 'root/queue',        # optional template overrides
    css       => 'root/queue.css',    # optional, appended
    js        => ['root/queue.js'],   # optional, appended
} };

Native routes under an under scope - never mount, which bypasses guards, CSRF and hooks. Registration croaks without a guard (set PUNK_QUEUE_ADMIN_INSECURE to run naked deliberately). With the app's csrf configured, every admin POST is CSRF-protected; the layout mirrors the app's csrf cookie into the csrf_token cookie Funky hardcodes - a script-readable double-submit cookie, so the mirror leaks nothing.

How the pages stay current

A 5-second poll, and that is the load-bearing part: every page re-reads the stats and its own view - its table, or the job it is showing. It has to be, because a job changes state inside a worker process, which has no way to reach a socket held by a web process.

live => 1 is the enhancement on top. It pushes what this web process itself did - an enqueue through $c->enqueue, a retry, a remove, a bulk action, a worker stop, a cron run - so those land immediately instead of on the next tick. Two things it is not: it does not carry worker-driven transitions (a claim, a finish, a failure, a backoff coming due, the scheduler firing), and its connection list is per web-worker process, so with several web workers a push reaches the browsers connected to that one. The poll covers both, for every client, which is why it runs whether live mode is on or not.

The UI is built on Funky (our zero-dependency, no-build framework), a pinned subset vendored under assets/ and served from memory - provenance and the re-vendor procedure live in assets/VENDORED.md. FontAwesome and Bootstrap are deliberately NOT vendored; a small override sheet supplies unicode glyphs and minimal structural styles.

Making it yours

Every page is a Template::Stencil template under assets/templates/: layout.tmpl, one per page (overview, jobs, job, workers, locks, crons). Point templates at a directory of your own and a name is looked up there first, ours second - so that directory holds only the templates you actually changed, and the rest keep working across upgrades. Which root serves which name is decided at registration: a template you meant to override but misspelled is a boot croak, not a page that silently stays ours.

Every template renders with:

prefix       where the UI is mounted, e.g. '/queue'
title        the page title             page      its DOM id
version      Punk::Queue's version      live      'true' / 'false'
csrf_cookie  the app's csrf cookie name, or ''
nav          [ { href, label, id }, ... ] - the pages, in order
styles       stylesheet URLs to link    scripts   script URLs to load
id           the job id (the `job` template only)

and the layout additionally gets content, the rendered page, which it places with {% raw content %}. Because styles and scripts are data, a replacement layout needs no knowledge of where the bundles live.

css and js take a file or an arrayref of files and append them to the served funky.css and app.js - at the end, which is what makes them overrides: the last CSS declaration wins, and a script that runs after app.js can build on it. They are read once, at registration, like everything else the UI serves, and they change the bundle's ETag, so no client can be left holding a cache entry from before the override.

Templates and override files are read at boot. That is the same promise the rest of a Punk app makes - everything wrong croaks at to_app, and nothing on the request path touches the disk - and it means a template edit needs a restart.

Bulk actions are selection-only, by explicit ids, capped at 500; a filter expression in the payload is rejected. "Retry everything matching" stays excluded on purpose - the CLI can loop if you mean it.

IN-SERVER MODE

plugin 'Queue' => { dsn => ..., in_server => {
    tasks    => ['mail.send'],     # REQUIRED allowlist
    queues   => ['default'],
    interval => 1,
    cap      => 5,
} };

Runs jobs on the Hyperman web workers' own loops. For low-volume, IO-bound, latency-tolerant work; run punk-queue worker for anything real. Off by default and railed: a required task allowlist, one job in flight per web worker, claims only from a timer (never the request path), a wall-clock cap (default 5s) enforced by a loop timer failing a returned future, and two cap breaches disable the mode in that process with an error log. Claims attach lazily on the first request of each worker process - to_app runs before the fork, and hm_abi has no post-fork hook (an on_worker_start entry would be cleaner, and is explicitly not required).

METHODS

import

Installs the keywords into the caller. Nothing else.

new / register($app, \%opts)

The Punk plugin contract. Options: dsn/user/password (or the app's database keyword), auto_migrate, admin, in_server, plus the queue tuning knobs (attempts_delay, max_backoff, missing_after, remove_after).

state_for($class)

The plugin's recorded state for an app class. Introspection and tests.

AUTHOR

LNATION <email@lnation.org>

LICENSE AND COPYRIGHT

This software is Copyright (c) 2026 by LNATION <email@lnation.org>.

This is free software, licensed under:

The Artistic License 2.0 (GPL Compatible)