background jobs
some work should not hold up the request that asked for it. a sign-up that sends a confirmation should answer at once, whether the mail server is quick or slow that day. senro's jobs module keeps such work as rows of a Postgres table, as Rails' Active Job does on Solid Queue: a job outlives the process that queued it, and any number of workers can share a queue. this guide covers the table, queueing a job, the worker that claims and runs it, what happens when a job fails, and how the guestbook sends its mail through a worker beside the server.
the table
every job is a row of senro_jobs. jobs/prepared makes the table when it is missing, so an app needs no migration of its own for it.
import "../pg"
import "std/io"
import "std/json"
import "std/list"
import "std/text"
import "std/time"
# A job as its row holds it.
type queued_job
id
name
args
attempts
# Work an app does after the request that asked for it, as Rails' Active Job
# runs it on Solid Queue: each job is a row of `senro_jobs`, so it outlives
# the process that queued it, and any number of workers can share a queue.
# A job has a queue, the name of the performer that does it, its arguments
# as a map of strings, and the moment from which it may run.
#
# A worker claims one due job at a time with `for update skip locked`, so two
# workers never take the same row, and leases it for five minutes: a worker
# that dies mid-job leaves the row to be claimed again once the lease runs
# out. A job that succeeds is deleted. One whose performer fails is tried
# again later, attempts^4 + 2 seconds after the failure, as Rails'
# `polynomially_longer` waits, and after five attempts it stays in the table
# with the reason, for someone to read.
#
# `prepared` makes the table jobs live in when it is missing.
pub fn prepared db
pg/run db ledger .> (_ -> effect db)
ledger = """
create table if not exists senro_jobs (
id bigserial primary key,
queue text not null,
name text not null,
args text not null,
run_at bigint not null,
attempts int not null default 0,
failure text not null default ''
)
"""
a job has a queue, the name of the performer that does it, its arguments as JSON, the moment from which it may run in milliseconds since the epoch, how many attempts it has had, and the reason the last one failed.
queueing a job
jobs/later db queue name args t writes the row. the arguments are a map of strings, and the moment is whatever the caller chooses: now, for work to do as soon as a worker is free, or a minute from now for work that should wait.
# A job as its row holds it.
type queued_job
id
name
args
attempts
# Queues the job `name` with `args` on `queue`, to run at `t` (milliseconds
# since the epoch) or after.
pub fn later db queue name args t
given = [queue name (json/encode args) "{t}"]
pg/query db queued given .> (_ -> effect db)
queued = """
insert into senro_jobs (queue, name, args, run_at)
values ($1, $2, $3, $4::bigint)
"""
working a queue
a worker claims one due job at a time. the claim is a single statement: it picks the oldest due row on the queue with for update skip locked, so two workers never take the same row and neither waits on the other, and in the same statement it counts the attempt and moves the row's run_at five minutes on. that is a lease. a worker that dies mid-job leaves the row behind, and it is due again once the lease runs out.
# The attempts a job gets, and how long a claim holds it.
pub attempts_allowed = 5
lease_ms = 5 * 60 * 1000
# Claims one job on `queue` that is due at `t`, and runs it. `performers` maps
# a job's name to a function of the session and the job's arguments that
# answers an effect. The answer says what happened: `none` when no job was
# due, or a line naming the job and how it went.
pub fn worked db queue performers t
given = [queue "{t}" "{t + lease_ms}" "{attempts_allowed}"]
pg/query db claimed given .> (r -> ran db performers t (list/first r.rows))
claimed = """
update senro_jobs set run_at = $3::bigint, attempts = attempts + 1
where id = (
select id from senro_jobs
where queue = $1 and run_at <= $2::bigint and attempts < $4::int
order by run_at, id
for update skip locked
limit 1
)
returning id, name, args, attempts
"""
performers maps a job's name to a function of the database session and the job's arguments that answers an effect. a job whose performer succeeds is deleted. jobs/worked answers a line saying what happened, or none when nothing was due.
fn ran _ _ _ none
effect none
# A performer's failure was born in the app, so the worker may rescue it.
# A job whose name has no performer is the worker's own finding, and is
# recorded the same way without being raised.
fn ran db performers t row
j = job_of row
run_by db performers[j.name] j t
fn run_by db none j t
failed db j t "no performer is named {j.name}"
fn run_by db perform j t
perform db j.args
.> (_ -> pg/query db finished [j.id])
.> (_ -> effect "ran {j.name}")
.? (u -> failed db j t u.reason)
finished = "delete from senro_jobs where id = $1::bigint"
when a job fails
a performer that fails is rescued with .?. the failure was born in the app's code, outside the jobs module, so the worker may catch it. the job keeps its reason in failure and may run again after attempts4 + 2 seconds: 3 seconds after the first failure, 18 after the second, 83 after the third, 258 after the fourth. that is the wait Rails' polynomially_longer gives. after five attempts the claim no longer picks the row, and it stays in the table with its reason for someone to read. a job whose name has no performer is recorded the same way, as a failure that says so.
# A failed attempt is kept with its reason, and the job waits attempts^4 + 2
# seconds before it may run again.
fn failed db j t reason
n = j.attempts
wait = (n * n * n * n + 2) * 1000
given = [j.id reason "{t + wait}"]
said = "failed {j.name} on attempt {n}: {reason}"
pg/query db kept given .> (_ -> effect said)
kept = """
update senro_jobs set failure = $2, run_at = $3::bigint
where id = $1::bigint
"""
senro's spec for the module works a queue at moments it chooses, so the waits show in its output. it queues a job that runs, one that always fails, one with no performer, one for a minute later, and one on another queue:
import "../../../jobs"
import "std/list"
# The moment the spec starts from, and the moments it works the queue at:
# four at once, then three and sixty seconds on, then pairs far enough apart
# that every job still failing gets an attempt in each, until none has any
# left.
pub t = 1000000
soon = [t t t t (t + 3000) (t + 60000) (t + 60000) (t + 60000)]
apart = [1000000 1000000 10000000 10000000 100000000 100000000]
pub times = list/concat soon (list/to_list (list/map apart (d -> t + d)))
# What each job named on the default queue does: `greet` prints, and
# `broken` fails every time.
pub performers = { "broken":broken "greet":greeted }
fn greeted _ args
print "hello, {args["name"]}"
fn broken _ _
err "it broke"
# Works the default queue once at each time in `times`, printing what each
# step did.
pub fn stepped db moments
list/fold moments (effect db) (acc at -> acc .> (_ -> step db at))
fn step db at
jobs/worked db "default" performers at
.> (said -> print "at {at - t}: {told said}")
.> (_ -> db)
fn told none
"nothing due"
fn told said
said
hello, Ada
at 0: ran greet
at 0: failed broken on attempt 1: it broke
at 0: failed missing on attempt 1: no performer is named missing
at 0: nothing due
at 3000: failed broken on attempt 2: it broke
at 60000: failed missing on attempt 2: no performer is named missing
at 60000: failed broken on attempt 3: it broke
hello, Bo
at 60000: ran greet
at 1000000: failed missing on attempt 3: no performer is named missing
at 1000000: failed broken on attempt 4: it broke
at 10000000: failed missing on attempt 4: no performer is named missing
at 10000000: failed broken on attempt 5: it broke
at 100000000: failed missing on attempt 5: no performer is named missing
at 100000000: nothing due
[{ "attempts":"5" "failure":"it broke" "name":"broken" "queue":"default" } { "attempts":"5" "failure":"no performer is named missing" "name":"missing" "queue":"default" } { "attempts":"0" "failure":"" "name":"greet" "queue":"mail" }]
the failing job runs at 0, 3,000 and 60,000 milliseconds, and the job queued for later waits for its minute. the pairs of steps further apart give each failing job one more attempt until both have had five. the step at 100,000,000 finds nothing due, and the job on the mail queue is never taken, since this worker works the default queue.
a worker beside the server
jobs/worker loops on jobs/worked with the clock's time, sleeping a second whenever nothing is due, and writes each outcome to standard error. jobs/beside runs one in a fiber of its own next to something else, such as the server, on a database session it opens for itself.
# Works `queue` for as long as the process runs: a job when one is due, and
# a second's sleep when none is. Each job's outcome goes to standard error.
pub fn worker db queue performers
time/now
.> (t -> worked db queue performers t)
.> (said -> told said)
.> (_ -> worker db queue performers)
# Runs `served`, such as `web/serve`, with a worker on `queue` beside it in a
# fiber of its own, on a session `connect` opens. The worker makes the jobs
# table when it is missing before it starts.
pub fn beside connect queue performers served
connect .> (db -> prepared db) .> (db -> worker db queue performers)
served
fn told none
time/sleep 1000
fn told said
io/write_err "senro jobs: {said}\n"
a bare line in a function body runs as a fiber, so beside starts the worker and then answers served, and the two run together for as long as the process does. more workers on more processes can share the queue, because the claim skips rows another worker holds.
mail sent later
the guestbook sends a confirmation when an account is made, a reset link when one is asked for, and an unlock link when too many wrong passwords lock an account. none of those requests should wait on the mail server. mail/delivered_later db message queues the message on the mail queue, as Action Mailer's deliver_later does, and mail/performers is what a worker on that queue runs to send it.
# Queues `m` on the `mail` queue of the jobs table, so the request that asked
# for it answers without waiting on the mail server, as Action Mailer's
# `deliver_later` does. A worker running `mail/performers` sends it the way
# `delivered` does. A message `delivered` would refuse is refused here, before
# it is queued.
pub fn delivered_later db m
why = refusal m
return err why if why != ""
fields = { "body":m.body "from":m.from "subject":m.subject "to":m.to }
time/now .> (t -> jobs/later db "mail" "deliver" fields t)
# The performers a worker on the `mail` queue runs.
pub performers = { "deliver":deliver }
fn deliver _ f
delivered (message f["from"] f["to"] f["subject"] f["body"])
a message mail/delivered would refuse, such as one whose subject holds a line break, is refused before it is queued, so the request that made it still hears about it. the guestbook's mailer queues each letter on the request's own session:
# The guestbook's mail, as a Rails mailer holds it: what each letter says,
# and the signed link it carries, which starts with `mail/url_for`. Each
# letter is queued on `db` and sent by the worker beside the server. First, a
# link to choose a new password, bound to the digest the account keeps, so
# it works once.
pub fn reset db id email kept
time/now
.> (t -> mail/url_for "/resets/{reset_token id kept t}/edit")
.> (link -> mail/delivered_later db (reset_letter email link))
and its entry serves through jobs/beside, so a worker on the mail queue runs as long as the server does:
# The server, with a worker beside it that sends the mail the app queues.
serving = web/serve 3000 served_routes connected
the entry's last step is jobs/beside connected "mail" mail/performers serving. a mail now arrives a second or so after the request that asked for it. the guestbook's browser spec waits for each one, and reads its counts of mail sent against the queue's order: once a later letter has arrived, any letter queued before it has arrived too.