Extra guides

Background Jobs

Register typed background jobs, choose a backend, enqueue work, and inspect job state.

By Guillermo Alvarez - Published - Updated

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:

  1. Store Kind, Queue, optional ScheduleKey, JSON Payload, State, attempts, RunAt, timestamps, and LastError.
  2. Default empty queues to lazyjobs.DefaultQueue.
  3. Claim only pending or retrying records whose RunAt is due.
  4. Make claim atomic so two workers cannot run the same record.
  5. Preserve payload bytes exactly enough for registered job decoding.
  6. Return newest recent records from List.
  7. Return aggregate counts by state, kind, queue, and queue/state from Stats.
  8. Honor ClaimParams.QueueLimits atomically 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.