A framework-agnostic resident worker for consuming jobs from Amazon SQS
Project description
sqs-job-worker
A framework-agnostic, long-running worker for processing Amazon SQS jobs. Hand it a boto3 client and it works; the core depends on no web framework, APM SDK, or deployment platform.
Features
- A simple long-running worker that receives and processes one message at a time; concurrency scales with the number of processes (containers)
- Polls multiple queues in weighted random order, prioritizing some queues without starving the low-weight ones
- A dedicated thread keeps extending the visibility timeout while a job runs, so even jobs with unpredictable runtimes are unlikely to be delivered twice
- Graceful shutdown on SIGTERM / SIGINT: in-flight jobs run to completion and unprocessed messages are returned to the queue immediately
- Rack-style middleware with three hooks: produce / poll / consume
- New Relic, OpenTelemetry, and Sentry trace propagation, plus draining for ECS rolling deploys, bundled as
contrib - Structured logging via structlog; correlation fields travel in SQS message attributes and are attached to consumer-side logs automatically
- Works with both standard and FIFO queues
Installation
$ pip install sqs-job-worker
To use an APM integration, install the corresponding extra:
$ pip install "sqs-job-worker[newrelic]"
$ pip install "sqs-job-worker[otel]"
$ pip install "sqs-job-worker[sentry]"
Requires Python 3.11 or later.
Quickstart
Create a QueueGroup that maps logical names to physical queues. Share the same definition between producers and consumers.
import boto3
from sqs_job_worker import QueueGroup
sqs = boto3.client("sqs")
queues = QueueGroup.build(boto_client=sqs, queues={"default": {"name": "my-app-jobs"}})
Enqueue a job:
queues.enqueue("default", "send_welcome_email", {"user_id": 42})
In the worker process, start the loop with a handler for each job_type:
def send_welcome_email(payload: dict) -> None:
...
queues.run_worker({"send_welcome_email": send_welcome_email})
run_worker() is a blocking loop with signal handling built in.
On ECS or Kubernetes, run this process as a long-running service as is.
Messages are delivered at least once. The same message may arrive more than once, so implement handlers idempotently.
Message format
The message body is the following JSON. Any producer that follows this format can enqueue jobs, including producers written in other languages.
{"job_type": "send_welcome_email", "payload": {"user_id": 42}}
- When the body cannot be parsed as JSON,
job_typeis not a string, orpayloadis not an object, the message is deleted, because no retry can fix it - When no handler is registered for the
job_type, the message is left undeleted, on the assumption that it will be redelivered to another worker responsible for that job
Correlation fields and trace headers travel in SQS message attributes, not in the body.
enqueue() also accepts SQS send options such as message_group_id / message_deduplication_id for FIFO queues.
For standard queues you can also delay delivery with delay_seconds.
FIFO queues do not support per-message delays.
queues.enqueue(
"default",
"recalculate_balance",
{"account_id": "a-1"},
message_group_id="a-1",
message_deduplication_id="job-1",
)
Retries and failures
- When a handler returns normally, the message is deleted
- When a handler raises, the message is not deleted and is redelivered after the visibility timeout expires. Configure retry limits and dead-letter queues through the SQS redrive policy
- When retrying cannot possibly succeed, raise
NonRetryableError. The message is deleted immediately without redelivery
from sqs_job_worker import NonRetryableError
def sync_user(payload: dict) -> None:
user = find_user(payload["user_id"])
if user is None:
raise NonRetryableError("user was deleted; retrying cannot succeed")
Multiple queues and weighted polling
queues = QueueGroup.build(
boto_client=sqs,
queues={
"critical": {"name": "my-app-jobs-critical", "weight": 5},
"default": {"name": "my-app-jobs", "weight": 1},
},
)
- Each cycle, the worker draws a random queue order based on the weights, short-polls each queue in that order, and processes the first message it finds
weightis a relative value used in this ordering draw. In the example above,criticalis more likely to come first, but every queue is checked every cycle, so low-weight queues are never left behind- When every queue is empty, the worker long-polls the queue that came first in the order for
idle_wait_seconds(default 20 seconds), waiting for a message to arrive
Instead of name, you can specify the queue URL directly with url. For per-queue tuning, construct an SqsQueue yourself and pass it to QueueGroup.
from sqs_job_worker import QueueGroup, SqsQueue
heavy = SqsQueue(queue_url, boto_client=sqs, max_runtime_seconds=7200)
queues = QueueGroup({"heavy": {"queue": heavy, "weight": 1}})
Long-running jobs and the visibility timeout
- When no visibility timeout is specified, the worker fetches the queue's configured value at startup
- While a job runs, a dedicated heartbeat thread keeps extending the visibility timeout at intervals of one third of the timeout. You don't need to raise the queue's visibility timeout to match the job's maximum runtime
- When a job runs longer than
max_runtime_seconds(default 3600 seconds), the heartbeat stops and the message returns to SQS for redelivery - When extending the visibility timeout fails, the message may already have been redelivered to another worker, so it is not deleted even if the job finishes successfully
Graceful shutdown
On SIGTERM / SIGINT, run_worker() stops receiving new messages, finishes the in-flight job, then exits.
A second signal exits immediately.
Messages received after the stop request are not processed; their visibility timeout is reset to 0 so they become available for redelivery right away.
If receive errors persist, the worker retries with jittered exponential backoff capped at 30 seconds. After 10 consecutive failures it exits with code 1, leaving the restart to the supervisor.
Middleware
Middleware wraps enqueueing, polling, and processing so you can inject cross-cutting behavior such as trace propagation and instrumentation.
Pass the middleware to use as a list via QueueGroup's middleware.
from sqs_job_worker.contrib.otel import OTelMiddleware
queues = QueueGroup.build(
boto_client=sqs,
queues={"default": {"name": "my-app-jobs"}},
middleware=[OTelMiddleware()],
)
Like Rack and WSGI, middleware is applied from the outside in, with the first list entry outermost.
Share the same QueueGroup definition between producers and consumers and the same middleware applies to both enqueueing and processing.
Middleware in contrib
The core depends only on boto3 and structlog; integrations that touch APM vendor SDKs or platform APIs are kept in contrib.
Install the corresponding extra to use an APM integration.
contrib.newrelic.NewRelicMiddleware— propagates New Relic distributed traces, instruments jobs asBackgroundTasks, and bindstrace_id/span_idto logscontrib.otel.OTelMiddleware— propagates traces with the propagator configured in your application, instruments jobs withCONSUMERspans carryingmessaging.*attributes, and bindstrace_id/span_idto logscontrib.sentry.SentryMiddleware— propagatessentry-trace/baggage, reports handler exceptions to Sentry, and leaves the retry decision to the workercontrib.ecs.EcsDrainMiddleware— during an ECS rolling deploy, pauses polling once a task from a newer revision of the same task definition family is RUNNING, preventing previous-generation workers from picking up new jobs. If the new generation fails and stops, polling resumes automatically- Pass an ECS client, e.g.
EcsDrainMiddleware(boto3.client("ecs")). The task role needs theecs:ListTasksandecs:DescribeTaskspermissions - The worker identifies its own task via the ECS Task Metadata Endpoint V4; outside ECS, or on ECS API errors, it fails open and keeps polling as usual
- Pass an ECS client, e.g.
Register only one trace-context-injecting middleware per process. Duplicates are detected as a ValueError when a job is enqueued.
Writing your own middleware
Subclass JobMiddleware and override only the hooks you need.
produce(job, call_next)wrapsenqueue(). Use it to inject correlation fields and trace headerspoll(call_next)wraps one polling cycle. Use it to pause receiving (draining) or for per-cycle instrumentationconsume(job, call_next)wraps handler execution. Use it to set up APM transactions or tenant scopes
from sqs_job_worker import Job, JobMiddleware
class TenantMiddleware(JobMiddleware):
def produce(self, job: Job, call_next):
job.correlation_fields.setdefault("tenant_id", current_tenant_id())
return call_next(job)
def consume(self, job: Job, call_next):
with tenant_scope(job.correlation_fields["tenant_id"]):
return call_next(job)
Correlation fields and trace propagation
- The dict passed as
enqueue(..., correlation_fields={"request_id": "r-1"})is stored as JSON in a single message attribute namedcorrelation_fields. On the consumer side it is bound to the structlog context automatically and attached to every log line the job emits. Keys reserved by the worker, such asqueueandmessage_id, are never overwritten - Trace headers (such as
traceparent) travel as individual message attributes. The core never interprets their contents; injection and extraction are handled by the tracing middleware in contrib - SQS allows up to 10 message attributes per message. When the limit would be exceeded, attributes given explicitly via
message_attributestake precedence: propagation attributes are dropped from the end and apropagation_attributes_droppedwarning is logged
Logging
Logs are structured with structlog. Configure the output destination and formatting in your application.
Job lifecycle events are emitted to the sqs_job_worker.job logger.
job_enqueued— on enqueue (queue,job_type,message_id)job_started— when execution starts (group_id,queue_wait_ms)job_finished— on completion (outcome,duration_ms)
The outcome of job_finished is one of:
success— completed normally; the message is deletedretry— the handler raised; the message is redelivered after the visibility timeout expiresdelete— the message is deleted due toNonRetryableErrorheartbeat_failed— extending the visibility timeout failed, ormax_runtime_secondswas exceeded; the message is left for redelivery rather than deleteddelete_failed— processing finished but the delete request failed; the result of the processing itself is recorded inprocessing_outcome
Logs emitted while a job is being processed automatically carry queue / message_id / receive_count / job_type, the correlation fields, and trace_id / span_id when an APM middleware is in use.
Development
$ uv sync --all-extras --all-groups
$ uv run pytest
$ uv run ruff check . && uv run ruff format --check .
$ uv run ty check src
License
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file sqs_job_worker-0.1.0.tar.gz.
File metadata
- Download URL: sqs_job_worker-0.1.0.tar.gz
- Upload date:
- Size: 28.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8ad82060bdbc5bd85fd104b4a710cb6f8289220f81bb2fd4039b93f40928b815
|
|
| MD5 |
d0b871f321d53482e12c876fdf94d4f7
|
|
| BLAKE2b-256 |
e5fab334744f43f4f6054ed7f8b6f2fbde339df77be2a7515dab71a41dc641b0
|
Provenance
The following attestation bundles were made for sqs_job_worker-0.1.0.tar.gz:
Publisher:
release.yml on xmart-corp/sqs-job-worker
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
sqs_job_worker-0.1.0.tar.gz -
Subject digest:
8ad82060bdbc5bd85fd104b4a710cb6f8289220f81bb2fd4039b93f40928b815 - Sigstore transparency entry: 2257340952
- Sigstore integration time:
-
Permalink:
xmart-corp/sqs-job-worker@a103cc1da696ff310a8a6e34d36d689f416af2e6 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/xmart-corp
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@a103cc1da696ff310a8a6e34d36d689f416af2e6 -
Trigger Event:
push
-
Statement type:
File details
Details for the file sqs_job_worker-0.1.0-py3-none-any.whl.
File metadata
- Download URL: sqs_job_worker-0.1.0-py3-none-any.whl
- Upload date:
- Size: 24.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9f3ffa23adf67deac0204aa90e50dc20f27de739ab62bc162754e017dce29b71
|
|
| MD5 |
77b1ac9a79e40ee7760a3b402d2435ce
|
|
| BLAKE2b-256 |
87b0b4b8a0128c565be1c99b7ff111d3148dc6f8bee624746f4c28dd8ffae01f
|
Provenance
The following attestation bundles were made for sqs_job_worker-0.1.0-py3-none-any.whl:
Publisher:
release.yml on xmart-corp/sqs-job-worker
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
sqs_job_worker-0.1.0-py3-none-any.whl -
Subject digest:
9f3ffa23adf67deac0204aa90e50dc20f27de739ab62bc162754e017dce29b71 - Sigstore transparency entry: 2257340965
- Sigstore integration time:
-
Permalink:
xmart-corp/sqs-job-worker@a103cc1da696ff310a8a6e34d36d689f416af2e6 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/xmart-corp
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@a103cc1da696ff310a8a6e34d36d689f416af2e6 -
Trigger Event:
push
-
Statement type: