๐ค jobs
amadan.net/rastrillo/rastrillo/jobs
Background work you can watch: Start runs a function in a goroutine
and hands back an id a status page can poll.
Background jobs is the guide.
In-memory, on purpose
Your app is single-process and a restart kills the goroutine, so a
persisted row would only persist a lie. A job is a goroutine, and a
deploy ends it mid-flight. Make jobs idempotent, and put work that has
to survive a restart in eventlog.
New, Start and Get
func New(logger *slog.Logger) *Jobs
func (j *Jobs) Start(owner, name, location string, fn func(ctx context.Context, progress func(string)) error) (Job, error)
func (j *Jobs) Get(id, owner string) (Job, bool)
Build one at boot. The zero value is not usable.
owner is the session Subject, a string, because magic-link subjects
are emails and password subjects are numeric strings. Key your own
job-related rows the same way.
fn's error text reaches the owner, so write it for them. A panic
inside fn becomes Failed rather than taking the process down.
Get answers only the owner. A wrong owner and an unknown id are
indistinguishable, the same 404 rule
scope enforces for rows.
The bounds
Start returns ErrOwnerBusy past four running jobs per owner. Flash
your own copy; the framework does not choose your wording.
A job still running after fifteen minutes is marked Failed and stops
counting against the limit. Its context expires at the same moment, so a
well-behaved fn stops too.
What no bound can do is kill a goroutine. An fn that ignores its
context runs invisibly until the process restarts โ it just no longer
blocks its owner. Honour ctx.
Job and Status
type Job struct {
ID string
Owner string
Name string
Status Status
Progress string
Err string
Location string
StartedAt time.Time
FinishedAt time.Time
}
Status is Running, Done or Failed. There is no "queued":
Start runs the goroutine immediately.
Location is where the owner lands when the job finishes; empty keeps
them on the status page. Progress is the latest text fn passed to
its progress callback, empty until it sets one.
The handlers
func NewHandlers(cfg Config) (*Handlers, error)
type Config struct {
Jobs *Jobs
Render func(w http.ResponseWriter, r *http.Request, d PageData)
RenderFragment func(w http.ResponseWriter, r *http.Request, d PageData)
}
All three are required and NewHandlers errors otherwise.
| Handler | Route | Behaviour |
|---|---|---|
Handlers.StatusPage |
GET /jobs/{id} |
full page; 303 to Location when done |
Handlers.Fragment |
GET /jobs/{id}/fragment |
the partial alone; 204 + Rastrillo-Location when done |
Handlers.Events |
GET /jobs/{id}/events |
Server-Sent Events |
Mount all three inside the sess.Require group.
RenderFragment has to draw the partial on its own, without the layout,
or the layout nests inside itself on the next poll.
PageData
type PageData struct {
Job Job
FragmentPath string
EventsPath string
PollSeconds int
}
FragmentPath goes in data-poll, and PollSeconds feeds
data-poll-every and the <noscript> meta refresh. Emit that meta only
while the job is running, or a failed page refreshes forever.
EventsPath is opt-in. Put it in data-poll-push and a supporting
browser upgrades to server push; ignore it and you get plain polling.
The stream
Events sends update, done and gone events with : ping
heartbeats every 15 seconds, per-write deadlines, and a bounded stream
lifetime. It is one-way, and the shim falls back to timer polling on its own,
permanently, if the stream fails.
Read this page as markdown โ exact, unstyled, and cheap for an agent to fetch.