Guide 6 of 12

Background jobs

Jobs are opt-in, but not a one-time choice: add River at new time with --jobs, or later, the moment you generate the first job.

bogie new blog --jobs

This runs River on the same Postgres the app already has: no Redis, a transactional enqueue, typed args. It adds a worker role, app/jobs, and g job.

Adding jobs to an app that has none

bogie g job NAME on an app made without --jobs adds jobs first, as if --jobs had been passed to new, then generates the job as usual:

bogie g job send_welcome
        note  this app has no app/jobs yet; adding --jobs
     updated  AGENTS.md
     updated  Dockerfile
     updated  Makefile
     updated  app/application.go
      create  app/jobs/jobs.go
      create  app/jobs/jobs_test.go
     updated  app/models/store.go
     updated  app/services/migrate/migrate.go
     updated  bin/ci
     updated  config/deploy.production.yml
     updated  config/deploy.yml
     updated  main.go
      insert  main.go: "worker": runWorker,
      insert  app/application.go: import "blog/app/jobs"
     updated  bogie.toml: jobs = true
         run  go mod tidy
      create  app/jobs/send_welcome.go
      create  app/jobs/send_welcome_test.go
      insert  app/jobs/jobs.go: registered += add(workers, &SendWelcomeWorker{Log: log})

It is the same trick as bogie app:update: render the app twice, once without jobs and once with, and merge the difference in. A file you have already edited merges rather than being overwritten; a real overlap leaves conflict markers, the same as app:update. Two refusals guard it, for the same reasons app:update has them:

Once jobs are on, every later g job just writes the job and registers it, the same as an app made with --jobs from the start.

Writing one

bogie g job send_welcome
      create  app/jobs/send_welcome.go
      create  app/jobs/send_welcome_test.go
      insert  app/jobs/jobs.go: registered += add(workers, &SendWelcomeWorker{Log: log})
type SendWelcomeArgs struct {
	// ID string `json:"id"`
}

func (SendWelcomeArgs) Kind() string { return "send_welcome" }

type SendWelcomeWorker struct {
	river.WorkerDefaults[SendWelcomeArgs]
	Log *slog.Logger
}

func (w *SendWelcomeWorker) Work(ctx context.Context, job *river.Job[SendWelcomeArgs]) error {
	w.Log.InfoContext(ctx, "send_welcome: not implemented yet", "job", job.ID)
	return nil
}

An Args type with a Kind, and a Worker with Work(ctx, job). Args are stored as JSON in the job row, so keep them to ids and small values; the worker loads whatever else it needs. Never change Kind once jobs of it have been queued: a queued job of the old kind would have no worker to run it. Dependencies go on the worker as fields, the smallest interface it needs (rule 2), passed on the registration line.

The marker

The generator registers the worker above // bogie:jobs in app/jobs/jobs.go, the same idempotent, checked-before-written insertion every generator uses:

registered += add(workers, &SendWelcomeWorker{Log: log})
// bogie:jobs

Enqueuing

From anywhere that holds the application, including inside a transaction so the job commits with the row it depends on or not at all:

app.Jobs.Insert(ctx, jobs.SendWelcomeArgs{}, nil)

// or, so the enqueue commits with the row:
app.Jobs.InsertTx(ctx, tx, jobs.SendWelcomeArgs{}, nil)

Running the worker

bogie worker

Or make worker. It is its own process, separate from bogie server, so a backlog in the queue never slows a request. In development bogie worker runs under Air and builds to its own binary, so it rebuilds and restarts on save independently of the server; make worker is one boot, no reload. River's own schema (the tables it needs to track jobs) is applied by migrate, through rivermigrate, not copied into db/migrate: it arrives the same way the app's own migrations do, at boot or with bogie db:migrate.

Removing one

bogie d job send_welcome

Removes the file and the registration line.