feat(11-01): run job workers in serve and queue:work, add queue:clear

- Manager gains the apparatus JobManager surface: StartJob, UpdateJobState,
  UpdateMetadata, FailJob, CancelJob (is_canceled + STOPPED + River JobCancel),
  StopJob (STOPPED only), CheckIfCanceled and GetMetadata, all raw column
  writes so updated_at is untouched
- serve starts the in-process worker unless queue.work_in_serve is false and
  stops it on shutdown; an app without jobs gets an idle worker
- queue:work runs a foreground worker with repeatable --queue filters;
  queue:clear deletes available, scheduled and retryable jobs of one queue
- the generated main appends conga.RuntimeCommands; summer delegates
  queue:work and queue:clear; make:job scaffolds a conga.Job
This commit is contained in:
Jakub Zych
2026-09-29 15:20:14 +02:00
parent 05ba88a54a
commit b319e7cc61
15 changed files with 939 additions and 38 deletions

View File

@@ -28,6 +28,8 @@ type WorkerOptions struct {
pollInterval time.Duration
// pollOnly builds a driver without the LISTEN pool (tests only).
pollOnly bool
// retryPolicy overrides River's retry schedule (tests only).
retryPolicy river.ClientRetryPolicy
}
// Worker is a running River work client. Stop it with Stop.
@@ -92,6 +94,13 @@ func StartWorker(ctx context.Context, app *backpack.App, plugins []party.Plugin,
return nil, fmt.Errorf("conga: register %s: %w", j.kind(), err)
}
}
if len(m.jobs) == 0 {
// River refuses to start a client without workers; an app with no
// jobs yet still gets a worker that starts and idles.
if err := river.AddWorkerSafely[idleArgs](workers, idleWorker{}); err != nil {
return nil, fmt.Errorf("conga: register idle worker: %w", err)
}
}
cfg := baseConfig(s, log)
cfg.Workers = workers
cfg.Queues = map[string]river.QueueConfig{}
@@ -101,6 +110,9 @@ func StartWorker(ctx context.Context, app *backpack.App, plugins []party.Plugin,
if o.pollInterval > 0 {
cfg.FetchPollInterval = o.pollInterval
}
if o.retryPolicy != nil {
cfg.RetryPolicy = o.retryPolicy
}
var listener *pgxpool.Pool
var driver *riverdatabasesql.Driver
@@ -126,6 +138,16 @@ func StartWorker(ctx context.Context, app *backpack.App, plugins []party.Plugin,
return &Worker{m: m, client: client, listener: listener, queues: sortedQueueNames(queues)}, nil
}
// StartServeWorker starts the in-process worker of the serve command on
// every known queue. It returns nil, nil when queue.work_in_serve is false,
// for deployments that run a separate queue:work process.
func StartServeWorker(ctx context.Context, app *backpack.App, plugins []party.Plugin) (*Worker, error) {
if !settingsFromApp(app).workInServe {
return nil, nil
}
return StartWorker(ctx, app, plugins, WorkerOptions{})
}
// Stop stops fetching jobs and waits for running jobs to finish. When ctx
// ends first, running jobs are cancelled. The listener pool is closed.
func (w *Worker) Stop(ctx context.Context) error {
@@ -242,3 +264,15 @@ func summerJobID(row *rivertype.JobRow) (uint, bool) {
}
return meta.SummerJobID, true
}
// idleArgs is the kind of the placeholder worker registered when an app has
// no jobs. Nothing inserts it.
type idleArgs struct{}
func (idleArgs) Kind() string { return "summercms_conga_idle" }
type idleWorker struct {
river.WorkerDefaults[idleArgs]
}
func (idleWorker) Work(context.Context, *river.Job[idleArgs]) error { return nil }