Skip to content

Workers

gas.Worker is Gas without the HTTP layer. It has the same DI container, service lifecycle, event bus, migrations, health and readiness checks, and kill switch as an App, and no router or server. Use it for background job processors, Lambda functions, and command-line tools.

gas.App embeds a Worker and adds HTTP on top, so every registration option works with both, and so does everything on App lifecycle that is not marked App only.

App only In a Worker
*gas.Router in the container Not registered. A constructor that asks for it fails startup with gas.ErrNotRegistered.
HTTP server, Serve, Handler, CSRF protection None
A request scope per HTTP request None; make scopes yourself (see below)
Binding the App’s gas.Config and emitting gas.AppConfigUpdated Not done
gas.SystemServerShuttingDown Not emitted

gas.NewWorker registers the *gas.EventBus, and the Worker itself as gas.HealthProvider and gas.ReadyProvider, in its container. It accepts the same gas.Option values as gas.NewApp, but only the Worker options: passing an HTTP option such as gas.WithErrorHandler or gas.WithTrustedOrigin panics.

cmd/worker/main.go
func main() {
cfg := config.New(config.WithProvider(providers.NewEnvProvider()))
if err := cfg.Load(); err != nil {
log.Fatal(err)
}
w := gas.NewWorker(
gas.WithServiceInstance[gas.ConfigProvider](cfg),
gas.WithSingletonService[gas.Logger](gaslog.NewSlogLogger()),
gas.WithSingletonService[gas.JobQueueProvider](queuesqs.New()),
gas.WithServiceInstance[QueueURL](QueueURL(os.Getenv("JOBS_QUEUE_URL"))),
gas.WithSingletonService[*Consumer](NewConsumer),
)
// Start everything, block until SIGINT or SIGTERM, then shut down.
if err := w.Run(); err != nil {
log.Fatal(err)
}
}

w.Run() starts the Worker, blocks until SIGINT or SIGTERM, then shuts it down, using context.Background() throughout. If startup fails, it returns the error without blocking.

When something else owns the process, call the parts yourself:

  • w.Start(ctx) builds and initializes every service, runs pending migrations, and runs the ready hooks, then returns. It does not block.
  • w.Shutdown(ctx) emits gas.SystemShuttingDown and closes every service in reverse initialization order. It runs once; later calls return nil.

A failed Start closes what it had initialized and cannot be retried on the same Worker; the rules are the same as for an App, described under A failed start leaves nothing running.

A Worker has no request loop of its own, so the work happens in a service that starts a goroutine in Init and stops it in Close:

consumer.go
// QueueURL is the queue the consumer reads. Registering a named type keeps
// it distinct from any other string in the container.
type QueueURL string
// Consumer pulls jobs in a background goroutine for as long as it is open.
type Consumer struct {
queue gas.JobQueueProvider
logger gas.Logger
url QueueURL
cancel context.CancelFunc
done chan struct{}
}
func NewConsumer(q gas.JobQueueProvider, logger gas.Logger, url QueueURL) *Consumer {
return &Consumer{queue: q, logger: logger, url: url}
}
func (c *Consumer) Name() string { return "consumer" }
// Init starts the loop. Its context is canceled as soon as Init returns, so
// the loop gets a context of its own, which Close cancels.
func (c *Consumer) Init(context.Context) error {
ctx, cancel := context.WithCancel(context.Background())
c.cancel = cancel
c.done = make(chan struct{})
go c.run(ctx)
return nil
}
// Close stops the loop and waits for it, bounded by the shutdown context.
func (c *Consumer) Close(ctx context.Context) error {
c.cancel()
select {
case <-c.done:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (c *Consumer) run(ctx context.Context) {
defer close(c.done)
url := string(c.url)
for ctx.Err() == nil {
jobs, err := c.queue.Dequeue(ctx, url, 10, 20*time.Second)
if err != nil {
if ctx.Err() != nil {
return
}
c.logger.Error("dequeue failed").Err("error", err).Send()
// Back off before polling again, so a persistent failure does
// not spin.
select {
case <-ctx.Done():
case <-time.After(time.Second):
}
continue
}
for _, job := range jobs {
c.handle(ctx, url, job)
}
}
}
func (c *Consumer) handle(ctx context.Context, url string, job gas.Job) {
if err := process(job); err != nil {
if nerr := c.queue.Nack(ctx, url, job); nerr != nil {
c.logger.Error("nack failed").Err("error", nerr).Send()
}
return
}
if err := c.queue.Ack(ctx, url, job); err != nil {
c.logger.Error("ack failed").Err("error", err).Send()
}
}

Two details matter here.

The loop must not use the context passed to Init. That context carries the service’s init deadline and is canceled as soon as Init returns, which would stop the loop immediately. The service makes a context of its own and cancels it in Close.

Close gets the shutdown context, so it waits for the loop to finish only as long as that context allows. Run passes context.Background(), which never ends; pass a context with a deadline to Shutdown if you need shutdown to be bounded.

The queue itself is the gas.JobQueueProvider from the Queue module, which covers enqueue options, delivery semantics, and configuration.

A Lambda function keeps its execution environment between invocations, so build and start the Worker once, from init, and resolve the handler from the container once:

cmd/lambda/main.go
// Handler processes one Lambda event. Its dependencies come from the
// container like any other singleton's.
type Handler struct {
logger gas.Logger
}
func NewHandler(logger gas.Logger) *Handler { return &Handler{logger: logger} }
func (h *Handler) Handle(_ context.Context, _ map[string]any) error {
h.logger.Info("event received").Send()
return nil
}
// worker lives at package level so it survives across invocations.
var worker *gas.Worker
// In a real function this is init: it runs once per execution environment.
func initWorker() {
worker = gas.NewWorker(
gas.WithSingletonService[gas.Logger](gaslog.NewSlogLogger()),
gas.WithSingletonService[*Handler](NewHandler),
)
// Start returns once services are initialized, migrations have run, and
// ready hooks have passed. It does not block.
if err := worker.Start(context.Background()); err != nil {
log.Fatal(err)
}
}
// In a real function this is main.
func lambdaMain() {
// Every singleton is built by now, so this only looks it up.
h := worker.ServiceContainer().MustResolve[*Handler]()
// The runtime calls the SIGTERM hook before shutting the environment
// down, which closes every service in reverse initialization order.
lambda.StartWithOptions(h.Handle, lambda.WithEnableSIGTERM(func() {
if err := worker.Shutdown(context.Background()); err != nil {
log.Printf("shutdown: %s", err)
}
}))
}

initWorker and lambdaMain stand in for the function’s init and main. lambda.StartWithOptions (from github.com/aws/aws-lambda-go/lambda) hands the resolved handler to the runtime, and lambda.WithEnableSIGTERM registers a hook that runs Worker.Shutdown when the runtime sends SIGTERM before shutting the environment down, so services are closed in reverse initialization order. Here a failed Start calls log.Fatal, so a broken deployment fails on its first cold start rather than on a later invocation.

The lambda-worker program in the repository’s example/ directory is a complete function built this way, with a database and an SQS queue; see Examples.

Nothing in a Worker opens scopes automatically. Code that uses scoped services, such as a job handler that wants one instance per job, creates a scope with ServiceContainer().NewScope(ctx) and closes it when the job is done; see Request scopes.