Files
probo/contrib/claude/go-worker.md
Bryan Frimin 43336078f4 Migrate workers to kit/worker
Replace hand-rolled polling loops, semaphores, and WaitGroups
in all 7 background workers with go.gearno.de/kit/worker. Each
worker now implements Handler[T] (Claim/Process) and optionally
StaleRecoverer, gaining automatic Prometheus metrics and
OpenTelemetry tracing. Bumps kit from v0.3.0 to v0.5.0.

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
2026-04-13 12:25:42 +02:00

2.8 KiB

Go Worker

Background workers use go.gearno.de/kit/worker. The kit handles the polling loop, semaphore-based concurrency, graceful shutdown, non-cancellable contexts, Prometheus metrics, and OpenTelemetry tracing. You only implement a handler.

Handler interface

Implement worker.Handler[T] with Claim and Process. Optionally implement worker.StaleRecoverer for stale row recovery.

type fooHandler struct {
	pg         *pg.Client
	logger     *log.Logger
	staleAfter time.Duration
}

func (h *fooHandler) Claim(ctx context.Context) (coredata.FooItem, error) {
	var item coredata.FooItem

	if err := h.pg.WithTx(
		ctx,
		func(ctx context.Context, tx pg.Tx) error {
			if err := item.LoadNextPendingForUpdateSkipLocked(ctx, tx); err != nil {
				return err
			}

			now := time.Now()
			item.Status = coredata.FooStatusProcessing
			item.UpdatedAt = now
			return item.Update(ctx, tx, coredata.NewNoScope())
		},
	); err != nil {
		if errors.Is(err, coredata.ErrResourceNotFound) {
			return coredata.FooItem{}, worker.ErrNoTask
		}
		return coredata.FooItem{}, err
	}

	return item, nil
}

func (h *fooHandler) Process(ctx context.Context, item coredata.FooItem) error {
	if err := h.handle(ctx, &item); err != nil {
		if failErr := h.fail(ctx, &item, err); failErr != nil {
			h.logger.ErrorCtx(ctx, "cannot fail item", log.Error(failErr))
		}
		return err
	}
	return nil
}

func (h *fooHandler) RecoverStale(ctx context.Context) error {
	return h.pg.WithConn(
		ctx,
		func(ctx context.Context, conn pg.Querier) error {
			return coredata.ResetStaleFooItems(ctx, conn, h.staleAfter)
		},
	)
}

Constructor

The constructor builds the handler and returns *worker.Worker[T]. Use worker.WithInterval and worker.WithMaxConcurrency for tuning. Keep domain-specific options (e.g. timeouts, staleAfter) on the handler struct.

func NewFooWorker(
	pgClient *pg.Client,
	logger *log.Logger,
	opts ...worker.Option,
) *worker.Worker[coredata.FooItem] {
	h := &fooHandler{
		pg:         pgClient,
		logger:     logger,
		staleAfter: 5 * time.Minute,
	}

	return worker.New(
		"foo-worker",
		h,
		logger,
		opts...,
	)
}

Key principles

  • Claim with FOR UPDATE SKIP LOCKED — prevents multiple workers from picking the same row
  • Return worker.ErrNoTask from Claim when no work is available (not the coredata sentinel)
  • Context is non-cancellable — the kit provides context.WithoutCancel to both Claim and Process
  • Process handles its own failures — update DB status on error, the kit only logs and records metrics
  • Stale recovery is optional — implement worker.StaleRecoverer if the worker marks rows as "processing"
  • Kit provides observability — Prometheus metrics (worker_tasks_total, worker_task_duration_seconds, etc.) and OTel traces are automatic