{"id":255,"date":"2026-11-08T03:03:13","date_gmt":"2026-11-08T03:03:13","guid":{"rendered":"https:\/\/rezachegini.com\/?p=255"},"modified":"2026-11-08T03:10:49","modified_gmt":"2026-11-08T03:10:49","slug":"building-a-job-board-api-part-10-a-queue-and-a-background-worker","status":"publish","type":"post","link":"https:\/\/rezachegini.com\/?p=255","title":{"rendered":"Building a Job Board API, Part 10: A Queue and a Background Worker"},"content":{"rendered":"\n<h2 class=\"wp-block-heading\">Why not just send the email?<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">When a company moves an application from &#8220;reviewing&#8221; to &#8220;interview&#8221;, the candidate should get an email. The simplest way would be to send it right inside the API request.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">But sending an email means talking to another server, which can be slow or down. If it&#8217;s slow, the company waits. If it&#8217;s down, the request fails, even though the status change itself worked perfectly.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So the API does something much smaller. It writes a short note, &#8220;application 3 changed from reviewing to interview&#8221;, puts it on a queue, and answers the company immediately. A separate program, the worker, reads notes from the queue and does the slow work. If the worker is down, the notes simply wait.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This is the first asynchronous part of the project. Until now, every action finished inside one HTTP request. And it is the goal of Phase 3: a status change queues a job, and the worker processes it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">In this part the worker only logs each message. The real email comes next. But the hard part, delivering messages reliably, is all here.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Two decisions before writing any code<\/h2>\n\n\n\n<ol class=\"wp-block-list\">\n<li>SQS, not Redis. My plan said &#8220;Redis locally, SQS on AWS&#8221;. But that means two different queue backends and two code paths. Instead, I use SQS through LocalStack everywhere, the same fake AWS I set up for S3 in Part 7. Same code locally and on AWS. And, importantly, LocalStack gives me SQS&#8217;s real retry behaviour, which I need to test properly.<\/li>\n\n\n\n<li>The folder is called cloud\/, not platform\/. My plan put cloud code in a platform\/ package. But Python has a built-in module called platform, and my folder would hide it. Any library doing import platform would get my code instead. So: cloud\/queue.py.<\/li>\n<\/ol>\n\n\n\n<h2 class=\"wp-block-heading\">How SQS delivers a message<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">One idea surprised me more than anything else in this step: receiving a message does not remove it.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"726\" src=\"http:\/\/rezachegini.com\/wp-content\/uploads\/2026\/11\/job-board-api-part-10-sqs-diagram-1024x726.png\" alt=\"How SQS delivers a message diagram\" class=\"wp-image-261\" srcset=\"https:\/\/rezachegini.com\/wp-content\/uploads\/2026\/11\/job-board-api-part-10-sqs-diagram-1024x726.png 1024w, https:\/\/rezachegini.com\/wp-content\/uploads\/2026\/11\/job-board-api-part-10-sqs-diagram-300x213.png 300w, https:\/\/rezachegini.com\/wp-content\/uploads\/2026\/11\/job-board-api-part-10-sqs-diagram-768x544.png 768w, https:\/\/rezachegini.com\/wp-content\/uploads\/2026\/11\/job-board-api-part-10-sqs-diagram.png 1411w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">When the worker receives a message, SQS only hides it for 30 seconds. This is called the visibility timeout. If the worker finishes the job and deletes the message, it&#8217;s gone for good. If the worker crashes, or the job fails, there is no delete. After 30 seconds the message becomes visible again, and the worker gets another try.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That&#8217;s how retries work, for free. But what about a message that can never succeed, for example one with a broken body? It would fail forever. That&#8217;s what the dead-letter queue (DLQ) is for. After 3 receives without a delete, SQS moves the message to a separate queue, where it is parked for a human to inspect.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This model is called at-least-once delivery. Every message is delivered at least once, but sometimes more than once. So whatever the worker does must be safe to repeat. Handling that properly is a later step, but I designed for it from the start, as you&#8217;ll see.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Step 1: Creating the queues<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">LocalStack runs every script in its init\/ready.d\/ folder when it starts. In Part 7, one script created the S3 bucket. Now a second one creates two queues:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>#!\/bin\/bash\nset -euo pipefail\n\ndlq_url=$(awslocal sqs create-queue --queue-name \"${SQS_QUEUE_NAME}-dlq\" --query QueueUrl --output text)\ndlq_arn=$(awslocal sqs get-queue-attributes --queue-url \"$dlq_url\" --attribute-names QueueArn --query Attributes.QueueArn --output text)\n\ncat &gt; \/tmp\/queue-attributes.json &lt;&lt;EOF\n{\n  \"VisibilityTimeout\": \"30\",\n  \"RedrivePolicy\": \"{\\\"deadLetterTargetArn\\\":\\\"${dlq_arn}\\\",\\\"maxReceiveCount\\\":\\\"3\\\"}\"\n}\nEOF\n\nawslocal sqs create-queue --queue-name \"${SQS_QUEUE_NAME}\" --attributes file:\/\/\/tmp\/queue-attributes.json<\/code><\/pre>\n\n\n\n<ul class=\"wp-block-list\">\n<li>The DLQ comes first. The main queue&#8217;s settings point at the DLQ by its ARN (Amazon Resource Name), so I need the ARN before I can create the main queue. &#8211;query picks it out of the get-queue-attributes response.<\/li>\n\n\n\n<li>&#8211;query &#8230; &#8211;output text picks one field from the JSON response and prints it without quotes, so $(&#8230;) captures a clean value.<\/li>\n\n\n\n<li>VisibilityTimeout: 30 must be longer than one message takes to process. If it were shorter, a slow worker would see its own message come back while it was still working on it.<\/li>\n\n\n\n<li>RedrivePolicy says: after 3 receives without a delete, move the message to the DLQ.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Why write the settings to a file? RedrivePolicy is a JSON document stored as a string inside another JSON document. Getting the quotes right on one command line is almost impossible. A file is much easier. The heredoc (&lt;&lt;EOF) is unquoted on purpose, so bash fills in the real ${dlq_arn}.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">In Compose, I added SQS to LocalStack (SERVICES=s3,sqs), and the healthcheck now checks the bucket and the queue:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>test: &#91;\"CMD-SHELL\", \"awslocal s3api head-bucket --bucket $${AWS_STORAGE_BUCKET_NAME} &amp;&amp; awslocal sqs get-queue-url --queue-name $${SQS_QUEUE_NAME}\"]<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">&amp;&amp; runs the second check only if the first passes. The main queue is created after the DLQ, so if the main queue exists, both do. The API and the worker wait for this healthcheck, so neither starts before its queue is ready.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">One side effect I expected: changing LocalStack&#8217;s settings means recreating the container, and that empties S3 again (no persistence, as in Part 7). So I re-uploaded my test resume afterwards.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Step 2: One interface for any cloud<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">In the Azure phase of this project, the queue will be Azure Service Bus, not SQS. I don&#8217;t want to rewrite the API and the worker when that happens. So they never talk to SQS directly. They talk to an interface.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The message<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>@dataclass(frozen=True)\nclass QueueMessage:\n    id: str\n    body: dict\n    receipt_handle: str<\/code><\/pre>\n\n\n\n<ul class=\"wp-block-list\">\n<li>@dataclass writes the boring parts of a class for me: the constructor, a readable print-out, and comparison.<\/li>\n\n\n\n<li>frozen=True makes it immutable. A received message can&#8217;t be changed by accident.<\/li>\n\n\n\n<li>id is SQS&#8217;s message ID. It stays the same on every redelivery.<\/li>\n\n\n\n<li>receipt_handle is a token for this one receive. SQS needs it to delete the message, and it changes on every redelivery. I tested both: same id: True, new receipt handle: True.<\/li>\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\">The interface<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>class Queue(ABC):\n    @abstractmethod\n    def send(self, body: dict) -&gt; str: ...\n\n    @abstractmethod\n    def receive(self, max_messages: int = 10, wait_seconds: int = 20) -&gt; list&#91;QueueMessage]: ...\n\n    @abstractmethod\n    def delete(self, message: QueueMessage) -&gt; None: ...<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">An abstract base class (ABC) lists methods without implementing them. With @abstractmethod, a backend that forgets one of the three can&#8217;t even be created: Python raises a TypeError immediately, not halfway through processing a message. delete takes the whole message, so callers never need to know that SQS uses receipt handles. Service Bus does it differently.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The SQS implementation<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>def receive(self, max_messages: int = 10, wait_seconds: int = 20) -&gt; list&#91;QueueMessage]:\n    response = self.client.receive_message(\n        QueueUrl=self.url,\n        MaxNumberOfMessages=max_messages,\n        WaitTimeSeconds=wait_seconds,\n    )\n    return &#91;\n        QueueMessage(id=m&#91;\"MessageId\"], body=json.loads(m&#91;\"Body\"]), receipt_handle=m&#91;\"ReceiptHandle\"])\n        for m in response.get(\"Messages\", &#91;])\n    ]<\/code><\/pre>\n\n\n\n<ul class=\"wp-block-list\">\n<li>WaitTimeSeconds=20 is long polling. SQS holds the request open until a message arrives, or for up to 20 seconds. An idle worker makes about 3 calls a minute, instead of hammering SQS in a tight loop. That matters, because SQS bills per request.<\/li>\n\n\n\n<li>MaxNumberOfMessages=10 takes up to 10 messages per call, SQS&#8217;s maximum, so there are fewer calls.<\/li>\n\n\n\n<li>response.get(&#8220;Messages&#8221;, []): when nothing arrives, SQS leaves out the Messages key completely. Writing response[&#8220;Messages&#8221;] would crash with a KeyError on every empty poll.<\/li>\n\n\n\n<li>Bodies are JSON, not pickle: any language can read them, and they&#8217;re safe to inspect by hand. That paid off later, when I read a bad message straight out of the DLQ.<\/li>\n<\/ul>\n\n\n\n<pre class=\"wp-block-code\"><code>@lru_cache(maxsize=1)\ndef get_queue() -&gt; Queue:\n    return SQSQueue(settings.SQS_QUEUE_NAME)<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The single entry point. It returns the interface type. In the Azure phase, this is the only function that changes.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Step 3: Publishing the event<\/h2>\n\n\n\n<pre class=\"wp-block-code\"><code>STATUS_CHANGED = \"application.status_changed\"\n\ndef publish_status_changed(application, old_status):\n    body = {\n        \"type\": STATUS_CHANGED,\n        \"event_id\": str(uuid.uuid4()),\n        \"application_id\": application.id,\n        \"old_status\": old_status,\n        \"new_status\": application.status,\n    }\n\n    def send():\n        try:\n            message_id = get_queue().send(body)\n            logger.info(\"Queued %s for application %s (message %s)\", STATUS_CHANGED, application.id, message_id)\n        except (BotoCoreError, ClientError):\n            logger.exception(\"Could not queue %s for application %s\", STATUS_CHANGED, application.id)\n\n    transaction.on_commit(send)<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">IDs, not data<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The message holds no email address and no name. Only the application&#8217;s ID and the two statuses. The worker loads the current data from the database itself. That keeps messages small, keeps personal data out of the queue, and means the worker always uses fresh data, not a copy that may be out of date.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">One name, defined once<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">STATUS_CHANGED is defined in one place, and both the API (which sends) and the worker (which receives) import it. If I mistype it, I get an ImportError at startup, not a quiet mismatch where the worker never recognises the message. After everything this series has taught me about silent name bugs, that was an easy choice.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Two different IDs<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">event_id identifies the change. SQS&#8217;s own MessageId identifies the delivery. If the same change were ever sent twice, the two messages would have different message IDs, but the same event ID. That difference is what will let the worker recognise duplicates later.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">on_commit, again<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The same pattern as the cache in Part 9, for two reasons:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>If the message were sent before the commit, a fast worker could read the application and still see the old status.<\/li>\n\n\n\n<li>If the change rolls back, no message should exist at all.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">And yes, send is a nested function. Without the last line, it would never run, and nothing would tell me. That was exactly my bug in Part 9. This time the line was there from the start.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">If SQS is down<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The error is logged, not raised. By the time send runs, the status change is already saved. Returning a 500 would tell the company that it failed, when it didn&#8217;t, and they might try again and change the status twice.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">(BotoCoreError, ClientError) covers every boto3 failure: BotoCoreError for network and client-side problems, ClientError for errors AWS sends back.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Only on a real change<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">In the view:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>old_status = application.status\nserializer.save()\nif application.status != old_status:\n    publish_status_changed(application, old_status)<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">old_status must be read before save(), because the serializer updates the same object in place. And if a company sets a status to the value it already has, no message is sent, so the candidate never gets a pointless duplicate email.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Step 4: The worker<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Booting Django by hand<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The worker is a plain Python script, not a web request, and it doesn&#8217;t run through manage.py. So it has to start Django itself:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>os.environ.setdefault(\"DJANGO_SETTINGS_MODULE\", \"config.settings\")\ndjango.setup()\n\nfrom django.db import close_old_connections  # noqa: E402\nfrom cloud.queue import get_queue  # noqa: E402\nfrom jobs.events import STATUS_CHANGED  # noqa: E402<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The order matters. My project imports must come after django.setup(). Importing jobs.events first would load the models before Django is ready, and crash with AppRegistryNotReady. # noqa: E402 tells the linter that &#8220;import not at the top of the file&#8221; is intended here.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Handling one message<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>def handle(message):\n    body = message.body\n    if body.get(\"type\") == STATUS_CHANGED:\n        logger.info(\"Application %s: %s -&gt; %s (event %s)\",\n            body&#91;\"application_id\"], body&#91;\"old_status\"], body&#91;\"new_status\"], body&#91;\"event_id\"])\n        return\n    raise ValueError(f\"Unknown message type: {body.get('type')!r}\")<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">An unknown message type raises an error. It is not skipped. If I skipped it, the worker would delete it, and the evidence would be gone. By raising, the message isn&#8217;t deleted, it&#8217;s retried, and after 3 tries it lands in the DLQ, where I can read it. A message that can never succeed is called a poison message.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The loop<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>while running:\n    close_old_connections()\n    for message in queue.receive():\n        try:\n            handle(message)\n        except Exception:\n            logger.exception(\"Failed to handle message %s; it will be retried\", message.id)\n            continue\n        queue.delete(message)<\/code><\/pre>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Delete only after success. On an error, continue skips the delete, and SQS brings the message back after 30 seconds.<\/li>\n\n\n\n<li>except Exception is broad on purpose: one bad message must never kill the worker. It still lets Ctrl+C and shutdown signals through, because those don&#8217;t count as an Exception in Python.<\/li>\n\n\n\n<li>close_old_connections(): Django normally closes database connections at the end of each request. A worker has no requests, so a connection could go stale (for example after a Postgres restart) and fail later. This line throws away broken connections, and the next query opens a fresh one.<\/li>\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\">Stopping cleanly<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">When Docker stops a container, it sends a SIGTERM signal, waits, and then kills the process with SIGKILL. I don&#8217;t want the worker killed halfway through a message.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>running = True\n\ndef stop(signum, frame):\n    global running\n    logger.info(\"Received signal %s, finishing current batch\", signum)\n    running = False\n\nsignal.signal(signal.SIGTERM, stop)\nsignal.signal(signal.SIGINT, stop)<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The handler only sets a flag. The loop finishes its current work, sees running is false, and exits normally. global running is required, because without it, running = False would create a new local variable, and the loop would never stop.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Same image, different command<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>worker:\n    build: .\n    command: &#91;\"python\", \"-m\", \"worker.run_worker\"]\n    env_file: .env\n    environment:\n        - POSTGRES_HOST=db\n    stop_grace_period: 30s<\/code><\/pre>\n\n\n\n<ul class=\"wp-block-list\">\n<li>One image, two process types. The API and the worker share the same code and dependencies. In Kubernetes, the worker will be a second Deployment from the same image, scaled separately.<\/li>\n\n\n\n<li>python -m worker.run_worker, not python worker\/run_worker.py. The -m form puts \/app on Python&#8217;s import path, so cloud and jobs can be imported.<\/li>\n\n\n\n<li>No ports. The worker only makes outgoing connections.<\/li>\n\n\n\n<li>stop_grace_period: 30s. Docker&#8217;s default is 10 seconds, but a long poll can take 20. I measured a real stop: 11.9 seconds from the signal to &#8220;Worker stopped&#8221;. The default would have killed it.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Step 5: Testing it<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">I used a small helper, qdepth, that prints how many messages are waiting in a queue and how many are in flight (received, but not deleted yet).<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The happy path<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Acme moved application 3 from &#8220;reviewing&#8221; to &#8220;interview&#8221;. The worker logged:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>Application 3: reviewing -&gt; interview (event 7618c3e2-...)<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">And the queue showed 0 waiting, 0 in flight: the message was received, handled and deleted.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Setting the same status again returned 200, and the worker saw nothing. No change, no message.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The worker is down<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">This is the test that shows why queues exist. I stopped the worker, then changed a status:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>The API still answered 200. It doesn&#8217;t need the worker at all.<\/li>\n\n\n\n<li>The queue showed 1 message waiting.<\/li>\n\n\n\n<li>I started the worker. Its first lines were Worker started, then Application 3: interview -&gt; reviewing. The queue went back to 0.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">The message simply waited. That is decoupling: the API and the worker only know about the queue, never about each other, so either one can be down.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">A rollback<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">I changed a status inside a transaction and forced a rollback. The status in the database stayed &#8220;reviewing&#8221;, the worker saw nothing, and the queue stayed at 0. on_commit threw the message away together with the rollback.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">A poison message<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">I sent a message with {&#8220;type&#8221;: &#8220;bogus&#8221;} by hand and waited. The worker log showed three failures, each with ValueError: Unknown message type: &#8216;bogus&#8217;, and the worker stayed Up the whole time. Then I read the DLQ and found the message, with its original body, {&#8220;type&#8221;: &#8220;bogus&#8221;}. Nothing was lost, and one bad message didn&#8217;t take the worker down.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">A clean shutdown<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>time docker compose stop worker<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The log showed Received signal 15, finishing current batch, then Worker stopped. Total: 11.9 seconds, inside the 30-second grace period. No SIGKILL.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">With these results, the goal of Phase 3 is demonstrated: a status change queues a job, and the worker processes it.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Problems I hit<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">1. A log line that could never appear<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">I expected the API to log Queued application.status_changed for application 3. It never did. But the worker did process the message, so it was definitely sent.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The reason: Django has no logging configuration in my project yet. Without one, Python only prints WARNING and above. My logger.info(&#8230;) line was simply dropped. The worker doesn&#8217;t have this problem, because it calls logging.basicConfig(level=logging.INFO) itself.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This had a nasty side effect on one of my tests. To check that &#8220;same status sends no message&#8221;, I counted Queued lines in the API log and got 0. But that count could never have been anything else, because the API can&#8217;t print INFO at all. The meaningful result was the worker&#8217;s count of 0.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This is the same lesson as the &#8220;negative test that lied&#8221; in Part 7: a check that says &#8220;nothing happened&#8221; is only proof if it could have seen something. Proper logging comes in Phase 4.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">2. Counting one failure instead of three<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">In the poison message test, I first counted only one failure, not three. Two things went wrong in my check, not in the code. I pressed Ctrl+C during a sleep 100, which only ended the sleep, so the next commands ran early. And my log window, &#8211;since 2m, started after a recent worker restart. The message was already in the DLQ, which by itself proves 3 receives. With &#8211;since 15m, I counted 3.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">3. LocalStack emptied S3 again<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Expected, and the same as in Part 7. Recreating LocalStack wiped the bucket, so I re-uploaded my test resume.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Known gaps<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li>A message can be lost. If SQS is unreachable at the moment of the commit, the status is saved but the message is gone, with only an error in the log. The proper fix is called a transactional outbox: save the event in a database table in the same transaction as the status change, and let a separate process send it to the queue. I haven&#8217;t built it in this phase, but I want to name it.<\/li>\n\n\n\n<li>The API logs nothing at INFO. Fixed in Phase 4.<\/li>\n\n\n\n<li>Duplicates on redelivery. At-least-once delivery means the same message can arrive twice. The event_id is ready for it. The fix is a later step.<\/li>\n\n\n\n<li>No auto-reload for the worker. After a code change: docker compose restart worker.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">What I learned<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Slow work doesn&#8217;t belong inside a request. Put a note on a queue and answer straight away.<\/li>\n\n\n\n<li>Receiving is not deleting. A message is only gone when the worker deletes it after success. That&#8217;s how retries work.<\/li>\n\n\n\n<li>A dead-letter queue keeps the evidence. Bad messages are parked, not lost, and they can&#8217;t loop forever.<\/li>\n\n\n\n<li>Long polling is cheaper and faster than asking an empty queue again and again.<\/li>\n\n\n\n<li>Code against an interface. The API and worker don&#8217;t know SQS exists, so Azure Service Bus can slot in later.<\/li>\n\n\n\n<li>Send IDs, not data. Smaller messages, no personal data in the queue, always-fresh data.<\/li>\n\n\n\n<li>Publish after the commit. Never for a rolled-back change, and the worker never reads the old state.<\/li>\n\n\n\n<li>One bad message must never kill the worker.<\/li>\n\n\n\n<li>Shut down gracefully. Set a flag on SIGTERM, finish the current work, and give Docker enough time to wait.<\/li>\n\n\n\n<li>A &#8220;nothing happened&#8221; check needs proof it could have seen something.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">What comes next<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The worker can receive messages, but it still only logs them. Next, it sends the real email to the candidate. After that comes the hardest part of Phase 3: idempotency, so that a message delivered twice never sends two emails.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Why not just send the email? When a company moves an application from &#8220;reviewing&#8221; to &#8220;interview&#8221;, the candidate should get an email.&hellip;<\/p>\n","protected":false},"author":2,"featured_media":259,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_rezaops_part":"10","_rezaops_linkedin_url":"https:\/\/www.linkedin.com\/pulse\/building-job-board-api-part-10-queue-background-worker-reza-chegini-0rgkc\/","footnotes":""},"categories":[85,10,116],"tags":[60,8,111,191,118,194,25,112,200],"series":[117],"class_list":["post-255","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-aws","category-devops","category-django","tag-aws","tag-devops","tag-django","tag-drf","tag-job-board","tag-localstack","tag-python","tag-rest-api","tag-sqs","series-job-board-api"],"jetpack_featured_media_url":"https:\/\/rezachegini.com\/wp-content\/uploads\/2026\/11\/job-board-api-part-10-cover.png","_links":{"self":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/255","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=255"}],"version-history":[{"count":3,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/255\/revisions"}],"predecessor-version":[{"id":262,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/255\/revisions\/262"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/media\/259"}],"wp:attachment":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=255"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=255"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=255"},{"taxonomy":"series","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fseries&post=255"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}