Extra guides
Background Jobs
Register typed background jobs, choose a backend, enqueue work, and inspect job state.
Define jobs
GoLazy jobs are typed Go structs with JSON payloads. A job has a stable kind
and a Work method:
package importwhatsapp
import (
"context"
"sample_app/app/jobs/basejob"
)
type Job struct {
basejob.BaseJob
ImportID int64 `json:"import_id"`
}
func (*Job) Kind() string { return "imports.whatsapp" }
func (j *Job) Work(ctx context.Context) error {
// Load services from ctx and process j.ImportID.
return nil
}
Put the application base job in app/jobs/basejob/basejob.go:
package basejob
import "golazy.dev/lazyjobs"
type BaseJob struct {
lazyjobs.BaseJob
}
The app base package keeps concrete job packages from importing the registry package and avoids Go import cycles.
Register jobs
Register jobs from app/jobs/jobs.go:
package jobs
import (
"golazy.dev/lazyjobs"
"sample_app/app/jobs/importwhatsapp"
)
func DefinedJobs(runner *lazyjobs.JobRunner) {
runner.MustRegister(&importwhatsapp.Job{})
}
Wire the registry into lazyapp.Config.Jobs:
lazyapp.New(lazyapp.Config{
Name: "sample_app",
Drawer: Draw,
Public: app.Public,
Views: app.Views,
Dependencies: Dependencies,
Jobs: lazyapp.Jobs(lazyjobs.Config{
Define: jobs.DefinedJobs,
Workers: 10,
QueueLimits: map[string]int{
"scraping": 1,
},
}),
})
Jobs runs after Dependencies with the dependency-initialized app context.
When no backend is passed, GoLazy uses the bundled in-memory backend.
Queues and concurrency
Workers is the runner-wide concurrency ceiling. Queue limits narrow one queue
inside that global limit:
lazyjobs.Config{
Workers: 10,
QueueLimits: map[string]int{
"scraping": 1,
},
}
That configuration lets up to ten jobs run at once, while only one job from the
scraping queue can run at a time. Jobs choose a default queue by implementing
JobQueue() string; one enqueue can override that default:
_, err := runner.EnqueueWith(ctx, &scrape.Job{URL: url}, lazyjobs.Queue("scraping"))
Use Enqueue, EnqueueIn, and EnqueueAt for the common cases. They remain
compatibility wrappers around EnqueueWith:
runner.Enqueue(ctx, &mail.Job{UserID: userID})
runner.EnqueueIn(ctx, &mail.Job{UserID: userID}, 10*time.Minute)
runner.EnqueueAt(ctx, &mail.Job{UserID: userID}, sendAt)
Scheduled jobs
Repeated schedules are fixed intervals. Register them beside job definitions:
func DefinedJobs(runner *lazyjobs.JobRunner) {
runner.MustRegister(&scrape.Job{})
runner.MustSchedule(lazyjobs.Every(
"scrape.providers",
6*time.Hour,
&scrape.Job{},
lazyjobs.Queue("scraping"),
))
}
The scheduler persists a schedule key, job payload, queue, interval, and next run time through the backend. If the app is down for several intervals, GoLazy enqueues at most one catch-up job and advances the next run into the future. If a pending, retrying, or running job already exists for the same schedule key, the scheduler advances the cursor without enqueuing overlapping work.
Schedules require a backend that implements lazyjobs.SchedulerBackend. The
bundled in-memory backend and golazy.dev/pg/pgjobs both do. A custom backend
can still implement only lazyjobs.Backend when an app does not register
schedules.
Backend interface
Durability is behind lazyjobs.Backend:
type Backend interface {
Insert(context.Context, lazyjobs.InsertParams) (lazyjobs.Record, error)
Claim(context.Context, lazyjobs.ClaimParams) (lazyjobs.Record, bool, error)
Complete(context.Context, int64) error
Retry(context.Context, lazyjobs.RetryParams) error
Discard(context.Context, lazyjobs.DiscardParams) error
List(context.Context, lazyjobs.ListOptions) ([]lazyjobs.Record, error)
Stats(context.Context) (lazyjobs.Stats, error)
}
Insert stores pending work. Claim must atomically select one due job from
the requested queues whose queue is below its configured limit, then move it to
running. Complete, Retry, and Discard record runner decisions after
Work returns or panics. List and Stats power operational views.
The backend owns persistence and concurrency. The runner owns decoding,
calling Work, retry-delay decisions, and state-transition calls.
Implement a backend
A backend should:
- Store
Kind,Queue, optionalScheduleKey, JSONPayload,State, attempts,RunAt, timestamps, andLastError. - Default empty queues to
lazyjobs.DefaultQueue. - Claim only
pendingorretryingrecords whoseRunAtis due. - Make claim atomic so two workers cannot run the same record.
- Preserve payload bytes exactly enough for registered job decoding.
- Return newest recent records from
List. - Return aggregate counts by state, kind, queue, and queue/state from
Stats. - Honor
ClaimParams.QueueLimitsatomically when more than one process can claim jobs.
If the backend owns network connections or goroutines, implement Close() error;
the runner calls it during shutdown when present.
Built-in backends
golazy.dev/lazyjobs/inmemoryjobs is the default in-process backend. It is
useful for development, tests, and apps where queued work does not need to
survive process restarts.
PostgreSQL apps can use golazy.dev/pg/pgjobs. Include
pgjobs.Migrations() in lazyapp.Config.Migrations and build the job config
from context after dependencies initialize. Read
PostgreSQL.
Enqueue work
Resolve the runner from the request or service context:
runner, ok := lazyjobs.RunnerFromContext(ctx)
if !ok {
return errors.New("jobs are not configured")
}
_, err := runner.Enqueue(ctx, &importwhatsapp.Job{ImportID: importID})
return err
The runner serializes the job payload to JSON, stores it through the backend, and starts in-process workers with the application.
Inspect jobs
When jobs are configured, GoLazy registers read-only GET /jobs on the app
control plane. It returns runner status, job definitions, schedules, queue
limits, state counts, and recent jobs. In lazy development, the GoLazy panel
proxies that endpoint and shows the same data in the Jobs tab.
Production builds still follow normal control-plane exposure rules: use
CONTROL_PLANE_ADDR when job state should be available outside application
routes.