NAME

Punk Mailer - the Punk::Queue example application

SYNOPSIS

cd example/Mailer

plackup -p 5000 app.psgi                                    # the web tier
punk-queue worker --app bin/queue.pl -q default,mail,reports -j 2   # the workers

open http://localhost:5000/
open http://localhost:5000/queue?token=punk-queue-admin     # the admin UI

DESCRIPTION

A signup form that hands the slow part to a queue.

The whole example exists to show one shape and its consequences. A request handler enqueues and answers; a separate worker process runs the body later, out of the same database. Nothing in the web tier waits for a task, and every task body runs identically whether it was enqueued by the form, by the cron scheduler or by hand from the CLI.

What is in here:

  • Three task targets written as 'Class#method', resolved at to_app exactly like route targets - so a typo is a boot croak, not a job that fails at 3am. Plus one written as a coderef, because small ones do not deserve a file.

  • Queue-level defaults (queue 'mail' => { attempts => 5 }) and task-level defaults, so the enqueue call carries only what is specific to that call.

  • Two crons: one naming a task already registered, one naming a 'Class#method' of its own.

  • The admin UI mounted at /queue inside the same application, behind the same guard stack, with its overview page and its stylesheet overridden from this directory.

  • In-server mode, off by default, behind MAILER_IN_SERVER=1.

RUNNING IT

The web tier

plackup -p 5000 app.psgi

Any PSGI server serves the pages, the API and the admin UI. On Hyperman the admin UI also gets live mode:

plackup -s Hyperman -p 5000 app.psgi

Live mode needs Hyperman's detach path; anywhere else the plugin warns once at boot and stays on the 5-second polling it is designed to fall back to.

The first boot migrates the schema and creates queue.db beside this file. Point MAILER_QUEUE_DSN at PostgreSQL to use that instead:

MAILER_QUEUE_DSN='dbi:Pg:dbname=mailer' plackup -p 5000 app.psgi

The workers

punk-queue worker --app bin/queue.pl -q default,mail,reports -j 2

Nothing is delivered until this is running - the index page says so when it sees no live workers, which is the failure everyone has once.

--app bin/queue.pl rather than --app Mailer on purpose. Loading the class is enough to reach the queue, but task and cron targets are resolved at to_app - that is what makes a typo a boot croak - so a worker that never called to_app would hold a queue with no bodies in it. bin/queue.pl compiles the app and hands back its queue; read-only commands need none of that and take a plain --dsn.

The worker pool also runs the cron scheduler unless told --no-scheduler.

The admin UI

http://localhost:5000/queue?token=punk-queue-admin

Guarded by Web::Auth#admin (a shared token, swapped for whatever your app already does). The plugin refuses to mount without a guard, because that UI retries and removes jobs, stops workers and runs crons: unguarded it is an unauthenticated control plane.

Poking at it from the CLI

punk-queue stats    --dsn dbi:SQLite:dbname=queue.db
punk-queue jobs     --dsn dbi:SQLite:dbname=queue.db --state failed
punk-queue job 1    --dsn dbi:SQLite:dbname=queue.db --json
punk-queue crons    --app bin/queue.pl
punk-queue cron run daily-digest --app bin/queue.pl
punk-queue enqueue  --app bin/queue.pl mail.welcome you@example.com

Requirements

Punk 0.04 (for install_kw, which is how the queue/task/cron keywords get into the app class), Punk::Queue, Template::Stencil for the pages, DBD::SQLite or DBD::Pg, and any PSGI server. Running from a built-but-uninstalled checkout works: app.psgi and bin/queue.pl both put the dist's blib on @INC when they find one.

LAYOUT

app.psgi                  the web tier's entry point
bin/queue.pl              the worker pool's entry point (--app target)
lib/Mailer.pm             the app class: keywords, plugin, routes
lib/Mailer/Controller/
  Web/Home.pm             pages: enqueue and answer, then watch
  Web/Auth.pm             the admin guard
  Job/Mail.pm             task bodies - welcome mail, digest fan-out
  Job/Report.pm           task body - the recurring report, with a lock
root/templates/           the app's own Stencil templates
root/static/mailer.css    the app's own stylesheet
root/queue/overview.tmpl  ONE overridden admin template
root/queue.css            appended to the admin UI's stylesheet bundle

THINGS TO NOTICE

The keywords are compile-time

use Punk::Plugin::Queue installs queue, task and cron into the app class through Punk's own installer, $app->install_kw - they are magic CVs sitting beside get and helper, and no glob is assigned anywhere. That is what makes the bare syntax on later lines parse:

task 'mail.welcome' => 'Job::Mail#welcome';

Putting plugin 'Queue' first works too, but only for parenthesised calls (task(...)), because plugin runs at runtime. The order in lib/Mailer.pm is the one to copy.

A task body is a body, not a method

'Job::Mail#welcome' resolves to the sub, and the queue calls it with the job first and the enqueued arguments after. There is no invocant and no $c - a task has no request. Job/Mail.pm says so at the top, next to the two rules the queue cannot enforce for you: be idempotent, because a worker killed past its timeout may have done half the work, and die loudly, because a body that swallows an error is a job that succeeded.

Enqueue, then have somewhere to look

/jobs/:id exists because "fire and forget" is only half a design. The job is a row: state, attempt count, worker, result. The same row is what the admin UI shows, and $c->job($id) is the helper the plugin installed for reading it.

unique, delay, priority

The signup handler passes unique, so a double-submitted form gets the first job's id back instead of sending two welcome mails. The dedupe key obeys the same name rule as tasks and queues (1-64 characters of [A-Za-z0-9_.:-]), which is why the address is folded into it rather than pasted in - the rule is narrow so that PostgreSQL LISTEN identifiers are safe by construction.

The digest fans out into jobs, not a loop

Job::Mail::digest enqueues one job per recipient rather than sending in a loop. One failure then retries alone, on its own backoff, instead of taking the batch down with it.

The report takes a lock

Job::Report::signups holds a named lock for the duration. Locks live in the database with the jobs, so it holds across every worker on every host, and taking it with a duration means a crashed holder releases itself.

The admin UI is yours to restyle

root/queue/ is a template root searched before the bundled one, one name at a time - it holds exactly the one template this app changes, and the other six keep coming from the dist across upgrades. root/queue.css is appended to the served bundle, after Funky and after punk-queue.css, so the cascade takes it. Both are read once, at registration: a template edit needs a restart, like everything else in a Punk app.

Which root serves which template is decided at registration too, so an override you meant to make but misspelled is a boot croak rather than a page that silently stays ours.

In-server mode is a shortcut, and knows it

MAILER_IN_SERVER=1 plackup -s Hyperman -p 5000 app.psgi

Runs mail.welcome on the web workers' own event loops - no worker pool at all. It is railed: an explicit task allowlist, one job in flight per web worker, claims only from a timer, a wall-clock cap, and two breaches disable it in that process. Fine for this example. Run a real worker pool for anything with volume.

SEE ALSO

Punk::Queue, Punk::Plugin::Queue, Punk, Punk::Plugin, Template::Stencil.

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)