Queue
What it is for
Section titled “What it is for”gas/queue sends and receives background jobs through AWS SQS, or through an
SQS-compatible service such as ElasticMQ or LocalStack. Its sqs.Service
implements gas.JobQueueProvider, the interface declared in core, so the code
that enqueues and consumes jobs imports only github.com/gasmod/gas and the
backend is chosen where the app is wired.
The interface is pull-based. Gas does not run a consumer for you: your code
calls Dequeue in its own loop, decides the concurrency and the failure
policy, and settles each job with Ack or Nack. A queue is named by its full
queue URL, and the package never creates queues.
| Package | Provides |
|---|---|
queue |
The sentinel ErrClosed |
queue/sqs |
Service, the SQS backend |
queue/queuetest |
MockQueue, a recording mock of gas.JobQueueProvider |
Install
Section titled “Install”go get github.com/gasmod/gas/queueWiring
Section titled “Wiring”w := gas.NewWorker( gas.WithServiceInstance[gas.ConfigProvider](cfg), gas.WithSingletonService[gas.Logger](gaslog.NewSlogLogger()),
// The SQS backend, under the interface services depend on. gas.WithSingletonService[gas.JobQueueProvider](queuesqs.New()),
// An app service that consumes jobs; see Consuming below. gas.WithSingletonService[*Consumer](NewConsumer),)sqs.New() returns a constructor that resolves gas.ConfigProvider and
gas.Logger from the container. It takes two options:
sqs.WithConfig(cfg)usescfgas the whole configuration and skips theConfigProvider. Start fromsqs.DefaultConfig()so the keys you do not set keep their defaults. A config given this way is used as given, without validation.sqs.WithClient(client)injects a ready SQS client;Initthen builds none and fetches no credentials.
A queue works the same in a gas.App and in a gas.Worker. The example uses a
Worker because a consumer usually runs without an HTTP server; see
Workers for the Worker itself, including running it on
AWS Lambda.
To point the backend at a local ElasticMQ without touching the environment, pass a config:
// elasticMQ points the backend at a local ElasticMQ with static credentials.// WithConfig replaces the whole Config and skips the ConfigProvider, so it// starts from DefaultConfig to keep the other defaults.func elasticMQ() gas.Option { cfg := queuesqs.DefaultConfig() cfg.Queue.Endpoint = "http://localhost:9324" cfg.Queue.AccessKeyID = "x" cfg.Queue.SecretAccessKey = "x"
return gas.WithSingletonService[gas.JobQueueProvider]( queuesqs.New(queuesqs.WithConfig(cfg)), )}Enqueueing
Section titled “Enqueueing”// SendWelcome enqueues one job. The queue is named by its full URL.func SendWelcome(ctx context.Context, q gas.JobQueueProvider, queueURL string, userID string) error { return q.Enqueue(ctx, queueURL, []byte(`{"user_id":"`+userID+`"}`), gas.WithDelay(10*time.Second), gas.WithJobAttributes(map[string]string{"kind": "welcome"}), )}Enqueue sends the payload as the message body. The options are declared in
core and map onto the SQS SendMessage request:
| Option | SQS field | Notes |
|---|---|---|
gas.WithDelay(d) |
DelaySeconds |
Whole seconds, truncated. Set only when d is positive |
gas.WithGroupID(id) |
MessageGroupId |
Set only when id is not empty |
gas.WithDedupeID(id) |
MessageDeduplicationId |
Set only when id is not empty |
gas.WithJobAttributes(m) |
MessageAttributes |
Each entry becomes a String attribute |
Gas passes the group and deduplication IDs on any queue and leaves the checks to SQS. They are meant for FIFO queues:
// On a FIFO queue (its URL ends in .fifo), the group orders messages and// the deduplication ID drops repeats.func SendReceipt(ctx context.Context, q gas.JobQueueProvider, fifoURL, orderID string) error { return q.Enqueue(ctx, fifoURL, []byte(orderID), gas.WithGroupID("order-"+orderID), gas.WithDedupeID("receipt-"+orderID), )}Consuming
Section titled “Consuming”A consumer is an ordinary service that owns its loop. This one starts the loop
in Init and stops it in Close:
// ConsumerConfig is bound from the ConfigProvider (key jobs.queueurl).type ConsumerConfig struct { Jobs struct { QueueURL string `validate:"required"` }}
// Consumer runs a pull loop for the life of the service.type Consumer struct { queue gas.JobQueueProvider logger gas.Logger url string
cancel context.CancelFunc done sync.WaitGroup}
func NewConsumer(q gas.JobQueueProvider, cfg gas.ConfigProvider, logger gas.Logger) (*Consumer, error) { var c ConsumerConfig if err := cfg.Bind(&c); err != nil { return nil, err } return &Consumer{queue: q, logger: logger, url: c.Jobs.QueueURL}, nil}
func (c *Consumer) Name() string { return "jobs-consumer" }
// Init starts the loop on its own context: the ctx Init receives is// canceled as soon as Init returns.func (c *Consumer) Init(context.Context) error { ctx, cancel := context.WithCancel(context.Background()) c.cancel = cancel c.done.Go(func() { c.run(ctx) }) return nil}
// Close stops the loop and waits for the batch in hand. It runs before the// queue's own Close, since the consumer depends on the queue.func (c *Consumer) Close(context.Context) error { c.cancel() c.done.Wait() return nil}
func (c *Consumer) run(ctx context.Context) { for ctx.Err() == nil { if err := c.Poll(ctx); err != nil { if ctx.Err() != nil || errors.Is(err, queue.ErrClosed) { return } c.logger.Error("poll failed").Err("error", err).Send() select { case <-ctx.Done(): case <-time.After(5 * time.Second): } } }}Two details in it matter. The ctx that Init receives is canceled as soon
as Init returns, so the loop runs on a context of its own. And because the
consumer takes gas.JobQueueProvider as a dependency, it is initialized after
the queue and closed before it, so Close drains the batch in hand while the
queue still accepts Ack and Nack.
Each pass receives a batch and settles every job in it:
// Poll receives up to 10 jobs, waiting up to 20 seconds for the first, and// settles each one: Ack removes it, Nack makes it visible again at once.func (c *Consumer) Poll(ctx context.Context) error { jobs, err := c.queue.Dequeue(ctx, c.url, 10, 20*time.Second) if err != nil { return err }
for _, job := range jobs { if err := handle(ctx, job); err != nil { c.logger.Warn("job failed, retrying").Str("job_id", job.ID).Err("error", err).Send() if err := c.queue.Nack(ctx, c.url, job); err != nil { return err } continue } if err := c.queue.Ack(ctx, c.url, job); err != nil { return err } } return nil}Dequeue(ctx, queueURL, maxMessages, wait) long-polls SQS ReceiveMessage:
maxMessagesis clamped to SQS’s range of 1 to 10. A value above 10 also logs a warning.waitis sent in whole seconds. When it truncates to zero, the configuredqueue.waittimesecondsis used instead. A nonzerowaitis passed as given, so keep it at 20 seconds or less, the most SQS allows.- The configured
queue.visibilitytimeoutis applied to every message received.
Each gas.Job carries the SQS message ID in ID, the receipt handle in
ReceiptHandle, and the body in Body. Attributes merges the message’s
system attributes with its message attributes that have a string value; on a
shared name the message attribute wins. Attributes is nil when the message
has neither.
Ack deletes the message. Nack sets its visibility timeout to zero, so it
can be received again at once. A job that is neither acked nor nacked becomes
visible again when its visibility timeout ends.
Using the SQS client directly
Section titled “Using the SQS client directly”Service.Client() returns the underlying *sqs.Client for calls the interface
does not cover, such as creating or purging a queue:
// The backend's SQS client covers what JobQueueProvider does not, such as// creating a queue.func createQueue(ctx context.Context, svc *queuesqs.Service, name string) (string, error) { out, err := svc.Client().CreateQueue(ctx, &awssqs.CreateQueueInput{QueueName: &name}) if err != nil { return "", err } return *out.QueueUrl, nil}Client returns nil until Init has built the client, and when WithClient
injected a client that is not an *sqs.Client.
Configuration
Section titled “Configuration”Unless WithConfig is given, Init binds the settings from the
gas.ConfigProvider over sqs.DefaultConfig(), so a key that is not set keeps
its default. The keys sit under queue:
| Key | Type | Default | Description |
|---|---|---|---|
queue.region |
string | us-east-1 |
AWS region. Must not be empty |
queue.endpoint |
string | empty | Custom endpoint URL, for ElasticMQ or LocalStack. Empty uses the AWS endpoint |
queue.accesskeyid |
string | empty | Static access key ID. Used only when queue.secretaccesskey is set too; otherwise the default AWS credential chain applies |
queue.secretaccesskey |
string | empty | Static secret access key |
queue.visibilitytimeout |
duration | 30s |
How long a received message stays hidden from other consumers. Must not be negative |
queue.waittimeseconds |
int | 20 |
Long-poll duration used when Dequeue is given no wait. 0 to 20 |
The rules in the table are validate tags, which ConfigProvider.Bind
enforces when it binds the config; a violation fails Init. How keys map to
environment variables is described on Config.
Init loads the AWS configuration and then fetches the credentials, both under
Init’s context, so a missing or broken credential setup fails startup rather
than the first call. With the default chain this can make a network call, for
example to the instance metadata service. Init does not contact SQS: an
unreachable endpoint, rejected credentials, or a missing queue surface on the
first operation.
Errors
Section titled “Errors”| Error | Returned by | When |
|---|---|---|
queue.ErrClosed |
Enqueue, Dequeue, Ack, Nack, CheckReady |
After Close |
gas/queue/sqs: config binding: ... |
Init |
The config cannot be bound or fails its validate tags |
gas/queue/sqs: load aws config: ... |
Init |
The AWS configuration cannot be loaded |
gas/queue/sqs: retrieve aws credentials: ... |
Init |
No credentials can be found |
gas/queue/sqs: enqueue: ... (and dequeue, ack, nack) |
The matching operation | The SQS call failed |
queue.ErrClosed is the only sentinel. Errors from SQS calls wrap the AWS
SDK’s error with %w, so errors.As reaches the SDK’s error types.
Health and readiness
Section titled “Health and readiness”sqs.Service implements gas.ReadyReporter, not gas.HealthReporter.
CheckReady returns gas/queue/sqs: not initialized before Init has built a
client and queue.ErrClosed after Close, so a readiness probe takes the
instance out of rotation while it drains. It does not call SQS.
Close only marks the service closed: the service starts no goroutines and has
nothing else to release. A later Init, as RestartService runs, clears the
mark.
Testing
Section titled “Testing”queuetest.MockQueue implements gas.JobQueueProvider. Each method records
the call in Calls (a Method name and its Args) and then delegates to the
matching EnqueueFn, DequeueFn, AckFn, or NackFn field. With the field
unset it returns zero values: a nil error, and no jobs from Dequeue.
CallCount(method) counts the calls to one method, and Reset clears them.
import ( "context" "testing" "time"
"github.com/gasmod/gas" "github.com/gasmod/gas/queue/queuetest")
// TestPollSettlesEachJob drives one Poll against the mock: the good job is// acked, the empty one nacked, and SQS is never involved.func TestPollSettlesEachJob(t *testing.T) { t.Parallel() q := &queuetest.MockQueue{ DequeueFn: func(context.Context, string, int, time.Duration) ([]gas.Job, error) { return []gas.Job{ {ID: "1", Body: []byte(`{"user_id":"42"}`)}, {ID: "2"}, }, nil }, } c := &Consumer{queue: q, logger: &gas.NopLogger{}, url: "test-queue"}
if err := c.Poll(context.Background()); err != nil { t.Fatalf("Poll: %v", err) } if got := q.CallCount("Ack"); got != 1 { t.Errorf("Ack calls = %d, want 1", got) } if got := q.CallCount("Nack"); got != 1 { t.Errorf("Nack calls = %d, want 1", got) }}To test against a real queue without AWS, run ElasticMQ or LocalStack and set
queue.endpoint to it.