CARLOS rastrillo docs

๐Ÿค– Background jobs

jobs is the observable handle for background work. Start runs a function in a goroutine and hands back an id a status page can poll.

It is in-memory

Your app is single-process, and a restart kills the goroutine anyway, so a persisted row would only persist a lie. A job is a goroutine, and a deploy ends it mid-flight.

Two things follow. Make long jobs idempotent and re-runnable, because they will be interrupted. And if the work must survive a restart, it does not belong here โ€” reach for eventlog.

For the same reason, a time.Ticker in your app is not a scheduler. On CARLOS the platform holds the clock and wakes you when something is due; see carlos.

Starting one

j := jobs.New(logger) // once, at boot

job, err := j.Start(owner, "Export notes", "/notes", func(ctx context.Context, progress func(string)) error {
	progress("gathering")
	// ...
	return nil
})
if errors.Is(err, jobs.ErrOwnerBusy) {
	flash.Set(w, "error", "You already have several exports running.")
	http.Redirect(w, r, "/notes", http.StatusSeeOther)
	return
}
http.Redirect(w, r, "/jobs/"+job.ID, http.StatusSeeOther)

owner is the session Subject, not sessions.UserID. It is a string, because magic-link subjects are email addresses and password subjects are numeric strings. Key your own job-related rows the same way.

location is where the owner lands when the job finishes; "" keeps them on the status page. fn's error text reaches the owner, so write it for them.

The bounds

An owner may have four jobs running at once. Start past that returns ErrOwnerBusy, and the copy you flash is yours to write.

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.

Reading a job

job, ok := j.Get(id, owner)

Get answers only the owner. A wrong owner and an unknown id are indistinguishable, the same 404 rule scoping enforces for rows.

There is no "queued" status. Start runs the goroutine immediately, so Running, Done and Failed are all of them. A panic inside fn becomes Failed instead of taking the process down.

The status page

h, err := jobs.NewHandlers(jobs.Config{
	Jobs:           j,
	Render:         renderJobPage,
	RenderFragment: renderJobFragment,
})

All three fields are required and NewHandlers errors otherwise. Mount the routes inside the sess.Require group:

r.Group(func(r chi.Router) {
	r.Use(sess.Require)
	r.Get("/jobs/{id}", h.StatusPage)
	r.Get("/jobs/{id}/fragment", h.Fragment)
	r.Get("/jobs/{id}/events", h.Events)
})

Both renderers receive a jobs.PageData carrying the Job, FragmentPath, EventsPath and PollSeconds.

RenderFragment has to draw the partial on its own, without the layout. Render a whole page there and the layout nests inside itself on the next poll.

A finished job with a Location makes StatusPage answer 303. The fragment's equivalent is 204 plus a Rastrillo-Location header.

It must work with scripts off

The status page carries a <noscript> meta refresh of PollSeconds, and only while the job is running. Emit it unconditionally and a failed page refreshes forever.

The JavaScript

The only JavaScript in the framework is static/rastrillo.js, an app-owned shim that rastrillo new writes beside tokens.css. The polling below does nothing until your markup opts in:

Attribute Effect
data-poll="URL" replace this element with the fetched fragment
data-poll-every="2" seconds between polls
data-poll-push="URL" upgrade to Server-Sent Events, falling back to polling
data-busy="false" opt out of the busy-button rule, on a form or one submit button
data-busy-label retitle the button while it works

data-busy changed direction. Every submit button now gets its loading state by default and the attribute is only ever an opt-out โ€” see Templates for the rule and Forms for what it does not promise. The shim has three sections: polling, which you opt into with the attributes above, and two that are simply on โ€” light dismiss for menus, and the busy rule.

Polling repeats while the new fragment still carries data-poll. The ui package's job-status partial drops the attribute once the job is done, which is what stops the loop. A fragment that always carries it polls forever.

PushURL on the partial โ€” the same value as EventsPath โ€” is opt-in. Set it and a supporting browser rides Events; the shim falls back to timer polling on its own, permanently, if the stream fails. Ignore it and you get plain polling.

The SSE stream sends update, done and gone events with : ping heartbeats every fifteen seconds, per-write deadlines, and a five-minute lifetime. It is one-way, and nothing about the page depends on it.

htmx remains a choice, not a dependency. examples/notes demonstrates the whole loop with an Export flow.

Read this page as markdown โ€” exact, unstyled, and cheap for an agent to fetch.