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.
What a Worker does not have
Section titled “What a Worker does not have”| 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.
Run, or Start and Shutdown
Section titled “Run, or Start and Shutdown”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)emitsgas.SystemShuttingDownand 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 long-running consumer
Section titled “A long-running consumer”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:
// 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.
On AWS Lambda
Section titled “On AWS Lambda”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:
// 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.
Scopes
Section titled “Scopes”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.