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

118
modules/conga/commands.go Normal file
View File

@@ -0,0 +1,118 @@
package conga
import (
"context"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/party"
"github.com/riverqueue/river"
"github.com/riverqueue/river/rivertype"
)
// clearBatch is the number of jobs one JobDeleteMany pass removes.
const clearBatch = 10000
// RuntimeCommands returns the queue:work and queue:clear commands for the
// application binary.
func RuntimeCommands(app *backpack.App, plugins []party.Plugin) []bonfire.Command {
return []bonfire.Command{
{
Name: "queue:work",
Description: "Run background job workers in the foreground",
Flags: []bonfire.Flag{{
Name: "queue",
Description: "Queue to work (repeatable; default all known queues)",
Repeatable: true,
}},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
return withDB(ctx, app, func() error {
return work(ctx, app, plugins, in.Flags("queue"), out)
})
},
},
{
Name: "queue:clear",
Description: "Clear all queued jobs, by deleting all pending jobs.",
Args: []bonfire.Arg{{
Name: "queue",
Description: "The name of the queue to clear (default \"default\")",
}},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
queue, _ := in.Argument("queue")
queue = strings.TrimSpace(queue)
if queue == "" {
queue = defaultQueue
}
return withDB(ctx, app, func() error {
return clearQueue(ctx, app, queue, out)
})
},
},
}
}
func work(ctx context.Context, app *backpack.App, plugins []party.Plugin, queues []string, out bonfire.Output) error {
ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer stop()
w, err := StartWorker(ctx, app, plugins, WorkerOptions{Queues: queues})
if err != nil {
return err
}
out.Info("worker started on queues: " + strings.Join(w.Queues(), ", "))
<-ctx.Done()
stopCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return w.Stop(stopCtx)
}
// clearQueue deletes the available, scheduled and retryable jobs of queue in
// batches until a pass deletes none. River never deletes running jobs.
func clearQueue(ctx context.Context, app *backpack.App, queue string, out bonfire.Output) error {
m, err := From(app)
if err != nil {
return err
}
client, err := m.insertClient()
if err != nil {
return err
}
out.Info(fmt.Sprintf("Clearing queue %q", queue))
total := 0
for {
res, err := client.JobDeleteMany(ctx, river.NewJobDeleteManyParams().
Queues(queue).
States(rivertype.JobStateAvailable, rivertype.JobStateScheduled, rivertype.JobStateRetryable).
First(clearBatch))
if err != nil {
return fmt.Errorf("conga: clear queue %q: %w", queue, err)
}
if len(res.Jobs) == 0 {
break
}
total += len(res.Jobs)
}
out.Info(fmt.Sprintf("Cleared %d jobs", total))
return nil
}
// withDB opens and publishes the shared pool for a CLI command and closes it
// when fn returns, the way lagoon's own commands do.
func withDB(ctx context.Context, app *backpack.App, fn func() error) error {
sqlDB, gdb, err := lagoon.OpenFromApp(ctx, app)
if err != nil {
return err
}
defer sqlDB.Close()
if err := lagoon.Publish(app, sqlDB, gdb); err != nil {
return err
}
return fn()
}